This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
import { getAuthConfig } from '../../utils/authConfig.js'
|
||||
import { getAuthConfig } from '../../utils/oidc.js'
|
||||
|
||||
export default defineEventHandler(() => getAuthConfig())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { setCookie } from 'h3'
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { verifyPassword } from '../../utils/password.js'
|
||||
import { getSessionMaxAgeDays } from '../../utils/session.js'
|
||||
import { getSessionMaxAgeDays } from '../../utils/constants.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
@@ -15,10 +15,10 @@ export default defineEventHandler(async (event) => {
|
||||
if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) {
|
||||
throw createError({ statusCode: 401, message: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
|
||||
// Invalidate all existing sessions for this user to prevent session fixation
|
||||
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
|
||||
|
||||
|
||||
const sessionDays = getSessionMaxAgeDays()
|
||||
const sid = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getAuthConfig } from '../../../utils/authConfig.js'
|
||||
import {
|
||||
getAuthConfig,
|
||||
getOidcConfig,
|
||||
getOidcRedirectUri,
|
||||
createOidcParams,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
exchangeCode,
|
||||
} from '../../../utils/oidc.js'
|
||||
import { getDb } from '../../../utils/db.js'
|
||||
import { getSessionMaxAgeDays } from '../../../utils/session.js'
|
||||
import { getSessionMaxAgeDays } from '../../../utils/constants.js'
|
||||
|
||||
const DEFAULT_ROLE = process.env.OIDC_DEFAULT_ROLE || 'member'
|
||||
|
||||
@@ -76,7 +76,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
// Invalidate all existing sessions for this user to prevent session fixation
|
||||
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
|
||||
|
||||
|
||||
const sessionDays = getSessionMaxAgeDays()
|
||||
const sid = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
@@ -15,12 +15,12 @@ export default defineEventHandler(async (event) => {
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 404, message: `Session not found: ${sessionId}` })
|
||||
}
|
||||
|
||||
|
||||
// Authorization check: only session owner or admin/leader can consume
|
||||
if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
|
||||
|
||||
if (!session.producerId) {
|
||||
throw createError({ statusCode: 404, message: 'No producer available for this session' })
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import { requireAuth } from '../../utils/authHelpers.js'
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
if (!user.avatar_path) return { ok: true }
|
||||
|
||||
|
||||
// Validate avatar path to prevent path traversal attacks
|
||||
const filename = user.avatar_path
|
||||
if (!filename || !/^[a-f0-9-]+\.(jpg|jpeg|png)$/i.test(filename)) {
|
||||
if (!filename || !/^[a-f0-9-]+\.(?:jpg|jpeg|png)$/i.test(filename)) {
|
||||
throw createError({ statusCode: 400, message: 'Invalid avatar path' })
|
||||
}
|
||||
|
||||
|
||||
const path = join(getAvatarsDir(), filename)
|
||||
await unlink(path).catch(() => {})
|
||||
const { run } = await getDb()
|
||||
|
||||
@@ -8,13 +8,13 @@ const MIME = Object.freeze({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
if (!user.avatar_path) throw createError({ statusCode: 404, message: 'No avatar' })
|
||||
|
||||
|
||||
// Validate avatar path to prevent path traversal attacks
|
||||
const filename = user.avatar_path
|
||||
if (!filename || !/^[a-f0-9-]+\.(jpg|jpeg|png)$/i.test(filename)) {
|
||||
if (!filename || !/^[a-f0-9-]+\.(?:jpg|jpeg|png)$/i.test(filename)) {
|
||||
throw createError({ statusCode: 400, message: 'Invalid avatar path' })
|
||||
}
|
||||
|
||||
|
||||
const path = join(getAvatarsDir(), filename)
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
const mime = MIME[ext] ?? 'application/octet-stream'
|
||||
|
||||
@@ -34,13 +34,13 @@ export default defineEventHandler(async (event) => {
|
||||
if (file.data.length > MAX_SIZE) throw createError({ statusCode: 400, message: 'File too large' })
|
||||
const mime = file.type ?? ''
|
||||
if (!ALLOWED_TYPES.includes(mime)) throw createError({ statusCode: 400, message: 'Invalid type; use JPEG or PNG' })
|
||||
|
||||
|
||||
// Validate file content matches declared MIME type
|
||||
const actualMime = validateImageContent(file.data)
|
||||
if (!actualMime || actualMime !== mime) {
|
||||
throw createError({ statusCode: 400, message: 'File content does not match declared type' })
|
||||
}
|
||||
|
||||
|
||||
const ext = EXT_BY_MIME[actualMime] ?? 'jpg'
|
||||
const filename = `${user.id}.${ext}`
|
||||
const dir = getAvatarsDir()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
import { POI_ICON_TYPES } from '../utils/poiConstants.js'
|
||||
import { POI_ICON_TYPES } from '../utils/validation.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event, { role: 'adminOrLeader' })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { POI_ICON_TYPES } from '../../utils/poiConstants.js'
|
||||
import { POI_ICON_TYPES } from '../../utils/validation.js'
|
||||
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getCookie } from 'h3'
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { skipAuth } from '../utils/authSkipPaths.js'
|
||||
import { skipAuth } from '../utils/authHelpers.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (skipAuth(event.path)) return
|
||||
|
||||
@@ -125,7 +125,7 @@ export default defineNitroPlugin((nitroApp) => {
|
||||
ws.send(JSON.stringify({ error: 'Session not found' }))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Only session owner or admin/leader can access the session
|
||||
if (session.userId !== userId && userRole !== 'admin' && userRole !== 'leader') {
|
||||
ws.send(JSON.stringify({ error: 'Forbidden' }))
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export function getAuthConfig() {
|
||||
const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
|
||||
const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
|
||||
return Object.freeze({ oidc: { enabled: hasOidc, label } })
|
||||
}
|
||||
@@ -8,3 +8,26 @@ export function requireAuth(event, opts = {}) {
|
||||
if (role === 'adminOrLeader' && !ROLES_ADMIN_OR_LEADER.includes(user.role)) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
return user
|
||||
}
|
||||
|
||||
// Auth path utilities
|
||||
export const SKIP_PATHS = Object.freeze([
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/config',
|
||||
'/api/auth/oidc/authorize',
|
||||
'/api/auth/oidc/callback',
|
||||
])
|
||||
|
||||
export const PROTECTED_PATH_PREFIXES = Object.freeze([
|
||||
'/api/cameras',
|
||||
'/api/devices',
|
||||
'/api/live',
|
||||
'/api/me',
|
||||
'/api/pois',
|
||||
'/api/users',
|
||||
])
|
||||
|
||||
export function skipAuth(path) {
|
||||
if (path.startsWith('/api/health') || path === '/health') return true
|
||||
return SKIP_PATHS.some(p => path === p || path.startsWith(p + '/'))
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/** Paths that skip auth (no session required). Do not add if any handler uses requireAuth. */
|
||||
export const SKIP_PATHS = Object.freeze([
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/config',
|
||||
'/api/auth/oidc/authorize',
|
||||
'/api/auth/oidc/callback',
|
||||
])
|
||||
|
||||
/** Path prefixes for protected routes. Used by tests to ensure they're never in SKIP_PATHS. */
|
||||
export const PROTECTED_PATH_PREFIXES = Object.freeze([
|
||||
'/api/cameras',
|
||||
'/api/devices',
|
||||
'/api/live',
|
||||
'/api/me',
|
||||
'/api/pois',
|
||||
'/api/users',
|
||||
])
|
||||
|
||||
export function skipAuth(path) {
|
||||
if (path.startsWith('/api/health') || path === '/health') return true
|
||||
return SKIP_PATHS.some(p => path === p || path.startsWith(p + '/'))
|
||||
}
|
||||
26
server/utils/bootstrap.js
vendored
26
server/utils/bootstrap.js
vendored
@@ -1,26 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { hashPassword } from './password.js'
|
||||
|
||||
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
|
||||
|
||||
const generateRandomPassword = () =>
|
||||
Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
|
||||
|
||||
export async function bootstrapAdmin(run, get) {
|
||||
const row = await get('SELECT COUNT(*) as n FROM users')
|
||||
if (row?.n !== 0) return
|
||||
|
||||
const email = process.env.BOOTSTRAP_EMAIL?.trim()
|
||||
const password = process.env.BOOTSTRAP_PASSWORD
|
||||
const identifier = (email && password) ? email : 'admin'
|
||||
const plainPassword = (email && password) ? password : generateRandomPassword()
|
||||
|
||||
await run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
|
||||
)
|
||||
|
||||
if (!email || !password) {
|
||||
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,10 @@ export const MAX_IDENTIFIER_LENGTH = Number(process.env.MAX_IDENTIFIER_LENGTH) |
|
||||
// Mediasoup
|
||||
export const MEDIASOUP_RTC_MIN_PORT = Number(process.env.MEDIASOUP_RTC_MIN_PORT) || 40000
|
||||
export const MEDIASOUP_RTC_MAX_PORT = Number(process.env.MEDIASOUP_RTC_MAX_PORT) || 49999
|
||||
|
||||
// Session
|
||||
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
|
||||
export function getSessionMaxAgeDays() {
|
||||
const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
|
||||
return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
import { MAX_PAYLOAD_BYTES } from './constants.js'
|
||||
|
||||
const TAK_MAGIC = 0xBF
|
||||
// CoT protocol detection constants
|
||||
export const COT_FIRST_BYTE_TAK = 0xBF
|
||||
export const COT_FIRST_BYTE_XML = 0x3C
|
||||
|
||||
/** @param {number} byte - First byte of stream. @returns {boolean} */
|
||||
export function isCotFirstByte(byte) {
|
||||
return byte === COT_FIRST_BYTE_TAK || byte === COT_FIRST_BYTE_XML
|
||||
}
|
||||
|
||||
const TRADITIONAL_DELIMITER = Buffer.from('</event>', 'utf8')
|
||||
|
||||
/**
|
||||
@@ -30,7 +38,7 @@ function readVarint(buf, offset) {
|
||||
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete/invalid.
|
||||
*/
|
||||
export function parseTakStreamFrame(buf) {
|
||||
if (!buf || buf.length < 2 || buf[0] !== TAK_MAGIC) return null
|
||||
if (!buf || buf.length < 2 || buf[0] !== COT_FIRST_BYTE_TAK) return null
|
||||
const { value: length, bytesRead } = readVarint(buf, 1)
|
||||
if (length < 0 || length > MAX_PAYLOAD_BYTES) return null
|
||||
const bytesConsumed = 1 + bytesRead + length
|
||||
@@ -44,7 +52,7 @@ export function parseTakStreamFrame(buf) {
|
||||
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete.
|
||||
*/
|
||||
export function parseTraditionalXmlFrame(buf) {
|
||||
if (!buf || buf.length < 8 || buf[0] !== 0x3C) return null
|
||||
if (!buf || buf.length < 8 || buf[0] !== COT_FIRST_BYTE_XML) return null
|
||||
const idx = buf.indexOf(TRADITIONAL_DELIMITER)
|
||||
if (idx === -1) return null
|
||||
const bytesConsumed = idx + TRADITIONAL_DELIMITER.length
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* CoT stream first-byte detection: TAK Protocol (0xBF) or traditional XML (0x3C '<').
|
||||
* Used by tests and any code that must distinguish CoT from other protocols.
|
||||
*/
|
||||
|
||||
export const COT_FIRST_BYTE_TAK = 0xBF
|
||||
export const COT_FIRST_BYTE_XML = 0x3C
|
||||
|
||||
/** @param {number} byte - First byte of stream. @returns {boolean} */
|
||||
export function isCotFirstByte(byte) {
|
||||
return byte === COT_FIRST_BYTE_TAK || byte === COT_FIRST_BYTE_XML
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { join, dirname } from 'node:path'
|
||||
import { mkdirSync, existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { promisify } from 'node:util'
|
||||
import { bootstrapAdmin } from './bootstrap.js'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { hashPassword } from './password.js'
|
||||
import { registerCleanup } from './shutdown.js'
|
||||
|
||||
// Resolve from project root so bundled server (e.g. .output) finds node_modules/sqlite3
|
||||
@@ -152,7 +153,29 @@ const initDb = async (db, run, all, get) => {
|
||||
await run(SCHEMA.pois)
|
||||
await run(SCHEMA.devices)
|
||||
|
||||
if (!testPath) await bootstrapAdmin(run, get)
|
||||
if (!testPath) {
|
||||
// Bootstrap admin user on first run
|
||||
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
|
||||
const generateRandomPassword = () =>
|
||||
Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
|
||||
|
||||
const row = await get('SELECT COUNT(*) as n FROM users')
|
||||
if (row?.n === 0) {
|
||||
const email = process.env.BOOTSTRAP_EMAIL?.trim()
|
||||
const password = process.env.BOOTSTRAP_PASSWORD
|
||||
const identifier = (email && password) ? email : 'admin'
|
||||
const plainPassword = (email && password) ? password : generateRandomPassword()
|
||||
|
||||
await run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
|
||||
)
|
||||
|
||||
if (!email || !password) {
|
||||
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDb() {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Custom error classes and error handling utilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base application error.
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
|
||||
super(message)
|
||||
this.name = this.constructor.name
|
||||
this.statusCode = statusCode
|
||||
this.code = code
|
||||
Error.captureStackTrace(this, this.constructor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error (400).
|
||||
*/
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message, details = null) {
|
||||
super(message, 400, 'VALIDATION_ERROR')
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not found error (404).
|
||||
*/
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource = 'Resource') {
|
||||
super(`${resource} not found`, 404, 'NOT_FOUND')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthorized error (401).
|
||||
*/
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(message, 401, 'UNAUTHORIZED')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forbidden error (403).
|
||||
*/
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(message, 403, 'FORBIDDEN')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict error (409).
|
||||
*/
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message = 'Conflict') {
|
||||
super(message, 409, 'CONFLICT')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error response for API.
|
||||
* @param {Error} error - Error object
|
||||
* @returns {object} Formatted error response
|
||||
*/
|
||||
export function formatErrorResponse(error) {
|
||||
if (error instanceof AppError) {
|
||||
return {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
...(error.details && { details: error.details }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: error?.message || 'Internal server error',
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import * as oidc from 'openid-client'
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||
const configCache = new Map()
|
||||
|
||||
// Auth configuration
|
||||
export function getAuthConfig() {
|
||||
const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
|
||||
const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
|
||||
return Object.freeze({ oidc: { enabled: hasOidc, label } })
|
||||
}
|
||||
|
||||
function getRedirectUri() {
|
||||
const explicit
|
||||
= process.env.OIDC_REDIRECT_URI ?? process.env.OPENID_REDIRECT_URI ?? ''
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Reusable query functions - eliminates SQL duplication across routes.
|
||||
*/
|
||||
|
||||
const updateEntity = async (db, table, id, updates, getById) => {
|
||||
if (Object.keys(updates).length === 0) return getById(db, id)
|
||||
const { buildUpdateQuery } = await import('./queryBuilder.js')
|
||||
const { query, params } = buildUpdateQuery(table, null, updates)
|
||||
if (!query) return getById(db, id)
|
||||
await db.run(query, [...params, id])
|
||||
return getById(db, id)
|
||||
}
|
||||
|
||||
export async function getDeviceById(db, id) {
|
||||
const result = await db.get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
||||
return result || null
|
||||
}
|
||||
|
||||
export async function getAllDevices(db) {
|
||||
return db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id')
|
||||
}
|
||||
|
||||
export async function createDevice(db, data) {
|
||||
const id = crypto.randomUUID()
|
||||
await db.run(
|
||||
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, data.name, data.device_type, data.vendor, data.lat, data.lng, data.stream_url, data.source_type, data.config],
|
||||
)
|
||||
return getDeviceById(db, id)
|
||||
}
|
||||
|
||||
export async function updateDevice(db, id, updates) {
|
||||
return updateEntity(db, 'devices', id, updates, getDeviceById)
|
||||
}
|
||||
|
||||
export async function getUserById(db, id) {
|
||||
const result = await db.get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
|
||||
return result || null
|
||||
}
|
||||
|
||||
export async function getUserByIdentifier(db, identifier) {
|
||||
const result = await db.get('SELECT id, identifier, role, password_hash FROM users WHERE identifier = ?', [identifier])
|
||||
return result || null
|
||||
}
|
||||
|
||||
export async function createUser(db, data) {
|
||||
const id = crypto.randomUUID()
|
||||
await db.run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, data.identifier, data.password_hash, data.role, data.created_at, data.auth_provider || 'local', data.oidc_issuer || null, data.oidc_sub || null],
|
||||
)
|
||||
return db.get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
export async function updateUser(db, id, updates) {
|
||||
if (Object.keys(updates).length === 0) return getUserById(db, id)
|
||||
const { buildUpdateQuery } = await import('./queryBuilder.js')
|
||||
const { query, params } = buildUpdateQuery('users', null, updates)
|
||||
if (!query) return getUserById(db, id)
|
||||
await db.run(query, [...params, id])
|
||||
return db.get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
export async function getPoiById(db, id) {
|
||||
const result = await db.get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
|
||||
return result || null
|
||||
}
|
||||
|
||||
export async function getAllPois(db) {
|
||||
return db.all('SELECT id, lat, lng, label, icon_type FROM pois ORDER BY id')
|
||||
}
|
||||
|
||||
export async function createPoi(db, data) {
|
||||
const id = crypto.randomUUID()
|
||||
await db.run(
|
||||
'INSERT INTO pois (id, lat, lng, label, icon_type) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, data.lat, data.lng, data.label || '', data.icon_type || 'pin'],
|
||||
)
|
||||
return getPoiById(db, id)
|
||||
}
|
||||
|
||||
export async function updatePoi(db, id, updates) {
|
||||
return updateEntity(db, 'pois', id, updates, getPoiById)
|
||||
}
|
||||
|
||||
export async function getSessionById(db, id) {
|
||||
const result = await db.get('SELECT id, user_id, expires_at FROM sessions WHERE id = ?', [id])
|
||||
return result || null
|
||||
}
|
||||
|
||||
export async function createDbSession(db, data) {
|
||||
await db.run(
|
||||
'INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[data.id, data.user_id, data.created_at, data.expires_at],
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteSession(db, id) {
|
||||
await db.run('DELETE FROM sessions WHERE id = ?', [id])
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Input sanitization utilities - pure functions for cleaning user input.
|
||||
*/
|
||||
|
||||
import { MAX_IDENTIFIER_LENGTH, MAX_STRING_LENGTH } from './constants.js'
|
||||
|
||||
const IDENTIFIER_REGEX = /^\w+$/
|
||||
|
||||
export function sanitizeString(str, maxLength = MAX_STRING_LENGTH) {
|
||||
if (typeof str !== 'string') return ''
|
||||
const trimmed = str.trim()
|
||||
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
|
||||
}
|
||||
|
||||
export function sanitizeIdentifier(str) {
|
||||
if (typeof str !== 'string') return ''
|
||||
const trimmed = str.trim()
|
||||
if (trimmed.length === 0 || trimmed.length > MAX_IDENTIFIER_LENGTH) return ''
|
||||
return IDENTIFIER_REGEX.test(trimmed) ? trimmed : ''
|
||||
}
|
||||
|
||||
export function sanitizeLabel(str, maxLength = MAX_STRING_LENGTH) {
|
||||
return sanitizeString(str, maxLength)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
|
||||
|
||||
export function getSessionMaxAgeDays() {
|
||||
const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
|
||||
return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
|
||||
}
|
||||
@@ -1,10 +1,32 @@
|
||||
/**
|
||||
* Validation schemas - pure functions for consistent input validation.
|
||||
* Validation and sanitization utilities - pure functions for consistent input validation and cleaning.
|
||||
*/
|
||||
|
||||
import { sanitizeString, sanitizeIdentifier, sanitizeLabel } from './sanitize.js'
|
||||
import { MAX_IDENTIFIER_LENGTH, MAX_STRING_LENGTH } from './constants.js'
|
||||
import { DEVICE_TYPES, SOURCE_TYPES } from './deviceUtils.js'
|
||||
import { POI_ICON_TYPES } from './poiConstants.js'
|
||||
|
||||
// Constants
|
||||
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
|
||||
|
||||
// Sanitization functions
|
||||
const IDENTIFIER_REGEX = /^\w+$/
|
||||
|
||||
export function sanitizeString(str, maxLength = MAX_STRING_LENGTH) {
|
||||
if (typeof str !== 'string') return ''
|
||||
const trimmed = str.trim()
|
||||
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
|
||||
}
|
||||
|
||||
export function sanitizeIdentifier(str) {
|
||||
if (typeof str !== 'string') return ''
|
||||
const trimmed = str.trim()
|
||||
if (trimmed.length === 0 || trimmed.length > MAX_IDENTIFIER_LENGTH) return ''
|
||||
return IDENTIFIER_REGEX.test(trimmed) ? trimmed : ''
|
||||
}
|
||||
|
||||
export function sanitizeLabel(str, maxLength = MAX_STRING_LENGTH) {
|
||||
return sanitizeString(str, maxLength)
|
||||
}
|
||||
|
||||
const ROLES = ['admin', 'leader', 'member']
|
||||
|
||||
|
||||
Reference in New Issue
Block a user