import { Database } from "bun:sqlite"; const DB_PATH = "./blastoise.db"; const SESSION_EXPIRY_DAYS = 7; const GUEST_SESSION_EXPIRY_HOURS = 24; const db = new Database(DB_PATH); // Initialize tables db.run(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER DEFAULT 0, is_guest INTEGER DEFAULT 0, created_at INTEGER DEFAULT (unixepoch()) ) `); // Migration: add is_guest column if it doesn't exist try { db.run(`ALTER TABLE users ADD COLUMN is_guest INTEGER DEFAULT 0`); } catch {} db.run(` CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER DEFAULT (unixepoch()), user_agent TEXT, ip_address TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `); // Migration: add columns if they don't exist try { db.run(`ALTER TABLE sessions ADD COLUMN user_agent TEXT`); } catch {} try { db.run(`ALTER TABLE sessions ADD COLUMN ip_address TEXT`); } catch {} db.run(` CREATE TABLE IF NOT EXISTS permissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT, permission TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE(user_id, resource_type, resource_id, permission) ) `); // Types export interface User { id: number; username: string; password_hash: string; is_admin: boolean; is_guest: boolean; created_at: number; } export interface Session { token: string; user_id: number; expires_at: number; created_at: number; user_agent: string | null; ip_address: string | null; } export interface Permission { id: number; user_id: number; resource_type: string; resource_id: string | null; permission: string; } export interface Track { id: string; title: string | null; artist: string | null; album: string | null; duration: number; size: number; created_at: number; } // User functions export async function createUser(username: string, password: string): Promise { const password_hash = await Bun.password.hash(password); // First user becomes admin const userCount = db.query("SELECT COUNT(*) as count FROM users WHERE is_guest = 0").get() as { count: number }; const is_admin = userCount.count === 0 ? 1 : 0; const result = db.query( "INSERT INTO users (username, password_hash, is_admin, is_guest) VALUES (?, ?, ?, 0) RETURNING *" ).get(username, password_hash, is_admin) as any; return { ...result, is_admin: !!result.is_admin, is_guest: false }; } export function createGuestUser(ipAddress: string, userAgent: string): User { const guestId = crypto.randomUUID().slice(0, 8); const username = `guest_${guestId}`; const password_hash = ""; // No password for guests const result = db.query( "INSERT INTO users (username, password_hash, is_admin, is_guest) VALUES (?, ?, 0, 1) RETURNING *" ).get(username, password_hash) as any; return { ...result, is_admin: false, is_guest: true }; } export function findUserById(id: number): User | null { const result = db.query("SELECT * FROM users WHERE id = ?").get(id) as any; if (!result) return null; return { ...result, is_admin: !!result.is_admin, is_guest: !!result.is_guest }; } export function findUserByUsername(username: string): User | null { const result = db.query("SELECT * FROM users WHERE username = ?").get(username) as any; if (!result) return null; return { ...result, is_admin: !!result.is_admin, is_guest: !!result.is_guest }; } export async function validatePassword(user: User, password: string): Promise { return Bun.password.verify(password, user.password_hash); } // Session functions export function createSession(userId: number, userAgent?: string, ipAddress?: string, isGuest: boolean = false): string { const token = crypto.randomUUID(); const expirySeconds = isGuest ? GUEST_SESSION_EXPIRY_HOURS * 60 * 60 : SESSION_EXPIRY_DAYS * 24 * 60 * 60; const expires_at = Math.floor(Date.now() / 1000) + expirySeconds; db.query("INSERT INTO sessions (token, user_id, expires_at, user_agent, ip_address) VALUES (?, ?, ?, ?, ?)") .run(token, userId, expires_at, userAgent ?? null, ipAddress ?? null); return token; } export function createGuestSession(userAgent?: string, ipAddress?: string): { user: User, token: string } { const user = createGuestUser(ipAddress ?? "unknown", userAgent ?? "unknown"); const token = createSession(user.id, userAgent, ipAddress, true); return { user, token }; } export function validateSession(token: string, currentUserAgent?: string, currentIpAddress?: string): User | null { const now = Math.floor(Date.now() / 1000); const session = db.query( "SELECT * FROM sessions WHERE token = ? AND expires_at > ?" ).get(token, now) as Session | null; if (!session) return null; const user = findUserById(session.user_id); if (!user) return null; // Invalidate if BOTH ip and user agent changed (potential session hijack) if (currentUserAgent && currentIpAddress && session.user_agent && session.ip_address) { const ipChanged = session.ip_address !== currentIpAddress; const uaChanged = session.user_agent !== currentUserAgent; if (ipChanged && uaChanged) { console.log(`[AUTH] Session invalidated (ip+ua changed): session=${token} old_ip=${session.ip_address} new_ip=${currentIpAddress}`); deleteSession(token); return null; } } // Sliding expiration - extend on each use const expirySeconds = user.is_guest ? GUEST_SESSION_EXPIRY_HOURS * 60 * 60 : SESSION_EXPIRY_DAYS * 24 * 60 * 60; const newExpiry = now + expirySeconds; db.query("UPDATE sessions SET expires_at = ? WHERE token = ?").run(newExpiry, token); return findUserById(session.user_id); } export function deleteSession(token: string): void { db.query("DELETE FROM sessions WHERE token = ?").run(token); } export function deleteExpiredSessions(): void { const now = Math.floor(Date.now() / 1000); db.query("DELETE FROM sessions WHERE expires_at <= ?").run(now); } // Permission functions export function hasPermission( userId: number, resourceType: string, resourceId: string | null, permission: string ): boolean { const user = findUserById(userId); if (!user) return false; if (user.is_admin) return true; const result = db.query(` SELECT 1 FROM permissions WHERE user_id = ? AND resource_type = ? AND (resource_id = ? OR resource_id IS NULL) AND permission = ? LIMIT 1 `).get(userId, resourceType, resourceId, permission); return !!result; } export function grantPermission( userId: number, resourceType: string, resourceId: string | null, permission: string ): void { db.query(` INSERT OR IGNORE INTO permissions (user_id, resource_type, resource_id, permission) VALUES (?, ?, ?, ?) `).run(userId, resourceType, resourceId, permission); } export function revokePermission( userId: number, resourceType: string, resourceId: string | null, permission: string ): void { db.query(` DELETE FROM permissions WHERE user_id = ? AND resource_type = ? AND resource_id IS ? AND permission = ? `).run(userId, resourceType, resourceId, permission); } export function getUserPermissions(userId: number): Permission[] { return db.query("SELECT * FROM permissions WHERE user_id = ?").all(userId) as Permission[]; } export function getAllUsers(): Omit[] { const users = db.query("SELECT id, username, is_admin, is_guest, created_at FROM users WHERE is_guest = 0").all() as any[]; return users.map(u => ({ ...u, is_admin: !!u.is_admin, is_guest: false })); } export function getUserSessions(userId: number): Omit[] { return db.query( "SELECT user_id, expires_at, created_at, user_agent, ip_address FROM sessions WHERE user_id = ? AND expires_at > ?" ).all(userId, Math.floor(Date.now() / 1000)) as Omit[]; } // Cleanup expired sessions periodically setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour // Channel tables db.run(` CREATE TABLE IF NOT EXISTS channels ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT DEFAULT '', created_by INTEGER, is_default INTEGER DEFAULT 0, current_index INTEGER DEFAULT 0, started_at INTEGER DEFAULT (unixepoch() * 1000), paused INTEGER DEFAULT 0, paused_at REAL DEFAULT 0, playback_mode TEXT DEFAULT 'repeat-all', created_at INTEGER DEFAULT (unixepoch()), FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) `); db.run(` CREATE TABLE IF NOT EXISTS channel_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT NOT NULL, track_id TEXT NOT NULL, position INTEGER NOT NULL, FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, UNIQUE(channel_id, position) ) `); // Create index for faster queue lookups db.run(`CREATE INDEX IF NOT EXISTS idx_channel_queue_channel ON channel_queue(channel_id)`); // Migration: add playback_mode column to channels try { db.run(`ALTER TABLE channels ADD COLUMN playback_mode TEXT DEFAULT 'repeat-all'`); } catch {} // Channel types export interface ChannelRow { id: string; name: string; description: string; created_by: number | null; is_default: number; current_index: number; started_at: number; paused: number; paused_at: number; playback_mode: string; created_at: number; } export interface ChannelQueueRow { id: number; channel_id: string; track_id: string; position: number; } // Channel CRUD functions export function saveChannel(channel: { id: string; name: string; description: string; createdBy: number | null; isDefault: boolean; currentIndex: number; startedAt: number; paused: boolean; pausedAt: number; playbackMode: string; }): void { db.query(` INSERT INTO channels (id, name, description, created_by, is_default, current_index, started_at, paused, paused_at, playback_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, current_index = excluded.current_index, started_at = excluded.started_at, paused = excluded.paused, paused_at = excluded.paused_at, playback_mode = excluded.playback_mode `).run( channel.id, channel.name, channel.description, channel.createdBy, channel.isDefault ? 1 : 0, channel.currentIndex, channel.startedAt, channel.paused ? 1 : 0, channel.pausedAt, channel.playbackMode ); } export function updateChannelState(channelId: string, state: { currentIndex: number; startedAt: number; paused: boolean; pausedAt: number; playbackMode: string; }): void { db.query(` UPDATE channels SET current_index = ?, started_at = ?, paused = ?, paused_at = ?, playback_mode = ? WHERE id = ? `).run(state.currentIndex, state.startedAt, state.paused ? 1 : 0, state.pausedAt, state.playbackMode, channelId); } export function loadChannel(id: string): ChannelRow | null { return db.query("SELECT * FROM channels WHERE id = ?").get(id) as ChannelRow | null; } export function loadAllChannels(): ChannelRow[] { return db.query("SELECT * FROM channels").all() as ChannelRow[]; } export function deleteChannelFromDb(id: string): void { db.query("DELETE FROM channels WHERE id = ?").run(id); } // Queue persistence functions export function saveChannelQueue(channelId: string, trackIds: string[]): void { db.query("BEGIN").run(); try { db.query("DELETE FROM channel_queue WHERE channel_id = ?").run(channelId); const insert = db.query( "INSERT INTO channel_queue (channel_id, track_id, position) VALUES (?, ?, ?)" ); for (let i = 0; i < trackIds.length; i++) { insert.run(channelId, trackIds[i], i); } db.query("COMMIT").run(); } catch (e) { db.query("ROLLBACK").run(); throw e; } } export function loadChannelQueue(channelId: string): string[] { const rows = db.query( "SELECT track_id FROM channel_queue WHERE channel_id = ? ORDER BY position" ).all(channelId) as { track_id: string }[]; return rows.map(r => r.track_id); } export function removeTrackFromQueues(trackId: string): void { db.query("DELETE FROM channel_queue WHERE track_id = ?").run(trackId); }