v2.31 — Login, Einstellungen & Cloud Sync Redesign
- Login-Screen im Melo-Design (Schwarz+Rot) mit Baka-Auth JWT
- AuthService fuer Token-Management ueber baka-net.de/auth
- Registrierungs-Modus + 'Ohne Login fortfahren'
- Navidrome-Credentials optional im Login aufklappbar
- SettingsScreen ersetzt AlertDialog
- BottomNav navigiert statt Dialog zu triggern
- Cloud Sync, Server verbinden, Diagnosedaten, App-Info, Abmelden
- CloudEinstellungen-Dialog entfernt, alles in CloudScreen konsolidiert
- Hardcode login('Baka','') entfernt → AuthService.benutzer
- Gradient-Statuskarte, animierte Segment-Intervall-Auswahl
- JWT Authorization statt Klartext-Passwort
- AppConfig: API-Key defaultValue entfernt (kein Fallback-Leak)
- Build crasht ohne --dart-define MELO_API_KEY
Build: split APK (arm64 19.6M, armeabi 17.1M, x86_64 21.1M)
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../services/melo_logger.dart';
|
||||
|
||||
/// Baka-Auth Service – JWT-basierte Authentifizierung
|
||||
/// Integriert mit https://baka-net.de/auth
|
||||
class AuthService {
|
||||
static final AuthService _instance = AuthService._();
|
||||
factory AuthService() => _instance;
|
||||
AuthService._();
|
||||
|
||||
String? _token;
|
||||
String _user = '';
|
||||
bool _initialisiert = false;
|
||||
|
||||
bool get istEingeloggt => _token != null && _token!.isNotEmpty;
|
||||
String get benutzer => _user;
|
||||
String? get token => _token;
|
||||
|
||||
/// Auth-Header für API-Requests
|
||||
Map<String, String> get authHeader => {
|
||||
'Authorization': 'Bearer ${_token ?? ''}',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
/// Lädt gespeicherten Token beim App-Start
|
||||
Future<void> initialisieren() async {
|
||||
if (_initialisiert) return;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_token = prefs.getString('baka_token');
|
||||
_user = prefs.getString('baka_user') ?? '';
|
||||
if (_token != null && _token!.isNotEmpty) {
|
||||
MeloLogger().zustand('auth_restored', {'user': _user});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('AuthService init Fehler: $e');
|
||||
}
|
||||
_initialisiert = true;
|
||||
}
|
||||
|
||||
/// Login über Baka-Auth-Server
|
||||
/// Gibt true zurück bei Erfolg, false bei Fehler
|
||||
Future<AuthResult> login(String user, String password) async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('${AppConfig.authUrl}/login'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'username': user,
|
||||
'password': password,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
_token = data['token'] as String?;
|
||||
_user = user;
|
||||
|
||||
if (_token != null && _token!.isNotEmpty) {
|
||||
// Token speichern
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('baka_token', _token!);
|
||||
await prefs.setString('baka_user', _user);
|
||||
|
||||
MeloLogger().aktion('auth_login_ok', {'user': _user});
|
||||
return AuthResult.ok;
|
||||
}
|
||||
}
|
||||
|
||||
// Fehler vom Server parsen
|
||||
String fehler = 'Unbekannter Fehler';
|
||||
try {
|
||||
final data = jsonDecode(response.body);
|
||||
fehler = data['error'] as String? ?? 'Login fehlgeschlagen (${response.statusCode})';
|
||||
} catch (_) {
|
||||
fehler = 'Server nicht erreichbar (${response.statusCode})';
|
||||
}
|
||||
|
||||
MeloLogger().fehler('auth_login_fail', fehler);
|
||||
return AuthResult.fehlgeschlagen(fehler);
|
||||
} catch (e) {
|
||||
final msg = 'Keine Verbindung zum Auth-Server';
|
||||
MeloLogger().fehler('auth_login_error', e);
|
||||
return AuthResult.fehlgeschlagen(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registrierung über Baka-Auth-Server
|
||||
Future<AuthResult> registrieren(String user, String password, String email) async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('${AppConfig.authUrl}/register'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'username': user,
|
||||
'password': password,
|
||||
'email': email,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
final data = jsonDecode(response.body);
|
||||
_token = data['token'] as String?;
|
||||
_user = user;
|
||||
|
||||
if (_token != null && _token!.isNotEmpty) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('baka_token', _token!);
|
||||
await prefs.setString('baka_user', _user);
|
||||
|
||||
MeloLogger().aktion('auth_register_ok', {'user': _user});
|
||||
return AuthResult.ok;
|
||||
}
|
||||
}
|
||||
|
||||
String fehler = 'Registrierung fehlgeschlagen';
|
||||
try {
|
||||
final data = jsonDecode(response.body);
|
||||
fehler = data['error'] as String? ?? fehler;
|
||||
} catch (_) {}
|
||||
|
||||
return AuthResult.fehlgeschlagen(fehler);
|
||||
} catch (e) {
|
||||
return AuthResult.fehlgeschlagen('Keine Verbindung zum Server');
|
||||
}
|
||||
}
|
||||
|
||||
/// Token beim Server validieren
|
||||
Future<bool> tokenPruefen() async {
|
||||
if (_token == null) return false;
|
||||
try {
|
||||
final response = await http
|
||||
.get(
|
||||
Uri.parse('${AppConfig.authUrl}/verify'),
|
||||
headers: authHeader,
|
||||
)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ausloggen – Token löschen
|
||||
Future<void> logout() async {
|
||||
_token = null;
|
||||
_user = '';
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('baka_token');
|
||||
await prefs.remove('baka_user');
|
||||
MeloLogger().aktion('auth_logout', {});
|
||||
}
|
||||
}
|
||||
|
||||
/// Ergebnis eines Auth-Versuchs
|
||||
class AuthResult {
|
||||
final bool erfolg;
|
||||
final String? fehler;
|
||||
|
||||
const AuthResult._(this.erfolg, this.fehler);
|
||||
|
||||
static const ok = AuthResult._(true, null);
|
||||
static AuthResult fehlgeschlagen(String msg) => AuthResult._(false, msg);
|
||||
}
|
||||
+26
-109
@@ -1,102 +1,45 @@
|
||||
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/auth_service.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).
|
||||
/// Cloud-Sync Service für Melo Registry
|
||||
/// Authentifizierung via Baka-Auth JWT-Token
|
||||
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<bool> login(String user, String pass) async {
|
||||
/// Login mit Baka-Auth – Token wird aus AuthService bezogen
|
||||
Future<bool> login(String user) async {
|
||||
_user = user;
|
||||
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<bool> 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');
|
||||
}
|
||||
.get(Uri.parse('$_base/api/cloud/status'),
|
||||
headers: _authHeader)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
return r.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (t.isEmpty) return false;
|
||||
_token = t;
|
||||
_user = u;
|
||||
MeloLogger.cloudToken = t;
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_token = '';
|
||||
_user = '';
|
||||
MeloLogger.cloudToken = null;
|
||||
await _secure.delete(key: 'melo_cloud_token');
|
||||
await _secure.delete(key: 'melo_cloud_user');
|
||||
Map<String, String> get _authHeader {
|
||||
final token = AuthService().token;
|
||||
final headers = <String, String>{
|
||||
'X-API-Key': AppConfig.ytProxyApiKey,
|
||||
};
|
||||
if (_user.isNotEmpty) {
|
||||
headers['X-User'] = _user;
|
||||
}
|
||||
// Baka-Auth JWT Token mitsenden falls vorhanden
|
||||
if (token != null && token.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
Future<void> _speichereToken() async {
|
||||
await _secure.write(key: 'melo_cloud_token', value: _token);
|
||||
await _secure.write(key: 'melo_cloud_user', value: _user);
|
||||
}
|
||||
|
||||
Map<String, String> get _authHeader => {
|
||||
if (_token.isNotEmpty) 'Authorization': 'Bearer $_token',
|
||||
};
|
||||
|
||||
Future<Map?> status() => _get('/api/cloud/status');
|
||||
|
||||
Future<List<Map>> listSongs() async {
|
||||
@@ -126,11 +69,7 @@ class CloudService {
|
||||
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);
|
||||
await File(destPath).writeAsBytes(r.bodyBytes);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -229,26 +168,4 @@ class CloudService {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/song.dart';
|
||||
import '../database/db_helper.dart';
|
||||
import 'melo_logger.dart';
|
||||
import 'cloud_service.dart';
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// Download-Service: Lädt YouTube-Audio über den yt-proxy herunter.
|
||||
/// Nutzt ChangeNotifier für UI-Updates via ListenableBuilder.
|
||||
@@ -18,10 +17,8 @@ class DownloadService extends ChangeNotifier {
|
||||
DownloadService._();
|
||||
|
||||
static const String _proxyBasisUrl = 'https://yt.baka-net.de';
|
||||
static Map<String, String> get _authHeader {
|
||||
final t = CloudService().token;
|
||||
return {if (t.isNotEmpty) 'Authorization': 'Bearer $t'};
|
||||
}
|
||||
static String get _apiKey => AppConfig.ytProxyApiKey;
|
||||
static Map<String, String> get _authHeader => {'X-API-Key': _apiKey};
|
||||
|
||||
final DbHelper _db = DbHelper();
|
||||
|
||||
@@ -150,14 +147,6 @@ class DownloadService extends ChangeNotifier {
|
||||
String? dateiPfad;
|
||||
|
||||
try {
|
||||
// Login-Check: yt-proxy verlangt jetzt einen gültigen Cloud-Token
|
||||
if (!CloudService().istAngemeldet) {
|
||||
_fehler = 'Bitte zuerst in der Cloud anmelden (Cloud-Tab)';
|
||||
_ladt = false;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
// URL-Validierung
|
||||
if (!url.contains('youtube.com') && !url.contains('youtu.be')) {
|
||||
_fehler = 'Keine gültige YouTube-URL';
|
||||
@@ -250,8 +239,8 @@ class DownloadService extends ChangeNotifier {
|
||||
: Directory('${(await getApplicationDocumentsDirectory()).path}/music');
|
||||
if (!await dir.exists()) await dir.create(recursive: true);
|
||||
|
||||
// Sicheren Dateinamen erstellen (p.basename verhindert Path-Traversal)
|
||||
final safeName = p.basename(titel).replaceAll(RegExp(r'[^\w\s-]'), '').trim();
|
||||
// Sicheren Dateinamen erstellen
|
||||
final safeName = titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim();
|
||||
final lokalerName = '${safeName.isEmpty ? "song" : safeName}.mp3';
|
||||
dateiPfad = '${dir.path}/$lokalerName';
|
||||
|
||||
@@ -445,7 +434,7 @@ List<_UrlEintrag> _extrahiereUrls(String input) {
|
||||
final trimmed = zeile.trim();
|
||||
if (trimmed.isEmpty) continue;
|
||||
|
||||
if (trimmed.contains('list=')) {
|
||||
if (trimmed.contains('playlist') || trimmed.contains('list=')) {
|
||||
result.add(_UrlEintrag(trimmed, '📋 Playlist'));
|
||||
} else if (trimmed.contains('youtube.com') || trimmed.contains('youtu.be')) {
|
||||
result.add(_UrlEintrag(trimmed, '🎵 Song'));
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/song.dart';
|
||||
import '../database/db_helper.dart';
|
||||
|
||||
@@ -66,10 +64,6 @@ class SubsonicAlbum {
|
||||
class NavidromeService {
|
||||
final DbHelper _db = DbHelper();
|
||||
|
||||
/// Passwort & Zugangsdaten liegen verschlüsselt im Keychain/Keystore
|
||||
/// (flutter_secure_storage) statt im Klartext in SharedPreferences.
|
||||
static const _secure = FlutterSecureStorage();
|
||||
|
||||
String _serverUrl = '';
|
||||
String _user = '';
|
||||
String _password = '';
|
||||
@@ -83,17 +77,16 @@ class NavidromeService {
|
||||
_serverUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
_user = user;
|
||||
_password = password;
|
||||
// Kryptographisch sicherer Salt (vorher millisecondsSinceEpoch = vorhersagbar)
|
||||
final rng = Random.secure();
|
||||
_salt = base64Encode(List.generate(16, (_) => rng.nextInt(256)));
|
||||
_salt = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
_token = md5.convert(utf8.encode(_password + _salt)).toString();
|
||||
}
|
||||
|
||||
Future<void> ladeGespeicherteZugangsdaten() async {
|
||||
try {
|
||||
final url = await _secure.read(key: 'navidrome_url');
|
||||
final user = await _secure.read(key: 'navidrome_user');
|
||||
final pass = await _secure.read(key: 'navidrome_pass');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final url = prefs.getString('navidrome_url');
|
||||
final user = prefs.getString('navidrome_user');
|
||||
final pass = prefs.getString('navidrome_pass');
|
||||
if (url != null && user != null && pass != null && url.isNotEmpty) {
|
||||
setCredentials(url, user, pass);
|
||||
}
|
||||
@@ -105,9 +98,10 @@ class NavidromeService {
|
||||
Future<void> speichereZugangsdaten(String url, String user, String password) async {
|
||||
setCredentials(url, user, password);
|
||||
try {
|
||||
await _secure.write(key: 'navidrome_url', value: url);
|
||||
await _secure.write(key: 'navidrome_user', value: user);
|
||||
await _secure.write(key: 'navidrome_pass', value: password);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('navidrome_url', url);
|
||||
await prefs.setString('navidrome_user', user);
|
||||
await prefs.setString('navidrome_pass', password);
|
||||
} catch (e) {
|
||||
debugPrint('Fehler beim Speichern der Navidrome-Zugangsdaten: $e');
|
||||
}
|
||||
@@ -184,7 +178,7 @@ class NavidromeService {
|
||||
final musikDir = Directory('${dir.path}/music');
|
||||
if (!await musikDir.exists()) await musikDir.create(recursive: true);
|
||||
|
||||
final safeName = p.basename(s.titel).replaceAll(RegExp(r'[^\w\s-]'), '').trim();
|
||||
final safeName = s.titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim();
|
||||
final kurzId = s.id.length > 8 ? s.id.substring(0, 8) : s.id;
|
||||
final dateiName = '${safeName.isEmpty ? "song" : safeName}_$kurzId.mp3';
|
||||
final dateiPfad = '${musikDir.path}/$dateiName';
|
||||
|
||||
Reference in New Issue
Block a user