Migração Supabase → Firebase : Conectar um Formulário de Contato no Firestore Sem Backend
Publié le 29 April 2026
- A cena : um formulário que não armazena nada
- Por que Firebase em vez de Supabase?
- Fase 1: Criar o projeto Firebase
- Fase 2: Reescrever o JavaScript de submissão
- Phase 3 : Limpar o código morto Supabase
- Fase 4 : Configurar o rodapé
- Fase 5 : Arquitetura final
- O que essa migração diz sobre o dogfooding
- Próximos passos (backlog)
- Referências
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 :
-
Uma base Supabase com tabela`contacts`e Row Level Security
-
Uma RPC`handle_contact_form`lado do servidor
-
Um trigger SQL chamando um webhook Google Apps Script
-
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 :
-
`contact.js`Ouça o submit, chame o mock Firebase
-
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:
-
Ir parahttps://console.firebase.google.com/[Firebase Console]
-
Criar um projeto`cheroliv-contact`(ou reutilizar um projeto existente)
-
Ativar Firestore no modo nativo (não Datastore)
-
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:
-
Ler os dados do formulário
-
Verificar o honeypot (campo`hp_name`— se estiver preenchido, é um bot, simulamos um sucesso sem enviar nada)
-
Chamar`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…})`
-
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()` etuser_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:
-
A classe`SupabaseManager`
-
A classe`ContactFormHandler`
-
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 |
|
Criação do artigo |
Documentação |
|
Reescrita (mock → Firestore real) |
Funcional |
|
Exclusão SupabaseManager + ContactFormHandler + init bloco |
Limpeza |
|
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`.