Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -2,154 +2,172 @@ 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
|
||||
}
|
||||
import { bootstrapAdmin } from './bootstrap.js'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const sqlite3 = require('sqlite3')
|
||||
|
||||
const SCHEMA_VERSION = 2
|
||||
const DB_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
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 SCHEMA = {
|
||||
schema_version: 'CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)',
|
||||
users: `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
|
||||
)`,
|
||||
users_v2: `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
|
||||
)`,
|
||||
users_oidc_index: `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`,
|
||||
sessions: `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)
|
||||
)`,
|
||||
pois: `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'
|
||||
)`,
|
||||
devices: `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
|
||||
)`,
|
||||
}
|
||||
|
||||
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() {
|
||||
const getDbPath = () => {
|
||||
if (testPath) return testPath
|
||||
if (process.env.DB_PATH) return process.env.DB_PATH
|
||||
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')
|
||||
const getSchemaVersion = async (get) => {
|
||||
try {
|
||||
const row = await get('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1')
|
||||
return row?.version || 0
|
||||
}
|
||||
catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateUsersIfNeeded(run, all) {
|
||||
const setSchemaVersion = (run, version) => run('INSERT OR REPLACE INTO schema_version (version) VALUES (?)', [version])
|
||||
|
||||
const migrateToV2 = async (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)
|
||||
|
||||
await run('BEGIN TRANSACTION')
|
||||
try {
|
||||
await run(SCHEMA.users_v2)
|
||||
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, ?, ?, ? FROM users', ['local', null, null])
|
||||
await run('DROP TABLE users')
|
||||
await run('ALTER TABLE users_new RENAME TO users')
|
||||
await run(SCHEMA.users_oidc_index)
|
||||
await run('COMMIT')
|
||||
}
|
||||
catch (error) {
|
||||
await run('ROLLBACK').catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const runMigrations = async (run, all, get) => {
|
||||
const version = await getSchemaVersion(get)
|
||||
if (version >= SCHEMA_VERSION) return
|
||||
if (version < 2) {
|
||||
await migrateToV2(run, all)
|
||||
await setSchemaVersion(run, 2)
|
||||
}
|
||||
}
|
||||
|
||||
const initDb = async (db, run, all, get) => {
|
||||
try {
|
||||
await run('PRAGMA journal_mode = WAL')
|
||||
}
|
||||
catch {
|
||||
// WAL not supported (e.g., network filesystem)
|
||||
}
|
||||
db.configure('busyTimeout', DB_BUSY_TIMEOUT_MS)
|
||||
|
||||
await run(SCHEMA.schema_version)
|
||||
await run(SCHEMA.users)
|
||||
await runMigrations(run, all, get)
|
||||
await run(SCHEMA.sessions)
|
||||
await run(SCHEMA.pois)
|
||||
await run(SCHEMA.devices)
|
||||
|
||||
if (!testPath) await bootstrapAdmin(run, get)
|
||||
}
|
||||
|
||||
export async function getDb() {
|
||||
if (dbInstance) return dbInstance
|
||||
const path = getDbPath()
|
||||
const db = new sqlite3.Database(path)
|
||||
|
||||
const db = new sqlite3.Database(getDbPath(), (err) => {
|
||||
if (err) {
|
||||
console.error('[db] Failed to open database:', err.message)
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
try {
|
||||
await initDb(db, run, all, get)
|
||||
}
|
||||
catch (error) {
|
||||
db.close()
|
||||
console.error('[db] Database initialization failed:', error.message)
|
||||
throw error
|
||||
}
|
||||
|
||||
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
|
||||
if (!dbInstance) return
|
||||
try {
|
||||
dbInstance.db.close((err) => {
|
||||
if (err) console.error('[db] Error closing database:', err.message)
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[db] Error closing database:', error.message)
|
||||
}
|
||||
dbInstance = null
|
||||
}
|
||||
|
||||
/**
|
||||
* For tests: use in-memory DB and reset singleton.
|
||||
* @param {string} path - e.g. ':memory:'
|
||||
*/
|
||||
export function setDbPathForTest(path) {
|
||||
testPath = path
|
||||
testPath = path || null
|
||||
closeDb()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user