Kontaktformular auf einer statischen Site

Kontaktformular auf einer statischen Site6 Min. Lesezeit

Ein Kontaktformular in eine Cloudflare-Pages-Site einbauen

Bau ein Kontaktformular in Cloudflare Pages ein, ohne eine Function zu schreiben: an ein Formular-Backend senden, Header in _headers setzen und eigene Turnstile-Schlüssel nutzen.

Preet SutharGründer, Ikiform

Statische Assets auf Cloudflare Pages beantworten einen POST mit 405, also schreibst du entweder eine Pages Function oder sendest das Formular an ein Formular-Backend. Mit Ikiform schreibst du keine Function: Setze die action des Formulars auf deinen Endpunkt, füge your-project.pages.dev zu Allowed domains hinzu, was auch jede Preview-URL abdeckt, und nimm Ikiform in die Content-Security-Policy in deiner _headers auf. Wenn du Cloudflare schon nutzt, kannst du deine eigenen Turnstile-Schlüssel im Formular hinterlegen.

Was bei Cloudflare Pages anders ist

Pages liefert deinen Build-Output aus dem Netzwerk von Cloudflare aus. Statische Assets beantworten GET; ein POST an eines davon bekommt 405 ohne Body:

Antwort auf einen POST
HTTP/2 405
server: cloudflare
content-length: 0

Pages kann Code ausführen, über Pages Functions in einem Ordner functions/. Eine functions/contact.ts könnte den Post annehmen, aber dann schreibst du ein Backend: Parsing, Validierung, einen E-Mail-Anbieter, Spamfilter und Speicherung. Die Dokumentation von Cloudflare selbst rät außerdem, neue Projekte auf Workers statt auf Pages zu starten. Ein Post an ein Formular-Backend umgeht beides, und dasselbe Formular funktioniert unverändert, falls du später zu Workers Static Assets wechselst.

Pages-EigenschaftWas das für das Formular bedeutet
Preview-URLs sind Subdomains, etwa abc123.your-project.pages.devEine erlaubte Domain, your-project.pages.dev, deckt die Produktion und jede Preview ab. Previews bei Vercel und Netlify brauchen eigene Einträge
Header kommen aus einer _headers-Datei im Build-OutputBis zu 100 Regeln, 2.000 Zeichen pro Zeile. Sie gelten nicht für Antworten von Pages Functions
Turnstile ist ein Cloudflare-ProduktDu kannst die verwaltete Prüfung von Ikiform ohne Schlüssel nutzen oder dein eigenes Widget aus dem Cloudflare-Dashboard
Ein Kontaktformular auf Cloudflare Pages mit eigenen Turnstile-SchlüsselnSeiteaufpages.devmit_headersausgeliefertTurnstile-Widgetdein SiteKeyIkiformverifiziertmitdeinemSecretgespeichertundgemailt403captcha_failedWidgetzurücksetzenTokenPOST + Tokenungültiges oder benutztes TokenEin Kontaktformular auf Cloudflare Pages mit eigenen Turnstile-SchlüsselnSeite aufpages.devmit _headersausgeliefertTurnstile-Widgetdein Site KeyIkiformverifiziert mit deinemSecretgespeichert undgemailt403captcha_failedWidgetzurücksetzenTokenPOST + Tokenungültiges oder benutztes Token
  1. Pages liefert die Kontaktseite aus, mit der Content-Security-Policy aus deiner _headers-Datei.
  2. Das Turnstile-Widget läuft mit deinem Site Key und legt ein Token im Formular ab.
  3. Das Formular sendet seine Felder und das Token an Ikiform.
  4. Ikiform verifiziert das Token mit deinem Secret und prüft den Hostnamen des Tokens gegen deine erlaubten Domains.
  5. Ein gültiger Post wird gespeichert und per E-Mail gemeldet. Ein fehlendes, abgelaufenes oder wiederverwendetes Token bekommt 403 captcha_failed, und die Seite setzt das Widget zurück.
Mit der verwalteten Prüfung von Ikiform gibt es stattdessen kein Widget auf deiner Seite; das HTML-Formular braucht gar keine Änderung.

Das Formular

Diese Version nutzt dein eigenes Turnstile-Widget, was sinnvoll ist, wenn du Turnstile schon in deinem Cloudflare-Konto verwaltest. Setze in den Einstellungen des Formulars Spam check auf Your own Turnstile keys und füge Site Key und Secret ein. Das Widget fügt dem Formular ein Feld cf-turnstile-response hinzu, und das Select team sagt dir, wer antworten soll:

public/contact.html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="team">Who should read this?</label>
  <select id="team" name="team">
    <option value="support">Support</option>
    <option value="sales">Sales</option>
    <option value="press">Press</option>
  </select>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6" minlength="10" required></textarea>

  <div class="cf-turnstile" data-sitekey="0x4AAAAAAAxxxxxxxxxxxxxx"></div>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send</button>
  <p id="contact-status" role="status"></p>
</form>

<script src="/contact.js" defer></script>

Das Skript hält Besucher auf der Seite. Ein Turnstile-Token ist nur einmal gültig, daher setzt es nach jedem fehlgeschlagenen Senden das Widget für den nächsten Versuch zurück:

public/contact.js
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  status.textContent = "Sending";
  const response = await fetch(form.action, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  }).catch(() => null);
  const result = await response?.json().catch(() => null);

  if (result?.ok) {
    form.replaceWith(Object.assign(document.createElement("p"), { textContent: "Sent. We'll reply by email." }));
    return;
  }

  window.turnstile?.reset();
  if (result?.error?.code === "captcha_failed") {
    status.textContent = "The human check expired. Complete it again, then send.";
    return;
  }
  const fields = result?.error?.fields ?? {};
  status.textContent = Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.";
});
  • Trag in den Einstellungen des Turnstile-Widgets in Cloudflare die Hostnamen ein, auf denen das Formular läuft. Ikiform prüft den Hostnamen des Tokens außerdem gegen Allowed domains des Formulars.
  • Posts ohne gültiges Token werden mit 403 captcha_failed abgelehnt. Das gilt auch für einen einfachen HTML-Post, der gesendet wird, bevor das Widget fertig ist.
  • Lieber ganz ohne Schlüssel? Setze Spam check auf Ikiform, lösche das Widget und sein Skript, und der HTML-Post führt die Prüfung auf ikiform.com aus. Die fetch-Version braucht dann challenge() aus dem SDK; siehe die Menschlichkeitsprüfung.

Deine Schlüssel oder die Prüfung von Ikiform

ItemPrüfung von IkiformEigene Turnstile-Schlüssel
Zu verwaltende SchlüsselKeineEin Site Key und ein Secret aus deinem Cloudflare-Dashboard
Änderung am HTML-FormularKeine. Die Prüfung läuft auf einer Seite von ikiform.comWidget und sein Skript hinzufügen
Version mit fetchchallenge() aus dem SDK aufrufencf-turnstile-response des Widgets mitsenden
Content-Security-PolicyNur form-action und connect-src für IkiformZusätzlich challenges.cloudflare.com in script-src und frame-src
Turnstile-AnalyticsNicht in deinem KontoIn deinem Cloudflare-Dashboard, neben deinen anderen Widgets

Die Prüfung von Ikiform ist weniger Einrichtung. Eigene Schlüssel lohnen sich, wenn du die Turnstile-Analytics ohnehin im Blick hast oder Aussehen und Platzierung des Widgets selbst bestimmen willst.

Das Widget lokal testen

Cloudflare veröffentlicht Test-Site-Keys und -Secrets, die immer bestehen oder immer scheitern, etwa das Paar 1x00000000000000000000AA und 1x0000000000000000000000000000000AA, das immer besteht. Produktions-Secrets lehnen Tokens ab, die mit einem Testschlüssel erzeugt wurden, also misch sie nicht in einem Formular. Leg ein zweites Ikiform-Formular für die lokale Arbeit an, gib ihm das Test-Schlüsselpaar und wechsle data-sitekey und Formular-ID gemeinsam. Lass Allowed domains dieses Formulars leer: Ikiform prüft den Hostnamen eines Tokens nur, wenn die Liste gesetzt ist, und Cloudflare dokumentiert nicht, welchen Hostnamen ein Test-Token meldet.

Die _headers-Datei

Leg _headers in dein Verzeichnis für statische Assets, etwa public/, damit sie im Build-Output landet. Turnstile braucht script-src und frame-src für https://challenges.cloudflare.com, und Ikiform braucht form-action und connect-src:

public/_headers
/*
  Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com

Wenn du später eine Pages Function zum selben Projekt hinzufügst, bekommen ihre Antworten diese Header nicht. Setze sie im Code der Function.

Wenn du zu Workers wechselst

Der Umzug der Site zu Workers Static Assets ändert, wie sie deployt wird, nicht, wie das Formular funktioniert. Das Formular sendet weiterhin an ikiform.com, also verarbeitet nichts auf Cloudflares Seite den Post. Prüfe nach dem Umzug zwei Dinge: Der neue workers.dev-Host oder die Route steht unter Allowed domains, und deine Header erreichen weiterhin die Seite, da Workers sie auf eigene Weise setzt.

Füge your-project.pages.dev und deine eigene Domain unter Allowed domains hinzu. Füge niemals pages.dev allein hinzu: Jedes Pages-Projekt würde passen.

Prüfen, ob es funktioniert

  1. Pushe einen Branch und öffne seine Preview-URL. Sende ab und prüfe, ob die Nachricht ankommt und nicht als Spam markiert ist: Die Preview ist eine Subdomain des Hosts deines Projekts.
  2. Sende in der Produktion erneut ab, geh dann zurück und sende dasselbe Formular ein zweites Mal, ohne das Widget abzuschließen. Erwarte die Meldung zum Zurücksetzen, keine gespeicherte Einsendung.
  3. Öffne Submissions im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
  4. Prüfe das Postfach jedes Benachrichtigungsempfängers. Jeder bekommt eine E-Mail mit den Feldern, und Reply geht an den Absender, wenn das Formular ein email-Feld hat.
  5. Nichts angekommen? Stell den Statusfilter im Dashboard auf Spam. Spam bekommt dieselbe Erfolgsantwort wie eine echte Einsendung.

Stolperfallen

ProblemWas zu tun ist
405 mit leerem BodyDas Formular hat an eine Pages-URL gesendet. Prüfe die deployte action und ob keine Function oder Redirect-Regel /contact umschreibt.
Das Turnstile-Widget erscheint nieDie Content-Security-Policy blockiert challenges.cloudflare.com. Füge es zu script-src und frame-src hinzu und prüfe, ob _headers im Build-Output liegt.
403 captcha_failed bei jedem PostSite Key und Secret stammen von verschiedenen Widgets, der Hostname ist beim Widget nicht eingetragen, oder ein Test-Secret ist mit einem echten Site Key kombiniert.
403 origin_not_allowed, oder jeder Post landet im SpamDie Domain der Seite steht nicht unter Allowed domains des Formulars. Füge sie hinzu, plus localhost für lokale Tests. Subdomains einer eingetragenen Domain sind erlaubt.

Fragen

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.