feat: initial commit — site + admin panel + Postgres content pipeline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AyrisAI
2026-07-21 01:24:47 +03:00
co-authored by Claude Fable 5
commit 3420d32271
188 changed files with 25053 additions and 0 deletions
+584
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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)} &lt;${escapeHtml(data.email)}&gt;
</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",
},
},
);
}
}