This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/services/cloud_service.dart
T
Hermes (Server) 8b6a04ead8 Fix: Review-Findings korrigiert (Tag-Leiste, Profil, Singleton u.a.)
CRIT:
- Tag-Leiste: Toggle-Kopf 'Tags & Filter' eingebaut (war unerreichbar) + TagStats
- Profil: Cloud-Count vor Dialog aufloesen (kein 'Instance of Future' mehr)
HIGH:
- CloudEinstellungen: toten Sync-Timer entfernt (echter Timer im ViewModel)
- StatistikCard + RecentWidget jetzt eingebaut (gesamtMB/gesamtMin endlich genutzt)
- CloudService als Singleton (konsistenter Token-Zustand in allen Services)
MED/LOW:
- Playlist-Erkennung nur noch via list= Parameter
- Player: fehlende Quelle wird geloggt statt still
- song_tile: null-ID-Guard vor Tag-Dialog
- Scanner-Log mit Exception-Objekt
- FavoritenService: anzahlFavoriten() fuer StatistikCard
2026-07-31 15:42:54 +02:00

235 lines
7.3 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import '../services/melo_logger.dart';
/// Cloud-Sync Service für Melo Registry.
/// Auth: Bearer-JWT vom Baka-Auth-Server (Login mit Nutzername + Passwort).
/// Der alte X-API-Key/X-User-Mechanismus wurde entfernt (IDOR-Lücke).
class CloudService {
static final CloudService _instanz = CloudService._();
factory CloudService() => _instanz;
CloudService._();
static String get _base => AppConfig.cloudUrl;
static String get _authBase => AppConfig.authUrl;
String _user = '';
String _token = '';
String get user => _user;
String get token => _token;
bool get istAngemeldet => _token.isNotEmpty;
/// Echter Login gegen den Baka-Auth-Server.
/// Der Token wird gespeichert und bei allen Cloud-Calls als
/// Authorization: Bearer `token` mitgeschickt.
Future<bool> login(String user, String pass) async {
try {
final r = await http
.post(Uri.parse('$_authBase/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': user, 'password': pass}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
if (d['status'] == 'ok' && d['token'] != null) {
_user = d['username'] as String? ?? user;
_token = d['token'] as String;
await _speichereToken();
MeloLogger.cloudToken = _token;
return true;
}
}
} catch (_) {}
return false;
}
/// Stellt gespeicherten Token wieder her (Auto-Login nach App-Start).
Future<bool> restoreLogin() async {
final prefs = await SharedPreferences.getInstance();
final t = prefs.getString('melo_cloud_token') ?? '';
if (t.isEmpty) return false;
_token = t;
_user = prefs.getString('melo_cloud_user') ?? '';
MeloLogger.cloudToken = t;
return true;
}
Future<void> logout() async {
_token = '';
_user = '';
MeloLogger.cloudToken = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove('melo_cloud_token');
await prefs.remove('melo_cloud_user');
}
Future<void> _speichereToken() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('melo_cloud_token', _token);
await prefs.setString('melo_cloud_user', _user);
}
Map<String, String> get _authHeader => {
if (_token.isNotEmpty) 'Authorization': 'Bearer $_token',
};
Future<Map?> status() => _get('/api/cloud/status');
Future<List<Map>> listSongs() async {
final r = await _get('/api/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'));
req.headers.addAll(_authHeader);
req.files.add(await http.MultipartFile.fromPath('file', filepath,
filename: filename));
final resp = await req.send().timeout(const Duration(seconds: 120));
final body = jsonDecode(await resp.stream.bytesToString());
return body['song_id'] as String?;
} catch (e) {
MeloLogger().fehler('cloud_upload', e);
return null;
}
}
Future<bool> download(String songId, String destPath) async {
try {
final r = await http
.get(Uri.parse('$_base/api/cloud/download/$songId'),
headers: _authHeader)
.timeout(const Duration(seconds: 120));
if (r.statusCode == 200) {
final file = File(destPath);
if (!await file.parent.exists()) {
await file.parent.create(recursive: true);
}
await file.writeAsBytes(r.bodyBytes);
return true;
}
return false;
} catch (e) {
MeloLogger().fehler('cloud_download', e);
return false;
}
}
Future<bool> delete(String songId) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/delete'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'song_id': songId}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
Future<Map?> _get(String path) async {
try {
final r = await http
.get(Uri.parse('$_base$path'), headers: _authHeader)
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
} catch (_) {}
return null;
}
Future<String?> share(List<String> songIds) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/share'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'song_ids': songIds}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
return d['code'] as String?;
}
} catch (_) {}
return null;
}
Future<Map?> importCode(String code) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/import'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'code': code}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
} catch (_) {}
return null;
}
Future<List<Map>> syncChanges(String since) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/sync'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'since': since}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
return List<Map>.from(d['changes'] ?? []);
}
} catch (_) {}
return [];
}
Future<List<Map>> globalList() async {
try {
final r = await http
.get(Uri.parse('$_base/api/cloud/global'), headers: _authHeader)
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
return List<Map>.from(jsonDecode(r.body)['songs'] ?? []);
}
} catch (_) {}
return [];
}
Future<bool> toggleGlobal(String songId) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/toggle-global'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'song_id': songId}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
/// Löscht alle Cloud-Songs des angemeldeten Users (inkl. Server-Dateien).
Future<bool> loescheMusik() async {
return _postOhneBody('/api/cloud/delete-music');
}
/// Löscht ALLE Cloud-Daten des Users (Musik + Shares + Ordner).
Future<bool> loescheAlles() async {
return _postOhneBody('/api/cloud/delete-all');
}
Future<bool> _postOhneBody(String path) async {
try {
final r = await http
.post(Uri.parse('$_base$path'),
headers: {..._authHeader, 'Content-Type': 'application/json'})
.timeout(const Duration(seconds: 30));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
}