время чтения : 12 minutes

В течение месяцев контактная форма этого сайта работала на mock JavaScript — обещание 85 % успеха, поддельный Firestore, ноль сохранённых данных. Исходный план предполагал бэкенд Supabase с Google Apps Script для почтовых уведомлений. Отброшено. Сегодня я рассказываю о миграции на Firebase Firestore: создание проекта, правила безопасности, переписывание JS, очистка от мёртвого кода Supabase. И почему этот выбор говорит о более широкой философии разработки.

Сцена: форма, которая ничего не хранит

Этот сайт генерируется JBake, мой плагин Gradle`bakery`. Он на 100 % статический — нет backend, нет базы данных. Но у меня есть форма обратной связи. Страница`contact.html`существует, HTML готов (поля имя, email, телефон, тема, сообщение, валидация HTML5, хонеypot anti-spam), стили 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. Триггер SQL, вызывающий вебхук Google Apps Script

  4. Google Apps Script, который отправляет уведомление по электронной почте Gmail

Соответствующий JavaScript-код всё ещё существует в`script.js`. Есть класс`SupabaseManager`который инициализирует клиент Supabase с глобальными переменными`SUPABASE_URL` et SUPABASE_KEY, и класс`ContactFormHandler`который слушает событие submit формы и вызывает`SupabaseManager.submitContactForm()`.

Проблема: эти глобальные переменные больше не вставляются в нижний колонтитул. Le`<script src="supabase-js">`был удалён. Код вызывает`supabase.createClient()`на переменной`supabase`которого больше нет. Поэтому:

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

Не только данные не сохраняются, но и код отправки мёртв.

Двойная призрачная отправка

Чтобы усугубить ситуацию, естьтихая конкуренциямежду двумя обработчиками на одной форме :

  1. `contact.js`слушай submit, вызови мок Firebase

  2. script.js— через`ContactFormHandler`— слушай также submit, зovi`SupabaseManager`

Оба делают`event.preventDefault() + event.stopPropagation(). Как`contact.js`загружается первым в`footer.thyme, его обработчик прикреплен первым. Он блокирует распространение.`ContactFormHandler`nunca не будет срабатывать.

Это даже не активная ошибка — это зомби. Код, который никогда не получает шанс выполниться.

@startuml
skinparam backgroundColor #FEFEFE

title Начальное состояние — Форма контакта
rectangle "contact.thyme\n(HTML Bootstrap, honeypot)" as FORM
rectangle "footer.thyme\n(Firebase SDK с config placeholder)" 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.5s, случайный)
note right of CONT
⚠️ Данные не хранятся
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 отправляется в GitHub через JGit. Добавление сервиса Google (Firebase) вместо стороннего сервиса (Supabase) уменьшает поверхность дисперсии.

Firestore в нативном режиме (не в режиме Datastore) также ближе к ментальной модели NoSQL‑документа, которую я держу в голове: коллекции, документы, типизированные поля, серверные таймстемпы, встроенные правила безопасности.

Этап 1: Создать проект Firebase

Инициализация

CLI Firebase не установлен на моей машине, поэтому я использую веб-консоль:

  1. Перейти наhttps://console.firebase.google.com/[Firebase Console]

  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— любой может создать документ, но с проверкой полей

  • Валидация на стороне сервера: минимальный/максимальный размер, формат email,created_at`должен соответствовать`request.time(анти-фальсификация)

  • `user_agent`отправляется для трассировки (не критично, но полезно)

Эти правила более строгие, чем простое`allow write: if true;`. Они препятствуют атакующему внедрять огромные полезные нагрузки или неправильно сформированные поля.

Фаза 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`гарантированно доступен когда обработчик`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`уже есть boilerplate Firebase, но с placeholder-значениями. Я заменяю :

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

По реальным значениям, полученным изНастройки проекта > Общие > Ваши приложения > Веб-приложениев консоли Firebase.

Значения чувствительны`apiKey`но я предпочитаю не коммитить их в открытом виде). Я их храню в`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 Rules :
- create: публичный, подтверждённый
- read: только аутентифицированные
end note

@enduml

Что эта миграция говорит о dogfooding

Этот сайт сгенерирован моим собственным плагином Gradle`bakery`. Форма обратной связи находится внутри сайта. Миграция Supabase → Firebase документирована в`AGENT.adoc`, она обсуждается в бэклоге, она тестируется через`./gradlew serve`, и она генерирует пост в блоге (тот, который вы читаете).

Это чистый dogfooding. Сайт — это продукт плагина, плагин — это продукт разработчика, разработчик документирует процесс на самом сайте.

Цикл замкнут.

Тот факт, что я тянул макет месяцами (целыми сессиями, когда форма молча вводила в заблуждение), заставил меня осознать одно: бэклог личного статического сайта никогда не бывает «завершён». Всегда есть приоритетная пользовательская история, всегда есть статья в черновике, всегда есть закомментированный участок в шаблоне. Дисциплина заключается не в том, чтобы всё завершить — а в том, чтобы завершить то, что видит пользователь.

Сломанный контактный формуляр хуже, чем отсутствие формуляра вообще. Это невыполненное обещание.

Сводка изменений

Файл Модификация Влияние

blog/2026/0113_*.adoc

Создание статьи

Документация

assets/js/contact.js

Переписывание (mock → реальный Firestore)

Функциональный

assets/js/script.js

Удаление SupabaseManager + ContactFormHandler + инициализация блока

очистка

templates/footer.thyme

Замена placeholder конфигурации → реальные значения

Конфигурация

Следующие шаги (backlog)

  • Уведомление по email: Облачная функция`onCreate`на`contact_messages`Отправляет письмо через SendGrid. Форма сохраняет данные, но я не получаю уведомления. Средний приоритет — сообщения видны в консоли Firebase.

  • Ограничение скорости на стороне клиента: Добавить timestamp в localStorage, чтобы предотвратить множественные отправки подряд. honeypot блокирует наивных ботов, а rate limiter блокировал бы более хитрых ботов.

  • Тесты: Тест Playwright, который отправляет форму и проверяет, что документ появляется в Firestore. Пока я тестирую вручную через`./gradlew serve`.

Похожие статьи