Files
kolaytercih/src/lib/auth.ts
bilalgursen 17046c830f feat(analitik): giris_basarili sunucu olayı
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>
2026-09-21 04:55:10 +03:00

186 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;