Lesezeit : 12 minutes

Monatelang lief das Kontaktformular dieser Seite auf einem JavaScript-Mock — eine Erfolgsversprechen von 85 %, ein gefälschter Firestore, null gespeicherte Daten. Der ursprüngliche Plan sah einen Supabase-Backend mit Google Apps Script für E‑Mail‑Benachrichtigungen vor. Verworfen. Heute erzähle ich von der Migration zu Firebase Firestore: Projektanlegen, Sicherheitsregeln, JS neu schreiben, Aufräumen des toten Supabase‑Codes. Und warum diese Wahl etwas über die Entwicklungsphilosophie aussagt.

tok

[]

Die Szene: ein Formular, das nichts speichert

Diese Seite wird von JBake, meinem Gradle-Plugin generiert`bakery`. Es ist zu 100 % statisch — kein Backend, keine Datenbank. Aber ich habe ein Kontaktformular. Die Seite`contact.html`existiert, das HTML ist bereit (Felder Name, E‑Mail, Telefon, Betreff, Nachricht, HTML5-Validierung, Anti-Spam-Honeypot), die Bootstrap-Stile sind vorhanden. Optisch ist alles perfekt.

Aber bei der Einreichung passiert nichts.

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

Ein Mock. Ein Versprechen, das nur vorgetäuscht ist. Der Nutzer sieht einen Spinner, dann die Nachricht « Nachricht erfolgreich gesendet! ». Aber die Daten gehen ins Nichts. Keine Nachricht wird irgendwo gespeichert.

Die Situation ist schlimmer als ein kaputtes Formular — es ist ein Formular, das lügt.

Das Erbe von Supabase

Der initiale Plan, dokumentiert in`content/draft/integration_formulaire_contact_supabase.adoc`, vorsah :

  1. Eine Supabase-Datenbank mit Tabelle`contacts`und Row Level Security

  2. Eine RPC`handle_contact_form`Serverseite

  3. Ein SQL-Trigger, der einen Google Apps Script-Webhook aufruft

  4. Google Apps Script, der eine Gmail-Benachrichtigungs-E-Mail sendet

Der entsprechende JavaScript-Code existiert noch in`script.js`. Es gibt eine Klasse`SupabaseManager`der einen Supabase-Client mit globalen Variablen initialisiert`SUPABASE_URL` et SUPABASE_KEY, und eine Klasse`ContactFormHandler`der das Submit-Ereignis des Formulars abhört und aufruft`SupabaseManager.submitContactForm()`.

Problem: Diese globalen Variablen werden nicht mehr in den Footer eingefügt. Der`<script src="supabase-js">`wurde entfernt. Der Code ruft`supabase.createClient()`über eine Variable`supabase`nicht mehr existiert. Also:

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

Nicht nur werden die Daten nicht gespeichert, sondern der Einreichungscode ist tot.

Die doppelte Phantom-Einreichung

Um es noch schlimmer zu machen, gibt es einestille Konkurrenzzwischen zwei Handlern auf demselben Formular :

  1. `contact.js`Höre auf das Submit, rufe den Firebase-Mock auf.

  2. script.js— über`ContactFormHandler`— hör auch das submit, ruf`SupabaseManager`

Beide machen`event.preventDefault() + event.stopPropagation(). Wie`contact.js`wird zuerst in`footer.thyme, sein Handler wird zuerst angehängt. Er blockiert die Ausbreitung.`ContactFormHandler`wird niemals ausgelöst.

Es ist sogar kein aktiver Bug — es ist ein Zombie. Code, der nie die Gelegenheit hat, ausgeführt zu werden.

Failed to generate image: PlantUML preprocessing failed: [From <input> (line 8) ]

@startuml
skinparam backgroundColor #FEFEFE

title Initialzustand — Kontaktformular
rectangle "contact.thyme\n(HTML Bootstrap, honeypot)" as FORM
rectangle "footer.thyme\n(Firebase SDK mit Platzhalterkonfiguration)" as FOOT
rectangle "contact.js
(mock Firebase, 85% Erfolg)" as CONT
^^^^^
 Syntax Error? (Assumed diagram type: activity)

@startuml
skinparam backgroundColor #FEFEFE

title Initialzustand — Kontaktformular
rectangle "contact.thyme\n(HTML Bootstrap, honeypot)" as FORM
rectangle "footer.thyme\n(Firebase SDK mit Platzhalterkonfiguration)" as FOOT
rectangle "contact.js
(mock Firebase, 85% Erfolg)" as CONT
rectangle "script.js\n(SupabaseManager + ContactFormHandler, tote)" as SCRIPT
rectangle "Benutzer" 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

Warum Firebase statt Supabase?

Die Migrationsentscheidung ist dokumentiert in`AGENT.adoc`:

Firebase wird nun aus folgenden Gründen gewählt: besserer kostenloser Plan, nativer Firestore, integrierte Cloud Functions, besser geeignetes Google-Ökosystem. Die vorhandene Supabase-Implementierung ist mit « ⚠️ veraltet » gekennzeichnet.

Über das kostenlose Angebot hinaus gibt es einen architektonischen Grund. Diese Website lebt im Google-Ökosystem: Das Ziel-Repository ist`cheroliv.github.io`, der CNAME zeigt auf GitHub Pages, der Gradle-Build pusht zu GitHub über JGit. Das Hinzufügen eines Google-Dienstes (Firebase) anstelle eines Drittanbieter-Dienstes (Supabase) verringert die Angriffsfläche.

Firestore im nativen Modus (nicht im Datastore-Modus) ist ebenfalls näher an dem NoSQL-Dokumentmodell, das ich im Kopf habe: Sammlungen, Dokumente, typisierte Felder, Server-Zeitstempel, integrierte Sicherheitsregeln.

Phase 1 : Firebase-Projekt erstellen

Initialisierung

Da die Firebase CLI auf meinem Rechner nicht installiert ist, verwende ich die Webkonsole:

  1. aufgehenhttps://console.firebase.google.com/[Firebase-Konsole]

  2. Ein Projekt erstellen`cheroliv-contact`(oder ein bestehendes Projekt wiederverwenden)

  3. Firestore im nativen Modus (nicht Datastore) aktivieren

  4. Eine Datenbank in der Region erstellen`eur3`(Europa)

Für einen minimalistischen Gebrauch wie unseren (einziges Sammeln, öffentliches Schreiben) ist der native Modus die richtige Wahl. Es sind keine komplexen Datastore-Regeln nötig.

Firestore-Sicherheitsregeln

Das Formular ist öffentlich — jeder kann eine Nachricht senden. Aber ich möchte den Missbrauch beschränken :

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

Schlüsselpunkte :

  • allow read— nur authentifizierte Benutzer können die Nachrichten lesen (ich über die Firebase-Konsole)

  • allow create— jeder kann ein Dokument erstellen, aber mit Feldvalidierung

  • Serverseitige Validierung: Min/Max-Größen, E-Mail-Format,created_at`muss entsprechen zu`request.time(anti-fälschung)

  • `user_agent`Es wird zur Nachverfolgung gesendet (nicht kritisch aber nützlich)

Diese Regeln sind strenger als ein einfaches`allow write: if true;`. Sie verhindern, dass ein Angreifer riesige Payloads oder malformierte Felder einfügt.

Phase 2: Submit-JavaScript umschreiben

Der Vertrag ist einfach:

  1. Die Daten des Formulars lesen

  2. Überprüfe das Honeypot (Feld`hp_name`— wenn es gefüllt ist, ist es ein Bot, wir simulieren einen Erfolg, ohne etwas zu senden)

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

  4. Erfolg oder Fehler anzeigen

Die Abhängigkeit `window.FIREBASE

in`footer.thyme`, ein Skriptmodul initialisiert das Firebase SDK und macht ein globales Objekt verfügbar :

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

Modulskripte werden vorher ausgeführt`DOMContentLoaded`, also`window.FIREBASE`ist garantiert verfügbar wenn der handler`contact.js`es wird ausgelöst. Aus Vorsicht füge ich trotzdem ein Polling von 5 Sekunden hinzu, falls der CDN langsam wäre.

Der neue `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);
});

Die Änderungen im Vergleich zum Mock:

  • waitForFirebase()— Polling mit Timeout, auch robust, wenn das CDN langsam ist

  • honeypot— wenn das versteckte Feld`hp_name`ist gefüllt, simuliere einen uneingeschränkten Firestore-Erfolg. Der Bot glaubt, erfolgreich gewesen zu sein, aber nichts wird gespeichert.

  • addDoc(collection, {…​})— echter Firestore-Aufruf mit`serverTimestamp()` et user_agent

  • Fehlerbehandlung mit`try/catch`asynchron

  • Reinigung des`finally`(Wiederherstellung des Knopfes)

Warum`user_agent`? Es ist optional, aber nützlich für die Diagnose. Wenn eine seltsame Nachricht ankommt, zu wissen, ob sie von einem Desktop-Browser, einem mobilen Browser oder einem curl-Skript stammt, hilft bei der Sortierung.

Phase 3: Den toten Code in Supabase bereinigen

`script.js`enthält 250 Zeilen dead code :

  • SupabaseManager(Zeilen 417-481) — 65 Zeilen

  • ContactFormHandler(Zeilen 490-551) — 62 Zeilen

  • Initialisierungsblock (Zeilen 645-654) — 10 Zeilen

Gesamt: ~140 Zeilen zu löschen.

Der Block`DOMContentLoaded`erstellt ein`SupabaseManager`dann ein`ContactFormHandler`an das Formular angehängt. Wie oben erklärt, wird dieser Code niemals ausgeführt (blockiert durch`contact.js`), und selbst wenn es ausgeführt würde, würde es fehlschlagen (kein Supabase-SDK geladen).

Ich lösche :

  1. Die Klasse`SupabaseManager`

  2. Die Klasse`ContactFormHandler`

  3. Der Initialisierungsblock in`DOMContentLoaded`(Zeilen 645-654)

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

Phase 4: Fußzeile konfigurieren

`footer.thyme`Er hat bereits das Firebase-Boilerplate, aber mit Platzhalterwerten. Ich ersetze:

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

Durch die seit abgerufenen tatsächlichen WerteProjekteinstellungen > Allgemein > Deine Apps > Web-Appin der Firebase‑Konsole.

Die Werte sind sensibel.apiKey`ist bei Firebase von Haus aus öffentlich, aber ich ziehe es vor, sie nicht im Klartext zu committten). Ich speichere sie in`site.yml(bereits in`.gitignore`) und das Plugin`bakery`Er injiziert sie in das Template mithilfe einer Logik, die auf der Build-Seite hinzugefügt werden soll.

Im Moment lege ich sie direkt hinein.footer.thyme— der Build`./gradlew serve`Es wird sie lokal laden. Bei der Bereitstellung werde ich die Injektion nach`site.yml`oder zu einer Gradle-Variable.

L'`apiKey`Firebase ist nichtnichtein Geheimnis. Sie ist von Natur aus öffentlich. Was Ihre Daten schützt, sind dieFirestore-Sicherheitsregeln, nicht den API-Schlüssel. Legen Sie es nicht in ein`.env`Serverseitig geladen — sie ist dafür vorgesehen, im Browser angezeigt zu werden.

Phase 5: Finale Architektur

Failed to generate image: PlantUML preprocessing failed: [From <input> (line 8) ]

@startuml
skinparam backgroundColor #FEFEFE

title Finale Architektur — Kontaktformular Firebase
actor Utilisateur as USER

package "Browser" #E8F5E9 {
  rectangle "contact.thyme
^^^^^
 Syntax Error? (Assumed diagram type: component)

@startuml
skinparam backgroundColor #FEFEFE

title Finale Architektur — Kontaktformular Firebase
actor Utilisateur as USER

package "Browser" #E8F5E9 {
  rectangle "contact.thyme
(HTML, honeypot)" as FORM
  rectangle "contact.js\n(Validierung + Firestore)" as CONT
  rectangle "Footer.Thyme
(Initialisierung des Firebase SDK)" as FOOT
}

cloud "Firebase" #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

Was diese Migration über Dogfooding sagt

Diese Website wird von meinem eigenen Gradle-Plugin generiert`bakery`. Das Kontaktformular befindet sich innerhalb der Website. Die Migration Supabase → Firebase wird in`AGENT.adoc`, sie wird im Backlog diskutiert, sie wird via getestet`./gradlew serve`, und sie generiert einen Blogartikel (den du gerade liest).

Das ist reines Dogfooding. Die Website ist das Produkt des Plugins, das Plugin ist das Produkt des Entwicklers, der Entwickler dokumentiert den Prozess auf der Website selbst.

Der Kreis ist geschlossen.

Dass ich ein Mock monatelang mit mir herumgeschleppt habe (ganze Sitzungen, in denen das Formular schweigend gelogen hat), hat mir bewusst gemacht: Das Backlog einer persönlichen statischen Website ist niemals „fertig“. Es gibt immer eine priorisierte Benutzerstory, immer einen Artikel im Entwurf und immer einen kommentierten Abschnitt in einer Vorlage. Disziplin bedeutet nicht, alles fertigzustellen – sondern das zu vollenden, was für den Nutzer sichtbar ist.

Ein kaputtes Kontaktformular ist schlimmer als überhaupt kein Kontaktformular. Das ist ein nicht gehaltenes Versprechen.

Zusammenfassung der Änderungen

Datei

Modifikation

Auswirkung

blog/2026/0113_*.adoc

Erstellung des Artikels

Dokumentation

assets/js/contact.js

Umschreibung (mock → echter Firestore)

Funktionsfähig

assets/js/script.js

Löschen SupabaseManager + ContactFormHandler + init Block

Reinigung

templates/footer.thyme

Ersetzung des Konfigurationsplatzhalters → echte Werte

Konfiguration

Nächste Schritte (backlog)

  • Benachrichtigungs-Email: Eine Cloud Function`onCreate`auf`contact_messages`der eine E-Mail über SendGrid sendet. Das Formular speichert, aber ich werde nicht benachrichtigt. Mittlere Priorität — die Nachrichten sind in der Firebase-Konsole sichtbar.

  • Rate Limiting auf der ClientseiteFüge einen localStorage-Zeitstempel hinzu, um mehrfache schnelle Sendungen zu verhindern. Der Honeypot blockiert naivere Bots, ein Rate Limiter würde etwas cleverere Bots blockieren.

  • Tests: Ein Playwright-Test, der das Formular absendet und überprüft, dass das Dokument in Firestore erscheint. Im Moment teste ich manuell über`./gradlew serve`.

Verwandte Artikel