feat(blocker): VIP-Liste als Kachel-Grid + Brand-Token-Konzept
VIP-Liste-Sektion: zwei Kachel-Sektionen statt flacher Chips — "Meine VIP-Domains" (eigene Custom-Domains, Stern + Status-Badge) und "Vordefinierte Top-Seiten" (kuratiert, schlicht). Read-only, kein Freigabe-Button. Kein Pulse-Ring (auf User-Wunsch entfernt). docs/concepts/brand-token-matching.md: abgenommenes Konzept für geteiltes Brand-Token-Matching (Layer 1 DNS + Mail/Mo) gegen den Nummern-Trick der Gambling-Industrie (slotoro.bet → slotoro88.bet). Im Backlog. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ef28f4947a
commit
7cc30db020
@ -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 (
|
||||
<View
|
||||
style={{
|
||||
@ -523,7 +507,7 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps)
|
||||
</TouchableOpacity>
|
||||
|
||||
{open && (
|
||||
<View style={{ paddingHorizontal: 14, paddingBottom: 14, gap: 10 }}>
|
||||
<View style={{ paddingHorizontal: 14, paddingBottom: 14, gap: 12 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
@ -541,26 +525,47 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps)
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{t('blocker.vip_layer2_count', { count: list.length })}
|
||||
</Text>
|
||||
{list.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: 11, columnGap: 9 }}>
|
||||
{list.map((d) => (
|
||||
<VipReadonlyChip
|
||||
{customDomains.length > 0 && (
|
||||
<VipSubSection
|
||||
title={t('blocker.vip_section_custom_title')}
|
||||
count={t('blocker.vip_section_custom_count', { count: customDomains.length })}
|
||||
colors={colors}
|
||||
>
|
||||
{customDomains.map((d) => (
|
||||
<VipCustomTile
|
||||
key={d}
|
||||
domain={d}
|
||||
status={customStatusMap.get(d)}
|
||||
status={customStatusMap.get(d)!}
|
||||
colors={colors}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</VipSubSection>
|
||||
)}
|
||||
|
||||
{curatedDomains.length > 0 && (
|
||||
<VipSubSection
|
||||
title={t('blocker.vip_section_curated_title')}
|
||||
count={t('blocker.vip_section_curated_count', { count: curatedDomains.length })}
|
||||
colors={colors}
|
||||
>
|
||||
{curatedDomains.map((d) => (
|
||||
<VipCuratedTile key={d} domain={d} colors={colors} />
|
||||
))}
|
||||
</VipSubSection>
|
||||
)}
|
||||
|
||||
{list.length === 0 && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: colors.textMuted,
|
||||
textAlign: 'center',
|
||||
paddingVertical: 12,
|
||||
}}
|
||||
>
|
||||
{t('blocker.vip_layer2_count', { count: 0 })}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@ -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 (
|
||||
<View style={{ gap: 8 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Text style={{ flex: 1, fontSize: 12, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||
{count}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: 10, columnGap: 8 }}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={{ position: 'relative' }}>
|
||||
{isPending && (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -3,
|
||||
left: -3,
|
||||
right: -3,
|
||||
bottom: -3,
|
||||
borderRadius: 999,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.brandOrange,
|
||||
opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.15, 0.95] }),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: isCustom ? colors.brandOrange : colors.border,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isCustom ? 'star' : 'globe-outline'}
|
||||
size={11}
|
||||
color={isCustom ? colors.brandOrange : colors.textMuted}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.brandOrange,
|
||||
borderRadius: 14,
|
||||
padding: 8,
|
||||
width: '31%',
|
||||
minHeight: 100,
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Ionicons name="star" size={10} color={colors.brandOrange} />
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 999,
|
||||
backgroundColor: statusColor,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 7, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
|
||||
{badgeLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 4 }}>
|
||||
{!imgError ? (
|
||||
<Image
|
||||
source={{ uri: `https://www.google.com/s2/favicons?domain=${stripped}&sz=128` }}
|
||||
style={{ width: 24, height: 24, borderRadius: 5 }}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 5,
|
||||
backgroundColor: colors.brandOrange,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 8, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
|
||||
{stripped.slice(0, 2).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{stripped}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function VipCuratedTile({ domain, colors }: { domain: string; colors: ColorScheme }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const stripped = domain.replace(/^www\./, '');
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 14,
|
||||
padding: 8,
|
||||
width: '31%',
|
||||
minHeight: 100,
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<Ionicons name="globe-outline" size={10} color={colors.textMuted} />
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 4 }}>
|
||||
{!imgError ? (
|
||||
<Image
|
||||
source={{ uri: `https://www.google.com/s2/favicons?domain=${stripped}&sz=128` }}
|
||||
style={{ width: 24, height: 24, borderRadius: 5 }}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 5,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 8, fontFamily: 'Nunito_700Bold', color: colors.textMuted }}>
|
||||
{stripped.slice(0, 2).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.text, maxWidth: 120 }}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{stripped}
|
||||
</Text>
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
203
docs/concepts/brand-token-matching.md
Normal file
203
docs/concepts/brand-token-matching.md
Normal file
@ -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.
|
||||
Loading…
x
Reference in New Issue
Block a user