Skip to content

File uploads

Kilat uses Cloudflare R2 for user-uploaded files. Avatars are the first use case, but the same pattern — stream to R2, serve from R2, cache at the edge — works for any file type: documents, images, PDFs, audio.

R2 External (S3)
Runtime Workers (edge) Any
Egress Free $$ (per-GB)
Latency ~5ms (co-located) ~50ms (cross-region)
Setup wrangler r2 bucket create IAM + SDK
Scaling Automatic Automatic

R2 is S3-compatible with zero egress fees — critical for apps that serve user-uploaded content. The Worker and R2 bucket are co-located in Cloudflare’s network, so reads are ~5ms.

Two routes, one bucket:

  1. UploadPOST /<resource>/upload streams the file body to R2 via multipart FormData, stores the public path in D1.
  2. ServeGET /uploads/* (or /avatars/*) reads from R2 and streams back with cache headers.

No disk storage, no temp files, no external upload service.

// The pattern: validate → stream to R2 → store path in D1
app.post("/profile/avatar", requireAuth, async (c) => {
const bucket = c.env?.AVATARS;
if (!bucket) return new Response("R2 not configured", { status: 503 });
const formData = await c.req.formData();
const file = formData.get("file");
if (!(file instanceof File)) return new Response("No file", { status: 422 });
// 1. Validate type and size
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
if (!ALLOWED.includes(file.type)) return new Response("Invalid type", { status: 422 });
if (file.size > MAX_AVATAR_BYTES) return new Response("Too large", { status: 413 });
// 2. Generate a random key — scoped by user, unguessable
const key = `avatars/${user.id}/${randomHex()}.${ext}`;
await bucket.put(key, file.stream(), {
httpMetadata: { contentType: file.type },
});
// 3. Store the public URL path in D1
await updateUserAvatar(`/avatars/${key}`, user.id);
return new Response(null, { status: 204 });
});
// Dedicated route — not through the static asset binding
app.get("/avatars/*", async (c) => {
const bucket = c.env?.AVATARS;
if (!bucket) return new Response("R2 not configured", { status: 503 });
const key = safeUrl(c.req.url).pathname.slice("/avatars/".length);
const object = await bucket.get(key);
if (object === null) return new Response("Not found", { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);
headers.set("cache-control", "public, max-age=3600");
return new Response(object.body, { headers });
});

cache-control: public, max-age=3600 tells Cloudflare’s edge to cache files for 1 hour. Since the R2 key includes a random component, a new upload gets a new URL — stale cache is never served after an update.

{type}/{userId}/{random16hex}.{ext}
  • type — namespace per feature (avatars, documents, covers, …)
  • userId — scope per user, easy to audit or bulk-delete
  • Random hex — prevents URL guessing and cache collisions
  • Extension — matches the original file type

The avatar implementation is the reference. To add a new upload type (e.g. document attachments):

  1. Add a route in routes/<feature>.routes.ts — validate, stream to R2, store the path in D1.
  2. Add a serve route (or reuse /uploads/* with a key prefix).
  3. Add validation — allowed types, max size, per-feature limits.
  4. Update CSP if serving from a new path prefix (img-src, media-src).

The R2 bucket is shared — one AVATARS binding can hold multiple key prefixes. Or create a separate bucket per file type if you prefer isolation.

Concern Mitigation
XSS via SVG Only raster types allowed for avatars — SVG can carry inline scripts
File size abuse Per-route size cap (avatars: 2 MB)
Unauthorized upload requireAuth guard + user ID in key
CSRF Origin check on all POST routes (see security.ts)
Content-type spoofing httpMetadata.contentType set from file.type, not user-controlled
Path traversal R2 keys are flat strings — no filesystem to traverse

If R2 is not enabled on the Cloudflare account, or the binding is missing, both upload and serving return a clear 503 with instructions:

Avatar storage not configured. Enable R2 at
https://dash.cloudflare.com → R2, then run:
npx wrangler r2 bucket create kilat-avatars

The rest of the app works normally — auth, SSR, D1, rate limiting. Only file upload and serving are affected.

The Profile page sends a FormData request when the user picks a file:

async function uploadAvatar(file: File) {
const form = new FormData();
form.append("file", file);
const res = await fetch("/profile/avatar", { method: "POST", body: form });
if (!res.ok) { /* show error */ return; }
router.reload(); // refresh shared props so the avatar updates
}

No progress bar — for a 2 MB file, the upload completes in under a second. For larger files, add a progress handler via XMLHttpRequest.upload.onprogress or switch to R2 multipart upload.

Terminal window
# Create the R2 bucket
npx wrangler r2 bucket create kilat-avatars
# Or use the scaffold --remote flag to create it automatically
npm create kilat@latest my-app --remote

The binding is already in wrangler.toml:

[[r2_buckets]]
binding = "AVATARS"
bucket_name = "kilat-avatars"

Local dev (wrangler dev --local) simulates R2 via Miniflare — no bucket needed.