tempo de leitura : 12 minutes

Durante meses, o formulário de contato deste site rodou em um mock JavaScript — uma promessa de 85% de sucesso, um Firestore falso, zero dados armazenados. O plano inicial previa um backend Supabase com Google Apps Script para as notificações por e‑mail. Abandonado. Hoje, eu conto a migração para o Firebase Firestore: criação do projeto, regras de segurança, reescrita do JS, limpeza do código morto Supabase. E por que essa escolha diz algo maior sobre a filosofia de desenvolvimento.

toc

[]

A cena : um formulário que não armazena nada

Este site é gerado pelo JBake, meu plugin Gradle`bakery`. É 100% estático—sem backend, sem banco de dados. Exceto que eu tenho um formulário de contato. A página`contact.html`existe, o HTML está pronto (campos nome, e-mail, telefone, assunto, mensagem, validação HTML5, honeypot anti-spam), os estilos Bootstrap estão no lugar. Visualmente, tudo está perfeito.

Exceto que na submissão, nada acontece.

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

Um mock. Uma promessa que finge. O utilizador vê um spinner, depois uma mensagem « Mensagem enviada com sucesso! ». Mas os dados vão para o vazio. Nenhuma mensagem é armazenada em lugar nenhum.

A situação é pior do que um formulário quebrado — é um formulário que mente.

O legado Supabase

O plano inicial, documentado em`content/draft/integration_formulaire_contact_supabase.adoc`, previa :

  1. Uma base Supabase com tabela`contacts`e Row Level Security

  2. Uma RPC`handle_contact_form`lado do servidor

  3. Um trigger SQL chamando um webhook Google Apps Script

  4. Google Apps Script que envia um e-mail Gmail de notificação

O código JavaScript correspondente ainda existe em`script.js`. Há uma classe`SupabaseManager`que inicializa um cliente Supabase com variáveis globais`SUPABASE_URL` et SUPABASE_KEY, e uma classe`ContactFormHandler`quem ouve o evento submit do formulário e chama`SupabaseManager.submitContactForm()`.

Problema: Essas variáveis globais não são mais injetadas no rodapé. O`<script src="supabase-js">`foi removido. O código chama`supabase.createClient()`em uma variável`supabase`que não existe mais. Portanto:

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

Não apenas os dados não são armazenados, mas o código de submissão está morto.

A submissão dupla fantasma

Para piorar as coisas, há umaconcorrência silenciosaentre dois handlers no mesmo formulário :

  1. `contact.js`Ouça o submit, chame o mock Firebase

  2. script.js— via`ContactFormHandler`— escuta também o submit, chama`SupabaseManager`

Os dois fazem`event.preventDefault() + event.stopPropagation(). Como`contact.js`é carregado primeiro em`footer.thyme, seu handler é anexado primeiro. Ele bloqueia a propagação.`ContactFormHandler`nunca será acionado.

Não é nem mesmo um bug ativo — é um zumbi. Código que nunca tem a oportunidade de ser executado.

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

@startuml
skinparam backgroundColor #FEFEFE

title Estado Inicial — Formulário de Contato
rectangle "contact.thyme
(HTML Bootstrap, honeypot)" as FORM
^^^^^
 Syntax Error? (Assumed diagram type: activity)

@startuml
skinparam backgroundColor #FEFEFE

title Estado Inicial — Formulário de Contato
rectangle "contact.thyme
(HTML Bootstrap, honeypot)" as FORM
rectangle "footer.thyme\n(Firebase SDK com placeholder de config)" as FOOT
rectangle "contact.js\n(mock Firebase, 85% de sucesso)" as CONT
rectangle "script.js\n(SupabaseManager + ContactFormHandler, mortos)" as SCRIPT
rectangle "Usuário" 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

Por que Firebase em vez de Supabase?

A decisão de migração é documentada em`AGENT.adoc`</think> (nothing)

Firebase agora é escolhido pelos seguintes motivos: melhor plano gratuito, Firestore nativo, Cloud Functions integradas, ecossistema Google mais adequado. A implementação Supabase existente está marcada « ⚠️ Abandonado ».

Além do plano gratuito, há uma razão arquitetônica. Este site vive no ecossistema do Google: o repositório alvo é`cheroliv.github.io`, o CNAME aponta para o GitHub Pages, o build Gradle faz push no GitHub via JGit. Adicionar um serviço Google (Firebase) em vez de um serviço terceirizado (Supabase) reduz a superfície de dispersão.

Firestore no modo nativo (não no modo Datastore) também está mais próximo do modelo mental de documento NoSQL que eu tenho em mente: coleções, documentos, campos tipados, timestamps do servidor, regras de segurança integradas.

Fase 1: Criar o projeto Firebase

Inicialização

Como a CLI do Firebase não está instalada na minha máquina, passo a usar o console da web:

  1. Ir parahttps://console.firebase.google.com/[Firebase Console]

  2. Criar um projeto`cheroliv-contact`(ou reutilizar um projeto existente)

  3. Ativar Firestore no modo nativo (não Datastore)

  4. Criar um banco de dados na região`eur3`(Europe)

Para um uso minimalista como o nosso (uma única coleção, gravação pública), o modo nativo é a escolha certa. Não é necessário ter regras Datastore complexas.

Regras de segurança Firestore

O formulário é público — qualquer pessoa pode enviar uma mensagem. Mas eu quero limitar os abusos:

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

Pontos-chave:

  • allow read— somente os usuários autenticados podem ler as mensagens (eu, via o console Firebase)

  • allow create— qualquer pessoa pode criar um documento, mas com validação dos campos

  • Validação do lado do servidor: tamanhos mínimo/máximo, formato de e-mail,created_at`deve corresponder a`request.time(anti-falsificação)

  • `user_agent`é enviado para rastreabilidade (não crítico mas útil)

Estas regras são mais estritas do que um simples`allow write: if true;`. Eles impedem que um atacante injete payloads enormes ou campos malformados.

Fase 2: Reescrever o JavaScript de submissão

O contrato é simples:

  1. Ler os dados do formulário

  2. Verificar o honeypot (campo`hp_name`— se estiver preenchido, é um bot, simulamos um sucesso sem enviar nada)

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

  4. Mostrar sucesso ou erro

A dependência `window.FIREBASE

Em`footer.thyme`, um módulo de script inicializa o Firebase SDK e expõe um objeto global:

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

Os scripts do módulo são executados antes`DOMContentLoaded`, portanto`window.FIREBASE`é garantido disponível quando o handler`contact.js`é disparado. Por precaução, adiciono mesmo um polling de 5 segundos caso o CDN esteja lento.

O novo `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);
});

As alterações em relação ao mock :

  • waitForFirebase()— polling com timeout, robusto mesmo se o CDN for lento

  • honeypot— se o campo oculto`hp_name`está preenchido, simular um sucesso sem apelo Firestore. O bot acredita ter tido sucesso mas nada é armazenado

  • addDoc(collection, {…​})— chamada verdadeira do Firestore com`serverTimestamp()` et user_agent

  • Gestão de erro com`try/catch`assíncrono

  • Limpeza do`finally`(restauro do botão)

Por que`user_agent`? É opcional, mas útil para o diagnóstico. Se chegar uma mensagem estranha, saber se ela vem de um navegador de desktop, móvel, ou de um script curl ajuda na triagem.

Phase 3 : Limpar o código morto Supabase

`script.js`contém 250 linhas de código morto :

  • SupabaseManager(linhas 417-481) — 65 linhas

  • ContactFormHandler(linhas 490-551) — 62 linhas

  • Bloco de inicialização (linhas 645-654) — 10 linhas

Total: ~140 linhas a excluir.

O bloco`DOMContentLoaded`cria um`SupabaseManager`então um`ContactFormHandler`relacionado ao formulário. Como explicado acima, esse código nunca é executado (bloqueado por`contact.js`), e mesmo se fosse executado, falharia (nenhum SDK Supabase carregado).)

Eu apago:

  1. A classe`SupabaseManager`

  2. A classe`ContactFormHandler`

  3. O bloco de inicialização em`DOMContentLoaded`(linhas 645-654)

Le reste de script.js`está intacto :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.

Fase 4 : Configurar o rodapé

`footer.thyme`já tem o boilerplate Firebase mas com valores de placeholder. Substituo:

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

Pelos valores reais recuperados desdeConfigurações do Projeto > Geral > Seus aplicativos > App da webna console Firebase.

Os valores são sensíveis (apiKey`é pública por design no Firebase, mas prefiro não commita-los em claro). Eu os armazeno em`site.yml(já em`.gitignore`) e o plugin`bakery`os injeta no modelo via uma lógica a ser adicionada no lado do build.

Por enquanto eu os coloco diretamente em`footer.thyme`— o build`./gradlew serve`os carregará localmente. Na implantação, migrerei a injeção para`site.yml`ou para uma variável Gradle.

L'`apiKey`Firebase não énãoum segredo. Ela é pública por projeto. O que protege os seus dados, são osregras de segurança do Firestore, não a chave de API. Não a coloque em um`.env`Carregado do lado do servidor — é destinado a ser exposto ao navegador.

Fase 5 : Arquitetura final

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

@startuml
skinparam backgroundColor #FEFEFE

title Arquitetura Final — Formulário de Contato Firebase
actor Utilisateur as USER

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

@startuml
skinparam backgroundColor #FEFEFE

title Arquitetura Final — Formulário de Contato Firebase
actor Utilisateur as USER

package "Navegador" #E8F5E9 {
  rectangle "contact.thyme
(HTML, honeypot)" as FORM
  rectangle "contact.js\n(validação + Firestore)" as CONT
  rectangle "footer.thyme\n(Inicialização do SDK Firebase)" 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

O que essa migração diz sobre o dogfooding

Este site é gerado pelo meu próprio plugin Gradle`bakery`. O formulário de contato fica dentro do site. A migração Supabase → Firebase está documentada em`AGENT.adoc`, ela é discutida no backlog, ela é testada via`./gradlew serve`, e ela gera um artigo de blog (aquele que você está lendo).

É puro dogfooding. O site é o produto do plugin, o plugin é o produto do desenvolvedor, o desenvolvedor documenta o processo no próprio site.

O ciclo está fechado.

O fato de ter arrastado um mock por meses (sessões inteiras onde o formulário mentia silenciosamente) fez-me perceber algo : o backlog de um site estático pessoal nunca é « acabado ». Sempre há uma US prioritária, sempre um artigo em rascunho, sempre uma seção comentada em um modelo. A disciplina não é terminar tudo — é terminar o que é visível pelo usuário.

Um formulário de contato quebrado é pior do que não ter nenhum formulário. É uma promessa não cumprida.

Resumo das modificações

arquivo

modificação

Impacto

blog/2026/0113_*.adoc

Criação do artigo

Documentação

assets/js/contact.js

Reescrita (mock → Firestore real)

Funcional

assets/js/script.js

Exclusão SupabaseManager + ContactFormHandler + init bloco

Limpeza

templates/footer.thyme

Substituição do placeholder de config → valores reais

configuração

Próximos passos (backlog)

  • Email de notificação: uma Cloud Function`onCreate`sobre`contact_messages`que envia um e-mail via SendGrid. O formulário armazena, mas eu não sou notificado. Prioridade média — as mensagens são visíveis na console do Firebase.

  • Limitação de taxa no lado do cliente: Adicionar um timestamp localStorage para impedir envios múltiplos em rajada. O honeypot bloqueia os bots ingênuos, um rate limiter bloquearia os bots um pouco mais espertos.

  • Testes: Um teste Playwright que envia o formulário e verifica se o documento aparece no Firestore. Por enquanto, estou testando manualmente via`./gradlew serve`.

Articles connexes