阅读时间 : 12 minutes

几个月来,该站点的联系表单一直在一个 mock JavaScript 上运行 — 一个 85% 成功率的承诺,一个假 Firestore,零存储的数据。最初的计划是使用 Supabase 后端配合 Google Apps Script 进行邮件通知。已放弃。今天,我讲述向 Firebase Firestore 迁移的过程: 创建 projet,règles de sécurité,重写 JS,清理死掉的 Supabase 代码。而且这个选择说明了更广泛的开发哲学。

场景:一个不存储任何内容的表单

此站点由 JBake 生成,这是我的 Gradle 插件`bakery`. 它是 100% 静态的 — 没有后端,没有数据库。除了我有一个联系表单。页面`contact.html`存在,HTML 已准备好(字段:姓名、邮箱、电话、主题、消息、HTML5 验证、防垃圾蜜罐),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);
});

一个模拟。一个假装的承诺。用户看到一个加载器,然后看到一条消息 « 消息已成功发送! )。但数据被送入虚无。没有任何消息被存储在任何地方。

情况比一个损坏的表单更糟 — 那是一个在撒谎的表单。

Supabase的遗产

初始计划,记录在`content/draft/integration_formulaire_contact_supabase.adoc`, 预见了 :

  1. 一个 Supabase 数据库带有表`contacts`和 Row Level Security

  2. 一个RPC`handle_contact_form`服务器端

  3. 一个调用 Google Apps Script webhook 的 SQL 触发器

  4. Google Apps Script 发送 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.'

不仅数据没有存储,提交代码也已经死了。

双重幽灵提交

为了使事情变得更糟,有一个无声的竞争在同一表单的两个处理程序之间 :

  1. `contact.js`监听提交,调用模拟 Firebase

  2. script.js— 通过`ContactFormHandler`—也监听提交,调用`SupabaseManager`

两者都`event.preventDefault() + event.stopPropagation(). 正如`contact.js`首先被加载在`footer.thyme, 其处理程序首先被附加。它阻止了传播。`ContactFormHandler`永远不会被触发。

这甚至不是一个活跃的 bug — 它是一个僵尸。从未有机会执行的代码。

@startuml
skinparam backgroundColor #FEFEFE

title 初始状态 — 联系表单
rectangle "contact.thyme\n(HTML Bootstrap, honeypot)" as FORM
rectangle "footer.thyme\n(Firebase SDK 带配置占位符)" as FOOT
rectangle "contact.js\n(mock Firebase, 85% 成功)" as CONT
rectangle "script.js\n(SupabaseManager + ContactFormHandler, 死亡)" as SCRIPT
rectangle "用户" as USER

USER -> FORM : 表单提交
FORM -> CONT : 提交事件(首先附加)
CONT -> CONT : preventDefault + stopPropagation
CONT -> CONT : 模拟 Promise (1.5秒,随机)
note right of CONT
  ⚠️ Aucune donnée stockée
end note

FORM ..> SCRIPT : 提交事件 (被 stopPropagation 阻止)
note right of SCRIPT : ❌ 从未触发
note right of SCRIPT : supabase-js 未加载

@enduml

为什么选择 Firebase 而不是 Supabase?

迁移决定已记录在`AGENT.adoc`:

Firebase 现在因以下原因被选中:免费计划更好,原生 Firestore,内置 Cloud Functions,更适合的 Google 生态系统。现有的 Supabase 实现被标记为「⚠️ 已弃用」。

除了免费计划之外,还有一个架构原因。此网站处于 Google 生态系统中:目标存储库是`cheroliv.github.io`, CNAME 指向 GitHub Pages,Gradle 构建通过 JGit 推送到 GitHub。 添加 Google 服务(Firebase)而不是第三方服务(Supabase)减少攻击面。

Firestore 原生模式(不是 Datastore 模式)也更接近我心中的 NoSQL 文档模型:集合、文档、类型化字段、服务器时间戳、内置的安全规则。

阶段 1 : 创建 Firebase 项目

初始化

由于我的机器上没有安装 Firebase CLI,我正在使用网页控制台:

  1. 前往https://console.firebase.google.com/[Firebase 控制台]

  2. 创建一个项目`cheroliv-contact`(或重用现有项目)

  3. 以原生模式激活 Firestore(而非 Datastore)

  4. 在区域创建数据库`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— 任何人都可以创建文档,但需进行字段验证

  • 服务器端验证:最小/最大尺寸,电子邮件格式,created_at`必须对应`request.time(防伪)

  • `user_agent`已发送用于追溯(非关键但有用)

这些规则比简单的更严格`allow write: if true;`. 它们可以防止攻击者注入巨大的payload或格式错误的字段。

阶段2:重写提交的JavaScript

合同很简单:

  1. 读取表单数据

  2. 检查蜜罐(字段`hp_name`— 如果它被填满,那就是一个机器人,我们模拟成功而不发送任何东西)

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

  4. 显示成功或错误

依赖 `window.FIREBASE

在`footer.thyme`, 一个脚本模块初始化 Firebase SDK 并暴露一个全局对象 :

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

模块脚本在之前执行`DOMContentLoaded`, 所以`window.FIREBASE`在 handler 时保证可用`contact.js`触发。为以防万一,我仍然添加了一个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);
});

相较于模型的更改 :

  • waitForFirebase()— 带超时的轮询,即使 CDN 很慢也很稳健

  • honeypot— 如果隐藏字段`hp_name`已填满,模拟 Firestore 的无条件成功。机器人认为自己成功了,但实际上什么也没有被存储。

  • addDoc(collection, {…​})`真实的 Firestore 调用 与`serverTimestamp() et user_agent

  • 错误处理与`try/catch`异步

  • 清洁的`finally`(按钮的恢复)

为什么`user_agent`? 这是可选的,但有助于诊断。如果收到奇怪的消息,知道它来自桌面浏览器、移动浏览器还是 curl 脚本有助于排查。

阶段 3 : 清理 Supabase 的死代码

`script.js`包含250行死代码:

  • SupabaseManager(第417-481行) — 65行

  • ContactFormHandler(第490-551行) — 62 行

  • 初始化块(行 645-654)——10 行

总计:~140 行需删除。

该块`DOMContentLoaded`创建一个`SupabaseManager`然后一个`ContactFormHandler`附加到表单。如前所述,此代码永不执行(被阻止`contact.js`), 即使它执行了,也会失败(未加载 Supabase SDK).

我删除:

  1. 类`SupabaseManager`

  2. 类`ContactFormHandler`

  3. 初始化块在`DOMContentLoaded`(第645-654行)

Le reste de script.js`仍然完好:`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.

阶段4:配置页脚

`footer.thyme`已经有了 Firebase 的 boilerplate,但带有占位符值。我替换:

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

通过自…​检索到的实际值项目设置 > 常规 > 您的应用 > Web 应用在 Firebase 控制台。

值是敏感的(apiKey`它在 Firebase 中默认是公开的,但我不想把它们以明文形式提交)。我将它们存储在`site.yml(已经 在`.gitignore`) 和插件`bakery`将它们通过在构建端添加逻辑注入模板。

目前,我直接把它们放进去`footer.thyme`— 构建`./gradlew serve`将它们加载到本地。 在部署时,我将把注入迁移到`site.yml`或到一个 Gradle 变量。

L'`apiKey`Firebase 不是步一个秘密。它是公开的,这是设计使然。保护您数据的是Firestore 安全规则, 不是 API 密钥. 不要把它放在一个`.env`服务器端加载 — 它旨在暴露给浏览器。

阶段 5 : 最终架构

@startuml
skinparam backgroundColor #FEFEFE

title 最终架构 — 联系表单 Firebase
actor Utilisateur as USER

package "浏览器" #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 : 提交事件
CONT --> FOOT : window.__FIREBASE__.addDoc()
FOOT --> FS : 插入文档\n(已验证的安全规则)

note right of FS
Firestore 规则:
- create: public,已验证
- read: 仅限 auth
end note

@enduml

这次迁移关于吃狗粮的说法

这个网站由我自己的 Gradle 插件生成`bakery`. 联系表单位于站内。Supabase → Firebase 的迁移已在 …​ 中记录`AGENT.adoc`, 它在待办事项中进行讨论,它通过进行测试`./gradlew serve`, 并且它生成一篇博客文章(你正在阅读的)。

这是纯粹的自用。网站是插件的产品,插件是开发者的产品,开发者在网站本身上记录了该过程。

循环闭合。

拖延了几个月的 mock(整个会话期间表单一直在静默撒谎)让我意识到:个人静态网站的 backlog 永远不会真正“完成”。总会有一个优先的 US,总有一篇草稿文章,总有一个模板中的注释部分。纪律不是把一切都完成,而是完成用户可见的部分。

一个损坏的联系表单,比没有表单更糟。这是未兑现的承诺。

修改摘要

文件 修改 影响

blog/2026/0113_*.adoc

创建文章

文档

assets/js/contact.js

重写(mock → 真实 Firestore)

功能的

assets/js/script.js

删除 SupabaseManager + ContactFormHandler + 初始化 bloc

清洁

templates/footer.thyme

替换配置占位符 → 实际值

配置

后续步骤 (backlog)

  • 通知邮件: 一个 Cloud Function`onCreate`在`contact_messages`发送邮件通过 SendGrid。表单存储了,但我没有收到通知。优先级中等 — Firebase 控制台中的消息是可见的。

  • 客户端速率限制: 添加 localStorage 时间戳以防止批量多次提交。蜜罐会阻止天真的机器人,而速率限制器会阻止稍微更聪明的机器人。

  • 测试: 一个 Playwright 测试,提交表单并验证文档出现在 Firestore 中。目前,我通过手动方式进行测试`./gradlew serve`。

相关文章