# Files and uploads

Send files directly to R2, confirm durable upload intent and recover interrupted multipart uploads.

<Diagram name="uploads" alt="An authenticated upload persists intent, creates an immutable R2 object, then confirms metadata; cron recovers unfinished confirmation." />

## Gateway and isolation [#gateway-and-isolation]

The web client uses authorized upload procedures for direct byte transport and the files-sdk gateway at `/api/files` for browsing, downloads and deletion. Every operation checks the current Session and membership. The server applies `orgs/<organizationId>/`; a filename cannot select or escape an Organization. One private R2 bucket serves all Organizations.

Keys preserve the User's filename, including spaces and Unicode, under [ADR-0001](/docs/adr/0001-stored-file-keys-keep-the-user-s-filename). Treat keys as opaque and encode them for URLs. Do not reintroduce filename sanitization that silently changes the display name.

## Files workspace [#files-workspace]

On the web, `useFilesWorkspace` (`features/files/files.hooks.ts`) owns the page: one tenant-scoped listing through the gateway, the Document Text status of each item and the selection the details dialog shows. Every Files query key starts with the Session identity (`user:organization`), so another Organization's same key never reuses a listing, a trash page or a signed URL. Components receive items and callbacks; the operations behind the card menu, the share dialog, the preview and the search live in `files.actions.hooks.ts`, `files.preview.hooks.ts` and `files.trash.hooks.ts`, and no component holds the files-sdk handle (W5).

The dashboard's file count and bytes come from the read-only `files.stats` procedure (`packages/api/src/features/files`), which sums the live objects under the Organization's prefix on the server and is prefetched by the dashboard loader. `uploaded_file` records confirmed uploads but not later deletes, moves or restores, so the listing, not the table, is the source.

## Durable upload lifecycle [#durable-upload-lifecycle]

`uploads.prepare` persists an intent after validating membership, MIME type and size. A signed PUT binds the exact key, size, content type and conditional create. Files above 16 MiB use multipart uploads with 8 MiB parts, at most three concurrent parts and at most three attempts per part. The browser sends `File.slice()` objects and resumes from server-listed parts.

Completion validates part lengths and ETags, then checks HEAD metadata before confirming the file. Signed URLs expire after 15 minutes; intents last 24 hours. Cron recovers missing acknowledgements and aborts abandoned multipart sessions. Explicit cancellation aborts multipart; single-PUT cleanup waits 16 minutes to cover a late upload through an issued URL.

Images can be up to 100 MiB, documents 1 GiB and ZIP archives 5 GiB. The legacy gateway proxy remains capped at 10 MiB and reauthorizes PUTs through the files-sdk patch. Soft delete is enabled; versioning is not. Text previews stop at 64 KiB, and downloads can use range requests without loading the whole original into the Worker.

Confirmed supported files enter the separate [Document Text workflow](/docs/features/documents). Storage limits and conversion limits are independent; a stored original can remain downloadable when text preparation fails. [ADR-0008](/docs/adr/0008-documents-and-large-uploads) explains both paths.

## Configure and extend [#configure-and-extend]

Supply `STORAGE_BUCKET`, `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY_ID` and `STORAGE_SECRET_ACCESS_KEY` in the intended environment. Setup does not create an R2 bucket. Configure bucket CORS for the actual web origins, upload/read methods and signed headers; expose `ETag` so multipart completion can read it. `apps/web/r2-cors.json` is the starting template, not a list of every worktree or deployed origin.

Extend size/MIME rules in storage config and retain admission, confirmation and source-version checks. Keep `files-sdk` on one compatible copy across web, API and storage and rebase its pinned patch on upgrades. Verify upload, download byte equality, anonymous denial, prefix isolation, multipart resume/cancel and interrupted confirmation.


## Key files [#key-files]

- [`apps/web/src/routes/api/files/$.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/routes/api/files/$.ts)
- [`apps/web/src/features/files/files.gateway.server.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/files/files.gateway.server.ts)
- [`packages/storage/src/create-r2-files.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/storage/src/create-r2-files.ts)
- [`packages/storage/src/upload-plugin.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/storage/src/upload-plugin.ts)
- [`apps/web/src/features/files/files.hooks.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/files/files.hooks.ts)
- [`packages/api/src/features/files/files.service.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/files/files.service.ts)
- [`packages/api/src/features/uploads/uploads.service.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/uploads/uploads.service.ts)
- [`packages/storage/src/direct-s3.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/storage/src/direct-s3.ts)
- [`packages/storage/src/config.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/storage/src/config.ts)
- [`patches/files-sdk@2.6.2.patch`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/patches/files-sdk@2.6.2.patch)

