feat(contact): implement Turnstile protection via API proxy

- Add `pages/api/contact.ts` to proxy n8n webhook and verify Turnstile tokens.
- Update `contact-us.astro` form to include Turnstile widget and validation logic.
- Replace hardcoded sitekey with `PUBLIC_TURNSTILE_SITE_KEY` from environment variables.
- Update `dev.vars` to include Cloudflare Turnstile keys.
This commit is contained in:
2026-03-01 14:06:44 +08:00
parent 84b5a498e6
commit df1efb4881
3 changed files with 131 additions and 4 deletions

View File

@@ -3,3 +3,10 @@
# Production Payload CMS URL (for SSR fetch)
PAYLOAD_CMS_URL=https://enchun-admin.anlstudio.cc
# Cloudflare Turnstile
CF_TURNSTILE_SITE_KEY=0x4AAAAAACkOUZK2u7Fo8IZ-
CF_TURNSTILE_SECRET_KEY=0x4AAAAAACkOUV9TDUOwVdcdLUakEVxJjww
# n8n webhook
N8N_WEBHOOK_URL=https://n8n.anlstudio.cc/webhook/contact

View File

@@ -0,0 +1,82 @@
import type { APIRoute } from "astro";
// Your Cloudflare Turnstile Secret Key
// In production, this should be set in environment variables
const TURNSTILE_SECRET_KEY = import.meta.env.CF_TURNSTILE_SECRET_KEY || import.meta.env.TURNSTILE_SECRET_KEY;
// The n8n webhook URL
const WEBHOOK_URL = import.meta.env.N8N_WEBHOOK_URL || "https://n8n.anlstudio.cc/webhook/contact";
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { name, phone, email, message, "cf-turnstile-response": token } = body;
// Basic validation
if (!name || !phone || !email || !message) {
return new Response(JSON.stringify({ error: "Missing required fields" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (!token) {
return new Response(JSON.stringify({ error: "[Turnstile] No token provided" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Verify Turnstile token
if (TURNSTILE_SECRET_KEY) {
const turnstileVerify = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
secret: TURNSTILE_SECRET_KEY,
response: token,
}).toString(),
}
);
const turnstileResult = await turnstileVerify.json();
if (!turnstileResult.success) {
return new Response(JSON.stringify({ error: "[Turnstile] Token verification failed" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
} else {
console.warn("TURNSTILE_SECRET_KEY is not defined. Skipping verification.");
}
// After successful verification, send data to n8n
const n8nResponse = await fetch(WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name, phone, email, message }),
});
if (!n8nResponse.ok) {
throw new Error(`n8n webhook failed with status ${n8nResponse.status}`);
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("API proxy error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View File

@@ -5,6 +5,7 @@
* Includes form validation, submission handling, and responsive layout
*/
import Layout from "../layouts/Layout.astro";
import HeaderBg from "../components/HeaderBg.astro";
// Metadata for SEO
const title = "聯絡我們 | 恩群數位行銷";
@@ -13,6 +14,7 @@ const description =
---
<Layout title={title} description={description}>
<HeaderBg />
<section class="py-20 bg-[#f4f5f7] scroll-mt-20 lg:py-10" id="contact">
<div
class="grid grid-cols-2 gap-16 items-start max-w-3xl mx-auto px-5 lg:grid-cols-2 lg:gap-10"
@@ -144,6 +146,20 @@ const description =
id="Message-error"></span>
</div>
<!-- Turnstile Widget -->
<div class="flex flex-col mt-2">
<div
class="cf-turnstile"
data-sitekey={import.meta.env.PUBLIC_TURNSTILE_SITE_KEY ||
"0x4AAAAAACkOUZK2u7Fo8IZ-"}
data-theme="light"
>
</div>
<span
class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="turnstile-error"></span>
</div>
<!-- Submit Button -->
<div class="flex justify-end mt-2">
<button
@@ -163,6 +179,8 @@ const description =
</section>
</Layout>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer
></script>
<script>
// Form validation and submission handler
function initContactForm() {
@@ -172,6 +190,9 @@ const description =
) as HTMLButtonElement;
const successMsg = document.getElementById("form-success") as HTMLElement;
const errorMsg = document.getElementById("form-error") as HTMLElement;
const turnstileError = document.getElementById(
"turnstile-error",
) as HTMLElement;
if (!form) return;
@@ -281,10 +302,28 @@ const description =
}
});
// Collect form data to check turnstile token
const formData = new FormData(form);
const turnstileToken = formData.get("cf-turnstile-response");
// Validate Turnstile
if (!turnstileToken) {
isFormValid = false;
if (turnstileError) {
turnstileError.textContent = "請完成驗證";
turnstileError.style.display = "block";
}
} else {
if (turnstileError) {
turnstileError.textContent = "";
turnstileError.style.display = "none";
}
}
if (!isFormValid) {
// Scroll to first error
const firstError = form.querySelector(
".input_field.error",
".input_field.error, #turnstile-error[style*='display: block']",
) as HTMLElement;
if (firstError) {
firstError.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -305,17 +344,16 @@ const description =
successMsg.style.display = "none";
errorMsg.style.display = "none";
// Collect form data
const formData = new FormData(form);
const data = {
name: formData.get("Name"),
phone: formData.get("Phone"),
email: formData.get("Email"),
message: formData.get("Message"),
"cf-turnstile-response": turnstileToken,
};
try {
// Submit to backend (via API proxy)
// Submit to API proxy
const response = await fetch("/api/contact", {
method: "POST",
headers: {