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.
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></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"></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
membuat alur penulis tetap satu layar: upload → salin → tempel di markdown.
Mau fitur seperti ini dibangun untuk bisnismu?