@stapel/cdn-react 0.1.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/CHANGELOG.md +1 -0
- package/MODULE.md +124 -0
- package/README.md +103 -0
- package/dist/api/cdnApi.d.ts +122 -0
- package/dist/api/cdnApi.d.ts.map +1 -0
- package/dist/api/cdnApi.js +27 -0
- package/dist/api/cdnApi.js.map +1 -0
- package/dist/api/generated/schema.d.ts +1050 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/types.d.ts +76 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +8 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +26 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/ImageUploadField.d.ts +12 -0
- package/dist/default/ImageUploadField.d.ts.map +1 -0
- package/dist/default/ImageUploadField.js +48 -0
- package/dist/default/ImageUploadField.js.map +1 -0
- package/dist/default/MediaGalleryField.d.ts +14 -0
- package/dist/default/MediaGalleryField.d.ts.map +1 -0
- package/dist/default/MediaGalleryField.js +65 -0
- package/dist/default/MediaGalleryField.js.map +1 -0
- package/dist/default/index.d.ts +15 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +13 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/phase.d.ts +17 -0
- package/dist/default/phase.d.ts.map +1 -0
- package/dist/default/phase.js +25 -0
- package/dist/default/phase.js.map +1 -0
- package/dist/flows/registry.d.ts +28 -0
- package/dist/flows/registry.d.ts.map +1 -0
- package/dist/flows/registry.js +26 -0
- package/dist/flows/registry.js.map +1 -0
- package/dist/headless/CdnProvider.d.ts +18 -0
- package/dist/headless/CdnProvider.d.ts.map +1 -0
- package/dist/headless/CdnProvider.js +14 -0
- package/dist/headless/CdnProvider.js.map +1 -0
- package/dist/headless/ImageUpload.d.ts +18 -0
- package/dist/headless/ImageUpload.d.ts.map +1 -0
- package/dist/headless/ImageUpload.js +16 -0
- package/dist/headless/ImageUpload.js.map +1 -0
- package/dist/headless/MediaUploader.d.ts +31 -0
- package/dist/headless/MediaUploader.d.ts.map +1 -0
- package/dist/headless/MediaUploader.js +30 -0
- package/dist/headless/MediaUploader.js.map +1 -0
- package/dist/headless/useUploadImage.d.ts +33 -0
- package/dist/headless/useUploadImage.d.ts.map +1 -0
- package/dist/headless/useUploadImage.js +118 -0
- package/dist/headless/useUploadImage.js.map +1 -0
- package/dist/headless/useUploadPreview.d.ts +22 -0
- package/dist/headless/useUploadPreview.d.ts.map +1 -0
- package/dist/headless/useUploadPreview.js +37 -0
- package/dist/headless/useUploadPreview.js.map +1 -0
- package/dist/headless/useUploadQueue.d.ts +83 -0
- package/dist/headless/useUploadQueue.d.ts.map +1 -0
- package/dist/headless/useUploadQueue.js +0 -0
- package/dist/headless/useUploadQueue.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +12 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +22 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +17 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +65 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +58 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +353 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +180 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +58 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +61 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +110 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +21 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +70 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +83 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +15 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/hash.d.ts +36 -0
- package/dist/model/hash.d.ts.map +1 -0
- package/dist/model/hash.js +59 -0
- package/dist/model/hash.js.map +1 -0
- package/dist/model/limits.d.ts +86 -0
- package/dist/model/limits.d.ts.map +1 -0
- package/dist/model/limits.js +163 -0
- package/dist/model/limits.js.map +1 -0
- package/dist/model/queries.d.ts +29 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +32 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +18 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +18 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/refs.d.ts +54 -0
- package/dist/model/refs.d.ts.map +1 -0
- package/dist/model/refs.js +82 -0
- package/dist/model/refs.js.map +1 -0
- package/dist/model/runtime.d.ts +40 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +23 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/model/upload.d.ts +88 -0
- package/dist/model/upload.d.ts.map +1 -0
- package/dist/model/upload.js +254 -0
- package/dist/model/upload.js.map +1 -0
- package/llms.txt +83 -0
- package/manifest.json +601 -0
- package/package.json +120 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/cdnApi.ts +187 -0
- package/src/api/generated/schema.ts +1052 -0
- package/src/api/types.ts +86 -0
- package/src/default/ErrorAlert.tsx +43 -0
- package/src/default/ImageUploadField.tsx +131 -0
- package/src/default/MediaGalleryField.tsx +233 -0
- package/src/default/index.ts +14 -0
- package/src/default/phase.ts +34 -0
- package/src/flows/registry.ts +38 -0
- package/src/headless/CdnProvider.tsx +19 -0
- package/src/headless/ImageUpload.tsx +24 -0
- package/src/headless/MediaUploader.tsx +43 -0
- package/src/headless/useUploadImage.ts +155 -0
- package/src/headless/useUploadPreview.ts +48 -0
- package/src/headless/useUploadQueue.ts +411 -0
- package/src/i18n/errorsMap.ts +33 -0
- package/src/i18n/es.ts +81 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +211 -0
- package/src/i18n/generated/errors.json +403 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +131 -0
- package/src/i18n/ru.ts +85 -0
- package/src/index.ts +154 -0
- package/src/model/context.tsx +25 -0
- package/src/model/hash.ts +67 -0
- package/src/model/limits.ts +207 -0
- package/src/model/queries.ts +64 -0
- package/src/model/queryKeys.ts +23 -0
- package/src/model/refs.ts +95 -0
- package/src/model/runtime.ts +52 -0
- package/src/model/upload.ts +365 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHA-256 of a file's bytes — the key the dedup pre-check is asked with.
|
|
3
|
+
*
|
|
4
|
+
* The backend computes the same digest over the same bytes
|
|
5
|
+
* (`Image.calculate_file_hash`, `hashlib.sha256(...).hexdigest()`), so the two
|
|
6
|
+
* agree by construction: 64 lowercase hex characters of the ORIGINAL upload,
|
|
7
|
+
* before any variant exists.
|
|
8
|
+
*
|
|
9
|
+
* ── Why this can be unavailable, and why that is not an error ──────────────
|
|
10
|
+
*
|
|
11
|
+
* `crypto.subtle` exists only in a SECURE CONTEXT. On `http://` (a LAN test
|
|
12
|
+
* box, an old staging host) it is simply not there, and neither is the
|
|
13
|
+
* pre-check. That is a lost optimisation, not a lost upload: the server
|
|
14
|
+
* deduplicates on its own side regardless, so the POST is still correct — it
|
|
15
|
+
* just costs the bytes. {@link canHashLocally} lets the flow say which of the
|
|
16
|
+
* two paths it took instead of failing, and `UploadItem.dedupSkipped` carries
|
|
17
|
+
* the reason all the way to a skin that wants to explain it.
|
|
18
|
+
*
|
|
19
|
+
* ── Why there is no progress here ──────────────────────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* `SubtleCrypto.digest` takes the whole buffer and returns one promise; it
|
|
22
|
+
* reports nothing in between and cannot be chunked without hand-rolling
|
|
23
|
+
* SHA-256, which is not a thing to hand-roll. Hashing is therefore a PHASE
|
|
24
|
+
* with a duration, not a percentage — see `model/upload.ts` on why the whole
|
|
25
|
+
* bag is phase-shaped.
|
|
26
|
+
*/
|
|
27
|
+
/** Whether this context can compute the digest at all (see the header). */
|
|
28
|
+
export function canHashLocally() {
|
|
29
|
+
return (typeof globalThis.crypto !== "undefined" &&
|
|
30
|
+
typeof globalThis.crypto.subtle?.digest === "function");
|
|
31
|
+
}
|
|
32
|
+
function toHex(buffer) {
|
|
33
|
+
const bytes = new Uint8Array(buffer);
|
|
34
|
+
let out = "";
|
|
35
|
+
for (const byte of bytes)
|
|
36
|
+
out += byte.toString(16).padStart(2, "0");
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `digest` is handed a `Uint8Array` VIEW rather than the raw `ArrayBuffer`.
|
|
41
|
+
* Both are valid `BufferSource`, but a buffer that crossed a realm boundary
|
|
42
|
+
* fails a strict `instanceof ArrayBuffer` check in some implementations (jsdom
|
|
43
|
+
* is one, which is where this surfaced), while a typed-array view is accepted
|
|
44
|
+
* everywhere. The view costs nothing — no copy — and removes a portability
|
|
45
|
+
* hazard that would otherwise only show up in somebody else's environment.
|
|
46
|
+
*/
|
|
47
|
+
const bufferSource = (buffer) => new Uint8Array(buffer);
|
|
48
|
+
/**
|
|
49
|
+
* The 64-character lowercase hex SHA-256 of `blob`'s bytes.
|
|
50
|
+
*
|
|
51
|
+
* Throws whatever the platform throws when there is no `crypto.subtle` — call
|
|
52
|
+
* {@link canHashLocally} first; the upload flow does.
|
|
53
|
+
*/
|
|
54
|
+
export async function sha256Hex(blob) {
|
|
55
|
+
const buffer = await blob.arrayBuffer();
|
|
56
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", bufferSource(buffer));
|
|
57
|
+
return toHex(digest);
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=hash.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hash.js","sourceRoot":"","sources":["../../src/model/hash.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,2EAA2E;AAC3E,MAAM,UAAU,cAAc;IAC5B,OAAO,CACL,OAAO,UAAU,CAAC,MAAM,KAAK,WAAW;QACxC,OAAO,UAAU,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,KAAK,UAAU,CACvD,CAAC;AACJ,CAAC;AAED,SAAS,KAAK,CAAC,MAAmB;IAChC,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC;IACrC,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,KAAK,MAAM,IAAI,IAAI,KAAK;QAAE,GAAG,IAAI,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IACpE,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,YAAY,GAAG,CAAC,MAAmB,EAA2B,EAAE,CACpE,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC;AAEzB;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,IAAU;IACxC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;IACxC,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAClD,SAAS,EACT,YAAY,CAAC,MAAM,CAAC,CACrB,CAAC;IACF,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC;AACvB,CAAC"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-side mirror of stapel-cdn's own intake gates.
|
|
3
|
+
*
|
|
4
|
+
* ── Why mirror at all ──────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* Because the alternative is uploading 40 MB over a phone connection to be
|
|
7
|
+
* told "File is too large" by the server that just received all of it. The
|
|
8
|
+
* gates below are the cheap half of what `_validate_image_upload` does before
|
|
9
|
+
* it hashes a byte (`stapel_cdn/views.py`): the size ceiling and the extension
|
|
10
|
+
* allowlist. The expensive half — an actual libvips decode, which is what
|
|
11
|
+
* catches a `.jpg` full of HTML — has no browser equivalent and is not
|
|
12
|
+
* simulated here. A file that passes this mirror can still be refused, and
|
|
13
|
+
* that refusal renders through the same error contour as any other.
|
|
14
|
+
*
|
|
15
|
+
* ── The rule the mirror must never break ───────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* A MIRROR THAT REFUSES WHAT THE SERVER WOULD ACCEPT IS WORSE THAN NO MIRROR.
|
|
18
|
+
* It blocks a valid upload with no way for the person to appeal, and the
|
|
19
|
+
* server never even hears about it. So every ceiling here is CONFIGURABLE on
|
|
20
|
+
* the runtime and defaults to the library's own default — because all of them
|
|
21
|
+
* are `STAPEL_CDN` settings a deployment moves (`stapel_cdn/conf.py`), and a
|
|
22
|
+
* hardcoded 20 MB would be a client-side lie on a host that raised it. The
|
|
23
|
+
* same argument in the opposite direction: a host that LOWERED the ceiling
|
|
24
|
+
* passes its number here and gets the refusal early, which is the point.
|
|
25
|
+
*
|
|
26
|
+
* The refusals below are raised with stapel-cdn's OWN error codes, so a skin
|
|
27
|
+
* renders "File is too large" from the generated bundle whether the sentence
|
|
28
|
+
* came from here or from the server. There is no second vocabulary for
|
|
29
|
+
* client-side refusals.
|
|
30
|
+
*/
|
|
31
|
+
import { StapelApiError } from "@stapel/core";
|
|
32
|
+
/** `error.413.file_too_large` — the byte ceiling, from `MAX_*_SIZE`. */
|
|
33
|
+
export declare const ERROR_FILE_TOO_LARGE = "error.413.file_too_large";
|
|
34
|
+
/** `error.400.invalid_format` — the extension allowlist. */
|
|
35
|
+
export declare const ERROR_INVALID_FORMAT = "error.400.invalid_format";
|
|
36
|
+
/** `error.400.no_file` — an empty pick. */
|
|
37
|
+
export declare const ERROR_NO_FILE = "error.400.no_file";
|
|
38
|
+
/**
|
|
39
|
+
* The ceilings for one intake kind. Defaults reproduce `stapel_cdn/conf.py`'s
|
|
40
|
+
* `DEFAULTS` at the pinned contract (v0.12.0).
|
|
41
|
+
*/
|
|
42
|
+
export interface CdnIntakeLimits {
|
|
43
|
+
/** Byte ceiling. `STAPEL_CDN["MAX_IMAGE_SIZE" | …]`. */
|
|
44
|
+
readonly maxBytes: number;
|
|
45
|
+
/** Lowercase extensions INCLUDING the dot, as the backend stores them. */
|
|
46
|
+
readonly extensions: readonly string[];
|
|
47
|
+
/**
|
|
48
|
+
* MIME types the backend narrows on, when it narrows on MIME at all. Only
|
|
49
|
+
* the generic-file intake does (`ALLOWED_FILE_MIME_TYPES`); images and
|
|
50
|
+
* videos are gated on the extension plus a decode/sniff, so this is
|
|
51
|
+
* `undefined` for them rather than a guess. An empty array would mean
|
|
52
|
+
* "nothing is allowed", which is a different and wrong statement.
|
|
53
|
+
*/
|
|
54
|
+
readonly mimeTypes?: readonly string[];
|
|
55
|
+
}
|
|
56
|
+
export interface CdnLimits {
|
|
57
|
+
readonly image: CdnIntakeLimits;
|
|
58
|
+
readonly video: CdnIntakeLimits;
|
|
59
|
+
readonly file: CdnIntakeLimits;
|
|
60
|
+
}
|
|
61
|
+
/** stapel-cdn's library defaults — the mirror's starting point. */
|
|
62
|
+
export declare const CDN_DEFAULT_LIMITS: CdnLimits;
|
|
63
|
+
/** Host overrides — every field optional, merged over the defaults. */
|
|
64
|
+
export interface CdnLimitsOverride {
|
|
65
|
+
readonly image?: Partial<CdnIntakeLimits>;
|
|
66
|
+
readonly video?: Partial<CdnIntakeLimits>;
|
|
67
|
+
readonly file?: Partial<CdnIntakeLimits>;
|
|
68
|
+
}
|
|
69
|
+
export declare function resolveCdnLimits(override?: CdnLimitsOverride): CdnLimits;
|
|
70
|
+
/** The lowercase extension of a filename, dot included, or `""`. */
|
|
71
|
+
export declare function fileExtension(name: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* The `accept` attribute for a file input, built from the same allowlist the
|
|
74
|
+
* refusal is built from — so the picker and the gate cannot disagree.
|
|
75
|
+
*/
|
|
76
|
+
export declare function acceptAttribute(limits: CdnIntakeLimits): string;
|
|
77
|
+
/**
|
|
78
|
+
* Check one file against one intake's ceilings.
|
|
79
|
+
*
|
|
80
|
+
* Returns the refusal as a {@link StapelApiError} in stapel-cdn's own
|
|
81
|
+
* vocabulary, or `null` when the file may be sent. Never throws: the caller
|
|
82
|
+
* (a queue admitting ten files at once) needs a per-file verdict, not a
|
|
83
|
+
* control-flow interruption on the first bad one.
|
|
84
|
+
*/
|
|
85
|
+
export declare function validateFile(file: File, limits: CdnIntakeLimits): StapelApiError | null;
|
|
86
|
+
//# sourceMappingURL=limits.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"limits.d.ts","sourceRoot":"","sources":["../../src/model/limits.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE9C,wEAAwE;AACxE,eAAO,MAAM,oBAAoB,6BAA6B,CAAC;AAC/D,4DAA4D;AAC5D,eAAO,MAAM,oBAAoB,6BAA6B,CAAC;AAC/D,2CAA2C;AAC3C,eAAO,MAAM,aAAa,sBAAsB,CAAC;AAEjD;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,0EAA0E;IAC1E,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IACvC;;;;;;OAMG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;CAChC;AAID,mEAAmE;AACnE,eAAO,MAAM,kBAAkB,EAAE,SA+BhC,CAAC;AAEF,uEAAuE;AACvE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;IAC1C,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;IAC1C,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;CAC1C;AAkBD,wBAAgB,gBAAgB,CAAC,QAAQ,CAAC,EAAE,iBAAiB,GAAG,SAAS,CAMxE;AAED,oEAAoE;AACpE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIlD;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAI/D;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,eAAe,GACtB,cAAc,GAAG,IAAI,CA+CvB"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-side mirror of stapel-cdn's own intake gates.
|
|
3
|
+
*
|
|
4
|
+
* ── Why mirror at all ──────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* Because the alternative is uploading 40 MB over a phone connection to be
|
|
7
|
+
* told "File is too large" by the server that just received all of it. The
|
|
8
|
+
* gates below are the cheap half of what `_validate_image_upload` does before
|
|
9
|
+
* it hashes a byte (`stapel_cdn/views.py`): the size ceiling and the extension
|
|
10
|
+
* allowlist. The expensive half — an actual libvips decode, which is what
|
|
11
|
+
* catches a `.jpg` full of HTML — has no browser equivalent and is not
|
|
12
|
+
* simulated here. A file that passes this mirror can still be refused, and
|
|
13
|
+
* that refusal renders through the same error contour as any other.
|
|
14
|
+
*
|
|
15
|
+
* ── The rule the mirror must never break ───────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* A MIRROR THAT REFUSES WHAT THE SERVER WOULD ACCEPT IS WORSE THAN NO MIRROR.
|
|
18
|
+
* It blocks a valid upload with no way for the person to appeal, and the
|
|
19
|
+
* server never even hears about it. So every ceiling here is CONFIGURABLE on
|
|
20
|
+
* the runtime and defaults to the library's own default — because all of them
|
|
21
|
+
* are `STAPEL_CDN` settings a deployment moves (`stapel_cdn/conf.py`), and a
|
|
22
|
+
* hardcoded 20 MB would be a client-side lie on a host that raised it. The
|
|
23
|
+
* same argument in the opposite direction: a host that LOWERED the ceiling
|
|
24
|
+
* passes its number here and gets the refusal early, which is the point.
|
|
25
|
+
*
|
|
26
|
+
* The refusals below are raised with stapel-cdn's OWN error codes, so a skin
|
|
27
|
+
* renders "File is too large" from the generated bundle whether the sentence
|
|
28
|
+
* came from here or from the server. There is no second vocabulary for
|
|
29
|
+
* client-side refusals.
|
|
30
|
+
*/
|
|
31
|
+
import { StapelApiError } from "@stapel/core";
|
|
32
|
+
/** `error.413.file_too_large` — the byte ceiling, from `MAX_*_SIZE`. */
|
|
33
|
+
export const ERROR_FILE_TOO_LARGE = "error.413.file_too_large";
|
|
34
|
+
/** `error.400.invalid_format` — the extension allowlist. */
|
|
35
|
+
export const ERROR_INVALID_FORMAT = "error.400.invalid_format";
|
|
36
|
+
/** `error.400.no_file` — an empty pick. */
|
|
37
|
+
export const ERROR_NO_FILE = "error.400.no_file";
|
|
38
|
+
const MB = 1024 * 1024;
|
|
39
|
+
/** stapel-cdn's library defaults — the mirror's starting point. */
|
|
40
|
+
export const CDN_DEFAULT_LIMITS = {
|
|
41
|
+
image: {
|
|
42
|
+
maxBytes: 20 * MB,
|
|
43
|
+
extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".heic", ".heif"],
|
|
44
|
+
},
|
|
45
|
+
video: {
|
|
46
|
+
maxBytes: 100 * MB,
|
|
47
|
+
extensions: [".mp4", ".webm", ".mov", ".avi", ".mkv"],
|
|
48
|
+
},
|
|
49
|
+
file: {
|
|
50
|
+
maxBytes: 50 * MB,
|
|
51
|
+
extensions: [
|
|
52
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
|
53
|
+
".txt", ".csv", ".zip", ".rar", ".7z", ".gz",
|
|
54
|
+
],
|
|
55
|
+
mimeTypes: [
|
|
56
|
+
"application/pdf",
|
|
57
|
+
"application/msword",
|
|
58
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
59
|
+
"application/vnd.ms-excel",
|
|
60
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
61
|
+
"application/vnd.ms-powerpoint",
|
|
62
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
63
|
+
"text/plain",
|
|
64
|
+
"text/csv",
|
|
65
|
+
"application/zip",
|
|
66
|
+
"application/x-rar-compressed",
|
|
67
|
+
"application/x-7z-compressed",
|
|
68
|
+
"application/gzip",
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
function mergeIntake(base, override) {
|
|
73
|
+
if (override === undefined)
|
|
74
|
+
return base;
|
|
75
|
+
return {
|
|
76
|
+
maxBytes: override.maxBytes ?? base.maxBytes,
|
|
77
|
+
extensions: override.extensions ?? base.extensions,
|
|
78
|
+
...(override.mimeTypes !== undefined
|
|
79
|
+
? { mimeTypes: override.mimeTypes }
|
|
80
|
+
: base.mimeTypes !== undefined
|
|
81
|
+
? { mimeTypes: base.mimeTypes }
|
|
82
|
+
: {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export function resolveCdnLimits(override) {
|
|
86
|
+
return {
|
|
87
|
+
image: mergeIntake(CDN_DEFAULT_LIMITS.image, override?.image),
|
|
88
|
+
video: mergeIntake(CDN_DEFAULT_LIMITS.video, override?.video),
|
|
89
|
+
file: mergeIntake(CDN_DEFAULT_LIMITS.file, override?.file),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** The lowercase extension of a filename, dot included, or `""`. */
|
|
93
|
+
export function fileExtension(name) {
|
|
94
|
+
const dot = name.lastIndexOf(".");
|
|
95
|
+
if (dot <= 0 || dot === name.length - 1)
|
|
96
|
+
return "";
|
|
97
|
+
return name.slice(dot).toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The `accept` attribute for a file input, built from the same allowlist the
|
|
101
|
+
* refusal is built from — so the picker and the gate cannot disagree.
|
|
102
|
+
*/
|
|
103
|
+
export function acceptAttribute(limits) {
|
|
104
|
+
const parts = [...limits.extensions];
|
|
105
|
+
if (limits.mimeTypes !== undefined)
|
|
106
|
+
parts.push(...limits.mimeTypes);
|
|
107
|
+
return parts.join(",");
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Check one file against one intake's ceilings.
|
|
111
|
+
*
|
|
112
|
+
* Returns the refusal as a {@link StapelApiError} in stapel-cdn's own
|
|
113
|
+
* vocabulary, or `null` when the file may be sent. Never throws: the caller
|
|
114
|
+
* (a queue admitting ten files at once) needs a per-file verdict, not a
|
|
115
|
+
* control-flow interruption on the first bad one.
|
|
116
|
+
*/
|
|
117
|
+
export function validateFile(file, limits) {
|
|
118
|
+
if (file.size === 0) {
|
|
119
|
+
return new StapelApiError({
|
|
120
|
+
code: ERROR_NO_FILE,
|
|
121
|
+
message: "No file provided",
|
|
122
|
+
status: 400,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (file.size > limits.maxBytes) {
|
|
126
|
+
return new StapelApiError({
|
|
127
|
+
code: ERROR_FILE_TOO_LARGE,
|
|
128
|
+
message: "File is too large",
|
|
129
|
+
// The server answers 413 for this; the mirror says the same number so a
|
|
130
|
+
// host branching on `status` cannot tell the two apart — which is the
|
|
131
|
+
// intent. `params` carries what the sentence would need to be specific,
|
|
132
|
+
// even though the backend's own copy has no slots today.
|
|
133
|
+
status: 413,
|
|
134
|
+
params: { max_bytes: limits.maxBytes, size: file.size },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const extension = fileExtension(file.name);
|
|
138
|
+
if (!limits.extensions.includes(extension)) {
|
|
139
|
+
return new StapelApiError({
|
|
140
|
+
code: ERROR_INVALID_FORMAT,
|
|
141
|
+
message: "Unsupported file format",
|
|
142
|
+
status: 400,
|
|
143
|
+
params: { extension, allowed: limits.extensions.join(", ") },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// MIME is checked only where the backend checks it, and only when the
|
|
147
|
+
// browser actually supplied one. A blank `file.type` is common (an
|
|
148
|
+
// extension the OS does not know) and is NOT evidence of a bad file — the
|
|
149
|
+
// backend reads the declared Content-Type the same way, as a narrowing
|
|
150
|
+
// device rather than a verdict.
|
|
151
|
+
if (limits.mimeTypes !== undefined &&
|
|
152
|
+
file.type.length > 0 &&
|
|
153
|
+
!limits.mimeTypes.includes(file.type)) {
|
|
154
|
+
return new StapelApiError({
|
|
155
|
+
code: ERROR_INVALID_FORMAT,
|
|
156
|
+
message: "Unsupported file format",
|
|
157
|
+
status: 400,
|
|
158
|
+
params: { mime: file.type },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=limits.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"limits.js","sourceRoot":"","sources":["../../src/model/limits.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { LoadState } from "@stapel/core";
|
|
2
|
+
import type { CdnImage, CdnRef } from "../api/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a stored `<type>/<hash>` reference back to the CDN row — the read
|
|
5
|
+
* that lets a composer REOPEN a draft and show what is already attached to it.
|
|
6
|
+
*
|
|
7
|
+
* ── The scope this read has, stated rather than discovered ─────────────────
|
|
8
|
+
*
|
|
9
|
+
* `file/exists/` filters on `uploaded_by=request.user`, always. So this
|
|
10
|
+
* resolves the CALLER'S OWN references and nothing else: it is the right
|
|
11
|
+
* instrument for "my draft's photos" and the wrong one for "this seller's
|
|
12
|
+
* photos". A buyer's storefront renders a listing's images from what the
|
|
13
|
+
* listings API gives it, not from here — stapel-cdn exposes no public
|
|
14
|
+
* read-by-reference endpoint at all (recorded as an upstream gap in the
|
|
15
|
+
* package README).
|
|
16
|
+
*
|
|
17
|
+
* A reference that resolves to nothing answers `ready(null)`, not `failed`:
|
|
18
|
+
* `{exists: false}` is a 200 and a true answer. "This reference is not mine /
|
|
19
|
+
* no longer stored" and "we could not ask" are different sentences and a skin
|
|
20
|
+
* must be able to tell them apart — which is why the value behind `ready` is
|
|
21
|
+
* nullable and the failure lives in the discriminant.
|
|
22
|
+
*/
|
|
23
|
+
export interface CdnRefBag {
|
|
24
|
+
readonly state: LoadState<CdnImage | null>;
|
|
25
|
+
readonly isFetching: boolean;
|
|
26
|
+
refetch(): void;
|
|
27
|
+
}
|
|
28
|
+
export declare function useCdnRef(ref: CdnRef | null | undefined): CdnRefBag;
|
|
29
|
+
//# sourceMappingURL=queries.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queries.d.ts","sourceRoot":"","sources":["../../src/model/queries.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAyB,QAAQ,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAK/E;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;IAC3C,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,SAAS,CA6BnE"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { loadStateFromQuery, useActiveSessionReady } from "@stapel/core";
|
|
3
|
+
import { useCdnApi } from "./context.js";
|
|
4
|
+
import { cdnQueryKeys } from "./queryKeys.js";
|
|
5
|
+
import { parseCdnRef } from "./refs.js";
|
|
6
|
+
export function useCdnRef(ref) {
|
|
7
|
+
const api = useCdnApi();
|
|
8
|
+
// Gated on the session for the reason core's own doc comment gives: the
|
|
9
|
+
// endpoint is `IsAuthenticated`, so firing it during the login bootstrap
|
|
10
|
+
// buys one guaranteed 401 per mounted thumbnail.
|
|
11
|
+
const sessionReady = useActiveSessionReady();
|
|
12
|
+
const parsed = ref === null || ref === undefined ? null : parseCdnRef(ref);
|
|
13
|
+
const query = useQuery({
|
|
14
|
+
queryKey: cdnQueryKeys.exists(parsed?.fileHash ?? ""),
|
|
15
|
+
queryFn: ({ signal }) => api.fileExists(parsed?.fileHash ?? "", { signal }),
|
|
16
|
+
enabled: sessionReady && parsed !== null,
|
|
17
|
+
// A content-addressed row does not change. Its VARIANTS do, once, when the
|
|
18
|
+
// background task finishes — the upload flow waits for that itself, so a
|
|
19
|
+
// refetch here would only re-ask a settled question.
|
|
20
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
21
|
+
retry: false,
|
|
22
|
+
select: (data) => data.exists && data.type === "image" && data.file !== null
|
|
23
|
+
? data.file
|
|
24
|
+
: null,
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
state: loadStateFromQuery(query),
|
|
28
|
+
isFetching: query.isFetching,
|
|
29
|
+
refetch: () => void query.refetch(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=queries.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queries.js","sourceRoot":"","sources":["../../src/model/queries.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAGzE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AA4BxC,MAAM,UAAU,SAAS,CAAC,GAA8B;IACtD,MAAM,GAAG,GAAG,SAAS,EAAE,CAAC;IACxB,wEAAwE;IACxE,yEAAyE;IACzE,iDAAiD;IACjD,MAAM,YAAY,GAAG,qBAAqB,EAAE,CAAC;IAC7C,MAAM,MAAM,GAAG,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IAE3E,MAAM,KAAK,GAAG,QAAQ,CAAC;QACrB,QAAQ,EAAE,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,IAAI,EAAE,CAAC;QACrD,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,EAAkC,EAAE,CACtD,GAAG,CAAC,UAAU,CAAC,MAAM,EAAE,QAAQ,IAAI,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC;QACpD,OAAO,EAAE,YAAY,IAAI,MAAM,KAAK,IAAI;QACxC,2EAA2E;QAC3E,yEAAyE;QACzE,qDAAqD;QACrD,SAAS,EAAE,MAAM,CAAC,iBAAiB;QACnC,KAAK,EAAE,KAAK;QACZ,MAAM,EAAE,CAAC,IAAI,EAAmB,EAAE,CAChC,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,IAAI,KAAK,OAAO,IAAI,IAAI,CAAC,IAAI,KAAK,IAAI;YACxD,CAAC,CAAE,IAAI,CAAC,IAAiB;YACzB,CAAC,CAAC,IAAI;KACX,CAAC,CAAC;IAEH,OAAO;QACL,KAAK,EAAE,kBAAkB,CAAC,KAAK,CAAC;QAChC,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,OAAO,EAAE,GAAG,EAAE,CAAC,KAAK,KAAK,CAAC,OAAO,EAAE;KACpC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Namespaced TanStack Query keys (frontend-standard §2). Everything under the
|
|
3
|
+
* `"cdn"` root so a host can invalidate the whole module or match a single
|
|
4
|
+
* read.
|
|
5
|
+
*
|
|
6
|
+
* There is exactly one cached read in this pair, and it is keyed on the CONTENT
|
|
7
|
+
* HASH rather than on the reference string. Two references that differ only in
|
|
8
|
+
* asset type (`avatar/<h>` and `product/<h>`) resolve through the same
|
|
9
|
+
* `file/exists/` call and must not fetch twice; and because the hash IS the
|
|
10
|
+
* identity of the bytes, a key built from it can never go stale in the way a
|
|
11
|
+
* mutable row's key can. Uploads are mutations and cache nothing.
|
|
12
|
+
*/
|
|
13
|
+
export declare const cdnQueryKeys: {
|
|
14
|
+
readonly all: readonly ["cdn"];
|
|
15
|
+
/** The owner-scoped `file/exists/` read for one content hash. */
|
|
16
|
+
exists(fileHash: string): readonly ["cdn", "exists", string];
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=queryKeys.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queryKeys.d.ts","sourceRoot":"","sources":["../../src/model/queryKeys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,eAAO,MAAM,YAAY,EAAE;IACzB,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC;IAC/B,iEAAiE;IACjE,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC,KAAK,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;CAI9D,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Namespaced TanStack Query keys (frontend-standard §2). Everything under the
|
|
3
|
+
* `"cdn"` root so a host can invalidate the whole module or match a single
|
|
4
|
+
* read.
|
|
5
|
+
*
|
|
6
|
+
* There is exactly one cached read in this pair, and it is keyed on the CONTENT
|
|
7
|
+
* HASH rather than on the reference string. Two references that differ only in
|
|
8
|
+
* asset type (`avatar/<h>` and `product/<h>`) resolve through the same
|
|
9
|
+
* `file/exists/` call and must not fetch twice; and because the hash IS the
|
|
10
|
+
* identity of the bytes, a key built from it can never go stale in the way a
|
|
11
|
+
* mutable row's key can. Uploads are mutations and cache nothing.
|
|
12
|
+
*/
|
|
13
|
+
const ROOT = "cdn";
|
|
14
|
+
export const cdnQueryKeys = {
|
|
15
|
+
all: [ROOT],
|
|
16
|
+
exists: (fileHash) => [ROOT, "exists", fileHash],
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=queryKeys.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queryKeys.js","sourceRoot":"","sources":["../../src/model/queryKeys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,IAAI,GAAG,KAAc,CAAC;AAE5B,MAAM,CAAC,MAAM,YAAY,GAIrB;IACF,GAAG,EAAE,CAAC,IAAI,CAAC;IACX,MAAM,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC;CACjD,CAAC"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<type>/<hash>` reference — the unit this pair hands out — and the one
|
|
3
|
+
* conversion between stapel-cdn's variant ladder and `@stapel/image`'s.
|
|
4
|
+
*
|
|
5
|
+
* `Profile.avatar` stores a reference. `Listing.images_draft` stores a list of
|
|
6
|
+
* them ("Opaque list of CDN image references", `stapel_listings/models.py`).
|
|
7
|
+
* Neither stores a URL, and this pair does not invent one: the reference is
|
|
8
|
+
* content-addressed and opaque, and the URLs live on the row the CDN returns.
|
|
9
|
+
*/
|
|
10
|
+
import type { StapelImage } from "@stapel/image";
|
|
11
|
+
import type { CdnImage, CdnRef, ParsedCdnRef } from "../api/types.js";
|
|
12
|
+
/** Build the reference for an asset type and a content hash. */
|
|
13
|
+
export declare function formatCdnRef(assetType: string, fileHash: string): CdnRef;
|
|
14
|
+
/**
|
|
15
|
+
* Split a reference into its halves, or `null` when the string is not one.
|
|
16
|
+
*
|
|
17
|
+
* Strict about the hash (64 lowercase hex — what the backend writes) and
|
|
18
|
+
* deliberately loose about the type, which is whatever this deployment put in
|
|
19
|
+
* `STAPEL_CDN["ASSET_TYPES"]` plus the hardcoded `product` of the general
|
|
20
|
+
* image endpoint. Rejecting an unfamiliar type here would refuse a host's own
|
|
21
|
+
* configuration.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseCdnRef(ref: CdnRef): ParsedCdnRef | null;
|
|
24
|
+
/**
|
|
25
|
+
* Convert an uploaded image row into the source-agnostic descriptor
|
|
26
|
+
* `@stapel/image`'s `<Image>` consumes.
|
|
27
|
+
*
|
|
28
|
+
* THE TWO CONTRACTS DISAGREE ON ONE FIELD AND THIS IS THE ONLY PLACE THAT
|
|
29
|
+
* KNOWS. stapel-cdn's `variants_meta[].tier` is an integer; `@stapel/image`
|
|
30
|
+
* reads the `stapel_core.media.dto` form, where `tier` is a decimal STRING
|
|
31
|
+
* with an `"original"` sentinel. Converting at the boundary costs one map and
|
|
32
|
+
* keeps both sides honest; teaching either one the other's shape would put a
|
|
33
|
+
* conditional in every renderer.
|
|
34
|
+
*
|
|
35
|
+
* `branch` is optional upstream and absent for thumbnail-class tiers; the
|
|
36
|
+
* renderer's contract wants an explicit `null` there, which is the same
|
|
37
|
+
* statement said out loud.
|
|
38
|
+
*
|
|
39
|
+
* The type import above is TYPE-ONLY and `@stapel/image` is an OPTIONAL peer:
|
|
40
|
+
* a host that renders its own images carries no dependency on it and this
|
|
41
|
+
* function still typechecks, because nothing of it survives to runtime.
|
|
42
|
+
*/
|
|
43
|
+
export declare function toStapelImage(image: CdnImage): StapelImage;
|
|
44
|
+
/**
|
|
45
|
+
* The reference for an uploaded image row.
|
|
46
|
+
*
|
|
47
|
+
* Reads `prefix` — the serializer's own `<type>/<hash>` — rather than
|
|
48
|
+
* rebuilding it from `type` and `file_hash`, so a host that reconfigures the
|
|
49
|
+
* layout upstream does not get a reference this pair invented.
|
|
50
|
+
*/
|
|
51
|
+
export declare function refOf(image: {
|
|
52
|
+
readonly prefix: string;
|
|
53
|
+
}): CdnRef;
|
|
54
|
+
//# sourceMappingURL=refs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refs.d.ts","sourceRoot":"","sources":["../../src/model/refs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,WAAW,EAAe,MAAM,eAAe,CAAC;AAC9D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAItE,gEAAgE;AAChE,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAExE;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAO5D;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,QAAQ,GAAG,WAAW,CA2B1D;AAED;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAEhE"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const HASH = /^[0-9a-f]{64}$/;
|
|
2
|
+
/** Build the reference for an asset type and a content hash. */
|
|
3
|
+
export function formatCdnRef(assetType, fileHash) {
|
|
4
|
+
return `${assetType}/${fileHash}`;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Split a reference into its halves, or `null` when the string is not one.
|
|
8
|
+
*
|
|
9
|
+
* Strict about the hash (64 lowercase hex — what the backend writes) and
|
|
10
|
+
* deliberately loose about the type, which is whatever this deployment put in
|
|
11
|
+
* `STAPEL_CDN["ASSET_TYPES"]` plus the hardcoded `product` of the general
|
|
12
|
+
* image endpoint. Rejecting an unfamiliar type here would refuse a host's own
|
|
13
|
+
* configuration.
|
|
14
|
+
*/
|
|
15
|
+
export function parseCdnRef(ref) {
|
|
16
|
+
const slash = ref.indexOf("/");
|
|
17
|
+
if (slash <= 0)
|
|
18
|
+
return null;
|
|
19
|
+
const assetType = ref.slice(0, slash);
|
|
20
|
+
const fileHash = ref.slice(slash + 1);
|
|
21
|
+
if (!HASH.test(fileHash))
|
|
22
|
+
return null;
|
|
23
|
+
return { assetType, fileHash };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Convert an uploaded image row into the source-agnostic descriptor
|
|
27
|
+
* `@stapel/image`'s `<Image>` consumes.
|
|
28
|
+
*
|
|
29
|
+
* THE TWO CONTRACTS DISAGREE ON ONE FIELD AND THIS IS THE ONLY PLACE THAT
|
|
30
|
+
* KNOWS. stapel-cdn's `variants_meta[].tier` is an integer; `@stapel/image`
|
|
31
|
+
* reads the `stapel_core.media.dto` form, where `tier` is a decimal STRING
|
|
32
|
+
* with an `"original"` sentinel. Converting at the boundary costs one map and
|
|
33
|
+
* keeps both sides honest; teaching either one the other's shape would put a
|
|
34
|
+
* conditional in every renderer.
|
|
35
|
+
*
|
|
36
|
+
* `branch` is optional upstream and absent for thumbnail-class tiers; the
|
|
37
|
+
* renderer's contract wants an explicit `null` there, which is the same
|
|
38
|
+
* statement said out loud.
|
|
39
|
+
*
|
|
40
|
+
* The type import above is TYPE-ONLY and `@stapel/image` is an OPTIONAL peer:
|
|
41
|
+
* a host that renders its own images carries no dependency on it and this
|
|
42
|
+
* function still typechecks, because nothing of it survives to runtime.
|
|
43
|
+
*/
|
|
44
|
+
export function toStapelImage(image) {
|
|
45
|
+
const variants = image.variants_meta.map((variant) => ({
|
|
46
|
+
tier: String(variant.tier),
|
|
47
|
+
branch: variant.branch ?? null,
|
|
48
|
+
url: variant.url,
|
|
49
|
+
width: variant.width,
|
|
50
|
+
height: variant.height,
|
|
51
|
+
}));
|
|
52
|
+
const width = image.original_width;
|
|
53
|
+
const height = image.original_height;
|
|
54
|
+
return {
|
|
55
|
+
source: "cdn",
|
|
56
|
+
url: image.original_url,
|
|
57
|
+
// The CDN row carries the extension, not the MIME type, and the variants
|
|
58
|
+
// are all WebP regardless of what came in. Saying `null` is the true
|
|
59
|
+
// statement; guessing a MIME from the extension would be the wrong one.
|
|
60
|
+
mime: null,
|
|
61
|
+
width,
|
|
62
|
+
height,
|
|
63
|
+
aspect: height > 0 ? width / height : null,
|
|
64
|
+
square: width === height,
|
|
65
|
+
// stapel-cdn generates no inline blur placeholder. `<Image>` degrades to
|
|
66
|
+
// the aspect box, which is the layout-shift protection that actually
|
|
67
|
+
// matters here.
|
|
68
|
+
preview_b64: null,
|
|
69
|
+
variants,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The reference for an uploaded image row.
|
|
74
|
+
*
|
|
75
|
+
* Reads `prefix` — the serializer's own `<type>/<hash>` — rather than
|
|
76
|
+
* rebuilding it from `type` and `file_hash`, so a host that reconfigures the
|
|
77
|
+
* layout upstream does not get a reference this pair invented.
|
|
78
|
+
*/
|
|
79
|
+
export function refOf(image) {
|
|
80
|
+
return image.prefix;
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=refs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refs.js","sourceRoot":"","sources":["../../src/model/refs.ts"],"names":[],"mappings":"AAYA,MAAM,IAAI,GAAG,gBAAgB,CAAC;AAE9B,gEAAgE;AAChE,MAAM,UAAU,YAAY,CAAC,SAAiB,EAAE,QAAgB;IAC9D,OAAO,GAAG,SAAS,IAAI,QAAQ,EAAE,CAAC;AACpC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW,CAAC,GAAW;IACrC,MAAM,KAAK,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,KAAK,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;IACtC,MAAM,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;IACtC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAC;IACtC,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC;AACjC,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,aAAa,CAAC,KAAe;IAC3C,MAAM,QAAQ,GAAkB,KAAK,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QACpE,IAAI,EAAE,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC;QAC1B,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,IAAI;QAC9B,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,MAAM,EAAE,OAAO,CAAC,MAAM;KACvB,CAAC,CAAC,CAAC;IACJ,MAAM,KAAK,GAAG,KAAK,CAAC,cAAc,CAAC;IACnC,MAAM,MAAM,GAAG,KAAK,CAAC,eAAe,CAAC;IACrC,OAAO;QACL,MAAM,EAAE,KAAK;QACb,GAAG,EAAE,KAAK,CAAC,YAAY;QACvB,yEAAyE;QACzE,qEAAqE;QACrE,wEAAwE;QACxE,IAAI,EAAE,IAAI;QACV,KAAK;QACL,MAAM;QACN,MAAM,EAAE,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,IAAI;QAC1C,MAAM,EAAE,KAAK,KAAK,MAAM;QACxB,yEAAyE;QACzE,qEAAqE;QACrE,gBAAgB;QAChB,WAAW,EAAE,IAAI;QACjB,QAAQ;KACT,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,KAAK,CAAC,KAAkC;IACtD,OAAO,KAAK,CAAC,MAAM,CAAC;AACtB,CAAC"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CreateModuleRuntimeOptions, ModuleRuntime } from "@stapel/core";
|
|
2
|
+
import type { CdnApi } from "../api/cdnApi.js";
|
|
3
|
+
import type { CdnLimits, CdnLimitsOverride } from "./limits.js";
|
|
4
|
+
import type { CdnVariantWaitOptions } from "./upload.js";
|
|
5
|
+
/**
|
|
6
|
+
* The wired CDN runtime — core's `ModuleRuntime` bound to this pair's API,
|
|
7
|
+
* plus the two things an upload pair has and a plain read pair does not: the
|
|
8
|
+
* DEPLOYMENT's intake ceilings, and how long to wait for a variant ladder.
|
|
9
|
+
*
|
|
10
|
+
* Both are runtime configuration rather than module state, so they ride here
|
|
11
|
+
* instead of through a second provider — the same argument chat-react makes
|
|
12
|
+
* for its socket URL.
|
|
13
|
+
*/
|
|
14
|
+
export type CdnRuntime = ModuleRuntime<CdnApi> & {
|
|
15
|
+
readonly limits: CdnLimits;
|
|
16
|
+
readonly variants: CdnVariantWaitOptions | undefined;
|
|
17
|
+
};
|
|
18
|
+
export interface CreateCdnRuntimeOptions extends CreateModuleRuntimeOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Override the client-side mirror of `STAPEL_CDN`'s ceilings. Defaults to
|
|
21
|
+
* the library's own defaults — see `model/limits.ts` for why this is a knob
|
|
22
|
+
* and not a constant.
|
|
23
|
+
*/
|
|
24
|
+
readonly limits?: CdnLimitsOverride;
|
|
25
|
+
/** How long the flow waits for the variant ladder after a store. */
|
|
26
|
+
readonly variants?: CdnVariantWaitOptions;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* ```tsx
|
|
30
|
+
* const runtime = createCdnRuntime({ baseUrl: "/cdn/api/v1/" });
|
|
31
|
+
* <CdnProvider runtime={runtime}>{app}</CdnProvider>
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* NOT ANONYMOUS. Every endpoint this pair calls needs at least a guest
|
|
35
|
+
* identity (`IsNotAnonymousUser`), and the avatar intake and the dedup
|
|
36
|
+
* pre-check need a real session (`IsAuthenticated`). A storefront mounts this
|
|
37
|
+
* behind its member routes; the public catalogue never touches it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createCdnRuntime(options: CreateCdnRuntimeOptions): CdnRuntime;
|
|
40
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/model/runtime.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,0BAA0B,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE9E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE/C,OAAO,KAAK,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEzD;;;;;;;;GAQG;AACH,MAAM,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,GAAG;IAC/C,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,qBAAqB,GAAG,SAAS,CAAC;CACtD,CAAC;AAEF,MAAM,WAAW,uBAAwB,SAAQ,0BAA0B;IACzE;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,iBAAiB,CAAC;IACpC,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,CAAC,EAAE,qBAAqB,CAAC;CAC3C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,UAAU,CAO7E"}
|