import { awardPoints } from "../../utils/scoring"; import { addUserCustomDomain, countActiveCustomDomains, 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 { isPublicEmailDomain } from "../../utils/public-email-domains"; // 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])?)+$/; /** * 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" }, }); } // Public-/Freemail-Domains (icloud.com, gmail.com …) hart ablehnen — web UND // mail. Realer Vorfall: User kopiert eine Casino-Spam-Adresse `xyz@icloud.com` // komplett ins Feld → wir extrahierten `icloud.com`. Das zu blocken würde die // gesamte Mail/Webmail des Users sperren. Siehe public-email-domains.ts. if (isPublicEmailDomain(value)) { throw createError({ statusCode: 400, data: { error: "PUBLIC_DOMAIN", message: "Das ist ein öffentlicher E-Mail-Anbieter — den können wir nicht blocken, sonst wäre deine ganze Mail betroffen. Blocke stattdessen die konkrete Casino-Domain aus dem Link der Mail.", }, }); } // 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: bereits in globaler Layer-1-Blocklist → kein Slot verbrennen ── // Layer 2 (webContent) wird ab 2026-05-25 ausschliesslich Country-Curated // gespeist — User-Custom-Domains landen NUR noch in Layer 1. Ein Custom-Slot // für eine bereits global geblocknte Domain ist daher sinnlos. if (type === "web" && inGlobal) { return { alreadyProtected: true, domain: value }; } // Slot-Limit prüfen — EIN gemeinsamer Pool für web + mail (Pro 10 / Legend 20). { 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, "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 }, // forceFullSweep: Alt-Mails der gerade hinzugefügten Domain werden sofort // erfasst — unabhängig davon ob lastFullSweepAt < 24h. Ohne dieses Flag // würde der inkrementelle Pfad greifen (UIDs > lastUid) und bereits vor- // handene Mails dieser Domain übersehen. body: { userId: user.id, forceFullSweep: true }, }).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, ); }); } 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 }); } });