@travetto/web 7.1.4 → 8.0.0-alpha.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/README.md +17 -15
- package/__index__.ts +2 -1
- package/package.json +11 -11
- package/src/context.ts +2 -2
- package/src/decorator/common.ts +3 -3
- package/src/interceptor/accept.ts +1 -1
- package/src/interceptor/body.ts +1 -1
- package/src/interceptor/compress.ts +28 -21
- package/src/interceptor/cookie.ts +7 -3
- package/src/interceptor/decompress.ts +17 -15
- package/src/interceptor/etag.ts +6 -12
- package/src/registry/registry-adapter.ts +10 -10
- package/src/registry/registry-index.ts +2 -2
- package/src/router/standard.ts +2 -2
- package/src/types/error.ts +2 -2
- package/src/types/message.ts +0 -3
- package/src/util/body.ts +80 -93
- package/src/util/common.ts +3 -3
- package/src/util/cookie.ts +35 -26
- package/src/util/keygrip.ts +47 -27
- package/support/test/dispatch-util.ts +30 -28
- package/support/test/suite/base.ts +1 -1
- package/support/test/suite/controller.ts +4 -6
package/src/util/body.ts
CHANGED
|
@@ -1,72 +1,74 @@
|
|
|
1
1
|
import { TextDecoder } from 'node:util';
|
|
2
|
-
import { Readable } from 'node:stream';
|
|
3
2
|
|
|
4
|
-
import { type
|
|
3
|
+
import { type BinaryType, BinaryUtil, type BinaryArray, castTo, Util, CodecUtil, hasToJSON, BinaryMetadataUtil, JSONUtil } from '@travetto/runtime';
|
|
5
4
|
|
|
6
|
-
import type {
|
|
5
|
+
import type { WebMessage } from '../types/message.ts';
|
|
7
6
|
import { WebHeaders } from '../types/headers.ts';
|
|
8
7
|
import { WebError } from '../types/error.ts';
|
|
9
8
|
|
|
10
|
-
const
|
|
9
|
+
const WebRawBinarySymbol = Symbol();
|
|
10
|
+
|
|
11
|
+
const NULL_TERMINATOR = BinaryUtil.makeBinaryArray(0);
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Utility classes for supporting web body operations
|
|
14
15
|
*/
|
|
15
16
|
export class WebBodyUtil {
|
|
16
17
|
|
|
18
|
+
/** Get Metadata Headers */
|
|
19
|
+
static getMetadataHeaders(value: BinaryType): [string, string][] {
|
|
20
|
+
const metadata = BinaryMetadataUtil.read(value);
|
|
21
|
+
const length = BinaryMetadataUtil.readLength(metadata);
|
|
22
|
+
|
|
23
|
+
const result = [
|
|
24
|
+
['Content-Type', metadata.contentType],
|
|
25
|
+
['Content-Length', length],
|
|
26
|
+
['Content-Encoding', metadata.contentEncoding],
|
|
27
|
+
['Cache-Control', metadata.cacheControl],
|
|
28
|
+
['Content-Language', metadata.contentLanguage],
|
|
29
|
+
...(metadata.range ? [['Accept-Ranges', 'bytes']] : []),
|
|
30
|
+
...(metadata.range ? [['Content-Range', `bytes ${metadata.range.start}-${metadata.range.end}/${metadata.size}`]] : []),
|
|
31
|
+
...(metadata.filename ? [['Content-Disposition', `attachment; filename="${metadata.filename}"`]] : [])
|
|
32
|
+
]
|
|
33
|
+
.map((pair) => [pair[0], pair[1]?.toString()])
|
|
34
|
+
.filter((pair): pair is [string, string] => pair[1] !== undefined);
|
|
35
|
+
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
/**
|
|
18
40
|
* Generate multipart body
|
|
19
41
|
*/
|
|
20
|
-
static async * buildMultiPartBody(form: FormData, boundary: string): AsyncIterable<
|
|
21
|
-
const
|
|
22
|
-
for (const [key,
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
yield `Content-Length: ${size}${nl}`;
|
|
30
|
-
if (type) {
|
|
31
|
-
yield `Content-Type: ${type}${nl}`;
|
|
42
|
+
static async * buildMultiPartBody(form: FormData, boundary: string): AsyncIterable<BinaryArray> {
|
|
43
|
+
const bytes = (value: string = '', suffix = '\r\n'): BinaryArray => CodecUtil.fromUTF8String(`${value}${suffix}`);
|
|
44
|
+
for (const [key, item] of form.entries()) {
|
|
45
|
+
yield bytes(`--${boundary}`);
|
|
46
|
+
const binaryValue = typeof item === 'string' ? CodecUtil.fromUTF8String(item) : item.slice();
|
|
47
|
+
|
|
48
|
+
// Headers
|
|
49
|
+
if (typeof item == 'string') {
|
|
50
|
+
BinaryMetadataUtil.write(binaryValue, { size: item.length });
|
|
32
51
|
}
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
yield chunk;
|
|
37
|
-
}
|
|
38
|
-
} else {
|
|
39
|
-
yield data;
|
|
52
|
+
|
|
53
|
+
if (item instanceof File) {
|
|
54
|
+
yield bytes(`Content-Disposition: form-data; name="${key}"; filename="${item.name || key}"`);
|
|
40
55
|
}
|
|
41
|
-
yield nl;
|
|
42
|
-
}
|
|
43
|
-
yield `--${boundary}--${nl}`;
|
|
44
|
-
}
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
['Content-Type', value.type],
|
|
52
|
-
['Content-Length', `${value.size}`],
|
|
53
|
-
['Content-Encoding', meta?.contentEncoding],
|
|
54
|
-
['Cache-Control', meta?.cacheControl],
|
|
55
|
-
['Content-Language', meta?.contentLanguage],
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
if (meta?.range) {
|
|
59
|
-
toAdd.push(
|
|
60
|
-
['Accept-Ranges', 'bytes'],
|
|
61
|
-
['Content-Range', `bytes ${meta.range.start}-${meta.range.end}/${meta.size}`],
|
|
62
|
-
);
|
|
63
|
-
}
|
|
57
|
+
for (const [header, value] of WebBodyUtil.getMetadataHeaders(binaryValue)) {
|
|
58
|
+
if (header.startsWith('Content-') && header !== 'Content-Disposition') {
|
|
59
|
+
yield bytes(`${header}: ${value}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
yield bytes();
|
|
64
|
+
if (binaryValue instanceof Blob) {
|
|
65
|
+
yield* BinaryUtil.toBinaryStream(binaryValue);
|
|
66
|
+
} else {
|
|
67
|
+
yield binaryValue;
|
|
68
|
+
}
|
|
69
|
+
yield bytes();
|
|
67
70
|
}
|
|
68
|
-
|
|
69
|
-
return toAdd.filter((pair): pair is [string, string] => !!pair[1]);
|
|
71
|
+
yield bytes(`--${boundary}--`);
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
/**
|
|
@@ -89,49 +91,35 @@ export class WebBodyUtil {
|
|
|
89
91
|
/**
|
|
90
92
|
* Convert an existing web message to a binary web message
|
|
91
93
|
*/
|
|
92
|
-
static toBinaryMessage(message: WebMessage): Omit<WebMessage<
|
|
93
|
-
const body = message
|
|
94
|
-
|
|
95
|
-
return castTo(message);
|
|
96
|
-
}
|
|
94
|
+
static toBinaryMessage(message: WebMessage): Omit<WebMessage<BinaryType>, 'context'> {
|
|
95
|
+
const { body } = message;
|
|
96
|
+
const out: Omit<WebMessage<BinaryType>, 'context'> = { headers: new WebHeaders(message.headers), body: null! };
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
for (const [key, value] of this.getBlobHeaders(body)) {
|
|
98
|
+
if (BinaryUtil.isBinaryType(body)) {
|
|
99
|
+
for (const [key, value] of this.getMetadataHeaders(body)) {
|
|
101
100
|
out.headers.set(key, value);
|
|
102
101
|
}
|
|
103
|
-
out.body =
|
|
102
|
+
out.body = body;
|
|
104
103
|
} else if (body instanceof FormData) {
|
|
105
104
|
const boundary = `${'-'.repeat(24)}-multipart-${Util.uuid()}`;
|
|
106
105
|
out.headers.set('Content-Type', `multipart/form-data; boundary=${boundary}`);
|
|
107
|
-
out.body =
|
|
108
|
-
} else if (BinaryUtil.isReadableStream(body)) {
|
|
109
|
-
out.body = Readable.fromWeb(body);
|
|
110
|
-
} else if (BinaryUtil.isAsyncIterable(body)) {
|
|
111
|
-
out.body = Readable.from(body);
|
|
112
|
-
} else if (body === null || body === undefined) {
|
|
113
|
-
out.body = Buffer.alloc(0);
|
|
114
|
-
} else if (BinaryUtil.isArrayBuffer(body)) {
|
|
115
|
-
out.body = Buffer.from(body);
|
|
106
|
+
out.body = this.buildMultiPartBody(body, boundary);
|
|
116
107
|
} else {
|
|
117
|
-
let text:
|
|
108
|
+
let text: BinaryArray;
|
|
118
109
|
if (typeof body === 'string') {
|
|
119
|
-
text = body;
|
|
110
|
+
text = CodecUtil.fromUTF8String(body);
|
|
120
111
|
} else if (hasToJSON(body)) {
|
|
121
|
-
text =
|
|
112
|
+
text = JSONUtil.toBinaryArray(body.toJSON());
|
|
122
113
|
} else if (body instanceof Error) {
|
|
123
|
-
text =
|
|
114
|
+
text = JSONUtil.toBinaryArray({ message: body.message });
|
|
124
115
|
} else {
|
|
125
|
-
text =
|
|
116
|
+
text = JSONUtil.toBinaryArray(body);
|
|
126
117
|
}
|
|
127
|
-
out.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (Buffer.isBuffer(out.body)) {
|
|
131
|
-
out.headers.set('Content-Length', `${out.body.byteLength}`);
|
|
118
|
+
out.headers.set('Content-Length', `${text.byteLength}`);
|
|
119
|
+
out.body = text;
|
|
132
120
|
}
|
|
133
121
|
|
|
134
|
-
out.headers.setIfAbsent('Content-Type', this.defaultContentType(
|
|
122
|
+
out.headers.setIfAbsent('Content-Type', this.defaultContentType(body));
|
|
135
123
|
|
|
136
124
|
return castTo(out);
|
|
137
125
|
}
|
|
@@ -139,9 +127,9 @@ export class WebBodyUtil {
|
|
|
139
127
|
/**
|
|
140
128
|
* Set body and mark as unprocessed
|
|
141
129
|
*/
|
|
142
|
-
static
|
|
130
|
+
static markRawBinary(body: BinaryType | undefined): typeof body {
|
|
143
131
|
if (body) {
|
|
144
|
-
Object.defineProperty(body,
|
|
132
|
+
Object.defineProperty(body, WebRawBinarySymbol, { value: body });
|
|
145
133
|
}
|
|
146
134
|
return body;
|
|
147
135
|
}
|
|
@@ -149,9 +137,8 @@ export class WebBodyUtil {
|
|
|
149
137
|
/**
|
|
150
138
|
* Is the input raw
|
|
151
139
|
*/
|
|
152
|
-
static
|
|
153
|
-
|
|
154
|
-
return !!body && ((Buffer.isBuffer(body) || BinaryUtil.isReadable(body)) && (body as Any)[WebRawStreamSymbol] === body);
|
|
140
|
+
static isRawBinary(body: unknown): body is BinaryType {
|
|
141
|
+
return BinaryUtil.isBinaryType(body) && castTo<{ [WebRawBinarySymbol]: unknown }>(body)[WebRawBinarySymbol] === body;
|
|
155
142
|
}
|
|
156
143
|
|
|
157
144
|
/**
|
|
@@ -160,7 +147,7 @@ export class WebBodyUtil {
|
|
|
160
147
|
static parseBody(type: string, body: string): unknown {
|
|
161
148
|
switch (type) {
|
|
162
149
|
case 'text': return body;
|
|
163
|
-
case 'json': return JSONUtil.
|
|
150
|
+
case 'json': return JSONUtil.fromUTF8(body);
|
|
164
151
|
case 'form': return Object.fromEntries(new URLSearchParams(body));
|
|
165
152
|
}
|
|
166
153
|
}
|
|
@@ -168,8 +155,8 @@ export class WebBodyUtil {
|
|
|
168
155
|
/**
|
|
169
156
|
* Read text from an input source
|
|
170
157
|
*/
|
|
171
|
-
static async readText(input:
|
|
172
|
-
encoding ??=
|
|
158
|
+
static async readText(input: BinaryType, limit: number, encoding?: string): Promise<{ text: string, read: number }> {
|
|
159
|
+
encoding ??= CodecUtil.detectEncoding(input) ?? 'utf-8';
|
|
173
160
|
|
|
174
161
|
let decoder: TextDecoder;
|
|
175
162
|
try {
|
|
@@ -178,26 +165,26 @@ export class WebBodyUtil {
|
|
|
178
165
|
throw WebError.for('Specified Encoding Not Supported', 415, { encoding });
|
|
179
166
|
}
|
|
180
167
|
|
|
181
|
-
if (
|
|
168
|
+
if (BinaryUtil.isBinaryArray(input)) {
|
|
182
169
|
if (input.byteLength > limit) {
|
|
183
170
|
throw WebError.for('Request Entity Too Large', 413, { received: input.byteLength, limit });
|
|
184
171
|
}
|
|
185
172
|
return { text: decoder.decode(input), read: input.byteLength };
|
|
186
173
|
}
|
|
187
174
|
|
|
188
|
-
let received =
|
|
175
|
+
let received = 0;
|
|
189
176
|
const all: string[] = [];
|
|
190
177
|
|
|
191
178
|
try {
|
|
192
|
-
for await (const chunk of
|
|
193
|
-
const
|
|
194
|
-
received +=
|
|
179
|
+
for await (const chunk of BinaryUtil.toBinaryStream(input)) {
|
|
180
|
+
const bytes = CodecUtil.readUtf8Chunk(chunk);
|
|
181
|
+
received += bytes.byteLength;
|
|
195
182
|
if (received > limit) {
|
|
196
183
|
throw WebError.for('Request Entity Too Large', 413, { received, limit });
|
|
197
184
|
}
|
|
198
|
-
all.push(decoder.decode(
|
|
185
|
+
all.push(decoder.decode(bytes, { stream: true }));
|
|
199
186
|
}
|
|
200
|
-
all.push(decoder.decode(
|
|
187
|
+
all.push(decoder.decode(NULL_TERMINATOR, { stream: false }));
|
|
201
188
|
return { text: all.join(''), read: received };
|
|
202
189
|
} catch (error) {
|
|
203
190
|
if (error instanceof Error && error.name === 'AbortError') {
|
package/src/util/common.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { RuntimeError, type ErrorCategory, Util } from '@travetto/runtime';
|
|
2
2
|
|
|
3
3
|
import { WebResponse } from '../types/response.ts';
|
|
4
4
|
import type { WebRequest } from '../types/request.ts';
|
|
@@ -116,8 +116,8 @@ export class WebCommonUtil {
|
|
|
116
116
|
|
|
117
117
|
const body = error instanceof Error ? error :
|
|
118
118
|
(!!error && typeof error === 'object' && ('message' in error && typeof error.message === 'string')) ?
|
|
119
|
-
new
|
|
120
|
-
new
|
|
119
|
+
new RuntimeError(error.message, { details: error }) :
|
|
120
|
+
new RuntimeError(`${error}`);
|
|
121
121
|
|
|
122
122
|
const webError: Error & Partial<WebError> = body;
|
|
123
123
|
const statusCode = webError.details?.statusCode ?? ERROR_CATEGORY_STATUS[webError.category!] ?? 500;
|
package/src/util/cookie.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { RuntimeError } from '@travetto/runtime';
|
|
2
2
|
|
|
3
3
|
import type { Cookie, CookieGetOptions, CookieSetOptions } from '../types/cookie.ts';
|
|
4
4
|
import { KeyGrip } from './keygrip.ts';
|
|
@@ -7,40 +7,43 @@ import { WebHeaderUtil } from './header.ts';
|
|
|
7
7
|
const pairText = (cookie: Cookie): string => `${cookie.name}=${cookie.value}`;
|
|
8
8
|
const pair = (key: string, value: unknown): string => `${key}=${value}`;
|
|
9
9
|
|
|
10
|
-
type CookieJarOptions =
|
|
10
|
+
type CookieJarOptions = CookieSetOptions;
|
|
11
11
|
|
|
12
12
|
export class CookieJar {
|
|
13
13
|
|
|
14
|
-
#grip
|
|
14
|
+
#grip: KeyGrip;
|
|
15
15
|
#cookies: Record<string, Cookie> = {};
|
|
16
16
|
#setOptions: CookieSetOptions = {};
|
|
17
17
|
#deleteOptions: CookieSetOptions = { maxAge: 0, expires: undefined };
|
|
18
18
|
|
|
19
|
-
constructor(
|
|
20
|
-
this.#grip = keys
|
|
19
|
+
constructor(options: CookieJarOptions = {}, keys?: string[] | KeyGrip) {
|
|
20
|
+
this.#grip = (keys instanceof KeyGrip ? keys : new KeyGrip(keys ?? []));
|
|
21
21
|
this.#setOptions = {
|
|
22
22
|
secure: false,
|
|
23
23
|
path: '/',
|
|
24
|
-
signed: !!keys?.length,
|
|
25
24
|
...options,
|
|
26
25
|
};
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
get shouldSign(): boolean {
|
|
29
|
+
return this.#setOptions.signed ?? this.#grip.active;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async export(cookie: Cookie, response?: boolean): Promise<string[]> {
|
|
30
33
|
const suffix = response ? WebHeaderUtil.buildCookieSuffix(cookie) : null;
|
|
31
34
|
const payload = pairText(cookie);
|
|
32
35
|
const out = suffix ? [[payload, ...suffix].join(';')] : [payload];
|
|
33
36
|
if (cookie.signed) {
|
|
34
|
-
const sigPair = pair(`${cookie.name}.sig`, this.#grip
|
|
37
|
+
const sigPair = pair(`${cookie.name}.sig`, await this.#grip.sign(payload));
|
|
35
38
|
out.push(suffix ? [sigPair, ...suffix].join(';') : sigPair);
|
|
36
39
|
}
|
|
37
40
|
return out;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
import(cookies: Cookie[]):
|
|
43
|
+
async import(cookies: Cookie[]): Promise<void> {
|
|
41
44
|
const signatures: Record<string, string> = {};
|
|
42
45
|
for (const cookie of cookies) {
|
|
43
|
-
if (this
|
|
46
|
+
if (this.shouldSign && cookie.name.endsWith('.sig')) {
|
|
44
47
|
signatures[cookie.name.replace(/[.]sig$/, '')] = cookie.value!;
|
|
45
48
|
} else {
|
|
46
49
|
this.#cookies[cookie.name] = { signed: false, ...cookie };
|
|
@@ -55,19 +58,18 @@ export class CookieJar {
|
|
|
55
58
|
cookie.signed = true;
|
|
56
59
|
|
|
57
60
|
const computed = pairText(cookie);
|
|
58
|
-
const
|
|
61
|
+
const result = await this.#grip.isValid(computed, value);
|
|
59
62
|
|
|
60
|
-
if (
|
|
63
|
+
if (result === 'invalid') {
|
|
61
64
|
delete this.#cookies[name];
|
|
62
|
-
} else if (
|
|
65
|
+
} else if (result === 'stale') {
|
|
63
66
|
cookie.response = true;
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
|
-
return this;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
has(name: string, options: CookieGetOptions = {}): boolean {
|
|
70
|
-
const needSigned = options.signed ?? this
|
|
72
|
+
const needSigned = options.signed ?? this.shouldSign;
|
|
71
73
|
return name in this.#cookies && this.#cookies[name].signed === needSigned;
|
|
72
74
|
}
|
|
73
75
|
|
|
@@ -78,45 +80,52 @@ export class CookieJar {
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
set(cookie: Cookie): void {
|
|
81
|
-
const alias =
|
|
83
|
+
const alias = {
|
|
82
84
|
...this.#setOptions,
|
|
83
85
|
...cookie,
|
|
84
86
|
response: true,
|
|
85
87
|
...(cookie.value === null || cookie.value === undefined) ? this.#deleteOptions : {},
|
|
86
88
|
};
|
|
87
89
|
|
|
90
|
+
alias.signed ??= this.shouldSign;
|
|
91
|
+
|
|
88
92
|
if (!this.#setOptions.secure && alias.secure) {
|
|
89
|
-
throw new
|
|
93
|
+
throw new RuntimeError('Cannot send secure cookie over unencrypted connection');
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
if (alias.signed && !this.#grip) {
|
|
93
|
-
throw new
|
|
96
|
+
if (alias.signed && !this.#grip.active) {
|
|
97
|
+
throw new RuntimeError('Signing keys required for signed cookies');
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
if (alias.maxAge !== undefined && !alias.expires) {
|
|
97
101
|
alias.expires = new Date(Date.now() + alias.maxAge);
|
|
98
102
|
}
|
|
103
|
+
|
|
104
|
+
this.#cookies[cookie.name] = alias;
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
getAll(): Cookie[] {
|
|
102
108
|
return Object.values(this.#cookies);
|
|
103
109
|
}
|
|
104
110
|
|
|
105
|
-
importCookieHeader(header: string | null | undefined):
|
|
111
|
+
importCookieHeader(header: string | null | undefined): Promise<void> {
|
|
106
112
|
return this.import(WebHeaderUtil.parseCookieHeader(header ?? ''));
|
|
107
113
|
}
|
|
108
114
|
|
|
109
|
-
importSetCookieHeader(headers: string[] | null | undefined):
|
|
115
|
+
importSetCookieHeader(headers: string[] | null | undefined): Promise<void> {
|
|
110
116
|
return this.import(headers?.map(WebHeaderUtil.parseSetCookieHeader) ?? []);
|
|
111
117
|
}
|
|
112
118
|
|
|
113
|
-
exportCookieHeader(): string {
|
|
114
|
-
return
|
|
119
|
+
exportCookieHeader(): Promise<string> {
|
|
120
|
+
return Promise.all(this.getAll()
|
|
121
|
+
.map(cookie => this.export(cookie)))
|
|
122
|
+
.then(parts => parts.flat().join('; '));
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
exportSetCookieHeader(): string[] {
|
|
118
|
-
return this.getAll()
|
|
125
|
+
exportSetCookieHeader(): Promise<string[]> {
|
|
126
|
+
return Promise.all(this.getAll()
|
|
119
127
|
.filter(cookie => cookie.response)
|
|
120
|
-
.
|
|
128
|
+
.map(cookie => this.export(cookie, true)))
|
|
129
|
+
.then(parts => parts.flat());
|
|
121
130
|
}
|
|
122
131
|
}
|
package/src/util/keygrip.ts
CHANGED
|
@@ -1,43 +1,63 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { AppError, castKey } from '@travetto/runtime';
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
import { BinaryUtil, CodecUtil, type BinaryArray } from '@travetto/runtime';
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
const key = crypto.randomBytes(32);
|
|
8
|
-
const ah = crypto.createHmac('sha256', key).update(a).digest();
|
|
9
|
-
const bh = crypto.createHmac('sha256', key).update(b).digest();
|
|
10
|
-
return ah.length === bh.length && crypto.timingSafeEqual(ah, bh);
|
|
11
|
-
}
|
|
5
|
+
const CHAR_MAPPING: Record<string, string> = { '/': '_', '+': '-', '=': '' };
|
|
12
6
|
|
|
13
7
|
export class KeyGrip {
|
|
14
8
|
|
|
9
|
+
static async getRandomHmacKey(): Promise<CryptoKey> {
|
|
10
|
+
const keyBytes = crypto.getRandomValues(new Uint8Array(32));
|
|
11
|
+
return crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static async hmac(value: string, cryptoKey: CryptoKey): Promise<BinaryArray> {
|
|
15
|
+
const input = BinaryUtil.binaryArrayToBuffer(CodecUtil.fromUTF8String(value));
|
|
16
|
+
return BinaryUtil.binaryArrayToUint8Array(await crypto.subtle.sign('HMAC', cryptoKey, input));
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
#keys: string[];
|
|
16
|
-
#
|
|
17
|
-
#encoding: crypto.BinaryToTextEncoding;
|
|
20
|
+
#cryptoKeys = new Map<string, Promise<CryptoKey>>();
|
|
18
21
|
|
|
19
|
-
constructor(keys: string[]
|
|
20
|
-
if (!keys.length) {
|
|
21
|
-
throw new AppError('Keys must be defined');
|
|
22
|
-
}
|
|
22
|
+
constructor(keys: string[]) {
|
|
23
23
|
this.#keys = keys;
|
|
24
|
-
this.#algorithm = algorithm;
|
|
25
|
-
this.#encoding = encoding;
|
|
26
24
|
}
|
|
27
25
|
|
|
28
|
-
|
|
29
|
-
return
|
|
30
|
-
.createHmac(this.#algorithm, key ?? this.#keys[0])
|
|
31
|
-
.update(data)
|
|
32
|
-
.digest(this.#encoding)
|
|
33
|
-
.replace(/[/+=]/g, ch => CHAR_MAPPING[castKey(ch)]);
|
|
26
|
+
get active(): boolean {
|
|
27
|
+
return this.#keys.length > 0;
|
|
34
28
|
}
|
|
35
29
|
|
|
36
|
-
|
|
37
|
-
return this.
|
|
30
|
+
async getCryptoKey(key: string): Promise<CryptoKey> {
|
|
31
|
+
return this.#cryptoKeys.getOrInsertComputed(key, async () => {
|
|
32
|
+
const keyBytes = BinaryUtil.binaryArrayToBuffer(CodecUtil.fromUTF8String(key));
|
|
33
|
+
return crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
34
|
+
});
|
|
38
35
|
}
|
|
39
36
|
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
async sign(data: string, key?: string): Promise<string> {
|
|
38
|
+
const cryptoKey = await this.getCryptoKey(key ?? this.#keys[0]);
|
|
39
|
+
const signature = await KeyGrip.hmac(data, cryptoKey);
|
|
40
|
+
return CodecUtil.toBase64String(signature).replace(/[/+=]/g, ch => CHAR_MAPPING[ch]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
verify(data: string, digest: string): Promise<boolean> {
|
|
44
|
+
return this.isValid(data, digest).then(result => result !== 'invalid');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async isValid(data: string, digest: string): Promise<'valid' | 'invalid' | 'stale'> {
|
|
48
|
+
const key = await KeyGrip.getRandomHmacKey();
|
|
49
|
+
const digestBytes = BinaryUtil.binaryArrayToUint8Array(await KeyGrip.hmac(digest, key));
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < this.#keys.length; i++) {
|
|
52
|
+
const signedBytes = BinaryUtil.binaryArrayToUint8Array(await KeyGrip.hmac(await this.sign(data, this.#keys[i]), key));
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
signedBytes.byteLength === digestBytes.byteLength &&
|
|
56
|
+
timingSafeEqual(digestBytes, signedBytes)
|
|
57
|
+
) {
|
|
58
|
+
return i === 0 ? 'valid' : 'stale';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return 'invalid';
|
|
42
62
|
}
|
|
43
63
|
}
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { Readable } from 'node:stream';
|
|
3
|
-
|
|
4
|
-
import { AppError, BinaryUtil, castTo, JSONUtil } from '@travetto/runtime';
|
|
1
|
+
import { BinaryUtil, castTo, type BinaryType, type BinaryArray, CodecUtil, JSONUtil } from '@travetto/runtime';
|
|
5
2
|
import { BindUtil } from '@travetto/schema';
|
|
6
3
|
|
|
7
4
|
import type { WebResponse } from '../../src/types/response.ts';
|
|
@@ -10,21 +7,26 @@ import { DecompressInterceptor } from '../../src/interceptor/decompress.ts';
|
|
|
10
7
|
import { WebBodyUtil } from '../../src/util/body.ts';
|
|
11
8
|
import { WebCommonUtil } from '../../src/util/common.ts';
|
|
12
9
|
|
|
13
|
-
const toBuffer = (src: Buffer | Readable) => Buffer.isBuffer(src) ? src : buffer(src);
|
|
14
|
-
|
|
15
10
|
/**
|
|
16
11
|
* Utilities for supporting custom test dispatchers
|
|
17
12
|
*/
|
|
18
13
|
export class WebTestDispatchUtil {
|
|
19
14
|
|
|
20
|
-
static async applyRequestBody(request: WebRequest): Promise<WebRequest
|
|
15
|
+
static async applyRequestBody(request: WebRequest, toByteArray: true): Promise<WebRequest<BinaryArray>>;
|
|
16
|
+
static async applyRequestBody(request: WebRequest, toByteArray?: false): Promise<WebRequest<BinaryType>>;
|
|
17
|
+
static async applyRequestBody(request: WebRequest, toByteArray: boolean = false): Promise<WebRequest<BinaryType>> {
|
|
21
18
|
if (request.body !== undefined) {
|
|
22
19
|
const sample = WebBodyUtil.toBinaryMessage(request);
|
|
23
20
|
sample.headers.forEach((v, k) => request.headers.set(k, Array.isArray(v) ? v.join(',') : v));
|
|
24
|
-
|
|
21
|
+
if (toByteArray) {
|
|
22
|
+
sample.body = sample.body ?
|
|
23
|
+
await BinaryUtil.toBinaryArray(sample.body) :
|
|
24
|
+
BinaryUtil.makeBinaryArray(0);
|
|
25
|
+
}
|
|
26
|
+
request.body = WebBodyUtil.markRawBinary(sample.body);
|
|
25
27
|
}
|
|
26
28
|
Object.assign(request.context, { httpQuery: BindUtil.flattenPaths(request.context.httpQuery ?? {}) });
|
|
27
|
-
return request;
|
|
29
|
+
return castTo(request);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
static async finalizeResponseBody(response: WebResponse, decompress?: boolean): Promise<WebResponse> {
|
|
@@ -32,32 +34,32 @@ export class WebTestDispatchUtil {
|
|
|
32
34
|
|
|
33
35
|
response.context.httpStatusCode = WebCommonUtil.getStatusCode(response);
|
|
34
36
|
|
|
35
|
-
if (decompress) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
} catch { }
|
|
46
|
-
}
|
|
37
|
+
if (decompress && BinaryUtil.isBinaryType(result)) {
|
|
38
|
+
const bufferResult = result = await BinaryUtil.toBinaryArray(result);
|
|
39
|
+
if (bufferResult.byteLength) {
|
|
40
|
+
try {
|
|
41
|
+
result = await DecompressInterceptor.decompress(
|
|
42
|
+
response.headers,
|
|
43
|
+
bufferResult,
|
|
44
|
+
{ applies: true, supportedEncodings: ['br', 'deflate', 'gzip', 'identity'] }
|
|
45
|
+
);
|
|
46
|
+
} catch { }
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
const
|
|
50
|
+
const isJSON = response.headers.get('Content-Type') === 'application/json';
|
|
51
|
+
const isText = response.headers.get('Content-Type')?.startsWith('text/') ?? false;
|
|
51
52
|
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
if (BinaryUtil.isBinaryArray(result) && (isJSON || isText)) {
|
|
54
|
+
result = CodecUtil.toUTF8String(result);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof result === 'string' && isJSON) {
|
|
58
|
+
result = JSONUtil.fromUTF8(result, { reviver: JSONUtil.TRANSMIT_REVIVER });
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
if (response.context.httpStatusCode && response.context.httpStatusCode >= 400) {
|
|
60
|
-
result = WebCommonUtil.catchResponse(
|
|
62
|
+
result = WebCommonUtil.catchResponse(result).body;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
response.body = result;
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { AppError, castTo } from '@travetto/runtime';
|
|
1
|
+
import { RuntimeError, BinaryUtil, castTo, CodecUtil } from '@travetto/runtime';
|
|
4
2
|
|
|
5
3
|
import { Controller } from '../../../src/decorator/controller.ts';
|
|
6
4
|
import { Get, Post, Put, Delete, Patch } from '../../../src/decorator/endpoint.ts';
|
|
@@ -59,13 +57,13 @@ export class TestController {
|
|
|
59
57
|
@Get('/stream')
|
|
60
58
|
@SetHeaders({ 'Content-Type': 'text/plain' })
|
|
61
59
|
getStream() {
|
|
62
|
-
return
|
|
60
|
+
return BinaryUtil.toBinaryStream(CodecUtil.fromUTF8String('hello'));
|
|
63
61
|
}
|
|
64
62
|
|
|
65
63
|
@Get('/buffer')
|
|
66
64
|
@Produces('text/plain')
|
|
67
65
|
getBuffer() {
|
|
68
|
-
return
|
|
66
|
+
return CodecUtil.fromUTF8String('hello');
|
|
69
67
|
}
|
|
70
68
|
|
|
71
69
|
@Get('/renderable')
|
|
@@ -98,6 +96,6 @@ export class TestController {
|
|
|
98
96
|
|
|
99
97
|
@Post('/ip')
|
|
100
98
|
notFound() {
|
|
101
|
-
throw new
|
|
99
|
+
throw new RuntimeError('Uh-oh', { category: 'general' });
|
|
102
100
|
}
|
|
103
101
|
}
|