Features & Build Notes · · 3 min read
Image upload to Cloudflare R2: validation, naming, immutable cache
How this site's blog editor uploads images to R2: an extension allowlist, size limits, slug + random-suffix filenames, bufferless streaming, and serving with immutable Cache-Control.
This site’s blog editor has an image-upload button: files land in Cloudflare R2 (object storage with zero egress fees), and a paste-ready markdown snippet comes back to the editor via HTMX. Here’s the full flow — including the three security decisions every upload feature must have.
The architecture
Editor (HTMX multipart) ──▶ POST /admin/upload ──▶ validate ──▶ R2.put()
│
Editor receives snippet ◀── <code></code>
Visitor ──▶ GET /uploads/:name ──▶ R2.get() ──▶ stream + immutable cache
1. The upload handler: validate first, store second
// src/routes/admin.tsx
const allowedImage: Record<string, string> = {
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml",
};
const MAX_UPLOAD = 10 * 1024 * 1024; // 10MB
adminRoutes.post("/admin/upload", async (c) => {
const form = await c.req.parseBody();
const file = form["image"];
if (!(file instanceof File)) return c.text("no file", 400);
if (file.size > MAX_UPLOAD) return c.text("file too large (max 10MB)", 400);
const dot = file.name.lastIndexOf(".");
const ext = dot === -1 ? "" : file.name.slice(dot).toLowerCase();
const contentType = allowedImage[ext];
if (!contentType) return c.text("unsupported file type", 415);
// Filename: slug of the original + 4 random bytes — unique without a database
const suffix = [...crypto.getRandomValues(new Uint8Array(4))]
.map((b) => b.toString(16).padStart(2, "0")).join("");
const name = slugify(file.name.slice(0, dot)) + "-" + suffix + ext;
await c.env.UPLOADS.put(name, file.stream(), { httpMetadata: { contentType } });
// The reply is a paste-ready markdown snippet (HTMX swaps it into the editor)
return c.html(`<code class="select-all"></code>`);
});
The three security decisions: an extension allowlist (not a blocklist), a size limit before touching storage, and a slugified filename + random suffix — the user’s original name is never used raw (goodbye path traversal and name collisions).
2. Serving from R2 with aggressive caching
adminRoutes.get("/uploads/:name", async (c) => {
const obj = await c.env.UPLOADS.get(c.req.param("name"));
if (!obj) return c.text("not found", 404);
return c.body(obj.body as ReadableStream, 200, {
"Content-Type": obj.httpMetadata?.contentType ?? "application/octet-stream",
"Cache-Control": "public, max-age=31536000, immutable",
ETag: obj.httpEtag,
});
});
A year of immutable is safe because the filename carries a random suffix — the content at that URL will never change; a new file always gets a new URL. It’s the same cache-busting pattern asset bundlers use.
3. The R2 binding in wrangler.jsonc
"r2_buckets": [
{ "binding": "UPLOADS", "bucket_name": "alifnugraha-uploads" }
]
In code, the bucket appears as c.env.UPLOADS with a simple API: .put(key, body), .get(key), .delete(key). Under wrangler dev it’s all emulated locally — no account, no cost.
Lessons
- R2 wins for public assets because of free egress — a blog image going viral doesn’t become an invoice.
- Streaming (
file.stream()→obj.body) means the Worker never holds the whole file in memory. - Returning the
snippet keeps the writer’s flow on one screen: upload → copy → paste into markdown.
Want something like this built for your business?