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)
|
# Production Payload CMS URL (for SSR fetch)
|
||||||
PAYLOAD_CMS_URL=https://enchun-admin.anlstudio.cc
|
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
|
* Includes form validation, submission handling, and responsive layout
|
||||||
*/
|
*/
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
import HeaderBg from "../components/HeaderBg.astro";
|
||||||
|
|
||||||
// Metadata for SEO
|
// Metadata for SEO
|
||||||
const title = "聯絡我們 | 恩群數位行銷";
|
const title = "聯絡我們 | 恩群數位行銷";
|
||||||
@@ -13,6 +14,7 @@ const description =
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={title} description={description}>
|
<Layout title={title} description={description}>
|
||||||
|
<HeaderBg />
|
||||||
<section class="py-20 bg-[#f4f5f7] scroll-mt-20 lg:py-10" id="contact">
|
<section class="py-20 bg-[#f4f5f7] scroll-mt-20 lg:py-10" id="contact">
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-2 gap-16 items-start max-w-3xl mx-auto px-5 lg:grid-cols-2 lg:gap-10"
|
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>
|
id="Message-error"></span>
|
||||||
</div>
|
</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 -->
|
<!-- Submit Button -->
|
||||||
<div class="flex justify-end mt-2">
|
<div class="flex justify-end mt-2">
|
||||||
<button
|
<button
|
||||||
@@ -163,6 +179,8 @@ const description =
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer
|
||||||
|
></script>
|
||||||
<script>
|
<script>
|
||||||
// Form validation and submission handler
|
// Form validation and submission handler
|
||||||
function initContactForm() {
|
function initContactForm() {
|
||||||
@@ -172,6 +190,9 @@ const description =
|
|||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
const successMsg = document.getElementById("form-success") as HTMLElement;
|
const successMsg = document.getElementById("form-success") as HTMLElement;
|
||||||
const errorMsg = document.getElementById("form-error") as HTMLElement;
|
const errorMsg = document.getElementById("form-error") as HTMLElement;
|
||||||
|
const turnstileError = document.getElementById(
|
||||||
|
"turnstile-error",
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
if (!form) return;
|
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) {
|
if (!isFormValid) {
|
||||||
// Scroll to first error
|
// Scroll to first error
|
||||||
const firstError = form.querySelector(
|
const firstError = form.querySelector(
|
||||||
".input_field.error",
|
".input_field.error, #turnstile-error[style*='display: block']",
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
if (firstError) {
|
if (firstError) {
|
||||||
firstError.scrollIntoView({ behavior: "smooth", block: "center" });
|
firstError.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
@@ -305,17 +344,16 @@ const description =
|
|||||||
successMsg.style.display = "none";
|
successMsg.style.display = "none";
|
||||||
errorMsg.style.display = "none";
|
errorMsg.style.display = "none";
|
||||||
|
|
||||||
// Collect form data
|
|
||||||
const formData = new FormData(form);
|
|
||||||
const data = {
|
const data = {
|
||||||
name: formData.get("Name"),
|
name: formData.get("Name"),
|
||||||
phone: formData.get("Phone"),
|
phone: formData.get("Phone"),
|
||||||
email: formData.get("Email"),
|
email: formData.get("Email"),
|
||||||
message: formData.get("Message"),
|
message: formData.get("Message"),
|
||||||
|
"cf-turnstile-response": turnstileToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Submit to backend (via API proxy)
|
// Submit to API proxy
|
||||||
const response = await fetch("/api/contact", {
|
const response = await fetch("/api/contact", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
Reference in New Issue
Block a user