兩條上傳路徑

Path A:透過 Server

Client → [file] → Server → S3

Server 收到 multipart form data,做驗證,存到 S3,回傳 URL。

Path B:Presigned URL(直傳 S3)

Client → [get presigned URL] → Server
Client → [file] → S3(直傳,不過 Server)
Client → [confirm upload] → Server

客戶端拿到 presigned URL 後直接把檔案送到 S3,不過 server。

Path A(透過 Server)Path B(Presigned URL)
架構複雜度中(多一個 round trip)
Server 負載高(IO 密集)低(server 不碰檔案)
驗證控制完整(server 可以讀取內容驗證)受限(S3 policy 限制大小、類型)
大檔案Server 要撐住記憶體 / streamingS3 原生支援分片上傳
適合場景小檔案、需要內容驗證、簡單實作大檔案、高流量、不想讓 server 成為瓶頸

Path A:Server-side Multipart

import multer from 'multer';
import path from 'path';
 
// 上傳到 memory,然後從 buffer 推到 S3
const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 10 * 1024 * 1024,  // 10 MB
    files: 5,                     // 一次最多 5 個檔案
  },
  fileFilter: (req, file, cb) => {
    const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
    if (!allowedMimeTypes.includes(file.mimetype)) {
      return cb(new Error('Only JPEG, PNG, WebP are allowed'));
    }
    cb(null, true);
  },
});
 
app.post('/uploads/images',
  authenticate,
  upload.single('image'),
  async (req, res) => {
    const file = req.file;
    if (!file) return res.status(400).json({ error: 'No file uploaded' });
 
    // 防 MIME type 偽造:用 file-type 讀取實際 magic bytes
    const { fileTypeFromBuffer } = await import('file-type');
    const detectedType = await fileTypeFromBuffer(file.buffer);
    if (!detectedType || !['jpg', 'png', 'webp'].includes(detectedType.ext)) {
      return res.status(400).json({ error: 'Invalid file content' });
    }
 
    const key = `uploads/${req.user.id}/${Date.now()}-${file.originalname}`;
    await s3Client.send(new PutObjectCommand({
      Bucket: process.env.AWS_S3_BUCKET,
      Key: key,
      Body: file.buffer,
      ContentType: detectedType.mime,
    }));
 
    const url = `https://${process.env.AWS_S3_BUCKET}.s3.amazonaws.com/${key}`;
    res.json({ url, key });
  }
);

流式上傳(大檔案,避免 memory 問題)

// multer disk storage → stream to S3
const upload = multer({ storage: multer.diskStorage({ destination: '/tmp' }) });
 
// 或直接 pipe stream 到 S3(不落地)
app.post('/uploads/video', authenticate, (req, res) => {
  const key = `videos/${req.user.id}/${Date.now()}.mp4`;
  const upload = new Upload({
    client: s3Client,
    params: { Bucket: process.env.AWS_S3_BUCKET, Key: key, Body: req },
  });
 
  upload.done().then(() => res.json({ key }));
});

Path B:Presigned URL 直傳 S3

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 
// Step 1:Client 請求 presigned URL
app.post('/uploads/presigned', authenticate, async (req, res) => {
  const { fileName, fileType, fileSize } = req.body;
 
  // Server 端驗證(不驗檔案內容,驗 metadata)
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
  if (!allowedTypes.includes(fileType)) {
    return res.status(400).json({ error: 'File type not allowed' });
  }
  if (fileSize > 10 * 1024 * 1024) {
    return res.status(400).json({ error: 'File too large (max 10MB)' });
  }
 
  const key = `uploads/${req.user.id}/${Date.now()}-${fileName}`;
 
  const command = new PutObjectCommand({
    Bucket: process.env.AWS_S3_BUCKET,
    Key: key,
    ContentType: fileType,
    ContentLength: fileSize,
    // S3 policy:只允許這個 content-type 和 size
    Metadata: { uploadedBy: req.user.id },
  });
 
  const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 300 });  // 5 分鐘有效
 
  // 記錄預期的上傳(用來後面的 confirm)
  await PendingUpload.create({ key, userId: req.user.id, expiresAt: new Date(Date.now() + 5 * 60 * 1000) });
 
  res.json({ presignedUrl, key });
});
 
// Step 2:Client 直接把檔案 PUT 到 S3(不過 server)
 
// Step 3:Client 告知 server 上傳完成
app.post('/uploads/confirm', authenticate, async (req, res) => {
  const { key } = req.body;
 
  const pending = await PendingUpload.findOne({ where: { key, userId: req.user.id } });
  if (!pending) return res.status(400).json({ error: 'Unknown upload' });
 
  // 確認 S3 上真的有這個物件
  try {
    await s3Client.send(new HeadObjectCommand({ Bucket: process.env.AWS_S3_BUCKET, Key: key }));
  } catch {
    return res.status(400).json({ error: 'Upload not found in storage' });
  }
 
  // 更新 DB(例如:把 key 存進 user profile)
  await userRepo.update(req.user.id, { avatarKey: key });
  await pending.destroy();
 
  const url = `https://${process.env.AWS_CDN_DOMAIN}/${key}`;
  res.json({ url });
});

共通的安全考量

MIME type 偽造:使用者可以把 .exe 改名為 photo.jpg 上傳。只靠副檔名或 Content-Type 不夠——要用 magic bytes 驗證:

import { fileTypeFromBuffer } from 'file-type';
 
const detected = await fileTypeFromBuffer(buffer);
if (!detected || detected.ext !== 'jpg') {
  throw new Error('File content does not match declared type');
}

路徑穿越(Path Traversal)

// ❌ 直接用 fileName
const key = `uploads/${req.body.fileName}`;  // '../../../etc/passwd'
 
// ✅ 只用 basename,或重新生成安全的 key
import path from 'path';
const safeName = path.basename(req.body.fileName).replace(/[^a-zA-Z0-9._-]/g, '');
const key = `uploads/${req.user.id}/${Date.now()}-${safeName}`;

CDN 而不是直接 S3 URL:對外顯示的 URL 用 CloudFront / CDN,不要直接暴露 S3 bucket URL。原因:S3 URL 是 public 的(如果 bucket 是 public),CDN 可以控制 cache、做 resize on-the-fly(Lambda@Edge)、做 geo-blocking。


延伸閱讀