Uploads
Overview
The Uploads module provides file upload and management for Lumio accounts. Files are uploaded via multipart REST endpoints and stored in a configurable storage backend (e.g., S3-compatible object storage). Upload metadata (filename, content type, size, storage key, purpose) is tracked in PostgreSQL. Download URLs are generated as presigned URLs with a one-hour expiry.
Every uploads operation on both protocols is gated on the feature:uploads feature flag in addition to the uploads:* permission. The dashboard segment /dashboard/uploads is wrapped in a FeatureRouteGate for the same flag.
Architecture
Backend
- GraphQL (
apps/api/src/graphql/uploads.rs) -- Queries for listing (optionalpurposefilter) and fetching uploads. Mutation for deleting uploads (stored file + metadata, via the shared deletion service). - REST (
apps/api/src/routes/uploads.rs) -- Full REST CRUD: list uploads (optionalpurposefilter), get upload with presigned download URL, multipart file upload, and delete (stored file + metadata, via the shared deletion service). - Widget images (
apps/api/src/routes/widget_images.rs,apps/api/src/services/widget_images.rs) -- thewidget_imagetype and size checks of the upload, and the read routeGET /v1/widget-images/{id}. See Widget images. - Storage -- Configurable storage backend accessible via
AppState.storage. Supportsupload(),presigned_download_url(), anddelete()operations. When no backend is configured,POST /v1/uploadsreturns400 Storage is not enabledandGET /v1/uploads/{id}returns the metadata withdownload_urlomitted. - Deletion service (
apps/api/src/services/uploads.rs) --delete_uploadis the single delete path behind GraphQLdeleteUploadand RESTDELETE /v1/uploads/{id}, so both protocols remove the stored file and return the same errors. - Database (
apps/api/src/db/uploads.rs) -- PostgreSQL operations for upload metadata CRUD.
WebSocket
Uploads have no WebSocket channel - channel_gate_for in crates/lo-websocket/src/gate.rs has no uploads arm, so an uploads:{account_id} subscription resolves to ChannelGate::Unknown and is rejected. Clients refresh the list by re-querying. This is a gap against the three-protocol rule, not a design choice.
Frontend
/dashboard/uploads (apps/web/src/app/(main)/(app)/dashboard/uploads/upload-list.tsx) renders:
- a flat file list showing icon (by content-type family), filename, human-readable size, content type and creation date;
- an Upload button that opens a hidden single-file
<input type="file">andPOSTs it to/api/uploadsasmultipart/form-data, then callsrouter.refresh(); - a per-row delete button with an inline confirmation card.
The page uploads one file at a time, sends no purpose field, shows a busy state rather than a byte-level progress bar, and does not surface a download link - download URLs are obtained via GET /v1/uploads/{id}. Files are referenced by other features (e.g., overlay custom assets, alert images).
API
GraphQL Queries
| Query | Guards | Description |
|---|---|---|
uploads(purpose: String) | feature:uploads + uploads:read | List the account's uploads, newest first, at most 200. purpose narrows the list to one exact value (e.g. widget_image). Each row carries a generated downloadUrl. |
upload(id: UUID!) | feature:uploads + uploads:read | Get a single upload by ID (account-scoped) |
The GraphQL Upload type is camelCase: id, accountId, uploadedBy, filename, contentType, sizeBytes, storageKey, purpose, createdAt, downloadUrl. downloadUrl is not a presigned storage URL - it is {server.public_url}/v1/uploads/{id}, the REST endpoint that mints the presigned URL on request.
GraphQL Mutations
| Mutation | Guards | Description |
|---|---|---|
deleteUpload(id: UUID!) | feature:uploads + uploads:delete | Delete upload: removes the file from storage and the metadata from the database (a failed file delete is queued for retry, see Delete Flow). Returns { success: true }; an unknown or foreign-account id fails with Upload not found. |
There is no GraphQL upload mutation: file transfer stays on REST because the payload is multipart.
REST Endpoints
| Method | Path | Guards | Description |
|---|---|---|---|
GET | /v1/uploads | feature:uploads + uploads:read | List the account's uploads, newest first, at most 200. Optional query purpose (exact match), same as GraphQL uploads(purpose:) |
GET | /v1/uploads/{id} | feature:uploads + uploads:read | Get upload details with a presigned download URL (1-hour expiry) |
POST | /v1/uploads | feature:uploads + uploads:create | Upload a file via multipart form. Returns 201. |
DELETE | /v1/uploads/{id} | feature:uploads + uploads:delete | Delete upload: removes file from storage and metadata from database (a failed file delete is queued for retry). Returns 204; an unknown or foreign-account id returns 404 Upload not found. |
GET | /v1/widget-images/{id} | feature:uploads; uploads:read, or an overlay / widget token | Bytes of a widget_image upload. See Widget images |
REST payloads are snake_case (account_id, content_type, size_bytes, storage_key, download_url, created_at).
Upload Request Format
Multipart form data with fields:
file(required) -- The file to upload. Filename and content type are read from the content disposition; a filename longer than 255 characters is cut to 255. For every purpose exceptwidget_imagethe server does not sniff or validate the content type, and there is no allow-list of file types. A request with nofilefield returns400 No file provided (use field name 'file').purpose(optional) -- Free-form purpose label for the upload (e.g.,alert_sound,overlay_image). It is not validated against any enum; the only constraint is the column width (varchar(50)). The one exception iswidget_image, which triggers the type and size checks under Widget images.
Any other multipart field is silently ignored.
Storage Key Format
accounts/{account_id}/uploads/{uuid_v7}.{extension}
Example: accounts/550e8400-e29b-41d4-a716-446655440000/uploads/01912345-6789-7abc-def0-123456789abc.png
The extension is the segment after the last . in the client-supplied filename, and only when it is at most 10 characters; otherwise bin is used. A widget_image gets the extension of its sniffed type instead (png, jpg, gif, webp, avif). Every account-owned object lives under accounts/{account_id}/, so the files of one account can be listed by prefix (StorageClient::list_prefix). Uploads stored before this layout keep their key {account_id}/{uuid_v7}.{extension}; the storage_key column always holds the real key.
Permissions
| Permission | Description |
|---|---|
uploads:read | View and download uploaded files |
uploads:create | Upload new files |
uploads:delete | Delete uploaded files |
Database
| Table | Database | Description |
|---|---|---|
uploads | PostgreSQL | id, account_id (FK, cascade), uploaded_by (nullable user FK, ON DELETE SET NULL: the file stays with the channel when the uploader deletes their profile), filename (varchar 255), content_type (nullable varchar 100), size_bytes (nullable bigint), storage_key (text), purpose (nullable varchar 50), created_at. Indexed on account_id. |
storage_deletion_queue | PostgreSQL | Object keys of deleted channels, uninstalled installs and deleted extensions whose files still have to be deleted: storage_key (unique), account_id (no FK; empty for extension and sweep keys), reason (account_deleted, install_deleted, extension_deleted, orphaned, upload_deleted), attempts, next_attempt_at, last_error. |
Data Flow
Upload Flow
- Client sends a multipart POST to
/v1/uploadswith the file and optional purpose. - Server reads the file data in chunks, checking against the effective per-file cap (see Size limit) during upload.
- A unique storage key is generated:
accounts/{account_id}/uploads/{uuid_v7}.{ext}. - File is uploaded to the storage backend via
storage.upload(). - Metadata (filename, content_type, size, storage_key, purpose) is saved to PostgreSQL by
db::uploads::create_upload_within_quota, which inserts the row only when the file fits the account's storage quota (see Storage quota). If it does not fit, the stored file is deleted again and the request fails with403 PLAN_LIMIT_REACHED"Storage limit reached". - Response includes the upload metadata.
Download Flow
- Client requests
GET /v1/uploads/{id}. - Server verifies account ownership.
- If a storage backend is available, a presigned download URL is generated with 1-hour expiry.
- The upload metadata and download URL are returned.
Delete Flow
GraphQL deleteUpload(id) (used by the dashboard through /api/uploads/{id}) and REST DELETE /v1/uploads/{id} both call services::uploads::delete_upload:
- Server verifies account ownership. An unknown id and another account's id are both reported as
Upload not found(GraphQL error / REST404), so ids do not leak across accounts. - File is deleted from storage (skipped when no storage backend is configured).
- Metadata is deleted from PostgreSQL.
- If the file delete failed, its key goes into
storage_deletion_queue(reasonupload_deleted) and the hourly account security worker retries it, so the bytes do not stay behind while the quota already counts them as freed. The delete itself still succeeds.
Size limit
The per-file cap is the smaller of two values, for every purpose:
- the deployment config key
storage.max_upload_size_bytes(LUMIO__STORAGE__MAX_UPLOAD_SIZE_BYTES,10000000inapps/api/config/default.toml), a ceiling for every account; - the account's
max_upload_size_bytes: anaccount_limitsoverride, else the plan's value (Free: 10 MiB, Pro: 50 MiB), else 10 MiB. The admin plan editor and the billing page show the same value.
A plan value above the deployment ceiling does not raise it: with the default config, Free and Pro accounts both get 10000000 bytes. A lower plan or override value is enforced. The check runs while the multipart stream is consumed, so an oversized file is rejected with 400 File exceeds maximum upload size of {n} bytes (with the effective cap as n) before the whole body has to be read. widget_image uploads have an additional 5 MiB hard cap (see Widget images).
Storage quota
The account's total upload storage is capped by the plan's max_storage_bytes (Free: 100 MB, Pro: 1 GB; an account_limits.max_storage_bytes override wins; an account without a plan gets 100 MB). The bytes counted are every row in uploads plus the account's own sounds - extension-bundled sounds are the extension's files and do not count. The check and the insert run in one transaction under a per-account advisory lock, so two parallel uploads cannot both pass. A file that does not fit is refused with 403 PLAN_LIMIT_REACHED ("Storage limit reached") and leaves neither a row nor a stored object.
The quota exists only on POST /v1/uploads: there is no GraphQL upload mutation (multipart stays on REST). The Sounds library keeps its own max_sound_storage_bytes cap; see Sounds.
Deletion with the channel
When a channel is deleted - dissolved by its owner (immediately or at the end of the grace period) or deleted by a platform operator - its uploads go with it, files included. The delete transaction first copies the storage_key of every upload (and of the channel's other stored objects, see Accounts) into storage_deletion_queue, then deletes the account; after the commit the files are deleted. A file whose delete fails stays queued, and the hourly account security worker retries it until it is gone. The count of deleted files is recorded as deleted_object_count on the account:dissolved / system:account_deleted audit event.
Extension uploads: deletion with the install or the extension
Extensions upload images through POST /v1/extensions/{extension_id}/uploads (extension token). These files are stored once per extension under extensions/{extension_id}/uploads/{hash}.{ext}, named after their content: when two channels upload the same image, both get a row in extension_uploads for the same file. A file is therefore deleted only when no other install still has a row for it.
- Uninstall. Uninstalling with "delete data now" deletes the install and, in the same transaction, queues every upload file of that install that no other install references; the files are deleted right after. Uninstalling with the 30-day retention keeps the install (and its files) until the window ends; the daily data-deletion worker then deletes the install and queues the files the same way, and the hourly account security worker deletes them. A file another channel also uploaded stays.
- Extension delete. Deleting a draft extension (
DELETE /v1/developer/extensions/{id}/deleteExtension) queues all its files in the same transaction - every install's uploads, the version files, bundle, source archive and manifest of every version, the icon and the screenshots - and deletes the bundled sounds of its installs, which point at those version files. After the commit the extension's storage prefixes ({extension_id}/andextensions/{extension_id}/) are listed and any file no row names is queued as well; then the files are deleted. Only files under these two prefixes are ever deleted. - Re-upload during a delete. Uploading a file and deleting a queued file of the same name take the same per-file database lock, and the upload always writes the file again. A channel uploading an image at the moment another channel's copy is deleted therefore keeps a working file. If the file is written but its row cannot be recorded (for example because the install was uninstalled in the meantime), the file is queued for deletion as an orphan.
- Orphan sweep. Uploads left behind by uninstalls and extension deletes before this cleanup existed are found by
POST /v1/admin/storage/orphaned-extension-uploads/sweep(GraphQLadminSweepOrphanedExtensionUploads), which needs theadmin:privacy-eraseadmin permission. It listsextensions/{extension_id}/uploads/for every existing extension and counts the files noextension_uploadsrow references. By default it is a dry run and changes nothing; withapply: trueit queues those files for deletion and records asystem:orphaned_storage_sweptaudit event. Each queued file is checked again under the per-file lock before it is deleted.
Failed deletes stay in storage_deletion_queue and are retried hourly, as for channel deletion. The queue's reason is install_deleted, extension_deleted or orphaned for these cases (account_deleted for a channel).
Widget images
The image widget shows images the channel uploaded itself. They are ordinary uploads with purpose widget_image: they live in uploads and object storage like every upload, count toward the storage quota, and stay in the library until the user deletes them (no automatic cleanup of unused images). A widget's settings name an image by reference (lumio-upload:<id>), never by URL.
Upload checks (POST /v1/uploads with purpose=widget_image; REST-only, there is no GraphQL upload):
| Check | Failure |
|---|---|
| The file's leading bytes are PNG, JPEG, GIF (animated allowed), WebP or AVIF. SVG, HTML and every other type are refused | 400 Unsupported image type: widget images must be PNG, JPEG, GIF, WebP or AVIF |
Size at most min(5 MiB, storage.max_upload_size_bytes, max_upload_size_bytes of the plan or account override) | 400 File exceeds maximum upload size of {n} bytes |
The sniffed MIME type is stored as content_type and its extension goes into the storage key; the client's content type and filename extension are ignored. The file is stored unchanged (metadata such as EXIF stays in it).
Read route GET /v1/widget-images/{id}: the overlay and widget hosts fetch the bytes with their own lm_overlay_* / lm_widget_* token (the dashboard preview with the session) and show them as a blob: URL. There is no public URL and no ?token= query.
| Caller | Result |
|---|---|
Session, personal or service key with uploads:read | 200 |
The same without uploads:read | 403 Missing permission: uploads:read |
Overlay token (lm_overlay_*), widget token (lm_widget_*) | 200 |
Shared overlay link (lm_share_*), popout token, extension token, developer key, MCP token | 403 This token cannot read widget images |
Another account's upload, another purpose, an unknown id, or a widget_image row whose stored type is not one of the five | 404 Widget image not found |
The lookup is one primary-key query filtered on the caller's account, purpose = 'widget_image' and the allowed types. A 200 carries the stored type, X-Content-Type-Options: nosniff, ETag: "<id>" and Cache-Control: private, no-cache; a matching If-None-Match gets 304 without reading storage. So a deleted image disappears on the next load of the overlay (the widget then shows no image), and the image never sits in a shared cache. Shared overlay links get no image on purpose: they go to third parties, and the channel did not hand them its uploads.
In the web app. The settings field of type image in the widget editor and designer (ImageUploadField) uploads a file with purpose=widget_image through POST /api/uploads, lists earlier images through GET /api/uploads?purpose=widget_image (GraphQL uploads(purpose:)), and offers Remove image; it refuses a file over 5 MB before uploading and translates the API's refusals. The URL text field stays for platform images and data:image/… URLs. Hosts never load the reference as a URL: they fetch /api/widget-images/{id} with their own credential and render a blob: URL (see Extension supervisor › Uploaded images). That Next.js proxy accepts the session cookie or an lm_overlay_* / lm_widget_* bearer (any other bearer gets 401, a ?token= query is ignored), forwards If-None-Match, and passes ETag, Cache-Control (fallback private, no-cache) and 304 through unchanged.
Key Files
| Path | Description |
|---|---|
apps/api/src/graphql/uploads.rs | GraphQL queries and mutations |
apps/api/src/routes/uploads.rs | REST endpoints (list, get, upload, delete) |
apps/api/src/routes/widget_images.rs | GET /v1/widget-images/{id}: token allowlist, ETag/304, headers |
apps/api/src/services/widget_images.rs | widget_image type sniff, allowed types, per-file cap |
apps/api/src/services/uploads.rs | Shared delete path (ownership, storage object, metadata) for GraphQL and REST |
apps/api/src/db/uploads.rs | Database operations for upload metadata, including the quota-checked insert |
apps/api/src/services/storage_erasure.rs | Deletes the stored files of a deleted channel, install or draft extension (queue + retry); orphaned extension upload sweep |
apps/api/src/db/storage_deletion.rs | Deletion queue: enqueue per channel / install / extension, per-file lock, claim + retry |
apps/api/src/routes/extension_uploads.rs | Extension upload + serve endpoints (extension token) |
apps/api/src/state.rs | AppState.storage -- storage backend reference |
apps/web/src/app/(main)/(app)/dashboard/uploads/layout.tsx | FeatureRouteGate feature="feature:uploads" on the whole segment |
apps/web/src/app/(main)/(app)/dashboard/uploads/upload-list.tsx | File manager UI |
apps/web/src/app/api/uploads/route.ts | Next.js proxy for list (optional ?purpose=) + multipart upload |
apps/web/src/app/api/widget-images/[id]/route.ts | Next.js proxy for GET /v1/widget-images/{id}: host bearer allowlist, If-None-Match / ETag / 304 passthrough |
apps/web/src/lib/widget-image-resolver.ts | Host resolver: lumio-upload: reference → blob: URL, revoked on drop/unmount |
apps/web/src/components/extension/ImageUploadField.tsx | Editor field for schema type image: upload, library, remove, URL |