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}>
|
<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’ın sayfasına yönlendirilirsin; orada
|
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
|
sitede başka şirketlere ait çerez, reklam ya da sosyal medya
|
||||||
eklentisi yoktur.
|
eklentisi yoktur.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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’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>
|
</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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
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