import { XMLParser } from 'fast-xml-parser' import { MAX_PAYLOAD_BYTES } from './constants.js' const TAK_MAGIC = 0xBF const TRADITIONAL_DELIMITER = Buffer.from('', 'utf8') /** * @param {Buffer} buf * @param {number} offset * @returns {{ value: number, bytesRead: number }} Decoded varint and bytes consumed. */ function readVarint(buf, offset) { let value = 0 let shift = 0 let bytesRead = 0 while (offset + bytesRead < buf.length) { const b = buf[offset + bytesRead] bytesRead += 1 value += (b & 0x7F) << shift if ((b & 0x80) === 0) return { value, bytesRead } shift += 7 if (shift > 28) return { value: 0, bytesRead: 0 } } return { value, bytesRead } } /** * TAK stream frame: 0xBF, varint length, payload. * @param {Buffer} buf * @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 const { value: length, bytesRead } = readVarint(buf, 1) if (length < 0 || length > MAX_PAYLOAD_BYTES) return null const bytesConsumed = 1 + bytesRead + length if (buf.length < bytesConsumed) return null return { payload: buf.subarray(1 + bytesRead, bytesConsumed), bytesConsumed } } /** * Traditional CoT: one XML message delimited by . * @param {Buffer} 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 const idx = buf.indexOf(TRADITIONAL_DELIMITER) if (idx === -1) return null const bytesConsumed = idx + TRADITIONAL_DELIMITER.length if (bytesConsumed > MAX_PAYLOAD_BYTES) return null return { payload: buf.subarray(0, bytesConsumed), bytesConsumed } } const xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', parseTagValue: false, ignoreDeclaration: true, ignorePiTags: true, processEntities: false, // Disable entity expansion to prevent XML bomb attacks maxAttributes: 100, parseAttributeValue: false, trimValues: true, parseTrueNumberOnly: false, arrayMode: false, stopNodes: [], // Could add depth limit here if needed }) /** * Case-insensitive key lookup in nested object. * @returns {unknown} Found value or undefined. */ function findInObject(obj, key) { if (!obj || typeof obj !== 'object') return undefined const k = key.toLowerCase() for (const [name, val] of Object.entries(obj)) { if (name.toLowerCase() === k) return val if (typeof val === 'object' && val !== null) { const found = findInObject(val, key) if (found !== undefined) return found } } return undefined } /** * Extract { username, password } from detail.auth (or __auth / credentials). * @returns {{ username: string, password: string } | null} Credentials or null if missing/invalid. */ function extractAuth(parsed) { const detail = findInObject(parsed, 'detail') if (!detail || typeof detail !== 'object') return null const auth = findInObject(detail, 'auth') ?? findInObject(detail, '__auth') ?? findInObject(detail, 'credentials') if (!auth || typeof auth !== 'object') return null const username = auth['@_username'] ?? auth['@_Username'] ?? auth.username const password = auth['@_password'] ?? auth['@_Password'] ?? auth.password if (typeof username !== 'string' || typeof password !== 'string' || !username.trim()) return null return { username: username.trim(), password } } /** * Parse CoT XML payload into auth or position. Does not mutate payload. * @param {Buffer} payload - UTF-8 XML * @returns {{ type: 'auth', username: string, password: string } | { type: 'cot', id: string, lat: number, lng: number, label: string, eventType: string } | null} Auth or position, or null. */ export function parseCotPayload(payload) { if (!payload?.length) return null const str = payload.toString('utf8').trim() if (!str.startsWith('<')) return null try { const parsed = xmlParser.parse(str) const event = findInObject(parsed, 'event') if (!event || typeof event !== 'object') return null const auth = extractAuth(parsed) if (auth) return { type: 'auth', username: auth.username, password: auth.password } const uid = String(event['@_uid'] ?? event.uid ?? '') const eventType = String(event['@_type'] ?? event.type ?? '') const point = findInObject(parsed, 'point') ?? findInObject(event, 'point') let lat = Number.NaN let lng = Number.NaN if (point && typeof point === 'object') { lat = Number(point['@_lat'] ?? point.lat) lng = Number(point['@_lon'] ?? point.lon ?? point['@_lng'] ?? point.lng) } if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null const detail = findInObject(parsed, 'detail') const contact = detail && typeof detail === 'object' ? (findInObject(detail, 'contact') ?? detail) : null const callsign = contact && typeof contact === 'object' ? (contact['@_callsign'] ?? contact.callsign ?? contact['@_Callsign']) : '' const label = typeof callsign === 'string' ? callsign.trim() || uid : uid return { type: 'cot', id: uid, lat, lng, label, eventType } } catch { return null } }