All checks were successful
Deploy / deploy (push) Successful in 7m45s
- Updated the app.db file to reflect recent changes in the application state. - Modified BACKLOG.md to include new entries regarding the removal of certain wizard steps, adjustments to priority chips, and the introduction of special quota conditions for candidates. - Added details on the decision to link priority chips to data sources and clarified the implications for user experience and data handling.
191 lines
7.2 KiB
TypeScript
191 lines
7.2 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.
|
||
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; |