chahinebrini c1edef8abd feat(magic): RebreakMagic device-binding + DNS profile
- backend: /api/magic/{register,devices,profile,release} + AdGuard provisioning + 24h cooldown
- prisma: magic_binding_fields migration (additive on UserDevice)
- mac-app: Phase 2 - Login + MacRegistration + Profile install
- marketing: landing section + /download/rebreakmagic + DMG
- lyra: forbidden phrases + RebreakMagic coach guidance
2026-06-02 09:15:19 +02:00

313 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,
};
}