๐Ÿ“‹ Overview

File upload, storage, and delivery for the platform. Provides Google-Drive-style folder hierarchies, per-user storage quotas, organisation-scoped visibility, and shared-link sharing with optional passwords and download caps. Authentication is delegated to the Account Service โ€” every protected endpoint accepts the same RS256-signed JWT it issues. A parallel gRPC surface on port 50052 (see proto/media/v1/media.proto) covers the same domain for service-to-service calls; this page documents the REST transport only.

๐Ÿ“ค Streaming upload

Multipart with magic-byte sniff and DB-enforced quota

๐Ÿ“ Folder tree

Hierarchical, max depth 100, sibling-name unique

๐Ÿ”— Shared links

Tokenised, optionally password-protected

๐Ÿ–ผ๏ธ Image variants

Original kept; high/medium/low generated on demand

๐ŸŽž๏ธ Video range streaming

Browser scrubbing without full-file download

Base URL

Constraints

  • Per-user storage quota (default 1 GB) is enforced via a PostgreSQL CHECK constraint, not application code.
  • Service-principal uploads (`principal_type: service` JWT) draw on a separate quota bucket (`MAX_STORAGE_PER_SERVICE`, default 10 GB) instead of the per-user 1 GB, since many end-users funnel through one owner id.
  • A trusted headless backend may attribute an upload to ANY organisation via the `X-Organization-Id` header when its token is `principal_type: service` AND carries `media:act_as_any_org`. User tokens granted the permission alone are NOT elevated (defence-in-depth).
  • Maximum folder depth is 100; sibling folder names must be unique per parent.
  • Default upload size cap is 100 MB (override with `APP__MAX_FILE_SIZE`).
  • Effective MIME is derived from a magic-byte sniff; client-declared `Content-Type` is overridden if it conflicts and re-validated against the allow/block policy.
  • Shared-link tokens are opaque random strings. Passwords are bcrypt-hashed; legacy SHA-256 hashes are transparently upgraded on access.
  • All write operations on folders and media require ownership; cross-tenant reparenting is rejected.
  • Image originals are never overwritten. Compressed variants (`high`/`medium`/`low`) are stored separately in `media_variants` and selected via `?variant=` on the download endpoint.

๐Ÿ“ค Streaming upload

POST /upload streams the request body straight to disk, sniffs the magic bytes, and rejects executables even when disguised by a fake MIME header. Storage quota is enforced at the DB layer (CHECK constraint, migration 20260424000001) โ€” the application pre-check is a fast-path only.

๐Ÿ“ Folder tree

Folders form a tree per owner (parent_id + depth + path). Sibling names must be unique under the same parent (migration 20260424000002). Recursive delete (force=true) purges every blob in the subtree before dropping rows.

๐Ÿ”— Shared links

Generate an opaque token URL with optional bcrypt password, expiry, and download cap. Anonymous accessors hit GET /share/:token. Legacy SHA-256 hashes are upgraded to bcrypt on first successful access.

๐Ÿ–ผ๏ธ Image variants

Image uploads keep the original bytes intact and never overwrite them. Three derived variants โ€” high (q=90), medium (q=75), low (q=50) โ€” can be requested via GET /media/{id}?variant=โ€ฆ. The medium tier is generated eagerly on upload (when IMAGE_EAGER_MEDIUM=true); high and low are generated lazily on first access and cached. All levels live in media_variants (one row per tier) โ€” see migration 20260505000001. Delete cascades clean every variant blob from disk, not just the DB rows.

๐ŸŽž๏ธ Video range streaming

For video/* MIMEs the download endpoint advertises Accept-Ranges: bytes and honours Range: request headers, responding with 206 Partial Content for the requested byte window. Lets the HTML5 <video> element seek mid-file without buffering from byte 0. Out-of-bounds requests return 416 with the canonical Content-Range: bytes */<total> hint. Non-video files don't advertise ranges and serve a full 200.

๐Ÿ”„ JWT Authentication Flow

Step-by-step authentication menggunakan JWT

1
Authenticate against Account Service โ–พ

Out of scope for this service. Obtain a JWT from the configured Account Service login endpoint.

2
Attach the token on every protected request โ–พ

Send Authorization: Bearer <token> on every endpoint except /health and /share/:token.

3
Permissions are claim-checked per route โ–พ

Each route asserts a specific permission (e.g. media:upload). Tokens missing the required permission get HTTP 403.

Public endpoints (/health, /share/:token) do not require a token.

๐Ÿ”„ Service Flow Diagram

Visualisasi alur data antara services

POST /folders

Creates a folder owned by the caller. If parent_id is provided, the folder is nested under that parent and inherits a depth of parent.depth + 1. The DB enforces sibling-name uniqueness โ€” duplicates return HTTP 409.

Request Body Fields

FieldTypeRequiredDescription
name string required Folder name. 1โ€“255 chars; no / or control characters.
parent_id uuid optional Parent folder. Omit for a root-level folder.
visibility string optional public | organization | private. default: organization
Example Request Body
{
  "name": "Vacation Photos",
  "parent_id": "4f2cโ€ฆ",
  "visibility": "organization"
}
Example Response
{
  "id": "f9a1โ€ฆ",
  "name": "Vacation Photos",
  "parent_id": "4f2cโ€ฆ",
  "path": "Documents/Vacation Photos",
  "depth": 2,
  "created_at": "2026-04-30T08:14:22Z"
}
GET /folders

Lists folders owned by the caller. Default scope is the current parent's children (or roots when parent_id is omitted). Set recursive=true to flatten the entire subtree.

Query Parameters

ParamTypeRequiredDescription
parent_id uuid optional Listing scope. Omit for root-level folders.
recursive bool optional Walk the full subtree under parent_id. (default: false)
sort string optional name | created_at | updated_at. (default: name)
order string optional asc | desc. (default: asc)
PATCH /folders/{id}

Renames a folder. The DB trigger trg_update_folder_path rewrites every descendant's path inside the same transaction. Root folders cannot be renamed.

Request Body Fields

FieldTypeRequiredDescription
name string required New folder name.
Example Request Body
{ "name": "Archive 2026" }
GET /folders/{id}/contents

Returns the immediate children: subfolders and media files in a single paginated response. Useful for a Drive-style file browser UI.

Query Parameters

ParamTypeRequiredDescription
page int optional 1-indexed page number. (default: 1)
per_page int optional Items per page. (default: 20)
DELETE /folders/{id}

Deletes the folder. The non-force path requires the folder to be empty (no media, no subfolders) and returns HTTP 412 if it is not. With force=true the entire subtree is purged: for every media in the subtree, all derived variant blobs (medium /high/low) are removed first, then the original blob, then the media row; finally the folder rows cascade. Variant cleanup failures are logged but never abort the purge โ€” same reasoning as the single-media delete endpoint.

Query Parameters

ParamTypeRequiredDescription
force bool optional Recursively delete every file and subfolder in the subtree. (default: false)
Example Response
{
  "deleted": true,
  "folder_id": "f9a1โ€ฆ",
  "deleted_items": { "folders": 3, "media": 17 }
}
GET /health

Returns a JSON payload indicating the service process is up. Does not check downstream dependencies (DB, storage).

model_3d_enabled advertises whether the 3D model pipeline is operational โ€” i.e. whether Blender (the primary converter) is configured and runnable on this host. Probed once at first call (blender --background --version) and cached. The frontend reads this to disable the "3D model" upload action up-front when the administrator hasn't installed/configured Blender, rather than letting a user upload and hit a 503 MODEL_3D_UNAVAILABLE.

Example Response
{
  "status": "healthy",
  "service": "media-service",
  "model_3d_enabled": true
}
GET /media

Paginated listing. Supports filtering by folder, visibility, file type, and a search term applied to original_name. The search input is parameterised at the SQL layer. Default sort is newest-first (created_at desc).

Each item carries model_3d (boolean): true when the file was uploaded via ?as=3d-model and has a renderable glTF. The UI uses it to offer the 3D viewer even when the stored MIME is ambiguous (e.g. a .zip bundle reported as application/zip).

Query Parameters

ParamTypeRequiredDescription
folder_id uuid optional Restrict to this folder; omit for all-folders view.
page int optional 1-indexed page number. (default: 1)
per_page int optional Items per page. (default: 20)
visibility string optional Filter: public | organization | private.
file_type string optional Filter: image | video | document | audio | other.
search string optional Substring match on original_name.
sort string optional created_at | name | size | updated_at. (default: created_at)
order string optional asc | desc. (default: desc)
Example Response
{
  "items": [
    {
      "id": "c150โ€ฆ",
      "original_name": "scene.zip",
      "mime_type": "application/zip",
      "size_bytes": 29144542,
      "visibility": "organization",
      "folder_id": null,
      "created_at": "2026-05-23T13:46:49Z",
      "model_3d": true
    },
    {
      "id": "9e1cโ€ฆ",
      "original_name": "photo.jpg",
      "mime_type": "image/jpeg",
      "size_bytes": 248122,
      "visibility": "organization",
      "folder_id": "4f2cโ€ฆ",
      "created_at": "2026-04-30T08:14:22Z",
      "model_3d": false
    }
  ],
  "total": 2,
  "page": 1,
  "per_page": 20,
  "total_pages": 1,
  "quota": { "used_bytes": 29392664, "max_bytes": 1073741824 }
}
GET /media/{id}

Streams the media bytes back with the original Content-Type and Content-Disposition. Visibility rules apply: public is unauthenticated, organization requires same-org JWT, private requires the owner's JWT.

Variant selection (?variant=) โ€” the service stores the original plus optional re-encoded variants. The variant tier is the same query string for every supported MIME class; what the server produces depends on the source type:

original (default) โ€” uncompressed bytes, format as uploaded. SHA-256 matches the upload-time checksum. high (q=90) โ€” light recompression, near-original quality. medium (q=75, max 1280 px) โ€” balanced; suitable for previews. low (q=50, max 320 px) โ€” aggressive; suitable for thumbnails.

Per-MIME thumbnail behaviour for ?variant=low (and the smaller-cap ?variant=medium):

image/* โ†’ resized + re-encoded in the original format (JPEG/PNG/WebP/GIF). Aspect ratio preserved. video/* โ†’ JPEG poster frame extracted via ffmpeg at the 1-second mark, scaled to fit the cap. application/pdf โ†’ JPEG of the first page rendered via pdftoppm at the cap resolution. audio/* โ†’ transparent PNG waveform (16:9), decoded with symphonia (ffmpeg fallback for containers symphonia can't read, e.g. Opus-in-WebM voice notes). 3D models (uploaded ?as=3d-model) โ†’ 320ร—320 transparent PNG rendered by f3d. Generated eagerly at upload. MIMEs without an adapter โ†’ HTTP 404 (no thumbnail). UI is expected to fall back to a generic icon.

3D model viewer tiers โ€” for media uploaded with ?as=3d-model, the glTF binary is served at three level-of-detail tiers so the viewer can stream a light model on mobile and the full one on desktop (the 3D analogue of image tiers). All three are meshopt-compressed glTF binary (model/gltf-binary):

web โ€” full geometry + full-resolution texture. web-medium โ€” ~50% triangles, texture capped at 2048 px. * web-low โ€” ~20% triangles, texture capped at 1024 px (the mobile default the viewer requests first).

Texture downscaling happens in Blender (gltfpack only resizes textures alongside KTX2, which we avoid to keep the viewer decoder-free); gltfpack does the geometry decimation. These tiers are produced by the asynchronous post-upload pipeline (see ?as=3d-model under Upload), never lazily on demand.

Three response states for a glb tier: 200 โ€” variant ready, body is the glb bytes. 202 Accepted โ€” model is still converting (metadata.model_3d_status = "processing"). Body is a JSON status {"error":"MODEL_3D_PROCESSING", "message":โ€ฆ}, NOT media bytes. Clients should back off + refresh, not pass the body to a model viewer. * 404 Not Found โ€” no such 3D asset (legacy upload that predates LODs, conversion failed, or the source was never uploaded as 3D). Frontend falls back to web or shows a "no preview" state.

All thumbnail responses ship Content-Type: image/jpeg or image/png regardless of the source MIME โ€” a single <img> element renders every case.

Aliases also accepted: orig / raw for original, hi for high, med for medium, lo for low. Invalid values return HTTP 400 with the allowed list.

The medium and low variants for images are generated eagerly on upload (when IMAGE_EAGER_MEDIUM=true); video / PDF / audio thumbnails plus all other levels are generated lazily on first request and cached for subsequent ones. Generation is best-effort โ€” adapters that can't decode the source surface 404, not 500.

The Content-Disposition filename is always the upload's display name regardless of variant โ€” the tier is invisible to the user-facing UI.

Range / streaming (video only) โ€” when the underlying MIME is video/*, the response includes Accept-Ranges: bytes and the endpoint honours Range: request headers. This lets the browser's HTML5 <video> element scrub mid-file without downloading from byte 0. Supported forms:

Range: bytes=N-M โ†’ 206 Partial Content Range: bytes=N- โ†’ 206 from N to EOF Range: bytes=-M โ†’ 206 last M bytes Multi-range / unparseable โ†’ ignored, full 200 returned * Out-of-bounds โ†’ 416 with Content-Range: bytes */<total>

Non-video MIMEs do NOT advertise Accept-Ranges and ignore any Range: header โ€” they always stream the full body.

Query Parameters

ParamTypeRequiredDescription
variant string optional original (default) | high | medium | low | web | web-medium | web-low. Aliases: orig/raw, hi, med, lo, glb. The web* tiers return the meshopt-compressed glTF binary for 3D uploads (only present when the upload used ?as=3d-model): web = full, web-medium/web-low = level-of-detail (reduced geometry + texture). For images low/medium are size-reduced thumbnails. (default: original)
GET /media/{id}/info

Returns the metadata only (no bytes). Useful for clients that want size/MIME/checksum without paying for the download.

PATCH /media/{id}

Updates the user-facing original_name. The on-disk filename is unchanged; only the display name is rewritten.

Request Body Fields

FieldTypeRequiredDescription
original_name string required New display name. Client should preserve the extension.
Example Request Body
{ "original_name": "vacation-2026-04.jpg" }
PATCH /media/{id}/visibility

Switches the media row's visibility level. Does not propagate to shared links; existing tokens keep working until they expire or are deleted.

Request Body Fields

FieldTypeRequiredDescription
visibility string required public | organization | private.
Example Request Body
{ "visibility": "private" }
POST /media/{id}/reprocess-3d

Re-runs the 3D conversion pipeline (Blender โ†’ gltfpack โ†’ f3d) for a media that was uploaded with ?as=3d-model. Use when metadata.model_3d_status is "failed" (transient binary failure, a corrupt input, etc.) or stuck "processing" after a service restart had killed the prior detached task.

Owner-only. Flips the row's status back to "processing" and spawns the pipeline asynchronously โ€” the response is 202 Accepted as soon as the task is queued. The pipeline updates the status to "ready" (variants on disk) or "failed" (+ a short model_3d_error) when it finishes.

Idempotent under concurrent calls: if the row is already "processing", the second call is a no-op (returns 202 without spawning another task). Conversion is deterministic so even a racing double-spawn would converge to the same outcome.

Reject conditions: 404 NOT_FOUND โ€” media does not exist 403 FORBIDDEN โ€” caller is not the owner 422 MODEL_3D_VALIDATION โ€” media was not uploaded as a 3D model (no metadata.model_3d flag), so there is nothing to re-convert. Re-upload via POST /upload?as=3d-model. 503 MODEL_3D_UNAVAILABLE โ€” Blender is not configured on this host (see model_3d_enabled on /health).

Example Request Body
# No body โ€” the media id is in the path. Send an empty POST.
curl -X POST 'https://media.ikavia.com/api/media/c1502f8c-cd40-40a7-b8b9-ecd320974298/reprocess-3d' \
  -H 'Authorization: Bearer <token>'
Example Response
# 202 Accepted โ€” task spawned. Refresh /media/{id}/info or the
# listing to observe model_3d_status change to ready/failed.
{
  "status": "processing",
  "message": "3D conversion restarted"
}
DELETE /media/{id}

Removes every blob (original + each derived variant) from storage, then deletes the media row. The matching media_variants rows go away via FK cascade. A StorageError::NotFound is tolerated per file so retries on a partially-failed delete are idempotent.

A failure cleaning a single variant blob is logged but does not abort the delete โ€” the user's media still goes away; worst case is one orphan blob on disk. Operators can sweep orphans by walking the storage directory and reconciling against media_variants.storage_path.

Deletion code guard (optional, per row). If the file was uploaded with X-Deletion-Code: <secret>, the row is "guarded" โ€” every delete must echo the same plaintext via the same header. Distinct 403 body codes signal which failure mode so UIs can prompt vs re-prompt:

DELETION_CODE_REQUIRED โ€” guarded row, no header sent. INVALID_DELETION_CODE โ€” guarded row, header sent but the hash didn't verify. Re-prompt the user. * FORBIDDEN โ€” role check failed (not owner, not org-admin). A code can't fix this.

Rows uploaded without the header (or before this feature was released) ignore the header on delete โ€” behaviour is unchanged.

Example Response
{
  "deleted": true,
  "media_id": "9e1cโ€ฆ",
  "filename": "9e1cโ€ฆ.jpg",
  "freed_bytes": 248122
}
GET /media/{id}/preview

Head-of-file text preview for text-y MIMEs (text/*, application/json, application/xml, application/x-yaml, application/javascript, plus extension-based fallback for markdown / source code: .md, .mmd, .txt, .log, .csv, .tsv, .json, .yaml, .toml, .xml, .rs, .ts, .py, .go, .java, .sql, .sh, etc.).

Returns up to 8 KB (cap is hard). Non-text MIMEs โ†’ 415 with body UNSUPPORTED. UTF-8 boundary safe via lossy decode so a cut mid-codepoint yields U+FFFD instead of a 5xx.

Query Parameters

ParamTypeRequiredDescription
bytes int optional How many bytes to return. Clamped server-side to 8192. (default: 8192)
Example Response
{
  "id": "3ca7โ€ฆ",
  "mime_type": "text/markdown",
  "content": "# Title\n\nFirst paragraphโ€ฆ",
  "truncated": true,
  "bytes_returned": 8192,
  "total_bytes": 42130
}
GET /media/{id}/manifest

Lists the entries inside a zip / tar / tar.gz without extracting any payload. Reads only the central-directory headers; bounded at 5 000 entries (zip-bomb cap) with a truncated flag past that.

Format is auto-detected from MIME, with a sensible extension fallback so application/octet-stream uploads ending in .zip / .tgz are still recognised. Non-archive MIMEs โ†’ 415.

Entry paths are sanitised: any .. segments and leading slashes are stripped before serialisation, so the UI can render name verbatim without re-validating.

Example Response
{
  "format": "zip",
  "entries": [
    { "name": "README.md", "size_bytes": 1234, "is_dir": false, "modified_at": "2026-04-12T10:00:00Z" },
    { "name": "src", "size_bytes": 0, "is_dir": true, "modified_at": null }
  ],
  "total_entries": 42,
  "truncated": false
}
GET /recent

Returns the caller's most recently accessed media, newest-first. Capped at the value the client requests (default applied server-side).

Query Parameters

ParamTypeRequiredDescription
limit int optional Max items to return; server applies a sane upper bound.
POST /media/{id}/track

Records an access event for the given media. Idempotent at the row level โ€” repeated tracks update the existing row's timestamp instead of inserting duplicates.

Request Body Fields

FieldTypeRequiredDescription
access_type string optional Free-form classifier (e.g. view, download, preview).
Example Request Body
{ "access_type": "view" }
DELETE /recent

Wipes the caller's entire recent-files history. The underlying media files are untouched.

GET /v1/media/{id}/exists

Lookup existence + ownership untuk satu media_id. Cocok untuk validasi single-field cover_outlet_media_id / attachment_id saat consumer menerima request dari owner.

Response shape:

exists selalu true di body 200 โ€” kalau tidak ada, response-nya 404 dengan body error standar. visibility salah satu public / organization / private. Consumer enforce same-org rule sendiri. organization_id null ketika row orphan (tidak ada org-nya). Untuk media visibility=organization, consumer bisa cocokkan dengan org-nya sendiri. expired dihitung server-side dari expires_at vs NOW(). Tidak ada off-by-one antar service clock.

Example Response
{
  "exists": true,
  "visibility": "organization",
  "organization_id": "545c61c3-b211-406a-bd0a-0140e7bbe941",
  "expired": false
}
POST /v1/media/bulk-check

Lookup existence + ownership untuk banyak media_id dalam satu round-trip. Pakai ini saat consumer menyimpan record dengan beberapa media reference (mis. outlet dengan cover + photo, atau survey response dengan multiple attachment).

Partial result by contract. Response 200 berisi satu entry per id yang di-request โ€” bahkan untuk id yang tidak ditemukan (entry-nya {id, exists: false}). Consumer bisa simpan yang valid + flag yang invalid dalam satu pass.

Order preserved. items[i].id == request.ids[i]. Kalau caller kirim duplicate id, response berisi duplikat juga.

Cap 100 ids. Body di luar cap โ†’ 400 VALIDATION_ERROR. Body kosong โ†’ 400 dengan pesan "ids must be a non-empty array".

Request Body Fields

FieldTypeRequiredDescription
ids array<uuid> required 1-100 media id. Duplicates allowed; their entries are duplicated 1:1 in the response.
Example Request Body
{
  "ids": [
    "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
    "ffffffff-ffff-ffff-ffff-ffffffffffff",
    "10cd8c18-8f45-45c5-8c0d-d7c545f2d729"
  ]
}
Example Response
{
  "items": [
    {
      "id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
      "exists": true,
      "visibility": "public",
      "organization_id": "545c61c3-b211-406a-bd0a-0140e7bbe941",
      "expired": false
    },
    {
      "id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
      "exists": false
    },
    {
      "id": "10cd8c18-8f45-45c5-8c0d-d7c545f2d729",
      "exists": true,
      "visibility": "organization",
      "organization_id": null,
      "expired": true
    }
  ]
}
POST /media/{id}/share

Mints a shared-link token for the given media. The caller must own the media or have download access through it. Visibility defaults to restricted (creator-only) โ€” set public for anonymous access or organization to gate by the caller's org.

Request Body Fields

FieldTypeRequiredDescription
visibility string optional public | organization | restricted. default: restricted
password string optional If set, accessors must supply this password (bcrypt-hashed at rest).
expires_in_hours int optional Lifetime in hours. Omit for no expiry.
max_downloads int optional Cap on successful downloads through this link.
Example Request Body
{
  "visibility": "public",
  "password": "spring-2026",
  "expires_in_hours": 168,
  "max_downloads": 50
}
Example Response
{
  "id": "ab12โ€ฆ",
  "token": "8f3c2a1bโ€ฆ",
  "share_url": "https://media.example.com/share/8f3c2a1bโ€ฆ",
  "visibility": "public",
  "has_password": true,
  "expires_at": "2026-05-07T08:14:22Z",
  "max_downloads": 50,
  "created_at": "2026-04-30T08:14:22Z"
}
GET /starred

Lists the caller's starred media in reverse-chronological order (most recently starred first). Each item includes the underlying media metadata plus starred_at and notes.

Query Parameters

ParamTypeRequiredDescription
page int optional 1-indexed page number. (default: 1)
per_page int optional Items per page. (default: 20)
POST /media/{id}/star

Stars (or re-stars) a media file. Optional notes are stored alongside the star. Calling this on an already-starred file is a no-error update.

Request Body Fields

FieldTypeRequiredDescription
notes string optional Optional annotation, surfaced in the listing.
Example Request Body
{ "notes": "Reference for the launch deck" }
DELETE /media/{id}/star

Removes the star. Idempotent โ€” unstarring an already-unstarred file returns success.

POST /media/{id}/toggle-star

Convenience endpoint that flips the star state. Useful for UIs that bind a single button to "star/unstar" without tracking current state client-side.

POST /starred/check

Bulk lookup: given a list of media IDs, returns which ones the caller has starred. Lets a list view annotate every row in one round-trip.

Request Body Fields

FieldTypeRequiredDescription
media_ids array<string> required Up to N media UUIDs to check at once.
Example Request Body
{ "media_ids": ["9e1cโ€ฆ", "f9a1โ€ฆ", "ab12โ€ฆ"] }
Example Response
{
  "items": [
    { "media_id": "9e1cโ€ฆ", "is_starred": true },
    { "media_id": "f9a1โ€ฆ", "is_starred": false },
    { "media_id": "ab12โ€ฆ", "is_starred": true }
  ]
}
POST /upload

Streams a single multipart file part (field name file) into storage. The response carries the canonical media metadata, including the SHA-256 checksum and the storage path of the original bytes.

Accepted credentials. Any RS256 JWT carrying media:upload is accepted โ€” both ordinary user tokens and service-principal tokens (principal_type: service), including the short-lived token a headless backend obtains from the Account Service POST /auth/system-token flow. There is no anonymous path: a valid token is always required (missing/invalid โ†’ 401). Service-principal uploads are attributed to the token's sub as owner_id; set visibility=public if the resulting file must be readable without auth (e.g. an anonymous applicant's document fetched later via GET /media/{id}).

Storage quota by principal. Human uploads draw on the per-user bucket (MAX_STORAGE_PER_USER, default 1 GiB). Service-principal uploads โ€” which funnel many end-users through a single owner id โ€” draw on a separate, typically larger per-service bucket (MAX_STORAGE_PER_SERVICE, default 10 GiB). The limit is applied both as the fast-path pre-check and as the authoritative storage_quota.max_bytes for the service owner; over-quota uploads fail with 413 and the staged blob is removed.

Cross-organisation attribution (trusted backends). The X-Organization-Id header lets the caller pick which organisation the resulting media belongs to. For ordinary user tokens that org must be in the caller's own membership set โ€” otherwise 403, no escalation. A headless backend that serves end-users across many orgs (e.g. a careers portal uploading documents on behalf of anonymous applicants) can bypass the membership gate when ALL three hold:

1. The token is principal_type: service (cryptographically pinned by account-service; cannot be forged by a user token). 2. The token carries the media:act_as_any_org permission. 3. X-Organization-Id: <target-org-uuid> is sent on the upload request.

The media's organization_id is then set to the supplied UUID; owner_id remains the service principal's sub. Without the header the existing fallback applies (the token's org_id claim, or NULL if absent). A user token granted media:act_as_any_org alone is NOT elevated โ€” the principal-type factor is a deliberate defence-in-depth against accidental grant. The cross-org write is logged with the service principal's sub and target org for audit.

Validation order: filename โ†’ MIME allow/block โ†’ quota fast-path โ†’ stream to disk (max-size enforced authoritatively) โ†’ magic-byte sniff โ†’ rebuild against policy โ†’ metadata probe โ†’ DB row.

Original is never overwritten. For images, the upload pipeline additionally generates the low + medium variants (true thumbnails at 320 / 1280 px max) post-DB-save when IMAGE_EAGER_MEDIUM=true. Video / PDF / audio thumbnails are generated lazily on first GET /media/{id}?variant=low. Variant generation is best-effort โ€” failure here logs a warning but the upload itself succeeds with the original committed.

Metadata probe runs at end of upload and writes type-specific facts to media.metadata: image/* โ†’ width, height (via the image crate) audio/* โ†’ duration_ms, bitrate_bps, sample_rate_hz, channels (via lofty) * video/* โ†’ duration_ms, width, height, bitrate_bps (via ffprobe)

Caller-supplied metadata (in the form body) wins per-key; the probe only fills keys the caller left empty.

Optional X-Deletion-Code header (per row). When present, server stores a sha256:<hex> hash of the supplied secret. Future deletes against this row must echo the same plaintext via the same header, or be refused with 403 DELETION_CODE_REQUIRED / INVALID_DELETION_CODE. Useful for service-to-service uploads where the panel web UI / other callers must not be able to delete files arbitrarily.

Caller is responsible for storing the plaintext code โ€” server provides no recovery. Code must be 16-256 printable ASCII characters; out-of-range or non-printable input โ†’ 400.

Optional ?as=3d-model (per upload โ€” ASYNCHRONOUS). Routes the upload through the 3D pipeline: Blender import/export โ†’ gltfpack compress โ†’ f3d render preview. Blender is the primary converter (accurate geometry + PBR materials + textures); assimp is a per-format fallback for inputs Blender can't read (e.g. .3ds) or transient Blender failures. Accepted inputs: .glb / .gltf (binary) โ€” already self-contained .stl / .ply (ASCII or binary) โ€” geometry-only .obj without mtllib declarations โ€” bare geometry .zip / .tar.gz โ€” must contain a recognisable entry (.obj+.mtl+textures, or .glb, .stl, etc.) * .fbx / .dae โ€” Blender importers; .3ds via assimp fallback

Lifecycle. The endpoint returns 201 immediately once the original bytes are saved โ€” it does NOT block on conversion. The response carries model_3d: true and model_3d_status: "processing". The pipeline runs detached (it survives client disconnect) and writes the final state to the same row:

model_3d_status: "ready" โ€” variants web (full), web-medium, web-low (mobile default), and low (PNG thumbnail) are all on disk and listed. model_3d_status: "failed" โ€” conversion errored; a short model_3d_error reason is recorded; the original file is kept (no rollback). Owner can re-trigger via POST /media/{id}/reprocess-3d.

Concurrency is capped by MEDIA_3D_MAX_CONCURRENCY (default 2); extra pipelines queue rather than thrash the host. On service restart, any row stuck in processing is swept to failed at startup so it's recoverable via reprocess.

Variant fetch while converting. GET /media/{id}?variant=web* for a model whose status is processing returns 202 Accepted with body {"error":"MODEL_3D_PROCESSING", ...} (distinct from 404 "no such 3D asset" and 200 ready). Clients should back off and retry after a refresh.

Reject conditions surfaced as model_3d_status: "failed" (the upload itself returns 201; the failure shows on the row): naked .obj with mtllib material.mtl โ€” bundle it in a zip alongside the referenced material + textures archive with no recognisable 3D file inside archive past size caps (50 MB / file, 200 MB cumulative, 5 000 entries) โ€” zip-bomb defence unsupported extension entirely

Disabled-feature (503 MODEL_3D_UNAVAILABLE returned synchronously on the upload itself): the host has no runnable Blender (MEDIA_BLENDER_BIN unset / not installed). The frontend reads model_3d_enabled from GET /health to warn before the user uploads.

When as is NOT set, even .glb / .stl uploads are stored as plain bytes โ€” no 3D processing (no variants, no status field).

On any pre-conversion failure (auth, MIME, quota, body-parse) the staged blob is removed; the invariant "DB row โ‡’ storage file exists" is preserved.

Query Parameters

ParamTypeRequiredDescription
folder_id uuid optional Target folder. Omit for root.
visibility string optional public | organization | private. Defaults to organization. (default: organization)
as string optional Opt-in pipeline tag. Currently supported: 3d-model. When set, upload is processed through the 3D converter + thumbnail renderer.

Request Body Fields

FieldTypeRequiredDescription
file file (multipart) required The file part. Filename is taken from the part's filename attribute.
Example Request Body
# multipart/form-data โ€” uploads always need a JWT (the
# `visibility` knob only affects who can READ the file later,
# not who can upload).

# 1) Organization-scoped (default). Anyone in the same org as
#    the uploader can download.
curl -X POST 'http://localhost:3001/upload?folder_id=4f2cโ€ฆ&visibility=organization' \
  -H 'Authorization: Bearer <token>' \
  -F 'file=@/path/to/photo.jpg'

# 2) Public. Anyone with the media id can download anonymously
#    via GET /media/{id} โ€” no token required on the read side.
#    Useful for assets that are meant to be embedded on a
#    public web page.
curl -X POST 'http://localhost:3001/upload?visibility=public' \
  -H 'Authorization: Bearer <token>' \
  -F 'file=@/path/to/banner.jpg'

# 3) Private. Only the uploader can download (and org admins
#    if your deployment grants them override).
curl -X POST 'http://localhost:3001/upload?visibility=private' \
  -H 'Authorization: Bearer <token>' \
  -F 'file=@/path/to/secret.pdf'

# 4) Guarded upload (X-Deletion-Code). The server hashes the
#    secret; remember to keep the plaintext on the caller side
#    or the file becomes undeleteable from outside ops/DB.
CODE=$(openssl rand -base64 32 | tr -d '\n')
curl -X POST 'http://localhost:3001/upload' \
  -H 'Authorization: Bearer <token>' \
  -H "X-Deletion-Code: $CODE" \
  -F 'file=@/path/to/asset.png'

# 5) 3D model upload (?as=3d-model). Server runs Blender โ†’
#    gltfpack โ†’ f3d, persisting three meshopt-compressed glTF
#    LOD tiers (?variant=web / web-medium / web-low) plus a
#    320ร—320 PNG preview (?variant=low). Bundle OBJ + MTL +
#    textures in a single zip; naked .obj with `mtllib` is
#    rejected. 503 MODEL_3D_UNAVAILABLE if Blender isn't
#    configured on the host.
curl -X POST 'http://localhost:3001/upload?as=3d-model' \
  -H 'Authorization: Bearer <token>' \
  -F 'file=@/path/to/scene.zip'

# 6) Standalone .glb โ€” also goes through the pipeline when
#    `?as=3d-model` is set; the server re-imports + re-packs
#    into the LOD tiers for the viewer-served format.
curl -X POST 'http://localhost:3001/upload?as=3d-model' \
  -H 'Authorization: Bearer <token>' \
  -F 'file=@/path/to/model.glb'

# 7) Service-principal upload (e.g. a headless backend acting for
#    an anonymous applicant). The bearer is a System-account token
#    minted by the Account Service POST /auth/system-token flow and
#    carrying `media:upload`. `visibility=public` lets the file be
#    fetched later without auth. Counts against the per-service
#    quota (MAX_STORAGE_PER_SERVICE), not the per-user bucket.
curl -X POST 'http://localhost:3001/upload?visibility=public' \
  -H 'Authorization: Bearer <system-account-jwt>' \
  -F 'file=@/path/to/cv.pdf'

# 8) Cross-organisation upload by a trusted backend. The bearer is
#    a System-account token carrying BOTH `media:upload` AND
#    `media:act_as_any_org`. X-Organization-Id picks the target
#    org โ€” the membership gate is skipped because both factors
#    hold (principal_type=service + permission). The resulting
#    media is attributed to that org; members of the org can read
#    it under `visibility=organization` without needing the file
#    to be public. A user token granted `media:act_as_any_org`
#    alone would still be gated by membership (403).
curl -X POST 'http://localhost:3001/upload?visibility=organization' \
  -H 'Authorization: Bearer <system-account-jwt>' \
  -H 'X-Organization-Id: 660e8400-e29b-41d4-a716-446655440001' \
  -F 'file=@/path/to/applicant-cv.pdf'
Example Response
# Image / non-3D upload โ€” `model_3d` is false, status omitted.
{
  "id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
  "filename": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11.jpg",
  "original_name": "photo.jpg",
  "mime_type": "image/jpeg",
  "size_bytes": 248122,
  "checksum": "sha256:7e1fโ€ฆ",
  "visibility": "organization",
  "folder_id": "4f2cโ€ฆ",
  "created_at": "2026-04-30T08:14:22Z",
  "model_3d": false,
  "model_3d_status": null
}

# 3D upload (`?as=3d-model`) โ€” returns immediately with status
# `processing`. The detached pipeline writes `ready` / `failed`
# to the same row when it finishes; clients refresh the list to
# see the new state, or hit POST /media/{id}/reprocess-3d to
# retry a failure.
{
  "id": "c1502f8c-cd40-40a7-b8b9-ecd320974298",
  "filename": "c1502f8c-cd40-40a7-b8b9-ecd320974298.zip",
  "original_name": "scene.zip",
  "mime_type": "application/zip",
  "size_bytes": 29144542,
  "checksum": "sha256:a3โ€ฆ",
  "visibility": "organization",
  "folder_id": null,
  "created_at": "2026-05-23T13:46:49Z",
  "model_3d": true,
  "model_3d_status": "processing"
}

๐Ÿ” Credentials

Konfigurasi credentials untuk testing API

Authorization: Bearer <value>
๐Ÿ’ก Info: Credentials tersimpan per environment di browser Anda (localStorage) dan tidak dikirim ke server.

Cara Menggunakan

1
Isi credentials di atas
2
Klik "Endpoints" di sidebar untuk memilih API
3
Klik "Try It" pada endpoint yang ingin di-test
4
Pilih metode autentikasi dan klik Send

๐Ÿ” Authentication

Methods and permissions for API access

Authentication Methods

JWT

Header: Authorization: Bearer <token>

Source: Account Service

RS256-signed JWT issued by the Account Service. The signature is verified against JWT_PUBLIC_KEY_PATH (defaults to public_key.pem in the working directory). Optional dynamic key rotation is supported via ACCOUNT_SERVICE_URL.

Token contains: sub (user UUID), org_id (organisation UUID, optional), permissions (e.g. ["media:upload", "folder:list"]), exp (standard JWT expiry)

Permissions

PermissionDescription
media:upload Upload new media files.
media:download Download media files; required to create shared links.
media:list List media; gates recent and starred listings.
media:update Rename media or change its visibility.
media:delete Delete media.
media:act_as_any_org Attribute an upload to ANY organisation via X-Organization-Id, bypassing the membership gate. Effective ONLY when paired with principal_type=service (trusted headless backend).
folder:create Create folders.
folder:list List folders and folder contents.
folder:update Rename or move folders.
folder:delete Delete folders (set force=true to delete recursively).