@anthusai/papyrus 1.0.0-next.9 → 1.1.0
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/auth/prospectus-google-client.js +66 -0
- package/amplify/auth/prospectus-google-client.ts +71 -0
- package/amplify/auth/redirect-urls.js +54 -0
- package/amplify/auth/redirect-urls.ts +66 -0
- package/amplify/auth/resource.js +23 -5
- package/amplify/auth/resource.ts +31 -5
- package/amplify/data/resource.js +42 -80
- package/amplify/data/resource.ts +42 -80
- package/amplify/data/schema.js +45 -62
- package/amplify/data/schema.ts +47 -62
- package/amplify/functions/category-action/handler.ts +5 -3
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +143 -1
- package/amplify/functions/content-actions/requirements.txt +2 -1
- package/amplify/functions/content-actions/resource.js +6 -1
- package/amplify/functions/content-actions/resource.ts +9 -2
- package/amplify/functions/email-submission-processor/handler.py +3 -1
- package/amplify/functions/renditions/handler.py +120 -0
- package/amplify/functions/renditions/requirements.txt +4 -0
- package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
- package/amplify/functions/shared/email-submission.ts +109 -1
- package/amplify/functions/shared/python-bundle.js +42 -2
- package/amplify/functions/shared/python-bundle.ts +47 -2
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- package/amplify/inbound-email/ses-intake.js +86 -0
- package/amplify/inbound-email/ses-intake.ts +110 -0
- package/amplify/plugins/registry.js +19 -0
- package/amplify/plugins/registry.ts +23 -0
- package/amplify/plugins/signup/function/handler.py +200 -0
- package/amplify/plugins/signup/function/resource.js +27 -0
- package/amplify/plugins/signup/function/resource.ts +35 -0
- package/amplify/plugins/signup/papyrus.plugin.js +59 -0
- package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
- package/amplify/plugins/signup/schema.js +40 -0
- package/amplify/plugins/signup/schema.ts +41 -0
- package/amplify/plugins/signup/waf.js +48 -0
- package/amplify/plugins/signup/waf.ts +51 -0
- package/amplify/plugins/types.js +1 -0
- package/amplify/plugins/types.ts +39 -0
- package/amplify/plugins/wire.js +24 -0
- package/amplify/plugins/wire.ts +26 -0
- package/amplify/publication/default-publication.js +18 -0
- package/amplify/publication/default-publication.ts +20 -0
- package/amplify/publication/invalidation-alarm.js +26 -0
- package/amplify/publication/invalidation-alarm.ts +30 -0
- package/amplify/publication/redirect-and-index.js +37 -0
- package/amplify/publication/renditions-function.js +74 -0
- package/amplify/publication/renditions-function.ts +112 -0
- package/amplify/publication/renditions-settings.js +51 -0
- package/amplify/publication/renditions-settings.ts +58 -0
- package/amplify/publication/reproject-on-deploy.js +73 -0
- package/amplify/publication/reproject-on-deploy.ts +78 -0
- package/amplify/publication/s3-projection-settings.js +67 -0
- package/amplify/publication/s3-projection-settings.ts +101 -0
- package/amplify/publication/s3-projection.js +151 -0
- package/amplify/publication/s3-projection.ts +201 -0
- package/amplify/resolver-waves.js +26 -0
- package/amplify/resolver-waves.ts +34 -0
- package/amplify/revalidate-invalidation-settings.js +30 -0
- package/amplify/revalidate-invalidation-settings.ts +35 -0
- package/amplify/revalidate-secret-policy.js +13 -0
- package/amplify/revalidate-secret-policy.ts +19 -0
- package/amplify/site-backend-config.js +65 -1
- package/amplify/site-backend-config.ts +153 -2
- package/amplify/site-backend-names.js +103 -0
- package/amplify/site-backend-names.ts +166 -0
- package/amplify/site-backend.js +150 -176
- package/amplify/site-backend.ts +189 -201
- package/amplify/storage-backups/construct.js +31 -0
- package/amplify/storage-backups/construct.ts +47 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
- package/app/[year]/[month]/[day]/page.tsx +1 -1
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
- package/app/api/archive/editions/route.ts +1 -1
- package/app/api/auth/prospectus/callback/route.ts +9 -0
- package/app/api/auth/prospectus/selection/route.ts +9 -0
- package/app/api/auth/prospectus/session/route.ts +9 -0
- package/app/api/auth/prospectus/sign-in/route.ts +9 -0
- package/app/api/auth/prospectus/sign-out/route.ts +9 -0
- package/app/api/cyclotron/priming-proof/route.ts +11 -0
- package/app/api/media/[...path]/route.ts +5 -16
- package/app/api/revalidate/route.ts +15 -7
- package/app/archive/page.tsx +1 -1
- package/app/articles/[slug]/page.tsx +7 -5
- package/app/blog-rhythm.css +278 -0
- package/app/cyclotron/page.tsx +13 -0
- package/app/globals.css +2 -0
- package/app/layout.tsx +16 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
- package/app/newsroom/[section]/page.tsx +6 -3
- package/app/newsroom/layout.tsx +4 -9
- package/app/newsroom/settings/page.tsx +6 -0
- package/app/newsroom/work/page.tsx +10 -0
- package/app/page.tsx +22 -8
- package/app/robots.ts +2 -5
- package/app/tailwind.css +1 -6
- package/components/amplify-client-provider.tsx +5 -22
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +152 -5
- package/components/blog-page-background.tsx +23 -6
- package/components/news-desk-page.tsx +4 -15
- package/components/news-desk-taxonomy-client.ts +90 -3
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +2 -1
- package/components/newsroom-board-view.tsx +99 -0
- package/components/newsroom-client-shell.tsx +12 -0
- package/components/newsroom-curation-provider.tsx +17 -0
- 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 +93 -0
- 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 +93 -0
- package/components/newsroom-newsprint-board.css +48 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +7 -1
- package/components/newsroom-organizer-shell.tsx +130 -0
- package/components/newsroom-organizer.css +63 -0
- package/components/newsroom-references-view.tsx +92 -8
- package/components/papyrus-console-shell.tsx +4 -2
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/priming-proof-page.tsx +94 -0
- package/components/publication-cyclotron-panel.tsx +57 -0
- package/components/reader-auth-control.tsx +8 -5
- package/components/reference-curation-controls.tsx +77 -0
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-priming.tsx +18 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/settings-page.tsx +2 -1
- package/components/signup-entries-panel.tsx +94 -0
- package/components/topic-steering-workspace.tsx +78 -8
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-near-viewport.ts +30 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +166 -27
- package/infra/amplify-app-shell.ts +180 -31
- package/infra/build-specs.js +23 -7
- package/infra/build-specs.ts +27 -10
- package/infra/site-config.js +248 -36
- package/infra/site-config.ts +313 -51
- package/lib/amplify-outputs-path.ts +5 -1
- package/lib/amplify-server-runtime.ts +36 -1
- package/lib/approved-priming-proof-reports.ts +6 -0
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +12 -0
- package/lib/category-repository.ts +43 -93
- package/lib/content-item-lifecycle.ts +55 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-types.ts +4 -0
- package/lib/curation-client.ts +61 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/graphql-content-repository.ts +56 -22
- package/lib/media-route-response.ts +45 -0
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-base-path.ts +12 -3
- package/lib/newsroom-board.ts +99 -0
- package/lib/newsroom-category-drilldown.ts +3 -2
- package/lib/newsroom-forum-routes.ts +6 -4
- package/lib/newsroom-index-filters.ts +14 -10
- package/lib/newsroom-nav.ts +3 -1
- package/lib/newsroom-organizer-nav.ts +37 -0
- package/lib/newsroom-section-routing.ts +20 -0
- package/lib/oauth-redirect-priority.ts +40 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/plugin-settings.ts +11 -0
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/priming-proof-report.ts +90 -0
- package/lib/prospectus-publication-access.ts +53 -0
- package/lib/prospectus-session.ts +196 -0
- package/lib/prospectus-sign-in-routes.ts +284 -0
- package/lib/prospectus-site-identity.ts +303 -0
- package/lib/publication-cyclotron.ts +74 -0
- package/lib/rate-limit-retry.ts +55 -0
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-cache-lifetime.ts +5 -0
- package/lib/reader-static-params.ts +85 -4
- package/lib/reader-storage-url.ts +9 -12
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-prewarm.ts +53 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/robots-policy.ts +14 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/semantic-graph.ts +7 -0
- package/lib/shared-credentials-provider.ts +61 -0
- package/lib/signup-csv.ts +24 -0
- package/lib/site-brand.ts +59 -0
- package/lib/staging-gated-path.ts +23 -0
- package/lib/staging-preview-object.ts +21 -5
- package/lib/test-editor-override.ts +4 -0
- package/lib/video-loading.ts +22 -0
- package/lib/video-script.ts +14 -0
- package/middleware.ts +11 -19
- package/package.json +73 -8
- package/renderers/pretext/article-page.tsx +39 -3
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +15 -102
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +123 -12
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.d.mts +9 -0
- package/src/with-papyrus.mjs +48 -3
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
- package/components/ai-elements/message.tsx +0 -360
|
@@ -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,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata stored with an editor's reference curation Message. When the
|
|
3
|
+
* relevance cyclotron decided the reference, the review records which
|
|
4
|
+
* decision it answered and whether its explanation may be shared (for the
|
|
5
|
+
* Cyclotron marketing recording). Both are optional.
|
|
6
|
+
*/
|
|
7
|
+
export type ReferenceCurationMetadataInput = {
|
|
8
|
+
action: string
|
|
9
|
+
curationStatus: string
|
|
10
|
+
reasonCode: string | null
|
|
11
|
+
referenceId: string
|
|
12
|
+
referenceLineageId: string
|
|
13
|
+
decisionRelationId?: string | null
|
|
14
|
+
shareable?: boolean | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function referenceCurationMetadata(input: ReferenceCurationMetadataInput): Record<string, unknown> {
|
|
18
|
+
const decisionRelationId = input.decisionRelationId?.trim() || null
|
|
19
|
+
return {
|
|
20
|
+
action: input.action,
|
|
21
|
+
curationStatus: input.curationStatus,
|
|
22
|
+
reasonCode: input.reasonCode,
|
|
23
|
+
curationReasonCode: input.reasonCode,
|
|
24
|
+
referenceId: input.referenceId,
|
|
25
|
+
referenceLineageId: input.referenceLineageId,
|
|
26
|
+
...(decisionRelationId ? {decisionRelationId} : {}),
|
|
27
|
+
...(decisionRelationId ? {shareable: input.shareable === true} : {}),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Duration } from "aws-cdk-lib";
|
|
2
|
+
import * as route53 from "aws-cdk-lib/aws-route53";
|
|
3
|
+
import * as ses from "aws-cdk-lib/aws-ses";
|
|
4
|
+
import * as sesActions from "aws-cdk-lib/aws-ses-actions";
|
|
5
|
+
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
|
|
6
|
+
/**
|
|
7
|
+
* Adds the SES receipt resources directly to `scope` (no wrapper construct) so
|
|
8
|
+
* the logical ids of the live p.apyr.us stack stay unchanged.
|
|
9
|
+
*/
|
|
10
|
+
export function addInboundEmailSesIntake(scope, props) {
|
|
11
|
+
if (props.domainIdentity) {
|
|
12
|
+
const { domain, hostedZoneId, hostedZoneName, recordName } = props.domainIdentity;
|
|
13
|
+
const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "PapyrusInboundDnsZone", {
|
|
14
|
+
hostedZoneId,
|
|
15
|
+
zoneName: hostedZoneName,
|
|
16
|
+
});
|
|
17
|
+
const verifyDomain = new AwsCustomResource(scope, "VerifyInboundEmailDomain", {
|
|
18
|
+
onCreate: {
|
|
19
|
+
service: "SES",
|
|
20
|
+
action: "verifyDomainIdentity",
|
|
21
|
+
parameters: { Domain: domain },
|
|
22
|
+
physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
|
|
23
|
+
},
|
|
24
|
+
onUpdate: {
|
|
25
|
+
service: "SES",
|
|
26
|
+
action: "verifyDomainIdentity",
|
|
27
|
+
parameters: { Domain: domain },
|
|
28
|
+
physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
|
|
29
|
+
},
|
|
30
|
+
policy: AwsCustomResourcePolicy.fromSdkCalls({
|
|
31
|
+
resources: AwsCustomResourcePolicy.ANY_RESOURCE,
|
|
32
|
+
}),
|
|
33
|
+
timeout: Duration.minutes(2),
|
|
34
|
+
});
|
|
35
|
+
new route53.TxtRecord(scope, "PapyrusInboundEmailDomainVerification", {
|
|
36
|
+
zone,
|
|
37
|
+
recordName: `_amazonses.${recordName}`,
|
|
38
|
+
ttl: Duration.minutes(5),
|
|
39
|
+
values: [verifyDomain.getResponseField("VerificationToken")],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const ruleSet = props.existingRuleSetName
|
|
43
|
+
? ses.ReceiptRuleSet.fromReceiptRuleSetName(scope, "PapyrusInboundEmailRuleSet", props.existingRuleSetName)
|
|
44
|
+
: new ses.ReceiptRuleSet(scope, "PapyrusInboundEmailRuleSet", {
|
|
45
|
+
receiptRuleSetName: props.ruleSetName,
|
|
46
|
+
});
|
|
47
|
+
ruleSet.addRule("PapyrusInboundSubmissions", {
|
|
48
|
+
receiptRuleName: props.ruleName,
|
|
49
|
+
recipients: props.recipients,
|
|
50
|
+
enabled: true,
|
|
51
|
+
scanEnabled: true,
|
|
52
|
+
tlsPolicy: ses.TlsPolicy.REQUIRE,
|
|
53
|
+
actions: [
|
|
54
|
+
new sesActions.S3({
|
|
55
|
+
bucket: props.storageBucket,
|
|
56
|
+
objectKeyPrefix: "inbound-email/",
|
|
57
|
+
}),
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
if (props.activateRuleSet && !props.existingRuleSetName) {
|
|
61
|
+
new AwsCustomResource(scope, "ActivateInboundEmailRuleSet", {
|
|
62
|
+
onCreate: {
|
|
63
|
+
service: "SES",
|
|
64
|
+
action: "setActiveReceiptRuleSet",
|
|
65
|
+
parameters: { RuleSetName: ruleSet.receiptRuleSetName },
|
|
66
|
+
physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
|
|
67
|
+
},
|
|
68
|
+
onUpdate: {
|
|
69
|
+
service: "SES",
|
|
70
|
+
action: "setActiveReceiptRuleSet",
|
|
71
|
+
parameters: { RuleSetName: ruleSet.receiptRuleSetName },
|
|
72
|
+
physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
|
|
73
|
+
},
|
|
74
|
+
onDelete: {
|
|
75
|
+
service: "SES",
|
|
76
|
+
action: "setActiveReceiptRuleSet",
|
|
77
|
+
parameters: { RuleSetName: null },
|
|
78
|
+
},
|
|
79
|
+
policy: AwsCustomResourcePolicy.fromSdkCalls({
|
|
80
|
+
resources: AwsCustomResourcePolicy.ANY_RESOURCE,
|
|
81
|
+
}),
|
|
82
|
+
timeout: Duration.minutes(2),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return { ruleSet };
|
|
86
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Duration } from "aws-cdk-lib";
|
|
2
|
+
import * as route53 from "aws-cdk-lib/aws-route53";
|
|
3
|
+
import * as ses from "aws-cdk-lib/aws-ses";
|
|
4
|
+
import * as sesActions from "aws-cdk-lib/aws-ses-actions";
|
|
5
|
+
import type * as s3 from "aws-cdk-lib/aws-s3";
|
|
6
|
+
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
|
|
7
|
+
import type { Construct } from "constructs";
|
|
8
|
+
|
|
9
|
+
export type InboundEmailSesIntakeProps = {
|
|
10
|
+
storageBucket: s3.IBucket;
|
|
11
|
+
recipients: string[];
|
|
12
|
+
ruleSetName: string;
|
|
13
|
+
/** Adds the rule to a rule set another stack owns instead of creating one. */
|
|
14
|
+
existingRuleSetName?: string;
|
|
15
|
+
/** `undefined` lets CloudFormation generate the name (the live p.apyr.us rule). */
|
|
16
|
+
ruleName?: string;
|
|
17
|
+
/** Only the legacy p.apyr.us pipeline sets this; a new site never activates a rule set. */
|
|
18
|
+
activateRuleSet: boolean;
|
|
19
|
+
domainIdentity?: {
|
|
20
|
+
domain: string;
|
|
21
|
+
hostedZoneId: string;
|
|
22
|
+
hostedZoneName: string;
|
|
23
|
+
recordName: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Adds the SES receipt resources directly to `scope` (no wrapper construct) so
|
|
29
|
+
* the logical ids of the live p.apyr.us stack stay unchanged.
|
|
30
|
+
*/
|
|
31
|
+
export function addInboundEmailSesIntake(scope: Construct, props: InboundEmailSesIntakeProps) {
|
|
32
|
+
if (props.domainIdentity) {
|
|
33
|
+
const { domain, hostedZoneId, hostedZoneName, recordName } = props.domainIdentity;
|
|
34
|
+
const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "PapyrusInboundDnsZone", {
|
|
35
|
+
hostedZoneId,
|
|
36
|
+
zoneName: hostedZoneName,
|
|
37
|
+
});
|
|
38
|
+
const verifyDomain = new AwsCustomResource(scope, "VerifyInboundEmailDomain", {
|
|
39
|
+
onCreate: {
|
|
40
|
+
service: "SES",
|
|
41
|
+
action: "verifyDomainIdentity",
|
|
42
|
+
parameters: { Domain: domain },
|
|
43
|
+
physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
|
|
44
|
+
},
|
|
45
|
+
onUpdate: {
|
|
46
|
+
service: "SES",
|
|
47
|
+
action: "verifyDomainIdentity",
|
|
48
|
+
parameters: { Domain: domain },
|
|
49
|
+
physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
|
|
50
|
+
},
|
|
51
|
+
policy: AwsCustomResourcePolicy.fromSdkCalls({
|
|
52
|
+
resources: AwsCustomResourcePolicy.ANY_RESOURCE,
|
|
53
|
+
}),
|
|
54
|
+
timeout: Duration.minutes(2),
|
|
55
|
+
});
|
|
56
|
+
new route53.TxtRecord(scope, "PapyrusInboundEmailDomainVerification", {
|
|
57
|
+
zone,
|
|
58
|
+
recordName: `_amazonses.${recordName}`,
|
|
59
|
+
ttl: Duration.minutes(5),
|
|
60
|
+
values: [verifyDomain.getResponseField("VerificationToken")],
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ruleSet = props.existingRuleSetName
|
|
65
|
+
? ses.ReceiptRuleSet.fromReceiptRuleSetName(scope, "PapyrusInboundEmailRuleSet", props.existingRuleSetName)
|
|
66
|
+
: new ses.ReceiptRuleSet(scope, "PapyrusInboundEmailRuleSet", {
|
|
67
|
+
receiptRuleSetName: props.ruleSetName,
|
|
68
|
+
});
|
|
69
|
+
ruleSet.addRule("PapyrusInboundSubmissions", {
|
|
70
|
+
receiptRuleName: props.ruleName,
|
|
71
|
+
recipients: props.recipients,
|
|
72
|
+
enabled: true,
|
|
73
|
+
scanEnabled: true,
|
|
74
|
+
tlsPolicy: ses.TlsPolicy.REQUIRE,
|
|
75
|
+
actions: [
|
|
76
|
+
new sesActions.S3({
|
|
77
|
+
bucket: props.storageBucket,
|
|
78
|
+
objectKeyPrefix: "inbound-email/",
|
|
79
|
+
}),
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (props.activateRuleSet && !props.existingRuleSetName) {
|
|
84
|
+
new AwsCustomResource(scope, "ActivateInboundEmailRuleSet", {
|
|
85
|
+
onCreate: {
|
|
86
|
+
service: "SES",
|
|
87
|
+
action: "setActiveReceiptRuleSet",
|
|
88
|
+
parameters: { RuleSetName: ruleSet.receiptRuleSetName },
|
|
89
|
+
physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
|
|
90
|
+
},
|
|
91
|
+
onUpdate: {
|
|
92
|
+
service: "SES",
|
|
93
|
+
action: "setActiveReceiptRuleSet",
|
|
94
|
+
parameters: { RuleSetName: ruleSet.receiptRuleSetName },
|
|
95
|
+
physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
|
|
96
|
+
},
|
|
97
|
+
onDelete: {
|
|
98
|
+
service: "SES",
|
|
99
|
+
action: "setActiveReceiptRuleSet",
|
|
100
|
+
parameters: { RuleSetName: null },
|
|
101
|
+
},
|
|
102
|
+
policy: AwsCustomResourcePolicy.fromSdkCalls({
|
|
103
|
+
resources: AwsCustomResourcePolicy.ANY_RESOURCE,
|
|
104
|
+
}),
|
|
105
|
+
timeout: Duration.minutes(2),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { ruleSet };
|
|
110
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { signupPlugin } from "./signup/papyrus.plugin.js";
|
|
2
|
+
/** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
|
|
3
|
+
export const KNOWN_PLUGINS = {
|
|
4
|
+
signup: signupPlugin,
|
|
5
|
+
};
|
|
6
|
+
export function resolvePlugins(requests) {
|
|
7
|
+
const resolved = [];
|
|
8
|
+
for (const request of requests ?? []) {
|
|
9
|
+
const name = typeof request === "string" ? request : request.name;
|
|
10
|
+
const plugin = KNOWN_PLUGINS[name];
|
|
11
|
+
if (plugin === undefined) {
|
|
12
|
+
throw new Error(`Unknown plugin "${name}". Known plugins: ${Object.keys(KNOWN_PLUGINS).join(", ")}`);
|
|
13
|
+
}
|
|
14
|
+
if (resolved.some((entry) => entry.plugin.manifest.name === name))
|
|
15
|
+
throw new Error(`Plugin "${name}" is listed twice.`);
|
|
16
|
+
resolved.push({ plugin, config: plugin.resolveConfig(request) });
|
|
17
|
+
}
|
|
18
|
+
return resolved;
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PapyrusPlugin, PluginRequest } from "./types";
|
|
2
|
+
import { signupPlugin } from "./signup/papyrus.plugin";
|
|
3
|
+
|
|
4
|
+
/** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
|
|
5
|
+
export const KNOWN_PLUGINS: Record<string, PapyrusPlugin<any, any>> = {
|
|
6
|
+
signup: signupPlugin,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type ResolvedPlugin = { plugin: PapyrusPlugin<any, any>; config: unknown };
|
|
10
|
+
|
|
11
|
+
export function resolvePlugins(requests: PluginRequest[] | undefined): ResolvedPlugin[] {
|
|
12
|
+
const resolved: ResolvedPlugin[] = [];
|
|
13
|
+
for (const request of requests ?? []) {
|
|
14
|
+
const name = typeof request === "string" ? request : request.name;
|
|
15
|
+
const plugin = KNOWN_PLUGINS[name];
|
|
16
|
+
if (plugin === undefined) {
|
|
17
|
+
throw new Error(`Unknown plugin "${name}". Known plugins: ${Object.keys(KNOWN_PLUGINS).join(", ")}`);
|
|
18
|
+
}
|
|
19
|
+
if (resolved.some((entry) => entry.plugin.manifest.name === name)) throw new Error(`Plugin "${name}" is listed twice.`);
|
|
20
|
+
resolved.push({ plugin, config: plugin.resolveConfig(request) });
|
|
21
|
+
}
|
|
22
|
+
return resolved;
|
|
23
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""submitSignup: the only operation of the signup plugin that a guest can call.
|
|
2
|
+
|
|
3
|
+
A guest sends a form name and an email-like value. This handler throttles the
|
|
4
|
+
caller, validates and length-limits every argument, drops honeypot and too-fast
|
|
5
|
+
submissions without telling the sender, and writes one SignupEntry through the
|
|
6
|
+
GraphQL API under an id derived from the content, so repeating a submission
|
|
7
|
+
changes nothing. Guests never read entries back.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from typing import Any, Callable
|
|
22
|
+
|
|
23
|
+
STATUS_ACCEPTED = "accepted"
|
|
24
|
+
STATUS_INVALID = "invalid"
|
|
25
|
+
STATUS_EXPIRED = "expired"
|
|
26
|
+
STATUS_THROTTLED = "throttled"
|
|
27
|
+
|
|
28
|
+
MAXIMUM_ARGUMENT_CHARACTERS = 1000
|
|
29
|
+
MAXIMUM_VALUE_CHARACTERS = 254
|
|
30
|
+
MINIMUM_FILL_SECONDS = 2
|
|
31
|
+
MAXIMUM_AGE_SECONDS = 24 * 60 * 60
|
|
32
|
+
RATE_WINDOW_SECONDS = 60
|
|
33
|
+
COUNTER_LIFETIME_SECONDS = 5 * 60
|
|
34
|
+
DEFAULT_ADDRESS_LIMIT_PER_WINDOW = 20
|
|
35
|
+
DEFAULT_IDENTITY_LIMIT_PER_WINDOW = 5
|
|
36
|
+
FORBIDDEN_FIRST_CHARACTERS = "=+-@"
|
|
37
|
+
FORM_NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,39}")
|
|
38
|
+
RENDERED_AT = re.compile(r"[0-9]{1,16}")
|
|
39
|
+
LABEL = r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
|
|
40
|
+
EMAIL_LIKE = re.compile(rf"[A-Za-z0-9.!#$%&'*+/=?^_`{{|}}~-]{{1,64}}@{LABEL}(?:\.{LABEL})+")
|
|
41
|
+
CREATE_ENTRY_MUTATION = (
|
|
42
|
+
"mutation CreateSignupEntry($input: CreateSignupEntryInput!) { createSignupEntry(input: $input) { id } }"
|
|
43
|
+
)
|
|
44
|
+
ALREADY_STORED_MARKERS = ("ConditionalCheckFailed", "conditional request failed")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class SignupSettings:
|
|
49
|
+
forms: frozenset[str]
|
|
50
|
+
address_limit: int = DEFAULT_ADDRESS_LIMIT_PER_WINDOW
|
|
51
|
+
identity_limit: int = DEFAULT_IDENTITY_LIMIT_PER_WINDOW
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def settings_from_environment(environment: dict[str, str] | None = None) -> SignupSettings:
|
|
55
|
+
environment = os.environ if environment is None else environment
|
|
56
|
+
forms = frozenset(name.strip() for name in environment.get("PAPYRUS_SIGNUP_FORMS", "").split(",") if name.strip())
|
|
57
|
+
return SignupSettings(
|
|
58
|
+
forms=forms,
|
|
59
|
+
address_limit=int(environment.get("PAPYRUS_SIGNUP_ADDRESS_LIMIT", DEFAULT_ADDRESS_LIMIT_PER_WINDOW)),
|
|
60
|
+
identity_limit=int(environment.get("PAPYRUS_SIGNUP_IDENTITY_LIMIT", DEFAULT_IDENTITY_LIMIT_PER_WINDOW)),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def result(status: str, message: str = "") -> dict[str, str]:
|
|
65
|
+
return {"status": status, "message": message}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalized_value(value: str) -> str:
|
|
69
|
+
local, _, domain = value.partition("@")
|
|
70
|
+
return f"{local}@{domain.lower()}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def entry_identifier(form: str, value: str) -> str:
|
|
74
|
+
return hashlib.sha256(f"{form}\n{normalized_value(value)}".encode("utf-8")).hexdigest()[:32]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def valid_value(value: str) -> bool:
|
|
78
|
+
return (
|
|
79
|
+
0 < len(value) <= MAXIMUM_VALUE_CHARACTERS
|
|
80
|
+
and value[0] not in FORBIDDEN_FIRST_CHARACTERS
|
|
81
|
+
and EMAIL_LIKE.fullmatch(value) is not None
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def caller_address(identity: dict[str, Any] | None) -> str:
|
|
86
|
+
"""The last of AppSync's sourceIp list: the connection's address. A sender-supplied x-forwarded-for only adds earlier entries."""
|
|
87
|
+
addresses = [str(address).strip() for address in (identity or {}).get("sourceIp") or [] if str(address).strip()]
|
|
88
|
+
return addresses[-1] if addresses else "unknown"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def rate_key(kind: str, subject: str, now_seconds: float) -> str:
|
|
92
|
+
window = int(now_seconds // RATE_WINDOW_SECONDS)
|
|
93
|
+
return f"{kind}#{hashlib.sha256(subject.encode('utf-8')).hexdigest()[:24]}#{window}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_rendered_at(text: str) -> float | None:
|
|
97
|
+
return int(text) / 1000.0 if RENDERED_AT.fullmatch(text) else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def submit_signup(
|
|
101
|
+
arguments: dict[str, Any],
|
|
102
|
+
*,
|
|
103
|
+
address: str,
|
|
104
|
+
identity_id: str | None,
|
|
105
|
+
now_seconds: float,
|
|
106
|
+
settings: SignupSettings,
|
|
107
|
+
count_hit: Callable[[str, int], int],
|
|
108
|
+
store_entry: Callable[[dict[str, str]], bool],
|
|
109
|
+
) -> dict[str, str]:
|
|
110
|
+
if count_hit(rate_key("address", address, now_seconds), COUNTER_LIFETIME_SECONDS) > settings.address_limit:
|
|
111
|
+
return result(STATUS_THROTTLED, "Too many requests. Try again in a minute.")
|
|
112
|
+
if identity_id and count_hit(rate_key("identity", identity_id, now_seconds), COUNTER_LIFETIME_SECONDS) > settings.identity_limit:
|
|
113
|
+
return result(STATUS_THROTTLED, "Too many requests. Try again in a minute.")
|
|
114
|
+
|
|
115
|
+
form, value = arguments.get("form"), arguments.get("value")
|
|
116
|
+
honeypot, rendered_at = arguments.get("website"), arguments.get("renderedAt")
|
|
117
|
+
texts = [form, value, honeypot, rendered_at]
|
|
118
|
+
if any(text is not None and not isinstance(text, str) for text in texts):
|
|
119
|
+
return result(STATUS_INVALID, "Malformed request.")
|
|
120
|
+
if any(text is not None and len(text) > MAXIMUM_ARGUMENT_CHARACTERS for text in texts):
|
|
121
|
+
return result(STATUS_INVALID, "Request too large.")
|
|
122
|
+
if not isinstance(form, str) or not FORM_NAME.fullmatch(form) or form not in settings.forms:
|
|
123
|
+
return result(STATUS_INVALID, "Unknown form.")
|
|
124
|
+
if not isinstance(value, str) or not valid_value(value):
|
|
125
|
+
return result(STATUS_INVALID, "Enter a valid email address.")
|
|
126
|
+
rendered_seconds = parse_rendered_at(rendered_at) if isinstance(rendered_at, str) else None
|
|
127
|
+
if rendered_seconds is None:
|
|
128
|
+
return result(STATUS_INVALID, "Malformed request.")
|
|
129
|
+
age_seconds = now_seconds - rendered_seconds
|
|
130
|
+
if age_seconds > MAXIMUM_AGE_SECONDS:
|
|
131
|
+
return result(STATUS_EXPIRED, "This form has expired. Reload the page and try again.")
|
|
132
|
+
if honeypot or age_seconds < MINIMUM_FILL_SECONDS:
|
|
133
|
+
return result(STATUS_ACCEPTED)
|
|
134
|
+
|
|
135
|
+
submitted_at = datetime.fromtimestamp(now_seconds, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
136
|
+
store_entry(
|
|
137
|
+
{
|
|
138
|
+
"id": entry_identifier(form, value),
|
|
139
|
+
"form": form,
|
|
140
|
+
"value": normalized_value(value),
|
|
141
|
+
"submittedAt": submitted_at,
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
return result(STATUS_ACCEPTED)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def counting_hits(table_name: str, client: Any = None) -> Callable[[str, int], int]:
|
|
148
|
+
import boto3
|
|
149
|
+
|
|
150
|
+
table_client = client or boto3.client("dynamodb")
|
|
151
|
+
|
|
152
|
+
def count_hit(key: str, lifetime_seconds: int) -> int:
|
|
153
|
+
response = table_client.update_item(
|
|
154
|
+
TableName=table_name,
|
|
155
|
+
Key={"pk": {"S": key}},
|
|
156
|
+
UpdateExpression="ADD hits :one SET expiresAt = :expires",
|
|
157
|
+
ExpressionAttributeValues={
|
|
158
|
+
":one": {"N": "1"},
|
|
159
|
+
":expires": {"N": str(int(time.time()) + lifetime_seconds)},
|
|
160
|
+
},
|
|
161
|
+
ReturnValues="UPDATED_NEW",
|
|
162
|
+
)
|
|
163
|
+
return int(response["Attributes"]["hits"]["N"])
|
|
164
|
+
|
|
165
|
+
return count_hit
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def storing_entries(endpoint: str, region: str) -> Callable[[dict[str, str]], bool]:
|
|
169
|
+
import boto3
|
|
170
|
+
from botocore.auth import SigV4Auth
|
|
171
|
+
from botocore.awsrequest import AWSRequest
|
|
172
|
+
|
|
173
|
+
def store_entry(entry: dict[str, str]) -> bool:
|
|
174
|
+
body = json.dumps({"query": CREATE_ENTRY_MUTATION, "variables": {"input": entry}}).encode("utf-8")
|
|
175
|
+
request = AWSRequest(method="POST", url=endpoint, data=body, headers={"content-type": "application/json"})
|
|
176
|
+
SigV4Auth(boto3.Session().get_credentials().get_frozen_credentials(), "appsync", region).add_auth(request)
|
|
177
|
+
http_request = urllib.request.Request(endpoint, data=body, headers=dict(request.headers.items()), method="POST")
|
|
178
|
+
with urllib.request.urlopen(http_request, timeout=10) as response:
|
|
179
|
+
payload = json.loads(response.read())
|
|
180
|
+
errors = payload.get("errors") or []
|
|
181
|
+
if not errors:
|
|
182
|
+
return True
|
|
183
|
+
if all(any(marker in str(error) for marker in ALREADY_STORED_MARKERS) for error in errors):
|
|
184
|
+
return False
|
|
185
|
+
raise RuntimeError(f"createSignupEntry failed: {errors}")
|
|
186
|
+
|
|
187
|
+
return store_entry
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def handler(event: dict[str, Any], _context: Any = None) -> dict[str, str]:
|
|
191
|
+
identity = event.get("identity") or {}
|
|
192
|
+
return submit_signup(
|
|
193
|
+
event.get("arguments") or {},
|
|
194
|
+
address=caller_address(identity),
|
|
195
|
+
identity_id=identity.get("cognitoIdentityId"),
|
|
196
|
+
now_seconds=time.time(),
|
|
197
|
+
settings=settings_from_environment(),
|
|
198
|
+
count_hit=counting_hits(os.environ["PAPYRUS_SIGNUP_RATE_TABLE"]),
|
|
199
|
+
store_entry=storing_entries(os.environ["PAPYRUS_GRAPHQL_ENDPOINT"], os.environ["AWS_REGION"]),
|
|
200
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineFunction } from "@aws-amplify/backend";
|
|
2
|
+
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
3
|
+
import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
|
|
4
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import { Architecture, Code, Function, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
export const signupSubmit = defineFunction((scope) => {
|
|
10
|
+
const rateTable = new Table(scope, "PapyrusSignupRateCounters", {
|
|
11
|
+
partitionKey: { name: "pk", type: AttributeType.STRING },
|
|
12
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
13
|
+
timeToLiveAttribute: "expiresAt",
|
|
14
|
+
removalPolicy: RemovalPolicy.DESTROY,
|
|
15
|
+
});
|
|
16
|
+
const lambda = new Function(scope, "papyrus-signup-submit", {
|
|
17
|
+
runtime: Runtime.PYTHON_3_12,
|
|
18
|
+
architecture: Architecture.ARM_64,
|
|
19
|
+
handler: "handler.handler",
|
|
20
|
+
timeout: Duration.seconds(10),
|
|
21
|
+
memorySize: 256,
|
|
22
|
+
code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
|
|
23
|
+
environment: { PAPYRUS_SIGNUP_RATE_TABLE: rateTable.tableName },
|
|
24
|
+
});
|
|
25
|
+
lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem"], resources: [rateTable.tableArn] }));
|
|
26
|
+
return lambda;
|
|
27
|
+
}, { resourceGroupName: "data" });
|