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; addDomain: ( pattern: string, kind?: "web" | "mail", opts?: { addToVip?: boolean }, ) => Promise; 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([ "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([]); const [apiCount, setApiCount] = useState(null); const [apiLimit, setApiLimit] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 => { 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 = { 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("/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, }; }