minor: heavily simplify server and app content. unify styling (#4)
All checks were successful
ci/woodpecker/push/push Pipeline was successful

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-02-14 04:52:18 +00:00
parent 1a143d2f8e
commit 17f28401ba
40 changed files with 595 additions and 933 deletions

View File

@@ -7,6 +7,6 @@ export default defineEventHandler(async (event) => {
requireAuth(event)
const [db, sessions] = await Promise.all([getDb(), getActiveSessions()])
const rows = await db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id')
const devices = rows.map(r => rowToDevice(r)).filter(Boolean).map(sanitizeDeviceForResponse)
const devices = rows.map(rowToDevice).filter(Boolean).map(sanitizeDeviceForResponse)
return { devices, liveSessions: sessions }
})

View File

@@ -1,32 +1,11 @@
/**
* Client-side logging endpoint.
* Accepts log messages from the browser and outputs them server-side.
*/
export default defineEventHandler(async (event) => {
// Note: Auth is optional - we rely on session cookie validation if needed
const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
export default defineEventHandler(async (event) => {
const body = await readBody(event).catch(() => ({}))
const { level, message, data, sessionId, userId } = body
const logPrefix = `[CLIENT${sessionId ? `:${sessionId}` : ''}${userId ? `:${userId.slice(0, 8)}` : ''}]`
const logMessage = data ? `${message} ${JSON.stringify(data)}` : message
switch (level) {
case 'error':
console.error(logPrefix, logMessage)
break
case 'warn':
console.warn(logPrefix, logMessage)
break
case 'info':
console.log(logPrefix, logMessage)
break
case 'debug':
console.log(logPrefix, logMessage)
break
default:
console.log(logPrefix, logMessage)
}
const prefix = `[CLIENT${sessionId ? `:${sessionId}` : ''}${userId ? `:${userId.slice(0, 8)}` : ''}]`
const msg = data ? `${message} ${JSON.stringify(data)}` : message
const method = CONSOLE_METHOD[level] || 'log'
console[method](prefix, msg)
return { ok: true }
})

View File

@@ -1,18 +1,15 @@
import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
const ICON_TYPES = ['pin', 'flag', 'waypoint']
import { POI_ICON_TYPES } from '../utils/poiConstants.js'
export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' })
const body = await readBody(event)
const lat = Number(body?.lat)
const lng = Number(body?.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
throw createError({ statusCode: 400, message: 'lat and lng required as numbers' })
}
if (!Number.isFinite(lat) || !Number.isFinite(lng)) throw createError({ statusCode: 400, message: 'lat and lng required as numbers' })
const label = typeof body?.label === 'string' ? body.label.trim() : ''
const iconType = ICON_TYPES.includes(body?.iconType) ? body.iconType : 'pin'
const iconType = POI_ICON_TYPES.includes(body?.iconType) ? body.iconType : 'pin'
const id = crypto.randomUUID()
const { run } = await getDb()
await run(

View File

@@ -1,20 +1,19 @@
import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
const ICON_TYPES = ['pin', 'flag', 'waypoint']
import { POI_ICON_TYPES } from '../../utils/poiConstants.js'
export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' })
const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = await readBody(event) || {}
const body = (await readBody(event)) || {}
const updates = []
const params = []
if (typeof body.label === 'string') {
updates.push('label = ?')
params.push(body.label.trim())
}
if (ICON_TYPES.includes(body.iconType)) {
if (POI_ICON_TYPES.includes(body.iconType)) {
updates.push('icon_type = ?')
params.push(body.iconType)
}

View File

@@ -1,9 +1,5 @@
import { getDb, closeDb } from '../utils/db.js'
/**
* Initialize DB at server startup.
* Close DB on server shutdown to avoid native sqlite3 crashes in worker teardown.
*/
export default defineNitroPlugin((nitroApp) => {
void getDb()
nitroApp.hooks.hook('close', () => {

View File

@@ -1,17 +1,7 @@
/**
* WebSocket server for WebRTC signaling.
* Attaches to Nitro's HTTP server and handles WebSocket connections.
*/
import { WebSocketServer } from 'ws'
import { getDb } from '../utils/db.js'
import { handleWebSocketMessage } from '../utils/webrtcSignaling.js'
/**
* Parse cookie header string into object.
* @param {string} cookieHeader
* @returns {Record<string, string>} Parsed cookie name-value pairs.
*/
function parseCookie(cookieHeader) {
const cookies = {}
if (!cookieHeader) return cookies
@@ -25,30 +15,16 @@ function parseCookie(cookieHeader) {
}
let wss = null
const connections = new Map() // sessionId -> Set<WebSocket>
const connections = new Map()
/**
* Get WebSocket server instance.
* @returns {WebSocketServer | null} WebSocket server instance or null.
*/
export function getWebSocketServer() {
return wss
}
/**
* Get connections for a session.
* @param {string} sessionId
* @returns {Set<WebSocket>} Set of WebSockets for the session.
*/
export function getSessionConnections(sessionId) {
return connections.get(sessionId) || new Set()
}
/**
* Add connection to session.
* @param {string} sessionId
* @param {WebSocket} ws
*/
export function addSessionConnection(sessionId, ws) {
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set())
@@ -56,11 +32,6 @@ export function addSessionConnection(sessionId, ws) {
connections.get(sessionId).add(ws)
}
/**
* Remove connection from session.
* @param {string} sessionId
* @param {WebSocket} ws
*/
export function removeSessionConnection(sessionId, ws) {
const conns = connections.get(sessionId)
if (conns) {
@@ -71,11 +42,6 @@ export function removeSessionConnection(sessionId, ws) {
}
}
/**
* Send message to all connections for a session.
* @param {string} sessionId
* @param {object} message
*/
export function broadcastToSession(sessionId, message) {
const conns = getSessionConnections(sessionId)
const data = JSON.stringify(message)

View File

@@ -1,17 +1,5 @@
/**
* 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,
},
}
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 } })
}

View File

@@ -1,20 +1,10 @@
/**
* 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.
*/
const ROLES_ADMIN_OR_LEADER = Object.freeze(['admin', 'leader'])
export function requireAuth(event, opts = {}) {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
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' })
}
if (role === 'admin' && user.role !== 'admin') throw createError({ statusCode: 403, message: 'Forbidden' })
if (role === 'adminOrLeader' && !ROLES_ADMIN_OR_LEADER.includes(user.role)) throw createError({ statusCode: 403, message: 'Forbidden' })
return user
}

View File

@@ -1,30 +1,21 @@
/**
* 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 = [
/** 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 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 = [
/** 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

View File

@@ -1,13 +1,10 @@
import { randomBytes } from 'node:crypto'
import { hashPassword } from './password.js'
const DEFAULT_ADMIN_IDENTIFIER = 'admin'
const PASSWORD_CHARS = 'abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
const generateRandomPassword = () => {
const bytes = randomBytes(14)
return Array.from(bytes, b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
}
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')
@@ -15,7 +12,7 @@ export async function bootstrapAdmin(run, get) {
const email = process.env.BOOTSTRAP_EMAIL?.trim()
const password = process.env.BOOTSTRAP_PASSWORD
const identifier = (email && password) ? email : DEFAULT_ADMIN_IDENTIFIER
const identifier = (email && password) ? email : 'admin'
const plainPassword = (email && password) ? password : generateRandomPassword()
await run(

View File

@@ -0,0 +1 @@
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])

View File

@@ -1,15 +1,6 @@
const DEFAULT_DAYS = 7
const MIN_DAYS = 1
const MAX_DAYS = 365
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
/**
* 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
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
}

View File

@@ -1,19 +1,6 @@
/**
* 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) {