File storage
How ShipKit stores files in Cloudflare R2 through a Worker binding — uploads, private downloads, profile photos, quotas and the optional files module.
All stored files live in one Cloudflare R2 bucket, reached through a Worker
binding called BUCKET. There are no S3 access keys and no presigned URLs:
the browser posts a file to a server route, the Worker streams it into R2, and
downloads come back through the Worker the same way. That keeps every read
behind your own session check, at the cost of every byte passing through a
Worker.
Three things use the bucket:
| What | Upload route | Read route | R2 key | Who can read |
|---|---|---|---|---|
| Files module | POST /api/files | GET /api/files/$fileId | <userId>/<uuid>/<filename> | The owner only |
| Profile photos (core) | POST /api/avatar | GET /api/avatar/$userId | avatars/<userId> | Anyone |
| Feedback screenshots | POST /api/feedback-image | GET /api/feedback-image/$userId/$id | feedback/<userId>/<id> | The uploader, and roles with feedback.read |
The binding
The bucket is declared in wrangler.jsonc:
"r2_buckets": [
{
"binding": "BUCKET",
"bucket_name": "shipkit-files"
}
]
In development, bun run dev runs the app in workerd with a local R2
binding, so uploads work with no Cloudflare account; the objects are kept
under .wrangler/. For production, create the bucket once before your first
deploy (the /deploy skill does this for you):
bunx wrangler r2 bucket create shipkit-files
Server code reaches the bucket through getBindings().BUCKET from
src/core/server/cf.ts — the one module allowed to import
cloudflare:workers (see Architecture).
Uploads and downloads are API routes rather than server functions because they
carry multipart bodies and binary streams. They are one of the few places the
template uses server.handlers instead of the normal data channel described
in Server functions.
Uploading a file
The files module's upload route is src/routes/api/files.ts. It takes a
multipart POST with a single file field, which is what the /files page
sends:
const form = new FormData()
form.append('file', file)
const res = await fetch('/api/files', { method: 'POST', body: form })
In order, the route:
- Requires a session (
401without one). - Rate-limits by user id, against the
PUBLIC_RATE_LIMITbinding (5 requests per minute by default inwrangler.jsonc) —429when over. - Refuses anything over 25 MB, first by the declared
Content-Lengthand again by the actual file size (413). - Checks the account's total against a 1 GB quota (
507when it would go over). - Streams the file into R2 with its content type, inserts a row into the
filestable, and writes afiles.uploadedaudit entry.
The two limits are constants at the top of the route:
const MAX_SIZE = 25 * 1024 * 1024 // 25 MB
const USER_QUOTA = 1024 * 1024 * 1024 // 1 GB
Change them there. Two limitations to know before raising them a lot: the
route reads the body with request.formData(), which buffers the whole file
in the Worker's memory, and Cloudflare caps request body size by plan. For
very large files, direct-to-R2 uploads with presigned URLs are the better
shape, and that would be a replacement of this one route. The quota check is
also a read before a write, so two parallel uploads can overshoot it by one
file.
Downloading a file
GET /api/files/$fileId (src/routes/api/files.$fileId.ts) looks the row up
by id and the caller's user id, so a file id is useless to anyone but its
owner. There is no public or shared link.
The response's Content-Disposition depends on the stored type. Images
(PNG, JPEG, GIF, WebP, AVIF), video, audio, plain text and PDF open inline;
everything else downloads as an attachment. This matters because the stored
type is whatever the uploader declared: an HTML or SVG file shown inline would
run its scripts on your app's origin with the viewer's session. If you add
sharing or an admin preview later, keep that rule.
Deleting
deleteFileFn in src/features/files/server/fns.ts is a normal server
function. It deletes the R2 object first and then the row. D1 and R2 cannot
share a transaction, so if the second step fails you are left with a row that
points at nothing — harmless and deletable again, which is why that order was
chosen over the reverse (an object nobody can find).
Profile photos
Avatars are part of core, not the files module. The account dialog posts to
/api/avatar, which accepts PNG, JPEG, WebP or GIF up to 2 MB
(src/features/auth/avatar.ts) and replaces the user's single object at
avatars/<userId>. There is no removal, only replacement.
The route stores a versioned URL in user.image:
/api/avatar/<userId>?v=<timestamp>
Avatars are public, and because every upload changes the ?v= value, the
read route serves them with Cache-Control: public, max-age=31536000, immutable. A user who never uploads keeps the picture URL their Google
account provided.
Account deletion and export
The files rows cascade with the user, but R2 objects do not. Each feature
that writes to the bucket registers cleanup in src/core/account/events.ts
(see Authentication for the account-deletion flow):
- the files module deletes every object it has a row for, before the rows go;
- core auth deletes the avatar;
- the feedback module deletes everything under the user's
feedback/prefix, which also catches screenshots whose feedback was never submitted.
This runs when the account is purged, not when it is closed, so a restored account keeps its files. Download my data includes file metadata (name, size, type, date), not the files themselves.
In the admin console
The files module adds a section to the user detail drawer and page, gated by
the files.read permission: the user's file count, total size and latest
uploads. It shows metadata only — an admin never gets a download link from
the console. See Admin console and
Permissions.
Removing the files module
The files module is optional. Its recipe removes src/features/files/, the
/files page, both /api/files routes, its sidebar row, the dashboard card
and the registry lines. The R2 binding stays in wrangler.jsonc, because
profile photos use it. See Deleting features.