@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/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,14 @@ 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',
|
|
24
32
|
] as const;
|
|
25
33
|
|
|
26
34
|
/**
|
|
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.
|
|
35
|
+
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. The drivers throw core's own `NotImplementedError`
|
|
36
|
+
* and this package keeps no title for it — one code, one owner, one title, or the two copies drift apart in silence.
|
|
29
37
|
* `X_IMAGE_UNSUPPORTED` / `X_IMAGE_DECODE_FAILED` are core's too and surface unwrapped (`image.ts`).
|
|
30
38
|
* `X_ENV_MISSING` is core's for the same reason: an unset `STORAGE_SIGNING_SECRET` outside
|
|
31
39
|
* development is a missing environment variable, not a storage concept needing its own code.
|
|
@@ -57,6 +65,9 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
|
|
|
57
65
|
X_STORAGE_LIST_FAILED: 'the objects could not be listed',
|
|
58
66
|
X_STORAGE_QUARANTINED: 'the object is still in quarantine',
|
|
59
67
|
X_STORAGE_NOT_PENDING: 'the key is not a pending upload',
|
|
68
|
+
X_STORAGE_KEY_CONFLICT: 'the key collides with another key on the local disk',
|
|
69
|
+
X_STORAGE_PUT_FAILED: 'the object could not be written',
|
|
70
|
+
X_STORAGE_READ_FAILED: 'the object could not be read',
|
|
60
71
|
};
|
|
61
72
|
|
|
62
73
|
// One unconditional call, so a second package claiming one of storage's codes throws
|
|
@@ -155,10 +166,55 @@ export const putTooLarge = (
|
|
|
155
166
|
`send it direct with grantUpload({ disk, orgId, request }) instead of put(), or raise the ceiling: ` +
|
|
156
167
|
(disk === 's3'
|
|
157
168
|
? `s3Driver({ bucket, maxPutBytes: ${bytes} })`
|
|
158
|
-
:
|
|
169
|
+
: disk === 'memory'
|
|
170
|
+
? `memoryDriver({ maxPutBytes: ${bytes} })`
|
|
171
|
+
: `localDriver({ root, maxPutBytes: ${bytes} })`),
|
|
159
172
|
meta: { disk, key, bytes, maxBytes },
|
|
160
173
|
});
|
|
161
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The SERVER-side `get()` ceiling — `putTooLarge`'s twin. `get()` buffers the whole object, and an
|
|
177
|
+
* object's size is not this process's to choose: a presigned PUT lands in the bucket unmeasured,
|
|
178
|
+
* so a later `get()` of it was heap growth the uploader picked.
|
|
179
|
+
*/
|
|
180
|
+
export const getTooLarge = (
|
|
181
|
+
disk: string,
|
|
182
|
+
key: string,
|
|
183
|
+
bytes: number,
|
|
184
|
+
maxBytes: number,
|
|
185
|
+
): StorageError =>
|
|
186
|
+
new StorageError({
|
|
187
|
+
code: 'X_STORAGE_TOO_LARGE',
|
|
188
|
+
cause: `"${key}" is ${bytes}B against the ${disk} disk's get ceiling of ${maxBytes}B, and get() buffers the whole object`,
|
|
189
|
+
fix:
|
|
190
|
+
`read it with disk.stream(key) instead of get(), or raise the ceiling: ` +
|
|
191
|
+
(disk === 's3'
|
|
192
|
+
? `s3Driver({ bucket, maxGetBytes: ${bytes} })`
|
|
193
|
+
: disk === 'memory'
|
|
194
|
+
? `memoryDriver({ maxGetBytes: ${bytes} })`
|
|
195
|
+
: `localDriver({ root, maxGetBytes: ${bytes} })`),
|
|
196
|
+
meta: { disk, key, bytes, maxBytes },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The local disk stores an object as a FILE at its key, so a key cannot also be the directory of
|
|
201
|
+
* another: `put('a')` then `put('a/b')`, or the reverse, or the same pair one tree over in the
|
|
202
|
+
* sidecars (`a` and `a.json/b`). It surfaced as a bare `ENOTDIR` / `EISDIR`. S3 and the memory
|
|
203
|
+
* disk hold both — this is the local disk's own limit, refused by name rather than hidden.
|
|
204
|
+
*
|
|
205
|
+
* The `fix` lists what is in the way: the remedy is storing one of the two under another key, and
|
|
206
|
+
* which one is only decidable by a reader who can see both.
|
|
207
|
+
*/
|
|
208
|
+
export const keyConflict = (disk: string, key: string, blocking: string): StorageError =>
|
|
209
|
+
new StorageError({
|
|
210
|
+
code: 'X_STORAGE_KEY_CONFLICT',
|
|
211
|
+
cause: `the "${disk}" disk is a local 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`,
|
|
212
|
+
// The REGISTERED name, never the driver kind: `disk('local')` on a disk registered as
|
|
213
|
+
// `uploads` lists another disk, or answers X_STORAGE_DISK_UNKNOWN.
|
|
214
|
+
fix: `disk(${renderFixLiteral(disk, "'local'")}).list({ prefix: ${renderFixLiteral(blocking, "'a/'")} })`,
|
|
215
|
+
meta: { disk, key, blocking },
|
|
216
|
+
});
|
|
217
|
+
|
|
162
218
|
/** The declared type is not on the allowlist at all. */
|
|
163
219
|
export const contentTypeNotAllowed = (
|
|
164
220
|
key: string,
|
|
@@ -256,6 +312,43 @@ export const deleteFailed = (
|
|
|
256
312
|
});
|
|
257
313
|
};
|
|
258
314
|
|
|
315
|
+
/**
|
|
316
|
+
* A write the disk REFUSED — `deleteFailed`'s twin for `put()` and `copy()`. The local disk's
|
|
317
|
+
* `EACCES` / `ENOSPC` / `EROFS` left as a bare filesystem `Error`, and a provider's refused PUT as
|
|
318
|
+
* a bare `S3Error`: no code, no fix, nothing for the http error map to render but an anonymous 500.
|
|
319
|
+
* The `fix` is the driver's, for `deleteFailed`'s reason.
|
|
320
|
+
*/
|
|
321
|
+
export const putFailed = (disk: string, key: string, error: unknown, fix: string): StorageError => {
|
|
322
|
+
const reason = renderThrowable(error);
|
|
323
|
+
return new StorageError({
|
|
324
|
+
code: 'X_STORAGE_PUT_FAILED',
|
|
325
|
+
cause: `disk "${disk}" refused to write "${key}": ${reason}`,
|
|
326
|
+
fix,
|
|
327
|
+
meta: { disk, key, reason },
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* A read the disk REFUSED — `putFailed`'s read-side twin, for `get`, `stat`, `exists`, `stream` and
|
|
333
|
+
* the read half of `copy`. A denied `s3:GetObject`, a throttle, `EACCES`, `EIO`. An object that is
|
|
334
|
+
* simply absent is `objectNotFound`, never this: "not there" and "could not look" need opposite
|
|
335
|
+
* answers from a caller. The `fix` is the driver's, for `deleteFailed`'s reason.
|
|
336
|
+
*/
|
|
337
|
+
export const readFailed = (
|
|
338
|
+
disk: string,
|
|
339
|
+
key: string,
|
|
340
|
+
error: unknown,
|
|
341
|
+
fix: string,
|
|
342
|
+
): StorageError => {
|
|
343
|
+
const reason = renderThrowable(error);
|
|
344
|
+
return new StorageError({
|
|
345
|
+
code: 'X_STORAGE_READ_FAILED',
|
|
346
|
+
cause: `disk "${disk}" refused to read "${key}": ${reason}`,
|
|
347
|
+
fix,
|
|
348
|
+
meta: { disk, key, reason },
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
|
|
259
352
|
/**
|
|
260
353
|
* A listing the disk REFUSED — a denied `s3:ListBucket`, a throttle, an expired credential, a root
|
|
261
354
|
* this process cannot read. Exactly `deleteFailed`'s shape and for exactly its reason: an empty
|
|
@@ -352,21 +445,12 @@ export const signedUrlUnverifiable = (diskName: string): StorageError =>
|
|
|
352
445
|
meta: { disk: diskName },
|
|
353
446
|
});
|
|
354
447
|
|
|
355
|
-
export const signingSecretMissing = (environment: string): StorageError =>
|
|
448
|
+
export const signingSecretMissing = (environment: string, disk = 'local'): StorageError =>
|
|
356
449
|
new StorageError({
|
|
357
450
|
code: 'X_ENV_MISSING',
|
|
358
451
|
// The environment names what `resolveEnvironment()` resolved, which may have come from
|
|
359
452
|
// NODE_ENV — naming ULTIMATE_ENV here reported a variable the process never set.
|
|
360
|
-
cause: `the
|
|
453
|
+
cause: `the ${disk} disk has no usable signing secret (no signingSecret option, and STORAGE_SIGNING_SECRET is unset, empty or the published development key) and the resolved environment is "${environment}", so it would sign URLs with the shipped development key`,
|
|
361
454
|
fix: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
|
|
362
455
|
meta: { key: 'STORAGE_SIGNING_SECRET', environment },
|
|
363
456
|
});
|
|
364
|
-
|
|
365
|
-
/** An interface-complete driver whose remote half is not bound yet. Always carries a fix. */
|
|
366
|
-
export const storageNotImplemented = (feature: string, fix: string): StorageError =>
|
|
367
|
-
new StorageError({
|
|
368
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
369
|
-
cause: `${feature} is declared but not implemented in @ultimat3/storage`,
|
|
370
|
-
fix,
|
|
371
|
-
meta: { feature },
|
|
372
|
-
});
|
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
|
@@ -43,22 +43,20 @@ export type {
|
|
|
43
43
|
StorageRead,
|
|
44
44
|
} from './driver';
|
|
45
45
|
export {
|
|
46
|
+
// Exported for the same reason `toBytes` is: a driver written outside this package has to
|
|
47
|
+
// refuse a `limit` the same way both shipped ones do, or it is a third answer to one question.
|
|
48
|
+
assertPutOptions,
|
|
46
49
|
DEFAULT_CONTENT_TYPE,
|
|
47
50
|
DEFAULT_LIST_LIMIT,
|
|
48
51
|
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
52
|
resolveListLimit,
|
|
52
53
|
sha256Base64,
|
|
53
54
|
toBytes,
|
|
54
55
|
} from './driver';
|
|
55
56
|
export type { LocalDriverOptions } from './driver-local';
|
|
56
|
-
export {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
STORAGE_SIGNING_SECRET_KEY,
|
|
60
|
-
usesDevStorageSecret,
|
|
61
|
-
} from './driver-local';
|
|
57
|
+
export { localDriver } from './driver-local';
|
|
58
|
+
export type { MemoryDriverOptions, MemoryStorageDriver } from './driver-memory';
|
|
59
|
+
export { memoryDriver } from './driver-memory';
|
|
62
60
|
export type {
|
|
63
61
|
S3ClientLike,
|
|
64
62
|
S3DriverOptions,
|
|
@@ -76,13 +74,17 @@ export {
|
|
|
76
74
|
contentTypeUnrecognised,
|
|
77
75
|
deleteFailed,
|
|
78
76
|
diskUnknown,
|
|
77
|
+
getTooLarge,
|
|
79
78
|
isStorageError,
|
|
79
|
+
keyConflict,
|
|
80
80
|
listFailed,
|
|
81
81
|
objectNotFound,
|
|
82
82
|
orgMismatch,
|
|
83
83
|
pathUnsafe,
|
|
84
|
+
putFailed,
|
|
84
85
|
putTooLarge,
|
|
85
86
|
quarantined,
|
|
87
|
+
readFailed,
|
|
86
88
|
STORAGE_ERROR_CODES,
|
|
87
89
|
STORAGE_ERROR_TITLES,
|
|
88
90
|
StorageError,
|
|
@@ -90,7 +92,6 @@ export {
|
|
|
90
92
|
signedUrlRejected,
|
|
91
93
|
signedUrlUnverifiable,
|
|
92
94
|
signingSecretMissing,
|
|
93
|
-
storageNotImplemented,
|
|
94
95
|
tooLarge,
|
|
95
96
|
uploadFailed,
|
|
96
97
|
} from './errors';
|
|
@@ -150,9 +151,15 @@ export {
|
|
|
150
151
|
SIGNED_URL_VERSION,
|
|
151
152
|
signConstraints,
|
|
152
153
|
signedUrlBaseFor,
|
|
154
|
+
signedUrlBasePath,
|
|
153
155
|
timingSafeEqual,
|
|
154
156
|
verifySignedUrl,
|
|
155
157
|
} from './signed-url';
|
|
158
|
+
export {
|
|
159
|
+
DEV_SIGNING_SECRET,
|
|
160
|
+
STORAGE_SIGNING_SECRET_KEY,
|
|
161
|
+
usesDevStorageSecret,
|
|
162
|
+
} from './signing-secret';
|
|
156
163
|
export type { Storage, StorageConfig } from './storage';
|
|
157
164
|
export { definedStorage, defineStorage, disk, resetStorage, storage } from './storage';
|
|
158
165
|
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
|
+
}
|
package/src/path.ts
CHANGED
|
@@ -42,6 +42,9 @@ function hasControlByte(key: string): boolean {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function unsafeReason(key: string): string | undefined {
|
|
45
|
+
// The type says string; a key off an untyped caller is whatever it is, and `.length` on
|
|
46
|
+
// `undefined` was a bare `TypeError` out of every driver method at once.
|
|
47
|
+
if (typeof key !== 'string') return 'is not a string';
|
|
45
48
|
if (key.length === 0) return 'is empty';
|
|
46
49
|
const bytes = utf8Bytes(key);
|
|
47
50
|
if (bytes > MAX_KEY_LENGTH) {
|
|
@@ -110,9 +113,21 @@ export function scopedKey(orgId: string, ...parts: readonly string[]): string {
|
|
|
110
113
|
return joinKey(ORG_PREFIX, assertOrgId(orgId), ...parts);
|
|
111
114
|
}
|
|
112
115
|
|
|
113
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Guard for read paths: a key handed in by a client must still belong to the actor's org.
|
|
118
|
+
*
|
|
119
|
+
* A PREDICATE, so it answers and never throws. An org id that cannot be one — empty, or carrying
|
|
120
|
+
* a separator — has no keys inside it, and that is `false`: it used to raise
|
|
121
|
+
* `X_STORAGE_PATH_UNSAFE` through `orgPrefix`, blaming the KEY for an actor with no org, and every
|
|
122
|
+
* caller that knew wrote `orgId === '' ||` in front of it while the ones that did not threw.
|
|
123
|
+
*/
|
|
114
124
|
export function isWithinOrg(key: string, orgId: string): boolean {
|
|
115
|
-
|
|
125
|
+
// `typeof`, because the caller is a guard on a request: an actor whose org claim is `undefined`
|
|
126
|
+
// at runtime must read as "inside no org", not as a `TypeError` out of `.includes`.
|
|
127
|
+
if (typeof key !== 'string' || typeof orgId !== 'string') return false;
|
|
128
|
+
const prefix = `${ORG_PREFIX}/${orgId}`;
|
|
129
|
+
if (orgId.includes('/') || !isSafeKey(prefix)) return false;
|
|
130
|
+
return isSafeKey(key) && key.startsWith(`${prefix}/`);
|
|
116
131
|
}
|
|
117
132
|
|
|
118
133
|
/**
|
package/src/signed-url.ts
CHANGED
|
@@ -12,7 +12,8 @@ import { assertSafeKey, isSafeKey } from './path';
|
|
|
12
12
|
* implementation now lives in `@ultimat3/core`, shared with `@ultimat3/auth`. */
|
|
13
13
|
export { timingSafeEqual };
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/** `v2` since the canonical string carries the base path: a `v1` signature names no disk. */
|
|
16
|
+
export const SIGNED_URL_VERSION = 'v2';
|
|
16
17
|
export const DEFAULT_SIGNED_URL_TTL_MS = 900_000;
|
|
17
18
|
/** The dev server mounts the download/upload route here; S3 disks never use it. */
|
|
18
19
|
export const DEFAULT_SIGNED_URL_BASE = '/_storage';
|
|
@@ -70,10 +71,34 @@ export type SignedUrlVerification =
|
|
|
70
71
|
| { readonly ok: true; readonly constraints: SignedUrlConstraints }
|
|
71
72
|
| { readonly ok: false; readonly reason: SignedUrlFailure; readonly detail: string };
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
const trimBase = (base: string): string => base.replace(/\/+$/, '');
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The PATH a base mounts at — what a route sees, and so the only part of a base both halves can
|
|
78
|
+
* agree on. An absolute `baseUrl` (`https://cdn.example.com/_storage/local`) names an origin too,
|
|
79
|
+
* and the verifier compared a request's pathname against the whole string: every URL minted under
|
|
80
|
+
* one was `malformed`. Never throws — a base `URL` cannot parse is compared as written.
|
|
81
|
+
*/
|
|
82
|
+
export function signedUrlBasePath(baseUrl: string): string {
|
|
83
|
+
try {
|
|
84
|
+
return trimBase(new URL(baseUrl, 'http://storage.invalid').pathname);
|
|
85
|
+
} catch {
|
|
86
|
+
return trimBase(baseUrl);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Newline-separated and order-fixed: an ambiguous canonical form is a forgeable one.
|
|
92
|
+
*
|
|
93
|
+
* `basePath` is the disk: `/_storage/<registered name>`. Every local disk signs with the one
|
|
94
|
+
* `STORAGE_SIGNING_SECRET`, so without it a URL for `uploads/<key>` verified on `private/<key>`
|
|
95
|
+
* with only the path segment edited — a grant for one disk was a grant for all of them. The
|
|
96
|
+
* origin is deliberately outside it: the verifying route is handed a path.
|
|
97
|
+
*/
|
|
98
|
+
export function canonicalRequest(constraints: SignedUrlConstraints, basePath: string): string {
|
|
75
99
|
return [
|
|
76
100
|
SIGNED_URL_VERSION,
|
|
101
|
+
basePath,
|
|
77
102
|
constraints.method,
|
|
78
103
|
constraints.key,
|
|
79
104
|
String(constraints.expiresAt),
|
|
@@ -87,6 +112,7 @@ const encoder = new TextEncoder();
|
|
|
87
112
|
export async function signConstraints(
|
|
88
113
|
secret: string,
|
|
89
114
|
constraints: SignedUrlConstraints,
|
|
115
|
+
basePath: string,
|
|
90
116
|
): Promise<string> {
|
|
91
117
|
const key = await crypto.subtle.importKey(
|
|
92
118
|
'raw',
|
|
@@ -95,11 +121,14 @@ export async function signConstraints(
|
|
|
95
121
|
false,
|
|
96
122
|
['sign'],
|
|
97
123
|
);
|
|
98
|
-
const mac = await crypto.subtle.sign(
|
|
124
|
+
const mac = await crypto.subtle.sign(
|
|
125
|
+
'HMAC',
|
|
126
|
+
key,
|
|
127
|
+
encoder.encode(canonicalRequest(constraints, basePath)),
|
|
128
|
+
);
|
|
99
129
|
return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
100
130
|
}
|
|
101
131
|
|
|
102
|
-
const trimBase = (base: string): string => base.replace(/\/+$/, '');
|
|
103
132
|
const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
|
|
104
133
|
|
|
105
134
|
/**
|
|
@@ -148,8 +177,11 @@ export async function buildSignedUrl(input: SignedUrlInput): Promise<string> {
|
|
|
148
177
|
if (constraints.contentType !== undefined) {
|
|
149
178
|
params.set(SIGNED_URL_PARAMS.contentType, constraints.contentType);
|
|
150
179
|
}
|
|
151
|
-
params.set(SIGNED_URL_PARAMS.signature, await signConstraints(input.secret, constraints));
|
|
152
180
|
const base = trimBase(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE);
|
|
181
|
+
params.set(
|
|
182
|
+
SIGNED_URL_PARAMS.signature,
|
|
183
|
+
await signConstraints(input.secret, constraints, signedUrlBasePath(base)),
|
|
184
|
+
);
|
|
153
185
|
return `${base}/${encodeKey(key)}?${params.toString()}`;
|
|
154
186
|
}
|
|
155
187
|
|
|
@@ -222,10 +254,11 @@ export async function verifySignedUrl(input: VerifySignedUrlInput): Promise<Sign
|
|
|
222
254
|
}
|
|
223
255
|
const signature = url.searchParams.get(SIGNED_URL_PARAMS.signature);
|
|
224
256
|
if (signature === null) return fail('malformed', `no ${SIGNED_URL_PARAMS.signature} parameter`);
|
|
225
|
-
const
|
|
257
|
+
const basePath = signedUrlBasePath(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE);
|
|
258
|
+
const parsed = parseConstraints(url, basePath);
|
|
226
259
|
if (typeof parsed === 'string') return fail(parsed, `${url.pathname} is not a signable request`);
|
|
227
260
|
|
|
228
|
-
const expected = await signConstraints(input.secret, parsed);
|
|
261
|
+
const expected = await signConstraints(input.secret, parsed, basePath);
|
|
229
262
|
if (!timingSafeEqual(expected, signature)) {
|
|
230
263
|
return fail('signature-mismatch', 'the constraints do not match the signature');
|
|
231
264
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Single responsibility: the secret a disk that mints its OWN URLs signs with — the published
|
|
2
|
+
// development fallback, the env key production must set, and the one rule (`localDriver` and
|
|
3
|
+
// `memoryDriver` both) for refusing the fallback outside a development or test environment.
|
|
4
|
+
|
|
5
|
+
import { isLocal, type ResolveEnvironmentOptions, resolveEnvironment } from '@ultimat3/core';
|
|
6
|
+
import { signingSecretMissing } from './errors';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The dev-only fallback signing key. A literal, not a per-process random one, so a restart does
|
|
10
|
+
* not invalidate every URL `x dev` handed out — and published in this repo, which is exactly why
|
|
11
|
+
* `localDriver` refuses to use it outside a development or test environment.
|
|
12
|
+
*/
|
|
13
|
+
export const DEV_SIGNING_SECRET = 'ultimate-dev-signing-secret';
|
|
14
|
+
|
|
15
|
+
/** The env key production must set. Named once, read by the driver and by the predicate below. */
|
|
16
|
+
export const STORAGE_SIGNING_SECRET_KEY = 'STORAGE_SIGNING_SECRET';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True while a local disk built without an explicit `signingSecret` would sign with the shipped
|
|
20
|
+
* development key — `x doctor` reports it, exactly as it reports `usesDevCursorSecret()`.
|
|
21
|
+
*
|
|
22
|
+
* Reads the environment, not a driver instance: this is the same question `x doctor` asks about
|
|
23
|
+
* the cursor secret, and a disk handed an explicit `signingSecret` in `app.config.ts` never
|
|
24
|
+
* consults the variable at all.
|
|
25
|
+
*
|
|
26
|
+
* `env` is core's own slot, so this half of the guard reads the SAME table its other half does:
|
|
27
|
+
* `dev-runtime.ts` asks `!isLocal({ env }) && usesDevStorageSecret({ env })`, and an embedding
|
|
28
|
+
* caller (`serveApp({ env })`, a test fixture) whose `env` is not `process.env` used to get one
|
|
29
|
+
* answer about the boot and one about the process — for the decision of whether a disk may be
|
|
30
|
+
* signed with the published development key. Defaulted to `process.env`, so a bare call is
|
|
31
|
+
* unchanged.
|
|
32
|
+
*/
|
|
33
|
+
export function usesDevStorageSecret(options?: Pick<ResolveEnvironmentOptions, 'env'>): boolean {
|
|
34
|
+
const source = options?.env ?? (process.env as Record<string, string | undefined>);
|
|
35
|
+
const configured = source[STORAGE_SIGNING_SECRET_KEY];
|
|
36
|
+
return configured === undefined || configured === '' || configured === DEV_SIGNING_SECRET;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The two options the rule reads; `LocalDriverOptions` carries both. */
|
|
40
|
+
export interface SigningSecretOptions {
|
|
41
|
+
readonly signingSecret?: string | undefined;
|
|
42
|
+
readonly env?: ResolveEnvironmentOptions['env'];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The secret a disk that mints its OWN URLs signs with — `localDriver` and `memoryDriver` both.
|
|
47
|
+
*
|
|
48
|
+
* A dev disk must work with zero config. Outside development the fallback is refused rather than
|
|
49
|
+
* used: the literal is published, so signing with it hands every reader the power to mint a PUT
|
|
50
|
+
* for any key with any size and type limit — which `acceptSignedUpload` then trusts over the app's
|
|
51
|
+
* own `uploadPolicy`. Refused at construction, so the boot fails rather than the first upload.
|
|
52
|
+
*
|
|
53
|
+
* The published literal counts as no secret at all, whichever way it arrives: an env var or an
|
|
54
|
+
* `app.config.ts` that pasted it in signs exactly as weakly as the fallback does. One table for
|
|
55
|
+
* all three reads — the secret, the environment test and the environment the refusal names.
|
|
56
|
+
* Splitting them is how the guard and the disk came to answer about two different processes.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveSigningSecret(disk: string, options: SigningSecretOptions): string {
|
|
59
|
+
const env = options.env ?? (process.env as Record<string, string | undefined>);
|
|
60
|
+
const supplied = options.signingSecret ?? env[STORAGE_SIGNING_SECRET_KEY];
|
|
61
|
+
const configured =
|
|
62
|
+
supplied === undefined || supplied === '' || supplied === DEV_SIGNING_SECRET
|
|
63
|
+
? undefined
|
|
64
|
+
: supplied;
|
|
65
|
+
// FAILS CLOSED: a process that names no environment resolves as `production` here, the answer
|
|
66
|
+
// core's `assertNoDevSecretsOutsideLocal` gives. `isLocal`'s own fallback is `development`, so the
|
|
67
|
+
// process that forgot to say signed with the published key — exactly the one that must not.
|
|
68
|
+
if (configured === undefined && !isLocal({ env, fallback: 'production' }))
|
|
69
|
+
throw signingSecretMissing(resolveEnvironment({ env, fallback: 'production' }), disk);
|
|
70
|
+
return configured ?? DEV_SIGNING_SECRET;
|
|
71
|
+
}
|