Upload file artinya pengguna mengirim file (foto, PDF) ke server-mu, lalu server menyimpannya. Bayangin redaksi majalah yang menerima kiriman foto dari pembaca. Petugas yang teliti menimbang amplopnya dulu (kebesaran, tolak), membuka dan melihat isinya sendiri (bukan percaya tulisan "FOTO" di amplop), memberi nomor arsip sendiri, lalu menyimpannya di lemari arsip belakang, bukan di papan pengumuman depan. Empat kebiasaan itu yang kita pasang di catatan-api: batas ukuran, cek isi file, nama acak, dan folder di luar public/.
🧪 Tebakan cepat: kamu mengganti nama
struk.pdfjadistruk.jpg, lalu meng-upload-nya lewat form di browser. Di server,req.file.mimetypeisinya apa? Tebak dulu, jawabannya ada di bagian "Tipe dicek dari isi, bukan dari label".
Form upload: multipart/form-data
Body JSON cuma bisa membawa teks. File dikirim dengan format lain: multipart/form-data. Form HTML-nya wajib menulis enctype itu. Tanpa itu, yang terkirim cuma nama file-nya.
<form action="/catatan/1/lampiran" method="POST" enctype="multipart/form-data">
<input type="file" name="foto">
<button>Upload</button>
</form>
Body-nya terbagi jadi beberapa part. Part file membawa nama field, nama file, tipe yang ditebak browser, lalu isi file-nya:
------WebKitFormBoundaryq8VnUqfzQn6w2kZ1
Content-Disposition: form-data; name="foto"; filename="foto.jpg"
Content-Type: image/jpeg
(2.202.010 byte isi file)
------WebKitFormBoundaryq8VnUqfzQn6w2kZ1--
express.json() gak bisa membaca format ini. Yang membacanya adalah multer.
Multer: dari request jadi req.file
Multer adalah middleware Express untuk multipart/form-data. Pasang dengan npm install multer, lalu:
const multer = require("multer");
const upload = multer({
storage: multer.memoryStorage(), // isi file ditaruh di req.file.buffer
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB = 5.242.880 byte
});
app.post("/catatan/:id/lampiran", upload.single("foto"), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: "Gak ada file di field foto" });
}
// cek tipe, simpan, lalu jawab 201 (bagian berikutnya)
});
upload.single("foto")membaca satu file dari fieldfoto, sama denganname="foto"di form. Untuk beberapa file:upload.array("foto", 5), hasilnya direq.files.- Request tanpa file tetap sampai ke handler, dengan
req.filebernilaiundefined. Langsung menulisreq.file.sizemelemparTypeError, jadi cek dulu dan jawab 400. memoryStorage()menaruh isi file di memori, aman selama ukurannya dibatasi.
Untuk foto.jpg, req.file berisi fieldname: 'foto', originalname: 'foto.jpg', mimetype: 'image/jpeg', size: 2202010 (byte), dan buffer (isi file-nya). Ingat baik-baik: originalname dan mimetype ditulis pengirim. Cuma size dan buffer yang dihitung dari isi sebenarnya.
Batas ukuran: 413
Dengan limits.fileSize, multer berhenti membaca begitu file melewati 5.242.880 byte, lalu melempar MulterError: File too large dengan code: "LIMIT_FILE_SIZE". Tanpa penanganan, Express menjawab 500 berisi halaman HTML error. Jawaban yang pas adalah 413 Payload Too Large, lewat middleware error (empat parameter) yang ditaruh setelah semua route:
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE") {
return res.status(413).json({ error: "File terlalu besar (max 5MB)" });
}
next(err); // error lain tetap ke handler bawaan
});
$ curl -s -w "\nHTTP %{http_code}\n" -F "[email protected]" http://localhost:3000/catatan/1/lampiran
{"error":"File terlalu besar (max 5MB)"}
HTTP 413
Tanpa limits, file 2 GB juga diterima dan ditaruh di memori. Beberapa kiriman seperti itu cukup untuk menghabiskan RAM server.
Tipe dicek dari isi, bukan dari label
req.file.mimetype adalah Content-Type yang ditulis pengirim. Browser menebaknya dari ekstensi, jadi struk.pdf yang diganti nama jadi struk.jpg tiba sebagai image/jpeg. Itu jawaban tebakannya. Lewat curl, orang bisa menulis apa saja: curl -F "[email protected];type=image/png". Ekstensi di originalname juga dari pengirim.
Yang jujur cuma isinya. Tiap format file diawali tanda tangan byte sendiri, disebut magic bytes:
| Format | Byte awal (hex) | Kalau dibaca sebagai teks |
|---|---|---|
| JPEG | ff d8 ff |
(bukan huruf) |
| PNG | 89 50 4e 47 |
.PNG |
| WebP | 52 49 46 46 .. .. .. .. 57 45 42 50 |
RIFF....WEBP |
25 50 44 46 |
%PDF |
|
| HTML | 3c 68 74 6d 6c |
<html |
function tipeDariIsi(buf) {
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "jpg";
if (buf[0] === 0x89 && buf.toString("ascii", 1, 4) === "PNG") return "png";
if (buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return "webp";
return null; // bukan gambar yang kita terima
}
buf[0]adalah byte pertama, angka 0 sampai 255.0xffitu 255 ditulis dalam hex.buf.toString("ascii", 1, 4)membaca byte ke-1 sampai ke-3 sebagai teks. Libraryfile-typemengenal ratusan format, tapi untuk tiga format gambar fungsi ini cukup.
Nama acak, simpan di luar public/
Sisa route-nya:
const path = require("node:path");
const fs = require("node:fs/promises");
const crypto = require("node:crypto");
const FOLDER = path.join(__dirname, "storage", "lampiran"); // bukan di public/
// di dalam handler, setelah cek req.file:
const ext = tipeDariIsi(req.file.buffer);
if (!ext) {
return res.status(415).json({ error: "Tipe file tidak diizinkan" });
}
const nama = crypto.randomUUID() + "." + ext;
await fs.writeFile(path.join(FOLDER, nama), req.file.buffer);
res.status(201).json({ lampiran: nama, ukuran: req.file.size });
- 415 Unsupported Media Type untuk isi yang bukan JPEG, PNG, atau WebP.
crypto.randomUUID()membuat nama seperti9b2f4c1e-6a3d-4e8b-b1f0-2c7d5a9e8f31.jpg. Dua orang yang sama-sama mengirimfoto.jpggak saling menimpa, dan ekstensinya dari hasil cek kita, jadiavatar.htmlgak akan pernah jadi file.htmldi disk-mu.public/disajikanexpress.static("public"): siapa pun bisa membuka isinya lewat URL, dan file.htmldi sana dijalankan browser sebagai halaman, lengkap dengan script-nya.storage/lampirangak punya URL. Tampilkan lewat route yang mengecek hak akses, lalures.sendFile.
Empat kiriman, satu server:
| File yang dikirim | Isi sebenarnya | Jawaban |
|---|---|---|
foto.jpg, 2,1 MB |
JPEG | 201, disimpan sebagai 9b2f...8f31.jpg |
besar.jpg, 6,2 MB |
JPEG | 413 File terlalu besar (max 5MB) |
struk.jpg, 184 KB |
415 Tipe file tidak diizinkan |
|
avatar.html, mengaku image/png |
HTML berisi script | 415 Tipe file tidak diizinkan |
Cara penyerang memanfaatkan upload yang longgar (web shell, path traversal, SVG berisi script) dibahas di pelajaran Secure File Upload di track Security.
Di latihan: validateUpload
Simulator latihan gak punya multer, jadi kamu memeriksa object { filename, size, mimetype }. Di sini anggap mimetype sudah hasil deteksi server. Urutannya sama: nama ada, file.size > MAX_SIZE ditolak, tipe dicek dengan allowedTypes.includes(file.mimetype). Kode awalnya mengembalikan { valid: true, ext, sizeMB } (ekstensi dari path.extname, misalnya ".jpg") atau { valid: false, error }. Cetak hasil tiga file-nya (foto.jpg 2 MB, document.pdf, bigfile.png 10 MB) dengan JSON.stringify:
{"valid":true,"ext":".jpg","sizeMB":"2MB"}
{"valid":false,"error":"Tipe file tidak diizinkan"}
{"valid":false,"error":"File terlalu besar (max 5MB)"}
⚠️ Jebakan yang sering ketemu
- Lupa
enctype="multipart/form-data". Form tetap terkirim, tapi tanpa isi file, jadireq.fileundefined. - Nama field beda.
upload.single("foto")tapi form-nyaname="gambar": multer melemparMulterError: Unexpected file field, hasilnya 500. - Batas ukuran tanpa error handler.
File too largejadi 500 berisi halaman HTML, bukan 413 berisi JSON. - Menyimpan dengan
originalnamedipublic/. File orang lain tertimpa, danavatar.htmlbisa dibuka siapa pun sebagai halaman.
TL;DR: File dikirim sebagai multipart/form-data (form wajib enctype itu) dan dibaca multer: upload.single("foto") mengisi req.file, yang undefined kalau gak ada file, jadi cek dulu (400). Pasang limits: { fileSize: 5 * 1024 * 1024 } dan ubah LIMIT_FILE_SIZE jadi 413 di error handler. mimetype dan ekstensi ditulis pengirim, jadi tentukan tipe dari magic bytes (ff d8 ff JPEG, 89 50 4e 47 PNG, RIFF....WEBP) dan tolak sisanya dengan 415. Simpan dengan nama crypto.randomUUID() plus ekstensi hasil cek, di folder tanpa URL seperti storage/lampiran, bukan public/.