fix: MEDIUM+LOW Claude-Audit-Findings (Issues #7-16)

MEDIUM:
- #7 API-Key XOR-obfuskiert in app_config.dart (strings zeigen keinen Klartext)
- #8 Navidrome-Download: Status-Code-Prüfung + hatValideMagicBytes() + istKorrupt
- #9 _sucheYtUrls() sendet jetzt X-API-Key Header an YT-Proxy
- #10 Log-Puffer auf 500 Einträge gecappt (älteste verwerfen)
- #11 MeloLogger.cloudToken entfernt (war nie gesetzt, toter Code)
- #12 TextEditingController-Leaks in 4 Files: ctrl.dispose() nach showDialog
- #13 Cloud-Sync: Last-Write-Wins Konfliktauflösung dokumentiert

LOW:
- #14 API-Versionierung: alle /api/cloud/ → /api/v1/cloud/
- #15 Auto-Scan: nur /Music, /Download, nicht ganz /storage
- #16 2 pre-existing flutter analyze Infos behoben (curly_braces, use_build_context_synchronously)

flutter analyze: No issues found.
This commit is contained in:
Dustin
2026-08-03 01:50:07 +02:00
parent e46ffa76a3
commit 0b8d8ce2f8
11 changed files with 111 additions and 47 deletions
+36 -27
View File
@@ -7,6 +7,15 @@ import '../services/melo_logger.dart';
/// Cloud-Sync Service für Melo Registry (v3 — vollständiges Sync-System)
/// Authentifizierung via Baka-Auth JWT-Token
///
/// ## Konfliktauflösung: Last-Write-Wins (LWW)
/// Bei Sync-Konflikten (Client und Server haben beide geändert) gewinnt der
/// Eintrag mit dem neueren `updated_at`-Timestamp. Strategie:
/// 1. `syncChanges(since)` lädt serverseitige Änderungen seit letztem Sync.
/// 2. Client vergleicht `updated_at` mit lokalem Stand — Server gewinnt bei Gleichstand.
/// 3. Lokale Änderungen werden danach per `syncAll()` hochgeladen.
/// Merge-Strategie wird NICHT unterstützt (kein 3-Way-Merge) — es gewinnt immer
/// der jüngste Timestamp.
class CloudService {
static final http.Client _client = http.Client();
@@ -16,7 +25,7 @@ class CloudService {
Future<bool> login(String user) async {
try {
final r = await _client
.get(Uri.parse('$_base/api/cloud/status'),
.get(Uri.parse('$_base/api/v1/cloud/status'),
headers: _authHeader)
.timeout(const Duration(seconds: 5));
return r.statusCode == 200;
@@ -41,21 +50,21 @@ class CloudService {
'Content-Type': 'application/json',
};
Future<Map?> status() => _get('/api/cloud/status');
Future<Map?> status() => _get('/api/v1/cloud/status');
Future<Map?> syncStatus() => _get('/api/cloud/sync-status');
Future<Map?> syncStatus() => _get('/api/v1/cloud/sync-status');
Future<Map?> syncAll() => _post('/api/cloud/sync-all', {});
Future<Map?> syncAll() => _post('/api/v1/cloud/sync-all', {});
Future<List<Map>> listSongs() async {
final r = await _get('/api/cloud/list');
final r = await _get('/api/v1/cloud/list');
return List<Map>.from(r?['songs'] ?? []);
}
Future<String?> upload(String filepath, String filename) async {
try {
final req =
http.MultipartRequest('POST', Uri.parse('$_base/api/cloud/upload'));
http.MultipartRequest('POST', Uri.parse('$_base/api/v1/cloud/upload'));
req.headers.addAll(_authHeader);
req.files.add(
await http.MultipartFile.fromPath('file', filepath,
@@ -72,7 +81,7 @@ class CloudService {
Future<bool> download(String songId, String destPath) async {
try {
final r = await _client
.get(Uri.parse('$_base/api/cloud/download/$songId'),
.get(Uri.parse('$_base/api/v1/cloud/download/$songId'),
headers: _authHeader)
.timeout(const Duration(seconds: 120));
if (r.statusCode == 200) {
@@ -89,7 +98,7 @@ class CloudService {
Future<bool> delete(String songId) async {
try {
final r = await _client
.post(Uri.parse('$_base/api/cloud/delete'),
.post(Uri.parse('$_base/api/v1/cloud/delete'),
headers: _jsonHeader,
body: jsonEncode({'song_id': songId}))
.timeout(const Duration(seconds: 10));
@@ -103,7 +112,7 @@ class CloudService {
Future<List<Map>> syncChanges(String since) async {
try {
final r = await _client
.post(Uri.parse('$_base/api/cloud/sync'),
.post(Uri.parse('$_base/api/v1/cloud/sync'),
headers: _jsonHeader,
body: jsonEncode({'since': since}))
.timeout(const Duration(seconds: 10));
@@ -119,31 +128,31 @@ class CloudService {
/// Alle Playlisten des Users auf dem Server
Future<List<Map>> getPlaylists() async {
final r = await _get('/api/cloud/playlists');
final r = await _get('/api/v1/cloud/playlists');
return List<Map>.from(r?['playlists'] ?? []);
}
/// Playlist erstellen
Future<Map?> createPlaylist(String name) async {
final r = await _post('/api/cloud/playlists', {'name': name});
final r = await _post('/api/v1/cloud/playlists', {'name': name});
return r?['playlist'];
}
/// Playlist löschen
Future<bool> deletePlaylist(int id) async {
final r = await _post('/api/cloud/playlists', {'id': id});
final r = await _post('/api/v1/cloud/playlists', {'id': id});
return r?['status'] == 'ok';
}
/// Playlist-Details mit Songs abrufen
Future<Map?> getPlaylist(int id) async {
return await _get('/api/cloud/playlists/$id');
return await _get('/api/v1/cloud/playlists/$id');
}
/// Songs zu Playlist hinzufügen
Future<int> addSongsToPlaylist(int playlistId, List<String> songIds) async {
final r = await _post(
'/api/cloud/playlists/$playlistId/songs', {'song_ids': songIds});
'/api/v1/cloud/playlists/$playlistId/songs', {'song_ids': songIds});
return r?['added'] ?? 0;
}
@@ -153,7 +162,7 @@ class CloudService {
final r = await _client
.delete(
Uri.parse(
'$_base/api/cloud/playlists/$playlistId/songs/$songId'),
'$_base/api/v1/cloud/playlists/$playlistId/songs/$songId'),
headers: _authHeader)
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
@@ -169,7 +178,7 @@ class CloudService {
final r = await _client
.put(
Uri.parse(
'$_base/api/cloud/playlists/$playlistId/positions'),
'$_base/api/v1/cloud/playlists/$playlistId/positions'),
headers: _jsonHeader,
body: jsonEncode({'positions': positions}))
.timeout(const Duration(seconds: 10));
@@ -183,20 +192,20 @@ class CloudService {
/// Favoriten vom Server abrufen
Future<List<Map>> getFavorites() async {
final r = await _get('/api/cloud/favorites');
final r = await _get('/api/v1/cloud/favorites');
return List<Map>.from(r?['favorites'] ?? []);
}
/// Favoriten synchronisieren (komplette Liste senden)
Future<bool> syncFavorites(List<String> songIds) async {
final r =
await _post('/api/cloud/favorites', {'song_ids': songIds});
await _post('/api/v1/cloud/favorites', {'song_ids': songIds});
return r?['status'] == 'ok';
}
/// Einzelnen Favoriten toggeln
Future<Map?> toggleFavorite(String songId) async {
return await _post('/api/cloud/favorites/toggle', {'song_id': songId});
return await _post('/api/v1/cloud/favorites/toggle', {'song_id': songId});
}
// ─── ✏️ Umbenennen ───
@@ -207,20 +216,20 @@ class CloudService {
final body = <String, dynamic>{'song_id': songId};
if (title != null) body['title'] = title;
if (artist != null) body['artist'] = artist;
return await _post('/api/cloud/rename', body);
return await _post('/api/v1/cloud/rename', body);
}
// ─── 🕐 Verlauf ───
/// Wiedergabe-Verlauf vom Server abrufen
Future<List<Map>> getHistory({int limit = 50}) async {
final r = await _get('/api/cloud/history?limit=$limit');
final r = await _get('/api/v1/cloud/history?limit=$limit');
return List<Map>.from(r?['history'] ?? []);
}
/// Wiedergabe-Verlauf-Einträge hochladen
Future<int> addHistory(List<Map<String, dynamic>> entries) async {
final r = await _post('/api/cloud/history', {'entries': entries});
final r = await _post('/api/v1/cloud/history', {'entries': entries});
return r?['added'] ?? 0;
}
@@ -229,7 +238,7 @@ class CloudService {
Future<List<Map>> globalList() async {
try {
final r = await _client
.get(Uri.parse('$_base/api/cloud/global'), headers: _authHeader)
.get(Uri.parse('$_base/api/v1/cloud/global'), headers: _authHeader)
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
return List<Map>.from(jsonDecode(r.body)['songs'] ?? []);
@@ -241,7 +250,7 @@ class CloudService {
Future<bool> toggleGlobal(String songId) async {
try {
final r = await _client
.post(Uri.parse('$_base/api/cloud/toggle-global'),
.post(Uri.parse('$_base/api/v1/cloud/toggle-global'),
headers: _jsonHeader,
body: jsonEncode({'song_id': songId}))
.timeout(const Duration(seconds: 10));
@@ -254,7 +263,7 @@ class CloudService {
Future<String?> share(List<String> songIds) async {
try {
final r = await _client
.post(Uri.parse('$_base/api/cloud/share'),
.post(Uri.parse('$_base/api/v1/cloud/share'),
headers: _jsonHeader,
body: jsonEncode({'song_ids': songIds}))
.timeout(const Duration(seconds: 10));
@@ -269,7 +278,7 @@ class CloudService {
Future<Map?> importCode(String code) async {
try {
final r = await _client
.post(Uri.parse('$_base/api/cloud/import'),
.post(Uri.parse('$_base/api/v1/cloud/import'),
headers: _jsonHeader,
body: jsonEncode({'code': code}))
.timeout(const Duration(seconds: 10));
@@ -281,7 +290,7 @@ class CloudService {
Future<List<Map>> getCorrupted() async {
try {
final r = await _client
.get(Uri.parse('$_base/api/cloud/corrupted'), headers: _authHeader)
.get(Uri.parse('$_base/api/v1/cloud/corrupted'), headers: _authHeader)
.timeout(const Duration(seconds: 15));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
+6 -6
View File
@@ -12,8 +12,7 @@ class MeloLogger {
factory MeloLogger() => _instanz;
MeloLogger._();
/// Wird nach Cloud-Login gesetzt, damit Crash-Logs authentifiziert ankommen.
static String? cloudToken;
static const _maxEintraege = 500;
bool _initialisiert = false;
String _sessionId = '';
@@ -53,6 +52,10 @@ class MeloLogger {
}
void _addEintrag(String kategorie, String aktion, [Map<String, dynamic>? details]) {
// Cap 500: älteste Einträge verwerfen
if (_eintraege.length >= _maxEintraege) {
_eintraege.removeAt(0);
}
_eintraege.add({
'id': _logId++,
'zeit': DateTime.now().toIso8601String(),
@@ -105,10 +108,7 @@ class MeloLogger {
try {
await http.post(
Uri.parse(_serverUrl),
headers: {
'Content-Type': 'application/json',
if (cloudToken != null) 'Authorization': 'Bearer $cloudToken',
},
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'typ': 'log_batch',
'session': _sessionId,
+14 -3
View File
@@ -9,7 +9,8 @@ import '../models/song.dart';
import '../database/db_helper.dart';
import '../utils/audio_validator.dart';
import 'id3_reader.dart';
import 'melo_logger.dart';
import '../services/melo_logger.dart';
import '../config/app_config.dart';
class MusikScanner {
static final MusikScanner _instanz = MusikScanner._();
@@ -152,9 +153,16 @@ class MusikScanner {
'/sdcard/Download',
];
// Externen Speicherpfad NUR für App-eigene Daten, nicht ganz /storage
try {
final extern = await getExternalStorageDirectory();
if (extern != null) ordner.add(extern.path);
if (extern != null) {
// Nur scannen wenn es ein spezifischer Unterordner ist (nicht Root)
final externPath = extern.path;
if (externPath.contains('Android/data') || externPath.contains('Melo')) {
ordner.add(externPath);
}
}
} catch (_) {}
for (final ord in ordner) {
@@ -232,7 +240,10 @@ class MusikScanner {
final query = '${song.kuenstler} ${song.titel}';
final url = '$suchUrlBasis?q=${Uri.encodeQueryComponent(query)}';
final antwort = await http.get(Uri.parse(url)).timeout(
final antwort = await http.get(
Uri.parse(url),
headers: {'X-API-Key': AppConfig.ytProxyApiKey},
).timeout(
const Duration(seconds: 10),
);
+18 -3
View File
@@ -9,6 +9,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../models/song.dart';
import '../database/db_helper.dart';
import '../utils/sanitize.dart';
import '../utils/audio_validator.dart';
/// Ein Song aus der Subsonic-API
class SubsonicSong {
@@ -192,16 +193,29 @@ class NavidromeService {
if (localSong != null) return localSong;
}
// Stream + speichern
// Stream + speichern mit Status-Code- und Integritäts-Prüfung
final uri = streamUrl(s.id);
final response = await http.Client().send(http.Request('GET', uri));
final streamedResponse = await http.Client().send(http.Request('GET', uri));
// Status-Code prüfen
if (streamedResponse.statusCode != 200) {
debugPrint('Navidrome Download Fehler: HTTP ${streamedResponse.statusCode}');
return null;
}
final sink = file.openWrite();
await for (final chunk in response.stream) {
await for (final chunk in streamedResponse.stream) {
sink.add(chunk);
}
await sink.flush();
await sink.close();
// Magic-Byte-Prüfung nach Download
bool istKorrupt = !hatValideMagicBytes(file);
if (istKorrupt) {
debugPrint('Navidrome Download: Korrupte Datei $dateiPfad');
}
final song = Song(
titel: s.titel,
kuenstler: s.kuenstler,
@@ -210,6 +224,7 @@ class NavidromeService {
dateiPfad: dateiPfad,
groesseBytes: await file.length(),
istHeruntergeladen: true,
istKorrupt: istKorrupt,
downloadQuelle: 'server',
);