@anthusai/papyrus 1.0.0-next.43 → 1.0.0-next.45
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/amplify/functions/content-actions/handler.py +54 -1
- package/amplify/functions/content-actions/requirements.txt +2 -1
- package/amplify/functions/content-actions/resource.js +6 -1
- package/amplify/functions/content-actions/resource.ts +9 -2
- package/amplify/functions/renditions/handler.py +94 -0
- package/amplify/functions/renditions/requirements.txt +4 -0
- package/amplify/functions/shared/python-bundle.js +42 -2
- package/amplify/functions/shared/python-bundle.ts +47 -2
- package/amplify/publication/redirect-and-index.js +37 -0
- package/amplify/publication/renditions-function.js +74 -0
- package/amplify/publication/renditions-function.ts +112 -0
- package/amplify/publication/renditions-settings.js +43 -0
- package/amplify/publication/renditions-settings.ts +50 -0
- package/amplify/publication/s3-projection-settings.js +63 -0
- package/amplify/publication/s3-projection-settings.ts +98 -0
- package/amplify/publication/s3-projection.js +126 -0
- package/amplify/publication/s3-projection.ts +174 -0
- package/amplify/site-backend-config.js +51 -1
- package/amplify/site-backend-config.ts +76 -0
- package/amplify/site-backend.js +27 -3
- package/amplify/site-backend.ts +27 -4
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning for the upload-time renditions function of an s3-projection
|
|
3
|
+
* site (no CDK imports, so it can be tested without synthesizing).
|
|
4
|
+
*
|
|
5
|
+
* The function exists only for a site whose `projection.json` carries an
|
|
6
|
+
* `images` section: that section is the one place the image pipeline settings
|
|
7
|
+
* live, shared by the renditions function and the projector.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
export const RENDITIONS_MEMORY_MB = 3008;
|
|
12
|
+
export const RENDITIONS_TIMEOUT_SECONDS = 300;
|
|
13
|
+
export const RENDITIONS_EPHEMERAL_STORAGE_MB = 2048;
|
|
14
|
+
export const RENDITIONS_STATIC_DIRECTORY = "/var/task/site_static";
|
|
15
|
+
export const RENDITIONS_MEDIA_PREFIX = "media/";
|
|
16
|
+
export const RENDITIONS_FUNCTION_NAME_ENV = "PAPYRUS_RENDITIONS_FUNCTION_NAME";
|
|
17
|
+
export function projectionSettingsHaveImages(settingsPath) {
|
|
18
|
+
if (!fs.existsSync(settingsPath))
|
|
19
|
+
return false;
|
|
20
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
21
|
+
return typeof parsed.images === "object" && parsed.images !== null;
|
|
22
|
+
}
|
|
23
|
+
export function planRenditions(publication, workingDirectory) {
|
|
24
|
+
if (publication?.staticDir === undefined)
|
|
25
|
+
return { enabled: false, siteFiles: [] };
|
|
26
|
+
const settingsFile = path.join(publication.staticDir, "projection.json");
|
|
27
|
+
if (!projectionSettingsHaveImages(path.resolve(workingDirectory, settingsFile)))
|
|
28
|
+
return { enabled: false, siteFiles: [] };
|
|
29
|
+
return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
|
|
30
|
+
}
|
|
31
|
+
export function renditionsEventPattern(mediaBucketName) {
|
|
32
|
+
return {
|
|
33
|
+
source: ["aws.s3"],
|
|
34
|
+
detailType: ["Object Created"],
|
|
35
|
+
detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function renditionsEnvironment(siteBucketName) {
|
|
39
|
+
return {
|
|
40
|
+
PAPYRUS_SITE_BUCKET: siteBucketName,
|
|
41
|
+
PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning for the upload-time renditions function of an s3-projection
|
|
3
|
+
* site (no CDK imports, so it can be tested without synthesizing).
|
|
4
|
+
*
|
|
5
|
+
* The function exists only for a site whose `projection.json` carries an
|
|
6
|
+
* `images` section: that section is the one place the image pipeline settings
|
|
7
|
+
* live, shared by the renditions function and the projector.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
export const RENDITIONS_MEMORY_MB = 3008;
|
|
13
|
+
export const RENDITIONS_TIMEOUT_SECONDS = 300;
|
|
14
|
+
export const RENDITIONS_EPHEMERAL_STORAGE_MB = 2048;
|
|
15
|
+
export const RENDITIONS_STATIC_DIRECTORY = "/var/task/site_static";
|
|
16
|
+
export const RENDITIONS_MEDIA_PREFIX = "media/";
|
|
17
|
+
export const RENDITIONS_FUNCTION_NAME_ENV = "PAPYRUS_RENDITIONS_FUNCTION_NAME";
|
|
18
|
+
|
|
19
|
+
export type RenditionsPlan = {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
siteFiles: Array<{ from: string; to: string }>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function projectionSettingsHaveImages(settingsPath: string): boolean {
|
|
25
|
+
if (!fs.existsSync(settingsPath)) return false;
|
|
26
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
|
|
27
|
+
return typeof parsed.images === "object" && parsed.images !== null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function planRenditions(publication: { staticDir?: string } | undefined, workingDirectory: string): RenditionsPlan {
|
|
31
|
+
if (publication?.staticDir === undefined) return { enabled: false, siteFiles: [] };
|
|
32
|
+
const settingsFile = path.join(publication.staticDir, "projection.json");
|
|
33
|
+
if (!projectionSettingsHaveImages(path.resolve(workingDirectory, settingsFile))) return { enabled: false, siteFiles: [] };
|
|
34
|
+
return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function renditionsEventPattern(mediaBucketName: string): Record<string, unknown> {
|
|
38
|
+
return {
|
|
39
|
+
source: ["aws.s3"],
|
|
40
|
+
detailType: ["Object Created"],
|
|
41
|
+
detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renditionsEnvironment(siteBucketName: string): Record<string, string> {
|
|
46
|
+
return {
|
|
47
|
+
PAPYRUS_SITE_BUCKET: siteBucketName,
|
|
48
|
+
PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const S3_PROJECTION_PROJECTOR_MODULE = "site_projector";
|
|
2
|
+
export const S3_PROJECTION_STATIC_DIRECTORY = "/var/task/site_static";
|
|
3
|
+
export function distributionIdFromArn(distributionArn) {
|
|
4
|
+
return distributionArn.slice(distributionArn.lastIndexOf("/") + 1);
|
|
5
|
+
}
|
|
6
|
+
export function s3ProjectionEnvironment(input) {
|
|
7
|
+
const environment = {
|
|
8
|
+
PAPYRUS_PUBLICATION_KIND: "s3-projection",
|
|
9
|
+
PAPYRUS_SITE_BUCKET: input.bucketName,
|
|
10
|
+
};
|
|
11
|
+
const distributionId = input.distributionId
|
|
12
|
+
?? (input.distributionArn !== undefined ? distributionIdFromArn(input.distributionArn) : undefined);
|
|
13
|
+
if (distributionId !== undefined)
|
|
14
|
+
environment.PAPYRUS_EDGE_DISTRIBUTION_ID = distributionId;
|
|
15
|
+
environment.PAPYRUS_REDIRECTS_KVS_ARN = input.keyValueStoreArn;
|
|
16
|
+
environment.PAPYRUS_PROJECTION_DLQ_URL = input.deadLetterQueueUrl;
|
|
17
|
+
if (input.publicHostName !== undefined)
|
|
18
|
+
environment.PAPYRUS_SITE_PUBLIC_URL = `https://${input.publicHostName}`;
|
|
19
|
+
if (input.projector !== undefined)
|
|
20
|
+
environment.PAPYRUS_SITE_PROJECTOR = S3_PROJECTION_PROJECTOR_MODULE;
|
|
21
|
+
if (input.staticDir !== undefined)
|
|
22
|
+
environment.PAPYRUS_SITE_STATIC_DIR = S3_PROJECTION_STATIC_DIRECTORY;
|
|
23
|
+
return environment;
|
|
24
|
+
}
|
|
25
|
+
export function s3ProjectionPolicyStatements(input) {
|
|
26
|
+
const statements = [
|
|
27
|
+
{ actions: ["s3:PutObject", "s3:DeleteObject", "s3:GetObject"], resources: [`${input.bucketArn}/*`] },
|
|
28
|
+
{ actions: ["s3:ListBucket"], resources: [input.bucketArn] },
|
|
29
|
+
];
|
|
30
|
+
if (input.distributionArn !== undefined) {
|
|
31
|
+
statements.push({ actions: ["cloudfront:CreateInvalidation"], resources: [input.distributionArn] });
|
|
32
|
+
}
|
|
33
|
+
statements.push({
|
|
34
|
+
actions: [
|
|
35
|
+
"cloudfront-keyvaluestore:DescribeKeyValueStore",
|
|
36
|
+
"cloudfront-keyvaluestore:GetKey",
|
|
37
|
+
"cloudfront-keyvaluestore:PutKey",
|
|
38
|
+
"cloudfront-keyvaluestore:DeleteKey",
|
|
39
|
+
"cloudfront-keyvaluestore:UpdateKeys",
|
|
40
|
+
"cloudfront-keyvaluestore:ListKeys",
|
|
41
|
+
],
|
|
42
|
+
resources: [input.keyValueStoreArn],
|
|
43
|
+
}, { actions: ["sqs:SendMessage"], resources: [input.deadLetterQueueArn] });
|
|
44
|
+
return statements;
|
|
45
|
+
}
|
|
46
|
+
export const S3_PROJECTION_CONTENT_ACTIONS_MEMORY_MB = 2048;
|
|
47
|
+
export function planS3ProjectionBackend(publication) {
|
|
48
|
+
if (publication === undefined)
|
|
49
|
+
return { enabled: false, siteFiles: [], contentActionsMemorySize: undefined };
|
|
50
|
+
const siteFiles = [];
|
|
51
|
+
if (publication.projector !== undefined) {
|
|
52
|
+
siteFiles.push({ from: publication.projector, to: `${S3_PROJECTION_PROJECTOR_MODULE}.py` });
|
|
53
|
+
}
|
|
54
|
+
if (publication.staticDir !== undefined) {
|
|
55
|
+
siteFiles.push({ from: publication.staticDir, to: "site_static" });
|
|
56
|
+
}
|
|
57
|
+
return { enabled: true, siteFiles, contentActionsMemorySize: S3_PROJECTION_CONTENT_ACTIONS_MEMORY_MB };
|
|
58
|
+
}
|
|
59
|
+
export function contentActionsPublicationWiring(publication) {
|
|
60
|
+
if (publication === undefined)
|
|
61
|
+
return { environment: {}, policyStatements: [] };
|
|
62
|
+
return { environment: publication.environment, policyStatements: publication.policyStatements };
|
|
63
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment contract and IAM statements for the s3-projection publication.
|
|
3
|
+
* Pure (no CDK imports) so both can be tested without synthesizing.
|
|
4
|
+
*/
|
|
5
|
+
export type S3ProjectionSettingsInput = {
|
|
6
|
+
bucketName: string;
|
|
7
|
+
bucketArn: string;
|
|
8
|
+
keyValueStoreArn: string;
|
|
9
|
+
deadLetterQueueUrl: string;
|
|
10
|
+
deadLetterQueueArn: string;
|
|
11
|
+
distributionArn?: string;
|
|
12
|
+
distributionId?: string;
|
|
13
|
+
publicHostName?: string;
|
|
14
|
+
projector?: string;
|
|
15
|
+
staticDir?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type S3ProjectionPolicyStatementSpec = {
|
|
19
|
+
actions: string[];
|
|
20
|
+
resources: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const S3_PROJECTION_PROJECTOR_MODULE = "site_projector";
|
|
24
|
+
export const S3_PROJECTION_STATIC_DIRECTORY = "/var/task/site_static";
|
|
25
|
+
|
|
26
|
+
export function distributionIdFromArn(distributionArn: string): string {
|
|
27
|
+
return distributionArn.slice(distributionArn.lastIndexOf("/") + 1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function s3ProjectionEnvironment(input: S3ProjectionSettingsInput): Record<string, string> {
|
|
31
|
+
const environment: Record<string, string> = {
|
|
32
|
+
PAPYRUS_PUBLICATION_KIND: "s3-projection",
|
|
33
|
+
PAPYRUS_SITE_BUCKET: input.bucketName,
|
|
34
|
+
};
|
|
35
|
+
const distributionId = input.distributionId
|
|
36
|
+
?? (input.distributionArn !== undefined ? distributionIdFromArn(input.distributionArn) : undefined);
|
|
37
|
+
if (distributionId !== undefined) environment.PAPYRUS_EDGE_DISTRIBUTION_ID = distributionId;
|
|
38
|
+
environment.PAPYRUS_REDIRECTS_KVS_ARN = input.keyValueStoreArn;
|
|
39
|
+
environment.PAPYRUS_PROJECTION_DLQ_URL = input.deadLetterQueueUrl;
|
|
40
|
+
if (input.publicHostName !== undefined) environment.PAPYRUS_SITE_PUBLIC_URL = `https://${input.publicHostName}`;
|
|
41
|
+
if (input.projector !== undefined) environment.PAPYRUS_SITE_PROJECTOR = S3_PROJECTION_PROJECTOR_MODULE;
|
|
42
|
+
if (input.staticDir !== undefined) environment.PAPYRUS_SITE_STATIC_DIR = S3_PROJECTION_STATIC_DIRECTORY;
|
|
43
|
+
return environment;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function s3ProjectionPolicyStatements(input: S3ProjectionSettingsInput): S3ProjectionPolicyStatementSpec[] {
|
|
47
|
+
const statements: S3ProjectionPolicyStatementSpec[] = [
|
|
48
|
+
{ actions: ["s3:PutObject", "s3:DeleteObject", "s3:GetObject"], resources: [`${input.bucketArn}/*`] },
|
|
49
|
+
{ actions: ["s3:ListBucket"], resources: [input.bucketArn] },
|
|
50
|
+
];
|
|
51
|
+
if (input.distributionArn !== undefined) {
|
|
52
|
+
statements.push({ actions: ["cloudfront:CreateInvalidation"], resources: [input.distributionArn] });
|
|
53
|
+
}
|
|
54
|
+
statements.push(
|
|
55
|
+
{
|
|
56
|
+
actions: [
|
|
57
|
+
"cloudfront-keyvaluestore:DescribeKeyValueStore",
|
|
58
|
+
"cloudfront-keyvaluestore:GetKey",
|
|
59
|
+
"cloudfront-keyvaluestore:PutKey",
|
|
60
|
+
"cloudfront-keyvaluestore:DeleteKey",
|
|
61
|
+
"cloudfront-keyvaluestore:UpdateKeys",
|
|
62
|
+
"cloudfront-keyvaluestore:ListKeys",
|
|
63
|
+
],
|
|
64
|
+
resources: [input.keyValueStoreArn],
|
|
65
|
+
},
|
|
66
|
+
{ actions: ["sqs:SendMessage"], resources: [input.deadLetterQueueArn] },
|
|
67
|
+
);
|
|
68
|
+
return statements;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type S3ProjectionBackendPlan = {
|
|
72
|
+
enabled: boolean;
|
|
73
|
+
siteFiles: Array<{ from: string; to: string }>;
|
|
74
|
+
contentActionsMemorySize: number | undefined;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const S3_PROJECTION_CONTENT_ACTIONS_MEMORY_MB = 2048;
|
|
78
|
+
|
|
79
|
+
export function planS3ProjectionBackend(
|
|
80
|
+
publication: { projector?: string; staticDir?: string } | undefined,
|
|
81
|
+
): S3ProjectionBackendPlan {
|
|
82
|
+
if (publication === undefined) return { enabled: false, siteFiles: [], contentActionsMemorySize: undefined };
|
|
83
|
+
const siteFiles: Array<{ from: string; to: string }> = [];
|
|
84
|
+
if (publication.projector !== undefined) {
|
|
85
|
+
siteFiles.push({ from: publication.projector, to: `${S3_PROJECTION_PROJECTOR_MODULE}.py` });
|
|
86
|
+
}
|
|
87
|
+
if (publication.staticDir !== undefined) {
|
|
88
|
+
siteFiles.push({ from: publication.staticDir, to: "site_static" });
|
|
89
|
+
}
|
|
90
|
+
return { enabled: true, siteFiles, contentActionsMemorySize: S3_PROJECTION_CONTENT_ACTIONS_MEMORY_MB };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function contentActionsPublicationWiring<Statement>(
|
|
94
|
+
publication: { environment: Record<string, string>; policyStatements: Statement[] } | undefined,
|
|
95
|
+
): { environment: Record<string, string>; policyStatements: Statement[] } {
|
|
96
|
+
if (publication === undefined) return { environment: {}, policyStatements: [] };
|
|
97
|
+
return { environment: publication.environment, policyStatements: publication.policyStatements };
|
|
98
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
2
|
+
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
|
3
|
+
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
|
4
|
+
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
|
|
5
|
+
import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
6
|
+
import * as route53 from "aws-cdk-lib/aws-route53";
|
|
7
|
+
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
8
|
+
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { assertValidPublicationConfig } from "../site-backend-config.js";
|
|
12
|
+
import { addRenditionsFunction } from "./renditions-function.js";
|
|
13
|
+
import { RENDITIONS_FUNCTION_NAME_ENV } from "./renditions-settings.js";
|
|
14
|
+
import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
|
|
15
|
+
const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
|
|
16
|
+
function createSiteBucket(scope, retainBucketOnDelete) {
|
|
17
|
+
return new s3.Bucket(scope, "SiteBucket", {
|
|
18
|
+
versioned: true,
|
|
19
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
20
|
+
enforceSSL: true,
|
|
21
|
+
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
22
|
+
lifecycleRules: [{ noncurrentVersionExpiration: Duration.days(90) }],
|
|
23
|
+
removalPolicy: retainBucketOnDelete ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
|
|
24
|
+
autoDeleteObjects: !retainBucketOnDelete,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function createDnsValidatedCertificate(scope, aliases, hostedZone) {
|
|
28
|
+
const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "SiteHostedZone", {
|
|
29
|
+
hostedZoneId: hostedZone.hostedZoneId,
|
|
30
|
+
zoneName: hostedZone.hostedZoneName,
|
|
31
|
+
});
|
|
32
|
+
return new acm.DnsValidatedCertificate(scope, "SiteCertificate", {
|
|
33
|
+
domainName: aliases[0],
|
|
34
|
+
subjectAlternativeNames: aliases.slice(1),
|
|
35
|
+
hostedZone: zone,
|
|
36
|
+
region: "us-east-1",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function createDistribution(scope, props, bucket, redirectFunction) {
|
|
40
|
+
const aliases = props.distribution?.aliases ?? [];
|
|
41
|
+
let certificate;
|
|
42
|
+
if (aliases.length > 0) {
|
|
43
|
+
certificate = props.distribution?.acm !== undefined
|
|
44
|
+
? createDnsValidatedCertificate(scope, aliases, props.distribution.acm)
|
|
45
|
+
: acm.Certificate.fromCertificateArn(scope, "SiteCertificate", props.distribution?.certificateArn ?? "");
|
|
46
|
+
}
|
|
47
|
+
return new cloudfront.Distribution(scope, "SiteDistribution", {
|
|
48
|
+
defaultRootObject: "index.html",
|
|
49
|
+
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
|
|
50
|
+
enableLogging: false,
|
|
51
|
+
domainNames: aliases.length > 0 ? aliases : undefined,
|
|
52
|
+
certificate,
|
|
53
|
+
defaultBehavior: {
|
|
54
|
+
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
|
|
55
|
+
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
56
|
+
compress: true,
|
|
57
|
+
functionAssociations: [{ function: redirectFunction, eventType: cloudfront.FunctionEventType.VIEWER_REQUEST }],
|
|
58
|
+
},
|
|
59
|
+
errorResponses: [
|
|
60
|
+
{ httpStatus: 404, responseHttpStatus: 404, responsePagePath: "/404.html" },
|
|
61
|
+
{ httpStatus: 403, responseHttpStatus: 404, responsePagePath: "/404.html" },
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export function addS3ProjectionPublication(scope, props, media) {
|
|
66
|
+
assertValidPublicationConfig({ kind: "s3-projection", ...props });
|
|
67
|
+
const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
|
|
68
|
+
const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
|
|
69
|
+
const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
|
|
70
|
+
runtime: cloudfront.FunctionRuntime.JS_2_0,
|
|
71
|
+
code: cloudfront.FunctionCode.fromFile({ filePath: redirectFunctionSourcePath }),
|
|
72
|
+
keyValueStore,
|
|
73
|
+
});
|
|
74
|
+
const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
|
|
75
|
+
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
76
|
+
retentionPeriod: Duration.days(14),
|
|
77
|
+
enforceSSL: true,
|
|
78
|
+
});
|
|
79
|
+
const createsDistribution = props.distribution?.create === true;
|
|
80
|
+
const distribution = createsDistribution ? createDistribution(scope, props, bucket, redirectFunction) : undefined;
|
|
81
|
+
let distributionArn;
|
|
82
|
+
if (distribution !== undefined) {
|
|
83
|
+
distributionArn = distribution.distributionArn;
|
|
84
|
+
}
|
|
85
|
+
else if (props.edgeDistributionArn !== undefined) {
|
|
86
|
+
distributionArn = props.edgeDistributionArn;
|
|
87
|
+
bucket.addToResourcePolicy(new PolicyStatement({
|
|
88
|
+
effect: Effect.ALLOW,
|
|
89
|
+
principals: [new ServicePrincipal("cloudfront.amazonaws.com")],
|
|
90
|
+
actions: ["s3:GetObject"],
|
|
91
|
+
resources: [bucket.arnForObjects("*")],
|
|
92
|
+
conditions: { StringEquals: { "aws:SourceArn": props.edgeDistributionArn } },
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
const renditions = media?.renditions.enabled === true
|
|
96
|
+
? addRenditionsFunction(scope, { siteBucket: bucket, mediaBucket: media.mediaBucket, siteFiles: media.renditions.siteFiles, code: media.renditionsCode })
|
|
97
|
+
: undefined;
|
|
98
|
+
const settingsInput = {
|
|
99
|
+
bucketName: bucket.bucketName,
|
|
100
|
+
bucketArn: bucket.bucketArn,
|
|
101
|
+
keyValueStoreArn: keyValueStore.keyValueStoreArn,
|
|
102
|
+
deadLetterQueueUrl: deadLetterQueue.queueUrl,
|
|
103
|
+
deadLetterQueueArn: deadLetterQueue.queueArn,
|
|
104
|
+
distributionArn,
|
|
105
|
+
distributionId: distribution?.distributionId,
|
|
106
|
+
publicHostName: distribution !== undefined
|
|
107
|
+
? (props.distribution?.aliases?.[0] ?? distribution.distributionDomainName)
|
|
108
|
+
: undefined,
|
|
109
|
+
projector: props.projector,
|
|
110
|
+
staticDir: props.staticDir,
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
bucket,
|
|
114
|
+
keyValueStore,
|
|
115
|
+
redirectFunction,
|
|
116
|
+
deadLetterQueue,
|
|
117
|
+
distribution,
|
|
118
|
+
distributionId: distribution?.distributionId,
|
|
119
|
+
renditions,
|
|
120
|
+
environment: {
|
|
121
|
+
...s3ProjectionEnvironment(settingsInput),
|
|
122
|
+
...(renditions !== undefined ? { [RENDITIONS_FUNCTION_NAME_ENV]: renditions.function.functionName } : {}),
|
|
123
|
+
},
|
|
124
|
+
policyStatements: s3ProjectionPolicyStatements(settingsInput).map((statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources })),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
2
|
+
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
|
3
|
+
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
|
4
|
+
import type { Code } from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
|
|
6
|
+
import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
7
|
+
import * as route53 from "aws-cdk-lib/aws-route53";
|
|
8
|
+
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
9
|
+
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
10
|
+
import type { Construct } from "constructs";
|
|
11
|
+
import { dirname, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { assertValidPublicationConfig, type PapyrusPublicationConfig } from "../site-backend-config";
|
|
14
|
+
import { addRenditionsFunction, type RenditionsFunction } from "./renditions-function";
|
|
15
|
+
import { RENDITIONS_FUNCTION_NAME_ENV, type RenditionsPlan } from "./renditions-settings";
|
|
16
|
+
import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
|
|
17
|
+
|
|
18
|
+
const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
|
|
19
|
+
|
|
20
|
+
export type S3ProjectionPublicationProps = Omit<PapyrusPublicationConfig, "kind"> & { kind?: "s3-projection" };
|
|
21
|
+
|
|
22
|
+
export type S3ProjectionMedia = {
|
|
23
|
+
mediaBucket: s3.IBucket;
|
|
24
|
+
renditions: RenditionsPlan;
|
|
25
|
+
renditionsCode?: Code;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type S3ProjectionPublication = {
|
|
29
|
+
bucket: s3.Bucket;
|
|
30
|
+
keyValueStore: cloudfront.KeyValueStore;
|
|
31
|
+
redirectFunction: cloudfront.Function;
|
|
32
|
+
deadLetterQueue: sqs.Queue;
|
|
33
|
+
distribution?: cloudfront.Distribution;
|
|
34
|
+
distributionId?: string;
|
|
35
|
+
renditions?: RenditionsFunction;
|
|
36
|
+
environment: Record<string, string>;
|
|
37
|
+
policyStatements: PolicyStatement[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function createSiteBucket(scope: Construct, retainBucketOnDelete: boolean): s3.Bucket {
|
|
41
|
+
return new s3.Bucket(scope, "SiteBucket", {
|
|
42
|
+
versioned: true,
|
|
43
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
44
|
+
enforceSSL: true,
|
|
45
|
+
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
46
|
+
lifecycleRules: [{ noncurrentVersionExpiration: Duration.days(90) }],
|
|
47
|
+
removalPolicy: retainBucketOnDelete ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
|
|
48
|
+
autoDeleteObjects: !retainBucketOnDelete,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createDnsValidatedCertificate(
|
|
53
|
+
scope: Construct,
|
|
54
|
+
aliases: string[],
|
|
55
|
+
hostedZone: { hostedZoneId: string; hostedZoneName: string },
|
|
56
|
+
): acm.ICertificate {
|
|
57
|
+
const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "SiteHostedZone", {
|
|
58
|
+
hostedZoneId: hostedZone.hostedZoneId,
|
|
59
|
+
zoneName: hostedZone.hostedZoneName,
|
|
60
|
+
});
|
|
61
|
+
return new acm.DnsValidatedCertificate(scope, "SiteCertificate", {
|
|
62
|
+
domainName: aliases[0],
|
|
63
|
+
subjectAlternativeNames: aliases.slice(1),
|
|
64
|
+
hostedZone: zone,
|
|
65
|
+
region: "us-east-1",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createDistribution(
|
|
70
|
+
scope: Construct,
|
|
71
|
+
props: S3ProjectionPublicationProps,
|
|
72
|
+
bucket: s3.Bucket,
|
|
73
|
+
redirectFunction: cloudfront.Function,
|
|
74
|
+
): cloudfront.Distribution {
|
|
75
|
+
const aliases = props.distribution?.aliases ?? [];
|
|
76
|
+
let certificate: acm.ICertificate | undefined;
|
|
77
|
+
if (aliases.length > 0) {
|
|
78
|
+
certificate = props.distribution?.acm !== undefined
|
|
79
|
+
? createDnsValidatedCertificate(scope, aliases, props.distribution.acm)
|
|
80
|
+
: acm.Certificate.fromCertificateArn(scope, "SiteCertificate", props.distribution?.certificateArn ?? "");
|
|
81
|
+
}
|
|
82
|
+
return new cloudfront.Distribution(scope, "SiteDistribution", {
|
|
83
|
+
defaultRootObject: "index.html",
|
|
84
|
+
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
|
|
85
|
+
enableLogging: false,
|
|
86
|
+
domainNames: aliases.length > 0 ? aliases : undefined,
|
|
87
|
+
certificate,
|
|
88
|
+
defaultBehavior: {
|
|
89
|
+
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
|
|
90
|
+
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
91
|
+
compress: true,
|
|
92
|
+
functionAssociations: [{ function: redirectFunction, eventType: cloudfront.FunctionEventType.VIEWER_REQUEST }],
|
|
93
|
+
},
|
|
94
|
+
errorResponses: [
|
|
95
|
+
{ httpStatus: 404, responseHttpStatus: 404, responsePagePath: "/404.html" },
|
|
96
|
+
{ httpStatus: 403, responseHttpStatus: 404, responsePagePath: "/404.html" },
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function addS3ProjectionPublication(
|
|
102
|
+
scope: Construct,
|
|
103
|
+
props: S3ProjectionPublicationProps,
|
|
104
|
+
media?: S3ProjectionMedia,
|
|
105
|
+
): S3ProjectionPublication {
|
|
106
|
+
assertValidPublicationConfig({ kind: "s3-projection", ...props });
|
|
107
|
+
const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
|
|
108
|
+
|
|
109
|
+
const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
|
|
110
|
+
const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
|
|
111
|
+
runtime: cloudfront.FunctionRuntime.JS_2_0,
|
|
112
|
+
code: cloudfront.FunctionCode.fromFile({ filePath: redirectFunctionSourcePath }),
|
|
113
|
+
keyValueStore,
|
|
114
|
+
});
|
|
115
|
+
const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
|
|
116
|
+
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
117
|
+
retentionPeriod: Duration.days(14),
|
|
118
|
+
enforceSSL: true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const createsDistribution = props.distribution?.create === true;
|
|
122
|
+
const distribution = createsDistribution ? createDistribution(scope, props, bucket, redirectFunction) : undefined;
|
|
123
|
+
|
|
124
|
+
let distributionArn: string | undefined;
|
|
125
|
+
if (distribution !== undefined) {
|
|
126
|
+
distributionArn = distribution.distributionArn;
|
|
127
|
+
} else if (props.edgeDistributionArn !== undefined) {
|
|
128
|
+
distributionArn = props.edgeDistributionArn;
|
|
129
|
+
bucket.addToResourcePolicy(
|
|
130
|
+
new PolicyStatement({
|
|
131
|
+
effect: Effect.ALLOW,
|
|
132
|
+
principals: [new ServicePrincipal("cloudfront.amazonaws.com")],
|
|
133
|
+
actions: ["s3:GetObject"],
|
|
134
|
+
resources: [bucket.arnForObjects("*")],
|
|
135
|
+
conditions: { StringEquals: { "aws:SourceArn": props.edgeDistributionArn } },
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const renditions = media?.renditions.enabled === true
|
|
141
|
+
? addRenditionsFunction(scope, { siteBucket: bucket, mediaBucket: media.mediaBucket, siteFiles: media.renditions.siteFiles, code: media.renditionsCode })
|
|
142
|
+
: undefined;
|
|
143
|
+
|
|
144
|
+
const settingsInput = {
|
|
145
|
+
bucketName: bucket.bucketName,
|
|
146
|
+
bucketArn: bucket.bucketArn,
|
|
147
|
+
keyValueStoreArn: keyValueStore.keyValueStoreArn,
|
|
148
|
+
deadLetterQueueUrl: deadLetterQueue.queueUrl,
|
|
149
|
+
deadLetterQueueArn: deadLetterQueue.queueArn,
|
|
150
|
+
distributionArn,
|
|
151
|
+
distributionId: distribution?.distributionId,
|
|
152
|
+
publicHostName: distribution !== undefined
|
|
153
|
+
? (props.distribution?.aliases?.[0] ?? distribution.distributionDomainName)
|
|
154
|
+
: undefined,
|
|
155
|
+
projector: props.projector,
|
|
156
|
+
staticDir: props.staticDir,
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
bucket,
|
|
160
|
+
keyValueStore,
|
|
161
|
+
redirectFunction,
|
|
162
|
+
deadLetterQueue,
|
|
163
|
+
distribution,
|
|
164
|
+
distributionId: distribution?.distributionId,
|
|
165
|
+
renditions,
|
|
166
|
+
environment: {
|
|
167
|
+
...s3ProjectionEnvironment(settingsInput),
|
|
168
|
+
...(renditions !== undefined ? { [RENDITIONS_FUNCTION_NAME_ENV]: renditions.function.functionName } : {}),
|
|
169
|
+
},
|
|
170
|
+
policyStatements: s3ProjectionPolicyStatements(settingsInput).map(
|
|
171
|
+
(statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources }),
|
|
172
|
+
),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -1 +1,51 @@
|
|
|
1
|
-
|
|
1
|
+
const CLOUDFRONT_DISTRIBUTION_ARN = /^arn:aws[a-z-]*:cloudfront::\d{12}:distribution\/[A-Z0-9]+$/;
|
|
2
|
+
function isRelativePathWithoutParentSegments(value) {
|
|
3
|
+
if (value === "" || value.startsWith("/") || value.includes("\\"))
|
|
4
|
+
return false;
|
|
5
|
+
return !value.split("/").includes("..");
|
|
6
|
+
}
|
|
7
|
+
export function validatePublicationConfig(publication) {
|
|
8
|
+
const problems = [];
|
|
9
|
+
if (typeof publication !== "object" || publication === null) {
|
|
10
|
+
return ["publication must be an object"];
|
|
11
|
+
}
|
|
12
|
+
const config = publication;
|
|
13
|
+
if (config.kind !== "s3-projection") {
|
|
14
|
+
problems.push(`publication.kind must be "s3-projection" (got ${JSON.stringify(config.kind)})`);
|
|
15
|
+
}
|
|
16
|
+
if (config.projector !== undefined) {
|
|
17
|
+
if (typeof config.projector !== "string" || !isRelativePathWithoutParentSegments(config.projector) || !config.projector.endsWith(".py")) {
|
|
18
|
+
problems.push("publication.projector must be a relative .py path without '..'");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (config.staticDir !== undefined) {
|
|
22
|
+
if (typeof config.staticDir !== "string" || !isRelativePathWithoutParentSegments(config.staticDir)) {
|
|
23
|
+
problems.push("publication.staticDir must be a relative path without '..'");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (config.edgeDistributionArn !== undefined && !CLOUDFRONT_DISTRIBUTION_ARN.test(config.edgeDistributionArn)) {
|
|
27
|
+
problems.push("publication.edgeDistributionArn must be a CloudFront distribution ARN (arn:aws:cloudfront::<account>:distribution/<id>)");
|
|
28
|
+
}
|
|
29
|
+
const distribution = config.distribution;
|
|
30
|
+
if (distribution !== undefined) {
|
|
31
|
+
if (config.edgeDistributionArn !== undefined && distribution.create === true) {
|
|
32
|
+
problems.push("publication.edgeDistributionArn and publication.distribution.create are mutually exclusive");
|
|
33
|
+
}
|
|
34
|
+
const aliases = distribution.aliases ?? [];
|
|
35
|
+
if (aliases.length > 0 && distribution.create !== true) {
|
|
36
|
+
problems.push("publication.distribution.aliases require distribution.create to be true");
|
|
37
|
+
}
|
|
38
|
+
if (aliases.length > 0 && !distribution.certificateArn && !distribution.acm) {
|
|
39
|
+
problems.push("publication.distribution.aliases require certificateArn or acm");
|
|
40
|
+
}
|
|
41
|
+
if (distribution.acm !== undefined && (!distribution.acm.hostedZoneId || !distribution.acm.hostedZoneName)) {
|
|
42
|
+
problems.push("publication.distribution.acm requires hostedZoneId and hostedZoneName");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return problems;
|
|
46
|
+
}
|
|
47
|
+
export function assertValidPublicationConfig(publication) {
|
|
48
|
+
const problems = validatePublicationConfig(publication);
|
|
49
|
+
if (problems.length > 0)
|
|
50
|
+
throw new Error(`Invalid backend.publication: ${problems.join("; ")}`);
|
|
51
|
+
}
|