canopycms 0.0.66-int.85 → 0.0.66
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/dist/assets/factory.js +4 -0
- package/dist/assets/store-s3.d.ts +20 -0
- package/dist/assets/store-s3.js +23 -1
- package/dist/cli/cli.js +512 -274
- package/dist/cli/generate-ai-content.js +143 -57
- package/dist/cli/init.js +140 -57
- package/dist/cli/template-files/cms-stack.ts.template +7 -4
- package/dist/config/schemas/config.d.ts +11 -6
- package/dist/config/schemas/media.d.ts +23 -6
- package/dist/config/schemas/media.js +38 -9
- package/dist/config/schemas/url.d.ts +38 -0
- package/dist/config/schemas/url.js +49 -0
- package/dist/config/types.d.ts +2 -0
- package/dist/editor/context/AssetContext.d.ts +12 -7
- package/dist/editor/context/AssetContext.js +12 -7
- package/dist/utils/sanitize-href.d.ts +41 -0
- package/dist/utils/sanitize-href.js +116 -0
- package/package.json +26 -14
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
export declare const mediaSchema: z.ZodDiscriminatedUnion<"adapter", [z.ZodObject<{
|
|
6
6
|
adapter: z.ZodLiteral<"local">;
|
|
7
|
-
publicBaseUrl: z.ZodOptional<z.ZodString
|
|
7
|
+
publicBaseUrl: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
8
8
|
/** Root directory for local asset storage. Defaults to the caller's dev-assets dir. */
|
|
9
9
|
directory: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>, string, string>>;
|
|
10
|
-
}, "
|
|
10
|
+
}, "strict", z.ZodTypeAny, {
|
|
11
11
|
adapter: "local";
|
|
12
12
|
publicBaseUrl?: string | undefined;
|
|
13
13
|
directory?: string | undefined;
|
|
@@ -19,25 +19,42 @@ export declare const mediaSchema: z.ZodDiscriminatedUnion<"adapter", [z.ZodObjec
|
|
|
19
19
|
adapter: z.ZodLiteral<"s3">;
|
|
20
20
|
bucket: z.ZodString;
|
|
21
21
|
region: z.ZodString;
|
|
22
|
-
publicBaseUrl: z.ZodOptional<z.ZodString
|
|
22
|
+
publicBaseUrl: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
23
|
+
/**
|
|
24
|
+
* Where the browser POSTs a presigned direct upload. Absolute http(s) URL or a
|
|
25
|
+
* site-relative path; defaults to the S3 REST endpoint the AWS SDK returns.
|
|
26
|
+
*
|
|
27
|
+
* Set this to route uploads through your own CDN so they are same-origin with the
|
|
28
|
+
* editor, which removes the need for a bucket CORS rule naming an exact origin. The
|
|
29
|
+
* signature is unaffected — a presigned POST's string-to-sign is the base64 policy
|
|
30
|
+
* alone, so the host never enters it (pinned by store-s3.test.ts).
|
|
31
|
+
*
|
|
32
|
+
* NOT a prefix. Unlike `publicBaseUrl`, nothing is joined onto this value; it replaces
|
|
33
|
+
* the POST target outright and is passed through byte-for-byte. Do not route it through
|
|
34
|
+
* `joinUrlPrefix` "for consistency" — that would strip the trailing slash and silently
|
|
35
|
+
* stop a CDN path pattern like `/asset-upload/*` from matching.
|
|
36
|
+
*/
|
|
37
|
+
uploadUrl: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
23
38
|
/** Max upload size in bytes for presigned direct uploads. Defaults to 50 MiB. */
|
|
24
39
|
maxUploadBytes: z.ZodOptional<z.ZodNumber>;
|
|
25
|
-
}, "
|
|
40
|
+
}, "strict", z.ZodTypeAny, {
|
|
26
41
|
adapter: "s3";
|
|
27
42
|
bucket: string;
|
|
28
43
|
region: string;
|
|
29
44
|
publicBaseUrl?: string | undefined;
|
|
45
|
+
uploadUrl?: string | undefined;
|
|
30
46
|
maxUploadBytes?: number | undefined;
|
|
31
47
|
}, {
|
|
32
48
|
adapter: "s3";
|
|
33
49
|
bucket: string;
|
|
34
50
|
region: string;
|
|
35
51
|
publicBaseUrl?: string | undefined;
|
|
52
|
+
uploadUrl?: string | undefined;
|
|
36
53
|
maxUploadBytes?: number | undefined;
|
|
37
54
|
}>, z.ZodObject<{
|
|
38
55
|
adapter: z.ZodLiteral<"lfs">;
|
|
39
|
-
publicBaseUrl: z.ZodOptional<z.ZodString
|
|
40
|
-
}, "
|
|
56
|
+
publicBaseUrl: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
57
|
+
}, "strict", z.ZodTypeAny, {
|
|
41
58
|
adapter: "lfs";
|
|
42
59
|
publicBaseUrl?: string | undefined;
|
|
43
60
|
}, {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { relativePathSchema } from './collection.js';
|
|
6
|
+
import { assetMountUrlSchema, uploadTargetUrlSchema } from './url.js';
|
|
6
7
|
// Media adapter configuration schema.
|
|
7
8
|
// Keyed as a discriminated union on `adapter` so each adapter's required fields are
|
|
8
9
|
// enforced at parse time. With a plain z.union, a malformed s3 config (e.g. missing
|
|
@@ -14,23 +15,51 @@ import { relativePathSchema } from './collection.js';
|
|
|
14
15
|
// Bucket-prefix layout (asset-originals/, asset-staging/, asset-meta/, assets/) is
|
|
15
16
|
// intentionally NOT configurable here — those are constants in assets/keys.ts, not
|
|
16
17
|
// per-site config (see .claude/future-tasks/assets-media-system.md).
|
|
18
|
+
//
|
|
19
|
+
// Every branch is .strict(). This is load-bearing, not tidiness: CanopyConfigSchema's own
|
|
20
|
+
// .strict() does NOT recurse into nested schemas, so until these branches carried their own,
|
|
21
|
+
// `media: { adapter: 's3', …, uploadURL: '/x' }` parsed successfully with the misspelled key
|
|
22
|
+
// silently stripped — and the adopter got the raw S3 endpoint back with no diagnostic
|
|
23
|
+
// anywhere. A config field whose whole purpose is to fail loudly at parse time cannot sit in
|
|
24
|
+
// a container that swallows typos.
|
|
17
25
|
export const mediaSchema = z.discriminatedUnion('adapter', [
|
|
18
|
-
z
|
|
26
|
+
z
|
|
27
|
+
.object({
|
|
19
28
|
adapter: z.literal('local'),
|
|
20
|
-
publicBaseUrl:
|
|
29
|
+
publicBaseUrl: assetMountUrlSchema.optional(),
|
|
21
30
|
/** Root directory for local asset storage. Defaults to the caller's dev-assets dir. */
|
|
22
31
|
directory: relativePathSchema.optional(),
|
|
23
|
-
})
|
|
24
|
-
|
|
32
|
+
})
|
|
33
|
+
.strict(),
|
|
34
|
+
z
|
|
35
|
+
.object({
|
|
25
36
|
adapter: z.literal('s3'),
|
|
26
37
|
bucket: z.string().min(1),
|
|
27
38
|
region: z.string().min(1),
|
|
28
|
-
publicBaseUrl:
|
|
39
|
+
publicBaseUrl: assetMountUrlSchema.optional(),
|
|
40
|
+
/**
|
|
41
|
+
* Where the browser POSTs a presigned direct upload. Absolute http(s) URL or a
|
|
42
|
+
* site-relative path; defaults to the S3 REST endpoint the AWS SDK returns.
|
|
43
|
+
*
|
|
44
|
+
* Set this to route uploads through your own CDN so they are same-origin with the
|
|
45
|
+
* editor, which removes the need for a bucket CORS rule naming an exact origin. The
|
|
46
|
+
* signature is unaffected — a presigned POST's string-to-sign is the base64 policy
|
|
47
|
+
* alone, so the host never enters it (pinned by store-s3.test.ts).
|
|
48
|
+
*
|
|
49
|
+
* NOT a prefix. Unlike `publicBaseUrl`, nothing is joined onto this value; it replaces
|
|
50
|
+
* the POST target outright and is passed through byte-for-byte. Do not route it through
|
|
51
|
+
* `joinUrlPrefix` "for consistency" — that would strip the trailing slash and silently
|
|
52
|
+
* stop a CDN path pattern like `/asset-upload/*` from matching.
|
|
53
|
+
*/
|
|
54
|
+
uploadUrl: uploadTargetUrlSchema.optional(),
|
|
29
55
|
/** Max upload size in bytes for presigned direct uploads. Defaults to 50 MiB. */
|
|
30
56
|
maxUploadBytes: z.number().int().positive().optional(),
|
|
31
|
-
})
|
|
32
|
-
|
|
57
|
+
})
|
|
58
|
+
.strict(),
|
|
59
|
+
z
|
|
60
|
+
.object({
|
|
33
61
|
adapter: z.literal('lfs'),
|
|
34
|
-
publicBaseUrl:
|
|
35
|
-
})
|
|
62
|
+
publicBaseUrl: assetMountUrlSchema.optional(),
|
|
63
|
+
})
|
|
64
|
+
.strict(),
|
|
36
65
|
]);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schemas for adopter-configured URL values.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately a separate file rather than an addition to `config.ts`: `config.ts` already
|
|
5
|
+
* imports `media.ts`, and `media.ts` needs these, so defining them there would close a cycle
|
|
6
|
+
* that `.dependency-cruiser.mjs`'s `no-circular` rule fails the build on.
|
|
7
|
+
*
|
|
8
|
+
* These are thin wrappers. The rule itself lives in `utils/sanitize-href.ts` alongside the
|
|
9
|
+
* package's only other http(s) allowlist, so config validation and content sanitization cannot
|
|
10
|
+
* drift into disagreeing about which URLs are acceptable.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
/**
|
|
14
|
+
* Where the browser POSTs a presigned direct upload (`media.uploadUrl`).
|
|
15
|
+
*
|
|
16
|
+
* Stricter than `assetMountUrlSchema` by exactly one case — no protocol-relative `//host` —
|
|
17
|
+
* because that spelling is ambiguous rather than insecure: it resolves to http or https
|
|
18
|
+
* depending on the editor page issuing the upload, so the config would not determine where a
|
|
19
|
+
* live credential is sent. `isHttpUrlOrSameOriginPath`'s doc carries the full reasoning,
|
|
20
|
+
* including why bare `http://` is nonetheless accepted.
|
|
21
|
+
*
|
|
22
|
+
* "Exactly one case" is measured, not asserted: a census over every 4-character string
|
|
23
|
+
* drawn from an 11-symbol alphabet (slash, backslash, dot, colon, %2e, ?, #, tab, and three
|
|
24
|
+
* letters), plus hand-written shapes, found 39 values the two schemas treat differently — and
|
|
25
|
+
* every one is a literal `//host` (three only after `.trim()` strips a leading tab).
|
|
26
|
+
*/
|
|
27
|
+
export declare const uploadTargetUrlSchema: z.ZodEffects<z.ZodString, string, string>;
|
|
28
|
+
/**
|
|
29
|
+
* Where `/assets/…` is mounted for the editor's own previews (`media.publicBaseUrl`).
|
|
30
|
+
*
|
|
31
|
+
* Two changes from the `z.string().url()` this replaced, both deliberate. It now accepts a
|
|
32
|
+
* site-relative path, which is what `.claude/future-tasks/editor-asset-mount-topology.md`
|
|
33
|
+
* option 1 asks for — the absolute-only constraint was a validation choice, and the `basePath`
|
|
34
|
+
* fallback in `editor/context/AssetContext.tsx` exists only to work around it. And it now
|
|
35
|
+
* rejects non-http(s) schemes, which `z.string().url()` accepted: `publicBaseUrl: 'mailto:a@b.c'`
|
|
36
|
+
* used to parse and then produce `/mailto:a@b.c/assets/…` at render time.
|
|
37
|
+
*/
|
|
38
|
+
export declare const assetMountUrlSchema: z.ZodEffects<z.ZodString, string, string>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schemas for adopter-configured URL values.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately a separate file rather than an addition to `config.ts`: `config.ts` already
|
|
5
|
+
* imports `media.ts`, and `media.ts` needs these, so defining them there would close a cycle
|
|
6
|
+
* that `.dependency-cruiser.mjs`'s `no-circular` rule fails the build on.
|
|
7
|
+
*
|
|
8
|
+
* These are thin wrappers. The rule itself lives in `utils/sanitize-href.ts` alongside the
|
|
9
|
+
* package's only other http(s) allowlist, so config validation and content sanitization cannot
|
|
10
|
+
* drift into disagreeing about which URLs are acceptable.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { isHttpUrlOrSameOriginPath } from '../../utils/sanitize-href.js';
|
|
14
|
+
/**
|
|
15
|
+
* Where the browser POSTs a presigned direct upload (`media.uploadUrl`).
|
|
16
|
+
*
|
|
17
|
+
* Stricter than `assetMountUrlSchema` by exactly one case — no protocol-relative `//host` —
|
|
18
|
+
* because that spelling is ambiguous rather than insecure: it resolves to http or https
|
|
19
|
+
* depending on the editor page issuing the upload, so the config would not determine where a
|
|
20
|
+
* live credential is sent. `isHttpUrlOrSameOriginPath`'s doc carries the full reasoning,
|
|
21
|
+
* including why bare `http://` is nonetheless accepted.
|
|
22
|
+
*
|
|
23
|
+
* "Exactly one case" is measured, not asserted: a census over every 4-character string
|
|
24
|
+
* drawn from an 11-symbol alphabet (slash, backslash, dot, colon, %2e, ?, #, tab, and three
|
|
25
|
+
* letters), plus hand-written shapes, found 39 values the two schemas treat differently — and
|
|
26
|
+
* every one is a literal `//host` (three only after `.trim()` strips a leading tab).
|
|
27
|
+
*/
|
|
28
|
+
export const uploadTargetUrlSchema = z
|
|
29
|
+
.string()
|
|
30
|
+
.trim()
|
|
31
|
+
.refine((value) => isHttpUrlOrSameOriginPath(value), {
|
|
32
|
+
message: 'must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment',
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* Where `/assets/…` is mounted for the editor's own previews (`media.publicBaseUrl`).
|
|
36
|
+
*
|
|
37
|
+
* Two changes from the `z.string().url()` this replaced, both deliberate. It now accepts a
|
|
38
|
+
* site-relative path, which is what `.claude/future-tasks/editor-asset-mount-topology.md`
|
|
39
|
+
* option 1 asks for — the absolute-only constraint was a validation choice, and the `basePath`
|
|
40
|
+
* fallback in `editor/context/AssetContext.tsx` exists only to work around it. And it now
|
|
41
|
+
* rejects non-http(s) schemes, which `z.string().url()` accepted: `publicBaseUrl: 'mailto:a@b.c'`
|
|
42
|
+
* used to parse and then produce `/mailto:a@b.c/assets/…` at render time.
|
|
43
|
+
*/
|
|
44
|
+
export const assetMountUrlSchema = z
|
|
45
|
+
.string()
|
|
46
|
+
.trim()
|
|
47
|
+
.refine((value) => isHttpUrlOrSameOriginPath(value, { allowProtocolRelative: true }), {
|
|
48
|
+
message: 'must be an absolute http(s) URL, a protocol-relative "//host" URL, or a site-relative path beginning with a single "/", with no query or fragment',
|
|
49
|
+
});
|
package/dist/config/types.d.ts
CHANGED
|
@@ -164,6 +164,8 @@ export type MediaConfig = {
|
|
|
164
164
|
bucket: string;
|
|
165
165
|
region: string;
|
|
166
166
|
publicBaseUrl?: string;
|
|
167
|
+
/** POST target for presigned direct uploads; see mediaSchema's s3 branch. */
|
|
168
|
+
uploadUrl?: string;
|
|
167
169
|
maxUploadBytes?: number;
|
|
168
170
|
} | {
|
|
169
171
|
adapter: 'lfs';
|
|
@@ -27,14 +27,19 @@ export interface AssetContextProviderProps extends AssetContextValue {
|
|
|
27
27
|
/**
|
|
28
28
|
* `baseUrl` (`media.publicBaseUrl`) wins; `basePath` is the fallback.
|
|
29
29
|
*
|
|
30
|
-
* They are ALTERNATIVES, never composed:
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* the README.
|
|
30
|
+
* They are ALTERNATIVES, never composed: whichever is used names where `/assets` is mounted, in
|
|
31
|
+
* full, and a deployment `basePath` cannot apply on top of it. Where the two differ is topology,
|
|
32
|
+
* not precedence - see the asset-mount table in the README.
|
|
34
33
|
*
|
|
35
|
-
*
|
|
36
|
-
* absolute-only)
|
|
37
|
-
*
|
|
34
|
+
* WHY THE FALLBACK EXISTS, AND WHY THAT REASON IS NOW GONE. It was added because
|
|
35
|
+
* `publicBaseUrl` was validated absolute-only (`z.string().url()`) and so could not express a
|
|
36
|
+
* bare path, leaving the editor's thumbnails, previews and crop images root-relative — and 404 —
|
|
37
|
+
* on a basePath deployment where Next serves `/assets`. That constraint was lifted when
|
|
38
|
+
* `publicBaseUrl` moved to `assetMountUrlSchema`, which accepts a site-relative path: an adopter
|
|
39
|
+
* can now simply set `publicBaseUrl: '/preview-123'`. The fallback is kept only so that existing
|
|
40
|
+
* basePath deployments do not break on upgrade, which makes removing it a deliberate decision
|
|
41
|
+
* rather than a cleanup - it is option 1 of
|
|
42
|
+
* `.claude/future-tasks/editor-asset-mount-topology.md`, still open.
|
|
38
43
|
*
|
|
39
44
|
* KNOWN LIMITATION: the fallback assumes that topology, and consults none. On a CloudFront/CDK
|
|
40
45
|
* deployment a basePath does NOT move the asset space (behaviors are anchored at the distribution
|
|
@@ -18,14 +18,19 @@ const AssetContext = createContext({});
|
|
|
18
18
|
/**
|
|
19
19
|
* `baseUrl` (`media.publicBaseUrl`) wins; `basePath` is the fallback.
|
|
20
20
|
*
|
|
21
|
-
* They are ALTERNATIVES, never composed:
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* the README.
|
|
21
|
+
* They are ALTERNATIVES, never composed: whichever is used names where `/assets` is mounted, in
|
|
22
|
+
* full, and a deployment `basePath` cannot apply on top of it. Where the two differ is topology,
|
|
23
|
+
* not precedence - see the asset-mount table in the README.
|
|
25
24
|
*
|
|
26
|
-
*
|
|
27
|
-
* absolute-only)
|
|
28
|
-
*
|
|
25
|
+
* WHY THE FALLBACK EXISTS, AND WHY THAT REASON IS NOW GONE. It was added because
|
|
26
|
+
* `publicBaseUrl` was validated absolute-only (`z.string().url()`) and so could not express a
|
|
27
|
+
* bare path, leaving the editor's thumbnails, previews and crop images root-relative — and 404 —
|
|
28
|
+
* on a basePath deployment where Next serves `/assets`. That constraint was lifted when
|
|
29
|
+
* `publicBaseUrl` moved to `assetMountUrlSchema`, which accepts a site-relative path: an adopter
|
|
30
|
+
* can now simply set `publicBaseUrl: '/preview-123'`. The fallback is kept only so that existing
|
|
31
|
+
* basePath deployments do not break on upgrade, which makes removing it a deliberate decision
|
|
32
|
+
* rather than a cleanup - it is option 1 of
|
|
33
|
+
* `.claude/future-tasks/editor-asset-mount-topology.md`, still open.
|
|
29
34
|
*
|
|
30
35
|
* KNOWN LIMITATION: the fallback assumes that topology, and consults none. On a CloudFront/CDK
|
|
31
36
|
* deployment a basePath does NOT move the asset space (behaviors are anchored at the distribution
|
|
@@ -44,6 +44,47 @@ export declare function isImplicitlyOffOrigin(url: string): boolean;
|
|
|
44
44
|
* same-origin string).
|
|
45
45
|
*/
|
|
46
46
|
export declare function neutralizeImplicitOffOrigin(url: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Whether `value` is usable as an adopter-CONFIGURED URL base or endpoint: an absolute
|
|
49
|
+
* `http(s)` URL, or a site-relative path with exactly one leading slash.
|
|
50
|
+
*
|
|
51
|
+
* This is the config-validation counterpart to `sanitizeHref`, which does the same job for
|
|
52
|
+
* untrusted CONTENT. The difference is what happens to a bad value: content is coerced to a
|
|
53
|
+
* fallback so a page still renders, whereas config should fail loudly at parse time — nobody
|
|
54
|
+
* is served by a silently-rewritten deployment setting. The http(s) allowlist itself lives in
|
|
55
|
+
* this file either way, so the two surfaces cannot drift into disagreeing about which schemes
|
|
56
|
+
* are acceptable (that drift is exactly why `utils/url-prefix.ts` exists — see its header).
|
|
57
|
+
*
|
|
58
|
+
* `allowProtocolRelative` exists because the two callers genuinely differ, and the difference
|
|
59
|
+
* is blast radius rather than taste:
|
|
60
|
+
*
|
|
61
|
+
* - A READ-side prefix (`media.publicBaseUrl`, joined onto `/assets/…`) may legitimately be
|
|
62
|
+
* `//cdn.example.com`. `utils/url-prefix.ts`'s `isAbsoluteUrl` documents that literal
|
|
63
|
+
* spelling as an intentionally-supported off-site pointer, so rejecting it here would break
|
|
64
|
+
* a working configuration. A bad value costs a broken `<img>`.
|
|
65
|
+
* - A WRITE-side endpoint (`media.uploadUrl`, where the browser POSTs a presigned upload) must
|
|
66
|
+
* not be protocol-relative even when spelled literally, because such a URL is AMBIGUOUS: it
|
|
67
|
+
* resolves to http or https depending on the scheme of whichever editor page happens to
|
|
68
|
+
* issue the upload, so the stored config does not determine where a live presigned credential
|
|
69
|
+
* and the user's file bytes are sent. That is the same principle every other rule here
|
|
70
|
+
* enforces — a config value must describe what actually happens.
|
|
71
|
+
*
|
|
72
|
+
* Note this is NOT an argument that http is forbidden on the write side; bare `http://` is
|
|
73
|
+
* accepted, because a loopback or in-cluster S3-compatible endpoint is a real need and a
|
|
74
|
+
* browser on an https editor blocks the mixed-content request anyway, so it fails closed. The
|
|
75
|
+
* objection to `//host` is that it is undetermined, not that it might be insecure.
|
|
76
|
+
*
|
|
77
|
+
* Every spelling that READS as site-relative while redefining the authority — `/\host`,
|
|
78
|
+
* `\\host`, `\/host`, `///host` — is rejected for both, in both modes. Those look site-relative
|
|
79
|
+
* to a human and to a naive `startsWith('/')` check, but WHATWG URL resolves each to a
|
|
80
|
+
* different authority (measured: `///x` resolves to host `x`, not to pathname `/x`), so they
|
|
81
|
+
* are never what an adopter meant. A scheme-qualified absolute URL is a separate case and is
|
|
82
|
+
* accepted — `https:///cdn.example.com/` is unusual but unambiguous, resolving identically
|
|
83
|
+
* standalone and against any base.
|
|
84
|
+
*/
|
|
85
|
+
export declare function isHttpUrlOrSameOriginPath(value: string, opts?: {
|
|
86
|
+
allowProtocolRelative?: boolean;
|
|
87
|
+
}): boolean;
|
|
47
88
|
/**
|
|
48
89
|
* Sanitize an untrusted URL for use in `href` attributes.
|
|
49
90
|
*
|
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
// fragment-only, query-only) resolve instead of throwing under `new URL()`.
|
|
3
3
|
// Never exposed in output - see the relative-reference branch below.
|
|
4
4
|
const SENTINEL_BASE = 'https://relative.invalid';
|
|
5
|
+
/**
|
|
6
|
+
* A path segment that is exactly `.` or `..`, in any spelling a URL parser collapses.
|
|
7
|
+
*
|
|
8
|
+
* `%2e` must be covered, not just the literal dot: WHATWG's single-dot and double-dot path
|
|
9
|
+
* segment definitions are case-insensitively percent-decoded, so `/upload/%2e%2e/` collapses in
|
|
10
|
+
* a browser exactly as `/upload/../` does. A literal-only guard reads as complete and is one
|
|
11
|
+
* encoding away from useless — measured before this was widened.
|
|
12
|
+
*
|
|
13
|
+
* Bounded repetition over an alternation of two literals, so no nested quantifier and no
|
|
14
|
+
* backtracking blowup — see `utils/url-prefix.ts`'s `stripTrailingSlashes` for why regex shape
|
|
15
|
+
* is watched in this area. Measured under 2ms across five ~400KB adversarial inputs, the slowest
|
|
16
|
+
* consistently being a string of 400k slashes. Stated as a bound rather than a range because
|
|
17
|
+
* the figures move run to run.
|
|
18
|
+
*/
|
|
19
|
+
const DOT_SEGMENT = /(^|\/)(\.|%2e){1,2}(\/|$)/i;
|
|
5
20
|
/**
|
|
6
21
|
* Whether `url` declares an explicit scheme (`https:`, `mailto:`, `javascript:`, ...).
|
|
7
22
|
*
|
|
@@ -64,6 +79,107 @@ export function neutralizeImplicitOffOrigin(url) {
|
|
|
64
79
|
const parsed = new URL(url, SENTINEL_BASE);
|
|
65
80
|
return parsed.pathname + parsed.search + parsed.hash;
|
|
66
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether `value` is usable as an adopter-CONFIGURED URL base or endpoint: an absolute
|
|
84
|
+
* `http(s)` URL, or a site-relative path with exactly one leading slash.
|
|
85
|
+
*
|
|
86
|
+
* This is the config-validation counterpart to `sanitizeHref`, which does the same job for
|
|
87
|
+
* untrusted CONTENT. The difference is what happens to a bad value: content is coerced to a
|
|
88
|
+
* fallback so a page still renders, whereas config should fail loudly at parse time — nobody
|
|
89
|
+
* is served by a silently-rewritten deployment setting. The http(s) allowlist itself lives in
|
|
90
|
+
* this file either way, so the two surfaces cannot drift into disagreeing about which schemes
|
|
91
|
+
* are acceptable (that drift is exactly why `utils/url-prefix.ts` exists — see its header).
|
|
92
|
+
*
|
|
93
|
+
* `allowProtocolRelative` exists because the two callers genuinely differ, and the difference
|
|
94
|
+
* is blast radius rather than taste:
|
|
95
|
+
*
|
|
96
|
+
* - A READ-side prefix (`media.publicBaseUrl`, joined onto `/assets/…`) may legitimately be
|
|
97
|
+
* `//cdn.example.com`. `utils/url-prefix.ts`'s `isAbsoluteUrl` documents that literal
|
|
98
|
+
* spelling as an intentionally-supported off-site pointer, so rejecting it here would break
|
|
99
|
+
* a working configuration. A bad value costs a broken `<img>`.
|
|
100
|
+
* - A WRITE-side endpoint (`media.uploadUrl`, where the browser POSTs a presigned upload) must
|
|
101
|
+
* not be protocol-relative even when spelled literally, because such a URL is AMBIGUOUS: it
|
|
102
|
+
* resolves to http or https depending on the scheme of whichever editor page happens to
|
|
103
|
+
* issue the upload, so the stored config does not determine where a live presigned credential
|
|
104
|
+
* and the user's file bytes are sent. That is the same principle every other rule here
|
|
105
|
+
* enforces — a config value must describe what actually happens.
|
|
106
|
+
*
|
|
107
|
+
* Note this is NOT an argument that http is forbidden on the write side; bare `http://` is
|
|
108
|
+
* accepted, because a loopback or in-cluster S3-compatible endpoint is a real need and a
|
|
109
|
+
* browser on an https editor blocks the mixed-content request anyway, so it fails closed. The
|
|
110
|
+
* objection to `//host` is that it is undetermined, not that it might be insecure.
|
|
111
|
+
*
|
|
112
|
+
* Every spelling that READS as site-relative while redefining the authority — `/\host`,
|
|
113
|
+
* `\\host`, `\/host`, `///host` — is rejected for both, in both modes. Those look site-relative
|
|
114
|
+
* to a human and to a naive `startsWith('/')` check, but WHATWG URL resolves each to a
|
|
115
|
+
* different authority (measured: `///x` resolves to host `x`, not to pathname `/x`), so they
|
|
116
|
+
* are never what an adopter meant. A scheme-qualified absolute URL is a separate case and is
|
|
117
|
+
* accepted — `https:///cdn.example.com/` is unusual but unambiguous, resolving identically
|
|
118
|
+
* standalone and against any base.
|
|
119
|
+
*/
|
|
120
|
+
export function isHttpUrlOrSameOriginPath(value, opts = {}) {
|
|
121
|
+
if (value === '')
|
|
122
|
+
return false;
|
|
123
|
+
// Reject ASCII control characters and spaces ANYWHERE in the value, not only at the edges.
|
|
124
|
+
// WHATWG URL strips tab/CR/LF during parsing wherever they occur, so `/\tx` parses as a
|
|
125
|
+
// perfectly ordinary same-origin path and would satisfy every check below — while the
|
|
126
|
+
// browser actually requests `/x`. A stored config value that does not describe what is sent
|
|
127
|
+
// is a defect even when it happens to work, so reject it rather than normalize it away.
|
|
128
|
+
for (let index = 0; index < value.length; index++) {
|
|
129
|
+
const code = value.charCodeAt(index);
|
|
130
|
+
if (code <= 0x20 || code === 0x7f)
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
// A query or fragment is meaningless on both sides this predicate serves. S3's POST Object
|
|
134
|
+
// takes no query parameters and a browser never transmits a fragment; and on the read side a
|
|
135
|
+
// prefix carrying either produces `https://cdn.example.com/?x=1/assets/…` once joined.
|
|
136
|
+
if (value.includes('?') || value.includes('#'))
|
|
137
|
+
return false;
|
|
138
|
+
// A backslash is never legitimate in a configured URL, and it is not merely cosmetic: WHATWG
|
|
139
|
+
// treats it as a path separator for special schemes, so `/asset\upload/` is SENT as
|
|
140
|
+
// `/asset/upload/`. Worse on the read side, where the value becomes a prefix — `/\` is a
|
|
141
|
+
// string `new URL()` REJECTS, so it slips past the off-origin check below and joins into
|
|
142
|
+
// `/\/assets/a.png`, which a browser then resolves to `https://assets/a.png` — a request to a
|
|
143
|
+
// host literally named `assets` (measured). Anyone
|
|
144
|
+
// wanting a literal backslash in a path must percent-encode it.
|
|
145
|
+
if (value.includes('\\'))
|
|
146
|
+
return false;
|
|
147
|
+
// Dot segments resolve away before the request is sent — `/asset-upload/..` is SENT as `/`
|
|
148
|
+
// and `/./x` as `/x` — so the stored value again fails to describe what happens.
|
|
149
|
+
if (DOT_SEGMENT.test(value))
|
|
150
|
+
return false;
|
|
151
|
+
if (declaresScheme(value)) {
|
|
152
|
+
// The scheme must be followed by a literal `//`. Parsing to an http(s) URL is NOT enough:
|
|
153
|
+
// WHATWG resolves a same-scheme reference carrying no authority as RELATIVE, so a browser
|
|
154
|
+
// on `https://editor.example.com/admin/media` sends `https:cdn.example.com` to
|
|
155
|
+
// `https://editor.example.com/admin/cdn.example.com` — while `new URL()` here would report
|
|
156
|
+
// `https://cdn.example.com/`. Accepting it would post the presigned credential and the
|
|
157
|
+
// user's bytes to a page-dependent path on the editor's own origin, surfacing only as a
|
|
158
|
+
// 404 with nothing pointing at the config. Dropping the `//` is an ordinary typo.
|
|
159
|
+
if (!/^https?:\/\//i.test(value))
|
|
160
|
+
return false;
|
|
161
|
+
// `http:` is deliberately allowed alongside `https:`: a local S3-compatible endpoint
|
|
162
|
+
// (MinIO, LocalStack) is `http://localhost:9000`, and that is the one setup in which an
|
|
163
|
+
// adopter would most want to exercise this path before deploying.
|
|
164
|
+
try {
|
|
165
|
+
new URL(value);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// A LITERAL `//host` is the only off-origin spelling that can be intentional, and only for
|
|
173
|
+
// callers that opt in. `value[2] !== '/'` matters: `///x` and `////x` are also "implicitly
|
|
174
|
+
// off-origin" and also start with `//`, but resolve to a host named `x` rather than to a
|
|
175
|
+
// path — so they must not ride in on the opt-in.
|
|
176
|
+
if (isImplicitlyOffOrigin(value)) {
|
|
177
|
+
return opts.allowProtocolRelative === true && value.startsWith('//') && value[2] !== '/';
|
|
178
|
+
}
|
|
179
|
+
// Site-relative, with exactly one leading slash. The second clause also covers `//`, which
|
|
180
|
+
// `isImplicitlyOffOrigin` reports as false only because `new URL('//', base)` throws.
|
|
181
|
+
return value.startsWith('/') && value[1] !== '/';
|
|
182
|
+
}
|
|
67
183
|
/**
|
|
68
184
|
* Sanitize an untrusted URL for use in `href` attributes.
|
|
69
185
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
|
|
3
3
|
"name": "canopycms",
|
|
4
|
-
"version": "0.0.66
|
|
4
|
+
"version": "0.0.66",
|
|
5
5
|
"description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -18,59 +18,71 @@
|
|
|
18
18
|
},
|
|
19
19
|
"exports": {
|
|
20
20
|
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
21
22
|
"import": "./dist/index.js",
|
|
22
|
-
"
|
|
23
|
+
"require": "./dist/index.js"
|
|
23
24
|
},
|
|
24
25
|
"./client": {
|
|
26
|
+
"types": "./dist/client.d.ts",
|
|
25
27
|
"import": "./dist/client.js",
|
|
26
|
-
"
|
|
28
|
+
"require": "./dist/client.js"
|
|
27
29
|
},
|
|
28
30
|
"./server": {
|
|
31
|
+
"types": "./dist/server.d.ts",
|
|
29
32
|
"import": "./dist/server.js",
|
|
30
|
-
"
|
|
33
|
+
"require": "./dist/server.js"
|
|
31
34
|
},
|
|
32
35
|
"./auth": {
|
|
36
|
+
"types": "./dist/auth/index.d.ts",
|
|
33
37
|
"import": "./dist/auth/index.js",
|
|
34
|
-
"
|
|
38
|
+
"require": "./dist/auth/index.js"
|
|
35
39
|
},
|
|
36
40
|
"./auth/cache": {
|
|
41
|
+
"types": "./dist/auth/cache.d.ts",
|
|
37
42
|
"import": "./dist/auth/cache.js",
|
|
38
|
-
"
|
|
43
|
+
"require": "./dist/auth/cache.js"
|
|
39
44
|
},
|
|
40
45
|
"./http": {
|
|
46
|
+
"types": "./dist/http/index.d.ts",
|
|
41
47
|
"import": "./dist/http/index.js",
|
|
42
|
-
"
|
|
48
|
+
"require": "./dist/http/index.js"
|
|
43
49
|
},
|
|
44
50
|
"./task-queue": {
|
|
51
|
+
"types": "./dist/task-queue/index.d.ts",
|
|
45
52
|
"import": "./dist/task-queue/index.js",
|
|
46
|
-
"
|
|
53
|
+
"require": "./dist/task-queue/index.js"
|
|
47
54
|
},
|
|
48
55
|
"./worker/task-queue": {
|
|
56
|
+
"types": "./dist/worker/task-queue.d.ts",
|
|
49
57
|
"import": "./dist/worker/task-queue.js",
|
|
50
|
-
"
|
|
58
|
+
"require": "./dist/worker/task-queue.js"
|
|
51
59
|
},
|
|
52
60
|
"./worker/cms-worker": {
|
|
61
|
+
"types": "./dist/worker/cms-worker.d.ts",
|
|
53
62
|
"import": "./dist/worker/cms-worker.js",
|
|
54
|
-
"
|
|
63
|
+
"require": "./dist/worker/cms-worker.js"
|
|
55
64
|
},
|
|
56
65
|
"./ai": {
|
|
66
|
+
"types": "./dist/ai/index.d.ts",
|
|
57
67
|
"import": "./dist/ai/index.js",
|
|
58
|
-
"
|
|
68
|
+
"require": "./dist/ai/index.js"
|
|
59
69
|
},
|
|
60
70
|
"./build": {
|
|
71
|
+
"types": "./dist/build/index.d.ts",
|
|
61
72
|
"import": "./dist/build/index.js",
|
|
62
|
-
"
|
|
73
|
+
"require": "./dist/build/index.js"
|
|
63
74
|
},
|
|
64
75
|
"./utils/error": {
|
|
76
|
+
"types": "./dist/utils/error.d.ts",
|
|
65
77
|
"import": "./dist/utils/error.js",
|
|
66
|
-
"
|
|
78
|
+
"require": "./dist/utils/error.js"
|
|
67
79
|
}
|
|
68
80
|
},
|
|
69
81
|
"files": [
|
|
70
82
|
"dist"
|
|
71
83
|
],
|
|
72
84
|
"engines": {
|
|
73
|
-
"node": ">=
|
|
85
|
+
"node": ">=22.12.0"
|
|
74
86
|
},
|
|
75
87
|
"dependencies": {
|
|
76
88
|
"@aws-sdk/client-s3": "^3.600.0",
|