واجهتُ العلّة في أسوأ صورة ممكنة: بطاقات المنتجات المدعومة تظهر سليمة تماماً على شاشتي، وتظهر مربّعات فارغة لكل زائرة لم تسجّل الدخول. كل شيء آخر كان يعمل — نصّ البطاقة وسعرها ورابطها يصل للزائرة، والصورة وحدها مفقودة. استغرق تشخيصها ساعتين ضائعتين في الاتجاه الخطأ: ظننتُ أولاً أن السبب سياسة أمان المحتوى تمنع نطاق الصور، ثم ظننتُ أن التحميل المتأخّر (lazy loading) يفشل داخل حاوية مخفيّة. الحقيقة كانت أبسط وأعمق: صور المنتدى المرفوعة من العضوات وصور البطاقات المعروضة للزوّار كانتا تعيشان في دلو (bucket) واحد، وقاعدة قراءة واحدة كانت تحكمهما معاً. أي قيمة تعطيها لتلك القاعدة تكسر إحدى الحالتين: إن قيّدتها للمسجّلات اختفت البطاقات عن الزوّار، وإن فتحتها للعموم صارت كل صورة يرفعها الأعضاء داخل المنتدى مقروءة لأي شخص. هذا المقال سِجلّ ما فعلناه فعلياً في مشروع منتدى إنتاجي: كيف فصلنا المسارين، وما تعلّمناه عن حدود القواعد نفسها، وكيف اختبرنا القرار بفرق بين رمزَي استجابة اثنين لا أكثر.
مسارَان متعاكسان في دلو واحد
الحلّ لم يكن في تعديل قيمة القاعدة، بل في تعديل شكل المسارات أولاً. لأن قواعد Storage تُطابق على المسار، فإن اختيار المسار هو القرار الأمني الحقيقي، وما بعده تفاصيل. رسمنا مسارَين مقصودَين:
forum/{userId}/{fileName}— كل صورة ترفعها عضوة تسكن تحت مجلّد يحمل معرّفها هي، لا مجلّداً مشتركاً.products/{fileName}— صور بطاقات المنتجات، مسار مسطّح بلا مجلّد مستخدم، لأن مالكها واحد: الإدارة.
الفرق ليس تنظيمياً. وجود {userId} في المسار الأول هو ما يجعل قاعدة الكتابة قابلة للتعبير أصلاً: صارت لدينا قطعة من المسار يمكن مقارنتها بهوية الطالب. وغياب أي متغيّر في المسار الثاني هو ما يجعل قراءته العامة آمنة: لا يستطيع أحد أن «يخترع» مساراً فرعياً جديداً يقع تحت المظلّة العامة. ملف القواعد يبدأ بالإصدار الثاني وبالخدمة نفسها:
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
ثم ثلاث دوال مساعدة استُخدمت في كل قاعدة بعدها: isAuthed() وهي مجرّد request.auth != null، وisAdminEmail() التي تتحقّق من أن البريد موجود ثم تقارنه بعد lower() بثابت بريد الإدارة، وisImage() التي سنعود إليها. كتابة الشروط كدوال ليست ترفاً: الشرط المكرّر حرفياً في أربعة مواضع سيتباعد مع الوقت، وقاعدة أمنية متباعدة عن أختها هي تعريف الثغرة.
الحالة الأولى: قراءة للمسجّلات، كتابة تحت مجلّد المالكة
صور المنتدى تظهر داخل المواضيع والردود، ولا معنى لعرضها لغير المسجّلات. فالقراءة كانت allow read: if isAuthed(); — سطر واحد، لكنّه يجب أن يُطابق قرار طبقة قاعدة البيانات حرفياً. في مشروعنا كانت وثائق المواضيع نفسها مقروءة للمسجّلات فقط، فلو فتحنا قراءة الصور للعموم لصار لدينا نظامان يقولان شيئين مختلفين عن المحتوى نفسه.
أما الكتابة فهي جوهر الحالة:
allow create: if isAuthed()
&& request.auth.uid == userId
&& isImage()
&& (request.resource.size < 12 * 1024 * 1024 || ...);
السطر الثاني هو كل الحكاية: request.auth.uid == userId تقارن هوية الطالب بالقطعة المتغيّرة من المسار. عضوة تحاول الرفع إلى مجلّد غيرها ترفضها القاعدة قبل أن يبدأ نقل البايتات. لاحظي أننا استعملنا create لا write: في قواعد Storage تنقسم write إلى create وupdate وdelete، والكتابة على كائن موجود مسبقاً هي update. وقد وضعنا صراحةً allow update: if false; لأن الصورة عندنا ثابتة: أي تغيير يعني رفعاً جديداً باسم جديد، لا استبدالاً صامتاً لملف يشير إليه رابط منشور بالفعل. هذا القرار فرض شكل اسم الملف في الكود: نبني الاسم من طابع زمني ومقطع عشوائي، مثل img_1754..._a7f3k2.jpg، والامتداد مشتقّ من نوع الملف المعلن ومُصفّى من أي حرف غير أبجدي رقمي ومقصوص إلى خمسة أحرف. لا اسم أصلي، ولا مسافات، ولا حروف عربية في الرابط.
الحذف مسموح لصاحبة المسار أو للإدارة: allow delete: if isAuthed() && (request.auth.uid == userId || isAdminEmail());. وفي كود الحذف اضطررنا للتفرقة بين مرجعين، لأن التطبيق يخزّن مرجع الصورة نصّاً قد يكون رابطاً أو معرّفاً داخلياً؛ فالاختبار عندنا هو مطابقة الرابط بنمط يتحقّق أنه رابط تخزين Firebase قبل نداء deleteObject، وإلا فهو مرجع قديم في قاعدة البيانات أو رابط خارجي لا نملكه.
allow write في مسار محتوى دائم. فصل create عن update يمنحك أهم ضمانة في التخزين: أن الرابط المنشور اليوم لن يشير غداً إلى بايتات مختلفة رفعها شخص آخر على المسار نفسه.
الحالة الثانية: قراءة عامة، كتابة للإدارة وحدها
بطاقات المنتجات تظهر في الصفحة الرئيسية وداخل المنتدى للزائرات غير المسجّلات، فقاعدتها معكوسة تماماً:
match /products/{fileName} {
allow read: if true;
allow write: if isAdminEmail() && isImage() && request.resource.size < 3 * 1024 * 1024;
allow delete: if isAdminEmail();
}
هنا write بدل create مقبولة ومقصودة: من حقّ الإدارة أن تستبدل صورة بطاقة بأخرى على المسار نفسه، لأنها هي من نشر الرابط أصلاً وهي من يقرّر تغيير محتواه.
والدرس المخفيّ في هذه الحالة أن القراءة العامة للملف وحدها لا تكفي لظهور البطاقة. بيانات البطاقة (العنوان والسعر ورابط الشراء) تسكن في وثيقة إعدادات واحدة، وكان لا بدّ من استثناء صريح في قواعد قاعدة البيانات يسمح للزائرة بقراءة تلك الوثيقة تحديداً دون بقيّة الإعدادات. أي أن ظهور البطاقة للزائرة يحتاج قرارين متوافقين في نظامَي قواعد مختلفين. عندما نسينا أحدهما ظهر العرض نصف مكسور: نصّ سليم وصورة مفقودة، وهو بالضبط العرض الذي أضلّني ساعتين. إن أردتِ تفصيل الطبقة الأخرى فقد أفردنا لها مقالاً: قواعد Firestore الآمنة عملياً.
لماذا يكسر دمج الحالتين إحداهما دائماً
قبل الفصل جرّبنا فعلياً ما يجرّبه الجميع: مسار واحد لكل الصور وقاعدة قراءة واحدة. النتيجة ليست مسألة تفضيل بل استحالة منطقية، لأن الشرط الوحيد المتاح في قاعدة القراءة هو حالة الطالب، ولا يوجد في طلب القراءة ما يميّز «صورة إدارية» عن «صورة عضوة»:
- القراءة للمسجّلات: صور المنتدى محميّة، والبطاقات تختفي عن كل الزوّار — أي يختفي الغرض التجاري من الصفحة الرئيسية.
- القراءة للعموم: البطاقات تظهر، وكل صورة شخصية ترفعها عضوة في موضوع مغلق تصبح مقروءة لمن يعرف مسارها.
- محاولة التمييز بالاسم: مثل السماح بالقراءة العامة للملفات التي يبدأ اسمها بـ
pub_— وهذا يعني أن التسمية صارت آلية تحكّم، وأي عضوة تختار اسم ملفها فتمنح نفسها القراءة العامة. سقطت الفكرة في دقيقتين.
ما لا يمكن التعبير عنه في قاعدة القراءة، عبّري عنه في المسار. الفصل بمقطع مسار واحد جعل القاعدتين مستقلّتين تماماً، وأصبح كل تغيير مستقبلي على إحداهما لا يمسّ الأخرى.
نوع الملف وحجمه داخل القواعد
الدالة المشتركة بين المسارين تفحص النوع المعلن:
function isImage() {
return request.resource.contentType.matches('image/.*')
&& !request.resource.contentType.matches('image/svg.*');
}
لماذا يُستثنى SVG بالذات
لأنه النوع الوحيد في قائمة الصور الذي ليس صورة فقط: ملف image/svg+xml مستند XML قد يحتوي وسم script، وحين يُفتح رابطه مباشرة في المتصفّح — لا داخل وسم img — يُنفَّذ ما فيه في سياق نطاق التخزين. رفع صورة صار مساراً لتنفيذ كود. لذلك يُستثنى في القواعد لا في الواجهة، ويُستثنى بنمط image/svg.* لا بمطابقة نصّ كامل، لأن النوع يأتي أحياناً بلاحقة معلمات.
لكن يجب أن نكون صادقين في حدود هذا الفحص: contentType قيمة يعلنها العميل عند الرفع — في كودنا تُمرَّر صراحةً مع بايتات الملف — ولا يفحص Storage البايتات ليتأكّد. فمن يتعمّد يمكنه إعلان نوع صورة لمحتوى ليس صورة. القاعدة إذن تقلّص سطح الخطأ ولا تصادق على المحتوى، والحماية الحقيقية أن العرض يمرّ دائماً عبر وسم img (فبايتات غير الصورة تفشل في الرسم بلا أثر)، وأن سياسة أمان المحتوى تحدّ ما يُنفَّذ أصلاً — وهو موضوع تناولناه في سياسة أمان المحتوى مع أطراف ثالثة.
ثلاثة حدود حجم، ولماذا في القواعد لا في الواجهة
في مسار المنتدى كتبنا الحدّ هكذا: صورة أقل من 12 ميجابايت للعضوة، أو أقل من 120 ميجابايت إن كانت الطالبة هي الإدارة. صياغته «أو» مقصودة لا شرطية ثلاثية: العضوة العادية تنجح بالشرط الأول وتسقط عند الثاني لأن فحص البريد يفشل، والإدارة ترفع الملف الكبير عبر الشرط الثاني. وفي مسار البطاقات الحدّ ثلاثة ميجابايت فقط، لأن الصور هناك مصغّرات بطاقات، وأي شيء أكبر يعني خطأً في التجهيز لا حاجة حقيقية.
الواجهة عندنا تفحص الأمرين قبل الرفع: ترفض image/svg برسالة تشرح الأنواع المسموحة، وترفض ما يبلغ ثلاثة ميجابايت أو أكثر. هذا الفحص المزدوج مقصود، ودوره تحسين التجربة فقط: تحذير فوري بلا انتظار رفع كامل ثم فشل. أما القرار فيبقى في القواعد لأن الواجهة يمكن تجاوزها بنداء مباشر للخدمة، ولأن request.resource.size متاح للقاعدة قبل اكتمال النقل. اعتبري كل تحقّق في الواجهة تجربةَ مستخدم، وكل تحقّق في القواعد أمناً — ولا تخلطي الوظيفتين.
storage/unauthorized يعني أن القواعد رفضت، وأشهر أسبابه ليس خطأً في منطقك: قواعد صحيحة في الملف لكن غير منشورة بعد. في مشروعنا خصّصنا لهذا الرمز رسالة مختلفة عن بقيّة الأخطاء — «القواعد غير منشورة» — بعد أن أهدرنا وقتاً في مراجعة منطق سليم أصلاً.
سرد المجلّد ليس قراءة ملف: 403 مقابل 404
هذه الفقرة أهم ما تعلّمناه، وأكثر ما يخالف الحدس. القاعدة عندنا هي match /forum/{userId}/{fileName}، أي أنها تُطابق مساراً من ثلاث قطع ينتهي باسم ملف. عمليّة السرد (list) لا تطلب ملفاً، بل تطلب بادئة (prefix): forum/{userId}/. وهذه البادئة لا تُطابق قاعدتنا لأنها تنقصها قطعة اسم الملف، فتسقط إلى قاعدة الإغلاق الافتراضي في الأسفل وتُرفض. النتيجة التي تبدو متناقضة: العضوة تقرأ كل ملف في مجلّدها بنجاح، ولا تستطيع أن تسأل «ما الموجود في مجلّدي؟».
وقد كان هذا في حالتنا سلوكاً مرغوباً لا عيباً: لا شاشة في التطبيق تعرض «كل صوري»، والمراجع محفوظة في وثائق المواضيع، فلا حاجة لسرد أصلاً — ومنع السرد يمنع أي كشف عن أسماء الملفات. لكن من يخطّط لمعرض صور يجب أن يعلم مقدّماً أنه يحتاج قاعدة إضافية على مستوى البادئة، مثل match /forum/{userId} { allow list: if request.auth.uid == userId; }، وإلا فسيطارد خطأً وهمياً في كوده.
كيف تختبرين هذا فعلياً
الاختبار الذي حسم الأمر عندي لا يحتاج أكثر من أمر واحد ومقارنة رمزَين. اطلبي الرابط بلا أي جلسة مسجّلة واطبعي رمز الاستجابة فقط:
curl.exe -s -o NUL -w "%{http_code}" "https://firebasestorage.googleapis.com/v0/b/BUCKET/o/products%2Fp123.jpg?alt=media"
وفسّري الناتج هكذا:
- 200 — القراءة مسموحة والملف موجود.
- 403 — القواعد رفضت. المشكلة في القواعد أو في نشرها، لا في المسار.
- 404 — القواعد سمحت لكِ بالمرور والملف غير موجود: المسار خطأ، أو الترميز خطأ (الشرطة المائلة داخل المسار يجب أن تُرمَّز
%2F)، أو الملف حُذف.
هذا التمييز يختصر التشخيص إلى نصفين لا يتقاطعان: 403 يعني «صلاحية» و404 يعني «مسار». في تشخيصي الأصلي كنتُ أرى 403 على صور البطاقات في متصفّح بلا تسجيل، بينما أرى 200 على المتصفّح نفسه بعد الدخول — وهذا وحده كان يكفي لإثبات أن العلّة قاعدة قراءة موحّدة، لا سياسة أمان ولا تحميل متأخّر. استعملنا المنطق نفسه داخل لوحة الإدارة: بعد كل رفع نضع الرابط النهائي في وسم img ونراقب onerror، فرسالة «رُفعت لكن رابطها لا يُحمّل» تفصل نجاح الكتابة عن نجاح القراءة، وهما صلاحيتان مختلفتان يخلط بينهما الجميع.
الإغلاق الافتراضي ورمز رابط التحميل
آخر مقطع في الملف هو أهمّها على المدى الطويل:
match /{allPaths=**} {
allow read, write: if false;
}
سلوك Storage الافتراضي هو الرفض عند غياب قاعدة مطابقة، فالسطر تحصيل حاصل تقنياً. كتبناه مع ذلك لسببين عمليين: أنه يجعل النيّة مقروءة لمن يفتح الملف بعد سنة، وأنه يحمي من المسار الذي سيُضاف لاحقاً. أي ميزة جديدة ترفع ملفات إلى مسار لم نصف قواعده ستفشل فوراً وبوضوح، بدل أن تعمل بصلاحية واسعة ورثتها من قاعدة عامة مكتوبة بتوسّع. الفشل الصريح المبكّر أرحم من نجاح صامت غير مقصود.
ما يعنيه الرمز (token) في الرابط للسرّية
حين يستخرج الكود رابط التحميل، ينتج رابط طويل ينتهي بـ ?alt=media&token= ومعرّف عشوائي. هذا الرمز مفتاح حامل (bearer): من يملك الرابط يقرأ الملف، بغضّ النظر عن قاعدة القراءة. أي أن allow read: if isAuthed() تحمي المسار من الاستكشاف والقراءة المباشرة، ولا تُبطل رابطاً مُرمَّزاً تسرّب. الخلاصة العملية أن سرّية صور المنتدى عندنا تقوم على طبقتين، والقواعد ليست الوحيدة فيهما: الروابط نفسها مخزَّنة داخل وثائق مواضيع لا تُقرأ إلا للمسجّلات، فالسرّية تعتمد على إخفاء الرابط بقدر اعتمادها على القاعدة. ومن يريد سرّية حقيقية لا يستعمل روابط التحميل المُرمَّزة، بل يقرأ البايتات عبر حزمة التطوير في كل مرّة لتُقيَّم القواعد فعلياً على كل قراءة. وإن تسرّب رابط، فالعلاج الوحيد إبطال رمزه من لوحة التحكّم — وهو يبطل الرابط القديم نهائياً ويجبر التطبيق على استخراج رابط جديد، فاحتسبي أثر ذلك على ما نسخته في وثائقك.
نقطة أخيرة تخصّ الأداء لا الأمن: نرفع كل صورة بترويسة تخزين مؤقّت طويلة (سنة كاملة، بوسم immutable). هذا آمن هنا فقط لأن التعديل ممنوع بالقاعدة والاسم فريد لكل رفع. لو سمحنا بالاستبدال على المسار نفسه لصار الكاش الطويل فخّاً يعرض صورة قديمة لا سبيل لتحديثها — وهي المشكلة ذاتها التي شرحناها في إدارة إصدارات Service Worker.
أسئلة شائعة
هل أستطيع الاعتماد على القواعد وحدها لمنع رفع ملفات ضارّة؟ لا. القواعد تفحص النوع المعلن والحجم فقط، ولا تفتح البايتات. فحص النوع يمنع أشهر خطر عملي (وهو SVG القابل للتنفيذ) ويقلّل العبث، لكن التحقّق الحقيقي من المحتوى يحتاج معالجة على الخادم بعد الرفع.
لماذا نجح الرفع وفشل العرض؟ لأنهما صلاحيتان منفصلتان: create وread. من الشائع أن يسمح مسارك بالكتابة تحت مجلّد المستخدم وتنسى تماماً قاعدة القراءة، فتنجح كل عمليات الرفع وتظهر صور مكسورة في الواجهة. اختبري القراءة من رابطها النهائي لا من نتيجة الرفع.
ولماذا فشل سرد الملفات مع أن قراءة الملف تعمل؟ لأن القواعد تُقيَّم على البادئة في عمليّة السرد، وقاعدة تنتهي بـ {fileName} لا تُطابق بادئة مجلّد. تحتاجين قاعدة على مستوى المجلّد تسمح بـ list صراحةً.
أيهما أوفر: Storage أم تخزين الصور في قاعدة البيانات؟ جرّبنا الثاني قبل الأول فعلياً — كانت الصور تُقطَّع أجزاءً بترميز نصّي وتُحفَظ وثائقَ متعدّدة، بسقف عشرين جزءاً للعضوة ومئتين للإدارة. عمل الحلّ ولا يزال مساراً احتياطياً في الكود عند فشل الرفع، لكنّه أغلى في القراءات وأثقل في التجميع. الصور الثابتة موطنها التخزين، والقراءات الغالية موضوع مستقلّ ناقشناه في ضبط تكلفة Firestore.
خلاصة
تصميم قواعد Storage قرار مسارات قبل أن يكون قرار شروط. افصلي المحتوى العام عن محتوى الأعضاء في مسارين مختلفين لأن قاعدة القراءة الواحدة لا تستطيع خدمة نيّتين متعاكستين. ضعي {userId} في المسار لتقدري على مطابقته بهوية الطالب، ومنعي التعديل لتضمني ثبات الروابط، واستثني SVG صراحةً، واحتفظي بحدود الحجم في القواعد واجعلي الواجهة تحذيراً مبكّراً لا حاجزاً وحيداً. أغلقي كل ما لم تصفيه برفض صريح، واختبري بالرموز: 403 صلاحية و404 مسار. وتذكّري أن رابط التحميل المُرمَّز مفتاحٌ بذاته، فاحمِ الرابط كما تحمين الملف.