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.

View as markdown

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

VisibilityurlLifetime
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.
privateA signed storage linkOne hour. List the files again to get a fresh one.

Limits

LimitValueMessage
Size25 MB (26,214,400 bytes)413 Files can be up to 25 MB.
Empty file0 bytes400 The file is empty.
TypeSee below415 That type of file can't be uploaded here.
Uploads started30 per minute per user429 Too many uploads. Try again in a minute.
StorageThe owner's plan, across all their apps413 This app is out of storage space.
Any request to files/<fileId>120 per minute per IP429 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.

File uploads · Ripping docs