Supabase から Firebase への移行:Firestore にバックエンドなしでお問い合わせフォームを接続
公開日: 29 April 2026
数ヶ月間、このサイトのお問い合わせフォームは 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`, 予定していた :
-
Supabaseのデータベースとテーブル`contacts`と Row Level Security
-
1つのRPC`handle_contact_form`サーバー サイド
-
Google Apps Script のウェブフックを呼び出す SQL トリガー
-
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つのハンドラー間で :
-
`contact.js`送信を聞き取り、Firebaseモックを呼び出す
-
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 がインストールされていないため、ウェブコンソールを使用しています:
-
移動する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— 誰でもドキュメントを作成できますが、フィールドのバリデーションがあります -
サーバーサイドバリデーション:最小/最大サイズ、メール形式、
created_at`それに対応しなければならない`request.time(偽造防止) -
`user_agent`トレーサビリティのために送信されます(クリティカルではありませんが、便利です)
これらのルールは単純なものよりも厳格です`allow write: if true;`. それらは攻撃者が巨大なペイロードや不正なフィールドを注入するのを防ぎます。
フェーズ 2: 提出のJavaScriptを書き直す
契約はシンプルです:
-
フォームのデータを読み込む
-
honeypot (フィールド)を確認する`hp_name`— それが埋められているなら、それはボットで、何も送信せずに成功をシミュレートする)
-
呼ぶ`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…})`
-
成功またはエラーを表示
依存関係 `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()` etuser_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がロードされていません)。
�削除します:
-
クラス`SupabaseManager`
-
クラス`ContactFormHandler`
-
初期化ブロックの中`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`, そして、それはブログ記事を生成します(あなたが今読んでいるものです)。
これは純粋なドッグフーディングです。サイトはプラグインの製品であり、プラグインは開発者の製品であり、開発者はサイト自身でプロセスを文書化しています。
一周回った。
何ヶ月もモックを抱え続けていたこと(フォームが黙って嘘をつく、まるまる一日のセッションが続いたこと)が、私にあることに気づかせた。個人の静的サイトのバックログは決して「終わる」ものではない。常に優先されるユーザーストーリーがあり、常にドラフトの記事があり、常にテンプレート内にコメントアウトされたセクションがある。規律とは、すべてを終わらせることではなく、ユーザーに見えるものを終わらせることである。
�壊れたお問い合わせフォームは、フォームがないよりも悪い。それは守られなかった約束だ。
変更のまとめ
ファイル |
変更 |
インパクト |
|
記事の作成 |
ドキュメンテーション |
|
書き換え(モック→実際のFirestore) |
機能的 |
|
SupabaseManager + ContactFormHandler + init bloc の削除 |
�掃除 |
|
設定プレースホルダーの置換 → 実際の値 |
構成 |
次のステップ (バックログ)
-
通知メール: 一つのクラウドファンクション`onCreate`の上に`contact_messages`SendGrid を介してメールを送信します。フォームはデータを保存しますが、私は通知を受けません。優先度は中 — メッセージは Firebase コンソールで表示されます。
-
クライアント側レート制限: localStorageにタイムスタンプを追加して、連続した複数の送信を防ぎます。ハニーポットは初心者ボットをブロックし、レートリミッターはやや賢いボットをブロックします。
-
テスト: フォームを送信し、ドキュメントがFirestoreに表示されることを確認するPlaywrightのテスト。現在、手動でテストしています via`./gradlew serve`。
関連記事
31 May 2026
14 May 2026