const DEVICE_TYPES = Object.freeze(['alpr', 'nvr', 'doorbell', 'feed', 'traffic', 'ip', 'drone']) const SOURCE_TYPES = Object.freeze(['mjpeg', 'hls']) const sanitizeStreamUrl = (url) => { if (typeof url !== 'string' || !url.trim()) return '' const u = url.trim() return (u.startsWith('https://') || u.startsWith('http://')) ? u : '' } /** @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} */ (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} */ (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 }