initial commit

This commit is contained in:
Madison Grubb
2026-02-10 23:32:26 -05:00
commit b7046dc0e6
133 changed files with 26080 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
/**
* Read auth config from env. Returns only non-secret data for client.
* Auth always allows local (password) sign-in and OIDC when configured.
* @returns {{ oidc: { enabled: boolean, label: string } }} Public auth config (oidc.enabled, oidc.label).
*/
export function getAuthConfig() {
const hasOidcEnv
= process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET
const envLabel = process.env.OIDC_LABEL ?? ''
const label = envLabel || (hasOidcEnv ? 'Sign in with OIDC' : '')
return {
oidc: {
enabled: !!hasOidcEnv,
label,
},
}
}

View File

@@ -0,0 +1,20 @@
/**
* Require authenticated user. Optionally require role. Throws 401 if none, 403 if role insufficient.
* @param {import('h3').H3Event} event
* @param {{ role?: 'admin' | 'adminOrLeader' }} [opts] - role: 'admin' = admin only; 'adminOrLeader' = admin or leader
* @returns {{ id: string, identifier: string, role: string }} The current user.
*/
export function requireAuth(event, opts = {}) {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const { role } = opts
if (role === 'admin' && user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
if (role === 'adminOrLeader' && user.role !== 'admin' && user.role !== 'leader') {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
return user
}

View File

@@ -0,0 +1,32 @@
/**
* Paths that skip auth middleware (no session required).
* Do not add a path here if any handler under it uses requireAuth (with or without role).
* When adding a new API route that requires auth, add its path prefix to PROTECTED_PATH_PREFIXES below
* so tests can assert it is never skipped.
*/
export const SKIP_PATHS = [
'/api/auth/login',
'/api/auth/logout',
'/api/auth/config',
'/api/auth/oidc/authorize',
'/api/auth/oidc/callback',
]
/**
* Path prefixes for API routes that require an authenticated user (or role).
* Every path in this list must NOT be skipped (skipAuth must return false).
* Used by tests to prevent protected routes from being added to SKIP_PATHS.
*/
export const PROTECTED_PATH_PREFIXES = [
'/api/cameras',
'/api/devices',
'/api/live',
'/api/me',
'/api/pois',
'/api/users',
]
export function skipAuth(path) {
if (path.startsWith('/api/health') || path === '/health') return true
return SKIP_PATHS.some(p => path === p || path.startsWith(p + '/'))
}

155
server/utils/db.js Normal file
View File

@@ -0,0 +1,155 @@
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()
}

View File

@@ -0,0 +1,83 @@
import { sanitizeStreamUrl } from './feedUtils.js'
const DEVICE_TYPES = Object.freeze(['alpr', 'nvr', 'doorbell', 'feed', 'traffic', 'ip', 'drone'])
const SOURCE_TYPES = Object.freeze(['mjpeg', 'hls'])
/** @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 */
/**
* @param {string} s
* @returns {string} 'mjpeg' or 'hls'
*/
function normalizeSourceType(s) {
return SOURCE_TYPES.includes(s) ? s : 'mjpeg'
}
/**
* @param {unknown} row
* @returns {DeviceRow | null} Normalized device row or null if invalid
*/
export function rowToDevice(row) {
if (!row || typeof row !== 'object') return null
const r = /** @type {Record<string, unknown>} */ (row)
if (typeof r.id !== 'string' || typeof r.name !== 'string' || typeof r.device_type !== 'string') return null
if (typeof r.lat !== 'number' && typeof r.lat !== 'string') return null
if (typeof r.lng !== 'number' && typeof r.lng !== 'string') return null
const lat = Number(r.lat)
const lng = Number(r.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
return {
id: r.id,
name: r.name,
device_type: r.device_type,
vendor: typeof r.vendor === 'string' ? r.vendor : null,
lat,
lng,
stream_url: typeof r.stream_url === 'string' ? r.stream_url : '',
source_type: normalizeSourceType(r.source_type),
config: typeof r.config === 'string' ? r.config : null,
}
}
/**
* Sanitize device for API response (safe stream URL, valid sourceType).
* @param {DeviceRow} device
* @returns {{ id: string, name: string, device_type: string, vendor: string | null, lat: number, lng: number, streamUrl: string, sourceType: string, config: string | null }} Sanitized device for API response
*/
export function sanitizeDeviceForResponse(device) {
return {
id: device.id,
name: device.name,
device_type: device.device_type,
vendor: device.vendor,
lat: device.lat,
lng: device.lng,
streamUrl: sanitizeStreamUrl(device.stream_url),
sourceType: normalizeSourceType(device.source_type),
config: device.config,
}
}
/**
* Validate and normalize device body for POST.
* @param {unknown} body
* @returns {{ name: string, device_type: string, vendor: string | null, lat: number, lng: number, stream_url: string, source_type: string, config: string | null }} Validated and normalized body fields
*/
export function validateDeviceBody(body) {
if (!body || typeof body !== 'object') throw createError({ statusCode: 400, message: 'body required' })
const b = /** @type {Record<string, unknown>} */ (body)
const name = typeof b.name === 'string' ? b.name.trim() || '' : ''
const deviceType = typeof b.device_type === 'string' && DEVICE_TYPES.includes(b.device_type) ? b.device_type : 'feed'
const vendor = typeof b.vendor === 'string' ? b.vendor.trim() || null : null
const lat = Number(b.lat)
const lng = Number(b.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
throw createError({ statusCode: 400, message: 'lat and lng required as finite numbers' })
}
const streamUrl = typeof b.stream_url === 'string' ? b.stream_url.trim() : ''
const sourceType = normalizeSourceType(b.source_type)
const config = typeof b.config === 'string' ? b.config : (b.config != null ? JSON.stringify(b.config) : null)
return { name, device_type: deviceType, vendor, lat, lng, stream_url: streamUrl, source_type: sourceType, config }
}
export { DEVICE_TYPES, SOURCE_TYPES }

54
server/utils/feedUtils.js Normal file
View File

@@ -0,0 +1,54 @@
/**
* 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

@@ -0,0 +1,158 @@
/**
* In-memory store for live sharing sessions (camera + location).
* Sessions expire after TTL_MS without an update.
*/
import { closeRouter, getProducer, getTransport } from './mediasoup.js'
const TTL_MS = 60_000 // 60 seconds without update = inactive
const sessions = new Map()
/**
* @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 now = Date.now()
const session = {
id,
userId,
label: (label || 'Live').trim() || 'Live',
lat: 0,
lng: 0,
updatedAt: now,
routerId: null,
producerId: null,
transportId: null,
}
sessions.set(id, session)
return session
}
/**
* @param {string} id
* @returns {LiveSession | undefined} The session or undefined.
*/
export function getLiveSession(id) {
return sessions.get(id)
}
/**
* 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()
for (const [, s] of sessions) {
if (s.userId === userId && now - s.updatedAt <= TTL_MS) {
return s
}
}
return undefined
}
/**
* @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)
if (!session) return
const now = Date.now()
if (Number.isFinite(updates.lat)) session.lat = updates.lat
if (Number.isFinite(updates.lng)) session.lng = updates.lng
if (updates.routerId !== undefined) session.routerId = updates.routerId
if (updates.producerId !== undefined) session.producerId = updates.producerId
if (updates.transportId !== undefined) session.transportId = updates.transportId
session.updatedAt = now
}
/**
* @param {string} id
*/
export function deleteLiveSession(id) {
sessions.delete(id)
}
/**
* 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 result = []
const expiredIds = []
for (const [id, s] of sessions) {
if (now - s.updatedAt <= TTL_MS) {
result.push({
id: s.id,
userId: s.userId,
label: s.label,
lat: s.lat,
lng: s.lng,
updatedAt: s.updatedAt,
hasStream: Boolean(s.producerId),
})
}
else {
expiredIds.push(id)
}
}
// 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
if (session.transportId) {
const transport = getTransport(session.transportId)
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
}

250
server/utils/mediasoup.js Normal file
View File

@@ -0,0 +1,250 @@
/**
* Mediasoup SFU (Selective Forwarding Unit) setup and management.
* Handles WebRTC router, transport, producer, and consumer creation.
*/
import os from 'node:os'
import mediasoup from 'mediasoup'
let worker = null
const routers = new Map() // sessionId -> Router
const transports = new Map() // transportId -> WebRtcTransport
export const producers = new Map() // producerId -> Producer
/**
* Initialize Mediasoup worker (singleton).
* @returns {Promise<mediasoup.types.Worker>} The Mediasoup worker.
*/
export async function getWorker() {
if (worker) return worker
worker = await mediasoup.createWorker({
logLevel: process.env.NODE_ENV === 'development' ? 'debug' : 'warn',
logTags: ['info', 'ice', 'dtls', 'rtp', 'srtp', 'rtcp'],
rtcMinPort: 40000,
rtcMaxPort: 49999,
})
worker.on('died', () => {
console.error('[mediasoup] Worker died, exiting')
process.exit(1)
})
return worker
}
/**
* Create or get a router for a live session.
* @param {string} sessionId
* @returns {Promise<mediasoup.types.Router>} Router for the session.
*/
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)
return router
}
/**
* 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
const parts = host.split('.')
if (parts.length !== 4) return false
for (const p of parts) {
const n = Number.parseInt(p, 10)
if (Number.isNaN(n) || n < 0 || n > 255 || String(n) !== p) return false
}
return true
}
/**
* First non-internal IPv4 from network interfaces (no env read).
* @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
for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) {
return addr.address
}
}
}
return null
}
/**
* 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()
if (envIp) return envIp
if (requestHost && isIPv4(requestHost)) return requestHost
return getAnnouncedIpFromInterfaces()
}
/**
* 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 listenIps = announcedIp
? [{ ip: '0.0.0.0', announcedIp }, { ip: '127.0.0.1' }]
: [{ ip: '127.0.0.1' }]
const transport = await router.createWebRtcTransport({
listenIps,
enableUdp: true,
enableTcp: true,
preferUdp: true,
initialAvailableOutgoingBitrate: 1_000_000,
}).catch((err) => {
console.error('[mediasoup] Transport creation failed:', err)
throw new Error(`Failed to create transport: ${err.message || String(err)}`)
})
transports.set(transport.id, transport)
transport.on('close', () => {
transports.delete(transport.id)
})
return {
transport,
params: {
id: transport.id,
iceParameters: transport.iceParameters,
iceCandidates: transport.iceCandidates,
dtlsParameters: transport.dtlsParameters,
},
}
}
/**
* Get transport by ID.
* @param {string} transportId
* @returns {mediasoup.types.WebRtcTransport | undefined} Transport or undefined.
*/
export function getTransport(transportId) {
return transports.get(transportId)
}
/**
* 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 })
producers.set(producer.id, producer)
producer.on('close', () => {
producers.delete(producer.id)
})
return producer
}
/**
* Get producer by ID.
* @param {string} producerId
* @returns {mediasoup.types.Producer | undefined} Producer or undefined.
*/
export function getProducer(producerId) {
return producers.get(producerId)
}
/**
* Get transports Map (for cleanup).
* @returns {Map<string, mediasoup.types.WebRtcTransport>} Map of transport ID to transport.
*/
export function getTransports() {
return transports
}
/**
* Create a consumer (viewer subscribes to producer's stream).
* @param {mediasoup.types.WebRtcTransport} transport
* @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({
producerId: producer.id,
rtpCapabilities,
paused: false,
})
consumer.on('transportclose', () => {})
consumer.on('producerclose', () => {})
return {
consumer,
params: {
id: consumer.id,
producerId: consumer.producerId,
kind: consumer.kind,
rtpParameters: consumer.rtpParameters,
},
}
}
/**
* Clean up router for a session.
* @param {string} sessionId
*/
export async function closeRouter(sessionId) {
const router = routers.get(sessionId)
if (router) {
router.close()
routers.delete(sessionId)
}
}
/**
* 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

@@ -0,0 +1,27 @@
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],
)
}
}

74
server/utils/oidc.js Normal file
View File

@@ -0,0 +1,74 @@
import * as oidc from 'openid-client'
const CACHE_TTL_MS = 60 * 60 * 1000
const configCache = new Map()
function getRedirectUri() {
const explicit
= process.env.OIDC_REDIRECT_URI ?? process.env.OPENID_REDIRECT_URI ?? ''
if (explicit.trim()) return explicit.trim()
const base = process.env.NUXT_APP_URL || process.env.APP_URL || ''
if (base) return `${base.replace(/\/$/, '')}/api/auth/oidc/callback`
const host = process.env.HOST || 'localhost'
const port = process.env.PORT || '3000'
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
return `${protocol}://${host}:${port}/api/auth/oidc/callback`
}
export async function getOidcConfig() {
const issuer = process.env.OIDC_ISSUER
const clientId = process.env.OIDC_CLIENT_ID
const clientSecret = process.env.OIDC_CLIENT_SECRET
if (!issuer || !clientId || !clientSecret) return null
const key = issuer
const cached = configCache.get(key)
if (cached && Date.now() < cached.expires) return cached.config
const server = new URL(issuer)
const config = await oidc.discovery(
server,
clientId,
undefined,
oidc.ClientSecretPost(clientSecret),
{ timeout: 5000 },
)
configCache.set(key, { config, expires: Date.now() + CACHE_TTL_MS })
return config
}
export function getOidcRedirectUri() {
return getRedirectUri()
}
export function constantTimeCompare(a, b) {
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false
let out = 0
for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i)
return out === 0
}
export function createOidcParams() {
return {
state: oidc.randomState(),
nonce: oidc.randomNonce(),
codeVerifier: oidc.randomPKCECodeVerifier(),
}
}
export async function getCodeChallenge(verifier) {
return oidc.calculatePKCECodeChallenge(verifier)
}
export function validateRedirectPath(redirect) {
if (typeof redirect !== 'string' || !redirect.startsWith('/') || redirect.startsWith('//')) return '/'
const path = redirect.split('?')[0]
if (path.includes('//')) return '/'
return redirect
}
export function buildAuthorizeUrl(config, params) {
return oidc.buildAuthorizationUrl(config, params)
}
export async function exchangeCode(config, currentUrl, checks) {
return oidc.authorizationCodeGrant(config, currentUrl, checks)
}

30
server/utils/password.js Normal file
View File

@@ -0,0 +1,30 @@
import { scryptSync, randomBytes } from 'node:crypto'
const SALT_LEN = 16
const KEY_LEN = 64
const SCRYPT_OPTS = { N: 16384, r: 8, p: 1 }
/**
* Hash a password for storage. Returns "salt:hash" hex string.
* @param {string} password
* @returns {string} Salt and hash as hex, colon-separated.
*/
export function hashPassword(password) {
const salt = randomBytes(SALT_LEN)
const hash = scryptSync(password, salt, KEY_LEN, SCRYPT_OPTS)
return `${salt.toString('hex')}:${hash.toString('hex')}`
}
/**
* Verify password against stored "salt:hash" value.
* @param {string} password
* @param {string} stored
* @returns {boolean} True if password matches.
*/
export function verifyPassword(password, stored) {
if (!stored || !stored.includes(':')) return false
const [saltHex, hashHex] = stored.split(':')
const salt = Buffer.from(saltHex, 'hex')
const hash = scryptSync(password, salt, KEY_LEN, SCRYPT_OPTS)
return hash.toString('hex') === hashHex
}

15
server/utils/session.js Normal file
View File

@@ -0,0 +1,15 @@
const DEFAULT_DAYS = 7
const MIN_DAYS = 1
const MAX_DAYS = 365
/**
* Session lifetime in days (for cookie and DB expires_at). Uses SESSION_MAX_AGE_DAYS.
* Clamped to 1365 days.
*/
export function getSessionMaxAgeDays() {
const raw = process.env.SESSION_MAX_AGE_DAYS != null
? Number.parseInt(process.env.SESSION_MAX_AGE_DAYS, 10)
: Number.NaN
if (Number.isFinite(raw)) return Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw))
return DEFAULT_DAYS
}

View File

@@ -0,0 +1,59 @@
/**
* WebRTC signaling message handlers.
* Processes WebSocket messages for WebRTC operations.
*/
import { getLiveSession, updateLiveSession } from './liveSessions.js'
import { getRouter, createTransport, getTransport } from './mediasoup.js'
/**
* Handle WebSocket message for WebRTC signaling.
* @param {string} userId
* @param {string} sessionId
* @param {string} type
* @param {object} data
* @returns {Promise<object | null>} Response message or null
*/
export async function handleWebSocketMessage(userId, sessionId, type, data) {
const session = getLiveSession(sessionId)
if (!session) {
return { error: 'Session not found' }
}
if (['create-transport', 'connect-transport'].includes(type) && session.userId !== userId) {
return { error: 'Forbidden' }
}
try {
switch (type) {
case 'get-router-rtp-capabilities': {
const router = await getRouter(sessionId)
return { type: 'router-rtp-capabilities', data: router.rtpCapabilities }
}
case 'create-transport': {
const router = await getRouter(sessionId)
const { transport, params } = await createTransport(router, true)
updateLiveSession(sessionId, { transportId: transport.id, routerId: router.id })
return { type: 'transport-created', data: params }
}
case 'connect-transport': {
const { transportId, dtlsParameters } = data
if (!transportId || !dtlsParameters) {
return { error: 'transportId and dtlsParameters required' }
}
const transport = getTransport(transportId)
if (!transport) {
return { error: 'Transport not found' }
}
await transport.connect({ dtlsParameters })
return { type: 'transport-connected', data: { connected: true } }
}
default:
return { error: `Unknown message type: ${type}` }
}
}
catch (err) {
console.error('[webrtc-signaling] Error:', err)
return { error: err.message || 'Internal error' }
}
}