minor: heavily simplify server and app content. unify styling (#4)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
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:
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
11
server/utils/bootstrap.js
vendored
11
server/utils/bootstrap.js
vendored
@@ -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(
|
||||
|
||||
1
server/utils/poiConstants.js
Normal file
1
server/utils/poiConstants.js
Normal file
@@ -0,0 +1 @@
|
||||
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
|
||||
@@ -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 1–365 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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user