読了時間 : 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. 1つのRPC`handle_contact_form`サーバー サイド

  3. Google Apps Script のウェブフックを呼び出す SQL トリガー

  4. Gmail の通知メールを送信する Google Apps Script

対応する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.'

データは保存されないだけでなく、提出コードは死んでいる。

幽霊の二重提出

事態をさらに悪化させるために、ある静かな競争同じフォーム上の2つのハンドラー間で :

  1. `contact.js`送信を聞き取り、Firebaseモックを呼び出す

  2. script.js— 経由して`ContactFormHandler`— サブミットも聞いて、呼び出す`SupabaseManager`

両方は`event.preventDefault() + event.stopPropagation(). ように`contact.js`最初にロードされます`footer.thyme, そのハンドラーは最初に接続されます。それが伝播をブロックします。`ContactFormHandler`決してトリガーされません。

これはアクティブなバグではなく、むしろゾンビです。決して実行される機会のないコード。

@startuml
skinparam backgroundColor #FEFEFE

title 初期状態 — お問い合わせフォーム
rectangle "contact.thyme\n(HTML Bootstrap, ハニーポット)" as FORM
rectangle "footer.thyme\n(Firebase SDK プレースホルダー構成)" as FOOT
rectangle "contact.js
(mock Firebase, 85% 成功)" as CONT
rectangle "script.js\n(SupabaseManager + ContactFormHandler, 死者)" 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

なぜFirebaseをSupabaseの代わりに使うのか?

移行の決定は文書化されています`AGENT.adoc`:

Firebaseは次の理由から選ばれています:無料プランが最適、ネイティブなFirestore、統合されたCloud Functions、Googleエコシステムに適合。既存のSupabase実装は「⚠️ 廃止済み」とマークされています。

無料プランを超えると、アーキテクチャ上の理由があります。このサイトはGoogleのエコシステム内に存在します:ターゲットリポジトリは`cheroliv.github.io`, CNAMEはGitHub Pagesを指します。GradleビルドはJGitを介してGitHubにプッシュされます。サードパーティ製サービス(Supabase)ではなく、Googleサービス(Firebase)を追加すると、拡散領域が削減されます。

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;`. それらは攻撃者が巨大なペイロードや不正なフィールドを注入するのを防ぎます。

フェーズ 2: 提出のJavaScriptを書き直す

契約はシンプルです:

  1. フォームのデータを読み込む

  2. honeypot (フィールド)を確認する`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`はハンドラーのとき利用可能であることが保証されます`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スクリプトから来ているかを知ると、振り分けに役立ちます。

Phase 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のボイラープレートがありますが、プレースホルダー値のため、私は置き換えます:

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 初期化)" as FOOT
}

cloud "ファイアベース" #BBDEFB {
  rectangle "Firestore\n(連絡メッセージ)" 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

この移行がドッグフーディングについて何を言っているか

このサイトは、私の独自のGradleプラグインで生成されます。bakery. コンタクトフォームはサイト内に存在します。 Supabase → Firebase への移行はドキュメント化されています。AGENT.adoc, バックログで議論され、経由でテストされています`./gradlew serve`, そして、それはブログ記事を生成します(あなたが今読んでいるものです)。

これは純粋なドッグフーディングです。サイトはプラグインの製品であり、プラグインは開発者の製品であり、開発者はサイト自身でプロセスを文書化しています。

一周回った。

何ヶ月もモックを抱え続けていたこと(フォームが黙って嘘をつく、まるまる一日のセッションが続いたこと)が、私にあることに気づかせた。個人の静的サイトのバックログは決して「終わる」ものではない。常に優先されるユーザーストーリーがあり、常にドラフトの記事があり、常にテンプレート内にコメントアウトされたセクションがある。規律とは、すべてを終わらせることではなく、ユーザーに見えるものを終わらせることである。

�壊れたお問い合わせフォームは、フォームがないよりも悪い。それは守られなかった約束だ。

変更のまとめ

ファイル

変更

インパクト

blog/2026/0113_*.adoc

記事の作成

ドキュメンテーション

assets/js/contact.js

書き換え(モック→実際のFirestore)

機能的

assets/js/script.js

SupabaseManager + ContactFormHandler + init bloc の削除

�掃除

templates/footer.thyme

設定プレースホルダーの置換 → 実際の値

構成

次のステップ (バックログ)

  • 通知メール: 一つのクラウドファンクション`onCreate`の上に`contact_messages`SendGrid を介してメールを送信します。フォームはデータを保存しますが、私は通知を受けません。優先度は中 — メッセージは Firebase コンソールで表示されます。

  • クライアント側レート制限: localStorageにタイムスタンプを追加して、連続した複数の送信を防ぎます。ハニーポットは初心者ボットをブロックし、レートリミッターはやや賢いボットをブロックします。

  • テスト: フォームを送信し、ドキュメントがFirestoreに表示されることを確認するPlaywrightのテスト。現在、手動でテストしています via`./gradlew serve`。

関連記事