@ultimat3/storage 23.0.0 → 25.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 +69 -42
- package/README.md +165 -15
- package/package.json +3 -3
- package/src/accept.ts +3 -6
- package/src/attachment.ts +46 -16
- package/src/driver-local-read.ts +114 -0
- package/src/driver-local-sidecar.ts +96 -0
- package/src/driver-local-write.ts +185 -0
- package/src/driver-local.ts +166 -188
- package/src/driver-memory-conflict.ts +45 -0
- package/src/driver-memory.ts +109 -25
- package/src/driver-s3-absent.ts +32 -0
- package/src/driver-s3-client.ts +94 -0
- package/src/driver-s3-lock.ts +131 -0
- package/src/driver-s3-signed.ts +220 -0
- package/src/driver-s3.ts +180 -167
- package/src/driver.ts +98 -19
- package/src/errors.ts +112 -13
- package/src/grant.ts +2 -2
- package/src/image.ts +34 -8
- package/src/index.ts +29 -16
- package/src/iso-bmff.ts +50 -0
- package/src/object-lock.ts +173 -0
- package/src/path.ts +17 -2
- package/src/signed-url.ts +41 -12
- package/src/signing-secret.ts +76 -0
- package/src/upload.ts +82 -5
package/src/driver.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// strings — a base64 round trip through JSON is how a "small" upload becomes a 33%-larger OOM —
|
|
4
4
|
// and never unbounded, which is the same failure with the sender choosing the size.
|
|
5
5
|
|
|
6
|
-
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import type { Clock, Page } from '@ultimat3/core';
|
|
7
7
|
import { assert } from '@ultimat3/core';
|
|
8
8
|
import { putTooLarge } from './errors';
|
|
9
|
+
import type { ObjectLock, ObjectLockOptions } from './object-lock';
|
|
9
10
|
import type { SignedUrlVerification } from './signed-url';
|
|
10
11
|
|
|
11
12
|
export type StorageBody = Uint8Array | ReadableStream<Uint8Array> | Blob;
|
|
@@ -23,7 +24,12 @@ export interface StorageListEntry {
|
|
|
23
24
|
/** Absent means the driver's listing does not carry it. Never a fabricated default. */
|
|
24
25
|
readonly contentType?: string | undefined;
|
|
25
26
|
readonly etag: string;
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* ABSENT when the provider did not say — never epoch 0, which every age test reads as "older
|
|
29
|
+
* than any window", and never an invalid `Date`, which is a value a reader has to remember to
|
|
30
|
+
* test. A reader that needs an age handles `undefined`; `sweepOrphans` spares it.
|
|
31
|
+
*/
|
|
32
|
+
readonly lastModified?: Date | undefined;
|
|
27
33
|
/** Present only when the driver actually stored what `put()` was handed — never invented. */
|
|
28
34
|
readonly cacheControl?: string | undefined;
|
|
29
35
|
readonly metadata?: Readonly<Record<string, string>> | undefined;
|
|
@@ -48,7 +54,12 @@ export interface ServerSideEncryption {
|
|
|
48
54
|
readonly kmsKeyId?: string | undefined;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
/**
|
|
58
|
+
* `retention` and `legalHold` (from `ObjectLockOptions`) are S3 Object Lock, per object: sent as
|
|
59
|
+
* `x-amz-object-lock-*` on s3 — which needs a bucket created with Object Lock — and EMULATED by the
|
|
60
|
+
* local and memory disks, which refuse a delete or an overwrite of a locked object until it lapses.
|
|
61
|
+
*/
|
|
62
|
+
export interface PutOptions extends ObjectLockOptions {
|
|
52
63
|
readonly contentType?: string | undefined;
|
|
53
64
|
readonly cacheControl?: string | undefined;
|
|
54
65
|
readonly metadata?: Readonly<Record<string, string>> | undefined;
|
|
@@ -60,18 +71,15 @@ export interface PutOptions {
|
|
|
60
71
|
|
|
61
72
|
export interface ListOptions {
|
|
62
73
|
readonly prefix?: string | undefined;
|
|
63
|
-
/**
|
|
64
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Opaque; pass back the `nextCursor` of the previous page. `null` and absent both mean the
|
|
76
|
+
* first page, so `cursor: page.nextCursor` threads a loop with no conversion — the loop stops
|
|
77
|
+
* on `hasMore: false` before a `null` is ever handed back as "the page after the last".
|
|
78
|
+
*/
|
|
79
|
+
readonly cursor?: string | null | undefined;
|
|
65
80
|
readonly limit?: number | undefined;
|
|
66
81
|
}
|
|
67
82
|
|
|
68
|
-
export interface ListPage {
|
|
69
|
-
readonly objects: readonly StorageListEntry[];
|
|
70
|
-
readonly truncated: boolean;
|
|
71
|
-
/** Absent when `truncated` is false. */
|
|
72
|
-
readonly cursor?: string | undefined;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
83
|
export interface StorageRead {
|
|
76
84
|
readonly object: StorageObject;
|
|
77
85
|
readonly bytes: Uint8Array;
|
|
@@ -107,14 +115,27 @@ export interface StorageDriver {
|
|
|
107
115
|
*/
|
|
108
116
|
readonly signedUrlBase?: string | undefined;
|
|
109
117
|
/**
|
|
110
|
-
* `defineStorage` tells the driver the key it was registered under, at boot, once.
|
|
111
|
-
* mints its own URLs hangs them off THAT, never off `name
|
|
112
|
-
* route resolves the segment through the registry, so a disk registered
|
|
113
|
-
* say `local` 404s every signature it just wrote
|
|
114
|
-
*
|
|
118
|
+
* `defineStorage` tells the driver the key it was registered under, at boot, once. Two readers:
|
|
119
|
+
* a driver that mints its own URLs hangs them off THAT, never off `name` — the mounted
|
|
120
|
+
* `/_storage/:disk/*key` route resolves the segment through the registry, so a disk registered
|
|
121
|
+
* as `uploads` whose URLs say `local` 404s every signature it just wrote — and every
|
|
122
|
+
* `X_STORAGE_NOT_FOUND`, whose `fix:` is `disk('<name>').list(…)`. `local`, `s3`
|
|
123
|
+
* and `memory` use it for both — their URLs and their not-found errors. Optional: a
|
|
124
|
+
* third-party driver that ignores it keeps minting and refusing under `name`.
|
|
115
125
|
*/
|
|
116
126
|
registerAs?(diskName: string): void;
|
|
117
127
|
put(key: string, body: StorageBody, options?: PutOptions): Promise<StorageObject>;
|
|
128
|
+
/**
|
|
129
|
+
* What a read would say about the object, WITHOUT its bytes — `undefined` when nothing is there.
|
|
130
|
+
* `promoteAttachment` decides on `stat().size`, because a presigned PUT lands on the disk
|
|
131
|
+
* unmeasured and `get()` is the one call that must not be how its size is learned. Required: a
|
|
132
|
+
* driver that cannot measure cannot be promoted from, and that is a compile error, not a refusal.
|
|
133
|
+
*/
|
|
134
|
+
stat(key: string): Promise<StorageObject | undefined>;
|
|
135
|
+
/**
|
|
136
|
+
* The whole object, BUFFERED — so bounded: an object over the driver's `maxGetBytes` is
|
|
137
|
+
* `X_STORAGE_TOO_LARGE`, exactly as `put()` refuses a body over `maxPutBytes`. Past it, `stream()`.
|
|
138
|
+
*/
|
|
118
139
|
get(key: string): Promise<StorageRead>;
|
|
119
140
|
/** Bytes without buffering — the only safe path for anything over a few MB. */
|
|
120
141
|
stream(key: string): Promise<ReadableStream<Uint8Array>>;
|
|
@@ -131,8 +152,20 @@ export interface StorageDriver {
|
|
|
131
152
|
*/
|
|
132
153
|
delete(key: string): Promise<void>;
|
|
133
154
|
exists(key: string): Promise<boolean>;
|
|
134
|
-
|
|
155
|
+
/**
|
|
156
|
+
* One page of core's ONE `Page` shape: `nextCursor` exists exactly when another object does,
|
|
157
|
+
* so a full last page answers `hasMore: false` rather than a cursor to an empty page.
|
|
158
|
+
*/
|
|
159
|
+
list(options?: ListOptions): Promise<Page<StorageListEntry>>;
|
|
135
160
|
signedUrl(key: string, options?: SignedUrlOptions): Promise<string>;
|
|
161
|
+
/**
|
|
162
|
+
* What locks one object — its Object Lock retention and legal hold — read back from the disk.
|
|
163
|
+
* `{ legalHold: false }` with no `retention` means nothing does, including on a bucket created
|
|
164
|
+
* without Object Lock. `X_STORAGE_NOT_FOUND` when no object is at the key. Optional, like
|
|
165
|
+
* `registerAs`: every shipped disk answers it, and a third-party driver that does not is a
|
|
166
|
+
* driver with no lock to report.
|
|
167
|
+
*/
|
|
168
|
+
retentionOf?(key: string): Promise<ObjectLock>;
|
|
136
169
|
/**
|
|
137
170
|
* Verify a URL THIS disk minted — without surrendering the key it minted with.
|
|
138
171
|
*
|
|
@@ -164,7 +197,7 @@ export const DEFAULT_LIST_LIMIT = 1000;
|
|
|
164
197
|
* The page size a `list()` honours — refused rather than clamped when it cannot be one.
|
|
165
198
|
*
|
|
166
199
|
* At the `ListOptions` seam and not inside a driver, because the two answered a non-positive
|
|
167
|
-
* limit differently: the local disk sliced `[0, 0)` and then dropped its own
|
|
200
|
+
* limit differently: the local disk sliced `[0, 0)` and then dropped its own "more" flag, so
|
|
168
201
|
* `list({ limit: 0 })` over a full disk reported a COMPLETE empty listing, while the s3 disk
|
|
169
202
|
* handed `maxKeys: 0` straight to the provider. `sweepOrphans` pages through `list()`, so a page
|
|
170
203
|
* that is empty and claims to be complete is a false erasure report — the same lie a swallowed
|
|
@@ -181,6 +214,45 @@ export function resolveListLimit(limit: number | undefined): number {
|
|
|
181
214
|
return limit;
|
|
182
215
|
}
|
|
183
216
|
|
|
217
|
+
const isStringRecord = (value: unknown): boolean =>
|
|
218
|
+
typeof value === 'object' &&
|
|
219
|
+
value !== null &&
|
|
220
|
+
!Array.isArray(value) &&
|
|
221
|
+
Object.values(value).every((entry) => typeof entry === 'string');
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* What `put()` was told ABOUT the bytes, held to its type before a byte moves. An untyped caller's
|
|
225
|
+
* `metadata: { n: 1n }` reached `JSON.stringify` on the local disk as a bare `TypeError`, and the
|
|
226
|
+
* memory disk stored it — against the `Record<string, string>` every reader is typed on.
|
|
227
|
+
*/
|
|
228
|
+
export function assertPutOptions(driver: string, options: PutOptions | undefined): void {
|
|
229
|
+
if (options === undefined) return;
|
|
230
|
+
for (const name of ['contentType', 'cacheControl'] as const) {
|
|
231
|
+
assert(
|
|
232
|
+
options[name] === undefined || typeof options[name] === 'string',
|
|
233
|
+
`put() on the ${driver} disk was given a ${name} that is not a string`,
|
|
234
|
+
`put(key, body, { ${name}: 'a string' }) — or omit ${name}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
assert(
|
|
238
|
+
options.metadata === undefined || isStringRecord(options.metadata),
|
|
239
|
+
`put() on the ${driver} disk was given metadata that is not a plain object of string values`,
|
|
240
|
+
"put(key, body, { metadata: { name: 'value' } }) — every value a string: String(n) a number, JSON.stringify anything nested",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A `prefix` or a `cursor` that is not a string reached `startsWith` and `<=` as whatever it was. */
|
|
245
|
+
export function assertListOptions(options: ListOptions | undefined): void {
|
|
246
|
+
for (const name of ['prefix', 'cursor'] as const) {
|
|
247
|
+
const value = options?.[name];
|
|
248
|
+
assert(
|
|
249
|
+
value === undefined || typeof value === 'string' || (name === 'cursor' && value === null),
|
|
250
|
+
`list() was given a ${name} that is not a string`,
|
|
251
|
+
`disk.list({ ${name}: 'a string' }) — or omit ${name}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
184
256
|
/** What `toBytes` needs to refuse a body: the ceiling, and the key to name in the refusal. */
|
|
185
257
|
export interface ByteLimit {
|
|
186
258
|
readonly driver: string;
|
|
@@ -212,6 +284,13 @@ export async function toBytes(body: StorageBody, limit: ByteLimit): Promise<Uint
|
|
|
212
284
|
}
|
|
213
285
|
return new Uint8Array(await body.arrayBuffer());
|
|
214
286
|
}
|
|
287
|
+
// The type says so; an untyped caller's `put(key, 'a string')` reached `getReader` as a
|
|
288
|
+
// bare `TypeError`.
|
|
289
|
+
assert(
|
|
290
|
+
body instanceof ReadableStream,
|
|
291
|
+
`put() on the ${limit.driver} disk was given a body that is not a Uint8Array, a Blob or a ReadableStream`,
|
|
292
|
+
"put(key, new TextEncoder().encode(text)) — bytes, never a string: encode it, or pass the request's own body stream",
|
|
293
|
+
);
|
|
215
294
|
return readBounded(body, limit);
|
|
216
295
|
}
|
|
217
296
|
|
package/src/errors.ts
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
// rejected upload must tell the caller which constraint fired and where that constraint is
|
|
3
3
|
// configured, or the caller retries the same bytes forever.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
registerErrorCodes,
|
|
7
|
+
renderFixLiteral,
|
|
8
|
+
renderThrowable,
|
|
9
|
+
UltimateError,
|
|
10
|
+
} from '@ultimat3/core';
|
|
6
11
|
|
|
7
12
|
/** Codes this package declares and owns. */
|
|
8
13
|
export const STORAGE_OWNED_ERROR_CODES = [
|
|
@@ -21,11 +26,15 @@ export const STORAGE_OWNED_ERROR_CODES = [
|
|
|
21
26
|
'X_STORAGE_LIST_FAILED',
|
|
22
27
|
'X_STORAGE_QUARANTINED',
|
|
23
28
|
'X_STORAGE_NOT_PENDING',
|
|
29
|
+
'X_STORAGE_KEY_CONFLICT',
|
|
30
|
+
'X_STORAGE_PUT_FAILED',
|
|
31
|
+
'X_STORAGE_READ_FAILED',
|
|
32
|
+
'X_STORAGE_OBJECT_LOCKED',
|
|
24
33
|
] as const;
|
|
25
34
|
|
|
26
35
|
/**
|
|
27
|
-
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s.
|
|
28
|
-
* keeps no title for it — one code, one owner, one title, or the two copies drift apart in silence.
|
|
36
|
+
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. The drivers throw core's own `NotImplementedError`
|
|
37
|
+
* and this package keeps no title for it — one code, one owner, one title, or the two copies drift apart in silence.
|
|
29
38
|
* `X_IMAGE_UNSUPPORTED` / `X_IMAGE_DECODE_FAILED` are core's too and surface unwrapped (`image.ts`).
|
|
30
39
|
* `X_ENV_MISSING` is core's for the same reason: an unset `STORAGE_SIGNING_SECRET` outside
|
|
31
40
|
* development is a missing environment variable, not a storage concept needing its own code.
|
|
@@ -57,6 +66,10 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
|
|
|
57
66
|
X_STORAGE_LIST_FAILED: 'the objects could not be listed',
|
|
58
67
|
X_STORAGE_QUARANTINED: 'the object is still in quarantine',
|
|
59
68
|
X_STORAGE_NOT_PENDING: 'the key is not a pending upload',
|
|
69
|
+
X_STORAGE_KEY_CONFLICT: 'the key collides with another key on a local or memory disk',
|
|
70
|
+
X_STORAGE_PUT_FAILED: 'the object could not be written',
|
|
71
|
+
X_STORAGE_READ_FAILED: 'the object could not be read',
|
|
72
|
+
X_STORAGE_OBJECT_LOCKED: 'the object is under retention or a legal hold',
|
|
60
73
|
};
|
|
61
74
|
|
|
62
75
|
// One unconditional call, so a second package claiming one of storage's codes throws
|
|
@@ -156,11 +169,55 @@ export const putTooLarge = (
|
|
|
156
169
|
(disk === 's3'
|
|
157
170
|
? `s3Driver({ bucket, maxPutBytes: ${bytes} })`
|
|
158
171
|
: disk === 'memory'
|
|
159
|
-
? `
|
|
172
|
+
? `memoryStorageDriver({ maxPutBytes: ${bytes} })`
|
|
160
173
|
: `localDriver({ root, maxPutBytes: ${bytes} })`),
|
|
161
174
|
meta: { disk, key, bytes, maxBytes },
|
|
162
175
|
});
|
|
163
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The SERVER-side `get()` ceiling — `putTooLarge`'s twin. `get()` buffers the whole object, and an
|
|
179
|
+
* object's size is not this process's to choose: a presigned PUT lands in the bucket unmeasured,
|
|
180
|
+
* so a later `get()` of it was heap growth the uploader picked.
|
|
181
|
+
*/
|
|
182
|
+
export const getTooLarge = (
|
|
183
|
+
disk: string,
|
|
184
|
+
key: string,
|
|
185
|
+
bytes: number,
|
|
186
|
+
maxBytes: number,
|
|
187
|
+
): StorageError =>
|
|
188
|
+
new StorageError({
|
|
189
|
+
code: 'X_STORAGE_TOO_LARGE',
|
|
190
|
+
cause: `"${key}" is ${bytes}B against the ${disk} disk's get ceiling of ${maxBytes}B, and get() buffers the whole object`,
|
|
191
|
+
fix:
|
|
192
|
+
`read it with disk.stream(key) instead of get(), or raise the ceiling: ` +
|
|
193
|
+
(disk === 's3'
|
|
194
|
+
? `s3Driver({ bucket, maxGetBytes: ${bytes} })`
|
|
195
|
+
: disk === 'memory'
|
|
196
|
+
? `memoryStorageDriver({ maxGetBytes: ${bytes} })`
|
|
197
|
+
: `localDriver({ root, maxGetBytes: ${bytes} })`),
|
|
198
|
+
meta: { disk, key, bytes, maxBytes },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The local disk stores an object as a FILE at its key, so a key cannot also be the directory of
|
|
203
|
+
* another: `put('a')` then `put('a/b')`, or the reverse, or the same pair one tree over in the
|
|
204
|
+
* sidecars (`a` and `a.json/b`). It surfaced as a bare `ENOTDIR` / `EISDIR`. S3 holds both —
|
|
205
|
+
* this is the local disk's own limit, refused by name rather than hidden, and the memory disk
|
|
206
|
+
* emulates it (`driver-memory-conflict.ts`) because a test's disk stands in for the dev disk.
|
|
207
|
+
*
|
|
208
|
+
* The `fix` lists what is in the way: the remedy is storing one of the two under another key, and
|
|
209
|
+
* which one is only decidable by a reader who can see both.
|
|
210
|
+
*/
|
|
211
|
+
export const keyConflict = (disk: string, key: string, blocking: string): StorageError =>
|
|
212
|
+
new StorageError({
|
|
213
|
+
code: 'X_STORAGE_KEY_CONFLICT',
|
|
214
|
+
cause: `the "${disk}" disk stores keys as POSIX paths (a local disk, or the memory disk standing in for one) and cannot store "${key}": "${blocking}" is already on it, and a POSIX path is a file or a directory, never both — so one key cannot be a path prefix of another here. Store one of the two under a different key; an s3 disk holds both`,
|
|
215
|
+
// The REGISTERED name, never the driver kind: `disk('local')` on a disk registered as
|
|
216
|
+
// `uploads` lists another disk, or answers X_STORAGE_DISK_UNKNOWN.
|
|
217
|
+
fix: `disk(${renderFixLiteral(disk, "'local'")}).list({ prefix: ${renderFixLiteral(blocking, "'a/'")} })`,
|
|
218
|
+
meta: { disk, key, blocking },
|
|
219
|
+
});
|
|
220
|
+
|
|
164
221
|
/** The declared type is not on the allowlist at all. */
|
|
165
222
|
export const contentTypeNotAllowed = (
|
|
166
223
|
key: string,
|
|
@@ -204,6 +261,20 @@ export const contentTypeUnrecognised = (key: string, declared: string): StorageE
|
|
|
204
261
|
meta: { key, declared },
|
|
205
262
|
});
|
|
206
263
|
|
|
264
|
+
/**
|
|
265
|
+
* The XML twin of `contentTypeUnrecognised`: no signature names an XML type, so "matches no known
|
|
266
|
+
* signature" would be false here. The finding is that the bytes are not UTF-8 text — UTF-16, a
|
|
267
|
+
* legacy charset, a control byte — so the screen for script-carrying markup never saw what a
|
|
268
|
+
* browser would decode. Same shipped code: what a client retries on is unchanged.
|
|
269
|
+
*/
|
|
270
|
+
export const xmlBodyUnreadable = (key: string, declared: string): StorageError =>
|
|
271
|
+
new StorageError({
|
|
272
|
+
code: 'X_STORAGE_TYPE_REJECTED',
|
|
273
|
+
cause: `"${key}" declares ${declared}, and its bytes are not readable UTF-8 text, so they cannot be screened for the script a browser would run from them`,
|
|
274
|
+
fix: `validateUpload({ key: ${renderFixLiteral(key, "'a/data.xml'")}, declaredContentType: ${renderFixLiteral(declared, "'application/xml'")}, bytes: new TextEncoder().encode(text) }, policy) — where text is the document decoded from its own charset (iconv -f UTF-16 -t UTF-8), with no byte-order mark and no control characters`,
|
|
275
|
+
meta: { key, declared },
|
|
276
|
+
});
|
|
277
|
+
|
|
207
278
|
export const checksumMismatch = (key: string, declared: string, actual: string): StorageError =>
|
|
208
279
|
new StorageError({
|
|
209
280
|
code: 'X_STORAGE_CHECKSUM_MISMATCH',
|
|
@@ -258,6 +329,43 @@ export const deleteFailed = (
|
|
|
258
329
|
});
|
|
259
330
|
};
|
|
260
331
|
|
|
332
|
+
/**
|
|
333
|
+
* A write the disk REFUSED — `deleteFailed`'s twin for `put()` and `copy()`. The local disk's
|
|
334
|
+
* `EACCES` / `ENOSPC` / `EROFS` left as a bare filesystem `Error`, and a provider's refused PUT as
|
|
335
|
+
* a bare `S3Error`: no code, no fix, nothing for the http error map to render but an anonymous 500.
|
|
336
|
+
* The `fix` is the driver's, for `deleteFailed`'s reason.
|
|
337
|
+
*/
|
|
338
|
+
export const putFailed = (disk: string, key: string, error: unknown, fix: string): StorageError => {
|
|
339
|
+
const reason = renderThrowable(error);
|
|
340
|
+
return new StorageError({
|
|
341
|
+
code: 'X_STORAGE_PUT_FAILED',
|
|
342
|
+
cause: `disk "${disk}" refused to write "${key}": ${reason}`,
|
|
343
|
+
fix,
|
|
344
|
+
meta: { disk, key, reason },
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* A read the disk REFUSED — `putFailed`'s read-side twin, for `get`, `stat`, `exists`, `stream` and
|
|
350
|
+
* the read half of `copy`. A denied `s3:GetObject`, a throttle, `EACCES`, `EIO`. An object that is
|
|
351
|
+
* simply absent is `objectNotFound`, never this: "not there" and "could not look" need opposite
|
|
352
|
+
* answers from a caller. The `fix` is the driver's, for `deleteFailed`'s reason.
|
|
353
|
+
*/
|
|
354
|
+
export const readFailed = (
|
|
355
|
+
disk: string,
|
|
356
|
+
key: string,
|
|
357
|
+
error: unknown,
|
|
358
|
+
fix: string,
|
|
359
|
+
): StorageError => {
|
|
360
|
+
const reason = renderThrowable(error);
|
|
361
|
+
return new StorageError({
|
|
362
|
+
code: 'X_STORAGE_READ_FAILED',
|
|
363
|
+
cause: `disk "${disk}" refused to read "${key}": ${reason}`,
|
|
364
|
+
fix,
|
|
365
|
+
meta: { disk, key, reason },
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
|
|
261
369
|
/**
|
|
262
370
|
* A listing the disk REFUSED — a denied `s3:ListBucket`, a throttle, an expired credential, a root
|
|
263
371
|
* this process cannot read. Exactly `deleteFailed`'s shape and for exactly its reason: an empty
|
|
@@ -363,12 +471,3 @@ export const signingSecretMissing = (environment: string, disk = 'local'): Stora
|
|
|
363
471
|
fix: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
|
|
364
472
|
meta: { key: 'STORAGE_SIGNING_SECRET', environment },
|
|
365
473
|
});
|
|
366
|
-
|
|
367
|
-
/** An interface-complete driver whose remote half is not bound yet. Always carries a fix. */
|
|
368
|
-
export const storageNotImplemented = (feature: string, fix: string): StorageError =>
|
|
369
|
-
new StorageError({
|
|
370
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
371
|
-
cause: `${feature} is declared but not implemented in @ultimat3/storage`,
|
|
372
|
-
fix,
|
|
373
|
-
meta: { feature },
|
|
374
|
-
});
|
package/src/grant.ts
CHANGED
|
@@ -83,12 +83,12 @@ export async function grantUpload(input: GrantUploadInput): Promise<UploadGrant>
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// Refused here as well as in the presigner one call down: `expiresAt: now + NaN` is `NaN` in the
|
|
86
|
-
// grant this function RETURNS, and the message a caller can act on names `
|
|
86
|
+
// grant this function RETURNS, and the message a caller can act on names `grantUpload`'s
|
|
87
87
|
// own option rather than `buildSignedUrl`'s.
|
|
88
88
|
// `=== undefined`, never `??`, for the reason `buildSignedUrl` states: `??` coalesces on `null`
|
|
89
89
|
// too, so an explicit one took the default instead of this refusal.
|
|
90
90
|
const expiresInMs = finiteCount(
|
|
91
|
-
'
|
|
91
|
+
'grantUpload',
|
|
92
92
|
'expiresInMs',
|
|
93
93
|
input.expiresInMs === undefined ? DEFAULT_SIGNED_URL_TTL_MS : input.expiresInMs,
|
|
94
94
|
1,
|
package/src/image.ts
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
import {
|
|
7
7
|
assertFiniteImageQuality,
|
|
8
8
|
blurDataUrl,
|
|
9
|
+
finiteCount,
|
|
9
10
|
type ImageFormat,
|
|
10
11
|
imageUnsupported,
|
|
11
12
|
probeImage,
|
|
12
13
|
transformImageBytes,
|
|
13
14
|
} from '@ultimat3/core';
|
|
14
|
-
import { assertSafeKey
|
|
15
|
+
import { assertSafeKey } from './path';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* The formats a stored VARIANT can be minted in — a strict subset of the ones `@ultimat3/core`
|
|
@@ -67,13 +68,28 @@ const FORMAT_EXTENSIONS: Readonly<Record<VariantFormat, string>> = {
|
|
|
67
68
|
png: 'png',
|
|
68
69
|
};
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* One edge of a variant box: a whole number of pixels, at least one. Core's `finiteCount`, for the
|
|
73
|
+
* reason `quality` goes through core's screen below — `{ width: NaN }` minted `a@wNaN.webp`, a
|
|
74
|
+
* writable key for bytes no encoder can produce, and `w1.5` / `w-3` are an unbounded number of
|
|
75
|
+
* keys in a bucket. `!== undefined`, never a truthiness test: an explicit `null` is refused.
|
|
76
|
+
*/
|
|
77
|
+
function edge(subject: string, option: 'width' | 'height', value: number | undefined): void {
|
|
78
|
+
if (value !== undefined) finiteCount(subject, option, value, 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
/**
|
|
71
82
|
* Derived, not stored: the same source + transform always yields the same key, so a variant
|
|
72
83
|
* is a cache lookup rather than a database row.
|
|
84
|
+
*
|
|
85
|
+
* The WHOLE source key is the stem, extension included. It used to be cut, so `p/hero.png` and
|
|
86
|
+
* `p/hero.jpg` minted one `p/hero@w100.webp` and whichever was requested first was served for
|
|
87
|
+
* both — two sources, one cache identity.
|
|
73
88
|
*/
|
|
74
89
|
export function variantKey(sourceKey: string, transform: ImageTransform): string {
|
|
75
90
|
const safe = assertSafeKey(sourceKey);
|
|
76
|
-
|
|
91
|
+
edge('variantKey', 'width', transform.width);
|
|
92
|
+
edge('variantKey', 'height', transform.height);
|
|
77
93
|
// `string`, not `VariantFormat`: a format off a query string or through an `as` reaches here as
|
|
78
94
|
// any string core can probe, and indexing FORMAT_EXTENSIONS with `gif` minted
|
|
79
95
|
// `photos/hero@full.undefined` — a well-formed, writable key naming a file nothing can serve.
|
|
@@ -101,7 +117,7 @@ export function variantKey(sourceKey: string, transform: ImageTransform): string
|
|
|
101
117
|
);
|
|
102
118
|
if (quality !== DEFAULT_QUALITY) parts.push(`q${quality}`);
|
|
103
119
|
if (parts.length === 0) parts.push('full');
|
|
104
|
-
return assertSafeKey(`${
|
|
120
|
+
return assertSafeKey(`${safe}@${parts.join('-')}.${FORMAT_EXTENSIONS[format]}`);
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
export interface SrcsetDescriptor {
|
|
@@ -139,23 +155,33 @@ export function srcsetDescriptors(
|
|
|
139
155
|
}));
|
|
140
156
|
}
|
|
141
157
|
|
|
142
|
-
/**
|
|
158
|
+
/** A derived edge is never zero: 1000x1 at width 100 rounded to a height no encoder can fill. */
|
|
159
|
+
const atLeastOne = (pixels: number): number => Math.max(1, Math.round(pixels));
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Aspect-ratio fitting. `cover` is the exact box asked for; everything else fits INSIDE the box
|
|
163
|
+
* and never above the source — upscaling is never useful, whichever edges were named.
|
|
164
|
+
*/
|
|
143
165
|
export function fitDimensions(source: ImageSize, transform: ImageTransform): ImageSize {
|
|
144
166
|
const ratio = source.height / source.width;
|
|
145
167
|
const { width, height } = transform;
|
|
168
|
+
edge('fitDimensions', 'width', width);
|
|
169
|
+
edge('fitDimensions', 'height', height);
|
|
146
170
|
if (width === undefined && height === undefined) return source;
|
|
147
171
|
if (height === undefined) {
|
|
148
172
|
const target = Math.min(width ?? source.width, source.width);
|
|
149
|
-
return { width: target, height:
|
|
173
|
+
return { width: target, height: atLeastOne(target * ratio) };
|
|
150
174
|
}
|
|
151
175
|
if (width === undefined) {
|
|
152
176
|
const target = Math.min(height, source.height);
|
|
153
|
-
return { width:
|
|
177
|
+
return { width: atLeastOne(target / ratio), height: target };
|
|
154
178
|
}
|
|
155
179
|
// `cover` crops to the exact box; `contain` scales down until both sides fit.
|
|
156
180
|
if (transform.fit === 'cover') return { width, height };
|
|
157
|
-
|
|
158
|
-
|
|
181
|
+
// Clamped at 1: a single named edge already stopped at the source, and two of them did not —
|
|
182
|
+
// a 40x20 source asked for 400x400 came back 400x200.
|
|
183
|
+
const scale = Math.min(1, width / source.width, height / source.height);
|
|
184
|
+
return { width: atLeastOne(source.width * scale), height: atLeastOne(source.height * scale) };
|
|
159
185
|
}
|
|
160
186
|
|
|
161
187
|
/**
|
package/src/index.ts
CHANGED
|
@@ -31,7 +31,6 @@ export {
|
|
|
31
31
|
export type {
|
|
32
32
|
ByteLimit,
|
|
33
33
|
ListOptions,
|
|
34
|
-
ListPage,
|
|
35
34
|
PutOptions,
|
|
36
35
|
ServerSideEncryption,
|
|
37
36
|
SignedUrlMethod,
|
|
@@ -43,33 +42,30 @@ export type {
|
|
|
43
42
|
StorageRead,
|
|
44
43
|
} from './driver';
|
|
45
44
|
export {
|
|
45
|
+
// Exported for the same reason `toBytes` is: a driver written outside this package has to
|
|
46
|
+
// refuse a `limit` the same way both shipped ones do, or it is a third answer to one question.
|
|
47
|
+
assertPutOptions,
|
|
46
48
|
DEFAULT_CONTENT_TYPE,
|
|
47
49
|
DEFAULT_LIST_LIMIT,
|
|
48
50
|
etagOf,
|
|
49
|
-
// Exported for the same reason `toBytes` is: a driver written outside this package has to
|
|
50
|
-
// refuse a `limit` the same way both shipped ones do, or it is a third answer to one question.
|
|
51
51
|
resolveListLimit,
|
|
52
52
|
sha256Base64,
|
|
53
53
|
toBytes,
|
|
54
54
|
} from './driver';
|
|
55
55
|
export type { LocalDriverOptions } from './driver-local';
|
|
56
|
-
export {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
} from './driver-local';
|
|
62
|
-
export type { MemoryDriverOptions, MemoryStorageDriver } from './driver-memory';
|
|
63
|
-
export { memoryDriver } from './driver-memory';
|
|
56
|
+
export { localDriver } from './driver-local';
|
|
57
|
+
export type { MemoryStorageDriver, MemoryStorageDriverOptions } from './driver-memory';
|
|
58
|
+
export { memoryStorageDriver } from './driver-memory';
|
|
59
|
+
export type { S3DriverOptions } from './driver-s3';
|
|
60
|
+
export { s3Driver } from './driver-s3';
|
|
64
61
|
export type {
|
|
65
62
|
S3ClientLike,
|
|
66
|
-
S3DriverOptions,
|
|
67
63
|
S3FileLike,
|
|
68
64
|
S3ListEntryLike,
|
|
69
65
|
S3ListResultLike,
|
|
70
66
|
S3StatLike,
|
|
71
|
-
} from './driver-s3';
|
|
72
|
-
export {
|
|
67
|
+
} from './driver-s3-client';
|
|
68
|
+
export type { S3FetchLike } from './driver-s3-signed';
|
|
73
69
|
export type { StorageErrorCode, StorageErrorInit } from './errors';
|
|
74
70
|
export {
|
|
75
71
|
checksumMismatch,
|
|
@@ -78,13 +74,17 @@ export {
|
|
|
78
74
|
contentTypeUnrecognised,
|
|
79
75
|
deleteFailed,
|
|
80
76
|
diskUnknown,
|
|
77
|
+
getTooLarge,
|
|
81
78
|
isStorageError,
|
|
79
|
+
keyConflict,
|
|
82
80
|
listFailed,
|
|
83
81
|
objectNotFound,
|
|
84
82
|
orgMismatch,
|
|
85
83
|
pathUnsafe,
|
|
84
|
+
putFailed,
|
|
86
85
|
putTooLarge,
|
|
87
86
|
quarantined,
|
|
87
|
+
readFailed,
|
|
88
88
|
STORAGE_ERROR_CODES,
|
|
89
89
|
STORAGE_ERROR_TITLES,
|
|
90
90
|
StorageError,
|
|
@@ -92,9 +92,9 @@ export {
|
|
|
92
92
|
signedUrlRejected,
|
|
93
93
|
signedUrlUnverifiable,
|
|
94
94
|
signingSecretMissing,
|
|
95
|
-
storageNotImplemented,
|
|
96
95
|
tooLarge,
|
|
97
96
|
uploadFailed,
|
|
97
|
+
xmlBodyUnreadable,
|
|
98
98
|
} from './errors';
|
|
99
99
|
export type { GrantUploadInput, UploadGrant, UploadRequest } from './grant';
|
|
100
100
|
export { grantUpload } from './grant';
|
|
@@ -120,6 +120,14 @@ export {
|
|
|
120
120
|
VARIANT_FORMATS,
|
|
121
121
|
variantKey,
|
|
122
122
|
} from './image';
|
|
123
|
+
export type {
|
|
124
|
+
LockedAction,
|
|
125
|
+
ObjectLock,
|
|
126
|
+
ObjectLockOptions,
|
|
127
|
+
ObjectRetention,
|
|
128
|
+
RetentionMode,
|
|
129
|
+
} from './object-lock';
|
|
130
|
+
export { isLocked, ObjectLockedError, RETENTION_MODES } from './object-lock';
|
|
123
131
|
|
|
124
132
|
export {
|
|
125
133
|
assertSafeKey,
|
|
@@ -152,9 +160,14 @@ export {
|
|
|
152
160
|
SIGNED_URL_VERSION,
|
|
153
161
|
signConstraints,
|
|
154
162
|
signedUrlBaseFor,
|
|
155
|
-
|
|
163
|
+
signedUrlBasePath,
|
|
156
164
|
verifySignedUrl,
|
|
157
165
|
} from './signed-url';
|
|
166
|
+
export {
|
|
167
|
+
DEV_SIGNING_SECRET,
|
|
168
|
+
STORAGE_SIGNING_SECRET_KEY,
|
|
169
|
+
usesDevStorageSecret,
|
|
170
|
+
} from './signing-secret';
|
|
158
171
|
export type { Storage, StorageConfig } from './storage';
|
|
159
172
|
export { definedStorage, defineStorage, disk, resetStorage, storage } from './storage';
|
|
160
173
|
export type { UploadCandidate, UploadPolicy, UploadPolicyInit, ValidatedUpload } from './upload';
|
package/src/iso-bmff.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Single responsibility: which media type an ISO base media file is. Every MP4, MOV, M4A, HEIC
|
|
2
|
+
// and AVIF opens with the same `ftyp` box, so the box alone says "container" — the four-byte MAJOR
|
|
3
|
+
// BRAND after it is what says which one, and reading only the box labelled all of them `video/mp4`.
|
|
4
|
+
|
|
5
|
+
const ascii = (bytes: Uint8Array, offset: number): string =>
|
|
6
|
+
String.fromCharCode(...bytes.subarray(offset, offset + 4));
|
|
7
|
+
|
|
8
|
+
/** The type a major brand cannot narrow: a container, as `application/zip` is one for OOXML. */
|
|
9
|
+
export const ISO_BMFF_CONTAINER = 'video/mp4';
|
|
10
|
+
|
|
11
|
+
/** HEIF's structural brands — a still HEIC and a still AVIF both ship under `mif1`. */
|
|
12
|
+
export const HEIF_CONTAINER = 'image/heif';
|
|
13
|
+
|
|
14
|
+
// A `Map`, never an object literal: the brand is four bytes off an upload, and `BRANDS['cons']`
|
|
15
|
+
// is harmless only until a brand spells a prototype key.
|
|
16
|
+
const BRANDS: ReadonlyMap<string, string> = new Map([
|
|
17
|
+
['avif', 'image/avif'],
|
|
18
|
+
['avis', 'image/avif'],
|
|
19
|
+
['heic', 'image/heic'],
|
|
20
|
+
['heix', 'image/heic'],
|
|
21
|
+
['heim', 'image/heic'],
|
|
22
|
+
['heis', 'image/heic'],
|
|
23
|
+
['hevc', 'image/heic'],
|
|
24
|
+
['hevx', 'image/heic'],
|
|
25
|
+
['mif1', HEIF_CONTAINER],
|
|
26
|
+
['msf1', HEIF_CONTAINER],
|
|
27
|
+
['qt ', 'video/quicktime'],
|
|
28
|
+
['M4A ', 'audio/mp4'],
|
|
29
|
+
['M4B ', 'audio/mp4'],
|
|
30
|
+
['M4P ', 'audio/mp4'],
|
|
31
|
+
['3gp4', 'video/3gpp'],
|
|
32
|
+
['3gp5', 'video/3gpp'],
|
|
33
|
+
['3gp6', 'video/3gpp'],
|
|
34
|
+
['3g2a', 'video/3gpp2'],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** Every type `sniffIsoBmff` can answer — what `validateUpload` reads "could confirm" from. */
|
|
38
|
+
export const ISO_BMFF_TYPES: readonly string[] = [
|
|
39
|
+
...new Set([ISO_BMFF_CONTAINER, ...BRANDS.values()]),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `undefined` when the bytes do not open with a `ftyp` box carrying a brand. A brand this table
|
|
44
|
+
* does not list answers the container: it is an ISO base media file, which is all the box proves,
|
|
45
|
+
* and that is the answer every brand got before this file existed.
|
|
46
|
+
*/
|
|
47
|
+
export function sniffIsoBmff(bytes: Uint8Array): string | undefined {
|
|
48
|
+
if (bytes.byteLength < 12 || ascii(bytes, 4) !== 'ftyp') return undefined;
|
|
49
|
+
return BRANDS.get(ascii(bytes, 8)) ?? ISO_BMFF_CONTAINER;
|
|
50
|
+
}
|