Formulaire de contact sur un site statique

Formulaire de contact sur un site statique7 min de lecture

Ajouter un formulaire de contact à un site Cloudflare Pages

Un formulaire de contact sur Cloudflare Pages sans écrire de Function : envoyez à un backend, définissez les en-têtes dans _headers, utilisez vos clés Turnstile.

Preet SutharFondateur, Ikiform

Les ressources statiques de Cloudflare Pages répondent 405 à un POST : écrivez une Pages Function, ou envoyez le formulaire à un backend de formulaires. Avec Ikiform, vous n'écrivez aucune Function : réglez l'action du formulaire sur votre endpoint, ajoutez your-project.pages.dev aux Allowed domains, ce qui couvre aussi toutes les URL de préproduction, et listez Ikiform dans la Content-Security-Policy de votre _headers. Si vous utilisez déjà Cloudflare, vous pouvez brancher vos propres clés Turnstile sur le formulaire.

Ce qui change sur Cloudflare Pages

Pages sert le résultat de votre build depuis le réseau de Cloudflare. Les ressources statiques répondent à GET ; un POST vers l'une d'elles reçoit 405 sans corps :

Réponse à un POST
HTTP/2 405
server: cloudflare
content-length: 0

Pages peut exécuter du code, via les Pages Functions d'un dossier functions/. Un functions/contact.ts pourrait accepter l'envoi, mais vous écririez alors un backend : analyse, validation, fournisseur d'e-mail, filtrage du spam et stockage. La documentation de Cloudflare recommande aussi de démarrer les nouveaux projets sur Workers plutôt que Pages. Envoyer vers un backend de formulaires évite les deux, et le même formulaire fonctionne tel quel si vous passez plus tard aux ressources statiques de Workers.

Particularité de PagesConséquence pour le formulaire
Les URL de préproduction sont des sous-domaines, comme abc123.your-project.pages.devUn seul domaine autorisé, your-project.pages.dev, couvre la production et toutes les préproductions. Les préproductions Vercel et Netlify demandent leurs propres entrées
Les en-têtes viennent d'un fichier _headers dans le résultat du buildJusqu'à 100 règles, 2 000 caractères par ligne. Ils ne s'appliquent pas aux réponses des Pages Functions
Turnstile est un produit CloudflareVous pouvez utiliser la vérification gérée d'Ikiform sans clés, ou votre propre widget depuis le tableau de bord Cloudflare
Un formulaire de contact sur Cloudflare Pages avec vos propres clés Turnstilepagesurpages.devservieavec_headerswidgetTurnstilevotre cléde siteIkiformvérifieavecvotresecretstockéetenvoyé403captcha_failedréinitialisele widgetjetonPOST + jetonjeton invalide ou utiliséUn formulaire de contact sur Cloudflare Pages avec vos propres clés Turnstilepage sur pages.devservie avec _headerswidget Turnstilevotre clé de siteIkiformvérifie avec votresecretstocké et envoyé403captcha_failedréinitialisele widgetjetonPOST + jetonjeton invalide ou utilisé
  1. Pages sert la page de contact, avec la Content-Security-Policy de votre fichier _headers.
  2. Le widget Turnstile s'exécute avec votre clé de site et place un jeton dans le formulaire.
  3. Le formulaire envoie ses champs et le jeton à Ikiform.
  4. Ikiform vérifie le jeton avec votre clé secrète et compare le nom d'hôte du jeton à vos domaines autorisés.
  5. Un envoi valide est stocké et envoyé par e-mail. Un jeton absent, expiré ou réutilisé reçoit 403 captcha_failed, et la page réinitialise le widget.
Avec la vérification gérée d'Ikiform, il n'y a pas de widget sur votre page ; le formulaire HTML n'a besoin d'aucune modification.

Le formulaire

Cette version utilise votre propre widget Turnstile, ce qui a du sens si vous gérez déjà Turnstile dans votre compte Cloudflare. Dans les réglages du formulaire, réglez Spam check sur Your own Turnstile keys et collez la clé de site et le secret. Le widget ajoute un champ cf-turnstile-response au formulaire, et le select team vous indique qui doit répondre :

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>

Le script garde les visiteurs sur la page. Un jeton Turnstile est à usage unique : après tout envoi échoué, il réinitialise donc le widget pour la tentative suivante :

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.";
});
  • Dans les réglages du widget Turnstile chez Cloudflare, listez les noms d'hôte où tourne le formulaire. Ikiform compare aussi le nom d'hôte du jeton aux Allowed domains du formulaire.
  • Les envois sans jeton valide sont refusés avec 403 captcha_failed. Cela inclut un envoi HTML classique parti avant que le widget n'ait terminé.
  • Vous préférez ne gérer aucune clé ? Réglez Spam check sur Ikiform, supprimez le widget et son script, et l'envoi HTML passe la vérification sur ikiform.com. La version fetch a alors besoin de challenge() du SDK ; voir la vérification humaine.

Vos clés ou la vérification d'Ikiform

ItemVérification d'IkiformVos propres clés Turnstile
Clés à gérerAucuneUne clé de site et un secret depuis votre tableau de bord Cloudflare
Modification du formulaire HTMLAucune. La vérification s'exécute sur une page ikiform.comAjouter le widget et son script
Version fetchAppeler challenge() du SDKEnvoyer le cf-turnstile-response du widget
Content-Security-PolicySeulement form-action et connect-src pour IkiformAussi challenges.cloudflare.com dans script-src et frame-src
Statistiques TurnstilePas dans votre compteDans votre tableau de bord Cloudflare, à côté de vos autres widgets

La vérification d'Ikiform demande moins de configuration. Vos propres clés ont du sens si vous suivez déjà les statistiques Turnstile, ou si vous voulez maîtriser l'apparence et l'emplacement du widget.

Tester le widget en local

Cloudflare publie des clés de site et des secrets de test qui réussissent ou échouent toujours, comme la paire toujours valide 1x00000000000000000000AA et 1x0000000000000000000000000000000AA. Les secrets de production rejettent les jetons créés avec une clé de test : ne les mélangez donc pas sur un même formulaire. Créez un second formulaire Ikiform pour le travail en local, donnez-lui la paire de clés de test, et changez ensemble le data-sitekey et l'id du formulaire. Laissez vides les Allowed domains de ce formulaire : Ikiform ne vérifie le nom d'hôte d'un jeton que lorsque la liste est définie, et Cloudflare ne documente pas le nom d'hôte que renvoie un jeton de test.

Le fichier _headers

Placez _headers dans votre répertoire de ressources statiques, comme public/, pour qu'il se retrouve dans le résultat du build. Turnstile a besoin de script-src et frame-src pour https://challenges.cloudflare.com, et Ikiform de form-action et 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

Si vous ajoutez plus tard une Pages Function au même projet, ses réponses ne reçoivent pas ces en-têtes. Définissez-les dans le code de la Function.

Si vous passez à Workers

Passer le site sur les ressources statiques de Workers change la façon dont il se déploie, pas le fonctionnement du formulaire. Le formulaire envoie toujours vers ikiform.com, donc rien côté Cloudflare ne le traite. Vérifiez deux choses après la migration : le nouvel hôte workers.dev ou la nouvelle route figure dans Allowed domains, et vos en-têtes atteignent toujours la page, puisque Workers a sa propre façon de les définir.

Ajoutez your-project.pages.dev et votre domaine personnalisé dans Allowed domains. N'ajoutez jamais pages.dev seul : tous les projets Pages correspondraient.

Vérifiez que tout fonctionne

  1. Poussez une branche et ouvrez son URL de préproduction. Envoyez, et vérifiez que le message arrive sans être marqué comme spam : la préproduction est un sous-domaine de l'hôte de votre projet.
  2. Envoyez à nouveau en production, puis revenez en arrière et renvoyez le même formulaire sans compléter le widget. Attendez-vous au message de réinitialisation, pas à une soumission stockée.
  3. Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
  4. Vérifiez la boîte de réception de chaque destinataire des notifications. Chacun reçoit un e-mail avec les champs, et Reply répond à l'expéditeur quand le formulaire a un champ email.
  5. Rien n'est arrivé ? Passez le filtre de statut du tableau de bord sur Spam. Le spam reçoit la même réponse de succès qu'une vraie soumission.

Pièges

ProblèmeQue faire
405 avec un corps videLe formulaire a envoyé vers une URL Pages. Vérifiez l'action déployée, et qu'aucune Function ni règle de redirection ne réécrit /contact.
Le widget Turnstile n'apparaît jamaisLa Content-Security-Policy bloque challenges.cloudflare.com. Ajoutez-le à script-src et frame-src, et vérifiez que _headers est bien dans le résultat du build.
403 captcha_failed à chaque envoiLa clé de site et le secret viennent de widgets différents, le nom d'hôte n'est pas listé sur le widget, ou un secret de test est associé à une vraie clé de site.
403 origin_not_allowed, ou tous les envois finissent en spamLe domaine de la page n'est pas dans les Allowed domains du formulaire. Ajoutez-le, plus localhost pour les tests en local. Les sous-domaines d'un domaine listé sont autorisés.

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.