import { awardPoints } from "../../utils/scoring"; import { addUserCustomDomain, countActiveCustomDomains, getWebCustomDomains, CUSTOM_DOMAIN_TYPES, type CustomDomainType, } from "../../db/domains"; import { getProfile } from "../../db/profile"; import { getPlanLimits } from "../../utils/plan-features"; import { usePrisma } from "../../utils/prisma"; import gamblingDomains from "../../data/gambling-domains.json"; // Regex: Domain muss mindestens eine TLD haben (z.B. "casino.de", "x.co.uk") const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; // Kuratierte Layer-2-VIP-Listen pro Land (gambling-domains.json). const CURATED_LISTS = gamblingDomains as unknown as Record; const VIP_COUNTRIES = ["DE", "GB", "FR", "TN"] as const; // Die VIP-Layer-2-Liste fasst max. 50 Domains; 20 davon sind für die // kuratierte Liste reserviert (RESERVED_CURATED in webcontent-domains.get.ts) // → max. 30 eigene Custom-Domains. Wird die überschritten, greift der // VIP-Slot-Replace-Flow (Swap mit 24h-Cooldown). const MAX_VIP_CUSTOM = 30; const SWAP_COOLDOWN_MS = 24 * 60 * 60 * 1000; /** Client-`country` (Geräte-Region) → unterstützter VIP-Ländercode. Fallback DE. */ function resolveVipCountry(raw: unknown): string { const c = typeof raw === "string" ? raw.toUpperCase() : ""; return (VIP_COUNTRIES as readonly string[]).includes(c) ? c : "DE"; } /** * Leitet Frontend-`kind` auf internen `CustomDomainType` ab. * * Variante A (neues Frontend): { pattern: string, kind: 'web' | 'mail' } * - kind='web' → type='web' * - kind='mail' → MUSS Domain-Shape haben (mit Punkt + TLD) → type='mail_domain' * Display-Name-Input (kein Punkt) → throws 400 INVALID_MAIL_DOMAIN * Wenn pattern volle Adresse (local@domain.tld) → local-part wird gestripped * * Variante B (direkt / Legacy): { domain: string, type: 'web' | 'mail_domain' | 'mail_display_name' } * - type='mail_display_name' → throws 400 DISPLAY_NAME_NOT_SUPPORTED (v1.0) * * Variante C (Auto-Detect): { pattern: string } ohne kind * - kind fehlt oder kind='auto' → Auto-Detect anhand pattern-Format * - pattern.includes('@') → wie kind='mail' (local-part strip, validate, mail_domain) * - pattern.includes('.') && kein '@' → wie kind='web' (validate, web) * - weder '@' noch '.' → 400 INVALID_PATTERN * * Display-Name-Blocking kommt in v1.1 mit eigener UX. * * Returns null wenn ein 400-Error geworfen werden soll — caller wirft dann den Error * basierend auf dem zurückgegebenen error-Code. */ type ResolveResult = | { ok: true; type: CustomDomainType; value: string } | { ok: false; error: "INVALID_MAIL_DOMAIN" | "DISPLAY_NAME_NOT_SUPPORTED" | "INVALID_PATTERN" }; function resolveTypeAndValue(body: any): ResolveResult { // Variante A + C: pattern vorhanden (mit oder ohne kind) if (body?.kind !== undefined || body?.pattern !== undefined) { const rawKind = (body?.kind as string)?.trim(); const pattern = (body?.pattern as string)?.trim() ?? ""; // Variante C: kind fehlt oder kind='auto' → Auto-Detect anhand pattern-Format if (rawKind === undefined || rawKind === null || rawKind === "" || rawKind === "auto") { if (pattern.includes("@")) { // Wie kind='mail': local-part strippen, als mail_domain speichern const atIdx = pattern.lastIndexOf("@"); const normalized = pattern.slice(atIdx + 1).toLowerCase().replace(/^https?:\/\//, "").trim(); if (normalized.includes(".") && DOMAIN_RE.test(normalized)) { return { ok: true, type: "mail_domain", value: normalized }; } return { ok: false, error: "INVALID_MAIL_DOMAIN" }; } if (pattern.includes(".")) { // Wie kind='web' return { ok: true, type: "web", value: pattern }; } // Weder '@' noch '.' → kein erkennbares Format return { ok: false, error: "INVALID_PATTERN" }; } const kind = rawKind; if (kind === "web") { return { ok: true, type: "web", value: pattern }; } if (kind === "mail") { // Defensiv: wenn volle Adresse übergeben (local@domain.tld) → local-part strippen let normalized = pattern; if (normalized.includes("@")) { const atIdx = normalized.lastIndexOf("@"); normalized = normalized.slice(atIdx + 1); } normalized = normalized.toLowerCase().replace(/^https?:\/\//, "").trim(); // Domain-Shape prüfen: muss Punkt haben und Domain-Regex bestehen if (normalized.includes(".") && DOMAIN_RE.test(normalized)) { return { ok: true, type: "mail_domain", value: normalized }; } // Sieht nach Display-Name aus (kein Punkt, kein @-Domain) → in v1.0 nicht unterstützt return { ok: false, error: "INVALID_MAIL_DOMAIN" }; } // Unbekanntes kind → 400 via validTypes-Check unten return { ok: true, type: kind as CustomDomainType, value: pattern }; } // Variante B: domain + type const rawType = (body?.type as string)?.trim() ?? "web"; const value = (body?.domain as string)?.trim() ?? ""; // v1.0: mail_display_name wird nicht mehr akzeptiert if (rawType === "mail_display_name") { return { ok: false, error: "DISPLAY_NAME_NOT_SUPPORTED" }; } return { ok: true, type: rawType as CustomDomainType, value }; } export default defineEventHandler(async (event) => { const user = await requireUser(event); const body = await readBody(event); const resolved = resolveTypeAndValue(body); if (!resolved.ok) { if (resolved.error === "INVALID_MAIL_DOMAIN") { throw createError({ statusCode: 400, data: { error: "INVALID_MAIL_DOMAIN", message: "Mail-Patterns brauchen eine Domain (z.B. only4-subscribers.com). Display-Name-Blocking kommt in einer späteren Version.", }, }); } if (resolved.error === "DISPLAY_NAME_NOT_SUPPORTED") { throw createError({ statusCode: 400, data: { error: "DISPLAY_NAME_NOT_SUPPORTED", message: "Mail-Patterns brauchen eine Domain (z.B. only4-subscribers.com). Display-Name-Blocking kommt in einer späteren Version.", }, }); } if (resolved.error === "INVALID_PATTERN") { throw createError({ statusCode: 400, data: { error: "INVALID_PATTERN", message: "Bitte eine Domain (z.B. casino.com) oder Mail-Adresse (z.B. info@casino.com) eingeben.", }, }); } } const { type, value: rawValue } = resolved as { ok: true; type: CustomDomainType; value: string }; if (!CUSTOM_DOMAIN_TYPES.includes(type as CustomDomainType)) { throw createError({ statusCode: 400, data: { error: "INVALID_TYPE", validTypes: CUSTOM_DOMAIN_TYPES }, }); } // domain/pattern validieren + normalisieren (nur web + mail_domain hier) // mail_domain: value wurde in resolveTypeAndValue bereits normalisiert (lowercase, local-part stripped) let value = rawValue; value = value.toLowerCase().replace(/^https?:\/\//, ""); if (!value || !DOMAIN_RE.test(value)) { throw createError({ statusCode: 400, data: { error: "INVALID_DOMAIN" } }); } if (type === "mail_domain" && !value.includes(".")) { throw createError({ statusCode: 400, data: { error: "MAIL_DOMAIN_MISSING_TLD" }, }); } // Ist die Domain schon in der globalen Layer-1-Blocklist? const db = usePrisma(); const globalMatch = await db.blocklistDomain.findFirst({ where: { domain: value, isActive: true }, select: { domain: true }, }); const inGlobal = !!globalMatch; // ─── Mail-Typen: schon global = kein Slot verbrennen ─────────────────── // Der Mail-Daemon scannt dieselbe Blocklist — ein Custom-Slot wäre redundant. if (type !== "web" && inGlobal) { return { alreadyGlobal: true, domain: value }; } // ─── Web: 3-Fall-Check gegen Layer 1 (global) + Layer 2 (kuratierte VIP) ── // // Layer 1 (VPN/URL-Filter) = globale Blocklist. Layer 2 (webContent/VIP) = // kuratierte gambling-domains.json + eigene Custom-Domains; greift als // Zweitschutz, falls Layer 1 aus ist. // 1. weder global noch kuratiert → normaler Custom-Eintrag ('active') // 2. global UND kuratiert → schon komplett geschützt, kein Slot // 3. global, aber NICHT kuratiert → Hinweis an User; bei addToVip=true wird // die Domain als 'approved' gespeichert (kein Slot, erscheint nur in der // VIP-Liste — 'approved' ist semantisch korrekt: sie IST in Layer 1). let webAddAsApproved = false; if (type === "web") { const country = resolveVipCountry(body?.country); const curatedList: string[] = CURATED_LISTS[country] ?? []; const inVipCurated = curatedList.includes(value); const addToVip = body?.addToVip === true; if (inGlobal && !addToVip) { return inVipCurated ? { alreadyProtected: true, domain: value } : { inGlobalNotVip: true, domain: value }; } if (inGlobal && addToVip) { webAddAsApproved = true; } // !inGlobal → normaler Add unten } // Slot-Limit prüfen — EIN gemeinsamer Pool für web + mail (Pro 10 / Legend // 20). Entfällt für webAddAsApproved (approved belegt keinen Slot). if (!webAddAsApproved) { const profile = await getProfile(user.id); const limit = getPlanLimits(profile?.plan ?? "pro").customDomains; if (limit !== Infinity) { const currentCount = await countActiveCustomDomains(user.id); if (currentCount >= limit) { throw createError({ statusCode: 403, data: { error: "LIMIT_REACHED", resource: "custom_domains", current: currentCount, limit, }, }); } } } try { const data = await addUserCustomDomain( user.id, value, "manual", type, webAddAsApproved ? "approved" : "active", ); await awardPoints(user.id, "custom_domain_submitted", { domain: value }).catch( () => {}, ); // Fire-and-forget: Scan sofort triggern damit neue Mail-Domain-Pattern innerhalb // von Sekunden wirkt — ohne auf den Cron-Intervall (30min) zu warten. // Casts on event: useRuntimeConfig braucht den event-Context für Nitro. if (type === "mail_domain" || type === "mail_display_name") { const config = useRuntimeConfig(event); const adminSecret = (config.adminSecret as string) || process.env.ADMIN_SECRET || ""; $fetch("/api/mail/scan-internal", { method: "POST", headers: { "x-admin-secret": adminSecret }, body: { userId: user.id }, }).catch((err: unknown) => { // Fire-and-forget: Fehler loggen, aber POST-Response nicht blockieren. // Der Scan ist best-effort — nächster Cron holt nach. console.warn( `[custom-domains] post-add scan-trigger failed for user ${user.id}:`, err, ); }); } if (webAddAsApproved) { return { ...data, addedToVip: true }; } // VIP-Slot-Replace: bringt die neue Web-Domain die VIP-Liste (Layer 2) // über ihr 30er-Cap, wird sie zunächst zurückgestellt (vipDeferUntil) — // der User wählt dann im Swap-Dialog, welche eigene Domain sie ersetzt. // Layer 1 schützt die neue Domain bereits ab sofort. if (type === "web") { const vipDomains = await getWebCustomDomains(user.id); if (vipDomains.length > MAX_VIP_CUSTOM) { await db.userCustomDomain.update({ where: { id: data.id }, data: { vipDeferUntil: new Date(Date.now() + SWAP_COOLDOWN_MS) }, }); return { ...data, vipFull: true }; } } return data; } catch (err: any) { const msg = err.message?.includes("duplicate") || err.code === "P2002" ? "Eintrag bereits vorhanden" : err.message ?? "Fehler"; throw createError({ statusCode: 400, message: msg }); } });