kodingup.camp

Beranda/Node.js Backend: Database, Auth, Keamanan & Deploy

Upload File

Modul 5 dari 106 menit bacaFile Uploads

Isi modul

    Kenapa ini penting

    Toko online tanpa foto produk tidak akan laku. Aplikasi lain butuh foto profil, bukti transfer, dokumen KTP, atau lampiran tiket bantuan. Semua itu berarti server harus menerima file dari pengguna. Fitur ini sekaligus salah satu pintu serangan paling berbahaya. File 5 GB bisa membuat disk server penuh. File bernama ../../.env bisa menimpa file penting. File shell.php yang mengaku sebagai foto.jpg bisa dijalankan kalau server salah konfigurasi.

    Analoginya seperti loket penerimaan paket di gedung kantor. Petugas tidak menerima paket begitu saja. Ia menimbang (batas ukuran), memeriksa isi lewat mesin X-ray (cek tipe file yang asli), menempelkan nomor resi buatannya sendiri (nama file dari server), lalu menyimpannya di gudang khusus, bukan di meja direktur (folder upload terpisah atau object storage).

    Konsep inti

    multipart/form-data dan multer

    JSON tidak cocok untuk mengirim file biner. Browser mengirim file lewat format multipart/form-data: body request dipecah menjadi beberapa "bagian" (part), masing-masing bisa berupa field teks atau isi file. express.json() tidak bisa membaca format ini. Di sinilah multer (2.5.0) berperan. Multer adalah middleware resmi dari organisasi Express untuk membaca multipart. Hasilnya diletakkan di req.file (satu file) atau req.files (banyak file), sementara field teksnya masuk ke req.body.

    Cara memakainya ada beberapa, sesuai jumlah file:

    Method Untuk Hasil
    upload.single('image') Satu file di field image req.file
    upload.array('images', 5) Maksimal 5 file di field images req.files (array)
    upload.fields([{ name: 'cover', maxCount: 1 }, { name: 'galeri', maxCount: 8 }]) Beberapa field berbeda req.files.cover, req.files.galeri
    upload.none() Form multipart tanpa file req.body saja

    Storage engine: disk vs memory

    Tiga lapis pertahanan

    1. Batas ukuran dan jumlah lewat opsi limits: fileSize (byte per file), files (jumlah file), fields, dan parts. Saat batas terlampaui, multer melempar MulterError dengan code seperti LIMIT_FILE_SIZE atau LIMIT_FILE_COUNT. Dokumentasi multer menyarankan status 413 (Content Too Large) untuk file yang kebesaran.
    2. Filter tipe lewat opsi fileFilter(req, file, cb): panggil cb(null, true) untuk menerima, cb(null, false) untuk melewatkan diam-diam, atau cb(error) untuk menolak dengan error. Masalahnya, file.mimetype dikirim oleh klien dan bisa dipalsukan dengan satu flag curl. Jadi ini hanya saringan pertama.
    3. Cek isi file yang asli setelah file tersimpan, dengan membaca beberapa byte pertamanya (magic bytes). File JPEG selalu diawali FF D8 FF, PNG diawali 89 50 4E 47, WEBP berisi RIFF....WEBP. Kalau tidak cocok, hapus file-nya dan tolak request.

    Ditambah satu aturan wajib: nama file dibuat server, misalnya UUID acak + ekstensi yang ditentukan dari tipe yang sudah divalidasi. Nama asli dari pengguna (file.originalname) tidak boleh dipakai sebagai nama file di disk. Nama itu bisa berisi ../, karakter aneh, atau bentrok dengan file lain.

    Menyajikan file yang diunggah

    Supaya gambar bisa dibuka browser, sajikan foldernya dengan express.static. Simpan di database hanya path relatifnya (/uploads/abc.png), bukan isi file. Folder upload tidak boleh berada di dalam folder kode (src/), dan tidak boleh di-commit ke Git.

    Simpan lokal vs object storage (S3/R2)

    Disk lokal server Object storage (AWS S3, Cloudflare R2, dll.)
    Setup Paling mudah Perlu akun, bucket, dan kunci API
    Skala Terbatas disk satu server. Kalau aplikasimu jalan di 2 server, file di server A tidak ada di server B Praktis tanpa batas, bisa diakses dari server mana pun
    Deploy ulang Di banyak platform (Render, Railway, Fly tanpa volume) disk hilang saat redeploy. Wajib pakai volume persisten Tidak terpengaruh deploy
    Biaya Termasuk biaya server Bayar per GB tersimpan dan transfer. R2 tidak menarik biaya egress (unduhan)
    CDN Harus diatur sendiri Mudah dipasang CDN atau domain publik

    Untuk belajar dan proyek kecil di satu VPS, disk lokal cukup. Untuk produksi yang serius, object storage hampir selalu lebih baik. R2 dan banyak penyedia lain kompatibel dengan API S3, jadi satu SDK (@aws-sdk/client-s3) bisa dipakai untuk semuanya.

    Pola yang lebih canggih adalah presigned URL: server membuat URL sementara yang sudah ditandatangani, lalu browser mengunggah file langsung ke bucket tanpa melewati server Express-mu. Server tidak lagi menanggung beban bandwidth file besar.

    Contoh kode

    Konfigurasi multer untuk gambar produk (dipakai di Proyek Akhir):

    // src/upload.js, konfigurasi multer untuk gambar produk
    import multer from 'multer';
    import crypto from 'node:crypto';
    import fs from 'node:fs/promises';
    
    const EKSTENSI = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp' };
    
    export function makeUpload(uploadDir) {
      const storage = multer.diskStorage({
        destination: uploadDir, // string → folder dibuat otomatis oleh multer
        // nama file BUATAN SERVER, bukan nama dari user (hindari ../../ dan bentrok nama)
        filename: (req, file, cb) => cb(null, crypto.randomUUID() + EKSTENSI[file.mimetype]),
      });
      return multer({
        storage,
        limits: { fileSize: 2 * 1024 * 1024, files: 1 }, // maks 2 MB, 1 file
        fileFilter: (req, file, cb) => {
          if (EKSTENSI[file.mimetype]) return cb(null, true);
          const err = new multer.MulterError('LIMIT_UNEXPECTED_FILE', file.fieldname);
          err.message = 'Hanya JPG, PNG, atau WEBP';
          cb(err);
        },
      });
    }
    
    // mimetype dikirim browser = bisa dipalsukan. Cek "magic bytes" isi file yang asli.
    export async function isRealImage(path) {
      const fh = await fs.open(path);
      const { buffer } = await fh.read(Buffer.alloc(12), 0, 12, 0);
      await fh.close();
      const hex = buffer.toString('hex');
      return hex.startsWith('ffd8ff')                       // JPEG
        || hex.startsWith('89504e47')                       // PNG
        || (hex.startsWith('52494646') && hex.slice(16, 24) === '57454250'); // RIFF....WEBP
    }
    

    Route upload gambar produk, khusus admin:

    // src/routes/products.js (potongan)
    r.post('/:id/image', ...adminOnly, upload.single('image'), async (req, res) => {
      if (!req.file) return res.status(400).json({ error: 'File gambar wajib (field "image")' });
      const id = IdSchema.safeParse(req.params.id);
      if (!id.success || !(await isRealImage(req.file.path))) {
        await fs.unlink(req.file.path); // buang file yang ditolak
        return res.status(400).json({ error: id.success ? 'Isi file bukan gambar' : 'ID tidak valid' });
      }
      const imageUrl = `/uploads/${req.file.filename}`;
      const p = db.update(products).set({ imageUrl }).where(eq(products.id, id.data)).returning().get();
      if (!p) {
        await fs.unlink(req.file.path);
        return res.status(404).json({ error: 'Produk tidak ditemukan' });
      }
      res.json(p);
    });
    

    Error multer ditangani di error handler global (src/app.js), sekaligus menyajikan folder upload:

    // src/app.js (potongan)
    app.use('/uploads', express.static(config.UPLOAD_DIR, { maxAge: '7d', index: false }));
    // ...
    app.use((err, req, res, next) => {
      if (err instanceof multer.MulterError) {
        const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
        return res.status(status).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File maksimal 2 MB' : err.message });
      }
      // ... error lain (Modul 6)
    });
    

    Uji dengan curl. Flag -F mengirim multipart. ;type= mengatur mimetype yang diklaim, persis seperti yang bisa dilakukan penyerang:

    # PNG asli
    curl -s -b admin.txt -F "image=@kaos.png;type=image/png" http://localhost:3000/api/products/1/image
    # → {"id":1,"name":"Kaos Polos Hitam","description":"","price":75000,"stock":10,
    #    "imageUrl":"/uploads/d1312114-c246-40b6-ba6f-312b5b7b09e0.png"}
    
    # file acak 3 MB yang mengaku JPEG
    head -c 3000000 /dev/urandom > besar.jpg
    curl -s -w " [%{http_code}]\n" -b admin.txt -F "image=@besar.jpg;type=image/jpeg" http://localhost:3000/api/products/1/image
    # → {"error":"File maksimal 2 MB"} [413]
    
    # file teks biasa (curl mengirim mimetype text/plain)
    curl -s -w " [%{http_code}]\n" -b admin.txt -F "image=@catatan.txt" http://localhost:3000/api/products/1/image
    # → {"error":"Hanya JPG, PNG, atau WEBP"} [400]
    
    # file teks yang MENGAKU image/jpeg → lolos fileFilter, tapi tertangkap magic bytes
    curl -s -w " [%{http_code}]\n" -b admin.txt -F "image=@catatan.txt;type=image/jpeg" http://localhost:3000/api/products/1/image
    # → {"error":"Isi file bukan gambar"} [400]
    
    # nama field salah
    curl -s -w " [%{http_code}]\n" -b admin.txt -F "foto=@kaos.png;type=image/png" http://localhost:3000/api/products/1/image
    # → {"error":"Unexpected file field"} [400]
    
    ls uploads
    # → d1312114-c246-40b6-ba6f-312b5b7b09e0.png   (hanya file yang lolos; yang ditolak sudah dibuang)
    

    Versi object storage untuk Cloudflare R2 (atau S3) dengan @aws-sdk/client-s3 3.1156.0 dan @aws-sdk/s3-request-presigner 3.1156.0. Pasangkan dengan multer.memoryStorage() supaya req.file.buffer tersedia:

    // storage-r2.js, simpan file ke object storage
    import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
    import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
    
    const s3 = new S3Client({
      region: 'auto', // R2 memakai 'auto'; untuk AWS S3 isi misalnya 'ap-southeast-3' (Jakarta)
      endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, // hapus untuk AWS S3
      credentials: {
        accessKeyId: process.env.R2_ACCESS_KEY_ID,
        secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
      },
    });
    
    // upload Buffer (dari multer.memoryStorage()) ke bucket
    export async function uploadKeBucket(buffer, key, contentType) {
      await s3.send(new PutObjectCommand({ Bucket: process.env.R2_BUCKET, Key: key, Body: buffer, ContentType: contentType }));
      return key;
    }
    
    // URL sementara (5 menit) supaya browser bisa upload LANGSUNG ke bucket
    export function urlUploadSementara(key, contentType) {
      return getSignedUrl(s3, new PutObjectCommand({ Bucket: process.env.R2_BUCKET, Key: key, ContentType: contentType }), { expiresIn: 300 });
    }
    
    // URL sementara untuk MENGUNDUH file privat (misalnya bukti transfer)
    export function urlUnduhSementara(key) {
      return getSignedUrl(s3, new GetObjectCommand({ Bucket: process.env.R2_BUCKET, Key: key }), { expiresIn: 300 });
    }
    
    const url = await urlUploadSementara('produk/kaos-123.webp', 'image/webp');
    console.log(url.split('?')[0]);
    // → https://toko-gambar.contohakun.r2.cloudflarestorage.com/produk/kaos-123.webp
    console.log(new URL(url).searchParams.get('X-Amz-Expires')); // → 300
    

    Pembuatan presigned URL di atas berjalan lokal tanpa menghubungi R2 (hanya menghitung tanda tangan), jadi output-nya bisa dicoba dengan kredensial contoh. Untuk benar-benar mengunggah, kamu butuh akun dan bucket sungguhan.

    Kesalahan umum

    1. Tidak memberi limits.fileSize. Defaultnya Infinity. Satu orang bisa memenuhi disk server. Selalu tentukan batas sesuai kebutuhan (foto produk: 2-5 MB).
    2. Percaya file.mimetype atau ekstensi nama file. Keduanya dikirim klien. Cek magic bytes, atau proses ulang gambarnya dengan library seperti sharp (0.35.5). Proses ulang otomatis gagal kalau isinya bukan gambar, dan sharp punya opsi eksplisit keepExif()/keepMetadata() untuk metadata, jadi kamu bisa memilih untuk tidak menyalin data lokasi GPS dari foto pengguna.
    3. Memakai file.originalname sebagai nama file di disk. Bisa berisi ../, spasi, karakter Unicode aneh, atau menimpa file milik orang lain. Pakai UUID buatan server.
    4. Lupa menghapus file yang ditolak. Dengan diskStorage, file sudah tertulis ke disk sebelum handler berjalan. Kalau validasi gagal di handler, fs.unlink() file-nya.
    5. Menyimpan upload di disk platform yang tidak persisten. Di Render/Railway/Fly tanpa volume, semua foto produk lenyap setiap kali deploy. Pakai volume persisten atau object storage.

    Latihan

    1. Mudah, Tambahkan dukungan GIF: mimetype image/gif, ekstensi .gif, dan cek magic bytes 47 49 46 38 ("GIF8").
    2. Sedang, Buat endpoint galeri POST /gallery yang menerima maksimal 5 gambar di field images. File yang isinya bukan gambar dibuang, sisanya dikembalikan sebagai daftar URL. Kalau dikirim 6 file, balas 400.
    3. Menantang, Saat gambar produk diganti atau produknya dihapus, file gambar lama di disk ikut terhapus. Tulis function hapusGambarLama(imageUrl, uploadDir) yang aman dari path traversal (misalnya imageUrl berisi /uploads/../../package.json).
    Lihat solusi

    1. Tambah satu entri di peta ekstensi dan satu baris di isRealImage.

    const EKSTENSI = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' };
    
    export async function isRealImage(path) {
      const fh = await fs.open(path);
      const { buffer } = await fh.read(Buffer.alloc(12), 0, 12, 0);
      await fh.close();
      const hex = buffer.toString('hex');
      return hex.startsWith('ffd8ff')
        || hex.startsWith('89504e47')
        || (hex.startsWith('52494646') && hex.slice(16, 24) === '57454250')
        || hex.startsWith('47494638'); // "GIF8" → GIF87a / GIF89a
    }
    // file berisi 'GIF89a...' → true, file teks 'GIF? bukan' → false
    

    2. Buat instance multer kedua dengan files: 5, lalu pakai .array().

    const galeri = multer({
      storage: multer.diskStorage({
        destination: 'uploads',
        filename: (req, f, cb) => cb(null, crypto.randomUUID() + EKSTENSI[f.mimetype]),
      }),
      limits: { fileSize: 2 * 1024 * 1024, files: 5 },
      fileFilter: (req, f, cb) => cb(null, Boolean(EKSTENSI[f.mimetype])), // tipe lain dilewati diam-diam
    });
    
    app.post('/gallery', galeri.array('images', 5), async (req, res) => {
      const urls = [];
      for (const f of req.files) {
        if (await isRealImage(f.path)) urls.push(`/uploads/${f.filename}`);
        else await fs.unlink(f.path);
      }
      res.status(201).json({ diterima: urls.length, urls });
    });
    // 2 PNG asli + 1 teks mengaku PNG → 201 { diterima: 2, urls: [...] }
    // 6 file → 400 dengan err.code 'LIMIT_FILE_COUNT' (file yang sempat tertulis dibersihkan multer)
    

    3. path.basename() membuang semua bagian folder, sehingga ../../ tidak berpengaruh.

    import path from 'node:path';
    import fs from 'node:fs/promises';
    
    export async function hapusGambarLama(imageUrl, uploadDir) {
      if (!imageUrl) return;
      const file = path.join(uploadDir, path.basename(imageUrl)); // basename: cegah ../
      await fs.rm(file, { force: true }); // force: tidak error kalau file sudah tidak ada
    }
    // hapusGambarLama('/uploads/abc.png', 'uploads')            → uploads/abc.png terhapus
    // hapusGambarLama('/uploads/../../package.json', 'uploads') → mencoba uploads/package.json, package.json asli aman
    
    // pemakaian di route upload, SETELAH update database berhasil:
    // const lama = db.select().from(products).where(eq(products.id, id)).get()?.imageUrl;
    // ... update ...
    // await hapusGambarLama(lama, config.UPLOAD_DIR);
    

    Ringkasan

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    How to Upload Files in Node.js Using Express and MulterJames Q Quick · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.