الترحيل من Supabase إلى Firebase: ربط نموذج اتصال بـ Firestore دون خلفية
Publié le 29 April 2026
- المشهد: نموذج لا يخزن شيئًا
- لماذا Firebase بدلاً من Supabase؟
- المرحلة 1 : إنشاء مشروع Firebase
- المرحلة 2: أعد كتابة جافا سكريبت الإرسال
- المرحلة 3 : تنظيف الكود الميت Supabase
- المرحلة 4: ضبط تذييل الصفحة
- المرحلة 5 : العمارة النهائية
- ما تقوله هذه الهجرة عن dogfooding
- الخطوات التالية (قائمة الانتظار)
- المراجع
خلال أشهر، كان نموذج الاتصال بهذا الموقع يعمل على محاكاة JavaScript — وعد بنسبة نجاح 85٪، وFirestore مزيف، ولا توجد بيانات مخزنة. كانت الخطة الأولية preved backend Supabase مع Google Apps Script للإشعارات البريدية. تم التخلي عنها. اليوم، أحكي عن الهجرة إلى Firebase Firestore: إنشاء المشروع، قواعد الأمان، إعادة كتابة JS، تنظيف الكود الميت لـ Supabase. ولماذا يقول هذا الاختيار شيئًا أكبر حول فلسفة التطوير.
- الوسواس القهري
-
[]
المشهد: نموذج لا يخزن شيئًا
هذا الموقع مُنشأ باستخدام JBake، المكوّن الإضافي Gradle الخاص بي`bakery`. إنه ثابت 100٪ — لا خلفية، لا قاعدة بيانات. ما عدا أن لديّ نموذج اتصال. الصفحة`contact.html`هو موجود، HTML جاهز (الحقول: الاسم، البريد الإلكتروني، الهاتف، الموضوع، الرسالة، التحقق من HTML5، مصيدة الهونيبوت لمكافحة البريد العشوائي)، أنماط Bootstrap في مكانها. بصريًا، كل شيء مثالي.
إلا أنه عند الإرسال، لا يحدث شيء.
const firebaseMock = new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.85) {
resolve({ status: 201, message: 'Message stored in Firestore.' });
} else {
reject({ status: 500, message: 'Firestore write failed.' });
}
}, 1500);
});
مُحاكاة. وعدٌ يظاهر بأنه حقيقي. يرى المستخدم رمز التحميل، ثم رسالة « تم إرسال الرسالة بنجاح! ». لكن البيانات تذهب إلى الفراغ. لم يتم تخزين أي رسالة في أي مكان.
الوضع أسوأ من نموذج مكسور — إنه نموذج يكذب.
إرث Supabase
الخطة الأولية، الموثقة في`content/draft/integration_formulaire_contact_supabase.adoc`, كان يتوقع :
-
قاعدة Supabase مع جدول`contacts`و Row Level Security
-
إحدى RPC`handle_contact_form`côté serveur
-
مشغل SQL الذي يستدعي ويبهوك Google Apps Script
-
نص برمجة Google Apps Script يرسل بريدًا إلكترونيًا من Gmail للإشعار
الكود JavaScript المقابل لا يزال موجودًا في`script.js`. هناك صف`SupabaseManager`الذي يقوم بتهيئة عميل Supabase باستخدام المتغيرات العامة`SUPABASE_URL` et SUPABASE_KEY, و فصل`ContactFormHandler`الذي يستمع إلى حدث إرسال النموذج ويستدعي`SupabaseManager.submitContactForm()`.
المشكلة : هذه المتغيرات العامة لم تعد تُحقَن في التذييل. ال`<script src="supabase-js">`تم إزالته. الكود يستدعي`supabase.createClient()`على متغير`supabase`الذي لم يعد موجود. إذن:
console.error : 'Supabase client library (supabase-js) is not loaded.'
ليس فقط البيانات لا تُخزن، بل كود الإرسال ميت.
الإرسال المزدوج الوهمي
لتفاقم الأمور، هناكمنافسة صامتةبين معالجين على نفس النموذج :
-
`contact.js`استمع إلى الإرسال، استدعاء محاكاة Firebase
-
script.js— عبر`ContactFormHandler`— استمع أيضًا إلى الإرسال، اتصل`SupabaseManager`
كلاهما يفعلان`event.preventDefault() + event.stopPropagation(). مثل`contact.js`يتم تحميله أولاً في`footer.thyme, معالجه مرفق أولًا. يمنع الانتشار.`ContactFormHandler`لن يتم تشغيله أبداً.
هذا ليس حتى خطأ نشط — إنه زومبي. كود لا يحصل أبداً على فرصة للتنفيذ.
Failed to generate image: PlantUML preprocessing failed: [From <input> (line 8) ] @startuml skinparam backgroundColor #FEFEFE title الحالة الأولية — نموذج الاتصال rectangle "contact.thyme\n(HTML Bootstrap, honeypot)" as FORM rectangle "footer.thyme\n(Firebase SDK مع مساحة نصية مؤقتة للإعدادات)" as FOOT rectangle "contact.js (mock Firebase, 85% النجاح)" as CONT ^^^^^ Syntax Error? (Assumed diagram type: activity) @startuml skinparam backgroundColor #FEFEFE title الحالة الأولية — نموذج الاتصال rectangle "contact.thyme\n(HTML Bootstrap, honeypot)" as FORM rectangle "footer.thyme\n(Firebase SDK مع مساحة نصية مؤقتة للإعدادات)" as FOOT rectangle "contact.js (mock Firebase, 85% النجاح)" as CONT rectangle "script.js (SupabaseManager + ContactFormHandler, الموتى)" as SCRIPT rectangle "مستخدم" as USER USER -> FORM : Soumission du formulaire FORM -> CONT : submit event (attaché en premier) CONT -> CONT : preventDefault + stopPropagation CONT -> CONT : mock Promise (1.5s, aléatoire) note right of CONT ⚠️ Aucune donnée stockée end note FORM ..> SCRIPT : submit event (bloqué par stopPropagation) note right of SCRIPT : ❌ Jamais déclenché note right of SCRIPT : ❌ supabase-js non chargé @enduml
لماذا Firebase بدلاً من Supabase؟
تم توثيق قرار الهجرة في`AGENT.adoc`:
تم اختيار Firebase الآن للأسباب التالية: أفضل خطة مجانية، Firestore أصلي، وظائف السحابة المتكاملة، نظام بيئي جوجل أكثر ملاءمة. تنفيذ Supabase الحالي مُشار إليه بـ « ⚠️ مهجور ».
بخلاف الخطة المجانية، هناك سبب هندسي. هذا الموقع يعيش في نظام جوجل البيئي: المخزن المستهدف هو`cheroliv.github.io`, يشير CNAME إلى GitHub Pages، يدفع بناء Gradle إلى GitHub عبر JGit. إضافة خدمة Google (Firebase) بدلاً من خدمة طرف ثالث (Supabase) تقلل من سطح التشتت.
Firestore في الوضع الأصلي (ليس في وضع Datastore) هو أيضًا أقرب إلى نموذج NoSQL للوثائق الذهني الذي في ذهني : المجموعات، المستندات، الحقول ذات الأنواع، الطوابع الزمنية للخادم، قواعد الأمان المتكاملة.
المرحلة 1 : إنشاء مشروع Firebase
تهيئة
لأن واجهة سطر أوامر Firebase غير مثبتة على جهازي، فإنني أستخدم وحدة التحكم على الويب :
-
اذهب إلىhttps://console.firebase.google.com/[وحدة تحكم Firebase]
-
إنشاء مشروع`cheroliv-contact`(أو إعادة استخدام مشروع موجود)
-
تفعيل Firestore في الوضع الأصلي (ليس Datastore)
-
إنشاء قاعدة بيانات في المنطقة`eur3`(أوروبا)
للاستخدام الحد الأدنى مثلنا (مجموعة واحدة، كتابة عامة)، الوضع الأصلي هو الاختيار الصحيح. لا حاجة لقواعد Datastore معقدة.
قواعد أمان Firestore
النموذج عام — أي شخص يمكنه إرسال رسالة. لكنني أريد الحد من الإساءات :
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /contact_messages/{messageId} {
// Lecture : admin uniquement (authentifié)
allow read: if request.auth != null;
// Écriture : publique, mais limitée
allow create: if request.auth == null
&& request.resource.data.name is string
&& request.resource.data.name.size() >= 1
&& request.resource.data.name.size() <= 100
&& request.resource.data.email is string
&& request.resource.data.email.matches('.*@.*\\..*')
&& request.resource.data.email.size() <= 254
&& request.resource.data.subject is string
&& request.resource.data.subject.size() >= 3
&& request.resource.data.subject.size() <= 200
&& request.resource.data.message is string
&& request.resource.data.message.size() >= 10
&& request.resource.data.message.size() <= 5000
&& request.resource.data.created_at == request.time
&& request.resource.data.user_agent is string
&& request.resource.data.user_agent.size() <= 500;
}
}
}
نقاط رئيسية :
-
allow read— فقط المستخدمين المصادق عليهم يمكنهم قراءة الرسائل (أنا، عبر وحدة تحكم Firebase) -
allow create— أي شخص يمكنه إنشاء مستند، ولكن مع التحقق من الحقول -
التحقق من جانب الخادم : الأحجام الدنيا/العليا, تنسيق البريد الإلكتروني,
created_at`يجب أن يتوافق مع`request.time(ضد التزييف) -
`user_agent`يُرسَل للتتبع (ليس حرجًا لكنه مفيد)
هذه القواعد أكثر صرامة من بسيطة`allow write: if true;`. تمنع المهاجم من حقن حمولات ضخمة أو حقول غير صحيحة.
المرحلة 2: أعد كتابة جافا سكريبت الإرسال
العقد بسيط :
-
قراءة بيانات النموذج
-
التحقق من فخ العسل (الحقل)
hp_name— إذا كان ممتلئًا، فهذا بوت؛ نحن نُحاكي نجاحًا دون إرسال أي شيء) -
اتصل`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…})`
-
عرض النجاح أو الخطأ
الاعتماد `window.FIREBASE
في`footer.thyme`, يقوم نص الوحدة بتهيئة SDK الخاص بـ Firebase ويكشف عن كائن عام :
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.0/firebase-app.js";
import { getFirestore, collection, addDoc, serverTimestamp }
from "https://www.gstatic.com/firebasejs/11.6.0/firebase-firestore.js";
const firebaseConfig = { /* valeurs réelles */ };
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
window.__FIREBASE__ = { db, collection, addDoc, serverTimestamp };
</script>
يتم تشغيل سكريبتات الوحدة قبل`DOMContentLoaded`, لذلك`window.FIREBASE`مضمون توفره عندما يكون المعالج`contact.js`يتم التشغيل. كإجراء احترازي، أضيف أيضًا استطلاعًا لمدة 5 ثوانٍ في حالة أن يكون الـ CDN بطيئًا.
الجديد `contact.js
document.addEventListener('DOMContentLoaded', function () {
'use strict';
const form = document.getElementById('contact-form');
if (!form) return;
const submitButton = form.querySelector('button[type="submit"]');
const successMessage = document.getElementById('contact-success-message');
const errorMessage = document.getElementById('contact-error-message');
// Éléments de validation
const nameInput = form.querySelector('input[name="name"]');
const emailInput = form.querySelector('input[name="email"]');
const phoneInput = form.querySelector('input[name="phone"]');
const subjectInput = form.querySelector('input[name="subject"]');
const messageInput = form.querySelector('textarea[name="message"]');
const honeypotInput = form.querySelector('input[name="hp_name"]');
/**
* Attend que window.__FIREBASE__ soit disponible.
* Timeout de 5 secondes — si le CDN Firebase est lent, on abandonne.
*/
function waitForFirebase(timeoutMs = 5000) {
return new Promise((resolve, reject) => {
if (window.__FIREBASE__) {
resolve(window.__FIREBASE__);
return;
}
const start = Date.now();
const interval = setInterval(() => {
if (window.__FIREBASE__) {
clearInterval(interval);
resolve(window.__FIREBASE__);
} else if (Date.now() - start > timeoutMs) {
clearInterval(interval);
reject(new Error('Firebase SDK non disponible après timeout'));
}
}, 100);
});
}
// --- Validation (identique à l'existant) ---
function validateForm() {
nameInput.setCustomValidity('');
emailInput.setCustomValidity('');
if (phoneInput) phoneInput.setCustomValidity('');
subjectInput.setCustomValidity('');
messageInput.setCustomValidity('');
if (nameInput.value.trim().length < 1) {
nameInput.setCustomValidity('Veuillez saisir votre nom.');
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(emailInput.value.trim())) {
emailInput.setCustomValidity('Veuillez saisir une adresse email valide.');
}
if (phoneInput && phoneInput.value.trim() !== '') {
const phonePattern = /^\d{10,15}$/;
if (!phonePattern.test(phoneInput.value.trim())) {
phoneInput.setCustomValidity('Veuillez saisir un numéro valide (10 à 15 chiffres).');
}
}
if (subjectInput.value.trim().length < 3) {
subjectInput.setCustomValidity('Veuillez saisir un sujet (3 caractères minimum).');
}
if (messageInput.value.trim().length < 10) {
messageInput.setCustomValidity('Veuillez saisir un message (10 caractères minimum).');
}
form.classList.add('was-validated');
return form.checkValidity();
}
// --- Handler de soumission ---
form.addEventListener('submit', async function (event) {
event.preventDefault();
event.stopPropagation();
if (!validateForm()) return;
// Honeypot : si rempli, simuler un succès sans rien envoyer
if (honeypotInput && honeypotInput.value.trim() !== '') {
successMessage.style.display = 'block';
form.reset();
form.classList.remove('was-validated');
return;
}
// UI : état d'envoi
submitButton.disabled = true;
submitButton.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Envoi en cours...
`;
successMessage.style.display = 'none';
errorMessage.style.display = 'none';
try {
const fb = await waitForFirebase();
const messagesCollection = fb.collection(fb.db, 'contact_messages');
await fb.addDoc(messagesCollection, {
name: nameInput.value.trim(),
email: emailInput.value.trim(),
phone: phoneInput ? phoneInput.value.trim() : '',
subject: subjectInput.value.trim(),
message: messageInput.value.trim(),
created_at: fb.serverTimestamp(),
user_agent: navigator.userAgent.substring(0, 500)
});
successMessage.style.display = 'block';
form.reset();
form.classList.remove('was-validated');
} catch (error) {
console.error('Erreur Firestore:', error);
errorMessage.style.display = 'block';
} finally {
submitButton.disabled = false;
submitButton.innerHTML = `
<i class="bi bi-send me-2"></i>
Envoyer le Message
`;
}
}, false);
});
التغييرات مقارنةً بالموك :
-
waitForFirebase()— الاستعلام مع مهلة زمنية، قوي حتى لو كان الـ CDN بطيئًا -
honeypot— إذا كان الحقل مخفيًا`hp_name`مملوء، محاكاة نجاح غير قابل للاستئناف في Firestore. يعتقد البوت أنه نجح لكن لا شيء مخزن. -
addDoc(collection, {…})— استدعاء حقيقي لـ Firestore مع`serverTimestamp()` etuser_agent -
معالجة الأخطاء مع`try/catch`غير متزامن
-
تنظيف`finally`(استعادة الزر)
|
لماذا`user_agent`? إنه اختياري، لكن مفيد للتشخيص. إذا وصلت رسالة غريبة، فإن معرفة ما إذا كانت قادمة من متصفح سطح مكتب، أو هاتف محمول، أو سكريبت curl تساعد في الفرز. |
المرحلة 3 : تنظيف الكود الميت Supabase
`script.js`يحتوي على 250 سطرًا من كود ميت :
-
SupabaseManager(الأسطر 417-481) — 65 سطرًا -
ContactFormHandler(الأسطر 490-551) — 62 سطر -
كتلة التهيئة (الأسطر 645-654) — 10 أسطر
الإجمالي : ~140 سطرًا يجب حذفه.
الكتلة`DOMContentLoaded`ينشئ`SupabaseManager`ثمّ واحد`ContactFormHandler`مرتبط بالنموذج. كما هو موضح أعلاه، لا يتم تنفيذ هذا الكود أبدًا (معطل من قبل`contact.js`), حتى لو تم تنفيذه، س يفشل (لم يتم تحميل SDK Supabase).
أنا أحذف :
-
الصف`SupabaseManager`
-
الصف`ContactFormHandler`
-
كتلة التهيئة في`DOMContentLoaded`(الأسطر 645-654)
Le reste de script.js`سليم:`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.
المرحلة 4: ضبط تذييل الصفحة
`footer.thyme`لديه بالفعل قالب Firebase لكن بقيم مؤقتة. أنا أستبدل :
const firebaseConfig = {
apiKey: "REMPLACER_PAR_VOTRE_API_KEY",
authDomain: "REMPLACER_PAR_VOTRE_AUTH_DOMAIN",
projectId: "REMPLACER_PAR_VOTRE_PROJECT_ID",
storageBucket: "REMPLACER_PAR_VOTRE_STORAGE_BUCKET",
messagingSenderId: "REMPLACER_PAR_VOTRE_SENDER_ID",
appId: "REMPLACER_PAR_VOTRE_APP_ID"
};
من القيم الفعلية المستردة منذإعدادات المشروع > العامة > تطبيقاتك > تطبيق الويبفي وحدة تحكم Firebase.
القيم حساسة`apiKey`هي عامة بالتصميم لدى Firebase، لكنني أفضل عدم إيداعها بشكل صريح) أنا أخزنها في`site.yml`(موجودًا في`.gitignore`) والإضافة`bakery`يحقنهم في القالب عبر منطق يُضاف إلى جانب عملية البناء
في الوقت الحالي، أضعها مباشرةً في`footer.thyme`— البناء`./gradlew serve`سيحملهم محليًا. عند النشر، سأنقل الحقن إلى`site.yml`أو نحو متغير Gradle.
|
L'`apiKey`Firebase ليسلاسر. هي عامة بالتصميم. ما يحمي بياناتك همقواعد أمان فايرستور, ليست مفتاح API. لا تضعه في`.env`محمل من جانب الخادم—هي مخصصة للتعرض للمتصفح |
المرحلة 5 : العمارة النهائية
Failed to generate image: PlantUML preprocessing failed: [From <input> (line 8) ]
@startuml
skinparam backgroundColor #FEFEFE
title الهندسة المعمارية النهائية — نموذج اتصال Firebase
actor Utilisateur as USER
package "متصفح" #E8F5E9 {
rectangle "contact.thyme
^^^^^
Syntax Error? (Assumed diagram type: component)
@startuml
skinparam backgroundColor #FEFEFE
title الهندسة المعمارية النهائية — نموذج اتصال Firebase
actor Utilisateur as USER
package "متصفح" #E8F5E9 {
rectangle "contact.thyme
(HTML, فخ العسل)" as FORM
rectangle "contact.js
(التحقق + Firestore)" as CONT
rectangle "footer.thyme
(تهيئة SDK Firebase)" as FOOT
}
cloud "فايربيس" #BBDEFB {
rectangle "Firestore
(اتصال_رسائل)" as FS
}
FORM --> CONT : submit event
CONT --> FOOT : window.__FIREBASE__.addDoc()
FOOT --> FS : Insert document\n(règles de sécurité validées)
note right of FS
Firestore Rules :
- create: public, validé
- read: auth uniquement
end note
@enduml
ما تقوله هذه الهجرة عن dogfooding
تم إنشاء هذا الموقع بواسطة المكون الإضافي Gradle الخاص بي`bakery`. نموذج الاتصال موجود داخل الموقع. الهجرة من Supabase إلى Firebase موثّقة في`AGENT.adoc`, يتم مناقشتها في backlog، يتم اختبارها عبر`./gradlew serve`، وهي تولد مقال مدونة (الذي تقرأه).
هذا هو dogfooding الخالص. الموقع هو نتاج الإضافة، والإضافة هي نتاج المطور، والمطور يوثق العملية في الموقع نفسه.
الحلقة مغلقة.
حقيقة أنني سحبت mock خلال أشهر (جلسات كاملة حيث كان النموذج يكذب بصمت) جعلتني أدرك شيئًا: الBacklog لموقع ثابت شخصي ليس أبداً « منتهي ». هناك دائمًا US ذات أولوية، دائمًا مقال بحالة draft، دائمًا قسم مُcommented داخل قالب template.
نموذج اتصال معطل، إنه أسوأ من عدم وجود نموذج على الإطلاق. إنها وعد غير مُحقّق.
ملخص التعديلات
ملف |
تعديل |
تأثير |
|
إنشاء المقالة |
التوثيق |
|
إعادة كتابة (mock → Firestore حقيقي) |
وظيفي |
|
حذف SupabaseManager + ContactFormHandler + init bloc |
تنظيف |
|
استبدال مساحة محجوزة config → القيم الفعلية |
التكوين |
الخطوات التالية (قائمة الانتظار)
-
رسالة إخطار: وظيفة سحابية واحدة`onCreate`على`contact_messages`الذي يرسل بريدًا عبر SendGrid. النموذج يخزن، لكنني لا أُستَقبل إشعارًا. أولوية متوسطة — الرسائل مرئية في وحدة تحكم Firebase.
-
تقييد المعدل من جهة العميل: إضافة طابع زمني localStorage لمنع الإرسال المتتالي المتكرر. الفخ (honeypot) يمنع البوتات الساذجة، ومُحدِّد المعدل (rate limiter) سيُعيق البوتات الأكثر ذكاءً.
-
اختباراتاختبار Playwright يرسل النموذج ويتحقق من ظهور المستند في Firestore. في الوقت الحالي، أقوم باختباره يدويًا عبر`./gradlew serve`.