canopycms 0.0.67 → 0.0.68-int.100
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 +21 -14
- 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 +111 -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 +10 -1
- package/dist/api/github-sync.js +20 -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/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +122 -0
- package/dist/assets/materialize.js +305 -0
- package/dist/assets/store-local.d.ts +4 -0
- package/dist/assets/store-local.js +11 -0
- package/dist/assets/store-s3.d.ts +10 -1
- package/dist/assets/store-s3.js +55 -3
- 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 +14 -1
- 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 +30 -0
- package/dist/cli/asset-refs.js +168 -0
- package/dist/cli/cli.d.ts +2 -1
- package/dist/cli/cli.js +4843 -1870
- package/dist/cli/generate-ai-content.js +1584 -444
- 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/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 +24 -20
- package/dist/editor/FormRenderer.js +34 -18
- 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 +11 -0
- package/dist/editor/hooks/useBranchManager.js +80 -2
- 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 +8 -1
- package/dist/editor/preview-bridge.js +15 -2
- 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 +8 -0
- package/dist/github-service.js +5 -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/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 +24 -9
- package/dist/server.js +22 -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-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/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 +39 -4
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ you misunderstood, which is why it is worth resolving the ref rather than guessi
|
|
|
45
45
|
|
|
46
46
|
## Branch roots
|
|
47
47
|
|
|
48
|
-
- Workspaces resolve per mode: `prod` uses `$CANOPYCMS_WORKSPACE_ROOT/content-branches` (default: `/mnt/efs/
|
|
48
|
+
- Workspaces resolve per mode: `prod` uses `$CANOPYCMS_WORKSPACE_ROOT/content-branches` (default: `/mnt/efs/content-branches`), `dev` uses `.canopy-dev/content-branches/<branch>`.
|
|
49
49
|
- For `prod` mode, you must set `defaultRemoteUrl`. For `dev`, `defaultRemoteUrl` is **optional** - if omitted, a local remote is auto-created at `.canopy-dev/remote.git`.
|
|
50
50
|
- Optionally configure `defaultRemoteName` (default: `origin`) and `defaultBaseBranch` (default: `main`).
|
|
51
51
|
- Git author identity is required for `prod` mode: set `gitBotAuthorName` and `gitBotAuthorEmail` so bot commits can be created reliably.
|
|
@@ -104,6 +104,7 @@ export default defineCanopyConfig({
|
|
|
104
104
|
colors: { brand: '#4f46e5' },
|
|
105
105
|
},
|
|
106
106
|
// previewBase: { 'content/posts': '/blog' }, // optional overrides
|
|
107
|
+
// previewPrefix: '/preview', // optional, in front of every preview URL
|
|
107
108
|
},
|
|
108
109
|
// For prod mode, defaultRemoteUrl is required.
|
|
109
110
|
// For dev, it's optional - if omitted, uses auto-initialized local remote at .canopy-dev/remote.git
|
|
@@ -323,7 +324,7 @@ name) works, but leaves the entry's real `urlPath` as
|
|
|
323
324
|
URL-derived surface then has to be told about one at a time. The example app in this repo used to
|
|
324
325
|
do that and no longer does.
|
|
325
326
|
|
|
326
|
-
Both methods return `{ data, path }`. `read` throws if the content is missing; `readByUrlPath` returns `null` instead. Pass a `branch` option when you want branch-specific data (e.g., for preview); otherwise it defaults to your
|
|
327
|
+
Both methods return `{ data, path }`. `read` throws if the content is missing; `readByUrlPath` returns `null` instead. Pass a `branch` option when you want branch-specific data (e.g., for preview); otherwise it defaults to the active branch: `defaultActiveBranch`, or when that is unset, git HEAD in `dev` and your base branch in `prod`. Any other branch must already exist and be readable by the current user, or it reads as not found. Both enforce the same branch/path access rules as the API handlers.
|
|
327
328
|
|
|
328
329
|
**Index entries and URL resolution**
|
|
329
330
|
|
|
@@ -387,7 +388,7 @@ import config from '../canopycms.config'
|
|
|
387
388
|
export default CanopyEditorPage(config)
|
|
388
389
|
```
|
|
389
390
|
|
|
390
|
-
The editor loads entries on the client from `/api/canopycms/[branch]/entries`, so server prefetch is optional. Use `
|
|
391
|
+
The editor loads entries on the client from `/api/canopycms/[branch]/entries`, so server prefetch is optional. Use `editor.previewBase` to override preview URLs.
|
|
391
392
|
|
|
392
393
|
6. **Theme it**
|
|
393
394
|
Wrap editor surfaces with `CanopyCMSProvider` to load Mantine styles and customize `brand`/`primary`/`neutral`/`accent` colors and color scheme. Pass `themeOptions` into `Editor` if desired.
|
|
@@ -401,7 +402,7 @@ _TODO_ show real examples of what to do
|
|
|
401
402
|
|
|
402
403
|
### Preview branch awareness
|
|
403
404
|
|
|
404
|
-
- When building preview URLs, include the current branch as a query param (e.g., `/?branch=feature-foo` or `/posts/hello?branch=feature-foo`) so SSR preview pages read from the same branch workspace the editor is editing. The `Editor` component appends the branch param
|
|
405
|
+
- When building preview URLs, include the current branch as a query param (e.g., `/?branch=feature-foo` or `/posts/hello?branch=feature-foo`) so SSR preview pages read from the same branch workspace the editor is editing. The `Editor` component appends the branch param to the preview URLs it builds; your page loaders should read `searchParams.branch` and pass it as the `branch` option to `getCanopy()`'s `read`/`readByUrlPath`.
|
|
405
406
|
- For public static builds, omit/ignore the branch param; this pattern is only for the editor/preview environment.
|
|
406
407
|
- Likewise, include `branch` in your editor route (e.g., `/edit?branch=feature-foo`) and have your editor page pass it to `<Editor>` so reloads/links preserve the selected branch. The `Editor` will also reflect branch switches back into the query string.
|
|
407
408
|
|
|
@@ -503,7 +504,7 @@ The `isLoading` object mirrors your data structure:
|
|
|
503
504
|
- **Auto-initialization**: If no `defaultRemoteUrl` is configured, CanopyCMS automatically creates a local remote at `.canopy-dev/remote.git` and seeds it with your current `baseBranch` (e.g., `main`). This allows fully local testing of branching and submission workflows without requiring an external GitHub remote.
|
|
504
505
|
- **Manual remote**: You can still provide an explicit `defaultRemoteUrl` to use a real remote or custom local path.
|
|
505
506
|
- Use `npx canopycms sync push` / `npx canopycms sync pull` to sync content between your working tree and the CMS.
|
|
506
|
-
- **`prod`**: EFS-backed roots under `$CANOPYCMS_WORKSPACE_ROOT` (default: `/mnt/efs
|
|
507
|
+
- **`prod`**: EFS-backed roots under `$CANOPYCMS_WORKSPACE_ROOT` (default: `/mnt/efs`). Requires `defaultRemoteUrl`.
|
|
507
508
|
|
|
508
509
|
Branch metadata lives in `.canopy-meta/branch.json`; registry in `branches.json` at the branches root. Content APIs resolve the workspace root from branch state + mode instead of relying on `process.cwd()`.
|
|
509
510
|
|
|
@@ -596,9 +597,9 @@ export default defineCanopyConfig({
|
|
|
596
597
|
|
|
597
598
|
## Assets and media
|
|
598
599
|
|
|
599
|
-
Uploaded images and PDFs go into a content-addressed asset store, and images are served
|
|
600
|
-
|
|
601
|
-
**S3** adapter ship today.
|
|
600
|
+
Uploaded images and PDFs go into a content-addressed asset store, and images are served as
|
|
601
|
+
resized derivatives stored before each release. Both the **local** adapter (for development)
|
|
602
|
+
and the **S3** adapter ship today.
|
|
602
603
|
|
|
603
604
|
```typescript
|
|
604
605
|
// canopycms.config.ts — production
|
|
@@ -616,8 +617,10 @@ media: { adapter: 's3', bucket: 'my-site-assets', region: 'us-east-1' }
|
|
|
616
617
|
immutable and deduplicated. A draft branch references its images immediately; publishing
|
|
617
618
|
needs no separate asset step.
|
|
618
619
|
- **Delivery** — images are served from `/assets/t/{directives}/…` URLs that resize, crop and
|
|
619
|
-
convert
|
|
620
|
-
|
|
620
|
+
convert, written before release by `canopycms collect-asset-refs <outDir>` then
|
|
621
|
+
`canopycms materialize-assets --refs <file>`, and cached immutably. Every `/assets/t/` URL a
|
|
622
|
+
page can request must appear as text in the build output. Build responsive markup with the
|
|
623
|
+
exported helpers, which are client-safe and take any width from 1 to 8192:
|
|
621
624
|
|
|
622
625
|
```typescript
|
|
623
626
|
import { assetUrl, assetSrcSet } from 'canopycms'
|
|
@@ -632,7 +635,9 @@ media: { adapter: 's3', bucket: 'my-site-assets', region: 'us-east-1' }
|
|
|
632
635
|
/>
|
|
633
636
|
```
|
|
634
637
|
|
|
635
|
-
|
|
638
|
+
Passing an `image` field value applies its stored crop (`opts.crop` overrides it). Its `width`
|
|
639
|
+
and `height` are the uncropped original's, so scale them by `crop.w` and `crop.h` for a cropped
|
|
640
|
+
value. SVGs and PDFs are served statically, with no transform.
|
|
636
641
|
|
|
637
642
|
- **Where `/assets` is mounted** — the URLs above are root-relative, because that is what gets
|
|
638
643
|
stored in content and content moves between branches and environments. If your renderer sees
|
|
@@ -669,8 +674,10 @@ to enable the interactive crop step, or `altOptional: true` for decorative image
|
|
|
669
674
|
> CanopyCMS" as visible to your whole editorial team.
|
|
670
675
|
|
|
671
676
|
For deployment, the `canopycms-cdk` package ships an `AssetSupport` construct that provisions
|
|
672
|
-
the bucket
|
|
673
|
-
|
|
674
|
-
|
|
677
|
+
the bucket and serves `/assets/*` from S3 only: an unmaterialized URL is a 403, and
|
|
678
|
+
`lazyPublicTransforms: true` transforms misses in a Lambda instead. Fuller documentation — every
|
|
679
|
+
config option, the release steps and their IAM grants, and the AWS wiring — is in the project
|
|
680
|
+
README's `#media-configuration` section, `docs/deploying-to-aws.md` and
|
|
681
|
+
`docs/adopter-migration.md`. Read them at the ref this
|
|
675
682
|
build came from, not at `main` — see
|
|
676
683
|
[Which documentation matches this build](#which-documentation-matches-this-build).
|
package/dist/ai/handler.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { ContentStore } from '../content-store.js';
|
|
14
14
|
import { BranchSchemaCache } from '../branch-schema-cache.js';
|
|
15
15
|
import { getErrorMessage } from '../utils/error.js';
|
|
16
|
+
import { workerNotReadyResponse } from '../http/worker-not-ready.js';
|
|
16
17
|
import { generateAIContent } from './generate.js';
|
|
17
18
|
import { resolveBranchRoot } from './resolve-branch.js';
|
|
18
19
|
/**
|
|
@@ -76,6 +77,13 @@ export function createAIContentHandler(options) {
|
|
|
76
77
|
catch (error) {
|
|
77
78
|
// Log the real error server-side; don't leak internals to unauthenticated callers
|
|
78
79
|
console.error('AI content handler error:', getErrorMessage(error));
|
|
80
|
+
const notReady = workerNotReadyResponse(error);
|
|
81
|
+
if (notReady) {
|
|
82
|
+
return new Response(JSON.stringify({ error: notReady.body.error }), {
|
|
83
|
+
status: notReady.status,
|
|
84
|
+
headers: { 'Content-Type': 'application/json', ...notReady.headers },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
79
87
|
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
|
80
88
|
status: 500,
|
|
81
89
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -17,19 +17,18 @@ import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../bran
|
|
|
17
17
|
// Same constants the reader uses, rather than a second copy of the two string
|
|
18
18
|
// literals.
|
|
19
19
|
import { BRANCH_META_DIR, BRANCH_META_FILE } from '../branch-metadata-file.js';
|
|
20
|
-
import { scanBranchHealth } from '../branch-health.js';
|
|
20
|
+
import { ORPHAN_YOUTH_THRESHOLD_MS, scanBranchHealth, } from '../branch-health.js';
|
|
21
|
+
import { formatDirStamp } from '../branch-provisioning.js';
|
|
21
22
|
import { ContentIdIndex } from '../content-id-index.js';
|
|
22
23
|
import { invalidateContentIndexesDurable } from '../content-index-generation.js';
|
|
23
24
|
import { getDefaultBranchBase, sanitizeBranchName } from '../paths/index.js';
|
|
24
25
|
import { withOccFileLock } from '../utils/occ-json-write.js';
|
|
25
|
-
import { tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
|
|
26
|
+
import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
|
|
26
27
|
import { withContentWriteLock, ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, } from '../utils/content-write-lock.js';
|
|
27
28
|
import { getErrorMessage, isNodeError, isNotFoundError } from '../utils/error.js';
|
|
28
29
|
import { defineEndpoint } from './route-builder.js';
|
|
29
30
|
/** [H1] A fresh (< 5 min old) init lock blocks purge -- provisioning may be running. */
|
|
30
31
|
const PROVISIONING_LOCK_FRESH_MS = 5 * 60_000;
|
|
31
|
-
/** An orphan dir younger than this may still be a clone in progress; corrupt dirs are exempt. */
|
|
32
|
-
const ORPHAN_YOUTH_THRESHOLD_MS = 15 * 60_000;
|
|
33
32
|
// Mirrors deleteTaskHandler's fileName pattern in admin.ts: conservative
|
|
34
33
|
// charset plus explicit traversal/dot-prefix refinements (the regex alone
|
|
35
34
|
// technically excludes '/' already, but the refinements keep intent explicit
|
|
@@ -40,13 +39,6 @@ const dirNameSchema = z
|
|
|
40
39
|
.refine((v) => !v.includes('..'), { message: 'dirName must not contain ..' })
|
|
41
40
|
.refine((v) => !v.startsWith('.'), { message: 'dirName must not be dot-prefixed' });
|
|
42
41
|
const branchDirParamsSchema = z.object({ dirName: dirNameSchema });
|
|
43
|
-
/** Compact UTC stamp for trash/archive names: `YYYYMMDDTHHMMSSZ` (no colons -- portability). */
|
|
44
|
-
function formatTrashStamp(date) {
|
|
45
|
-
return date
|
|
46
|
-
.toISOString()
|
|
47
|
-
.replace(/[-:]/g, '')
|
|
48
|
-
.replace(/\.\d{3}Z$/, 'Z');
|
|
49
|
-
}
|
|
50
42
|
/**
|
|
51
43
|
* Re-resolve dirName under baseRoot and enforce containment (belt-and-
|
|
52
44
|
* suspenders on top of the zod regex, matching deleteTaskHandler's pattern
|
|
@@ -147,12 +139,17 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
|
|
|
147
139
|
// True orphan (no branch.json, no load error) vs. corrupt-metadata
|
|
148
140
|
// (loadOnly threw). Only true orphans are subject to the youth rail below.
|
|
149
141
|
const isTrueOrphan = !hadLoadError;
|
|
150
|
-
// [H1] freshness rail: a fresh init lock means provisioning
|
|
151
|
-
// be in progress; a stale one is just
|
|
152
|
-
|
|
142
|
+
// [H1] freshness rail: a fresh init lock means provisioning, or the worker's
|
|
143
|
+
// sync of this branch, may genuinely be in progress; a stale one is just
|
|
144
|
+
// crash debris and does not block.
|
|
145
|
+
const lockPath = path.join(baseRoot, branchProvisioningLockName(params.dirName));
|
|
153
146
|
const lockStat = await fs.stat(lockPath).catch(() => null);
|
|
154
147
|
if (lockStat && Date.now() - lockStat.mtimeMs < PROVISIONING_LOCK_FRESH_MS) {
|
|
155
|
-
return {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
status: 409,
|
|
151
|
+
error: 'Provisioning, or the worker syncing this branch, may be in progress',
|
|
152
|
+
};
|
|
156
153
|
}
|
|
157
154
|
// Youth rail: a brand-new orphan dir may be a clone that just hasn't
|
|
158
155
|
// written branch.json yet. Corrupt-metadata dirs are exempt -- a
|
|
@@ -171,7 +168,7 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
|
|
|
171
168
|
// contention instead of hanging the request for ~5 minutes.
|
|
172
169
|
let releaseProvisioningLock;
|
|
173
170
|
try {
|
|
174
|
-
releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot,
|
|
171
|
+
releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(params.dirName));
|
|
175
172
|
}
|
|
176
173
|
catch (err) {
|
|
177
174
|
if (isNodeError(err) && err.code === 'ELOCKED') {
|
|
@@ -197,7 +194,7 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
|
|
|
197
194
|
// [C1] The timestamp lives in the NAME, not the dir's mtime: rename()
|
|
198
195
|
// preserves the original mtime, so mtime-based retention would delete
|
|
199
196
|
// a months-stale orphan's trash on the very first cleanup pass.
|
|
200
|
-
const trashName = `.trash-${params.dirName}-${
|
|
197
|
+
const trashName = `.trash-${params.dirName}-${formatDirStamp(new Date())}`;
|
|
201
198
|
const trashPath = path.join(baseRoot, trashName);
|
|
202
199
|
try {
|
|
203
200
|
await fs.rename(dirPath, trashPath);
|
|
@@ -279,7 +276,7 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
|
|
|
279
276
|
// Held until the `finally` below, AFTER save() completes.
|
|
280
277
|
let releaseProvisioningLock;
|
|
281
278
|
try {
|
|
282
|
-
releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot,
|
|
279
|
+
releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(params.dirName));
|
|
283
280
|
}
|
|
284
281
|
catch (err) {
|
|
285
282
|
if (isNodeError(err) && err.code === 'ELOCKED') {
|
|
@@ -301,7 +298,7 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
|
|
|
301
298
|
if (recheck === 'missing') {
|
|
302
299
|
throw new RepairPreconditionError('No metadata file -- use purge for orphans', 409);
|
|
303
300
|
}
|
|
304
|
-
const archivedName = `${BRANCH_META_FILE}.corrupt-${
|
|
301
|
+
const archivedName = `${BRANCH_META_FILE}.corrupt-${formatDirStamp(new Date())}`;
|
|
305
302
|
await fs.rename(branchJsonPath, path.join(dirPath, BRANCH_META_DIR, archivedName));
|
|
306
303
|
return archivedName;
|
|
307
304
|
});
|
|
@@ -444,7 +441,7 @@ const repairContentDuplicatesHandler = async (_gc, ctx, _req, params) => {
|
|
|
444
441
|
if (duplicates.length === 0) {
|
|
445
442
|
return { ok: false, status: 409, error: 'No duplicate content IDs found' };
|
|
446
443
|
}
|
|
447
|
-
const stamp =
|
|
444
|
+
const stamp = formatDirStamp(new Date());
|
|
448
445
|
try {
|
|
449
446
|
for (const dup of duplicates) {
|
|
450
447
|
const archivedAs = [];
|
package/dist/api/admin.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import type { ApiResponse } from './types.js';
|
|
10
10
|
import type { Task, QueueStats, CorruptTaskFile } from '../task-queue/cms-task-queue.js';
|
|
11
|
-
import type { WorkerStatusReport } from '../types.js';
|
|
11
|
+
import type { BuildIdentity, WorkerStatusReport } from '../types.js';
|
|
12
12
|
import type { OperatingMode } from '../operating-mode/index.js';
|
|
13
13
|
export type { BranchHealthResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse, } from './admin-branch-health.js';
|
|
14
14
|
type WorkerLivenessState = 'alive' | 'stale' | 'absent';
|
|
@@ -31,6 +31,27 @@ export interface AdminStatusData {
|
|
|
31
31
|
worker: WorkerLiveness;
|
|
32
32
|
workerStatus: WorkerStatusReport | null;
|
|
33
33
|
statusReadError?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Why this process cannot provision the settings workspace (groups and path rules), when it
|
|
36
|
+
* cannot. Its requests that resolve a user answer 503 meanwhile; bootstrap admins still reach
|
|
37
|
+
* /admin.
|
|
38
|
+
*/
|
|
39
|
+
settingsWorkspaceError?: string;
|
|
40
|
+
/** Build of the API process answering this request. */
|
|
41
|
+
build: BuildIdentity;
|
|
42
|
+
/** Whether `media` is configured; without it every upload returns 501. */
|
|
43
|
+
assetStore: {
|
|
44
|
+
configured: boolean;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Whether THIS API process can load sharp. When false, any editor image not yet stored (a new
|
|
48
|
+
* crop, thumbnail or preview size, transformed on demand here) fails, and raster uploads are
|
|
49
|
+
* accepted without decode validation. Speaks only for the process that answers the request.
|
|
50
|
+
*/
|
|
51
|
+
imageProcessing: {
|
|
52
|
+
available: boolean;
|
|
53
|
+
error?: string;
|
|
54
|
+
};
|
|
34
55
|
}
|
|
35
56
|
/** Response type for GET /admin/status */
|
|
36
57
|
export type AdminStatusResponse = ApiResponse<AdminStatusData>;
|
|
@@ -54,10 +75,10 @@ declare const listAdminTasksParamsSchema: z.ZodObject<{
|
|
|
54
75
|
status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
|
|
55
76
|
limit: z.ZodOptional<z.ZodNumber>;
|
|
56
77
|
}, "strip", z.ZodTypeAny, {
|
|
57
|
-
status: "
|
|
78
|
+
status: "failed" | "corrupt" | "pending" | "processing" | "completed";
|
|
58
79
|
limit?: number | undefined;
|
|
59
80
|
}, {
|
|
60
|
-
status: "
|
|
81
|
+
status: "failed" | "corrupt" | "pending" | "processing" | "completed";
|
|
61
82
|
limit?: number | undefined;
|
|
62
83
|
}>;
|
|
63
84
|
export type ListAdminTasksParams = z.infer<typeof listAdminTasksParamsSchema>;
|
|
@@ -65,10 +86,10 @@ declare const deleteTaskParamsSchema: z.ZodObject<{
|
|
|
65
86
|
status: z.ZodEnum<["pending", "failed", "corrupt"]>;
|
|
66
87
|
fileName: z.ZodEffects<z.ZodString, string, string>;
|
|
67
88
|
}, "strip", z.ZodTypeAny, {
|
|
68
|
-
status: "
|
|
89
|
+
status: "failed" | "corrupt" | "pending";
|
|
69
90
|
fileName: string;
|
|
70
91
|
}, {
|
|
71
|
-
status: "
|
|
92
|
+
status: "failed" | "corrupt" | "pending";
|
|
72
93
|
fileName: string;
|
|
73
94
|
}>;
|
|
74
95
|
export type DeleteTaskParams = z.infer<typeof deleteTaskParamsSchema>;
|
|
@@ -108,10 +129,10 @@ export declare const ADMIN_ROUTES: {
|
|
|
108
129
|
status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
|
|
109
130
|
limit: z.ZodOptional<z.ZodNumber>;
|
|
110
131
|
}, "strip", z.ZodTypeAny, {
|
|
111
|
-
status: "
|
|
132
|
+
status: "failed" | "corrupt" | "pending" | "processing" | "completed";
|
|
112
133
|
limit?: number | undefined;
|
|
113
134
|
}, {
|
|
114
|
-
status: "
|
|
135
|
+
status: "failed" | "corrupt" | "pending" | "processing" | "completed";
|
|
115
136
|
limit?: number | undefined;
|
|
116
137
|
}>, undefined, AdminTasksResponse>;
|
|
117
138
|
readonly retryTask: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
@@ -125,10 +146,10 @@ export declare const ADMIN_ROUTES: {
|
|
|
125
146
|
status: z.ZodEnum<["pending", "failed", "corrupt"]>;
|
|
126
147
|
fileName: z.ZodEffects<z.ZodString, string, string>;
|
|
127
148
|
}, "strip", z.ZodTypeAny, {
|
|
128
|
-
status: "
|
|
149
|
+
status: "failed" | "corrupt" | "pending";
|
|
129
150
|
fileName: string;
|
|
130
151
|
}, {
|
|
131
|
-
status: "
|
|
152
|
+
status: "failed" | "corrupt" | "pending";
|
|
132
153
|
fileName: string;
|
|
133
154
|
}>, undefined, AdminDeleteTaskResponse>;
|
|
134
155
|
};
|
package/dist/api/admin.js
CHANGED
|
@@ -12,7 +12,9 @@ import { getQueueStats, listTasks, listCorruptTaskFiles, requeueFailedTask, } fr
|
|
|
12
12
|
import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
|
|
13
13
|
import { WORKER_STATUS_FILE } from '../task-queue/worker-status.js';
|
|
14
14
|
import { defineEndpoint } from './route-builder.js';
|
|
15
|
-
import { getErrorMessage, isNotFoundError } from '../utils/error.js';
|
|
15
|
+
import { getErrorMessage, isNotFoundError, redactCredentials } from '../utils/error.js';
|
|
16
|
+
import { getBuildIdentity } from '../build-identity.js';
|
|
17
|
+
import { loadSharp } from '../assets/sharp-loader.js';
|
|
16
18
|
import { ADMIN_BRANCH_HEALTH_ROUTES } from './admin-branch-health.js';
|
|
17
19
|
/**
|
|
18
20
|
* 60_000 = DEFAULT_LOCK_STALE_MS in worker/cms-worker.ts, hardcoded rather
|
|
@@ -72,6 +74,15 @@ async function readWorkerStatus(taskDir) {
|
|
|
72
74
|
return { workerStatus: null, statusReadError: getErrorMessage(err) };
|
|
73
75
|
}
|
|
74
76
|
}
|
|
77
|
+
async function readSettingsWorkspaceError(ctx) {
|
|
78
|
+
try {
|
|
79
|
+
await ctx.services.getSettingsBranchRoot();
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return redactCredentials(getErrorMessage(err));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
75
86
|
/** Age (ms) of the oldest file in pending/, or undefined if empty/missing. */
|
|
76
87
|
async function getOldestPendingAgeMs(taskDir) {
|
|
77
88
|
const pendingDir = path.join(taskDir, 'pending');
|
|
@@ -123,14 +134,26 @@ const deleteTaskParamsSchema = z.object({
|
|
|
123
134
|
.regex(/^[A-Za-z0-9._-]{1,120}\.json$/)
|
|
124
135
|
.refine((v) => !v.includes('..'), { message: 'fileName must not contain ..' }),
|
|
125
136
|
});
|
|
137
|
+
/** Loads sharp in this process; a failure is reported, never thrown. */
|
|
138
|
+
async function probeImageProcessing() {
|
|
139
|
+
try {
|
|
140
|
+
await loadSharp();
|
|
141
|
+
return { available: true };
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
return { available: false, error: getErrorMessage(err) };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
126
147
|
const getAdminStatusHandler = async (_gc, ctx, _req) => {
|
|
127
148
|
const taskDir = getTaskQueueDir(ctx.services.config);
|
|
128
149
|
try {
|
|
129
|
-
const [queueStats, oldestPendingAgeMs, worker, { workerStatus, statusReadError }] = await Promise.all([
|
|
150
|
+
const [queueStats, oldestPendingAgeMs, worker, { workerStatus, statusReadError }, settingsWorkspaceError, imageProcessing,] = await Promise.all([
|
|
130
151
|
getQueueStats(taskDir),
|
|
131
152
|
getOldestPendingAgeMs(taskDir),
|
|
132
153
|
classifyWorkerLiveness(taskDir),
|
|
133
154
|
readWorkerStatus(taskDir),
|
|
155
|
+
readSettingsWorkspaceError(ctx),
|
|
156
|
+
probeImageProcessing(),
|
|
134
157
|
]);
|
|
135
158
|
return {
|
|
136
159
|
ok: true,
|
|
@@ -145,6 +168,10 @@ const getAdminStatusHandler = async (_gc, ctx, _req) => {
|
|
|
145
168
|
worker,
|
|
146
169
|
workerStatus,
|
|
147
170
|
...(statusReadError ? { statusReadError } : {}),
|
|
171
|
+
...(settingsWorkspaceError ? { settingsWorkspaceError } : {}),
|
|
172
|
+
build: getBuildIdentity(),
|
|
173
|
+
assetStore: { configured: !!ctx.assetStore },
|
|
174
|
+
imageProcessing,
|
|
148
175
|
},
|
|
149
176
|
};
|
|
150
177
|
}
|
|
@@ -255,6 +282,9 @@ const getAdminStatus = defineEndpoint({
|
|
|
255
282
|
queue: { pending: 0, processing: 0, completed: 0, failed: 0, corrupt: 0 },
|
|
256
283
|
worker: { state: 'absent' },
|
|
257
284
|
workerStatus: null,
|
|
285
|
+
build: { canopycmsVersion: '0.0.0' },
|
|
286
|
+
assetStore: { configured: false },
|
|
287
|
+
imageProcessing: { available: true },
|
|
258
288
|
},
|
|
259
289
|
guards: ['admin'],
|
|
260
290
|
handler: getAdminStatusHandler,
|
package/dist/api/assets.js
CHANGED
|
@@ -4,8 +4,8 @@ import { ASSET_PREFIXES } from '../assets/keys.js';
|
|
|
4
4
|
import { ALLOWED_UPLOAD_CONTENT_TYPES } from '../assets/pipeline.js';
|
|
5
5
|
import { finalizeStagedUpload } from '../assets/finalize.js';
|
|
6
6
|
import { assetSrc } from '../assets/asset-src.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { canonicalizeTransformPath } from '../assets/transform-directives.js';
|
|
8
|
+
import { storeTransform, TRANSFORM_CACHE_CONTROL } from '../assets/materialize.js';
|
|
9
9
|
import { isAdmin } from '../authorization/helpers.js';
|
|
10
10
|
function toAssetRecord(meta) {
|
|
11
11
|
return { ...meta, src: assetSrc(meta) };
|
|
@@ -212,63 +212,41 @@ const deleteAssetHandler = async (ctx, req, params) => {
|
|
|
212
212
|
await ctx.assetStore.deleteMeta(params.key);
|
|
213
213
|
return { ok: true, status: 200, data: { deleted: true } };
|
|
214
214
|
};
|
|
215
|
-
/** Cache-Control applied to every transform output this route writes/serves - matches finalize.ts's PUBLIC_CACHE_CONTROL for static public objects. */
|
|
216
|
-
const TRANSFORM_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
217
215
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* dedupes with any equivalent request), then serve the bytes just computed.
|
|
222
|
-
*
|
|
223
|
-
* `key` here already starts with `assets/t/` and already missed `rawAssetHandler`'s cache-hit
|
|
224
|
-
* `readPublicObject` check.
|
|
216
|
+
* The largest body this route returns inline. The CMS Lambda's Function URL buffers its response
|
|
217
|
+
* and caps it at 6 MiB after base64 encoding, which inflates by 4/3, so 4 MiB leaves headroom.
|
|
218
|
+
* Same bound and reasoning as the transform Lambda's `INLINE_BODY_LIMIT_BYTES`.
|
|
225
219
|
*/
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
// When the URL omits an explicit `f=` format, the transform preserves the
|
|
247
|
-
// source format, so the URL's `{ext}` must match the source's real ext
|
|
248
|
-
// exactly - the parser alone can't check this (it doesn't know the source
|
|
249
|
-
// format until this meta lookup).
|
|
250
|
-
const requestedFormat = parsed.directives.identity ? undefined : parsed.directives.format;
|
|
251
|
-
if (requestedFormat === undefined && parsed.ext !== meta.ext) {
|
|
252
|
-
return { ok: false, status: 400, error: 'Extension does not match the source format' };
|
|
253
|
-
}
|
|
254
|
-
const original = await assetStore.readOriginal(parsed.hash32);
|
|
255
|
-
if (!original) {
|
|
256
|
-
return { ok: false, status: 404, error: 'Not found' };
|
|
257
|
-
}
|
|
258
|
-
const transformed = await applyTransform({ data: original.data, ext: original.ext }, parsed.directives);
|
|
220
|
+
const INLINE_BODY_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
221
|
+
/**
|
|
222
|
+
* A 302 to a presigned store URL. `no-store` because the URL expires: a cached redirect would
|
|
223
|
+
* outlive it, and a browser revisiting the page would follow it to a 403.
|
|
224
|
+
*/
|
|
225
|
+
function presignedRedirect(url) {
|
|
226
|
+
return {
|
|
227
|
+
kind: 'binary',
|
|
228
|
+
status: 302,
|
|
229
|
+
body: new Uint8Array(),
|
|
230
|
+
headers: { location: url, cacheControl: 'no-store' },
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* This route's own lazy transform: `storeTransform` computes and stores the output, then this
|
|
235
|
+
* serves the bytes just computed. `parsed` is the canonical parse from `canonicalizeTransformPath`,
|
|
236
|
+
* and `canonicalKey` already missed `rawAssetHandler`'s cache check.
|
|
237
|
+
*/
|
|
238
|
+
async function serveLazyTransform(assetStore, parsed, canonicalKey) {
|
|
239
|
+
const transformed = await storeTransform(assetStore, parsed, canonicalKey);
|
|
259
240
|
if (!transformed.ok) {
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
241
|
+
// The real status, not a flat 502: none of these rejections is "this server failed".
|
|
242
|
+
const error = transformed.status === 404 ? 'Not found' : transformed.error;
|
|
243
|
+
return { ok: false, status: transformed.status, error };
|
|
244
|
+
}
|
|
245
|
+
if (transformed.data.byteLength > INLINE_BODY_LIMIT_BYTES && assetStore.presignPublicObjectRead) {
|
|
246
|
+
const url = await assetStore.presignPublicObjectRead(canonicalKey);
|
|
247
|
+
if (url)
|
|
248
|
+
return presignedRedirect(url);
|
|
264
249
|
}
|
|
265
|
-
const canonicalKey = `${ASSET_PREFIXES.transform}/${formatDirectives(parsed.directives)}/${parsed.hash32}/${parsed.slug}.${parsed.ext}`;
|
|
266
|
-
await assetStore.putPublicObject({
|
|
267
|
-
key: canonicalKey,
|
|
268
|
-
data: transformed.data,
|
|
269
|
-
contentType: transformed.contentType,
|
|
270
|
-
cacheControl: TRANSFORM_CACHE_CONTROL,
|
|
271
|
-
});
|
|
272
250
|
return {
|
|
273
251
|
kind: 'binary',
|
|
274
252
|
status: 200,
|
|
@@ -276,16 +254,47 @@ async function serveLazyTransform(assetStore, key) {
|
|
|
276
254
|
headers: { contentType: transformed.contentType, cacheControl: TRANSFORM_CACHE_CONTROL },
|
|
277
255
|
};
|
|
278
256
|
}
|
|
257
|
+
/** A redirect to, or the bytes of, the public object stored at `key`; `null` when there is none. */
|
|
258
|
+
async function serveStoredObject(assetStore, key) {
|
|
259
|
+
if (assetStore.presignPublicObjectRead) {
|
|
260
|
+
const url = await assetStore.presignPublicObjectRead(key);
|
|
261
|
+
return url ? presignedRedirect(url) : null;
|
|
262
|
+
}
|
|
263
|
+
const object = await assetStore.readPublicObject(key);
|
|
264
|
+
if (!object)
|
|
265
|
+
return null;
|
|
266
|
+
return {
|
|
267
|
+
kind: 'binary',
|
|
268
|
+
status: 200,
|
|
269
|
+
body: object.data,
|
|
270
|
+
headers: {
|
|
271
|
+
contentType: object.contentType,
|
|
272
|
+
contentDisposition: object.contentDisposition,
|
|
273
|
+
cacheControl: object.cacheControl,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
279
277
|
/**
|
|
280
278
|
* Serve a public asset object (sanitized svg/pdf finalize wrote, or a cached transform output)
|
|
281
|
-
*
|
|
282
|
-
* `ASSET_ROUTES`/the client generator: this returns raw bytes
|
|
283
|
-
* JSON envelope, so a generated `response.json()` client method
|
|
284
|
-
* this route directly (`<img>`/`<a>` src, or a framework rewrite),
|
|
279
|
+
* to the editor, the live preview, and `withCanopy`'s `/assets/*` rewrite. Hand-built (not
|
|
280
|
+
* `defineEndpoint`), not registered in `ASSET_ROUTES`/the client generator: this returns raw bytes
|
|
281
|
+
* (`CanopyBinaryResponse`), not a JSON envelope, so a generated `response.json()` client method
|
|
282
|
+
* would be wrong. Consumers hit this route directly (`<img>`/`<a>` src, or a framework rewrite),
|
|
283
|
+
* never through `client.ts`.
|
|
285
284
|
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
* origin-group -> S3 -> Lambda on miss)
|
|
285
|
+
* A transform key (`assets/t/...`) is resolved to its canonical key first, and that key is what is
|
|
286
|
+
* cache-checked and, on a miss, computed by `serveLazyTransform`. Mirrors `AssetSupport`'s lazy
|
|
287
|
+
* mode (CloudFront origin-group -> S3 -> Lambda on miss), except that a non-canonical spelling is
|
|
288
|
+
* served the canonical bytes rather than the Lambda's 301: this route is authenticated, and
|
|
289
|
+
* redirecting to `/assets/t/...` would bounce the request onto the public path.
|
|
290
|
+
*
|
|
291
|
+
* A stored object on a store that can presign (S3) is answered with a 302 to a presigned GET, so
|
|
292
|
+
* its bytes never pass through this process: the CMS Lambda is concurrency-capped and uncached,
|
|
293
|
+
* the live preview asks for every image on a page at once, and its Function URL cannot return a
|
|
294
|
+
* body over about 6 MiB. The redirect never targets the public `/assets/...` URL, because whether
|
|
295
|
+
* that URL reaches this route again is topology: `withCanopy` rewrites `/assets/:path*` here, so
|
|
296
|
+
* on any deployment where Next serves `/assets` the redirect would loop. Other stores stream the
|
|
297
|
+
* bytes, as does a fresh transform no larger than `INLINE_BODY_LIMIT_BYTES`.
|
|
289
298
|
*/
|
|
290
299
|
const rawAssetHandler = async (ctx, _req, params) => {
|
|
291
300
|
if (!ctx.assetStore)
|
|
@@ -300,23 +309,22 @@ const rawAssetHandler = async (ctx, _req, params) => {
|
|
|
300
309
|
if (!key.startsWith(publicPrefix) || key.includes('..')) {
|
|
301
310
|
return { ok: false, status: 404, error: 'Not found' };
|
|
302
311
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
contentDisposition: object.contentDisposition,
|
|
312
|
-
cacheControl: object.cacheControl,
|
|
313
|
-
},
|
|
314
|
-
};
|
|
312
|
+
let readKey = key;
|
|
313
|
+
let transform;
|
|
314
|
+
if (key.startsWith(transformPrefix)) {
|
|
315
|
+
const canonical = canonicalizeTransformPath(key.slice(transformPrefix.length).split('/'), 'any');
|
|
316
|
+
if (!canonical.ok)
|
|
317
|
+
return { ok: false, status: 400, error: canonical.error };
|
|
318
|
+
readKey = `${transformPrefix}${canonical.canonicalPath}`;
|
|
319
|
+
transform = canonical;
|
|
315
320
|
}
|
|
316
|
-
|
|
321
|
+
const stored = await serveStoredObject(ctx.assetStore, readKey);
|
|
322
|
+
if (stored)
|
|
323
|
+
return stored;
|
|
324
|
+
if (!transform) {
|
|
317
325
|
return { ok: false, status: 404, error: 'Not found' };
|
|
318
326
|
}
|
|
319
|
-
return serveLazyTransform(ctx.assetStore,
|
|
327
|
+
return serveLazyTransform(ctx.assetStore, transform, readKey);
|
|
320
328
|
};
|
|
321
329
|
// Deliberately no 'writableBranch' guard on any endpoint below: none take a
|
|
322
330
|
// :branch param -- the asset store is branch-agnostic (a single global store,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a create of a branch name its own creator just made answers with that branch rather
|
|
3
|
+
* than a 409, so a request killed between publishing the branch and responding is safe to retry.
|
|
4
|
+
* Dependency-free: the editor applies the same rule when it settles an unanswered create.
|
|
5
|
+
* @internal Exported for tests.
|
|
6
|
+
*/
|
|
7
|
+
export declare const IDEMPOTENT_CREATE_WINDOW_MS: number;
|
|
8
|
+
/** Whether `branch` can be the result of `userId`'s create: theirs, and inside the window. */
|
|
9
|
+
export declare function isCreatorsRecentBranch(branch: {
|
|
10
|
+
createdBy: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
}, userId: string | undefined): boolean;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a create of a branch name its own creator just made answers with that branch rather
|
|
3
|
+
* than a 409, so a request killed between publishing the branch and responding is safe to retry.
|
|
4
|
+
* Dependency-free: the editor applies the same rule when it settles an unanswered create.
|
|
5
|
+
* @internal Exported for tests.
|
|
6
|
+
*/
|
|
7
|
+
export const IDEMPOTENT_CREATE_WINDOW_MS = 5 * 60_000;
|
|
8
|
+
/** Whether `branch` can be the result of `userId`'s create: theirs, and inside the window. */
|
|
9
|
+
export function isCreatorsRecentBranch(branch, userId) {
|
|
10
|
+
return (userId !== undefined &&
|
|
11
|
+
branch.createdBy === userId &&
|
|
12
|
+
Date.now() - Date.parse(branch.createdAt) < IDEMPOTENT_CREATE_WINDOW_MS);
|
|
13
|
+
}
|