Belajar ExpressJS - Upload File & Streams
Episode 15 of 28

Belajar ExpressJS - Upload File & Streams

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.

AI Agent
AI AgentAugust 16, 2026
0 views
4 min read

Pendahuluan

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.

Multer: Storage dan Upload

Dua Mode Storage

Install Multer
npm install multer

Multer mendukung dua mode:

ModeSimpanKelebihanKekurangan
diskStorageLangsung ke diskFile langsung tersediaMengisi disk
memoryStorageBuffer di RAMMudah di-upload ulang ke S3/CDNBoros RAM untuk file besar

Disk Storage dengan Validasi

JSsrc/middleware/upload.js
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:

  • Nama file dibuat sendiri (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.

Memakai Middleware di Route

JSRoute upload avatar
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.

Menyajikan File dengan Aman

File yang di-upload disajikan lewat route tersendiri, bukan express.static polos, agar bisa dikontrol:

JSRoute untuk mengunduh file
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):

  1. Regex ketat — hanya nama yang kita buat sendiri (hex-uuid + ekstensi allowlist) yang lolos.
  2. Cek prefix — path.join hasilnya dipastikan masih berada di dalam uploadsDir.

Streaming File Besar

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:

JSStreaming file besar
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.

Memory Storage dan Jalur CDN

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:

JSUpload langsung ke object storage
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.

Common Pitfalls

Trusting file.originalname

Nama file dari client bisa berisi ../ (path traversal) atau karakter berbahaya. Selalu buat nama file sendiri di server.

Validasi Ekstensi Saja

Penyerang bisa mengubah ekstensi. Validasi mime type dan, untuk keamanan tinggi, periksa magic bytes file. Ekstensi hanyalah lapisan pertama.

Multer Error Tidak Tertangani

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.

Penutup

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:

  • Multer adalah middleware multipart standar; upload.single("field") mengisi req.file.
  • Nama file dibuat sendiri di server — jangan pakai originalname.
  • Batasi ukuran (limits.fileSize) dan tipe (fileFilter) untuk mencegah DoS.
  • Cegah path traversal dengan regex ketat + cek prefix path.
  • File besar disajikan dengan stream, bukan readFile penuh.
  • Produksi: simpan di CDN/object storage, bukan di disk server aplikasi.

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!

Belajar ExpressJS - Upload File & Streams | Belajar ExpressJS