Migracija Supabase → Firebase : Povezivanje kontaktnog oblika na Firestore bez backenda
Објављено 29 April 2026
- Scena: obrasac koji ne čuva ništa
- Zašto Firebase umesto Supabase?
- Faza 1 : Kreiranje Firebase projekta
- Faza 2 : Prepisati JavaScript za slanje
- Faza 3 : Čišćenje mrtvog koda Supabase
- Фаза 4 : Подеси футер
- Faza 5: Konačna arhitektura
- Šta ova migracija kaže o dogfoodingu
- Sledeći koraci (backlog)
- Reference
Mesecima, kontakt forma ovog sajta je radila na mock JavaScript-u — obećanje od 85 % uspeha, lažni Firestore, nula podataka skupljena. Početni plan je bio da se koristi Supabase backend sa Google Apps Script-om za e-mail obaveštenja. Otkazano. Danas pričam o migraciji na Firebase Firestore: kreiranje projekta, pravila bezbednosti, prepisanje JS koda, čišćenje mrtvog Supabase koda. I zašto ovaj izbor govori o nečem širim u vezi s filozofijom razvoja.
- tik
-
[]
Scena: obrasac koji ne čuva ništa
Ovaj sajt je generisan od strane JBake, moj Gradle plugin`bakery`. To je 100 % statično — bez bekenda, bez baze podataka. Osim što imam kontaktni obrazac. Stranica`contact.html`postoji, HTML je spreman (polja: ime, e‑mail, telefon, tema, poruka, HTML5 validacija, anti‑spam honeypot), Bootstrap stilovi su postavljeni. Vizuelno, sve je perfektnog.
Osim što pri podnosenju, ništa se ne dešava.
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);
});
Један макет. Обећање које претприча. Корисник види спинер, затим поруку « Порука је uspeшно послата! ». Но подаци улазе у ништа. Никаква порука није чувана нигде.
Situacija je gora od pokvarenog obrasca — to je obrasac koji laže.
Nasledje Supabase
Početni plan, dokumentovan u`content/draft/integration_formulaire_contact_supabase.adoc`, mislio je:
-
jedna Supabase baza sa tabelom`contacts`i Row Level Security
-
Jedna RPC`handle_contact_form`страна сервера
-
SQL trigger koji poziva webhook Google Apps Script
-
Google Apps Script koji šalje obaveštenje e‑mail putem Gmail
Соответни JavaScript код још увек постоји у`script.js`. Ima jednu klasu`SupabaseManager`qui inicijalizuje klijent Supabase sa globalnim promenljivama`SUPABASE_URL` et SUPABASE_KEY, i jedna klasa`ContactFormHandler`koji sluša submit događaj forme i poziva`SupabaseManager.submitContactForm()`.
Problem: ove globalne promenljive više nisu ubacene u futer. Le`<script src="supabase-js">`je uklonjen. Kod poziva`supabase.createClient()`на променљивој`supabase`Koji više ne postoji. Zato:
console.error : 'Supabase client library (supabase-js) is not loaded.'
Ni podaci nisu skladišteni, nego i kod za slanje je mrtav.
duplo fantomsko podnosenje
Da stvari postanu gore, ima jednatiha konkurencija</tool_call> dva handleri na istom formularu:
-
`contact.js`слушај сабмит, позови мок Firebase
-
script.js— putem`ContactFormHandler`— слушај и submit, зови`SupabaseManager`
Oba rade`event.preventDefault() + event.stopPropagation(). kao`contact.js`је учитаван први у`footer.thyme, njegov handler je povezan kao prvi. On blokira propagaciju.`ContactFormHandler`nikada neće biti pokrenut.
Nije ni aktivan bug — to je zombi. Kod koji nikada nema priliku da se izvrši.
@startuml skinparam backgroundColor #FEFEFE title Почетно Стање — Контактна Форма rectangle "contact.thyme\n(HTML Bootstrap, honeypot)" as FORM rectangle "footer.thyme\n(Firebase SDK sa placeholder-om za konfiguraciju)" as FOOT rectangle "contact.js\n(mock Firebase, 85% uspeh)" as CONT rectangle "script.js (SupabaseManager + ContactFormHandler, mrti)" as SCRIPT rectangle "Корисник" 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
Zašto Firebase umesto Supabase?
Odluka o migraciji je dokumentovana u`AGENT.adoc` :
Firebase se bira iz sledećih razloga: bolji besplatan plan, natívni Firestore, integrisani Cloud Functions i bolje prilagođen Googleov ekosistem. Postojeća Supabase implementacija je označena kao « ⚠️ Opustena ».
Osim besplatnog plana, postoji arhitektonski razlog. Ovaj sajt živi u Google ekosistemu: ciljni depozitar je`cheroliv.github.io`, CNAME pokazuje na GitHub Pages, Gradle build push na GitHub preko JGit. Dodavanje usluge Google (Firebase) umesto usluge treće strane (Supabase) smanjuje površinu rasipanja.
Firestore u nativnom režimu (nije u Datastore režimu) je i bliže mentalnom modelu NoSQL dokumenta koji imam na umu: kolekcije, dokumenti, tipirana polja, vremenske oznake servera, integrirana pravila bezbednosti.
Faza 1 : Kreiranje Firebase projekta
Inicijalizacija
Firebase CLI nije instalirana na mojem računaru, pa prelazim na web konzolu:
-
Ићи наhttps://console.firebase.google.com/[Firebase konzola]
-
Kreiraj projekat`cheroliv-contact`(ili ponovo koristiti postojeći projekat)
-
Uključi Firestore u nativnom načinu (nije Datastore)
-
Kreirati bazu podataka u regionu`eur3`(Evropa)
Za minimalističku upotrebu kao naša (jedna kolekcija, javno pisanje), natprirodni mod je pravi izbor. Nema potrebe za kompleksnim pravilima Datastore.
Правила безбедности Firestore
Formular je javn — bilo ko može poslati poruku. Ali želim ograničiti zlouporabe :
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;
}
}
}
Ključne tačke:
-
allow read— samo autentifikovani korisnici mogu čitati poruke (ja, preko Firebase konzole) -
allow create— svako može da kreira dokument, ali sa validacijom polja -
Validacija na serveru: minimalne/maksimalne veličine, format e-pošte,
created_at`mora da odgovara na`request.time(anti-falsifikacija) -
`user_agent`se šalje za trazabilnost (nije kritično, ali je korisno)
Ova pravila su stroža nego jednostavno`allow write: if true;`. Spriječavaju napadaču da injektira ogromne payload-e ili loše formirane polja.
Faza 2 : Prepisati JavaScript za slanje
Ugovor je jednostavan:
-
Прочитај податке формулара
-
Проверити honeypot (polje`hp_name`— ako je popunjeno, to je bot, simuliramo uspeh bez slanja čega)
-
pozivati`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…})`
-
Prikaži uspeh ili grešku
Зависност `window.FIREBASE
У`footer.thyme`, jedan skript modul inicijalizuje Firebase SDK i izlaže globalni objekat :
<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>
Skriptovi modula se izvršavaju pre`DOMContentLoaded`, zato`window.FIREBASE`garantirano je dostupno kad handler`contact.js`se pokreće. Iz mere opreznosti, ja ipak dodajem polling od 5 sekundi u slučaju da CDN bude spor.
Novi `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);
});
Измене у односу према макету :
-
waitForFirebase()— polling sa timeoutom, robustan čak i ako je CDN sporo -
honeypot— ako je skriveno polje`hp_name`je pun, simulirati odlučivan uspeh Firestore. Bot misli da je uspeo, ali ništa nije skladišteno. -
addDoc(collection, {…})— prav poziv Firestore sa`serverTimestamp()` etuser_agent -
Упретње грешке са`try/catch`асинхрон
-
Чишћење`finally`(обнова дугмета)
|
Зашто`user_agent`? To je opcionalno, ali korisno za dijagnostiku. Ako dodje seltska poruka, znati da li dolazi iz desktop pregledača, mobilnog uređaja ili skripta curl pomaže u razvrstavanju. |
Faza 3 : Čišćenje mrtvog koda Supabase
`script.js`sadrži 250 redova mrtvog koda:
-
SupabaseManager(линије 417-481) — 65 линија -
ContactFormHandler(линије 490-551) — 62 линија -
Blok inicijalizacije (redovi 645-654) — 10 redova
Ukupno: ~140 linije koje treba izbrisati.
Блок`DOMContentLoaded`kreirao jedan`SupabaseManager`zatim jedan`ContactFormHandler`Прикључен на формулар. Како је објашњено изнад, овај код се никогда не извршава (блокиран од`contact.js`), i čak ako bi se izvršio, ne bi uspio (Supabase SDK nije učitan).
Brišem:
-
класа`SupabaseManager`
-
klasa`ContactFormHandler`
-
Блок иницијализације у`DOMContentLoaded`(linije 645-654)
Le reste de script.js`је непоређен :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.
Фаза 4 : Подеси футер
`footer.thyme`Већ има boilerplate Firebase но с placeholder вредностма. Замењујем :
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"
};
Одизна реалне вредности добијене одPostavke projekta > Opšte > Vaše aplikacije > Web aplikacijau konzoli Firebase.
Vrednosti su osetljive`apiKey`jest javna po dizajnu kod Firebase, preferiram ne ih commitovati ). Je ih čuvam u`site.yml`(већ у`.gitignore`) и dodatak`bakery`injicira u šablon preko logike koju treba dodati na strani builda.
Za sada, ih stavim direktno u`footer.thyme`— izgradnja`./gradlew serve`će ih učitati lokalno. При deploy-ovanju, ću premestiti injekciju ka`site.yml`ili ka Gradle promenljivoj.
|
L'`apiKey`Firebase nijeкоракnije tajna. Ona je javna po dizajnu. Šta štiti vaše podatke, supravila bezbednosti Firestore, ne je ključ API. Ne ga stavite u`.env`Ukrcano na serveru — namenjeno je da se izloži u pregledaču. |
Faza 5: Konačna arhitektura
@startuml
skinparam backgroundColor #FEFEFE
title Конačна архитектура — Форма контакта Firebase
actor Utilisateur as USER
package "Прегледач" #E8F5E9 {
rectangle "contact.thyme\n(HTML, honeypot)" as FORM
rectangle "contact.js
(validacija + Firestore)" as CONT
rectangle "footer.thyme
(Pokretanje 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
Šta ova migracija kaže o dogfoodingu
Овај сайт је генерисан мојем сопсним Gradle додатком`bakery`. Oblik za kontakt živi unutar sajta. Migracija Supabase → Firebase je dokumentirana u`AGENT.adoc`, ona je diskutovana u backlogu, ona je testirana putem`./gradlew serve`, i ona generiše članak na blogu (koji čitate).
Sajt je proizvod plugina, plugin je proizvod razvojnog programera, a razvojni programer dokumentuje proces na samom sajtu.
Petlja je zatvorena.
Čin da sam dugoročno nosio mock (cijeli sesiji u koji je obrazac ticho lažio) učinio mi je da se uočim nešto: backlog ličnog statičkog sajta nikada nije «završen». Uvijek je jedna prioritetna US, uvijek jedan članak u nacrtu, uvijek jedna komentarisana sekcija u šablonu. Disciplina nije sve završiti — već završiti ono što je vidljivo korisniku.
Pokvareni kontakt obrazac, to je gore nego da nema nikakvog obrazca. To je neispunjeno obećanje.
Сажетак промена
Fajl |
Измена |
uticaj |
|
Креирање статка |
Документација |
|
Prepisivanje (mock → stvarni Firestore) |
функционалан |
|
Брисање SupabaseManager + ContactFormHandler + init bloc |
Čišćenje |
|
Zamena placeholder-a za konfiguraciju → stvarne vrednosti |
Конфигурација |
Sledeći koraci (backlog)
-
Obaveštenje putem e‑maila: Jedna Cloud Function`onCreate`na`contact_messages`koji šalje e-mail putem SendGrid. Forma se čuva, ali ne dobijam obaveštenje. Srednji prioritet — poruke su vidljive u Firebase konzoli.
-
Rate limiting na klijentskoj strani: Додај timestamp у localStorage за sprečavanje višestrukog slanja u nizu. Honeypot blokira naivne botove, a rate limiter bi blokirao bolje prateće botove.
-
Тестови: Test Playwright koji šalje formu i proverava da li se dokument pojavljuje u Firestore. Trenutno testiram rukom putem`./gradlew serve`.