canopycms-cdk 0.0.66-int.85 → 0.0.66-int.86
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.
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
import { Construct } from 'constructs';
|
|
2
2
|
import { Duration, RemovalPolicy, aws_cloudfront as cloudfront, aws_iam as iam, aws_lambda as lambda, aws_logs as logs, aws_s3 as s3 } from 'aws-cdk-lib';
|
|
3
|
+
/**
|
|
4
|
+
* Configuration for the presigned-upload CloudFront behavior. See
|
|
5
|
+
* `AssetSupportProps.uploadBehavior` to switch it on and
|
|
6
|
+
* `AssetSupport.uploadBehavior()` for the topology it belongs in.
|
|
7
|
+
*/
|
|
8
|
+
export interface AssetUploadBehaviorOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Origins the edge advertises in `Access-Control-Allow-Origin`, scoped to
|
|
11
|
+
* this behavior alone.
|
|
12
|
+
*
|
|
13
|
+
* `['*']` by default, and a wildcard is defensible HERE in a way a
|
|
14
|
+
* bucket-wide CORS rule is not, because **the edge authorises nothing**:
|
|
15
|
+
* measured, a presigned POST with a corrupted signature sent through this
|
|
16
|
+
* exact path returns 403 and nothing lands. Authority is entirely the
|
|
17
|
+
* presigned policy, which pins the bucket, the exact key, the content type,
|
|
18
|
+
* a size range and a 15-minute expiry. So the wildcard widens who may READ
|
|
19
|
+
* the response, not who may write - and the response is an empty 204.
|
|
20
|
+
*
|
|
21
|
+
* Narrow it to your editor origin(s) if you would rather; nothing else here
|
|
22
|
+
* depends on the wildcard.
|
|
23
|
+
*
|
|
24
|
+
* ORIGINS ARE MATCHED EXACTLY, and `'*'` is honoured only as the sole entry.
|
|
25
|
+
* CloudFront's response headers policy would accept a leftmost-subdomain
|
|
26
|
+
* pattern (`https://*.preview.example.com`), but the CORS preflight is
|
|
27
|
+
* answered at the edge by a CloudFront Function that compares strings, so a
|
|
28
|
+
* pattern would pass the POST response's policy and fail every preflight.
|
|
29
|
+
* Rather than half-honour it, the constructor refuses any entry containing
|
|
30
|
+
* `*` unless the list is exactly `['*']`.
|
|
31
|
+
*
|
|
32
|
+
* @default ['*']
|
|
33
|
+
*/
|
|
34
|
+
readonly allowedOrigins?: string[];
|
|
35
|
+
}
|
|
3
36
|
export interface AssetSupportProps {
|
|
4
37
|
/**
|
|
5
38
|
* Use an existing bucket (BYO mode - e.g. a site's existing content
|
|
@@ -18,8 +51,46 @@ export interface AssetSupportProps {
|
|
|
18
51
|
* (the editor's own origin(s) - e.g. `http://localhost:3000` in dev, or
|
|
19
52
|
* the deployed editor's domain). Only applied in standalone mode (see
|
|
20
53
|
* `bucket`).
|
|
54
|
+
*
|
|
55
|
+
* This exists solely to write the bucket's CORS rule, which is only needed
|
|
56
|
+
* because the browser POSTs presigned uploads cross-origin. `uploadBehavior`
|
|
57
|
+
* is the OTHER way to satisfy that requirement: it supplies
|
|
58
|
+
* `Access-Control-Allow-Origin` from the edge instead, so no bucket rule is
|
|
59
|
+
* written and no exact origin has to be named anywhere.
|
|
60
|
+
*
|
|
61
|
+
* Optional since that edge route exists - it was required while the bucket
|
|
62
|
+
* rule was the only route. Standalone mode still refuses to synth with
|
|
63
|
+
* NEITHER, because the resulting failure is a genuinely misleading one (see
|
|
64
|
+
* the error's own text). An empty array counts as absent: CloudFormation
|
|
65
|
+
* rejects a CORS rule with no origins, so it can only ever have been a
|
|
66
|
+
* mistake.
|
|
67
|
+
*
|
|
68
|
+
* @default - no bucket CORS rule; standalone mode then requires `uploadBehavior`
|
|
69
|
+
*/
|
|
70
|
+
readonly editorOrigins?: string[];
|
|
71
|
+
/**
|
|
72
|
+
* Build a CloudFront behavior that accepts the editor's presigned-POST
|
|
73
|
+
* uploads - the infrastructure half of `media.uploadUrl` (see the README's
|
|
74
|
+
* "Routing uploads through your own CDN"). Retrieve the result with
|
|
75
|
+
* `uploadBehavior()`, which also documents the distribution it belongs on.
|
|
76
|
+
*
|
|
77
|
+
* OFF unless set, and deliberately so: this is the only thing in this
|
|
78
|
+
* construct that puts a write-capable, OAC-UNSIGNED origin in front of the
|
|
79
|
+
* bucket, and that must never appear in a template by accident. Pass
|
|
80
|
+
* `uploadBehavior: {}` to take the defaults.
|
|
81
|
+
*
|
|
82
|
+
* BYO-BUCKET CAVEAT: this origin addresses the bucket by
|
|
83
|
+
* `bucketRegionalDomainName`, and for a bucket imported with
|
|
84
|
+
* `Bucket.fromBucketName()` that resolves to the STACK's region, not the
|
|
85
|
+
* bucket's. The read path tolerates a mismatch because CloudFront follows
|
|
86
|
+
* S3's region redirect on an S3-type origin; this one is a custom origin and
|
|
87
|
+
* does not, so S3's 301 reaches the browser, where a redirected cross-origin
|
|
88
|
+
* POST surfaces as an opaque network error. Import a cross-region bucket
|
|
89
|
+
* with `Bucket.fromBucketAttributes({ region })` so the domain name is right.
|
|
90
|
+
*
|
|
91
|
+
* @default - no upload behavior is built
|
|
21
92
|
*/
|
|
22
|
-
readonly
|
|
93
|
+
readonly uploadBehavior?: AssetUploadBehaviorOptions;
|
|
23
94
|
/**
|
|
24
95
|
* Advisory upload-size cap in bytes. Not enforced by this construct -
|
|
25
96
|
* the actual cap comes from the presigned POST's `content-length-range`
|
|
@@ -153,6 +224,16 @@ export declare const ASSETS_TRANSFORM_PATH_PATTERN: string;
|
|
|
153
224
|
* `attachTo()` (below) or `CanopyCmsDistribution`'s `assetSupport` prop
|
|
154
225
|
* instead. Exported so `cms-distribution.ts`'s synth-time guard can
|
|
155
226
|
* recognize the mistake and name it in its error.
|
|
227
|
+
*
|
|
228
|
+
* `uploadBehavior()` deliberately adds NO key here, considered and rejected
|
|
229
|
+
* when it was added. It returns a bare `BehaviorOptions` rather than a named
|
|
230
|
+
* property of a returned object, so there is no spread to get wrong in the
|
|
231
|
+
* first place - and a speculative `'upload'` entry would actively misfire on
|
|
232
|
+
* an adopter whose distribution has a real upload route, since CloudFront
|
|
233
|
+
* treats a path pattern's leading slash as optional and `upload` is a legal
|
|
234
|
+
* spelling of `/upload` (see `normalizePathPattern` in cms-distribution.ts).
|
|
235
|
+
* Keep this list to property names that actually exist on
|
|
236
|
+
* `AssetCloudFrontBehaviors`.
|
|
156
237
|
*/
|
|
157
238
|
export declare const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS: readonly ["assets", "assetsTransform"];
|
|
158
239
|
/**
|
|
@@ -228,6 +309,9 @@ export interface AssetCloudFrontBehaviors {
|
|
|
228
309
|
* for why the order is the whole point); `assetBehaviors()` is the escape
|
|
229
310
|
* hatch for a distribution built entirely by hand (see
|
|
230
311
|
* `AssetCloudFrontBehaviors`'s doc comment).
|
|
312
|
+
* - `uploadBehavior()`, opt-in, the write half: a CloudFront behavior that
|
|
313
|
+
* accepts the editor's presigned-POST uploads, for adopters who set
|
|
314
|
+
* `media.uploadUrl`. Belongs on its own distribution - see that method.
|
|
231
315
|
* - `grantUploadAccess()`, the exact prefix-scoped grants the CMS/editor
|
|
232
316
|
* principal needs to run `S3AssetStore` (packages/canopycms/src/assets/store-s3.ts).
|
|
233
317
|
*/
|
|
@@ -243,8 +327,29 @@ export declare class AssetSupport extends Construct {
|
|
|
243
327
|
/** Effective advisory upload-size cap (see `AssetSupportProps.maxUploadBytes`). */
|
|
244
328
|
readonly maxUploadBytes: number;
|
|
245
329
|
private readonly behaviors;
|
|
330
|
+
/** Set only when `AssetSupportProps.uploadBehavior` is set; validated in the constructor. */
|
|
331
|
+
private readonly uploadOptions?;
|
|
332
|
+
/**
|
|
333
|
+
* Memoized on the first `uploadBehavior()` call, NOT built in the
|
|
334
|
+
* constructor. Opting in creates three CloudFront resources (function,
|
|
335
|
+
* origin request policy, response headers policy) and response headers
|
|
336
|
+
* policies have a default account quota of 20 - so an adopter who sets the
|
|
337
|
+
* prop on a per-environment `AssetSupport` while building the single shared
|
|
338
|
+
* upload distribution the accessor recommends would otherwise mint a set per
|
|
339
|
+
* environment for one route. Deferring is invisible other than in what gets
|
|
340
|
+
* emitted, with one exception worth knowing rather than discovering: calling
|
|
341
|
+
* the accessor AFTER a synth has run modifies the construct tree, and CDK
|
|
342
|
+
* refuses that with `ConstructTreeModifiedAfterSynth` naming the constructs
|
|
343
|
+
* added. Loud, not silent.
|
|
344
|
+
*/
|
|
345
|
+
private upload?;
|
|
246
346
|
constructor(scope: Construct, id: string, props: AssetSupportProps);
|
|
247
347
|
private buildBehaviors;
|
|
348
|
+
/**
|
|
349
|
+
* The behavior that accepts the editor's presigned-POST uploads. Every piece
|
|
350
|
+
* below is load-bearing; see `uploadBehavior()` for where the result goes.
|
|
351
|
+
*/
|
|
352
|
+
private buildUploadBehavior;
|
|
248
353
|
/**
|
|
249
354
|
* The two CloudFront behavior configs this system needs.
|
|
250
355
|
*
|
|
@@ -261,6 +366,51 @@ export declare class AssetSupport extends Construct {
|
|
|
261
366
|
* doc comment for that shape and its ordering requirements.
|
|
262
367
|
*/
|
|
263
368
|
assetBehaviors(): AssetCloudFrontBehaviors;
|
|
369
|
+
/**
|
|
370
|
+
* The CloudFront behavior that accepts the editor's presigned-POST uploads -
|
|
371
|
+
* the infrastructure half of `media.uploadUrl`. Requires
|
|
372
|
+
* `AssetSupportProps.uploadBehavior`.
|
|
373
|
+
*
|
|
374
|
+
* GIVE IT ITS OWN DISTRIBUTION, serving this route and nothing else:
|
|
375
|
+
*
|
|
376
|
+
* ```ts
|
|
377
|
+
* const uploads = new cloudfront.Distribution(this, 'AssetUploads', {
|
|
378
|
+
* defaultBehavior: assetSupport.uploadBehavior(),
|
|
379
|
+
* })
|
|
380
|
+
* // media.uploadUrl = `https://${uploads.distributionDomainName}/`
|
|
381
|
+
* ```
|
|
382
|
+
*
|
|
383
|
+
* No custom domain or certificate is needed - the `d111...cloudfront.net`
|
|
384
|
+
* name is a perfectly good `uploadUrl` - and as the default behavior of a
|
|
385
|
+
* one-route distribution there is no path pattern and so no ordering
|
|
386
|
+
* question of the kind `attachTo()` exists to settle.
|
|
387
|
+
*
|
|
388
|
+
* Two shapes this deliberately is NOT, both settled 2026-09-11 (the record
|
|
389
|
+
* is `.claude/future-tasks/resolved/asset-support-upload-behavior.md`):
|
|
390
|
+
*
|
|
391
|
+
* - NOT a behavior on the site's own distribution. `CustomErrorResponses`
|
|
392
|
+
* are distribution-wide, so a site that maps 403 to its own 404 page
|
|
393
|
+
* applies that to S3's upload errors too and the editor reports the
|
|
394
|
+
* substituted status. The site's cookies and any cached basic-auth
|
|
395
|
+
* credential are also live hazards there that have to be stripped by
|
|
396
|
+
* policy and function (`buildUploadBehavior` removes both - cookies via
|
|
397
|
+
* the origin request policy, `Authorization` in the viewer-request
|
|
398
|
+
* function) rather than simply never
|
|
399
|
+
* being sent. It works; it is just strictly worse.
|
|
400
|
+
* - NOT one shared distribution serving asset reads AND writes for every
|
|
401
|
+
* environment. One assets distribution means one `AssetSupport`, and
|
|
402
|
+
* `AssetSupport` owns the transform Lambda - so it would also mean one
|
|
403
|
+
* transform Lambda shared by every environment, and that Lambda ships
|
|
404
|
+
* inside this package. A `canopycms-cdk` bump would then move every
|
|
405
|
+
* environment's asset pipeline at once, which is a graduated rollout
|
|
406
|
+
* traded away for an upload path. Reads and transforms stay
|
|
407
|
+
* per-environment; only the upload route moves.
|
|
408
|
+
*
|
|
409
|
+
* The upload route is the one part of this system with no per-environment
|
|
410
|
+
* code in it - it depends on the bucket and nothing else - which is why it
|
|
411
|
+
* is the part that can be split off this way.
|
|
412
|
+
*/
|
|
413
|
+
uploadBehavior(): cloudfront.BehaviorOptions;
|
|
264
414
|
/**
|
|
265
415
|
* Attach both asset behaviors to a concrete CloudFront distribution, in the
|
|
266
416
|
* only safe order.
|
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { Construct } from 'constructs';
|
|
5
|
-
import { Duration, RemovalPolicy, Stack, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_lambda as lambda, aws_logs as logs, aws_s3 as s3, } from 'aws-cdk-lib';
|
|
5
|
+
import { Duration, RemovalPolicy, Stack, Token, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_lambda as lambda, aws_logs as logs, aws_s3 as s3, } from 'aws-cdk-lib';
|
|
6
6
|
import { attachLambdaExecutionPolicies } from './lambda-execution-role.js';
|
|
7
7
|
// This package (`canopycms-cdk`) is `"type": "module"`, so its compiled
|
|
8
8
|
// output is real ESM - `__dirname` is not a global there. Derive it from
|
|
@@ -51,8 +51,36 @@ const PREFIXES = {
|
|
|
51
51
|
public: 'assets',
|
|
52
52
|
transform: 'assets/t',
|
|
53
53
|
};
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* CORS preflight cache duration for presigned-POST uploads from the editor.
|
|
56
|
+
* Used for the bucket's own CORS rule and, when `uploadBehavior` is on, for
|
|
57
|
+
* both the edge's preflight response and its response headers policy.
|
|
58
|
+
*/
|
|
55
59
|
const CORS_MAX_AGE_SECONDS = 3000;
|
|
60
|
+
/**
|
|
61
|
+
* The only method the upload route exists to serve. Shared by the preflight
|
|
62
|
+
* response the CloudFront Function returns and the response headers policy's
|
|
63
|
+
* `Access-Control-Allow-Methods`, so a browser cannot be told two different
|
|
64
|
+
* things about what it may send. (The BEHAVIOR still allows all methods -
|
|
65
|
+
* CloudFront has no narrower set containing POST - which is a separate concern
|
|
66
|
+
* handled by the URI rewrite; see `buildUploadBehavior`.)
|
|
67
|
+
*/
|
|
68
|
+
const UPLOAD_ALLOWED_METHOD = 'POST';
|
|
69
|
+
/**
|
|
70
|
+
* Render a string array as a JavaScript literal for embedding in CloudFront
|
|
71
|
+
* Function source.
|
|
72
|
+
*
|
|
73
|
+
* `JSON.stringify` already escapes quotes and backslashes, so an adopter's
|
|
74
|
+
* origin string cannot terminate the literal and inject code. It does NOT
|
|
75
|
+
* escape U+2028/U+2029, which are legal in JSON but were line terminators in
|
|
76
|
+
* JavaScript before ES2019 - and the CloudFront Functions runtime is not a
|
|
77
|
+
* runtime to find out about on. Escaping them costs one `replace`.
|
|
78
|
+
*/
|
|
79
|
+
function toFunctionLiteral(values) {
|
|
80
|
+
return JSON.stringify(values)
|
|
81
|
+
.replace(/\u2028/g, '\\u2028')
|
|
82
|
+
.replace(/\u2029/g, '\\u2029');
|
|
83
|
+
}
|
|
56
84
|
/** Matches packages/canopycms/src/assets/store-s3.ts's `DEFAULT_MAX_UPLOAD_BYTES`. */
|
|
57
85
|
const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
58
86
|
const TRANSFORM_LAMBDA_MEMORY_MB = 1536;
|
|
@@ -150,6 +178,16 @@ export const ASSETS_TRANSFORM_PATH_PATTERN = `/${PREFIXES.transform}/*`;
|
|
|
150
178
|
* `attachTo()` (below) or `CanopyCmsDistribution`'s `assetSupport` prop
|
|
151
179
|
* instead. Exported so `cms-distribution.ts`'s synth-time guard can
|
|
152
180
|
* recognize the mistake and name it in its error.
|
|
181
|
+
*
|
|
182
|
+
* `uploadBehavior()` deliberately adds NO key here, considered and rejected
|
|
183
|
+
* when it was added. It returns a bare `BehaviorOptions` rather than a named
|
|
184
|
+
* property of a returned object, so there is no spread to get wrong in the
|
|
185
|
+
* first place - and a speculative `'upload'` entry would actively misfire on
|
|
186
|
+
* an adopter whose distribution has a real upload route, since CloudFront
|
|
187
|
+
* treats a path pattern's leading slash as optional and `upload` is a legal
|
|
188
|
+
* spelling of `/upload` (see `normalizePathPattern` in cms-distribution.ts).
|
|
189
|
+
* Keep this list to property names that actually exist on
|
|
190
|
+
* `AssetCloudFrontBehaviors`.
|
|
153
191
|
*/
|
|
154
192
|
export const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS = ['assets', 'assetsTransform'];
|
|
155
193
|
/**
|
|
@@ -169,6 +207,9 @@ export const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS = ['assets', 'assetsTransform'];
|
|
|
169
207
|
* for why the order is the whole point); `assetBehaviors()` is the escape
|
|
170
208
|
* hatch for a distribution built entirely by hand (see
|
|
171
209
|
* `AssetCloudFrontBehaviors`'s doc comment).
|
|
210
|
+
* - `uploadBehavior()`, opt-in, the write half: a CloudFront behavior that
|
|
211
|
+
* accepts the editor's presigned-POST uploads, for adopters who set
|
|
212
|
+
* `media.uploadUrl`. Belongs on its own distribution - see that method.
|
|
172
213
|
* - `grantUploadAccess()`, the exact prefix-scoped grants the CMS/editor
|
|
173
214
|
* principal needs to run `S3AssetStore` (packages/canopycms/src/assets/store-s3.ts).
|
|
174
215
|
*/
|
|
@@ -195,6 +236,32 @@ export class AssetSupport extends Construct {
|
|
|
195
236
|
' pnpm --filter canopycms-cdk run build:lambda');
|
|
196
237
|
}
|
|
197
238
|
this.maxUploadBytes = props.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
239
|
+
// An empty array is treated as absent throughout: CloudFormation rejects a
|
|
240
|
+
// CORS rule whose AllowedOrigins is empty, so `editorOrigins: []` could
|
|
241
|
+
// only ever have been a mistake, and it is better caught by the check
|
|
242
|
+
// below than by a deploy-time template rejection.
|
|
243
|
+
const editorOrigins = props.editorOrigins ?? [];
|
|
244
|
+
// A browser presigned upload needs an Access-Control-Allow-Origin from
|
|
245
|
+
// SOMEWHERE - the bucket's own CORS rule (`editorOrigins`) or the edge
|
|
246
|
+
// (`uploadBehavior`). Standalone mode owns the bucket, so it can tell that
|
|
247
|
+
// neither is configured and say so here rather than let it be discovered
|
|
248
|
+
// in a browser. BYO-bucket mode cannot: the caller owns that bucket's CORS
|
|
249
|
+
// configuration and this construct has no way to read it (`IBucket` has no
|
|
250
|
+
// `addCorsRule`, which is the same reason `editorOrigins` is inert there).
|
|
251
|
+
if (!props.bucket && editorOrigins.length === 0 && !props.uploadBehavior) {
|
|
252
|
+
throw new Error('AssetSupport: standalone mode creates the bucket, and a browser presigned upload needs ' +
|
|
253
|
+
'Access-Control-Allow-Origin from either the bucket or the edge - this construct was ' +
|
|
254
|
+
'given neither. Pass `editorOrigins` (the editor origin(s) that POST uploads), or ' +
|
|
255
|
+
'`uploadBehavior` to route uploads through a CloudFront distribution that supplies the ' +
|
|
256
|
+
'header at the edge. If you have wired an equivalent upload path yourself, outside ' +
|
|
257
|
+
'this construct, pass `editorOrigins` anyway - a bucket CORS rule your own path makes ' +
|
|
258
|
+
'redundant is harmless, and this check cannot see that path.\n' +
|
|
259
|
+
'Worth knowing why this is worth failing on: S3 ACCEPTS a cross-origin presigned POST ' +
|
|
260
|
+
'with no CORS rule at all and simply declines to ADVERTISE it (measured: 204, no ' +
|
|
261
|
+
'ACAO header, object landed). So the object reaches asset-staging/ and the browser ' +
|
|
262
|
+
'still reports the upload as a network error - a failure that looks like anything ' +
|
|
263
|
+
'except missing CORS.');
|
|
264
|
+
}
|
|
198
265
|
if (props.bucket) {
|
|
199
266
|
this.bucket = props.bucket;
|
|
200
267
|
}
|
|
@@ -230,15 +297,20 @@ export class AssetSupport extends Construct {
|
|
|
230
297
|
expiration: props.transformOutputRetention ?? TRANSFORM_OUTPUT_RETENTION,
|
|
231
298
|
},
|
|
232
299
|
],
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
300
|
+
// Omitted entirely when `editorOrigins` is absent - the upload is then
|
|
301
|
+
// going through `uploadBehavior`, which supplies ACAO at the edge, and
|
|
302
|
+
// writing a rule with no origins would be an invalid template anyway.
|
|
303
|
+
cors: editorOrigins.length > 0
|
|
304
|
+
? [
|
|
305
|
+
{
|
|
306
|
+
id: 'editor-presigned-upload',
|
|
307
|
+
allowedOrigins: editorOrigins,
|
|
308
|
+
allowedMethods: [s3.HttpMethods.POST, s3.HttpMethods.PUT, s3.HttpMethods.GET],
|
|
309
|
+
allowedHeaders: ['*'],
|
|
310
|
+
maxAge: CORS_MAX_AGE_SECONDS,
|
|
311
|
+
},
|
|
312
|
+
]
|
|
313
|
+
: undefined,
|
|
242
314
|
});
|
|
243
315
|
}
|
|
244
316
|
// Dedicated CloudWatch log group for the transform Lambda's stdout/
|
|
@@ -325,6 +397,75 @@ export class AssetSupport extends Construct {
|
|
|
325
397
|
authType: lambda.FunctionUrlAuthType.AWS_IAM,
|
|
326
398
|
});
|
|
327
399
|
this.behaviors = this.buildBehaviors();
|
|
400
|
+
// Validated eagerly, built lazily (see `upload`'s comment). An empty
|
|
401
|
+
// `allowedOrigins` is the same class of mistake as an empty
|
|
402
|
+
// `editorOrigins` and gets the same treatment: CloudFormation rejects
|
|
403
|
+
// `AccessControlAllowOrigins` with no items, so silently falling back to
|
|
404
|
+
// the `['*']` default would turn a forwarded-empty-env-var into either a
|
|
405
|
+
// failed deploy or, worse, a wildcard the caller did not ask for.
|
|
406
|
+
if (props.uploadBehavior?.allowedOrigins?.length === 0) {
|
|
407
|
+
throw new Error('AssetSupport: uploadBehavior.allowedOrigins is an empty array. CloudFront requires at ' +
|
|
408
|
+
'least one origin, and defaulting an explicitly-empty list to the wildcard would ' +
|
|
409
|
+
"silently widen what you asked for. Omit the property to take the `['*']` default, " +
|
|
410
|
+
'or list the origin(s) the editor is served from.');
|
|
411
|
+
}
|
|
412
|
+
// Two consumers read `allowedOrigins` and they do not share a matcher: the
|
|
413
|
+
// response headers policy (which decides ACAO on the POST response) accepts
|
|
414
|
+
// CloudFront's pattern grammar, including a leftmost-subdomain wildcard
|
|
415
|
+
// like `https://*.preview.example.com`; the preflight responder compiled
|
|
416
|
+
// into the CloudFront Function can only do exact-string comparison, because
|
|
417
|
+
// reimplementing that grammar in an edge function without being able to
|
|
418
|
+
// test it against CloudFront is how the two silently disagree.
|
|
419
|
+
//
|
|
420
|
+
// So a pattern is refused rather than half-honoured. Accepting one would
|
|
421
|
+
// deploy clean and then fail every upload from a matching origin at the
|
|
422
|
+
// PREFLIGHT, with a correct-looking policy sitting right next to it - the
|
|
423
|
+
// same invisible failure the preflight responder was added to remove.
|
|
424
|
+
const wildcardOrigins = (props.uploadBehavior?.allowedOrigins ?? []).filter((origin) => origin.includes('*'));
|
|
425
|
+
const isBareWildcard = props.uploadBehavior?.allowedOrigins?.join() === '*';
|
|
426
|
+
if (wildcardOrigins.length > 0 && !isBareWildcard) {
|
|
427
|
+
throw new Error(`AssetSupport: uploadBehavior.allowedOrigins contains a wildcard pattern ` +
|
|
428
|
+
`(${wildcardOrigins.map((o) => JSON.stringify(o)).join(', ')}). CloudFront's response ` +
|
|
429
|
+
`headers policy would accept it, but the CORS preflight is answered at the edge by a ` +
|
|
430
|
+
`CloudFront Function that compares origins exactly - so every upload from a matching ` +
|
|
431
|
+
`origin would fail its preflight while the policy looked correct. Use exactly ` +
|
|
432
|
+
`['*'] to allow any origin, or list each editor origin in full.`);
|
|
433
|
+
}
|
|
434
|
+
// Snapshotted, not aliased. The guard above runs now but `allowedOrigins`
|
|
435
|
+
// is not READ until the accessor builds the behavior, so holding the
|
|
436
|
+
// caller's array by reference would let `origins.length = 0` in between
|
|
437
|
+
// walk straight past the check just made.
|
|
438
|
+
this.uploadOptions = props.uploadBehavior && {
|
|
439
|
+
...props.uploadBehavior,
|
|
440
|
+
allowedOrigins: props.uploadBehavior.allowedOrigins?.slice(),
|
|
441
|
+
};
|
|
442
|
+
// Setting `uploadBehavior` SATISFIES the standalone guard above, but only
|
|
443
|
+
// calling `uploadBehavior()` and attaching the result actually produces an
|
|
444
|
+
// Access-Control-Allow-Origin from anywhere. Opting in and stopping there -
|
|
445
|
+
// which is exactly what copying the README snippet and not finishing looks
|
|
446
|
+
// like - lands a standalone bucket with no CORS rule and no edge route: the
|
|
447
|
+
// silent failure the guard's own text calls misleading, reached through the
|
|
448
|
+
// guard rather than around it.
|
|
449
|
+
//
|
|
450
|
+
// A synth-time validation is the only place this can be caught, since the
|
|
451
|
+
// constructor cannot know what the caller will do next. It is the first
|
|
452
|
+
// `addValidation` in this package; the alternative (checking in the
|
|
453
|
+
// constructor) cannot express "and nothing used it".
|
|
454
|
+
this.node.addValidation({
|
|
455
|
+
validate: () => {
|
|
456
|
+
if (props.uploadBehavior && editorOrigins.length === 0 && !props.bucket && !this.upload) {
|
|
457
|
+
return [
|
|
458
|
+
'AssetSupport: `uploadBehavior` was set but uploadBehavior() was never called, so ' +
|
|
459
|
+
'no upload behavior was attached to any distribution and nothing supplies ' +
|
|
460
|
+
'Access-Control-Allow-Origin. With no `editorOrigins` either, a browser upload ' +
|
|
461
|
+
'will land the object in asset-staging/ and still report a network error. Pass ' +
|
|
462
|
+
"the behavior to a distribution (see uploadBehavior()'s doc comment), or pass " +
|
|
463
|
+
'`editorOrigins` instead.',
|
|
464
|
+
];
|
|
465
|
+
}
|
|
466
|
+
return [];
|
|
467
|
+
},
|
|
468
|
+
});
|
|
328
469
|
}
|
|
329
470
|
buildBehaviors() {
|
|
330
471
|
const s3Origin = origins.S3BucketOrigin.withOriginAccessControl(this.bucket);
|
|
@@ -371,6 +512,221 @@ export class AssetSupport extends Construct {
|
|
|
371
512
|
};
|
|
372
513
|
return { assets, assetsTransform };
|
|
373
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* The behavior that accepts the editor's presigned-POST uploads. Every piece
|
|
517
|
+
* below is load-bearing; see `uploadBehavior()` for where the result goes.
|
|
518
|
+
*/
|
|
519
|
+
buildUploadBehavior(options) {
|
|
520
|
+
// A DISTINCT origin for the bucket, with OAC signing OFF - the read
|
|
521
|
+
// behaviors' origin cannot be reused here no matter what this behavior
|
|
522
|
+
// sets, because OAC is a property of the ORIGIN, not the behavior. (On the
|
|
523
|
+
// dedicated distribution `uploadBehavior()` recommends this is the only
|
|
524
|
+
// origin present; the point is that it cannot be the read one.)
|
|
525
|
+
// CloudFront signs origin requests but never hashes
|
|
526
|
+
// the body, so an OAC-signed origin rejects every multipart POST whatever
|
|
527
|
+
// the viewer sent. Measured, that is `400 InvalidArgument`, the body
|
|
528
|
+
// naming the mechanism - `x-amz-content-sha256 must be UNSIGNED-PAYLOAD,
|
|
529
|
+
// ... or a valid sha256 value`. It fails CLOSED, but it is an
|
|
530
|
+
// argument-validation error, NOT the 403 that the Lambda Function URL
|
|
531
|
+
// origin produces in the same situation (docs/deploying-to-aws.md's
|
|
532
|
+
// "CloudFront OAC and request body signing"): there Lambda verifies a
|
|
533
|
+
// SIGNATURE over that header, here S3 validates its VALUE FORMAT. An
|
|
534
|
+
// adopter told to expect 403 goes hunting for a permissions problem that
|
|
535
|
+
// does not exist.
|
|
536
|
+
//
|
|
537
|
+
// `HttpOrigin` rather than `S3BucketOrigin.withBucketDefaults()`, which
|
|
538
|
+
// would also be unsigned, for two reasons. It is the exact shape this
|
|
539
|
+
// whole path was measured against end to end on live AWS resources (204,
|
|
540
|
+
// object landed); and it is the only one of the two that can state the
|
|
541
|
+
// CloudFront->S3 protocol, which for a request carrying a live upload
|
|
542
|
+
// credential in its body should be pinned rather than inferred.
|
|
543
|
+
// `withBucketDefaults()` emits `S3OriginConfig`, which has no
|
|
544
|
+
// `OriginProtocolPolicy` field at all.
|
|
545
|
+
// A dot in the bucket name breaks this origin specifically. S3's wildcard
|
|
546
|
+
// certificate covers one label (`*.s3.<region>.amazonaws.com`), so
|
|
547
|
+
// `my.docs.bucket.s3.<region>.amazonaws.com` fails TLS validation and
|
|
548
|
+
// CloudFront answers 502 on every upload. This is a CUSTOM origin doing
|
|
549
|
+
// ordinary TLS validation; the read path uses an S3-type origin, which
|
|
550
|
+
// CloudFront treats differently, so nothing else here would surface it -
|
|
551
|
+
// and dotted names are most likely in exactly the BYO case ("a site's
|
|
552
|
+
// existing content bucket") this behavior is written for. Only checkable
|
|
553
|
+
// when the name is a real literal, which for an imported bucket it is.
|
|
554
|
+
const bucketName = this.bucket.bucketName;
|
|
555
|
+
if (!Token.isUnresolved(bucketName) && bucketName.includes('.')) {
|
|
556
|
+
throw new Error(`AssetSupport: uploadBehavior cannot use bucket "${bucketName}" - a dot in the bucket ` +
|
|
557
|
+
`name puts an extra label in its regional domain name, which S3's wildcard ` +
|
|
558
|
+
`certificate does not cover, so CloudFront fails TLS to the origin and answers 502 on ` +
|
|
559
|
+
`every upload. The read behaviors are unaffected (they use an S3-type origin). Use a ` +
|
|
560
|
+
`dot-free bucket for uploads, or route uploads somewhere other than this construct.`);
|
|
561
|
+
}
|
|
562
|
+
const uploadOrigin = new origins.HttpOrigin(this.bucket.bucketRegionalDomainName, {
|
|
563
|
+
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
|
|
564
|
+
});
|
|
565
|
+
// S3's POST Object is only valid at the bucket ROOT - without this rewrite
|
|
566
|
+
// the upload gets 405 MethodNotAllowed. It is also what CONTAINS this
|
|
567
|
+
// behavior: rewriting unconditionally means no request arriving here can
|
|
568
|
+
// address a key at all, so the `ALLOW_ALL` below buys an anonymous caller
|
|
569
|
+
// only the bucket-level operations at `/` (list, create, delete bucket).
|
|
570
|
+
// A bucket this construct creates refuses all of them anonymously
|
|
571
|
+
// (BLOCK_ALL, no public policy); in BYO-bucket mode that is the caller's
|
|
572
|
+
// bucket policy to have got right, as it already is for the read path. The
|
|
573
|
+
// only thing that can WRITE either way is a request carrying a valid
|
|
574
|
+
// presigned policy, and only to the single key that policy names. Do not
|
|
575
|
+
// make the rewrite conditional; that gives key addressability back.
|
|
576
|
+
//
|
|
577
|
+
// The same function also drops `Authorization`. A site behind HTTP basic
|
|
578
|
+
// auth otherwise sends S3 a credential it cannot parse
|
|
579
|
+
// (`400 InvalidArgument - Unsupported Authorization Type`), and the
|
|
580
|
+
// presigned POST carries its own authority in the body, so nothing on this
|
|
581
|
+
// route ever wants the header. It is dropped HERE, in the function, rather
|
|
582
|
+
// than by naming it in the origin request policy's `allExcept` list below,
|
|
583
|
+
// and that placement is deliberate: CloudFront rejects `Authorization` in
|
|
584
|
+
// an origin request policy's header ALLOWLIST outright ("The parameter
|
|
585
|
+
// Headers contains Authorization that is not allowed"), and whether the
|
|
586
|
+
// same validation fires on an `allExcept` list is not something synth can
|
|
587
|
+
// tell you - it would surface as a failed `cdk deploy` on the whole stack.
|
|
588
|
+
// A viewer-request function has no such ambiguity: `method` is the only
|
|
589
|
+
// read-only field on the request object, headers are freely mutable, and
|
|
590
|
+
// `Authorization` is the header CloudFront's own basic-auth function
|
|
591
|
+
// examples read. Dropping it at the viewer also means it is gone before
|
|
592
|
+
// any policy runs, so the guarantee does not depend on the policy at all.
|
|
593
|
+
// The same function ALSO answers the CORS preflight, and that is not
|
|
594
|
+
// optional decoration - without it this route cannot work at all.
|
|
595
|
+
//
|
|
596
|
+
// The editor's upload is NOT a CORS simple request, though it looks like
|
|
597
|
+
// one: `xhr-upload.ts` sends multipart/form-data with no custom headers,
|
|
598
|
+
// but it assigns `xhr.upload.onprogress` before `send()`, and registering
|
|
599
|
+
// ANY listener on the `XMLHttpRequestUpload` object is its own disqualifier
|
|
600
|
+
// from the simple-request rules, independent of method, headers and content
|
|
601
|
+
// type. So every browser upload begins with `OPTIONS /`.
|
|
602
|
+
//
|
|
603
|
+
// Nothing else on this route would answer it. CloudFront does not
|
|
604
|
+
// synthesize preflight responses - a response headers policy supplies
|
|
605
|
+
// values for headers "in responses to CORS preflight requests", i.e. it
|
|
606
|
+
// decorates a response that something else produced - and `ALLOW_ALL`
|
|
607
|
+
// forwards the OPTIONS to S3, which with no CORS configuration answers
|
|
608
|
+
// `403 CORSResponse: CORS is not enabled for this bucket`. A preflight that
|
|
609
|
+
// is not a 2xx fails the browser's check whatever headers are attached, so
|
|
610
|
+
// the POST is never sent and the upload dies as an opaque network error.
|
|
611
|
+
//
|
|
612
|
+
// This is why the end-to-end measurements did not catch it: they replayed
|
|
613
|
+
// the POST as a script, which has no preflight. A scripted POST is not an
|
|
614
|
+
// acceptance test for this route; a real browser upload is.
|
|
615
|
+
//
|
|
616
|
+
// Short-circuiting at the viewer also strengthens the containment argument
|
|
617
|
+
// above rather than weakening it - an OPTIONS that never reaches the origin
|
|
618
|
+
// can address nothing.
|
|
619
|
+
const allowedOrigins = options.allowedOrigins ?? ['*'];
|
|
620
|
+
const rewriteToBucketRoot = new cloudfront.Function(this, 'AssetUploadRewriteFunction', {
|
|
621
|
+
code: cloudfront.FunctionCode.fromInline([
|
|
622
|
+
`var ALLOWED_ORIGINS = ${toFunctionLiteral(allowedOrigins)};`,
|
|
623
|
+
'function handler(event) {',
|
|
624
|
+
' var request = event.request;',
|
|
625
|
+
" if (request.method === 'OPTIONS') {",
|
|
626
|
+
' var headers = {',
|
|
627
|
+
` 'access-control-allow-methods': { value: '${UPLOAD_ALLOWED_METHOD}' },`,
|
|
628
|
+
" 'access-control-allow-headers': { value: '*' },",
|
|
629
|
+
` 'access-control-max-age': { value: '${CORS_MAX_AGE_SECONDS}' }`,
|
|
630
|
+
' };',
|
|
631
|
+
// Echo the caller's own Origin when the list is narrowed: a preflight
|
|
632
|
+
// may only be answered with `*` or the single requesting origin, so a
|
|
633
|
+
// multi-entry list cannot be returned verbatim. No match means no
|
|
634
|
+
// ACAO, and the browser's own check refuses the upload - which is the
|
|
635
|
+
// correct outcome, and a clearer one than a 403 here would be.
|
|
636
|
+
" var origin = request.headers.origin ? request.headers.origin.value : '';",
|
|
637
|
+
" if (ALLOWED_ORIGINS.length === 1 && ALLOWED_ORIGINS[0] === '*') {",
|
|
638
|
+
" headers['access-control-allow-origin'] = { value: '*' };",
|
|
639
|
+
' } else if (ALLOWED_ORIGINS.indexOf(origin) !== -1) {',
|
|
640
|
+
" headers['access-control-allow-origin'] = { value: origin };",
|
|
641
|
+
' }',
|
|
642
|
+
" return { statusCode: 204, statusDescription: 'No Content', headers: headers };",
|
|
643
|
+
' }',
|
|
644
|
+
" request.uri = '/';",
|
|
645
|
+
' delete request.headers.authorization;',
|
|
646
|
+
' return request;',
|
|
647
|
+
'}',
|
|
648
|
+
].join('\n')),
|
|
649
|
+
});
|
|
650
|
+
// `denyList('host')` emits CloudFormation's `allExcept`. In the HEADERS
|
|
651
|
+
// dimension only, that is the managed ALL_VIEWER_EXCEPT_HOST_HEADER policy
|
|
652
|
+
// - the shape this path was measured against. It is NOT that policy
|
|
653
|
+
// overall, and must not be replaced by it: the managed one is documented as
|
|
654
|
+
// "Cookies: All, Query strings: All", so adopting it would forward the
|
|
655
|
+
// editor session cookie to S3, which is one of the two hazards this whole
|
|
656
|
+
// policy exists to remove. Only the header behavior is shared.
|
|
657
|
+
//
|
|
658
|
+
// `Authorization` is handled by the function above, not here.
|
|
659
|
+
//
|
|
660
|
+
// Cookies go through `CookiesConfig`, not the header list (a CloudFront
|
|
661
|
+
// Function does not even see them in `request.headers`), so
|
|
662
|
+
// `cookieBehavior.none()` is what strips them: an upload route mounted on
|
|
663
|
+
// a site's own distribution would otherwise hand S3 - and S3's access logs
|
|
664
|
+
// - the editor session cookie. On the dedicated distribution recommended
|
|
665
|
+
// in `uploadBehavior()` a cross-origin XHR without `withCredentials` sends
|
|
666
|
+
// none in the first place; this makes it true either way.
|
|
667
|
+
const originRequestPolicy = new cloudfront.OriginRequestPolicy(this, 'AssetUploadOriginRequestPolicy', {
|
|
668
|
+
cookieBehavior: cloudfront.OriginRequestCookieBehavior.none(),
|
|
669
|
+
headerBehavior: cloudfront.OriginRequestHeaderBehavior.denyList('host'),
|
|
670
|
+
// A presigned POST carries everything in the multipart body.
|
|
671
|
+
queryStringBehavior: cloudfront.OriginRequestQueryStringBehavior.none(),
|
|
672
|
+
});
|
|
673
|
+
// The edge supplies Access-Control-Allow-Origin, scoped to this behavior
|
|
674
|
+
// only. This is the measurement that unblocked the whole design: a
|
|
675
|
+
// response-headers policy DOES attach ACAO to a 2xx from an unsigned S3
|
|
676
|
+
// origin on POST with NO bucket CORS configuration at all (204, `ACAO: *`,
|
|
677
|
+
// object landed). Acceptance and advertisement are independent in S3 -
|
|
678
|
+
// bucket CORS governs only whether S3 advertises - which is exactly the
|
|
679
|
+
// property that lets this construct avoid writing a bucket CORS rule.
|
|
680
|
+
//
|
|
681
|
+
// `originOverride: true` so the header is ours deterministically even on a
|
|
682
|
+
// bucket that does have its own CORS configuration.
|
|
683
|
+
//
|
|
684
|
+
// AllowMethods/AllowHeaders/MaxAge take effect only on a CORS PREFLIGHT,
|
|
685
|
+
// and this path DOES trigger one - see the CloudFront Function above, which
|
|
686
|
+
// answers it, and which exists because nothing else would. They are set
|
|
687
|
+
// here anyway, sharing `UPLOAD_ALLOWED_METHOD` with that function so the
|
|
688
|
+
// two cannot tell a browser different things, and because a response
|
|
689
|
+
// headers policy is documented to decorate responses without saying whether
|
|
690
|
+
// a function-generated one is among them. If it is, `originOverride: true`
|
|
691
|
+
// means the policy's values REPLACE the function's identical ones; if it is
|
|
692
|
+
// not, the function's stand alone. Either way the viewer sees exactly one
|
|
693
|
+
// of each, which is what makes it safe not to know.
|
|
694
|
+
const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, 'AssetUploadResponseHeadersPolicy', {
|
|
695
|
+
corsBehavior: {
|
|
696
|
+
accessControlAllowOrigins: allowedOrigins,
|
|
697
|
+
accessControlAllowCredentials: false,
|
|
698
|
+
accessControlAllowMethods: [UPLOAD_ALLOWED_METHOD],
|
|
699
|
+
accessControlAllowHeaders: ['*'],
|
|
700
|
+
accessControlMaxAge: Duration.seconds(CORS_MAX_AGE_SECONDS),
|
|
701
|
+
originOverride: true,
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
return {
|
|
705
|
+
origin: uploadOrigin,
|
|
706
|
+
// A POST is 405 without this. CloudFront offers no narrower set that
|
|
707
|
+
// includes POST - GET_HEAD, GET_HEAD_OPTIONS and ALL are the only three.
|
|
708
|
+
// The URI rewrite above is what makes that acceptable.
|
|
709
|
+
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
|
|
710
|
+
// HTTPS_ONLY, NOT the read behaviors' REDIRECT_TO_HTTPS. CloudFront
|
|
711
|
+
// redirects with 301, and a browser turns a 301'd POST into a GET - so
|
|
712
|
+
// an `http://` upload URL would silently become a bodyless GET that this
|
|
713
|
+
// behavior rewrites to `/` and S3 refuses, with the file never sent.
|
|
714
|
+
// HTTPS_ONLY answers 403 instead: same refusal, visible.
|
|
715
|
+
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
|
|
716
|
+
// Nothing on this route is cacheable, and CACHING_DISABLED's all-`none`
|
|
717
|
+
// cache key is also what keeps the origin request policy above legal
|
|
718
|
+
// (CloudFront requires it to be a superset of the cache key).
|
|
719
|
+
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
|
|
720
|
+
originRequestPolicy,
|
|
721
|
+
responseHeadersPolicy,
|
|
722
|
+
functionAssociations: [
|
|
723
|
+
{
|
|
724
|
+
function: rewriteToBucketRoot,
|
|
725
|
+
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
|
|
726
|
+
},
|
|
727
|
+
],
|
|
728
|
+
};
|
|
729
|
+
}
|
|
374
730
|
/**
|
|
375
731
|
* The two CloudFront behavior configs this system needs.
|
|
376
732
|
*
|
|
@@ -389,6 +745,62 @@ export class AssetSupport extends Construct {
|
|
|
389
745
|
assetBehaviors() {
|
|
390
746
|
return this.behaviors;
|
|
391
747
|
}
|
|
748
|
+
/**
|
|
749
|
+
* The CloudFront behavior that accepts the editor's presigned-POST uploads -
|
|
750
|
+
* the infrastructure half of `media.uploadUrl`. Requires
|
|
751
|
+
* `AssetSupportProps.uploadBehavior`.
|
|
752
|
+
*
|
|
753
|
+
* GIVE IT ITS OWN DISTRIBUTION, serving this route and nothing else:
|
|
754
|
+
*
|
|
755
|
+
* ```ts
|
|
756
|
+
* const uploads = new cloudfront.Distribution(this, 'AssetUploads', {
|
|
757
|
+
* defaultBehavior: assetSupport.uploadBehavior(),
|
|
758
|
+
* })
|
|
759
|
+
* // media.uploadUrl = `https://${uploads.distributionDomainName}/`
|
|
760
|
+
* ```
|
|
761
|
+
*
|
|
762
|
+
* No custom domain or certificate is needed - the `d111...cloudfront.net`
|
|
763
|
+
* name is a perfectly good `uploadUrl` - and as the default behavior of a
|
|
764
|
+
* one-route distribution there is no path pattern and so no ordering
|
|
765
|
+
* question of the kind `attachTo()` exists to settle.
|
|
766
|
+
*
|
|
767
|
+
* Two shapes this deliberately is NOT, both settled 2026-09-11 (the record
|
|
768
|
+
* is `.claude/future-tasks/resolved/asset-support-upload-behavior.md`):
|
|
769
|
+
*
|
|
770
|
+
* - NOT a behavior on the site's own distribution. `CustomErrorResponses`
|
|
771
|
+
* are distribution-wide, so a site that maps 403 to its own 404 page
|
|
772
|
+
* applies that to S3's upload errors too and the editor reports the
|
|
773
|
+
* substituted status. The site's cookies and any cached basic-auth
|
|
774
|
+
* credential are also live hazards there that have to be stripped by
|
|
775
|
+
* policy and function (`buildUploadBehavior` removes both - cookies via
|
|
776
|
+
* the origin request policy, `Authorization` in the viewer-request
|
|
777
|
+
* function) rather than simply never
|
|
778
|
+
* being sent. It works; it is just strictly worse.
|
|
779
|
+
* - NOT one shared distribution serving asset reads AND writes for every
|
|
780
|
+
* environment. One assets distribution means one `AssetSupport`, and
|
|
781
|
+
* `AssetSupport` owns the transform Lambda - so it would also mean one
|
|
782
|
+
* transform Lambda shared by every environment, and that Lambda ships
|
|
783
|
+
* inside this package. A `canopycms-cdk` bump would then move every
|
|
784
|
+
* environment's asset pipeline at once, which is a graduated rollout
|
|
785
|
+
* traded away for an upload path. Reads and transforms stay
|
|
786
|
+
* per-environment; only the upload route moves.
|
|
787
|
+
*
|
|
788
|
+
* The upload route is the one part of this system with no per-environment
|
|
789
|
+
* code in it - it depends on the bucket and nothing else - which is why it
|
|
790
|
+
* is the part that can be split off this way.
|
|
791
|
+
*/
|
|
792
|
+
uploadBehavior() {
|
|
793
|
+
if (!this.uploadOptions) {
|
|
794
|
+
throw new Error('AssetSupport: uploadBehavior() needs the `uploadBehavior` prop, which is off by ' +
|
|
795
|
+
'default because it is the only thing this construct builds that puts a ' +
|
|
796
|
+
'write-capable, OAC-UNSIGNED origin in front of the bucket. Pass ' +
|
|
797
|
+
'`uploadBehavior: {}` to opt in and take the defaults.');
|
|
798
|
+
}
|
|
799
|
+
// Memoized: calling this twice must not attach two sets of policies, and
|
|
800
|
+
// must not fail on duplicate construct ids.
|
|
801
|
+
this.upload ??= this.buildUploadBehavior(this.uploadOptions);
|
|
802
|
+
return this.upload;
|
|
803
|
+
}
|
|
392
804
|
/**
|
|
393
805
|
* Attach both asset behaviors to a concrete CloudFront distribution, in the
|
|
394
806
|
* only safe order.
|
package/dist/index.d.ts
CHANGED
|
@@ -5,4 +5,4 @@ export type { CanopyCmsServiceProps } from './constructs/cms-service.js';
|
|
|
5
5
|
export { CanopyCmsDistribution } from './constructs/cms-distribution.js';
|
|
6
6
|
export type { CanopyCmsDistributionProps } from './constructs/cms-distribution.js';
|
|
7
7
|
export { AssetSupport } from './constructs/asset-support.js';
|
|
8
|
-
export type { AssetSupportProps, AssetCloudFrontBehaviors } from './constructs/asset-support.js';
|
|
8
|
+
export type { AssetSupportProps, AssetCloudFrontBehaviors, AssetUploadBehaviorOptions, } from './constructs/asset-support.js';
|
|
@@ -4368,6 +4368,66 @@ var init_collection = __esm({
|
|
|
4368
4368
|
}
|
|
4369
4369
|
});
|
|
4370
4370
|
|
|
4371
|
+
// ../canopycms/src/utils/sanitize-href.ts
|
|
4372
|
+
function declaresScheme(url) {
|
|
4373
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(url.trim());
|
|
4374
|
+
}
|
|
4375
|
+
function isImplicitlyOffOrigin(url) {
|
|
4376
|
+
if (declaresScheme(url)) return false;
|
|
4377
|
+
try {
|
|
4378
|
+
return new URL(url, SENTINEL_BASE).origin !== SENTINEL_BASE;
|
|
4379
|
+
} catch {
|
|
4380
|
+
return false;
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
function isHttpUrlOrSameOriginPath(value, opts = {}) {
|
|
4384
|
+
if (value === "") return false;
|
|
4385
|
+
for (let index = 0; index < value.length; index++) {
|
|
4386
|
+
const code = value.charCodeAt(index);
|
|
4387
|
+
if (code <= 32 || code === 127) return false;
|
|
4388
|
+
}
|
|
4389
|
+
if (value.includes("?") || value.includes("#")) return false;
|
|
4390
|
+
if (value.includes("\\")) return false;
|
|
4391
|
+
if (DOT_SEGMENT.test(value)) return false;
|
|
4392
|
+
if (declaresScheme(value)) {
|
|
4393
|
+
if (!/^https?:\/\//i.test(value)) return false;
|
|
4394
|
+
try {
|
|
4395
|
+
new URL(value);
|
|
4396
|
+
return true;
|
|
4397
|
+
} catch {
|
|
4398
|
+
return false;
|
|
4399
|
+
}
|
|
4400
|
+
}
|
|
4401
|
+
if (isImplicitlyOffOrigin(value)) {
|
|
4402
|
+
return opts.allowProtocolRelative === true && value.startsWith("//") && value[2] !== "/";
|
|
4403
|
+
}
|
|
4404
|
+
return value.startsWith("/") && value[1] !== "/";
|
|
4405
|
+
}
|
|
4406
|
+
var SENTINEL_BASE, DOT_SEGMENT;
|
|
4407
|
+
var init_sanitize_href = __esm({
|
|
4408
|
+
"../canopycms/src/utils/sanitize-href.ts"() {
|
|
4409
|
+
"use strict";
|
|
4410
|
+
SENTINEL_BASE = "https://relative.invalid";
|
|
4411
|
+
DOT_SEGMENT = /(^|\/)(\.|%2e){1,2}(\/|$)/i;
|
|
4412
|
+
}
|
|
4413
|
+
});
|
|
4414
|
+
|
|
4415
|
+
// ../canopycms/src/config/schemas/url.ts
|
|
4416
|
+
var uploadTargetUrlSchema, assetMountUrlSchema;
|
|
4417
|
+
var init_url = __esm({
|
|
4418
|
+
"../canopycms/src/config/schemas/url.ts"() {
|
|
4419
|
+
"use strict";
|
|
4420
|
+
init_zod();
|
|
4421
|
+
init_sanitize_href();
|
|
4422
|
+
uploadTargetUrlSchema = external_exports.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value), {
|
|
4423
|
+
message: 'must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
|
|
4424
|
+
});
|
|
4425
|
+
assetMountUrlSchema = external_exports.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value, { allowProtocolRelative: true }), {
|
|
4426
|
+
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'
|
|
4427
|
+
});
|
|
4428
|
+
}
|
|
4429
|
+
});
|
|
4430
|
+
|
|
4371
4431
|
// ../canopycms/src/config/schemas/media.ts
|
|
4372
4432
|
var mediaSchema;
|
|
4373
4433
|
var init_media = __esm({
|
|
@@ -4375,25 +4435,41 @@ var init_media = __esm({
|
|
|
4375
4435
|
"use strict";
|
|
4376
4436
|
init_zod();
|
|
4377
4437
|
init_collection();
|
|
4438
|
+
init_url();
|
|
4378
4439
|
mediaSchema = external_exports.discriminatedUnion("adapter", [
|
|
4379
4440
|
external_exports.object({
|
|
4380
4441
|
adapter: external_exports.literal("local"),
|
|
4381
|
-
publicBaseUrl:
|
|
4442
|
+
publicBaseUrl: assetMountUrlSchema.optional(),
|
|
4382
4443
|
/** Root directory for local asset storage. Defaults to the caller's dev-assets dir. */
|
|
4383
4444
|
directory: relativePathSchema.optional()
|
|
4384
|
-
}),
|
|
4445
|
+
}).strict(),
|
|
4385
4446
|
external_exports.object({
|
|
4386
4447
|
adapter: external_exports.literal("s3"),
|
|
4387
4448
|
bucket: external_exports.string().min(1),
|
|
4388
4449
|
region: external_exports.string().min(1),
|
|
4389
|
-
publicBaseUrl:
|
|
4450
|
+
publicBaseUrl: assetMountUrlSchema.optional(),
|
|
4451
|
+
/**
|
|
4452
|
+
* Where the browser POSTs a presigned direct upload. Absolute http(s) URL or a
|
|
4453
|
+
* site-relative path; defaults to the S3 REST endpoint the AWS SDK returns.
|
|
4454
|
+
*
|
|
4455
|
+
* Set this to route uploads through your own CDN so they are same-origin with the
|
|
4456
|
+
* editor, which removes the need for a bucket CORS rule naming an exact origin. The
|
|
4457
|
+
* signature is unaffected — a presigned POST's string-to-sign is the base64 policy
|
|
4458
|
+
* alone, so the host never enters it (pinned by store-s3.test.ts).
|
|
4459
|
+
*
|
|
4460
|
+
* NOT a prefix. Unlike `publicBaseUrl`, nothing is joined onto this value; it replaces
|
|
4461
|
+
* the POST target outright and is passed through byte-for-byte. Do not route it through
|
|
4462
|
+
* `joinUrlPrefix` "for consistency" — that would strip the trailing slash and silently
|
|
4463
|
+
* stop a CDN path pattern like `/asset-upload/*` from matching.
|
|
4464
|
+
*/
|
|
4465
|
+
uploadUrl: uploadTargetUrlSchema.optional(),
|
|
4390
4466
|
/** Max upload size in bytes for presigned direct uploads. Defaults to 50 MiB. */
|
|
4391
4467
|
maxUploadBytes: external_exports.number().int().positive().optional()
|
|
4392
|
-
}),
|
|
4468
|
+
}).strict(),
|
|
4393
4469
|
external_exports.object({
|
|
4394
4470
|
adapter: external_exports.literal("lfs"),
|
|
4395
|
-
publicBaseUrl:
|
|
4396
|
-
})
|
|
4471
|
+
publicBaseUrl: assetMountUrlSchema.optional()
|
|
4472
|
+
}).strict()
|
|
4397
4473
|
]);
|
|
4398
4474
|
}
|
|
4399
4475
|
});
|
|
@@ -28367,6 +28443,9 @@ var BLOCK_STRUCTURAL_KEYS = new Set(BLOCK_DISCRIMINATOR_KEYS);
|
|
|
28367
28443
|
init_error();
|
|
28368
28444
|
init_logger();
|
|
28369
28445
|
|
|
28446
|
+
// ../canopycms/src/utils/url-prefix.ts
|
|
28447
|
+
init_sanitize_href();
|
|
28448
|
+
|
|
28370
28449
|
// ../canopycms/src/static/seo.ts
|
|
28371
28450
|
var DEFAULT_SEO_FIELD_NAMES = {
|
|
28372
28451
|
title: "metaTitle",
|
|
@@ -30365,6 +30444,7 @@ var DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
|
30365
30444
|
// ../canopycms/src/assets/store-s3.ts
|
|
30366
30445
|
var import_client_s3 = require("@aws-sdk/client-s3");
|
|
30367
30446
|
var import_s3_presigned_post = require("@aws-sdk/s3-presigned-post");
|
|
30447
|
+
init_sanitize_href();
|
|
30368
30448
|
var DEFAULT_MAX_UPLOAD_BYTES2 = 50 * 1024 * 1024;
|
|
30369
30449
|
var PRESIGN_EXPIRY_SECONDS = 15 * 60;
|
|
30370
30450
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canopycms-cdk",
|
|
3
|
-
"version": "0.0.66-int.
|
|
3
|
+
"version": "0.0.66-int.86",
|
|
4
4
|
"description": "AWS CDK constructs and EC2 worker for CanopyCMS deployment",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
"types": "dist/index.d.ts",
|
|
15
15
|
"exports": {
|
|
16
16
|
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
17
18
|
"import": "./dist/index.js",
|
|
18
|
-
"
|
|
19
|
+
"require": "./dist/index.js"
|
|
19
20
|
}
|
|
20
21
|
},
|
|
21
22
|
"files": [
|
|
@@ -25,12 +26,12 @@
|
|
|
25
26
|
"lambda/asset-transform/dist"
|
|
26
27
|
],
|
|
27
28
|
"engines": {
|
|
28
|
-
"node": ">=
|
|
29
|
+
"node": ">=22.12.0"
|
|
29
30
|
},
|
|
30
31
|
"peerDependencies": {
|
|
31
32
|
"aws-cdk-lib": "^2.260.0",
|
|
32
33
|
"constructs": "^10.0.0",
|
|
33
|
-
"canopycms": "0.0.66-int.
|
|
34
|
+
"canopycms": "0.0.66-int.86"
|
|
34
35
|
},
|
|
35
36
|
"peerDependenciesMeta": {
|
|
36
37
|
"aws-cdk-lib": {
|
|
@@ -54,8 +55,8 @@
|
|
|
54
55
|
"tsx": "^4.19.0",
|
|
55
56
|
"typescript": "^5.6.3",
|
|
56
57
|
"vitest": "^4.1.0",
|
|
57
|
-
"canopycms
|
|
58
|
-
"canopycms": "0.0.66-int.
|
|
58
|
+
"canopycms": "0.0.66-int.86",
|
|
59
|
+
"canopycms-auth-clerk": "0.0.66-int.86"
|
|
59
60
|
},
|
|
60
61
|
"scripts": {
|
|
61
62
|
"build": "rm -rf dist && tsc && node ../../scripts/add-js-extensions.mjs dist",
|
package/worker/dist/index.js
CHANGED
|
@@ -12366,6 +12366,66 @@ var init_collection = __esm({
|
|
|
12366
12366
|
}
|
|
12367
12367
|
});
|
|
12368
12368
|
|
|
12369
|
+
// ../canopycms/src/utils/sanitize-href.ts
|
|
12370
|
+
function declaresScheme(url) {
|
|
12371
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(url.trim());
|
|
12372
|
+
}
|
|
12373
|
+
function isImplicitlyOffOrigin(url) {
|
|
12374
|
+
if (declaresScheme(url)) return false;
|
|
12375
|
+
try {
|
|
12376
|
+
return new URL(url, SENTINEL_BASE).origin !== SENTINEL_BASE;
|
|
12377
|
+
} catch {
|
|
12378
|
+
return false;
|
|
12379
|
+
}
|
|
12380
|
+
}
|
|
12381
|
+
function isHttpUrlOrSameOriginPath(value, opts = {}) {
|
|
12382
|
+
if (value === "") return false;
|
|
12383
|
+
for (let index = 0; index < value.length; index++) {
|
|
12384
|
+
const code = value.charCodeAt(index);
|
|
12385
|
+
if (code <= 32 || code === 127) return false;
|
|
12386
|
+
}
|
|
12387
|
+
if (value.includes("?") || value.includes("#")) return false;
|
|
12388
|
+
if (value.includes("\\")) return false;
|
|
12389
|
+
if (DOT_SEGMENT.test(value)) return false;
|
|
12390
|
+
if (declaresScheme(value)) {
|
|
12391
|
+
if (!/^https?:\/\//i.test(value)) return false;
|
|
12392
|
+
try {
|
|
12393
|
+
new URL(value);
|
|
12394
|
+
return true;
|
|
12395
|
+
} catch {
|
|
12396
|
+
return false;
|
|
12397
|
+
}
|
|
12398
|
+
}
|
|
12399
|
+
if (isImplicitlyOffOrigin(value)) {
|
|
12400
|
+
return opts.allowProtocolRelative === true && value.startsWith("//") && value[2] !== "/";
|
|
12401
|
+
}
|
|
12402
|
+
return value.startsWith("/") && value[1] !== "/";
|
|
12403
|
+
}
|
|
12404
|
+
var SENTINEL_BASE, DOT_SEGMENT;
|
|
12405
|
+
var init_sanitize_href = __esm({
|
|
12406
|
+
"../canopycms/src/utils/sanitize-href.ts"() {
|
|
12407
|
+
"use strict";
|
|
12408
|
+
SENTINEL_BASE = "https://relative.invalid";
|
|
12409
|
+
DOT_SEGMENT = /(^|\/)(\.|%2e){1,2}(\/|$)/i;
|
|
12410
|
+
}
|
|
12411
|
+
});
|
|
12412
|
+
|
|
12413
|
+
// ../canopycms/src/config/schemas/url.ts
|
|
12414
|
+
var uploadTargetUrlSchema, assetMountUrlSchema;
|
|
12415
|
+
var init_url = __esm({
|
|
12416
|
+
"../canopycms/src/config/schemas/url.ts"() {
|
|
12417
|
+
"use strict";
|
|
12418
|
+
init_zod();
|
|
12419
|
+
init_sanitize_href();
|
|
12420
|
+
uploadTargetUrlSchema = external_exports.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value), {
|
|
12421
|
+
message: 'must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
|
|
12422
|
+
});
|
|
12423
|
+
assetMountUrlSchema = external_exports.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value, { allowProtocolRelative: true }), {
|
|
12424
|
+
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'
|
|
12425
|
+
});
|
|
12426
|
+
}
|
|
12427
|
+
});
|
|
12428
|
+
|
|
12369
12429
|
// ../canopycms/src/config/schemas/media.ts
|
|
12370
12430
|
var mediaSchema;
|
|
12371
12431
|
var init_media = __esm({
|
|
@@ -12373,25 +12433,41 @@ var init_media = __esm({
|
|
|
12373
12433
|
"use strict";
|
|
12374
12434
|
init_zod();
|
|
12375
12435
|
init_collection();
|
|
12436
|
+
init_url();
|
|
12376
12437
|
mediaSchema = external_exports.discriminatedUnion("adapter", [
|
|
12377
12438
|
external_exports.object({
|
|
12378
12439
|
adapter: external_exports.literal("local"),
|
|
12379
|
-
publicBaseUrl:
|
|
12440
|
+
publicBaseUrl: assetMountUrlSchema.optional(),
|
|
12380
12441
|
/** Root directory for local asset storage. Defaults to the caller's dev-assets dir. */
|
|
12381
12442
|
directory: relativePathSchema.optional()
|
|
12382
|
-
}),
|
|
12443
|
+
}).strict(),
|
|
12383
12444
|
external_exports.object({
|
|
12384
12445
|
adapter: external_exports.literal("s3"),
|
|
12385
12446
|
bucket: external_exports.string().min(1),
|
|
12386
12447
|
region: external_exports.string().min(1),
|
|
12387
|
-
publicBaseUrl:
|
|
12448
|
+
publicBaseUrl: assetMountUrlSchema.optional(),
|
|
12449
|
+
/**
|
|
12450
|
+
* Where the browser POSTs a presigned direct upload. Absolute http(s) URL or a
|
|
12451
|
+
* site-relative path; defaults to the S3 REST endpoint the AWS SDK returns.
|
|
12452
|
+
*
|
|
12453
|
+
* Set this to route uploads through your own CDN so they are same-origin with the
|
|
12454
|
+
* editor, which removes the need for a bucket CORS rule naming an exact origin. The
|
|
12455
|
+
* signature is unaffected — a presigned POST's string-to-sign is the base64 policy
|
|
12456
|
+
* alone, so the host never enters it (pinned by store-s3.test.ts).
|
|
12457
|
+
*
|
|
12458
|
+
* NOT a prefix. Unlike `publicBaseUrl`, nothing is joined onto this value; it replaces
|
|
12459
|
+
* the POST target outright and is passed through byte-for-byte. Do not route it through
|
|
12460
|
+
* `joinUrlPrefix` "for consistency" — that would strip the trailing slash and silently
|
|
12461
|
+
* stop a CDN path pattern like `/asset-upload/*` from matching.
|
|
12462
|
+
*/
|
|
12463
|
+
uploadUrl: uploadTargetUrlSchema.optional(),
|
|
12388
12464
|
/** Max upload size in bytes for presigned direct uploads. Defaults to 50 MiB. */
|
|
12389
12465
|
maxUploadBytes: external_exports.number().int().positive().optional()
|
|
12390
|
-
}),
|
|
12466
|
+
}).strict(),
|
|
12391
12467
|
external_exports.object({
|
|
12392
12468
|
adapter: external_exports.literal("lfs"),
|
|
12393
|
-
publicBaseUrl:
|
|
12394
|
-
})
|
|
12469
|
+
publicBaseUrl: assetMountUrlSchema.optional()
|
|
12470
|
+
}).strict()
|
|
12395
12471
|
]);
|
|
12396
12472
|
}
|
|
12397
12473
|
});
|