import { Database } from "bun:sqlite"; const DB_PATH = "./musicroom.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) ) `); // Content-addressed tracks table db.run(` CREATE TABLE IF NOT EXISTS tracks ( id TEXT PRIMARY KEY, title TEXT, artist TEXT, album TEXT, duration REAL NOT NULL, size INTEGER NOT NULL, created_at INTEGER DEFAULT (unixepoch()) ) `); // 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 // Track functions export function upsertTrack(track: Omit): void { db.query(` INSERT INTO tracks (id, title, artist, album, duration, size) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = COALESCE(excluded.title, title), artist = COALESCE(excluded.artist, artist), album = COALESCE(excluded.album, album) `).run(track.id, track.title, track.artist, track.album, track.duration, track.size); } export function getTrack(id: string): Track | null { return db.query("SELECT * FROM tracks WHERE id = ?").get(id) as Track | null; } export function getAllTracks(): Track[] { return db.query("SELECT * FROM tracks ORDER BY title").all() as Track[]; }