feat(giris): adres başına 60 sn soğuma

Aynı e-posta adresine 60 sn içinde ikinci giriş linki gönderilmez: istek
yine 200 döner (form "gönderildi" gösterir), mail çıkmaz. Üçüncü kişiye
mail bombardımanını ve Resend kotasının yakılmasını engeller; bugüne dek
tek sınır better-auth'un IP limitiydi (5 istek/60 sn).

- Kaynak süreç içi Map (globalThis.__girisEpostaSon), anahtar
  trim+lowercase adres. verification tablosundan türetilmez: orada
  identifier token'dır ve satır sendMagicLink'ten önce yazılır.
- Pencere kaymaz (düşen istek süreyi uzatmaz); her çağrıda eski kayıtlar
  budanır. Gönderim hata verirse kayıt silinir, kullanıcı hemen yeniden
  deneyebilir.
- Yalnızca NODE_ENV=production: dev paneli "anında giriş" art arda istek
  atıyor. Konsol modunda da işler (lokal docker testi).

Canlı giriş yolunu değiştirir; merge öncesi lokal docker testi şart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-21 04:55:10 +03:00
parent 5992cf3637
commit 2af4a9f781

View File

@@ -22,7 +22,67 @@ function onaySayfasiUrl(verifyUrl: string): string {
return hedef.toString(); 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<string, number> {
const g = globalThis as { __girisEpostaSon?: Map<string, number> };
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) { 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") { if (process.env.NODE_ENV !== "production") {
// Dev paneli "anında giriş" için son üretilen linki yakalar (bkz. // 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 // src/lib/dev/actions.ts) — panel onay adımını atlayıp doğrudan verify