File Upload — REST APIs
رفع الملفات (File Upload)
رفع الملفات من أخطر نقاط أي تطبيق: ملف ضخم يملأ القرص، أو "صورة" هي في الحقيقة سكربت، أو اسم ملف ../../server.js.
كيف يصل الملف؟
النموذج أو الطلب يُرسل بصيغة multipart/form-data (لا JSON):
<form action="/api/avatar" method="post" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/png,image/jpeg">
<button type="submit">رفع</button>
</form>express.json() لا يقرأ هذه الصيغة؛ نحتاج multer (أو busboy).
multer مع حدود
const multer = require("multer");
const upload = multer({
storage: multer.memoryStorage(), // أو diskStorage
limits: { fileSize: 2 * 1024 * 1024, files: 1 }, // 2MB، ملف واحد
fileFilter: (req, file, cb) => {
const ok = ["image/png", "image/jpeg", "image/webp"].includes(file.mimetype);
cb(ok ? null : new Error("نوع غير مسموح"), ok);
},
});
// app.post("/api/avatar", requireAuth, upload.single("avatar"), handler)بلا limits يستطيع أي شخص رفع ملف بحجم القرص كله.
لا تثق بالنوع ولا بالاسم
- file.mimetype والامتداد يرسلهما العميل ويمكن تزويرهما. افحص التوقيع الحقيقي للملف (البايتات الأولى: PNG يبدأ بـ 89 50 4E 47) بمكتبة مثل file-type، أو أعد معالجة الصورة بمكتبة صور (sharp) — يزيل أيضاً البيانات الوصفية مثل موقع GPS.
- لا تستخدم اسم الملف الأصلي للحفظ: ولّد اسماً عشوائياً (crypto.randomUUID() + ".png") وخزّن الاسم الأصلي في قاعدة البيانات إن احتجته.
- لا تحفظ الملفات المرفوعة داخل مجلد يُنفَّذ أو يُقدَّم كصفحات (مثل public مع HTML). ملف .html مرفوع ومقدَّم من نطاقك = XSS.
أين تُخزَّن؟
| الخيار | ملاحظات |
|---|---|
| قرص الخادم | بسيط، لكنه يضيع مع إعادة النشر على أغلب المنصات الحديثة ولا يتوسع لعدة خوادم |
| تخزين سحابي (S3، Cloudflare R2، Supabase Storage، Firebase Storage) | الحل المعتاد؛ والأفضل: الخادم يصدر رابط رفع موقّع قصير المدة، والعميل يرفع مباشرة دون أن يمر الملف بخادمك |
معالجة أخطاء multer
تجاوز الحد يرمي multer.MulterError برمز مثل LIMIT_FILE_SIZE؛ حوّله في معالج الأخطاء إلى 413 أو 400 برسالة واضحة.
تمرين
أضف إلى المثال الكامل فحص توقيع الملف (أول 4 بايتات) لـ PNG و JPEG من req.file.buffer، وارفض الملف إذا لم يطابق ادعاء mimetype.
// npm install multer
const multer = require('multer');
const path = require('path');
// ===== Disk Storage =====
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
// unique filename: user-123-1699999999.jpg
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, `user-${req.user.id}-${uniqueSuffix}${ext}`);
}
});
// ===== File Filter =====
const fileFilter = (req, file, cb) => {
// قبول الصور فقط
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('يرجى رفع صورة فقط'), false);
}
};
// ===== Multer Configuration =====
const upload = multer({
storage: storage,
fileFilter: fileFilter,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB max
}
});
// ===== Single File Upload =====
app.post('/api/upload/avatar',
upload.single('avatar'), // 'avatar' = field name
(req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'لم يتم رفع ملف' });
}
res.json({
success: true,
file: {
filename: req.file.filename,
path: req.file.path,
size: req.file.size,
mimetype: req.file.mimetype
}
});
}
);
// ===== Multiple Files =====
app.post('/api/upload/photos',
upload.array('photos', 5), // max 5 files
(req, res) => {
res.json({
success: true,
files: req.files.map(f => ({
filename: f.filename,
size: f.size
}))
});
}
);
// ===== Mixed Fields =====
const uploadFields = upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'gallery', maxCount: 5 }
]);
app.post('/api/upload/profile', uploadFields, (req, res) => {
const avatar = req.files['avatar'][0];
const gallery = req.files['gallery'];
// ...
});
// ===== Error Handling =====
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'الملف كبير جداً (الحد 5MB)' });
}
}
next(err);
});اختبر فهمك