diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 01f4ab5..16d629c 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -26,11 +26,18 @@ jobs: GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }} RESEND_API_KEY=${{ secrets.RESEND_API_KEY }} EMAIL_FROM=${{ secrets.EMAIL_FROM }} + # İsteğe bağlı: secret yoksa boş gelir → kod destek@kolaytercih.com'a düşer. + EMAIL_REPLY_TO=${{ secrets.EMAIL_REPLY_TO }} IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }} IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }} IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }} OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }} OPENROUTER_MODEL=${{ secrets.OPENROUTER_MODEL }} + # Sunucu tarafı Rybbit olayları (odeme_tamamlandi, giris_basarili — + # src/lib/analitik-sunucu.ts). Üçünden biri boşsa gönderim no-op. + RYBBIT_HOST=${{ secrets.RYBBIT_HOST }} + RYBBIT_SITE_ID=${{ secrets.RYBBIT_SITE_ID }} + RYBBIT_API_KEY=${{ secrets.RYBBIT_API_KEY }} EOF # BuildKit'te "docker build --memory" YOK SAYILIR (moby/buildkit#1362); diff --git a/src/lib/analitik-sunucu.ts b/src/lib/analitik-sunucu.ts index 0c10688..3e2b8cf 100644 --- a/src/lib/analitik-sunucu.ts +++ b/src/lib/analitik-sunucu.ts @@ -1,7 +1,7 @@ import "server-only"; /** * Rybbit'e sunucudan olay gönderimi (POST /api/track). Yalnızca sunucu - * tarafından çağrılır (lib/odeme.ts) — anahtar NEXT_PUBLIC_ değil. + * tarafından çağrılır (lib/odeme.ts, lib/auth.ts) — anahtar NEXT_PUBLIC_ değil. * * Neden sunucu tarafı: ödeme dönüşümü tarayıcıdan gönderilirse reklam * engelleyiciler ve iyzico dönüşünde kapatılan sekmeler yüzünden kaybolur. @@ -14,7 +14,7 @@ const HOST = process.env.RYBBIT_HOST; const SITE_ID = process.env.RYBBIT_SITE_ID; const API_KEY = process.env.RYBBIT_API_KEY; -export type SunucuOlayAdi = "odeme_tamamlandi"; +export type SunucuOlayAdi = "odeme_tamamlandi" | "giris_basarili"; export async function sunucuOlayi( ad: SunucuOlayAdi, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 121c003..680a0eb 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,10 +2,11 @@ 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 } from "./eposta-gonder"; +import { epostaGonder, epostaKonsolModu } from "./eposta-gonder"; /** * Verify URL'ini maile konacak onay sayfası URL'ine çevirir. Doğrulama linki @@ -22,7 +23,67 @@ function onaySayfasiUrl(verifyUrl: string): string { 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 @@ -30,13 +91,15 @@ async function sendMagicLinkEmail(email: string, url: string) { (globalThis as { __sonMagicLink?: string }).__sonMagicLink = url; } const mailLinki = onaySayfasiUrl(url); - if (!process.env.RESEND_API_KEY) { - // Dev fallback: Resend anahtarı yoksa linki terminale yaz + 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 hatası better-auth'a fırlatılır — aksi halde kullanıcı - // "gönderildi" ekranını görür ama mail hiç çıkmaz. + // 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)); } @@ -95,6 +158,28 @@ export const auth = betterAuth({ }, }, }, + 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", + }, + }); + }, + }, + }, }, }); diff --git a/src/lib/eposta-gonder.ts b/src/lib/eposta-gonder.ts index a4e6050..aa38b3a 100644 --- a/src/lib/eposta-gonder.ts +++ b/src/lib/eposta-gonder.ts @@ -4,23 +4,62 @@ import type { EpostaIcerik } from "./eposta"; /** * Ortak e-posta göndericisi (Resend). Şablonlar eposta.ts'te, gönderim * burada — magic link (auth.ts) ve kredi hatırlatması aynı yoldan çıkar. - * RESEND_API_KEY yoksa dev fallback: içerik terminale yazılır. + * + * Env okumalarının tamamı ÇAĞRI ANINDA yapılır, modül seviyesinde değil: + * `next build` NODE_ENV=production + env'siz koşar; modül yüklenirken hata + * fırlatmak build'i kırar. */ + +/** Maile yanıt verilirse düşeceği adres — mail.kolaytercih.com'un MX'i yok. */ +const VARSAYILAN_YANIT_ADRESI = "destek@kolaytercih.com"; + +/** Prod'da konsol fallback'ini bilinçli açan kaçış bayrağı (lokal docker testi). */ +function kacisBayragi(): boolean { + return process.env.EPOSTA_KONSOL_FALLBACK === "1"; +} + +/** + * Konsol modu: gönderim yerine içerik terminale yazılır. Yalnızca Resend + * anahtarı yokken ve (dev'de ya da EPOSTA_KONSOL_FALLBACK=1 ile) geçerlidir. + * Production'da anahtar yoksa konsol moduna DÜŞÜLMEZ — giriş token'ı taşıyan + * link prod loglarına yazılmasın, kullanıcı da "gönderildi" sanmasın diye + * epostaGonder net hata fırlatır. + */ +export function epostaKonsolModu(): boolean { + return ( + !process.env.RESEND_API_KEY && + (process.env.NODE_ENV !== "production" || kacisBayragi()) + ); +} + export async function epostaGonder( to: string, icerik: EpostaIcerik, ): Promise { - if (!process.env.RESEND_API_KEY) { + if (epostaKonsolModu()) { console.log( - `\n[eposta] (dev) ${to} adresine gönderilecekti — ${icerik.subject}\n${icerik.text}\n`, + `\n[eposta] (konsol modu) ${to} adresine gönderilecekti — ${icerik.subject}\n${icerik.text}\n`, ); return; } + const anahtar = process.env.RESEND_API_KEY; + if (!anahtar) { + throw new Error("[eposta] RESEND_API_KEY tanımlı değil"); + } + // EMAIL_FROM yoksa resend.dev adresi yalnızca Resend hesap sahibine teslim + // eder — prod'da sessizce buna düşmek yerine yapılandırma hatasını göster. + const gonderen = process.env.EMAIL_FROM; + if (!gonderen && process.env.NODE_ENV === "production" && !kacisBayragi()) { + throw new Error("[eposta] EMAIL_FROM tanımlı değil"); + } const { Resend } = await import("resend"); - const resend = new Resend(process.env.RESEND_API_KEY); + const resend = new Resend(anahtar); const { error } = await resend.emails.send({ - from: process.env.EMAIL_FROM ?? "KolayTercih ", + from: gonderen || "KolayTercih ", to, + // `||`: CI'da secret tanımsızsa env boş string gelir; boş Reply-To ile + // Resend'e gitmek yerine varsayılana düş. + replyTo: process.env.EMAIL_REPLY_TO || VARSAYILAN_YANIT_ADRESI, subject: icerik.subject, html: icerik.html, // Düz metin alternatifi spam puanını düşürür ve HTML'i engelleyen diff --git a/src/lib/eposta.ts b/src/lib/eposta.ts index 996149b..f5dc673 100644 --- a/src/lib/eposta.ts +++ b/src/lib/eposta.ts @@ -6,8 +6,9 @@ import "server-only"; * desteğini güvenilir biçimde sunmaz — bu yüzden düzen tablo tabanlı ve tüm * stiller satır içi. Logo işareti sitedeki SVG'nin raster kopyası olan * `/kolay-tercih-mark.png` ile gösterilir (SVG'yi Gmail/Outlook engeller). - * Web fontları (Bricolage Grotesque, Cal Sans) ile progresif olarak - * yüklenir; desteklemeyen istemciler sistem yazı tipine düşer. + * Harici font 'i yok: çoğu istemci yok sayıyor, bazı spam filtreleri + * harici kaynağı eksi puan sayıyor. Cal Sans / Bricolage Grotesque yalnızca + * cihazda kuruluysa görünür; aksi hâlde sistem yazı tipine düşer. */ import { SITE_URL } from "./seo"; @@ -25,6 +26,11 @@ const BASLIK_TIPI = `'Cal Sans', ${YAZI_TIPI}`; /** Sitedeki kelime markası Bricolage Grotesque (font-bricolage). */ const MARKA_TIPI = `'Bricolage Grotesque', ${YAZI_TIPI}`; +/** Alt bilgideki iletişim satırı — HTML footer ve düz metin gövdeler ortak kullanır. */ +const SITE_ALANI = "kolaytercih.com"; +const DESTEK_EPOSTA = "destek@kolaytercih.com"; +const METIN_ALT_BILGI = ["", "—", `${SITE_ALANI} · ${DESTEK_EPOSTA}`]; + export type EpostaIcerik = { subject: string; html: string; @@ -76,8 +82,6 @@ function kabuk({ ${kacir(baslikEtiketi)} - -
${onizleme}
@@ -96,7 +100,8 @@ function kabuk({ KolayTercih · YKS tercih listeni dakikalar içinde hazırla
- Bu e-postayı hesabınla ilgili bir işlem yaptığın için aldın. + Bu e-postayı hesabınla ilgili bir işlem yaptığın için aldın.
+ ${SITE_ALANI} · ${DESTEK_EPOSTA} @@ -155,6 +160,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik { "", "Bağlantı 15 dakika geçerli ve yalnızca bir kez kullanılabilir.", "Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.", + ...METIN_ALT_BILGI, ].join("\n"), }; } @@ -201,6 +207,7 @@ export function krediHatirlatmaEpostasi(): EpostaIcerik { "", "Listenin tamamını açmak ya da danışmana soru sormak istersen kredi", "yükleyebilirsin — listen ve sohbet geçmişin olduğu gibi durur.", + ...METIN_ALT_BILGI, ].join("\n"), }; } \ No newline at end of file