dev #58

Merged
root merged 4 commits from dev into main 2026-05-27 02:40:15 +03:00
6 changed files with 173 additions and 4 deletions
Showing only changes of commit a09182fb98 - Show all commits

View File

@@ -5,6 +5,8 @@ export const contactMessageSchema = z.object({
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
subject: z.string().trim().max(200, "Konu çok uzun").optional().default(""),
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
// Cloudflare Turnstile token'ı (captcha aktifse zorunlu, sunucuda doğrulanır)
turnstileToken: z.string().optional(),
});
export type ContactMessage = z.infer<typeof contactMessageSchema>;

View File

@@ -25,6 +25,11 @@ export class ContactService {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz form verisi");
}
const ok = await this.verifyTurnstile(parsed.data.turnstileToken);
if (!ok) {
throw new BadRequestException("Doğrulama başarısız, lütfen tekrar deneyin.");
}
const { name, email, message } = parsed.data;
const subject = parsed.data.subject?.trim() || "Yeni mesaj";
@@ -47,4 +52,23 @@ export class ContactService {
this.logger.log(`Contact form submitted by ${email}`);
return { success: true };
}
// Cloudflare Turnstile doğrulaması. Secret tanımlı değilse atlanır (true döner).
private async verifyTurnstile(token: string | undefined): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) return true;
if (!token) return false;
try {
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret, response: token }),
});
const data = (await res.json()) as { success?: boolean };
return data.success === true;
} catch (error) {
this.logger.error(`Turnstile verification failed: ${error}`);
return false;
}
}
}

View File

@@ -0,0 +1,106 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
// Cloudflare Turnstile site key (public). sase.tr/dev.sase.tr/localhost domainleri
// için kayıtlı. Gerekirse VITE_TURNSTILE_SITE_KEY ile override edilebilir.
const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || "0x4AAAAAADW2whBc2Nj9nJk3";
const SCRIPT_ID = "cf-turnstile-script";
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
interface TurnstileApi {
render: (el: HTMLElement, opts: Record<string, unknown>) => string;
reset: (id: string) => void;
remove: (id: string) => void;
}
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
let scriptPromise: Promise<void> | null = null;
function loadTurnstile(): Promise<void> {
if (typeof window === "undefined") return Promise.resolve();
if (window.turnstile) return Promise.resolve();
if (scriptPromise) return scriptPromise;
scriptPromise = new Promise<void>((resolve, reject) => {
const existing = document.getElementById(SCRIPT_ID);
if (existing) {
existing.addEventListener("load", () => resolve());
return;
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = SCRIPT_SRC;
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Turnstile yüklenemedi"));
document.head.appendChild(script);
});
return scriptPromise;
}
export interface TurnstileHandle {
/** Widget'ı sıfırlar — token tek kullanımlık olduğu için başarısız submit sonrası çağrılır. */
reset: () => void;
}
interface TurnstileProps {
onVerify: (token: string) => void;
onExpire?: () => void;
theme?: "light" | "dark" | "auto";
className?: string;
}
/**
* Cloudflare Turnstile widget'ı. Çözüm tamamlanınca `onVerify(token)` çağrılır;
* token form gönderiminde sunucuya iletilir ve orada doğrulanır.
*/
export const Turnstile = forwardRef<TurnstileHandle, TurnstileProps>(function Turnstile(
{ onVerify, onExpire, theme = "auto", className },
ref,
) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
const callbacksRef = useRef({ onVerify, onExpire });
callbacksRef.current = { onVerify, onExpire };
useImperativeHandle(ref, () => ({
reset: () => {
if (widgetIdRef.current && window.turnstile) {
window.turnstile.reset(widgetIdRef.current);
}
},
}));
useEffect(() => {
let cancelled = false;
loadTurnstile()
.then(() => {
if (cancelled || !containerRef.current || !window.turnstile || widgetIdRef.current) return;
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: SITE_KEY,
theme,
callback: (token: string) => callbacksRef.current.onVerify(token),
"expired-callback": () => callbacksRef.current.onExpire?.(),
"error-callback": () => callbacksRef.current.onExpire?.(),
});
})
.catch(() => {});
return () => {
cancelled = true;
if (widgetIdRef.current && window.turnstile) {
try {
window.turnstile.remove(widgetIdRef.current);
} catch {}
widgetIdRef.current = null;
}
};
}, [theme]);
return <div ref={containerRef} className={className} />;
});

View File

@@ -1,3 +1,4 @@
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
import { api } from "@/lib/api-client";
import { signIn, signUp } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
@@ -9,7 +10,7 @@ import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Check, Eye, EyeOff, ShieldCheck } from "lucide-react";
import { useState } from "react";
import { useRef, useState } from "react";
export const Route = createFileRoute("/_auth/register")({
component: RegisterPage,
@@ -28,6 +29,8 @@ function RegisterPage() {
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showRefCode, setShowRefCode] = useState(!!ref);
const [captchaToken, setCaptchaToken] = useState("");
const turnstileRef = useRef<TurnstileHandle>(null);
const passwordRules = [
{ ok: password.length >= 8, label: "8+ karakter" },
{ ok: /[A-Z]/.test(password), label: "1 büyük harf" },
@@ -51,9 +54,20 @@ function RegisterPage() {
// better-auth returns { error } rather than throwing (see login.tsx).
// The old code awaited without checking error, so failed signups still
// reported success, fired user_signed_up, and redirected to the dashboard.
const { error } = await signUp.email({ name, email, password, callbackURL: redirectUrl });
const { error } = await signUp.email({
name,
email,
password,
callbackURL: redirectUrl,
fetchOptions: captchaToken
? { headers: { "x-captcha-response": captchaToken } }
: undefined,
});
if (error) {
// Token tek kullanımlık — başarısız denemeden sonra widget'ı sıfırla
turnstileRef.current?.reset();
setCaptchaToken("");
let exists = false;
try {
({ exists } = await api.post<{ exists: boolean }>("/users/check-email", { email }));
@@ -230,6 +244,12 @@ function RegisterPage() {
Referans kodun var mı?
</button>
)}
<Turnstile
ref={turnstileRef}
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Kayıt yapılıyor..." : "Ücretsiz Başla"}
</Button>

View File

@@ -1,11 +1,12 @@
import { SiteHeader } from "@/components/site-header";
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
import { usePageMeta } from "@/hooks/use-page-meta";
import { ApiError, api } from "@/lib/api-client";
import { toast } from "@/lib/toast";
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@sase/ui";
import { createFileRoute } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import { type FormEvent, useState } from "react";
import { type FormEvent, useRef, useState } from "react";
import { z } from "zod";
export const Route = createFileRoute("/contact")({
@@ -31,7 +32,9 @@ function ContactForm() {
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [errors, setErrors] = useState<FieldErrors>({});
const [captchaToken, setCaptchaToken] = useState("");
const [loading, setLoading] = useState(false);
const turnstileRef = useRef<TurnstileHandle>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -48,7 +51,7 @@ function ContactForm() {
setErrors({});
setLoading(true);
try {
await api.post("/contact", parsed.data);
await api.post("/contact", { ...parsed.data, turnstileToken: captchaToken });
toast.success("Mesajınız gönderildi", {
description: "En kısa sürede size dönüş yapacağız.",
});
@@ -56,7 +59,12 @@ function ContactForm() {
setEmail("");
setSubject("");
setMessage("");
turnstileRef.current?.reset();
setCaptchaToken("");
} catch (err) {
// Token tek kullanımlık — başarısız denemeden sonra widget'ı sıfırla
turnstileRef.current?.reset();
setCaptchaToken("");
const msg =
err instanceof ApiError ? err.message : "Mesaj gönderilemedi. Lütfen tekrar deneyin.";
toast.error(msg);
@@ -127,6 +135,12 @@ function ContactForm() {
{errors.message && <p className="text-sm text-destructive">{errors.message}</p>}
</div>
<Turnstile
ref={turnstileRef}
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
/>
<Button type="submit" disabled={loading} className="w-full sm:w-auto">
{loading ? (
<>

View File

@@ -16,6 +16,9 @@ export const envSchema = z.object({
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
// Cloudflare Turnstile (captcha) — tanımlı değilse captcha atlanır
TURNSTILE_SECRET_KEY: z.string().optional(),
MINIO_ENDPOINT: z.string(),
MINIO_ACCESS_KEY: z.string(),
MINIO_SECRET_KEY: z.string(),