@nitida/asset-client 0.17.0 → 0.18.1
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/AGENTS.md +26 -0
- package/README.md +6 -3
- package/dist/index.cjs +265 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +266 -85
- package/dist/index.d.ts +266 -85
- package/dist/index.js +258 -99
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access.ts +232 -0
- package/src/index.ts +219 -1
- package/src/transform.ts +44 -5
package/src/access.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed URLs for PRIVATE assets — the `/a/{tenant}/…?exp&sig` tree.
|
|
3
|
+
*
|
|
4
|
+
* ## Who calls this, and who must not
|
|
5
|
+
*
|
|
6
|
+
* The tenant's BACKEND, which knows who the viewer is and holds the signing
|
|
7
|
+
* key. Never a browser: shipping the signing key to the client would let any
|
|
8
|
+
* visitor mint URLs for any private asset of that tenant, which is the whole
|
|
9
|
+
* property the tree exists to provide. This module is deliberately importable
|
|
10
|
+
* from anywhere — it runs on WebCrypto, so browsers, Node, Bun and Workers all
|
|
11
|
+
* work — and that convenience is exactly why the warning is here rather than
|
|
12
|
+
* in a doc nobody reads at the call site.
|
|
13
|
+
*
|
|
14
|
+
* ## Not the same signature as `signTransformUrl`
|
|
15
|
+
*
|
|
16
|
+
* `signTransformUrl` vouches for a WIDTH; this vouches for a VIEWER, until
|
|
17
|
+
* `exp`. They come from the same `signing_key` but not the same key material:
|
|
18
|
+
* the access key is derived (`HMAC(signing_key, "nitida/access/v1")`) so that
|
|
19
|
+
* no crafted transform path can be replayed as an access signature. The full
|
|
20
|
+
* argument lives beside the server implementation in `access-signing.ts`; the
|
|
21
|
+
* short version is that a shared payload with a prefix separator IS
|
|
22
|
+
* collidable, because both fields of the transform message are
|
|
23
|
+
* attacker-influenced path segments.
|
|
24
|
+
*
|
|
25
|
+
* ## `exp` is mandatory
|
|
26
|
+
*
|
|
27
|
+
* A signed URL that never expires is a public URL as soon as someone forwards
|
|
28
|
+
* it. There is no "no expiry" option here, and there will not be one.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const ACCESS_KEY_INFO = "nitida/access/v1";
|
|
32
|
+
|
|
33
|
+
async function hmac(
|
|
34
|
+
key: ArrayBuffer | Uint8Array,
|
|
35
|
+
message: string,
|
|
36
|
+
): Promise<Uint8Array> {
|
|
37
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
38
|
+
"raw",
|
|
39
|
+
key as unknown as ArrayBuffer,
|
|
40
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
41
|
+
false,
|
|
42
|
+
["sign"],
|
|
43
|
+
);
|
|
44
|
+
return new Uint8Array(
|
|
45
|
+
await crypto.subtle.sign(
|
|
46
|
+
"HMAC",
|
|
47
|
+
cryptoKey,
|
|
48
|
+
new TextEncoder().encode(message),
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const toHex = (b: Uint8Array) =>
|
|
54
|
+
[...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The per-tenant access key, derived from `signing_key`. Same value the origin
|
|
58
|
+
* computes and hands the edge — nothing needs to be stored or synchronised.
|
|
59
|
+
*/
|
|
60
|
+
export async function deriveAccessKey(signingKey: string): Promise<Uint8Array> {
|
|
61
|
+
return hmac(new TextEncoder().encode(signingKey), ACCESS_KEY_INFO);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The signed message. Byte-identical to the server's `accessMessage` and the
|
|
66
|
+
* worker's — three implementations of one string, which is why all three pin
|
|
67
|
+
* the exact bytes in a test.
|
|
68
|
+
*
|
|
69
|
+
* `tenantPrefix` is the base36 segment as it appears in the URL, not the
|
|
70
|
+
* decimal id: tenant 10 lives at `/a/a/`, and the thing being vouched for is a
|
|
71
|
+
* path.
|
|
72
|
+
*/
|
|
73
|
+
export function accessMessage(
|
|
74
|
+
tenantPrefix: string,
|
|
75
|
+
exp: number,
|
|
76
|
+
resourcePath: string,
|
|
77
|
+
): string {
|
|
78
|
+
return `${tenantPrefix}\n${exp}\n${resourcePath.replace(/^\/+/, "")}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type SignAccessOptions = {
|
|
82
|
+
/** Lifetime in seconds. Required — see the header. */
|
|
83
|
+
expiresInSeconds: number;
|
|
84
|
+
/** Injectable clock, for tests that need a URL already dead on arrival. */
|
|
85
|
+
nowSeconds?: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Turn a PUBLIC-tree URL into a signed PRIVATE-tree URL.
|
|
90
|
+
*
|
|
91
|
+
* https://8ok.uk/5/v/<sha16>-lg.webp
|
|
92
|
+
* → https://8ok.uk/a/5/v/<sha16>-lg.webp?exp=…&sig=…
|
|
93
|
+
*
|
|
94
|
+
* Accepts a URL that is already under `/a/` and re-signs it, so calling twice
|
|
95
|
+
* is not an error and does not produce `/a/a/`.
|
|
96
|
+
*/
|
|
97
|
+
export async function signAccessUrl(
|
|
98
|
+
publicUrl: string,
|
|
99
|
+
signingKey: string,
|
|
100
|
+
opts: SignAccessOptions,
|
|
101
|
+
): Promise<string> {
|
|
102
|
+
if (!Number.isFinite(opts.expiresInSeconds) || opts.expiresInSeconds <= 0) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
"signAccessUrl: `expiresInSeconds` must be a positive number — a signed URL without an expiry is a public URL the moment it is forwarded.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const u = new URL(publicUrl);
|
|
108
|
+
const segments = u.pathname.split("/").filter(Boolean);
|
|
109
|
+
// ⚠️ `a` is BOTH the private tree's prefix and tenant 10 in base36, so
|
|
110
|
+
// "starts with /a/ ⇒ already signed" would eat tenant 10's own segment and
|
|
111
|
+
// sign `/a/v/…` as if `v` were the tenant. What tells them apart is the
|
|
112
|
+
// resource kind, always `v` or `r` directly after the tenant: the private
|
|
113
|
+
// tree is `/a/<tenant>/<v|r>/…`, and tenant 10's public `/a/v/<sha>-lg.webp`
|
|
114
|
+
// is not. Same base36 trap that makes `/10/` the platform's favourite 404.
|
|
115
|
+
// `[vrt]`, not `[vr]`: `t` joined the private tree when transforms did, and
|
|
116
|
+
// this line was left behind — so re-signing `/a/5/t/<dsl>/<sha>.webp` read
|
|
117
|
+
// `a` as the tenant and `5` as the resource kind, and threw. The check two
|
|
118
|
+
// dozen lines below already said `[vrt]`; a regex that disagrees with its own
|
|
119
|
+
// file is the shape this bug always takes.
|
|
120
|
+
if (segments[0] === "a" && segments[2] && /^[vrt]$/.test(segments[2])) {
|
|
121
|
+
segments.shift();
|
|
122
|
+
}
|
|
123
|
+
const tenantPrefix = segments.shift();
|
|
124
|
+
if (!tenantPrefix || segments.length === 0) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`signAccessUrl: expected a tenant-prefixed CDN path like /<tenant>/v/<sha>-<preset>.<ext>, got ${u.pathname}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
// ⭐ The segment after the tenant is ALWAYS the resource kind. Checking it is
|
|
130
|
+
// not pedantry — it catches the one mistake this signature shape invites.
|
|
131
|
+
//
|
|
132
|
+
// `/t/<dsl>/<sha>.webp` is a real, valid public transform URL, and it is the
|
|
133
|
+
// obvious thing to hand this function. Without this check `t` is read as the
|
|
134
|
+
// TENANT (29 in base36) and the result is `/a/t/<dsl>/…`: a URL that is
|
|
135
|
+
// perfectly signed, structurally plausible, and 404s for a reason nobody can
|
|
136
|
+
// see. Found by using it, 2026-08-22, on the very first private transform.
|
|
137
|
+
//
|
|
138
|
+
// A transform needs its tenant prepended first — which is exactly what
|
|
139
|
+
// `getPrivateTransformUrl` does, so the fix is almost always to call that.
|
|
140
|
+
if (!/^[vrt]$/.test(segments[0]!)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`signAccessUrl: expected /<tenant>/<v|r|t>/… but the segment after the tenant is "${segments[0]}". ` +
|
|
143
|
+
(segments[0]?.includes("=")
|
|
144
|
+
? `That looks like a transform DSL, so the path is probably /t/<dsl>/<sha>.<ext> — which has no tenant in it (\`t\` here was read as tenant ${Number.parseInt(tenantPrefix, 36)}). Use getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds }) instead.`
|
|
145
|
+
: `Got ${u.pathname}.`),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const resourcePath = segments.join("/");
|
|
149
|
+
const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
150
|
+
const exp = now + Math.floor(opts.expiresInSeconds);
|
|
151
|
+
|
|
152
|
+
const sig = toHex(
|
|
153
|
+
await hmac(
|
|
154
|
+
await deriveAccessKey(signingKey),
|
|
155
|
+
accessMessage(tenantPrefix, exp, resourcePath),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
u.pathname = `/a/${tenantPrefix}/${resourcePath}`;
|
|
160
|
+
u.searchParams.set("exp", String(exp));
|
|
161
|
+
u.searchParams.set("sig", sig);
|
|
162
|
+
return u.toString();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** What every URL builder accepts so it can refuse a doomed URL. */
|
|
166
|
+
export type VisibilityHint = { visibility?: "public" | "private" };
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Refuse to build a public URL for a private asset.
|
|
170
|
+
*
|
|
171
|
+
* Doctrine of the house: **a silence reads as "you can't"**. Returning
|
|
172
|
+
* `https://8ok.uk/5/v/<sha>-lg.webp` for a private asset is not a smaller
|
|
173
|
+
* failure than throwing — it is a URL that answers 404, in a platform where a
|
|
174
|
+
* 404 has always meant "that file does not exist". The caller then debugs the
|
|
175
|
+
* wrong thing.
|
|
176
|
+
*
|
|
177
|
+
* Only refuses when it was actually TOLD. A caller passing `{ sha }` carries no
|
|
178
|
+
* visibility, and guessing would break every existing call site to protect
|
|
179
|
+
* assets that are not there.
|
|
180
|
+
*/
|
|
181
|
+
export function assertPublic(
|
|
182
|
+
asset: VisibilityHint,
|
|
183
|
+
fn: string,
|
|
184
|
+
/**
|
|
185
|
+
* The call to make instead — declared per call site, not guessed.
|
|
186
|
+
*
|
|
187
|
+
* It matters which one: `getPrivateAssetUrl` signs a STORED preset, and
|
|
188
|
+
* pointing a transform caller at it sends them to a function that cannot do
|
|
189
|
+
* what they asked for. The first version of this message named
|
|
190
|
+
* `getPrivateAssetUrl` for all seven builders; a test caught it.
|
|
191
|
+
*/
|
|
192
|
+
escape: string,
|
|
193
|
+
): void {
|
|
194
|
+
if (asset.visibility !== "private") return;
|
|
195
|
+
throw new Error(
|
|
196
|
+
`${fn}: this asset is private, so a public CDN URL for it will answer 404 — that is the feature, not a missing file. ` +
|
|
197
|
+
`Mint a signed URL on your BACKEND instead: await ${escape}. ` +
|
|
198
|
+
"Never ship the signing key to a browser.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Refuse a value whose `sha` is missing or malformed, instead of interpolating
|
|
204
|
+
* it into a URL.
|
|
205
|
+
*
|
|
206
|
+
* ## Found by a Haiku agent, 2026-08-23
|
|
207
|
+
*
|
|
208
|
+
* It did the most natural thing there is — passed the result of `upload()`
|
|
209
|
+
* straight to `transform()` — and got:
|
|
210
|
+
*
|
|
211
|
+
* https://8ok.uk/t/width=1280/undefined.webp
|
|
212
|
+
*
|
|
213
|
+
* `UploadResult` carries `sha256`; every URL builder wants `sha`. TypeScript
|
|
214
|
+
* catches the mismatch, but an agent running through `bun` (or anyone in plain
|
|
215
|
+
* JS) sees no error at all: just a 200-shaped URL with the word `undefined` in
|
|
216
|
+
* it, which 404s later and somewhere else.
|
|
217
|
+
*
|
|
218
|
+
* The house rule applies exactly as it does to private assets: **a silence
|
|
219
|
+
* reads as "you can't"**. A builder that cannot name the asset must say so at
|
|
220
|
+
* the call site, not hand back a string that will fail far from here.
|
|
221
|
+
*/
|
|
222
|
+
export function assertSha(asset: { sha?: unknown }, fn: string): void {
|
|
223
|
+
const sha = asset?.sha;
|
|
224
|
+
if (typeof sha === "string" && /^[0-9a-f]{16,64}$/i.test(sha)) return;
|
|
225
|
+
const hint =
|
|
226
|
+
asset && typeof asset === "object" && "sha256" in asset
|
|
227
|
+
? " The value you passed has `sha256` but not `sha` — that is the shape `upload()` returns. Use `{ sha: result.sha256.slice(0, 16) }`, or fetch the DTO with `assets.get(id)`."
|
|
228
|
+
: ` Got ${JSON.stringify(sha)}.`;
|
|
229
|
+
throw new Error(
|
|
230
|
+
`${fn}: no usable \`sha\` on the value you passed, so the URL would contain "undefined" and 404 somewhere else.${hint}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -337,6 +337,23 @@ export type AssetDTO = {
|
|
|
337
337
|
*/
|
|
338
338
|
presets: string;
|
|
339
339
|
status: "processing" | "ready" | "failed";
|
|
340
|
+
/**
|
|
341
|
+
* Who may fetch the bytes.
|
|
342
|
+
*
|
|
343
|
+
* - `"public"` — the CDN serves it to anyone with the URL. The default,
|
|
344
|
+
* and what all 27 484 assets were until this field existed.
|
|
345
|
+
* - `"private"` — every public door answers **404**: the stored variants,
|
|
346
|
+
* the raw original, the HLS ladder and `/t/`. The bytes are reachable
|
|
347
|
+
* only through a signed URL under `/a/{tenant}/…?exp&sig`, which your
|
|
348
|
+
* BACKEND mints with {@link getPrivateAssetUrl}.
|
|
349
|
+
*
|
|
350
|
+
* Optional so an older server that does not send it is read as `"public"` —
|
|
351
|
+
* which is what such a server means.
|
|
352
|
+
*
|
|
353
|
+
* ⚠️ A 404 on a private asset is not a missing file. It is the feature
|
|
354
|
+
* working. See {@link getPrivateAssetUrl}.
|
|
355
|
+
*/
|
|
356
|
+
visibility?: "public" | "private";
|
|
340
357
|
/** Soft-delete timestamp (ISO). Hidden from catalog when set. */
|
|
341
358
|
deletedAt?: string | null;
|
|
342
359
|
/**
|
|
@@ -354,6 +371,15 @@ export type AssetDTO = {
|
|
|
354
371
|
oext?: string | null;
|
|
355
372
|
};
|
|
356
373
|
|
|
374
|
+
export {
|
|
375
|
+
accessMessage,
|
|
376
|
+
assertPublic,
|
|
377
|
+
assertSha,
|
|
378
|
+
deriveAccessKey,
|
|
379
|
+
type SignAccessOptions,
|
|
380
|
+
signAccessUrl,
|
|
381
|
+
type VisibilityHint,
|
|
382
|
+
} from "./access";
|
|
357
383
|
export type {
|
|
358
384
|
AssetPalette,
|
|
359
385
|
PaletteSwatch,
|
|
@@ -371,7 +397,18 @@ export {
|
|
|
371
397
|
relativeLuminance,
|
|
372
398
|
} from "./palette";
|
|
373
399
|
|
|
400
|
+
import {
|
|
401
|
+
assertPublic,
|
|
402
|
+
assertSha,
|
|
403
|
+
type SignAccessOptions,
|
|
404
|
+
signAccessUrl,
|
|
405
|
+
type VisibilityHint,
|
|
406
|
+
} from "./access";
|
|
374
407
|
import type { AssetPalette } from "./palette";
|
|
408
|
+
import {
|
|
409
|
+
getTransformUrlUnchecked,
|
|
410
|
+
type SignedTransformOptions,
|
|
411
|
+
} from "./transform";
|
|
375
412
|
|
|
376
413
|
// ---------------------------------------------------------------------------
|
|
377
414
|
// CDN base
|
|
@@ -517,9 +554,88 @@ type OriginalHints = {
|
|
|
517
554
|
* ```
|
|
518
555
|
*/
|
|
519
556
|
export function getAssetUrl(
|
|
557
|
+
asset: Pick<AssetDTO, "sha"> & OriginalHints & VisibilityHint,
|
|
558
|
+
preset: VariantPreset,
|
|
559
|
+
): string {
|
|
560
|
+
// Refuses rather than returning a URL that 404s. See `assertPublic`.
|
|
561
|
+
assertSha(asset, "getAssetUrl");
|
|
562
|
+
assertPublic(
|
|
563
|
+
asset,
|
|
564
|
+
"getAssetUrl",
|
|
565
|
+
`getPrivateAssetUrl(asset, "${preset}", signingKey, { expiresInSeconds: 300 })`,
|
|
566
|
+
);
|
|
567
|
+
// ⭐ THE TWO DEFAULTS DID NOT COMPOSE, AND THIS IS THE SEAM
|
|
568
|
+
//
|
|
569
|
+
// `upload()` defaults to `presets: ["original"]` — deliberately, so a bare
|
|
570
|
+
// upload never silently spends the storage budget. `urlFor()` defaults to
|
|
571
|
+
// `lg`. Put together, the obvious two-line program a programmatic caller
|
|
572
|
+
// writes —upload, then ask for a URL— produced a **404**, because `lg` was
|
|
573
|
+
// never generated. Measured 2026-08-22: 1 290 assets are in exactly that
|
|
574
|
+
// state, 228 of them in a production tenant.
|
|
575
|
+
//
|
|
576
|
+
// So when the DTO TELLS us the preset was never materialised, fall back to
|
|
577
|
+
// the transform route, which generates it on demand and caches it. The
|
|
578
|
+
// caller gets optimised bytes instead of a dead link, the raw stays
|
|
579
|
+
// untouched, and no storage is spent on sizes nobody asked for — measured on
|
|
580
|
+
// the same tenant, the stored ladder is 79% of raw, so pre-materialising
|
|
581
|
+
// everything would be ~9 GB for sizes that may never be requested.
|
|
582
|
+
//
|
|
583
|
+
// Same discipline as `assertPublic`: this only fires when we were TOLD.
|
|
584
|
+
// `Pick<AssetDTO,"sha">` carries no `presets`, so the common call is
|
|
585
|
+
// untouched and no existing behaviour changes.
|
|
586
|
+
const fallback = transformFallbackFor(asset, preset);
|
|
587
|
+
if (fallback) return fallback;
|
|
588
|
+
return buildPublicAssetUrl(asset, preset);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The `/t/` URL that stands in for a preset the asset does not have, or `null`
|
|
593
|
+
* when there is nothing to stand in for.
|
|
594
|
+
*
|
|
595
|
+
* ⚠️ It can be over-eager, and that is the honest trade. Measured 2026-08-22:
|
|
596
|
+
* an asset whose row lists only `original` served `-l.webp` with a 200 — the
|
|
597
|
+
* `variants` column under-reports (the contamination behind doc 240 §4.3b), so
|
|
598
|
+
* the fallback sometimes pays for a transform of a rendition that already
|
|
599
|
+
* exists. Both answers are correct bytes; one costs an encode. That is a much
|
|
600
|
+
* smaller wrong than the 404 it replaces, and it heals itself as rows are
|
|
601
|
+
* reconciled — but it is a reason to fix the rows, not to trust them more.
|
|
602
|
+
*
|
|
603
|
+
* Returns null — i.e. keeps the old behaviour — when the DTO does not say what
|
|
604
|
+
* it has, when the preset IS present, or when the preset has no pixel ceiling
|
|
605
|
+
* to translate into a width (`original`, `poster`, `video`, `hls`, `mp3`);
|
|
606
|
+
* those are stored objects, not renditions, and inventing a transform for them
|
|
607
|
+
* would trade a 404 for a wrong answer.
|
|
608
|
+
*/
|
|
609
|
+
function transformFallbackFor(
|
|
610
|
+
asset: Pick<AssetDTO, "sha"> & Partial<Pick<AssetDTO, "presets">>,
|
|
611
|
+
preset: VariantPreset,
|
|
612
|
+
): string | null {
|
|
613
|
+
if (typeof asset.presets !== "string") return null;
|
|
614
|
+
if (hasPreset({ presets: asset.presets }, preset)) return null;
|
|
615
|
+
const maxDim = PRESET_MAX_DIM[preset];
|
|
616
|
+
if (maxDim == null) return null;
|
|
617
|
+
return `${cdnBaseUrl}/t/format=webp,width=${maxDim}/${asset.sha}.webp`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The public-tree URL, with no visibility check.
|
|
622
|
+
*
|
|
623
|
+
* Split out because `getPrivateAssetUrl` needs exactly this and must NOT be
|
|
624
|
+
* refused by the guard: the private tree is the same path with `/a/` in front
|
|
625
|
+
* and a signature behind, so the builder that mints a legitimate private URL
|
|
626
|
+
* would otherwise be blocked by the check that exists to send callers to it.
|
|
627
|
+
*/
|
|
628
|
+
function buildPublicAssetUrl(
|
|
520
629
|
asset: Pick<AssetDTO, "sha"> & OriginalHints,
|
|
521
630
|
preset: VariantPreset,
|
|
522
631
|
): string {
|
|
632
|
+
// The guard lives HERE and not only in `getAssetUrl`, because
|
|
633
|
+
// `getPrivateAssetUrl` reaches this function directly. Without it, passing an
|
|
634
|
+
// upload result produced `/a/5/v/undefined-l.webp?exp=…&sig=…` — a URL with a
|
|
635
|
+
// **cryptographically valid signature over a path containing `undefined`**.
|
|
636
|
+
// That is strictly worse than the public case: the signature makes it look
|
|
637
|
+
// authoritative, and it passes shape checks at the edge before 404ing.
|
|
638
|
+
assertSha(asset, "getPrivateAssetUrl");
|
|
523
639
|
if (preset === "original") {
|
|
524
640
|
// The stored URL beats every derivation, because it IS the key. Only fall
|
|
525
641
|
// through to a guess when the caller gave us the sha and nothing else.
|
|
@@ -544,6 +660,89 @@ export function getAssetUrl(
|
|
|
544
660
|
return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;
|
|
545
661
|
}
|
|
546
662
|
|
|
663
|
+
/**
|
|
664
|
+
* The signed URL for one preset of a PRIVATE asset — what every refusal above
|
|
665
|
+
* points at.
|
|
666
|
+
*
|
|
667
|
+
* ```ts
|
|
668
|
+
* // On your BACKEND, once you have decided this viewer may see it:
|
|
669
|
+
* const url = await getPrivateAssetUrl(asset, "lg", tenantSigningKey, {
|
|
670
|
+
* expiresInSeconds: 300,
|
|
671
|
+
* });
|
|
672
|
+
* ```
|
|
673
|
+
*
|
|
674
|
+
* It works on a public asset too — `/a/` is a different door onto the same
|
|
675
|
+
* object — but there is no reason to pay for it: a public URL is cacheable at
|
|
676
|
+
* the edge and costs nothing, a signed one is neither.
|
|
677
|
+
*
|
|
678
|
+
* ⚠️ **Backend only.** Handing the signing key to a browser lets any visitor
|
|
679
|
+
* mint URLs for every private asset the tenant owns, which is the whole
|
|
680
|
+
* property the private tree exists to provide.
|
|
681
|
+
*
|
|
682
|
+
* ⚠️ Needs {@link setTenantId} (or a `NitidaClient` with `tenantId`), like
|
|
683
|
+
* every variant URL builder: the tenant segment is base36 and part of what the
|
|
684
|
+
* signature covers, so a missing tenant does not produce a wrong URL — it
|
|
685
|
+
* produces an unsignable one.
|
|
686
|
+
*/
|
|
687
|
+
export async function getPrivateAssetUrl(
|
|
688
|
+
asset: Pick<AssetDTO, "sha"> & OriginalHints,
|
|
689
|
+
preset: VariantPreset,
|
|
690
|
+
signingKey: string,
|
|
691
|
+
opts: SignAccessOptions,
|
|
692
|
+
): Promise<string> {
|
|
693
|
+
return signAccessUrl(buildPublicAssetUrl(asset, preset), signingKey, opts);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The signed URL for a TRANSFORM of a private asset — an arbitrary width, crop
|
|
698
|
+
* or format, not just the sizes that happen to be materialised.
|
|
699
|
+
*
|
|
700
|
+
* ```ts
|
|
701
|
+
* const url = await getPrivateTransformUrl(
|
|
702
|
+
* asset,
|
|
703
|
+
* { width: 1280, format: "webp" },
|
|
704
|
+
* tenantSigningKey,
|
|
705
|
+
* { expiresInSeconds: 300 },
|
|
706
|
+
* );
|
|
707
|
+
* // → https://8ok.uk/a/5/t/format=webp,width=1280/<sha>.webp?exp=…&sig=…
|
|
708
|
+
* ```
|
|
709
|
+
*
|
|
710
|
+
* Why this exists at all: a private asset that can only be served at the sizes
|
|
711
|
+
* someone already generated is barely a product. The signed tree mirrors the
|
|
712
|
+
* public one, transforms included.
|
|
713
|
+
*
|
|
714
|
+
* Returns `null` when `opts` serialize to an empty DSL — same contract as
|
|
715
|
+
* {@link getTransformUrl}, because "no transform requested" is not an error,
|
|
716
|
+
* it just means you wanted {@link getPrivateAssetUrl}.
|
|
717
|
+
*
|
|
718
|
+
* ⚠️ **Backend only**, like every signer here. And note the width is a plain
|
|
719
|
+
* `number`: a signed URL is a trusted caller, so the edge ladder does not
|
|
720
|
+
* apply — the same rule `getSignedTransformUrl` already follows.
|
|
721
|
+
*/
|
|
722
|
+
export async function getPrivateTransformUrl(
|
|
723
|
+
asset: Pick<AssetDTO, "sha">,
|
|
724
|
+
opts: SignedTransformOptions,
|
|
725
|
+
signingKey: string,
|
|
726
|
+
signOpts: SignAccessOptions,
|
|
727
|
+
): Promise<string | null> {
|
|
728
|
+
const url = getTransformUrlUnchecked(asset, opts);
|
|
729
|
+
if (!url) return null;
|
|
730
|
+
// `/t/<dsl>/<file>` has no tenant in it — the private tree needs one, and it
|
|
731
|
+
// is the same process-global the variant builders use.
|
|
732
|
+
const tid = getTenantId();
|
|
733
|
+
if (tid == null) {
|
|
734
|
+
throw new Error(
|
|
735
|
+
"getPrivateTransformUrl: no tenant is configured. Call setTenantId(id) (or construct a NitidaClient with `tenantId`) — the tenant is part of what the signature covers, so this cannot be guessed.",
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
const u = new URL(url);
|
|
739
|
+
return signAccessUrl(
|
|
740
|
+
`${u.origin}/${tid.toString(36)}${u.pathname}`,
|
|
741
|
+
signingKey,
|
|
742
|
+
signOpts,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
547
746
|
/**
|
|
548
747
|
* Did the processor actually generate this preset?
|
|
549
748
|
*
|
|
@@ -614,8 +813,27 @@ function stripMultiCharTokens(presets: string): string {
|
|
|
614
813
|
*/
|
|
615
814
|
const IMAGE_PRESETS: VariantPreset[] = ["thumb", "sm", "md", "lg", "xl"];
|
|
616
815
|
export function getAssetSrcSet(
|
|
617
|
-
asset: Pick<AssetDTO, "sha" | "presets"
|
|
816
|
+
asset: Pick<AssetDTO, "sha" | "presets"> & VisibilityHint,
|
|
618
817
|
): string {
|
|
818
|
+
// ⚠️ NOT given the transform fallback that `getAssetUrl` has, on purpose.
|
|
819
|
+
//
|
|
820
|
+
// A srcSet is a set of PROMISES about pixel width, and the fallback cannot
|
|
821
|
+
// keep them. `/t/width=3840/` on a 900 px source returns 900 px — sharp runs
|
|
822
|
+
// `withoutEnlargement: true` — so the candidate would advertise 3840w and
|
|
823
|
+
// deliver 900, and the browser would pick it for a large viewport and get
|
|
824
|
+
// the small image. That is worse than the empty srcSet it replaces: an empty
|
|
825
|
+
// srcSet degrades to `src`, which now resolves through the fallback and
|
|
826
|
+
// works. A lying srcSet degrades to a wrong choice, silently.
|
|
827
|
+
//
|
|
828
|
+
// Doing this properly means capping the rungs by the asset's real width, and
|
|
829
|
+
// this signature does not carry it (`Pick<AssetDTO,"sha"|"presets">`). Worth
|
|
830
|
+
// doing; not worth guessing.
|
|
831
|
+
assertSha(asset, "getAssetSrcSet");
|
|
832
|
+
assertPublic(
|
|
833
|
+
asset,
|
|
834
|
+
"getAssetSrcSet",
|
|
835
|
+
"getPrivateAssetUrl(asset, preset, signingKey, { expiresInSeconds: 300 }) per preset",
|
|
836
|
+
);
|
|
619
837
|
return IMAGE_PRESETS.filter(
|
|
620
838
|
(p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null,
|
|
621
839
|
)
|
package/src/transform.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* request's Accept header.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { assertPublic, assertSha, type VisibilityHint } from "./access";
|
|
21
22
|
import type { AssetDTO } from "./index";
|
|
22
23
|
import { getCdnBase } from "./index";
|
|
23
24
|
|
|
@@ -227,9 +228,15 @@ function extForOptions(opts: SignedTransformOptions): string {
|
|
|
227
228
|
* autoPlay muted loop playsInline />
|
|
228
229
|
*/
|
|
229
230
|
export function getVideoTransformUrl(
|
|
230
|
-
asset: Pick<AssetDTO, "sha"
|
|
231
|
+
asset: Pick<AssetDTO, "sha"> & VisibilityHint,
|
|
231
232
|
opts: TransformOptions,
|
|
232
233
|
): string | null {
|
|
234
|
+
assertSha(asset, "getVideoTransformUrl");
|
|
235
|
+
assertPublic(
|
|
236
|
+
asset,
|
|
237
|
+
"getVideoTransformUrl",
|
|
238
|
+
"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })",
|
|
239
|
+
);
|
|
233
240
|
const dsl = serializeTransform(opts);
|
|
234
241
|
if (!dsl) return null;
|
|
235
242
|
const ext = opts.format === "webm" ? "webm" : "mp4";
|
|
@@ -295,9 +302,15 @@ export function getVideoTransformUrl(
|
|
|
295
302
|
* capped at 1920 wide. `getAssetUrl(sha, "video")` is always <= 1080p.
|
|
296
303
|
*/
|
|
297
304
|
export function getHlsStreamingUrl(
|
|
298
|
-
asset: Pick<AssetDTO, "sha"
|
|
305
|
+
asset: Pick<AssetDTO, "sha"> & VisibilityHint,
|
|
299
306
|
opts: Omit<TransformOptions, "format"> = {},
|
|
300
307
|
): string {
|
|
308
|
+
assertSha(asset, "getHlsStreamingUrl");
|
|
309
|
+
assertPublic(
|
|
310
|
+
asset,
|
|
311
|
+
"getHlsStreamingUrl",
|
|
312
|
+
'getPrivateAssetUrl(asset, "hls", signingKey, { expiresInSeconds: 300 }) — the worker re-signs the playlist children',
|
|
313
|
+
);
|
|
301
314
|
// Always serialize with format=hls so the server routes correctly.
|
|
302
315
|
const merged: TransformOptions = { ...opts, format: "hls" };
|
|
303
316
|
const dsl = serializeTransform(merged);
|
|
@@ -309,6 +322,8 @@ export function getHlsStreamingUrl(
|
|
|
309
322
|
* callers should prefer the existing variant URL builder in that case so
|
|
310
323
|
* the request hits a pre-generated variant instead of an on-the-fly encode.
|
|
311
324
|
*/
|
|
325
|
+
export { buildTransformUrl as getTransformUrlUnchecked };
|
|
326
|
+
|
|
312
327
|
function buildTransformUrl(
|
|
313
328
|
asset: Pick<AssetDTO, "sha">,
|
|
314
329
|
opts: SignedTransformOptions,
|
|
@@ -320,9 +335,15 @@ function buildTransformUrl(
|
|
|
320
335
|
}
|
|
321
336
|
|
|
322
337
|
export function getTransformUrl(
|
|
323
|
-
asset: Pick<AssetDTO, "sha"
|
|
338
|
+
asset: Pick<AssetDTO, "sha"> & VisibilityHint,
|
|
324
339
|
opts: TransformOptions,
|
|
325
340
|
): string | null {
|
|
341
|
+
assertSha(asset, "getTransformUrl");
|
|
342
|
+
assertPublic(
|
|
343
|
+
asset,
|
|
344
|
+
"getTransformUrl",
|
|
345
|
+
"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })",
|
|
346
|
+
);
|
|
326
347
|
return buildTransformUrl(asset, opts);
|
|
327
348
|
}
|
|
328
349
|
|
|
@@ -338,10 +359,22 @@ export function getTransformUrl(
|
|
|
338
359
|
* requested) — same contract as {@link getTransformUrl}.
|
|
339
360
|
*/
|
|
340
361
|
export function getSignedTransformUrl(
|
|
341
|
-
asset: Pick<AssetDTO, "sha"
|
|
362
|
+
asset: Pick<AssetDTO, "sha"> & VisibilityHint,
|
|
342
363
|
opts: SignedTransformOptions,
|
|
343
364
|
signingKey: string,
|
|
344
365
|
): Promise<string> | null {
|
|
366
|
+
// ⭐ The guard belongs here MOST of all, and it was the one place it was
|
|
367
|
+
// missing. A `?sig=` on `/t/` is a WIDTH permit, not access: on a private
|
|
368
|
+
// asset the URL it produces is a perfectly signed 404. And this is exactly
|
|
369
|
+
// where a backend developer holding a signing key and a private asset ends
|
|
370
|
+
// up — so without this, the same call site throws when unsigned and returns
|
|
371
|
+
// a doomed URL when signed, which is the worst of both.
|
|
372
|
+
assertSha(asset, "getSignedTransformUrl");
|
|
373
|
+
assertPublic(
|
|
374
|
+
asset,
|
|
375
|
+
"getSignedTransformUrl",
|
|
376
|
+
"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })",
|
|
377
|
+
);
|
|
345
378
|
const url = buildTransformUrl(asset, opts);
|
|
346
379
|
if (!url) return null;
|
|
347
380
|
return signTransformUrl(url, signingKey);
|
|
@@ -405,10 +438,16 @@ async function hmacSha256Hex(key: string, message: string): Promise<string> {
|
|
|
405
438
|
* />
|
|
406
439
|
*/
|
|
407
440
|
export function getTransformSrcSet(
|
|
408
|
-
asset: Pick<AssetDTO, "sha"
|
|
441
|
+
asset: Pick<AssetDTO, "sha"> & VisibilityHint,
|
|
409
442
|
widths: number[],
|
|
410
443
|
extraOpts: Omit<TransformOptions, "width"> = {},
|
|
411
444
|
): string {
|
|
445
|
+
assertSha(asset, "getTransformSrcSet");
|
|
446
|
+
assertPublic(
|
|
447
|
+
asset,
|
|
448
|
+
"getTransformSrcSet",
|
|
449
|
+
"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 }) per width",
|
|
450
|
+
);
|
|
412
451
|
return widths
|
|
413
452
|
.map((w) => {
|
|
414
453
|
// Build via the internal (number-width) builder: `widths` is an explicit
|