السلسلة: هذا الدرس جزء من سلسلة NestJS 11. اقرأ المقال الرئيسي.
رفع الملفات من أكثر العمليات إيلاماً لـ backend سيئ المعمارية. تمرير PDF بـ 50 ميغا عبر خادم NestJS يستهلك ذاكرة، يُشبع اتصالات HTTP، وينتهي بإسقاط التطبيق تحت حمل متوسط. النهج الصحيح، المعياري منذ 2020، توقيع URL مُسبَّق-التفويض جانب الخادم وترك العميل يرفع مباشرة نحو تخزين الكائنات. هذا الدرس يبني هذه المعمارية كاملة على NestJS 11 مع AWS SDK v3.
المتطلبات
- API NestJS 11 مع مصادقة JWT
- Bucket متوافق S3 مع زوج access-key/secret-key
- أساسيات signed URL وCORS
- 75 دقيقة
الخطوة 1 — اختيار مزوّد التخزين
كل المزوّدين المتوافقين مع S3 يتقاسمون نفس API لكن تسعيرتهم تتباعد بقوة. Amazon S3 يفوتر ~23 USD/تيرا/شهر إضافة 90 USD/تيرا للنطاق الصادر. Cloudflare R2 يفوتر 15 USD/تيرا/شهر بلا أي رسوم نطاق صادر — الأكثر اقتصاداً للمحتوى الكثير الاستهلاك. Backblaze B2 يبقى منافساً للتخزين البارد بـ 6 USD/تيرا/شهر. Hetzner Object Storage ~6 USD/تيرا/شهر، خيار أوروبي للامتثال GDPR.
// uploads/uploads.config.ts
export const s3Config = {
endpoint: process.env.S3_ENDPOINT, // https://r2.cloudflarestorage.com لـ R2
region: process.env.S3_REGION ?? 'auto',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
forcePathStyle: process.env.S3_PATH_STYLE === 'true', // true لـ MinIO
};
الخطوة 2 — تثبيت AWS SDK v3
cd apps/api
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
pnpm add zod
SDK v3 وحداتي: نُثبّت فقط ما نحتاج. مقارنة بـ SDK v2، حجم node_modules مقسوم على 10.
الخطوة 3 — كشف endpoint توقيع
// uploads/uploads.controller.ts
@Post('sign')
@UseGuards(JwtAuthGuard)
async signUpload(@Body() body: SignDto, @CurrentUser() user: User) {
const allowedTypes = ['image/png','image/jpeg','image/webp','application/pdf'];
if (!allowedTypes.includes(body.contentType)) throw new BadRequestException();
if (body.size > 50 * 1024 * 1024) throw new PayloadTooLargeException();
const key = 'users/' + user.id + '/' + randomUUID() + '.' + extension(body.contentType);
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
ContentType: body.contentType,
ContentLength: body.size,
});
const url = await getSignedUrl(this.s3, command, { expiresIn: 300 });
return { url, key };
}
ثلاثة اختيارات أمنية حرجة. مفتاح S3 مُسبَق بـ ID المستخدم: يمنع عميلاً من تخمين مفتاح مستخدم آخر. اسم الملف يُستبدَل بـ UUID. ContentLength مشمول في التوقيع: إن أرسل العميل ملفاً أكبر من المُعلَن، S3 يرفض الرفع بـ 403.
الخطوة 4 — الرفع جانب العميل
// جانب front (TypeScript)
const { url, key } = await api.post('/uploads/sign', {
contentType: file.type, size: file.size,
}).then(r => r.data);
await fetch(url, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
await api.post('/uploads/confirm', { key }); // تسجيل المرجع في القاعدة
خادم NestJS لا يلامس الثنائي أبداً، ما يلغي عنق زجاجة الذاكرة. على VPS متواضع، يمكن دعم مئات الرفوعات المتزامنة بلا تشبّع.
الخطوة 5 — ضبط CORS للـ bucket
{
"CORSRules": [{
"AllowedOrigins": ["https://app.acme.io"],
"AllowedMethods": ["PUT", "POST", "GET"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}]
}
لا تستخدم أبداً "AllowedOrigins": ["*"] في الإنتاج: هذا الانفتاح يسمح لأي موقع بطلب رفع.
الخطوة 6 — Multipart upload للملفات الكبيرة
فوق 100 ميغا، الرفع في PUT واحد يصير هشّاً: قطع شبكي في المنتصف يجبر على إعادة البدء من الصفر. S3 يقترح multipart upload يُقسّم الملف إلى أجزاء بين 5 ميغا (أدنى) و5 جيغا (أقصى)، تُرفع متوازية، مع إمكان الاستئناف part-by-part.
@Post('multipart/init')
async initMultipart(@Body() body: InitDto) {
const cmd = new CreateMultipartUploadCommand({
Bucket: process.env.S3_BUCKET, Key: body.key, ContentType: body.contentType,
});
const { UploadId } = await this.s3.send(cmd);
return { uploadId: UploadId };
}
@Post('multipart/sign-part')
async signPart(@Body() { key, uploadId, partNumber }: SignPartDto) {
const cmd = new UploadPartCommand({ Bucket: process.env.S3_BUCKET, Key: key, UploadId: uploadId, PartNumber: partNumber });
const url = await getSignedUrl(this.s3, cmd, { expiresIn: 600 });
return { url };
}
الخطوة 7 — خدمة الملفات للقراءة
@Get(':key/url')
@UseGuards(JwtAuthGuard, PoliciesGuard)
async getReadUrl(@Param('key') key: string, @CurrentUser() user: User) {
if (!key.startsWith('users/' + user.id + '/') && user.role !== 'ADMIN') {
throw new ForbiddenException();
}
const cmd = new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key });
return { url: await getSignedUrl(this.s3, cmd, { expiresIn: 300 }) };
}
التحقق key.startsWith فحص أوّلي يمنع مستخدماً مُصادَقاً من توقيع URL لملف غيره. هذا الدفاع المُتعمَّق يُضاف إلى سياسة Casbin بلا أن يستبدلها.
الخطوة 8 — مضاد فيروسات وتحقق بعد الرفع
// uploads/scan.processor.ts
@Processor('scan')
export class ScanProcessor extends WorkerHost {
async process(job: Job<{ key: string }>) {
const obj = await this.s3.send(new GetObjectCommand({ Key: job.data.key, Bucket: bucket }));
const buffer = Buffer.from(await obj.Body.transformToByteArray());
const result = await this.clam.scanBuffer(buffer);
if (result.isInfected) {
await this.s3.send(new DeleteObjectCommand({ Key: job.data.key, Bucket: bucket }));
throw new Error('Infected: ' + result.viruses.join(','));
}
}
}
طالما لم يُصادِق الفحص، الكائن المتعلّق يبقى في حالة pending_scan ولا يُعرَض للمستخدمين الآخرين. هذا الحجر الصحي يتجنّب انتشار malware في منصّة تعاونية.
أخطاء شائعة
| الخطأ | السبب | الحل |
|---|---|---|
| Upload محجوب CORS | Bucket بلا قاعدة CORS | اضبط CORS بأصل دقيق |
| توقيع غير صالح | فرق ساعة خادم/عميل > 15 دقيقة | NTP sync على المضيف |
| ملف 100x أكبر من المتوقَّع | ContentLength غائب من التوقيع |
أدرج في PutObjectCommand |
| فاتورة نطاق ضخمة | قراءة عامة بلا CDN | R2 أو CDN أمام S3 |
| ملفات يتيمة في bucket | Confirm لم يُستدعَ | Job BullMQ تطهير ليلي |
أسئلة شائعة
هل نُشفّر الملفات جانب العميل؟ لبيانات حساسة (طبية، مالية)، نعم. التشفير جانب العميل بمفتاح مُستنبَط من كلمة سرّ المستخدم يضمن أن وصولاً إلى bucket لا يسمح بالقراءة. للغالبية، تشفير at-rest S3 (SSE-S3) يكفي.
كيف نحدّ معدل الرفع لكل مستخدم؟ rate-limiter @nestjs/throttler على endpoint /uploads/sign يحدّ تردد طلبات التوقيع.
أي سياسة عمر للملفات؟ اضبط lifecycle rule على bucket تنقل الملفات إلى Glacier بعد 90 يوماً وتحذفها بعد 7 سنوات.
هل يمكن استبدال S3 بـ volume Coolify؟ لملفات صغيرة بحجم قليل، نعم. لمئات الجيغابايتات، S3-compatible لا غنى عنه.