Building apps
File uploads
How a generated app stores files people upload, the size and type limits, and how public and private files are addressed.
Overview
Uploads need a signed-in person. A file goes straight from the browser to storage through a signed upload URL, then the app confirms it. Ripping keeps the row (app_files) and the bytes (one private bucket, app-files, at <projectId>/<ownerId>/<fileId>-<name>).
The client module
type AppFile = { id: string; name: string; type: string; size: number; visibility: "public" | "private"; url: string | null; ownerId: string | null; mine: boolean; createdAt: string };
function uploadFile(file: File, opts: { visibility?: "public" | "private" } = {}): Promise<AppFile>; // default "private"
function useFiles(): {
files: AppFile[]; uploading: boolean; error: string | null;
upload: (file: File, opts?: { visibility?: "public" | "private" }) => Promise<AppFile>;
remove: (id: string) => Promise<void>;
refresh: () => Promise<void>;
};
const formatBytes: (n: number) => string; // "12 KB", "3.4 MB"
useFiles lists the signed-in person's own files, refetches on auth:change, and removes optimistically (refreshing when the server refuses). With no token every call throws Sign in to use files. A failed PUT of the bytes throws The upload didn't finish. Try again.; any other failure throws the server's message or Request failed (<status>).
<FileUpload> in src/components/FileUpload.tsx is the button:
function FileUpload(props: { onUploaded: (file: AppFile) => void; accept?: string; visibility?: "public" | "private"; label?: string }): JSX.Element;
Save file.url in a record to show the file later.
Public and private
| Visibility | url | Lifetime |
|---|---|---|
public | <API>/api/apps/<projectId>/files/<fileId> | Permanent. Anyone with the link gets a 302 to a one-hour signed link (cache-control private, max-age=600). Save it in a record: an avatar, a listing photo. |
private | A signed storage link | One hour. List the files again to get a fresh one. |
Limits
| Limit | Value | Message |
|---|---|---|
| Size | 25 MB (26,214,400 bytes) | 413 Files can be up to 25 MB. |
| Empty file | 0 bytes | 400 The file is empty. |
| Type | See below | 415 That type of file can't be uploaded here. |
| Uploads started | 30 per minute per user | 429 Too many uploads. Try again in a minute. |
| Storage | The owner's plan, across all their apps | 413 This app is out of storage space. |
Any request to files/<fileId> | 120 per minute per IP | 429 Too many requests. Try again shortly. |
Allowed MIME types: image/png, image/jpeg, image/gif, image/webp, image/avif, application/pdf, text/plain, text/csv, text/markdown, application/json, audio/mpeg, audio/mp4, audio/wav, audio/ogg, audio/webm, video/mp4, video/webm, video/quicktime, application/zip, Word, Excel and PowerPoint (.docx, .xlsx, .pptx, .doc, .xls).
File names are normalised: accents stripped, anything but word characters, ., - and space removed, spaces turned to -, leading dots and dashes removed, kept to the last 100 characters; an empty result becomes file.
The files route
/api/apps/<projectId>/files, always with Authorization: Bearer <token>. Every error is { "error": "<message>" }.
List
GET /api/apps/<projectId>/files
{ "files": [ { "id": "…", "name": "photo.jpg", "type": "image/jpeg", "size": 51234, "visibility": "public", "url": "https://…/api/apps/<projectId>/files/<id>", "ownerId": "…", "mine": true, "createdAt": "…" } ] }
The caller's own ready files, newest first, up to 200. An admin may pass ?all=1 for everyone's. Signed out: 401 Sign in to see your files.
Start an upload
POST /api/apps/<projectId>/files
Content-Type: application/json
{ "name": "photo.jpg", "type": "image/jpeg", "size": 51234, "visibility": "public" }
{ "id": "<fileId>", "uploadUrl": "https://…signed…", "confirm": "https://…/api/apps/<projectId>/files/<fileId>" }
Returns 201. The row is created with status uploading. Then send the bytes:
PUT <uploadUrl>
Content-Type: image/jpeg
<file body>
Signed out: 401 Sign in to upload. Storage failure: 500 Could not start the upload.
Confirm
POST /api/apps/<projectId>/files/<fileId>
Authorization: Bearer <token>
{ "file": { "id": "…", "name": "photo.jpg", "type": "image/jpeg", "size": 51234, "visibility": "public", "url": "…", "ownerId": "…", "mine": true, "createdAt": "…" } }
Ripping reads the real size from storage (the declared size only reserved room), rechecks the plan's storage, and marks the row ready. Confirming an already-ready file returns it again. Failures: 404 Not found. (not your file), 409 The upload didn't arrive. Try again., 413 This app is out of storage space. (the object is removed).
Delete
DELETE /api/apps/<projectId>/files?id=<fileId>
{ "ok": true }
Removes the object and the row. Failures: 401 Sign in to delete files., 404 Not found., 403 Only the person who uploaded this (or an admin) can delete it.
Fetch a public file
GET /api/apps/<projectId>/files/<fileId>
No token. 302 to a one-hour signed link for a ready public file; 404 Not found for anything else, including private files.