UNPKG

@tak-ps/node-cot

Version:

Lightweight JavaScript library for parsing and manipulating TAK messages

473 lines 18.5 kB
import { createHash } from 'node:crypto'; import { pipeline } from 'stream/promises'; import { rimraf } from 'rimraf'; import { Type } from '@sinclair/typebox'; import Err from '@openaddresses/batch-error'; import archiver from 'archiver'; import StreamZip from 'node-stream-zip'; import { Readable } from 'node:stream'; import { v4 as randomUUID } from 'uuid'; import CoT from './cot.js'; import { CoTParser } from './parser.js'; import xmljs from 'xml-js'; import os from 'node:os'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; import AJV from 'ajv'; export const Parameter = Type.Object({ _attributes: Type.Object({ name: Type.String(), value: Type.String({ default: '' }) }) }); export const ManifestContent = Type.Object({ _attributes: Type.Object({ ignore: Type.Boolean(), zipEntry: Type.String() }), Parameter: Type.Union([Parameter, Type.Array(Parameter)]) }); export const Group = Type.Object({ _attributes: Type.Object({ name: Type.String(), }), }); export const Permission = Type.Object({ _attributes: Type.Object({ name: Type.String(), }), }); const MissionPackageManifest = Type.Object({ _attributes: Type.Object({ version: Type.String() }), Configuration: Type.Object({ Parameter: Type.Array(Parameter) }), Contents: Type.Object({ Content: Type.Optional(Type.Union([ManifestContent, Type.Array(ManifestContent)])) }), // Used in MissionArchive Exports Groups: Type.Optional(Type.Object({ group: Type.Optional(Type.Union([Group, Type.Array(Group)])) })), Role: Type.Optional(Type.Object({ _attributes: Type.Object({ name: Type.String() }), Permissions: Type.Optional(Type.Union([Permission, Type.Array(Permission)])) })) }); export const Manifest = Type.Object({ MissionPackageManifest }); const checkManifest = (new AJV({ strict: false, useDefaults: true, allErrors: true, coerceTypes: true, allowUnionTypes: true })) .compile(Manifest); /** * Helper class for creating and parsing static Data Packages * @class * * @prop path The local path to the Data Package working directory * @prop destroyed Indcates that the DataPackage has been destroyed and all local files removed * @prop version DataPackage schema version - 2 is most common * @prop contents Array Manifest of DataPackage contents * @prop settings Top level DataPackage settings */ export class DataPackage { path; destroyed; version; contents; settings; unknown; /** * @constructor * @param uid Unique ID of the Data Package * @param name Human Readable name of the DataPackage * @param opts Optional Options */ constructor(uid, name, opts = {}) { if (opts && opts.path) { this.path = opts.path; } else { this.path = os.tmpdir() + '/' + randomUUID(); } this.destroyed = false; fs.mkdirSync(this.path, { recursive: true }); this.version = '2'; this.unknown = {}; this.settings = { uid: uid ?? randomUUID(), name: name ?? 'New Data Package' }; this.contents = []; } /** * The Package should be imported and then removed */ setEphemeral() { this.settings.onReceiveImport = true; this.settings.onReceiveDelete = true; } /** * The Package should be imported and the package retained */ setPermanent() { this.settings.onReceiveImport = true; this.settings.onReceiveDelete = false; } /** * Return a string version of the Manifest document */ manifest() { const manifest = { MissionPackageManifest: { _attributes: { version: this.version }, Configuration: { Parameter: [] }, Contents: { Content: this.contents }, ...this.unknown } }; for (const key in this.settings) { if (!this.settings[key]) continue; manifest.MissionPackageManifest.Configuration.Parameter.push({ _attributes: { name: key, value: String(this.settings[key]) } }); } const xml = `<?xml version="1.0" encoding="UTF-8"?>\n${xmljs.js2xml(manifest, { compact: true })}`; return xml; } /** * Mission Sync archived are returned in DataPackage format * Return true if the DataPackage is a MissionSync Archive */ isMissionArchive() { return !!(this.settings.mission_guid && this.settings.mission_name); } /** * When DataPackages are uploaded to TAK Server they generally use an EUD * calculated Hash */ static async hash(path) { const input = fs.createReadStream(path); const hash = createHash('sha256'); await pipeline(input, hash); return hash.digest('hex'); } /** * When DataPackages are uploaded to TAK Server they generally use an EUD * calculated Hash */ async hash(entry) { return await DataPackage.hash(this.path + '/raw/' + entry); } /** * Return a DataPackage version of a raw Data Package Zip * * @public * @param input path to zipped DataPackage on disk * @param [opts] Parser Options * @param [opts.strict] By default the DataPackage must contain a manifest file, turning strict mode off will generate a manifest based on the contents of the file * @param [opts.cleanup] If the Zip is parsed as a DataSync successfully, remove the initial zip file */ static async parse(input, opts) { input = input instanceof URL ? path.normalize(decodeURIComponent(input.pathname)) : input; if (!opts) opts = {}; if (opts.strict === undefined) opts.strict = true; if (opts.cleanup === undefined) opts.cleanup = true; const pkg = new DataPackage(); const zip = new StreamZip.async({ file: input, skipEntryNameValidation: true }); const preentries = await zip.entries(); if (opts.strict && !preentries['MANIFEST/manifest.xml']) { throw new Err(400, null, 'No MANIFEST/manifest.xml found in Data Package'); } await fsp.mkdir(pkg.path + '/raw', { recursive: true }); await zip.extract(null, pkg.path + '/raw/'); if (preentries['MANIFEST/manifest.xml']) { const xml = xmljs.xml2js(String(await fsp.readFile(pkg.path + '/raw/MANIFEST/manifest.xml')), { compact: true }); checkManifest(xml); if (checkManifest.errors) throw new Err(400, null, `${checkManifest.errors[0].message} (${checkManifest.errors[0].instancePath})`); const manifest = xml; pkg.version = manifest.MissionPackageManifest._attributes.version; if (Array.isArray(manifest.MissionPackageManifest.Contents.Content)) { pkg.contents = manifest.MissionPackageManifest.Contents.Content; } else if (manifest.MissionPackageManifest.Contents.Content) { pkg.contents = [manifest.MissionPackageManifest.Contents.Content]; } for (const param of manifest.MissionPackageManifest.Configuration.Parameter) { if (['onReceiveImport', 'onReceiveDelete'].includes(param._attributes.name) && typeof param._attributes.value === 'string') { pkg.settings[param._attributes.name] = param._attributes.value === 'false' ? false : true; } else { pkg.settings[param._attributes.name] = param._attributes.value; } } for (const [key, value] of Object.entries(manifest.MissionPackageManifest)) { // Top level properties that are encoded in the class if (['_attributes', 'Contents', 'Configuration'].includes(key)) continue; pkg.unknown[key] = value; } } else { pkg.settings.name = path.parse(input).name; pkg.settings.uid = await this.hash(input); pkg.setEphemeral(); for (const [key, value] of Object.entries(preentries)) { if (value.isDirectory) continue; pkg.#addContent(key, await pkg.hash(key)); } } await zip.close(); if (opts.cleanup) { await fsp.unlink(input); } return pkg; } #addContent(zipEntry, uid, name, ignore = false) { if (this.destroyed) throw new Err(400, null, 'Attempt to access Data Package after it has been destroyed'); // TODO: Seen in the wild but not currently implemented here: // <Parameter name="contentType" value="KML"/> // <Parameter name="visible" value="false"/> this.contents.push({ _attributes: { ignore: ignore, zipEntry }, Parameter: [{ _attributes: { name: 'uid', value: uid }, }, { _attributes: { name: 'name', value: name ?? path.parse(zipEntry).base } }] }); } /** * Return CoT objects for all CoT type features in the Data Package * * CoTs have their `attachment_list` field populated if parseAttachments is set to true. * While this field is populated automatically by some ATAK actions such as QuickPic other attachment actions do not automatically populate this field other than the link provided between a CoT and it's attachment in the MANIFEST file */ async cots(opts = { respectIgnore: true, parseAttachments: true }) { if (this.destroyed) throw new Err(400, null, 'Attempt to access Data Package after it has been destroyed'); const cotsMap = new Map(); const cots = []; for (const content of this.contents) { if (!content) continue; if (path.parse(content._attributes.zipEntry).ext !== '.cot') continue; if (opts.respectIgnore && content._attributes.ignore) continue; const cot = CoTParser.from_xml(await fsp.readFile(this.path + '/raw/' + content._attributes.zipEntry)); cotsMap.set(cot.uid(), cot); cots.push(cot); } if (opts.parseAttachments) { const attachments = this.#attachments(cotsMap, { respectIgnore: opts.respectIgnore }); for (const cot of cots) { if (!cot.raw.event.detail) { cot.raw.event.detail = {}; } const attaches = attachments.get(cot.uid()); if (!attaches) continue; for (const attach of attaches) { if (!cot.raw.event.detail.attachment_list) { cot.raw.event.detail.attachment_list = { _attributes: { hashes: '[]' } }; } const hashes = JSON.parse(cot.raw.event.detail.attachment_list._attributes.hashes); // Until told otherwise the FileHash appears to always be the directory name const hash = await this.hash(attach._attributes.zipEntry); if (!hashes.includes(hash)) { hashes.push(hash); } cot.raw.event.detail.attachment_list._attributes.hashes = JSON.stringify(hashes); } } } return cots; } #attachments(cots, opts = { respectIgnore: true }) { const attachments = new Map(); for (const content of this.contents) { if (!content) continue; if (path.parse(content._attributes.zipEntry).ext === '.cot') continue; if (opts.respectIgnore && content._attributes.ignore) continue; const params = Array.isArray(content.Parameter) ? content.Parameter : [content.Parameter]; for (const param of params) { if (param._attributes.name === 'uid' && cots.has(param._attributes.value)) { const existing = attachments.get(param._attributes.value); if (existing) { existing.push(content); } else { attachments.set(param._attributes.value, [content]); } break; } } } return attachments; } /** * Return a list of files that are NOT attachments or CoT markers * The Set returned has a list of file paths that can be passed to getFile(path) */ async files(opts = { respectIgnore: true }) { const attachments = await this.attachments(opts); const files = new Set(); const attachment_entries = new Set(); for (const entries of attachments.values()) { for (const entry of entries) { attachment_entries.add(entry._attributes.zipEntry); } } for (const content of this.contents) { if (!content) continue; if (path.parse(content._attributes.zipEntry).ext === '.cot') continue; if (opts.respectIgnore && content._attributes.ignore) continue; if (attachment_entries.has(content._attributes.zipEntry)) { continue; } files.add(content._attributes.zipEntry); } return files; } /** * Return attachments that are associated in the Manifest with a given CoT * Note: this does not return files that are NOT associated with a CoT */ async attachments(opts = { respectIgnore: true }) { const cots = new Map(); for (const cot of await this.cots({ respectIgnore: opts.respectIgnore, parseAttachments: false })) { cots.set(cot.uid(), cot); } return this.#attachments(cots, opts); } async getFileBuffer(path) { if (this.destroyed) throw new Err(400, null, 'Attempt to access Data Package after it has been destroyed'); try { await fsp.access(`${this.path}/raw/${path}`); } catch (err) { throw new Err(400, err instanceof Error ? err : new Error(String(err)), 'Could not access file in Data Package'); } return await fsp.readFile(`${this.path}/raw/${path}`); } /** * Get any file from a Package */ async getFile(path) { if (this.destroyed) throw new Err(400, null, 'Attempt to access Data Package after it has been destroyed'); try { await fsp.access(`${this.path}/raw/${path}`); } catch (err) { throw new Err(400, err instanceof Error ? err : new Error(String(err)), 'Could not access file in Data Package'); } return fs.createReadStream(`${this.path}/raw/${path}`); } /** * Add any file to a Package * * @param file - Input ReadableStream of File at attach * @param opts - Options * @param opts.uid - Optional UID for the File, a UUID will be generated if not supplied * @param opts.name - Filename for the file * @param opts.ignore - Should the file be ignore, defaults to false * @param opts.attachment - Should the file be associated as an attachment to a CoT. If so this should contain the UID of the CoT */ async addFile(file, opts) { if (this.destroyed) throw new Err(400, null, 'Attempt to access Data Package after it has been destroyed'); if (!opts.ignore) opts.ignore = false; const uid = opts.uid ?? randomUUID(); this.#addContent(`${uid}/${opts.name}`, opts.attachment || uid, opts.name, opts.ignore); await fsp.mkdir(`${this.path}/raw/${uid}/`, { recursive: true }); await fsp.writeFile(`${this.path}/raw/${uid}/${opts.name}`, file); } /** * Add a CoT marker to the Package */ async addCoT(cot, opts = { ignore: false }) { if (this.destroyed) throw new Err(400, null, 'Attempt to access Data Package after it has been destroyed'); const name = cot.callsign(); this.#addContent(`${cot.raw.event._attributes.uid}/${cot.raw.event._attributes.uid}.cot`, cot.raw.event._attributes.uid, name, opts.ignore); await fsp.mkdir(`${this.path}/raw/${cot.raw.event._attributes.uid}/`, { recursive: true }); await fsp.writeFile(`${this.path}/raw/${cot.raw.event._attributes.uid}/${cot.raw.event._attributes.uid}.cot`, CoTParser.to_xml(cot)); } /** * Destory the underlying FS resources and prevent further mutation */ async destroy() { await rimraf(this.path); this.destroyed = true; } /** * Compile the DataPackage into a TAK compatible ZIP File * Note this function can be called multiple times and does not * affect the ability of the class to continue building a Package */ async finalize() { if (this.destroyed) throw new Err(400, null, 'Attempt to access Data Package after it has been destroyed'); await fsp.mkdir(this.path + '/raw/MANIFEST', { recursive: true }); await fsp.writeFile(this.path + '/raw/MANIFEST/manifest.xml', this.manifest()); return new Promise((resolve) => { const archive = archiver('zip', { zlib: { level: 9 } }); const output = fs.createWriteStream(this.path + `/${this.settings.uid}.zip`); archive.pipe(output); output.on('close', () => { return resolve(this.path + `/${this.settings.uid}.zip`); }); archive.directory(this.path + '/raw/', '/'); archive.finalize(); }); } } //# sourceMappingURL=data-package.js.map