feat(protection): runtime-Sync + cache-first fuer iOS Layer-2-Domain-Liste

syncWebContentDomains (gespiegelt von syncBlocklist): holt die Domain-Liste vom
Backend, cached sie als webcontent-domains.json im App-Group-Container, ETag/304,
Reapply nach Sync wenn FC aktiv. loadWebContentDomains liest cache-first, faellt
auf die gebuendelte gambling-domains.json zurueck (Offline-Seed). Getriggert am
selben Punkt wie syncBlocklist (useBlocklistSync).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-21 20:21:54 +02:00
parent 86ed603a45
commit cc2d963d1f
7 changed files with 328 additions and 16 deletions

View File

@ -1,4 +1,5 @@
import { useCallback, useState } from 'react';
import { Platform } from 'react-native';
import Constants from 'expo-constants';
import { supabase } from '../lib/supabase';
import { protection } from '../lib/protection';
@ -16,6 +17,13 @@ type SyncResult = { ok: boolean; count?: number; plan?: string; error?: string }
* - bei App-Resume (in case Server-Updates kamen)
*
* Backend respondet 304 wenn ETag matched kein Re-Download.
*
* iOS-Layer-2: am selben Trigger wird auch die kuratierte webContent-Gambling-
* Domain-Liste vom Backend gesynct (`syncWebContentDomains`
* `webcontent-domains.json` im App-Group-Cache). Best-effort und entkoppelt:
* solange der Layer-2-Endpoint nicht deployed ist, schlägt dieser Sync fehl
* das beeinflusst das Blocklist-Sync-Ergebnis NICHT (der native
* loadWebContentDomains fällt sauber auf die gebündelte JSON zurück).
*/
export function useBlocklistSync() {
const [syncing, setSyncing] = useState(false);
@ -35,6 +43,24 @@ export function useBlocklistSync() {
return result;
}
// iOS-Layer-2: webContent-Domain-Liste am selben Trigger mitsyncen.
// Bewusst NICHT awaited mit dem Blocklist-Sync gekoppelt — ein
// Fehlschlag (z.B. Endpoint noch nicht deployed) darf das Blocklist-
// Ergebnis nicht kippen. Fallback auf die gebündelte JSON greift nativ.
if (Platform.OS === 'ios') {
protection
.syncWebContentDomains({ baseURL, authToken })
.then((res) =>
console.log('[webcontent-sync] ok:', JSON.stringify(res)),
)
.catch((e: any) =>
console.warn(
'[webcontent-sync] failed (bundled fallback active):',
e?.message ?? e,
),
);
}
const res = await protection.syncBlocklist({ baseURL, authToken });
const result = { ok: true, count: res.count, plan: res.plan };
setLastResult(result);

View File

@ -19,6 +19,8 @@ import type {
HealthProbeResult,
SyncBlocklistOpts,
SyncBlocklistResult,
SyncWebContentDomainsOpts,
SyncWebContentDomainsResult,
SystemSettingsTarget,
WebContentFilterResult,
} from "../modules/rebreak-protection";
@ -248,6 +250,26 @@ export const protection = {
return RebreakProtection.clearWebContentFilter();
},
/**
* Synct die kuratierte Layer-2-Gambling-Domain-Liste vom Backend in den
* App-Group-Cache (`webcontent-domains.json`). Die gebündelte JSON bleibt
* Offline-Seed/Fallback. Nach erfolgreichem Sync wird der webContent-Filter
* wenn Family Controls authorisiert ist nativ sofort reapplied.
*
* No-op auf Android/Web (Layer 2 ist iOS-only) gibt updated:false zurück.
* Best-effort: solange der Backend-Endpoint nicht deployed ist, schlägt der
* Fetch fehl der native loadWebContentDomains fällt dann sauber auf die
* gebündelte JSON zurück. Der Aufrufer behandelt einen Fehler als nicht-fatal.
*/
async syncWebContentDomains(
opts: SyncWebContentDomainsOpts,
): Promise<SyncWebContentDomainsResult> {
if (Platform.OS !== "ios") {
return { updated: false };
}
return RebreakProtection.syncWebContentDomains(opts);
},
/** Android: VpnService neu starten falls er laufen sollte (`filter_enabled`)
* aber tot ist (Reinstall / OS-Kill). Bei App-Start/Foreground aufrufen,
* damit der State nicht an aber tot" bleibt. No-op auf iOS/Web. */

View File

@ -11,6 +11,8 @@ export type {
RebreakProtectionEvents,
SyncBlocklistOpts,
SyncBlocklistResult,
SyncWebContentDomainsOpts,
SyncWebContentDomainsResult,
SystemSettingsTarget,
WebContentFilterResult,
} from './src/RebreakProtection.types';

View File

@ -23,6 +23,12 @@ private let WEBCONTENT_DOMAINS_FILE = "gambling-domains"
// Wird hier defensiv durchgesetzt nie mehr als 50 an blockedByFilter geben.
private let WEBCONTENT_MAX_DOMAINS = 50
private let WEBCONTENT_FALLBACK_REGION = "DE"
// Runtime-Sync-Cache: die vom Backend geholte Domain-Liste landet als
// webcontent-domains.json im App-Group-Container. loadWebContentDomains liest
// cache-first; die gebündelte gambling-domains.json bleibt Offline-Seed/Fallback.
private let WEBCONTENT_CACHE_FILENAME = "webcontent-domains.json"
private let WEBCONTENT_ETAG_KEY = "webcontent_domains_etag"
private let WEBCONTENT_LAST_SYNC_KEY = "webcontent_domains_last_sync_at"
// Shared Log-Store
@ -675,6 +681,176 @@ public class RebreakProtectionModule: Module {
)
}
// syncWebContentDomains: Layer-2-Domain-Liste vom Backend
//
// Gespiegelt von `syncBlocklist` (oben). Holt die kuratierte Gambling-
// Domain-Liste (`GET /api/protection/webcontent-domains`) und schreibt das
// JSON atomar in den App-Group-Container als `webcontent-domains.json`.
// `loadWebContentDomains` liest danach cache-first aus dieser Datei; die
// gebündelte `gambling-domains.json` bleibt nur noch Offline-Seed/Fallback.
//
// URL-/Auth-Handling exakt wie `syncBlocklist`: baseURL + authToken kommen
// aus JS (`opts`), Bearer-Auth, ETag/If-None-Match, Retry mit Backoff.
//
// Nach erfolgreichem Sync wird wenn Family Controls authorisiert ist
// `applyWebContentLayer()` erneut aufgerufen, damit die neue Liste sofort
// greift. Solange der Backend-Endpoint nicht deployed ist, schlägt der
// Fetch fehl `loadWebContentDomains` fällt sauber auf die gebündelte
// JSON zurück (App funktioniert offline weiter).
AsyncFunction("syncWebContentDomains") { (opts: [String: String]) async throws -> [String: Any] in
guard let baseURL = opts["baseURL"], let authToken = opts["authToken"] else {
throw NSError(
domain: "RebreakProtection", code: 400,
userInfo: [NSLocalizedDescriptionKey: "missing baseURL or authToken"]
)
}
guard let endpoint = URL(string: "\(baseURL)/api/protection/webcontent-domains") else {
throw NSError(
domain: "RebreakProtection", code: 400,
userInfo: [NSLocalizedDescriptionKey: "invalid baseURL"]
)
}
// Retry mit Backoff (1s, 2s) fängt transiente Netzwerk-Fehler.
let maxAttempts = 3
var attempt = 1
var lastError: Error?
while attempt <= maxAttempts {
do {
var request = URLRequest(url: endpoint)
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
if let lastEtag = UserDefaults(suiteName: APP_GROUP)?.string(forKey: WEBCONTENT_ETAG_KEY) {
request.setValue(lastEtag, forHTTPHeaderField: "If-None-Match")
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "invalid response"]
)
}
if httpResponse.statusCode == 304 {
SharedLogStore.append("📡 [webContent] sync 304 (cached)")
return ["updated": false]
}
guard httpResponse.statusCode == 200 else {
throw NSError(
domain: "RebreakProtection", code: httpResponse.statusCode,
userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"]
)
}
// Vor dem Schreiben validieren: muss parsebares JSON mit Country-Keys
// sein (gleiche Struktur wie die gebündelte gambling-domains.json).
// Eine kaputte Response NICHT in den Cache schreiben sonst killt
// sie den Fallback.
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NSError(
domain: "RebreakProtection", code: 422,
userInfo: [NSLocalizedDescriptionKey: "response is not valid JSON object"]
)
}
// Mindestens ein Country-Key mit einem String-Array erwartet.
let countryKeyCount = root.keys.filter { !$0.hasPrefix("_") }.count
guard countryKeyCount > 0 else {
throw NSError(
domain: "RebreakProtection", code: 422,
userInfo: [NSLocalizedDescriptionKey: "response has no country keys"]
)
}
guard let appGroupURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)
else {
throw NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "App-Group container unavailable"]
)
}
let finalURL = appGroupURL.appendingPathComponent(WEBCONTENT_CACHE_FILENAME)
let tmpURL = finalURL.appendingPathExtension("tmp")
try data.write(to: tmpURL, options: .atomic)
// DiGA-Hardening (gespiegelt von syncBlocklist).
try? (tmpURL as NSURL).setResourceValue(
URLFileProtection.complete,
forKey: .fileProtectionKey
)
var mut = tmpURL
var rv = URLResourceValues()
rv.isExcludedFromBackup = true
try? mut.setResourceValues(rv)
// Atomic replace
_ = try? FileManager.default.removeItem(at: finalURL)
try FileManager.default.moveItem(at: tmpURL, to: finalURL)
// ETag + lastSync persistieren
let defaults = UserDefaults(suiteName: APP_GROUP)
if let newEtag = httpResponse.value(forHTTPHeaderField: "etag") {
defaults?.set(newEtag, forKey: WEBCONTENT_ETAG_KEY)
}
defaults?.set(ISO8601DateFormatter().string(from: Date()), forKey: WEBCONTENT_LAST_SYNC_KEY)
let version = ((root["_meta"] as? [String: Any])?["version"] as? Int) ?? 0
SharedLogStore.append(
"📡 [webContent] sync ok: \(countryKeyCount) Länder (version=\(version))"
)
// Reapply: wenn Family Controls authorisiert ist, die neue Liste
// sofort wirksam machen. Best-effort ein Fehlschlag hier kippt den
// erfolgreichen Sync NICHT.
var reapplied = false
if #available(iOS 16.0, *),
AuthorizationCenter.shared.authorizationStatus == .approved {
let wc = Self.applyWebContentLayer()
reapplied = wc.enabled
SharedLogStore.append(
"🛡️ [webContent] reapply nach sync: enabled=\(wc.enabled) count=\(wc.count) region=\(wc.region)"
)
}
return [
"updated": true,
"countries": countryKeyCount,
"version": version,
"reapplied": reapplied,
]
} catch {
lastError = error
let nsError = error as NSError
let isTransient = nsError.domain == NSURLErrorDomain && (
nsError.code == NSURLErrorNotConnectedToInternet ||
nsError.code == NSURLErrorCannotConnectToHost ||
nsError.code == NSURLErrorTimedOut ||
nsError.code == NSURLErrorNetworkConnectionLost ||
nsError.code == NSURLErrorCannotFindHost
)
if isTransient && attempt < maxAttempts {
let delaySec = UInt64(attempt)
SharedLogStore.append("⏳ [webContent] sync transient \(nsError.code), retry in \(delaySec)s")
try? await Task.sleep(nanoseconds: delaySec * 1_000_000_000)
attempt += 1
continue
}
SharedLogStore.append("❌ [webContent] sync failed: \(error.localizedDescription)")
throw error
}
}
throw lastError ?? NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "max attempts exhausted"]
)
}
// runHealthProbe: hidden WKWebView gegen bet365
AsyncFunction("runHealthProbe") { (opts: [String: Any]?) async -> [String: Any] in
@ -820,16 +996,49 @@ public class RebreakProtectionModule: Module {
return (true, webDomains.count, resolvedRegion)
}
/// Lädt die kuratierte Gambling-Domain-Liste für ein Land aus dem
/// gebündelten JSON (gambling-domains.json). Das JSON wird von der Podspec
/// als RebreakProtectionResources.bundle ins App-Bundle gepackt.
/// Lädt die kuratierte Gambling-Domain-Liste für ein Land **cache-first**.
///
/// Struktur: `{ "DE": ["..."], "GB": [...], ... }` plus `_comment`/`_meta`-
/// Felder, die hier ignoriert werden. Liefert [] wenn das Bundle/JSON fehlt
/// oder das Land nicht gelistet ist der Aufrufer behandelt das als
/// 1. Zuerst die per `syncWebContentDomains` vom Backend geholte
/// `webcontent-domains.json` aus dem App-Group-Container lesen.
/// 2. Nur wenn die fehlt / nicht parsebar ist / das Land nicht enthält
/// Fallback auf die gebündelte `gambling-domains.json` im
/// RebreakProtectionResources.bundle (Offline-Seed).
///
/// Beide Quellen haben dieselbe Struktur (`{ "DE": ["..."], ... }` plus
/// `_comment`/`_meta`-Felder, die ignoriert werden) und werden über
/// `parseDomainsJSON` identisch geparst. Liefert [] wenn keine der beiden
/// Quellen das Land listet der Aufrufer behandelt das als
/// no_domains_for_region.
private static func loadWebContentDomains(forRegion region: String) -> [String] {
// Resource-Bundle innerhalb des Frameworks/Pods auflösen.
// 1) Cache-first: App-Group-webcontent-domains.json
if let appGroupURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP) {
let cacheURL = appGroupURL.appendingPathComponent(WEBCONTENT_CACHE_FILENAME)
if let domains = parseDomainsJSON(at: cacheURL, forRegion: region) {
SharedLogStore.append(
"🛡️ [loadWebContentDomains] cache-hit (\(domains.count) Domains, \(region.uppercased()))")
return domains
}
}
// 2) Fallback: gebündelte gambling-domains.json (Offline-Seed)
if let url = bundledWebContentDomainsURL(),
let domains = parseDomainsJSON(at: url, forRegion: region) {
SharedLogStore.append(
"🛡️ [loadWebContentDomains] bundle-fallback (\(domains.count) Domains, \(region.uppercased()))")
return domains
}
SharedLogStore.append(
"❌ [loadWebContentDomains] keine Domains für \(region.uppercased()) (cache+bundle leer)")
return []
}
/// Löst die URL der gebündelten `gambling-domains.json` auf. Das JSON wird
/// von der Podspec als RebreakProtectionResources.bundle ins App-Bundle
/// gepackt; je nach Build-Konfig liegt es im Pod-Bundle oder flach im
/// Main-Bundle.
private static func bundledWebContentDomainsURL() -> URL? {
let moduleBundle = Bundle(for: RebreakProtectionModule.self)
var jsonURL: URL? = moduleBundle.url(
forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
@ -846,20 +1055,24 @@ public class RebreakProtectionModule: Module {
jsonURL = Bundle.main.url(
forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
}
return jsonURL
}
guard let url = jsonURL,
/// Parst eine Domain-Liste-JSON-Datei (Cache ODER Bundle identische
/// Struktur) und gibt die normalisierte Domain-Liste für `region` zurück.
/// Liefert `nil` wenn die Datei fehlt, nicht parsebar ist oder das Land
/// nicht enthält der Aufrufer behandelt `nil` als diese Quelle leer,
/// nächste versuchen".
private static func parseDomainsJSON(at url: URL, forRegion region: String) -> [String]? {
guard FileManager.default.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let domains = root[region.uppercased()] as? [String]
else {
SharedLogStore.append("❌ [loadWebContentDomains] \(WEBCONTENT_DOMAINS_FILE).json nicht ladbar")
return []
}
guard let domains = root[region.uppercased()] as? [String] else {
return []
return nil
}
// Defensiv normalisieren: leere Strings raus, lowercase, dedupliziert,
// hart auf das Apple-Limit gekappt.
// hart auf das Apple-Limit (WEBCONTENT_MAX_DOMAINS) gekappt.
var seen = Set<String>()
var cleaned: [String] = []
for raw in domains {
@ -869,6 +1082,8 @@ public class RebreakProtectionModule: Module {
cleaned.append(d)
if cleaned.count >= WEBCONTENT_MAX_DOMAINS { break }
}
// Ein leeres Array ist eine gültige Antwort dieser Quelle (Land gelistet,
// aber 0 Domains) der Aufrufer entscheidet dann no_domains_for_region.
return cleaned
}

View File

@ -74,6 +74,30 @@ export type SyncBlocklistResult = {
plan?: string;
};
/**
* Opts für syncWebContentDomains (iOS Layer 2). Gleiche Shape wie
* SyncBlocklistOpts baseURL + authToken kommen aus der Supabase-Session.
*/
export type SyncWebContentDomainsOpts = {
baseURL: string;
authToken: string;
};
/**
* Ergebnis von syncWebContentDomains (iOS Layer 2).
* `updated` = neue Liste wurde heruntergeladen + gecached (false bei 304).
* `countries` = Anzahl Country-Keys in der Response.
* `version` = `_meta.version` der Backend-Response.
* `reapplied` = applyWebContentLayer() wurde nach dem Sync erneut ausgeführt
* (nur wenn Family Controls authorisiert ist).
*/
export type SyncWebContentDomainsResult = {
updated: boolean;
countries?: number;
version?: number;
reapplied?: boolean;
};
export type HealthProbeOutcome = "blocked" | "loaded" | "offline" | "timeout";
export type HealthProbeOpts = {

View File

@ -9,6 +9,8 @@ import type {
RebreakProtectionEvents,
SyncBlocklistOpts,
SyncBlocklistResult,
SyncWebContentDomainsOpts,
SyncWebContentDomainsResult,
SystemSettingsTarget,
WebContentFilterResult,
} from './RebreakProtection.types';
@ -76,6 +78,23 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
*/
clearWebContentFilter(): Promise<{ cleared: boolean; error?: string }>;
/**
* iOS Layer 2 synct die kuratierte Gambling-Domain-Liste vom Backend
* (`GET /api/protection/webcontent-domains`) und cached sie als
* `webcontent-domains.json` im App-Group-Container. `loadWebContentDomains`
* liest danach cache-first; die gebündelte `gambling-domains.json` bleibt
* nur noch Offline-Seed/Fallback.
*
* Gespiegelt von `syncBlocklist`: baseURL + authToken aus der Supabase-
* Session, Bearer-Auth, ETag/If-None-Match, Retry mit Backoff. Nach
* erfolgreichem Sync wird wenn Family Controls authorisiert ist
* `applyWebContentLayer()` erneut ausgeführt, damit die neue Liste sofort
* greift. Server respondet 304 wenn ETag matched updated=false.
*/
syncWebContentDomains(
opts: SyncWebContentDomainsOpts,
): Promise<SyncWebContentDomainsResult>;
/** Aktueller Device-State. Polling- und Health-Check-Pfad. */
getDeviceState(): Promise<DeviceLayers>;

View File

@ -35,6 +35,10 @@ class RebreakProtectionModuleWeb extends NativeModule<RebreakProtectionEvents> {
return { cleared: false, error: 'web_stub' };
}
async syncWebContentDomains() {
return { updated: false };
}
async syncBlocklist(): Promise<SyncBlocklistResult> {
return { updated: false, count: 0 };
}