feat: initial commit — site + admin panel + Postgres content pipeline
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
// functions/contact/index.ts
|
||||
|
||||
declare global {
|
||||
interface KVNamespace {
|
||||
get(key: string): Promise<string | null>;
|
||||
put(
|
||||
key: string,
|
||||
value: string,
|
||||
options?: { expirationTtl?: number },
|
||||
): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
interface Env {
|
||||
RESEND_API_KEY: string;
|
||||
EMAILS_API: string;
|
||||
EMAILS_TO: string;
|
||||
EMAILS_TO_CC: string;
|
||||
EMAILS_FROM: string;
|
||||
TURNSTILE_SECRET_KEY?: string;
|
||||
RATE_LIMIT_KV?: KVNamespace;
|
||||
}
|
||||
|
||||
function escapeHtml(input: unknown): string {
|
||||
return String(input ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function safeEmailHref(email: string): string {
|
||||
return `mailto:${encodeURIComponent(email)}`;
|
||||
}
|
||||
|
||||
type Validated = {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
subject: "job-application" | "suggestion" | "something-else";
|
||||
subjectOther?: string;
|
||||
message: string;
|
||||
consent: boolean;
|
||||
};
|
||||
|
||||
function validatePayload(
|
||||
body: any,
|
||||
): { ok: true; value: Validated } | { ok: false; error: string } {
|
||||
if (!body || typeof body !== "object") {
|
||||
return { ok: false, error: "Invalid payload" };
|
||||
}
|
||||
|
||||
const fullName = String(body.fullName ?? "").trim();
|
||||
|
||||
if (fullName.length < 4 || fullName.length > 100) {
|
||||
return { ok: false, error: "Invalid name length" };
|
||||
}
|
||||
|
||||
const email = String(body.email ?? "").trim();
|
||||
const emailRe = /^(?:[^\s@]+)@(?:[^\s@]+)\.[^\s@]{2,}$/i;
|
||||
|
||||
if (!emailRe.test(email) || email.length > 254) {
|
||||
return { ok: false, error: "Invalid email" };
|
||||
}
|
||||
|
||||
const phone = String(body.phone ?? "").trim();
|
||||
|
||||
if (phone) {
|
||||
const phoneRe =
|
||||
/^\+?[1-9]\d{0,3}[\s\-().]?\d{1,4}[\s\-().]?\d{1,4}[\s\-().]?\d{1,9}$/;
|
||||
|
||||
if (!phoneRe.test(phone) || phone.length > 30) {
|
||||
return { ok: false, error: "Invalid phone" };
|
||||
}
|
||||
}
|
||||
|
||||
const subject = String(body.subject ?? "");
|
||||
const allowedSubjects = ["job-application", "suggestion", "something-else"];
|
||||
|
||||
if (!allowedSubjects.includes(subject)) {
|
||||
return { ok: false, error: "Invalid subject" };
|
||||
}
|
||||
|
||||
const subjectOther = String(body.subjectOther ?? "").trim();
|
||||
|
||||
if (subject === "something-else" && subjectOther.length === 0) {
|
||||
return { ok: false, error: "Subject other required" };
|
||||
}
|
||||
|
||||
if (subjectOther.length > 120) {
|
||||
return { ok: false, error: "subjectOther too long" };
|
||||
}
|
||||
|
||||
const message = String(body.message ?? "").trim();
|
||||
|
||||
if (message.length < 25 || message.length > 600) {
|
||||
return { ok: false, error: "Invalid message length" };
|
||||
}
|
||||
|
||||
const consent = Boolean(body.consent);
|
||||
|
||||
if (!consent) {
|
||||
return { ok: false, error: "Consent required" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
fullName,
|
||||
email,
|
||||
phone,
|
||||
subject: subject as Validated["subject"],
|
||||
subjectOther,
|
||||
message,
|
||||
consent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowedOrigin(request: Request): boolean {
|
||||
const origin = request.headers.get("Origin") || "";
|
||||
|
||||
const allowed = ["https://mucomutfak.com", "https://www.mucomutfak.com"];
|
||||
|
||||
if (!origin) return true;
|
||||
|
||||
return allowed.includes(origin);
|
||||
}
|
||||
|
||||
async function verifyTurnstile(
|
||||
secret: string | undefined,
|
||||
token: string | undefined,
|
||||
remoteIp?: string,
|
||||
) {
|
||||
if (!secret) return { ok: false, error: "Turnstile not configured" };
|
||||
if (!token) return { ok: false, error: "Missing Turnstile token" };
|
||||
|
||||
try {
|
||||
const form = new URLSearchParams();
|
||||
|
||||
form.set("secret", secret);
|
||||
form.set("response", token);
|
||||
|
||||
if (remoteIp) {
|
||||
form.set("remoteip", remoteIp);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
},
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.success) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: "Turnstile failed",
|
||||
details: json["error-codes"] || json,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, error: "Turnstile verification error" };
|
||||
}
|
||||
}
|
||||
|
||||
async function rateLimitCheck(
|
||||
ip: string | null,
|
||||
kv: KVNamespace | undefined,
|
||||
): Promise<{ ok: true } | { ok: false; retryAfter?: number }> {
|
||||
if (!kv || !ip) return { ok: true };
|
||||
|
||||
const key = `cf_rl:${ip}:${new Date().toISOString().slice(0, 16)}`;
|
||||
|
||||
try {
|
||||
const existing = await kv.get(key);
|
||||
const count = existing ? parseInt(existing, 10) : 0;
|
||||
const limitPerMinute = 6;
|
||||
|
||||
if (count >= limitPerMinute) {
|
||||
return { ok: false, retryAfter: 60 };
|
||||
}
|
||||
|
||||
await kv.put(key, String(count + 1), {
|
||||
expirationTtl: 65,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
console.error("KV rate limit error", e);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function onRequestPost({
|
||||
request,
|
||||
env,
|
||||
}: {
|
||||
request: Request;
|
||||
env: Env;
|
||||
}) {
|
||||
try {
|
||||
console.log("[contact] function invoked", {
|
||||
origin: request.headers.get("Origin"),
|
||||
hasSecret: !!env.TURNSTILE_SECRET_KEY,
|
||||
hasKV: !!env.RATE_LIMIT_KV,
|
||||
});
|
||||
|
||||
if (!isAllowedOrigin(request)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Forbidden",
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (body?.checkIfYouAreNotARobot) {
|
||||
console.warn("Robot triggered from request");
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const validated = validatePayload(body);
|
||||
|
||||
if (!validated.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: validated.error,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const data = validated.value;
|
||||
|
||||
const ip =
|
||||
request.headers.get("cf-connecting-ip") ||
|
||||
request.headers.get("x-forwarded-for") ||
|
||||
null;
|
||||
|
||||
const rateLimit = await rateLimitCheck(ip, env.RATE_LIMIT_KV);
|
||||
|
||||
if (!rateLimit.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Rate limit exceeded",
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const turnstileToken = String(body?.turnstileToken ?? "");
|
||||
|
||||
if (env.TURNSTILE_SECRET_KEY) {
|
||||
console.log("[contact] using TURNSTILE_SECRET_KEY");
|
||||
|
||||
const turnstile = await verifyTurnstile(
|
||||
env.TURNSTILE_SECRET_KEY,
|
||||
turnstileToken,
|
||||
ip || undefined,
|
||||
);
|
||||
|
||||
if (!turnstile.ok) {
|
||||
console.warn("Turnstile failed", turnstile);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Bot verification failed",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const subjectLine =
|
||||
data.subject === "something-else"
|
||||
? data.subjectOther || "Contact Form"
|
||||
: data.subject;
|
||||
|
||||
const receivedAt = new Date().toISOString();
|
||||
const sourceOrigin =
|
||||
request.headers.get("Origin") || request.headers.get("Referer") || "N/A";
|
||||
const clientIp = ip || "N/A";
|
||||
const subjectOtherSafe =
|
||||
data.subject === "something-else" ? data.subjectOther || "" : "";
|
||||
const consentText = data.consent ? "Yes" : "No";
|
||||
const phoneText = data.phone || "-";
|
||||
|
||||
const quotedMessage = String(data.message)
|
||||
.split("\n")
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n");
|
||||
|
||||
const mailText = `
|
||||
Yeni Mesajınız Var!
|
||||
|
||||
── İletişim Detayları ─────────────────────
|
||||
Ad Soyad: ${data.fullName}
|
||||
E-posta: ${data.email}
|
||||
Telefon: ${phoneText}
|
||||
Konu: ${subjectLine} ${subjectOtherSafe ? " - " + subjectOtherSafe : ""}
|
||||
İletişim İzni: ${consentText}
|
||||
|
||||
── Mesaj ─────────────────────────────
|
||||
${data.message}
|
||||
|
||||
── Meta ─────────────────────────────────
|
||||
Origin: ${sourceOrigin}
|
||||
IP: ${clientIp}
|
||||
Tarih: ${receivedAt}
|
||||
|
||||
── Yanıtla ─
|
||||
> Ad Soyad: ${data.fullName}
|
||||
> E-posta: ${data.email}
|
||||
> Telefon: ${phoneText}
|
||||
> Konu: ${subjectLine} ${subjectOtherSafe ? " - " + subjectOtherSafe : ""}
|
||||
>
|
||||
${quotedMessage}
|
||||
`;
|
||||
|
||||
const mailHtml = `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<style>
|
||||
.wrapper { width:100%; background:#f5f7fb; margin:0; padding:24px 12px; }
|
||||
.container { width:100%; max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; border:1px solid #e5e7eb; overflow:hidden; }
|
||||
.header { padding:20px 24px; background:#0f172a; color:#ffffff; }
|
||||
.title { margin:0; font-size:20px; line-height:1.3; }
|
||||
.section { padding:20px 24px; }
|
||||
.kv { width:100%; border-collapse:collapse; }
|
||||
.kv th { text-align:left; padding:8px 0; font-size:13px; color:#64748b; width:160px; vertical-align:top; }
|
||||
.kv td { padding:8px 0; font-size:14px; color:#0f172a; }
|
||||
.divider { height:1px; background:#e5e7eb; margin:12px 0; }
|
||||
.message { white-space:pre-wrap; padding:12px; border-radius:8px; background:#f8fafc; border:1px solid #e2e8f0; color:#0f172a; font-size:14px; }
|
||||
.footer { padding:16px 24px; font-size:12px; color:#94a3b8; background:#fafafa; }
|
||||
.pill { display:inline-block; padding:4px 10px; border-radius:9999px; border:1px solid #e2e8f0; font-size:12px; color:#0f172a; background:#f8fafc; }
|
||||
.btn { display:inline-block; font-size:14px; text-decoration:none; padding:10px 14px; border-radius:8px; border:1px solid #0f172a; }
|
||||
@media (max-width: 480px) {
|
||||
.section { padding:16px; }
|
||||
.header { padding:16px; }
|
||||
.kv th { width:120px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background:#f5f7fb;">
|
||||
<div class="wrapper">
|
||||
<table role="presentation" class="container" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td class="header">
|
||||
<h2 class="title">Yeni Mesajınız Var!</h2>
|
||||
<div style="margin-top:8px;">
|
||||
<span class="pill">${escapeHtml(subjectLine)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="section">
|
||||
<table class="kv" role="presentation">
|
||||
<tr>
|
||||
<th>Ad Soyad</th>
|
||||
<td>${escapeHtml(data.fullName)}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>E-posta</th>
|
||||
<td>
|
||||
<a href="${safeEmailHref(data.email)}" style="color:#0f172a;">
|
||||
${escapeHtml(data.email)}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>Telefon</th>
|
||||
<td>${escapeHtml(phoneText)}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>Konu</th>
|
||||
<td>
|
||||
${escapeHtml(subjectLine)}
|
||||
${subjectOtherSafe ? " - " + escapeHtml(subjectOtherSafe) : ""}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>İletişim İzni</th>
|
||||
<td>${escapeHtml(consentText)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="margin-bottom:6px;">Mesaj</div>
|
||||
|
||||
<div class="message">
|
||||
${escapeHtml(data.message)}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div style="font-weight:600; margin-bottom:6px;">
|
||||
Reply-ready quote
|
||||
</div>
|
||||
|
||||
<blockquote style="margin:0; padding-left:12px; border-left:3px solid #e2e8f0; color:#334155;">
|
||||
<div>
|
||||
<strong>Kimden:</strong>
|
||||
${escapeHtml(data.fullName)} <${escapeHtml(data.email)}>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Telefon:</strong>
|
||||
${escapeHtml(phoneText)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Konu:</strong>
|
||||
${escapeHtml(subjectLine)}
|
||||
${subjectOtherSafe ? " - " + escapeHtml(subjectOtherSafe) : ""}
|
||||
</div>
|
||||
|
||||
<div style="margin-top:8px; white-space:pre-wrap;">
|
||||
${escapeHtml(data.message)}
|
||||
</div>
|
||||
</blockquote>
|
||||
|
||||
<div style="margin-top:16px;">
|
||||
<a class="btn" href="${safeEmailHref(data.email)}">
|
||||
Yanıtla ${escapeHtml(data.fullName)}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="section" style="padding-top:0;">
|
||||
<table class="kv" role="presentation">
|
||||
<tr>
|
||||
<th>Origin</th>
|
||||
<td>${escapeHtml(sourceOrigin)}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<td>${escapeHtml(clientIp)}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>Tarih</th>
|
||||
<td>${escapeHtml(receivedAt)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="footer">
|
||||
Bu e-posta iletişim formundan otomatik olarak gönderilmiştir.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const cc = env.EMAILS_TO_CC
|
||||
? env.EMAILS_TO_CC.split(",")
|
||||
.map((email) => email.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const mailResponse = await fetch(env.EMAILS_API, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: env.EMAILS_FROM,
|
||||
to: [env.EMAILS_TO],
|
||||
cc,
|
||||
subject: `Yeni Mesajınız Var: ${subjectLine}${
|
||||
subjectOtherSafe ? " - " + subjectOtherSafe : ""
|
||||
}`,
|
||||
text: mailText,
|
||||
html: mailHtml,
|
||||
reply_to: data.email,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!mailResponse.ok) {
|
||||
const detail = await mailResponse.text().catch(() => "no details");
|
||||
|
||||
console.error("Resend error:", mailResponse.status, detail);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Unable to send message",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const responseJson = await mailResponse.json().catch(() => ({}));
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
id: responseJson.id || null,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Unhandled error in contact function", err);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Server error",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user