import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 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 = ''; /// Token liegt verschlüsselt im Keychain/Keystore (flutter_secure_storage) — /// nicht mehr im Klartext in SharedPreferences (Security-Audit CRIT-1). static const _secure = FlutterSecureStorage(); 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 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). /// Migriert einmalig alte SharedPreferences-Einträge in SecureStorage. Future restoreLogin() async { final prefs = await SharedPreferences.getInstance(); var t = await _secure.read(key: 'melo_cloud_token') ?? ''; var u = await _secure.read(key: 'melo_cloud_user') ?? ''; // Migration alter Versionen (Token lag früher in SharedPreferences) if (t.isEmpty) { final altT = prefs.getString('melo_cloud_token') ?? ''; final altU = prefs.getString('melo_cloud_user') ?? ''; if (altT.isNotEmpty) { t = altT; u = altU; await _secure.write(key: 'melo_cloud_token', value: t); if (u.isNotEmpty) { await _secure.write(key: 'melo_cloud_user', value: u); } await prefs.remove('melo_cloud_token'); await prefs.remove('melo_cloud_user'); } } if (t.isEmpty) return false; _token = t; _user = u; MeloLogger.cloudToken = t; return true; } Future logout() async { _token = ''; _user = ''; MeloLogger.cloudToken = null; await _secure.delete(key: 'melo_cloud_token'); await _secure.delete(key: 'melo_cloud_user'); } Future _speichereToken() async { await _secure.write(key: 'melo_cloud_token', value: _token); await _secure.write(key: 'melo_cloud_user', value: _user); } Map get _authHeader => { if (_token.isNotEmpty) 'Authorization': 'Bearer $_token', }; Future status() => _get('/api/cloud/status'); Future> listSongs() async { final r = await _get('/api/cloud/list'); return List.from(r?['songs'] ?? []); } Future 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 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 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 _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 share(List 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 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> 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.from(d['changes'] ?? []); } } catch (_) {} return []; } Future> 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.from(jsonDecode(r.body)['songs'] ?? []); } } catch (_) {} return []; } Future 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 loescheMusik() async { return _postOhneBody('/api/cloud/delete-music'); } /// Löscht ALLE Cloud-Daten des Users (Musik + Shares + Ordner). Future loescheAlles() async { return _postOhneBody('/api/cloud/delete-all'); } Future _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; } } }