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
+1307
-58
No files matched your search
+6
-4
@@ -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
|
||||||
|
|||||||
@@ -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:
|
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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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:
|
||||||
|
|||||||
@@ -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';
|
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;
|
||||||
|
}
|
||||||
@@ -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 '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
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user