Migration Supabase → Firebase : Brancher un Formulaire de Contact sur Firestore Sans Backend
Δημοσιεύτηκε 29 April 2026
- Η σκηνή: μια φόρμα που δεν αποθηκεύει τίποτα
- Γιατί Firebase αντί Supabase;
- Φάση 1: Δημιουργήστε το έργο Firebase
- Φάση 2: Επαναγραφή του JavaScript υποβολής
- Φάση 3: Καθαρισμός νεκρού κώδικα Supabase
- Φάση 4 : Διαμόρφωση του υποσέλιδου
- Φάση 5 : Τελική αρχιτεκτονική
- Τι λέει αυτή η μεταίσχυση για το dogfooding
- Επόμενα βήματα (backlog)
- Αναφορές
Για μήνες, το φόρμα επαφής αυτού του ιστότοπου είχε τρέξει σε ένα mock JavaScript — μια υπόσχεση 85 % επιτυχίας, ένα ψεύτικο Firestore, μηδενικά αποθηκευμένα δεδομένα. Ο αρχικός σχεδιασμός είχε προβλέψει ένα backend Supabase με Google Apps Script για τις ειδοποιήσεις email. Απορρίφθηκε. Σήμερα, αφηγούμαι τη μετάβαση στο Firebase Firestore: δημιουργία του έργου, κανόνες ασφαλείας, επανέγραψη του JS, καθαρισμός του νεκρού κώδικα του Supabase. Και γιατί αυτή η επιλογή δείχνει κάτι πιο γενικό σχετικά με τη φιλοσοφία της ανάπτυξης.
Η σκηνή: μια φόρμα που δεν αποθηκεύει τίποτα
Αυτό το ιστότοπο παράγεται από το JBake, το πρόσθετό μου Gradle`bakery`. Είναι 100 % στατικό — χωρίς backend, χωρίς βάση δεδομένων. Εκτός από αυτό, έχω μια φόρμα επαφής. Η σελίδα`contact.html`υπάρχει, το HTML είναι έτοιμο (πεδία όνομα, email, τηλέφωνο, θέμα, μήνυμα, έλεγχος HTML5, honeypot anti-spam), τα στυλ Bootstrap είναι στη θέση τους. Οπτικά, όλα είναι τέλεια.
Πέρα από την υποβολή, τίποτα δεν συμβαίνει.
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);
});
Ένας mock. Μια υπόσχεση που φαίνεται. Ο χρήστης βλέπει ένα spinner, και έπειτα ένα μήνυμα « Το μήνυμα στάλθηκε με επιτυχία! ». Αλλά τα δεδομένα πηγαίνουν στο κενό. Κανένα μήνυμα δεν αποθηκεύεται πουθενά.
Η κατάσταση είναι χειρότερη από ένα κατεστραμμένο φόρμα — είναι ένα φόρμα που ψεύδεται.
Η κληρονομιά Supabase
Το αρχικό σχέδιο, τεκμηριωμένο σε`content/draft/integration_formulaire_contact_supabase.adoc`, προέγραφε :
-
Μια βάση Supabase με πίνακα`contacts`και Row Level Security
-
Μια RPC`handle_contact_form`πλευρά διακομιστή
-
Ένας trigger SQL που καλεί έναν webhook Google Apps Script
-
Google Apps Script που στέλνει ένα ειδοποιητικό email Gmail
Ο αντίστοιχος κώδικας JavaScript υπάρχει ακόμα στο`script.js`. Υπάρχει μια τάξη`SupabaseManager`που αρχικοποιεί ένα πελάτη Supabase με παγκόσμιες μεταβλητές`SUPABASE_URL` et SUPABASE_KEY, και μια κλάση`ContactFormHandler`ακούει το.submit της φόρμας και καλεί`SupabaseManager.submitContactForm()`.
Πρόβλημα: αυτές οι παγκόσμιες μεταβλητές δεν προστίθενται πλέον στο υποσέλιδο. Ο`<script src="supabase-js">`αφαιρέθηκε. Ο κώδικας καλεί`supabase.createClient()`σε μια μεταβλητή`supabase`που δεν υπάρχει πια. Άρα :
console.error : 'Supabase client library (supabase-js) is not loaded.'
Δεν μόνο τα δεδομένα δεν αποθηκεύονται, αλλά και ο κώδικας υποβολής είναι νεκρός.
Η διπλή φαντασματική υποβολή
Για να χαλάσει τα πράγματα, υπάρχει μιασιγουρός διαγωνισμόςμεταξύ δύο χειριστών στην ίδια φόρμα:
-
`contact.js`άκουσέ το submit, κάλεσε το mock Firebase
-
script.js— μέσω`ContactFormHandler`— άkouε επίσης το submit, κάλεσε`SupabaseManager`
Οι δύο κάνουν`event.preventDefault() + event.stopPropagation().ώς`contact.js est chargé en premier dans footer.thyme, ο handler του είναι συνδεδεμένος πρώτος. Αποτρέπει την διάδοση.`ContactFormHandler`θα ενεργοποιηθεί ποτέ.
Δεν είναι καν ενεργό bug — είναι ένα ζομπι. Κώδικας που δεν έχει ποτέ την ευκαιρία να εκτελεστεί.
@startuml skinparam backgroundColor #FEFEFE title État Initial — Formulaire de Contact rectangle "contact.thyme\n(HTML Bootstrap, honeypot)" as FORM rectangle "footer.thyme\n(Firebase SDK avec config placeholder)" as FOOT rectangle "contact.js\n(mock Firebase, 85% succès)" as CONT rectangle "script.js\n(SupabaseManager + ContactFormHandler, morts)" as SCRIPT rectangle "Utilisateur" 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
Γιατί Firebase αντί Supabase;
Η απόφαση μετανάστευσης είναι καταγεγραμμένη σε`AGENT.adoc`:
Το Firebase τώρα επιλέγεται για τα ακόλουθα λόγους: καλύτερο δωρεάν σχέδιο, Firestore φυσικό, ενσωματωμένες Cloud Functions, πιο κατάλληλο οικοσύστημα Google. Η υπάρχουσα υλοποίηση του Supabase είναι σημειωμένη «⚠️ Απορριφθεί».
Αυτή η τοποθεσία ζει στο οικοσύστημα του Google: ο προορισμός του αποθετήριου είναι`cheroliv.github.io`, το CNAME δείχνει στα GitHub Pages, ο Gradle build push στο GitHub μέσω JGit. Η προσθήκη μιας υπηρεσίας Google (Firebase) αντί μιας υπηρεσίας τρίτων (Supabase) μειώνει την επιφάνεια διασποράς.
Το Firestore σε ενδεδειγμένο режим (όχι σε режим Datastore) είναι και πιο κοντά στο νοητικό μοντέλο NoSQL εγγράφων που έχω στο μυαλó: συλλογές, έγγραφα, τυποποιημένα πεδία, timestamps διακομιστή, ενσωματωμένοι κανόνες ασφαλείας.
Φάση 1: Δημιουργήστε το έργο Firebase
Αρχικοποίηση
Η CLI Firebase δεν είναι εγκατεστημένη στον υπολογιστή μου, επομένως βγαίνω στην κονσόλα web :
-
πάει στοhttps://console.firebase.google.com/[Κονσόλα Firebase]
-
Δημιουργία ενός έργου`cheroliv-contact`(ή να επαναχρησιμοποιήσετε ένα υπάρχον έργο)
-
Ενεργοποιήστε το Firestore σε φυσική κατάσταση (όχι Datastore)
-
Δημιουργήστε μια βάση δεδομένων σε περιοχή`eur3`(Ευρώπη)
Για ελαφρύ χρήση όπως της μας (μία συλλογή, δημόσια εγγραφή), ο φυσικός τρόπος είναι η σωστή επιλογή. Δεν χρειάζεστε σύνθετους κανόνες του Datastore.
Κανόνες ασφάλειας του Firestore
Η φόρμα είναι δημόσια—όποιος μπορεί να στείλει ένα μήνυμα. Αλλά θέλω να περιορίσω τα αυξοδοτήματα:
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;
}
}
}
Βασικά σημεία :
-
allow read— μόνοιοι έγκυροι χρήστες μπορούν να διαβάζουν τα μηνύματα (εγώ, μέσω της κονσόλας Firebase) -
allow create— όποιος μπορεί να δημιουργήσει ένα έγγραφο, αλλά με επικύρωση πεδίων -
Επαλήθευση διακομιστή : μεγέθη min/max, μορφή email,
created_at`πρέπει να ταιριάζει με`request.time(αντί-ψεύτρεψης) -
`user_agent`στέλνεται για πιστευτικότητα (όχι κρίσιμο αλλά χρήσιμο)
Αυτοίοι κανόνες είναι πιο στερνοί από ένα απλό`allow write: if true;`. Εμποδίζουν έναν επιτιθέμενο να εντάξει μεγάλα payloads ή κακώς διαμορφωμένα πεδία.
Φάση 2: Επαναγραφή του JavaScript υποβολής
Το συμβόλαιο είναι απλό:
-
Διαβάστε τα δεδομένα του φόρμας
-
Ελέγξτε το honeypot (πεδίο`hp_name`— αν είναι γεμάτο, είναι ένα bot, σοκιμάζουμε μια επιτυχία χωρίς να στείλουμε τίποτα)
-
να καλέσετε`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…})`
-
Εμφάνιση επιτυχίας ή σφάλματος
Η εξάρτηση `window.FIREBASE
σε`footer.thyme`, ένα ενδιάμεσο script αρχικοποιεί το SDK του Firebase και εκθέτει ένα καθολικό αντικείμενο :
<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>
Τα σενάρια module εκτελούνται πριν`DOMContentLoaded`, άρα`window.FIREBASE`εγγυάται διαθέσιμο όταν ο χειριστής`contact.js`ενεργοποιείται. Για προφυλακτικούς λόγους, προσθέτω επίσης ένα polling 5 δευτερόλεπτα, εάν το CDN αργεί.
Το νέο `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);
});
Οι αλλαγές σε σχέση με το mock :
-
waitForFirebase()— polling με timeout, αυστηρός ακόμα και αν ο CDN είναι αργός -
honeypot— αν το κρυφό πεδίο`hp_name`Είναι γεμάτο, να προσομοιώσει μια απολύτως επιτυχημένη λειτουργία του Firestore. Το bot νομίζει ότι دارد επιτυχία αλλά τίποτα δεν αποθηκεύεται. -
addDoc(collection, {…})— αληθής κλήση Firestore με`serverTimestamp()` etuser_agent -
διαχείριση σφάλματος με`try/catch`ασύγχρονος
-
Καθαρισμός του`finally`(επαναφορά του κουμπιού)
|
Γιατί`user_agent`; Είναι προαιρετικό, αλλά χρήσιμο για το διάγνωση. Εάν λάβετε ένα παράξενο μήνυμα, το να γνωρίζετε αν προέρχεται από έναν desktop browser, mobile, ή από ένα script curl βοηθεί στην ταξινόμηση. |
Φάση 3: Καθαρισμός νεκρού κώδικα Supabase
`script.js`περιέχει 250 γραμμές νεκρού κώδικα :
-
SupabaseManager(γραμμές 417-481) — 65 γραμμές -
ContactFormHandler(γραμμές 490-551) — 62 γραμμές -
Τμήμα αρχικοποίησης (γραμμές 645-654) — 10 γραμμές
Σύνολο: περίπου 140 γραμμές για διαγραφή.
Το μπλοκ`DOMContentLoaded`δημιουργεί ένα`SupabaseManager`και ένα`ContactFormHandler`συνημμένο στο φόρμα. όπως εξήγηθηκε παραπάνω, αυτός ο κώδικας δεν εκτελείται ποτέ (μπολούμενος από`contact.js`), και ακόμα κι αν το εκτελούσε, θα αποτυχαί (δεν φορτώνεται το SDK Supabase).
Διαγράφω :
-
Η τάξη`SupabaseManager`
-
Η τάξη`ContactFormHandler`
-
Το μπλοκ αρχικοποίησης στο`DOMContentLoaded`(γραμμές 645-654)
Le reste de script.js`ειναι ακατάβλητο :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.
Φάση 4 : Διαμόρφωση του υποσέλιδου
`footer.thyme`Έχει ήδη το boilerplate του Firebase αλλά με τιμές placeholders. Αντικαθιστώ :
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"
};
Μέσω των πραγματικών τιμών που ανακτηθούν απόΡυθμίσεις Έργου > Γενικές > Οι εφαρμογές σας > Εφαρμογή Ιστούστην κονσόλα Firebase.
Οι τιμές είναι αισθητικές`apiKey`είναι δημόσια από το σχεδιασμό στο Firebase, αλλά προτιμώ να μην τα υποβάλω σε απλό κείμενο). Τα αποθηκεύω στο`site.yml`(ακριβώς σε`.gitignore`) και το πρόσθετο`bakery`τον εγχέουν στο πρότυπο μέσω λογικής που πρέπει να προστεθεί στην πλευρά του build.
Για το παρόν, τα βάζω άμεσα σε`footer.thyme`— το build`./gradlew serve`Θα τα φορτώσει τοπικά. Κατά την ανάπτυξη, θα μεταφέρω την ένθεση προς`site.yml`ή σε μια μεταβλητή Gradle.
|
L'`apiKey`Firebase δεν είναιδενένα μυστικό. Είναι δημόσια από σχεδιασμό. Τι προστατεύει τα δεδομένα σας, είναι τακανόνες ασφαλείας Firestore, δεν το κλειδί API. Μην το βάλετε σε ένα`.env`Φορτωμένο στο διακομιστή — είναι προορισμένο να αποκαλυφθεί στον περιηγητή. |
Φάση 5 : Τελική αρχιτεκτονική
@startuml
skinparam backgroundColor #FEFEFE
title Architecture Finale — Formulaire de Contact Firebase
actor Utilisateur as USER
package "Navigateur" #E8F5E9 {
rectangle "contact.thyme\n(HTML, honeypot)" as FORM
rectangle "contact.js\n(validation + 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
Τι λέει αυτή η μεταίσχυση για το dogfooding
Αυτός ο ιστότοπος δημιουργείται από το δικό μου plugin Gradle`bakery`. Το φόρμα επαφής βρίσκεται μέσα στον ιστότοπο. Η μεταμόνωση Supabase → Firebase είναι τεκμηριωμένη στο`AGENT.adoc`, συζητείται στο backlog, δοκιμάζεται μέσω`./gradlew serve`, και δημιουργεί ένα άρθρο ιστολογίου (το οποίο διαβάζετε).
Είναι καθαρά dogfooding. Ο ιστότοπος είναι το προϊόν του πρόσθετου, το πρόσθετο είναι το προϊόν του προγραμματιστή, ο προγραμματιστής τεκμηριώνει τη διαδικασία στον ιστότοπο ίδιο.
Ο κύκλος είναι κλεισμένος.
Το να τον έχουμε κρατήσει ως mock για μήνες (ολοκληROSές συνεδρίες όπου η φόρμα ψεύδιζε σιωπηλά) μέ μ έκανε να συνειδητοποιήσω κάτι : το backlog ενός προσωπικού στατικού ιστότοπου ποτέ δεν είναι « τελειωμένο ». Πάντα υπάρχει μια US προτεραιότητα, πάντα υπάρχει ένα άρθρο σε draft, πάντα υπάρχει μια σχολιασμένη ενότητα σε ένα πρότυπο. Η διαcipline δεν είναι να τελειώσουμε όλα — είναι να τελειώσουμε το ορατό από τον χρήστη.
Ένας σπασμένος φόρμα επαφής είναι χειρότερος από το να μην υπάρχει καθόλου φόρμα. Είναι μια επαγγελία που δεν εφαρμόστηκε.
Περίληψη των αλλαγών
| αρχείο | Τροποποίηση | επίπτωση |
|---|---|---|
|
Δημιουργία του άρθρου |
Τεκμηρίωση |
|
Επαναγραφή (mock → πραγματικό Firestore) |
Λειτουργικό |
|
Διαγραφή SupabaseManager + ContactFormHandler + init bloc |
Καθαρισμός |
|
αντικατάσταση config placeholder → πραγματικές τιμές |
διαμόρφωση |
Επόμενα βήματα (backlog)
-
Ειδοποίηση email: Μια Cloud Function`onCreate`σε`contact_messages`στέλνει ένα email μέσω SendGrid. Η φόρμα αποθηκεύει, αλλά δεν ειδοποιούμαι. Μέση προτεραιότητα — Τα μηνύματα είναι ορατά στην κονσόλα Firebase.
-
Rate limiting της πλευράς του πελάτη: Προσθέστε ένα timestamp του localStorage για να αποτρέψετε τις πολλαπλές υποβολές σε σωληναίδη. Ο honeypot αποκλείει τα απλά bot, ένας περιορισμού ρυθμού (rate limiter) θα αποκλείει τα λίγο πιο έξυπνα bot.
-
Τεστ: Ένας τεστ Playwright που υποβάλει τη φόρμα και ελέγχει ότι το εγγράφο εμφανίζεται στο Firestore. Για το παρόν, δοκιμάζω χειροκίνητα μέσω`./gradlew serve`.