TN-User fielen bisher mangels TN-Liste auf die DE-Liste zurück. Jetzt eigene (kurze) TN-Starter-Liste: mbet216.com, 2xbet365.com, cesar365.com, icombet.com, unibet365.net (von einem TN-Test-User gemeldet). TN in COUNTRY_KEYS (webcontent-Endpoint) + VIP_COUNTRIES (Geräte-Region- Auflösung + Add-Check). Native Region-Logik ist generisch (Locale.region → JSON-Key) — kein Native-Code nötig. gambling-domains.json _meta v3. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
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<string, string[]>;
|
|
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 });
|
|
}
|
|
});
|