@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/CLAUDE.md +46 -7
- package/README.md +119 -8
- package/package.json +3 -2
- package/src/accept.ts +3 -6
- package/src/attachment.ts +40 -7
- package/src/driver-local-read.ts +104 -0
- package/src/driver-local-sidecar.ts +38 -0
- package/src/driver-local-write.ts +185 -0
- package/src/driver-local.ts +87 -164
- package/src/driver-memory.ts +220 -0
- package/src/driver-s3-region.ts +39 -0
- package/src/driver-s3.ts +129 -31
- package/src/driver.ts +63 -1
- package/src/errors.ts +99 -15
- package/src/grant.ts +2 -2
- package/src/image.ts +34 -8
- package/src/index.ts +16 -9
- package/src/iso-bmff.ts +50 -0
- package/src/path.ts +17 -2
- package/src/signed-url.ts +41 -8
- package/src/signing-secret.ts +71 -0
- package/src/upload.ts +31 -5
- package/src/driver-s3-fixture.ts +0 -164
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
/**
|
package/src/driver-s3-fixture.ts
DELETED
|
@@ -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
|
-
}
|