Security: Cloud-Auth auf Bearer-JWT umgestellt (IDOR-Luecke geschlossen)
- cloud_service: echter Login gegen baka-auth, Token statt X-API-Key/X-User - app_config: hartcodierten API-Key-Default entfernt - download_service + melo_logger: Bearer-Token statt X-API-Key - navidrome: Passwort in flutter_secure_storage (Keychain/Keystore) - song: token-haltige stream_url wird nicht mehr in SQLite persistiert - cloud_screen: Pfad-Traversal beim Download-Dateinamen gefixt (p.basename) - home_screen: Login-Dialog mit Passwort-Feld, Auto-Sync nutzt restoreLogin
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user