import { join } from 'node:path' import { mkdirSync, existsSync } from 'node:fs' import { createRequire } from 'node:module' import { promisify } from 'node:util' import { randomBytes } from 'node:crypto' import { hashPassword } from './password.js' const DEFAULT_ADMIN_IDENTIFIER = 'admin' const DEFAULT_PASSWORD_LENGTH = 14 const PASSWORD_CHARS = 'abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789' function generateRandomPassword() { const bytes = randomBytes(DEFAULT_PASSWORD_LENGTH) let s = '' for (let i = 0; i < DEFAULT_PASSWORD_LENGTH; i++) { s += PASSWORD_CHARS[bytes[i] % PASSWORD_CHARS.length] } return s } const require = createRequire(import.meta.url) const sqlite3 = require('sqlite3') let dbInstance = null /** Set by tests to use :memory: or a temp path */ let testPath = null const USERS_SQL = `CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, identifier TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL )` const USERS_V2_SQL = `CREATE TABLE users_new ( id TEXT PRIMARY KEY, identifier TEXT UNIQUE NOT NULL, password_hash TEXT, role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL, auth_provider TEXT NOT NULL DEFAULT 'local', oidc_issuer TEXT, oidc_sub TEXT )` const USERS_OIDC_UNIQUE = `CREATE UNIQUE INDEX IF NOT EXISTS users_oidc_unique ON users(oidc_issuer, oidc_sub) WHERE oidc_issuer IS NOT NULL AND oidc_sub IS NOT NULL` const SESSIONS_SQL = `CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) )` const POIS_SQL = `CREATE TABLE IF NOT EXISTS pois ( id TEXT PRIMARY KEY, lat REAL NOT NULL, lng REAL NOT NULL, label TEXT NOT NULL DEFAULT '', icon_type TEXT NOT NULL DEFAULT 'pin' )` const DEVICES_SQL = `CREATE TABLE IF NOT EXISTS devices ( id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', device_type TEXT NOT NULL, vendor TEXT, lat REAL NOT NULL, lng REAL NOT NULL, stream_url TEXT NOT NULL DEFAULT '', source_type TEXT NOT NULL DEFAULT 'mjpeg', config TEXT )` function getDbPath() { if (testPath) return testPath const dir = join(process.cwd(), 'data') if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) return join(dir, 'kestrelos.db') } async function bootstrap(db) { if (testPath) return const row = await db.get('SELECT COUNT(*) as n FROM users') if (row?.n !== 0) return const email = process.env.BOOTSTRAP_EMAIL?.trim() const password = process.env.BOOTSTRAP_PASSWORD const identifier = (email && password) ? email : DEFAULT_ADMIN_IDENTIFIER const plainPassword = (email && password) ? password : generateRandomPassword() const id = crypto.randomUUID() const now = new Date().toISOString() await db.run( 'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, identifier, hashPassword(plainPassword), 'admin', now, 'local', null, null], ) if (!email || !password) { console.log('\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n') console.log(` Identifier: ${identifier}\n Password: ${plainPassword}\n`) console.log(' Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n') } } async function migrateUsersIfNeeded(run, all) { const info = await all('PRAGMA table_info(users)') if (info.some(c => c.name === 'auth_provider')) return await run(USERS_V2_SQL) await run( `INSERT INTO users_new (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) SELECT id, identifier, password_hash, role, created_at, 'local', NULL, NULL FROM users`, ) await run('DROP TABLE users') await run('ALTER TABLE users_new RENAME TO users') await run(USERS_OIDC_UNIQUE) } export async function getDb() { if (dbInstance) return dbInstance const path = getDbPath() const db = new sqlite3.Database(path) const run = promisify(db.run.bind(db)) const all = promisify(db.all.bind(db)) const get = promisify(db.get.bind(db)) await run(USERS_SQL) await migrateUsersIfNeeded(run, all) await run(SESSIONS_SQL) await run(POIS_SQL) await run(DEVICES_SQL) await bootstrap({ run, get }) dbInstance = { db, run, all, get } return dbInstance } /** * Close the DB connection. Call on server shutdown to avoid native sqlite3 crashes in worker teardown. */ export function closeDb() { if (dbInstance) { try { dbInstance.db.close() } catch { // ignore if already closed } dbInstance = null } } /** * For tests: use in-memory DB and reset singleton. * @param {string} path - e.g. ':memory:' */ export function setDbPathForTest(path) { testPath = path closeDb() }