mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-18 12:49:54 +02:00
feat: Add initial backend server with user authentication, session management, and playlist CRUD functionality.
This commit is contained in:
1 parent
a1ce183cd3
commit
ac4f274031
19 files changed
+1324
-75
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user