thời gian đọc: 12 minutes

Trong nhiều tháng, biểu mẫu liên hệ của trang web này đã chạy trên một mock JavaScript — một lời hứa với 85% thành công, một Firestore giả, không có dữ liệu nào được lưu trữ. Kế hoạch ban đầu dự kiến một backend Supabase cùng với Google Apps Script để gửi thông báo email. Đã bị bỏ dở. Hôm nay, tôi kể về quá trình di chuyển sang Firebase Firestore: khởi tạo dự án, thiết lập quy tắc bảo mật, viết lại JS, dọn dẹp mã Supabase dư thừa. Và tại sao lựa chọn này lại nói lên điều gì đó rộng lớn hơn về triết lý phát triển.

Ngữ cảnh: một biểu mẫu không lưu trữ bất cứ thứ gì

Trang web này được tạo bởi JBake, plugin Gradle của tôi`bakery`. Nó tĩnh 100% — không backend, không cơ sở dữ liệu. Ngoại trừ việc tôi có một biểu mẫu liên hệ. Trang`contact.html`tồn tại, HTML đã sẵn sàng (các trường tên, email, điện thoại, chủ đề, tin nhắn, xác thực HTML5, honeypot chống spam), các kiểu dáng Bootstrap đã được thiết lập. Về mặt hình ảnh, mọi thứ đều hoàn hảo.

Có điều là khi nhấn gửi, không có gì xảy ra cả.

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);
});

Một bản mock. Một promise giả vờ. Người dùng thấy một spinner, sau đó là một thông báo « Message envoyé avec succès ! ». Nhưng dữ liệu lại gửi vào hư vô. Không có tin nhắn nào được lưu trữ ở bất cứ đâu.

Tình huống này còn tệ hơn một biểu mẫu bị hỏng — đó là một biểu mẫu biết nói dối.

Di sản Supabase

Kế hoạch ban đầu, được ghi chép trong`content/draft/integration_formulaire_contact_supabase.adoc`, đã dự kiến:

  1. Một cơ sở dữ liệu Supabase với bảng`contacts`và Row Level Security

  2. Một RPC`handle_contact_form`phía máy chủ

  3. Một trigger SQL gọi một webhook Google Apps Script

  4. Google Apps Script gửi email thông báo qua Gmail

Mã JavaScript tương ứng vẫn còn tồn tại trong`script.js`. Có một lớp`SupabaseManager`mà khởi tạo một client Supabase với các biến toàn cục`SUPABASE_URL` et SUPABASE_KEY, và một lớp`ContactFormHandler`mà lắng nghe sự kiện submit của biểu mẫu và gọi`SupabaseManager.submitContactForm()`Vui lòng cung cấp văn bản cần dịch.

Vấn đề: các biến toàn cục này không còn được inject vào footer. Le`<script src="supabase-js">`đã bị xóa. Mã gọi`supabase.createClient()`trên một biến`supabase`không còn tồn tại. Vì vậy:

console.error : 'Supabase client library (supabase-js) is not loaded.'

Không những dữ liệu không được lưu trữ, mà mã gửi đi cũng đã chết.

Sự nộp bài bóng ma kép

Tệ hơn nữa, có mộtcạnh tranh trong im lặnggiữa hai handler trên cùng một biểu mẫu:

  1. `contact.js`lắng nghe submit, gọi mock Firebase

  2. script.js— thông qua`ContactFormHandler`— cũng lắng nghe submit, gọi`SupabaseManager`

Cả hai đều làm`event.preventDefault() + event.stopPropagation()`. Như`contact.js`được tải đầu tiên vào`footer.thyme`l’handler của nó được gắn vào trước. Nó chặn sự lan truyền.`ContactFormHandler`sẽ không bao giờ được kích hoạt.

Đây thậm chí không phải là một bug đang hoạt động — nó là một thây ma. Là đoạn mã không bao giờ có cơ hội được thực thi.

@startuml
skinparam backgroundColor #FEFEFE

title Trạng thái ban đầu — biểu mẫu liên hệ
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% thành công)" as CONT
rectangle "script.js\n(SupabaseManager + ContactFormHandler, tử vong)" as SCRIPT
rectangle "Người dùng" as USER

USER -> FORM : Gửi biểu mẫu
FORM -> CONT : sự kiện gửi (đính kèm trước)
CONT -> CONT : preventDefault + stopPropagation
CONT -> CONT : Promise giả lập (1.5s, ngẫu nhiên)
note right of CONT
Không có dữ liệu được lưu trữ
end note

FORM ..> SCRIPT : submit event (được chặn bởi stopPropagation)
note right of SCRIPT : Chưa từng được kích hoạt
note right of SCRIPT : supabase-js chưa được tải

@enduml

Tại sao nên chọn Firebase thay vì Supabase?

Quyết định di chuyển được ghi chép trong`AGENT.adoc`Vui lòng cung cấp văn bản cần dịch.

Firebase hiện được chọn vì những lý do sau: gói miễn phí tốt hơn, Firestore gốc, Cloud Functions tích hợp, hệ sinh thái Google phù hợp hơn. Việc triển khai Supabase hiện tại được đánh dấu là « ⚠️ Abandonné ».

Ngoài gói miễn phí, còn có một lý do về mặt kiến trúc. Trang web này nằm trong hệ sinh thái Google: kho lưu trữ đích là`cheroliv.github.io`, bản ghi CNAME trỏ đến GitHub Pages, bản build Gradle được push lên GitHub thông qua JGit. Việc thêm một dịch vụ của Google (Firebase) thay vì một dịch vụ bên thứ ba (Supabase) giúp giảm bớt bề mặt phân tán.

Firestore ở chế độ native (không phải chế độ Datastore) cũng gần gũi hơn với mô hình tư duy NoSQL document mà tôi đang hình dung: collections, documents, các trường được định kiểu, server timestamps, và các quy tắc bảo mật tích hợp.

Giai đoạn 1: Tạo dự án Firebase

Khởi tạo

Vì Firebase CLI chưa được cài đặt trên máy của tôi, tôi sẽ sử dụng console web:

  1. Đi đếnhttps://console.firebase.google.com/[Firebase Console]

  2. Tạo dự án`cheroliv-contact`(hoặc tái sử dụng một dự án hiện có)

  3. Kích hoạt Firestore ở chế độ native (không phải Datastore)

  4. Tạo cơ sở dữ liệu trong vùng`eur3`(Châu Âu)

Đối với nhu cầu sử dụng tối giản như của chúng tôi (một collection duy nhất, ghi công khai), chế độ native là lựa chọn đúng đắn. Không cần đến các quy tắc Datastore phức tạp.

Quy tắc bảo mật Firestore

Biểu mẫu này là công khai — bất kỳ ai cũng có thể gửi tin nhắn. Nhưng tôi muốn hạn chế việc lạm dụng:

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;
    }
  }
}

Các điểm chính:

  • allow read— chỉ những người dùng đã được xác thực mới có thể đọc tin nhắn (tôi, thông qua console Firebase)

  • allow create— bất kỳ ai cũng có thể tạo tài liệu, nhưng có xác thực các trường

  • Xác thực phía máy chủ: kích thước tối thiểu/tối đa, định dạng email,created_at`phải khớp với`request.time(chống làm giả)

  • `user_agent`được gửi để truy xuất nguồn gốc (không quan trọng nhưng hữu ích)

Những quy tắc này nghiêm ngặt hơn một đơn thuần`allow write: if true;`Chúng ngăn chặn kẻ tấn công chèn các payload khổng lồ hoặc các trường không đúng định dạng.

Giai đoạn 2: Viết lại JavaScript gửi biểu mẫu

Hợp đồng rất đơn giản:

  1. Đọc dữ liệu từ biểu mẫu

  2. Kiểm tra honeypot (trường`hp_name`— nếu nó được điền, đó là một bot, chúng ta giả lập thành công mà không gửi bất cứ điều gì)

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

  4. Hiển thị thành công hoặc lỗi

Sự phụ thuộc `window.FIREBASE

Trong`footer.thyme`, một script module khởi tạo Firebase SDK và công khai một đối tượng toàn cục:

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

Các script module chạy trước`DOMContentLoaded`, vì vậy`window.FIREBASE`được đảm bảo khả dụng khi handler`contact.js`được kích hoạt. Để thận trọng, tôi vẫn thêm một lần polling 5 giây trong trường hợp CDN bị chậm.

File contact.js mới

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);
});

Những thay đổi so với mock:

  • waitForFirebase()— polling có timeout, hoạt động ổn định ngay cả khi CDN chậm

  • honeypot— nếu trường ẩn`hp_name`đã được điền, mô phỏng thành công mà không cần gọi Firestore. Bot tin rằng đã thành công nhưng không có gì được lưu trữ

  • addDoc(collection, {…​})— lệnh gọi Firestore thực tế với`serverTimestamp()` et user_agent

  • Xử lý lỗi với`try/catch`bất đồng bộ

  • Dọn dẹp`finally`(khôi phục nút)

Tại sao`user_agent`? Điều này là tùy chọn, nhưng hữu ích cho việc chẩn đoán. Nếu có một thông báo lạ xuất hiện, việc biết nó đến từ trình duyệt desktop, di động hay một script curl sẽ giúp ích cho việc phân loại.

Giai đoạn 3: Dọn dẹp mã thừa Supabase

`script.js`chứa 250 dòng dead code:

  • SupabaseManager(dòng 417-481) — 65 dòng

  • ContactFormHandler(dòng 490-551) — 62 dòng

  • Khối khởi tạo (dòng 645-654) — 10 dòng

Tổng cộng: ~140 dòng cần xóa.

Khối`DOMContentLoaded`tạo một`SupabaseManager`sau đó một`ContactFormHandler`được gắn với biểu mẫu. Như đã giải thích ở trên, mã này không bao giờ được thực thi (bị chặn bởi`contact.js`), và ngay cả khi nó được thực thi, nó cũng sẽ thất bại (không có SDK Supabase nào được tải).

Tôi xóa:

  1. Lớp`SupabaseManager`

  2. Lớp`ContactFormHandler`

  3. Khối khởi tạo trong`DOMContentLoaded`(dòng 645-654)

Le reste de script.js`vẫn còn nguyên vẹn :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, `PhoneInputManager`Vui lòng cung cấp văn bản cần dịch.

`footer.thyme`đã có sẵn boilerplate Firebase nhưng với các giá trị placeholder. Tôi thay thế:

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"
};

Bằng các giá trị thực tế được lấy từCài đặt dự án > Chung > Ứng dụng của bạn > Ứng dụng webtrong bảng điều khiển Firebase.

Các giá trị là nhạy cảm (apiKey`là mặc định là công khai trong Firebase, nhưng tôi muốn tránh commit chúng dưới dạng văn bản thuần túy). Tôi lưu trữ chúng trong`site.yml(đã ở trong`.gitignore`) và plugin`bakery`tiêm chúng vào template thông qua một logic cần thêm ở phía build.

Hiện tại, tôi cho chúng trực tiếp vào`footer.thyme`— bản build`./gradlew serve`sẽ tải chúng cục bộ. Khi triển khai, tôi sẽ di chuyển việc tiêm (injection) sang`site.yml`hoặc tới một biến Gradle.

L'`apiKey`Firebase không phải làkhôngmột bí mật. Nó được thiết kế để công khai. Điều bảo vệ dữ liệu của bạn chính là cácquy tắc bảo mật Firestore, không phải khóa API. Đừng đặt nó vào trong một`.env`được tải ở phía máy chủ — nó được dùng để hiển thị cho trình duyệt.

Giai đoạn 5: Kiến trúc cuối cùng

@startuml
skinparam backgroundColor #FEFEFE

title Kiến trúc cuối cùng — biểu mẫu liên hệ Firebase
actor Utilisateur as USER

package "Trình duyệt" #E8F5E9 {
  rectangle "contact.thyme\n(HTML, honeypot)" as FORM
  rectangle "contact.js\n(xác thực + Firestore)" as CONT
  rectangle "footer.thyme\n(Firebase SDK init)" as FOOT
}

cloud "Firebase" #BBDEFB {
  rectangle "Firestore\n(contact_messages)" as FS
}

FORM --> CONT : Gửi sự kiện
CONT --> FOOT : window.__FIREBASE__.addDoc()
FOOT --> FS : Insert document\n(quy tắc an toàn đã được phê duyệt)

note right of FS
Firestore Rules :
- tạo: công khai, đã được xác thực
- đọc: chỉ cho người dùng đã xác thực
end note

@enduml

Điều mà cuộc di cư này nói về dogfooding

Trang web này được tạo bởi plugin Gradle của riêng tôi`bakery`. Biểu mẫu liên hệ nằm bên trong trang web. Quá trình di chuyển Supabase → Firebase được ghi chép trong`AGENT.adoc`, nó được thảo luận trong backlog, nó được kiểm thử thông qua`./gradlew serve`, và nó tạo ra một bài viết blog (bài mà bạn đang đọc).

Đây thuần túy là dogfooding. Trang web là sản phẩm của plugin, plugin là sản phẩm của nhà phát triển, và nhà phát triển tài liệu hóa quy trình ngay trên chính trang web đó.

Vòng tròn đã khép lại.

Việc kéo dài một bản mock trong nhiều tháng (những phiên làm việc mà biểu mẫu im lặng nói dối) đã khiến tôi nhận ra một điều: backlog của một trang web tĩnh cá nhân không bao giờ "kết thúc". Luôn có một US ưu tiên, luôn có một bài viết đang nháp, luôn có một phần bị comment trong một template. Kỷ luật không phải là hoàn thành tất cả mọi thứ — mà là hoàn thành những gì hiển thị với người dùng.

Một biểu mẫu liên hệ bị hỏng còn tệ hơn là không có biểu mẫu nào. Đó là một lời hứa không được thực hiện.

Tóm tắt các thay đổi

Tệp Chỉnh sửa Tác động

blog/2026/0113_*.adoc

Tạo bài viết

Tài liệu hướng dẫn

assets/js/contact.js

Viết lại (mock → Firestore thực tế)

Chức năng

assets/js/script.js

Loại bỏ SupabaseManager + ContactFormHandler + init bloc

Làm sạch

templates/footer.thyme

Thay thế config placeholder → giá trị thực tế

Cấu hình

Các bước tiếp theo (backlog)

  • Email thông báo: Một Cloud Function`onCreate`trên`contact_messages`người gửi email qua SendGrid. Biểu mẫu có lưu dữ liệu, nhưng tôi không nhận được thông báo. Ưu tiên trung bình — các tin nhắn vẫn hiển thị trong console Firebase.

  • Giới hạn tốc độ phía client: Thêm một timestamp localStorage để ngăn chặn việc gửi nhiều yêu cầu liên tục. Honeypot chặn các bot đơn giản, còn rate limiter sẽ chặn các bot tinh vi hơn một chút.

  • Kiểm thử: Một bài kiểm tra Playwright gửi biểu mẫu và xác minh rằng tài liệu xuất hiện trong Firestore. Hiện tại, tôi đang kiểm tra thủ công thông qua`./gradlew serve`Vui lòng cung cấp văn bản cần dịch.

Bài viết liên quan