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 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, get upload with presigned download URL, multipart file upload, and delete (stored file + metadata, via the shared deletion service). - 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 | feature:uploads + uploads:read | List all uploads for the account. 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 (best effort) and the metadata from the database. 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 all uploads for the account |
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 (best effort) and metadata from database. Returns 204; an unknown or foreign-account id returns 404 Upload not found. |
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; the 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)).
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. 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), 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
max_upload_size_bytesduring 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 (best effort -- logged if it fails; skipped when no storage backend is configured).
- Metadata is deleted from PostgreSQL.
Size limit
The upload size cap comes from the deployment config key storage.max_upload_size_bytes (LUMIO__STORAGE__MAX_UPLOAD_SIZE_BYTES, 10000000 in apps/api/config/default.toml). It is the same value for every account regardless of plan. 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 before the whole body has to be read.
The per-plan max_upload_size_bytes column (Free: 10 MB, Pro: 50 MB) is surfaced through the billing queries and the admin plan editor but is not read by this route; the per-file cap is the deployment-wide value above.
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).
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/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 + multipart upload |