import { Database } from "bun:sqlite"; const DB_PATH = "./musicroom.db"; const SESSION_EXPIRY_DAYS = 7; 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, created_at INTEGER DEFAULT (unixepoch()) ) `); 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; 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; } // 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").get() as { count: number }; const is_admin = userCount.count === 0 ? 1 : 0; const result = db.query( "INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?) RETURNING *" ).get(username, password_hash, is_admin) as any; return { ...result, is_admin: !!result.is_admin }; } 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 }; } 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 }; } 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): string { const token = crypto.randomUUID(); const expires_at = Math.floor(Date.now() / 1000) + (SESSION_EXPIRY_DAYS * 24 * 60 * 60); 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 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; // 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 newExpiry = now + (SESSION_EXPIRY_DAYS * 24 * 60 * 60); 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, created_at FROM users").all() as any[]; return users.map(u => ({ ...u, is_admin: !!u.is_admin })); } 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