initial commit
This commit is contained in:
3
server/api/auth/config.get.js
Normal file
3
server/api/auth/config.get.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getAuthConfig } from '../../utils/authConfig.js'
|
||||
|
||||
export default defineEventHandler(() => getAuthConfig())
|
||||
34
server/api/auth/login.post.js
Normal file
34
server/api/auth/login.post.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { setCookie } from 'h3'
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { verifyPassword } from '../../utils/password.js'
|
||||
import { getSessionMaxAgeDays } from '../../utils/session.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const identifier = body?.identifier?.trim()
|
||||
const password = body?.password
|
||||
if (!identifier || typeof password !== 'string') {
|
||||
throw createError({ statusCode: 400, message: 'identifier and password required' })
|
||||
}
|
||||
const { get, run } = await getDb()
|
||||
const user = await get('SELECT id, identifier, role, password_hash FROM users WHERE identifier = ?', [identifier])
|
||||
if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) {
|
||||
throw createError({ statusCode: 401, message: 'Invalid credentials' })
|
||||
}
|
||||
const sessionDays = getSessionMaxAgeDays()
|
||||
const sid = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
const expires = new Date(now.getTime() + sessionDays * 24 * 60 * 60 * 1000)
|
||||
await run(
|
||||
'INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[sid, user.id, now.toISOString(), expires.toISOString()],
|
||||
)
|
||||
setCookie(event, 'session_id', sid, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: sessionDays * 24 * 60 * 60,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
})
|
||||
return { user: { id: user.id, identifier: user.identifier, role: user.role } }
|
||||
})
|
||||
18
server/api/auth/logout.post.js
Normal file
18
server/api/auth/logout.post.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { deleteCookie, getCookie } from 'h3'
|
||||
import { getDb } from '../../utils/db.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const sid = getCookie(event, 'session_id')
|
||||
if (sid) {
|
||||
try {
|
||||
const { run } = await getDb()
|
||||
await run('DELETE FROM sessions WHERE id = ?', [sid])
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
deleteCookie(event, 'session_id', { path: '/' })
|
||||
}
|
||||
setResponseStatus(event, 204)
|
||||
return null
|
||||
})
|
||||
41
server/api/auth/oidc/authorize.get.js
Normal file
41
server/api/auth/oidc/authorize.get.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getAuthConfig } from '../../../utils/authConfig.js'
|
||||
import {
|
||||
getOidcConfig,
|
||||
getOidcRedirectUri,
|
||||
createOidcParams,
|
||||
getCodeChallenge,
|
||||
buildAuthorizeUrl,
|
||||
} from '../../../utils/oidc.js'
|
||||
|
||||
const SCOPES = process.env.OIDC_SCOPES || 'openid profile email'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { oidc: { enabled } } = getAuthConfig()
|
||||
if (!enabled) throw createError({ statusCode: 400, message: 'OIDC not enabled' })
|
||||
|
||||
const config = await getOidcConfig()
|
||||
if (!config) throw createError({ statusCode: 500, message: 'OIDC not configured' })
|
||||
|
||||
const redirectUri = getOidcRedirectUri()
|
||||
const { state, nonce, codeVerifier } = createOidcParams()
|
||||
const codeChallenge = await getCodeChallenge(codeVerifier)
|
||||
|
||||
const params = {
|
||||
redirect_uri: redirectUri,
|
||||
scope: SCOPES,
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
}
|
||||
|
||||
const url = buildAuthorizeUrl(config, params)
|
||||
setCookie(event, 'oidc_state', JSON.stringify({ state, nonce, codeVerifier }), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 600,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
})
|
||||
return sendRedirect(event, url.href, 302)
|
||||
})
|
||||
96
server/api/auth/oidc/callback.get.js
Normal file
96
server/api/auth/oidc/callback.get.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { getCookie, deleteCookie, setCookie, getRequestURL } from 'h3'
|
||||
import {
|
||||
getOidcConfig,
|
||||
constantTimeCompare,
|
||||
validateRedirectPath,
|
||||
exchangeCode,
|
||||
} from '../../../utils/oidc.js'
|
||||
import { getDb } from '../../../utils/db.js'
|
||||
import { getSessionMaxAgeDays } from '../../../utils/session.js'
|
||||
|
||||
const DEFAULT_ROLE = process.env.OIDC_DEFAULT_ROLE || 'member'
|
||||
|
||||
function getIdentifier(claims) {
|
||||
return claims?.email ?? claims?.preferred_username ?? claims?.name ?? claims?.sub ?? 'oidc-user'
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const code = query?.code
|
||||
const state = query?.state
|
||||
if (!code || !state) throw createError({ statusCode: 400, message: 'Invalid request' })
|
||||
|
||||
const cookieRaw = getCookie(event, 'oidc_state')
|
||||
if (!cookieRaw) throw createError({ statusCode: 400, message: 'Invalid request' })
|
||||
let stored
|
||||
try {
|
||||
stored = JSON.parse(cookieRaw)
|
||||
}
|
||||
catch {
|
||||
throw createError({ statusCode: 400, message: 'Invalid request' })
|
||||
}
|
||||
if (!stored?.state || !constantTimeCompare(state, stored.state)) {
|
||||
throw createError({ statusCode: 400, message: 'Invalid request' })
|
||||
}
|
||||
|
||||
const config = await getOidcConfig()
|
||||
if (!config) throw createError({ statusCode: 500, message: 'OIDC not configured' })
|
||||
|
||||
const currentUrl = getRequestURL(event)
|
||||
const checks = {
|
||||
expectedState: state,
|
||||
expectedNonce: stored.nonce,
|
||||
pkceCodeVerifier: stored.codeVerifier,
|
||||
}
|
||||
|
||||
let tokens
|
||||
try {
|
||||
tokens = await exchangeCode(config, currentUrl, checks)
|
||||
}
|
||||
catch {
|
||||
deleteCookie(event, 'oidc_state', { path: '/' })
|
||||
throw createError({ statusCode: 401, message: 'Authentication failed' })
|
||||
}
|
||||
|
||||
deleteCookie(event, 'oidc_state', { path: '/' })
|
||||
|
||||
const claims = tokens.claims?.()
|
||||
if (!claims?.sub) throw createError({ statusCode: 401, message: 'Authentication failed' })
|
||||
|
||||
const issuer = process.env.OIDC_ISSUER ?? ''
|
||||
const { get, run } = await getDb()
|
||||
let user = await get(
|
||||
'SELECT id, identifier, role FROM users WHERE oidc_issuer = ? AND oidc_sub = ?',
|
||||
[issuer, claims.sub],
|
||||
)
|
||||
if (!user) {
|
||||
const id = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
const identifier = getIdentifier(claims)
|
||||
await run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, identifier, null, DEFAULT_ROLE, now, 'oidc', issuer, claims.sub],
|
||||
)
|
||||
user = await get('SELECT id, identifier, role FROM users WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
const sessionDays = getSessionMaxAgeDays()
|
||||
const sid = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
const expires = new Date(now.getTime() + sessionDays * 24 * 60 * 60 * 1000)
|
||||
await run(
|
||||
'INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[sid, user.id, now.toISOString(), expires.toISOString()],
|
||||
)
|
||||
setCookie(event, 'session_id', sid, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: sessionDays * 24 * 60 * 60,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
})
|
||||
|
||||
const redirectParam = query?.redirect
|
||||
const path = validateRedirectPath(redirectParam)
|
||||
return sendRedirect(event, path.startsWith('http') ? path : new URL(path, getRequestURL(event).origin).href, 302)
|
||||
})
|
||||
12
server/api/cameras.get.js
Normal file
12
server/api/cameras.get.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
import { getActiveSessions } from '../utils/liveSessions.js'
|
||||
import { rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
|
||||
|
||||
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)
|
||||
return { devices, liveSessions: sessions }
|
||||
})
|
||||
13
server/api/devices.get.js
Normal file
13
server/api/devices.get.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
import { rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event)
|
||||
const { all } = await getDb()
|
||||
const rows = await 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)
|
||||
return devices.map(sanitizeDeviceForResponse)
|
||||
})
|
||||
19
server/api/devices.post.js
Normal file
19
server/api/devices.post.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
import { validateDeviceBody, rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event, { role: 'adminOrLeader' })
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const { name, device_type, vendor, lat, lng, stream_url, source_type, config } = validateDeviceBody(body)
|
||||
const id = crypto.randomUUID()
|
||||
const { run, get } = await getDb()
|
||||
await run(
|
||||
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, name, device_type, vendor, lat, lng, stream_url, source_type, config],
|
||||
)
|
||||
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
||||
const device = rowToDevice(row)
|
||||
if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' })
|
||||
return sanitizeDeviceForResponse(device)
|
||||
})
|
||||
12
server/api/devices/[id].delete.js
Normal file
12
server/api/devices/[id].delete.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.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 { run } = await getDb()
|
||||
await run('DELETE FROM devices WHERE id = ?', [id])
|
||||
setResponseStatus(event, 204)
|
||||
return null
|
||||
})
|
||||
15
server/api/devices/[id].get.js
Normal file
15
server/api/devices/[id].get.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { rowToDevice, sanitizeDeviceForResponse } from '../../utils/deviceUtils.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event)
|
||||
const id = event.context.params?.id
|
||||
if (!id) throw createError({ statusCode: 400, message: 'id required' })
|
||||
const { get } = await getDb()
|
||||
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
||||
if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
|
||||
const device = rowToDevice(row)
|
||||
if (!device) throw createError({ statusCode: 500, message: 'Invalid device row' })
|
||||
return sanitizeDeviceForResponse(device)
|
||||
})
|
||||
57
server/api/devices/[id].patch.js
Normal file
57
server/api/devices/[id].patch.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { rowToDevice, sanitizeDeviceForResponse, DEVICE_TYPES, SOURCE_TYPES } from '../../utils/deviceUtils.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).catch(() => ({}))) || {}
|
||||
const updates = []
|
||||
const params = []
|
||||
if (typeof body.name === 'string') {
|
||||
updates.push('name = ?')
|
||||
params.push(body.name.trim())
|
||||
}
|
||||
if (DEVICE_TYPES.includes(body.device_type)) {
|
||||
updates.push('device_type = ?')
|
||||
params.push(body.device_type)
|
||||
}
|
||||
if (body.vendor !== undefined) {
|
||||
updates.push('vendor = ?')
|
||||
params.push(typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null)
|
||||
}
|
||||
if (Number.isFinite(body.lat)) {
|
||||
updates.push('lat = ?')
|
||||
params.push(body.lat)
|
||||
}
|
||||
if (Number.isFinite(body.lng)) {
|
||||
updates.push('lng = ?')
|
||||
params.push(body.lng)
|
||||
}
|
||||
if (typeof body.stream_url === 'string') {
|
||||
updates.push('stream_url = ?')
|
||||
params.push(body.stream_url.trim())
|
||||
}
|
||||
if (SOURCE_TYPES.includes(body.source_type)) {
|
||||
updates.push('source_type = ?')
|
||||
params.push(body.source_type)
|
||||
}
|
||||
if (body.config !== undefined) {
|
||||
updates.push('config = ?')
|
||||
params.push(typeof body.config === 'string' ? body.config : (body.config != null ? JSON.stringify(body.config) : null))
|
||||
}
|
||||
const { run, get } = await getDb()
|
||||
if (updates.length === 0) {
|
||||
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
||||
if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
|
||||
const device = rowToDevice(row)
|
||||
return device ? sanitizeDeviceForResponse(device) : row
|
||||
}
|
||||
params.push(id)
|
||||
await run(`UPDATE devices SET ${updates.join(', ')} WHERE id = ?`, params)
|
||||
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
||||
if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
|
||||
const device = rowToDevice(row)
|
||||
return device ? sanitizeDeviceForResponse(device) : row
|
||||
})
|
||||
7
server/api/live.get.js
Normal file
7
server/api/live.get.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getActiveSessions } from '../utils/liveSessions.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (!event.context.user) return []
|
||||
const sessions = await getActiveSessions()
|
||||
return sessions
|
||||
})
|
||||
35
server/api/live/[id].delete.js
Normal file
35
server/api/live/[id].delete.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { getLiveSession, deleteLiveSession } from '../../utils/liveSessions.js'
|
||||
import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const id = event.context.params?.id
|
||||
if (!id) throw createError({ statusCode: 400, message: 'id required' })
|
||||
|
||||
const session = getLiveSession(id)
|
||||
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
|
||||
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
|
||||
// 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
|
||||
await closeRouter(id)
|
||||
|
||||
deleteLiveSession(id)
|
||||
return { ok: true }
|
||||
})
|
||||
31
server/api/live/[id].patch.js
Normal file
31
server/api/live/[id].patch.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { getLiveSession, updateLiveSession } from '../../utils/liveSessions.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const id = event.context.params?.id
|
||||
if (!id) throw createError({ statusCode: 400, message: 'id required' })
|
||||
|
||||
const session = getLiveSession(id)
|
||||
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
|
||||
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const lat = Number(body?.lat)
|
||||
const lng = Number(body?.lng)
|
||||
const updates = {}
|
||||
if (Number.isFinite(lat)) updates.lat = lat
|
||||
if (Number.isFinite(lng)) updates.lng = lng
|
||||
if (Object.keys(updates).length) {
|
||||
updateLiveSession(id, updates)
|
||||
}
|
||||
|
||||
const updated = getLiveSession(id)
|
||||
return {
|
||||
id: updated.id,
|
||||
label: updated.label,
|
||||
lat: updated.lat,
|
||||
lng: updated.lng,
|
||||
updatedAt: updated.updatedAt,
|
||||
}
|
||||
})
|
||||
15
server/api/live/debug-request-host.get.js
Normal file
15
server/api/live/debug-request-host.get.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getRequestHost, getRequestURL } from 'h3'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
|
||||
/**
|
||||
* Diagnostic: returns the host the server sees for this request.
|
||||
* Use from the phone or laptop to verify the server receives the expected hostname (e.g. LAN IP).
|
||||
* Auth required.
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
requireAuth(event)
|
||||
return {
|
||||
host: getRequestHost(event),
|
||||
hostname: getRequestURL(event).hostname,
|
||||
}
|
||||
})
|
||||
40
server/api/live/start.post.js
Normal file
40
server/api/live/start.post.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import {
|
||||
createSession,
|
||||
getActiveSessionByUserId,
|
||||
deleteLiveSession,
|
||||
} from '../../utils/liveSessions.js'
|
||||
import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event, { role: 'adminOrLeader' })
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const label = typeof body?.label === 'string' ? body.label.trim() : ''
|
||||
|
||||
// Replace any existing live session for this user (one session per user)
|
||||
const existing = getActiveSessionByUserId(user.id)
|
||||
if (existing) {
|
||||
if (existing.producerId) {
|
||||
const producer = getProducer(existing.producerId)
|
||||
if (producer) producer.close()
|
||||
}
|
||||
if (existing.transportId) {
|
||||
const transport = getTransport(existing.transportId)
|
||||
if (transport) transport.close()
|
||||
}
|
||||
if (existing.routerId) {
|
||||
await closeRouter(existing.id).catch((err) => {
|
||||
console.error('[live.start] Error closing previous router:', err)
|
||||
})
|
||||
}
|
||||
deleteLiveSession(existing.id)
|
||||
console.log('[live.start] Replaced previous session:', existing.id)
|
||||
}
|
||||
|
||||
const session = createSession(user.id, label || `Live: ${user.identifier || 'User'}`)
|
||||
console.log('[live.start] Session created:', { id: session.id, userId: user.id, label: session.label })
|
||||
return {
|
||||
id: session.id,
|
||||
label: session.label,
|
||||
}
|
||||
})
|
||||
34
server/api/live/webrtc/connect-transport.post.js
Normal file
34
server/api/live/webrtc/connect-transport.post.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { requireAuth } from '../../../utils/authHelpers.js'
|
||||
import { getLiveSession } from '../../../utils/liveSessions.js'
|
||||
import { getTransport } from '../../../utils/mediasoup.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event) // Verify authentication
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const { sessionId, transportId, dtlsParameters } = body
|
||||
|
||||
if (!sessionId || !transportId || !dtlsParameters) {
|
||||
throw createError({ statusCode: 400, message: 'sessionId, transportId, and dtlsParameters required' })
|
||||
}
|
||||
|
||||
const session = getLiveSession(sessionId)
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||
}
|
||||
// Note: Both publisher and viewers can connect their own transports
|
||||
// The transportId ensures they can only connect transports they created
|
||||
|
||||
const transport = getTransport(transportId)
|
||||
if (!transport) {
|
||||
throw createError({ statusCode: 404, message: 'Transport not found' })
|
||||
}
|
||||
|
||||
try {
|
||||
await transport.connect({ dtlsParameters })
|
||||
return { connected: true }
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[connect-transport] Transport connect failed:', transportId, err.message || err)
|
||||
throw createError({ statusCode: 500, message: err.message || 'Transport connect failed' })
|
||||
}
|
||||
})
|
||||
55
server/api/live/webrtc/create-consumer.post.js
Normal file
55
server/api/live/webrtc/create-consumer.post.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { requireAuth } from '../../../utils/authHelpers.js'
|
||||
import { getLiveSession } from '../../../utils/liveSessions.js'
|
||||
import { getRouter, getTransport, getProducer, createConsumer } from '../../../utils/mediasoup.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event) // Verify authentication
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const { sessionId, transportId, rtpCapabilities } = body
|
||||
|
||||
if (!sessionId || !transportId || !rtpCapabilities) {
|
||||
throw createError({ statusCode: 400, message: 'sessionId, transportId, and rtpCapabilities required' })
|
||||
}
|
||||
|
||||
const session = getLiveSession(sessionId)
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 404, message: `Session not found: ${sessionId}` })
|
||||
}
|
||||
if (!session.producerId) {
|
||||
throw createError({ statusCode: 404, message: 'No producer available for this session' })
|
||||
}
|
||||
|
||||
const transport = getTransport(transportId)
|
||||
if (!transport) {
|
||||
throw createError({ statusCode: 404, message: `Transport not found: ${transportId}` })
|
||||
}
|
||||
|
||||
const producer = getProducer(session.producerId)
|
||||
if (!producer) {
|
||||
console.error('[create-consumer] Producer not found:', session.producerId)
|
||||
throw createError({ statusCode: 404, message: `Producer not found: ${session.producerId}` })
|
||||
}
|
||||
|
||||
if (producer.paused) {
|
||||
await producer.resume()
|
||||
}
|
||||
|
||||
if (producer.closed) {
|
||||
throw createError({ statusCode: 404, message: 'Producer is closed' })
|
||||
}
|
||||
|
||||
const router = await getRouter(sessionId)
|
||||
const canConsume = router.canConsume({ producerId: producer.id, rtpCapabilities })
|
||||
if (!canConsume) {
|
||||
throw createError({ statusCode: 400, message: 'Cannot consume this producer' })
|
||||
}
|
||||
|
||||
try {
|
||||
const { params } = await createConsumer(transport, producer, rtpCapabilities)
|
||||
return params
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[create-consumer] Error creating consumer:', err)
|
||||
throw createError({ statusCode: 500, message: `Failed to create consumer: ${err.message || String(err)}` })
|
||||
}
|
||||
})
|
||||
43
server/api/live/webrtc/create-producer.post.js
Normal file
43
server/api/live/webrtc/create-producer.post.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { requireAuth } from '../../../utils/authHelpers.js'
|
||||
import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js'
|
||||
import { getTransport, producers } from '../../../utils/mediasoup.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const { sessionId, transportId, kind, rtpParameters } = body
|
||||
|
||||
if (!sessionId || !transportId || !kind || !rtpParameters) {
|
||||
throw createError({ statusCode: 400, message: 'sessionId, transportId, kind, and rtpParameters required' })
|
||||
}
|
||||
|
||||
const session = getLiveSession(sessionId)
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||
}
|
||||
if (session.userId !== user.id) {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
|
||||
const transport = getTransport(transportId)
|
||||
if (!transport) {
|
||||
throw createError({ statusCode: 404, message: 'Transport not found' })
|
||||
}
|
||||
|
||||
const producer = await transport.produce({ kind, rtpParameters })
|
||||
producers.set(producer.id, producer)
|
||||
producer.on('close', () => {
|
||||
producers.delete(producer.id)
|
||||
const s = getLiveSession(sessionId)
|
||||
if (s && s.producerId === producer.id) {
|
||||
updateLiveSession(sessionId, { producerId: null })
|
||||
}
|
||||
})
|
||||
|
||||
updateLiveSession(sessionId, { producerId: producer.id })
|
||||
|
||||
return {
|
||||
id: producer.id,
|
||||
kind: producer.kind,
|
||||
}
|
||||
})
|
||||
39
server/api/live/webrtc/create-transport.post.js
Normal file
39
server/api/live/webrtc/create-transport.post.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getRequestURL } from 'h3'
|
||||
import { requireAuth } from '../../../utils/authHelpers.js'
|
||||
import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js'
|
||||
import { getRouter, createTransport } from '../../../utils/mediasoup.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const { sessionId, isProducer } = body
|
||||
|
||||
if (!sessionId) {
|
||||
throw createError({ statusCode: 400, message: 'sessionId required' })
|
||||
}
|
||||
|
||||
const session = getLiveSession(sessionId)
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||
}
|
||||
|
||||
// Only publisher (session owner) can create producer transport
|
||||
// Viewers can create consumer transports
|
||||
if (isProducer && session.userId !== user.id) {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
|
||||
const url = getRequestURL(event)
|
||||
const requestHost = url.hostname
|
||||
const router = await getRouter(sessionId)
|
||||
const { transport, params } = await createTransport(router, Boolean(isProducer), requestHost)
|
||||
|
||||
if (isProducer) {
|
||||
updateLiveSession(sessionId, {
|
||||
transportId: transport.id,
|
||||
routerId: router.id,
|
||||
})
|
||||
}
|
||||
|
||||
return params
|
||||
})
|
||||
20
server/api/live/webrtc/router-rtp-capabilities.get.js
Normal file
20
server/api/live/webrtc/router-rtp-capabilities.get.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { requireAuth } from '../../../utils/authHelpers.js'
|
||||
import { getLiveSession } from '../../../utils/liveSessions.js'
|
||||
import { getRouter } from '../../../utils/mediasoup.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event)
|
||||
const sessionId = getQuery(event).sessionId
|
||||
|
||||
if (!sessionId) {
|
||||
throw createError({ statusCode: 400, message: 'sessionId required' })
|
||||
}
|
||||
|
||||
const session = getLiveSession(sessionId)
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||
}
|
||||
|
||||
const router = await getRouter(sessionId)
|
||||
return router.rtpCapabilities
|
||||
})
|
||||
32
server/api/log.post.js
Normal file
32
server/api/log.post.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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 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)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
5
server/api/me.get.js
Normal file
5
server/api/me.get.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const user = event.context.user
|
||||
if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
|
||||
})
|
||||
40
server/api/me/password.put.js
Normal file
40
server/api/me/password.put.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { hashPassword, verifyPassword } from '../../utils/password.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const currentUser = requireAuth(event)
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const currentPassword = body?.currentPassword
|
||||
const newPassword = body?.newPassword
|
||||
|
||||
if (typeof currentPassword !== 'string' || currentPassword.length < 1) {
|
||||
throw createError({ statusCode: 400, message: 'Current password is required' })
|
||||
}
|
||||
if (typeof newPassword !== 'string' || newPassword.length < 1) {
|
||||
throw createError({ statusCode: 400, message: 'New password cannot be empty' })
|
||||
}
|
||||
|
||||
const { get, run } = await getDb()
|
||||
const user = await get(
|
||||
'SELECT id, password_hash, auth_provider FROM users WHERE id = ?',
|
||||
[currentUser.id],
|
||||
)
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, message: 'User not found' })
|
||||
}
|
||||
const authProvider = user.auth_provider ?? 'local'
|
||||
if (authProvider !== 'local') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Password change is only for local accounts. Use your identity provider to change password.',
|
||||
})
|
||||
}
|
||||
if (!verifyPassword(currentPassword, user.password_hash)) {
|
||||
throw createError({ statusCode: 400, message: 'Current password is incorrect' })
|
||||
}
|
||||
|
||||
const passwordHash = hashPassword(newPassword)
|
||||
await run('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, currentUser.id])
|
||||
return { ok: true }
|
||||
})
|
||||
7
server/api/pois.get.js
Normal file
7
server/api/pois.get.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const { all } = await getDb()
|
||||
const rows = await all('SELECT id, lat, lng, label, icon_type FROM pois ORDER BY id')
|
||||
return rows
|
||||
})
|
||||
23
server/api/pois.post.js
Normal file
23
server/api/pois.post.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
|
||||
const ICON_TYPES = ['pin', 'flag', 'waypoint']
|
||||
|
||||
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' })
|
||||
}
|
||||
const label = typeof body?.label === 'string' ? body.label.trim() : ''
|
||||
const iconType = ICON_TYPES.includes(body?.iconType) ? body.iconType : 'pin'
|
||||
const id = crypto.randomUUID()
|
||||
const { run } = await getDb()
|
||||
await run(
|
||||
'INSERT INTO pois (id, lat, lng, label, icon_type) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, lat, lng, label, iconType],
|
||||
)
|
||||
return { id, lat, lng, label, icon_type: iconType }
|
||||
})
|
||||
12
server/api/pois/[id].delete.js
Normal file
12
server/api/pois/[id].delete.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.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 { run } = await getDb()
|
||||
await run('DELETE FROM pois WHERE id = ?', [id])
|
||||
setResponseStatus(event, 204)
|
||||
return null
|
||||
})
|
||||
41
server/api/pois/[id].patch.js
Normal file
41
server/api/pois/[id].patch.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
|
||||
const ICON_TYPES = ['pin', 'flag', 'waypoint']
|
||||
|
||||
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 updates = []
|
||||
const params = []
|
||||
if (typeof body.label === 'string') {
|
||||
updates.push('label = ?')
|
||||
params.push(body.label.trim())
|
||||
}
|
||||
if (ICON_TYPES.includes(body.iconType)) {
|
||||
updates.push('icon_type = ?')
|
||||
params.push(body.iconType)
|
||||
}
|
||||
if (Number.isFinite(body.lat)) {
|
||||
updates.push('lat = ?')
|
||||
params.push(body.lat)
|
||||
}
|
||||
if (Number.isFinite(body.lng)) {
|
||||
updates.push('lng = ?')
|
||||
params.push(body.lng)
|
||||
}
|
||||
if (updates.length === 0) {
|
||||
const { get } = await getDb()
|
||||
const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
|
||||
if (!row) throw createError({ statusCode: 404, message: 'POI not found' })
|
||||
return row
|
||||
}
|
||||
params.push(id)
|
||||
const { run, get } = await getDb()
|
||||
await run(`UPDATE pois SET ${updates.join(', ')} WHERE id = ?`, params)
|
||||
const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
|
||||
if (!row) throw createError({ statusCode: 404, message: 'POI not found' })
|
||||
return row
|
||||
})
|
||||
12
server/api/users.get.js
Normal file
12
server/api/users.get.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
if (user.role !== 'admin' && user.role !== 'leader') {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
const { all } = await getDb()
|
||||
const rows = await all('SELECT id, identifier, role, auth_provider FROM users ORDER BY identifier')
|
||||
return rows.map(r => ({ id: r.id, identifier: r.identifier, role: r.role, auth_provider: r.auth_provider ?? 'local' }))
|
||||
})
|
||||
38
server/api/users.post.js
Normal file
38
server/api/users.post.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
import { hashPassword } from '../utils/password.js'
|
||||
|
||||
const ROLES = ['admin', 'leader', 'member']
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event, { role: 'admin' })
|
||||
const body = await readBody(event)
|
||||
const identifier = body?.identifier?.trim()
|
||||
const password = body?.password
|
||||
const role = body?.role
|
||||
|
||||
if (!identifier || identifier.length < 1) {
|
||||
throw createError({ statusCode: 400, message: 'identifier required' })
|
||||
}
|
||||
if (typeof password !== 'string' || password.length < 1) {
|
||||
throw createError({ statusCode: 400, message: 'password required' })
|
||||
}
|
||||
if (!role || !ROLES.includes(role)) {
|
||||
throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
|
||||
}
|
||||
|
||||
const { run, get } = await getDb()
|
||||
const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier])
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, message: 'Identifier already in use' })
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
await run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, identifier, hashPassword(password), role, now, 'local', null, null],
|
||||
)
|
||||
const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
||||
return user
|
||||
})
|
||||
24
server/api/users/[id].delete.js
Normal file
24
server/api/users/[id].delete.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const currentUser = requireAuth(event, { role: 'admin' })
|
||||
const id = event.context.params?.id
|
||||
if (!id) throw createError({ statusCode: 400, message: 'id required' })
|
||||
|
||||
if (id === currentUser.id) {
|
||||
throw createError({ statusCode: 400, message: 'Cannot delete your own account' })
|
||||
}
|
||||
|
||||
const { run, get } = await getDb()
|
||||
const user = await get('SELECT id, auth_provider FROM users WHERE id = ?', [id])
|
||||
if (!user) throw createError({ statusCode: 404, message: 'User not found' })
|
||||
if (user.auth_provider !== 'local') {
|
||||
throw createError({ statusCode: 403, message: 'Only local users can be deleted' })
|
||||
}
|
||||
|
||||
await run('DELETE FROM sessions WHERE user_id = ?', [id])
|
||||
await run('DELETE FROM users WHERE id = ?', [id])
|
||||
setResponseStatus(event, 204)
|
||||
return null
|
||||
})
|
||||
60
server/api/users/[id].patch.js
Normal file
60
server/api/users/[id].patch.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { hashPassword } from '../../utils/password.js'
|
||||
|
||||
const ROLES = ['admin', 'leader', 'member']
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event, { role: 'admin' })
|
||||
const id = event.context.params?.id
|
||||
if (!id) throw createError({ statusCode: 400, message: 'id required' })
|
||||
const body = await readBody(event)
|
||||
const { run, get } = await getDb()
|
||||
|
||||
const user = await get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
|
||||
if (!user) throw createError({ statusCode: 404, message: 'User not found' })
|
||||
|
||||
const updates = []
|
||||
const params = []
|
||||
|
||||
if (body?.role !== undefined) {
|
||||
const role = body.role
|
||||
if (!role || !ROLES.includes(role)) {
|
||||
throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
|
||||
}
|
||||
updates.push('role = ?')
|
||||
params.push(role)
|
||||
}
|
||||
|
||||
if (user.auth_provider === 'local') {
|
||||
if (body?.identifier !== undefined) {
|
||||
const identifier = body.identifier?.trim()
|
||||
if (!identifier || identifier.length < 1) {
|
||||
throw createError({ statusCode: 400, message: 'identifier cannot be empty' })
|
||||
}
|
||||
const existing = await get('SELECT id FROM users WHERE identifier = ? AND id != ?', [identifier, id])
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, message: 'Identifier already in use' })
|
||||
}
|
||||
updates.push('identifier = ?')
|
||||
params.push(identifier)
|
||||
}
|
||||
if (body?.password !== undefined && body.password !== '') {
|
||||
const password = body.password
|
||||
if (typeof password !== 'string' || password.length < 1) {
|
||||
throw createError({ statusCode: 400, message: 'password cannot be empty' })
|
||||
}
|
||||
updates.push('password_hash = ?')
|
||||
params.push(hashPassword(password))
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
|
||||
}
|
||||
|
||||
params.push(id)
|
||||
await run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params)
|
||||
const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
||||
return updated
|
||||
})
|
||||
22
server/middleware/auth.js
Normal file
22
server/middleware/auth.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getCookie } from 'h3'
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { skipAuth } from '../utils/authSkipPaths.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (skipAuth(event.path)) return
|
||||
const sid = getCookie(event, 'session_id')
|
||||
if (!sid) return
|
||||
try {
|
||||
const { get } = await getDb()
|
||||
const session = await get('SELECT user_id, expires_at FROM sessions WHERE id = ?', [sid])
|
||||
if (!session || new Date(session.expires_at) < new Date()) return
|
||||
const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [session.user_id])
|
||||
if (user) {
|
||||
const authProvider = user.auth_provider ?? 'local'
|
||||
event.context.user = { id: user.id, identifier: user.identifier, role: user.role, auth_provider: authProvider }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// ignore db errors; context stays unset
|
||||
}
|
||||
})
|
||||
15
server/plugins/db.init.js
Normal file
15
server/plugins/db.init.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getDb, closeDb } from '../utils/db.js'
|
||||
import { migrateFeedsToDevices } from '../utils/migrateFeedsToDevices.js'
|
||||
|
||||
/**
|
||||
* Initialize DB (and run bootstrap if no users) 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.
|
||||
*/
|
||||
// eslint-disable-next-line no-undef
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
void getDb().then(() => migrateFeedsToDevices())
|
||||
nitroApp.hooks.hook('close', () => {
|
||||
closeDb()
|
||||
})
|
||||
})
|
||||
188
server/plugins/websocket.js
Normal file
188
server/plugins/websocket.js
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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
|
||||
cookieHeader.split(';').forEach((cookie) => {
|
||||
const [name, ...valueParts] = cookie.trim().split('=')
|
||||
if (name && valueParts.length > 0) {
|
||||
cookies[name] = decodeURIComponent(valueParts.join('='))
|
||||
}
|
||||
})
|
||||
return cookies
|
||||
}
|
||||
|
||||
let wss = null
|
||||
const connections = new Map() // sessionId -> Set<WebSocket>
|
||||
|
||||
/**
|
||||
* 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())
|
||||
}
|
||||
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) {
|
||||
conns.delete(ws)
|
||||
if (conns.size === 0) {
|
||||
connections.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
for (const ws of conns) {
|
||||
if (ws.readyState === 1) { // OPEN
|
||||
ws.send(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('ready', async () => {
|
||||
const server = nitroApp.h3App.server || nitroApp.h3App.nodeServer
|
||||
if (!server) {
|
||||
console.warn('[websocket] Could not attach to HTTP server')
|
||||
return
|
||||
}
|
||||
|
||||
wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/ws',
|
||||
verifyClient: async (info, callback) => {
|
||||
// Verify session cookie on upgrade request
|
||||
const cookies = parseCookie(info.req.headers.cookie || '')
|
||||
const sessionId = cookies.session_id
|
||||
if (!sessionId) {
|
||||
callback(false, 401, 'Unauthorized')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { get } = await getDb()
|
||||
const session = await get('SELECT user_id, expires_at FROM sessions WHERE id = ?', [sessionId])
|
||||
if (!session || new Date(session.expires_at) < new Date()) {
|
||||
callback(false, 401, 'Unauthorized')
|
||||
return
|
||||
}
|
||||
// Store user_id in request for later use
|
||||
info.req.userId = session.user_id
|
||||
callback(true)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[websocket] Auth error:', err)
|
||||
callback(false, 500, 'Internal Server Error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const userId = req.userId
|
||||
if (!userId) {
|
||||
ws.close(1008, 'Unauthorized')
|
||||
return
|
||||
}
|
||||
|
||||
let currentSessionId = null
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString())
|
||||
const { sessionId, type } = message
|
||||
|
||||
if (!sessionId || !type) {
|
||||
ws.send(JSON.stringify({ error: 'Invalid message format' }))
|
||||
return
|
||||
}
|
||||
|
||||
// Track session connection
|
||||
if (currentSessionId !== sessionId) {
|
||||
if (currentSessionId) {
|
||||
removeSessionConnection(currentSessionId, ws)
|
||||
}
|
||||
currentSessionId = sessionId
|
||||
addSessionConnection(sessionId, ws)
|
||||
}
|
||||
|
||||
// Handle WebRTC signaling message
|
||||
const response = await handleWebSocketMessage(userId, sessionId, type, message.data || {})
|
||||
if (response) {
|
||||
ws.send(JSON.stringify(response))
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[websocket] Message error:', err)
|
||||
ws.send(JSON.stringify({ error: err.message || 'Internal error' }))
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
if (currentSessionId) {
|
||||
removeSessionConnection(currentSessionId, ws)
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('[websocket] Connection error:', err)
|
||||
})
|
||||
})
|
||||
|
||||
console.log('[websocket] WebSocket server started on /ws')
|
||||
})
|
||||
|
||||
nitroApp.hooks.hook('close', () => {
|
||||
if (wss) {
|
||||
wss.close()
|
||||
wss = null
|
||||
}
|
||||
})
|
||||
})
|
||||
7
server/routes/health.get.js
Normal file
7
server/routes/health.get.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default defineEventHandler(() => ({
|
||||
status: 'ok',
|
||||
endpoints: {
|
||||
live: '/health/live',
|
||||
ready: '/health/ready',
|
||||
},
|
||||
}))
|
||||
1
server/routes/health/live.get.js
Normal file
1
server/routes/health/live.get.js
Normal file
@@ -0,0 +1 @@
|
||||
export default defineEventHandler(() => ({ status: 'alive' }))
|
||||
1
server/routes/health/ready.get.js
Normal file
1
server/routes/health/ready.get.js
Normal file
@@ -0,0 +1 @@
|
||||
export default defineEventHandler(() => ({ status: 'ready' }))
|
||||
17
server/utils/authConfig.js
Normal file
17
server/utils/authConfig.js
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
20
server/utils/authHelpers.js
Normal file
20
server/utils/authHelpers.js
Normal 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
|
||||
}
|
||||
32
server/utils/authSkipPaths.js
Normal file
32
server/utils/authSkipPaths.js
Normal 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
155
server/utils/db.js
Normal 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()
|
||||
}
|
||||
83
server/utils/deviceUtils.js
Normal file
83
server/utils/deviceUtils.js
Normal 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
54
server/utils/feedUtils.js
Normal 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)
|
||||
}
|
||||
158
server/utils/liveSessions.js
Normal file
158
server/utils/liveSessions.js
Normal 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
250
server/utils/mediasoup.js
Normal 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())
|
||||
}
|
||||
27
server/utils/migrateFeedsToDevices.js
Normal file
27
server/utils/migrateFeedsToDevices.js
Normal 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
74
server/utils/oidc.js
Normal 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
30
server/utils/password.js
Normal 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
15
server/utils/session.js
Normal 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 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
|
||||
}
|
||||
59
server/utils/webrtcSignaling.js
Normal file
59
server/utils/webrtcSignaling.js
Normal 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' }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user