@ultimat3/storage 29.3.2 → 30.0.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 +27 -47
- package/README.md +130 -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-read.ts +13 -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 +257 -283
- package/src/driver-memory-multipart.ts +41 -0
- package/src/driver-memory.ts +205 -135
- 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-range.ts +99 -0
- package/src/driver-s3-settle.ts +150 -0
- package/src/driver-s3-signed.ts +69 -4
- package/src/driver-s3-sse.ts +29 -0
- package/src/driver-s3.ts +113 -65
- package/src/driver.ts +98 -38
- package/src/errors-multipart.ts +35 -0
- package/src/errors-promote.ts +14 -0
- package/src/errors-range.ts +47 -0
- package/src/errors-sniff.ts +35 -0
- package/src/errors-stream.ts +62 -0
- package/src/errors.ts +16 -1
- 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 +81 -2
- package/src/multipart-staged.ts +127 -0
- package/src/multipart.ts +119 -0
- package/src/put-stream.ts +52 -0
- package/src/range.ts +102 -0
- 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
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
|
+
}
|