From 03edbfefb7b41c46573641a4279585b705328444 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 6 Dec 2025 01:51:07 +0100 Subject: [PATCH] feat: Add initial backend server with user authentication, session management, and playlist CRUD functionality. --- Dockerfile | 10 +- bin/api/auth_handler.dart | 144 +++++++++ bin/api/playlists_handler.dart | 186 +++++++++++ bin/database/database.dart | 291 ++++++++++++++++++ bin/middleware/auth_middleware.dart | 57 ++++ bin/models/playlist.dart | 65 ++++ bin/models/session.dart | 37 +++ bin/models/user.dart | 31 ++ bin/pubspec.yaml | 4 + bin/server.dart | 54 +++- bin/utils/password_hasher.dart | 17 + docker-compose.yml | 12 +- lib/core/api/api_client.dart | 83 +++++ lib/core/router/app_router.dart | 18 +- lib/core/services/auth_api_service.dart | 98 ++++++ lib/core/services/playlist_api_service.dart | 116 +++++++ .../auth/providers/auth_provider.dart | 75 +++-- .../screens/playlist_selection_screen.dart | 95 +++--- lib/main.dart | 6 +- 19 files changed, 1324 insertions(+), 75 deletions(-) create mode 100644 bin/api/auth_handler.dart create mode 100644 bin/api/playlists_handler.dart create mode 100644 bin/database/database.dart create mode 100644 bin/middleware/auth_middleware.dart create mode 100644 bin/models/playlist.dart create mode 100644 bin/models/session.dart create mode 100644 bin/models/user.dart create mode 100644 bin/utils/password_hasher.dart create mode 100644 lib/core/api/api_client.dart create mode 100644 lib/core/services/auth_api_service.dart create mode 100644 lib/core/services/playlist_api_service.dart diff --git a/Dockerfile b/Dockerfile index 2153acd..7696eba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,12 +36,14 @@ FROM dart:stable WORKDIR /app -# Copy server code and pubspec -COPY bin/server.dart ./bin/ -COPY bin/pubspec.yaml ./ +# Install SQLite3 library for FFI +RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev && rm -rf /var/lib/apt/lists/* + +# Copy entire bin directory (API, database, etc.) +COPY bin/ ./bin/ # Get dependencies -RUN dart pub get +RUN dart pub get --directory=bin # Copy built web application from builder stage COPY --from=builder /app/build/web /app/web diff --git a/bin/api/auth_handler.dart b/bin/api/auth_handler.dart new file mode 100644 index 0000000..2bd7448 --- /dev/null +++ b/bin/api/auth_handler.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import '../database/database.dart'; + +class AuthHandler { + final AppDatabase db; + + AuthHandler(this.db); + + Router get router { + final router = Router(); + + router.post('/login', _login); + router.post('/logout', _logout); + router.get('/me', _getCurrentUser); + + return router; + } + + /// POST /api/auth/login + Future _login(Request request) async { + try { + final payload = jsonDecode(await request.readAsString()) as Map; + final username = payload['username'] as String?; + final password = payload['password'] as String?; + + if (username == null || password == null) { + return Response(400, body: jsonEncode({ + 'success': false, + 'error': 'Username and password are required', + }), headers: {'Content-Type': 'application/json'}); + } + + // Verify credentials + final user = db.verifyCredentials(username, password); + if (user == null) { + return Response(401, body: jsonEncode({ + 'success': false, + 'error': 'Invalid credentials', + }), headers: {'Content-Type': 'application/json'}); + } + + // Create session + final session = db.createSession(user.id); + + return Response.ok(jsonEncode({ + 'success': true, + 'user': user.toJson(), + 'token': session.token, + }), headers: {'Content-Type': 'application/json'}); + } catch (e) { + return Response.internalServerError( + body: jsonEncode({'success': false, 'error': e.toString()}), + headers: {'Content-Type': 'application/json'}, + ); + } + } + + /// POST /api/auth/logout + Future _logout(Request request) async { + try { + final token = _extractToken(request); + if (token == null) { + return Response(401, body: jsonEncode({ + 'success': false, + 'error': 'Unauthorized', + }), headers: {'Content-Type': 'application/json'}); + } + + db.deleteSession(token); + + return Response.ok(jsonEncode({ + 'success': true, + }), headers: {'Content-Type': 'application/json'}); + } catch (e) { + return Response.internalServerError( + body: jsonEncode({'success': false, 'error': e.toString()}), + headers: {'Content-Type': 'application/json'}, + ); + } + } + + /// GET /api/auth/me + Future _getCurrentUser(Request request) async { + try { + final token = _extractToken(request); + if (token == null) { + return Response(401, body: jsonEncode({ + 'success': false, + 'error': 'Unauthorized', + }), headers: {'Content-Type': 'application/json'}); + } + + final session = db.findSessionByToken(token); + if (session == null) { + return Response(401, body: jsonEncode({ + 'success': false, + 'error': 'Invalid or expired session', + }), headers: {'Content-Type': 'application/json'}); + } + + final user = db.findUserById(session.userId); + if (user == null) { + return Response(404, body: jsonEncode({ + 'success': false, + 'error': 'User not found', + }), headers: {'Content-Type': 'application/json'}); + } + + return Response.ok(jsonEncode({ + 'user': user.toJson(), + }), headers: {'Content-Type': 'application/json'}); + } catch (e) { + return Response.internalServerError( + body: jsonEncode({'success': false, 'error': e.toString()}), + headers: {'Content-Type': 'application/json'}, + ); + } + } + + /// Extract token from Authorization header or cookie + String? _extractToken(Request request) { + // Try Authorization header first + final authHeader = request.headers['authorization']; + if (authHeader != null && authHeader.startsWith('Bearer ')) { + return authHeader.substring(7); + } + + // Try cookie + final cookie = request.headers['cookie']; + if (cookie != null) { + final parts = cookie.split(';'); + for (final part in parts) { + final trimmed = part.trim(); + if (trimmed.startsWith('session=')) { + return trimmed.substring(8); + } + } + } + + return null; + } +} diff --git a/bin/api/playlists_handler.dart b/bin/api/playlists_handler.dart new file mode 100644 index 0000000..40527f6 --- /dev/null +++ b/bin/api/playlists_handler.dart @@ -0,0 +1,186 @@ +import 'dart:convert'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import '../database/database.dart'; + +class PlaylistsHandler { + final AppDatabase db; + + PlaylistsHandler(this.db); + + Router get router { + final router = Router(); + + router.get('/', _getPlaylists); + router.post('/', _createPlaylist); + router.put('/', _updatePlaylist); + router.delete('/', _deletePlaylist); + + return router; + } + + /// GET /api/playlists + Future _getPlaylists(Request request) async { + try { + final userId = request.context['userId'] as String?; + if (userId == null) { + return Response(401, body: jsonEncode({ + 'success': false, + 'error': 'Unauthorized', + }), headers: {'Content-Type': 'application/json'}); + } + + final playlists = db.getPlaylists(userId); + + return Response.ok(jsonEncode({ + 'playlists': playlists.map((p) => p.toJson()).toList(), + }), headers: {'Content-Type': 'application/json'}); + } catch (e) { + return Response.internalServerError( + body: jsonEncode({'success': false, 'error': e.toString()}), + headers: {'Content-Type': 'application/json'}, + ); + } + } + + /// POST /api/playlists + Future _createPlaylist(Request request) async { + try { + final userId = request.context['userId'] as String?; + if (userId == null) { + return Response(401, body: jsonEncode({ + 'success': false, + 'error': 'Unauthorized', + }), headers: {'Content-Type': 'application/json'}); + } + + final payload = jsonDecode(await request.readAsString()) as Map; + final name = payload['name'] as String?; + final serverUrl = payload['serverUrl'] as String?; + final username = payload['username'] as String?; + final password = payload['password'] as String?; + final dns = payload['dns'] as String?; + + if (name == null || serverUrl == null || username == null || password == null) { + return Response(400, body: jsonEncode({ + 'success': false, + 'error': 'Missing required fields', + }), headers: {'Content-Type': 'application/json'}); + } + + final playlist = db.createPlaylist( + userId: userId, + name: name, + serverUrl: serverUrl, + username: username, + password: password, + dns: dns ?? serverUrl, + ); + + return Response.ok(jsonEncode({ + 'success': true, + 'playlist': playlist.toJson(), + }), headers: {'Content-Type': 'application/json'}); + } catch (e) { + return Response.internalServerError( + body: jsonEncode({'success': false, 'error': e.toString()}), + headers: {'Content-Type': 'application/json'}, + ); + } + } + + /// PUT /api/playlists/:id + Future _updatePlaylist(Request request, String id) async { + try { + final userId = request.context['userId'] as String?; + if (userId == null) { + return Response(401, body: jsonEncode({ + 'success': false, + 'error': 'Unauthorized', + }), headers: {'Content-Type': 'application/json'}); + } + + // Verify playlist belongs to user + final existing = db.getPlaylistById(id); + if (existing == null) { + return Response(404, body: jsonEncode({ + 'success': false, + 'error': 'Playlist not found', + }), headers: {'Content-Type': 'application/json'}); + } + + if (existing.userId != userId) { + return Response(403, body: jsonEncode({ + 'success': false, + 'error': 'Forbidden', + }), headers: {'Content-Type': 'application/json'}); + } + + final payload = jsonDecode(await request.readAsString()) as Map; + final name = payload['name'] as String? ?? existing.name; + final serverUrl = payload['serverUrl'] as String? ?? existing.serverUrl; + final username = payload['username'] as String? ?? existing.username; + final password = payload['password'] as String? ?? existing.password; + final dns = payload['dns'] as String? ?? existing.dns; + + final playlist = db.updatePlaylist( + playlistId: id, + name: name, + serverUrl: serverUrl, + username: username, + password: password, + dns: dns, + ); + + return Response.ok(jsonEncode({ + 'success': true, + 'playlist': playlist.toJson(), + }), headers: {'Content-Type': 'application/json'}); + } catch (e) { + return Response.internalServerError( + body: jsonEncode({'success': false, 'error': e.toString()}), + headers: {'Content-Type': 'application/json'}, + ); + } + } + + /// DELETE /api/playlists/:id + Future _deletePlaylist(Request request, String id) async { + try { + final userId = request.context['userId'] as String?; + if (userId == null) { + return Response(401, body: jsonEncode({ + 'success': false, + 'error': 'Unauthorized', + }), headers: {'Content-Type': 'application/json'}); + } + + // Verify playlist belongs to user + final existing = db.getPlaylistById(id); + if (existing == null) { + return Response(404,body: jsonEncode({ + 'success': false, + 'error': 'Playlist not found', + }), headers: {'Content-Type': 'application/json'}); + } + + if (existing.userId != userId) { + return Response(403, body: jsonEncode({ + 'success': false, + 'error': 'Forbidden', + }), headers: {'Content-Type': 'application/json'}); + } + + db.deletePlaylist(id); + + return Response.ok(jsonEncode({ + 'success': true, + }), headers: {'Content-Type': 'application/json'}); + } catch (e) { + return Response.internalServerError( + body: jsonEncode({'success': false, 'error': e.toString()}), + headers: {'Content-Type': 'application/json'}, + ); + } + } +} diff --git a/bin/database/database.dart b/bin/database/database.dart new file mode 100644 index 0000000..082521e --- /dev/null +++ b/bin/database/database.dart @@ -0,0 +1,291 @@ +import 'dart:io'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:uuid/uuid.dart'; +import '../models/user.dart'; +import '../models/playlist.dart'; +import '../models/session.dart' as models; +import '../utils/password_hasher.dart'; + +class AppDatabase { + late final Database _db; + final _uuid = const Uuid(); + + /// Initialize database and create tables + Future init() async { + final dbPath = '/app/data/xtremflow.db'; + + // Ensure data directory exists + final dir = Directory('/app/data'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + _db = sqlite3.open(dbPath); + + await _createTables(); + print('Database initialized: $dbPath'); + } + + /// Create database tables + Future _createTables() async { + // Users table + _db.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + '''); + + // Playlists table + _db.execute(''' + CREATE TABLE IF NOT EXISTS playlists ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + server_url TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + dns TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + '''); + + // Sessions table + _db.execute(''' + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + '''); + + // Indexes + _db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)'); + _db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)'); + _db.execute('CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)'); + } + + /// Seed default admin user if no users exist + Future seedAdmin() async { + final result = _db.select('SELECT COUNT(*) as count FROM users'); + final count = result.first['count'] as int; + + if (count == 0) { + final adminId = _uuid.v4(); + final passwordHash = PasswordHasher.hash('admin'); + + _db.execute(''' + INSERT INTO users (id, username, password_hash, is_admin) + VALUES (?, ?, ?, 1) + ''', [adminId, 'admin', passwordHash]); + + print('Default admin user created (username: admin, password: admin)'); + } + } + + // ==================== Users ==================== + + /// Find user by username + User? findUserByUsername(String username) { + final result = _db.select( + 'SELECT * FROM users WHERE username = ?', + [username], + ); + + if (result.isEmpty) return null; + return User.fromMap(result.first); + } + + /// Find user by ID + User? findUserById(String userId) { + final result = _db.select( + 'SELECT * FROM users WHERE id = ?', + [userId], + ); + + if (result.isEmpty) return null; + return User.fromMap(result.first); + } + + /// Verify user credentials + User? verifyCredentials(String username, String password) { + final result = _db.select( + 'SELECT * FROM users WHERE username = ?', + [username], + ); + + if (result.isEmpty) return null; + + final passwordHash = result.first['password_hash'] as String; + if (!PasswordHasher.verify(password, passwordHash)) { + return null; + } + + return User.fromMap(result.first); + } + + /// Create new user + User createUser(String username, String password, {bool isAdmin = false}) { + final userId = _uuid.v4(); + final passwordHash = PasswordHasher.hash(password); + + _db.execute(''' + INSERT INTO users (id, username, password_hash, is_admin) + VALUES (?, ?, ?, ?) + ''', [userId, username, passwordHash, isAdmin ? 1 : 0]); + + return User( + id: userId, + username: username, + isAdmin: isAdmin, + createdAt: DateTime.now(), + ); + } + + // ==================== Sessions ==================== + + /// Create new session + models.Session createSession(String userId, {Duration? duration}) { + final sessionId = _uuid.v4(); + final token = _uuid.v4(); + final expiresAt = DateTime.now().add(duration ?? const Duration(days: 7)); + + _db.execute(''' + INSERT INTO sessions (id, user_id, token, expires_at) + VALUES (?, ?, ?, ?) + ''', [sessionId, userId, token, expiresAt.toIso8601String()]); + + return models.Session( + id: sessionId, + userId: userId, + token: token, + expiresAt: expiresAt, + createdAt: DateTime.now(), + ); + } + + /// Find session by token + models.Session? findSessionByToken(String token) { + final result = _db.select( + 'SELECT * FROM sessions WHERE token = ?', + [token], + ); + + if (result.isEmpty) return null; + + final session = models.Session.fromMap(result.first); + + // Check if expired + if (session.isExpired) { + deleteSession(token); + return null; + } + + return session; + } + + /// Delete session (logout) + void deleteSession(String token) { + _db.execute('DELETE FROM sessions WHERE token = ?', [token]); + } + + /// Clean expired sessions + void cleanExpiredSessions() { + _db.execute( + 'DELETE FROM sessions WHERE expires_at < ?', + [DateTime.now().toIso8601String()], + ); + } + + // ==================== Playlists ==================== + + /// Get all playlists for a user + List getPlaylists(String userId) { + final result = _db.select( + 'SELECT * FROM playlists WHERE user_id = ? ORDER BY created_at DESC', + [userId], + ); + + return result.map((row) => Playlist.fromMap(row)).toList(); + } + + /// Get playlist by ID + Playlist? getPlaylistById(String playlistId) { + final result = _db.select( + 'SELECT * FROM playlists WHERE id = ?', + [playlistId], + ); + + if (result.isEmpty) return null; + return Playlist.fromMap(result.first); + } + + /// Create new playlist + Playlist createPlaylist({ + required String userId, + required String name, + required String serverUrl, + required String username, + required String password, + String? dns, + }) { + final playlistId = _uuid.v4(); + final now = DateTime.now().toIso8601String(); + + _db.execute(''' + INSERT INTO playlists (id, user_id, name, server_url, username, password, dns, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', [playlistId, userId, name, serverUrl, username, password, dns, now, now]); + + return Playlist( + id: playlistId, + userId: userId, + name: name, + serverUrl: serverUrl, + username: username, + password: password, + dns: dns, + createdAt: DateTime.parse(now), + updatedAt: DateTime.parse(now), + ); + } + + /// Update playlist + Playlist updatePlaylist({ + required String playlistId, + required String name, + required String serverUrl, + required String username, + required String password, + String? dns, + }) { + final now = DateTime.now().toIso8601String(); + + _db.execute(''' + UPDATE playlists + SET name = ?, server_url = ?, username = ?, password = ?, dns = ?, updated_at = ? + WHERE id = ? + ''', [name, serverUrl, username, password, dns, now, playlistId]); + + return getPlaylistById(playlistId)!; + } + + /// Delete playlist + void deletePlaylist(String playlistId) { + _db.execute('DELETE FROM playlists WHERE id = ?', [playlistId]); + } + + /// Close database connection + void close() { + _db.dispose(); + } +} diff --git a/bin/middleware/auth_middleware.dart b/bin/middleware/auth_middleware.dart new file mode 100644 index 0000000..71e3ca9 --- /dev/null +++ b/bin/middleware/auth_middleware.dart @@ -0,0 +1,57 @@ +import 'package:shelf/shelf.dart'; +import '../database/database.dart'; + +/// Middleware to authenticate requests +Middleware authMiddleware(AppDatabase db) { + return (Handler handler) { + return (Request request) async { + // Skip auth for login endpoint + if (request.url.path.startsWith('api/auth/login')) { + return handler(request); + } + + // Extract token + final token = _extractToken(request); + if (token == null) { + return Response(401, body: 'Unauthorized'); + } + + // Verify session + final session = db.findSessionByToken(token); + if (session == null) { + return Response(401, body: 'Invalid or expired session'); + } + + // Add userId to context + final updatedRequest = request.change(context: { + ...request.context, + 'userId': session.userId, + }); + + return handler(updatedRequest); + }; + }; +} + +/// Extract token from Authorization header or cookie +String? _extractToken(Request request) { + // Try Authorization header first + final authHeader = request.headers['authorization']; + if (authHeader != null && authHeader.startsWith('Bearer ')) { + return authHeader.substring(7); + } + + // Try cookie + final cookie = request.headers['cookie']; + if (cookie != null) { + final parts = cookie.split(';'); + for (final part in parts) { + final trimmed = part.trim(); + if (trimmed.startsWith('session=')) { + return trimmed.substring(8); + } + } + } + + return null; +} diff --git a/bin/models/playlist.dart b/bin/models/playlist.dart new file mode 100644 index 0000000..2022e15 --- /dev/null +++ b/bin/models/playlist.dart @@ -0,0 +1,65 @@ +class Playlist { + final String id; + final String userId; + final String name; + final String serverUrl; + final String username; + final String password; + final String? dns; + final DateTime createdAt; + final DateTime updatedAt; + + Playlist({ + required this.id, + required this.userId, + required this.name, + required this.serverUrl, + required this.username, + required this.password, + this.dns, + required this.createdAt, + required this.updatedAt, + }); + + factory Playlist.fromMap(Map map) { + return Playlist( + id: map['id'] as String, + userId: map['user_id'] as String, + name: map['name'] as String, + serverUrl: map['server_url'] as String, + username: map['username'] as String, + password: map['password'] as String, + dns: map['dns'] as String?, + createdAt: DateTime.parse(map['created_at'] as String), + updatedAt: DateTime.parse(map['updated_at'] as String), + ); + } + + Map toJson() { + return { + 'id': id, + 'userId': userId, + 'name': name, + 'serverUrl': serverUrl, + 'username': username, + 'password': password, + 'dns': dns, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + }; + } + + Map toMap() { + return { + 'id': id, + 'user_id': userId, + 'name': name, + 'server_url': serverUrl, + 'username': username, + 'password': password, + 'dns': dns, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } +} diff --git a/bin/models/session.dart b/bin/models/session.dart new file mode 100644 index 0000000..bb057b9 --- /dev/null +++ b/bin/models/session.dart @@ -0,0 +1,37 @@ +class Session { + final String id; + final String userId; + final String token; + final DateTime expiresAt; + final DateTime createdAt; + + Session({ + required this.id, + required this.userId, + required this.token, + required this.expiresAt, + required this.createdAt, + }); + + factory Session.fromMap(Map map) { + return Session( + id: map['id'] as String, + userId: map['user_id'] as String, + token: map['token'] as String, + expiresAt: DateTime.parse(map['expires_at'] as String), + createdAt: DateTime.parse(map['created_at'] as String), + ); + } + + Map toMap() { + return { + 'id': id, + 'user_id': userId, + 'token': token, + 'expires_at': expiresAt.toIso8601String(), + 'created_at': createdAt.toIso8601String(), + }; + } + + bool get isExpired => DateTime.now().isAfter(expiresAt); +} diff --git a/bin/models/user.dart b/bin/models/user.dart new file mode 100644 index 0000000..84bbe49 --- /dev/null +++ b/bin/models/user.dart @@ -0,0 +1,31 @@ +class User { + final String id; + final String username; + final bool isAdmin; + final DateTime createdAt; + + User({ + required this.id, + required this.username, + required this.isAdmin, + required this.createdAt, + }); + + factory User.fromMap(Map map) { + return User( + id: map['id'] as String, + username: map['username'] as String, + isAdmin: (map['is_admin'] as int) == 1, + createdAt: DateTime.parse(map['created_at'] as String), + ); + } + + Map toJson() { + return { + 'id': id, + 'username': username, + 'isAdmin': isAdmin, + 'createdAt': createdAt.toIso8601String(), + }; + } +} diff --git a/bin/pubspec.yaml b/bin/pubspec.yaml index 19b1181..ad951e4 100644 --- a/bin/pubspec.yaml +++ b/bin/pubspec.yaml @@ -9,5 +9,9 @@ environment: dependencies: shelf: ^1.4.1 shelf_static: ^1.1.2 + shelf_router: ^1.1.4 http: ^1.2.0 args: ^2.4.2 + sqlite3: ^2.4.0 + crypto: ^3.0.3 + uuid: ^4.3.3 diff --git a/bin/server.dart b/bin/server.dart index 2d8cf84..797003e 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -1,9 +1,15 @@ import 'dart:io'; +import 'dart:async'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_static/shelf_static.dart'; +import 'package:shelf_router/shelf_router.dart'; import 'package:http/http.dart' as http; import 'package:args/args.dart'; +import 'database/database.dart'; +import 'api/auth_handler.dart'; +import 'api/playlists_handler.dart'; +import 'middleware/auth_middleware.dart'; void main(List args) async { // Parse command line arguments @@ -15,6 +21,24 @@ void main(List args) async { final port = int.parse(result['port']); final webPath = result['path']; + // Initialize database + final db = AppDatabase(); + await db.init(); + await db.seedAdmin(); + + // Create API handlers + final authHandler = AuthHandler(db); + final playlistsHandler = PlaylistsHandler(db); + + // Setup router + final apiRouter = Router() + // Auth endpoints (no auth middleware) - full path including /api/ + ..mount('/api/auth', authHandler.router) + // Playlists endpoints (with auth middleware) + ..mount('/api/playlists', Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler(playlistsHandler.router.call)); + // Create handlers final staticHandler = createStaticHandler( webPath, @@ -24,7 +48,8 @@ void main(List args) async { // Main handler with API proxy final handler = Cascade() - .add(_createApiProxyHandler()) + .add(_createApiHandler(apiRouter)) + .add(_createXtreamProxyHandler()) .add(staticHandler) .handler; @@ -43,7 +68,28 @@ void main(List args) async { print('Server started on port ${server.port}'); print('Serving static files from: $webPath'); - print('API proxy available at: /api/xtream/*'); + print('REST API available at: /api/auth/* and /api/playlists/*'); + print('Xtream proxy available at: /api/xtream/*'); + + // Clean expired sessions periodically (every hour) + Timer.periodic(const Duration(hours: 1), (_) { + db.cleanExpiredSessions(); + print('Cleaned expired sessions'); + }); +} + +/// Create API handler +Handler _createApiHandler(Router apiRouter) { + return (Request request) async { + final path = request.url.path; + + // Only handle /api/* requests (excluding /api/xtream) + if (path.startsWith('api/') && !path.startsWith('api/xtream/')) { + return apiRouter(request); + } + + return Response.notFound('Not found'); + }; } /// CORS middleware to allow cross-origin requests @@ -68,8 +114,8 @@ final _corsHeaders = { 'Access-Control-Allow-Headers': 'Origin, Content-Type, Accept, Authorization', }; -/// Create API proxy handler -Handler _createApiProxyHandler() { +/// Create Xtream proxy handler +Handler _createXtreamProxyHandler() { return (Request request) async { final path = request.url.path; diff --git a/bin/utils/password_hasher.dart b/bin/utils/password_hasher.dart new file mode 100644 index 0000000..ba4d109 --- /dev/null +++ b/bin/utils/password_hasher.dart @@ -0,0 +1,17 @@ +import 'dart:convert'; +import 'package:crypto/crypto.dart'; + +class PasswordHasher { + /// Hash a password using SHA256 (simple implementation) + /// Note: In production, use a proper bcrypt implementation + static String hash(String password) { + final bytes = utf8.encode(password); + final digest = sha256.convert(bytes); + return digest.toString(); + } + + /// Verify a password against a hash + static bool verify(String password, String hash) { + return PasswordHasher.hash(password) == hash; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index aa4f7fb..5ead088 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,16 @@ services: iptv-web: - build: - context: . - dockerfile: Dockerfile - container_name: xtremflow - restart: unless-stopped + build: . ports: - "8089:8089" networks: - nginx_default + volumes: + - xtremflow-data:/app/data + restart: unless-stopped + +volumes: + xtremflow-data: networks: nginx_default: diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart new file mode 100644 index 0000000..ec5436e --- /dev/null +++ b/lib/core/api/api_client.dart @@ -0,0 +1,83 @@ +import 'package:dio/dio.dart'; +import 'dart:html' as html; + +/// API Client for communicating with the backend +class ApiClient { + static final ApiClient _instance = ApiClient._internal(); + factory ApiClient() => _instance; + + late final Dio _dio; + String? _token; + + ApiClient._internal() { + _dio = Dio(BaseOptions( + baseUrl: _getBaseUrl(), + headers: {'Content-Type': 'application/json'}, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + + // Add interceptor for logging + _dio.interceptors.add(LogInterceptor( + requestBody: true, + responseBody: true, + )); + } + + /// Get base URL (same origin for production) + String _getBaseUrl() { + // Use current origin for API calls + return ''; + } + + /// Set authentication token + void setToken(String? token) { + _token = token; + if (token != null) { + _dio.options.headers['Authorization'] = 'Bearer $token'; + // Store in localStorage for persistence + html.window.localStorage['auth_token'] = token; + } else { + _dio.options.headers.remove('Authorization'); + html.window.localStorage.remove('auth_token'); + } + } + + /// Get stored token from localStorage + String? getStoredToken() { + return html.window.localStorage['auth_token']; + } + + /// Restore token from localStorage + void restoreToken() { + final storedToken = getStoredToken(); + if (storedToken != null) { + setToken(storedToken); + } + } + + /// Clear token + void clearToken() { + setToken(null); + } + + /// GET request + Future get(String path) async { + return _dio.get(path); + } + + /// POST request + Future post(String path, {dynamic data}) async { + return _dio.post(path, data: data); + } + + /// PUT request + Future put(String path, {dynamic data}) async { + return _dio.put(path, data: data); + } + + /// DELETE request + Future delete(String path) async { + return _dio.delete(path); + } +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 3fe8196..fa25cd4 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -9,15 +9,20 @@ import '../../features/admin/screens/admin_panel.dart'; import '../models/playlist_config.dart'; final routerProvider = Provider((ref) { - final authNotifier = ref.watch(authProvider.notifier); final authState = ref.watch(authProvider); return GoRouter( initialLocation: '/login', + refreshListenable: RouterRefreshNotifier(ref), redirect: (context, state) { final isLoggedIn = authState.isAuthenticated; final isLoginRoute = state.matchedLocation == '/login'; + // Wait for initial auth check to complete + if (!authState.isInitialized) { + return null; + } + // Redirect to login if not authenticated if (!isLoggedIn && !isLoginRoute) { return '/login'; @@ -67,3 +72,14 @@ final routerProvider = Provider((ref) { ], ); }); + +/// Notifier that triggers router refresh when auth state changes +class RouterRefreshNotifier extends ChangeNotifier { + RouterRefreshNotifier(this._ref) { + _ref.listen(authProvider, (_, __) { + notifyListeners(); + }); + } + + final Ref _ref; +} diff --git a/lib/core/services/auth_api_service.dart b/lib/core/services/auth_api_service.dart new file mode 100644 index 0000000..1d4fabe --- /dev/null +++ b/lib/core/services/auth_api_service.dart @@ -0,0 +1,98 @@ +import '../api/api_client.dart'; +import '../models/app_user.dart'; + +/// Service for authentication via API +class AuthApiService { + final ApiClient _api = ApiClient(); + + /// Login with username and password + Future login(String username, String password) async { + try { + final response = await _api.post('/api/auth/login', data: { + 'username': username, + 'password': password, + }); + + final data = response.data as Map; + + if (data['success'] == true) { + final token = data['token'] as String; + final userData = data['user'] as Map; + + _api.setToken(token); + + return AuthResult( + success: true, + user: AppUser( + id: userData['id'] as String, + username: userData['username'] as String, + passwordHash: '', // Not needed for API auth + isAdmin: userData['isAdmin'] as bool? ?? false, + createdAt: DateTime.now(), + ), + token: token, + ); + } else { + return AuthResult( + success: false, + error: data['error'] as String? ?? 'Login failed', + ); + } + } catch (e) { + return AuthResult( + success: false, + error: 'Network error: $e', + ); + } + } + + /// Logout + Future logout() async { + try { + await _api.post('/api/auth/logout'); + } finally { + _api.clearToken(); + } + } + + /// Get current user from token + Future getCurrentUser() async { + _api.restoreToken(); + + if (_api.getStoredToken() == null) { + return null; + } + + try { + final response = await _api.get('/api/auth/me'); + final data = response.data as Map; + final userData = data['user'] as Map; + + return AppUser( + id: userData['id'] as String, + username: userData['username'] as String, + passwordHash: '', + isAdmin: userData['isAdmin'] as bool? ?? false, + createdAt: DateTime.now(), + ); + } catch (e) { + _api.clearToken(); + return null; + } + } +} + +/// Result of authentication attempt +class AuthResult { + final bool success; + final AppUser? user; + final String? token; + final String? error; + + AuthResult({ + required this.success, + this.user, + this.token, + this.error, + }); +} diff --git a/lib/core/services/playlist_api_service.dart b/lib/core/services/playlist_api_service.dart new file mode 100644 index 0000000..4227d67 --- /dev/null +++ b/lib/core/services/playlist_api_service.dart @@ -0,0 +1,116 @@ +import '../api/api_client.dart'; +import '../models/playlist_config.dart'; + +/// Service for managing playlists via API +class PlaylistApiService { + final ApiClient _api = ApiClient(); + + /// Get all playlists for current user + Future> getPlaylists() async { + try { + final response = await _api.get('/api/playlists'); + final data = response.data as Map; + final playlistsData = data['playlists'] as List; + + return playlistsData.map((p) { + final playlist = p as Map; + return PlaylistConfig( + id: playlist['id'] as String, + name: playlist['name'] as String, + dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String, + username: playlist['username'] as String, + password: playlist['password'] as String, + createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(), + ); + }).toList(); + } catch (e) { + print('Error fetching playlists: $e'); + return []; + } + } + + /// Create a new playlist + Future createPlaylist({ + required String name, + required String dns, + required String username, + required String password, + }) async { + try { + final response = await _api.post('/api/playlists', data: { + 'name': name, + 'serverUrl': dns, + 'username': username, + 'password': password, + 'dns': dns, + }); + + final data = response.data as Map; + + if (data['success'] == true) { + final playlist = data['playlist'] as Map; + return PlaylistConfig( + id: playlist['id'] as String, + name: playlist['name'] as String, + dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String, + username: playlist['username'] as String, + password: playlist['password'] as String, + createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(), + ); + } + return null; + } catch (e) { + print('Error creating playlist: $e'); + return null; + } + } + + /// Update a playlist + Future updatePlaylist({ + required String id, + required String name, + required String dns, + required String username, + required String password, + }) async { + try { + final response = await _api.put('/api/playlists/$id', data: { + 'name': name, + 'serverUrl': dns, + 'username': username, + 'password': password, + 'dns': dns, + }); + + final data = response.data as Map; + + if (data['success'] == true) { + final playlist = data['playlist'] as Map; + return PlaylistConfig( + id: playlist['id'] as String, + name: playlist['name'] as String, + dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String, + username: playlist['username'] as String, + password: playlist['password'] as String, + createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(), + ); + } + return null; + } catch (e) { + print('Error updating playlist: $e'); + return null; + } + } + + /// Delete a playlist + Future deletePlaylist(String id) async { + try { + final response = await _api.delete('/api/playlists/$id'); + final data = response.data as Map; + return data['success'] == true; + } catch (e) { + print('Error deleting playlist: $e'); + return false; + } + } +} diff --git a/lib/features/auth/providers/auth_provider.dart b/lib/features/auth/providers/auth_provider.dart index 80b15dd..4933163 100644 --- a/lib/features/auth/providers/auth_provider.dart +++ b/lib/features/auth/providers/auth_provider.dart @@ -1,5 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/database/hive_service.dart'; +import '../../../core/services/auth_api_service.dart'; import '../../../core/models/app_user.dart'; /// Auth state @@ -7,11 +7,13 @@ class AuthState { final AppUser? currentUser; final bool isLoading; final String? errorMessage; + final bool isInitialized; const AuthState({ this.currentUser, this.isLoading = false, this.errorMessage, + this.isInitialized = false, }); bool get isAuthenticated => currentUser != null; @@ -21,52 +23,83 @@ class AuthState { AppUser? currentUser, bool? isLoading, String? errorMessage, + bool? isInitialized, + bool clearUser = false, }) { return AuthState( - currentUser: currentUser ?? this.currentUser, + currentUser: clearUser ? null : (currentUser ?? this.currentUser), isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage, + isInitialized: isInitialized ?? this.isInitialized, ); } } -/// Auth notifier +/// Auth notifier using API class AuthNotifier extends StateNotifier { - AuthNotifier() : super(const AuthState()); + final AuthApiService _authService = AuthApiService(); + + AuthNotifier() : super(const AuthState()) { + // Auto-check for existing session + checkSession(); + } + + /// Check if there's an existing valid session + Future checkSession() async { + state = state.copyWith(isLoading: true); + + try { + final user = await _authService.getCurrentUser(); + if (user != null) { + state = AuthState( + currentUser: user, + isLoading: false, + isInitialized: true, + ); + } else { + state = const AuthState(isInitialized: true); + } + } catch (e) { + state = const AuthState(isInitialized: true); + } + } /// Login with username and password Future login(String username, String password) async { state = state.copyWith(isLoading: true, errorMessage: null); try { - final usersBox = HiveService.usersBox; + final result = await _authService.login(username, password); - // Search for user by username - final user = usersBox.values.firstWhere( - (user) => user.username == username, - orElse: () => throw Exception('User not found'), - ); - - // Verify password using salt-based hashing - if (!HiveService.verifyPassword(password, user.passwordHash)) { - throw Exception('Invalid password'); + if (result.success && result.user != null) { + state = AuthState( + currentUser: result.user, + isLoading: false, + isInitialized: true, + ); + return true; + } else { + state = AuthState( + isLoading: false, + isInitialized: true, + errorMessage: result.error ?? 'Login failed', + ); + return false; } - - state = AuthState(currentUser: user, isLoading: false); - return true; } catch (e) { state = AuthState( isLoading: false, - errorMessage: 'Invalid username or password', + isInitialized: true, + errorMessage: 'Network error: $e', ); return false; } } - /// Logout current user - void logout() { - state = const AuthState(); + Future logout() async { + await _authService.logout(); + state = const AuthState(isInitialized: true); } /// Get current user diff --git a/lib/features/iptv/screens/playlist_selection_screen.dart b/lib/features/iptv/screens/playlist_selection_screen.dart index 94c2786..49e1c69 100644 --- a/lib/features/iptv/screens/playlist_selection_screen.dart +++ b/lib/features/iptv/screens/playlist_selection_screen.dart @@ -2,24 +2,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import '../../../core/database/hive_service.dart'; +import '../../../core/services/playlist_api_service.dart'; +import '../../../core/models/playlist_config.dart'; import '../../auth/providers/auth_provider.dart'; +/// Provider for fetching playlists from API +final playlistsProvider = FutureProvider>((ref) async { + final service = PlaylistApiService(); + return service.getPlaylists(); +}); + class PlaylistSelectionScreen extends ConsumerWidget { const PlaylistSelectionScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final currentUser = ref.watch(authProvider).currentUser; - final playlistsBox = HiveService.playlistsBox; - - // Filter playlists assigned to current user (or all if admin) - final availablePlaylists = playlistsBox.values.where((playlist) { - if (currentUser == null) return false; - if (currentUser.isAdmin) return playlist.isActive; - return currentUser.assignedPlaylistIds.contains(playlist.id) && - playlist.isActive; - }).toList(); + final playlistsAsync = ref.watch(playlistsProvider); return Scaffold( appBar: AppBar( @@ -36,16 +35,36 @@ class PlaylistSelectionScreen extends ConsumerWidget { ), IconButton( icon: const Icon(Icons.logout), - onPressed: () { - ref.read(authProvider.notifier).logout(); - context.go('/login'); + onPressed: () async { + await ref.read(authProvider.notifier).logout(); + if (context.mounted) { + context.go('/login'); + } }, tooltip: 'Logout', ), ], ), - body: availablePlaylists.isEmpty - ? Center( + body: playlistsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: Colors.red.shade400), + const SizedBox(height: 16), + Text('Error loading playlists', style: GoogleFonts.roboto(fontSize: 18)), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () => ref.refresh(playlistsProvider), + child: const Text('Retry'), + ), + ], + ), + ), + data: (playlists) { + if (playlists.isEmpty) { + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -74,32 +93,36 @@ class PlaylistSelectionScreen extends ConsumerWidget { ), ], ), - ) - : GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - childAspectRatio: 1.5, - ), - itemCount: availablePlaylists.length, - itemBuilder: (context, index) { - final playlist = availablePlaylists[index]; - return _PlaylistCard( - playlist: playlist, - onTap: () { - context.go('/dashboard', extra: playlist); - }, - ); - }, + ); + } + + return GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 1.5, ), + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[index]; + return _PlaylistCard( + playlist: playlist, + onTap: () { + context.go('/dashboard', extra: playlist); + }, + ); + }, + ); + }, + ), ); } } class _PlaylistCard extends StatelessWidget { - final dynamic playlist; + final PlaylistConfig playlist; final VoidCallback onTap; const _PlaylistCard({ @@ -151,7 +174,7 @@ class _PlaylistCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - Uri.parse(playlist.dns).host, + Uri.tryParse(playlist.dns)?.host ?? playlist.dns, style: GoogleFonts.roboto( fontSize: 12, color: Colors.white.withOpacity(0.7), diff --git a/lib/main.dart b/lib/main.dart index dca650c..f5745bd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'core/database/hive_service.dart'; import 'core/router/app_router.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Initialize Hive database with encryption - await HiveService.init(); + // Note: Hive is no longer needed for auth - using API backend now runApp(const ProviderScope(child: MyApp())); } @@ -48,7 +46,7 @@ class MyApp extends ConsumerWidget { ), ), ), - themeMode: ThemeMode.dark, // Default to dark mode for IPTV + themeMode: ThemeMode.system, routerConfig: router, ); }