improve db
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed

This commit is contained in:
Madison Grubb
2026-02-12 13:28:36 -05:00
parent 9d153c852d
commit fbb38c5dd7
17 changed files with 292 additions and 654 deletions

View File

@@ -62,7 +62,7 @@ See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
## Configuration ## Configuration
- **Feeds**: Edit `server/data/feeds.json` to add cameras/feeds. Each feed needs `id`, `name`, `lat`, `lng`, `streamUrl`, and `sourceType` (`mjpeg` or `hls`). Home Assistant and other sources use the same shape; use proxy URLs for HA. - **Devices**: Manage cameras/devices via the API (`/api/devices`) or the Members/Cameras UI. Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and `PORT` as needed (e.g. in Docker/Helm). - **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and `PORT` as needed (e.g. in Docker/Helm).
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for provider-specific examples. - **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for provider-specific examples.
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you dont set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal—copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs. - **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you dont set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal—copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
@@ -85,8 +85,8 @@ Health: `GET /health` (overview), `GET /health/live` (liveness), `GET /health/re
## Security ## Security
- Feed list is validated server-side (`getValidFeeds`); only valid entries are returned. - Device data is validated server-side; only valid entries are returned.
- Stream URLs are treated as untrusted; the UI only uses `http://` or `https://` URLs for display. - Stream URLs are sanitized to `http://` or `https://` only; other protocols are rejected.
## License ## License

View File

@@ -214,10 +214,6 @@
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
const props = defineProps({ const props = defineProps({
feeds: {
type: Array,
default: () => [],
},
devices: { devices: {
type: Array, type: Array,
default: () => [], default: () => [],
@@ -382,8 +378,7 @@ function updateMarkers() {
if (m) m.remove() if (m) m.remove()
}) })
const feedSources = [...(props.feeds || []), ...(props.devices || [])] const validSources = (props.devices || []).filter(f => typeof f?.lat === 'number' && typeof f?.lng === 'number')
const validSources = feedSources.filter(f => typeof f?.lat === 'number' && typeof f?.lng === 'number')
markersRef.value = validSources.map(item => markersRef.value = validSources.map(item =>
L.marker([item.lat, item.lng]).addTo(ctx.map).on('click', () => emit('select', item)), L.marker([item.lat, item.lng]).addTo(ctx.map).on('click', () => emit('select', item)),
) )
@@ -622,7 +617,7 @@ onBeforeUnmount(() => {
destroyMap() destroyMap()
}) })
watch(() => [props.feeds, props.devices], () => updateMarkers(), { deep: true }) watch(() => props.devices, () => updateMarkers(), { deep: true })
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true }) watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true }) watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
</script> </script>

View File

@@ -3,7 +3,6 @@
<div class="relative h-2/3 w-full md:h-full md:flex-1"> <div class="relative h-2/3 w-full md:h-full md:flex-1">
<ClientOnly> <ClientOnly>
<KestrelMap <KestrelMap
:feeds="[]"
:devices="devices ?? []" :devices="devices ?? []"
:pois="pois ?? []" :pois="pois ?? []"
:live-sessions="liveSessions ?? []" :live-sessions="liveSessions ?? []"

View File

@@ -1,14 +1,11 @@
import { getDb, closeDb } from '../utils/db.js' import { getDb, closeDb } from '../utils/db.js'
import { migrateFeedsToDevices } from '../utils/migrateFeedsToDevices.js'
/** /**
* Initialize DB (and run bootstrap if no users) at server startup * Initialize DB at server startup.
* so credentials are printed in the terminal before any request.
* Close DB on server shutdown to avoid native sqlite3 crashes in worker teardown. * Close DB on server shutdown to avoid native sqlite3 crashes in worker teardown.
*/ */
export default defineNitroPlugin((nitroApp) => { export default defineNitroPlugin((nitroApp) => {
void getDb().then(() => migrateFeedsToDevices()) void getDb()
nitroApp.hooks.hook('close', () => { nitroApp.hooks.hook('close', () => {
closeDb() closeDb()
}) })

29
server/utils/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,29 @@
import { randomBytes } from 'node:crypto'
import { hashPassword } from './password.js'
const DEFAULT_ADMIN_IDENTIFIER = 'admin'
const PASSWORD_CHARS = 'abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
const generateRandomPassword = () => {
const bytes = randomBytes(14)
return Array.from(bytes, b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
}
export async function bootstrapAdmin(run, get) {
const row = await 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()
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
)
if (!email || !password) {
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
}
}

View File

@@ -2,154 +2,172 @@ import { join } from 'node:path'
import { mkdirSync, existsSync } from 'node:fs' import { mkdirSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import { randomBytes } from 'node:crypto' import { bootstrapAdmin } from './bootstrap.js'
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 require = createRequire(import.meta.url)
const sqlite3 = require('sqlite3') const sqlite3 = require('sqlite3')
const SCHEMA_VERSION = 2
const DB_BUSY_TIMEOUT_MS = 5000
let dbInstance = null let dbInstance = null
/** Set by tests to use :memory: or a temp path */
let testPath = null let testPath = null
const USERS_SQL = `CREATE TABLE IF NOT EXISTS users ( const SCHEMA = {
id TEXT PRIMARY KEY, schema_version: 'CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)',
identifier TEXT UNIQUE NOT NULL, users: `CREATE TABLE IF NOT EXISTS users (
password_hash TEXT NOT NULL, id TEXT PRIMARY KEY,
role TEXT NOT NULL DEFAULT 'member', identifier TEXT UNIQUE NOT NULL,
created_at TEXT 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 ( const getDbPath = () => {
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 if (testPath) return testPath
if (process.env.DB_PATH) return process.env.DB_PATH
const dir = join(process.cwd(), 'data') const dir = join(process.cwd(), 'data')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
return join(dir, 'kestrelos.db') return join(dir, 'kestrelos.db')
} }
async function bootstrap(db) { const getSchemaVersion = async (get) => {
if (testPath) return try {
const row = await db.get('SELECT COUNT(*) as n FROM users') const row = await get('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1')
if (row?.n !== 0) return return row?.version || 0
const email = process.env.BOOTSTRAP_EMAIL?.trim() }
const password = process.env.BOOTSTRAP_PASSWORD catch {
const identifier = (email && password) ? email : DEFAULT_ADMIN_IDENTIFIER return 0
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 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)') const info = await all('PRAGMA table_info(users)')
if (info.some(c => c.name === 'auth_provider')) return if (info.some(c => c.name === 'auth_provider')) return
await run(USERS_V2_SQL)
await run( await run('BEGIN TRANSACTION')
`INSERT INTO users_new (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) try {
SELECT id, identifier, password_hash, role, created_at, 'local', NULL, NULL FROM users`, 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('DROP TABLE users')
await run('ALTER TABLE users_new RENAME TO users') await run('ALTER TABLE users_new RENAME TO users')
await run(USERS_OIDC_UNIQUE) 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() { export async function getDb() {
if (dbInstance) return dbInstance 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 run = promisify(db.run.bind(db))
const all = promisify(db.all.bind(db)) const all = promisify(db.all.bind(db))
const get = promisify(db.get.bind(db)) const get = promisify(db.get.bind(db))
await run(USERS_SQL)
await migrateUsersIfNeeded(run, all) try {
await run(SESSIONS_SQL) await initDb(db, run, all, get)
await run(POIS_SQL) }
await run(DEVICES_SQL) catch (error) {
await bootstrap({ run, get }) db.close()
console.error('[db] Database initialization failed:', error.message)
throw error
}
dbInstance = { db, run, all, get } dbInstance = { db, run, all, get }
return dbInstance return dbInstance
} }
/**
* Close the DB connection. Call on server shutdown to avoid native sqlite3 crashes in worker teardown.
*/
export function closeDb() { export function closeDb() {
if (dbInstance) { if (!dbInstance) return
try { try {
dbInstance.db.close() dbInstance.db.close((err) => {
} if (err) console.error('[db] Error closing database:', err.message)
catch { })
// ignore if already closed
}
dbInstance = null
} }
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) { export function setDbPathForTest(path) {
testPath = path testPath = path || null
closeDb() closeDb()
} }

View File

@@ -1,8 +1,12 @@
import { sanitizeStreamUrl } from './feedUtils.js'
const DEVICE_TYPES = Object.freeze(['alpr', 'nvr', 'doorbell', 'feed', 'traffic', 'ip', 'drone']) const DEVICE_TYPES = Object.freeze(['alpr', 'nvr', 'doorbell', 'feed', 'traffic', 'ip', 'drone'])
const SOURCE_TYPES = Object.freeze(['mjpeg', 'hls']) const SOURCE_TYPES = Object.freeze(['mjpeg', 'hls'])
const sanitizeStreamUrl = (url) => {
if (typeof url !== 'string' || !url.trim()) return ''
const u = url.trim()
return (u.startsWith('https://') || u.startsWith('http://')) ? u : ''
}
/** @typedef {{ id: string, name: string, device_type: string, vendor: string | null, lat: number, lng: number, stream_url: string, source_type: string, config: string | null }} DeviceRow */ /** @typedef {{ id: string, name: string, device_type: string, vendor: string | null, lat: number, lng: number, stream_url: string, source_type: string, config: string | null }} DeviceRow */
/** /**

View File

@@ -1,54 +0,0 @@
/**
* Validates a single feed object shape (pure function).
* @param {unknown} item
* @returns {boolean} True if item has id, name, lat, lng with correct types.
*/
export function isValidFeed(item) {
if (!item || typeof item !== 'object') return false
const o = /** @type {Record<string, unknown>} */ (item)
return (
typeof o.id === 'string'
&& typeof o.name === 'string'
&& typeof o.lat === 'number'
&& typeof o.lng === 'number'
)
}
/**
* Returns a safe stream URL (http/https only) or empty string. Prevents javascript:, data:, etc.
* @param {unknown} url
* @returns {string} Safe http(s) URL or empty string.
*/
export function sanitizeStreamUrl(url) {
if (typeof url !== 'string' || !url.trim()) return ''
const u = url.trim()
if (u.startsWith('https://') || u.startsWith('http://')) return u
return ''
}
/**
* Sanitizes a validated feed for API response: safe streamUrl and sourceType only.
* @param {{ id: string, name: string, lat: number, lng: number, [key: string]: unknown }} feed
* @returns {{ id: string, name: string, lat: number, lng: number, streamUrl: string, sourceType: string, description?: string }} Sanitized feed for API.
*/
export function sanitizeFeedForResponse(feed) {
return {
id: feed.id,
name: feed.name,
lat: feed.lat,
lng: feed.lng,
streamUrl: sanitizeStreamUrl(feed.streamUrl),
sourceType: feed.sourceType === 'hls' ? 'hls' : 'mjpeg',
...(typeof feed.description === 'string' ? { description: feed.description } : {}),
}
}
/**
* Filters and returns only valid feeds from an array (pure function).
* @param {unknown[]} list
* @returns {Array<{ id: string, name: string, lat: number, lng: number }>} Array of valid feed objects.
*/
export function getValidFeeds(list) {
if (!Array.isArray(list)) return []
return list.filter(isValidFeed)
}

View File

@@ -1,43 +1,17 @@
/**
* In-memory store for live sharing sessions (camera + location).
* Sessions expire after TTL_MS without an update.
*/
import { closeRouter, getProducer, getTransport } from './mediasoup.js' import { closeRouter, getProducer, getTransport } from './mediasoup.js'
const TTL_MS = 60_000 // 60 seconds without update = inactive const TTL_MS = 60_000
const sessions = new Map() const sessions = new Map()
/** export const createSession = (userId, label = '') => {
* @typedef {{
* id: string
* userId: string
* label: string
* lat: number
* lng: number
* updatedAt: number
* routerId: string | null
* producerId: string | null
* transportId: string | null
* }} LiveSession
*/
/**
* @param {string} userId
* @param {string} [label]
* @returns {LiveSession} The created live session.
*/
export function createSession(userId, label = '') {
const id = crypto.randomUUID() const id = crypto.randomUUID()
const now = Date.now()
const session = { const session = {
id, id,
userId, userId,
label: (label || 'Live').trim() || 'Live', label: (label || 'Live').trim() || 'Live',
lat: 0, lat: 0,
lng: 0, lng: 0,
updatedAt: now, updatedAt: Date.now(),
routerId: null, routerId: null,
producerId: null, producerId: null,
transportId: null, transportId: null,
@@ -46,34 +20,16 @@ export function createSession(userId, label = '') {
return session return session
} }
/** export const getLiveSession = (id) => sessions.get(id)
* @param {string} id
* @returns {LiveSession | undefined} The session or undefined.
*/
export function getLiveSession(id) {
return sessions.get(id)
}
/** export const getActiveSessionByUserId = (userId) => {
* Get an existing active session for a user (for replacing with a new one).
* @param {string} userId
* @returns {LiveSession | undefined} The first active session for the user, or undefined.
*/
export function getActiveSessionByUserId(userId) {
const now = Date.now() const now = Date.now()
for (const [, s] of sessions) { for (const s of sessions.values()) {
if (s.userId === userId && now - s.updatedAt <= TTL_MS) { if (s.userId === userId && now - s.updatedAt <= TTL_MS) return s
return s
}
} }
return undefined
} }
/** export const updateLiveSession = (id, updates) => {
* @param {string} id
* @param {{ lat?: number, lng?: number, routerId?: string | null, producerId?: string | null, transportId?: string | null }} updates
*/
export function updateLiveSession(id, updates) {
const session = sessions.get(id) const session = sessions.get(id)
if (!session) return if (!session) return
const now = Date.now() const now = Date.now()
@@ -85,74 +41,52 @@ export function updateLiveSession(id, updates) {
session.updatedAt = now session.updatedAt = now
} }
/** export const deleteLiveSession = (id) => sessions.delete(id)
* @param {string} id
*/ export const clearSessions = () => sessions.clear()
export function deleteLiveSession(id) {
sessions.delete(id) const cleanupSession = async (session) => {
if (session.producerId) {
const producer = getProducer(session.producerId)
producer?.close()
}
if (session.transportId) {
const transport = getTransport(session.transportId)
transport?.close()
}
if (session.routerId) {
await closeRouter(session.id).catch((err) => {
console.error(`[liveSessions] Error closing router for expired session ${session.id}:`, err)
})
}
} }
/** export const getActiveSessions = async () => {
* Clear all sessions (for tests only).
*/
export function clearSessions() {
sessions.clear()
}
/**
* Returns sessions updated within TTL_MS (active only).
* Also cleans up expired sessions.
* @returns {Promise<Array<{ id: string, userId: string, label: string, lat: number, lng: number, updatedAt: number, hasStream: boolean }>>} Active sessions with hasStream flag.
*/
export async function getActiveSessions() {
const now = Date.now() const now = Date.now()
const result = [] const active = []
const expiredIds = [] const expired = []
for (const [id, s] of sessions) {
if (now - s.updatedAt <= TTL_MS) { for (const session of sessions.values()) {
result.push({ if (now - session.updatedAt <= TTL_MS) {
id: s.id, active.push({
userId: s.userId, id: session.id,
label: s.label, userId: session.userId,
lat: s.lat, label: session.label,
lng: s.lng, lat: session.lat,
updatedAt: s.updatedAt, lng: session.lng,
hasStream: Boolean(s.producerId), updatedAt: session.updatedAt,
hasStream: Boolean(session.producerId),
}) })
} }
else { else {
expiredIds.push(id) expired.push(session)
} }
} }
// Clean up expired sessions and their WebRTC resources
for (const id of expiredIds) {
const session = sessions.get(id)
if (session) {
// Clean up producer if it exists
if (session.producerId) {
const producer = getProducer(session.producerId)
if (producer) {
producer.close()
}
}
// Clean up transport if it exists for (const session of expired) {
if (session.transportId) { await cleanupSession(session)
const transport = getTransport(session.transportId) sessions.delete(session.id)
if (transport) {
transport.close()
}
}
// Clean up router
if (session.routerId) {
await closeRouter(id).catch((err) => {
console.error(`[liveSessions] Error closing router for expired session ${id}:`, err)
})
}
sessions.delete(id)
}
} }
return result
return active
} }

View File

@@ -1,21 +1,18 @@
/**
* Mediasoup SFU (Selective Forwarding Unit) setup and management.
* Handles WebRTC router, transport, producer, and consumer creation.
*/
import os from 'node:os' import os from 'node:os'
import mediasoup from 'mediasoup' import mediasoup from 'mediasoup'
let worker = null let worker = null
const routers = new Map() // sessionId -> Router const routers = new Map()
const transports = new Map() // transportId -> WebRtcTransport const transports = new Map()
export const producers = new Map() // producerId -> Producer export const producers = new Map()
/** const MEDIA_CODECS = [
* Initialize Mediasoup worker (singleton). { kind: 'video', mimeType: 'video/H264', clockRate: 90000, parameters: { 'packetization-mode': 1, 'profile-level-id': '42e01f' } },
* @returns {Promise<mediasoup.types.Worker>} The Mediasoup worker. { kind: 'video', mimeType: 'video/VP8', clockRate: 90000 },
*/ { kind: 'video', mimeType: 'video/VP9', clockRate: 90000 },
export async function getWorker() { ]
export const getWorker = async () => {
if (worker) return worker if (worker) return worker
worker = await mediasoup.createWorker({ worker = await mediasoup.createWorker({
logLevel: process.env.NODE_ENV === 'development' ? 'debug' : 'warn', logLevel: process.env.NODE_ENV === 'development' ? 'debug' : 'warn',
@@ -30,50 +27,15 @@ export async function getWorker() {
return worker return worker
} }
/** export const getRouter = async (sessionId) => {
* Create or get a router for a live session. const existing = routers.get(sessionId)
* @param {string} sessionId if (existing) return existing
* @returns {Promise<mediasoup.types.Router>} Router for the session. const router = await (await getWorker()).createRouter({ mediaCodecs: MEDIA_CODECS })
*/
export async function getRouter(sessionId) {
if (routers.has(sessionId)) {
return routers.get(sessionId)
}
const w = await getWorker()
const router = await w.createRouter({
mediaCodecs: [
{
kind: 'video',
mimeType: 'video/H264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '42e01f',
},
},
{
kind: 'video',
mimeType: 'video/VP8',
clockRate: 90000,
},
{
kind: 'video',
mimeType: 'video/VP9',
clockRate: 90000,
},
],
})
routers.set(sessionId, router) routers.set(sessionId, router)
return router return router
} }
/** const isIPv4 = (host) => {
* True if the string is a valid IPv4 address (numeric a.b.c.d, each octet 0-255).
* Used to accept request Host as announced IP only when it's safe (no hostnames/DNS rebinding).
* @param {string} host
* @returns {boolean} True if host is a valid IPv4 address.
*/
function isIPv4(host) {
if (typeof host !== 'string' || !host) return false if (typeof host !== 'string' || !host) return false
const parts = host.split('.') const parts = host.split('.')
if (parts.length !== 4) return false if (parts.length !== 4) return false
@@ -84,45 +46,24 @@ function isIPv4(host) {
return true return true
} }
/** const getAnnouncedIpFromInterfaces = () => {
* First non-internal IPv4 from network interfaces (no env read). for (const addrs of Object.values(os.networkInterfaces())) {
* @returns {string | null} First non-internal IPv4 address or null.
*/
function getAnnouncedIpFromInterfaces() {
const ifaces = os.networkInterfaces()
for (const addrs of Object.values(ifaces)) {
if (!addrs) continue if (!addrs) continue
for (const addr of addrs) { for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) { if (addr.family === 'IPv4' && !addr.internal) return addr.address
return addr.address
}
} }
} }
return null return null
} }
/** const resolveAnnouncedIp = (requestHost) => {
* Resolve announced IP: env override, then request host if IPv4, then auto-detect. Pure and deterministic.
* @param {string | undefined} requestHost - Host header from the client.
* @returns {string | null} The IP to announce in ICE, or null for localhost-only.
*/
function resolveAnnouncedIp(requestHost) {
const envIp = process.env.MEDIASOUP_ANNOUNCED_IP?.trim() const envIp = process.env.MEDIASOUP_ANNOUNCED_IP?.trim()
if (envIp) return envIp if (envIp) return envIp
if (requestHost && isIPv4(requestHost)) return requestHost if (requestHost && isIPv4(requestHost)) return requestHost
return getAnnouncedIpFromInterfaces() return getAnnouncedIpFromInterfaces()
} }
/** export const createTransport = async (router, _isProducer = false, requestHost = undefined) => {
* Create a WebRTC transport for a router.
* @param {mediasoup.types.Router} router
* @param {boolean} _isProducer - true for publisher, false for consumer (reserved for future use)
* @param {string} [requestHost] - Hostname from the request (e.g. getRequestURL(event).hostname). If a valid IPv4, used as announced IP so the client can reach the server.
* @returns {Promise<{ transport: mediasoup.types.WebRtcTransport, params: object }>} Transport and connection params.
*/
// eslint-disable-next-line no-unused-vars
export async function createTransport(router, _isProducer = false, requestHost = undefined) {
// LAN first so the phone (and remote viewers) try the reachable IP before 127.0.0.1 (loopback on the client).
const announcedIp = resolveAnnouncedIp(requestHost) const announcedIp = resolveAnnouncedIp(requestHost)
const listenIps = announcedIp const listenIps = announcedIp
? [{ ip: '0.0.0.0', announcedIp }, { ip: '127.0.0.1' }] ? [{ ip: '0.0.0.0', announcedIp }, { ip: '127.0.0.1' }]
@@ -138,10 +79,10 @@ export async function createTransport(router, _isProducer = false, requestHost =
console.error('[mediasoup] Transport creation failed:', err) console.error('[mediasoup] Transport creation failed:', err)
throw new Error(`Failed to create transport: ${err.message || String(err)}`) throw new Error(`Failed to create transport: ${err.message || String(err)}`)
}) })
transports.set(transport.id, transport) transports.set(transport.id, transport)
transport.on('close', () => { transport.on('close', () => transports.delete(transport.id))
transports.delete(transport.id)
})
return { return {
transport, transport,
params: { params: {
@@ -153,61 +94,22 @@ export async function createTransport(router, _isProducer = false, requestHost =
} }
} }
/** export const getTransport = (transportId) => transports.get(transportId)
* Get transport by ID.
* @param {string} transportId
* @returns {mediasoup.types.WebRtcTransport | undefined} Transport or undefined.
*/
export function getTransport(transportId) {
return transports.get(transportId)
}
/** export const createProducer = async (transport, track) => {
* Create a producer (publisher's video track).
* @param {mediasoup.types.WebRtcTransport} transport
* @param {MediaStreamTrack} track
* @returns {Promise<mediasoup.types.Producer>} The producer.
*/
export async function createProducer(transport, track) {
const producer = await transport.produce({ track }) const producer = await transport.produce({ track })
producers.set(producer.id, producer) producers.set(producer.id, producer)
producer.on('close', () => { producer.on('close', () => producers.delete(producer.id))
producers.delete(producer.id)
})
return producer return producer
} }
/** export const getProducer = (producerId) => producers.get(producerId)
* Get producer by ID.
* @param {string} producerId
* @returns {mediasoup.types.Producer | undefined} Producer or undefined.
*/
export function getProducer(producerId) {
return producers.get(producerId)
}
/** export const getTransports = () => transports
* Get transports Map (for cleanup).
* @returns {Map<string, mediasoup.types.WebRtcTransport>} Map of transport ID to transport.
*/
export function getTransports() {
return transports
}
/** export const createConsumer = async (transport, producer, rtpCapabilities) => {
* Create a consumer (viewer subscribes to producer's stream). if (producer.closed) throw new Error('Producer is closed')
* @param {mediasoup.types.WebRtcTransport} transport if (producer.paused) await producer.resume()
* @param {mediasoup.types.Producer} producer
* @param {boolean} rtpCapabilities
* @returns {Promise<{ consumer: mediasoup.types.Consumer, params: object }>} Consumer and connection params.
*/
export async function createConsumer(transport, producer, rtpCapabilities) {
if (producer.closed) {
throw new Error('Producer is closed')
}
if (producer.paused) {
await producer.resume()
}
const consumer = await transport.consume({ const consumer = await transport.consume({
producerId: producer.id, producerId: producer.id,
@@ -229,11 +131,7 @@ export async function createConsumer(transport, producer, rtpCapabilities) {
} }
} }
/** export const closeRouter = async (sessionId) => {
* Clean up router for a session.
* @param {string} sessionId
*/
export async function closeRouter(sessionId) {
const router = routers.get(sessionId) const router = routers.get(sessionId)
if (router) { if (router) {
router.close() router.close()
@@ -241,10 +139,4 @@ export async function closeRouter(sessionId) {
} }
} }
/** export const getActiveRouters = () => Array.from(routers.keys())
* Get all active routers (for debugging/monitoring).
* @returns {Array<string>} Session IDs with active routers
*/
export function getActiveRouters() {
return Array.from(routers.keys())
}

View File

@@ -1,27 +0,0 @@
import { join } from 'node:path'
import { readFileSync, existsSync } from 'node:fs'
import { getDb } from './db.js'
import { sanitizeStreamUrl } from './feedUtils.js'
/**
* One-time migration: insert entries from server/data/feeds.json into devices (device_type = 'feed').
* No-op if devices table already has rows or feeds file is missing.
*/
export async function migrateFeedsToDevices() {
const db = await getDb()
const row = await db.get('SELECT COUNT(*) as n FROM devices')
if (row?.n > 0) return
const path = join(process.cwd(), 'server/data/feeds.json')
if (!existsSync(path)) return
const data = JSON.parse(readFileSync(path, 'utf8'))
const list = Array.isArray(data) ? data : []
for (const feed of list) {
if (!feed?.id || typeof feed.name !== 'string' || typeof feed.lat !== 'number' || typeof feed.lng !== 'number') continue
const streamUrl = sanitizeStreamUrl(feed.streamUrl) ?? ''
const sourceType = feed.sourceType === 'hls' ? 'hls' : 'mjpeg'
await db.run(
'INSERT OR IGNORE INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[feed.id, feed.name, 'feed', null, feed.lat, feed.lng, streamUrl, sourceType, null],
)
}
}

View File

@@ -42,25 +42,42 @@ function ensureDevCerts() {
} }
async function globalSetup() { async function globalSetup() {
// Ensure dev certificates exist
ensureDevCerts() ensureDevCerts()
// Create test admin user if it doesn't exist let retries = 3
const { get, run } = await getDb() let lastError = null
const existingUser = await get('SELECT id FROM users WHERE identifier = ?', [TEST_ADMIN.identifier]) while (retries > 0) {
try {
const { get, run } = await getDb()
const existingUser = await get('SELECT id FROM users WHERE identifier = ?', [TEST_ADMIN.identifier])
if (!existingUser) { if (!existingUser) {
const id = crypto.randomUUID() const id = crypto.randomUUID()
const now = new Date().toISOString() const now = new Date().toISOString()
await run( await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, TEST_ADMIN.identifier, hashPassword(TEST_ADMIN.password), TEST_ADMIN.role, now, 'local', null, null], [id, TEST_ADMIN.identifier, hashPassword(TEST_ADMIN.password), TEST_ADMIN.role, now, 'local', null, null],
) )
console.log(`[test] Created test admin user: ${TEST_ADMIN.identifier}`) console.log(`[test] Created test admin user: ${TEST_ADMIN.identifier}`)
} }
else { else {
console.log(`[test] Test admin user already exists: ${TEST_ADMIN.identifier}`) console.log(`[test] Test admin user already exists: ${TEST_ADMIN.identifier}`)
}
return
}
catch (error) {
lastError = error
if (error.message?.includes('SQLITE_BUSY') || error.message?.includes('database is locked')) {
retries--
if (retries > 0) {
await new Promise(resolve => setTimeout(resolve, 100 * (4 - retries)))
continue
}
}
throw error
}
} }
throw lastError
} }
export default globalSetup export default globalSetup

View File

@@ -10,24 +10,24 @@ vi.mock('leaflet.offline', () => ({ tileLayerOffline: null, savetiles: null }))
describe('KestrelMap', () => { describe('KestrelMap', () => {
it('renders map container', async () => { it('renders map container', async () => {
const wrapper = await mountSuspended(KestrelMap, { const wrapper = await mountSuspended(KestrelMap, {
props: { feeds: [] }, props: { devices: [] },
}) })
expect(wrapper.find('[data-testid="kestrel-map"]').exists()).toBe(true) expect(wrapper.find('[data-testid="kestrel-map"]').exists()).toBe(true)
}) })
it('accepts feeds prop', async () => { it('accepts devices prop', async () => {
const feeds = [ const devices = [
{ id: '1', name: 'A', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg' }, { id: '1', name: 'A', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg' },
] ]
const wrapper = await mountSuspended(KestrelMap, { const wrapper = await mountSuspended(KestrelMap, {
props: { feeds }, props: { devices },
}) })
expect(wrapper.props('feeds')).toEqual(feeds) expect(wrapper.props('devices')).toEqual(devices)
}) })
it('has select emit', async () => { it('has select emit', async () => {
const wrapper = await mountSuspended(KestrelMap, { const wrapper = await mountSuspended(KestrelMap, {
props: { feeds: [] }, props: { devices: [] },
}) })
wrapper.vm.$emit('select', { id: 'x', name: 'X', lat: 0, lng: 0 }) wrapper.vm.$emit('select', { id: 'x', name: 'X', lat: 0, lng: 0 })
expect(wrapper.emitted('select')).toHaveLength(1) expect(wrapper.emitted('select')).toHaveLength(1)
@@ -67,7 +67,7 @@ describe('KestrelMap', () => {
it('accepts pois and canEditPois props', async () => { it('accepts pois and canEditPois props', async () => {
const wrapper = await mountSuspended(KestrelMap, { const wrapper = await mountSuspended(KestrelMap, {
props: { props: {
feeds: [], devices: [],
pois: [{ id: 'p1', lat: 37.7, lng: -122.4, label: 'P', icon_type: 'pin' }], pois: [{ id: 'p1', lat: 37.7, lng: -122.4, label: 'P', icon_type: 'pin' }],
canEditPois: false, canEditPois: false,
}, },

View File

@@ -1,14 +0,0 @@
import { describe, it, expect } from 'vitest'
import { getValidFeeds } from '../../server/utils/feedUtils.js'
describe('API contract', () => {
it('getValidFeeds returns array suitable for API response', () => {
const raw = [
{ id: '1', name: 'A', lat: 1, lng: 2 },
{ id: '2', name: 'B', lat: 3, lng: 4 },
]
const out = getValidFeeds(raw)
expect(Array.isArray(out)).toBe(true)
expect(out).toHaveLength(2)
})
})

View File

@@ -1,119 +0,0 @@
import { describe, it, expect } from 'vitest'
import { isValidFeed, getValidFeeds, sanitizeStreamUrl, sanitizeFeedForResponse } from '../../server/utils/feedUtils.js'
describe('feedUtils', () => {
describe('isValidFeed', () => {
it('returns true for valid feed', () => {
expect(isValidFeed({
id: '1',
name: 'Cam',
lat: 37.7,
lng: -122.4,
})).toBe(true)
})
it('returns false for null', () => {
expect(isValidFeed(null)).toBe(false)
})
it('returns false for missing id', () => {
expect(isValidFeed({ name: 'x', lat: 0, lng: 0 })).toBe(false)
})
it('returns false for wrong lat type', () => {
expect(isValidFeed({ id: '1', name: 'x', lat: '37', lng: -122 })).toBe(false)
})
})
describe('getValidFeeds', () => {
it('returns only valid feeds', () => {
const list = [
{ id: 'a', name: 'A', lat: 1, lng: 2 },
null,
{ id: 'b', name: 'B', lat: 3, lng: 4 },
]
expect(getValidFeeds(list)).toHaveLength(2)
})
it('returns empty array for non-array', () => {
expect(getValidFeeds(null)).toEqual([])
expect(getValidFeeds({})).toEqual([])
})
})
describe('sanitizeStreamUrl', () => {
it('allows http and https', () => {
expect(sanitizeStreamUrl('https://example.com/stream')).toBe('https://example.com/stream')
expect(sanitizeStreamUrl('http://example.com/stream')).toBe('http://example.com/stream')
})
it('returns empty for javascript:, data:, and other schemes', () => {
expect(sanitizeStreamUrl('javascript:alert(1)')).toBe('')
expect(sanitizeStreamUrl('data:text/html,<script>')).toBe('')
expect(sanitizeStreamUrl('file:///etc/passwd')).toBe('')
})
it('returns empty for non-strings or empty', () => {
expect(sanitizeStreamUrl('')).toBe('')
expect(sanitizeStreamUrl(' ')).toBe('')
expect(sanitizeStreamUrl(null)).toBe('')
expect(sanitizeStreamUrl(123)).toBe('')
})
})
describe('sanitizeFeedForResponse', () => {
it('returns safe shape with sanitized streamUrl and sourceType', () => {
const feed = {
id: 'f1',
name: 'Cam',
lat: 37,
lng: -122,
streamUrl: 'https://safe.com/s',
sourceType: 'mjpeg',
}
const out = sanitizeFeedForResponse(feed)
expect(out).toEqual({
id: 'f1',
name: 'Cam',
lat: 37,
lng: -122,
streamUrl: 'https://safe.com/s',
sourceType: 'mjpeg',
})
})
it('strips dangerous streamUrl and normalizes sourceType', () => {
const feed = {
id: 'f2',
name: 'Bad',
lat: 0,
lng: 0,
streamUrl: 'javascript:alert(1)',
sourceType: 'hls',
}
const out = sanitizeFeedForResponse(feed)
expect(out.streamUrl).toBe('')
expect(out.sourceType).toBe('hls')
})
it('includes description only when string', () => {
const withDesc = sanitizeFeedForResponse({
id: 'a',
name: 'n',
lat: 0,
lng: 0,
description: 'A camera',
})
expect(withDesc.description).toBe('A camera')
const noDesc = sanitizeFeedForResponse({
id: 'b',
name: 'n',
lat: 0,
lng: 0,
description: 123,
})
expect(noDesc).not.toHaveProperty('description')
})
})
})

View File

@@ -1,32 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
import { migrateFeedsToDevices } from '../../server/utils/migrateFeedsToDevices.js'
describe('migrateFeedsToDevices', () => {
beforeEach(() => {
setDbPathForTest(':memory:')
})
afterEach(() => {
setDbPathForTest(null)
})
it('runs without error when devices table is empty', async () => {
const db = await getDb()
await expect(migrateFeedsToDevices()).resolves.toBeUndefined()
const rows = await db.all('SELECT id FROM devices')
expect(rows.length).toBeGreaterThanOrEqual(0)
})
it('is no-op when devices already has rows', async () => {
const db = await getDb()
await db.run(
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
['existing', 'Existing', 'feed', null, 0, 0, '', 'mjpeg', null],
)
await migrateFeedsToDevices()
const rows = await db.all('SELECT id FROM devices')
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('existing')
})
})

View File

@@ -22,7 +22,6 @@ export default defineVitestConfig({
'app/composables/useCameras.js', // Visibility/polling branches; covered by E2E 'app/composables/useCameras.js', // Visibility/polling branches; covered by E2E
'server/utils/mediasoup.js', // Requires real mediasoup worker; covered by integration/E2E 'server/utils/mediasoup.js', // Requires real mediasoup worker; covered by integration/E2E
'server/utils/db.js', // Bootstrap/path branches depend on env; covered by integration 'server/utils/db.js', // Bootstrap/path branches depend on env; covered by integration
'server/utils/migrateFeedsToDevices.js', // File-system branches; one-time migration
'**/*.spec.js', '**/*.spec.js',
'**/*.config.js', '**/*.config.js',
'**/node_modules/**', '**/node_modules/**',