@ultimat3/storage 22.15.0 → 24.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/src/upload.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  contentTypeUnrecognised,
17
17
  tooLarge,
18
18
  } from './errors';
19
+ import { HEIF_CONTAINER, ISO_BMFF_CONTAINER, ISO_BMFF_TYPES, sniffIsoBmff } from './iso-bmff';
19
20
  import { assertSafeKey } from './path';
20
21
 
21
22
  export const DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
@@ -65,7 +66,12 @@ export function uploadPolicy(init: UploadPolicyInit = {}): UploadPolicy {
65
66
  init.maxBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : init.maxBytes,
66
67
  1,
67
68
  ),
68
- allowedContentTypes: init.allowedContentTypes ?? IMAGE_CONTENT_TYPES,
69
+ // Normalised, because the declared type is: `validateUpload` and `grantUpload` compare the
70
+ // NORMALISED declaration against this list, so an allowlist spelling an alias (`image/jpg`,
71
+ // `audio/x-m4a`) named a type no upload could ever match.
72
+ allowedContentTypes: (init.allowedContentTypes ?? IMAGE_CONTENT_TYPES).map(
73
+ normalizeContentType,
74
+ ),
69
75
  requireChecksum: init.requireChecksum ?? false,
70
76
  };
71
77
  }
@@ -111,7 +117,6 @@ const MAGIC_RULES: readonly MagicRule[] = [
111
117
  ],
112
118
  },
113
119
  { type: 'application/pdf', parts: [{ offset: 0, pattern: ascii('%PDF-') }] },
114
- { type: 'video/mp4', parts: [{ offset: 4, pattern: ascii('ftyp') }] },
115
120
  // Every OOXML document and epub is a zip; the container is as far as magic bytes go.
116
121
  { type: 'application/zip', parts: [{ offset: 0, pattern: [0x50, 0x4b, 0x03, 0x04] }] },
117
122
  ];
@@ -154,7 +159,9 @@ export function sniffContentType(bytes: Uint8Array): string | undefined {
154
159
  for (const rule of MAGIC_RULES) {
155
160
  if (matches(bytes, rule)) return rule.type;
156
161
  }
157
- return sniffText(bytes);
162
+ // Not a `MagicRule`: every ISO base media file opens with the same `ftyp` box, and the one rule
163
+ // that matched it labelled AVIF, HEIC, MOV and M4A all `video/mp4`. The major brand decides.
164
+ return sniffIsoBmff(bytes) ?? sniffText(bytes);
158
165
  }
159
166
 
160
167
  // A `Map`, not an object literal: `base` is the transport's own `Content-Type` header by the time
@@ -164,6 +171,10 @@ const ALIASES: ReadonlyMap<string, string> = new Map([
164
171
  ['image/jpg', 'image/jpeg'],
165
172
  ['image/x-png', 'image/png'],
166
173
  ['application/x-pdf', 'application/pdf'],
174
+ // What Chrome and Safari report for an `.m4a` / `.m4v` picked from disk.
175
+ ['audio/x-m4a', 'audio/mp4'],
176
+ ['audio/m4a', 'audio/mp4'],
177
+ ['video/x-m4v', 'video/mp4'],
167
178
  ]);
168
179
 
169
180
  /** Strip parameters and case: `IMAGE/PNG; charset=binary` and `image/png` are one type. */
@@ -182,11 +193,23 @@ const TEXT_FAMILY = new Set([
182
193
  'text/xml',
183
194
  ]);
184
195
 
185
- /** A generic sniff (zip container, plain text) may stand in for a specific declared type. */
196
+ // A brand that names only the container: `isom` is on an audio-only MP4 as often as on a video,
197
+ // and `mif1` on a still HEIC and a still AVIF alike. `image/heif` is also the general name of a
198
+ // file whose brand says `heic`, so that pair matches in the other direction too.
199
+ const MP4_CONTAINED = new Set(['audio/mp4']);
200
+ const HEIF_CONTAINED = new Set(['image/heic', 'image/avif']);
201
+
202
+ /**
203
+ * A generic sniff (zip container, ISO-BMFF container, plain text) may stand in for a specific
204
+ * declared type.
205
+ */
186
206
  export function contentTypeMatches(declared: string, sniffed: string): boolean {
187
207
  const type = normalizeContentType(declared);
188
208
  if (type === sniffed) return true;
189
209
  if (sniffed === 'application/zip') return ZIP_CONTAINERS.has(type);
210
+ if (sniffed === ISO_BMFF_CONTAINER) return MP4_CONTAINED.has(type);
211
+ if (sniffed === HEIF_CONTAINER) return HEIF_CONTAINED.has(type);
212
+ if (sniffed === 'image/heic') return type === HEIF_CONTAINER;
190
213
  if (sniffed === 'text/plain') return TEXT_FAMILY.has(type);
191
214
  return false;
192
215
  }
@@ -199,7 +222,10 @@ export function contentTypeMatches(declared: string, sniffed: string): boolean {
199
222
  * as provably wrong as one under `image/png`.
200
223
  */
201
224
  function hasSignature(declared: string): boolean {
202
- return MAGIC_RULES.some((rule) => contentTypeMatches(declared, rule.type));
225
+ return (
226
+ MAGIC_RULES.some((rule) => contentTypeMatches(declared, rule.type)) ||
227
+ ISO_BMFF_TYPES.some((type) => contentTypeMatches(declared, type))
228
+ );
203
229
  }
204
230
 
205
231
  /**
@@ -1,164 +0,0 @@
1
- // The in-memory `Bun.S3Client` stand-in the s3 driver's tests drive, and the helpers that read
2
- // its answers. Shared rather than copied because `driver-s3.test.ts` (reads, copies, listings,
3
- // credentials) and `driver-s3-put.test.ts` (everything `put()` refuses) must exercise the SAME
4
- // fake — two fakes drifting apart would be two providers, agreeing only by construction.
5
-
6
- import type { S3ClientLike, S3FileLike, S3ListResultLike, S3StatLike } from './driver-s3';
7
- import { isStorageError, objectNotFound } from './errors';
8
-
9
- /** The driver's private `DRIVER_NAME`; the fake reports failures against the same disk. */
10
- export const FAKE_DISK = 's3';
11
-
12
- export interface FakeObject {
13
- bytes: Uint8Array;
14
- type?: string | undefined;
15
- etag?: string | undefined;
16
- lastModified?: string | Date | undefined;
17
- }
18
-
19
- export interface PresignCall {
20
- key: string;
21
- options: {
22
- method?: string | undefined;
23
- expiresIn?: number | undefined;
24
- type?: string | undefined;
25
- };
26
- }
27
-
28
- export interface ListCall {
29
- prefix?: string | undefined;
30
- maxKeys?: number | undefined;
31
- continuationToken?: string | undefined;
32
- }
33
-
34
- /**
35
- * What Bun hands back when the SERVICE refuses: an ordinary `Error` named `S3Error`, carrying the
36
- * provider's own code and status. The driver must read those structurally, because nothing in the
37
- * type system says a caught value has them.
38
- */
39
- export function s3Error(code: string, statusCode: number, key: string): Error {
40
- return Object.assign(new Error(`${code}: the fake provider refused ${key}`), {
41
- name: 'S3Error',
42
- code,
43
- statusCode,
44
- path: key,
45
- });
46
- }
47
-
48
- /** In-memory stand-in for `Bun.S3Client`, driven through `S3ClientLike` — no socket, ever. */
49
- export class FakeS3Client implements S3ClientLike {
50
- readonly store = new Map<string, FakeObject>();
51
- readonly fileCalls: string[] = [];
52
- readonly presignCalls: PresignCall[] = [];
53
- readonly listCalls: ListCall[] = [];
54
- listResult: S3ListResultLike = { contents: [] };
55
- /** A provider REFUSAL (403/503/reset) — the failure `delete()` must surface, not swallow. */
56
- failDeleteFor: string | undefined;
57
- /** A provider "there is nothing there" — the one failure `delete()` may call success. */
58
- absentDeleteFor: string | undefined;
59
- /**
60
- * A refused LISTING — a denied `s3:ListBucket`, a throttle, an expired credential. There is no
61
- * "absent" counterpart: a prefix with no keys is an empty page, never a rejection, so every
62
- * failure `list()` can meet is one it must surface.
63
- */
64
- failListWith: Error | undefined;
65
-
66
- /** Which key each handed-out `S3FileLike` stands for, so `write(sourceFile)` can read it. */
67
- private readonly fileKeys = new WeakMap<object, string>();
68
-
69
- /** The bytes behind an `S3FileLike` passed as `write`'s data, or `undefined` for raw bytes. */
70
- sourceOf(data: unknown): Uint8Array | undefined {
71
- if (typeof data !== 'object' || data === null) return undefined;
72
- const sourceKey = this.fileKeys.get(data);
73
- return sourceKey === undefined ? undefined : this.store.get(sourceKey)?.bytes;
74
- }
75
-
76
- file(key: string): S3FileLike {
77
- this.fileCalls.push(key);
78
- const store = this.store;
79
- const client = this;
80
- const handle: S3FileLike = {
81
- async write(data, options) {
82
- // `copy()` hands the SOURCE S3File to write(), exactly as Bun's own union allows, so the
83
- // fake has to read one back out of its store rather than assume bytes.
84
- const source = client.sourceOf(data);
85
- const bytes =
86
- source !== undefined
87
- ? source
88
- : data instanceof Uint8Array
89
- ? data
90
- : new Uint8Array(await (data as Blob).arrayBuffer());
91
- store.set(key, { bytes, type: options?.type });
92
- return bytes.byteLength;
93
- },
94
- async arrayBuffer() {
95
- const entry = store.get(key);
96
- // Reads the way the provider does: a GET on a key that is not there is a 404, and the
97
- // driver is expected to have gated it behind exists() before ever getting here.
98
- if (entry === undefined) throw objectNotFound(FAKE_DISK, key);
99
- return new Uint8Array(entry.bytes).buffer;
100
- },
101
- async exists() {
102
- return store.has(key);
103
- },
104
- async delete() {
105
- // Shaped like the real thing: an `S3Error` is a plain Error with `name: 'S3Error'`, a
106
- // provider `code` and a status. Deliberately NOT a StorageError — the driver has to
107
- // classify a rejection it never authored, which is the only kind a provider hands back.
108
- if (client.absentDeleteFor === key) throw s3Error('NoSuchKey', 404, key);
109
- if (client.failDeleteFor === key) throw s3Error('AccessDenied', 403, key);
110
- store.delete(key);
111
- },
112
- stream() {
113
- const entry = store.get(key);
114
- const bytes = entry?.bytes ?? new Uint8Array();
115
- return new ReadableStream<Uint8Array>({
116
- start(controller) {
117
- controller.enqueue(bytes);
118
- controller.close();
119
- },
120
- });
121
- },
122
- async stat(): Promise<S3StatLike> {
123
- const entry = store.get(key);
124
- if (entry === undefined) throw objectNotFound(FAKE_DISK, key);
125
- return {
126
- size: entry.bytes.byteLength,
127
- type: entry.type,
128
- etag: entry.etag,
129
- lastModified: entry.lastModified,
130
- };
131
- },
132
- presign(options) {
133
- client.presignCalls.push({ key, options });
134
- return `https://fake.example/${key}?signed`;
135
- },
136
- };
137
- this.fileKeys.set(handle, key);
138
- return handle;
139
- }
140
-
141
- async list(input: ListCall): Promise<S3ListResultLike> {
142
- this.listCalls.push(input);
143
- if (this.failListWith !== undefined) throw this.failListWith;
144
- return this.listResult;
145
- }
146
- }
147
-
148
- export const bytesOf = (text: string): Uint8Array => new TextEncoder().encode(text);
149
- export const textOf = (bytes: Uint8Array): string => new TextDecoder().decode(bytes);
150
-
151
- /** The error code a call answered with, or how it failed to answer with one. */
152
- export function codeOf(caught: unknown): string {
153
- return isStorageError(caught) ? caught.code : `not-a-storage-error: ${String(caught)}`;
154
- }
155
-
156
- /** The thrown value itself, where the assertion is about its `cause`/`fix` and not just its code. */
157
- export async function catchError(fn: () => Promise<unknown>): Promise<unknown> {
158
- try {
159
- await fn();
160
- return undefined;
161
- } catch (error) {
162
- return error;
163
- }
164
- }