@ultimat3/storage 29.4.0 → 30.1.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/CLAUDE.md +25 -45
- package/README.md +102 -12
- package/package.json +12 -2
- package/src/accept-part.ts +30 -0
- package/src/accept.ts +7 -0
- package/src/attachment.ts +99 -6
- package/src/byte-pump.ts +46 -0
- package/src/driver-local-list.ts +75 -0
- package/src/driver-local-multipart.ts +133 -0
- package/src/driver-local-remove.ts +39 -0
- package/src/driver-local-stream.ts +104 -0
- package/src/driver-local-write.ts +28 -5
- package/src/driver-local.ts +250 -295
- package/src/driver-memory-multipart.ts +41 -0
- package/src/driver-memory.ts +205 -143
- package/src/driver-s3-client.ts +9 -0
- package/src/driver-s3-copy.ts +35 -0
- package/src/driver-s3-lock.ts +9 -2
- package/src/driver-s3-multipart-session.ts +87 -0
- package/src/driver-s3-multipart.ts +177 -0
- package/src/driver-s3-presign.ts +52 -0
- package/src/driver-s3-put-stream.ts +97 -0
- package/src/driver-s3-settle.ts +150 -0
- package/src/driver-s3-signed.ts +68 -3
- package/src/driver-s3-sse.ts +29 -0
- package/src/driver-s3.ts +89 -65
- package/src/driver.ts +86 -36
- package/src/errors-multipart.ts +35 -0
- package/src/errors-promote.ts +14 -0
- package/src/errors-sniff.ts +35 -0
- package/src/errors-stream.ts +62 -0
- package/src/errors.ts +13 -0
- package/src/expire.ts +169 -0
- package/src/grant-multipart.ts +196 -0
- package/src/grant.ts +42 -25
- package/src/image-warm.ts +84 -0
- package/src/image.ts +57 -0
- package/src/index.ts +75 -3
- package/src/multipart-staged.ts +127 -0
- package/src/multipart.ts +119 -0
- package/src/put-stream.ts +52 -0
- package/src/range.ts +35 -4
- package/src/signed-url.ts +59 -2
- package/src/subject-target.ts +82 -0
- package/src/upload-client-multipart.ts +156 -0
- package/src/upload-client.ts +21 -0
- package/src/upload.ts +107 -21
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `storageSubjectTarget(disk, keysOf)`: the subject's stored objects as a subject target a
|
|
2
|
+
// `defineSubject({ targets })` lists — structural, so `@ultimat3/jobs` imports nothing from here.
|
|
3
|
+
// Which keys are the person's is the app's (`keysOf`); the export lists each object, the erase
|
|
4
|
+
// deletes them. A key the app does not name is not found: there is no listing by owner.
|
|
5
|
+
|
|
6
|
+
import type { StorageDriver } from './driver';
|
|
7
|
+
|
|
8
|
+
/** The subject as a target is handed it: the key asked for, and the subject's row read fresh. */
|
|
9
|
+
export interface StorageSubjectRef {
|
|
10
|
+
readonly key: string;
|
|
11
|
+
readonly row: Readonly<Record<string, unknown>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StorageSubjectTargetOptions {
|
|
15
|
+
/** The step key and export folder. Default `storage`. Unique among one subject's targets. */
|
|
16
|
+
readonly name?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Cell = string | number | boolean | null;
|
|
20
|
+
|
|
21
|
+
export interface StorageSubjectTarget {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly columns: readonly string[];
|
|
24
|
+
exportPage(
|
|
25
|
+
subject: StorageSubjectRef,
|
|
26
|
+
cursor: string | null,
|
|
27
|
+
size: number,
|
|
28
|
+
): Promise<{
|
|
29
|
+
readonly rows: readonly Readonly<Record<string, Cell>>[];
|
|
30
|
+
readonly cursor: string | null;
|
|
31
|
+
}>;
|
|
32
|
+
erase(subject: StorageSubjectRef, size: number): Promise<number>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const STORAGE_SUBJECT_COLUMNS = ['key', 'size', 'contentType', 'lastModified'] as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `disk` is a THUNK, read per call, as an export's sink is: a subject is declared at import and
|
|
39
|
+
* the app's disks exist after `defineStorage()`.
|
|
40
|
+
*/
|
|
41
|
+
export function storageSubjectTarget(
|
|
42
|
+
disk: () => StorageDriver,
|
|
43
|
+
/** The person's keys — a list the app builds, or lists from a prefix it owns. */
|
|
44
|
+
keysOf: (subject: StorageSubjectRef) => readonly string[] | Promise<readonly string[]>,
|
|
45
|
+
options: StorageSubjectTargetOptions = {},
|
|
46
|
+
): StorageSubjectTarget {
|
|
47
|
+
return {
|
|
48
|
+
name: options.name ?? 'storage',
|
|
49
|
+
columns: STORAGE_SUBJECT_COLUMNS,
|
|
50
|
+
async exportPage(subject, cursor, size) {
|
|
51
|
+
const keys = await keysOf(subject);
|
|
52
|
+
const start = cursor === null ? 0 : Number(cursor);
|
|
53
|
+
const page = keys.slice(start, start + size);
|
|
54
|
+
const rows: Record<string, Cell>[] = [];
|
|
55
|
+
for (const key of page) {
|
|
56
|
+
const found = await disk().stat(key);
|
|
57
|
+
if (found === undefined) continue;
|
|
58
|
+
rows.push({
|
|
59
|
+
key,
|
|
60
|
+
size: found.size,
|
|
61
|
+
contentType: found.contentType,
|
|
62
|
+
// An instant, UTC, its zone in the string — never formatted for a locale here. Absent
|
|
63
|
+
// when the provider sent none, and never invented.
|
|
64
|
+
lastModified: found.lastModified?.toISOString() ?? null,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const next = start + page.length;
|
|
68
|
+
return { rows, cursor: next < keys.length ? String(next) : null };
|
|
69
|
+
},
|
|
70
|
+
async erase(subject, size) {
|
|
71
|
+
// Only what still exists counts, so a replayed batch answers short and the loop ends.
|
|
72
|
+
let removed = 0;
|
|
73
|
+
for (const key of await keysOf(subject)) {
|
|
74
|
+
if (removed >= size) break;
|
|
75
|
+
if (!(await disk().exists(key))) continue;
|
|
76
|
+
await disk().delete(key);
|
|
77
|
+
removed += 1;
|
|
78
|
+
}
|
|
79
|
+
return removed;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Single responsibility: the browser half of an upload in parts — grant (or resume), PUT each part
|
|
2
|
+
// at its own signed URL with bounded concurrency and core's retry, then complete. Browser-safe as
|
|
3
|
+
// `upload-client.ts` is: no `Bun.*`, no `node:`, no driver import, and every network call is either
|
|
4
|
+
// the app's typed action or the one `SignedPut` seam.
|
|
5
|
+
|
|
6
|
+
import { finiteCount, retry } from '@ultimat3/core';
|
|
7
|
+
import { uploadFailed } from './errors';
|
|
8
|
+
import type { UploadRequest } from './grant';
|
|
9
|
+
import type { MultipartGrant } from './grant-multipart';
|
|
10
|
+
import type { CompletedPart, UploadedPart } from './multipart';
|
|
11
|
+
import type { SignedPut, UploadProgress, UploadSource } from './upload-client';
|
|
12
|
+
|
|
13
|
+
/** One open session, as each of the app's calls names it. */
|
|
14
|
+
export interface UploadSessionRef {
|
|
15
|
+
readonly key: string;
|
|
16
|
+
readonly uploadId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The app's typed action calls, each wrapping one server function of `grant-multipart.ts` under the
|
|
21
|
+
* app's own policy — the reason `grant` is the app's in `uploadFile`.
|
|
22
|
+
*/
|
|
23
|
+
export interface MultipartUploadClient {
|
|
24
|
+
readonly grant: (request: UploadRequest) => Promise<MultipartGrant>;
|
|
25
|
+
readonly signPart: (
|
|
26
|
+
input: UploadSessionRef & { readonly partNumber: number; readonly size: number },
|
|
27
|
+
) => Promise<string>;
|
|
28
|
+
readonly listParts: (input: UploadSessionRef) => Promise<readonly UploadedPart[]>;
|
|
29
|
+
/** Etags are read on the server (`completeUpload`), so no bucket CORS has to expose `ETag`. */
|
|
30
|
+
readonly complete: (
|
|
31
|
+
input: UploadSessionRef & { readonly parts: readonly CompletedPart[] },
|
|
32
|
+
) => Promise<unknown>;
|
|
33
|
+
/** Called when the caller's `signal` aborts: a cancelled upload leaves no parts billing. */
|
|
34
|
+
readonly abort: (input: UploadSessionRef) => Promise<unknown>;
|
|
35
|
+
/** At or under this, one PUT as before. Default 64 MiB. */
|
|
36
|
+
readonly partThresholdBytes?: number | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* A grant from an earlier, interrupted call for the same file: its parts are listed first and only
|
|
39
|
+
* the missing ones are sent. Persisting it is the caller's — this file keeps no browser storage.
|
|
40
|
+
*/
|
|
41
|
+
readonly resume?: MultipartGrant | undefined;
|
|
42
|
+
/** Handed the grant before any part moves, so the caller can persist it for `resume`. */
|
|
43
|
+
readonly onGrant?: ((grant: MultipartGrant) => void) | undefined;
|
|
44
|
+
/** Test seam for the retry waits. Defaults to a timer. */
|
|
45
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const DEFAULT_PART_THRESHOLD_BYTES = 64 * 1024 * 1024;
|
|
49
|
+
/** Parts in flight at once: enough to fill a phone's uplink, few enough to keep its memory. */
|
|
50
|
+
export const PART_CONCURRENCY = 4;
|
|
51
|
+
const PART_RETRY = { attempts: 4, base: 500, max: 8_000, jitter: 'full' } as const;
|
|
52
|
+
|
|
53
|
+
export const partThresholdOf = (client: MultipartUploadClient): number =>
|
|
54
|
+
finiteCount(
|
|
55
|
+
'uploadFile',
|
|
56
|
+
'multipart.partThresholdBytes',
|
|
57
|
+
client.partThresholdBytes === undefined
|
|
58
|
+
? DEFAULT_PART_THRESHOLD_BYTES
|
|
59
|
+
: client.partThresholdBytes,
|
|
60
|
+
1,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export interface MultipartUploadInput {
|
|
64
|
+
readonly file: UploadSource;
|
|
65
|
+
readonly client: MultipartUploadClient;
|
|
66
|
+
readonly put: SignedPut;
|
|
67
|
+
readonly onProgress?: ((progress: UploadProgress) => void) | undefined;
|
|
68
|
+
readonly signal?: AbortSignal | undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A grant for this file, or the resumed one; a resume for another size is a different file. */
|
|
72
|
+
async function sessionFor(input: MultipartUploadInput): Promise<MultipartGrant> {
|
|
73
|
+
const { file, client } = input;
|
|
74
|
+
const resumed = client.resume;
|
|
75
|
+
if (resumed !== undefined && resumed.size === file.size) return resumed;
|
|
76
|
+
const grant = await client.grant({
|
|
77
|
+
filename: file.name,
|
|
78
|
+
contentType: file.type,
|
|
79
|
+
size: file.size,
|
|
80
|
+
});
|
|
81
|
+
client.onGrant?.(grant);
|
|
82
|
+
return grant;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Send every part not yet on the disk, then complete. Answers the grant the object landed under. */
|
|
86
|
+
export async function uploadInParts(input: MultipartUploadInput): Promise<MultipartGrant> {
|
|
87
|
+
const { file, client, put, signal } = input;
|
|
88
|
+
const grant = await sessionFor(input);
|
|
89
|
+
const session = { key: grant.key, uploadId: grant.uploadId };
|
|
90
|
+
const total = file.size;
|
|
91
|
+
const count = Math.ceil(total / grant.partBytes);
|
|
92
|
+
const landed = client.resume === grant ? await client.listParts(session) : [];
|
|
93
|
+
const have = new Set(
|
|
94
|
+
landed
|
|
95
|
+
.filter(
|
|
96
|
+
(part) =>
|
|
97
|
+
part.size === Math.min(grant.partBytes, total - (part.partNumber - 1) * grant.partBytes),
|
|
98
|
+
)
|
|
99
|
+
.map((part) => part.partNumber),
|
|
100
|
+
);
|
|
101
|
+
let confirmed = landed
|
|
102
|
+
.filter((part) => have.has(part.partNumber))
|
|
103
|
+
.reduce((sum, part) => sum + part.size, 0);
|
|
104
|
+
input.onProgress?.({ loaded: confirmed, total, ratio: total > 0 ? confirmed / total : 0 });
|
|
105
|
+
|
|
106
|
+
const missing: number[] = [];
|
|
107
|
+
for (let number = 1; number <= count; number += 1) if (!have.has(number)) missing.push(number);
|
|
108
|
+
|
|
109
|
+
const cancelled = () => uploadFailed(grant.key, 0, 'the upload was aborted');
|
|
110
|
+
// A rejected wait ends core's retry loop with that error: a cancelled upload stops retrying.
|
|
111
|
+
const waitUnlessAborted = (ms: number): Promise<void> =>
|
|
112
|
+
signal?.aborted === true
|
|
113
|
+
? Promise.reject(cancelled())
|
|
114
|
+
: (client.sleep ?? ((wait) => new Promise<void>((done) => setTimeout(done, wait))))(ms);
|
|
115
|
+
|
|
116
|
+
const sendPart = async (partNumber: number): Promise<void> => {
|
|
117
|
+
const start = (partNumber - 1) * grant.partBytes;
|
|
118
|
+
const body = file.slice(start, Math.min(start + grant.partBytes, total));
|
|
119
|
+
await retry(
|
|
120
|
+
async () => {
|
|
121
|
+
const url = await client.signPart({ ...session, partNumber, size: body.size });
|
|
122
|
+
await put({
|
|
123
|
+
url,
|
|
124
|
+
contentType: grant.contentType,
|
|
125
|
+
body,
|
|
126
|
+
...(signal === undefined ? {} : { signal }),
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
PART_RETRY,
|
|
130
|
+
{ sleep: waitUnlessAborted },
|
|
131
|
+
);
|
|
132
|
+
confirmed += body.size;
|
|
133
|
+
input.onProgress?.({ loaded: confirmed, total, ratio: total > 0 ? confirmed / total : 0 });
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const worker = async (): Promise<void> => {
|
|
137
|
+
for (let next = missing.shift(); next !== undefined; next = missing.shift()) {
|
|
138
|
+
if (signal?.aborted === true) return;
|
|
139
|
+
await sendPart(next);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
try {
|
|
143
|
+
await Promise.all(Array.from({ length: Math.min(PART_CONCURRENCY, missing.length) }, worker));
|
|
144
|
+
if (signal?.aborted === true) throw cancelled();
|
|
145
|
+
} catch (error) {
|
|
146
|
+
// Cancelled is abandoned: the parts are dropped now rather than billing until a sweep. Any
|
|
147
|
+
// other failure keeps the session open, so the same grant passed as `resume` sends only what
|
|
148
|
+
// is missing. A refused abort is the sweep's to reclaim (`abortStaleUploads`), and the
|
|
149
|
+
// caller hears the failure that stopped the upload, not the clean-up's.
|
|
150
|
+
if (signal?.aborted === true) await client.abort(session).catch(() => undefined);
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
const parts = Array.from({ length: count }, (_, at) => ({ partNumber: at + 1 }));
|
|
154
|
+
await client.complete({ ...session, parts });
|
|
155
|
+
return grant;
|
|
156
|
+
}
|
package/src/upload-client.ts
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
import { clientTransport } from '@ultimat3/core';
|
|
13
13
|
import { tooLarge, uploadFailed } from './errors';
|
|
14
14
|
import type { UploadGrant, UploadRequest } from './grant';
|
|
15
|
+
import {
|
|
16
|
+
type MultipartUploadClient,
|
|
17
|
+
partThresholdOf,
|
|
18
|
+
uploadInParts,
|
|
19
|
+
} from './upload-client-multipart';
|
|
15
20
|
|
|
16
21
|
export interface UploadProgress {
|
|
17
22
|
readonly loaded: number;
|
|
@@ -144,6 +149,11 @@ export interface UploadFileInput {
|
|
|
144
149
|
* package has no way to know which route it was projected onto.
|
|
145
150
|
*/
|
|
146
151
|
readonly grant: (request: UploadRequest) => Promise<UploadGrant>;
|
|
152
|
+
/**
|
|
153
|
+
* The parts form, beside `grant` — which answers one signed PUT and cannot carry parts. A file
|
|
154
|
+
* over `partThresholdBytes` (64 MiB) goes up in parts, resumable; at or under it, `grant` as before.
|
|
155
|
+
*/
|
|
156
|
+
readonly multipart?: MultipartUploadClient | undefined;
|
|
147
157
|
readonly onProgress?: ((progress: UploadProgress) => void) | undefined;
|
|
148
158
|
readonly signal?: AbortSignal | undefined;
|
|
149
159
|
readonly put?: SignedPut | undefined;
|
|
@@ -162,6 +172,17 @@ export interface UploadedFile {
|
|
|
162
172
|
* that was always going to be refused.
|
|
163
173
|
*/
|
|
164
174
|
export async function uploadFile(input: UploadFileInput): Promise<UploadedFile> {
|
|
175
|
+
const multipart = input.multipart;
|
|
176
|
+
if (multipart !== undefined && input.file.size > partThresholdOf(multipart)) {
|
|
177
|
+
const grant = await uploadInParts({
|
|
178
|
+
file: input.file,
|
|
179
|
+
client: multipart,
|
|
180
|
+
put: input.put ?? defaultSignedPut(),
|
|
181
|
+
...(input.onProgress === undefined ? {} : { onProgress: input.onProgress }),
|
|
182
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
183
|
+
});
|
|
184
|
+
return { key: grant.key, contentType: grant.contentType, size: input.file.size };
|
|
185
|
+
}
|
|
165
186
|
const grant = await input.grant({
|
|
166
187
|
filename: input.file.name,
|
|
167
188
|
contentType: input.file.type,
|
package/src/upload.ts
CHANGED
|
@@ -17,11 +17,21 @@ import {
|
|
|
17
17
|
tooLarge,
|
|
18
18
|
xmlBodyUnreadable,
|
|
19
19
|
} from './errors';
|
|
20
|
+
import { leadingWhitespacePastLimit, xmlBodyPastWindow } from './errors-sniff';
|
|
20
21
|
import { HEIF_CONTAINER, ISO_BMFF_CONTAINER, ISO_BMFF_TYPES, sniffIsoBmff } from './iso-bmff';
|
|
21
22
|
import { assertSafeKey } from './path';
|
|
22
23
|
|
|
23
24
|
export const DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* How much of a stored object promotion reads to decide its type. Every signature fits, and
|
|
28
|
+
* `MAX_LEADING_WHITESPACE` is what stops a body hiding its markup past the end of it.
|
|
29
|
+
*/
|
|
30
|
+
export const SNIFF_WINDOW_BYTES = 64 * 1024;
|
|
31
|
+
|
|
32
|
+
/** Bytes of whitespace a body may open with. No real file opens with more. */
|
|
33
|
+
export const MAX_LEADING_WHITESPACE = 4096;
|
|
34
|
+
|
|
25
35
|
/**
|
|
26
36
|
* `image/svg+xml` is deliberately ABSENT, and this is the default `uploadPolicy()` allowlist. An
|
|
27
37
|
* SVG is a script document: served back from the app's own origin under its declared type it runs
|
|
@@ -127,10 +137,11 @@ const matches = (bytes: Uint8Array, rule: MagicRule): boolean =>
|
|
|
127
137
|
part.pattern.every((byte, index) => bytes[part.offset + index] === byte),
|
|
128
138
|
);
|
|
129
139
|
|
|
130
|
-
|
|
140
|
+
/** `truncated`: the bytes are a head, so a character cut at their end is not a bad sequence. */
|
|
141
|
+
function sniffText(bytes: Uint8Array, truncated: boolean): string | undefined {
|
|
131
142
|
let text: string;
|
|
132
143
|
try {
|
|
133
|
-
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
144
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: truncated });
|
|
134
145
|
} catch {
|
|
135
146
|
return undefined;
|
|
136
147
|
}
|
|
@@ -139,7 +150,9 @@ function sniffText(bytes: Uint8Array): string | undefined {
|
|
|
139
150
|
const printable = code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d;
|
|
140
151
|
if (!printable) return undefined;
|
|
141
152
|
}
|
|
142
|
-
|
|
153
|
+
// Trimmed BEFORE it is cut. The other way round, 512 bytes of padding left an empty head, and
|
|
154
|
+
// XHTML under `application/xml` was accepted as the plain text it was not.
|
|
155
|
+
const head = text.trimStart().slice(0, 512).toLowerCase();
|
|
143
156
|
if (
|
|
144
157
|
head.startsWith('<!doctype html') ||
|
|
145
158
|
head.startsWith('<html') ||
|
|
@@ -195,14 +208,28 @@ function activeMarkupType(text: string): 'text/html' | 'image/svg+xml' | undefin
|
|
|
195
208
|
return scanned.includes(SVG_NAMESPACE) ? 'image/svg+xml' : undefined;
|
|
196
209
|
}
|
|
197
210
|
|
|
198
|
-
|
|
199
|
-
export function sniffContentType(bytes: Uint8Array): string | undefined {
|
|
211
|
+
function sniffBytes(bytes: Uint8Array, truncated: boolean): string | undefined {
|
|
200
212
|
for (const rule of MAGIC_RULES) {
|
|
201
213
|
if (matches(bytes, rule)) return rule.type;
|
|
202
214
|
}
|
|
203
215
|
// Not a `MagicRule`: every ISO base media file opens with the same `ftyp` box, and the one rule
|
|
204
216
|
// that matched it labelled AVIF, HEIC, MOV and M4A all `video/mp4`. The major brand decides.
|
|
205
|
-
return sniffIsoBmff(bytes) ?? sniffText(bytes);
|
|
217
|
+
return sniffIsoBmff(bytes) ?? sniffText(bytes, truncated);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** `undefined` means "no rule recognised it", never "it is fine". */
|
|
221
|
+
export const sniffContentType = (bytes: Uint8Array): string | undefined => sniffBytes(bytes, false);
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whether the first `MAX_LEADING_WHITESPACE + 1` bytes are all whitespace. Decoded leniently, and
|
|
225
|
+
* with `stream` so a wide space cut at the end of the slice is not a replacement character: the
|
|
226
|
+
* question is asked of bytes the strict sniff may go on to give up on.
|
|
227
|
+
*/
|
|
228
|
+
function opensPastWhitespaceLimit(bytes: Uint8Array): boolean {
|
|
229
|
+
if (bytes.byteLength <= MAX_LEADING_WHITESPACE) return false;
|
|
230
|
+
const opening = bytes.subarray(0, MAX_LEADING_WHITESPACE + 1);
|
|
231
|
+
const text = new TextDecoder('utf-8', { ignoreBOM: true }).decode(opening, { stream: true });
|
|
232
|
+
return text.trimStart() === '';
|
|
206
233
|
}
|
|
207
234
|
|
|
208
235
|
// A `Map`, not an object literal: `base` is the transport's own `Content-Type` header by the time
|
|
@@ -274,23 +301,24 @@ const isXmlType = (type: string): boolean =>
|
|
|
274
301
|
type === 'application/xml' || type === 'text/xml' || type.endsWith('+xml');
|
|
275
302
|
|
|
276
303
|
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
304
|
+
* Whether these bytes may be what `declared` says: the one type rule, over a whole body or a head.
|
|
305
|
+
* `truncated` says the object goes on past `bytes`, which only matters to the two questions a head
|
|
306
|
+
* cannot answer in full: a character cut at its end, and whether an XML document runs a script.
|
|
280
307
|
*/
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
308
|
+
function assertTypeAgrees(
|
|
309
|
+
key: string,
|
|
310
|
+
declared: string,
|
|
311
|
+
bytes: Uint8Array,
|
|
312
|
+
truncated: boolean,
|
|
313
|
+
): void {
|
|
314
|
+
if (opensPastWhitespaceLimit(bytes)) {
|
|
315
|
+
throw leadingWhitespacePastLimit(key, declared, MAX_LEADING_WHITESPACE);
|
|
316
|
+
}
|
|
317
|
+
// `activeMarkupType` must read the whole document, because XML runs a script anywhere in it.
|
|
318
|
+
if (truncated && isXmlType(declared)) {
|
|
319
|
+
throw xmlBodyPastWindow(key, declared, SNIFF_WINDOW_BYTES);
|
|
292
320
|
}
|
|
293
|
-
const sniffed =
|
|
321
|
+
const sniffed = sniffBytes(bytes, truncated);
|
|
294
322
|
if (sniffed === undefined) {
|
|
295
323
|
// `undefined` is "no rule recognised it", never "it is fine" — `sniffContentType`'s own doc
|
|
296
324
|
// says so and this branch read it the other way, so any body the sniffer bailed on (one
|
|
@@ -306,6 +334,26 @@ export function validateUpload(
|
|
|
306
334
|
} else if (!contentTypeMatches(declared, sniffed)) {
|
|
307
335
|
throw contentTypeMismatch(key, declared, sniffed);
|
|
308
336
|
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Throws the first violated constraint, in this order: key, size, type, checksum. The key comes
|
|
341
|
+
* before the size because a key nothing may store makes the other three moot, and which
|
|
342
|
+
* constraint a rejected upload reports is what the client retries on — `upload.test.ts` pins it.
|
|
343
|
+
*/
|
|
344
|
+
export function validateUpload(
|
|
345
|
+
candidate: UploadCandidate,
|
|
346
|
+
policy: UploadPolicy = uploadPolicy(),
|
|
347
|
+
): ValidatedUpload {
|
|
348
|
+
const key = assertSafeKey(candidate.key);
|
|
349
|
+
const size = candidate.bytes.byteLength;
|
|
350
|
+
if (size > policy.maxBytes) throw tooLarge(key, size, policy.maxBytes);
|
|
351
|
+
|
|
352
|
+
const declared = normalizeContentType(candidate.declaredContentType);
|
|
353
|
+
if (!policy.allowedContentTypes.includes(declared)) {
|
|
354
|
+
throw contentTypeNotAllowed(key, declared, policy.allowedContentTypes);
|
|
355
|
+
}
|
|
356
|
+
assertTypeAgrees(key, declared, candidate.bytes, false);
|
|
309
357
|
|
|
310
358
|
const checksum = sha256Base64(candidate.bytes);
|
|
311
359
|
const claimed = candidate.checksum;
|
|
@@ -315,3 +363,41 @@ export function validateUpload(
|
|
|
315
363
|
}
|
|
316
364
|
return { key, contentType: declared, bytes: candidate.bytes, size, checksum };
|
|
317
365
|
}
|
|
366
|
+
|
|
367
|
+
export interface StoredHeadCandidate {
|
|
368
|
+
readonly key: string;
|
|
369
|
+
/** The type the object was stored under. On a provider disk, whatever the uploader sent. */
|
|
370
|
+
readonly declaredContentType: string;
|
|
371
|
+
/** The object's first bytes: `disk.get(key, { range: [0, SNIFF_WINDOW_BYTES - 1] })`. */
|
|
372
|
+
readonly head: Uint8Array;
|
|
373
|
+
/** The WHOLE object's size, which is how a head knows it is one. */
|
|
374
|
+
readonly size: number;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export interface ValidatedHead {
|
|
378
|
+
readonly key: string;
|
|
379
|
+
readonly contentType: string;
|
|
380
|
+
readonly size: number;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* `validateUpload` for bytes already on a disk, from a head and a size, so promotion neither
|
|
385
|
+
* buffers an object nor refuses one past `maxGetBytes`. Same order, no checksum: a whole-body hash
|
|
386
|
+
* is what this exists not to need. An XML type over the window is refused here and only here, as
|
|
387
|
+
* the server-side path reads all of it.
|
|
388
|
+
*/
|
|
389
|
+
export function validateStoredHead(
|
|
390
|
+
candidate: StoredHeadCandidate,
|
|
391
|
+
policy: UploadPolicy = uploadPolicy(),
|
|
392
|
+
): ValidatedHead {
|
|
393
|
+
const key = assertSafeKey(candidate.key);
|
|
394
|
+
const { size } = candidate;
|
|
395
|
+
if (size > policy.maxBytes) throw tooLarge(key, size, policy.maxBytes);
|
|
396
|
+
|
|
397
|
+
const declared = normalizeContentType(candidate.declaredContentType);
|
|
398
|
+
if (!policy.allowedContentTypes.includes(declared)) {
|
|
399
|
+
throw contentTypeNotAllowed(key, declared, policy.allowedContentTypes);
|
|
400
|
+
}
|
|
401
|
+
assertTypeAgrees(key, declared, candidate.head, size > candidate.head.byteLength);
|
|
402
|
+
return { key, contentType: declared, size };
|
|
403
|
+
}
|