Oturum oluşturulduğunda (databaseHooks.session.create.after) Rybbit'e sunucudan giris_basarili düşer; yontem = eposta | google | diger (better-auth uç yolundan türetilir). Huninin "link istendi → giriş yapıldı" adımı bugüne dek ölçülemiyordu. - await yok: Rybbit'in 3 sn'lik timeout'u girişi geciktirmez; sunucuOlayi hata fırlatmaz. E-posta adresi olaya girmez, yalnızca kullanıcı ID. - RYBBIT_HOST/SITE_ID/API_KEY yoksa no-op — prod .env.production'da bu üçü bugün yok (bkz. ayrı ci.yaml commit'i), eklenene dek olay düşmez. Giriş akışının davranışını değiştirmez (yalnızca yan etki ekler). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
186 lines
6.8 KiB
TypeScript
186 lines
6.8 KiB
TypeScript
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<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) {
|
||
// 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.
|
||
(globalThis as { __sonMagicLink?: string }).__sonMagicLink = url;
|
||
}
|
||
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; |