chahinebrini ced749018b fix(custom-domains): web-Domains nicht am alreadyGlobal-Check abweisen
Der alreadyGlobal-Pre-Check (Domain schon in der 208k-Layer-1-Blocklist →
kein Custom-Slot) gilt jetzt nur noch fuer Mail-Typen. Fuer type='web'
uebersprungen: Web-Custom-Domains speisen die Layer-2-VIP-Liste, eine separate
Schicht — eine global (Layer 1) gelistete Domain muss in die VIP koennen,
gerade weil Layer 2 das Netz ist, wenn Layer 1 aus ist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:14:18 +02:00

254 lines
9.4 KiB
TypeScript

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 NUR für Mail-Typen: ist die Domain schon in der globalen
// Blocklist? Dann keinen Slot verbrennen — der Mail-Daemon scannt dieselbe
// Blocklist, ein Custom-Slot wäre redundant.
//
// Für `web` BEWUSST NICHT: Web-Custom-Domains speisen die Layer-2-VIP-Liste
// (webContent / Family Controls) — eine SEPARATE Schicht von der globalen
// Layer-1-Blocklist (URL-Filter / VPN). Eine Domain in Layer 1 ist NICHT
// automatisch in der Layer-2-VIP-50; und Layer 2 ist gerade das Netz für den
// Fall, dass Layer 1 deaktiviert wird. Global gelistete Domains müssen also
// in die VIP aufgenommen werden können.
if (type !== "web") {
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 });
}
});