import { awardPoints } from "../../utils/scoring"; import { addUserCustomDomain, countActiveCustomDomainsSplit, CUSTOM_DOMAIN_TYPES, type CustomDomainType, } from "../../db/domains"; import { getProfile } from "../../db/profile"; import { getPlanLimits } from "../../utils/plan-features"; import { usePrisma } from "../../utils/prisma"; // 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" }, }); } // Pre-check: domain already on the global blocklist? Don't burn a slot for // something the 208k-domain global filter already covers. Return 200 with a // flag so the frontend can surface "already protected, no slot needed" // without the user paying for it. mail_domain is included in the same check // because mail-domains land in the same blocklist set the daemon scans. const db = usePrisma(); const globalMatch = await db.blocklistDomain.findFirst({ where: { domain: value, isActive: true }, select: { domain: true }, }); if (globalMatch) { return { alreadyGlobal: true, domain: value }; } // Per-type Slot-Limit prüfen const profile = await getProfile(user.id); const limits = getPlanLimits(profile?.plan ?? "free"); // Welcher Bucket? const bucket: "web" | "mail" = type === "web" ? "web" : "mail"; const bucketLimit = limits.customDomains[bucket]; if (bucketLimit !== Infinity) { const split = await countActiveCustomDomainsSplit(user.id); const currentCount = split[bucket]; if (currentCount >= bucketLimit) { const errorCode = bucket === "web" ? "WEB_LIMIT_REACHED" : "MAIL_LIMIT_REACHED"; throw createError({ statusCode: 403, data: { error: errorCode, resource: "custom_domains", bucket, current: currentCount, limit: bucketLimit, }, }); } } try { const data = await addUserCustomDomain(user.id, value, "manual", type); 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, ); }); } 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 }); } });