import "server-only"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { magicLink } from "better-auth/plugins"; import { sunucuOlayi } from "./analitik-sunucu"; import { appDb, schema } from "./appdb"; import { grantCredits, DENEME_KREDISI } from "./credits"; import { magicLinkEpostasi } from "./eposta"; import { epostaGonder, epostaKonsolModu } from "./eposta-gonder"; /** * Verify URL'ini maile konacak onay sayfası URL'ine çevirir. Doğrulama linki * GET'lendiği anda token'ı tüketen tek kullanımlık bir uçtur; mail güvenlik * tarayıcıları (SafeLinks vb.) ve tarayıcı ön-yüklemeleri linki kullanıcıdan * önce "ziyaret edip" token'ı yakıyordu. Maildeki link bu yüzden token'ı * tüketmeyen /giris/dogrula sayfasına gider; verify ancak oradaki butonla * tetiklenir. */ function onaySayfasiUrl(verifyUrl: string): string { const kaynak = new URL(verifyUrl); const hedef = new URL("/giris/dogrula", kaynak.origin); hedef.search = kaynak.search; return hedef.toString(); } /** Aynı adrese bu süre içinde en çok bir giriş e-postası gider. */ const GIRIS_SOGUMA_MS = 60_000; /** * Adres → son gönderim zamanı. Süreç içi Map: tek konteyner çalışıyoruz, * şema/DB yazımı gerektirmez; yeniden başlatmada sıfırlanır (ikinci katman * better-auth'un IP limiti). `verification` tablosundan türetilmez — orada * identifier token'dır ve satır sendMagicLink'ten ÖNCE yazılır. globalThis: * modül birden çok kez yüklense de tek kayıt (appdb/index.ts deseni). */ function girisSogumaKaydi(): Map { const g = globalThis as { __girisEpostaSon?: Map }; return (g.__girisEpostaSon ??= new Map()); } function sogumaAnahtari(email: string): string { return email.trim().toLowerCase(); } /** * Adres soğumada değilse pencereyi başlatır ve `true` döner; soğumadaysa * `false` (pencere kaymaz — süre ilk gönderimden sayılır). Her çağrıda süresi * dolmuş kayıtlar budanır, Map büyümez. */ export function girisSogumasiAl(email: string, simdi = Date.now()): boolean { const kayit = girisSogumaKaydi(); for (const [adres, zaman] of kayit) { if (simdi - zaman >= GIRIS_SOGUMA_MS) kayit.delete(adres); } const anahtar = sogumaAnahtari(email); if (kayit.has(anahtar)) return false; kayit.set(anahtar, simdi); return true; } /** Gönderim başarısızsa pencereyi geri verir — kullanıcı hemen yeniden deneyebilsin. */ export function girisSogumasiBirak(email: string): void { girisSogumaKaydi().delete(sogumaAnahtari(email)); } async function sendMagicLinkEmail(email: string, url: string) { // Soğuma yalnızca prod'da: dev paneli "anında giriş" aynı adrese art arda // istek atıyor. Konsol modunda da işler (lokal docker testi görsün). const sogumaAcik = process.env.NODE_ENV === "production"; if (sogumaAcik && !girisSogumasiAl(email)) { // Sessizce çık: istek 200 döner, form "gönderildi" gösterir, mail çıkmaz — // üçüncü kişiye mail bombardımanı ve Resend kotası yakılması engellenir. console.info( "[giris] Soğuma penceresinde tekrar istek — e-posta gönderilmedi.", ); return; } try { await girisLinkiniIlet(email, url); } catch (err) { if (sogumaAcik) girisSogumasiBirak(email); throw err; } } async function girisLinkiniIlet(email: string, url: string) { if (process.env.NODE_ENV !== "production") { // Dev paneli "anında giriş" için son üretilen linki yakalar (bkz. // src/lib/dev/actions.ts) — panel onay adımını atlayıp doğrudan verify // URL'ine gittiği için ham link saklanır. Prod'da asla çalışmaz. const g = globalThis as { __sonMagicLink?: string; __devAnindaGiris?: boolean }; g.__sonMagicLink = url; // Dev paneli "anında giriş"te e-posta HİÇ gönderilmez: panel linki kendisi // açar. Dev env'inde Resend anahtarı varken example.com alıcısına gerçek // gönderim başarısız olup girişi sessizce düşürüyordu (6 Eki 2026 QA). if (g.__devAnindaGiris) return; } const mailLinki = onaySayfasiUrl(url); if (epostaKonsolModu()) { // Konsol modu (dev ya da EPOSTA_KONSOL_FALLBACK=1): linki terminale yaz. // Prod'da anahtar yoksa buraya girilmez; epostaGonder hata fırlatır — // token'lı link prod loglarına düşmez. console.log(`\n[giris] Magic link for ${email}:\n${mailLinki}\n`); return; } // Gönderim/yapılandırma hatası better-auth'a fırlatılır — aksi halde // kullanıcı "gönderildi" ekranını görür ama mail hiç çıkmaz. await epostaGonder(email, magicLinkEpostasi(mailLinki)); } export const auth = betterAuth({ baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000", secret: process.env.BETTER_AUTH_SECRET, database: drizzleAdapter(appDb, { provider: "sqlite", schema: { user: schema.user, session: schema.session, account: schema.account, verification: schema.verification, }, }), session: { // Session'ı imzalı cookie'de önbelle: her istekte session tablosu // sorgusu yapılmaz. Kredi/paket tazeliği getCurrentUser()'daki ayrı // user sorgusuyla korunur (src/lib/session.ts). cookieCache: { enabled: true, maxAge: 5 * 60 }, }, user: { additionalFields: { creditBalance: { type: "number", defaultValue: 0, input: false }, hasPaket: { type: "boolean", defaultValue: false, input: false }, }, }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID ?? "", clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "", }, }, plugins: [ magicLink({ // Varsayılan 5 dk, mail gecikmesi + onay adımı için 15 dk'ya çıkarıldı. // Süreyi değiştirirken e-posta metnini (src/lib/eposta.ts) ve giriş // ekranındaki "gönderildi" mesajını (giris-form.tsx) da güncelle. expiresIn: 900, sendMagicLink: async ({ email, url }) => { await sendMagicLinkEmail(email, url); }, }), ], databaseHooks: { user: { create: { after: async (newUser) => { // refId = userId → UNIQUE(reason, refId) ile tek seferlik await grantCredits({ userId: newUser.id, delta: DENEME_KREDISI, reason: "trial_grant", refId: newUser.id, }); }, }, }, session: { create: { after: async (session, ctx) => { // Huninin son adımı: oturum gerçekten açıldı. Sunucudan gider — // reklam engelleyici ve kapanan sekme kaybettirmez. `await` YOK: // Rybbit'in 3 sn'lik timeout'u girişi geciktirmesin (sunucuOlayi // hata fırlatmaz). E-posta adresi özelliklere girmez. const yol = ctx?.path ?? ""; void sunucuOlayi("giris_basarili", { kullaniciId: session.userId, pathname: "/giris", ozellikler: { yontem: yol.startsWith("/magic-link") ? "eposta" : yol.startsWith("/callback") ? "google" : "diger", }, }); }, }, }, }, }); export type Session = typeof auth.$Infer.Session;