This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
export function getAuthConfig() {
|
||||
const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
|
||||
const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
|
||||
return Object.freeze({ oidc: { enabled: hasOidc, label } })
|
||||
}
|
||||
@@ -8,3 +8,26 @@ export function requireAuth(event, opts = {}) {
|
||||
if (role === 'adminOrLeader' && !ROLES_ADMIN_OR_LEADER.includes(user.role)) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
return user
|
||||
}
|
||||
|
||||
// Auth path utilities
|
||||
export const SKIP_PATHS = Object.freeze([
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/config',
|
||||
'/api/auth/oidc/authorize',
|
||||
'/api/auth/oidc/callback',
|
||||
])
|
||||
|
||||
export const PROTECTED_PATH_PREFIXES = Object.freeze([
|
||||
'/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 + '/'))
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/** Paths that skip auth (no session required). Do not add if any handler uses requireAuth. */
|
||||
export const SKIP_PATHS = Object.freeze([
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/config',
|
||||
'/api/auth/oidc/authorize',
|
||||
'/api/auth/oidc/callback',
|
||||
])
|
||||
|
||||
/** Path prefixes for protected routes. Used by tests to ensure they're never in SKIP_PATHS. */
|
||||
export const PROTECTED_PATH_PREFIXES = Object.freeze([
|
||||
'/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 + '/'))
|
||||
}
|
||||
26
server/utils/bootstrap.js
vendored
26
server/utils/bootstrap.js
vendored
@@ -1,26 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { hashPassword } from './password.js'
|
||||
|
||||
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
|
||||
|
||||
const generateRandomPassword = () =>
|
||||
Array.from(randomBytes(14), 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 : 'admin'
|
||||
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`)
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,10 @@ export const MAX_IDENTIFIER_LENGTH = Number(process.env.MAX_IDENTIFIER_LENGTH) |
|
||||
// Mediasoup
|
||||
export const MEDIASOUP_RTC_MIN_PORT = Number(process.env.MEDIASOUP_RTC_MIN_PORT) || 40000
|
||||
export const MEDIASOUP_RTC_MAX_PORT = Number(process.env.MEDIASOUP_RTC_MAX_PORT) || 49999
|
||||
|
||||
// Session
|
||||
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
|
||||
export function getSessionMaxAgeDays() {
|
||||
const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
|
||||
return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
import { MAX_PAYLOAD_BYTES } from './constants.js'
|
||||
|
||||
const TAK_MAGIC = 0xBF
|
||||
// CoT protocol detection constants
|
||||
export const COT_FIRST_BYTE_TAK = 0xBF
|
||||
export const COT_FIRST_BYTE_XML = 0x3C
|
||||
|
||||
/** @param {number} byte - First byte of stream. @returns {boolean} */
|
||||
export function isCotFirstByte(byte) {
|
||||
return byte === COT_FIRST_BYTE_TAK || byte === COT_FIRST_BYTE_XML
|
||||
}
|
||||
|
||||
const TRADITIONAL_DELIMITER = Buffer.from('</event>', 'utf8')
|
||||
|
||||
/**
|
||||
@@ -30,7 +38,7 @@ function readVarint(buf, offset) {
|
||||
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete/invalid.
|
||||
*/
|
||||
export function parseTakStreamFrame(buf) {
|
||||
if (!buf || buf.length < 2 || buf[0] !== TAK_MAGIC) return null
|
||||
if (!buf || buf.length < 2 || buf[0] !== COT_FIRST_BYTE_TAK) return null
|
||||
const { value: length, bytesRead } = readVarint(buf, 1)
|
||||
if (length < 0 || length > MAX_PAYLOAD_BYTES) return null
|
||||
const bytesConsumed = 1 + bytesRead + length
|
||||
@@ -44,7 +52,7 @@ export function parseTakStreamFrame(buf) {
|
||||
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete.
|
||||
*/
|
||||
export function parseTraditionalXmlFrame(buf) {
|
||||
if (!buf || buf.length < 8 || buf[0] !== 0x3C) return null
|
||||
if (!buf || buf.length < 8 || buf[0] !== COT_FIRST_BYTE_XML) return null
|
||||
const idx = buf.indexOf(TRADITIONAL_DELIMITER)
|
||||
if (idx === -1) return null
|
||||
const bytesConsumed = idx + TRADITIONAL_DELIMITER.length
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* CoT stream first-byte detection: TAK Protocol (0xBF) or traditional XML (0x3C '<').
|
||||
* Used by tests and any code that must distinguish CoT from other protocols.
|
||||
*/
|
||||
|
||||
export const COT_FIRST_BYTE_TAK = 0xBF
|
||||
export const COT_FIRST_BYTE_XML = 0x3C
|
||||
|
||||
/** @param {number} byte - First byte of stream. @returns {boolean} */
|
||||
export function isCotFirstByte(byte) {
|
||||
return byte === COT_FIRST_BYTE_TAK || byte === COT_FIRST_BYTE_XML
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { join, dirname } from 'node:path'
|
||||
import { mkdirSync, existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { promisify } from 'node:util'
|
||||
import { bootstrapAdmin } from './bootstrap.js'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { hashPassword } from './password.js'
|
||||
import { registerCleanup } from './shutdown.js'
|
||||
|
||||
// Resolve from project root so bundled server (e.g. .output) finds node_modules/sqlite3
|
||||
@@ -152,7 +153,29 @@ const initDb = async (db, run, all, get) => {
|
||||
await run(SCHEMA.pois)
|
||||
await run(SCHEMA.devices)
|
||||
|
||||
if (!testPath) await bootstrapAdmin(run, get)
|
||||
if (!testPath) {
|
||||
// Bootstrap admin user on first run
|
||||
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
|
||||
const generateRandomPassword = () =>
|
||||
Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
|
||||
|
||||
const row = await get('SELECT COUNT(*) as n FROM users')
|
||||
if (row?.n === 0) {
|
||||
const email = process.env.BOOTSTRAP_EMAIL?.trim()
|
||||
const password = process.env.BOOTSTRAP_PASSWORD
|
||||
const identifier = (email && password) ? email : 'admin'
|
||||
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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDb() {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Custom error classes and error handling utilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base application error.
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
|
||||
super(message)
|
||||
this.name = this.constructor.name
|
||||
this.statusCode = statusCode
|
||||
this.code = code
|
||||
Error.captureStackTrace(this, this.constructor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error (400).
|
||||
*/
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message, details = null) {
|
||||
super(message, 400, 'VALIDATION_ERROR')
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not found error (404).
|
||||
*/
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource = 'Resource') {
|
||||
super(`${resource} not found`, 404, 'NOT_FOUND')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthorized error (401).
|
||||
*/
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(message, 401, 'UNAUTHORIZED')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forbidden error (403).
|
||||
*/
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(message, 403, 'FORBIDDEN')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict error (409).
|
||||
*/
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message = 'Conflict') {
|
||||
super(message, 409, 'CONFLICT')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error response for API.
|
||||
* @param {Error} error - Error object
|
||||
* @returns {object} Formatted error response
|
||||
*/
|
||||
export function formatErrorResponse(error) {
|
||||
if (error instanceof AppError) {
|
||||
return {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
...(error.details && { details: error.details }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: error?.message || 'Internal server error',
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import * as oidc from 'openid-client'
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||
const configCache = new Map()
|
||||
|
||||
// Auth configuration
|
||||
export function getAuthConfig() {
|
||||
const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
|
||||
const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
|
||||
return Object.freeze({ oidc: { enabled: hasOidc, label } })
|
||||
}
|
||||
|
||||
function getRedirectUri() {
|
||||
const explicit
|
||||
= process.env.OIDC_REDIRECT_URI ?? process.env.OPENID_REDIRECT_URI ?? ''
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Reusable query functions - eliminates SQL duplication across routes.
|
||||
*/
|
||||
|
||||
const updateEntity = async (db, table, id, updates, getById) => {
|
||||
if (Object.keys(updates).length === 0) return getById(db, id)
|
||||
const { buildUpdateQuery } = await import('./queryBuilder.js')
|
||||
const { query, params } = buildUpdateQuery(table, null, updates)
|
||||
if (!query) return getById(db, id)
|
||||
await db.run(query, [...params, id])
|
||||
return getById(db, id)
|
||||
}
|
||||
|
||||
export async function getDeviceById(db, id) {
|
||||
const result = await db.get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
||||
return result || null
|
||||
}
|
||||
|
||||
export async function getAllDevices(db) {
|
||||
return db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id')
|
||||
}
|
||||
|
||||
export async function createDevice(db, data) {
|
||||
const id = crypto.randomUUID()
|
||||
await db.run(
|
||||
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, data.name, data.device_type, data.vendor, data.lat, data.lng, data.stream_url, data.source_type, data.config],
|
||||
)
|
||||
return getDeviceById(db, id)
|
||||
}
|
||||
|
||||
export async function updateDevice(db, id, updates) {
|
||||
return updateEntity(db, 'devices', id, updates, getDeviceById)
|
||||
}
|
||||
|
||||
export async function getUserById(db, id) {
|
||||
const result = await db.get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
|
||||
return result || null
|
||||
}
|
||||
|
||||
export async function getUserByIdentifier(db, identifier) {
|
||||
const result = await db.get('SELECT id, identifier, role, password_hash FROM users WHERE identifier = ?', [identifier])
|
||||
return result || null
|
||||
}
|
||||
|
||||
export async function createUser(db, data) {
|
||||
const id = crypto.randomUUID()
|
||||
await db.run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, data.identifier, data.password_hash, data.role, data.created_at, data.auth_provider || 'local', data.oidc_issuer || null, data.oidc_sub || null],
|
||||
)
|
||||
return db.get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
export async function updateUser(db, id, updates) {
|
||||
if (Object.keys(updates).length === 0) return getUserById(db, id)
|
||||
const { buildUpdateQuery } = await import('./queryBuilder.js')
|
||||
const { query, params } = buildUpdateQuery('users', null, updates)
|
||||
if (!query) return getUserById(db, id)
|
||||
await db.run(query, [...params, id])
|
||||
return db.get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
export async function getPoiById(db, id) {
|
||||
const result = await db.get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
|
||||
return result || null
|
||||
}
|
||||
|
||||
export async function getAllPois(db) {
|
||||
return db.all('SELECT id, lat, lng, label, icon_type FROM pois ORDER BY id')
|
||||
}
|
||||
|
||||
export async function createPoi(db, data) {
|
||||
const id = crypto.randomUUID()
|
||||
await db.run(
|
||||
'INSERT INTO pois (id, lat, lng, label, icon_type) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, data.lat, data.lng, data.label || '', data.icon_type || 'pin'],
|
||||
)
|
||||
return getPoiById(db, id)
|
||||
}
|
||||
|
||||
export async function updatePoi(db, id, updates) {
|
||||
return updateEntity(db, 'pois', id, updates, getPoiById)
|
||||
}
|
||||
|
||||
export async function getSessionById(db, id) {
|
||||
const result = await db.get('SELECT id, user_id, expires_at FROM sessions WHERE id = ?', [id])
|
||||
return result || null
|
||||
}
|
||||
|
||||
export async function createDbSession(db, data) {
|
||||
await db.run(
|
||||
'INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[data.id, data.user_id, data.created_at, data.expires_at],
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteSession(db, id) {
|
||||
await db.run('DELETE FROM sessions WHERE id = ?', [id])
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Input sanitization utilities - pure functions for cleaning user input.
|
||||
*/
|
||||
|
||||
import { MAX_IDENTIFIER_LENGTH, MAX_STRING_LENGTH } from './constants.js'
|
||||
|
||||
const IDENTIFIER_REGEX = /^\w+$/
|
||||
|
||||
export function sanitizeString(str, maxLength = MAX_STRING_LENGTH) {
|
||||
if (typeof str !== 'string') return ''
|
||||
const trimmed = str.trim()
|
||||
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
|
||||
}
|
||||
|
||||
export function sanitizeIdentifier(str) {
|
||||
if (typeof str !== 'string') return ''
|
||||
const trimmed = str.trim()
|
||||
if (trimmed.length === 0 || trimmed.length > MAX_IDENTIFIER_LENGTH) return ''
|
||||
return IDENTIFIER_REGEX.test(trimmed) ? trimmed : ''
|
||||
}
|
||||
|
||||
export function sanitizeLabel(str, maxLength = MAX_STRING_LENGTH) {
|
||||
return sanitizeString(str, maxLength)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
|
||||
|
||||
export function getSessionMaxAgeDays() {
|
||||
const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
|
||||
return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
|
||||
}
|
||||
@@ -1,10 +1,32 @@
|
||||
/**
|
||||
* Validation schemas - pure functions for consistent input validation.
|
||||
* Validation and sanitization utilities - pure functions for consistent input validation and cleaning.
|
||||
*/
|
||||
|
||||
import { sanitizeString, sanitizeIdentifier, sanitizeLabel } from './sanitize.js'
|
||||
import { MAX_IDENTIFIER_LENGTH, MAX_STRING_LENGTH } from './constants.js'
|
||||
import { DEVICE_TYPES, SOURCE_TYPES } from './deviceUtils.js'
|
||||
import { POI_ICON_TYPES } from './poiConstants.js'
|
||||
|
||||
// Constants
|
||||
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
|
||||
|
||||
// Sanitization functions
|
||||
const IDENTIFIER_REGEX = /^\w+$/
|
||||
|
||||
export function sanitizeString(str, maxLength = MAX_STRING_LENGTH) {
|
||||
if (typeof str !== 'string') return ''
|
||||
const trimmed = str.trim()
|
||||
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
|
||||
}
|
||||
|
||||
export function sanitizeIdentifier(str) {
|
||||
if (typeof str !== 'string') return ''
|
||||
const trimmed = str.trim()
|
||||
if (trimmed.length === 0 || trimmed.length > MAX_IDENTIFIER_LENGTH) return ''
|
||||
return IDENTIFIER_REGEX.test(trimmed) ? trimmed : ''
|
||||
}
|
||||
|
||||
export function sanitizeLabel(str, maxLength = MAX_STRING_LENGTH) {
|
||||
return sanitizeString(str, maxLength)
|
||||
}
|
||||
|
||||
const ROLES = ['admin', 'leader', 'member']
|
||||
|
||||
|
||||
Reference in New Issue
Block a user