@anthusai/papyrus 2.0.0-next.2 → 2.0.0-next.21
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/data/resource.js +15 -0
- package/amplify/data/resource.ts +17 -0
- package/amplify/data/schema.js +20 -0
- package/amplify/data/schema.ts +22 -0
- package/amplify/functions/assignment-action/data-access.js +88 -0
- package/amplify/functions/assignment-action/data-access.ts +87 -0
- package/amplify/functions/content-actions/handler.py +22 -0
- package/amplify/functions/editorial-checks/handler.py +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/functions/editorial-checks/resource.js +43 -0
- package/amplify/functions/editorial-checks/resource.ts +60 -0
- package/amplify/functions/editorial-checks/schema.js +92 -0
- package/amplify/functions/editorial-checks/schema.ts +94 -0
- package/amplify/functions/editorial-checks/wire.js +49 -0
- package/amplify/functions/editorial-checks/wire.ts +57 -0
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/publication/redirect-and-index.js +83 -0
- package/amplify/publication/redirect-rules.js +61 -0
- package/amplify/publication/redirect-rules.ts +79 -0
- package/amplify/publication/s3-projection.js +5 -1
- package/amplify/publication/s3-projection.ts +7 -1
- package/amplify/site-backend-config.ts +10 -0
- package/amplify/site-backend.js +31 -0
- package/amplify/site-backend.ts +38 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +347 -0
- package/components/editorial-finding-card.tsx +162 -0
- package/components/editorial-findings-panel.tsx +91 -0
- package/components/editorial-preview-popover.tsx +103 -0
- package/components/markus-body-editor.tsx +213 -0
- package/components/markus-ir-preview.tsx +147 -6
- package/components/monitoring-overview-view.tsx +240 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsroom-article-editor.tsx +136 -11
- package/components/newsroom-board-view.tsx +6 -7
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-shell.tsx +7 -3
- package/components/newsroom-organizer.css +74 -61
- package/components/newsroom-references-view.tsx +14 -14
- package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
- package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
- package/components/newsroom-theme-fonts/night-desk.css +10 -0
- package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
- package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
- package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
- package/components/newsroom-theme.css +39 -0
- package/components/newsroom-theme.tsx +33 -0
- package/components/publication-cyclotron-panel.tsx +10 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +30 -21
- package/components/reference-cyclotron-priming.tsx +4 -4
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +27 -2
- package/components/use-editorial-checks.ts +133 -0
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +342 -0
- package/lib/editorial-checks-backend.ts +16 -0
- package/lib/editorial-checks-client.ts +194 -0
- package/lib/editorial-checks-demo.ts +147 -0
- package/lib/editorial-checks-settings.ts +27 -0
- package/lib/editorial-checks.ts +171 -0
- package/lib/editorial-preview.ts +228 -0
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +144 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/relevance-status.ts +2 -2
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +23 -3
- package/package.json +47 -3
- package/routes.manifest.json +34 -1
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
- package/components/newsroom-newsprint-board.css +0 -7
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineFunction } from "@aws-amplify/backend";
|
|
2
|
+
/** The public form receiving endpoint. Wired (table, Function URL, origins, output) by `wire.ts`. */
|
|
3
|
+
export const publicForms = defineFunction({
|
|
4
|
+
name: "papyrus-public-forms",
|
|
5
|
+
entry: "./handler.ts",
|
|
6
|
+
timeoutSeconds: 10,
|
|
7
|
+
memoryMB: 256,
|
|
8
|
+
resourceGroupName: "data",
|
|
9
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineFunction } from "@aws-amplify/backend";
|
|
2
|
+
|
|
3
|
+
/** The public form receiving endpoint. Wired (table, Function URL, origins, output) by `wire.ts`. */
|
|
4
|
+
export const publicForms = defineFunction({
|
|
5
|
+
name: "papyrus-public-forms",
|
|
6
|
+
entry: "./handler.ts",
|
|
7
|
+
timeoutSeconds: 10,
|
|
8
|
+
memoryMB: 256,
|
|
9
|
+
resourceGroupName: "data",
|
|
10
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
2
|
+
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
|
|
3
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
4
|
+
import { FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
export const PUBLIC_FORMS_OUTPUT_KEY = "publicForms";
|
|
6
|
+
/**
|
|
7
|
+
* Wires the endpoint: the rate-limit counter table (TTL), the Function URL and the function's settings, and returns
|
|
8
|
+
* the URL for the `custom.publicForms.endpoint` output. Everything lives in the function's own stack.
|
|
9
|
+
*/
|
|
10
|
+
export function wirePublicForms(backend, wiring) {
|
|
11
|
+
const lambda = backend.publicForms.resources.lambda;
|
|
12
|
+
const stack = Stack.of(lambda);
|
|
13
|
+
const table = new dynamodb.Table(stack, "PublicFormsLimits", {
|
|
14
|
+
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
|
15
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
16
|
+
timeToLiveAttribute: "expiresAt",
|
|
17
|
+
removalPolicy: RemovalPolicy.DESTROY,
|
|
18
|
+
});
|
|
19
|
+
lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem"], resources: [table.tableArn] }));
|
|
20
|
+
backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_TABLE", table.tableName);
|
|
21
|
+
backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_ORIGINS", wiring.allowedOrigins.join(","));
|
|
22
|
+
backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_HONEYPOTS", (wiring.honeypotFields ?? []).join(","));
|
|
23
|
+
// CORS is answered by the handler (it must echo exactly one allowed origin), so the URL carries no CORS block.
|
|
24
|
+
return lambda.addFunctionUrl({ authType: FunctionUrlAuthType.NONE }).url;
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
2
|
+
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
|
|
3
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
4
|
+
import { type Function as LambdaFunction, FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
|
|
6
|
+
export const PUBLIC_FORMS_OUTPUT_KEY = "publicForms";
|
|
7
|
+
|
|
8
|
+
export type PublicFormsWiring = {
|
|
9
|
+
allowedOrigins: string[];
|
|
10
|
+
honeypotFields?: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wires the endpoint: the rate-limit counter table (TTL), the Function URL and the function's settings, and returns
|
|
15
|
+
* the URL for the `custom.publicForms.endpoint` output. Everything lives in the function's own stack.
|
|
16
|
+
*/
|
|
17
|
+
export function wirePublicForms(backend: { publicForms: { resources: { lambda: unknown }; addEnvironment: (name: string, value: string) => void } }, wiring: PublicFormsWiring): string {
|
|
18
|
+
const lambda = backend.publicForms.resources.lambda as LambdaFunction;
|
|
19
|
+
const stack = Stack.of(lambda);
|
|
20
|
+
const table = new dynamodb.Table(stack, "PublicFormsLimits", {
|
|
21
|
+
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
|
22
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
23
|
+
timeToLiveAttribute: "expiresAt",
|
|
24
|
+
removalPolicy: RemovalPolicy.DESTROY,
|
|
25
|
+
});
|
|
26
|
+
lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem"], resources: [table.tableArn] }));
|
|
27
|
+
backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_TABLE", table.tableName);
|
|
28
|
+
backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_ORIGINS", wiring.allowedOrigins.join(","));
|
|
29
|
+
backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_HONEYPOTS", (wiring.honeypotFields ?? []).join(","));
|
|
30
|
+
// CORS is answered by the handler (it must echo exactly one allowed origin), so the URL carries no CORS block.
|
|
31
|
+
return lambda.addFunctionUrl({ authType: FunctionUrlAuthType.NONE }).url;
|
|
32
|
+
}
|
|
@@ -78,23 +78,29 @@ export function siteFilesContentHash(siteFiles) {
|
|
|
78
78
|
function pipSpec() {
|
|
79
79
|
return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
|
|
80
80
|
}
|
|
81
|
+
/** What the first pip install gets: `papyrus-newsroom` with its dependencies, plus the function's own requirements. Empty means skip it. */
|
|
82
|
+
export function firstPipRequirements(options) {
|
|
83
|
+
return [...(options.noDeps ? [] : [pipSpec()]), ...(options.requirements ?? [])];
|
|
84
|
+
}
|
|
81
85
|
function bundleFromPip(outputDir, options) {
|
|
82
86
|
const extra = (process.env.PAPYRUS_PIP_EXTRA_ARGS ?? "").trim().split(/\s+/).filter(Boolean);
|
|
83
87
|
const python = (process.env.PAPYRUS_PIP_PYTHON ?? "python3").trim();
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
88
|
+
const requirements = firstPipRequirements(options);
|
|
89
|
+
if (requirements.length > 0) {
|
|
90
|
+
execFileSync(python, [
|
|
91
|
+
"-m", "pip", "install",
|
|
92
|
+
"--target", outputDir,
|
|
93
|
+
"--no-cache-dir",
|
|
94
|
+
"--platform", "manylinux2014_aarch64",
|
|
95
|
+
"--implementation", "cp",
|
|
96
|
+
"--python-version", "3.12",
|
|
97
|
+
"--only-binary=:all:",
|
|
98
|
+
// Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
|
|
99
|
+
"--ignore-requires-python",
|
|
100
|
+
...extra,
|
|
101
|
+
...requirements,
|
|
102
|
+
], { stdio: "inherit" });
|
|
103
|
+
}
|
|
98
104
|
if (options.noDeps) {
|
|
99
105
|
execFileSync(python, [
|
|
100
106
|
"-m", "pip", "install",
|
|
@@ -102,27 +102,34 @@ function pipSpec(): string {
|
|
|
102
102
|
return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/** What the first pip install gets: `papyrus-newsroom` with its dependencies, plus the function's own requirements. Empty means skip it. */
|
|
106
|
+
export function firstPipRequirements(options: Pick<PythonBundleOptions, "noDeps" | "requirements">): string[] {
|
|
107
|
+
return [...(options.noDeps ? [] : [pipSpec()]), ...(options.requirements ?? [])];
|
|
108
|
+
}
|
|
109
|
+
|
|
105
110
|
function bundleFromPip(outputDir: string, options: PythonBundleOptions): void {
|
|
106
111
|
const extra = (process.env.PAPYRUS_PIP_EXTRA_ARGS ?? "").trim().split(/\s+/).filter(Boolean);
|
|
107
112
|
const python = (process.env.PAPYRUS_PIP_PYTHON ?? "python3").trim();
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
113
|
+
const requirements = firstPipRequirements(options);
|
|
114
|
+
if (requirements.length > 0) {
|
|
115
|
+
execFileSync(
|
|
116
|
+
python,
|
|
117
|
+
[
|
|
118
|
+
"-m", "pip", "install",
|
|
119
|
+
"--target", outputDir,
|
|
120
|
+
"--no-cache-dir",
|
|
121
|
+
"--platform", "manylinux2014_aarch64",
|
|
122
|
+
"--implementation", "cp",
|
|
123
|
+
"--python-version", "3.12",
|
|
124
|
+
"--only-binary=:all:",
|
|
125
|
+
// Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
|
|
126
|
+
"--ignore-requires-python",
|
|
127
|
+
...extra,
|
|
128
|
+
...requirements,
|
|
129
|
+
],
|
|
130
|
+
{ stdio: "inherit" },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
126
133
|
if (options.noDeps) {
|
|
127
134
|
execFileSync(
|
|
128
135
|
python,
|
|
@@ -12,6 +12,9 @@ async function lookUpRedirectTarget(uri) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function rewriteToIndexDocument(uri) {
|
|
15
|
+
if (uri === "/404") {
|
|
16
|
+
return "/404.html";
|
|
17
|
+
}
|
|
15
18
|
if (uri.endsWith("/")) {
|
|
16
19
|
return uri + "index.html";
|
|
17
20
|
}
|
|
@@ -22,6 +25,72 @@ function rewriteToIndexDocument(uri) {
|
|
|
22
25
|
return uri;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
// Pattern redirects: data from the site's projection.json, spliced between the markers at deploy
|
|
29
|
+
// time by redirect-rules.ts. Each rule is {p: regex, t: target with $1..$9, s: status, x: excluded paths}.
|
|
30
|
+
const patternRules = /*PAPYRUS_PATTERN_RULES*/[]/*END_PAPYRUS_PATTERN_RULES*/;
|
|
31
|
+
|
|
32
|
+
function lookUpPatternRedirect(uri) {
|
|
33
|
+
for (let index = 0; index < patternRules.length; index += 1) {
|
|
34
|
+
const rule = patternRules[index];
|
|
35
|
+
if (rule.x.indexOf(uri) !== -1) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const match = new RegExp(rule.p).exec(uri);
|
|
39
|
+
if (match !== null) {
|
|
40
|
+
return {
|
|
41
|
+
status: rule.s,
|
|
42
|
+
target: rule.t.replace(/\$([0-9])/g, function (whole, group) {
|
|
43
|
+
return match[Number(group)] || "";
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Content negotiation for agents: when the site publishes Markdown twins (standard front end, agents.markdown), a request
|
|
52
|
+
// whose Accept header prefers text/markdown over text/html is rewritten to the .md object before the cache lookup. The
|
|
53
|
+
// flag is spliced in by redirect-rules.ts at deploy time.
|
|
54
|
+
const negotiateMarkdown = /*PAPYRUS_NEGOTIATE_MARKDOWN*/false/*END_PAPYRUS_NEGOTIATE_MARKDOWN*/;
|
|
55
|
+
|
|
56
|
+
function qualityOf(accept, mediaType) {
|
|
57
|
+
let quality = 0;
|
|
58
|
+
const parts = accept.toLowerCase().split(",");
|
|
59
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
60
|
+
const fields = parts[index].split(";");
|
|
61
|
+
if (fields[0].trim() !== mediaType) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
quality = 1;
|
|
65
|
+
for (let field = 1; field < fields.length; field += 1) {
|
|
66
|
+
const pair = fields[field].trim();
|
|
67
|
+
if (pair.indexOf("q=") === 0) {
|
|
68
|
+
quality = Number(pair.substring(2));
|
|
69
|
+
if (isNaN(quality)) {
|
|
70
|
+
quality = 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return quality;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function prefersMarkdown(request) {
|
|
79
|
+
const header = request.headers && request.headers.accept;
|
|
80
|
+
if (!header || !header.value) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const markdown = qualityOf(header.value, "text/markdown");
|
|
84
|
+
return markdown > 0 && markdown > qualityOf(header.value, "text/html");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function markdownTwin(uri) {
|
|
88
|
+
if (uri === "/404.html" || uri.length < 6 || uri.substring(uri.length - 5) !== ".html") {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return uri.substring(0, uri.length - 5) + ".md";
|
|
92
|
+
}
|
|
93
|
+
|
|
25
94
|
async function handler(event) {
|
|
26
95
|
const request = event.request;
|
|
27
96
|
const redirectTarget = await lookUpRedirectTarget(request.uri);
|
|
@@ -32,6 +101,20 @@ async function handler(event) {
|
|
|
32
101
|
headers: { location: { value: redirectTarget } },
|
|
33
102
|
};
|
|
34
103
|
}
|
|
104
|
+
const patternRedirect = lookUpPatternRedirect(request.uri);
|
|
105
|
+
if (patternRedirect !== null) {
|
|
106
|
+
return {
|
|
107
|
+
statusCode: patternRedirect.status,
|
|
108
|
+
statusDescription: "Moved",
|
|
109
|
+
headers: { location: { value: patternRedirect.target } },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
35
112
|
request.uri = rewriteToIndexDocument(request.uri);
|
|
113
|
+
if (negotiateMarkdown && prefersMarkdown(request)) {
|
|
114
|
+
const twin = markdownTwin(request.uri);
|
|
115
|
+
if (twin !== null) {
|
|
116
|
+
request.uri = twin;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
36
119
|
return request;
|
|
37
120
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
/** Mirrors DIRECTORY_HTML_PATTERN and DIRECTORY_SLASH_PATTERN in standard/config.py. */
|
|
4
|
+
const DIRECTORY_HTML_PATTERN = "^/(?!(?:.*/)?index\\.html$)(.+)\\.html$";
|
|
5
|
+
const DIRECTORY_SLASH_PATTERN = "^/((?:[^/]+/)*[^/.]+)$";
|
|
6
|
+
const START_MARKER = "/*PAPYRUS_PATTERN_RULES*/";
|
|
7
|
+
const END_MARKER = "/*END_PAPYRUS_PATTERN_RULES*/";
|
|
8
|
+
/**
|
|
9
|
+
* The pattern rules a site declares in projection.json: its `redirects.patterns`, plus, for
|
|
10
|
+
* `urls.style: "directory"`, `/x.html` to `/x/`. Mirrors `StandardSettings.edge_rules` in Python.
|
|
11
|
+
*/
|
|
12
|
+
export function edgeRulesFromSettings(settings) {
|
|
13
|
+
const rules = (settings.redirects?.patterns ?? []).map((rule) => ({
|
|
14
|
+
p: rule.pattern,
|
|
15
|
+
t: rule.to,
|
|
16
|
+
s: rule.status ?? 301,
|
|
17
|
+
x: rule.except ?? [],
|
|
18
|
+
}));
|
|
19
|
+
if (settings.urls?.style === "directory") {
|
|
20
|
+
// /x.html to /x/ (a directory index file such as /x/index.html is served as it is), then /x to /x/.
|
|
21
|
+
rules.push({ p: DIRECTORY_HTML_PATTERN, t: "/$1/", s: 301, x: ["/404.html"] });
|
|
22
|
+
rules.push({ p: DIRECTORY_SLASH_PATTERN, t: "/$1/", s: 301, x: ["/404"] });
|
|
23
|
+
}
|
|
24
|
+
return rules;
|
|
25
|
+
}
|
|
26
|
+
/** A standard-front-end site publishes Markdown twins unless it turns `agents.markdown` off. */
|
|
27
|
+
export function negotiatesMarkdown(settings) {
|
|
28
|
+
return settings.site !== undefined && settings.agents?.markdown !== false;
|
|
29
|
+
}
|
|
30
|
+
export function readEdgeConfig(staticDir, baseDirectory = process.cwd()) {
|
|
31
|
+
if (staticDir === undefined)
|
|
32
|
+
return { rules: [], negotiateMarkdown: false };
|
|
33
|
+
const settingsPath = resolve(baseDirectory, staticDir, "projection.json");
|
|
34
|
+
if (!existsSync(settingsPath))
|
|
35
|
+
return { rules: [], negotiateMarkdown: false };
|
|
36
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
37
|
+
return { rules: edgeRulesFromSettings(settings), negotiateMarkdown: negotiatesMarkdown(settings) };
|
|
38
|
+
}
|
|
39
|
+
export function readEdgeRules(staticDir, baseDirectory = process.cwd()) {
|
|
40
|
+
return readEdgeConfig(staticDir, baseDirectory).rules;
|
|
41
|
+
}
|
|
42
|
+
const NEGOTIATE_START = "/*PAPYRUS_NEGOTIATE_MARKDOWN*/";
|
|
43
|
+
const NEGOTIATE_END = "/*END_PAPYRUS_NEGOTIATE_MARKDOWN*/";
|
|
44
|
+
/** The CloudFront Function source with the site's pattern rules and Markdown negotiation flag spliced in. */
|
|
45
|
+
export function compileRedirectFunctionSource(source, rules, options = {}) {
|
|
46
|
+
const start = source.indexOf(START_MARKER);
|
|
47
|
+
const end = source.indexOf(END_MARKER);
|
|
48
|
+
if (start === -1 || end === -1 || end < start) {
|
|
49
|
+
throw new Error("redirect function source has no pattern-rule markers");
|
|
50
|
+
}
|
|
51
|
+
let compiled = rules.length === 0 ? source : source.slice(0, start + START_MARKER.length) + JSON.stringify(rules) + source.slice(end);
|
|
52
|
+
if (options.negotiateMarkdown === true) {
|
|
53
|
+
const from = compiled.indexOf(NEGOTIATE_START);
|
|
54
|
+
const to = compiled.indexOf(NEGOTIATE_END);
|
|
55
|
+
if (from === -1 || to === -1 || to < from) {
|
|
56
|
+
throw new Error("redirect function source has no markdown negotiation markers");
|
|
57
|
+
}
|
|
58
|
+
compiled = compiled.slice(0, from + NEGOTIATE_START.length) + "true" + compiled.slice(to);
|
|
59
|
+
}
|
|
60
|
+
return compiled;
|
|
61
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** One pattern redirect as the edge function reads it: regex, target with $1..$9, status, excluded paths. */
|
|
5
|
+
export type EdgeRedirectRule = { p: string; t: string; s: number; x: string[] };
|
|
6
|
+
|
|
7
|
+
/** Mirrors DIRECTORY_HTML_PATTERN and DIRECTORY_SLASH_PATTERN in standard/config.py. */
|
|
8
|
+
const DIRECTORY_HTML_PATTERN = "^/(?!(?:.*/)?index\\.html$)(.+)\\.html$";
|
|
9
|
+
const DIRECTORY_SLASH_PATTERN = "^/((?:[^/]+/)*[^/.]+)$";
|
|
10
|
+
|
|
11
|
+
const START_MARKER = "/*PAPYRUS_PATTERN_RULES*/";
|
|
12
|
+
const END_MARKER = "/*END_PAPYRUS_PATTERN_RULES*/";
|
|
13
|
+
|
|
14
|
+
type ProjectionRedirects = { patterns?: { pattern: string; to: string; status?: number; except?: string[] }[] };
|
|
15
|
+
type ProjectionSettings = { site?: unknown; urls?: { style?: string }; redirects?: ProjectionRedirects; agents?: { markdown?: boolean } };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The pattern rules a site declares in projection.json: its `redirects.patterns`, plus, for
|
|
19
|
+
* `urls.style: "directory"`, `/x.html` to `/x/`. Mirrors `StandardSettings.edge_rules` in Python.
|
|
20
|
+
*/
|
|
21
|
+
export function edgeRulesFromSettings(settings: ProjectionSettings): EdgeRedirectRule[] {
|
|
22
|
+
const rules = (settings.redirects?.patterns ?? []).map((rule) => ({
|
|
23
|
+
p: rule.pattern,
|
|
24
|
+
t: rule.to,
|
|
25
|
+
s: rule.status ?? 301,
|
|
26
|
+
x: rule.except ?? [],
|
|
27
|
+
}));
|
|
28
|
+
if (settings.urls?.style === "directory") {
|
|
29
|
+
// /x.html to /x/ (a directory index file such as /x/index.html is served as it is), then /x to /x/.
|
|
30
|
+
rules.push({ p: DIRECTORY_HTML_PATTERN, t: "/$1/", s: 301, x: ["/404.html"] });
|
|
31
|
+
rules.push({ p: DIRECTORY_SLASH_PATTERN, t: "/$1/", s: 301, x: ["/404"] });
|
|
32
|
+
}
|
|
33
|
+
return rules;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A standard-front-end site publishes Markdown twins unless it turns `agents.markdown` off. */
|
|
37
|
+
export function negotiatesMarkdown(settings: ProjectionSettings): boolean {
|
|
38
|
+
return settings.site !== undefined && settings.agents?.markdown !== false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type EdgeConfig = { rules: EdgeRedirectRule[]; negotiateMarkdown: boolean };
|
|
42
|
+
|
|
43
|
+
export function readEdgeConfig(staticDir: string | undefined, baseDirectory: string = process.cwd()): EdgeConfig {
|
|
44
|
+
if (staticDir === undefined) return { rules: [], negotiateMarkdown: false };
|
|
45
|
+
const settingsPath = resolve(baseDirectory, staticDir, "projection.json");
|
|
46
|
+
if (!existsSync(settingsPath)) return { rules: [], negotiateMarkdown: false };
|
|
47
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as ProjectionSettings;
|
|
48
|
+
return { rules: edgeRulesFromSettings(settings), negotiateMarkdown: negotiatesMarkdown(settings) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readEdgeRules(staticDir: string | undefined, baseDirectory: string = process.cwd()): EdgeRedirectRule[] {
|
|
52
|
+
return readEdgeConfig(staticDir, baseDirectory).rules;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const NEGOTIATE_START = "/*PAPYRUS_NEGOTIATE_MARKDOWN*/";
|
|
56
|
+
const NEGOTIATE_END = "/*END_PAPYRUS_NEGOTIATE_MARKDOWN*/";
|
|
57
|
+
|
|
58
|
+
/** The CloudFront Function source with the site's pattern rules and Markdown negotiation flag spliced in. */
|
|
59
|
+
export function compileRedirectFunctionSource(
|
|
60
|
+
source: string,
|
|
61
|
+
rules: EdgeRedirectRule[],
|
|
62
|
+
options: { negotiateMarkdown?: boolean } = {},
|
|
63
|
+
): string {
|
|
64
|
+
const start = source.indexOf(START_MARKER);
|
|
65
|
+
const end = source.indexOf(END_MARKER);
|
|
66
|
+
if (start === -1 || end === -1 || end < start) {
|
|
67
|
+
throw new Error("redirect function source has no pattern-rule markers");
|
|
68
|
+
}
|
|
69
|
+
let compiled = rules.length === 0 ? source : source.slice(0, start + START_MARKER.length) + JSON.stringify(rules) + source.slice(end);
|
|
70
|
+
if (options.negotiateMarkdown === true) {
|
|
71
|
+
const from = compiled.indexOf(NEGOTIATE_START);
|
|
72
|
+
const to = compiled.indexOf(NEGOTIATE_END);
|
|
73
|
+
if (from === -1 || to === -1 || to < from) {
|
|
74
|
+
throw new Error("redirect function source has no markdown negotiation markers");
|
|
75
|
+
}
|
|
76
|
+
compiled = compiled.slice(0, from + NEGOTIATE_START.length) + "true" + compiled.slice(to);
|
|
77
|
+
}
|
|
78
|
+
return compiled;
|
|
79
|
+
}
|
|
@@ -7,11 +7,13 @@ import { SqsDestination } from "aws-cdk-lib/aws-lambda-destinations";
|
|
|
7
7
|
import * as route53 from "aws-cdk-lib/aws-route53";
|
|
8
8
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
9
9
|
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
10
11
|
import { dirname, resolve } from "node:path";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { assertValidPublicationConfig } from "../site-backend-config.js";
|
|
13
14
|
import { addRenditionsFunction } from "./renditions-function.js";
|
|
14
15
|
import { RENDITIONS_FUNCTION_NAME_ENV } from "./renditions-settings.js";
|
|
16
|
+
import { compileRedirectFunctionSource, readEdgeConfig } from "./redirect-rules.js";
|
|
15
17
|
import { addInvalidationPathAlarm } from "./invalidation-alarm.js";
|
|
16
18
|
import { distributionIdFromArn, s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
|
|
17
19
|
const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
|
|
@@ -70,7 +72,9 @@ export function addS3ProjectionPublication(scope, props, media) {
|
|
|
70
72
|
const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
|
|
71
73
|
const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
|
|
72
74
|
runtime: cloudfront.FunctionRuntime.JS_2_0,
|
|
73
|
-
code: cloudfront.FunctionCode.
|
|
75
|
+
code: cloudfront.FunctionCode.fromInline(compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), readEdgeConfig(props.staticDir).rules, {
|
|
76
|
+
negotiateMarkdown: readEdgeConfig(props.staticDir).negotiateMarkdown,
|
|
77
|
+
})),
|
|
74
78
|
keyValueStore,
|
|
75
79
|
});
|
|
76
80
|
const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
|
|
@@ -10,11 +10,13 @@ import * as route53 from "aws-cdk-lib/aws-route53";
|
|
|
10
10
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
11
11
|
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
12
12
|
import type { Construct } from "constructs";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
13
14
|
import { dirname, resolve } from "node:path";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
16
|
import { assertValidPublicationConfig, type PapyrusPublicationConfig } from "../site-backend-config";
|
|
16
17
|
import { addRenditionsFunction, type RenditionsFunction } from "./renditions-function";
|
|
17
18
|
import { RENDITIONS_FUNCTION_NAME_ENV, type RenditionsPlan } from "./renditions-settings";
|
|
19
|
+
import { compileRedirectFunctionSource, readEdgeConfig } from "./redirect-rules";
|
|
18
20
|
import { addInvalidationPathAlarm } from "./invalidation-alarm";
|
|
19
21
|
import { distributionIdFromArn, s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
|
|
20
22
|
|
|
@@ -112,7 +114,11 @@ export function addS3ProjectionPublication(
|
|
|
112
114
|
const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
|
|
113
115
|
const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
|
|
114
116
|
runtime: cloudfront.FunctionRuntime.JS_2_0,
|
|
115
|
-
code: cloudfront.FunctionCode.
|
|
117
|
+
code: cloudfront.FunctionCode.fromInline(
|
|
118
|
+
compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), readEdgeConfig(props.staticDir).rules, {
|
|
119
|
+
negotiateMarkdown: readEdgeConfig(props.staticDir).negotiateMarkdown,
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
116
122
|
keyValueStore,
|
|
117
123
|
});
|
|
118
124
|
const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
|
|
@@ -178,6 +178,16 @@ export type PapyrusSiteBackendConfig = {
|
|
|
178
178
|
* Unset: no plugin, and the synthesized backend is exactly what it was before plugins existed.
|
|
179
179
|
*/
|
|
180
180
|
plugins?: PluginRequest[];
|
|
181
|
+
/**
|
|
182
|
+
* The public form receiving endpoint every site gets (docs/public-forms.md). It accepts posts only from the site's
|
|
183
|
+
* own public hosts (distribution aliases, reader domain, revalidate URL, sign-in redirect URLs) plus these.
|
|
184
|
+
*/
|
|
185
|
+
publicForms?: {
|
|
186
|
+
/** Extra https origins (or http://localhost) allowed to post, for example `https://www.example.org`. */
|
|
187
|
+
allowedOrigins?: string[];
|
|
188
|
+
/** Extra honeypot field names besides `website`. */
|
|
189
|
+
honeypotFields?: string[];
|
|
190
|
+
};
|
|
181
191
|
features?: {
|
|
182
192
|
consoleResponder?: boolean;
|
|
183
193
|
inboundEmail?: boolean;
|
package/amplify/site-backend.js
CHANGED
|
@@ -20,9 +20,14 @@ import { addInvalidationPathAlarm } from "./publication/invalidation-alarm.js";
|
|
|
20
20
|
import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRedirectUrls } from "./auth/resource.js";
|
|
21
21
|
import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client.js";
|
|
22
22
|
import { defineSiteData } from "./data/resource.js";
|
|
23
|
+
import { editorialChecks } from "./functions/editorial-checks/resource.js";
|
|
24
|
+
import { monitoringView } from "./functions/monitoring-view/resource.js";
|
|
25
|
+
import { wireMonitoringView } from "./functions/monitoring-view/wire.js";
|
|
26
|
+
import { wireEditorialChecks } from "./functions/editorial-checks/wire.js";
|
|
23
27
|
import { resolvePlugins } from "./plugins/registry.js";
|
|
24
28
|
import { wirePlugins } from "./plugins/wire.js";
|
|
25
29
|
import { assignmentAction } from "./functions/assignment-action/resource.js";
|
|
30
|
+
import { scopeAssignmentDataAccess } from "./functions/assignment-action/data-access.js";
|
|
26
31
|
import { categoryAction } from "./functions/category-action/resource.js";
|
|
27
32
|
import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource.js";
|
|
28
33
|
import { contentActions, setSiteFilesForContentActionsBundle } from "./functions/content-actions/resource.js";
|
|
@@ -30,6 +35,9 @@ import { knowledgeQuery } from "./functions/knowledge-query/resource.js";
|
|
|
30
35
|
import { manageUserRole } from "./functions/manage-user-role/resource.js";
|
|
31
36
|
import { modelAttachmentUpload } from "./functions/model-attachment-upload/resource.js";
|
|
32
37
|
import { newsroomSummary } from "./functions/newsroom-summary/resource.js";
|
|
38
|
+
import { derivePublicFormOrigins } from "./functions/public-forms/origins.js";
|
|
39
|
+
import { publicForms } from "./functions/public-forms/resource.js";
|
|
40
|
+
import { wirePublicForms } from "./functions/public-forms/wire.js";
|
|
33
41
|
import { procedureAction } from "./functions/procedure-action/resource.js";
|
|
34
42
|
import { readerSettings } from "./functions/reader-settings/resource.js";
|
|
35
43
|
import { emailSubmissionProcessor } from "./functions/email-submission-processor/resource.js";
|
|
@@ -88,12 +96,15 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
88
96
|
auth: defineSiteAuth(authConfig),
|
|
89
97
|
categoryAction,
|
|
90
98
|
contentActions,
|
|
99
|
+
editorialChecks,
|
|
91
100
|
data: defineSiteData(pluginDefinitions),
|
|
92
101
|
knowledgeQuery,
|
|
93
102
|
manageUserRole,
|
|
103
|
+
monitoringView,
|
|
94
104
|
modelAttachmentUpload,
|
|
95
105
|
newsroomSummary,
|
|
96
106
|
procedureAction,
|
|
107
|
+
publicForms,
|
|
97
108
|
readerSettings,
|
|
98
109
|
emailSubmissionProcessor,
|
|
99
110
|
sesInboundReceive,
|
|
@@ -304,10 +315,19 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
304
315
|
}
|
|
305
316
|
grantCorporaRead(backend.knowledgeQuery.resources.lambda);
|
|
306
317
|
grantNewsroomReadWrite(backend.assignmentAction.resources.lambda);
|
|
318
|
+
scopeAssignmentDataAccess(backend.assignmentAction.resources.lambda, backend.data.resources.graphqlApi.arn);
|
|
307
319
|
grantNewsroomReadWrite(backend.categoryAction.resources.lambda);
|
|
308
320
|
grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda);
|
|
309
321
|
const contentActionsLambda = backend.contentActions.resources.lambda;
|
|
310
322
|
grantNewsroomReadWriteDelete(contentActionsLambda);
|
|
323
|
+
if ((activeBrand?.corpusKey ?? "").trim() !== "") {
|
|
324
|
+
// A deploy creates the publication's canonical corpus from the brand when the backend has none.
|
|
325
|
+
contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_CORPUS_KEY", (activeBrand?.corpusKey ?? "").trim());
|
|
326
|
+
contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_NAME", (activeBrand?.publicationName ?? activeBrand?.corpusKey ?? "").trim());
|
|
327
|
+
}
|
|
328
|
+
// AppSync validates the token; pending intake additionally binds its actor
|
|
329
|
+
// and publication to this deployment's pool and existing API endpoint.
|
|
330
|
+
contentActionsLambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
|
|
311
331
|
contentActionsLambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
|
|
312
332
|
contentActionsLambda.addToRolePolicy(new PolicyStatement({
|
|
313
333
|
actions: ["appsync:GraphQL"],
|
|
@@ -548,8 +568,17 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
548
568
|
: []),
|
|
549
569
|
],
|
|
550
570
|
}));
|
|
571
|
+
const publicFormsEndpoint = wirePublicForms(backend, {
|
|
572
|
+
allowedOrigins: derivePublicFormOrigins({
|
|
573
|
+
explicit: site.publicForms?.allowedOrigins,
|
|
574
|
+
hosts: [activeBrand?.readerDeployment?.domain, ...(publication?.distribution?.aliases ?? [])],
|
|
575
|
+
urls: [site.revalidateBaseUrl, ...siteAuthRedirectUrls(authConfig)],
|
|
576
|
+
}),
|
|
577
|
+
honeypotFields: site.publicForms?.honeypotFields,
|
|
578
|
+
});
|
|
551
579
|
backend.addOutput({
|
|
552
580
|
custom: {
|
|
581
|
+
publicForms: { endpoint: publicFormsEndpoint },
|
|
553
582
|
prospectusSignIn: prospectusGoogleOnlyClient
|
|
554
583
|
? { clientId: prospectusGoogleOnlyClient.ref, callbackPath: PROSPECTUS_SIGN_IN_CALLBACK_PATH }
|
|
555
584
|
: null,
|
|
@@ -584,6 +613,8 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
584
613
|
: null,
|
|
585
614
|
},
|
|
586
615
|
});
|
|
616
|
+
wireEditorialChecks(backend);
|
|
617
|
+
wireMonitoringView(backend);
|
|
587
618
|
wirePlugins(backend, plugins, contentActionsLambda);
|
|
588
619
|
return backend;
|
|
589
620
|
}
|