- Navidrome-Salt wieder kryptographisch sicher (Random.secure, 16 Bytes) - Regression: v2.31-Redesign (36c744d) hatte den Security-Audit-Fix aus77a2042stillschweigend auf Timestamp zurueckgesetzt - Server-IDs (Navidrome s.id, Registry sid) vor Dateinamen defensiv sanitized (sanitizeDateiname) — kein Path-Traversal ueber Server-Werte - MANAGE_EXTERNAL_STORAGE entfernt: deklariert aber nie angefragt (toter Berechtigungs-Surface, Play-Store-Policy-Risiko) - Neuer Test: navidrome_salt_test.dart (Salt != Timestamp, 16 Bytes, eindeutig) Verifiziert: flutter analyze 0 Issues, flutter test 156/156
272 lines
8.5 KiB
Dart
272 lines
8.5 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:math';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:crypto/crypto.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
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 {
|
||
final String id;
|
||
final String titel;
|
||
final String kuenstler;
|
||
final String album;
|
||
final int dauerSekunden;
|
||
final int groesseBytes;
|
||
final String? coverId;
|
||
|
||
const SubsonicSong({
|
||
required this.id,
|
||
required this.titel,
|
||
this.kuenstler = '',
|
||
this.album = '',
|
||
this.dauerSekunden = 0,
|
||
this.groesseBytes = 0,
|
||
this.coverId,
|
||
});
|
||
|
||
factory SubsonicSong.fromJson(Map<String, dynamic> j) => SubsonicSong(
|
||
id: j['id'] as String,
|
||
titel: j['title'] as String? ?? 'Unbekannt',
|
||
kuenstler: j['artist'] as String? ?? '',
|
||
album: j['album'] as String? ?? '',
|
||
dauerSekunden: (j['duration'] as int?) ?? 0,
|
||
groesseBytes: (j['size'] as int?) ?? 0,
|
||
coverId: j['coverArt'] as String?,
|
||
);
|
||
}
|
||
|
||
/// Ein Album aus der Subsonic-API
|
||
class SubsonicAlbum {
|
||
final String id;
|
||
final String name;
|
||
final String? coverId;
|
||
final int songCount;
|
||
|
||
const SubsonicAlbum({
|
||
required this.id,
|
||
required this.name,
|
||
this.coverId,
|
||
this.songCount = 0,
|
||
});
|
||
|
||
factory SubsonicAlbum.fromJson(Map<String, dynamic> j) => SubsonicAlbum(
|
||
id: j['id'] as String,
|
||
name: j['name'] as String? ?? j['title'] as String? ?? 'Unbekannt',
|
||
coverId: j['coverArt'] as String?,
|
||
songCount: (j['songCount'] as int?) ?? 0,
|
||
);
|
||
}
|
||
|
||
class NavidromeService {
|
||
final DbHelper _db = DbHelper();
|
||
final FlutterSecureStorage _secure = const FlutterSecureStorage();
|
||
|
||
String _serverUrl = '';
|
||
String _user = '';
|
||
String _password = '';
|
||
String _salt = '';
|
||
String? _token;
|
||
|
||
static const _version = '1.16.1';
|
||
static const _client = 'Melo';
|
||
|
||
void setCredentials(String url, String user, String password) {
|
||
_serverUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||
_user = user;
|
||
_password = password;
|
||
// Kryptographisch sicherer Salt (Random.secure statt vorhersagbarem
|
||
// Timestamp) — Security-Audit-Fix, war in v2.31-Redesign verloren gegangen.
|
||
final rng = Random.secure();
|
||
_salt = base64Encode(List.generate(16, (_) => rng.nextInt(256)));
|
||
_token = md5.convert(utf8.encode(_password + _salt)).toString();
|
||
}
|
||
|
||
Future<void> ladeGespeicherteZugangsdaten() async {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final url = prefs.getString('navidrome_url');
|
||
final user = prefs.getString('navidrome_user');
|
||
final pass = await _secure.read(key: 'navidrome_pass');
|
||
if (url != null && user != null && pass != null && url.isNotEmpty) {
|
||
setCredentials(url, user, pass);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Fehler beim Laden der Navidrome-Zugangsdaten: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> speichereZugangsdaten(String url, String user, String password) async {
|
||
setCredentials(url, user, password);
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setString('navidrome_url', url);
|
||
await prefs.setString('navidrome_user', user);
|
||
await _secure.write(key: 'navidrome_pass', value: password);
|
||
} catch (e) {
|
||
debugPrint('Fehler beim Speichern der Navidrome-Zugangsdaten: $e');
|
||
}
|
||
}
|
||
|
||
/// Trennt die Verbindung dauerhaft: In-Memory-Zustand UND persistierte
|
||
/// Zugangsdaten (SharedPreferences + SecureStorage) werden gelöscht.
|
||
/// Sonst lädt `ladeGespeicherteZugangsdaten()` beim nächsten App-Start
|
||
/// die alten Daten wieder und der Musikserver-Chip wäre wieder grün.
|
||
Future<void> loescheZugangsdaten() async {
|
||
_serverUrl = '';
|
||
_user = '';
|
||
_password = '';
|
||
_salt = '';
|
||
_token = null;
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.remove('navidrome_url');
|
||
await prefs.remove('navidrome_user');
|
||
await _secure.delete(key: 'navidrome_pass');
|
||
} catch (e) {
|
||
debugPrint('Fehler beim Löschen der Navidrome-Zugangsdaten: $e');
|
||
}
|
||
}
|
||
|
||
bool get istVerbunden => _serverUrl.isNotEmpty && _user.isNotEmpty;
|
||
|
||
/// Nur für Tests: Salt einsehbar, um die Salt-Qualität zu prüfen.
|
||
@visibleForTesting
|
||
String get saltFuerTests => _salt;
|
||
|
||
Uri _uri(String endpoint, [Map<String, String>? extra]) {
|
||
final params = {
|
||
'u': _user,
|
||
't': _token!,
|
||
's': _salt,
|
||
'v': _version,
|
||
'c': _client,
|
||
'f': 'json',
|
||
};
|
||
if (extra != null) params.addAll(extra);
|
||
return Uri.parse('$_serverUrl/rest/$endpoint').replace(queryParameters: params);
|
||
}
|
||
|
||
/// Verbindung testen
|
||
Future<bool> ping() async {
|
||
try {
|
||
final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10));
|
||
return r.statusCode == 200;
|
||
} catch (e) {
|
||
debugPrint('Navidrome Ping Fehler: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// Alle Alben abrufen
|
||
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50}) async {
|
||
try {
|
||
final r = await http.get(_uri('getAlbumList.view', {'type': 'newest', 'size': '$anzahl'})).timeout(const Duration(seconds: 15));
|
||
if (r.statusCode != 200) return [];
|
||
final data = jsonDecode(r.body);
|
||
final list = data['subsonic-response']?['albumList']?['album'] as List? ?? [];
|
||
return list.map((j) => SubsonicAlbum.fromJson(j)).toList();
|
||
} catch (e) {
|
||
debugPrint('Navidrome getAlben Fehler: $e');
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/// Songs eines Albums abrufen
|
||
Future<List<SubsonicSong>> getSongs(String albumId) async {
|
||
try {
|
||
final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15));
|
||
if (r.statusCode != 200) return [];
|
||
final data = jsonDecode(r.body);
|
||
final songs = data['subsonic-response']?['album']?['song'] as List? ?? [];
|
||
return songs.map((j) => SubsonicSong.fromJson(j)).toList();
|
||
} catch (e) {
|
||
debugPrint('Navidrome getSongs Fehler: $e');
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/// Song-Stream-URL
|
||
Uri streamUrl(String songId) {
|
||
return _uri('stream.view', {'id': songId, 'format': 'raw'});
|
||
}
|
||
|
||
/// Cover-URL
|
||
Uri? coverUrl(String coverId) {
|
||
return _uri('getCoverArt.view', {'id': coverId});
|
||
}
|
||
|
||
/// Song von Navidrome runterladen und lokal speichern
|
||
Future<Song?> downloadSong(SubsonicSong s) async {
|
||
try {
|
||
final dir = await getApplicationDocumentsDirectory();
|
||
final musikDir = Directory('${dir.path}/music');
|
||
if (!await musikDir.exists()) await musikDir.create(recursive: true);
|
||
|
||
final safeName = sanitizeDateiname(s.titel);
|
||
// Server-ID defensiv sanitizen (kein Path-Traversal über s.id)
|
||
final safeId = sanitizeDateiname(s.id);
|
||
final kurzId = safeId.length > 8 ? safeId.substring(0, 8) : safeId;
|
||
final dateiName = '${safeName}_$kurzId.mp3';
|
||
final dateiPfad = '${musikDir.path}/$dateiName';
|
||
|
||
final file = File(dateiPfad);
|
||
if (await file.exists()) {
|
||
final localSong = await _db.songNachPfad(dateiPfad);
|
||
if (localSong != null) return localSong;
|
||
}
|
||
|
||
// Stream + speichern mit Status-Code- und Integritäts-Prüfung
|
||
final uri = streamUrl(s.id);
|
||
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 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,
|
||
album: s.album,
|
||
dauerSekunden: s.dauerSekunden,
|
||
dateiPfad: dateiPfad,
|
||
groesseBytes: await file.length(),
|
||
istHeruntergeladen: true,
|
||
istKorrupt: istKorrupt,
|
||
downloadQuelle: 'server',
|
||
);
|
||
|
||
// Dauer per just_audio ermitteln wenn Navidrome keine liefert
|
||
// (optional, für später)
|
||
|
||
await _db.songEinfuegen(song);
|
||
return song;
|
||
} catch (e) {
|
||
debugPrint('Navidrome Download Fehler: $e');
|
||
return null;
|
||
}
|
||
}
|
||
}
|