@anthusai/papyrus 1.0.0-next.42 → 1.0.0-next.44
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 +1 -0
- package/amplify/functions/content-actions/resource.js +6 -1
- package/amplify/functions/content-actions/resource.ts +9 -2
- 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/s3-projection-settings.js +63 -0
- package/amplify/publication/s3-projection-settings.ts +98 -0
- package/amplify/publication/s3-projection.js +117 -0
- package/amplify/publication/s3-projection.ts +152 -0
- package/amplify/revalidate-secret-policy.js +13 -0
- package/amplify/revalidate-secret-policy.ts +19 -0
- package/amplify/site-backend-config.js +51 -1
- package/amplify/site-backend-config.ts +76 -0
- package/amplify/site-backend.js +27 -5
- package/amplify/site-backend.ts +29 -6
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -6,6 +6,13 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
from papyrus_content.graphql_authoring import create_authoring_client
|
|
8
8
|
from papyrus_content.markus_renderer.derive import derive_body
|
|
9
|
+
from papyrus_content.projection.engine import project
|
|
10
|
+
from papyrus_content.projection.runtime import (
|
|
11
|
+
DEAD_LETTER_QUEUE_ENV,
|
|
12
|
+
context_from_environment,
|
|
13
|
+
projection_configured,
|
|
14
|
+
public_item_url,
|
|
15
|
+
)
|
|
9
16
|
from papyrus_content.rebuild_trigger import (
|
|
10
17
|
DEFAULT_STAGING_BRANCH,
|
|
11
18
|
reader_target_from_environment,
|
|
@@ -56,6 +63,45 @@ def derive_markus_action(arguments: dict) -> dict:
|
|
|
56
63
|
return response
|
|
57
64
|
|
|
58
65
|
|
|
66
|
+
def send_to_projection_dead_letter_queue(item_id: str, unpublished: bool, error: str) -> None:
|
|
67
|
+
queue_url = (os.environ.get(DEAD_LETTER_QUEUE_ENV) or "").strip()
|
|
68
|
+
if not queue_url:
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
import boto3
|
|
72
|
+
|
|
73
|
+
boto3.client("sqs").send_message(
|
|
74
|
+
QueueUrl=queue_url,
|
|
75
|
+
MessageBody=json.dumps({"itemId": item_id, "unpublished": unpublished, "error": error}),
|
|
76
|
+
)
|
|
77
|
+
except Exception: # noqa: BLE001 - the dead letter is best effort; the response already reports the failure
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
|
|
82
|
+
if not projection_configured():
|
|
83
|
+
return {}
|
|
84
|
+
liveness = {} if unpublished else {"live": False}
|
|
85
|
+
try:
|
|
86
|
+
context = context_from_environment(client)
|
|
87
|
+
report = project(context, item_id, unpublished=unpublished)
|
|
88
|
+
except Exception as error: # noqa: BLE001 - a projection failure never fails the save
|
|
89
|
+
message = f"{type(error).__name__}: {error}"
|
|
90
|
+
send_to_projection_dead_letter_queue(item_id, unpublished, message)
|
|
91
|
+
return {**liveness, "projected": False, "paths": [], "error": message}
|
|
92
|
+
response = {"projected": report.ok, "paths": [*report.written, *report.deleted]}
|
|
93
|
+
if not unpublished:
|
|
94
|
+
response["live"] = report.ok
|
|
95
|
+
url = public_item_url(os.environ, report.item_paths)
|
|
96
|
+
if url:
|
|
97
|
+
response["url"] = url
|
|
98
|
+
if not report.ok:
|
|
99
|
+
message = "; ".join(report.errors)
|
|
100
|
+
send_to_projection_dead_letter_queue(item_id, unpublished, message)
|
|
101
|
+
response["error"] = message
|
|
102
|
+
return response
|
|
103
|
+
|
|
104
|
+
|
|
59
105
|
def save_item_draft_action(arguments: dict, actor: str) -> dict:
|
|
60
106
|
client, _claims = create_authoring_client()
|
|
61
107
|
fields = ItemFields(
|
|
@@ -68,7 +114,7 @@ def save_item_draft_action(arguments: dict, actor: str) -> dict:
|
|
|
68
114
|
id=arguments.get("id"),
|
|
69
115
|
)
|
|
70
116
|
record = save_item(client, fields, actor=actor, expected_content_hash=arguments.get("expectedContentHash"))
|
|
71
|
-
|
|
117
|
+
response = {
|
|
72
118
|
"ok": True,
|
|
73
119
|
"item": {
|
|
74
120
|
"id": record["id"],
|
|
@@ -78,6 +124,11 @@ def save_item_draft_action(arguments: dict, actor: str) -> dict:
|
|
|
78
124
|
"versionNumber": record.get("versionNumber"),
|
|
79
125
|
},
|
|
80
126
|
}
|
|
127
|
+
if record["status"] == "published" and projection_configured():
|
|
128
|
+
published = publish_item(client, record["id"], actor=actor)
|
|
129
|
+
response["item"]["versionNumber"] = published.version_number
|
|
130
|
+
response.update(project_item_action(client, record["id"], unpublished=False))
|
|
131
|
+
return response
|
|
81
132
|
|
|
82
133
|
|
|
83
134
|
def site_triggers_for(client, item_id: str) -> dict:
|
|
@@ -108,6 +159,7 @@ def publish_item_action(arguments: dict, actor: str) -> dict:
|
|
|
108
159
|
}
|
|
109
160
|
if result.changed:
|
|
110
161
|
response.update(site_triggers_for(client, arguments["id"]))
|
|
162
|
+
response.update(project_item_action(client, arguments["id"], unpublished=False))
|
|
111
163
|
return response
|
|
112
164
|
|
|
113
165
|
|
|
@@ -117,6 +169,7 @@ def unpublish_item_action(arguments: dict, actor: str) -> dict:
|
|
|
117
169
|
response = {"ok": True, "changed": result.changed}
|
|
118
170
|
if result.changed:
|
|
119
171
|
response.update(site_triggers_for(client, arguments["id"]))
|
|
172
|
+
response.update(project_item_action(client, arguments["id"], unpublished=True))
|
|
120
173
|
return response
|
|
121
174
|
|
|
122
175
|
|
|
@@ -7,11 +7,15 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { pythonLambdaCode } from "../shared/python-bundle.js";
|
|
8
8
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const projectRoot = path.resolve(dirname, "../../..");
|
|
10
|
-
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=10,<12"];
|
|
10
|
+
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=10,<12", "awscrt>=0.23,<1"];
|
|
11
11
|
function installCheckoutRequirements(outputDir) {
|
|
12
12
|
const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
|
|
13
13
|
execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
|
|
14
14
|
}
|
|
15
|
+
let siteFilesForNextContentActionsBundle;
|
|
16
|
+
export function setSiteFilesForContentActionsBundle(siteFiles) {
|
|
17
|
+
siteFilesForNextContentActionsBundle = siteFiles;
|
|
18
|
+
}
|
|
15
19
|
export const contentActions = defineFunction((scope) => {
|
|
16
20
|
return new Function(scope, "papyrus-content-actions", {
|
|
17
21
|
runtime: Runtime.PYTHON_3_12,
|
|
@@ -25,6 +29,7 @@ export const contentActions = defineFunction((scope) => {
|
|
|
25
29
|
requirements: contentActionsRequirements,
|
|
26
30
|
noDeps: true,
|
|
27
31
|
checkoutExtras: installCheckoutRequirements,
|
|
32
|
+
...(siteFilesForNextContentActionsBundle !== undefined ? { siteFiles: siteFilesForNextContentActionsBundle } : {}),
|
|
28
33
|
}, [
|
|
29
34
|
"bash",
|
|
30
35
|
"-c",
|
|
@@ -5,12 +5,12 @@ import { Construct } from "constructs";
|
|
|
5
5
|
import { execSync } from "node:child_process";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { pythonLambdaCode } from "../shared/python-bundle";
|
|
8
|
+
import { pythonLambdaCode, type SiteBundleFile } from "../shared/python-bundle";
|
|
9
9
|
|
|
10
10
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const projectRoot = path.resolve(dirname, "../../..");
|
|
12
12
|
|
|
13
|
-
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=10,<12"];
|
|
13
|
+
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=10,<12", "awscrt>=0.23,<1"];
|
|
14
14
|
|
|
15
15
|
function installCheckoutRequirements(outputDir: string): void {
|
|
16
16
|
const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
|
|
@@ -20,6 +20,12 @@ function installCheckoutRequirements(outputDir: string): void {
|
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
let siteFilesForNextContentActionsBundle: SiteBundleFile[] | undefined;
|
|
24
|
+
|
|
25
|
+
export function setSiteFilesForContentActionsBundle(siteFiles: SiteBundleFile[] | undefined): void {
|
|
26
|
+
siteFilesForNextContentActionsBundle = siteFiles;
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
export const contentActions = defineFunction(
|
|
24
30
|
(scope: Construct) => {
|
|
25
31
|
return new Function(scope, "papyrus-content-actions", {
|
|
@@ -35,6 +41,7 @@ export const contentActions = defineFunction(
|
|
|
35
41
|
requirements: contentActionsRequirements,
|
|
36
42
|
noDeps: true,
|
|
37
43
|
checkoutExtras: installCheckoutRequirements,
|
|
44
|
+
...(siteFilesForNextContentActionsBundle !== undefined ? { siteFiles: siteFilesForNextContentActionsBundle } : {}),
|
|
38
45
|
},
|
|
39
46
|
[
|
|
40
47
|
"bash",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AssetHashType, DockerImage } from "aws-cdk-lib";
|
|
2
2
|
import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { execFileSync } from "node:child_process";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import path from "node:path";
|
|
@@ -41,6 +42,39 @@ export function toPep440(version) {
|
|
|
41
42
|
throw new Error(`Cannot map ${version} to a PEP 440 version`);
|
|
42
43
|
return m[2] === undefined ? m[1] : `${m[1]}.dev${m[2]}`;
|
|
43
44
|
}
|
|
45
|
+
function isBytecodeArtifact(sourcePath) {
|
|
46
|
+
return sourcePath.split(path.sep).includes("__pycache__") || sourcePath.endsWith(".pyc");
|
|
47
|
+
}
|
|
48
|
+
export function copySiteFiles(outputDir, siteFiles) {
|
|
49
|
+
for (const siteFile of siteFiles ?? []) {
|
|
50
|
+
const sourcePath = path.resolve(process.cwd(), siteFile.from);
|
|
51
|
+
if (!fs.existsSync(sourcePath))
|
|
52
|
+
throw new Error(`Site file to bundle does not exist: ${siteFile.from}`);
|
|
53
|
+
const destinationPath = path.join(outputDir, siteFile.to);
|
|
54
|
+
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
55
|
+
fs.cpSync(sourcePath, destinationPath, { recursive: true, filter: (candidate) => !isBytecodeArtifact(candidate) });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function listFilesRecursively(root) {
|
|
59
|
+
if (fs.statSync(root).isFile())
|
|
60
|
+
return [root];
|
|
61
|
+
return fs.readdirSync(root).sort().flatMap((entry) => listFilesRecursively(path.join(root, entry)))
|
|
62
|
+
.filter((candidate) => !isBytecodeArtifact(candidate));
|
|
63
|
+
}
|
|
64
|
+
export function siteFilesContentHash(siteFiles) {
|
|
65
|
+
const hash = createHash("sha256");
|
|
66
|
+
for (const siteFile of siteFiles ?? []) {
|
|
67
|
+
const sourcePath = path.resolve(process.cwd(), siteFile.from);
|
|
68
|
+
hash.update(`${siteFile.to}\0`);
|
|
69
|
+
if (!fs.existsSync(sourcePath))
|
|
70
|
+
continue;
|
|
71
|
+
for (const filePath of listFilesRecursively(sourcePath)) {
|
|
72
|
+
hash.update(`${path.relative(sourcePath, filePath)}\0`);
|
|
73
|
+
hash.update(fs.readFileSync(filePath));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return hash.digest("hex").slice(0, 16);
|
|
77
|
+
}
|
|
44
78
|
function pipSpec() {
|
|
45
79
|
return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
|
|
46
80
|
}
|
|
@@ -77,6 +111,7 @@ function bundleFromPip(outputDir, options) {
|
|
|
77
111
|
], { stdio: "inherit" });
|
|
78
112
|
}
|
|
79
113
|
fs.copyFileSync(path.join(packageRoot, "amplify/functions", options.functionDir, "handler.py"), path.join(outputDir, "handler.py"));
|
|
114
|
+
copySiteFiles(outputDir, options.siteFiles);
|
|
80
115
|
if (options.corpora) {
|
|
81
116
|
const corporaDir = path.join(outputDir, "corpora");
|
|
82
117
|
fs.mkdirSync(corporaDir, { recursive: true });
|
|
@@ -96,6 +131,7 @@ export function bundleFromCheckout(outputDir, options) {
|
|
|
96
131
|
fs.cpSync(path.join(packageRoot, "src", module), path.join(outputDir, module), { recursive: true });
|
|
97
132
|
}
|
|
98
133
|
options.checkoutExtras?.(outputDir);
|
|
134
|
+
copySiteFiles(outputDir, options.siteFiles);
|
|
99
135
|
}
|
|
100
136
|
/**
|
|
101
137
|
* CDK Code for a Python Lambda. In package mode the asset hash is derived
|
|
@@ -103,14 +139,18 @@ export function bundleFromCheckout(outputDir, options) {
|
|
|
103
139
|
*/
|
|
104
140
|
export function pythonLambdaCode(options, dockerCommand) {
|
|
105
141
|
const packageMode = isPackageMode();
|
|
142
|
+
const hasSiteFiles = (options.siteFiles ?? []).length > 0;
|
|
143
|
+
const siteFilesSuffix = hasSiteFiles ? `:site-${siteFilesContentHash(options.siteFiles)}` : "";
|
|
106
144
|
return Code.fromAsset(packageRoot, {
|
|
107
145
|
...(packageMode
|
|
108
146
|
? {
|
|
109
147
|
assetHashType: AssetHashType.CUSTOM,
|
|
110
|
-
assetHash: `${options.functionDir}:${pipSpec()}:${(options.requirements ?? []).join(",")}`
|
|
148
|
+
assetHash: `${options.functionDir}:${pipSpec()}:${(options.requirements ?? []).join(",")}${siteFilesSuffix}`
|
|
111
149
|
.replace(/[^a-zA-Z0-9._:=,-]/g, "_"),
|
|
112
150
|
}
|
|
113
|
-
:
|
|
151
|
+
: hasSiteFiles
|
|
152
|
+
? { assetHashType: AssetHashType.OUTPUT }
|
|
153
|
+
: {}),
|
|
114
154
|
bundling: {
|
|
115
155
|
image: Runtime.PYTHON_3_12.bundlingImage ?? DockerImage.fromRegistry("public.ecr.aws/sam/build-python3.12"),
|
|
116
156
|
local: {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AssetHashType, DockerImage } from "aws-cdk-lib";
|
|
2
2
|
import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { execFileSync } from "node:child_process";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import path from "node:path";
|
|
@@ -57,8 +58,46 @@ export type PythonBundleOptions = {
|
|
|
57
58
|
noDeps?: boolean;
|
|
58
59
|
/** Copy `<cwd>/corpora/*.yml` into the asset (publication-owned steering config). */
|
|
59
60
|
corpora?: boolean;
|
|
61
|
+
/** Site repo files copied into the asset; `from` is relative to the current working directory, `to` to the asset root. A directory is copied recursively. */
|
|
62
|
+
siteFiles?: SiteBundleFile[];
|
|
60
63
|
};
|
|
61
64
|
|
|
65
|
+
export type SiteBundleFile = { from: string; to: string };
|
|
66
|
+
|
|
67
|
+
function isBytecodeArtifact(sourcePath: string): boolean {
|
|
68
|
+
return sourcePath.split(path.sep).includes("__pycache__") || sourcePath.endsWith(".pyc");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function copySiteFiles(outputDir: string, siteFiles: SiteBundleFile[] | undefined): void {
|
|
72
|
+
for (const siteFile of siteFiles ?? []) {
|
|
73
|
+
const sourcePath = path.resolve(process.cwd(), siteFile.from);
|
|
74
|
+
if (!fs.existsSync(sourcePath)) throw new Error(`Site file to bundle does not exist: ${siteFile.from}`);
|
|
75
|
+
const destinationPath = path.join(outputDir, siteFile.to);
|
|
76
|
+
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
77
|
+
fs.cpSync(sourcePath, destinationPath, { recursive: true, filter: (candidate) => !isBytecodeArtifact(candidate) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function listFilesRecursively(root: string): string[] {
|
|
82
|
+
if (fs.statSync(root).isFile()) return [root];
|
|
83
|
+
return fs.readdirSync(root).sort().flatMap((entry) => listFilesRecursively(path.join(root, entry)))
|
|
84
|
+
.filter((candidate) => !isBytecodeArtifact(candidate));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function siteFilesContentHash(siteFiles: SiteBundleFile[] | undefined): string {
|
|
88
|
+
const hash = createHash("sha256");
|
|
89
|
+
for (const siteFile of siteFiles ?? []) {
|
|
90
|
+
const sourcePath = path.resolve(process.cwd(), siteFile.from);
|
|
91
|
+
hash.update(`${siteFile.to}\0`);
|
|
92
|
+
if (!fs.existsSync(sourcePath)) continue;
|
|
93
|
+
for (const filePath of listFilesRecursively(sourcePath)) {
|
|
94
|
+
hash.update(`${path.relative(sourcePath, filePath)}\0`);
|
|
95
|
+
hash.update(fs.readFileSync(filePath));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return hash.digest("hex").slice(0, 16);
|
|
99
|
+
}
|
|
100
|
+
|
|
62
101
|
function pipSpec(): string {
|
|
63
102
|
return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
|
|
64
103
|
}
|
|
@@ -107,6 +146,7 @@ function bundleFromPip(outputDir: string, options: PythonBundleOptions): void {
|
|
|
107
146
|
path.join(packageRoot, "amplify/functions", options.functionDir, "handler.py"),
|
|
108
147
|
path.join(outputDir, "handler.py"),
|
|
109
148
|
);
|
|
149
|
+
copySiteFiles(outputDir, options.siteFiles);
|
|
110
150
|
if (options.corpora) {
|
|
111
151
|
const corporaDir = path.join(outputDir, "corpora");
|
|
112
152
|
fs.mkdirSync(corporaDir, { recursive: true });
|
|
@@ -129,6 +169,7 @@ export function bundleFromCheckout(outputDir: string, options: PythonBundleOptio
|
|
|
129
169
|
fs.cpSync(path.join(packageRoot, "src", module), path.join(outputDir, module), { recursive: true });
|
|
130
170
|
}
|
|
131
171
|
options.checkoutExtras?.(outputDir);
|
|
172
|
+
copySiteFiles(outputDir, options.siteFiles);
|
|
132
173
|
}
|
|
133
174
|
|
|
134
175
|
/**
|
|
@@ -137,14 +178,18 @@ export function bundleFromCheckout(outputDir: string, options: PythonBundleOptio
|
|
|
137
178
|
*/
|
|
138
179
|
export function pythonLambdaCode(options: PythonBundleOptions, dockerCommand: string[]): Code {
|
|
139
180
|
const packageMode = isPackageMode();
|
|
181
|
+
const hasSiteFiles = (options.siteFiles ?? []).length > 0;
|
|
182
|
+
const siteFilesSuffix = hasSiteFiles ? `:site-${siteFilesContentHash(options.siteFiles)}` : "";
|
|
140
183
|
return Code.fromAsset(packageRoot, {
|
|
141
184
|
...(packageMode
|
|
142
185
|
? {
|
|
143
186
|
assetHashType: AssetHashType.CUSTOM,
|
|
144
|
-
assetHash: `${options.functionDir}:${pipSpec()}:${(options.requirements ?? []).join(",")}`
|
|
187
|
+
assetHash: `${options.functionDir}:${pipSpec()}:${(options.requirements ?? []).join(",")}${siteFilesSuffix}`
|
|
145
188
|
.replace(/[^a-zA-Z0-9._:=,-]/g, "_"),
|
|
146
189
|
}
|
|
147
|
-
:
|
|
190
|
+
: hasSiteFiles
|
|
191
|
+
? { assetHashType: AssetHashType.OUTPUT }
|
|
192
|
+
: {}),
|
|
148
193
|
bundling: {
|
|
149
194
|
image: Runtime.PYTHON_3_12.bundlingImage ?? DockerImage.fromRegistry("public.ecr.aws/sam/build-python3.12"),
|
|
150
195
|
local: {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import cf from "cloudfront";
|
|
2
|
+
|
|
3
|
+
const redirectStore = cf.kvs();
|
|
4
|
+
|
|
5
|
+
async function lookUpRedirectTarget(uri) {
|
|
6
|
+
try {
|
|
7
|
+
const target = await redirectStore.get(uri);
|
|
8
|
+
return target ? String(target) : null;
|
|
9
|
+
} catch (error) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function rewriteToIndexDocument(uri) {
|
|
15
|
+
if (uri.endsWith("/")) {
|
|
16
|
+
return uri + "index.html";
|
|
17
|
+
}
|
|
18
|
+
const lastSegment = uri.substring(uri.lastIndexOf("/") + 1);
|
|
19
|
+
if (lastSegment.indexOf(".") === -1) {
|
|
20
|
+
return uri + "/index.html";
|
|
21
|
+
}
|
|
22
|
+
return uri;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function handler(event) {
|
|
26
|
+
const request = event.request;
|
|
27
|
+
const redirectTarget = await lookUpRedirectTarget(request.uri);
|
|
28
|
+
if (redirectTarget !== null) {
|
|
29
|
+
return {
|
|
30
|
+
statusCode: 301,
|
|
31
|
+
statusDescription: "Moved Permanently",
|
|
32
|
+
headers: { location: { value: redirectTarget } },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
request.uri = rewriteToIndexDocument(request.uri);
|
|
36
|
+
return request;
|
|
37
|
+
}
|
|
@@ -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,117 @@
|
|
|
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 { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
|
|
13
|
+
const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
|
|
14
|
+
function createSiteBucket(scope, retainBucketOnDelete) {
|
|
15
|
+
return new s3.Bucket(scope, "SiteBucket", {
|
|
16
|
+
versioned: true,
|
|
17
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
18
|
+
enforceSSL: true,
|
|
19
|
+
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
20
|
+
lifecycleRules: [{ noncurrentVersionExpiration: Duration.days(90) }],
|
|
21
|
+
removalPolicy: retainBucketOnDelete ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
|
|
22
|
+
autoDeleteObjects: !retainBucketOnDelete,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function createDnsValidatedCertificate(scope, aliases, hostedZone) {
|
|
26
|
+
const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "SiteHostedZone", {
|
|
27
|
+
hostedZoneId: hostedZone.hostedZoneId,
|
|
28
|
+
zoneName: hostedZone.hostedZoneName,
|
|
29
|
+
});
|
|
30
|
+
return new acm.DnsValidatedCertificate(scope, "SiteCertificate", {
|
|
31
|
+
domainName: aliases[0],
|
|
32
|
+
subjectAlternativeNames: aliases.slice(1),
|
|
33
|
+
hostedZone: zone,
|
|
34
|
+
region: "us-east-1",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function createDistribution(scope, props, bucket, redirectFunction) {
|
|
38
|
+
const aliases = props.distribution?.aliases ?? [];
|
|
39
|
+
let certificate;
|
|
40
|
+
if (aliases.length > 0) {
|
|
41
|
+
certificate = props.distribution?.acm !== undefined
|
|
42
|
+
? createDnsValidatedCertificate(scope, aliases, props.distribution.acm)
|
|
43
|
+
: acm.Certificate.fromCertificateArn(scope, "SiteCertificate", props.distribution?.certificateArn ?? "");
|
|
44
|
+
}
|
|
45
|
+
return new cloudfront.Distribution(scope, "SiteDistribution", {
|
|
46
|
+
defaultRootObject: "index.html",
|
|
47
|
+
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
|
|
48
|
+
enableLogging: false,
|
|
49
|
+
domainNames: aliases.length > 0 ? aliases : undefined,
|
|
50
|
+
certificate,
|
|
51
|
+
defaultBehavior: {
|
|
52
|
+
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
|
|
53
|
+
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
54
|
+
compress: true,
|
|
55
|
+
functionAssociations: [{ function: redirectFunction, eventType: cloudfront.FunctionEventType.VIEWER_REQUEST }],
|
|
56
|
+
},
|
|
57
|
+
errorResponses: [
|
|
58
|
+
{ httpStatus: 404, responseHttpStatus: 404, responsePagePath: "/404.html" },
|
|
59
|
+
{ httpStatus: 403, responseHttpStatus: 404, responsePagePath: "/404.html" },
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export function addS3ProjectionPublication(scope, props) {
|
|
64
|
+
assertValidPublicationConfig({ kind: "s3-projection", ...props });
|
|
65
|
+
const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
|
|
66
|
+
const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
|
|
67
|
+
const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
|
|
68
|
+
runtime: cloudfront.FunctionRuntime.JS_2_0,
|
|
69
|
+
code: cloudfront.FunctionCode.fromFile({ filePath: redirectFunctionSourcePath }),
|
|
70
|
+
keyValueStore,
|
|
71
|
+
});
|
|
72
|
+
const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
|
|
73
|
+
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
74
|
+
retentionPeriod: Duration.days(14),
|
|
75
|
+
enforceSSL: true,
|
|
76
|
+
});
|
|
77
|
+
const createsDistribution = props.distribution?.create === true;
|
|
78
|
+
const distribution = createsDistribution ? createDistribution(scope, props, bucket, redirectFunction) : undefined;
|
|
79
|
+
let distributionArn;
|
|
80
|
+
if (distribution !== undefined) {
|
|
81
|
+
distributionArn = distribution.distributionArn;
|
|
82
|
+
}
|
|
83
|
+
else if (props.edgeDistributionArn !== undefined) {
|
|
84
|
+
distributionArn = props.edgeDistributionArn;
|
|
85
|
+
bucket.addToResourcePolicy(new PolicyStatement({
|
|
86
|
+
effect: Effect.ALLOW,
|
|
87
|
+
principals: [new ServicePrincipal("cloudfront.amazonaws.com")],
|
|
88
|
+
actions: ["s3:GetObject"],
|
|
89
|
+
resources: [bucket.arnForObjects("*")],
|
|
90
|
+
conditions: { StringEquals: { "aws:SourceArn": props.edgeDistributionArn } },
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
const settingsInput = {
|
|
94
|
+
bucketName: bucket.bucketName,
|
|
95
|
+
bucketArn: bucket.bucketArn,
|
|
96
|
+
keyValueStoreArn: keyValueStore.keyValueStoreArn,
|
|
97
|
+
deadLetterQueueUrl: deadLetterQueue.queueUrl,
|
|
98
|
+
deadLetterQueueArn: deadLetterQueue.queueArn,
|
|
99
|
+
distributionArn,
|
|
100
|
+
distributionId: distribution?.distributionId,
|
|
101
|
+
publicHostName: distribution !== undefined
|
|
102
|
+
? (props.distribution?.aliases?.[0] ?? distribution.distributionDomainName)
|
|
103
|
+
: undefined,
|
|
104
|
+
projector: props.projector,
|
|
105
|
+
staticDir: props.staticDir,
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
bucket,
|
|
109
|
+
keyValueStore,
|
|
110
|
+
redirectFunction,
|
|
111
|
+
deadLetterQueue,
|
|
112
|
+
distribution,
|
|
113
|
+
distributionId: distribution?.distributionId,
|
|
114
|
+
environment: s3ProjectionEnvironment(settingsInput),
|
|
115
|
+
policyStatements: s3ProjectionPolicyStatements(settingsInput).map((statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources })),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
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 type { Construct } from "constructs";
|
|
10
|
+
import { dirname, resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { assertValidPublicationConfig, type PapyrusPublicationConfig } from "../site-backend-config";
|
|
13
|
+
import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
|
|
14
|
+
|
|
15
|
+
const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
|
|
16
|
+
|
|
17
|
+
export type S3ProjectionPublicationProps = Omit<PapyrusPublicationConfig, "kind"> & { kind?: "s3-projection" };
|
|
18
|
+
|
|
19
|
+
export type S3ProjectionPublication = {
|
|
20
|
+
bucket: s3.Bucket;
|
|
21
|
+
keyValueStore: cloudfront.KeyValueStore;
|
|
22
|
+
redirectFunction: cloudfront.Function;
|
|
23
|
+
deadLetterQueue: sqs.Queue;
|
|
24
|
+
distribution?: cloudfront.Distribution;
|
|
25
|
+
distributionId?: string;
|
|
26
|
+
environment: Record<string, string>;
|
|
27
|
+
policyStatements: PolicyStatement[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function createSiteBucket(scope: Construct, retainBucketOnDelete: boolean): s3.Bucket {
|
|
31
|
+
return new s3.Bucket(scope, "SiteBucket", {
|
|
32
|
+
versioned: true,
|
|
33
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
34
|
+
enforceSSL: true,
|
|
35
|
+
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
36
|
+
lifecycleRules: [{ noncurrentVersionExpiration: Duration.days(90) }],
|
|
37
|
+
removalPolicy: retainBucketOnDelete ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
|
|
38
|
+
autoDeleteObjects: !retainBucketOnDelete,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createDnsValidatedCertificate(
|
|
43
|
+
scope: Construct,
|
|
44
|
+
aliases: string[],
|
|
45
|
+
hostedZone: { hostedZoneId: string; hostedZoneName: string },
|
|
46
|
+
): acm.ICertificate {
|
|
47
|
+
const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "SiteHostedZone", {
|
|
48
|
+
hostedZoneId: hostedZone.hostedZoneId,
|
|
49
|
+
zoneName: hostedZone.hostedZoneName,
|
|
50
|
+
});
|
|
51
|
+
return new acm.DnsValidatedCertificate(scope, "SiteCertificate", {
|
|
52
|
+
domainName: aliases[0],
|
|
53
|
+
subjectAlternativeNames: aliases.slice(1),
|
|
54
|
+
hostedZone: zone,
|
|
55
|
+
region: "us-east-1",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createDistribution(
|
|
60
|
+
scope: Construct,
|
|
61
|
+
props: S3ProjectionPublicationProps,
|
|
62
|
+
bucket: s3.Bucket,
|
|
63
|
+
redirectFunction: cloudfront.Function,
|
|
64
|
+
): cloudfront.Distribution {
|
|
65
|
+
const aliases = props.distribution?.aliases ?? [];
|
|
66
|
+
let certificate: acm.ICertificate | undefined;
|
|
67
|
+
if (aliases.length > 0) {
|
|
68
|
+
certificate = props.distribution?.acm !== undefined
|
|
69
|
+
? createDnsValidatedCertificate(scope, aliases, props.distribution.acm)
|
|
70
|
+
: acm.Certificate.fromCertificateArn(scope, "SiteCertificate", props.distribution?.certificateArn ?? "");
|
|
71
|
+
}
|
|
72
|
+
return new cloudfront.Distribution(scope, "SiteDistribution", {
|
|
73
|
+
defaultRootObject: "index.html",
|
|
74
|
+
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
|
|
75
|
+
enableLogging: false,
|
|
76
|
+
domainNames: aliases.length > 0 ? aliases : undefined,
|
|
77
|
+
certificate,
|
|
78
|
+
defaultBehavior: {
|
|
79
|
+
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
|
|
80
|
+
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
81
|
+
compress: true,
|
|
82
|
+
functionAssociations: [{ function: redirectFunction, eventType: cloudfront.FunctionEventType.VIEWER_REQUEST }],
|
|
83
|
+
},
|
|
84
|
+
errorResponses: [
|
|
85
|
+
{ httpStatus: 404, responseHttpStatus: 404, responsePagePath: "/404.html" },
|
|
86
|
+
{ httpStatus: 403, responseHttpStatus: 404, responsePagePath: "/404.html" },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function addS3ProjectionPublication(scope: Construct, props: S3ProjectionPublicationProps): S3ProjectionPublication {
|
|
92
|
+
assertValidPublicationConfig({ kind: "s3-projection", ...props });
|
|
93
|
+
const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
|
|
94
|
+
|
|
95
|
+
const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
|
|
96
|
+
const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
|
|
97
|
+
runtime: cloudfront.FunctionRuntime.JS_2_0,
|
|
98
|
+
code: cloudfront.FunctionCode.fromFile({ filePath: redirectFunctionSourcePath }),
|
|
99
|
+
keyValueStore,
|
|
100
|
+
});
|
|
101
|
+
const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
|
|
102
|
+
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
103
|
+
retentionPeriod: Duration.days(14),
|
|
104
|
+
enforceSSL: true,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const createsDistribution = props.distribution?.create === true;
|
|
108
|
+
const distribution = createsDistribution ? createDistribution(scope, props, bucket, redirectFunction) : undefined;
|
|
109
|
+
|
|
110
|
+
let distributionArn: string | undefined;
|
|
111
|
+
if (distribution !== undefined) {
|
|
112
|
+
distributionArn = distribution.distributionArn;
|
|
113
|
+
} else if (props.edgeDistributionArn !== undefined) {
|
|
114
|
+
distributionArn = props.edgeDistributionArn;
|
|
115
|
+
bucket.addToResourcePolicy(
|
|
116
|
+
new PolicyStatement({
|
|
117
|
+
effect: Effect.ALLOW,
|
|
118
|
+
principals: [new ServicePrincipal("cloudfront.amazonaws.com")],
|
|
119
|
+
actions: ["s3:GetObject"],
|
|
120
|
+
resources: [bucket.arnForObjects("*")],
|
|
121
|
+
conditions: { StringEquals: { "aws:SourceArn": props.edgeDistributionArn } },
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const settingsInput = {
|
|
127
|
+
bucketName: bucket.bucketName,
|
|
128
|
+
bucketArn: bucket.bucketArn,
|
|
129
|
+
keyValueStoreArn: keyValueStore.keyValueStoreArn,
|
|
130
|
+
deadLetterQueueUrl: deadLetterQueue.queueUrl,
|
|
131
|
+
deadLetterQueueArn: deadLetterQueue.queueArn,
|
|
132
|
+
distributionArn,
|
|
133
|
+
distributionId: distribution?.distributionId,
|
|
134
|
+
publicHostName: distribution !== undefined
|
|
135
|
+
? (props.distribution?.aliases?.[0] ?? distribution.distributionDomainName)
|
|
136
|
+
: undefined,
|
|
137
|
+
projector: props.projector,
|
|
138
|
+
staticDir: props.staticDir,
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
bucket,
|
|
142
|
+
keyValueStore,
|
|
143
|
+
redirectFunction,
|
|
144
|
+
deadLetterQueue,
|
|
145
|
+
distribution,
|
|
146
|
+
distributionId: distribution?.distributionId,
|
|
147
|
+
environment: s3ProjectionEnvironment(settingsInput),
|
|
148
|
+
policyStatements: s3ProjectionPolicyStatements(settingsInput).map(
|
|
149
|
+
(statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources }),
|
|
150
|
+
),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IAM statement for the content-actions Lambda's read of the revalidate secret.
|
|
3
|
+
* Pure (no CDK imports) so the policy can be tested without synthesizing.
|
|
4
|
+
* The Lambda reads the parameter through boto3 get_parameters (plural), so the
|
|
5
|
+
* plural action must be granted alongside the singular one.
|
|
6
|
+
*/
|
|
7
|
+
export const REVALIDATE_SECRET_READ_ACTIONS = ["ssm:GetParameter", "ssm:GetParameters"];
|
|
8
|
+
export function revalidateSecretReadStatement(input) {
|
|
9
|
+
return {
|
|
10
|
+
actions: REVALIDATE_SECRET_READ_ACTIONS,
|
|
11
|
+
resources: [`arn:${input.partition}:ssm:${input.region}:${input.account}:parameter${input.parameterName}`],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IAM statement for the content-actions Lambda's read of the revalidate secret.
|
|
3
|
+
* Pure (no CDK imports) so the policy can be tested without synthesizing.
|
|
4
|
+
* The Lambda reads the parameter through boto3 get_parameters (plural), so the
|
|
5
|
+
* plural action must be granted alongside the singular one.
|
|
6
|
+
*/
|
|
7
|
+
export const REVALIDATE_SECRET_READ_ACTIONS = ["ssm:GetParameter", "ssm:GetParameters"];
|
|
8
|
+
|
|
9
|
+
export function revalidateSecretReadStatement(input: {
|
|
10
|
+
partition: string;
|
|
11
|
+
region: string;
|
|
12
|
+
account: string;
|
|
13
|
+
parameterName: string;
|
|
14
|
+
}): { actions: string[]; resources: string[] } {
|
|
15
|
+
return {
|
|
16
|
+
actions: REVALIDATE_SECRET_READ_ACTIONS,
|
|
17
|
+
resources: [`arn:${input.partition}:ssm:${input.region}:${input.account}:parameter${input.parameterName}`],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -22,6 +22,80 @@ export type PapyrusAuthConfig = {
|
|
|
22
22
|
applyCognitoDomainPrefix?: boolean;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
export type S3ProjectionDistributionConfig = {
|
|
26
|
+
create: boolean;
|
|
27
|
+
aliases?: string[];
|
|
28
|
+
certificateArn?: string;
|
|
29
|
+
acm?: { hostedZoneId: string; hostedZoneName: string };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type PapyrusPublicationConfig = {
|
|
33
|
+
kind: "s3-projection";
|
|
34
|
+
/** Site repo path (relative, `.py`) of the SiteProjector bundled into content-actions as `site_projector`. */
|
|
35
|
+
projector?: string;
|
|
36
|
+
/** Site repo directory (relative) bundled into content-actions as `site_static/`. */
|
|
37
|
+
staticDir?: string;
|
|
38
|
+
/** Existing CloudFront distribution that reads the site bucket through OAC. */
|
|
39
|
+
edgeDistributionArn?: string;
|
|
40
|
+
/** Create the distribution (and optionally its certificate) in the site backend. Default: not created. */
|
|
41
|
+
distribution?: S3ProjectionDistributionConfig;
|
|
42
|
+
/** Default true: the site bucket is retained when the stack is deleted. `false` destroys it and its objects. */
|
|
43
|
+
retainBucketOnDelete?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const CLOUDFRONT_DISTRIBUTION_ARN = /^arn:aws[a-z-]*:cloudfront::\d{12}:distribution\/[A-Z0-9]+$/;
|
|
47
|
+
|
|
48
|
+
function isRelativePathWithoutParentSegments(value: string): boolean {
|
|
49
|
+
if (value === "" || value.startsWith("/") || value.includes("\\")) return false;
|
|
50
|
+
return !value.split("/").includes("..");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validatePublicationConfig(publication: unknown): string[] {
|
|
54
|
+
const problems: string[] = [];
|
|
55
|
+
if (typeof publication !== "object" || publication === null) {
|
|
56
|
+
return ["publication must be an object"];
|
|
57
|
+
}
|
|
58
|
+
const config = publication as Partial<PapyrusPublicationConfig> & { kind?: unknown };
|
|
59
|
+
if (config.kind !== "s3-projection") {
|
|
60
|
+
problems.push(`publication.kind must be "s3-projection" (got ${JSON.stringify(config.kind)})`);
|
|
61
|
+
}
|
|
62
|
+
if (config.projector !== undefined) {
|
|
63
|
+
if (typeof config.projector !== "string" || !isRelativePathWithoutParentSegments(config.projector) || !config.projector.endsWith(".py")) {
|
|
64
|
+
problems.push("publication.projector must be a relative .py path without '..'");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (config.staticDir !== undefined) {
|
|
68
|
+
if (typeof config.staticDir !== "string" || !isRelativePathWithoutParentSegments(config.staticDir)) {
|
|
69
|
+
problems.push("publication.staticDir must be a relative path without '..'");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (config.edgeDistributionArn !== undefined && !CLOUDFRONT_DISTRIBUTION_ARN.test(config.edgeDistributionArn)) {
|
|
73
|
+
problems.push("publication.edgeDistributionArn must be a CloudFront distribution ARN (arn:aws:cloudfront::<account>:distribution/<id>)");
|
|
74
|
+
}
|
|
75
|
+
const distribution = config.distribution;
|
|
76
|
+
if (distribution !== undefined) {
|
|
77
|
+
if (config.edgeDistributionArn !== undefined && distribution.create === true) {
|
|
78
|
+
problems.push("publication.edgeDistributionArn and publication.distribution.create are mutually exclusive");
|
|
79
|
+
}
|
|
80
|
+
const aliases = distribution.aliases ?? [];
|
|
81
|
+
if (aliases.length > 0 && distribution.create !== true) {
|
|
82
|
+
problems.push("publication.distribution.aliases require distribution.create to be true");
|
|
83
|
+
}
|
|
84
|
+
if (aliases.length > 0 && !distribution.certificateArn && !distribution.acm) {
|
|
85
|
+
problems.push("publication.distribution.aliases require certificateArn or acm");
|
|
86
|
+
}
|
|
87
|
+
if (distribution.acm !== undefined && (!distribution.acm.hostedZoneId || !distribution.acm.hostedZoneName)) {
|
|
88
|
+
problems.push("publication.distribution.acm requires hostedZoneId and hostedZoneName");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return problems;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function assertValidPublicationConfig(publication: unknown): void {
|
|
95
|
+
const problems = validatePublicationConfig(publication);
|
|
96
|
+
if (problems.length > 0) throw new Error(`Invalid backend.publication: ${problems.join("; ")}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
25
99
|
export type PapyrusSiteBackendConfig = {
|
|
26
100
|
/** Brand id; namespaces account-global resources (S3 Vectors index name). */
|
|
27
101
|
brandId?: string;
|
|
@@ -56,6 +130,8 @@ export type PapyrusSiteBackendConfig = {
|
|
|
56
130
|
/** Let this stack verify the SES domain identity and write its TXT record. Default false: verify once by hand. */
|
|
57
131
|
manageDomainIdentity?: boolean;
|
|
58
132
|
};
|
|
133
|
+
/** Opt-in static projection of published pages into an S3 site bucket. Unset: nothing is created. */
|
|
134
|
+
publication?: PapyrusPublicationConfig;
|
|
59
135
|
features?: {
|
|
60
136
|
consoleResponder?: boolean;
|
|
61
137
|
inboundEmail?: boolean;
|
package/amplify/site-backend.js
CHANGED
|
@@ -7,13 +7,17 @@ import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aw
|
|
|
7
7
|
import { dirname, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves.js";
|
|
10
|
+
import { addS3ProjectionPublication } from "./publication/s3-projection.js";
|
|
11
|
+
import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings.js";
|
|
10
12
|
import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
|
|
13
|
+
import { assertValidPublicationConfig } from "./site-backend-config.js";
|
|
14
|
+
import { revalidateSecretReadStatement } from "./revalidate-secret-policy.js";
|
|
11
15
|
import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
|
|
12
16
|
import { data } from "./data/resource.js";
|
|
13
17
|
import { assignmentAction } from "./functions/assignment-action/resource.js";
|
|
14
18
|
import { categoryAction } from "./functions/category-action/resource.js";
|
|
15
19
|
import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource.js";
|
|
16
|
-
import { contentActions } from "./functions/content-actions/resource.js";
|
|
20
|
+
import { contentActions, setSiteFilesForContentActionsBundle } from "./functions/content-actions/resource.js";
|
|
17
21
|
import { knowledgeQuery } from "./functions/knowledge-query/resource.js";
|
|
18
22
|
import { manageUserRole } from "./functions/manage-user-role/resource.js";
|
|
19
23
|
import { modelAttachmentUpload } from "./functions/model-attachment-upload/resource.js";
|
|
@@ -61,6 +65,10 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
61
65
|
...baseAuthConfig,
|
|
62
66
|
newsroomBasePath: baseAuthConfig.newsroomBasePath ?? activeBrand?.newsroomBasePath,
|
|
63
67
|
};
|
|
68
|
+
if (site.publication !== undefined)
|
|
69
|
+
assertValidPublicationConfig(site.publication);
|
|
70
|
+
const publicationPlan = planS3ProjectionBackend(site.publication);
|
|
71
|
+
setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
|
|
64
72
|
const backend = defineBackend({
|
|
65
73
|
assignmentAction,
|
|
66
74
|
auth: defineSiteAuth(authConfig),
|
|
@@ -301,6 +309,18 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
301
309
|
if (rebuildSettings.resources.length > 0) {
|
|
302
310
|
contentActionsLambda.addToRolePolicy(new PolicyStatement({ actions: rebuildSettings.actions, resources: rebuildSettings.resources }));
|
|
303
311
|
}
|
|
312
|
+
let s3ProjectionPublication;
|
|
313
|
+
if (site.publication !== undefined) {
|
|
314
|
+
s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication);
|
|
315
|
+
contentActionsLambda.node.defaultChild.memorySize = publicationPlan.contentActionsMemorySize;
|
|
316
|
+
}
|
|
317
|
+
const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
|
|
318
|
+
for (const [name, value] of Object.entries(publicationWiring.environment)) {
|
|
319
|
+
contentActionsLambda.addEnvironment(name, value);
|
|
320
|
+
}
|
|
321
|
+
for (const statement of publicationWiring.policyStatements) {
|
|
322
|
+
contentActionsLambda.addToRolePolicy(statement);
|
|
323
|
+
}
|
|
304
324
|
if ((site.revalidateBaseUrl ?? "").trim() !== "") {
|
|
305
325
|
backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_BASE_URL", (site.revalidateBaseUrl ?? "").trim());
|
|
306
326
|
const revalidateSecretParameter = (process.env.PAPYRUS_REVALIDATE_SECRET_PARAMETER ?? "").trim();
|
|
@@ -309,10 +329,12 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
309
329
|
}
|
|
310
330
|
backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET_PARAMETER", revalidateSecretParameter);
|
|
311
331
|
const revalidateStack = Stack.of(contentActionsLambda);
|
|
312
|
-
contentActionsLambda.addToRolePolicy(new PolicyStatement({
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
332
|
+
contentActionsLambda.addToRolePolicy(new PolicyStatement(revalidateSecretReadStatement({
|
|
333
|
+
partition: revalidateStack.partition,
|
|
334
|
+
region: revalidateStack.region,
|
|
335
|
+
account: revalidateStack.account,
|
|
336
|
+
parameterName: revalidateSecretParameter,
|
|
337
|
+
})));
|
|
316
338
|
}
|
|
317
339
|
grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
|
|
318
340
|
grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
|
package/amplify/site-backend.ts
CHANGED
|
@@ -3,20 +3,24 @@ import { Stack } from "aws-cdk-lib";
|
|
|
3
3
|
import type * as backup from "aws-cdk-lib/aws-backup";
|
|
4
4
|
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
|
|
5
5
|
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
6
|
-
import { CfnEventSourceMapping, Function as LambdaFunction, FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
|
|
6
|
+
import { CfnEventSourceMapping, CfnFunction, Function as LambdaFunction, FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
|
|
7
7
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
8
8
|
import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aws-s3vectors";
|
|
9
9
|
import type { Construct } from "constructs";
|
|
10
10
|
import { dirname, resolve } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves";
|
|
13
|
+
import { addS3ProjectionPublication, type S3ProjectionPublication } from "./publication/s3-projection";
|
|
14
|
+
import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings";
|
|
13
15
|
import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
|
|
16
|
+
import { assertValidPublicationConfig } from "./site-backend-config";
|
|
17
|
+
import { revalidateSecretReadStatement } from "./revalidate-secret-policy";
|
|
14
18
|
import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource";
|
|
15
19
|
import { data } from "./data/resource";
|
|
16
20
|
import { assignmentAction } from "./functions/assignment-action/resource";
|
|
17
21
|
import { categoryAction } from "./functions/category-action/resource";
|
|
18
22
|
import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource";
|
|
19
|
-
import { contentActions } from "./functions/content-actions/resource";
|
|
23
|
+
import { contentActions, setSiteFilesForContentActionsBundle } from "./functions/content-actions/resource";
|
|
20
24
|
import { knowledgeQuery } from "./functions/knowledge-query/resource";
|
|
21
25
|
import { manageUserRole } from "./functions/manage-user-role/resource";
|
|
22
26
|
import { modelAttachmentUpload } from "./functions/model-attachment-upload/resource";
|
|
@@ -79,6 +83,9 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
79
83
|
...baseAuthConfig,
|
|
80
84
|
newsroomBasePath: baseAuthConfig.newsroomBasePath ?? activeBrand?.newsroomBasePath,
|
|
81
85
|
};
|
|
86
|
+
if (site.publication !== undefined) assertValidPublicationConfig(site.publication);
|
|
87
|
+
const publicationPlan = planS3ProjectionBackend(site.publication);
|
|
88
|
+
setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
|
|
82
89
|
const backend = defineBackend({
|
|
83
90
|
assignmentAction,
|
|
84
91
|
auth: defineSiteAuth(authConfig),
|
|
@@ -387,6 +394,18 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
387
394
|
new PolicyStatement({ actions: rebuildSettings.actions, resources: rebuildSettings.resources }),
|
|
388
395
|
);
|
|
389
396
|
}
|
|
397
|
+
let s3ProjectionPublication: S3ProjectionPublication | undefined;
|
|
398
|
+
if (site.publication !== undefined) {
|
|
399
|
+
s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication);
|
|
400
|
+
(contentActionsLambda.node.defaultChild as CfnFunction).memorySize = publicationPlan.contentActionsMemorySize;
|
|
401
|
+
}
|
|
402
|
+
const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
|
|
403
|
+
for (const [name, value] of Object.entries(publicationWiring.environment)) {
|
|
404
|
+
contentActionsLambda.addEnvironment(name, value);
|
|
405
|
+
}
|
|
406
|
+
for (const statement of publicationWiring.policyStatements) {
|
|
407
|
+
contentActionsLambda.addToRolePolicy(statement);
|
|
408
|
+
}
|
|
390
409
|
if ((site.revalidateBaseUrl ?? "").trim() !== "") {
|
|
391
410
|
backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_BASE_URL", (site.revalidateBaseUrl ?? "").trim());
|
|
392
411
|
const revalidateSecretParameter = (process.env.PAPYRUS_REVALIDATE_SECRET_PARAMETER ?? "").trim();
|
|
@@ -398,10 +417,14 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
398
417
|
backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET_PARAMETER", revalidateSecretParameter);
|
|
399
418
|
const revalidateStack = Stack.of(contentActionsLambda);
|
|
400
419
|
contentActionsLambda.addToRolePolicy(
|
|
401
|
-
new PolicyStatement(
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
420
|
+
new PolicyStatement(
|
|
421
|
+
revalidateSecretReadStatement({
|
|
422
|
+
partition: revalidateStack.partition,
|
|
423
|
+
region: revalidateStack.region,
|
|
424
|
+
account: revalidateStack.account,
|
|
425
|
+
parameterName: revalidateSecretParameter,
|
|
426
|
+
}),
|
|
427
|
+
),
|
|
405
428
|
);
|
|
406
429
|
}
|
|
407
430
|
grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED