vreme čitanja : 12 minutes

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:

  1. jedna Supabase baza sa tabelom`contacts`i Row Level Security

  2. Jedna RPC`handle_contact_form`страна сервера

  3. SQL trigger koji poziva webhook Google Apps Script

  4. 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:

  1. `contact.js`слушај сабмит, позови мок Firebase

  2. 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:

  1. Ићи наhttps://console.firebase.google.com/[Firebase konzola]

  2. Kreiraj projekat`cheroliv-contact`(ili ponovo koristiti postojeći projekat)

  3. Uključi Firestore u nativnom načinu (nije Datastore)

  4. 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:

  1. Прочитај податке формулара

  2. Проверити honeypot (polje`hp_name`— ako je popunjeno, to je bot, simuliramo uspeh bez slanja čega)

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

  4. 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()` et user_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:

  1. класа`SupabaseManager`

  2. klasa`ContactFormHandler`

  3. Блок иницијализације у`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

blog/2026/0113_*.adoc

Креирање статка

Документација

assets/js/contact.js

Prepisivanje (mock → stvarni Firestore)

функционалан

assets/js/script.js

Брисање SupabaseManager + ContactFormHandler + init bloc

Čišćenje

templates/footer.thyme

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

Повезани чланци