Datenschutz: Fehlerberichte nur mit Zustimmung senden (Opt-in)
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,
Standard AUS. Ohne ausdrueckliche Zustimmung verlaesst kein Bericht das
Geraet - auch nicht beim allerersten Start und auch nicht im Zeitfenster
zwischen logger.init() und settings.init().
- 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.
- LoggerService bekam einen injizierbaren http.Client (Muster wie BakaAuth),
damit sich belegen laesst, dass ohne Zustimmung KEIN Request rausgeht.
Auswirkung auf bestehende Installationen: Wer bisher unfreiwillig Berichte
gesendet hat, sendet ab diesem Update keine mehr, bis er zustimmt. Das ist
gewollt - die Zustimmung wurde nie eingeholt.
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.
Auf main (34d6d8e) rebast. settings_screen.dart hat sich sauber automatisch
zusammengefuehrt - die Umbenennungen und MeloTheme-Farben der UI-Politur
bleiben erhalten, der Datenschutz-Abschnitt fuegt sich ein. Im CHANGELOG
kollidierten beide Seiten am Kopf; beide Eintraege wurden behalten.
Tests: 340 gruen (main allein: 328), 12 neue in
test/settings/diagnose_opt_out_test.dart. Fuenf tragende Stellen per
Mutationsprobe geprueft. flutter analyze: keine Befunde.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbuWT267W8rFDsaavANuEn
This commit is contained in:
co-authored by
Claude Opus 5
parent
34d6d8e52a
commit
bb800adb61
@@ -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,39 @@ 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<LogEntry> _logs = [];
|
||||
late final String _deviceModel;
|
||||
bool _initialized = false;
|
||||
// Opt-in: standardmäßig AUS. Ohne ausdrückliche Zustimmung des Nutzers
|
||||
// verlässt kein Log das Gerät.
|
||||
bool _sendeDiagnosedaten = false;
|
||||
|
||||
/// Steuert, ob Logs den Server erreichen. Opt-in: ohne ausdrückliche
|
||||
/// Zustimmung bleibt der Versand aus. 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<void> init() async {
|
||||
if (_initialized) return;
|
||||
@@ -46,6 +74,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) ?? false;
|
||||
} catch (_) {
|
||||
// Nicht lesbar: beim bisherigen Verhalten bleiben.
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
debugPrint('✅ LoggerService initialized for $_deviceModel');
|
||||
}
|
||||
@@ -61,8 +100,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)}=<redacted>',
|
||||
);
|
||||
s = s
|
||||
@@ -87,17 +128,22 @@ class LoggerService extends ChangeNotifier {
|
||||
/// bleibt unberührt — die Maskierung greift nur auf dem Versand-Pfad.
|
||||
@visibleForTesting
|
||||
static Map<String, dynamic> 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<void> error(String message, [Object? error, StackTrace? stackTrace]) async {
|
||||
Future<void> error(
|
||||
String message, [
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
]) async {
|
||||
final entry = LogEntry(
|
||||
level: 'ERROR',
|
||||
message: message,
|
||||
@@ -146,19 +192,29 @@ class LoggerService extends ChangeNotifier {
|
||||
|
||||
/// Hochladen der Logs zum Server
|
||||
Future<void> _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<LogEntry>.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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user