¡No te pierdas las próximas publicaciones!

thumbnail

Te puedes desuscribir en cualquier momento.

cover

Cómo añadir Cloudflare Turnstile a tus formularios con React Router Framework


Tengo un video de cómo añadir el re-captcha de Google a tu proyecto web, aquí te lo dejo, pero, en esta entrada, te voy a mostrar lo que yo hago para implementar esta herramienta de Cloudflare llamada: Turnstile, y, así, ****asegurarme de que mis formularios sean usados solo por usuarios reales. 👶🏻

Haremos la integración paso a paso. En cada paso te voy ir mostrando cómo uso yo las rutas de recursos de React Router, cómo las consumo con fetcher y cómo obtengo la respuesta para usarla en el cliente también, y hasta cómo podemos evitar el redireccionamiento natural usando el componente <fetcher.Form/>. 🤯

Además, te voy a mostrar cómo suelo cargar <scripts> de terceros con React y mi custom hook: useScript. 😬 

Y pos ya, seguro que esto te interesa, tráete las nueces, el chocolate semi-amargo y comencemos pues. 🍫👩🏻‍🔧

👀 Pequeño disclaimer: El código está simplificado para fines educativos, este documento pretende ser solo una sugerencia de piezas, te recomiendo ampliamente leer la documentación oficial con calma. 🗒️

🤖 Comencemos con el servidor

Yo ya tengo algunas rutas de recursos en el repo de easybits.cloud, que es open-source, así que, simplemente añadiré una coincidencia, yo les llamo “intents”, se los copié a Ryan Florence, creo... 🤨

Así, solo añadimos un bloque if en mi ruta existente routes/api/v1/utils.ts.

// routes/api/v1/utils.ts export const action = async ({ request }: Route.ActionArgs) => { const formData = await request.formData(); const intent = formData.get("intent"); // Este es el intent if (intent === "send_confirmation") { const email = formData.get("email") as string; z.string().email().parse(email); // <= zod parsing (email validation) const success = await handleTurnstilePost(request, formData); // body está ya consumido // turnstile validation (bot validation) ^ if (!success) throw new Response("Pensamos que eres un robot. 🤖 No puedes pasar.", { status: 401, }); // En este punto, la validación fue existosa. await sendConfrimation(email); return { success }; } if (intent === "test_action_email") { // ...

La lógica dentro de nuestro bloque if aquí, es muy sencilla: esperamos la respuesta booleana que obtendremos de la función handleTurnstilePost, si la respuesta es false detonamos Unauthorized, o enviamos el correo y le devolvemos un objeto con la llave success al fetcher así, el cliente mostrará el confeti. 🎉

Veamos entonces todo lo que handleTurnstilePost tiene que hacer para validar el token inyectó el script en el objeto formData. 👀

🪪 Esta es la verdadera validación en el servidor

Tampoco es para tanto, no es nada más que una típica petición post. 🦾

export const handleTurnstilePost = async (request: Request, body: FormData) => { // Se valida el token llamando al // "/siteverify" API endpoint. const url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; const result = await fetch(url, { body: new URLSearchParams({ secret: process.env.TURNSTILE_SECRET!, response:body.get("cf-turnstile-response")!, // Turnstile inyecta un token en "cf-turnstile-response". remoteip:request.headers.get("CF-Connecting-IP")!, }), method: "POST", }); const outcome = (await result.json()) as { success: boolean }; console.info("::TURNSTILE_SITEVERIFY_RESPONSE::", outcome); // INFO return outcome.success; };

Aquí, prefiero emplear URLSearchParams que se pueden serializar a FormData, así no tengo que usar el método .set() del FormData para construirlo y escribo mucho menos código. Me da menos hueva armar un objeto. 🥚🔫

Necesitamos la variable de entorno TURNSTILE_SECRET, que podremos encontrar en nuestro dashboard ya que añadamos un nuevo Widget. Cópiatelo. 🧻

turnstile widget dashboard

Esto es todo lo que hay en el servidor, es momento de cargar el script del cliente que hará su magia para generar el token que el action ya esta esperando y validando.

Este *script* colocará el token como parte del formulario que se quiere asegurar. 🔒

📼 useScript colocará un <script> en modo defer y async dentro de <head>

Así es, y es un hook bien sencillo.

import { useEffect } from "react"; export const useScript = (url: string, cb?: (arg0: Event) => void) => { useEffect(() => { const s = document.createElement("script"); s.async = true; s.defer = true; s.src = url; document.head.appendChild(s); s.onload = (e) => { // Esto es opcional cb?.(e); }; }, []); };

Este hook está super simplificado para este caso específico, pero podrías añadir la opción de poblar el textContent. 🤓 Usaremos este hook de la siguiente manera.

Creando un componente <Turnstile/> reutilizable ✅

Podemos fácilmente crear un componente en el que juntaremos la carga del script y los atributos necesarios para que el script inyecte lo necesario en estos nodos <div>.

export const Turnstile = () => { useScript("https://challenges.cloudflare.com/turnstile/v0/api.js"); return ( <div className="fixed bottom-1 right-1 z-50"> <div className="cf-turnstile" data-sitekey="0x4FFFFFAMBbVWGBqyYY47hTw" // <= Aquí va tu "Site Key" data-theme="light" ></div> </div> ); };

Observa que hemos pasado el url del script de Turnstile para que nuestro custom hook añada un nodo script a la cabecera de la página y este cargue. 📑

¡Ya es momento de echarlo a andar! 🚘

Usando nuestro componente en un formulario

Este otro componente es fácil de leer. Se llama SuscriptionBox y se puede usar en todo el sitio en cualquiera de las rutas, porque es autónomo en su comunicación con el servidor gracias al fetcher. 🫨

export const SuscriptionBox = ({ className }: { className?: string }) => { const fetcher = useFetcher(); const isSuccess = fetcher.data?.success; return ( <section className={cn( "max-w-3xl h-fit md:h-72 border-black border-[2px] overflow-hidden md:rounded-t-full bg-coverSuscription rounded-r-3xl rounded-t-3xl md:rounded-r-full bg-cover mx-auto p-6 md:p-8 justify-center relative", className )} > <div className="w-full text-center relative"> <h3 className="text-2xl md:text-3xl font-bold"> Suscríbete a nuestro newsletter </h3> <p className="text-base md:text-xl mt-2 md:mt-3 max-w-4xl mx-auto"> Recibe un resumen mensual de las mejores consejos de marketing y business para creadores, o de las nuevas funcionalidades nuevas de EasyBits. </p> {!isSuccess && ( <fetcher.Form action="/api/v1/utils" method="post" className="flex gap-4 max-w-2xl mx-auto mt-10 flex-wrap md:flex-nowrap justify-center" > <input name="email" required className="bg-white rounded-xl w-full border-2 border-black " placeholder="ejemplo@easybist.cloud" />{" "} <BrutalButton isLoading={fetcher.state !== "idle"} name="intent" value="send_confirmation" type="submit" containerClassName=" -mt-[2px] ml-[1px]" id="Suscripcion" > ¡Apuntarme! </BrutalButton> <Turnstile /> </fetcher.Form> )} {isSuccess && ( <p className="text-xl mt-2 md:mt-3 font-bold text-brand-500"> ¡Super! Ahora, revisa tu correo para confirmar tu cuenta. 🎊 </p> )} {isSuccess && <BrendisConfetti />} </div> </section> ); };

Hay mucho qué observar aquí, pero, podemos comenzar con que declaramos al fetcher y también declaramos una variable isSuccess que como se define directamente en el scope del componente, cada que el fetcher cambie se estará redefiniendo, asegurándonos así de que el dato será fresco. 💦

Yo suelo definir muchas variables así. Ahora, mira hasta abajo del componente, encontrarás que isSuccess se usa para mostrar el confeti y ocultar el formulario. 😶‍🌫️ 

Toda la información para comunicarse con el servidor está presente en las propiedades del <fetcher.Form>, a este estilo se le conoce como declarativo y se evita tener que usar una función para prevenir el default del onsubmit. ✅

  • action Indica nuestro endpoint donde está el bloque if con el intent que creamos al principio.
  • method Indica el tipo de petición HTTP, debe ser post, put, patch o delete para detonar el action(o la ruta de recursos).
  • El <input> al tener el atributo name será incluido en la petición dentro de un formData, este es el comportamiento natural de un input dentro de un formulario web.
  • Pasa lo mismo con el <button> que también lleva el atributo name con el valor send_confirmation, será parte del formData y se usará para coincidir con un bloque if dentro de la función action. 🤯

Si necesitas el token en el cliente antes, puedes usar el render explícito.

export const Turnstile = ({ setIsDisabled, }: { setIsDisabled?: (arg0: boolean) => void; }) => { const ref = useRef(null); useScript("https://challenges.cloudflare.com/turnstile/v0/api.js", () => { window.turnstile?.ready(() => { if (ref.current.id) return; ref.current.id = "loaded"; // avoiding duplication window.turnstile?.render(ref.current, { callback: enable, sitekey: "0x4AATRTYUbVIYBqxLL33hTw", }); }); }); const enable = (token: string) => { console.log(token); if (token) { setIsDisabled?.(false); // habilitamos o desabilitamos el botón del form } else { setIsDisabled?.(true); } }; return <div ref={ref} className="fixed bottom-0 right-0 z-50" />; };

¡Y ya está! Nos hemos asegurado de que las peticiones a este formulario sean auténticas y ahora podemos emplear nuestro componente <Turnstile /> en conjunto con la función handleTurnstilePost en cualquier formulario público de nuestro sitio web. ✅

Bien hecho Geek. Espero esto te sea de utilidad, nos leemos pronto. 🤘🏼

Abrazo. Bliss. 🤓

Enlaces relacionados

Docs oficiales de Turnstile

Link al código

Mi video sobre Google Recaptcha

meta cover

2 maneras muy simples de mejorar tus entrevistas de trabajo.

Checa este otro Post

meta cover

¿Por qué estudiar mínimo 3 horas a la semana te va a conseguir un mejor futuro?

Checa este otro Post

¡Nuevo curso!

Animaciones web con React + Motion 🧙🏻