UNPKG

@tak-ps/node-tak

Version:

Lightweight JavaScript library for communicating with TAK Server

269 lines (223 loc) 8.35 kB
import { Readable } from 'node:stream'; import mime from 'mime'; import Err from '@openaddresses/batch-error'; import Commands, { CommandOutputFormat, type ParsedArgs } from '../commands.js'; import { TAKList } from './types.js'; import { Type, Static } from '@sinclair/typebox'; export const Content = Type.Object({ UID: Type.String(), SubmissionDateTime: Type.String(), Keywords: Type.Array(Type.String()), MIMEType: Type.String(), SubmissionUser: Type.String(), PrimaryKey: Type.String(), Hash: Type.String(), CreatorUid: Type.String(), Name: Type.String() }); export const TAKList_Content = TAKList(Type.Object({ filename: Type.String(), keywords: Type.Array(Type.String()), mimeType: Type.String(), name: Type.String(), submissionTime: Type.String(), submitter: Type.String(), uid: Type.String(), size: Type.Integer(), })); export const Config = Type.Object({ uploadSizeLimit: Type.Integer() }) export default class FileCommands extends Commands { schema = { list: { description: 'List Files', params: Type.Object({}), query: Type.Object({}), formats: [ CommandOutputFormat.JSON ] } } async cli(args: ParsedArgs): Promise<object | string> { if (args._[3] === 'list') { const list = await this.list(); if (args.format === 'json') { return list; } else { return list.data.map((data) => { return data.filename; }).join('\n'); } } else { throw new Error('Unsupported Subcommand'); } } async list(): Promise<Static<typeof TAKList_Content>> { const url = new URL(`/Marti/api/sync/search`, this.api.url); return await this.api.fetch(url, { method: 'GET' }); } async meta(hash: string): Promise<string> { const url = new URL(`/Marti/sync/${encodeURIComponent(hash)}/metadata`, this.api.url); const res = await this.api.fetch(url, { method: 'GET' }, true); const body = await res.text(); return body; } async download(hash: string): Promise<Readable> { const url = new URL(`/Marti/sync/content`, this.api.url); url.searchParams.append('hash', hash); const res = await this.api.fetch(url, { method: 'GET' }, true); // The raw fetch path skips status validation - without this check a // TAK Server error page would be streamed as if it were file content if (res.status < 200 || res.status >= 400) { const body = await res.text().catch(() => ''); throw new Err(res.status, null, body || `Status Code: ${res.status}`); } return res.body; } async adminDelete(hash: string) { const url = new URL(`/Marti/api/files/${hash}`, this.api.url); return await this.api.fetch(url, { method: 'DELETE', }); } async delete(hash: string) { const url = new URL(`/Marti/sync/delete`, this.api.url); url.searchParams.append('hash', hash) return await this.api.fetch(url, { method: 'DELETE', }); } // TODO Return a Content Object /** * Update a Package that should appear in the Public Data Packages List */ async uploadPackage(opts: { name: string; creatorUid: string; hash: string; keywords?: string[]; mimetype?: string; groups?: string[]; }, body: Readable | Buffer): Promise<string> { const url = new URL(`/Marti/sync/missionupload`, this.api.url); url.searchParams.append('filename', opts.name) url.searchParams.append('creatorUid', opts.creatorUid) url.searchParams.append('hash', opts.hash) if (opts.mimetype) { url.searchParams.append('mimetype', opts.mimetype) } // Needs this keywordt to appear in public list url.searchParams.append('keyword', 'missionpackage'); if (opts.keywords) { for (const keyword of opts.keywords) { if (keyword.toLowerCase() === 'missionpackage') continue; url.searchParams.append('keyword', keyword); } } if (opts.groups) { for (const group of opts.groups) { // This is intentionally case sensitive due to an apparent bug in TAK server url.searchParams.append('Groups', group); } } const form = new FormData() const contentType = opts.mimetype || mime.getType(opts.name) || undefined; const file = await toBlob(body, contentType); form.append('assetfile', file, opts.name); const res = await this.api.fetch(url, { method: 'POST', body: form }) as string; return res; } /** * Update a Package that will not appear in the Public Data Packages List * used primarily for sharing files between TAK clients */ async upload(opts: { name: string; contentLength: number; contentType?: string; keywords: string[]; creatorUid: string; latitude?: string; longitude?: string; altitude?: string; }, body: Readable | Buffer): Promise<Static<typeof Content>> { const url = new URL(`/Marti/sync/upload`, this.api.url); url.searchParams.append('name', opts.name) url.searchParams.append('keywords', opts.keywords.join(',')) url.searchParams.append('creatorUid', opts.creatorUid) if (opts.altitude) url.searchParams.append('altitude', opts.altitude); if (opts.longitude) url.searchParams.append('longitude', opts.longitude); if (opts.latitude) url.searchParams.append('latitude', opts.latitude); if (body instanceof Buffer) { body = Readable.from(body as Buffer); } const res = await this.api.fetch(url, { method: 'POST', headers: { 'Content-Type': opts.contentType || mime.getType(opts.name) || 'application/octet-stream', 'Content-Length': opts.contentLength }, body: body as Readable }); return typeof res === 'string' ? JSON.parse(res) : res; } async count() { const url = new URL('/Marti/api/files/metadata/count', this.api.url); return await this.api.fetch(url, { method: 'GET' }); } async update(hash: string, opts: { keywords?: string[]; expiration?: number; }): Promise<void> { if (opts.keywords !== undefined) { const url = new URL(`/Marti/api/sync/metadata/${encodeURIComponent(hash)}/keywords`, this.api.url); await this.api.fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: opts.keywords }); } if (opts.expiration !== undefined) { const url = new URL(`/Marti/api/sync/metadata/${encodeURIComponent(hash)}/expiration`, this.api.url); url.searchParams.append('expiration', String(opts.expiration)); await this.api.fetch(url, { method: 'PUT', }); } } async config(): Promise<Static<typeof Config>> { const url = new URL('/files/api/config', this.api.url); return await this.api.fetch(url, { method: 'GET' }); } } async function toBlob(body: Readable | Buffer, type?: string): Promise<Blob> { if (body instanceof Buffer) { const bytes = new Uint8Array(body); return type ? new Blob([bytes], { type }) : new Blob([bytes]); } const chunks: Buffer[] = []; for await (const chunk of body) { if (chunk instanceof Buffer) { chunks.push(chunk); } else if (typeof chunk === 'string') { chunks.push(Buffer.from(chunk)); } else { chunks.push(Buffer.from(chunk)); } } const buff = Buffer.concat(chunks); const bytes = new Uint8Array(buff); return type ? new Blob([bytes], { type }) : new Blob([bytes]); }