@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.
Files changed (50) hide show
  1. package/CLAUDE.md +27 -47
  2. package/README.md +130 -12
  3. package/package.json +12 -2
  4. package/src/accept-part.ts +30 -0
  5. package/src/accept.ts +7 -0
  6. package/src/attachment.ts +99 -6
  7. package/src/byte-pump.ts +46 -0
  8. package/src/driver-local-list.ts +75 -0
  9. package/src/driver-local-multipart.ts +133 -0
  10. package/src/driver-local-read.ts +13 -0
  11. package/src/driver-local-remove.ts +39 -0
  12. package/src/driver-local-stream.ts +104 -0
  13. package/src/driver-local-write.ts +28 -5
  14. package/src/driver-local.ts +257 -283
  15. package/src/driver-memory-multipart.ts +41 -0
  16. package/src/driver-memory.ts +205 -135
  17. package/src/driver-s3-client.ts +9 -0
  18. package/src/driver-s3-copy.ts +35 -0
  19. package/src/driver-s3-lock.ts +9 -2
  20. package/src/driver-s3-multipart-session.ts +87 -0
  21. package/src/driver-s3-multipart.ts +177 -0
  22. package/src/driver-s3-presign.ts +52 -0
  23. package/src/driver-s3-put-stream.ts +97 -0
  24. package/src/driver-s3-range.ts +99 -0
  25. package/src/driver-s3-settle.ts +150 -0
  26. package/src/driver-s3-signed.ts +69 -4
  27. package/src/driver-s3-sse.ts +29 -0
  28. package/src/driver-s3.ts +113 -65
  29. package/src/driver.ts +98 -38
  30. package/src/errors-multipart.ts +35 -0
  31. package/src/errors-promote.ts +14 -0
  32. package/src/errors-range.ts +47 -0
  33. package/src/errors-sniff.ts +35 -0
  34. package/src/errors-stream.ts +62 -0
  35. package/src/errors.ts +16 -1
  36. package/src/expire.ts +169 -0
  37. package/src/grant-multipart.ts +196 -0
  38. package/src/grant.ts +42 -25
  39. package/src/image-warm.ts +84 -0
  40. package/src/image.ts +57 -0
  41. package/src/index.ts +81 -2
  42. package/src/multipart-staged.ts +127 -0
  43. package/src/multipart.ts +119 -0
  44. package/src/put-stream.ts +52 -0
  45. package/src/range.ts +102 -0
  46. package/src/signed-url.ts +59 -2
  47. package/src/subject-target.ts +82 -0
  48. package/src/upload-client-multipart.ts +156 -0
  49. package/src/upload-client.ts +21 -0
  50. package/src/upload.ts +107 -21
@@ -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
- function sniffText(bytes: Uint8Array): string | undefined {
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
- const head = text.slice(0, 512).trimStart().toLowerCase();
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
- /** `undefined` means "no rule recognised it", never "it is fine". */
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
- * Throws the first violated constraint, in this order: key, size, type, checksum. The key comes
278
- * before the size because a key nothing may store makes the other three moot, and which
279
- * constraint a rejected upload reports is what the client retries on — `upload.test.ts` pins it.
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
- export function validateUpload(
282
- candidate: UploadCandidate,
283
- policy: UploadPolicy = uploadPolicy(),
284
- ): ValidatedUpload {
285
- const key = assertSafeKey(candidate.key);
286
- const size = candidate.bytes.byteLength;
287
- if (size > policy.maxBytes) throw tooLarge(key, size, policy.maxBytes);
288
-
289
- const declared = normalizeContentType(candidate.declaredContentType);
290
- if (!policy.allowedContentTypes.includes(declared)) {
291
- throw contentTypeNotAllowed(key, declared, policy.allowedContentTypes);
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 = sniffContentType(candidate.bytes);
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
+ }