mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
feat: Add initial backend server with user authentication, session management, and playlist CRUD functionality.
This commit is contained in:
1 parent
c78012dbdd
commit
03edbfefb7
19 files changed
+1324
-75
No files matched your search
+6
-4
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+50
-4
@@ -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<String> args) async {
|
||||
// Parse command line arguments
|
||||
@@ -15,6 +21,24 @@ void main(List<String> 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<String> 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<String> 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;
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,20 @@ import '../../features/admin/screens/admin_panel.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
|
||||
final routerProvider = Provider<GoRouter>((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<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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
Future<bool> 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<void> logout() async {
|
||||
await _authService.logout();
|
||||
state = const AuthState(isInitialized: true);
|
||||
}
|
||||
|
||||
/// Get current user
|
||||
|
||||
@@ -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<List<PlaylistConfig>>((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),
|
||||
|
||||
+2
-4
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user