@tak-ps/node-tak
Version:
Lightweight JavaScript library for communicating with TAK Server
269 lines (223 loc) • 8.35 kB
text/typescript
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]);
}