Menangani upload file dengan Multer: storage disk dan memory, validasi tipe dan ukuran, penyajian file yang aman, streaming file besar, serta arah penyimpanan menuju CDN.

Setelah di episode 14 kalian menangani komunikasi realtime, episode 15 membahas jenis input lain yang sering diabaikan keamanannya: file. Avatar, dokumen, gambar — hampir semua aplikasi produksi menerima upload, dan upload yang ditangani sembarangan adalah pintu masuk klasik (file berbahaya, path traversal, DoS via ukuran file).
Mengapa Multer? Karena ia adalah middleware file upload standar Express yang mengintegrasikan multipart/form-data ke pipeline kita. Di episode ini kita membangun upload yang aman, menyajikan file yang di-upload, memahami streaming untuk file besar, dan merencanakan jalur menuju CDN.
npm install multerMulter mendukung dua mode:
| Mode | Simpan | Kelebihan | Kekurangan |
|---|---|---|---|
diskStorage | Langsung ke disk | File langsung tersedia | Mengisi disk |
memoryStorage | Buffer di RAM | Mudah di-upload ulang ke S3/CDN | Boros RAM untuk file besar |
import multer from "multer"
import path from "node:path"
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, path.join(process.cwd(), "uploads"))
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname)
const unique = `${Date.now()}-${crypto.randomUUID()}${ext}`
cb(null, unique)
},
})
export const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowed = [".jpg", ".jpeg", ".png", ".webp", ".pdf"]
if (!allowed.includes(path.extname(file.originalname).toLowerCase())) {
return cb(new AppError(400, "INVALID_FILE_TYPE", "Tipe file tidak diizinkan"))
}
cb(null, true)
},
})Tiga keputusan keamanan penting:
Date.now() + UUID) — nama asli dari client (file.originalname) tidak pernah dipakai sebagai nama file. Ini menutup path traversal dan nama eksekutif berbahaya.limits.fileSize membatasi ukuran — pertahanan pertama terhadap DoS.fileFilter membatasi tipe ekstensi — upload jadi white-list, bukan black-list.import { upload } from "../middleware/upload.js"
router.post(
"/avatar",
upload.single("avatar"),
(req, res) => {
if (!req.file) {
throw new AppError(400, "NO_FILE", "File avatar wajib diunggah")
}
res.status(201).json({
data: {
id: req.file.filename,
size: req.file.size,
url: `/files/${req.file.filename}`,
},
})
}
)upload.single("avatar") mem-parsing field avatar dari multipart form dan mengisi req.file. Setelah middleware ini, req.body tetap berisi field teks lain dari form — upload dan data biasa bisa digabung.
File yang di-upload disajikan lewat route tersendiri, bukan express.static polos, agar bisa dikontrol:
import { readFile } from "node:fs/promises"
import path from "node:path"
const uploadsDir = path.join(process.cwd(), "uploads")
router.get("/files/:name", async (req, res) => {
const { name } = req.params
if (!/^[a-f0-9-]+\.(jpg|png|webp|pdf)$/i.test(name)) {
throw new AppError(400, "INVALID_FILE", "Nama file tidak valid")
}
const fullPath = path.join(uploadsDir, name)
if (!fullPath.startsWith(uploadsDir)) {
throw new AppError(400, "INVALID_FILE", "Nama file tidak valid")
}
res.type(path.extname(name))
res.send(await readFile(fullPath))
})Dua lapis pertahanan untuk mencegah path traversal (../../../etc/passwd):
hex-uuid + ekstensi allowlist) yang lolos.path.join hasilnya dipastikan masih berada di dalam uploadsDir.Untuk file besar (video, backup, dataset), membaca seluruh file ke memori dengan readFile tidak efisien. Gunakan stream — file mengalir dari disk ke client tanpa memuat penuh di RAM:
import { createReadStream, stat } from "node:fs"
import { stat as statFs } from "node:fs/promises"
import path from "node:path"
router.get("/download/:name", async (req, res) => {
const fullPath = path.join(uploadsDir, req.params.name)
if (!fullPath.startsWith(uploadsDir)) {
throw new AppError(400, "INVALID_FILE", "Nama file tidak valid")
}
const fileStat = await statFs(fullPath)
res.setHeader("Content-Length", fileStat.size)
res.setHeader(
"Content-Disposition",
`attachment; filename="${path.basename(fullPath)}"`
)
createReadStream(fullPath)
.on("error", () => {
res.status(500).end()
})
.pipe(res)
})createReadStream(...).pipe(res) menghubungkan pembacaan file langsung ke response — memori konstan berapa pun ukuran file. Header Content-Disposition: attachment memaksa browser mengunduh, bukan menampilkan.
Note
Streaming adalah contoh nyata model event loop Node.js: file mengalir dalam chunk, memori stabil, dan response mulai terkirim sebelum file selesai dibaca. Inilah alasan Node unggul untuk transfer file besar — dan mengapa antarmuka req/res Express pada dasarnya adalah dua stream.
Untuk produksi, file biasanya tidak disimpan di disk server aplikasi — melainkan di-upload ke object storage/CDN (S3, R2, Cloudinary). Multer mode memory membantu di sini:
import multer from "multer"
import { putObject } from "../utils/storage.js"
const uploadMemory = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
})
router.post(
"/images",
uploadMemory.single("image"),
async (req, res) => {
const key = `images/${crypto.randomUUID()}.jpg`
const url = await putObject(key, req.file.buffer, req.file.mimetype)
res.status(201).json({ data: { url } })
}
)Kelebihan pendekatan ini: server aplikasi tidak menyimpan file sama sekali, disk tidak pernah penuh, dan URL file sudah siap disajikan CDN dengan bandwidth yang jauh lebih murah.
Nama file dari client bisa berisi ../ (path traversal) atau karakter berbahaya. Selalu buat nama file sendiri di server.
Penyerang bisa mengubah ekstensi. Validasi mime type dan, untuk keamanan tinggi, periksa magic bytes file. Ekstensi hanyalah lapisan pertama.
Error Multer (file terlalu besar, tipe tidak cocok) muncul sebagai error middleware — pastikan error handler episode 7 menangkapnya dan memetakan LIMIT_FILE_SIZE ke 413.
Tip
Uji dengan curl: curl -F "avatar=@foto.jpg" http://localhost:3000/avatar. Coba juga kirim file .html berisi script untuk melihat fileFilter menolaknya — praktik kecil yang menanamkan kebiasaan uji serangan pada setiap fitur.
Episode 15 menuntaskan penanganan file: Multer dengan disk dan memory storage, validasi tipe dan ukuran, penyajian yang aman dari path traversal, streaming file besar, dan jalur produksi menuju object storage/CDN.
Inti yang harus dibawa pulang:
upload.single("field") mengisi req.file.originalname.limits.fileSize) dan tipe (fileFilter) untuk mencegah DoS.readFile penuh.Di episode 16 selanjutnya kita akan merapikan konfigurasi: configuration & environment — dotenv, env vars, config terpusat per environment, dan praktik mengelola secret tanpa commit. Sampai jumpa di episode 16!