Files
kestrelos/server/utils/validation.js
Madison Grubb b0e8dd7ad9
Some checks failed
ci/woodpecker/pr/pr Pipeline failed
make kestrel a tak server, so that it can send and receive pois as cots data
2026-02-17 10:42:53 -05:00

129 lines
5.9 KiB
JavaScript

/**
* Validation schemas - pure functions for consistent input validation.
*/
import { sanitizeString, sanitizeIdentifier, sanitizeLabel } from './sanitize.js'
import { DEVICE_TYPES, SOURCE_TYPES } from './deviceUtils.js'
import { POI_ICON_TYPES } from './poiConstants.js'
const ROLES = ['admin', 'leader', 'member']
const validateNumber = (value, field) => {
const num = Number(value)
return Number.isFinite(num) ? { valid: true, value: num } : { valid: false, error: `${field} must be a finite number` }
}
const validateEnum = (value, allowed, field) => allowed.includes(value) ? { valid: true, value } : { valid: false, error: `Invalid ${field}` }
const handleField = (d, field, handler, updates, errors, outputField = null) => {
if (d[field] !== undefined) {
const result = handler(d[field])
if (result.valid) updates[outputField || field] = result.value
else errors.push(result.error)
}
}
export function validateDevice(data) {
if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] }
const d = /** @type {Record<string, unknown>} */ (data)
const errors = []
const latCheck = validateNumber(d.lat, 'lat')
const lngCheck = validateNumber(d.lng, 'lng')
if (!latCheck.valid || !lngCheck.valid) errors.push('lat and lng required as finite numbers')
if (errors.length > 0) return { valid: false, errors }
return {
valid: true,
errors: [],
data: {
name: sanitizeString(d.name, 1000),
device_type: validateEnum(d.device_type, DEVICE_TYPES, 'device_type').value || 'feed',
vendor: d.vendor !== undefined ? sanitizeString(d.vendor, 255) : null,
lat: latCheck.value,
lng: lngCheck.value,
stream_url: typeof d.stream_url === 'string' ? sanitizeString(d.stream_url, 2000) : '',
source_type: validateEnum(d.source_type, SOURCE_TYPES, 'source_type').value || 'mjpeg',
config: d.config == null ? null : (typeof d.config === 'string' ? d.config : JSON.stringify(d.config)),
},
}
}
export function validateUpdateDevice(data) {
if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} }
const d = /** @type {Record<string, unknown>} */ (data)
const errors = []
const updates = {}
if (d.name !== undefined) updates.name = sanitizeString(d.name, 1000)
handleField(d, 'device_type', v => validateEnum(v, DEVICE_TYPES, 'device_type'), updates, errors)
if (d.vendor !== undefined) updates.vendor = d.vendor === null || d.vendor === '' ? null : sanitizeString(d.vendor, 255)
handleField(d, 'lat', v => validateNumber(v, 'lat'), updates, errors)
handleField(d, 'lng', v => validateNumber(v, 'lng'), updates, errors)
if (d.stream_url !== undefined) updates.stream_url = sanitizeString(d.stream_url, 2000)
handleField(d, 'source_type', v => validateEnum(v, SOURCE_TYPES, 'source_type'), updates, errors)
if (d.config !== undefined) updates.config = d.config === null ? null : (typeof d.config === 'string' ? d.config : JSON.stringify(d.config))
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates }
}
export function validateUser(data) {
if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] }
const d = /** @type {Record<string, unknown>} */ (data)
const errors = []
const identifier = sanitizeIdentifier(d.identifier)
const password = typeof d.password === 'string' ? d.password : ''
const role = typeof d.role === 'string' ? d.role : ''
if (!identifier) errors.push('identifier required')
if (!password) errors.push('password required')
if (!role || !ROLES.includes(role)) errors.push('role must be admin, leader, or member')
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: { identifier, password, role: role || 'member' } }
}
export function validateUpdateUser(data) {
if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} }
const d = /** @type {Record<string, unknown>} */ (data)
const errors = []
const updates = {}
if (d.role !== undefined) {
if (ROLES.includes(d.role)) updates.role = d.role
else errors.push('role must be admin, leader, or member')
}
if (d.identifier !== undefined) {
const identifier = sanitizeIdentifier(d.identifier)
if (!identifier) errors.push('identifier cannot be empty')
else updates.identifier = identifier
}
if (d.password !== undefined && d.password !== '') {
if (typeof d.password !== 'string' || !d.password) errors.push('password cannot be empty')
else updates.password = d.password
}
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates }
}
export function validatePoi(data) {
if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] }
const d = /** @type {Record<string, unknown>} */ (data)
const latCheck = validateNumber(d.lat, 'lat')
const lngCheck = validateNumber(d.lng, 'lng')
if (!latCheck.valid || !lngCheck.valid) return { valid: false, errors: ['lat and lng required as finite numbers'] }
return {
valid: true,
errors: [],
data: {
lat: latCheck.value,
lng: lngCheck.value,
label: sanitizeLabel(d.label, 500),
icon_type: validateEnum(d.iconType, POI_ICON_TYPES, 'iconType').value || 'pin',
},
}
}
export function validateUpdatePoi(data) {
if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} }
const d = /** @type {Record<string, unknown>} */ (data)
const errors = []
const updates = {}
if (d.label !== undefined) updates.label = sanitizeLabel(d.label, 500)
handleField(d, 'iconType', v => validateEnum(v, POI_ICON_TYPES, 'iconType'), updates, errors, 'icon_type')
handleField(d, 'lat', v => validateNumber(v, 'lat'), updates, errors)
handleField(d, 'lng', v => validateNumber(v, 'lng'), updates, errors)
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates }
}