Migration Supabase → Firebase : Menghubungkan Formulir Kontak ke Firestore Tanpa Backend
Diterbitkan 29 April 2026
- Skeno: sebuah formulir yang tidak menyimpan apa-apa
- Mengapa Firebase alih-alih Supabase?
- Tahap 1 : Membuat proyek Firebase
- Tahap 2 : Menulis ulang JavaScript pengiriman
- Fase 3 : Membersihkan kode mati Supabase
- Phase 4 : Mengatur footer
- Tahap 5 : Arsitektur akhir
- Apa yang dikatakan oleh migrasi ini tentang dogfooding
- Langkah selanjutnya (backlog)
- Referensi
Selama bertahun-tahun, formulir kontak situs ini berjalan pada mock JavaScript — sebuah janji dengan tingkat keberhasilan 85 %, Firestore palsu, tidak ada data yang disimpan. Rencana awal mengantisipasi backend Supabase dengan Google Apps Script untuk notifikasi email. Ditinggalkan. Hari ini, saya menceritakan migrasi ke Firebase Firestore: pembuatan projekt, aturan keamanan, penulisan ulang JS, pembersihan kode mati Supabase. Dan mengapa pilihan ini mengartikan sesuatu yang lebih luas tentang filosofi pengembangan.
- toc
-
[]
Skeno: sebuah formulir yang tidak menyimpan apa-apa
Situs ini dihasilkan oleh JBake, plugin Gradle saya`bakery`. Ini 100% statis — tidak ada backend, tidak ada basis data. Namun saya memiliki formulir kontak. Halaman`contact.html`ada, HTML sudah siap (kolom nama, email, telepon, subjek, pesan, validasi HTML5, honeypot anti-spam), gaya Bootstrap sudah terpasang. Secara visual, semuanya sempurna.
Namun saat pengajuan, tidak terjadi apa-apa.
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);
});
Suatu mock. Sebuah janji yang hanya berpura-pura. Pengguna melihat spinner, lalu pesan « Pesan terkirim dengan sukses ! ». Tapi data pergi ke dalam hampa. Tidak ada pesan yang disimpan di mana pun.
Situasi lebih buruk daripada formulir yang rusak — ini formulir yang berbohong.
Warisan Supabase
Rencana awal, yang didokumentasikan di`content/draft/integration_formulaire_contact_supabase.adoc`, memperkirakan :
-
Suatu basis data Supabase dengan tabel`contacts`dan Keamanan Tingkat Baris
-
Sebuah RPC`handle_contact_form`sisi server
-
Sebuah trigger SQL yang memanggil sebuah webhook Google Apps Script
-
Google Apps Script yang mengirim email notifikasi Gmail
Kode JavaScript yang sesuai masih ada di`script.js`. Ada sebuah kelas`SupabaseManager`yang menginisialisasi klien Supabase dengan variabel global`SUPABASE_URL` et SUPABASE_KEY, dan sebuah kelas`ContactFormHandler`yang mendengarkan acara submit formulir dan memanggil`SupabaseManager.submitContactForm()`.
Masalah : variabel global ini tidak lagi di-inject di dalam footer. Yang`<script src="supabase-js">`telah dihapus. Kode memanggil`supabase.createClient()`pada satu variabel`supabase`yang tidak ada lagi. Maka:
console.error : 'Supabase client library (supabase-js) is not loaded.'
Bukan hanya data tidak disimpan, tetapi kode pengiriman sudah mati.
pengiriman ganda hantu
Untuk memburukkan hal-hal, ada sebuahpersaingan diamantara dua handler pada formulir yang sama :
-
`contact.js`dengarkan submit, panggil mock Firebase
-
script.js— via`ContactFormHandler`— dengarkan juga submit, telepon`SupabaseManager`
Keduanya melakukan`event.preventDefault() + event.stopPropagation(). Seperti`contact.js`dimuat terlebih dahulu di`footer.thyme, handler-nya dipasang terlebih dahulu. Dia menghambat penyebaran.`ContactFormHandler`tidak akan pernah dipicu.
Ini bahkan bukan sebuah bug aktif — ini sebuah zombie. Kode yang tidak pernah kesempatan untuk dieksekusi.
@startuml skinparam backgroundColor #FEFEFE title Status Awal — Formulir Kontak rectangle "contact.thyme (HTML Bootstrap, honeypot)" as FORM rectangle "footer.thyme (Firebase SDK dengan placeholder konfigurasi)" as FOOT rectangle "contact.js\n(mock Firebase, 85% keberhasilan)" as CONT rectangle "script.js\n(SupabaseManager + ContactFormHandler, mati)" as SCRIPT rectangle "Pengguna" 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
Mengapa Firebase alih-alih Supabase?
Keputusan migrasi dicatat dalam`AGENT.adoc`:
Firebase kini dipilih karena alasan-alasan berikut: paket gratis terbaik, Firestore asli, Cloud Functions terintegrasi, ekosistem Google yang lebih sesuai. Implementasi Supabase yang ada ditandai sebagai « ⚠️ Ditinggalkan ».
Di luar rencana gratis, ada alasan arsitektur. Situs ini berada di ekosistem Google: repositori target adalah`cheroliv.github.io`, CNAME menunjuk ke GitHub Pages, build Gradle melakukan push ke GitHub melalui JGit. Menambahkan layanan Google (Firebase) alih-alih layanan pihak ketiga (Supabase) mengurangi permukaan penyebaran.
Firestore dalam mode native (bukan mode Datastore) juga lebih dekat dengan model mental NoSQL dokumen yang saya miliki: koleksi, dokumen, kolom bertipe, stempel waktu server, aturan keamanan terintegrasi.
Tahap 1 : Membuat proyek Firebase
Inisialisasi
Karena CLI Firebase tidak terinstal di komputer saya, saya menggunakan konsol web:
-
Pergi kehttps://console.firebase.google.com/[Konsol Firebase]
-
Membuat proyek`cheroliv-contact`(atau menggunakan kembali proyek yang ada)
-
Aktifkan Firestore dalam mode asli (bukan Datastore)
-
Membuat database di wilayah`eur3`(Eropa)
Untuk penggunaan minimalis seperti milik kita (satu koleksi, penulisan publik), mode asli adalah pilihan yang tepat. Tidak perlu aturan Datastore yang kompleks.
Aturan keamanan Firestore
Formulir ini bersifat publik — siapa pun dapat mengirim pesan. Tapi saya ingin membatasi penyalahgunaan:
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;
}
}
}
poin kunci :
-
allow read— hanya pengguna terautentikasi yang dapat membaca pesan (saya, melalui konsol Firebase) -
allow create— siapa pun dapat membuat dokumen, tetapi dengan validasi kolom -
Validasi sisi server : ukuran min/max, format email,
created_at`harus sesuai dengan`request.time(anti-falsifikasi) -
`user_agent`dikirim untuk pelacakan (tidak kritis tetapi berguna)
Aturan ini lebih ketat daripada yang sederhana`allow write: if true;`. Mereka mencegah seorang penyerang menginjeksi payload besar atau bidang yang tidak terformat dengan benar.
Tahap 2 : Menulis ulang JavaScript pengiriman
Kontraktnya sederhana :
-
Membaca data formulir
-
Periksa honeypot (kolom`hp_name`— jika itu terisi, itu adalah bot, kita mensimulasikan keberhasilan tanpa mengirimkan apa-apa)
-
memanggil`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…})`
-
Menampilkan keberhasilan atau kesalahan
Ketergantungan `window.FIREBASE
dalam`footer.thyme`, skrip modul menginisialisasi Firebase SDK dan mengekspos objek 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>
Skrip modul berjalan sebelum`DOMContentLoaded`, maka`window.FIREBASE`dijamin tersedia ketika handler`contact.js`Memicu. Untuk berhati-hati, saya tetap menambahkan polling 5 detik jika CDN lambat.
Baru `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);
});
Perubahan dibandingkan dengan mock:
-
waitForFirebase()— polling dengan timeout, kuat bahkan jika CDN lambat -
honeypot— jika bidang tersembunyi`hp_name`terisi, mensimulasikan kesuksesan pasti Firestore. Bot berpikir telah berhasil tetapi tidak ada yang disimpan. -
addDoc(collection, {…})— panggilan Firestore yang sebenarnya dengan`serverTimestamp()` etuser_agent -
Manajemen kesalahan dengan`try/catch`asinkron
-
Pembersihan`finally`(pemulihan tombol)
|
mengapa`user_agent`? Ini opsional, tapi berguna untuk diagnosis. Jika pesan aneh tiba, mengetahui apakah berasal dari browser desktop, seluler, atau skrip curl membantu dalam penyortiran. |
Fase 3 : Membersihkan kode mati Supabase
`script.js`mengandung 250 baris kode mati :
-
SupabaseManager(baris 417-481) — 65 baris -
ContactFormHandler(baris 490-551) — 62 baris -
Blok inisialisasi (baris 645-654) — 10 baris
Total : ~140 baris yang akan dihapus.
Blok`DOMContentLoaded`membuat sebuah`SupabaseManager`lalu satu`ContactFormHandler`ditekatkan pada formulir. Seperti yang dijelaskan di atas, kode ini tidak pernah dieksekusi (diblokir oleh`contact.js`), dan bahkan jika dieksekusi, ia akan gagal (tidak ada SDK Supabase yang dimuat).
Saya menghapus:
-
Kelas`SupabaseManager`
-
Kelas`ContactFormHandler`
-
Blok inisialisasi dalam`DOMContentLoaded`(baris 645-654)
Le reste de script.js`masih utuh :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.
Phase 4 : Mengatur footer
`footer.thyme`sudah memiliki boilerplate Firebase tetapi dengan nilai placeholder. Saya mengganti :
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"
};
Dengan nilai sebenarnya yang diperoleh sejakPengaturan Proyek > Umum > Aplikasi Anda > Aplikasi Webdi konsol Firebase.
Nilai sensitif`apiKey`adalah publik oleh desain di Firebase, namun saya lebih suka tidak melakukan commit-nya dalam bentuk jelas). Saya menyimpannya di`site.yml`(sudah dalam`.gitignore`) dan plugin`bakery`Menyuntikkan mereka ke dalam template melalui logika yang ditambahkan pada sisi build.
Saat ini, saya menempatkannya langsung di`footer.thyme`— build`./gradlew serve`akan memuatnya secara lokal. Saat penyebaran, saya akan memigrasikan inyeksi ke`site.yml`atau ke variabel Gradle.
|
L'`apiKey`Firebase bukanlangkahsebuah rahasia. Ia bersifat publik oleh desain. Yang melindungi data Anda adalahperaturan keamanan Firestore, not the API key. Do not put it in a`.env`dimuat sisi server — ia dimaksudkan untuk diekspos ke peramban |
Tahap 5 : Arsitektur akhir
@startuml
skinparam backgroundColor #FEFEFE
title Arsitektur Final — Formulir Kontak Firebase
actor Utilisateur as USER
package "peramban" #E8F5E9 {
rectangle "contact.thyme\n(HTML, honeypot)" as FORM
rectangle "contact.js\n(validation + Firestore)" as CONT
rectangle "footer.thyme
(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
Apa yang dikatakan oleh migrasi ini tentang dogfooding
Situs ini dihasilkan oleh plugin Gradle saya sendiri.bakery. Formulir kontak berada di dalam situs. Migrasi Supabase → Firebase didokumentasikan dalam`AGENT.adoc`, dia dibahas dalam backlog, dia diuji melalui`./gradlew serve`, dan ia menghasilkan sebuah postingan blog (yang sedang Anda baca).
Ini adalah dogfooding murni. Situs adalah produk dari plugin, plugin adalah produk dari pengembang, pengembang mendokumentasikan proses di situs itu sendiri.
Sudah lengkap lingkarannya.
Fakta bahwa saya telah menahan sebuah mock selama berbulan-bulan (sesi penuh di mana formulir itu diam-diam menyalahkan) membuatku menyadari sesuatu: backlog dari sebuah situs statis pribadi tidak pernah 'selesai'. Selalu ada US prioritas, selalu ada artikel dalam draft, selalu ada bagian yang dikomentari dalam template. Disiplin bukanlah untuk menyelesaikan semuanya — melainkan menyelesaikan apa yang terlihat oleh pengguna.
Formulir kontak yang rusak lebih buruk daripada tidak ada formulir sama sekali. Itu adalah janji yang tidak dipenuhi.
Ringkasan perubahan
Berkas |
Modifikasi |
Dampak |
|
Pembuatan artikel |
Dokumentasi |
|
Penulisan ulang (mock → Firestore sebenarnya) |
fungsional |
|
Penghapusan SupabaseManager + ContactFormHandler + init blok |
Pembersihan |
|
Penggantian config placeholder → nilai nyata |
Konfigurasi |
Langkah selanjutnya (backlog)
-
Email pemberitahuan: Sebuah Cloud Function`onCreate`pada`contact_messages`yang mengirim email melalui SendGrid. Formulir menyimpan, tetapi saya tidak mendapatkan pemberitahuan. Prioritas sedang — pesan terlihat di konsol Firebase.
-
Rate limiting sisi klien: Menambahkan timestamp localStorage untuk mencegah pengiriman berulang dalam satu rafalan. Honeypot memblokir bot yang naif, rate limiter akan memblokir bot yang sedikit lebih cerdas.
-
Uji: Sebuah tes Playwright yang mengirimkan formulir dan memverifikasi bahwa dokumen muncul di Firestore. Saat ini, saya menguji secara manual melalui`./gradlew serve`.