serverAssets approach didn't bundle the template into the Nitro output (no .output-staging/server/chunks/raw/ dir, no asset-storage mount in nitro.mjs). Logs confirm: '[Magic] Profile template missing in serverAssets'. Drop serverAssets entirely. Inline the template (~2KB) as a TS constant in backend/server/utils/magic-profile-template.ts. Build- robust, no FS/storage dependency at runtime. Canonical source of truth remains ops/mdm/rebreak-mac-dns-filter.mobileconfig — keep in sync manually until/unless we add a codegen step.
392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { apiFetch } from "../lib/api";
|
|
import { resolveVipCountry } from "./useWebContentDomains";
|
|
import { useBlockerStatsStore } from "../stores/blockerStats";
|
|
|
|
export type DomainStatus = "active" | "submitted" | "approved" | "rejected";
|
|
|
|
export type EntryKind = "web" | "mail_domain" | "mail_display_name";
|
|
|
|
export type CustomDomain = {
|
|
id: string;
|
|
domain: string;
|
|
type?: EntryKind;
|
|
status: DomainStatus;
|
|
addedAt?: string;
|
|
postId?: string | null;
|
|
submission?: {
|
|
id: string;
|
|
yesVotes: number;
|
|
noVotes: number;
|
|
status: string;
|
|
} | null;
|
|
vipDeferUntil?: string | null;
|
|
vipEvictAt?: string | null;
|
|
};
|
|
|
|
export type Plan = "free" | "pro" | "legend";
|
|
|
|
/**
|
|
* Ergebnis von addDomain. Neben `ok` transportiert es die 3-Fall-Logik des
|
|
* Backends für Web-Domains gegen Layer 1 (global) + Layer 2 (kuratierte VIP):
|
|
* alreadyGlobal — Mail-Pattern schon global → kein Slot verbrannt
|
|
* alreadyProtected — Web-Domain in global UND kuratierter VIP → nichts zu tun
|
|
* inGlobalNotVip — Web-Domain in global, NICHT in kuratierter VIP →
|
|
* User kann sie per addToVip-Re-Request zur VIP nehmen
|
|
* addedToVip — Domain wurde als VIP-Zweitschutz ('approved') gespeichert
|
|
*/
|
|
export type AddDomainResult = {
|
|
ok: boolean;
|
|
error?: string;
|
|
alreadyGlobal?: boolean;
|
|
alreadyProtected?: boolean;
|
|
inGlobalNotVip?: boolean;
|
|
addedToVip?: boolean;
|
|
vipFull?: boolean;
|
|
newDomainId?: string;
|
|
};
|
|
|
|
export type Tier = {
|
|
plan: Plan;
|
|
domainLimit: number; // pro=10, legend=20 (web + mail gemeinsam)
|
|
refillEnabled: boolean; // pro/legend=true
|
|
globalBlocklist: boolean; // pro/legend=true
|
|
canSubmit: boolean; // pro/legend=true
|
|
usedSlots: number; // active+submitted (NICHT approved/rejected)
|
|
atLimit: boolean;
|
|
};
|
|
|
|
function deriveTier(plan: Plan, domains: CustomDomain[]): Tier {
|
|
// Slots: EIN gemeinsamer Pool für web + mail. Free-Tier ist entfallen.
|
|
const limit = plan === "legend" ? 20 : 10;
|
|
const refill = plan !== "free";
|
|
const usedSlots = domains.filter(
|
|
(d) => d.status === "active" || d.status === "submitted",
|
|
).length;
|
|
return {
|
|
plan,
|
|
domainLimit: limit,
|
|
refillEnabled: refill,
|
|
globalBlocklist: refill,
|
|
canSubmit: refill,
|
|
usedSlots,
|
|
atLimit: usedSlots >= limit,
|
|
};
|
|
}
|
|
|
|
export type UseCustomDomainsReturn = {
|
|
domains: CustomDomain[];
|
|
tier: Tier;
|
|
/** Belegte Slots (active + submitted) — web + mail gemeinsamer Pool. */
|
|
count: number;
|
|
/** Slot-Limit gesamt (Pro 10 / Legend 20). */
|
|
limit: number;
|
|
loading: boolean;
|
|
error: string | null;
|
|
refresh: () => Promise<void>;
|
|
addDomain: (
|
|
pattern: string,
|
|
kind?: "web" | "mail",
|
|
opts?: { addToVip?: boolean },
|
|
) => Promise<AddDomainResult>;
|
|
submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
|
|
removeDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
|
|
submitVipSwap: (
|
|
newDomainId: string,
|
|
evictedDomainId: string,
|
|
) => Promise<{ ok: boolean; error?: string }>;
|
|
/** Live-Validate (regex) ob string gültiger Domain-Name ist. */
|
|
isValidDomain: (s: string) => boolean;
|
|
/** Normalize: lowercase, http(s)://, /path stripping, www. weg. */
|
|
normalizeDomain: (s: string) => string;
|
|
};
|
|
|
|
const DOMAIN_REGEX = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i;
|
|
|
|
export function normalizeDomain(input: string): string {
|
|
let s = input.trim().toLowerCase();
|
|
if (s.startsWith("https://")) s = s.slice(8);
|
|
else if (s.startsWith("http://")) s = s.slice(7);
|
|
const slash = s.indexOf("/");
|
|
if (slash >= 0) s = s.slice(0, slash);
|
|
if (s.startsWith("www.")) s = s.slice(4);
|
|
return s;
|
|
}
|
|
|
|
export function isValidDomain(input: string): boolean {
|
|
const n = normalizeDomain(input);
|
|
if (!n || n.length > 253) return false;
|
|
return DOMAIN_REGEX.test(n);
|
|
}
|
|
|
|
/**
|
|
* Public-/Freemail-Provider — dürfen NIE als Custom-Domain (web ODER mail)
|
|
* geblockt werden: icloud.com/gmail.com zu blocken würde die ganze Mail/Webmail
|
|
* des Users sperren. Realer Vorfall: User kopiert eine Casino-Spam-Adresse
|
|
* `xyz@icloud.com` komplett ins Feld → wir extrahieren `icloud.com`.
|
|
* Spiegel-Liste im Backend: `backend/server/utils/public-email-domains.ts`
|
|
* — bei Änderungen beide synchron halten.
|
|
*/
|
|
const PUBLIC_EMAIL_DOMAINS = new Set<string>([
|
|
"gmail.com",
|
|
"googlemail.com",
|
|
"icloud.com",
|
|
"me.com",
|
|
"mac.com",
|
|
"outlook.com",
|
|
"outlook.de",
|
|
"hotmail.com",
|
|
"hotmail.de",
|
|
"hotmail.co.uk",
|
|
"hotmail.fr",
|
|
"live.com",
|
|
"live.de",
|
|
"msn.com",
|
|
"yahoo.com",
|
|
"yahoo.de",
|
|
"yahoo.co.uk",
|
|
"yahoo.fr",
|
|
"ymail.com",
|
|
"rocketmail.com",
|
|
"gmx.de",
|
|
"gmx.net",
|
|
"gmx.at",
|
|
"gmx.ch",
|
|
"gmx.com",
|
|
"web.de",
|
|
"aol.com",
|
|
"aim.com",
|
|
"proton.me",
|
|
"protonmail.com",
|
|
"pm.me",
|
|
"tutanota.com",
|
|
"tutanota.de",
|
|
"tuta.io",
|
|
"posteo.de",
|
|
"posteo.net",
|
|
"mailbox.org",
|
|
"hey.com",
|
|
"t-online.de",
|
|
"freenet.de",
|
|
"arcor.de",
|
|
"mail.com",
|
|
"mail.de",
|
|
"email.de",
|
|
"zoho.com",
|
|
"fastmail.com",
|
|
"fastmail.fm",
|
|
"hushmail.com",
|
|
"yandex.com",
|
|
"yandex.ru",
|
|
"mail.ru",
|
|
"laposte.net",
|
|
"orange.fr",
|
|
"free.fr",
|
|
"sfr.fr",
|
|
"wanadoo.fr",
|
|
"qq.com",
|
|
"163.com",
|
|
"126.com",
|
|
"naver.com",
|
|
"daum.net",
|
|
]);
|
|
|
|
export function isPublicEmailDomain(domain: string): boolean {
|
|
return PUBLIC_EMAIL_DOMAINS.has(domain.trim().toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* Custom-Domain CRUD gegen `/api/custom-domains/*` mit Tier-aware Limits.
|
|
*
|
|
* Slot-Modell (Single-Source-of-Truth: User.plan):
|
|
* Pro → 10 Slots, Refill bei approved/rejected, Submit erlaubt
|
|
* Legend → 20 Slots, Refill, Submit
|
|
* web + mail teilen sich EINEN gemeinsamen Slot-Pool. Free-Tier ist entfallen.
|
|
*/
|
|
export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
|
const [domains, setDomains] = useState<CustomDomain[]>([]);
|
|
const [apiCount, setApiCount] = useState<number | null>(null);
|
|
const [apiLimit, setApiLimit] = useState<number | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchDomains = useCallback(async () => {
|
|
try {
|
|
// Backend `GET /api/custom-domains` returns
|
|
// { items: CustomDomain[], count: number, limit: number }
|
|
// (count/limit = gemeinsamer web+mail-Slot-Pool). Array-Fallback deckt
|
|
// eine ältere Response-Form ab, falls ein gecachter Client das hier
|
|
// trifft bevor das Deploy landet.
|
|
const res = await apiFetch<
|
|
| CustomDomain[]
|
|
| {
|
|
items?: CustomDomain[];
|
|
domains?: CustomDomain[];
|
|
count?: number;
|
|
limit?: number;
|
|
}
|
|
>("/api/custom-domains");
|
|
let arr: CustomDomain[] = [];
|
|
let count: number | null = null;
|
|
let limit: number | null = null;
|
|
if (Array.isArray(res)) {
|
|
arr = res;
|
|
} else if (res) {
|
|
arr = (res as any).items ?? (res as any).domains ?? [];
|
|
count =
|
|
typeof (res as any).count === "number" ? (res as any).count : null;
|
|
limit =
|
|
typeof (res as any).limit === "number" ? (res as any).limit : null;
|
|
}
|
|
setDomains(arr);
|
|
setApiCount(count);
|
|
setApiLimit(limit);
|
|
setError(null);
|
|
} catch (e: any) {
|
|
console.error("[useCustomDomains] fetch failed:", e?.message ?? e);
|
|
setError(e?.message ?? "unknown");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchDomains();
|
|
}, [fetchDomains]);
|
|
|
|
const addDomain = useCallback(
|
|
async (
|
|
input: string,
|
|
kind?: "web" | "mail",
|
|
opts?: { addToVip?: boolean },
|
|
): Promise<AddDomainResult> => {
|
|
const resolvedKind: "web" | "mail" =
|
|
kind ?? (input.includes("@") ? "mail" : "web");
|
|
if (resolvedKind === "web" && !isValidDomain(input))
|
|
return { ok: false, error: "invalid_domain" };
|
|
if (resolvedKind === "mail" && !input.trim())
|
|
return { ok: false, error: "invalid_pattern" };
|
|
// Slot-Limit-Vorabcheck gegen den Backend-count/limit (Single Source of
|
|
// Truth — EIN gemeinsamer Pool). Wenn die API noch keine count/limit
|
|
// geliefert hat → skip, das Backend rejected dann mit LIMIT_REACHED.
|
|
// Entfällt bei addToVip: 'approved'-Einträge belegen keinen Slot.
|
|
if (
|
|
!opts?.addToVip &&
|
|
apiCount != null &&
|
|
apiLimit != null &&
|
|
apiCount >= apiLimit
|
|
) {
|
|
return { ok: false, error: "limit_reached" };
|
|
}
|
|
const pattern =
|
|
resolvedKind === "web" ? normalizeDomain(input) : input.trim();
|
|
// Public-/Freemail-Domain (icloud.com, gmail.com …) hart ablehnen — web UND
|
|
// mail. Sonst würde das Blocken die gesamte Mail/Webmail des Users sperren.
|
|
const domainToCheck =
|
|
resolvedKind === "mail" && pattern.includes("@")
|
|
? pattern.slice(pattern.lastIndexOf("@") + 1)
|
|
: pattern;
|
|
if (isPublicEmailDomain(domainToCheck))
|
|
return { ok: false, error: "public_domain" };
|
|
const body: Record<string, string | boolean> = { pattern };
|
|
if (kind !== undefined) body.kind = kind;
|
|
// Land mitschicken — Backend prüft die kuratierte VIP-Liste des Landes.
|
|
if (resolvedKind === "web") body.country = resolveVipCountry();
|
|
if (opts?.addToVip) body.addToVip = true;
|
|
try {
|
|
const res = await apiFetch<any>("/api/custom-domains", {
|
|
method: "POST",
|
|
body,
|
|
});
|
|
if (res?.alreadyGlobal) return { ok: false, alreadyGlobal: true };
|
|
if (res?.alreadyProtected) return { ok: false, alreadyProtected: true };
|
|
if (res?.inGlobalNotVip) return { ok: false, inGlobalNotVip: true };
|
|
await fetchDomains();
|
|
if (res?.vipFull)
|
|
return { ok: true, vipFull: true, newDomainId: res.id };
|
|
return { ok: true, addedToVip: res?.addedToVip === true };
|
|
} catch (e: any) {
|
|
return { ok: false, error: e?.message ?? "add_failed" };
|
|
}
|
|
},
|
|
[apiCount, apiLimit, fetchDomains],
|
|
);
|
|
|
|
const submitDomain = useCallback(
|
|
async (id: string) => {
|
|
const tier = deriveTier(plan, domains);
|
|
if (!tier.canSubmit)
|
|
return { ok: false, error: "plan_does_not_support_submit" };
|
|
try {
|
|
await apiFetch(`/api/custom-domains/${id}/submit`, {
|
|
method: "POST",
|
|
body: {},
|
|
});
|
|
// Optimistisches lokales Update: Half-Donut im ProtectionDetailsSheet
|
|
// soll sofort die neue Freigabe zeigen, ohne 60s auf Stats-Refresh zu warten.
|
|
useBlockerStatsStore.getState().bumpMyInReview(1);
|
|
await fetchDomains();
|
|
return { ok: true };
|
|
} catch (e: any) {
|
|
return { ok: false, error: e?.message ?? "submit_failed" };
|
|
}
|
|
},
|
|
[plan, domains, fetchDomains],
|
|
);
|
|
|
|
const removeDomain = useCallback(
|
|
async (id: string) => {
|
|
try {
|
|
await apiFetch(`/api/custom-domains/${id}`, { method: "DELETE" });
|
|
await fetchDomains();
|
|
return { ok: true };
|
|
} catch (e: any) {
|
|
return { ok: false, error: e?.message ?? "remove_failed" };
|
|
}
|
|
},
|
|
[fetchDomains],
|
|
);
|
|
|
|
const submitVipSwap = useCallback(
|
|
async (newDomainId: string, evictedDomainId: string) => {
|
|
try {
|
|
await apiFetch("/api/custom-domains/vip-swap", {
|
|
method: "POST",
|
|
body: { newDomainId, evictedDomainId },
|
|
});
|
|
await fetchDomains();
|
|
return { ok: true };
|
|
} catch (e: any) {
|
|
return { ok: false, error: e?.message ?? "vip_swap_failed" };
|
|
}
|
|
},
|
|
[fetchDomains],
|
|
);
|
|
|
|
const tier = deriveTier(plan, domains);
|
|
|
|
// API-Werte bevorzugen (Single Source of Truth); lokale Ableitung als
|
|
// Fallback, damit die UI auch bei einem stale-bundle-Moment funktioniert.
|
|
const count: number =
|
|
apiCount ??
|
|
domains.filter((d) => d.status === "active" || d.status === "submitted")
|
|
.length;
|
|
const limit: number = apiLimit ?? (plan === "legend" ? 20 : 10);
|
|
|
|
return {
|
|
domains,
|
|
tier,
|
|
count,
|
|
limit,
|
|
loading,
|
|
error,
|
|
refresh: fetchDomains,
|
|
addDomain,
|
|
submitDomain,
|
|
removeDomain,
|
|
submitVipSwap,
|
|
isValidDomain,
|
|
normalizeDomain,
|
|
};
|
|
}
|