tempo di lettura : 12 minutes

Per mesi, il modulo di contatto di questo sito ha girato su un mock JavaScript — una promessa di successo dell'85%, un falso Firestore, zero dati memorizzati. Il piano iniziale prevedeva un backend Supabase con Google Apps Script per le notifiche via email. Abbandonato. Oggi racconto la migrazione verso Firebase Firestore: creazione del progetto, regole di sicurezza, riscrittura del JS, pulizia del codice morto di Supabase. E perché questa scelta dice qualcosa di più ampio sulla filosofia di sviluppo.

tic

[]

La scena: un modulo che non memorizza nulla

Questo sito è generato da JBake, il mio plugin Gradle`bakery`. È 100 % statico — nessun backend, nessun database. Tranne che ho un modulo di contatto. La pagina`contact.html`esiste, il HTML è pronto (campi nome, email, telefono, oggetto, messaggio, validazione HTML5, honeypot antispam), gli stili Bootstrap sono al loro posto. Visivamente, tutto è perfetto.

Tranne che al momento dell’invio, niente succede

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

Un mock. Una promessa che fa finta. L’utente vede uno spinner, poi un messaggio « Messaggio inviato con successo! ». Ma i dati vanno nel vuoto. Nessun messaggio viene memorizzato da nessuna parte.

La situazione è peggiore di un modulo rotto — è un modulo che mente.

L’eredità Supabase

Il piano iniziale, documentato in`content/draft/integration_formulaire_contact_supabase.adoc`, prevedeva:

  1. Un database Supabase con una tabella`contacts` et Row Level Security

  2. Un RPC`handle_contact_form`lato server

  3. Un trigger SQL che chiama un webhook Google Apps Script

  4. Google Apps Script che invia una mail Gmail di notifica

Il codice JavaScript corrispondente esiste ancora in`script.js`. C’è una classe`SupabaseManager`che inizializza un client Supabase con variabili globali`SUPABASE_URL` et SUPABASE_KEY, e una classe`ContactFormHandler`che ascolta l’evento submit del modulo e chiama`SupabaseManager.submitContactForm()`.

Problema: queste variabili globali non vengono più iniettate nel piè di pagina. Il`<script src="supabase-js">`È stato rimosso. Il codice chiama`supabase.createClient()`su una variabile`supabase`che non esiste più. Quindi:

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

Non solo i dati non vengono memorizzati, ma il codice di invio è morto.

La doppia sottomissione fantasma

Per peggiorare le cose, c’è unaconcorrenza silenziosatra due handlers sullo stesso modulo :

  1. `contact.js`ascolta il submit, chiama il mock Firebase

  2. script.js— via`ContactFormHandler`— ascolta anche il submit, chiama`SupabaseManager`

Entrambi fanno`event.preventDefault() + event.stopPropagation(). Come`contact.js`è caricato per primo in`footer.thyme, il suo handler è collegato per primo. Blocca la propagazione.`ContactFormHandler`non verrà mai attivato.

Non è nemmeno un bug attivo — è un zombie. Codice che non ha mai l’occasione di eseguirsi.

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

@startuml
skinparam backgroundColor #FEFEFE

title Stato Iniziale — Modulo di Contatto
rectangle "contatto.timo
(HTML Bootstrap, trappola per api)" as FORM
^^^^^
 Syntax Error? (Assumed diagram type: activity)

@startuml
skinparam backgroundColor #FEFEFE

title Stato Iniziale — Modulo di Contatto
rectangle "contatto.timo
(HTML Bootstrap, trappola per api)" as FORM
rectangle "footer.thyme
(Firebase SDK con placeholder di configurazione)" as FOOT
rectangle "contact.js
(mock Firebase, 85% successo)" as CONT
rectangle "script.js\n(SupabaseManager + ContactFormHandler, morti)" as SCRIPT
rectangle "Utente" 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

Perché Firebase anziché Supabase?

La decisione di migrazione è documentata nella`AGENT.adoc`(No output)

Firebase è ora scelto per i seguenti motivi: piano gratuito migliore, Firestore nativo, Cloud Functions integrate, ecosistema Google più adatto. L’implementazione Supabase esistente è contrassegnata da « ⚠️ Abbandonato ».

Oltre il piano gratuito, c’è una ragione architettonica. Questo sito vive nell’ecosistema Google: il repository di destinazione è`cheroliv.github.io`, il CNAME punta su GitHub Pages, il build Gradle effettua il push su GitHub tramite JGit. Aggiungere un servizio Google (Firebase) anziché un servizio di terze parti (Supabase) riduce la superficie di dispersione.

Firestore in modalità nativa (non in modalità Datastore) è anche più vicino al modello mentale di documento NoSQL che ho in testa: raccolte, documenti, campi tipizzati, timestamp lato server, regole di sicurezza integrate.

Fase 1: Crea il progetto Firebase

Inizializzazione

La CLI Firebase non essendo installata sul mio computer, passo tramite la console web :

  1. Andare suhttps://console.firebase.google.com/[Console Firebase]

  2. Creare un progetto`cheroliv-contact`(o riutilizzare un progetto esistente)

  3. Attivare Firestore in modalità nativa (non Datastore)

  4. Creare un database nella regione`eur3`(Europa)

Per un uso minimale come il nostro (una sola collezione, scrittura pubblica), il modo nativo è la scelta giusta. Non è necessario avere regole Datastore complesse.

Regole di sicurezza Firestore

Il modulo è pubblico — chiunque può inviare un messaggio. Ma voglio limitare gli abusi :

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

Punti chiave :

  • allow read— solo gli utenti autenticati possono leggere i messaggi (io, tramite la console Firebase)

  • allow create— Chiunque può creare un documento, ma con la validazione dei campi

  • Validazione lato server : dimensioni min/max, formato email,created_at`deve corrispondere a`request.time(anti-falsificazione)

  • `user_agent`viene inviato per la tracciabilità (non critico ma utile)

Queste regole sono più rigorose di un semplice`allow write: if true;`. Impediscono a un attaccante di iniettare payload enormi o campi malformati.

Fase 2 : Riscrivere il JavaScript di invio

Il contratto è semplice:

  1. Leggere i dati del modulo

  2. Verificare l’honeypot (campo`hp_name`— se è pieno, è un bot, simuliamo un successo senza inviare nulla)

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

  4. Mostra successo o errore

La dipendenza `window.FIREBASE

in`footer.thyme`, uno script modulo inizializza l’SDK Firebase e espone un oggetto globale :

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

Gli script di modulo vengono eseguiti prima`DOMContentLoaded`, quindi`window.FIREBASE`è garantito disponibile quando il handler`contact.js`si attiva. Per precauzione, aggiungo comunque un polling di 5 secondi nel caso in cui il CDN fosse lento.

Il nuovo `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);
});

Le modifiche rispetto al mock :

  • waitForFirebase() — polling avec timeout, robuste même si le CDN est lent

  • honeypot— se il campo nascosto`hp_name`è pieno, simulare un successo senza appello Firestore. Il bot crede di aver avuto successo ma nulla viene memorizzato

  • addDoc(collection, {…​})— vera chiamata Firestore con`serverTimestamp()` et user_agent

  • Gestione dell’errore con`try/catch`asincrono

  • Pulizia del`finally`(ripristino del pulsante)

Perché`user_agent`È opzionale, ma utile per la diagnostica. Se arriva un messaggio strano, sapere se proviene da un browser desktop, mobile o da uno script curl aiuta nel triage.

Fase 3 : Pulire il codice morto Supabase

`script.js`contiene 250 linee di dead code :

  • SupabaseManager(righe 417-481) — 65 righe

  • ContactFormHandler(righe 490-551) — 62 righe

  • Blocco di inizializzazione (righe 645-654) — 10 righe

Totale: ~140 linee da eliminare.

Il blocco`DOMContentLoaded`crea un`SupabaseManager`poi un`ContactFormHandler`Allegato al modulo. Come spiegato sopra, questo codice non viene mai eseguito (bloccato da`contact.js`), e anche se venisse eseguito, fallirebbe (nessun SDK Supabase caricato).

Elimino:

  1. La classe`SupabaseManager`

  2. La classe`ContactFormHandler`

  3. Il blocco di inizializzazione in`DOMContentLoaded`(righe 645-654)

Le reste de script.js# JBake CLI Commands ` # Initialize a new JBake project jbake -i

Bake (generate) the site jbake -b

Bake and serve locally jbake -b -s

Bake and watch for changes jbake -b --reset

Specify source and destination jbake source_folder output_folder

Clear the output directory before baking jbake -b . output --reset ` è intatto :`ThemeManager`, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.

Phase 4 : Configurer il piè di pagina

`footer.thyme`ha già il boilerplate Firebase ma con i valori placeholder. Io sostituisco :

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

Dai valori reali recuperati daImpostazioni del progetto > Generale > Le tue app > App webnella console Firebase

I valori sono sensibili`apiKey`è pubblica di default presso Firebase, ma preferisco non committarli in chiaro). Li salvo in`site.yml`(già in`.gitignore`) et le plugin `bakery`li iniettano nel template tramite una logica da aggiungere lato build

Per ora, li metto direttamente dentro`footer.thyme`# Integrazione Gradle JBake può essere integrato nelle build Gradle utilizzando il plugin JBake per Gradle o chiamando direttamente l’interfaccia a riga di comando di JBake:

----
tasks.register<JavaExec>("bake") {
    mainClass.set("org.jbake.launcher.Main")
    classpath = configurations["jbake"]
    args = listOf(projectDir.absolutePath, "$buildDir/jbake")
}`./gradlew serve`li caricherà localmente. Durante il deployment, migrerò l'iniezione verso`site.yml`o verso una variabile Gradle.

[WARNING]
====
L'`apiKey`Firebase non è**passo
paso**un segreto. È pubblica per progettazione. Ciò che protegge i tuoi dati, sono i**regole di sicurezza Firestore**, non la chiave API. Non metterla in un`.env`caricato lato server — è destinata a essere esposta al navigatore
====

== Fase 5 : Architettura finale

[plantuml, format=svg, id=diag-after, alt="Architecture finale — Firebase Firestore"]
----
@startuml
skinparam backgroundColor #FEFEFE

title Architettura Finale — Modulo di Contatto Firebase
actor Utilisateur as USER

package "Navigatore" #E8F5E9 {
  rectangle "contact.thyme\n(HTML, honeypot)" as FORM
  rectangle "contact.js\n(validazione + 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
----

== Cosa dice questa migrazione sul dogfooding

Questo sito è generato dal mio plugin Gradle personale`bakery`. Il modulo di contatto si trova all'interno del sito. La migrazione Supabase → Firebase è documentata in`AGENT.adoc`, è discussa nel backlog, viene testata via`./gradlew serve`, e genera un articolo di blog (quello che stai leggendo).

È puro dogfooding. Il sito è il prodotto del plugin, il plugin è il prodotto dello sviluppatore, lo sviluppatore documenta il processo nel sito stesso.

Il cerchio è chiuso.

Avere trascinato un mock per mesi (sessioni intere in cui il modulo mentiva silenziosamente) mi ha fatto realizzare qualcosa: il backlog di un sito statico personale non è mai « finito ». C'è sempre una US prioritaria, sempre un articolo in bozza, sempre una sezione commentata in un template. La disciplina non consiste nel finire tutto — consiste nel finire ciò che è visibile dall'utente.

Un modulo di contatto rotto, è peggio di nessun modulo affatto. È una promessa non mantenuta.

=== Riepilogo delle modifiche

|===
|File |modificazione |Impatto |`blog/2026/0113_*.adoc` |Creazione dell'articolo |Documentazione |`assets/js/contact.js` |Riscrittura (mock → Firestore reale) |Funzionale |`assets/js/script.js` |Rimozione SupabaseManager + ContactFormHandler + init bloc |Pulizia |`templates/footer.thyme` |Sostituzione segnaposto di configurazione → valori reali |Configuration
|===

== Passi successivi (backlog)

* **Email di notifica**: Una Cloud Function`onCreate`su`contact_messages`che invia una mail tramite SendGrid. Il modulo salva, ma non ricevo notifiche. Priorità media — i messaggi sono visibili nella console Firebase.
* **Rate limiting côté client**Aggiungere un timestamp localStorage per impedire le invii multipli a raffica. Il honeypot blocca i bot ingenui, un rate limiter bloccherebbe i bot un po' più furbi.
* **Test**Un test Playwright che invia il modulo e verifica che il documento appaia in Firestore. Per ora, lo testo manualmente via`./gradlew serve`.

== Riferimenti

* https://firebase.google.com/docs/firestore/security/get-started[Documentazione delle regole di sicurezza di Firestore]
* https://firebase.google.com/docs/firestore/manage-data/add-data#add_a_document[Documentazione Firestore addDoc — Web v9 modulare]
* link:/blog/2026/0108_gouvernance_agent_opencode_eager_lazy_post.html[Serie governance agent — Parte 1 : Eager/Lazy]
* link:/blog/2026/0110_mecanisme_backup_contexte_agent_post.html[Serie governance agente — Parte 2 : Hot/Warm/Cold]
* link:/blog/2026/0111_audit_contexte_agent_post.html[Serie governance agent — Parte 3 : Audit]
----

Articoli correlati