Docs menu

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.

Cloudflare R2HTMXHonoSecurity

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>![](/uploads/name-a1b2c3.png)</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">![](/uploads/${name})</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 ![](/uploads/...) snippet keeps the writer’s flow on one screen: upload → copy → paste into markdown.

Want something like this built for your business?