User found that adding bet365.com (which is in the 208k global filter)
silently took a custom-domain slot — they paid a slot for something
the global blocklist already covered. Two pieces:
1. backend/custom-domains/index.post.ts: before any slot-limit check or
DB insert, look the domain up in blocklist_domain (active rows). If
present, return 200 { alreadyGlobal: true, domain }. No row gets
written, no slot consumed. The existing frontend hook + AddSheet
already handle the alreadyGlobal flag — they surface the
"bereits global blockiert" alert and don't refresh as if the entry
landed in the user's list.
2. blocker.tsx default mailOpen state flipped from true to false so the
Eigene Mails section starts collapsed on page load. Domains stays
the primary affordance; mail-patterns are an opt-in expansion.
188 lines
6.6 KiB
TypeScript
188 lines
6.6 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)
|
|
*
|
|
* 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" };
|
|
|
|
function resolveTypeAndValue(body: any): ResolveResult {
|
|
// Variante A: pattern + kind
|
|
if (body?.kind !== undefined || body?.pattern !== undefined) {
|
|
const kind = (body?.kind as string)?.trim() ?? "web";
|
|
const pattern = (body?.pattern as string)?.trim() ?? "";
|
|
|
|
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.",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
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(
|
|
() => {},
|
|
);
|
|
|
|
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 });
|
|
}
|
|
});
|