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}> <Parallax fromStart strength={10}>
<h1 className="font-heading text-3xl font-bold">Çerez Politikası</h1> <h1 className="font-heading text-3xl font-bold">Çerez Politikası</h1>
<p className="mt-2 text-sm text-slate-500"> <p className="mt-2 text-sm text-slate-500">
Son güncelleme: 5 Ekim 2026 Son güncelleme: 11 Ekim 2026
</p> </p>
</Parallax> </Parallax>
@@ -117,7 +117,11 @@ export default function CerezPolitikasiPage() {
Paket satın alırken kart formunu iyzico gösterir ve ödeme Paket satın alırken kart formunu iyzico gösterir ve ödeme
güvenliği için kendi çerezlerini kullanabilir. Google ile giriş güvenliği için kendi çerezlerini kullanabilir. Google ile giriş
yaparsan Google&rsquo;ın sayfasına yönlendirilirsin; orada 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 sitede başka şirketlere ait çerez, reklam ya da sosyal medya
eklentisi yoktur. eklentisi yoktur.
</p> </p>

View File

@@ -30,7 +30,7 @@ export default function GizlilikPage() {
Gizlilik ve Kişisel Verilerin Korunması Gizlilik ve Kişisel Verilerin Korunması
</h1> </h1>
<p className="mt-2 text-sm text-slate-500"> <p className="mt-2 text-sm text-slate-500">
Son güncelleme: 5 Ekim 2026 Son güncelleme: 11 Ekim 2026
</p> </p>
</Parallax> </Parallax>
@@ -179,10 +179,19 @@ export default function GizlilikPage() {
programlar ve kaydettiğin tercihler. Senin yazdığın mesaj, adın programlar ve kaydettiğin tercihler. Senin yazdığın mesaj, adın
ve e-postan gönderilmez. ve e-postan gönderilmez.
</li> </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> </ul>
<p className="mt-2"> <p className="mt-2">
KVKK açısından e-posta sağlayıcısına, yapay zekâ sağlayıcısına ve 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 yapılan iletim yurt dışı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. aktarımdır ve hizmetin sunulabilmesi için gereklidir.
Sağlayıcı değişirse bu bölüm aynı gün güncellenir. Sağlayıcı değişirse bu bölüm aynı gün güncellenir.
</p> </p>

View File

@@ -2,28 +2,112 @@
// Sürüm notları sayfasının altındaki geri bildirim kutusu: başlık, metin // Sürüm notları sayfasının altındaki geri bildirim kutusu: başlık, metin
// alanı, buton. Mesaj geriBildirimGonder sunucu aksiyonuyla destek kutusuna // 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 { ArrowRight } from "lucide-react";
import { toast } from "@/components/ui/bildirim"; import { toast } from "@/components/ui/bildirim";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { TypingAnimation } from "@/components/ui/typing-animation"; 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 BASLIK = "Bir hata mı gördün?";
const DEVAM = "Yalın üretim metodunu benimsiyor ve fikirlerine önem veriyoruz."; 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 }) { export function GeriBildirimFormu({ className = "" }: { className?: string }) {
const [mesaj, setMesaj] = useState(""); const [mesaj, setMesaj] = useState("");
const [pending, setPending] = useState(false); 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) { async function gonder(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setPending(true); setPending(true);
try { 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) { if (sonuc.ok) {
toast.success("Geri bildirimin ulaştı, teşekkürler."); toast.success("Geri bildirimin ulaştı, teşekkürler.");
setMesaj(""); setMesaj("");
@@ -73,17 +157,23 @@ export function GeriBildirimFormu({ className = "" }: { className?: string }) {
aria-labelledby="geri-bildirim-baslik" aria-labelledby="geri-bildirim-baslik"
value={mesaj} value={mesaj}
onChange={(e) => setMesaj(e.target.value)} onChange={(e) => setMesaj(e.target.value)}
onFocus={korumayiBaslat}
maxLength={2000} maxLength={2000}
placeholder="Çekinme, aklına geleni yaz…" placeholder="Çekinme, aklına geleni yaz…"
disabled={pending} disabled={pending}
className="min-h-28 resize-none border-0 bg-transparent px-3 py-2 focus-visible:ring-0 disabled:bg-transparent" 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"> <div className="flex justify-end">
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
variant="cta" variant="cta"
disabled={pending || mesaj.trim().length < 10} disabled={
pending ||
mesaj.trim().length < 10 ||
(korumaAcik && !token)
}
> >
{pending ? "Gönderiliyor…" : "Gönder"} {pending ? "Gönderiliyor…" : "Gönder"}
<ArrowRight className="size-4" aria-hidden /> <ArrowRight className="size-4" aria-hidden />

View File

@@ -5,6 +5,7 @@ import { getSession } from "@/lib/session";
import { geriBildirimEpostasi } from "@/lib/eposta"; import { geriBildirimEpostasi } from "@/lib/eposta";
import { epostaGonder } from "@/lib/eposta-gonder"; import { epostaGonder } from "@/lib/eposta-gonder";
import { SURUM } from "@/lib/surum"; import { SURUM } from "@/lib/surum";
import { turnstileDogrula, turnstileSiteAnahtari } from "@/lib/turnstile";
const DESTEK_ADRESI = "destek@kolaytercih.com"; const DESTEK_ADRESI = "destek@kolaytercih.com";
const MIN_UZUNLUK = 10; const MIN_UZUNLUK = 10;
@@ -39,8 +40,19 @@ function sinirAsildi(ip: string): boolean {
return false; 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( export async function geriBildirimGonder(
hamMesaj: string, hamMesaj: string,
korumaTokeni?: string | null,
): Promise<{ ok: true } | { ok: false; error: string }> { ): Promise<{ ok: true } | { ok: false; error: string }> {
const mesaj = typeof hamMesaj === "string" ? hamMesaj.trim() : ""; const mesaj = typeof hamMesaj === "string" ? hamMesaj.trim() : "";
if (mesaj.length < MIN_UZUNLUK) { 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.", 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 { try {
await epostaGonder( 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;
}
}