Files
Melo/lib/services/logger_service.dart
T
Hermes (Server)andClaude Opus 5 239aae3ea3 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbuWT267W8rFDsaavANuEn
2026-08-21 12:24:11 +02:00

230 lines
7.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
final String message;
final String? stackTrace;
final DateTime timestamp;
final String? userAgent;
final String? deviceModel;
LogEntry({
required this.level,
required this.message,
this.stackTrace,
DateTime? timestamp,
this.userAgent,
this.deviceModel,
}) : timestamp = timestamp ?? DateTime.now();
Map<String, dynamic> toJson() => {
'level': level,
'message': message,
'stackTrace': stackTrace,
'timestamp': timestamp.toIso8601String(),
'userAgent': userAgent,
'deviceModel': deviceModel,
};
}
/// 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;
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<void> init() async {
if (_initialized) return;
// Datenschutz: nur grobe Plattform senden statt Hersteller+Modell
// (das war ein Geräte-Fingerprint). Reicht für Bug-Triage.
_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');
}
// ─── Datenschutz: Bereinigung vor dem Server-Upload ───
static const int _maxFrames = 8;
/// Maskiert sensible Fragmente in Log-Freitext (message/stackTrace), bevor
/// er an den Server geht: sensible `key=value`-Fragmente, Datei-Pfade,
/// nackte URLs, Bearer-Token, JWTs und lange Hex-IDs.
@visibleForTesting
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,
),
(m) => '${m.group(1)}=<redacted>',
);
s = s
.replaceAll(RegExp(r'/(storage|data|home|Users|var)/\S+'), '<path>')
.replaceAll(RegExp(r'https?://\S+'), '<url>')
.replaceAll(RegExp(r'Bearer\s+[A-Za-z0-9._-]+'), 'Bearer <token>')
.replaceAll(RegExp(r'eyJ[A-Za-z0-9._-]{10,}'), '<jwt>')
.replaceAll(RegExp(r'\b[A-Fa-f0-9]{16,}\b'), '<hex>');
return s;
}
/// Kürzt einen (bereits gescrubbten) Stack-Trace auf wenige Zeilen.
@visibleForTesting
static String capFrames(String s) {
final zeilen = s.split('\n');
if (zeilen.length <= _maxFrames) return s;
final rest = zeilen.length - _maxFrames;
return '${zeilen.take(_maxFrames).join('\n')}\n… ($rest weitere Zeilen entfernt)';
}
/// Bereinigter Upload-Payload eines Eintrags. Der lokale Puffer/debugPrint
/// 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,
};
/// Logge einen Fehler (wird sofort zum Server geschickt)
Future<void> error(
String message, [
Object? error,
StackTrace? stackTrace,
]) async {
final entry = LogEntry(
level: 'ERROR',
message: message,
stackTrace: '$error\n$stackTrace',
deviceModel: _deviceModel,
);
_logs.add(entry);
debugPrint('❌ ERROR: $message');
await _uploadIfError(entry);
}
/// Logge eine Warnung
void warning(String message) {
final entry = LogEntry(
level: 'WARNING',
message: message,
deviceModel: _deviceModel,
);
_logs.add(entry);
debugPrint('⚠️ WARNING: $message');
_checkAndFlush();
}
/// Logge Info-Event
void info(String message) {
final entry = LogEntry(
level: 'INFO',
message: message,
deviceModel: _deviceModel,
);
_logs.add(entry);
debugPrint('️ INFO: $message');
_checkAndFlush();
}
void _checkAndFlush() {
if (_logs.length >= _batchSize) {
_uploadLogs();
}
}
Future<void> _uploadIfError(LogEntry entry) async {
debugPrint('📤 Uploading ERROR immediately: ${entry.message}');
await _uploadLogs();
}
/// 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 _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)',
);
} catch (e) {
debugPrint('⚠️ Failed to upload logs: $e');
}
}
/// Manuelles Flushen (z.B. vor App-Exit)
Future<void> flush() async {
await _uploadLogs();
}
int get logCount => _logs.length;
}
/// Global Logger Instance
final logger = LoggerService();