Menu docs

Fitur & Build Notes · · 3 menit baca

Upload gambar ke Cloudflare R2: validasi, penamaan, cache immutable

Cara editor blog situs ini meng-upload gambar ke R2: allowlist ekstensi, batas ukuran, nama file slug + suffix acak, streaming tanpa buffer, dan penyajian dengan Cache-Control immutable.

Cloudflare R2HTMXHonoSecurity

Editor blog situs ini punya tombol upload gambar: file masuk ke Cloudflare R2 (object storage tanpa biaya egress), dan snippet markdown siap-tempel dikembalikan ke editor via HTMX. Ini alur lengkapnya — termasuk tiga keputusan keamanan yang wajib ada di fitur upload mana pun.

Arsitekturnya

Editor (HTMX multipart) ──▶ POST /admin/upload ──▶ validasi ──▶ R2.put()
                                                        │
Editor menerima snippet  ◀── <code>![](/uploads/nama-a1b2c3.png)</code>
Pengunjung ──▶ GET /uploads/:nama ──▶ R2.get() ──▶ stream + cache immutable

1. Handler upload: validasi dulu, simpan kemudian

// 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);

  // Nama file: slug dari nama asli + 4 byte acak — unik tanpa 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 } });

  // Balasannya snippet markdown siap-tempel (ditukar HTMX ke editor)
  return c.html(`<code class="select-all">![](/uploads/${name})</code>`);
});

Tiga keputusan keamanannya: allowlist ekstensi (bukan blocklist), batas ukuran sebelum menyentuh storage, dan nama file di-slug-kan + suffix acak — nama asli dari user tidak pernah dipakai mentah (selamat tinggal path traversal dan tabrakan nama).

2. Menyajikan dari R2 dengan cache agresif

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,
  });
});

immutable selama setahun aman karena nama file mengandung suffix acak — konten di URL itu tidak akan pernah berubah; file baru selalu dapat URL baru. Ini pola cache-busting yang sama dengan bundler asset.

3. Binding R2 di wrangler.jsonc

"r2_buckets": [
  { "binding": "UPLOADS", "bucket_name": "alifnugraha-uploads" }
]

Di kode, bucket muncul sebagai c.env.UPLOADS dengan API sederhana: .put(key, body), .get(key), .delete(key). Di wrangler dev, semuanya diemulasi lokal — tanpa akun, tanpa biaya.

Pelajaran

  • R2 unggul untuk aset publik karena egress gratis — gambar blog yang viral tidak menjadi tagihan.
  • Streaming (file.stream()obj.body) berarti Worker tidak pernah menampung seluruh file di memori.
  • Snippet balasan ![](/uploads/...) membuat alur penulis tetap satu layar: upload → salin → tempel di markdown.

Mau fitur seperti ini dibangun untuk bisnismu?