Let users upload files

You need: storage and auth composed, and pithy provision run.

The flow

The object model is what a completed upload becomes, big files is the multipart path, quotas and cleanup is what bounds it, and serving files is how the bytes come back.

1. Start the upload. Your Worker creates a pending row and returns a presigned URL.

POST /storage
{ "path": "invoices/2026/q3.pdf", "contentType": "application/pdf", "size": 184320 }
→ { "id": "…", "url": "https://…" }

2. The client PUTs the bytes straight to R2. They never pass through your Worker.

3. Complete it.

POST /storage/<id>/complete

The row flips to stored, and the content type is overwritten with what R2 actually stored — a presigned PUT cannot sign a content type, so only the completed row is authoritative.

Declare the size, and mean it

The size is required whenever a quota is configured, because a null size reserves nothing.

The pending row reserves those bytes the moment it is written, which is what makes a quota hold under a concurrent burst — see quotas, ownership and cleanup.

Your path, not your key

You supply invoices/2026/q3.pdf. That is stored, indexed and listable from D1. It never reaches R2.

R2 gets obj/<uuid>, derived by the server.

So a ../ cannot escape a prefix, two clients cannot collide on a name, and no client-controlled text is ever interpolated into a key.

Above 100 MiB it goes multipart

The threshold is 100 MiB and the default part size is 64 MiB, both configurable.

GET /storage/<id>/parts       → what R2 holds, and fresh URLs for the rest
POST /storage/<id>/abort

A dropped client is cheap. Re-list the parts, re-presign the missing ones, and the client sends only what is left — the server holds no transfer state beyond the pending row.

See big files for the ceilings.

List and manage

GET    /storage                  your objects
PATCH  /storage/<id>             rename the path, change visibility
DELETE /storage/<id>
GET    /storage/<id>/url         a fresh presigned read URL
POST   /storage/<id>/copy

Every one is scoped to the authenticated caller.

Abandoned uploads clean themselves up

A pending row past its TTL is reclaimed by the sweep Worker provisioning stands up alongside the bucket.

So a client that starts an upload and disappears does not leave a reservation eating somebody’s quota forever.

When to reach for media instead

If the file is an image, a video, an audio file or a document you want text out of, the media capability sits on top of this one and adds a per-type backend plus the enrichment Workflows.

Storage is the right layer for bytes you just need kept.

ESC