File Upload — Node.js

Menangani file upload di Express menggunakan middleware multer. Setup multer: import multer from "multer"; // Simpan di folder uploads/ const upload =…

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.pdf jadi struk.jpg, lalu meng-upload-nya lewat form di browser. Di server, req.file.mimetype isinya 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)
});

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
PDF 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
}

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

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 PDF 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

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/.

Yang akan kamu pelajari