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:
@@ -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’ın sayfasına yönlendirilirsin; orada
|
||||
Google’ın çerez politikası geçerlidir. Bunların dışında
|
||||
Google’ın çerez politikası geçerlidir. Geri bildirim
|
||||
kutusuna yazmaya başladığında otomatik gönderimleri ayırt etmek
|
||||
için Cloudflare Turnstile’ı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>
|
||||
|
||||
@@ -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’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’e iletilir.
|
||||
Yazdığın mesaj Cloudflare’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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
62
src/lib/turnstile.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user