บทที่ 6backend|10 KB

บทที่ 6: File Upload System (Multer)

บทที่ 6: File Upload System (Multer)

🎯 เป้าหมายการเรียนรู้

หลังจากเรียนบทนี้แล้ว คุณจะสามารถ:

  • เข้าใจการทำงานของ File Upload
  • ใช้ Multer สำหรับจัดการ file uploads
  • สร้าง storage configuration
  • Validate file types และ sizes
  • จัดการ file paths และ URLs
  • Handle upload errors

📚 ทฤษฎีและแนวคิด

File Upload คืออะไร?

File Upload คือการส่งไฟล์จาก client ไปยัง server

ประเภทไฟล์ใน QUOM:

  • 🎵 Audio: MP3, WAV (บันทึกเสียง)
  • 🖼️ Image: PNG, JPG (รูปภาพคำคม)
  • 📄 Avatar: PNG, JPG (รูปโปรไฟล์)

Multer คืออะไร?

Multer คือ middleware สำหรับจัดการ multipart/form-data (file uploads)

ข้อดี:

  • ✅ ใช้งานง่าย
  • ✅ รองรับหลายไฟล์
  • ✅ Validate ได้
  • ✅ Flexible storage options

💻 โค้ดจริงจาก QUOM App

1. Upload Utility

ไฟล์: backend/src/utils/upload.js

import multer from "multer";
import path from "path";
import fs from "fs";

export function makeUploader(prefix = "audio") {
  // 1. Ensure upload directory exists
  const dir = process.env.UPLOAD_DIR || "uploads";
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }

  // 2. Configure storage
  const storage = multer.diskStorage({
    destination: (req, file, cb) => cb(null, dir),
    filename: (req, file, cb) => {
      const ext = path.extname(file.originalname || "");
      const safe = Date.now() + "-" + Math.random().toString(16).slice(2);
      cb(null, `${prefix}-${safe}${ext || ".bin"}`);
    }
  });

  // 3. Create multer instance
  return multer({ 
    storage, 
    limits: { fileSize: 30 * 1024 * 1024 } // 30MB
  });
}

อธิบายทีละบรรทัด (Imports):

  • import multer from "multer"

    • Library ยอดนิยมสำหรับจัดการ File Upload (Multipart/form-data)
  • import path from "path"

    • Utility สำหรับจัดการ File names และ Extensions (เช่น .jpg, .mp3)
  • import fs from "fs"

    • File System module ใช้สำหรับสร้าง Folder หรือลบไฟล์

อธิบาย:

1. Directory Setup

const dir = process.env.UPLOAD_DIR || "uploads";
if (!fs.existsSync(dir)) {
  fs.mkdirSync(dir, { recursive: true });
}
  • ตรวจสอบว่ามีโฟลเดอร์หรือยัง
  • ถ้าไม่มี สร้างใหม่
  • recursive: true: สร้าง parent directories ด้วย

2. Filename Generation

const ext = path.extname(file.originalname || "");
const safe = Date.now() + "-" + Math.random().toString(16).slice(2);
cb(null, `${prefix}-${safe}${ext || ".bin"}`);

ตัวอย่าง:

  • Input: recording.mp3
  • Output: audio-1705747200000-a3f5b2c1d4.mp3

ทำไมต้องสร้างชื่อใหม่:

  • ป้องกันชื่อซ้ำ
  • ป้องกัน path traversal attack
  • เก็บ extension เดิม

3. File Size Limit

limits: { fileSize: 30 * 1024 * 1024 } // 30MB
  • 30 MB = 30 × 1024 × 1024 bytes
  • ป้องกัน upload ไฟล์ใหญ่เกินไป

2. Upload Audio Endpoint

ไฟล์: backend/src/routes/notes.js

import { makeUploader } from "../utils/upload.js";

const upload = makeUploader("audio");

notesRouter.post("/:id/upload-audio", 
  upload.single("audio"), 
  async (req, res) => {
    // 1. Find note
    const note = await Note.findOne({ 
      _id: req.params.id, 
      userId: req.user._id 
    });
    
    if (!note) {
      return res.status(404).json({ 
        ok: false, 
        message: "Note not found" 
      });
    }

    // 2. Build file URL
    const base = process.env.BASE_URL || 
      `http://localhost:${process.env.PORT || 4010}`;
    const url = `${base}/uploads/${req.file.filename}`;

    // 3. Save file info to database
    note.audio = {
      fileName: req.file.filename,
      url,
      mimeType: req.file.mimetype || "",
      durationMs: Number(req.body.durationMs || 0),
    };
    
    await note.save();

    res.json({ ok: true, note });
  }
);

อธิบาย:

1. Multer Middleware

upload.single("audio")
  • single(): รับ 1 ไฟล์
  • "audio": field name ใน form-data
  • ไฟล์จะอยู่ใน req.file

2. File Object

req.file = {
  fieldname: 'audio',
  originalname: 'recording.mp3',
  encoding: '7bit',
  mimetype: 'audio/mpeg',
  destination: 'uploads/',
  filename: 'audio-1705747200000-a3f5b2c1d4.mp3',
  path: 'uploads/audio-1705747200000-a3f5b2c1d4.mp3',
  size: 1234567
}

3. Build URL

const base = process.env.BASE_URL || 
  `http://localhost:${process.env.PORT || 4010}`;
const url = `${base}/uploads/${req.file.filename}`;

ตัวอย่าง:

  • Development: http://localhost:4010/uploads/audio-123.mp3
  • Production: https://api.quom.com/uploads/audio-123.mp3

3. Serve Static Files

ไฟล์: backend/src/server.js

import express from "express";
import path from "path";

const app = express();

// Serve uploaded files
const uploadDir = process.env.UPLOAD_DIR || "uploads";
app.use("/uploads", express.static(uploadDir));

การใช้งาน:

  • URL: GET /uploads/audio-123.mp3
  • Express จะส่งไฟล์กลับไป

4. File Validation

const upload = multer({
  storage,
  limits: { 
    fileSize: 30 * 1024 * 1024 // 30MB
  },
  fileFilter: (req, file, cb) => {
    // Allow only audio files
    const allowedMimes = [
      'audio/mpeg',
      'audio/mp3',
      'audio/wav',
      'audio/x-wav'
    ];
    
    if (allowedMimes.includes(file.mimetype)) {
      cb(null, true); // Accept
    } else {
      cb(new Error('Invalid file type. Only audio files allowed.'));
    }
  }
});

fileFilter Parameters:

  • req: Express request
  • file: File object
  • cb: Callback (error, accept)

5. Multiple Files Upload

// Multiple files (same field)
upload.array("photos", 5) // Max 5 files

// Multiple fields
upload.fields([
  { name: 'audio', maxCount: 1 },
  { name: 'image', maxCount: 1 }
])

Usage:

notesRouter.post("/upload-multiple", 
  upload.array("photos", 5),
  async (req, res) => {
    // req.files is an array
    const files = req.files.map(f => ({
      filename: f.filename,
      url: `/uploads/${f.filename}`
    }));
    
    res.json({ ok: true, files });
  }
);

6. Error Handling

notesRouter.post("/:id/upload-audio", 
  (req, res, next) => {
    upload.single("audio")(req, res, (err) => {
      if (err instanceof multer.MulterError) {
        // Multer errors
        if (err.code === 'LIMIT_FILE_SIZE') {
          return res.status(400).json({
            ok: false,
            message: 'File too large (max 30MB)'
          });
        }
        return res.status(400).json({
          ok: false,
          message: err.message
        });
      } else if (err) {
        // Other errors (e.g., fileFilter)
        return res.status(400).json({
          ok: false,
          message: err.message
        });
      }
      next();
    });
  },
  async (req, res) => {
    // Handle upload
  }
);

🔒 Security Best Practices

1. Validate File Types

// ✅ ดี - Whitelist
const allowedMimes = ['audio/mpeg', 'audio/wav'];
if (!allowedMimes.includes(file.mimetype)) {
  return cb(new Error('Invalid file type'));
}

// ❌ อันตราย - ไม่ validate
cb(null, true); // รับทุกไฟล์!

2. Limit File Size

// ✅ ดี
limits: { fileSize: 30 * 1024 * 1024 }

// ❌ อันตราย - ไม่จำกัด
limits: { fileSize: Infinity }

3. Sanitize Filenames

// ✅ ดี - สร้างชื่อใหม่
const safe = Date.now() + "-" + Math.random().toString(16).slice(2);

// ❌ อันตราย - ใช้ชื่อเดิม
const filename = file.originalname; // อาจมี ../../../etc/passwd

4. Store Outside Web Root

// ✅ ดี
const dir = "uploads"; // นอก public folder

// ❌ อันตราย
const dir = "public/uploads"; // เข้าถึงได้โดยตรง

📝 แบบฝึกหัด

โจทย์ที่ 1: Image Upload

// TODO: สร้าง image uploader
// - รับเฉพาะ PNG, JPG
// - จำกัดขนาด 5MB
// - Resize เป็น 800x800

โจทย์ที่ 2: Delete File

// TODO: สร้าง endpoint ลบไฟล์
// - ลบจาก database
// - ลบไฟล์จริงด้วย fs.unlink()

โจทย์ที่ 3: Cloud Storage

// TODO: Upload ไป AWS S3
// - ใช้ aws-sdk
// - Return S3 URL

📌 สรุป

✅ Multer - Middleware สำหรับ file uploads
✅ Storage - diskStorage configuration
✅ Validation - File type และ size
✅ Security - Sanitize filenames
✅ Static Files - Serve uploaded files

ไฟล์ที่เกี่ยวข้อง


🔜 บทถัดไป

บทที่ 7: Email Service และ OTP
เรียนรู้การส่ง email และสร้างระบบ OTP


📚 แหล่งเรียนรู้เพิ่มเติม