# 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

```ts
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:

```ts
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

```http
GET /api/apps/<projectId>/files
```

```json
{ "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

```http
POST /api/apps/<projectId>/files
Content-Type: application/json

{ "name": "photo.jpg", "type": "image/jpeg", "size": 51234, "visibility": "public" }
```

```json
{ "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:

```http
PUT <uploadUrl>
Content-Type: image/jpeg

<file body>
```

Signed out: `401 Sign in to upload.` Storage failure: `500 Could not start the upload.`

### Confirm

```http
POST /api/apps/<projectId>/files/<fileId>
Authorization: Bearer <token>
```

```json
{ "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

```http
DELETE /api/apps/<projectId>/files?id=<fileId>
```

```json
{ "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

```http
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.
