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 (metadata only; storage cleanup is handled by the REST endpoint or background job). - REST (
apps/api/src/routes/uploads.rs) -- Full REST CRUD: list uploads, get upload with presigned download URL, multipart file upload, and delete (storage + metadata). - 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. - 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 metadata from the database. Storage cleanup is not performed — use the REST endpoint when the object must go too. |
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. |
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
{account_id}/{uuid_v7}.{extension}
Example: 550e8400-e29b-41d4-a716-446655440000/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.
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), 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. |
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:
{account_id}/{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.
- 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
- Client requests
DELETE /v1/uploads/{id}. - Server verifies account ownership.
- File is deleted from storage (best effort -- logged if it fails).
- 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 plans table does carry max_upload_size_bytes and max_storage_bytes columns (Free: 10 MB / 100 MB, Pro: 50 MB / 1 GB) and they are surfaced through the billing queries and the admin plan editor, but apps/api/src/routes/uploads.rs does not read them — neither the per-plan file-size cap nor any total-storage quota is enforced on this route. Per-plan enforcement does exist for the separate Sounds library; see Sounds.
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/db/uploads.rs | Database operations for upload metadata |
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 |