diff --git a/apps/rebreak-native/hooks/useBlocklistSync.ts b/apps/rebreak-native/hooks/useBlocklistSync.ts index fea8d16..9643b22 100644 --- a/apps/rebreak-native/hooks/useBlocklistSync.ts +++ b/apps/rebreak-native/hooks/useBlocklistSync.ts @@ -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); diff --git a/apps/rebreak-native/lib/protection.ts b/apps/rebreak-native/lib/protection.ts index a3fab5d..c339ad1 100644 --- a/apps/rebreak-native/lib/protection.ts +++ b/apps/rebreak-native/lib/protection.ts @@ -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 { + 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. */ diff --git a/apps/rebreak-native/modules/rebreak-protection/index.ts b/apps/rebreak-native/modules/rebreak-protection/index.ts index e766c44..016bf8f 100644 --- a/apps/rebreak-native/modules/rebreak-protection/index.ts +++ b/apps/rebreak-native/modules/rebreak-protection/index.ts @@ -11,6 +11,8 @@ export type { RebreakProtectionEvents, SyncBlocklistOpts, SyncBlocklistResult, + SyncWebContentDomainsOpts, + SyncWebContentDomainsResult, SystemSettingsTarget, WebContentFilterResult, } from './src/RebreakProtection.types'; diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift index b97bb3b..19cfe98 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift @@ -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() 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 } diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts index c9a3261..6e1d2fc 100644 --- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts @@ -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 = { diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts index 69a6452..e107f99 100644 --- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts @@ -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; + /** + * 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; + /** Aktueller Device-State. Polling- und Health-Check-Pfad. */ getDeviceState(): Promise; diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts index 895ba50..601551d 100644 --- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts @@ -35,6 +35,10 @@ class RebreakProtectionModuleWeb extends NativeModule { return { cleared: false, error: 'web_stub' }; } + async syncWebContentDomains() { + return { updated: false }; + } + async syncBlocklist(): Promise { return { updated: false, count: 0 }; }