Upload File Aman dengan Claude Code: FormData, Validasi, Pratinjau, dan S3
Upload file aman untuk SaaS dengan Claude Code: File API, FormData, fetch, validasi, progres, dan S3.
Upload file terlihat sederhana sampai fitur itu dipakai di SaaS sungguhan. Foto profil, invoice PDF, impor CSV, kontrak, dan lampiran chat semuanya dimulai dari tombol “pilih file”. Di balik tombol itu ada API browser, format request, validasi server, izin storage, apakah file boleh publik, pratinjau, progres, biaya, dan audit log.
Claude Code sangat membantu untuk pekerjaan seperti ini. Ia bisa membuat komponen React, route API, helper validasi, adapter storage, dan README. Namun prompt harus jelas. Jika hanya menulis “buat upload file”, hasilnya mungkin berjalan sebagai demo, tetapi menyimpan nama file asli, terlalu percaya pada MIME dari browser, tidak membatasi ukuran, atau mengembalikan URL publik terlalu cepat.
Artikel ini memakai urutan yang mudah diaudit. Pertama kita pisahkan File API, FormData, dan Fetch API. Setelah itu kita buat upload minimal, pratinjau dan progres nyata di React, validasi server, lalu keputusan kapan pindah ke S3, Cloud Storage, Azure Blob Storage, atau Cloudflare R2.
Untuk bagian storage, baca juga Claude Code dan AWS S3. Untuk sudut pandang keamanan, gunakan praktik keamanan Claude Code.
Pisahkan peran di browser
Bagian pertama adalah File API. Saat pengguna memilih file lewat<input type="file">atau drag and drop, browser memberi objekFile. Dari situ kita bisa membacafile.name, file.size, file.type, dan file.lastModified. Untuk gambar,URL.createObjectURL(file) bisa membuat URL sementara untuk pratinjau.
Bagian kedua adalah FormData. Ini wadah untuk mengirim field dan file sebagaimultipart/form-data. Biasanya kita memakaiformData.append("file", file). Detail penting: saat memakai FormData dengan fetch, jangan set headerContent-Typesecara manual. Browser harus menambahkan multipart boundary sendiri.
Bagian ketiga adalah fetch. Untuk upload sederhana, fetch cukup:
const formData = new FormData();
formData.append("file", file);
await fetch("/api/upload", {
method: "POST",
body: formData
});
Pengecualiannya adalah progres nyata. Fetch bersih untuk request, tetapi event progres upload lebih mudah lewatXMLHttpRequest.upload.onprogress. Karena itu saya biasanya meminta Claude Code memakai fetch untuk jalur sederhana, dan XMLHttpRequest jika produk membutuhkan progress bar yang benar-benar terhubung ke transfer.
Implementasi minimal dengan HTML dan fetch
Jangan mulai dari React dan S3. Pastikan dulu alurnya: pilih file, validasi ringan di browser, tampilkan pratinjau, kirim FormData.
<form id="upload-form">
<input id="file-input" name="file" type="file" accept="image/png,image/jpeg,application/pdf" />
<button type="submit">Upload</button>
</form>
<img id="preview" alt="" style="max-width: 240px; display: none;" />
<p id="message"></p>
<script type="module">
const MAX_BYTES = 5 * 1024 * 1024;
const allowedTypes = new Set(["image/png", "image/jpeg", "application/pdf"]);
const form = document.querySelector("#upload-form");
const input = document.querySelector("#file-input");
const preview = document.querySelector("#preview");
const message = document.querySelector("#message");
input.addEventListener("change", () => {
const file = input.files?.[0];
preview.style.display = "none";
preview.removeAttribute("src");
message.textContent = "";
if (!file) return;
if (!allowedTypes.has(file.type)) {
message.textContent = "Hanya PNG, JPEG, dan PDF yang diizinkan.";
input.value = "";
return;
}
if (file.size > MAX_BYTES) {
message.textContent = "Ukuran file maksimal 5MB.";
input.value = "";
return;
}
if (file.type.startsWith("image/")) {
preview.src = URL.createObjectURL(file);
preview.style.display = "block";
preview.onload = () => URL.revokeObjectURL(preview.src);
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = input.files?.[0];
if (!file) {
message.textContent = "Pilih file terlebih dahulu.";
return;
}
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData
});
const result = await response.json();
message.textContent = response.ok ? `Tersimpan: ${result.name}` : result.error;
});
</script>
Validasi browser hanya untuk pengalaman pengguna. Klien berbahaya bisa melewati JavaScript ini. Server tetap harus memeriksa MIME, ekstensi, ukuran, nama penyimpanan, dan lokasi penyimpanan.
React dengan pratinjau dan progres nyata
Di React, pisahkan state: file yang dipilih, URL pratinjau, progres, error, dan nama file tersimpan. Ini membuat kode lebih mudah diperluas oleh Claude Code.
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "application/pdf"]);
type UploadResult = { ok: true; name: string; size: number; type: string };
export function FileUploadBox() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [uploadedName, setUploadedName] = useState<string | null>(null);
const canUpload = useMemo(() => selectedFile && !error, [selectedFile, error]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
setUploadedName(null);
setProgress(0);
setError(null);
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
if (!file) return setSelectedFile(null);
if (!ALLOWED_TYPES.has(file.type)) {
setSelectedFile(null);
return setError("Hanya PNG, JPEG, dan PDF yang diizinkan.");
}
if (file.size > MAX_BYTES) {
setSelectedFile(null);
return setError("Ukuran file maksimal 5MB.");
}
setSelectedFile(file);
if (file.type.startsWith("image/")) setPreviewUrl(URL.createObjectURL(file));
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedFile) return;
const formData = new FormData();
formData.append("file", selectedFile);
const result = await uploadWithProgress(formData, setProgress);
setUploadedName(result.name);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input type="file" accept="image/png,image/jpeg,application/pdf" onChange={handleFileChange} />
{previewUrl && <img src={previewUrl} alt="Pratinjau file" width={240} />}
{selectedFile && <p>{selectedFile.name} / {Math.round(selectedFile.size / 1024)}KB</p>}
{error && <p role="alert">{error}</p>}
<progress value={progress} max={100}>{progress}%</progress>
<button type="submit" disabled={!canUpload}>Upload</button>
{uploadedName && <p>Tersimpan: {uploadedName}</p>}
</form>
);
}
function uploadWithProgress(formData: FormData, onProgress: (progress: number) => void) {
return new Promise<UploadResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
});
xhr.addEventListener("load", () => {
const body = JSON.parse(xhr.responseText || "{}");
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
else reject(new Error(body.error ?? "Upload failed"));
});
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.send(formData);
});
}
Jika tidak mengukur progres nyata, tampilkan status “sedang upload” saja. Progress bar palsu terlihat bagus di demo, tetapi mengecewakan saat koneksi lambat.
Validasi server yang bisa direview
Contoh berikut adalah Next.js Route Handler yang menyimpan ke.local-uploads. Untuk produksi, ganti bagian penyimpanan dengan adapter S3, Cloud Storage, Azure Blob Storage, atau R2.
// app/api/upload/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
["image/png", ".png"],
["image/jpeg", ".jpg"],
["application/pdf", ".pdf"]
]);
export async function POST(request: NextRequest) {
const formData = await request.formData();
const value = formData.get("file");
if (!(value instanceof File)) {
return NextResponse.json({ error: "File tidak ditemukan." }, { status: 400 });
}
const expectedExt = ALLOWED_TYPES.get(value.type);
const originalExt = path.extname(value.name).toLowerCase();
if (!expectedExt) return NextResponse.json({ error: "MIME tidak diizinkan." }, { status: 400 });
if (value.size === 0 || value.size > MAX_BYTES) {
return NextResponse.json({ error: "File harus lebih dari 0 dan maksimal 5MB." }, { status: 400 });
}
if (expectedExt === ".jpg" && ![".jpg", ".jpeg"].includes(originalExt)) {
return NextResponse.json({ error: "Ekstensi JPEG tidak valid." }, { status: 400 });
}
if (expectedExt !== ".jpg" && originalExt !== expectedExt) {
return NextResponse.json({ error: "MIME dan ekstensi tidak cocok." }, { status: 400 });
}
const bytes = Buffer.from(await value.arrayBuffer());
const storedName = `${randomUUID()}${expectedExt === ".jpg" ? ".jpg" : expectedExt}`;
const uploadDir = path.join(process.cwd(), ".local-uploads");
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, storedName), bytes, { flag: "wx" });
return NextResponse.json({ ok: true, name: storedName, size: value.size, type: value.type });
}
Minimalnya cek MIME, ekstensi, ukuran, nama simpan, dan lokasi simpan. Untuk file sensitif, tambahkan magic number, decoding gambar, antivirus, autentikasi, kuota, dan audit log.
Kapan pindah ke S3 atau Cloud Storage
Local storage cukup untuk belajar dan prototipe. Jika file adalah aset pelanggan, server bisa lebih dari satu, perlu lifecycle rule, download privat, atau proses gambar asinkron, pakai object storage.
Tidak harus langsung upload dari browser ke S3. Untuk file kecil, server bisa menerima, memvalidasi, lalu menyimpan ke storage. Presigned URL berguna saat ukuran file, traffic, atau bandwidth server mulai menjadi masalah.
Ubah upload lokal Next.js yang ada menjadi penyimpanan S3.
Kondisi saat ini: /api/upload menerima FormData dan sudah memvalidasi MIME, ekstensi, dan ukuran.
Batasan: jangan gunakan nama asli sebagai S3 key. Simpan sebagai uploads/yyyy/mm/dd/{uuid}.ext.
Batasan: hanya image/png, image/jpeg, dan application/pdf. Maksimal 5MB.
Batasan: bucket tetap private. API mengembalikan file ID, bukan URL publik.
Output: app/api/upload/route.ts, lib/storage/s3.ts, test untuk kegagalan, README env vars.
Verifikasi: jelaskan test file terlalu besar, ekstensi salah, belum login, dan upload berhasil.
Contoh nyata, kegagalan, dan prompt
Contoh pertama adalah foto profil. Pratinjau, crop, retry, dan thumbnail penting. Mulai dari PNG, JPEG, WebP; jangan izinkan SVG tanpa tinjauan.
Contoh kedua adalah impor CSV. Upload hanya pintu masuk. Yang penting adalah kolom, encoding, jumlah baris, duplikasi, rollback, dan laporan error.
Contoh ketiga adalah invoice atau kontrak PDF. Jangan kembalikan URL publik. Simpan privat, cek user dan organisasi, lalu berikan signed URL yang pendek.
Kesalahan umum: menganggapacceptsebagai keamanan, menyimpan nama asli, mengembalikan URL publik terlalu cepat, progress palsu, dan izin S3 terlalu luas.
Tambahkan fitur upload file aman ke aplikasi Next.js ini.
Tujuan: layar admin SaaS mengupload satu PNG/JPEG/PDF tiap kali.
Client: komponen React dengan File API; tampilkan nama, ukuran, pratinjau, error, dan status upload.
Transport: POST FormData ke /api/upload. Jika perlu progres nyata, gunakan XMLHttpRequest dan jelaskan mengapa bukan fetch.
Server: di app/api/upload/route.ts, terima FormData dan validasi MIME, ekstensi, batas 5MB, dan file kosong.
Storage: jangan gunakan nama asli sebagai nama simpan. Gunakan UUID + ekstensi di .local-uploads.
Larangan: jangan simpan langsung ke public/. Jangan klaim ekstensi saja cukup aman. Jangan buat bucket S3 public.
Verifikasi: jelaskan test ukuran berlebih, ekstensi salah, file kosong, dan upload berhasil.
Referensi: MDN File API, FormData, Fetch API.
Catatan verifikasi Masa
Saat menguji alur ini, kualitas paling naik ketika “upload sederhana dengan fetch” dipisah dari “upload dengan progres nyata”. Jika hanya meminta progress bar, hasilnya kadang indah tetapi tidak terhubung ke progres jaringan sebenarnya. Memulai dari local storage juga membantu: File API, FormData, validasi, dan pratinjau stabil dulu, baru adapter storage diganti ke S3.
Ringkasan
Upload file aman bukan sekadar input. Ia adalah batas antara perangkat pengguna, server aplikasi, storage, dan model izin. Claude Code bisa membuatnya cepat jika prompt menyebut File API, FormData, validasi server, batas ukuran, nama simpan yang dibuat server, progres jujur, dan object storage privat.
Jika ingin menerapkan pola ini ke repository nyata, pelatihan dan konsultasi Claude Code dapat membahas alur upload, S3, signed URL, test, dan checklist review. PDF gratis dan materi belajar juga bisa menjadi langkah awal.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.