canopycms 0.0.67 → 0.0.68-int.101
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/README.md +24 -17
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +17 -20
- package/dist/api/admin.d.ts +30 -9
- package/dist/api/admin.js +32 -2
- package/dist/api/assets.js +84 -76
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +14 -5
- package/dist/api/branch-status.d.ts +5 -5
- package/dist/api/branch-status.js +26 -6
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +7 -2
- package/dist/api/branch.d.ts +15 -2
- package/dist/api/branch.js +118 -92
- package/dist/api/client.d.ts +18 -6
- package/dist/api/client.js +79 -12
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +19 -1
- package/dist/api/github-sync.js +57 -10
- package/dist/api/index.d.ts +1 -1
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/asset-url.d.ts +22 -16
- package/dist/assets/asset-url.js +42 -25
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/assets/finalize.js +3 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +153 -0
- package/dist/assets/materialize.js +402 -0
- package/dist/assets/store-local.d.ts +19 -5
- package/dist/assets/store-local.js +69 -10
- package/dist/assets/store-s3.d.ts +38 -7
- package/dist/assets/store-s3.js +128 -22
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +31 -4
- package/dist/auth/file-based-auth-cache.js +7 -8
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.d.ts +7 -1
- package/dist/branch-health.js +8 -3
- package/dist/branch-metadata.d.ts +5 -5
- package/dist/branch-metadata.js +44 -36
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +240 -94
- package/dist/build/asset-refs.d.ts +93 -0
- package/dist/build/asset-refs.js +251 -0
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/asset-refs.d.ts +52 -0
- package/dist/cli/asset-refs.js +194 -0
- package/dist/cli/cli.d.ts +15 -1
- package/dist/cli/cli.js +5928 -2195
- package/dist/cli/configured-asset-store.d.ts +12 -0
- package/dist/cli/configured-asset-store.js +46 -0
- package/dist/cli/generate-ai-content.js +2185 -587
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +30 -27
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +23 -12
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/config/helpers.js +1 -2
- package/dist/config/schemas/config.d.ts +19 -32
- package/dist/config/schemas/config.js +9 -11
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +4 -20
- package/dist/config/schemas/url.js +6 -22
- package/dist/config/types.d.ts +30 -20
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/BranchManager.d.ts +7 -1
- package/dist/editor/BranchManager.js +29 -14
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +2 -6
- package/dist/editor/Editor.d.ts +4 -3
- package/dist/editor/Editor.js +32 -22
- package/dist/editor/FormRenderer.js +34 -18
- package/dist/editor/PreviewFrame.d.ts +24 -0
- package/dist/editor/PreviewFrame.js +140 -0
- package/dist/editor/admin/SystemHealthPanel.js +47 -7
- package/dist/editor/components/BranchesDrawer.d.ts +9 -0
- package/dist/editor/components/BranchesDrawer.js +4 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/context/AssetContext.d.ts +18 -35
- package/dist/editor/context/AssetContext.js +20 -29
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/editor-config.d.ts +1 -2
- package/dist/editor/editor-config.js +0 -23
- package/dist/editor/editor-utils.d.ts +24 -21
- package/dist/editor/editor-utils.js +75 -62
- package/dist/editor/fields/BlockField.d.ts +1 -0
- package/dist/editor/fields/BlockField.js +6 -4
- package/dist/editor/fields/CodeField.d.ts +1 -0
- package/dist/editor/fields/CodeField.js +2 -2
- package/dist/editor/fields/DateTimeField.d.ts +1 -0
- package/dist/editor/fields/DateTimeField.js +2 -2
- package/dist/editor/fields/FieldDescription.d.ts +16 -0
- package/dist/editor/fields/FieldDescription.js +11 -0
- package/dist/editor/fields/ImageField.d.ts +1 -0
- package/dist/editor/fields/ImageField.js +3 -2
- package/dist/editor/fields/InlineGroupField.js +4 -1
- package/dist/editor/fields/MarkdownField.d.ts +1 -0
- package/dist/editor/fields/MarkdownField.js +115 -20
- package/dist/editor/fields/NumberField.d.ts +1 -0
- package/dist/editor/fields/NumberField.js +2 -2
- package/dist/editor/fields/NumberListField.d.ts +1 -0
- package/dist/editor/fields/NumberListField.js +2 -2
- package/dist/editor/fields/ObjectField.d.ts +1 -0
- package/dist/editor/fields/ObjectField.js +5 -2
- package/dist/editor/fields/ReferenceField.d.ts +1 -0
- package/dist/editor/fields/ReferenceField.js +5 -4
- package/dist/editor/fields/SelectField.d.ts +1 -0
- package/dist/editor/fields/SelectField.js +2 -2
- package/dist/editor/fields/StringListField.d.ts +1 -0
- package/dist/editor/fields/StringListField.js +2 -2
- package/dist/editor/fields/TextField.d.ts +1 -0
- package/dist/editor/fields/TextField.js +2 -2
- package/dist/editor/fields/ToggleField.d.ts +1 -0
- package/dist/editor/fields/ToggleField.js +7 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
- package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
- package/dist/editor/fields/mdx-jsx-support.js +137 -0
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +52 -0
- package/dist/editor/hooks/useBranchActions.d.ts +13 -2
- package/dist/editor/hooks/useBranchActions.js +48 -17
- package/dist/editor/hooks/useBranchManager.d.ts +21 -0
- package/dist/editor/hooks/useBranchManager.js +204 -37
- package/dist/editor/hooks/useBranchesData.d.ts +6 -0
- package/dist/editor/hooks/useBranchesData.js +9 -3
- package/dist/editor/hooks/useCommentSystem.js +25 -6
- package/dist/editor/hooks/useDraftManager.d.ts +16 -1
- package/dist/editor/hooks/useDraftManager.js +193 -36
- package/dist/editor/hooks/useEntriesData.js +3 -3
- package/dist/editor/hooks/useEntryManager.d.ts +2 -1
- package/dist/editor/hooks/useEntryManager.js +34 -19
- package/dist/editor/media/AssetCard.d.ts +1 -1
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +7 -0
- package/dist/editor/media/editor-image-src.js +12 -0
- package/dist/editor/preview-asset-base.d.ts +8 -0
- package/dist/editor/preview-asset-base.js +13 -0
- package/dist/editor/preview-bridge.d.ts +9 -21
- package/dist/editor/preview-bridge.js +19 -139
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/editor/theme.js +2 -2
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +173 -6
- package/dist/git-manager.js +594 -76
- package/dist/github-service.d.ts +15 -0
- package/dist/github-service.js +19 -1
- package/dist/http/handler.js +37 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +10 -0
- package/dist/http/worker-not-ready.js +24 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +1 -4
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -2
- package/dist/paths/branch.js +16 -10
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/paths/validation.js +0 -2
- package/dist/preview.d.ts +8 -0
- package/dist/preview.js +7 -0
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +54 -31
- package/dist/schema/schema-store.js +77 -31
- package/dist/server.d.ts +64 -9
- package/dist/server.js +48 -8
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +197 -45
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-serialize.d.ts +5 -2
- package/dist/utils/content-serialize.js +98 -24
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +32 -0
- package/dist/utils/git.js +42 -0
- package/dist/utils/occ-json-write.js +10 -2
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +35 -5
- package/dist/utils/provisioning-lock.js +73 -12
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/sanitize-href.d.ts +8 -22
- package/dist/utils/sanitize-href.js +11 -28
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/utils/yaml-source-splice.d.ts +58 -0
- package/dist/utils/yaml-source-splice.js +515 -0
- package/dist/validation/entry-validator.js +9 -3
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.d.ts +8 -3
- package/dist/worker/cms-worker.js +38 -2
- package/dist/worker/git-sync.d.ts +30 -12
- package/dist/worker/git-sync.js +253 -58
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +95 -23
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +11 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +132 -0
- package/dist/worker/task-runner.js +99 -14
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +8 -2
package/dist/assets/store-s3.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* S3-backed AssetStore. Same bucket-prefix layout as LocalAssetStore (see
|
|
3
3
|
* keys.ts). Assumes an EXISTING content bucket (versioning/SSE/replication
|
|
4
|
-
* already configured by the site's CDK stack)
|
|
5
|
-
*
|
|
4
|
+
* already configured by the site's CDK stack). It reads and writes under the
|
|
5
|
+
* five asset prefixes, plus a caller's output prefix for `copyPublicObject`.
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import { DeleteObjectCommand, GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3';
|
|
8
|
+
import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, paginateListObjectsV2, } from '@aws-sdk/client-s3';
|
|
9
9
|
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
|
|
10
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
10
11
|
import { isHttpUrlOrSameOriginPath } from '../utils/sanitize-href.js';
|
|
11
12
|
import { ASSET_PREFIXES, createKeyBuilders } from './keys.js';
|
|
12
13
|
const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
13
14
|
const PRESIGN_EXPIRY_SECONDS = 15 * 60;
|
|
15
|
+
/** A presigned read is followed by the browser at once; it is never stored or cached. */
|
|
16
|
+
const PRESIGNED_READ_EXPIRY_SECONDS = 5 * 60;
|
|
14
17
|
function matchesAwsError(err, name, httpStatusCode) {
|
|
15
18
|
if (!(err instanceof Error))
|
|
16
19
|
return false;
|
|
@@ -18,7 +21,20 @@ function matchesAwsError(err, name, httpStatusCode) {
|
|
|
18
21
|
return shaped.name === name || shaped.$metadata?.httpStatusCode === httpStatusCode;
|
|
19
22
|
}
|
|
20
23
|
const isPreconditionFailed = (err) => matchesAwsError(err, 'PreconditionFailed', 412);
|
|
21
|
-
|
|
24
|
+
/**
|
|
25
|
+
* S3's 409 for a conditional write racing another write to the same key that is still in flight.
|
|
26
|
+
* Matched by name only: a 409 is also `OperationAborted`, which is not this.
|
|
27
|
+
*/
|
|
28
|
+
const isConditionalRequestConflict = (err) => err instanceof Error && err.name === 'ConditionalRequestConflict';
|
|
29
|
+
/** At most 4 attempts and under 1.75 s of waiting; a race still unresolved after that throws the 409. */
|
|
30
|
+
const CONFLICT_ATTEMPTS = 4;
|
|
31
|
+
const CONFLICT_BASE_DELAY_MS = 250;
|
|
32
|
+
/**
|
|
33
|
+
* A missing key, never a missing bucket: S3 answers both with 404, and only a GET's error code names
|
|
34
|
+
* which. A HEAD's 404 has no body, so `hasPublicObject` reads either as absent; a caller that must
|
|
35
|
+
* tell them apart follows with a GET (assets/materialize.ts does).
|
|
36
|
+
*/
|
|
37
|
+
const isNoSuchKey = (err) => matchesAwsError(err, 'NoSuchKey', 404) && err.name !== 'NoSuchBucket';
|
|
22
38
|
export class S3AssetStore {
|
|
23
39
|
constructor(options) {
|
|
24
40
|
this.capabilities = { directUpload: true };
|
|
@@ -35,6 +51,38 @@ export class S3AssetStore {
|
|
|
35
51
|
const prefixes = options.prefixes ?? ASSET_PREFIXES;
|
|
36
52
|
this.keys = createKeyBuilders(prefixes);
|
|
37
53
|
this.stagingPrefix = `${prefixes.staging}/`;
|
|
54
|
+
this.sleep =
|
|
55
|
+
options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Sends one create-only request (`IfNoneMatch: '*'`), so S3 answers a taken key with 412. A
|
|
59
|
+
* `ConditionalRequestConflict` is retried here, the only retry layer for it, so a caller with no
|
|
60
|
+
* retry of its own (the lazy transform Lambda, finalize) ends in one of the two results.
|
|
61
|
+
*
|
|
62
|
+
* With `retriedIsCreated`, a 412 after any error the SDK had already retried counts as
|
|
63
|
+
* `created`: one of those attempts may have committed and lost its response. Public objects use
|
|
64
|
+
* it; meta does not, since a meta `already-exists` sends finalize to read the stored winner.
|
|
65
|
+
*/
|
|
66
|
+
async createIfAbsent(send, options = {}) {
|
|
67
|
+
let sdkRetried = false;
|
|
68
|
+
for (let attempt = 1;; attempt++) {
|
|
69
|
+
try {
|
|
70
|
+
await send();
|
|
71
|
+
return 'created';
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
sdkRetried ||= (err.$metadata?.attempts ?? 1) > 1;
|
|
75
|
+
if (isPreconditionFailed(err)) {
|
|
76
|
+
return options.retriedIsCreated && sdkRetried ? 'created' : 'already-exists';
|
|
77
|
+
}
|
|
78
|
+
if (!isConditionalRequestConflict(err) || attempt >= CONFLICT_ATTEMPTS)
|
|
79
|
+
throw err;
|
|
80
|
+
await this.sleep(CONFLICT_BASE_DELAY_MS * 2 ** (attempt - 1) * (0.5 + Math.random() / 2));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
putIfAbsent(input, options = {}) {
|
|
85
|
+
return this.createIfAbsent(() => this.client.send(new PutObjectCommand({ ...input, Bucket: this.bucket, IfNoneMatch: '*' })), options);
|
|
38
86
|
}
|
|
39
87
|
/**
|
|
40
88
|
* Staging methods accept caller-influenced keys (finalize receives the key
|
|
@@ -90,14 +138,19 @@ export class S3AssetStore {
|
|
|
90
138
|
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
91
139
|
}
|
|
92
140
|
async putOriginal(input) {
|
|
93
|
-
|
|
94
|
-
Bucket: this.bucket,
|
|
141
|
+
return this.putIfAbsent({
|
|
95
142
|
Key: this.keys.originalKey(input.hash32, input.ext),
|
|
96
143
|
Body: input.data,
|
|
97
144
|
ContentType: input.contentType,
|
|
98
|
-
})
|
|
145
|
+
});
|
|
99
146
|
}
|
|
100
|
-
async readOriginal(hash32) {
|
|
147
|
+
async readOriginal(hash32, ext) {
|
|
148
|
+
if (ext !== undefined) {
|
|
149
|
+
const direct = await this.getObject(this.keys.originalKey(hash32, ext));
|
|
150
|
+
const data = await direct?.Body?.transformToByteArray();
|
|
151
|
+
if (data)
|
|
152
|
+
return { data, ext, contentType: direct?.ContentType };
|
|
153
|
+
}
|
|
101
154
|
const prefix = this.keys.originalPrefix(hash32);
|
|
102
155
|
const listed = await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, MaxKeys: 1 }));
|
|
103
156
|
const foundKey = listed.Contents?.[0]?.Key;
|
|
@@ -112,14 +165,45 @@ export class S3AssetStore {
|
|
|
112
165
|
return { data, ext: foundKey.slice(prefix.length), contentType: result.ContentType };
|
|
113
166
|
}
|
|
114
167
|
async putPublicObject(input) {
|
|
115
|
-
|
|
116
|
-
|
|
168
|
+
// `encodeURIComponent` writes a space as `%20`, which reads the same to any decoder;
|
|
169
|
+
// URLSearchParams writes `+`.
|
|
170
|
+
const tagging = Object.entries(input.tags ?? {})
|
|
171
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
172
|
+
.join('&');
|
|
173
|
+
return this.putIfAbsent({
|
|
117
174
|
Key: input.key,
|
|
118
175
|
Body: input.data,
|
|
119
176
|
ContentType: input.contentType,
|
|
120
177
|
ContentDisposition: input.contentDisposition,
|
|
121
178
|
CacheControl: input.cacheControl,
|
|
122
|
-
|
|
179
|
+
Tagging: tagging || undefined,
|
|
180
|
+
}, { retriedIsCreated: true });
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* A server-side CopyObject. S3 URL-decodes `CopySource`, and keys hold `=`, `,` and `:`, so each
|
|
184
|
+
* segment is encoded and the `/` between them is not. `MetadataDirective: 'COPY'` keeps the
|
|
185
|
+
* source's Content-Type, Cache-Control and Content-Disposition; `TaggingDirective: 'REPLACE'`
|
|
186
|
+
* with no `Tagging` leaves the copy untagged and needs no tagging permission, even from a
|
|
187
|
+
* lazy-tagged source; `COPY` would need `s3:GetObjectTagging` and `s3:PutObjectTagging`.
|
|
188
|
+
*/
|
|
189
|
+
async copyPublicObject(sourceKey, destKey) {
|
|
190
|
+
const copySource = `${this.bucket}/${sourceKey.split('/').map(encodeURIComponent).join('/')}`;
|
|
191
|
+
try {
|
|
192
|
+
return await this.createIfAbsent(() => this.client.send(new CopyObjectCommand({
|
|
193
|
+
Bucket: this.bucket,
|
|
194
|
+
Key: destKey,
|
|
195
|
+
CopySource: copySource,
|
|
196
|
+
IfNoneMatch: '*',
|
|
197
|
+
MetadataDirective: 'COPY',
|
|
198
|
+
TaggingDirective: 'REPLACE',
|
|
199
|
+
})), { retriedIsCreated: true });
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
// By name alone: a copy's error always carries its code, and a missing bucket is a 404 too.
|
|
203
|
+
if (err instanceof Error && err.name === 'NoSuchKey')
|
|
204
|
+
return 'source-missing';
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
123
207
|
}
|
|
124
208
|
async readPublicObject(key) {
|
|
125
209
|
const result = await this.getObject(key);
|
|
@@ -135,23 +219,45 @@ export class S3AssetStore {
|
|
|
135
219
|
cacheControl: result.CacheControl,
|
|
136
220
|
};
|
|
137
221
|
}
|
|
138
|
-
|
|
222
|
+
/**
|
|
223
|
+
* A HEAD. S3 answers a missing key with 403, not 404, unless the caller may `s3:ListBucket`,
|
|
224
|
+
* so a role without it sees an error here rather than `false`.
|
|
225
|
+
*/
|
|
226
|
+
async hasPublicObject(key) {
|
|
139
227
|
try {
|
|
140
|
-
await this.client.send(new
|
|
141
|
-
|
|
142
|
-
Key: this.keys.metaKey(hash32),
|
|
143
|
-
Body: JSON.stringify(meta),
|
|
144
|
-
ContentType: 'application/json',
|
|
145
|
-
IfNoneMatch: '*',
|
|
146
|
-
}));
|
|
147
|
-
return 'created';
|
|
228
|
+
await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
229
|
+
return true;
|
|
148
230
|
}
|
|
149
231
|
catch (err) {
|
|
150
|
-
if (
|
|
151
|
-
return
|
|
232
|
+
if (isNoSuchKey(err))
|
|
233
|
+
return false;
|
|
152
234
|
throw err;
|
|
153
235
|
}
|
|
154
236
|
}
|
|
237
|
+
async *listPublicObjectKeys(prefix) {
|
|
238
|
+
const pages = paginateListObjectsV2({ client: this.client }, { Bucket: this.bucket, Prefix: prefix });
|
|
239
|
+
for await (const page of pages) {
|
|
240
|
+
for (const object of page.Contents ?? []) {
|
|
241
|
+
if (object.Key)
|
|
242
|
+
yield object.Key;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** HEADs first: signing is local and succeeds for a missing key too. */
|
|
247
|
+
async presignPublicObjectRead(key) {
|
|
248
|
+
if (!(await this.hasPublicObject(key)))
|
|
249
|
+
return null;
|
|
250
|
+
return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), {
|
|
251
|
+
expiresIn: PRESIGNED_READ_EXPIRY_SECONDS,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
async putMetaIfAbsent(hash32, meta) {
|
|
255
|
+
return this.putIfAbsent({
|
|
256
|
+
Key: this.keys.metaKey(hash32),
|
|
257
|
+
Body: JSON.stringify(meta),
|
|
258
|
+
ContentType: 'application/json',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
155
261
|
async getMeta(hash32) {
|
|
156
262
|
const bytes = await this.getObjectBytes(this.keys.metaKey(hash32));
|
|
157
263
|
if (!bytes)
|
|
@@ -4,17 +4,16 @@
|
|
|
4
4
|
* NO imports (not even other files in this directory) so it can be imported
|
|
5
5
|
* from client bundles (via `assetUrl`/`assetSrcSet` in asset-url.ts, exported
|
|
6
6
|
* off the package's main entry) as well as from the server-only transform
|
|
7
|
-
* engine (transform.ts) and the
|
|
7
|
+
* engine (transform.ts) and the lazy transform Lambda, without
|
|
8
8
|
* ever pulling in node:crypto, sharp, or any other server-only dependency.
|
|
9
9
|
*
|
|
10
10
|
* `{directives}` is either the literal identity token (`orig`) or a
|
|
11
11
|
* comma-separated list of `key=value` pairs drawn from:
|
|
12
|
-
* w={int} output width
|
|
13
|
-
*
|
|
14
|
-
* time via `withoutEnlargement`, not here)
|
|
12
|
+
* w={int} output width, accepted per `TransformWidthPolicy` (upscaling
|
|
13
|
+
* is rejected at transform time via `withoutEnlargement`, not here)
|
|
15
14
|
* f={fmt} output format: webp | jpeg | png - when present, the URL's
|
|
16
15
|
* `{ext}` must equal it exactly
|
|
17
|
-
* q={int} quality
|
|
16
|
+
* q={int} quality, a multiple of 5 in [30, 95] under either policy
|
|
18
17
|
* c={rect} normalized crop rect `x:y:w:h`, four floats in [0,1] with
|
|
19
18
|
* x+w<=1 and y+h<=1, w>0 and h>0 (colon-separated - commas are
|
|
20
19
|
* the directive separator)
|
|
@@ -56,6 +55,18 @@ export type ParseTransformPathResult = ({
|
|
|
56
55
|
readonly ok: false;
|
|
57
56
|
readonly error: string;
|
|
58
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* Which widths a transform path accepts. The policy only narrows what parses; a width that parses
|
|
60
|
+
* under both has the same canonical spelling under both.
|
|
61
|
+
*
|
|
62
|
+
* - `allowlist`: multiples of 160 in [160, 4096], plus the small rungs 32, 48, 64, 96 and 128. For
|
|
63
|
+
* the opt-in anonymous on-demand path, where every accepted width is a new stored object an
|
|
64
|
+
* anonymous caller can mint.
|
|
65
|
+
* - `any`: any integer in [1, 8192]. For every path where the URLs come from site code or a
|
|
66
|
+
* signed-in editor: the authenticated route, `assetUrl`/`assetSrcSet`, the collector and the
|
|
67
|
+
* materializer.
|
|
68
|
+
*/
|
|
69
|
+
export type TransformWidthPolicy = 'allowlist' | 'any';
|
|
59
70
|
/**
|
|
60
71
|
* Decompression-bomb cap: the max `width * height` (post-decode pixel count)
|
|
61
72
|
* any raster the pipeline handles may have - checked both at upload finalize
|
|
@@ -63,10 +74,12 @@ export type ParseTransformPathResult = ({
|
|
|
63
74
|
* time (transform.ts, via sharp's `limitInputPixels`). Shared here (rather
|
|
64
75
|
* than duplicated in each server-only module) so it stays a single number,
|
|
65
76
|
* and because this file is dependency-free/isomorphic it can be imported by
|
|
66
|
-
* both without pulling sharp or file-type into anything.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
77
|
+
* both without pulling sharp or file-type into anything. 24 MP (a 6000x4000
|
|
78
|
+
* camera frame) is sized by measured peak memory: the worst case, a full-size
|
|
79
|
+
* WebP encode of an RGBA WebP source, peaks at 1246 MiB here and grows about
|
|
80
|
+
* 37 MiB per MP, so this is the largest cap that fits the 2048 MB CMS and
|
|
81
|
+
* transform Lambdas with allocator headroom. For an animation the cap bounds
|
|
82
|
+
* all decoded frames together.
|
|
70
83
|
*/
|
|
71
84
|
export declare const MAX_INPUT_PIXELS: number;
|
|
72
85
|
/**
|
|
@@ -87,8 +100,8 @@ export declare const MAX_INPUT_PIXELS: number;
|
|
|
87
100
|
* ignore) rather than by two constants that happen to match today.
|
|
88
101
|
*/
|
|
89
102
|
export declare const MAX_ANIMATED_FRAMES = 60;
|
|
90
|
-
/** True if `
|
|
91
|
-
export declare function isAllowedTransformWidth(width: number): boolean;
|
|
103
|
+
/** True if `policy` accepts `width`; see `TransformWidthPolicy`. */
|
|
104
|
+
export declare function isAllowedTransformWidth(width: number, policy: TransformWidthPolicy): boolean;
|
|
92
105
|
/**
|
|
93
106
|
* True when x, y, w, h describe a valid normalized crop rect: all four values
|
|
94
107
|
* finite and in [0,1], w and h strictly positive, and the rect fits within
|
|
@@ -98,16 +111,42 @@ export declare function isAllowedTransformWidth(width: number): boolean;
|
|
|
98
111
|
* identical constraints.
|
|
99
112
|
*/
|
|
100
113
|
export declare function isValidCropRect(x: number, y: number, w: number, h: number): boolean;
|
|
114
|
+
/**
|
|
115
|
+
* Round a crop rect to `CROP_PRECISION` decimals, shrinking `w`/`h` to the
|
|
116
|
+
* space left when rounding pushes `x+w` or `y+h` past 1 (0.66665 + 0.33335
|
|
117
|
+
* rounds to 0.6667 + 0.3334). The result can still be degenerate (a `w` or `h`
|
|
118
|
+
* that rounds to 0), which `isValidCropRect` rejects.
|
|
119
|
+
*/
|
|
120
|
+
export declare function roundCropRect(rect: CropRect): CropRect;
|
|
101
121
|
/**
|
|
102
122
|
* Parse the three path segments after the `assets/t/` prefix:
|
|
103
123
|
* `[directivesRaw, hash32, "{slug}.{ext}"]`. The raw route strips the
|
|
104
124
|
* `assets/t/` prefix and splits the remaining key by `/` before calling this.
|
|
105
125
|
*/
|
|
106
|
-
export declare function parseTransformPath(segments: readonly string[]): ParseTransformPathResult;
|
|
126
|
+
export declare function parseTransformPath(segments: readonly string[], policy: TransformWidthPolicy): ParseTransformPathResult;
|
|
107
127
|
/**
|
|
108
128
|
* Canonical string form of a directive set, so equivalent directive sets
|
|
109
129
|
* (different key order, different float formatting) always map to the same
|
|
110
|
-
* cache key. Order is fixed alphabetically by key: c, f, q, w
|
|
130
|
+
* cache key. Order is fixed alphabetically by key: c, f, q, w, and crop is
|
|
131
|
+
* rounded by `roundCropRect`.
|
|
111
132
|
*/
|
|
112
133
|
export declare function formatDirectives(directives: TransformDirectives): string;
|
|
134
|
+
export type CanonicalTransformPathResult = ({
|
|
135
|
+
readonly ok: true;
|
|
136
|
+
/** `{directives}/{hash32}/{slug}.{ext}` in canonical form, without the `assets/t/` prefix. */
|
|
137
|
+
readonly canonicalPath: string;
|
|
138
|
+
/** True when the requested segments already were `canonicalPath`, byte for byte. */
|
|
139
|
+
readonly isCanonical: boolean;
|
|
140
|
+
} & ParsedTransformPath) | {
|
|
141
|
+
readonly ok: false;
|
|
142
|
+
readonly error: string;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Parse transform-path segments and resolve them to the one spelling their
|
|
146
|
+
* output is stored under. The returned `directives` are parsed back from that
|
|
147
|
+
* canonical spelling, never taken from the request, so a transform computed
|
|
148
|
+
* from them always matches its key. A crop that rounds to zero extent has no
|
|
149
|
+
* canonical spelling and is rejected.
|
|
150
|
+
*/
|
|
151
|
+
export declare function canonicalizeTransformPath(segments: readonly string[], policy: TransformWidthPolicy): CanonicalTransformPathResult;
|
|
113
152
|
export {};
|
|
@@ -4,17 +4,16 @@
|
|
|
4
4
|
* NO imports (not even other files in this directory) so it can be imported
|
|
5
5
|
* from client bundles (via `assetUrl`/`assetSrcSet` in asset-url.ts, exported
|
|
6
6
|
* off the package's main entry) as well as from the server-only transform
|
|
7
|
-
* engine (transform.ts) and the
|
|
7
|
+
* engine (transform.ts) and the lazy transform Lambda, without
|
|
8
8
|
* ever pulling in node:crypto, sharp, or any other server-only dependency.
|
|
9
9
|
*
|
|
10
10
|
* `{directives}` is either the literal identity token (`orig`) or a
|
|
11
11
|
* comma-separated list of `key=value` pairs drawn from:
|
|
12
|
-
* w={int} output width
|
|
13
|
-
*
|
|
14
|
-
* time via `withoutEnlargement`, not here)
|
|
12
|
+
* w={int} output width, accepted per `TransformWidthPolicy` (upscaling
|
|
13
|
+
* is rejected at transform time via `withoutEnlargement`, not here)
|
|
15
14
|
* f={fmt} output format: webp | jpeg | png - when present, the URL's
|
|
16
15
|
* `{ext}` must equal it exactly
|
|
17
|
-
* q={int} quality
|
|
16
|
+
* q={int} quality, a multiple of 5 in [30, 95] under either policy
|
|
18
17
|
* c={rect} normalized crop rect `x:y:w:h`, four floats in [0,1] with
|
|
19
18
|
* x+w<=1 and y+h<=1, w>0 and h>0 (colon-separated - commas are
|
|
20
19
|
* the directive separator)
|
|
@@ -24,9 +23,11 @@
|
|
|
24
23
|
*/
|
|
25
24
|
/** The identity transform directive name - EXIF-strip only, no other change. */
|
|
26
25
|
export const IDENTITY_TRANSFORM_DIRECTIVE = 'orig';
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
26
|
+
const MIN_ALLOWLIST_WIDTH = 160;
|
|
27
|
+
const MAX_ALLOWLIST_WIDTH = 4096;
|
|
28
|
+
const ALLOWLIST_WIDTH_STEP = 160;
|
|
29
|
+
const SMALL_ALLOWLIST_WIDTHS = new Set([32, 48, 64, 96, 128]);
|
|
30
|
+
const MAX_ANY_WIDTH = 8192;
|
|
30
31
|
/**
|
|
31
32
|
* Decompression-bomb cap: the max `width * height` (post-decode pixel count)
|
|
32
33
|
* any raster the pipeline handles may have - checked both at upload finalize
|
|
@@ -34,12 +35,14 @@ const WIDTH_STEP = 160;
|
|
|
34
35
|
* time (transform.ts, via sharp's `limitInputPixels`). Shared here (rather
|
|
35
36
|
* than duplicated in each server-only module) so it stays a single number,
|
|
36
37
|
* and because this file is dependency-free/isomorphic it can be imported by
|
|
37
|
-
* both without pulling sharp or file-type into anything.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* both without pulling sharp or file-type into anything. 24 MP (a 6000x4000
|
|
39
|
+
* camera frame) is sized by measured peak memory: the worst case, a full-size
|
|
40
|
+
* WebP encode of an RGBA WebP source, peaks at 1246 MiB here and grows about
|
|
41
|
+
* 37 MiB per MP, so this is the largest cap that fits the 2048 MB CMS and
|
|
42
|
+
* transform Lambdas with allocator headroom. For an animation the cap bounds
|
|
43
|
+
* all decoded frames together.
|
|
41
44
|
*/
|
|
42
|
-
export const MAX_INPUT_PIXELS =
|
|
45
|
+
export const MAX_INPUT_PIXELS = 6000 * 4000;
|
|
43
46
|
/**
|
|
44
47
|
* Cap on decoded animated frames (GIF/WebP) - without this, `{ animated: true }`
|
|
45
48
|
* (sharp's `pages: -1`) decodes every frame of a maliciously-crafted
|
|
@@ -61,12 +64,17 @@ export const MAX_ANIMATED_FRAMES = 60;
|
|
|
61
64
|
// Quality is allowlisted (multiples of 5 in [30, 95] - 14 values) for the
|
|
62
65
|
// same cache-stuffing reason as width: every accepted directive combination
|
|
63
66
|
// becomes a stored cache object in prod, so unbounded q would multiply the
|
|
64
|
-
// per-asset variant space by 100. Crop
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
+
// per-asset variant space by 100. Crop stays effectively unbounded (editor
|
|
68
|
+
// rects need float precision); the public path answers it by computing nothing
|
|
69
|
+
// (ARCHITECTURE.md, "Why transform by URL directive and materialize at release?").
|
|
67
70
|
const MIN_QUALITY = 30;
|
|
68
71
|
const MAX_QUALITY = 95;
|
|
69
72
|
const QUALITY_STEP = 5;
|
|
73
|
+
/**
|
|
74
|
+
* Decimal places a crop value keeps in its canonical form. The editor rounds a
|
|
75
|
+
* new crop to this (editor/media/crop-math.ts), so a crop the editor stores is
|
|
76
|
+
* already canonical; a URL carrying more decimals canonicalizes to the rounded rect.
|
|
77
|
+
*/
|
|
70
78
|
const CROP_PRECISION = 4;
|
|
71
79
|
const HASH32_RE = /^[a-f0-9]{32}$/;
|
|
72
80
|
const SLUG_RE = /^[a-z0-9-]+$/;
|
|
@@ -85,15 +93,22 @@ const KNOWN_DIRECTIVE_KEYS = new Set(['w', 'f', 'q', 'c']);
|
|
|
85
93
|
function isOutputFormat(value) {
|
|
86
94
|
return value === 'webp' || value === 'jpeg' || value === 'png';
|
|
87
95
|
}
|
|
88
|
-
/** True if `
|
|
89
|
-
export function isAllowedTransformWidth(width) {
|
|
90
|
-
|
|
96
|
+
/** True if `policy` accepts `width`; see `TransformWidthPolicy`. */
|
|
97
|
+
export function isAllowedTransformWidth(width, policy) {
|
|
98
|
+
if (!Number.isInteger(width))
|
|
99
|
+
return false;
|
|
100
|
+
if (policy === 'any')
|
|
101
|
+
return width >= 1 && width <= MAX_ANY_WIDTH;
|
|
102
|
+
return (SMALL_ALLOWLIST_WIDTHS.has(width) ||
|
|
103
|
+
(width >= MIN_ALLOWLIST_WIDTH &&
|
|
104
|
+
width <= MAX_ALLOWLIST_WIDTH &&
|
|
105
|
+
width % ALLOWLIST_WIDTH_STEP === 0));
|
|
91
106
|
}
|
|
92
|
-
function parseWidth(value) {
|
|
107
|
+
function parseWidth(value, policy) {
|
|
93
108
|
if (!POSITIVE_INT_RE.test(value))
|
|
94
109
|
return null;
|
|
95
110
|
const n = Number(value);
|
|
96
|
-
return isAllowedTransformWidth(n) ? n : null;
|
|
111
|
+
return isAllowedTransformWidth(n, policy) ? n : null;
|
|
97
112
|
}
|
|
98
113
|
/** True if `quality` is on the allowlist: a multiple of 5 in [30, 95]. */
|
|
99
114
|
function isAllowedTransformQuality(quality) {
|
|
@@ -145,6 +160,27 @@ function parseCrop(value) {
|
|
|
145
160
|
return null;
|
|
146
161
|
return { x, y, w, h };
|
|
147
162
|
}
|
|
163
|
+
function roundToCropPrecision(n) {
|
|
164
|
+
const factor = 10 ** CROP_PRECISION;
|
|
165
|
+
return Math.round(n * factor) / factor;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Round a crop rect to `CROP_PRECISION` decimals, shrinking `w`/`h` to the
|
|
169
|
+
* space left when rounding pushes `x+w` or `y+h` past 1 (0.66665 + 0.33335
|
|
170
|
+
* rounds to 0.6667 + 0.3334). The result can still be degenerate (a `w` or `h`
|
|
171
|
+
* that rounds to 0), which `isValidCropRect` rejects.
|
|
172
|
+
*/
|
|
173
|
+
export function roundCropRect(rect) {
|
|
174
|
+
const x = roundToCropPrecision(rect.x);
|
|
175
|
+
const y = roundToCropPrecision(rect.y);
|
|
176
|
+
let w = roundToCropPrecision(rect.w);
|
|
177
|
+
let h = roundToCropPrecision(rect.h);
|
|
178
|
+
if (x + w > 1)
|
|
179
|
+
w = roundToCropPrecision(1 - x);
|
|
180
|
+
if (y + h > 1)
|
|
181
|
+
h = roundToCropPrecision(1 - y);
|
|
182
|
+
return { x, y, w, h };
|
|
183
|
+
}
|
|
148
184
|
function formatUnitFloat(n) {
|
|
149
185
|
return n.toFixed(CROP_PRECISION);
|
|
150
186
|
}
|
|
@@ -156,7 +192,7 @@ function err(error) {
|
|
|
156
192
|
* `assets/t/` and the hash32 segment) into a `TransformDirectives`, or
|
|
157
193
|
* `orig` into the identity directive.
|
|
158
194
|
*/
|
|
159
|
-
function parseDirectivesString(raw) {
|
|
195
|
+
function parseDirectivesString(raw, policy) {
|
|
160
196
|
if (raw === IDENTITY_TRANSFORM_DIRECTIVE) {
|
|
161
197
|
return { ok: true, directives: { identity: true } };
|
|
162
198
|
}
|
|
@@ -187,7 +223,7 @@ function parseDirectivesString(raw) {
|
|
|
187
223
|
seen.add(key);
|
|
188
224
|
switch (key) {
|
|
189
225
|
case 'w': {
|
|
190
|
-
const w = parseWidth(value);
|
|
226
|
+
const w = parseWidth(value, policy);
|
|
191
227
|
if (w === null)
|
|
192
228
|
return err(`Invalid width: '${value}'`);
|
|
193
229
|
width = w;
|
|
@@ -222,7 +258,7 @@ function parseDirectivesString(raw) {
|
|
|
222
258
|
* `[directivesRaw, hash32, "{slug}.{ext}"]`. The raw route strips the
|
|
223
259
|
* `assets/t/` prefix and splits the remaining key by `/` before calling this.
|
|
224
260
|
*/
|
|
225
|
-
export function parseTransformPath(segments) {
|
|
261
|
+
export function parseTransformPath(segments, policy) {
|
|
226
262
|
if (segments.length !== 3) {
|
|
227
263
|
return err(`Expected 3 path segments (directives/hash32/slug.ext), got ${segments.length}`);
|
|
228
264
|
}
|
|
@@ -242,7 +278,7 @@ export function parseTransformPath(segments) {
|
|
|
242
278
|
if (!EXT_RE.test(ext)) {
|
|
243
279
|
return err(`Invalid ext: '${ext}'`);
|
|
244
280
|
}
|
|
245
|
-
const parsedDirectives = parseDirectivesString(directivesRaw);
|
|
281
|
+
const parsedDirectives = parseDirectivesString(directivesRaw, policy);
|
|
246
282
|
if (!parsedDirectives.ok) {
|
|
247
283
|
return parsedDirectives;
|
|
248
284
|
}
|
|
@@ -255,7 +291,8 @@ export function parseTransformPath(segments) {
|
|
|
255
291
|
/**
|
|
256
292
|
* Canonical string form of a directive set, so equivalent directive sets
|
|
257
293
|
* (different key order, different float formatting) always map to the same
|
|
258
|
-
* cache key. Order is fixed alphabetically by key: c, f, q, w
|
|
294
|
+
* cache key. Order is fixed alphabetically by key: c, f, q, w, and crop is
|
|
295
|
+
* rounded by `roundCropRect`.
|
|
259
296
|
*/
|
|
260
297
|
export function formatDirectives(directives) {
|
|
261
298
|
if (directives.identity) {
|
|
@@ -263,7 +300,7 @@ export function formatDirectives(directives) {
|
|
|
263
300
|
}
|
|
264
301
|
const parts = [];
|
|
265
302
|
if (directives.crop) {
|
|
266
|
-
const { x, y, w, h } = directives.crop;
|
|
303
|
+
const { x, y, w, h } = roundCropRect(directives.crop);
|
|
267
304
|
parts.push(`c=${formatUnitFloat(x)}:${formatUnitFloat(y)}:${formatUnitFloat(w)}:${formatUnitFloat(h)}`);
|
|
268
305
|
}
|
|
269
306
|
if (directives.format !== undefined) {
|
|
@@ -282,3 +319,26 @@ export function formatDirectives(directives) {
|
|
|
282
319
|
// empty directives segment.
|
|
283
320
|
return parts.length > 0 ? parts.join(',') : IDENTITY_TRANSFORM_DIRECTIVE;
|
|
284
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* Parse transform-path segments and resolve them to the one spelling their
|
|
324
|
+
* output is stored under. The returned `directives` are parsed back from that
|
|
325
|
+
* canonical spelling, never taken from the request, so a transform computed
|
|
326
|
+
* from them always matches its key. A crop that rounds to zero extent has no
|
|
327
|
+
* canonical spelling and is rejected.
|
|
328
|
+
*/
|
|
329
|
+
export function canonicalizeTransformPath(segments, policy) {
|
|
330
|
+
const parsed = parseTransformPath(segments, policy);
|
|
331
|
+
if (!parsed.ok)
|
|
332
|
+
return parsed;
|
|
333
|
+
const canonicalSegments = [
|
|
334
|
+
formatDirectives(parsed.directives),
|
|
335
|
+
parsed.hash32,
|
|
336
|
+
`${parsed.slug}.${parsed.ext}`,
|
|
337
|
+
];
|
|
338
|
+
const canonical = parseTransformPath(canonicalSegments, policy);
|
|
339
|
+
if (!canonical.ok) {
|
|
340
|
+
return err(`No valid form at ${CROP_PRECISION} crop decimals: ${canonical.error}`);
|
|
341
|
+
}
|
|
342
|
+
const canonicalPath = canonicalSegments.join('/');
|
|
343
|
+
return { ...canonical, canonicalPath, isCanonical: canonicalPath === segments.join('/') };
|
|
344
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The shared transform engine: applies a parsed `TransformDirectives` to source image bytes
|
|
3
3
|
* with sharp. Server-only - never import this from client/editor code (kept in its own file,
|
|
4
|
-
* separate from the dependency-free transform-directives.ts).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* separate from the dependency-free transform-directives.ts). Its one caller here is
|
|
5
|
+
* `storeTransform` (materialize.ts), which the raw route, `materialize-assets` and the lazy
|
|
6
|
+
* transform Lambda share.
|
|
7
7
|
*
|
|
8
8
|
* sharp is imported for its TYPES only and loaded on first use through `loadSharp()`
|
|
9
9
|
* (sharp-loader.ts), so importing this module never loads libvips - see sharp-loader.ts for
|
package/dist/assets/transform.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The shared transform engine: applies a parsed `TransformDirectives` to source image bytes
|
|
3
3
|
* with sharp. Server-only - never import this from client/editor code (kept in its own file,
|
|
4
|
-
* separate from the dependency-free transform-directives.ts).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* separate from the dependency-free transform-directives.ts). Its one caller here is
|
|
5
|
+
* `storeTransform` (materialize.ts), which the raw route, `materialize-assets` and the lazy
|
|
6
|
+
* transform Lambda share.
|
|
7
7
|
*
|
|
8
8
|
* sharp is imported for its TYPES only and loaded on first use through `loadSharp()`
|
|
9
9
|
* (sharp-loader.ts), so importing this module never loads libvips - see sharp-loader.ts for
|
|
@@ -27,7 +27,12 @@ import { loadSharp } from './sharp-loader.js';
|
|
|
27
27
|
import { MAX_ANIMATED_FRAMES, MAX_INPUT_PIXELS, } from './transform-directives.js';
|
|
28
28
|
/** Raster formats the transform engine accepts as input. svg/pdf never reach here - they're served statically. */
|
|
29
29
|
const ALLOWED_INPUT_EXTS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif']);
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Cap on a resize's encoded output, against cache-stuffing with giant re-encodes. An identity
|
|
32
|
+
* (`orig`) is exempt: an asset has one, its input is bounded by the upload and pixel caps, and a
|
|
33
|
+
* noisy PNG over about 10 MiB re-encodes to at least its own size, so the cap would refuse an
|
|
34
|
+
* ordinary upload's `orig` and fail the release that references it.
|
|
35
|
+
*/
|
|
31
36
|
const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
32
37
|
const CONTENT_TYPE_BY_FORMAT = {
|
|
33
38
|
webp: 'image/webp',
|
|
@@ -154,9 +159,9 @@ export async function applyTransform(input, directives) {
|
|
|
154
159
|
// `pages` value that EXCEEDS the source's actual page count makes sharp
|
|
155
160
|
// throw ("bad page number") rather than clamping, so `MAX_ANIMATED_FRAMES`
|
|
156
161
|
// can only be applied as `Math.min(totalPages, MAX_ANIMATED_FRAMES)`.
|
|
157
|
-
// `limitInputPixels`
|
|
158
|
-
//
|
|
159
|
-
//
|
|
162
|
+
// `limitInputPixels` on this probe counts one frame, so an oversized still
|
|
163
|
+
// throws here; the load below counts every frame it reads, so an over-cap
|
|
164
|
+
// animation throws there (measured at the cap: at most about one frame's buffer).
|
|
160
165
|
const probeMeta = await sharp(input.data, { limitInputPixels: MAX_INPUT_PIXELS }).metadata();
|
|
161
166
|
const totalPages = probeMeta.pages ?? 1;
|
|
162
167
|
const pagesToRead = Math.min(totalPages, MAX_ANIMATED_FRAMES);
|
|
@@ -178,7 +183,7 @@ export async function applyTransform(input, directives) {
|
|
|
178
183
|
? encode(pipeline, format, quality)
|
|
179
184
|
: encodeSourceFormat(pipeline, sourceExt, quality);
|
|
180
185
|
const data = await pipeline.toBuffer();
|
|
181
|
-
if (data.byteLength > MAX_OUTPUT_BYTES) {
|
|
186
|
+
if (!directives.identity && data.byteLength > MAX_OUTPUT_BYTES) {
|
|
182
187
|
return {
|
|
183
188
|
ok: false,
|
|
184
189
|
status: 413,
|