leestijd : 12 minutes

Tijdens maanden draaide het contactformulier van deze site op een mock JavaScript — een belofte van 85% succes, een neppe Firestore, geen opgeslagen data. Het oorspronkelijke plan voorzag een Supabase-backend met Google Apps Script voor e-mailnotificaties. Verlaten. Vandaag vertel ik over de migratie naar Firebase Firestore: aanmaken van het project, beveiligingsregels, herschrijven van de JS, opruimen van de dode Supabase-code. En waarom deze keuze iets zegt over een bredere ontwikkelingsfilosofie.

De scène: een formulier dat niets opslaat

Deze site wordt gegenereerd door JBake, mijn Gradle-plugin`bakery`. Het is 100 % statisch — geen backend, geen database. Behalve dat ik een contactformulier heb. De pagina`contact.html`bestaat, de HTML is klaar (velden naam, e-mail, telefoon, onderwerp, bericht, validatie HTML5, honeypot anti-spam), de Bootstrap-stijlen zijn klaar. Visueel, alles is perfect.

Behalve bij de indiening gebeurt er niets.

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);
});

Een mock. Een belofte die doet alsof. De gebruiker ziet een spinner, dan een bericht « Bericht verzonden met succes! ». Maar de gegevens verdwijnen in het niets. Geen bericht wordt ergens opgeslagen.

De situatie is erger dan een gebroken formulier — het is een formulier dat liegt.

Erfgoed Supabase

Het oorspronkelijke plan, gedocumenteerd in`content/draft/integration_formulaire_contact_supabase.adoc`, voorzag :

  1. Een Supabase-database met tabel`contacts`en Row Level Security

  2. Een RPC`handle_contact_form`serverzijde

  3. Een SQL-trigger die een webhook Google Apps Script aanroept

  4. Google Apps Script dat een Gmail-meldingsmail verzendt

De corresponderende JavaScript-code bestaat nog steeds in`script.js`. Er is een klas`SupabaseManager`die een Supabase-client initialiseert met globale variabelen`SUPABASE_URL` et SUPABASE_KEY, en een klas`ContactFormHandler`die het submit-gebeurtenis van het formulier luistert en aanroept`SupabaseManager.submitContactForm()`.

Probleem : deze globale variabelen worden niet langer in de footer geïnjecteerd. Het`<script src="supabase-js">`is verwijderd. De code roept`supabase.createClient()`op een variabele`supabase`die niet meer bestaat. Daarom:

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

Niet alleen worden de gegevens niet opgeslagen, maar de indiende code is dood.

Dubbele spookinzending

Om dingen erger te maken, is er eenstille concurrentietussen twee handlers op hetzelfde formulier:

  1. `contact.js`luister naar de submit, roep de mock Firebase aan

  2. script.js— via`ContactFormHandler`— luister ook naar het submit, bel`SupabaseManager`

Beiden doen`event.preventDefault() + event.stopPropagation(). Als`contact.js`wordt eerst geladen in`footer.thyme, zijn handler wordt als eerste gekoppeld. Het blokkeert de verspreiding.`ContactFormHandler`zal nooit worden geactiveerd.

Dit is zelfs geen actieve bug — het is een zombie. Code dat nooit de kans krijgt om uitgevoerd te worden.

@startuml
skinparam backgroundColor #FEFEFE

title Initiele staat — Contactformulier
rectangle "contact.thyme\n(HTML Bootstrap, honeypot)" as FORM
rectangle "footer.thyme\n(Firebase SDK met config placeholder)" as FOOT
rectangle "contact.js\n(mock Firebase, 85% succes)" as CONT
rectangle "script.js\n(SupabaseManager + ContactFormHandler, doden)" as SCRIPT
rectangle "Gebruiker" as USER

USER -> FORM : Indienen van het formulier
FORM -> CONT : Verzend gebeurtenis (eerst bijgevoegd)
CONT -> CONT : preventDefault + stopPropagation
CONT -> CONT : mock Promise (1,5s, willekeurig)
note right of CONT
Geen gegevens opgeslagen
end note

FORM ..> SCRIPT : submit-event (geblokkeerd door stopPropagation)
note right of SCRIPT : nooit geactiveerd
note right of SCRIPT : ❌ supabase-js niet geladen

@enduml

Waarom Firebase in plaats van Supabase?

De migratiebeslissing is gedocumenteerd in`AGENT.adoc`:

Firebase wordt nu gekozen om de volgende redenen: beste gratis plan, native Firestore, ingeintegreerde Cloud Functions, beter geschikt Google-ecosysteem. De bestaande Supabase-implementatie is gemarkeerd « ⚠️ Afgebroken ».

Buiten het gratis plan is er een architectonische reden. Deze site leeft in het Google-ecosysteem: het doelrepository is`cheroliv.github.io`, de CNAME verwijst naar GitHub Pages, de Gradle-build pusht naar GitHub via JGit. Het toevoegen van een Google-service (Firebase) in plaats van een derdepartijdienst (Supabase) vermindert de aanvalsoppervlakte.

Firestore in natieve modus (niet in Datastore-modus) is ook dichter bij het mentale NoSQL-documentmodel dat ik in gedachten heb: collecties, documenten, getypeerde velden, servertijdstempels, ingebouwde beveiligingsregels.

Fase 1: Maak het Firebase-project

Initialisatie

Omdat de Firebase CLI niet op mijn machine is geïnstalleerd, maak ik gebruik van de webconsole:

  1. Ga naarhttps://console.firebase.google.com/[Firebase Console]

  2. Een project maken`cheroliv-contact`(of een bestaand project hergebruiken)

  3. Activeer Firestore in de native modus (niet Datastore)

  4. Maak een database in de regio`eur3`(Europe)

Voor een minimalistisch gebruik zoals bij ons (één collectie, openbare schrijftoegang), is de native modus de juiste keuze. Geen behoefte aan complexe Datastore-regels.

Firestore-beveiligingsregels

Het formulier is openbaar — iedereen kan een bericht versturen. Maar ik wil misbruik beperken:

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;
    }
  }
}

Belangrijke punten :

  • allow read— alleen geauthenticeerde gebruikers kunnen de berichten lezen (ik, via de Firebase-console)

  • allow create— iedereen kan een document maken, maar met validatie van de velden

  • Server-side validatie: minimale/maximale grootte, e‑mailindeling,created_at`moet overeenkomen met`request.time(anti-falsificatie)

  • `user_agent`wordt verzonden voor traceerbaarheid (niet kritisch maar nuttig)

Deze regels zijn strenger dan een simpel`allow write: if true;`. Ze voorkomen dat een aanvaller enorme payloads of verkeerd gevormde velden injecteert.

Fase 2: Herschrijf de JavaScript voor indienen

Het contract is eenvoudig:

  1. Lees de gegevens van het formulier

  2. Controleer het honeypot (veld`hp_name`— als het is ingevuld, is het een bot, we simuleren een succes zonder iets te verzenden)

  3. Bellen`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…​})`

  4. Toon succes of fout

De afhankelijkheid `window.FIREBASE

In`footer.thyme`, een script module initialiseert Firebase SDK en exposeert een globaal object :

<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>

De module-scripts worden uitgevoerd vóór`DOMContentLoaded`, dus`window.FIREBASE`is gegarandeerd beschikbaar wanneer de handler`contact.js`wordt geactiveerd. Als voorzorgsmaatregel voeg ik echter nog een polling van 5 seconden toe, voor het geval de CDN traag zou zijn.

Het nieuwe `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);
});

De wijzigingen ten opzichte van de mock:

  • waitForFirebase()— polling met timeout, robuust zelfs als de CDN traag is

  • honeypot— als het verborgen veld`hp_name`is gevuld, een succes zonder beroep Firestore simuleren. De bot denkt dat het geslaagd is maar er wordt niets opgeslagen.

  • addDoc(collection, {…​})— echte Firestore-aanroep met`serverTimestamp()` et user_agent

  • Foutbeheer met`try/catch`asynchroon

  • Schoonmaak van de`finally`herstel van de knop

Waarom`user_agent`? Het is optioneel, maar handig voor diagnose. Als een vreemd bericht binnenkomt, helpt het weten of het van een desktopbrowser, mobiele browser of een curl‑script komt bij het sorteren.

Fase 3: dode code opschonen Supabase

`script.js`bevat 250 regels dode code :

  • SupabaseManager(regels 417-481) — 65 regels

  • ContactFormHandler(regels 490-551) — 62 regels

  • Initialisatieblok (regels 645-654) — 10 regels

Totaal : ~140 regels te verwijderen.

het blok`DOMContentLoaded`maakt een`SupabaseManager`dan een`ContactFormHandler`Aangehecht aan het formulier. Zoals hierboven uitgelegd, wordt deze code nooit uitgevoerd (geblokkeerd door`contact.js`), en zelfs als het zou worden uitgevoerd, het zou falen (geen Supabase SDK geladen).

Ik verwijder :

  1. De klas`SupabaseManager`

  2. De klas`ContactFormHandler`

  3. Het initialisatieblok in`DOMContentLoaded`(regels 645-654)

Le reste de script.js`is intact :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.

`footer.thyme`Heeft al een Firebase-boilerplate, maar met placeholderwaarden. Ik vervang:

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"
};

Met de werkelijke waarden die sinds zijn opgehaaldProjectinstellingen > Algemeen > Jouw apps > Webappin de Firebase-console.

De waarden zijn gevoelig (apiKey`is publiek by design bij Firebase, maar ik prefereer ze niet in clear text te committen). Ik sla ze op in`site.yml(al in`.gitignore`) en de plugin`bakery`Hij/zij injecteert ze in de sjabloon via een logica die aan de build‑zijde moet worden toegevoegd.

Voorlopig zet ik ze rechtstreeks in`footer.thyme`— de build`./gradlew serve`Het zal ze lokaal laden. Bij implementatie zal ik de injectie migreren naar`site.yml`of naar een Gradle-variabele.

L'`apiKey`Firebase is nietnieteen geheim. Ze is openbaar door ontwerp. Wat uw gegevens beschermt, zijn deFirestore-beveiligingsregels, niet de API-sleutel. Plaats het niet in een`.env`Serverzijde geladen — het is bedoeld om aan de browser te worden blootgesteld.

Phase 5 : finale architectuur

@startuml
skinparam backgroundColor #FEFEFE

title Finale architectuur — Contactformulier Firebase
actor Utilisateur as USER

package "browser" #E8F5E9 {
  rectangle "contact.thyme\n(HTML, honeypot)" as FORM
  rectangle "contact.js\n(validatie + 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 : Plaats document\n(gevalideerde veiligheidsregels)

note right of FS
Firestore Regels:
- maken: openbaar, gevalideerd
- lezen: alleen auth
end note

@enduml

Wat deze migratie zegt over dogfooding

Deze site wordt gegenereerd door mijn eigen Gradle-plugin`bakery`Het contactformulier bevindt zich binnen de site. De migratie van Supabase → Firebase wordt gedocumenteerd in`AGENT.adoc`, zij wordt besproken in de backlog, zij wordt getest via`./gradlew serve`, en zij genereert een blogartikel (dat je nu leest).

Dit is pure dogfooding. De site is het product van de plug‑in, de plug‑in is het product van de ontwikkelaar, de ontwikkelaar documenteert het proces op de site zelf.

De lus is gesloten.

Het feit dat ik een mock maandenlang heb meegesleept (hele sessies waarin het formulier stilzwijgend loog) heeft me iets doen beseffen: de backlog van een persoonlijke statische site is nooit « af ». Er is altijd een prioritaire US, altijd een conceptartikel, altijd een commentaarsectie in een sjabloon. Discipline is niet om alles af te maken — het is om af te maken wat zichtbaar is voor de gebruiker.

Een kapot contactformulier is erger dan helemaal geen formulier. Het is een niet nagekomen belofte.

Overzicht van de wijzigingen

bestand Wijziging Impact

blog/2026/0113_*.adoc

Aanmaken van het artikel

Documentatie

assets/js/contact.js

Herschrijven (mock → echte Firestore)

Functioneel

assets/js/script.js

Verwijdering SupabaseManager + ContactFormHandler + init bloc

reiniging

templates/footer.thyme

Vervanging config placeholder → werkelijke waarden

Configuratie

Volgende stappen (backlog)

  • Meldingse-mail: Een Cloud Function`onCreate`op`contact_messages`die een e-mail verzendt via SendGrid. Het formulier wordt opgeslagen, maar ik word niet op de hoogte gebracht. Gemiddelde prioriteit — de berichten zijn zichtbaar in de Firebase-console.

  • Rate limiting aan de clientzijde: Voeg een localStorage-timestamp toe om meerdere indienen in korte tijd te voorkomen. De honeypot blokkeert naïeve bots, een rate limiter zou iets slimmere bots blokkeren.

  • Tests: Een Playwright-test die het formulier indient en controleert of het document verschijnt in Firestore. Voor nu test ik dit handmatig via`./gradlew serve`.

Gerelateerde artikelen