Files
kestrelos/server/utils/db.js
Madison Grubb b7046dc0e6 initial commit
2026-02-10 23:32:26 -05:00

156 lines
4.8 KiB
JavaScript

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()
}