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:

WhatUpload routeRead routeR2 keyWho can read
Files modulePOST /api/filesGET /api/files/$fileId<userId>/<uuid>/<filename>The owner only
Profile photos (core)POST /api/avatarGET /api/avatar/$userIdavatars/<userId>Anyone
Feedback screenshotsPOST /api/feedback-imageGET /api/feedback-image/$userId/$idfeedback/<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:

  1. Requires a session (401 without one).
  2. Rate-limits by user id, against the PUBLIC_RATE_LIMIT binding (5 requests per minute by default in wrangler.jsonc) — 429 when over.
  3. Refuses anything over 25 MB, first by the declared Content-Length and again by the actual file size (413).
  4. Checks the account's total against a 1 GB quota (507 when it would go over).
  5. Streams the file into R2 with its content type, inserts a row into the files table, and writes a files.uploaded audit 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.