زمان خواندن : 12 minutes

در ماه‌ها، فرم تماس این سایت روی یک mock JavaScript اجرا می‌شد — یک وعده 85٪ موفقیت، یک Firestore جعلی، داده‌ای ذخیره نشده. برنامه اولیه یک بک‌اند Supabase همراه با Google Apps Script برای اعلان‌های ایمیل پیش‌بینی کرده بود. لغو شد. امروز، من Migration به Firebase Firestore را تعریف می‌کنم: ایجاد پروژه، قوانین امنیت، بازنویسی JS، پاک‌سازی کدmort Supabase. و چرا این انتخاب چیزی را دربارهٔ فلسفه توسعهٔ بیشتر می‌گوید.

طقطقه

[]

صحنه: یک فرم که هیچ چیزی را ذخیره نمی‌کند

این سایت توسط JBake، پلاگین Gradle من تولید می‌شود`bakery`. این 100٪ استاتیک است — بدون بک‌اند، بدون پایگاه داده. به‌جز اینکه یک فرم تماس دارم. صفحه`contact.html`موجود است، HTML آماده است (فیلدهای نام، ایمیل، تلفن، موضوع، پیام، اعتبارسنجی HTML5، honeypot ضد اسپام)، استایل‌های 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);
});

یک موک. یک وعده که مجرداً ظاهر می‌کند. کاربر یک spinner می‌بیند، سپس پیام « پیام با موفقیت ارسال شد ! ». اما داده‌ها به خالی می‌روند. هیچ پیامی در هیچ جایگاهی ذخیره نمی‌شود.

وضعیت از یک فرم شکسته بدتر است — این یک فرم است که دروغ می‌گوید.

ارث Supabase

برنامه اولیه، مستند شده در`content/draft/integration_formulaire_contact_supabase.adoc`, پیش‌بینی می کرد :

  1. یک پایگاه داده Supabase با جدول`contacts`و امنیت سطری

  2. یک RPC`handle_contact_form`سمت سرور

  3. یک trigger SQL که یک webhook Google Apps Script را فراخوانی می‌کند

  4. گوگل اپز اسکریپت که یک ایمیل Gmail اطلاع‌رسانی ارسال می‌کند

کد JavaScript متناظر هنوز در`script.js`. یک کلاس وجود دارد`SupabaseManager`یک کلاینت Supabase را با متغیرهای جهانی مقداردهی اولیه می‌کند`SUPABASE_URL` et SUPABASE_KEY, و یک کلاس`ContactFormHandler`که رویداد submit فرم را گوش می‌دهد و صدا می‌زند`SupabaseManager.submitContactForm()`.

مشکل: این متغیرهای سراسری دیگر در فوتر تزریق نمی‌شوند. Le`<script src="supabase-js">`حذف شد. کد صدا می‌زند.`supabase.createClient()`بر یک متغیر`supabase`که دیگر وجود ندارد. بنادراین:

console.error : 'Supabase client library (supabase-js) is not loaded.'

نه تنها داده‌ها ذخیره نمی‌شوند، بلکه کد ارسال مرده است.

ارسال دوگانهphantom

برای بدتر کردن موارد، یکرقابت ساکتبین دو handler در همان فرم :

  1. `contact.js`Submit را گوش دهید، Firebase mock را فراخوانی کنید

  2. script.js— از voies`ContactFormHandler`— گوش بده به submit، تماس بزن`SupabaseManager`

هر دو می‌کنند`event.preventDefault() + event.stopPropagation(). مثل`contact.js`در ابتدا بارگذاری می‌شود`footer.thyme, هندلر آن به‌عنوان اول متصل می‌شود. انتشار آن را مسدود می‌کند.`ContactFormHandler`هرگز اجرا نخواهد شد.

این یک باگ فعال نیست — یک زومبی است. کدی که هرگز فرصة اجرا شدن را ندارد.

@startuml
skinparam backgroundColor #FEFEFE

title وضعیت اولیه — فرم تماس
rectangle "contact.thyme
(HTML Bootstrap, honeypot)" as FORM
rectangle "footer.thyme\n(Firebase SDK با مکان‌نویس تنظیمات)" as FOOT
rectangle "contact.js\n(mock Firebase, 85% موفقیت)" as CONT
rectangle "script.js\n(SupabaseManager + ContactFormHandler, mortalities)" 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 بومی، توابع ابری Cloud Functions یکپارچه، اکوسیستم گوگل مناسب‌تر. پیاده‌سازی موجود Supabase علامت‌گذاری شده به عنوان « ⚠️ abandoned».

به غیر از پلن رایگان، یک دلیل معماری وجود دارد. این سایت در اکوسیستم Google زندگی می‌کند: مخزن هدف است.cheroliv.github.io, CNAME به GitHub Pages اشاره دارد، بيلد Gradle به GitHub از طریق JGit push می‌شود. افزودن سرویس گوگل (Firebase) به جای سرویس شخص ثالث (Supabase) سطح پراکندگی را کاهش می‌دهد.

Firestore در حالت بومی (نه حالت Datastore) نیز نزدیکتر به مدل ذهنی اسناد NoSQL که در ذهن دارم است: مجموعه‌ها، اسناد، فیلدهای تایپ‌شده، timestamp‌های سرور، قوانین امنیتی یکپارچه.

مرحله ۱: ایجاد پروژه Firebase

راه‌اندازی

چون Firebase CLI بر روی دستگاه من نصب نیست، از کنسول وب استفاده می‌کنم :

  1. رفتن بهhttps://console.firebase.google.com/[کنسول فایرباز]

  2. ایجاد یک پروژه`cheroliv-contact`(یا از یک پروژه موجود استفاده مجدد کنید)

  3. Firestore را در حالت بومی فعال کنید (نه Datastore)

  4. ایجاد یک پایگاه داده در منطقه`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;`. آنها مانع از تزریق payloadهای عظیم یا فیلدهای نادرست توسط یک مهاجم می‌شوند.

فاز 2 : بازنویسی JavaScript ارسال

قرارداد ساده است :

  1. داده‌های فرم را بخوانید

  2. بررسیhoneypot (فیلد`hp_name`— اگر پر شده باشد، ربات است؛ یک موفقیت شبیه‌سازی می‌کنیم بدون اینکه چیزی ارسال شود)

  3. تماس بزن`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…​})`

  4. نمایش موفقیت یا خطا

وابستگی `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`ضمناً در دسترس است وقتی handler`contact.js`شروع می‌شود. به عنوان احتیاط، همیشه یک پولینگ ۵ ثانیه‌ای به دلیل احتمال کند بودن 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);
});

تغییرات نسبت به mock:

  • waitForFirebase()— polling با تایم‌اوت، مقاوم حتی اگر CDN کند باشد

  • honeypot— اگر فیلد مخفی`hp_name`مخزن پر شده است، یک موفقیت قطعی Firestore شبیه‌سازی شود. ربات فکر می‌کند موفق شده است اما هیچ چیزی ذخیره نشده است

  • addDoc(collection, {…​})— تماس واقعی Firestore با`serverTimestamp()` et user_agent

  • مدیریت خطا با`try/catch`ناهمگام

  • پاکسازی از`finally`(بازیابی دکمه)

چرا`user_agent`? این گزینه‌ای است، اما برای تشخیص مفید است. اگر پیامی عجیب arrival، دانستن که آیا از مرورگر دسکتاپ، موبایل یا اسکریپت curl می‌آید، به Trier کمک می‌کند.

مرحله ۳: پاک کردن کد بی‌استفاده Supabase

`script.js`حاوی ۲۵۰ خط کد مرده است :

  • SupabaseManager(خطوط 417-481) — 65 خط

  • ContactFormHandler(خطوط 490-551) — 62 خطوط

  • بخش راه‌اندازی (خطوط 645-654) — 10 خط

جمع: حدود 140 خط باید حذف شود.

بلوک`DOMContentLoaded`یک ایجاد می‌کند`SupabaseManager`پس یک`ContactFormHandler`همان‌طور که پیش‌تر توضیح داده شد، این کد هیچ‌گاه اجرا نمی‌شود (مسدود توسط`contact.js`), حتی اگر اجرا می‌شد، شکست می‌خورد (SDK Supabase بارگذاری نشده است).

من حذف می‌کنم :

  1. کلاس`SupabaseManager`

  2. کلاس`ContactFormHandler`

  3. بلوک مقداردهی اولیه در`DOMContentLoaded`(خطوط 645-654)

Le reste de script.js`سالم است :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.

مرحله ۴: پیکربندی فوتر

`footer.thyme`ِاو هم قالب اولیه Firebase را دارد اما با مقادیر placeholder. من جایگزین می‌کنم :

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 است، اما ترجیح می دهم آن‌ها را به صورت واضح commit نکنم) من آن‌ها را در`site.yml`(قبلاً در`.gitignore`) و پلاگین`bakery`او آن‌ها را از طریق یک منطق که باید در سمت ساخت افزوده شود، در قالب تزریق می‌کند.

در حال حاضر، مستقیماً آن‌ها را اینجا می‌ذارم`footer.thyme`— بيلد`./gradlew serve`آنها را به صورت محلی بارگذاری خواهد کرد. در زمان استقرار، تزریق را به`site.yml`یا به یک متغیر Gradle.

L'`apiKey`Firebase نیستنهیک راز. عمومی به طراحی است. چه چیزی داده‌های شما را محافظت می‌کند، آنها هستند.قوانین امنیتی Firestore, این کلید API نیست. این را در یک`.env`بارگذاری سمت سرور — هدفش نمایش در مرورگر است.

فاز ۵ : معماری نهایی

@startuml
skinparam backgroundColor #FEFEFE

title معماری نهایی — فرم تماس Firebase
actor Utilisateur as USER

package "مرورگر" #E8F5E9 {
  rectangle "contact.thyme\n(HTML, honeypot)" as FORM
  rectangle "contact.js
(اعتبار‌سنجی + Firestore)" as CONT
  rectangle "footer.thyme\n(راه‌انداز SDK Firebase)" as FOOT
}

cloud "فایربیز" #BBDEFB {
  rectangle "Firestore
(contact_messages)" 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`, او است بحث می‌شود در بک‌لاگ، او است آزمایش می‌شود از طریق`./gradlew serve`, و یک مقاله در وبلاگ می‌سازد (مقاله‌ای که شما دارید بخوانید).

این dogfooding خالص است. سایت محصول پلاگین است، پلاگین محصول توسعه‌دهنده است، توسعه‌دهنده فرایند را در خود سایت مستند می‌کند.

چکره بسته شد.

به دلیل این‌که یک mock را چندین ماه کشیده‌ام (جلسات کامل‌هایی که فرم به‌طور ساکت دروغ می‌گفت)، به این نتیجه رسیدم: بک‌لاگ یک وب‌سایت استاتیک شخصی هرگز « تمام » نیست. همیشه یک US اولویت دار وجود دارد، همیشه یک مقاله پیش‌نویس وجود دارد، همیشه بخشی annotations در یک تمپلیت وجود دارد. انضباط به این معنی نیست که همه چیز را تمام کنیم؛ به این معنی است که چیزهایی را که برای کاربرvisible است، تمام کنیم.

فرم تماس شکسته، حتی بدتر از نداشتن فرم به هیچ عنوان نیست. این یک وعده نقض‌شده است.

خلاصه تغییرات

فایل

تغییر

تاثیر

blog/2026/0113_*.adoc

ایجاد مقاله

مستندات

assets/js/contact.js

بازنویسی (mock → Firestore واقعی)

عملی

assets/js/script.js

حذف SupabaseManager + ContactFormHandler + init بلوک

پاکسازی

templates/footer.thyme

جایگزین کردن placeholder پیکربندی → مقادیر واقعی

پیکربندی

مراحل بعدی (کارهای معلق)

  • ایمیل اعلان: یک Cloud Function`onCreate`بر`contact_messages`یک ایمیل از طریق SendGrid ارسال می‌کند. فرم ذخیره می‌شود، اما من اطلاع نمی‌یابم. أولویت متوسط — پیام‌ها در کنسول Firebase قابل مشاهده هستند.

  • محدودیت نرخ سمت کلاینت: افزودن زمان‌سنج localStorage برای جلوگیری از ارسال‌های چندگانه به‌سرعت. honeypot ربات‌های ساذ را مسدود می‌کند، یک محدودکننده نرخ ربات‌های هوشمند‌تر را نیز می‌تواند مسدود کند.

  • آزمون‌هایک تست Playwright که فرم را ارسال می‌کند و بررسی می‌کند که سند در Firestore ظاهر شود. در حال حاضر، به‌صورت دستی از طریق`./gradlew serve`.

مقالات مرتبط