From 239aae3ea3e13217c947b03e73e5a894da10a869 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Fri, 21 Aug 2026 12:24:11 +0200 Subject: [PATCH] Datenschutz: Fehlerberichte lassen sich abschalten Uebertragung eines Befunds aus dem Security-Audit der alten Melo-App (21.08.2026) in die neue Codebasis. Ausgangslage hier war eine ANDERE als dort: LoggerService lud jeden Fehler unbedingt zu logs.baka-net.de hoch, es gab ueberhaupt keinen Schalter. In der alten App existierte er zwar, wirkte aber nicht ueber einen Neustart hinaus. Neu: - Schalter "Fehlerberichte senden" in den Einstellungen unter Datenschutz. - Sperre an der einzigen Stelle, durch die jeder Upload laeuft. Lokales debugPrint bleibt, aber nichts verlaesst das Geraet; der Puffer wird geleert, damit er nicht unbegrenzt waechst. - Die Entscheidung ueberlebt den Neustart: AppSettings speichert sie und reicht sie an den Logger durch, UND LoggerService liest sie beim Start zusaetzlich selbst - main() startet den Logger vor den Einstellungen, und schon dieses Zeitfenster darf bei Widerspruch nichts senden. - LoggerService bekam einen injizierbaren http.Client (Muster wie BakaAuth), damit sich belegen laesst, dass bei Opt-out KEIN Request rausgeht. Nicht uebernommen: der zweite Befund (Logout liess Zugangsdaten zurueck) trifft hier nicht zu. BakaAuth.abmelden() loescht genau die Schluessel, die anmelden() schreibt, und Navidrome hat mit loescheZugangsdaten() ein eigenes, vollstaendiges Trennen. Der alte Fehler - Logout raeumt tote Schluessel ab, waehrend die echten liegen bleiben - existiert in dieser Codebasis nicht. Standard bleibt bewusst "an", damit sich fuer bestehende Nutzer nichts ungefragt aendert. Opt-in waere datenschutzfreundlicher, ist aber eine Produktentscheidung. Tests: 304 gruen (vorher 295), 9 neue. Alle drei tragenden Stellen per Mutationsprobe geprueft. flutter analyze: keine Befunde. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QbuWT267W8rFDsaavANuEn --- CHANGELOG.md | 29 +++++ lib/services/logger_service.dart | 93 ++++++++++--- lib/settings/app_settings.dart | 22 ++++ lib/settings/settings_screen.dart | 12 ++ test/settings/diagnose_opt_out_test.dart | 158 +++++++++++++++++++++++ 5 files changed, 294 insertions(+), 20 deletions(-) create mode 100644 test/settings/diagnose_opt_out_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4287128..35467dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🛡️ Fehlerberichte lassen sich jetzt abschalten (2026-08-21) +- **Neu: Schalter „Fehlerberichte senden"** in den Einstellungen unter + *Datenschutz*. Bisher lud `LoggerService` jeden Fehler unbedingt zu + `logs.baka-net.de` hoch — es gab **keine** Möglichkeit, das abzustellen. +- Aus heißt wirklich aus: Die Sperre sitzt an der einzigen Stelle, durch die + jeder Upload läuft. Lokales Protokollieren (`debugPrint`) bleibt unberührt, + aber nichts verlässt mehr das Gerät. Der Puffer wird dabei geleert, damit er + im Dauerbetrieb nicht unbegrenzt wächst. +- Die Entscheidung **überlebt den Neustart**: `AppSettings` speichert sie, und + `LoggerService` liest sie beim Start zusätzlich selbst — denn `main()` + startet den Logger, *bevor* die Einstellungen geladen sind, und schon dieses + Zeitfenster darf bei Widerspruch nichts senden. +- Hintergrund: Übertragung eines Befunds aus dem Security-Audit der alten + Melo-App. Dort gab es den Schalter zwar, er hatte aber keine Wirkung über + einen Neustart hinaus — die Anzeige stand auf „aus", gesendet wurde weiter. + Hier ist das strukturell ausgeschlossen und durch Tests festgehalten. +- Tests: 9 neue in `test/settings/diagnose_opt_out_test.dart` (**304 gesamt**, + vorher 295). Alle drei tragenden Stellen per Mutationsprobe geprüft — ohne + Sperre, ohne Durchreichen an den Logger und ohne Lesen beim Start fallen + jeweils genau die zuständigen Tests um. +- Für die Testbarkeit bekam `LoggerService` einen injizierbaren HTTP-Client + (gleiches Muster wie `BakaAuth`), damit sich belegen lässt, dass bei + Widerspruch wirklich **kein** Request rausgeht. + +> Hinweis: Der Standard bleibt **an**, damit sich am bisherigen Verhalten für +> bestehende Nutzer nichts ungefragt ändert. Ein Wechsel auf Opt-in (Standard +> aus) wäre die datenschutzfreundlichere Variante — das ist eine +> Produktentscheidung und bewusst nicht mit erledigt. + ### 📋 Backlog-Datei angelegt (2026-08-21) - Neu: `BACKLOG.md` — bewusst zurückgestellte Punkte an einer Stelle, damit nichts stillschweigend liegen bleibt. Drin stehen aktuell der übersprungene diff --git a/lib/services/logger_service.dart b/lib/services/logger_service.dart index 33f3e77..0dc5021 100644 --- a/lib/services/logger_service.dart +++ b/lib/services/logger_service.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'dart:convert'; import 'dart:io'; +import 'package:shared_preferences/shared_preferences.dart'; class LogEntry { final String level; // DEBUG, INFO, WARNING, ERROR @@ -32,12 +33,36 @@ class LogEntry { /// Centralized Logging Service mit Auto-Upload zu Server class LoggerService extends ChangeNotifier { + /// [client] nur für Tests: erlaubt zu prüfen, dass bei Widerspruch des + /// Nutzers wirklich KEIN Request das Gerät verlässt. + LoggerService({http.Client? client}) : _client = client ?? http.Client(); + + final http.Client _client; + static const String _logServerUrl = 'https://logs.baka-net.de/api/logs'; static const int _batchSize = 5; + /// Schlüssel der Diagnose-Entscheidung in den SharedPreferences. + /// Liegt hier und nicht in AppSettings, damit der Logger ihn ohne + /// Rückwärts-Abhängigkeit selbst lesen kann. + static const String diagnoseKey = 'diagnose_daten'; + final List _logs = []; late final String _deviceModel; bool _initialized = false; + bool _sendeDiagnosedaten = true; + + /// Steuert, ob Logs den Server erreichen. Lokales `debugPrint` läuft + /// unabhängig davon weiter — nur der Versand wird unterbunden. + /// [AppSettings] hält den Wert persistent und schiebt Änderungen hierher. + bool get sendeDiagnosedaten => _sendeDiagnosedaten; + + set sendeDiagnosedaten(bool wert) { + if (_sendeDiagnosedaten == wert) return; + _sendeDiagnosedaten = wert; + // Bei Widerspruch nichts Aufgestautes zurückbehalten. + if (!wert) _logs.clear(); + } Future init() async { if (_initialized) return; @@ -46,6 +71,17 @@ class LoggerService extends ChangeNotifier { _deviceModel = Platform.isAndroid ? 'android' : (Platform.isIOS ? 'ios' : Platform.operatingSystem); + // Die Einstellung wird hier direkt gelesen und nicht von AppSettings + // abgewartet: main() initialisiert den Logger VOR den Einstellungen, und + // schon dieses Zeitfenster darf nichts senden, wenn der Nutzer + // widersprochen hat. + try { + final prefs = await SharedPreferences.getInstance(); + _sendeDiagnosedaten = prefs.getBool(diagnoseKey) ?? true; + } catch (_) { + // Nicht lesbar: beim bisherigen Verhalten bleiben. + } + _initialized = true; debugPrint('✅ LoggerService initialized for $_deviceModel'); } @@ -61,8 +97,10 @@ class LoggerService extends ChangeNotifier { static String scrubText(String? roh) { if (roh == null || roh.isEmpty) return roh ?? ''; var s = roh.replaceAllMapped( - RegExp(r'\b(user|username|benutzer|pw|pass|passwort|password|token|secret|url|serverurl)=([^\s&]+)', - caseSensitive: false), + RegExp( + r'\b(user|username|benutzer|pw|pass|passwort|password|token|secret|url|serverurl)=([^\s&]+)', + caseSensitive: false, + ), (m) => '${m.group(1)}=', ); s = s @@ -87,17 +125,22 @@ class LoggerService extends ChangeNotifier { /// bleibt unberührt — die Maskierung greift nur auf dem Versand-Pfad. @visibleForTesting static Map sanitizedJson(LogEntry l) => { - 'level': l.level, - 'message': scrubText(l.message), - 'stackTrace': - l.stackTrace == null ? null : capFrames(scrubText(l.stackTrace)), - 'timestamp': l.timestamp.toIso8601String(), - 'userAgent': l.userAgent, - 'deviceModel': l.deviceModel, - }; + 'level': l.level, + 'message': scrubText(l.message), + 'stackTrace': l.stackTrace == null + ? null + : capFrames(scrubText(l.stackTrace)), + 'timestamp': l.timestamp.toIso8601String(), + 'userAgent': l.userAgent, + 'deviceModel': l.deviceModel, + }; /// Logge einen Fehler (wird sofort zum Server geschickt) - Future error(String message, [Object? error, StackTrace? stackTrace]) async { + Future error( + String message, [ + Object? error, + StackTrace? stackTrace, + ]) async { final entry = LogEntry( level: 'ERROR', message: message, @@ -146,19 +189,29 @@ class LoggerService extends ChangeNotifier { /// Hochladen der Logs zum Server Future _uploadLogs() async { + // Opt-out des Nutzers: nichts verlässt das Gerät. Der Puffer wird + // trotzdem geleert, sonst wüchse er im Dauerbetrieb unbegrenzt. + if (!_sendeDiagnosedaten) { + _logs.clear(); + return; + } if (_logs.isEmpty) return; try { final logsToUpload = List.from(_logs); - await http.post( - Uri.parse(_logServerUrl), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'logs': logsToUpload.map(sanitizedJson).toList(), - 'timestamp': DateTime.now().toIso8601String(), - }), - ).timeout(const Duration(seconds: 10)); + await _client + .post( + Uri.parse(_logServerUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'logs': logsToUpload.map(sanitizedJson).toList(), + 'timestamp': DateTime.now().toIso8601String(), + }), + ) + .timeout(const Duration(seconds: 10)); _logs.clear(); - debugPrint('✅ Logs uploaded successfully (${logsToUpload.length} entries)'); + debugPrint( + '✅ Logs uploaded successfully (${logsToUpload.length} entries)', + ); } catch (e) { debugPrint('⚠️ Failed to upload logs: $e'); } diff --git a/lib/settings/app_settings.dart b/lib/settings/app_settings.dart index 7137d44..ef83e7e 100644 --- a/lib/settings/app_settings.dart +++ b/lib/settings/app_settings.dart @@ -1,16 +1,21 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../services/logger_service.dart'; + /// Nutzer-Einstellungen, die die ganze App betreffen. Werden beim Start /// einmal geladen und bei jeder Änderung sofort gespeichert. class AppSettings extends ChangeNotifier { static const _keyGroupCovers = 'gleiche_kategorie_gleiches_cover'; static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer'; static const _keyNormalizeVolume = 'gleiche_lautstaerke'; + // Schlüssel gehört dem Logger — der liest ihn beim Start selbst. + static const _keyDiagnose = LoggerService.diagnoseKey; bool _groupCoversByCategory = true; bool _autoPlayOnHeadphones = false; bool _normalizeVolume = true; + bool _sendeDiagnosedaten = true; /// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der /// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild. @@ -23,11 +28,19 @@ class AppSettings extends ChangeNotifier { /// auf das Niveau der leiseren abgesenkt. bool get normalizeVolume => _normalizeVolume; + /// Fehler- und Absturzberichte an logs.baka-net.de senden. + /// Aus: die App protokolliert weiter lokal, aber nichts verlässt das Gerät. + bool get sendeDiagnosedaten => _sendeDiagnosedaten; + Future init() async { final prefs = await SharedPreferences.getInstance(); _groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true; _autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false; _normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true; + _sendeDiagnosedaten = prefs.getBool(_keyDiagnose) ?? true; + // An den Logger durchreichen, damit Anzeige und tatsächliches + // Sendeverhalten nicht auseinanderlaufen können. + logger.sendeDiagnosedaten = _sendeDiagnosedaten; notifyListeners(); } @@ -54,4 +67,13 @@ class AppSettings extends ChangeNotifier { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_keyAutoPlayHeadphones, value); } + + Future setSendeDiagnosedaten(bool value) async { + if (_sendeDiagnosedaten == value) return; + _sendeDiagnosedaten = value; + logger.sendeDiagnosedaten = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyDiagnose, value); + } } diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 86a8601..9da0008 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -244,6 +244,18 @@ class _SettingsScreenState extends State { ), ), const Divider(height: 1), + const _SectionLabel('Datenschutz'), + Consumer( + builder: (context, settings, _) => SwitchListTile( + secondary: const Icon(Icons.shield_outlined), + title: const Text('Fehlerberichte senden'), + subtitle: const Text( + 'Hilft beim Finden von Abstürzen. Aus: nichts verlässt das Gerät'), + value: settings.sendeDiagnosedaten, + onChanged: settings.setSendeDiagnosedaten, + ), + ), + const Divider(height: 1), const _SectionLabel('Über Melo'), const ListTile( leading: Icon(Icons.info_outline), diff --git a/test/settings/diagnose_opt_out_test.dart b/test/settings/diagnose_opt_out_test.dart new file mode 100644 index 0000000..efa41fb --- /dev/null +++ b/test/settings/diagnose_opt_out_test.dart @@ -0,0 +1,158 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; +import 'package:http/http.dart' as http; +import 'package:melo/services/logger_service.dart'; +import 'package:melo/settings/app_settings.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Übertragung eines Befunds aus dem Security-Audit der alten Melo-App +/// (21.08.2026): Dort stand die Diagnose-Einstellung in einer statischen +/// Variablen mit Default `true`, die beim Start nie aus den Einstellungen +/// gefüllt wurde — ein Opt-out überlebte keinen Neustart, während der +/// Schalter weiterhin "aus" anzeigte. +/// +/// Hier wird festgehalten, dass genau das nicht passieren kann. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() => logger.sendeDiagnosedaten = true); + + group('AppSettings.init', () { + test('übernimmt ein gespeichertes Opt-out und reicht es an den Logger', () async { + SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: false}); + logger.sendeDiagnosedaten = true; // Zustand nach frischem App-Start + + final settings = AppSettings(); + await settings.init(); + + expect(settings.sendeDiagnosedaten, isFalse); + expect(logger.sendeDiagnosedaten, isFalse, + reason: 'Anzeige und Sendeverhalten dürfen nicht auseinanderlaufen'); + }); + + test('ohne gespeicherte Entscheidung bleibt es beim Standard (an)', () async { + SharedPreferences.setMockInitialValues({}); + + final settings = AppSettings(); + await settings.init(); + + expect(settings.sendeDiagnosedaten, isTrue); + expect(logger.sendeDiagnosedaten, isTrue); + }); + + test('ein Opt-out überlebt den Neustart', () async { + SharedPreferences.setMockInitialValues({}); + final ersteSitzung = AppSettings(); + await ersteSitzung.init(); + await ersteSitzung.setSendeDiagnosedaten(false); + + // Neustart: neue Instanzen, gleiche Ablage. + logger.sendeDiagnosedaten = true; + final zweiteSitzung = AppSettings(); + await zweiteSitzung.init(); + + expect(zweiteSitzung.sendeDiagnosedaten, isFalse); + expect(logger.sendeDiagnosedaten, isFalse); + }); + }); + + group('AppSettings.setSendeDiagnosedaten', () { + test('schaltet den Logger sofort um und speichert', () async { + SharedPreferences.setMockInitialValues({}); + final settings = AppSettings(); + await settings.init(); + + await settings.setSendeDiagnosedaten(false); + expect(logger.sendeDiagnosedaten, isFalse); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool(LoggerService.diagnoseKey), isFalse); + + await settings.setSendeDiagnosedaten(true); + expect(logger.sendeDiagnosedaten, isTrue); + expect(prefs.getBool(LoggerService.diagnoseKey), isTrue); + }); + + test('benachrichtigt Zuhörer', () async { + SharedPreferences.setMockInitialValues({}); + final settings = AppSettings(); + await settings.init(); + + var rufe = 0; + settings.addListener(() => rufe++); + await settings.setSendeDiagnosedaten(false); + await settings.setSendeDiagnosedaten(false); // unverändert → kein Ruf + + expect(rufe, 1); + }); + }); + + group('LoggerService – Versand-Sperre', () { + /// Der Kern des Opt-outs: bei Widerspruch darf KEIN Request das Gerät + /// verlassen. Ohne diesen Test wäre genau die Sperre ungeprüft. + test('sendet nichts, wenn der Nutzer widersprochen hat', () async { + SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: false}); + var requests = 0; + final dienst = LoggerService( + client: MockClient((_) async { + requests++; + return http.Response('{}', 200); + }), + ); + await dienst.init(); + + await dienst.error('Absturz mit Details'); + await dienst.flush(); + + expect(requests, 0, reason: 'Bei Opt-out darf nichts hochgeladen werden'); + expect(dienst.logCount, 0, reason: 'Puffer darf nicht unbegrenzt wachsen'); + }); + + test('sendet, wenn der Nutzer zugestimmt hat', () async { + SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true}); + var requests = 0; + final dienst = LoggerService( + client: MockClient((_) async { + requests++; + return http.Response('{}', 200); + }), + ); + await dienst.init(); + + await dienst.error('Absturz mit Details'); + + expect(requests, greaterThan(0)); + }); + + test('das Umschalten auf aus stoppt den Versand sofort', () async { + SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true}); + var requests = 0; + final dienst = LoggerService( + client: MockClient((_) async { + requests++; + return http.Response('{}', 200); + }), + ); + await dienst.init(); + dienst.sendeDiagnosedaten = false; + + await dienst.error('Absturz nach dem Abschalten'); + await dienst.flush(); + + expect(requests, 0); + }); + }); + + group('LoggerService.init', () { + test('liest das Opt-out selbst, ohne auf AppSettings zu warten', () async { + // main() initialisiert den Logger VOR den Einstellungen — auch dieses + // Zeitfenster darf nichts senden. + SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: false}); + + final eigener = LoggerService(); + await eigener.init(); + + expect(eigener.sendeDiagnosedaten, isFalse); + }); + }); +}