diff --git a/apps/rebreak-native/components/blocker/VipDomainList.tsx b/apps/rebreak-native/components/blocker/VipDomainList.tsx index df9a772..ca675aa 100644 --- a/apps/rebreak-native/components/blocker/VipDomainList.tsx +++ b/apps/rebreak-native/components/blocker/VipDomainList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useMemo } from 'react'; +import React, { useEffect, useRef, useState, useMemo } from 'react'; import { View, Text, TouchableOpacity, Animated, ActivityIndicator } from 'react-native'; import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; @@ -442,27 +442,12 @@ type VipListProps = { colors: ColorScheme; }; -/** - * "VIP-Liste" — Zweitschutz-Sektion. Collapsible. - * - * Zeigt die LANDABHÄNGIGE VIP-Layer-2-Liste, wie das Backend sie komponiert: - * die eigenen Web-Domains des Users + die kuratierte globale Gambling-Liste - * für die Geräte-Region (DE / GB / FR), dedupliziert, hart auf 50 gekappt. - * - * Diese Liste greift als Zweitschutz, falls Layer 1 (VPN/URL-Filter) ein - * technisches Problem hat. - */ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps) { const { t } = useTranslation(); const { domains: vipList, loading, refetch } = useWebContentDomains(); - // Eigene Web-Domains (inkl. approved — die sind auch in der VIP). Map - // domain → status, damit Chips ihre Herkunft + Bearbeitungszustand kennen. const webCustoms = useMemo( - () => - domains.filter( - (d) => (d.type === 'web' || !d.type) && d.status !== 'rejected', - ), + () => domains.filter((d) => (d.type === 'web' || !d.type) && d.status !== 'rejected'), [domains], ); const customStatusMap = useMemo(() => { @@ -471,9 +456,6 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps) return m; }, [webCustoms]); - // Realtime: ändert sich die Custom-Domain-Liste (Add / Approve / Reject — - // via useDomainSubmissionRealtime → refreshDomains), die komponierte VIP- - // Liste neu vom Backend holen. Mount-Fetch macht der Hook schon selbst. const domainsSig = useMemo( () => domains.map((d) => `${d.id}:${d.status}`).join('|'), [domains], @@ -487,9 +469,11 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps) refetch(); }, [domainsSig, refetch]); - // Endpoint-Liste bevorzugen; bis sie da ist, die eigenen Domains zeigen. const list = vipList ?? [...customStatusMap.keys()]; + const customDomains = list.filter((d) => customStatusMap.has(d)); + const curatedDomains = list.filter((d) => !customStatusMap.has(d)); + return ( {open && ( - + ) : ( <> - - {t('blocker.vip_layer2_count', { count: list.length })} - - {list.length > 0 && ( - - {list.map((d) => ( - 0 && ( + + {customDomains.map((d) => ( + ))} - + + )} + + {curatedDomains.length > 0 && ( + + {curatedDomains.map((d) => ( + + ))} + + )} + + {list.length === 0 && ( + + {t('blocker.vip_layer2_count', { count: 0 })} + )} )} @@ -570,77 +575,185 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps) ); } -/** - * VIP-Chip. Eigene Custom-Domains kriegen einen Stern; noch nicht final - * abgeschlossene (active / submitted) zusätzlich einen pulsierenden Ring — - * der signalisiert „neu / in Bearbeitung". Nach Approval (oder Reject → - * verschwindet) wird via Realtime-Refetch ohne Ring neu gerendert. - */ -function VipReadonlyChip({ +function VipSubSection({ + title, + count, + colors, + children, +}: { + title: string; + count: string; + colors: ColorScheme; + children: React.ReactNode; +}) { + return ( + + + + {title} + + + {count} + + + + {children} + + + ); +} + +function VipCustomTile({ domain, status, colors, }: { domain: string; - status?: DomainStatus; + status: DomainStatus; colors: ColorScheme; }) { + const { t } = useTranslation(); + const [imgError, setImgError] = useState(false); const stripped = domain.replace(/^www\./, ''); - const isCustom = status !== undefined; - const isPending = status === 'active' || status === 'submitted'; - const pulse = useRef(new Animated.Value(0)).current; - useEffect(() => { - if (!isPending) return; - const loop = Animated.loop( - Animated.sequence([ - Animated.timing(pulse, { toValue: 1, duration: 850, useNativeDriver: true }), - Animated.timing(pulse, { toValue: 0, duration: 850, useNativeDriver: true }), - ]), - ); - loop.start(); - return () => loop.stop(); - }, [isPending, pulse]); + const statusColor: string = (() => { + switch (status) { + case 'submitted': return colors.warning; + case 'approved': return '#22c55e'; + default: return colors.brandOrange; + } + })(); + + const badgeLabel: string = (() => { + switch (status) { + case 'submitted': return t('blocker.domain_badge_pruefung'); + case 'approved': return t('blocker.domain_badge_active'); + default: return t('blocker.domain_badge_active'); + } + })(); return ( - - {isPending && ( - - )} - - + + + + + + {badgeLabel} + + + + + + {!imgError ? ( + setImgError(true)} + /> + ) : ( + + + {stripped.slice(0, 2).toUpperCase()} + + + )} + + {stripped} + + + + ); +} + +function VipCuratedTile({ domain, colors }: { domain: string; colors: ColorScheme }) { + const [imgError, setImgError] = useState(false); + const stripped = domain.replace(/^www\./, ''); + + return ( + + + + + + + {!imgError ? ( + setImgError(true)} + /> + ) : ( + + + {stripped.slice(0, 2).toUpperCase()} + + + )} {stripped} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index c195354..5adaa64 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -391,6 +391,10 @@ "vip_layer2_desc": "Zweitschutz: Diese Liste greift, falls der URL-Filter (Layer 1) ein technisches Problem hat. Sie enthält deine eigenen Domains plus einen kuratierten globalen Anteil.", "vip_layer2_global_hint": "+ %{count} bekannte Glücksspielseiten automatisch geschützt", "vip_layer2_count": "%{count} Seiten in deiner VIP-Liste", + "vip_section_custom_title": "Meine VIP-Domains", + "vip_section_curated_title": "Vordefinierte Top-Seiten", + "vip_section_custom_count": "%{count} eigene", + "vip_section_curated_count": "%{count} kuratiert", "remove_domain_sheet_heading": "Domain entfernen", "remove_domain_title": "Kurz nachdenken.", "remove_domain_intro": "Du bist dabei, diese Seite aus deiner persönlichen Sperrliste zu entfernen. Das passiert sofort — sie wäre dann wieder erreichbar.", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 1a01916..a2c4ca0 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -391,6 +391,10 @@ "vip_layer2_desc": "Second-layer protection: this list activates if the URL filter (Layer 1) has a technical issue. It includes your custom domains plus a curated global portion.", "vip_layer2_global_hint": "+ %{count} known gambling sites automatically protected", "vip_layer2_count": "%{count} sites in your VIP list", + "vip_section_custom_title": "My VIP domains", + "vip_section_curated_title": "Top predefined sites", + "vip_section_custom_count": "%{count} custom", + "vip_section_curated_count": "%{count} curated", "remove_domain_sheet_heading": "Remove domain", "remove_domain_title": "Take a moment.", "remove_domain_intro": "You're about to remove this site from your personal blocklist. This takes effect immediately — the site would be reachable again.", diff --git a/docs/concepts/brand-token-matching.md b/docs/concepts/brand-token-matching.md new file mode 100644 index 0000000..cae1825 --- /dev/null +++ b/docs/concepts/brand-token-matching.md @@ -0,0 +1,203 @@ +# Konzept: Geteiltes Brand-Token-Matching + +Status: **Konzept abgenommen (2026-05-22) — im Backlog, Umsetzung später (§10)** +Datum: 2026-05-22 +Scope: Layer 1 (DNS-Sinkhole / PacketTunnel) + Mail-Scan (Mo). NICHT Layer 2. + +--- + +## 1. Problem + +Glücksspiel-Marken umgehen exakte Blocklisten mit dem **Nummern-/Suffix-Trick**: +dieselbe wiedererkennbare Marke, variierende Zahl/Suffix/TLD. + +``` +slotoro.bet → slotoro88.bet · slotoro2.com · slotoro-casino.net · slotorobet.io +winrolla.com → winrolla57.com · winrolla-bet.net +``` + +- **Layer 1** (DNS-Hash-Set, 329k Hashes): matcht nur **exakt**. Hashes erlauben + kein Substring-/Prefix-Matching — `slotoro88.bet` hasht zu etwas völlig anderem + als `slotoro.bet`. Architektur-bedingt. +- **Layer 2** (Apple `ManagedSettings`-`WebDomain`): matcht nur exakte Domain + + Subdomains. Keine Substring-API. **Nicht erweiterbar** — out of scope. +- **Mail-Scan (Mo)**: hat dasselbe Problem (`winrolla → winrolla57`). + +Die HaGeZi-Feed-Liste fängt viele Varianten mit **Latenz** — der Feed muss die +neue Variante erst aufnehmen. Brand-Token-Matching schließt genau diese +Latenzlücke und greift sofort bei jeder Variante einer **bekannten** Marke. + +--- + +## 2. Ziel & Scope + +**Ziel:** Ein einziges, geteiltes Brand-Token-System — dieselbe kuratierte +Token-Liste + derselbe Matching-Algorithmus für Layer 1 (DNS) und Mail-Scan. + +**Im Scope:** Varianten **bekannter** Marken abfangen. +**Nicht im Scope:** +- Brandneue Marken ohne Token (kein Token → kein Match). +- Layer 2 (Apple-API kann es nicht). +- Ersatz für Feed/Blocklist — das System **ergänzt** sie, ersetzt sie nicht. + +--- + +## 3. Kernidee: der Brand-Token + +Ein **Brand-Token** = ein distinktiver, kleingeschriebener Marken-Kern, der eine +Glücksspielmarke identifiziert und in legitimen Domains praktisch nicht vorkommt. + +``` +Gute Tokens: slotoro · winrolla · spinrollz · wazamba +Schlechte Tokens: bet · win · play · casino · slot · vegas + (Wörterbuch-/Generikwörter → False-Positive-Magneten) +``` + +Regel: **kuratiert, nie automatisch.** Mindestlänge **≥ 5 Zeichen**. Kürzere +Marken bleiben bei der exakten Blocklist. + +--- + +## 4. Der Matching-Algorithmus (das geteilte Stück) + +Der entscheidende Teil. **Kein freies Substring** (`host.contains(token)` ist +gefährlich). Stattdessen **Entnummerierung + Segment-Exaktvergleich**: + +### Ablauf (pro Host) + +``` +1. Host in Labels splitten an '.' slotoro88.bet → ["slotoro88", "bet"] +2. Pro Label: in Segmente splitten an '-' play-slotoro → ["play", "slotoro"] +3. Pro Segment: + a. Trailing-Ziffernlauf abschneiden → `core` slotoro88 → slotoro + b. core EXAKT in tokenSet? → MATCH + c. sonst: core beginnt mit einem Token UND + der Rest ∈ genericSuffixSet? → MATCH + d. sonst → kein Match +4. Kein Segment matcht → kein Block. +``` + +`genericSuffixSet` = generische Glücksspiel-Anhängsel, die nur als **Rest nach +einem Token** zählen (nie standalone): `bet bets casino casinos slot slots spin +spins win wins play vegas game games poker sport sports wetten lotto gambling`. + +### Warum das robust ist + +Es ist **Exaktvergleich auf dem entnummerierten Segment**, kein Substring. +Ein kurzes Token wie `win` würde `winter` NICHT treffen: `winter` → core +`winter` → `winter` ≠ `win` → kein Match. False Positives entstehen nur, wenn +ein legitimes Segment **exakt** ein Token ist — bei distinktiven Tokens ~null. + +### Beispiele + +| Host | Segment-core | Ergebnis | +|---|---|---| +| `slotoro.bet` | `slotoro` | MATCH (exakt) | +| `slotoro88.bet` | `slotoro88`→`slotoro` | MATCH (entnummeriert) | +| `slotoro2.com` | `slotoro2`→`slotoro` | MATCH | +| `play-slotoro.net` | Segment `slotoro` | MATCH | +| `slotoro-casino.io` | Segment `slotoro` | MATCH | +| `slotorobet.com` | `slotorobet` = `slotoro`+`bet` | MATCH (Token+Suffix) | +| `stat.slotoro88.bet` | Label `slotoro88`→`slotoro` | MATCH | +| `winter.com` | `winter` | kein Match (≠ Token) | +| `slotorox.com` | `slotorox` = `slotoro`+`x`, `x`∉Suffixe | kein Match | + +### Verbindlichkeit + +Der Algorithmus muss **bit-identisch** in Swift (PacketTunnel) und TS +(Mo-Scan + Backend-Validierung) implementiert sein — analog zu +`domainHash.ts` ↔ `DomainHasher.swift`. Ein gemeinsamer Spec-Testvektor-Satz +(Input-Host → erwartetes Ergebnis) wird in beiden Test-Suites geprüft. + +--- + +## 5. Token-Liste: Kuration & Quelle + +- **DB-Tabelle** `brand_tokens` (token, source, addedAt, isActive, note). +- **Kuratiert von ReBreak-Admins** — nie auto-generiert (FP-Risiko). +- **Bootstrap:** die existierende 329k-Blocklist nach gemeinsamen SLD-Präfix- + Clustern minen → Token-Vorschläge → menschliche Freigabe. +- **Laufende Quelle:** wenn ein User über die Custom-Domain-/Submission-Flows + eine offensichtliche Variante einreicht, kann der Admin statt nur der Domain + das **Brand-Token** promoten. +- **Allowlist-Notausgang** (`brand_token_allowlist`): falls ein Token doch + kollidiert, einzelne legitime Domains explizit ausnehmen. v1 optional, im + Design vorgesehen. + +--- + +## 6. Verteilung aufs Gerät (Layer 1) + +- Neuer Endpoint `GET /api/url-filter/brand-tokens` — JSON/Zeilenliste, ETag- + gecacht. Spiegelt exakt den `blocklist.bin`-Sync-Mechanismus: + App lädt → schreibt in App-Group → Darwin-Notification → Extension reloadet. +- Datei klein (~wenige KB bei einigen hundert Tokens). +- **File-Protection `.completeUntilFirstUserAuthentication`** — wie nach dem + Layer-1-Bugfix (2026-05-22), damit die Extension sie auch bei gesperrtem + Gerät lesen kann. +- **Privacy-Abwägung (ehrlich):** die Hash-Liste ist gehasht, damit keine + Klartext-Casino-Domains auf dem Gerät eines Spielers liegen. Brand-Tokens + sind Klartext-Markenfragmente — Substring-Matching gegen Hashes ist + unmöglich, Klartext ist also nötig. Es sind aber nur einige hundert + Markenfragmente, kein browsing-history-verräterischer Datensatz. Datei + file-protected. **Entscheidung (2026-05-22): Klartext akzeptiert.** + `hans-mueller`-DSGVO-Gegencheck bei der Umsetzung (kein Blocker fürs Konzept). + +--- + +## 7. Integration + +### Layer 1 — `DnsFilter.classify` +Nach dem `hashList.matchesAnySuffix(domain)`-**Miss**, vor `.forward`: + +``` +if hashList-Miss: + if brandTokens.matches(domain): // der Algo aus §4 + return .block +return .forward +``` + +Neue Klasse `BrandTokenList` (Pendant zu `HashList`) — lädt die Token-Datei, +reloadet via Darwin-Notification. Performance: pro verfehlter Query ein paar +Set-Lookups → Mikrosekunden, NE-Memory-unkritisch. + +### Mail (Mo) +Derselbe `brandTokens.matches(...)` auf die Absender-Domain (und optional den +Display-Namen) anwenden. Owner der Integration bleibt Mo; das geteilte Stück +ist die Token-Liste + der §4-Algorithmus. + +--- + +## 8. Was es NICHT tut (ehrliche Grenzen) + +- Keine **neuen** Marken (ohne Token kein Match). +- Glued-Varianten ohne Ziffern/Trenner/Generik-Suffix (`slotoroxyz.com`) → + Miss; bleibt Sache des Feeds. +- Kein Layer-2-Support. +- False Positives werden stark reduziert, nicht zu 100% eliminiert → Allowlist. + +--- + +## 9. Entscheidungen + +**Entschieden (2026-05-22):** +1. **Privacy:** Klartext-Brand-Tokens auf dem Gerät akzeptiert, file-protected. + `hans-mueller`-DSGVO-Gegencheck bei der Umsetzung (kein Blocker fürs Konzept). +2. **Generic-Suffix-Regel (§4c):** drin — v1 enthält Token+Suffix-Matching + (`slotorobet` = `slotoro`+`bet`). + +**Noch offen (bei Umsetzung klären):** +3. **Token-Kuration:** rein Admin, oder community-/submission-gespeist? +4. **Sync-Kanal:** eigener Endpoint, oder in den `blocklist.bin`-Sync bündeln? +5. **Bootstrap-Aufwand:** Mining der 329k-Liste nach Präfix-Clustern — wie viel + manuelle Freigabe ist realistisch? + +--- + +## 10. Rollout-Phasen + +- **Phase 1:** DB-Tabelle + §4-Algo-Spec + Testvektoren + Bootstrap-Mining + + Admin-Kuration-UI. +- **Phase 2:** Endpoint + Sync + `BrandTokenList` + `DnsFilter`-Einbau (Layer 1). +- **Phase 3:** Mo-Mail-Integration. +- Hinter Feature-Flag, mit FP-Report-Pfad zum Monitoring.