feat: Add initial backend server with user authentication, session management, and playlist CRUD functionality.

This commit is contained in:
Michael committed 2025-12-06 01:51:07 +01:00
1 parent c78012dbdd
commit 03edbfefb7
19 files changed
+1307 -58

No files matched your search

+6 -4
View File
@@ -36,12 +36,14 @@ FROM dart:stable
WORKDIR /app WORKDIR /app
# Copy server code and pubspec # Install SQLite3 library for FFI
COPY bin/server.dart ./bin/ RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev && rm -rf /var/lib/apt/lists/*
COPY bin/pubspec.yaml ./
# Copy entire bin directory (API, database, etc.)
COPY bin/ ./bin/
# Get dependencies # Get dependencies
RUN dart pub get RUN dart pub get --directory=bin
# Copy built web application from builder stage # Copy built web application from builder stage
COPY --from=builder /app/build/web /app/web COPY --from=builder /app/build/web /app/web
+144
View File
@@ -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<Response> _login(Request request) async {
try {
final payload = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
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<Response> _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<Response> _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;
}
}
+186
View File
@@ -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('/<id>', _updatePlaylist);
router.delete('/<id>', _deletePlaylist);
return router;
}
/// GET /api/playlists
Future<Response> _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<Response> _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<String, dynamic>;
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<Response> _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<String, dynamic>;
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<Response> _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'},
);
}
}
}
+291
View File
@@ -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<void> 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<void> _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<void> 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<Playlist> 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();
}
}
+57
View File
@@ -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;
}
+65
View File
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'name': name,
'serverUrl': serverUrl,
'username': username,
'password': password,
'dns': dns,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
Map<String, dynamic> 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(),
};
}
}
+37
View File
@@ -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<String, dynamic> 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<String, dynamic> toMap() {
return {
'id': id,
'user_id': userId,
'token': token,
'expires_at': expiresAt.toIso8601String(),
'created_at': createdAt.toIso8601String(),
};
}
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
+31
View File
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'isAdmin': isAdmin,
'createdAt': createdAt.toIso8601String(),
};
}
}
+4
View File
@@ -9,5 +9,9 @@ environment:
dependencies: dependencies:
shelf: ^1.4.1 shelf: ^1.4.1
shelf_static: ^1.1.2 shelf_static: ^1.1.2
shelf_router: ^1.1.4
http: ^1.2.0 http: ^1.2.0
args: ^2.4.2 args: ^2.4.2
sqlite3: ^2.4.0
crypto: ^3.0.3
uuid: ^4.3.3
+50 -4
View File
@@ -1,9 +1,15 @@
import 'dart:io'; import 'dart:io';
import 'dart:async';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_static/shelf_static.dart'; import 'package:shelf_static/shelf_static.dart';
import 'package:shelf_router/shelf_router.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:args/args.dart'; 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<String> args) async { void main(List<String> args) async {
// Parse command line arguments // Parse command line arguments
@@ -15,6 +21,24 @@ void main(List<String> args) async {
final port = int.parse(result['port']); final port = int.parse(result['port']);
final webPath = result['path']; 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 // Create handlers
final staticHandler = createStaticHandler( final staticHandler = createStaticHandler(
webPath, webPath,
@@ -24,7 +48,8 @@ void main(List<String> args) async {
// Main handler with API proxy // Main handler with API proxy
final handler = Cascade() final handler = Cascade()
.add(_createApiProxyHandler()) .add(_createApiHandler(apiRouter))
.add(_createXtreamProxyHandler())
.add(staticHandler) .add(staticHandler)
.handler; .handler;
@@ -43,7 +68,28 @@ void main(List<String> args) async {
print('Server started on port ${server.port}'); print('Server started on port ${server.port}');
print('Serving static files from: $webPath'); 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 /// CORS middleware to allow cross-origin requests
@@ -68,8 +114,8 @@ final _corsHeaders = {
'Access-Control-Allow-Headers': 'Origin, Content-Type, Accept, Authorization', 'Access-Control-Allow-Headers': 'Origin, Content-Type, Accept, Authorization',
}; };
/// Create API proxy handler /// Create Xtream proxy handler
Handler _createApiProxyHandler() { Handler _createXtreamProxyHandler() {
return (Request request) async { return (Request request) async {
final path = request.url.path; final path = request.url.path;
+17
View File
@@ -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;
}
}
+7 -5
View File
@@ -1,14 +1,16 @@
services: services:
iptv-web: iptv-web:
build: build: .
context: .
dockerfile: Dockerfile
container_name: xtremflow
restart: unless-stopped
ports: ports:
- "8089:8089" - "8089:8089"
networks: networks:
- nginx_default - nginx_default
volumes:
- xtremflow-data:/app/data
restart: unless-stopped
volumes:
xtremflow-data:
networks: networks:
nginx_default: nginx_default:
+83
View File
@@ -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<Response> get(String path) async {
return _dio.get(path);
}
/// POST request
Future<Response> post(String path, {dynamic data}) async {
return _dio.post(path, data: data);
}
/// PUT request
Future<Response> put(String path, {dynamic data}) async {
return _dio.put(path, data: data);
}
/// DELETE request
Future<Response> delete(String path) async {
return _dio.delete(path);
}
}
+17 -1
View File
@@ -9,15 +9,20 @@ import '../../features/admin/screens/admin_panel.dart';
import '../models/playlist_config.dart'; import '../models/playlist_config.dart';
final routerProvider = Provider<GoRouter>((ref) { final routerProvider = Provider<GoRouter>((ref) {
final authNotifier = ref.watch(authProvider.notifier);
final authState = ref.watch(authProvider); final authState = ref.watch(authProvider);
return GoRouter( return GoRouter(
initialLocation: '/login', initialLocation: '/login',
refreshListenable: RouterRefreshNotifier(ref),
redirect: (context, state) { redirect: (context, state) {
final isLoggedIn = authState.isAuthenticated; final isLoggedIn = authState.isAuthenticated;
final isLoginRoute = state.matchedLocation == '/login'; final isLoginRoute = state.matchedLocation == '/login';
// Wait for initial auth check to complete
if (!authState.isInitialized) {
return null;
}
// Redirect to login if not authenticated // Redirect to login if not authenticated
if (!isLoggedIn && !isLoginRoute) { if (!isLoggedIn && !isLoginRoute) {
return '/login'; return '/login';
@@ -67,3 +72,14 @@ final routerProvider = Provider<GoRouter>((ref) {
], ],
); );
}); });
/// Notifier that triggers router refresh when auth state changes
class RouterRefreshNotifier extends ChangeNotifier {
RouterRefreshNotifier(this._ref) {
_ref.listen(authProvider, (_, __) {
notifyListeners();
});
}
final Ref _ref;
}
+98
View File
@@ -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<AuthResult> 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<String, dynamic>;
if (data['success'] == true) {
final token = data['token'] as String;
final userData = data['user'] as Map<String, dynamic>;
_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<void> logout() async {
try {
await _api.post('/api/auth/logout');
} finally {
_api.clearToken();
}
}
/// Get current user from token
Future<AppUser?> 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<String, dynamic>;
final userData = data['user'] as Map<String, dynamic>;
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,
});
}
+116
View File
@@ -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<List<PlaylistConfig>> getPlaylists() async {
try {
final response = await _api.get('/api/playlists');
final data = response.data as Map<String, dynamic>;
final playlistsData = data['playlists'] as List<dynamic>;
return playlistsData.map((p) {
final playlist = p as Map<String, dynamic>;
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<PlaylistConfig?> 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<String, dynamic>;
if (data['success'] == true) {
final playlist = data['playlist'] as Map<String, dynamic>;
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<PlaylistConfig?> 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<String, dynamic>;
if (data['success'] == true) {
final playlist = data['playlist'] as Map<String, dynamic>;
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<bool> deletePlaylist(String id) async {
try {
final response = await _api.delete('/api/playlists/$id');
final data = response.data as Map<String, dynamic>;
return data['success'] == true;
} catch (e) {
print('Error deleting playlist: $e');
return false;
}
}
}
+53 -20
View File
@@ -1,5 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; 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'; import '../../../core/models/app_user.dart';
/// Auth state /// Auth state
@@ -7,11 +7,13 @@ class AuthState {
final AppUser? currentUser; final AppUser? currentUser;
final bool isLoading; final bool isLoading;
final String? errorMessage; final String? errorMessage;
final bool isInitialized;
const AuthState({ const AuthState({
this.currentUser, this.currentUser,
this.isLoading = false, this.isLoading = false,
this.errorMessage, this.errorMessage,
this.isInitialized = false,
}); });
bool get isAuthenticated => currentUser != null; bool get isAuthenticated => currentUser != null;
@@ -21,52 +23,83 @@ class AuthState {
AppUser? currentUser, AppUser? currentUser,
bool? isLoading, bool? isLoading,
String? errorMessage, String? errorMessage,
bool? isInitialized,
bool clearUser = false,
}) { }) {
return AuthState( return AuthState(
currentUser: currentUser ?? this.currentUser, currentUser: clearUser ? null : (currentUser ?? this.currentUser),
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage, errorMessage: errorMessage,
isInitialized: isInitialized ?? this.isInitialized,
); );
} }
} }
/// Auth notifier /// Auth notifier using API
class AuthNotifier extends StateNotifier<AuthState> { class AuthNotifier extends StateNotifier<AuthState> {
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<void> 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 /// Login with username and password
Future<bool> login(String username, String password) async { Future<bool> login(String username, String password) async {
state = state.copyWith(isLoading: true, errorMessage: null); state = state.copyWith(isLoading: true, errorMessage: null);
try { try {
final usersBox = HiveService.usersBox; final result = await _authService.login(username, password);
// Search for user by username if (result.success && result.user != null) {
final user = usersBox.values.firstWhere( state = AuthState(
(user) => user.username == username, currentUser: result.user,
orElse: () => throw Exception('User not found'), isLoading: false,
isInitialized: true,
); );
// Verify password using salt-based hashing
if (!HiveService.verifyPassword(password, user.passwordHash)) {
throw Exception('Invalid password');
}
state = AuthState(currentUser: user, isLoading: false);
return true; return true;
} else {
state = AuthState(
isLoading: false,
isInitialized: true,
errorMessage: result.error ?? 'Login failed',
);
return false;
}
} catch (e) { } catch (e) {
state = AuthState( state = AuthState(
isLoading: false, isLoading: false,
errorMessage: 'Invalid username or password', isInitialized: true,
errorMessage: 'Network error: $e',
); );
return false; return false;
} }
} }
/// Logout current user /// Logout current user
void logout() { Future<void> logout() async {
state = const AuthState(); await _authService.logout();
state = const AuthState(isInitialized: true);
} }
/// Get current user /// Get current user
@@ -2,24 +2,23 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.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'; import '../../auth/providers/auth_provider.dart';
/// Provider for fetching playlists from API
final playlistsProvider = FutureProvider<List<PlaylistConfig>>((ref) async {
final service = PlaylistApiService();
return service.getPlaylists();
});
class PlaylistSelectionScreen extends ConsumerWidget { class PlaylistSelectionScreen extends ConsumerWidget {
const PlaylistSelectionScreen({super.key}); const PlaylistSelectionScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentUser = ref.watch(authProvider).currentUser; final currentUser = ref.watch(authProvider).currentUser;
final playlistsBox = HiveService.playlistsBox; final playlistsAsync = ref.watch(playlistsProvider);
// 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();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -36,16 +35,36 @@ class PlaylistSelectionScreen extends ConsumerWidget {
), ),
IconButton( IconButton(
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
onPressed: () { onPressed: () async {
ref.read(authProvider.notifier).logout(); await ref.read(authProvider.notifier).logout();
if (context.mounted) {
context.go('/login'); context.go('/login');
}
}, },
tooltip: 'Logout', tooltip: 'Logout',
), ),
], ],
), ),
body: availablePlaylists.isEmpty body: playlistsAsync.when(
? Center( 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( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -74,8 +93,10 @@ class PlaylistSelectionScreen extends ConsumerWidget {
), ),
], ],
), ),
) );
: GridView.builder( }
return GridView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisCount: 3,
@@ -83,9 +104,9 @@ class PlaylistSelectionScreen extends ConsumerWidget {
mainAxisSpacing: 16, mainAxisSpacing: 16,
childAspectRatio: 1.5, childAspectRatio: 1.5,
), ),
itemCount: availablePlaylists.length, itemCount: playlists.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final playlist = availablePlaylists[index]; final playlist = playlists[index];
return _PlaylistCard( return _PlaylistCard(
playlist: playlist, playlist: playlist,
onTap: () { onTap: () {
@@ -93,13 +114,15 @@ class PlaylistSelectionScreen extends ConsumerWidget {
}, },
); );
}, },
);
},
), ),
); );
} }
} }
class _PlaylistCard extends StatelessWidget { class _PlaylistCard extends StatelessWidget {
final dynamic playlist; final PlaylistConfig playlist;
final VoidCallback onTap; final VoidCallback onTap;
const _PlaylistCard({ const _PlaylistCard({
@@ -151,7 +174,7 @@ class _PlaylistCard extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
Uri.parse(playlist.dns).host, Uri.tryParse(playlist.dns)?.host ?? playlist.dns,
style: GoogleFonts.roboto( style: GoogleFonts.roboto(
fontSize: 12, fontSize: 12,
color: Colors.white.withOpacity(0.7), color: Colors.white.withOpacity(0.7),
+2 -4
View File
@@ -1,13 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/database/hive_service.dart';
import 'core/router/app_router.dart'; import 'core/router/app_router.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Initialize Hive database with encryption // Note: Hive is no longer needed for auth - using API backend now
await HiveService.init();
runApp(const ProviderScope(child: MyApp())); 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, routerConfig: router,
); );
} }