@tak-ps/node-tak 12.24.0 → 12.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/api/certificate.d.ts +77 -0
- package/dist/lib/api/certificate.js +159 -0
- package/dist/lib/api/certificate.js.map +1 -1
- package/dist/lib/api.js +5 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/utils/html-error.d.ts +32 -0
- package/dist/lib/utils/html-error.js +172 -0
- package/dist/lib/utils/html-error.js.map +1 -0
- package/dist/test/certificate.test.d.ts +1 -0
- package/dist/test/certificate.test.js +301 -0
- package/dist/test/certificate.test.js.map +1 -0
- package/dist/test/html-error.test.d.ts +1 -0
- package/dist/test/html-error.test.js +115 -0
- package/dist/test/html-error.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/index.ts +1 -0
- package/lib/api/certificate.ts +184 -0
- package/lib/api.ts +5 -1
- package/lib/utils/html-error.ts +208 -0
- package/package.json +1 -1
- package/test/certificate.test.ts +339 -0
- package/test/html-error.test.ts +139 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["../cli.ts","../index.ts","../test/default.test.ts","../test/findCoT.test.ts","../test/mission-layer.test.ts","../test/mission.test.ts","../test/pipeline.test.ts","../test/queue.test.ts","../test/uploadPackage.test.ts","../lib/api.ts","../lib/auth.ts","../lib/commands.ts","../lib/fetch.ts","../lib/multipart.ts","../lib/stream.ts","../lib/api/certificate.ts","../lib/api/client.ts","../lib/api/contacts.ts","../lib/api/credentials.ts","../lib/api/export.ts","../lib/api/files.ts","../lib/api/groups.ts","../lib/api/iconsets.ts","../lib/api/injectors.ts","../lib/api/locate.ts","../lib/api/mission-invite.ts","../lib/api/mission-layer.ts","../lib/api/mission-log.ts","../lib/api/mission.ts","../lib/api/oauth.ts","../lib/api/package.ts","../lib/api/profile.ts","../lib/api/query.ts","../lib/api/repeater.ts","../lib/api/security.ts","../lib/api/subscriptions.ts","../lib/api/types.ts","../lib/api/video.ts","../lib/utils/queue.ts"],"version":"6.0.3"}
|
|
1
|
+
{"root":["../cli.ts","../index.ts","../test/certificate.test.ts","../test/default.test.ts","../test/findCoT.test.ts","../test/html-error.test.ts","../test/mission-layer.test.ts","../test/mission.test.ts","../test/pipeline.test.ts","../test/queue.test.ts","../test/uploadPackage.test.ts","../lib/api.ts","../lib/auth.ts","../lib/commands.ts","../lib/fetch.ts","../lib/multipart.ts","../lib/stream.ts","../lib/api/certificate.ts","../lib/api/client.ts","../lib/api/contacts.ts","../lib/api/credentials.ts","../lib/api/export.ts","../lib/api/files.ts","../lib/api/groups.ts","../lib/api/iconsets.ts","../lib/api/injectors.ts","../lib/api/locate.ts","../lib/api/mission-invite.ts","../lib/api/mission-layer.ts","../lib/api/mission-log.ts","../lib/api/mission.ts","../lib/api/oauth.ts","../lib/api/package.ts","../lib/api/profile.ts","../lib/api/query.ts","../lib/api/repeater.ts","../lib/api/security.ts","../lib/api/subscriptions.ts","../lib/api/types.ts","../lib/api/video.ts","../lib/utils/html-error.ts","../lib/utils/queue.ts"],"version":"6.0.3"}
|
package/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import TAKAPI from './lib/api.js';
|
|
|
9
9
|
import { TAKAuth } from './lib/auth.js';
|
|
10
10
|
import { Queue } from './lib/utils/queue.js';
|
|
11
11
|
export * from './lib/auth.js';
|
|
12
|
+
export * from './lib/utils/html-error.js';
|
|
12
13
|
|
|
13
14
|
/* eslint-disable no-control-regex */
|
|
14
15
|
export const REGEX_CONTROL = /[\u000B-\u001F\u007F-\u009F]/g;
|
package/lib/api/certificate.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import Commands from '../commands.js';
|
|
2
|
+
import Err from '@openaddresses/batch-error';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { X509Certificate } from 'node:crypto';
|
|
5
|
+
import { TAKServerError } from '../utils/html-error.js';
|
|
2
6
|
import { Type } from '@sinclair/typebox';
|
|
3
7
|
import type { Static } from '@sinclair/typebox';
|
|
4
8
|
import { TAKItem, TAKList } from './types.js';
|
|
@@ -22,6 +26,44 @@ export const Certificate = Type.Object({
|
|
|
22
26
|
export const TAKList_Certificate = TAKList(Certificate);
|
|
23
27
|
export const TAKItem_Certificate = TAKItem(Certificate);
|
|
24
28
|
|
|
29
|
+
export const CertificateValidation = Type.Object({
|
|
30
|
+
fingerprint: Type.String({ description: 'SHA-256 fingerprint of the DER encoded certificate - the TAK Server `hash`' }),
|
|
31
|
+
subject: Type.String(),
|
|
32
|
+
username: Type.String({ description: 'Common Name of the certificate subject - the TAK Server `userDn`' }),
|
|
33
|
+
validFrom: Type.String(),
|
|
34
|
+
validTo: Type.String(),
|
|
35
|
+
expired: Type.Boolean({ description: 'validTo is in the past' }),
|
|
36
|
+
known: Type.Boolean({ description: 'The TAK Server has a record for this fingerprint' }),
|
|
37
|
+
revoked: Type.Boolean({ description: 'The TAK Server record carries a revocationDate' }),
|
|
38
|
+
revocationDate: Type.Optional(Type.String({ format: 'date-time' })),
|
|
39
|
+
valid: Type.Boolean({ description: 'Neither expired nor revoked' }),
|
|
40
|
+
certificate: Type.Optional(Certificate)
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const CertificateProbe = Type.Object({
|
|
44
|
+
accepted: Type.Boolean({ description: 'The TAK Server authenticated the credentials' }),
|
|
45
|
+
version: Type.Optional(Type.String({ description: 'TAK Server version string when accepted' })),
|
|
46
|
+
reason: Type.Optional(Type.Union([
|
|
47
|
+
Type.Literal('revoked', { description: 'X509Authenticator raised RevokedException' }),
|
|
48
|
+
Type.Literal('rejected', { description: 'Authentication failed for another reason (BadCredentialsException)' }),
|
|
49
|
+
Type.Literal('tls', { description: 'The TLS handshake failed - typically an expired or untrusted client certificate' })
|
|
50
|
+
])),
|
|
51
|
+
message: Type.Optional(Type.String())
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract the Common Name from an X509 subject string as reported by Node
|
|
56
|
+
* (newline separated `key=value` pairs)
|
|
57
|
+
*/
|
|
58
|
+
function commonName(subject: string): string | undefined {
|
|
59
|
+
for (const line of subject.split('\n')) {
|
|
60
|
+
const [key, ...rest] = line.split('=');
|
|
61
|
+
if (key.trim() === 'CN') return rest.join('=').trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
25
67
|
export default class CertificateCommands extends Commands {
|
|
26
68
|
schema = {}
|
|
27
69
|
|
|
@@ -46,6 +88,19 @@ export default class CertificateCommands extends Commands {
|
|
|
46
88
|
return await this.api.fetch(url) as Static<typeof TAKList_Certificate>;
|
|
47
89
|
}
|
|
48
90
|
|
|
91
|
+
/**
|
|
92
|
+
* List Active Certificates
|
|
93
|
+
*
|
|
94
|
+
* The most recently issued certificate for each Client UID / User DN pair
|
|
95
|
+
*
|
|
96
|
+
* {@link https://docs.tak.gov/api/takserver/redoc#tag/cert-manager-admin-api/operation/getActive TAK Server Docs}.
|
|
97
|
+
*/
|
|
98
|
+
async listActive(): Promise<Static<typeof TAKList_Certificate>> {
|
|
99
|
+
const url = new URL('/Marti/api/certadmin/cert/active', this.api.url);
|
|
100
|
+
|
|
101
|
+
return await this.api.fetch(url) as Static<typeof TAKList_Certificate>;
|
|
102
|
+
}
|
|
103
|
+
|
|
49
104
|
/**
|
|
50
105
|
* List Revoked Certificates
|
|
51
106
|
*
|
|
@@ -79,6 +134,105 @@ export default class CertificateCommands extends Commands {
|
|
|
79
134
|
return await this.api.fetch(url) as Static<typeof TAKList_Certificate>;
|
|
80
135
|
}
|
|
81
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Probe whether the TAK Server accepts the credentials this API instance was created with
|
|
139
|
+
*
|
|
140
|
+
* The X509 filter runs on every request and rethrows on failure, so `GET /Marti/api/version`
|
|
141
|
+
* (an anonymous, DB-free endpoint returning a short string) is the cheapest authoritative
|
|
142
|
+
* check - unlike {@link validate} it reflects the server's actual enforcement (e.g. whether
|
|
143
|
+
* `x509checkRevocation` is enabled) and requires no admin credentials. An expired client
|
|
144
|
+
* certificate is rejected during the TLS handshake and surfaces as `reason: 'tls'`.
|
|
145
|
+
*
|
|
146
|
+
* Only authentication failures are returned as a verdict - any other error (server
|
|
147
|
+
* unreachable, unexpected status) is rethrown so it is not mistaken for a rejection
|
|
148
|
+
*/
|
|
149
|
+
async probe(): Promise<Static<typeof CertificateProbe>> {
|
|
150
|
+
const url = new URL('/Marti/api/version', this.api.url);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const version = String(await this.api.fetch(url)).trim();
|
|
154
|
+
|
|
155
|
+
return { accepted: true, version };
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
158
|
+
const details = err instanceof TAKServerError ? err.details : '';
|
|
159
|
+
const haystack = `${message}\n${details}`;
|
|
160
|
+
|
|
161
|
+
let reason: Static<typeof CertificateProbe>['reason'];
|
|
162
|
+
if (/RevokedException|revoked certificate/i.test(haystack)) {
|
|
163
|
+
reason = 'revoked';
|
|
164
|
+
} else if (/BadCredentialsException|AuthenticationException|TAK Server authentication/i.test(haystack)) {
|
|
165
|
+
reason = 'rejected';
|
|
166
|
+
} else if (
|
|
167
|
+
err instanceof Err && err.status === 400 && !(err instanceof TAKServerError)
|
|
168
|
+
&& /certificate|handshake|alert|EPROTO|ECONNRESET|SSL|TLS/i.test(haystack)
|
|
169
|
+
) {
|
|
170
|
+
reason = 'tls';
|
|
171
|
+
} else {
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { accepted: false, reason, message };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Validate a PEM encoded client certificate against local expiry and the TAK Server revocation record
|
|
181
|
+
*
|
|
182
|
+
* Expiry is evaluated locally from the certificate. Revocation is looked up via the
|
|
183
|
+
* Certificate Admin API using the same SHA-256 fingerprint the TAK Server `X509Authenticator`
|
|
184
|
+
* consults - `get(hash)` first (a single cached row) falling back to the user's certificate
|
|
185
|
+
* list when the TAK Server answers 500, which is how it reports an unknown hash.
|
|
186
|
+
*
|
|
187
|
+
* Note: The TAK Server only enforces revocation when `auth.x509checkRevocation` (or
|
|
188
|
+
* `x509TokenAuth`) is enabled in CoreConfig - `revoked` reports the stored state regardless
|
|
189
|
+
*/
|
|
190
|
+
async validate(
|
|
191
|
+
pem: string,
|
|
192
|
+
opts: {
|
|
193
|
+
now?: Date;
|
|
194
|
+
} = {}
|
|
195
|
+
): Promise<Static<typeof CertificateValidation>> {
|
|
196
|
+
const x509 = new X509Certificate(pem);
|
|
197
|
+
const now = opts.now ?? new Date();
|
|
198
|
+
|
|
199
|
+
const username = commonName(x509.subject);
|
|
200
|
+
if (!username) throw new Error('Certificate subject does not contain a Common Name');
|
|
201
|
+
|
|
202
|
+
const fingerprint = x509.fingerprint256.toUpperCase();
|
|
203
|
+
|
|
204
|
+
let certificate: Static<typeof Certificate> | undefined;
|
|
205
|
+
try {
|
|
206
|
+
// Single cached row lookup - the same query X509Authenticator performs
|
|
207
|
+
certificate = (await this.get(fingerprint)).data;
|
|
208
|
+
} catch (err) {
|
|
209
|
+
// The TAK Server reports an unknown hash via sendError(500), which is
|
|
210
|
+
// indistinguishable from a genuine failure - disambiguate against the
|
|
211
|
+
// (uncached, but authoritative) list of the user's certificates
|
|
212
|
+
if (!(err instanceof Err) || err.status < 500) throw err;
|
|
213
|
+
|
|
214
|
+
certificate = (await this.list(username)).data
|
|
215
|
+
.find((cert) => cert.hash.toUpperCase() === fingerprint);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const expired = Date.parse(x509.validTo) < now.getTime();
|
|
219
|
+
const revoked = !!certificate?.revocationDate;
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
fingerprint,
|
|
223
|
+
subject: x509.subject,
|
|
224
|
+
username,
|
|
225
|
+
validFrom: x509.validFrom,
|
|
226
|
+
validTo: x509.validTo,
|
|
227
|
+
expired,
|
|
228
|
+
known: !!certificate,
|
|
229
|
+
revoked,
|
|
230
|
+
revocationDate: certificate?.revocationDate,
|
|
231
|
+
valid: !expired && !revoked,
|
|
232
|
+
certificate
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
82
236
|
/**
|
|
83
237
|
* Get Single Certificate
|
|
84
238
|
*
|
|
@@ -107,6 +261,36 @@ export default class CertificateCommands extends Commands {
|
|
|
107
261
|
}) as string;
|
|
108
262
|
}
|
|
109
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Download Certificates by Id
|
|
266
|
+
*
|
|
267
|
+
* Returns a ZIP archive stream containing one `{n}_{userDn}_ClientCert.pem` entry per certificate
|
|
268
|
+
*
|
|
269
|
+
* {@link https://docs.tak.gov/api/takserver/redoc#tag/cert-manager-admin-api/operation/downloadCertificates TAK Server Docs}.
|
|
270
|
+
*/
|
|
271
|
+
async downloadIds(
|
|
272
|
+
ids: Array<string | number>
|
|
273
|
+
): Promise<Readable> {
|
|
274
|
+
if (ids.length === 0) {
|
|
275
|
+
throw new Error('At least one ID must be provided');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const url = new URL(`/Marti/api/certadmin/cert/download/${ids.map((id) => encodeURIComponent(String(id))).join(',')}`, this.api.url);
|
|
279
|
+
|
|
280
|
+
const res = await this.api.fetch(url, {
|
|
281
|
+
method: 'GET'
|
|
282
|
+
}, true);
|
|
283
|
+
|
|
284
|
+
// The raw fetch path skips status validation - without this check a
|
|
285
|
+
// TAK Server error page would be streamed as if it were the ZIP archive
|
|
286
|
+
if (res.status < 200 || res.status >= 400) {
|
|
287
|
+
const body = await res.text().catch(() => '');
|
|
288
|
+
throw new Err(res.status, null, body || `Status Code: ${res.status}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return res.body;
|
|
292
|
+
}
|
|
293
|
+
|
|
110
294
|
/**
|
|
111
295
|
* Revoke Single Certificate
|
|
112
296
|
*
|
package/lib/api.ts
CHANGED
|
@@ -21,6 +21,7 @@ import Subscription from './api/subscriptions.js';
|
|
|
21
21
|
import Video from './api/video.js';
|
|
22
22
|
import Export from './api/export.js';
|
|
23
23
|
import Err from '@openaddresses/batch-error';
|
|
24
|
+
import { TAKServerError, isHTML } from './utils/html-error.js';
|
|
24
25
|
import * as auth from './auth.js';
|
|
25
26
|
|
|
26
27
|
type ErrorWithCause = Error & {
|
|
@@ -210,6 +211,9 @@ export default class TAKAPI {
|
|
|
210
211
|
bdy = null;
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
// TAK Server returns HTML error pages
|
|
215
|
+
if (isHTML(bdy)) throw new TAKServerError(res.status, bdy);
|
|
216
|
+
|
|
213
217
|
throw new Err(res.status, null, bdy || `Status Code: ${res.status}`);
|
|
214
218
|
}
|
|
215
219
|
|
|
@@ -219,7 +223,7 @@ export default class TAKAPI {
|
|
|
219
223
|
return await res.text();
|
|
220
224
|
}
|
|
221
225
|
} catch (err) {
|
|
222
|
-
if (err instanceof
|
|
226
|
+
if (err instanceof Err) throw err;
|
|
223
227
|
throw new Err(400, null, formatFetchError(err));
|
|
224
228
|
}
|
|
225
229
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import Err from '@openaddresses/batch-error';
|
|
2
|
+
import xmljs from '@tak-ps/xml-js';
|
|
3
|
+
import type { Element } from '@tak-ps/xml-js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TAK Server (Tomcat) returns HTML "Exception Report" pages on failure.
|
|
7
|
+
* Parse them into a human readable message + plain-text details
|
|
8
|
+
* so clients don't render raw HTML.
|
|
9
|
+
*/
|
|
10
|
+
export interface HTMLErrorDetails {
|
|
11
|
+
/** <title> or <h1> */
|
|
12
|
+
title?: string;
|
|
13
|
+
/** <p><b>Label</b> value</p> pairs - ie: Type, Message, Description */
|
|
14
|
+
fields: Record<string, string>;
|
|
15
|
+
/** <pre> stack traces */
|
|
16
|
+
exception?: string;
|
|
17
|
+
/** Visible text fallback */
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const HIDDEN = new Set(['style', 'script', 'head']);
|
|
22
|
+
|
|
23
|
+
function collapse(input: string): string {
|
|
24
|
+
return input.replace(/\s+/g, ' ').trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Linear-time tag stripper for malformed HTML - drops the contents of HIDDEN elements */
|
|
28
|
+
function stripTags(html: string): string {
|
|
29
|
+
const lower = html.toLowerCase();
|
|
30
|
+
let out = '';
|
|
31
|
+
let i = 0;
|
|
32
|
+
|
|
33
|
+
while (i < html.length) {
|
|
34
|
+
const open = html.indexOf('<', i);
|
|
35
|
+
if (open === -1) {
|
|
36
|
+
out += html.slice(i);
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
out += html.slice(i, open) + ' ';
|
|
41
|
+
|
|
42
|
+
const close = html.indexOf('>', open);
|
|
43
|
+
if (close === -1) break;
|
|
44
|
+
|
|
45
|
+
const tag = lower.slice(open + 1, close).trim().split(/\s/, 1)[0];
|
|
46
|
+
i = close + 1;
|
|
47
|
+
|
|
48
|
+
if (HIDDEN.has(tag)) {
|
|
49
|
+
const end = lower.indexOf(`</${tag}`, i);
|
|
50
|
+
if (end === -1) break;
|
|
51
|
+
i = html.indexOf('>', end);
|
|
52
|
+
if (i === -1) break;
|
|
53
|
+
i += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return collapse(out);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function textOf(el: Element, opts: { skipPre?: boolean } = {}): string {
|
|
61
|
+
if (el.type === 'text') return String(el.text ?? '');
|
|
62
|
+
if (el.type !== 'element' || !el.elements) return '';
|
|
63
|
+
if (opts.skipPre && el.name === 'pre') return '';
|
|
64
|
+
|
|
65
|
+
return el.elements.map((child) => textOf(child, opts)).join('');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function* walk(el: Element, skipHidden = true): Generator<Element> {
|
|
69
|
+
if (el.type === 'element' && el.name && skipHidden && HIDDEN.has(el.name.toLowerCase())) return;
|
|
70
|
+
|
|
71
|
+
if (el.type === 'element') yield el;
|
|
72
|
+
|
|
73
|
+
for (const child of el.elements || []) yield* walk(child, skipHidden);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function find(root: Element, name: string): Element | undefined {
|
|
77
|
+
for (const el of walk(root, false)) {
|
|
78
|
+
if (el.name?.toLowerCase() === name) return el;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isHTML(body: unknown): body is string {
|
|
83
|
+
if (typeof body !== 'string') return false;
|
|
84
|
+
|
|
85
|
+
const head = body.slice(0, 512).trimStart().toLowerCase();
|
|
86
|
+
|
|
87
|
+
return head.startsWith('<!doctype html')
|
|
88
|
+
|| head.startsWith('<html')
|
|
89
|
+
|| /^<(head|body|title|h1)[\s>]/.test(head);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Returns null if body is not HTML */
|
|
93
|
+
export function parseHTMLError(body: unknown): HTMLErrorDetails | null {
|
|
94
|
+
if (!isHTML(body)) return null;
|
|
95
|
+
|
|
96
|
+
const details: HTMLErrorDetails = {
|
|
97
|
+
fields: {},
|
|
98
|
+
text: '',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
let root: Element;
|
|
102
|
+
try {
|
|
103
|
+
root = xmljs.xml2js(body, { compact: false }) as Element;
|
|
104
|
+
} catch {
|
|
105
|
+
// Not well-formed XHTML - fall back to visible text
|
|
106
|
+
details.text = stripTags(body);
|
|
107
|
+
|
|
108
|
+
return details;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const title = find(root, 'title') || find(root, 'h1');
|
|
112
|
+
if (title && collapse(textOf(title))) details.title = collapse(textOf(title));
|
|
113
|
+
|
|
114
|
+
const pres: string[] = [];
|
|
115
|
+
let text = '';
|
|
116
|
+
|
|
117
|
+
for (const el of walk(root)) {
|
|
118
|
+
const name = el.name?.toLowerCase();
|
|
119
|
+
|
|
120
|
+
if (name === 'p' && el.elements?.[0]?.name?.toLowerCase() === 'b') {
|
|
121
|
+
const label = collapse(textOf(el.elements[0]));
|
|
122
|
+
const value = collapse(el.elements.slice(1).map((child) => textOf(child)).join(''));
|
|
123
|
+
|
|
124
|
+
if (label && value) details.fields[label] = value;
|
|
125
|
+
} else if (name === 'pre') {
|
|
126
|
+
const value = textOf(el)
|
|
127
|
+
.split('\n')
|
|
128
|
+
.map((line) => line.trimEnd())
|
|
129
|
+
.join('\n')
|
|
130
|
+
.trim();
|
|
131
|
+
|
|
132
|
+
if (value) pres.push(value);
|
|
133
|
+
} else if (name === 'body') {
|
|
134
|
+
text = collapse(textOf(el, { skipPre: true }));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (pres.length) details.exception = pres.join('\n\n');
|
|
139
|
+
|
|
140
|
+
details.text = text || collapse(textOf(root, { skipPre: true }));
|
|
141
|
+
|
|
142
|
+
return details;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Most useful human readable line from the page */
|
|
146
|
+
export function summarizeHTMLError(details: HTMLErrorDetails, status?: number): string {
|
|
147
|
+
const message = details.fields.Message
|
|
148
|
+
|| details.fields.message
|
|
149
|
+
|| details.fields.Description
|
|
150
|
+
|| details.fields.description;
|
|
151
|
+
|
|
152
|
+
if (message) return message;
|
|
153
|
+
|
|
154
|
+
if (details.exception) {
|
|
155
|
+
// "<ExceptionClass>: <message>"
|
|
156
|
+
const first = details.exception.split('\n')[0].trim();
|
|
157
|
+
const colon = first.indexOf(': ');
|
|
158
|
+
if (colon !== -1 && colon < first.length - 2) return first.slice(colon + 2).trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (details.title) return details.title;
|
|
162
|
+
|
|
163
|
+
if (details.text) return details.text.length > 200 ? details.text.slice(0, 197) + '...' : details.text;
|
|
164
|
+
|
|
165
|
+
return status ? `Status Code: ${status}` : 'Unknown TAK Server Error';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Plain-text breakdown for an "Advanced" details pane */
|
|
169
|
+
export function formatHTMLError(details: HTMLErrorDetails): string {
|
|
170
|
+
const lines: string[] = [];
|
|
171
|
+
|
|
172
|
+
if (details.title) lines.push(details.title);
|
|
173
|
+
|
|
174
|
+
for (const [label, value] of Object.entries(details.fields)) {
|
|
175
|
+
if (value) lines.push(`${label}: ${value}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (details.exception) {
|
|
179
|
+
if (lines.length) lines.push('');
|
|
180
|
+
lines.push(details.exception);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!lines.length && details.text) lines.push(details.text);
|
|
184
|
+
|
|
185
|
+
return lines.join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* TAK Server HTML error page - `safe` is the summary, `details` the full breakdown
|
|
190
|
+
*/
|
|
191
|
+
export class TAKServerError extends Err {
|
|
192
|
+
details: string;
|
|
193
|
+
html: string;
|
|
194
|
+
parsed: HTMLErrorDetails;
|
|
195
|
+
|
|
196
|
+
constructor(status: number, html: string, parsed?: HTMLErrorDetails) {
|
|
197
|
+
const details = parsed || parseHTMLError(html) || {
|
|
198
|
+
fields: {},
|
|
199
|
+
text: stripTags(html)
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
super(status, null, summarizeHTMLError(details, status));
|
|
203
|
+
|
|
204
|
+
this.parsed = details;
|
|
205
|
+
this.details = formatHTMLError(details);
|
|
206
|
+
this.html = html;
|
|
207
|
+
}
|
|
208
|
+
}
|
package/package.json
CHANGED