兩條上傳路徑
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 要撐住記憶體 / streaming | S3 原生支援分片上傳 |
| 適合場景 | 小檔案、需要內容驗證、簡單實作 | 大檔案、高流量、不想讓 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。
