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:
@@ -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
|
||||
|
||||
82
apps/frontend/src/pages/api/contact.ts
Normal file
82
apps/frontend/src/pages/api/contact.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user