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:
parent
86ed603a45
commit
cc2d963d1f
@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { protection } from '../lib/protection';
|
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)
|
* - bei App-Resume (in case Server-Updates kamen)
|
||||||
*
|
*
|
||||||
* Backend respondet 304 wenn ETag matched → kein Re-Download.
|
* 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() {
|
export function useBlocklistSync() {
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
@ -35,6 +43,24 @@ export function useBlocklistSync() {
|
|||||||
return result;
|
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 res = await protection.syncBlocklist({ baseURL, authToken });
|
||||||
const result = { ok: true, count: res.count, plan: res.plan };
|
const result = { ok: true, count: res.count, plan: res.plan };
|
||||||
setLastResult(result);
|
setLastResult(result);
|
||||||
|
|||||||
@ -19,6 +19,8 @@ import type {
|
|||||||
HealthProbeResult,
|
HealthProbeResult,
|
||||||
SyncBlocklistOpts,
|
SyncBlocklistOpts,
|
||||||
SyncBlocklistResult,
|
SyncBlocklistResult,
|
||||||
|
SyncWebContentDomainsOpts,
|
||||||
|
SyncWebContentDomainsResult,
|
||||||
SystemSettingsTarget,
|
SystemSettingsTarget,
|
||||||
WebContentFilterResult,
|
WebContentFilterResult,
|
||||||
} from "../modules/rebreak-protection";
|
} from "../modules/rebreak-protection";
|
||||||
@ -248,6 +250,26 @@ export const protection = {
|
|||||||
return RebreakProtection.clearWebContentFilter();
|
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`)
|
/** Android: VpnService neu starten falls er laufen sollte (`filter_enabled`)
|
||||||
* aber tot ist (Reinstall / OS-Kill). Bei App-Start/Foreground aufrufen,
|
* aber tot ist (Reinstall / OS-Kill). Bei App-Start/Foreground aufrufen,
|
||||||
* damit der State nicht „an aber tot" bleibt. No-op auf iOS/Web. */
|
* damit der State nicht „an aber tot" bleibt. No-op auf iOS/Web. */
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export type {
|
|||||||
RebreakProtectionEvents,
|
RebreakProtectionEvents,
|
||||||
SyncBlocklistOpts,
|
SyncBlocklistOpts,
|
||||||
SyncBlocklistResult,
|
SyncBlocklistResult,
|
||||||
|
SyncWebContentDomainsOpts,
|
||||||
|
SyncWebContentDomainsResult,
|
||||||
SystemSettingsTarget,
|
SystemSettingsTarget,
|
||||||
WebContentFilterResult,
|
WebContentFilterResult,
|
||||||
} from './src/RebreakProtection.types';
|
} from './src/RebreakProtection.types';
|
||||||
|
|||||||
@ -23,6 +23,12 @@ private let WEBCONTENT_DOMAINS_FILE = "gambling-domains"
|
|||||||
// Wird hier defensiv durchgesetzt — nie mehr als 50 an blockedByFilter geben.
|
// Wird hier defensiv durchgesetzt — nie mehr als 50 an blockedByFilter geben.
|
||||||
private let WEBCONTENT_MAX_DOMAINS = 50
|
private let WEBCONTENT_MAX_DOMAINS = 50
|
||||||
private let WEBCONTENT_FALLBACK_REGION = "DE"
|
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 ─────────────────────────────────────────────────────────
|
// ─── 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 ─────────
|
// ───────── runHealthProbe: hidden WKWebView gegen bet365 ─────────
|
||||||
|
|
||||||
AsyncFunction("runHealthProbe") { (opts: [String: Any]?) async -> [String: Any] in
|
AsyncFunction("runHealthProbe") { (opts: [String: Any]?) async -> [String: Any] in
|
||||||
@ -820,16 +996,49 @@ public class RebreakProtectionModule: Module {
|
|||||||
return (true, webDomains.count, resolvedRegion)
|
return (true, webDomains.count, resolvedRegion)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lädt die kuratierte Gambling-Domain-Liste für ein Land aus dem
|
/// Lädt die kuratierte Gambling-Domain-Liste für ein Land — **cache-first**.
|
||||||
/// gebündelten JSON (gambling-domains.json). Das JSON wird von der Podspec
|
|
||||||
/// als RebreakProtectionResources.bundle ins App-Bundle gepackt.
|
|
||||||
///
|
///
|
||||||
/// Struktur: `{ "DE": ["..."], "GB": [...], ... }` plus `_comment`/`_meta`-
|
/// 1. Zuerst die per `syncWebContentDomains` vom Backend geholte
|
||||||
/// Felder, die hier ignoriert werden. Liefert [] wenn das Bundle/JSON fehlt
|
/// `webcontent-domains.json` aus dem App-Group-Container lesen.
|
||||||
/// oder das Land nicht gelistet ist — der Aufrufer behandelt das als
|
/// 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.
|
/// no_domains_for_region.
|
||||||
private static func loadWebContentDomains(forRegion region: String) -> [String] {
|
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)
|
let moduleBundle = Bundle(for: RebreakProtectionModule.self)
|
||||||
var jsonURL: URL? = moduleBundle.url(
|
var jsonURL: URL? = moduleBundle.url(
|
||||||
forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
|
forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
|
||||||
@ -846,20 +1055,24 @@ public class RebreakProtectionModule: Module {
|
|||||||
jsonURL = Bundle.main.url(
|
jsonURL = Bundle.main.url(
|
||||||
forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
|
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 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 {
|
else {
|
||||||
SharedLogStore.append("❌ [loadWebContentDomains] \(WEBCONTENT_DOMAINS_FILE).json nicht ladbar")
|
return nil
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let domains = root[region.uppercased()] as? [String] else {
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
// Defensiv normalisieren: leere Strings raus, lowercase, dedupliziert,
|
// 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 seen = Set<String>()
|
||||||
var cleaned: [String] = []
|
var cleaned: [String] = []
|
||||||
for raw in domains {
|
for raw in domains {
|
||||||
@ -869,6 +1082,8 @@ public class RebreakProtectionModule: Module {
|
|||||||
cleaned.append(d)
|
cleaned.append(d)
|
||||||
if cleaned.count >= WEBCONTENT_MAX_DOMAINS { break }
|
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
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -74,6 +74,30 @@ export type SyncBlocklistResult = {
|
|||||||
plan?: string;
|
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 HealthProbeOutcome = "blocked" | "loaded" | "offline" | "timeout";
|
||||||
|
|
||||||
export type HealthProbeOpts = {
|
export type HealthProbeOpts = {
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import type {
|
|||||||
RebreakProtectionEvents,
|
RebreakProtectionEvents,
|
||||||
SyncBlocklistOpts,
|
SyncBlocklistOpts,
|
||||||
SyncBlocklistResult,
|
SyncBlocklistResult,
|
||||||
|
SyncWebContentDomainsOpts,
|
||||||
|
SyncWebContentDomainsResult,
|
||||||
SystemSettingsTarget,
|
SystemSettingsTarget,
|
||||||
WebContentFilterResult,
|
WebContentFilterResult,
|
||||||
} from './RebreakProtection.types';
|
} from './RebreakProtection.types';
|
||||||
@ -76,6 +78,23 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
|
|||||||
*/
|
*/
|
||||||
clearWebContentFilter(): Promise<{ cleared: boolean; error?: string }>;
|
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. */
|
/** Aktueller Device-State. Polling- und Health-Check-Pfad. */
|
||||||
getDeviceState(): Promise<DeviceLayers>;
|
getDeviceState(): Promise<DeviceLayers>;
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,10 @@ class RebreakProtectionModuleWeb extends NativeModule<RebreakProtectionEvents> {
|
|||||||
return { cleared: false, error: 'web_stub' };
|
return { cleared: false, error: 'web_stub' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async syncWebContentDomains() {
|
||||||
|
return { updated: false };
|
||||||
|
}
|
||||||
|
|
||||||
async syncBlocklist(): Promise<SyncBlocklistResult> {
|
async syncBlocklist(): Promise<SyncBlocklistResult> {
|
||||||
return { updated: false, count: 0 };
|
return { updated: false, count: 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user