feat(geri-bildirim): Cloudflare Turnstile bot koruması

Form token üretir, sunucu aksiyonu Cloudflare'e doğrulatır. Betik metin
alanına ilk odaklanışta yüklenir; onay kutusu yalnız Cloudflare elle onay
istediğinde görünür. TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY çağrı anında
okunur; biri eksikse koruma kapalıdır. Gizlilik ve çerez metinleri
sağlayıcıyı anacak şekilde güncellendi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-11 02:08:19 +03:00
parent b4b4c849f8
commit fa362505b4
5 changed files with 198 additions and 10 deletions

View File

@@ -32,7 +32,7 @@ export default function CerezPolitikasiPage() {
<Parallax fromStart strength={10}>
<h1 className="font-heading text-3xl font-bold">Çerez Politikası</h1>
<p className="mt-2 text-sm text-slate-500">
Son güncelleme: 5 Ekim 2026
Son güncelleme: 11 Ekim 2026
</p>
</Parallax>
@@ -117,7 +117,11 @@ export default function CerezPolitikasiPage() {
Paket satın alırken kart formunu iyzico gösterir ve ödeme
güvenliği için kendi çerezlerini kullanabilir. Google ile giriş
yaparsan Google&rsquo;ın sayfasına yönlendirilirsin; orada
Google&rsquo;ın çerez politikası geçerlidir. Bunların dışında
Google&rsquo;ın çerez politikası geçerlidir. Geri bildirim
kutusuna yazmaya başladığında otomatik gönderimleri ayırt etmek
için Cloudflare Turnstile&rsquo;ın doğrulama betiği yüklenir; bu
betik seni siteler arasında izlemez ve reklam amacıyla
kullanılmaz. Bunların dışında
sitede başka şirketlere ait çerez, reklam ya da sosyal medya
eklentisi yoktur.
</p>

View File

@@ -30,7 +30,7 @@ export default function GizlilikPage() {
Gizlilik ve Kişisel Verilerin Korunması
</h1>
<p className="mt-2 text-sm text-slate-500">
Son güncelleme: 5 Ekim 2026
Son güncelleme: 11 Ekim 2026
</p>
</Parallax>
@@ -179,10 +179,19 @@ export default function GizlilikPage() {
programlar ve kaydettiğin tercihler. Senin yazdığın mesaj, adın
ve e-postan gönderilmez.
</li>
<li>
<strong>Cloudflare Turnstile (ABD)</strong> — geri bildirim
kutusunu otomatik gönderimlerden korur. Kutuya yazmaya
başladığında Cloudflare&rsquo;in doğrulama betiği yüklenir; IP
adresin ile tarayıcı ve cihaz bilgilerin, karşıdakinin insan
olup olmadığının anlaşılması için Cloudflare&rsquo;e iletilir.
Yazdığın mesaj Cloudflare&rsquo;e gönderilmez.
</li>
</ul>
<p className="mt-2">
KVKK açısından e-posta sağlayıcısına, yapay zekâ sağlayıcısına ve
cevap denetimi sağlayıcısına yapılan iletim yurt dışına
KVKK açısından e-posta sağlayıcısına, yapay zekâ sağlayıcısına,
cevap denetimi sağlayıcısına ve bot koruması sağlayıcısına
yapılan iletim yurt dışına
aktarımdır ve hizmetin sunulabilmesi için gereklidir.
Sağlayıcı değişirse bu bölüm aynı gün güncellenir.
</p>

View File

@@ -2,28 +2,112 @@
// Sürüm notları sayfasının altındaki geri bildirim kutusu: başlık, metin
// alanı, buton. Mesaj geriBildirimGonder sunucu aksiyonuyla destek kutusuna
// e-posta olarak gider.
// e-posta olarak gider. Bot koruması Cloudflare Turnstile: betik ancak metin
// alanına ilk odaklanıldığında yüklenir, kutusu yalnız Cloudflare elle onay
// istediğinde görünür (çoğu ziyaretçi hiç görmez).
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ArrowRight } from "lucide-react";
import { toast } from "@/components/ui/bildirim";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { geriBildirimGonder } from "@/features/geri-bildirim/geri-bildirim-actions";
import {
geriBildirimGonder,
geriBildirimKorumasi,
} from "@/features/geri-bildirim/geri-bildirim-actions";
const BASLIK = "Bir hata mı gördün?";
const DEVAM = "Yalın üretim metodunu benimsiyor ve fikirlerine önem veriyoruz.";
const TURNSTILE_BETIGI =
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
type Turnstile = {
render: (kap: HTMLElement, ayar: Record<string, unknown>) => string;
reset: (kimlik: string) => void;
remove: (kimlik: string) => void;
};
declare global {
interface Window {
turnstile?: Turnstile;
}
}
let betikSozu: Promise<void> | null = null;
function turnstileBetiginiYukle(): Promise<void> {
betikSozu ??= new Promise<void>((coz, reddet) => {
const betik = document.createElement("script");
betik.src = TURNSTILE_BETIGI;
betik.async = true;
betik.onload = () => coz();
betik.onerror = () => {
betikSozu = null;
reddet(new Error("Turnstile betiği yüklenemedi"));
};
document.head.appendChild(betik);
});
return betikSozu;
}
export function GeriBildirimFormu({ className = "" }: { className?: string }) {
const [mesaj, setMesaj] = useState("");
const [pending, setPending] = useState(false);
// Koruma açık ve token henüz gelmediyse buton bekler. Betik yüklenemezse
// (engelleyici, ağ) korumaAcik false kalır; son sözü sunucu söyler.
const [korumaAcik, setKorumaAcik] = useState(false);
const [token, setToken] = useState<string | null>(null);
const [onayGorunur, setOnayGorunur] = useState(false);
const korumaKabi = useRef<HTMLDivElement>(null);
const widget = useRef<string | null>(null);
const korumaBasladi = useRef(false);
async function korumayiBaslat() {
if (korumaBasladi.current) return;
korumaBasladi.current = true;
try {
const { siteAnahtari } = await geriBildirimKorumasi();
if (!siteAnahtari) return;
await turnstileBetiginiYukle();
if (!korumaKabi.current || !window.turnstile) return;
widget.current = window.turnstile.render(korumaKabi.current, {
sitekey: siteAnahtari,
appearance: "interaction-only",
size: "flexible",
language: "tr",
theme: document.documentElement.classList.contains("dark")
? "dark"
: "light",
callback: (yeni: string) => setToken(yeni),
"before-interactive-callback": () => setOnayGorunur(true),
"after-interactive-callback": () => setOnayGorunur(false),
"expired-callback": () => setToken(null),
"error-callback": () => setToken(null),
});
setKorumaAcik(true);
} catch {
korumaBasladi.current = false;
}
}
useEffect(() => {
return () => {
if (widget.current) window.turnstile?.remove(widget.current);
};
}, []);
async function gonder(e: React.FormEvent) {
e.preventDefault();
setPending(true);
try {
const sonuc = await geriBildirimGonder(mesaj);
const sonuc = await geriBildirimGonder(mesaj, token);
// Token tek kullanımlık: sonuç ne olursa olsun yenisi istenir.
if (widget.current) {
setToken(null);
window.turnstile?.reset(widget.current);
}
if (sonuc.ok) {
toast.success("Geri bildirimin ulaştı, teşekkürler.");
setMesaj("");
@@ -73,17 +157,23 @@ export function GeriBildirimFormu({ className = "" }: { className?: string }) {
aria-labelledby="geri-bildirim-baslik"
value={mesaj}
onChange={(e) => setMesaj(e.target.value)}
onFocus={korumayiBaslat}
maxLength={2000}
placeholder="Çekinme, aklına geleni yaz…"
disabled={pending}
className="min-h-28 resize-none border-0 bg-transparent px-3 py-2 focus-visible:ring-0 disabled:bg-transparent"
/>
<div ref={korumaKabi} className={onayGorunur ? "mb-2" : undefined} />
<div className="flex justify-end">
<Button
type="submit"
size="lg"
variant="cta"
disabled={pending || mesaj.trim().length < 10}
disabled={
pending ||
mesaj.trim().length < 10 ||
(korumaAcik && !token)
}
>
{pending ? "Gönderiliyor…" : "Gönder"}
<ArrowRight className="size-4" aria-hidden />

View File

@@ -5,6 +5,7 @@ import { getSession } from "@/lib/session";
import { geriBildirimEpostasi } from "@/lib/eposta";
import { epostaGonder } from "@/lib/eposta-gonder";
import { SURUM } from "@/lib/surum";
import { turnstileDogrula, turnstileSiteAnahtari } from "@/lib/turnstile";
const DESTEK_ADRESI = "destek@kolaytercih.com";
const MIN_UZUNLUK = 10;
@@ -39,8 +40,19 @@ function sinirAsildi(ip: string): boolean {
return false;
}
/**
* Formun bot korumasını (Turnstile) kurması için site anahtarı. Koruma
* kapalıysa null döner ve form korumasız çalışır.
*/
export async function geriBildirimKorumasi(): Promise<{
siteAnahtari: string | null;
}> {
return { siteAnahtari: turnstileSiteAnahtari() };
}
export async function geriBildirimGonder(
hamMesaj: string,
korumaTokeni?: string | null,
): Promise<{ ok: true } | { ok: false; error: string }> {
const mesaj = typeof hamMesaj === "string" ? hamMesaj.trim() : "";
if (mesaj.length < MIN_UZUNLUK) {
@@ -61,6 +73,17 @@ export async function geriBildirimGonder(
error: "Kısa sürede çok mesaj gönderdin. Birkaç dakika sonra tekrar dene.",
};
}
const insan = await turnstileDogrula(
typeof korumaTokeni === "string" ? korumaTokeni : null,
ip === "bilinmiyor" ? null : ip,
);
if (!insan) {
return {
ok: false,
error:
"Güvenlik doğrulaması tamamlanamadı. Sayfayı yenileyip tekrar dener misin?",
};
}
try {
await epostaGonder(

62
src/lib/turnstile.ts Normal file
View File

@@ -0,0 +1,62 @@
import "server-only";
/**
* Cloudflare Turnstile (bot koruması) — sunucu tarafı. Formda üretilen token
* burada Cloudflare'e doğrulatılır; tarayıcının "geçtim" demesine güvenilmez.
*
* Anahtarlar public önekli DEĞİL ve çağrı anında okunur (bkz. app-url.ts):
* site anahtarı da tarayıcıya sunucu aksiyonuyla gider, build'e gömülmez.
* İki anahtardan biri eksikse koruma kapalıdır — form anahtarsız ortamda
* (lokal, anahtar girilmeden yapılan deploy) çalışmaya devam eder.
*/
const DOGRULAMA_UCU =
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
/** Koruma açıksa tarayıcıya verilecek site anahtarı, kapalıysa null. */
export function turnstileSiteAnahtari(): string | null {
const site = process.env.TURNSTILE_SITE_KEY;
return site && process.env.TURNSTILE_SECRET_KEY ? site : null;
}
/**
* Token geçerliyse (ya da koruma kapalıysa) true. Cloudflare'e ulaşılamazsa
* false döner: doğrulanamayan istek geçmiş sayılmaz.
*/
export async function turnstileDogrula(
token: string | null | undefined,
ip: string | null,
): Promise<boolean> {
if (!turnstileSiteAnahtari()) {
if (process.env.NODE_ENV === "production") {
console.warn("[turnstile] Anahtarlar tanımlı değil, koruma kapalı");
}
return true;
}
if (!token) return false;
const govde = new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY!,
response: token,
});
if (ip) govde.set("remoteip", ip);
try {
const yanit = await fetch(DOGRULAMA_UCU, {
method: "POST",
body: govde,
signal: AbortSignal.timeout(5000),
});
const sonuc = (await yanit.json()) as {
success: boolean;
"error-codes"?: string[];
};
if (!sonuc.success) {
console.warn("[turnstile] Reddedildi:", sonuc["error-codes"]);
}
return sonuc.success;
} catch (hata) {
console.error("[turnstile] Doğrulama ucuna ulaşılamadı:", hata);
return false;
}
}