เวลาอ่าน : `12 minutes

เป็นเวลาหลายเดือน ฟอร์มติดต่อของเว็บไซต์นี้ทำงานบน JavaScript จำลอง — Promise ที่มีโอกาสสำเร็จ 85% Firestore ปลอม และไม่มีข้อมูลถูกจัดเก็บเลย แผนเริ่มแรกวางไว้ให้ใช้ backend Supabase พร้อม Google Apps Script สำหรับการแจ้งเตือนทางอีเมล ถูกยกเลิกไปแล้ว วันนี้ฉันเล่าเกี่ยวกับการย้ายไปยัง Firebase Firestore: การสร้างโปรเจกต์ กฎความปลอดภัย การเขียน JS ใหม่ การล้างโค้ดที่ไม่ได้ใช้งานของ Supabase และทำไมทางเลือกนี้จึงสะท้อนอะไรที่กว้างกว่าด้านปรัชญาการพัฒนา

ฉาก: แบบฟอร์มที่ไม่จัดเก็บสิ่งใดเลย

เว็บไซต์นี้ถูกสร้างขึ้นโดย JBake ซึ่งเป็นปลั๊กอิน Gradle ของฉัน`bakery`. มันเป็นแบบสแตติก 100 % — ไม่มีแบ็กเอนด์ ไม่มีฐานข้อมูล ยกเว้นว่าฉันมีแบบฟอร์มติดต่อ หน้า`contact.html`มีอยู่, HTML พร้อมแล้ว (ช่องชื่อ, อีเมล, โทรศัพท์, หัวข้อ, ข้อความ, การตรวจสอบความถูกต้อง HTML5, honeypot ป้องกันสแปม), สไตล์ 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()`.

ปัญหา: ตัวแปรโกลบอลเหล่านี้ไม่ถูกฉีดเข้าไปในฟุตเตอร์อีกต่อไป`<script src="supabase-js">`ถูกลบออกแล้ว โค้ดเรียก`supabase.createClient()`บนตัวแปร`supabase`ซึ่งไม่มีอยู่อีกแล้ว ดังนั้น:

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

ไม่เพียงแต่ข้อมูลจะไม่ถูกจัดเก็บเท่านั้น แต่โค้ดสำหรับการส่งก็ใช้การไม่ได้แล้ว

การส่งซ้ำแบบผี

เพื่อทำให้เรื่องแย่ลงไปอีก มีการแข่งขันอันเงียบงันระหว่าง handler สองตัวบนฟอร์มเดียวกัน:

  1. `contact.js`ฟัง submit แล้วเรียกใช้ mock Firebase

  2. script.js— ผ่าน`ContactFormHandler`— ฟังเหตุการณ์ submit ด้วย เรียก`SupabaseManager`

ทั้งสองทำ`event.preventDefault() + event.stopPropagation(). เช่น`contact.js`ถูกโหลดก่อนใน`footer.thyme, ตัวจัดการของมันถูกผูกไว้ก่อน มันบล็อกการแพร่กระจาย`ContactFormHandler`จะไม่ถูกทริกเกอร์เลย

มันไม่ใช่แม้แต่บั๊กที่ยังทำงานอยู่ — มันคือซอมบี้ โค้ดที่ไม่เคยมีโอกาสได้ทำงาน

@startuml
skinparam backgroundColor #FEFEFE

title État Initial — Formulaire de Contact

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% succès)" as CONT
rectangle "script.js\n(SupabaseManager + ContactFormHandler, morts)" as SCRIPT
rectangle "Utilisateur" 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`No source text was provided to translate. Please send the French text you’d like translated into Thai.

ตอนนี้ Firebase ได้รับเลือกให้ใช้ด้วยเหตุผลดังต่อไปนี้: แพ็กเกจฟรีที่ดีกว่า, Firestore แบบเนทีฟ, Cloud Functions ที่ผสานรวมในตัว, ระบบนิเวศ Google ที่เหมาะสมกว่า ส่วนการใช้งาน Supabase ที่มีอยู่เดิมถูกทำเครื่องหมายว่า « ⚠️ ถูกยกเลิก

นอกเหนือจากแผนฟรีแล้ว ยังมีเหตุผลเชิงสถาปัตยกรรม เว็บไซต์นี้อยู่ในระบบนิเวศของ Google: รีโพซิทอรีเป้าหมายคือ`cheroliv.github.io`, CNAME ชี้ไปที่ GitHub Pages, การ build ด้วย Gradle จะ push ไปยัง GitHub ผ่าน JGit การเพิ่มบริการของ Google (Firebase) แทนบริการของบุคคลที่สาม (Supabase) ช่วยลดพื้นที่การกระจาย

Firestore ในโหมดเนทีฟ (ไม่ใช่โหมด Datastore) ก็ใกล้เคียงกับโมเดลความคิดแบบ NoSQL document ที่ฉันมีในหัวมากกว่าเช่นกัน: คอลเลกชัน เอกสาร ฟิลด์ที่มีชนิดข้อมูล ทิมสแตมป์จากเซิร์ฟเวอร์ และกฎความปลอดภัยในตัว

ระยะที่ 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. ตรวจสอบฮันนี่พอท (ฟิลด์`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`ถูกทริกเกอร์ โดยเป็นมาตรการป้องกัน ฉันยังเพิ่มการ polling ทุก 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);
});

การเปลี่ยนแปลงเมื่อเทียบกับ mock:

  • waitForFirebase()— การ polling พร้อม timeout ซึ่งทนทานแม้ 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`), และแม้ว่ามันจะทำงาน มันก็จะล้มเหลว (ไม่ได้โหลด SDK Supabase)

ฉันลบ:

  1. ชั้นเรียน`SupabaseManager`

  2. ชั้นเรียน`ContactFormHandler`

  3. บล็อกการเริ่มต้นใน`DOMContentLoaded`(บรรทัดที่ 645-654)

Le reste de script.js`ยังคงสภาพสมบูรณ์ :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, `PhoneInputManager`Please provide the French text you’d like me to translate into Thai.

ระยะที่ 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`เป็นสาธารณะโดยการออกแบบที่ Firebase แต่ฉันไม่ต้องการคอมมิตมันแบบเปิดเผย) ฉันเก็บมันไว้ใน`site.yml(มีอยู่แล้วใน`.gitignore`) และปลั๊กอิน`bakery`ฉีดสิ่งเหล่านั้นเข้าไปในเทมเพลตผ่านตรรกะที่ต้องเพิ่มฝั่ง build

ตอนนี้ฉันใส่มันโดยตรงใน`footer.thyme`— บิลด์`./gradlew serve`จะโหลดในเครื่อง เมื่อดีพลอย ฉันจะย้ายการฉีดไปยัง`site.yml`หรือไปยังตัวแปร Gradle

L'`apiKey`Firebase ไม่ใช่ไม่ความลับ มันเป็นสาธารณะโดยการออกแบบ สิ่งที่ปกป้องข้อมูลของคุณคือกฎความปลอดภัย Firestore, ไม่ใช่คีย์ API อย่าใส่ไว้ใน`.env`โหลดฝั่งเซิร์ฟเวอร์ — มีไว้เพื่อเปิดเผยต่อเบราว์เซอร์

ระยะที่ 5 : สถาปัตยกรรมสุดท้าย

@startuml
skinparam backgroundColor #FEFEFE

title Architecture Finale — Formulaire de Contact Firebase

actor Utilisateur as USER

package "Navigateur" #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 : 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

สิ่งที่การย้ายระบบครั้งนี้บอกเกี่ยวกับ dogfooding

เว็บไซต์นี้ถูกสร้างขึ้นโดยปลั๊กอิน Gradle ของฉันเอง`bakery`. แบบฟอร์มติดต่ออยู่ภายในเว็บไซต์ การย้ายจาก Supabase → Firebase มีเอกสารประกอบอยู่ใน`AGENT.adoc`, มันถูกพูดคุยในแบ็กล็อก, มันถูกทดสอบผ่าน`./gradlew serve`, และมันสร้างบทความบล็อก (บทความที่คุณกำลังอ่านนี้)

นี่คือด็อกฟู้ดดิ้งแท้ ๆ เว็บไซต์เป็นผลิตภัณฑ์ของปลั๊กอิน ปลั๊กอินเป็นผลิตภัณฑ์ของนักพัฒนา และนักพัฒนาจัดทำเอกสารกระบวนการไว้ในเว็บไซต์เอง

วงจรได้ปิดลงแล้ว

การที่ต้องลากม็อกตัวหนึ่งไว้เป็นเวลาหลายเดือน (ทั้งเซสชันที่ฟอร์มโกหกอย่างเงียบ ๆ) ทำให้ฉันตระหนักถึงบางอย่าง: แบ็กล็อกของเว็บไซต์ส่วนตัวแบบสแตติกไม่มีทาง “เสร็จสมบูรณ์” ได้เลย มันมียูสสตอรี่ที่สำคัญกว่าอยู่เสมอ มีบทความที่ยังเป็นฉบับร่างอยู่เสมอ มีส่วนที่ถูกคอมเมนต์ไว้ในเทมเพลตอยู่เสมอ วินัยไม่ใช่การทำทุกอย่างให้เสร็จ — แต่คือการทำสิ่งที่ผู้ใช้มองเห็นให้เสร็จ

แบบฟอร์มติดต่อที่ใช้การไม่ได้ แย่กว่าการไม่มีแบบฟอร์มเลยด้วยซ้ำ นั่นคือคำสัญญาที่ไม่ได้รักษา

สรุปการแก้ไข

ไฟล์ การแก้ไข ผลกระทบ

blog/2026/0113_*.adoc

การสร้างบทความ

เอกสารประกอบ

assets/js/contact.js

การเขียนใหม่ (mock → Firestore จริง)

ใช้งานได้

assets/js/script.js

การลบ SupabaseManager + ContactFormHandler + บล็อก init

การทำความสะอาด

templates/footer.thyme

การแทนที่ placeholder ของ config → ค่าจริง

การกำหนดค่า

ขั้นตอนถัดไป (backlog)

  • อีเมลแจ้งเตือน: Cloud Function หนึ่งรายการ`onCreate`บน`contact_messages`ซึ่งส่งอีเมลผ่าน SendGrid แบบฟอร์มบันทึกข้อมูล แต่ฉันไม่ได้รับการแจ้งเตือน ความสำคัญปานกลาง — ข้อความต่าง ๆ มองเห็นได้ในคอนโซล Firebase

  • การจำกัดอัตราการเรียกใช้ฝั่งไคลเอนต์: เพิ่ม timestamp ใน localStorage เพื่อป้องกันการส่งหลายครั้งแบบรัวๆ ฮันนี่พอตบล็อกบอทแบบง่ายๆ ส่วน rate limiter จะบล็อกบอทที่ฉลาดขึ้นเล็กน้อย

  • การทดสอบ: การทดสอบด้วย Playwright ที่ส่งแบบฟอร์มและตรวจสอบว่าเอกสารปรากฏใน Firestore สำหรับตอนนี้ ฉันกำลังทดสอบด้วยตนเองผ่าน`./gradlew serve`.

บทความที่เกี่ยวข้อง