χρόνος ανάγνωσης : 12 minutes

Για μήνες, το φόρμα επαφής αυτού του ιστότοπου είχε τρέξει σε ένα mock JavaScript — μια υπόσχεση 85 % επιτυχίας, ένα ψεύτικο Firestore, μηδενικά αποθηκευμένα δεδομένα. Ο αρχικός σχεδιασμός είχε προβλέψει ένα backend Supabase με Google Apps Script για τις ειδοποιήσεις email. Απορρίφθηκε. Σήμερα, αφηγούμαι τη μετάβαση στο Firebase Firestore: δημιουργία του έργου, κανόνες ασφαλείας, επανέγραψη του JS, καθαρισμός του νεκρού κώδικα του Supabase. Και γιατί αυτή η επιλογή δείχνει κάτι πιο γενικό σχετικά με τη φιλοσοφία της ανάπτυξης.

Η σκηνή: μια φόρμα που δεν αποθηκεύει τίποτα

Αυτό το ιστότοπο παράγεται από το JBake, το πρόσθετό μου Gradle`bakery`. Είναι 100 % στατικό — χωρίς backend, χωρίς βάση δεδομένων. Εκτός από αυτό, έχω μια φόρμα επαφής. Η σελίδα`contact.html`υπάρχει, το HTML είναι έτοιμο (πεδία όνομα, email, τηλέφωνο, θέμα, μήνυμα, έλεγχος HTML5, honeypot anti-spam), τα στυλ 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);
});

Ένας mock. Μια υπόσχεση που φαίνεται. Ο χρήστης βλέπει ένα spinner, και έπειτα ένα μήνυμα « Το μήνυμα στάλθηκε με επιτυχία! ». Αλλά τα δεδομένα πηγαίνουν στο κενό. Κανένα μήνυμα δεν αποθηκεύεται πουθενά.

Η κατάσταση είναι χειρότερη από ένα κατεστραμμένο φόρμα — είναι ένα φόρμα που ψεύδεται.

Η κληρονομιά Supabase

Το αρχικό σχέδιο, τεκμηριωμένο σε`content/draft/integration_formulaire_contact_supabase.adoc`, προέγραφε :

  1. Μια βάση Supabase με πίνακα`contacts`και Row Level Security

  2. Μια RPC`handle_contact_form`πλευρά διακομιστή

  3. Ένας trigger SQL που καλεί έναν webhook Google Apps Script

  4. Google Apps Script που στέλνει ένα ειδοποιητικό email Gmail

Ο αντίστοιχος κώδικας JavaScript υπάρχει ακόμα στο`script.js`. Υπάρχει μια τάξη`SupabaseManager`που αρχικοποιεί ένα πελάτη Supabase με παγκόσμιες μεταβλητές`SUPABASE_URL` et SUPABASE_KEY, και μια κλάση`ContactFormHandler`ακούει το.submit της φόρμας και καλεί`SupabaseManager.submitContactForm()`.

Πρόβλημα: αυτές οι παγκόσμιες μεταβλητές δεν προστίθενται πλέον στο υποσέλιδο. Ο`<script src="supabase-js">`αφαιρέθηκε. Ο κώδικας καλεί`supabase.createClient()`σε μια μεταβλητή`supabase`που δεν υπάρχει πια. Άρα :

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

Δεν μόνο τα δεδομένα δεν αποθηκεύονται, αλλά και ο κώδικας υποβολής είναι νεκρός.

Η διπλή φαντασματική υποβολή

Για να χαλάσει τα πράγματα, υπάρχει μιασιγουρός διαγωνισμόςμεταξύ δύο χειριστών στην ίδια φόρμα:

  1. `contact.js`άκουσέ το submit, κάλεσε το mock Firebase

  2. script.js— μέσω`ContactFormHandler`— άkouε επίσης το submit, κάλεσε`SupabaseManager`

Οι δύο κάνουν`event.preventDefault() + event.stopPropagation().ώς`contact.js est chargé en premier dans footer.thyme, ο handler του είναι συνδεδεμένος πρώτος. Αποτρέπει την διάδοση.`ContactFormHandler`θα ενεργοποιηθεί ποτέ.

Δεν είναι καν ενεργό bug — είναι ένα ζομπι. Κώδικας που δεν έχει ποτέ την ευκαιρία να εκτελεστεί.

@startuml
skinparam backgroundColor #FEFEFE

title État Initial — Formulaire de Contact

rectangle "contact.thyme\n(HTML Bootstrap, honeypot)" as FORM
rectangle "footer.thyme\n(Firebase SDK avec config placeholder)" as FOOT
rectangle "contact.js\n(mock Firebase, 85% succès)" as CONT
rectangle "script.js\n(SupabaseManager + ContactFormHandler, morts)" as SCRIPT
rectangle "Utilisateur" 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, πιο κατάλληλο οικοσύστημα Google. Η υπάρχουσα υλοποίηση του Supabase είναι σημειωμένη «⚠️ Απορριφθεί».

Αυτή η τοποθεσία ζει στο οικοσύστημα του Google: ο προορισμός του αποθετήριου είναι`cheroliv.github.io`, το CNAME δείχνει στα GitHub Pages, ο Gradle build push στο GitHub μέσω JGit. Η προσθήκη μιας υπηρεσίας Google (Firebase) αντί μιας υπηρεσίας τρίτων (Supabase) μειώνει την επιφάνεια διασποράς.

Το Firestore σε ενδεδειγμένο режим (όχι σε режим Datastore) είναι και πιο κοντά στο νοητικό μοντέλο NoSQL εγγράφων που έχω στο μυαλó: συλλογές, έγγραφα, τυποποιημένα πεδία, timestamps διακομιστή, ενσωματωμένοι κανόνες ασφαλείας.

Φάση 1: Δημιουργήστε το έργο Firebase

Αρχικοποίηση

Η CLI Firebase δεν είναι εγκατεστημένη στον υπολογιστή μου, επομένως βγαίνω στην κονσόλα web :

  1. πάει στοhttps://console.firebase.google.com/[Κονσόλα Firebase]

  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— όποιος μπορεί να δημιουργήσει ένα έγγραφο, αλλά με επικύρωση πεδίων

  • Επαλήθευση διακομιστή : μεγέθη min/max, μορφή email,created_at`πρέπει να ταιριάζει με`request.time(αντί-ψεύτρεψης)

  • `user_agent`στέλνεται για πιστευτικότητα (όχι κρίσιμο αλλά χρήσιμο)

Αυτοίοι κανόνες είναι πιο στερνοί από ένα απλό`allow write: if true;`. Εμποδίζουν έναν επιτιθέμενο να εντάξει μεγάλα payloads ή κακώς διαμορφωμένα πεδία.

Φάση 2: Επαναγραφή του JavaScript υποβολής

Το συμβόλαιο είναι απλό:

  1. Διαβάστε τα δεδομένα του φόρμας

  2. Ελέγξτε το honeypot (πεδίο`hp_name`— αν είναι γεμάτο, είναι ένα bot, σοκιμάζουμε μια επιτυχία χωρίς να στείλουμε τίποτα)

  3. να καλέσετε`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…​})`

  4. Εμφάνιση επιτυχίας ή σφάλματος

Η εξάρτηση `window.FIREBASE

σε`footer.thyme`, ένα ενδιάμεσο script αρχικοποιεί το 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>

Τα σενάρια module εκτελούνται πριν`DOMContentLoaded`, άρα`window.FIREBASE`εγγυάται διαθέσιμο όταν ο χειριστής`contact.js`ενεργοποιείται. Για προφυλακτικούς λόγους, προσθέτω επίσης ένα polling 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);
});

Οι αλλαγές σε σχέση με το mock :

  • waitForFirebase()— polling με timeout, αυστηρός ακόμα και αν ο CDN είναι αργός

  • honeypot— αν το κρυφό πεδίο`hp_name`Είναι γεμάτο, να προσομοιώσει μια απολύτως επιτυχημένη λειτουργία του Firestore. Το bot νομίζει ότι دارد επιτυχία αλλά τίποτα δεν αποθηκεύεται.

  • addDoc(collection, {…​})— αληθής κλήση Firestore με`serverTimestamp()` et user_agent

  • διαχείριση σφάλματος με`try/catch`ασύγχρονος

  • Καθαρισμός του`finally`(επαναφορά του κουμπιού)

Γιατί`user_agent`; Είναι προαιρετικό, αλλά χρήσιμο για το διάγνωση. Εάν λάβετε ένα παράξενο μήνυμα, το να γνωρίζετε αν προέρχεται από έναν desktop browser, mobile, ή από ένα script 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).

Διαγράφω :

  1. Η τάξη`SupabaseManager`

  2. Η τάξη`ContactFormHandler`

  3. Το μπλοκ αρχικοποίησης στο`DOMContentLoaded`(γραμμές 645-654)

Le reste de script.js`ειναι ακατάβλητο :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.

Φάση 4 : Διαμόρφωση του υποσέλιδου

`footer.thyme`Έχει ήδη το boilerplate του Firebase αλλά με τιμές placeholders. Αντικαθιστώ :

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`τον εγχέουν στο πρότυπο μέσω λογικής που πρέπει να προστεθεί στην πλευρά του build.

Για το παρόν, τα βάζω άμεσα σε`footer.thyme`— το build`./gradlew serve`Θα τα φορτώσει τοπικά. Κατά την ανάπτυξη, θα μεταφέρω την ένθεση προς`site.yml`ή σε μια μεταβλητή Gradle.

L'`apiKey`Firebase δεν είναιδενένα μυστικό. Είναι δημόσια από σχεδιασμό. Τι προστατεύει τα δεδομένα σας, είναι τακανόνες ασφαλείας Firestore, δεν το κλειδί API. Μην το βάλετε σε ένα`.env`Φορτωμένο στο διακομιστή — είναι προορισμένο να αποκαλυφθεί στον περιηγητή.

Φάση 5 : Τελική αρχιτεκτονική

@startuml
skinparam backgroundColor #FEFEFE

title Architecture Finale — Formulaire de Contact Firebase

actor Utilisateur as USER

package "Navigateur" #E8F5E9 {
  rectangle "contact.thyme\n(HTML, honeypot)" as FORM
  rectangle "contact.js\n(validation + Firestore)" as CONT
  rectangle "footer.thyme\n(Firebase SDK init)" as FOOT
}

cloud "Firebase" #BBDEFB {
  rectangle "Firestore\n(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

Αυτός ο ιστότοπος δημιουργείται από το δικό μου plugin Gradle`bakery`. Το φόρμα επαφής βρίσκεται μέσα στον ιστότοπο. Η μεταμόνωση Supabase → Firebase είναι τεκμηριωμένη στο`AGENT.adoc`, συζητείται στο backlog, δοκιμάζεται μέσω`./gradlew serve`, και δημιουργεί ένα άρθρο ιστολογίου (το οποίο διαβάζετε).

Είναι καθαρά dogfooding. Ο ιστότοπος είναι το προϊόν του πρόσθετου, το πρόσθετο είναι το προϊόν του προγραμματιστή, ο προγραμματιστής τεκμηριώνει τη διαδικασία στον ιστότοπο ίδιο.

Ο κύκλος είναι κλεισμένος.

Το να τον έχουμε κρατήσει ως mock για μήνες (ολοκληROSές συνεδρίες όπου η φόρμα ψεύδιζε σιωπηλά) μέ μ έκανε να συνειδητοποιήσω κάτι : το backlog ενός προσωπικού στατικού ιστότοπου ποτέ δεν είναι « τελειωμένο ». Πάντα υπάρχει μια US προτεραιότητα, πάντα υπάρχει ένα άρθρο σε draft, πάντα υπάρχει μια σχολιασμένη ενότητα σε ένα πρότυπο. Η διαcipline δεν είναι να τελειώσουμε όλα — είναι να τελειώσουμε το ορατό από τον χρήστη.

Ένας σπασμένος φόρμα επαφής είναι χειρότερος από το να μην υπάρχει καθόλου φόρμα. Είναι μια επαγγελία που δεν εφαρμόστηκε.

Περίληψη των αλλαγών

αρχείο Τροποποίηση επίπτωση

blog/2026/0113_*.adoc

Δημιουργία του άρθρου

Τεκμηρίωση

assets/js/contact.js

Επαναγραφή (mock → πραγματικό Firestore)

Λειτουργικό

assets/js/script.js

Διαγραφή SupabaseManager + ContactFormHandler + init bloc

Καθαρισμός

templates/footer.thyme

αντικατάσταση config placeholder → πραγματικές τιμές

διαμόρφωση

Επόμενα βήματα (backlog)

  • Ειδοποίηση email: Μια Cloud Function`onCreate`σε`contact_messages`στέλνει ένα email μέσω SendGrid. Η φόρμα αποθηκεύει, αλλά δεν ειδοποιούμαι. Μέση προτεραιότητα — Τα μηνύματα είναι ορατά στην κονσόλα Firebase.

  • Rate limiting της πλευράς του πελάτη: Προσθέστε ένα timestamp του localStorage για να αποτρέψετε τις πολλαπλές υποβολές σε σωληναίδη. Ο honeypot αποκλείει τα απλά bot, ένας περιορισμού ρυθμού (rate limiter) θα αποκλείει τα λίγο πιο έξυπνα bot.

  • Τεστ: Ένας τεστ Playwright που υποβάλει τη φόρμα και ελέγχει ότι το εγγράφο εμφανίζεται στο Firestore. Για το παρόν, δοκιμάζω χειροκίνητα μέσω`./gradlew serve`.

Σχετικά άρθρα