@anthusai/papyrus 5.5.0 → 5.6.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/data/resource.js +2 -0
- package/amplify/data/resource.ts +2 -0
- package/amplify/data/schema.js +2 -0
- package/amplify/data/schema.ts +2 -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/site-backend-config.ts +10 -0
- package/amplify/site-backend.js +13 -0
- package/amplify/site-backend.ts +14 -0
- package/package.json +2 -1
- package/routes.manifest.json +1 -1
package/amplify/data/resource.js
CHANGED
|
@@ -6,6 +6,7 @@ import { knowledgeQuery } from "../functions/knowledge-query/resource.js";
|
|
|
6
6
|
import { manageUserRole } from "../functions/manage-user-role/resource.js";
|
|
7
7
|
import { modelAttachmentUpload } from "../functions/model-attachment-upload/resource.js";
|
|
8
8
|
import { newsroomSummary } from "../functions/newsroom-summary/resource.js";
|
|
9
|
+
import { publicForms } from "../functions/public-forms/resource.js";
|
|
9
10
|
import { procedureAction } from "../functions/procedure-action/resource.js";
|
|
10
11
|
import { readerSettings } from "../functions/reader-settings/resource.js";
|
|
11
12
|
import { emailSubmissionProcessor } from "../functions/email-submission-processor/resource.js";
|
|
@@ -2165,6 +2166,7 @@ const schema = a.schema({
|
|
|
2165
2166
|
allow.resource(modelAttachmentUpload),
|
|
2166
2167
|
allow.resource(newsroomSummary),
|
|
2167
2168
|
allow.resource(procedureAction),
|
|
2169
|
+
allow.resource(publicForms).to(["mutate"]),
|
|
2168
2170
|
allow.resource(sesInboundReceive),
|
|
2169
2171
|
allow.resource(emailSubmissionProcessor),
|
|
2170
2172
|
allow.resource(slackEvents),
|
package/amplify/data/resource.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { knowledgeQuery } from "../functions/knowledge-query/resource";
|
|
|
6
6
|
import { manageUserRole } from "../functions/manage-user-role/resource";
|
|
7
7
|
import { modelAttachmentUpload } from "../functions/model-attachment-upload/resource";
|
|
8
8
|
import { newsroomSummary } from "../functions/newsroom-summary/resource";
|
|
9
|
+
import { publicForms } from "../functions/public-forms/resource";
|
|
9
10
|
import { procedureAction } from "../functions/procedure-action/resource";
|
|
10
11
|
import { readerSettings } from "../functions/reader-settings/resource";
|
|
11
12
|
import { emailSubmissionProcessor } from "../functions/email-submission-processor/resource";
|
|
@@ -2292,6 +2293,7 @@ const schema = a.schema({
|
|
|
2292
2293
|
allow.resource(modelAttachmentUpload),
|
|
2293
2294
|
allow.resource(newsroomSummary),
|
|
2294
2295
|
allow.resource(procedureAction),
|
|
2296
|
+
allow.resource(publicForms).to(["mutate"]),
|
|
2295
2297
|
allow.resource(sesInboundReceive),
|
|
2296
2298
|
allow.resource(emailSubmissionProcessor),
|
|
2297
2299
|
allow.resource(slackEvents),
|
package/amplify/data/schema.js
CHANGED
|
@@ -8,6 +8,7 @@ import { knowledgeQuery } from "../functions/knowledge-query/resource.js";
|
|
|
8
8
|
import { manageUserRole } from "../functions/manage-user-role/resource.js";
|
|
9
9
|
import { modelAttachmentUpload } from "../functions/model-attachment-upload/resource.js";
|
|
10
10
|
import { newsroomSummary } from "../functions/newsroom-summary/resource.js";
|
|
11
|
+
import { publicForms } from "../functions/public-forms/resource.js";
|
|
11
12
|
import { procedureAction } from "../functions/procedure-action/resource.js";
|
|
12
13
|
import { readerSettings } from "../functions/reader-settings/resource.js";
|
|
13
14
|
const authoringOperations = [
|
|
@@ -2012,6 +2013,7 @@ export const schema = a.schema(baseDefinitions).authorization((allow) => [
|
|
|
2012
2013
|
allow.resource(modelAttachmentUpload),
|
|
2013
2014
|
allow.resource(newsroomSummary),
|
|
2014
2015
|
allow.resource(procedureAction),
|
|
2016
|
+
allow.resource(publicForms).to(["mutate"]),
|
|
2015
2017
|
]);
|
|
2016
2018
|
/** The deployed schema of a site that enables plugins: the base definitions plus what each plugin contributes (docs/plugins.md). */
|
|
2017
2019
|
export function schemaWithPlugins(pluginDefinitions) {
|
package/amplify/data/schema.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { knowledgeQuery } from "../functions/knowledge-query/resource";
|
|
|
8
8
|
import { manageUserRole } from "../functions/manage-user-role/resource";
|
|
9
9
|
import { modelAttachmentUpload } from "../functions/model-attachment-upload/resource";
|
|
10
10
|
import { newsroomSummary } from "../functions/newsroom-summary/resource";
|
|
11
|
+
import { publicForms } from "../functions/public-forms/resource";
|
|
11
12
|
import { procedureAction } from "../functions/procedure-action/resource";
|
|
12
13
|
import { readerSettings } from "../functions/reader-settings/resource";
|
|
13
14
|
|
|
@@ -2133,6 +2134,7 @@ export const schema = a.schema(baseDefinitions).authorization((allow) => [
|
|
|
2133
2134
|
allow.resource(modelAttachmentUpload),
|
|
2134
2135
|
allow.resource(newsroomSummary),
|
|
2135
2136
|
allow.resource(procedureAction),
|
|
2137
|
+
allow.resource(publicForms).to(["mutate"]),
|
|
2136
2138
|
]);
|
|
2137
2139
|
|
|
2138
2140
|
/** The deployed schema of a site that enables plugins: the base definitions plus what each plugin contributes (docs/plugins.md). */
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* The public form receiving endpoint, without any AWS in it: `handlePublicFormRequest` takes the Function URL
|
|
4
|
+
* event plus a store and a rate limiter, so every rule here is exercised by `scripts/test-public-forms.ts`.
|
|
5
|
+
*
|
|
6
|
+
* Contract (docs/public-forms.md): POST JSON from one of the site's own origins; the answer never reflects input.
|
|
7
|
+
*/
|
|
8
|
+
export const FORM_SUBMISSION_MESSAGE_KIND = "form_submission";
|
|
9
|
+
export const FORM_SUBMISSION_MESSAGE_DOMAIN = "inbox";
|
|
10
|
+
export const FORM_SUBMISSION_FEED_KEY = "messages";
|
|
11
|
+
export const NEWSLETTER_FORM_ID = "newsletter";
|
|
12
|
+
export const NEWSLETTER_MESSAGE_TYPE = "newsletter_signup";
|
|
13
|
+
export const DEFAULT_FORM_ID = "contact";
|
|
14
|
+
export const DEFAULT_HONEYPOT_FIELDS = ["website"];
|
|
15
|
+
export const DEFAULT_PUBLIC_FORMS_CONFIG = {
|
|
16
|
+
allowedOrigins: [],
|
|
17
|
+
honeypotFields: DEFAULT_HONEYPOT_FIELDS,
|
|
18
|
+
maxBodyBytes: 16 * 1024,
|
|
19
|
+
maxFields: 20,
|
|
20
|
+
maxValueLength: 5000,
|
|
21
|
+
ipLimit: 10,
|
|
22
|
+
ipWindowSeconds: 600,
|
|
23
|
+
formLimitPerHour: 120,
|
|
24
|
+
siteLimitPerHour: 400,
|
|
25
|
+
};
|
|
26
|
+
const FORM_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
27
|
+
const FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]{0,39}$/;
|
|
28
|
+
const EMAIL = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
|
|
29
|
+
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
|
|
30
|
+
export function parseOrigins(value) {
|
|
31
|
+
const origins = [];
|
|
32
|
+
for (const entry of (value ?? "").split(",")) {
|
|
33
|
+
const trimmed = entry.trim();
|
|
34
|
+
if (trimmed === "")
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(trimmed);
|
|
38
|
+
if (url.protocol === "https:" || url.hostname === "localhost")
|
|
39
|
+
origins.push(url.origin);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// An unparsable origin is dropped: the allow-list can only get stricter.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...new Set(origins)];
|
|
46
|
+
}
|
|
47
|
+
function positiveInteger(value, fallback) {
|
|
48
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
49
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
50
|
+
}
|
|
51
|
+
export function configFromEnvironment(env) {
|
|
52
|
+
const honeypots = (env.PAPYRUS_PUBLIC_FORMS_HONEYPOTS ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
53
|
+
return {
|
|
54
|
+
...DEFAULT_PUBLIC_FORMS_CONFIG,
|
|
55
|
+
allowedOrigins: parseOrigins(env.PAPYRUS_PUBLIC_FORMS_ORIGINS),
|
|
56
|
+
honeypotFields: [...new Set([...DEFAULT_HONEYPOT_FIELDS, ...honeypots])],
|
|
57
|
+
ipLimit: positiveInteger(env.PAPYRUS_PUBLIC_FORMS_IP_LIMIT, DEFAULT_PUBLIC_FORMS_CONFIG.ipLimit),
|
|
58
|
+
formLimitPerHour: positiveInteger(env.PAPYRUS_PUBLIC_FORMS_FORM_LIMIT, DEFAULT_PUBLIC_FORMS_CONFIG.formLimitPerHour),
|
|
59
|
+
siteLimitPerHour: positiveInteger(env.PAPYRUS_PUBLIC_FORMS_SITE_LIMIT, DEFAULT_PUBLIC_FORMS_CONFIG.siteLimitPerHour),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function reply(statusCode, body, headers = {}) {
|
|
63
|
+
return {
|
|
64
|
+
statusCode,
|
|
65
|
+
headers: { "Cache-Control": "no-store", "Content-Type": "application/json", "X-Content-Type-Options": "nosniff", ...headers },
|
|
66
|
+
body: body === null ? "" : JSON.stringify(body),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function header(event, name) {
|
|
70
|
+
const headers = event.headers ?? {};
|
|
71
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
72
|
+
if (key.toLowerCase() === name && typeof value === "string")
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
function clean(value, multiline) {
|
|
78
|
+
const stripped = value.replace(CONTROL, "").replace(/\r\n?/g, "\n");
|
|
79
|
+
return (multiline ? stripped : stripped.replace(/\n/g, " ")).trim();
|
|
80
|
+
}
|
|
81
|
+
function parseSubmission(event, config) {
|
|
82
|
+
const raw = event.isBase64Encoded === true ? Buffer.from(event.body ?? "", "base64").toString("utf8") : (event.body ?? "");
|
|
83
|
+
let payload;
|
|
84
|
+
try {
|
|
85
|
+
payload = JSON.parse(raw);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return { ok: false, error: "invalid-json" };
|
|
89
|
+
}
|
|
90
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload))
|
|
91
|
+
return { ok: false, error: "invalid-body" };
|
|
92
|
+
const entries = Object.entries(payload);
|
|
93
|
+
if (entries.length > config.maxFields)
|
|
94
|
+
return { ok: false, error: "too-many-fields" };
|
|
95
|
+
const fields = {};
|
|
96
|
+
let formId = "";
|
|
97
|
+
let page = "";
|
|
98
|
+
let honeypotFilled = false;
|
|
99
|
+
for (const [name, value] of entries) {
|
|
100
|
+
if (!FIELD_NAME.test(name))
|
|
101
|
+
return { ok: false, error: "invalid-field" };
|
|
102
|
+
if (value === null || value === undefined)
|
|
103
|
+
continue;
|
|
104
|
+
if (typeof value !== "string")
|
|
105
|
+
return { ok: false, error: "invalid-field" };
|
|
106
|
+
if (value.length > config.maxValueLength)
|
|
107
|
+
return { ok: false, error: "field-too-long" };
|
|
108
|
+
if (config.honeypotFields.includes(name)) {
|
|
109
|
+
if (value.trim() !== "")
|
|
110
|
+
honeypotFilled = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (name === "form")
|
|
114
|
+
formId = value.trim().toLowerCase();
|
|
115
|
+
else if (name === "page")
|
|
116
|
+
page = clean(value, false).slice(0, 500);
|
|
117
|
+
else
|
|
118
|
+
fields[name] = clean(value, name === "message");
|
|
119
|
+
}
|
|
120
|
+
return { ok: true, formId, fields, page, honeypotFilled };
|
|
121
|
+
}
|
|
122
|
+
function queryFormId(event) {
|
|
123
|
+
const match = /(?:^|&)form=([^&]*)/.exec(event.rawQueryString ?? "");
|
|
124
|
+
if (match === null)
|
|
125
|
+
return "";
|
|
126
|
+
try {
|
|
127
|
+
return decodeURIComponent(match[1]).trim().toLowerCase();
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function ipSubject(ip) {
|
|
134
|
+
return createHash("sha256").update(`papyrus-public-forms:${ip}`).digest("hex").slice(0, 32);
|
|
135
|
+
}
|
|
136
|
+
export function buildSubmissionRecord(input) {
|
|
137
|
+
const { formId, fields } = input;
|
|
138
|
+
const newsletter = formId === NEWSLETTER_FORM_ID;
|
|
139
|
+
const name = fields.name ?? "";
|
|
140
|
+
const email = fields.email ?? "";
|
|
141
|
+
const topic = fields.topic ?? "";
|
|
142
|
+
const message = fields.message ?? "";
|
|
143
|
+
const who = name !== "" ? (email !== "" ? `${name} <${email}>` : name) : email;
|
|
144
|
+
const headline = newsletter ? "Newsletter signup" : topic !== "" ? `${formId}: ${topic}` : formId;
|
|
145
|
+
const lines = [`Form: ${formId}`];
|
|
146
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
147
|
+
if (key === "message" || value === "")
|
|
148
|
+
continue;
|
|
149
|
+
lines.push(`${key}: ${value}`);
|
|
150
|
+
}
|
|
151
|
+
if (input.page !== "")
|
|
152
|
+
lines.push(`page: ${input.page}`);
|
|
153
|
+
const content = message !== "" ? `${lines.join("\n")}\n\n${message}` : lines.join("\n");
|
|
154
|
+
const summarySource = message !== "" ? message.replace(/\s+/g, " ") : "";
|
|
155
|
+
const summary = `${headline}${who !== "" ? ` from ${who}` : ""}${summarySource !== "" ? `: ${summarySource}` : ""}`.slice(0, 200);
|
|
156
|
+
return {
|
|
157
|
+
id: input.id,
|
|
158
|
+
messageKind: FORM_SUBMISSION_MESSAGE_KIND,
|
|
159
|
+
messageDomain: FORM_SUBMISSION_MESSAGE_DOMAIN,
|
|
160
|
+
messageType: newsletter ? NEWSLETTER_MESSAGE_TYPE : FORM_SUBMISSION_MESSAGE_KIND,
|
|
161
|
+
status: "active",
|
|
162
|
+
summary,
|
|
163
|
+
content,
|
|
164
|
+
source: "public-form",
|
|
165
|
+
authorLabel: who !== "" ? who.slice(0, 200) : "Anonymous visitor",
|
|
166
|
+
searchVisibility: "private",
|
|
167
|
+
responseTarget: "none",
|
|
168
|
+
responseStatus: "COMPLETED",
|
|
169
|
+
metadata: JSON.stringify({
|
|
170
|
+
kind: newsletter ? NEWSLETTER_MESSAGE_TYPE : FORM_SUBMISSION_MESSAGE_KIND,
|
|
171
|
+
formId,
|
|
172
|
+
topic,
|
|
173
|
+
fields,
|
|
174
|
+
page: input.page,
|
|
175
|
+
origin: input.origin,
|
|
176
|
+
receivedAt: input.receivedAt,
|
|
177
|
+
}),
|
|
178
|
+
createdAt: input.receivedAt,
|
|
179
|
+
updatedAt: input.receivedAt,
|
|
180
|
+
newsroomFeedKey: FORM_SUBMISSION_FEED_KEY,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export async function handlePublicFormRequest(event, config, deps) {
|
|
184
|
+
const method = (event.requestContext?.http?.method ?? "").toUpperCase();
|
|
185
|
+
const origin = header(event, "origin");
|
|
186
|
+
const allowed = origin !== "" && config.allowedOrigins.includes(origin);
|
|
187
|
+
const cors = allowed
|
|
188
|
+
? {
|
|
189
|
+
"Access-Control-Allow-Origin": origin,
|
|
190
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
191
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
192
|
+
"Access-Control-Max-Age": "600",
|
|
193
|
+
Vary: "Origin",
|
|
194
|
+
}
|
|
195
|
+
: { Vary: "Origin" };
|
|
196
|
+
if (method === "OPTIONS")
|
|
197
|
+
return allowed ? reply(204, null, cors) : reply(403, { ok: false, error: "origin-not-allowed" }, cors);
|
|
198
|
+
if (method !== "POST")
|
|
199
|
+
return reply(405, { ok: false, error: "method-not-allowed" }, { ...cors, Allow: "POST, OPTIONS" });
|
|
200
|
+
if (!allowed)
|
|
201
|
+
return reply(403, { ok: false, error: "origin-not-allowed" }, cors);
|
|
202
|
+
if (!/^application\/json\s*(;|$)/i.test(header(event, "content-type")))
|
|
203
|
+
return reply(415, { ok: false, error: "unsupported-media-type" }, cors);
|
|
204
|
+
const bodyBytes = Buffer.byteLength(event.body ?? "", event.isBase64Encoded === true ? "base64" : "utf8");
|
|
205
|
+
if (bodyBytes > config.maxBodyBytes)
|
|
206
|
+
return reply(413, { ok: false, error: "payload-too-large" }, cors);
|
|
207
|
+
const parsed = parseSubmission(event, config);
|
|
208
|
+
if (!parsed.ok)
|
|
209
|
+
return reply(400, { ok: false, error: parsed.error }, cors);
|
|
210
|
+
// A filled honeypot looks like success to the bot and stores nothing.
|
|
211
|
+
if (parsed.honeypotFilled)
|
|
212
|
+
return reply(200, { ok: true }, cors);
|
|
213
|
+
const formId = parsed.formId || queryFormId(event) || DEFAULT_FORM_ID;
|
|
214
|
+
if (!FORM_ID.test(formId))
|
|
215
|
+
return reply(400, { ok: false, error: "invalid-form" }, cors);
|
|
216
|
+
const { fields } = parsed;
|
|
217
|
+
const email = fields.email ?? "";
|
|
218
|
+
if (email !== "" && (email.length > 254 || !EMAIL.test(email)))
|
|
219
|
+
return reply(400, { ok: false, error: "invalid-email" }, cors);
|
|
220
|
+
if (formId === NEWSLETTER_FORM_ID && email === "")
|
|
221
|
+
return reply(400, { ok: false, error: "email-required" }, cors);
|
|
222
|
+
if (email === "" && (fields.message ?? "") === "")
|
|
223
|
+
return reply(400, { ok: false, error: "empty-submission" }, cors);
|
|
224
|
+
const subject = ipSubject(event.requestContext?.http?.sourceIp ?? "unknown");
|
|
225
|
+
let within;
|
|
226
|
+
try {
|
|
227
|
+
within =
|
|
228
|
+
(await deps.limiter.hit("ip", subject, config.ipWindowSeconds, config.ipLimit))
|
|
229
|
+
&& (await deps.limiter.hit("form", formId, 3600, config.formLimitPerHour))
|
|
230
|
+
&& (await deps.limiter.hit("site", "all", 3600, config.siteLimitPerHour));
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return reply(503, { ok: false, error: "unavailable" }, cors);
|
|
234
|
+
}
|
|
235
|
+
if (!within)
|
|
236
|
+
return reply(429, { ok: false, error: "rate-limited" }, { ...cors, "Retry-After": "600" });
|
|
237
|
+
const receivedAt = (deps.now ?? (() => new Date()))().toISOString();
|
|
238
|
+
const id = (deps.newId ?? (() => `form-${randomUUID().replace(/-/g, "")}`))();
|
|
239
|
+
try {
|
|
240
|
+
await deps.store.save(buildSubmissionRecord({ id, formId, fields, page: parsed.page, origin, receivedAt }));
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Never pretend a submission was received when it was not.
|
|
244
|
+
return reply(500, { ok: false, error: "store-failed" }, cors);
|
|
245
|
+
}
|
|
246
|
+
return reply(200, { ok: true }, cors);
|
|
247
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The public form receiving endpoint, without any AWS in it: `handlePublicFormRequest` takes the Function URL
|
|
5
|
+
* event plus a store and a rate limiter, so every rule here is exercised by `scripts/test-public-forms.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Contract (docs/public-forms.md): POST JSON from one of the site's own origins; the answer never reflects input.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const FORM_SUBMISSION_MESSAGE_KIND = "form_submission";
|
|
11
|
+
export const FORM_SUBMISSION_MESSAGE_DOMAIN = "inbox";
|
|
12
|
+
export const FORM_SUBMISSION_FEED_KEY = "messages";
|
|
13
|
+
export const NEWSLETTER_FORM_ID = "newsletter";
|
|
14
|
+
export const NEWSLETTER_MESSAGE_TYPE = "newsletter_signup";
|
|
15
|
+
export const DEFAULT_FORM_ID = "contact";
|
|
16
|
+
export const DEFAULT_HONEYPOT_FIELDS = ["website"];
|
|
17
|
+
|
|
18
|
+
export type PublicFormsConfig = {
|
|
19
|
+
/** Exact origins (scheme + host [+ port]) the browser may post from. Empty: every request is refused. */
|
|
20
|
+
allowedOrigins: string[];
|
|
21
|
+
/** Field names that, when filled, mark a bot. */
|
|
22
|
+
honeypotFields: string[];
|
|
23
|
+
maxBodyBytes: number;
|
|
24
|
+
maxFields: number;
|
|
25
|
+
maxValueLength: number;
|
|
26
|
+
/** Submissions one address may make per `ipWindowSeconds`, across all forms. */
|
|
27
|
+
ipLimit: number;
|
|
28
|
+
ipWindowSeconds: number;
|
|
29
|
+
/** Submissions one form accepts per hour from everyone; and the whole site across all form ids. */
|
|
30
|
+
formLimitPerHour: number;
|
|
31
|
+
siteLimitPerHour: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_PUBLIC_FORMS_CONFIG: PublicFormsConfig = {
|
|
35
|
+
allowedOrigins: [],
|
|
36
|
+
honeypotFields: DEFAULT_HONEYPOT_FIELDS,
|
|
37
|
+
maxBodyBytes: 16 * 1024,
|
|
38
|
+
maxFields: 20,
|
|
39
|
+
maxValueLength: 5000,
|
|
40
|
+
ipLimit: 10,
|
|
41
|
+
ipWindowSeconds: 600,
|
|
42
|
+
formLimitPerHour: 120,
|
|
43
|
+
siteLimitPerHour: 400,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type FormSubmissionRecord = {
|
|
47
|
+
id: string;
|
|
48
|
+
messageKind: typeof FORM_SUBMISSION_MESSAGE_KIND;
|
|
49
|
+
messageDomain: string;
|
|
50
|
+
messageType: string;
|
|
51
|
+
status: "active";
|
|
52
|
+
summary: string;
|
|
53
|
+
content: string;
|
|
54
|
+
source: string;
|
|
55
|
+
authorLabel: string;
|
|
56
|
+
searchVisibility: "private";
|
|
57
|
+
responseTarget: "none";
|
|
58
|
+
responseStatus: "COMPLETED";
|
|
59
|
+
metadata: string;
|
|
60
|
+
createdAt: string;
|
|
61
|
+
updatedAt: string;
|
|
62
|
+
newsroomFeedKey: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type SubmissionStore = { save(record: FormSubmissionRecord): Promise<void> };
|
|
66
|
+
|
|
67
|
+
/** `hit` counts one event in the window and answers whether it is still within `limit`. */
|
|
68
|
+
export type RateLimiter = {
|
|
69
|
+
hit(scope: string, subject: string, windowSeconds: number, limit: number): Promise<boolean>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type PublicFormsDeps = {
|
|
73
|
+
store: SubmissionStore;
|
|
74
|
+
limiter: RateLimiter;
|
|
75
|
+
now?: () => Date;
|
|
76
|
+
newId?: () => string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type FunctionUrlEvent = {
|
|
80
|
+
rawQueryString?: string;
|
|
81
|
+
headers?: Record<string, string | undefined>;
|
|
82
|
+
body?: string | null;
|
|
83
|
+
isBase64Encoded?: boolean;
|
|
84
|
+
requestContext?: { http?: { method?: string; sourceIp?: string } };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type FunctionUrlResult = { statusCode: number; headers: Record<string, string>; body: string };
|
|
88
|
+
|
|
89
|
+
const FORM_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
90
|
+
const FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]{0,39}$/;
|
|
91
|
+
const EMAIL = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
|
|
92
|
+
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
|
|
93
|
+
|
|
94
|
+
export function parseOrigins(value: string | undefined): string[] {
|
|
95
|
+
const origins: string[] = [];
|
|
96
|
+
for (const entry of (value ?? "").split(",")) {
|
|
97
|
+
const trimmed = entry.trim();
|
|
98
|
+
if (trimmed === "") continue;
|
|
99
|
+
try {
|
|
100
|
+
const url = new URL(trimmed);
|
|
101
|
+
if (url.protocol === "https:" || url.hostname === "localhost") origins.push(url.origin);
|
|
102
|
+
} catch {
|
|
103
|
+
// An unparsable origin is dropped: the allow-list can only get stricter.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return [...new Set(origins)];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function positiveInteger(value: string | undefined, fallback: number): number {
|
|
110
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
111
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function configFromEnvironment(env: Record<string, string | undefined>): PublicFormsConfig {
|
|
115
|
+
const honeypots = (env.PAPYRUS_PUBLIC_FORMS_HONEYPOTS ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
116
|
+
return {
|
|
117
|
+
...DEFAULT_PUBLIC_FORMS_CONFIG,
|
|
118
|
+
allowedOrigins: parseOrigins(env.PAPYRUS_PUBLIC_FORMS_ORIGINS),
|
|
119
|
+
honeypotFields: [...new Set([...DEFAULT_HONEYPOT_FIELDS, ...honeypots])],
|
|
120
|
+
ipLimit: positiveInteger(env.PAPYRUS_PUBLIC_FORMS_IP_LIMIT, DEFAULT_PUBLIC_FORMS_CONFIG.ipLimit),
|
|
121
|
+
formLimitPerHour: positiveInteger(env.PAPYRUS_PUBLIC_FORMS_FORM_LIMIT, DEFAULT_PUBLIC_FORMS_CONFIG.formLimitPerHour),
|
|
122
|
+
siteLimitPerHour: positiveInteger(env.PAPYRUS_PUBLIC_FORMS_SITE_LIMIT, DEFAULT_PUBLIC_FORMS_CONFIG.siteLimitPerHour),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function reply(statusCode: number, body: Record<string, unknown> | null, headers: Record<string, string> = {}): FunctionUrlResult {
|
|
127
|
+
return {
|
|
128
|
+
statusCode,
|
|
129
|
+
headers: { "Cache-Control": "no-store", "Content-Type": "application/json", "X-Content-Type-Options": "nosniff", ...headers },
|
|
130
|
+
body: body === null ? "" : JSON.stringify(body),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function header(event: FunctionUrlEvent, name: string): string {
|
|
135
|
+
const headers = event.headers ?? {};
|
|
136
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
137
|
+
if (key.toLowerCase() === name && typeof value === "string") return value;
|
|
138
|
+
}
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function clean(value: string, multiline: boolean): string {
|
|
143
|
+
const stripped = value.replace(CONTROL, "").replace(/\r\n?/g, "\n");
|
|
144
|
+
return (multiline ? stripped : stripped.replace(/\n/g, " ")).trim();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type Parsed =
|
|
148
|
+
| { ok: true; formId: string; fields: Record<string, string>; page: string; honeypotFilled: boolean }
|
|
149
|
+
| { ok: false; error: string };
|
|
150
|
+
|
|
151
|
+
function parseSubmission(event: FunctionUrlEvent, config: PublicFormsConfig): Parsed {
|
|
152
|
+
const raw = event.isBase64Encoded === true ? Buffer.from(event.body ?? "", "base64").toString("utf8") : (event.body ?? "");
|
|
153
|
+
let payload: unknown;
|
|
154
|
+
try {
|
|
155
|
+
payload = JSON.parse(raw);
|
|
156
|
+
} catch {
|
|
157
|
+
return { ok: false, error: "invalid-json" };
|
|
158
|
+
}
|
|
159
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return { ok: false, error: "invalid-body" };
|
|
160
|
+
const entries = Object.entries(payload as Record<string, unknown>);
|
|
161
|
+
if (entries.length > config.maxFields) return { ok: false, error: "too-many-fields" };
|
|
162
|
+
|
|
163
|
+
const fields: Record<string, string> = {};
|
|
164
|
+
let formId = "";
|
|
165
|
+
let page = "";
|
|
166
|
+
let honeypotFilled = false;
|
|
167
|
+
for (const [name, value] of entries) {
|
|
168
|
+
if (!FIELD_NAME.test(name)) return { ok: false, error: "invalid-field" };
|
|
169
|
+
if (value === null || value === undefined) continue;
|
|
170
|
+
if (typeof value !== "string") return { ok: false, error: "invalid-field" };
|
|
171
|
+
if (value.length > config.maxValueLength) return { ok: false, error: "field-too-long" };
|
|
172
|
+
if (config.honeypotFields.includes(name)) {
|
|
173
|
+
if (value.trim() !== "") honeypotFilled = true;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (name === "form") formId = value.trim().toLowerCase();
|
|
177
|
+
else if (name === "page") page = clean(value, false).slice(0, 500);
|
|
178
|
+
else fields[name] = clean(value, name === "message");
|
|
179
|
+
}
|
|
180
|
+
return { ok: true, formId, fields, page, honeypotFilled };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function queryFormId(event: FunctionUrlEvent): string {
|
|
184
|
+
const match = /(?:^|&)form=([^&]*)/.exec(event.rawQueryString ?? "");
|
|
185
|
+
if (match === null) return "";
|
|
186
|
+
try {
|
|
187
|
+
return decodeURIComponent(match[1]).trim().toLowerCase();
|
|
188
|
+
} catch {
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function ipSubject(ip: string): string {
|
|
194
|
+
return createHash("sha256").update(`papyrus-public-forms:${ip}`).digest("hex").slice(0, 32);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function buildSubmissionRecord(input: {
|
|
198
|
+
id: string;
|
|
199
|
+
formId: string;
|
|
200
|
+
fields: Record<string, string>;
|
|
201
|
+
page: string;
|
|
202
|
+
origin: string;
|
|
203
|
+
receivedAt: string;
|
|
204
|
+
}): FormSubmissionRecord {
|
|
205
|
+
const { formId, fields } = input;
|
|
206
|
+
const newsletter = formId === NEWSLETTER_FORM_ID;
|
|
207
|
+
const name = fields.name ?? "";
|
|
208
|
+
const email = fields.email ?? "";
|
|
209
|
+
const topic = fields.topic ?? "";
|
|
210
|
+
const message = fields.message ?? "";
|
|
211
|
+
const who = name !== "" ? (email !== "" ? `${name} <${email}>` : name) : email;
|
|
212
|
+
const headline = newsletter ? "Newsletter signup" : topic !== "" ? `${formId}: ${topic}` : formId;
|
|
213
|
+
const lines = [`Form: ${formId}`];
|
|
214
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
215
|
+
if (key === "message" || value === "") continue;
|
|
216
|
+
lines.push(`${key}: ${value}`);
|
|
217
|
+
}
|
|
218
|
+
if (input.page !== "") lines.push(`page: ${input.page}`);
|
|
219
|
+
const content = message !== "" ? `${lines.join("\n")}\n\n${message}` : lines.join("\n");
|
|
220
|
+
const summarySource = message !== "" ? message.replace(/\s+/g, " ") : "";
|
|
221
|
+
const summary = `${headline}${who !== "" ? ` from ${who}` : ""}${summarySource !== "" ? `: ${summarySource}` : ""}`.slice(0, 200);
|
|
222
|
+
return {
|
|
223
|
+
id: input.id,
|
|
224
|
+
messageKind: FORM_SUBMISSION_MESSAGE_KIND,
|
|
225
|
+
messageDomain: FORM_SUBMISSION_MESSAGE_DOMAIN,
|
|
226
|
+
messageType: newsletter ? NEWSLETTER_MESSAGE_TYPE : FORM_SUBMISSION_MESSAGE_KIND,
|
|
227
|
+
status: "active",
|
|
228
|
+
summary,
|
|
229
|
+
content,
|
|
230
|
+
source: "public-form",
|
|
231
|
+
authorLabel: who !== "" ? who.slice(0, 200) : "Anonymous visitor",
|
|
232
|
+
searchVisibility: "private",
|
|
233
|
+
responseTarget: "none",
|
|
234
|
+
responseStatus: "COMPLETED",
|
|
235
|
+
metadata: JSON.stringify({
|
|
236
|
+
kind: newsletter ? NEWSLETTER_MESSAGE_TYPE : FORM_SUBMISSION_MESSAGE_KIND,
|
|
237
|
+
formId,
|
|
238
|
+
topic,
|
|
239
|
+
fields,
|
|
240
|
+
page: input.page,
|
|
241
|
+
origin: input.origin,
|
|
242
|
+
receivedAt: input.receivedAt,
|
|
243
|
+
}),
|
|
244
|
+
createdAt: input.receivedAt,
|
|
245
|
+
updatedAt: input.receivedAt,
|
|
246
|
+
newsroomFeedKey: FORM_SUBMISSION_FEED_KEY,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function handlePublicFormRequest(
|
|
251
|
+
event: FunctionUrlEvent,
|
|
252
|
+
config: PublicFormsConfig,
|
|
253
|
+
deps: PublicFormsDeps,
|
|
254
|
+
): Promise<FunctionUrlResult> {
|
|
255
|
+
const method = (event.requestContext?.http?.method ?? "").toUpperCase();
|
|
256
|
+
const origin = header(event, "origin");
|
|
257
|
+
const allowed = origin !== "" && config.allowedOrigins.includes(origin);
|
|
258
|
+
const cors: Record<string, string> = allowed
|
|
259
|
+
? {
|
|
260
|
+
"Access-Control-Allow-Origin": origin,
|
|
261
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
262
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
263
|
+
"Access-Control-Max-Age": "600",
|
|
264
|
+
Vary: "Origin",
|
|
265
|
+
}
|
|
266
|
+
: { Vary: "Origin" };
|
|
267
|
+
|
|
268
|
+
if (method === "OPTIONS") return allowed ? reply(204, null, cors) : reply(403, { ok: false, error: "origin-not-allowed" }, cors);
|
|
269
|
+
if (method !== "POST") return reply(405, { ok: false, error: "method-not-allowed" }, { ...cors, Allow: "POST, OPTIONS" });
|
|
270
|
+
if (!allowed) return reply(403, { ok: false, error: "origin-not-allowed" }, cors);
|
|
271
|
+
if (!/^application\/json\s*(;|$)/i.test(header(event, "content-type"))) return reply(415, { ok: false, error: "unsupported-media-type" }, cors);
|
|
272
|
+
|
|
273
|
+
const bodyBytes = Buffer.byteLength(event.body ?? "", event.isBase64Encoded === true ? "base64" : "utf8");
|
|
274
|
+
if (bodyBytes > config.maxBodyBytes) return reply(413, { ok: false, error: "payload-too-large" }, cors);
|
|
275
|
+
|
|
276
|
+
const parsed = parseSubmission(event, config);
|
|
277
|
+
if (!parsed.ok) return reply(400, { ok: false, error: parsed.error }, cors);
|
|
278
|
+
// A filled honeypot looks like success to the bot and stores nothing.
|
|
279
|
+
if (parsed.honeypotFilled) return reply(200, { ok: true }, cors);
|
|
280
|
+
|
|
281
|
+
const formId = parsed.formId || queryFormId(event) || DEFAULT_FORM_ID;
|
|
282
|
+
if (!FORM_ID.test(formId)) return reply(400, { ok: false, error: "invalid-form" }, cors);
|
|
283
|
+
const { fields } = parsed;
|
|
284
|
+
const email = fields.email ?? "";
|
|
285
|
+
if (email !== "" && (email.length > 254 || !EMAIL.test(email))) return reply(400, { ok: false, error: "invalid-email" }, cors);
|
|
286
|
+
if (formId === NEWSLETTER_FORM_ID && email === "") return reply(400, { ok: false, error: "email-required" }, cors);
|
|
287
|
+
if (email === "" && (fields.message ?? "") === "") return reply(400, { ok: false, error: "empty-submission" }, cors);
|
|
288
|
+
|
|
289
|
+
const subject = ipSubject(event.requestContext?.http?.sourceIp ?? "unknown");
|
|
290
|
+
let within: boolean;
|
|
291
|
+
try {
|
|
292
|
+
within =
|
|
293
|
+
(await deps.limiter.hit("ip", subject, config.ipWindowSeconds, config.ipLimit))
|
|
294
|
+
&& (await deps.limiter.hit("form", formId, 3600, config.formLimitPerHour))
|
|
295
|
+
&& (await deps.limiter.hit("site", "all", 3600, config.siteLimitPerHour));
|
|
296
|
+
} catch {
|
|
297
|
+
return reply(503, { ok: false, error: "unavailable" }, cors);
|
|
298
|
+
}
|
|
299
|
+
if (!within) return reply(429, { ok: false, error: "rate-limited" }, { ...cors, "Retry-After": "600" });
|
|
300
|
+
|
|
301
|
+
const receivedAt = (deps.now ?? (() => new Date()))().toISOString();
|
|
302
|
+
const id = (deps.newId ?? (() => `form-${randomUUID().replace(/-/g, "")}`))();
|
|
303
|
+
try {
|
|
304
|
+
await deps.store.save(buildSubmissionRecord({ id, formId, fields, page: parsed.page, origin, receivedAt }));
|
|
305
|
+
} catch {
|
|
306
|
+
// Never pretend a submission was received when it was not.
|
|
307
|
+
return reply(500, { ok: false, error: "store-failed" }, cors);
|
|
308
|
+
}
|
|
309
|
+
return reply(200, { ok: true }, cors);
|
|
310
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { DynamoDBClient, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
|
|
2
|
+
import { getLambdaDataClient, LAMBDA_DATA_AUTH_MODE } from "../shared/lambda-data-client";
|
|
3
|
+
import {
|
|
4
|
+
configFromEnvironment,
|
|
5
|
+
handlePublicFormRequest,
|
|
6
|
+
type FormSubmissionRecord,
|
|
7
|
+
type FunctionUrlEvent,
|
|
8
|
+
type RateLimiter,
|
|
9
|
+
type SubmissionStore,
|
|
10
|
+
} from "./core";
|
|
11
|
+
|
|
12
|
+
const dynamo = new DynamoDBClient({});
|
|
13
|
+
|
|
14
|
+
/** Counters live in a TTL table; the window number is part of the key, so a window simply stops being read. */
|
|
15
|
+
const limiter: RateLimiter = {
|
|
16
|
+
async hit(scope, subject, windowSeconds, limit) {
|
|
17
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
18
|
+
const window = Math.floor(nowSeconds / windowSeconds);
|
|
19
|
+
const result = await dynamo.send(
|
|
20
|
+
new UpdateItemCommand({
|
|
21
|
+
TableName: process.env.PAPYRUS_PUBLIC_FORMS_TABLE,
|
|
22
|
+
Key: { pk: { S: `${scope}#${subject}#${windowSeconds}#${window}` } },
|
|
23
|
+
UpdateExpression: "ADD hits :one SET expiresAt = :expires",
|
|
24
|
+
ExpressionAttributeValues: { ":one": { N: "1" }, ":expires": { N: String((window + 2) * windowSeconds) } },
|
|
25
|
+
ReturnValues: "UPDATED_NEW",
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
return Number(result.Attributes?.hits?.N ?? "0") <= limit;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const store: SubmissionStore = {
|
|
33
|
+
async save(record: FormSubmissionRecord) {
|
|
34
|
+
const client = await getLambdaDataClient();
|
|
35
|
+
const created = await client.models.Message.create(record as never, { authMode: LAMBDA_DATA_AUTH_MODE });
|
|
36
|
+
if (created.errors?.length) throw new Error("Message create failed");
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const handler = (event: FunctionUrlEvent) =>
|
|
41
|
+
handlePublicFormRequest(event, configFromEnvironment(process.env), { store, limiter });
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
function httpsOrigin(value) {
|
|
2
|
+
try {
|
|
3
|
+
const url = new URL(value.includes("://") ? value : `https://${value}`);
|
|
4
|
+
return url.protocol === "https:" && url.hostname !== "localhost" ? url.origin : undefined;
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function derivePublicFormOrigins(sources) {
|
|
11
|
+
const origins = new Set();
|
|
12
|
+
for (const value of sources.explicit ?? []) {
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(value);
|
|
15
|
+
if (url.protocol === "https:" || (url.protocol === "http:" && url.hostname === "localhost"))
|
|
16
|
+
origins.add(url.origin);
|
|
17
|
+
else
|
|
18
|
+
throw new Error("not allowed");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error(`publicForms.allowedOrigins entry ${JSON.stringify(value)} must be an https origin (or http://localhost).`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const host of sources.hosts ?? []) {
|
|
25
|
+
const origin = host === undefined || host.trim() === "" ? undefined : httpsOrigin(host.trim());
|
|
26
|
+
if (origin !== undefined)
|
|
27
|
+
origins.add(origin);
|
|
28
|
+
}
|
|
29
|
+
for (const value of sources.urls ?? []) {
|
|
30
|
+
const origin = value === undefined || value.trim() === "" ? undefined : httpsOrigin(value.trim());
|
|
31
|
+
if (origin !== undefined)
|
|
32
|
+
origins.add(origin);
|
|
33
|
+
}
|
|
34
|
+
return [...origins].sort();
|
|
35
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser origins a site's public forms accept: the site's own public hosts, never a wildcard.
|
|
3
|
+
* Pure so that `scripts/test-public-forms.ts` can pin it.
|
|
4
|
+
*/
|
|
5
|
+
export type PublicFormsOriginSources = {
|
|
6
|
+
/** `publicForms.allowedOrigins` in the site's backend config: always honoured (https, or localhost for development). */
|
|
7
|
+
explicit?: readonly string[];
|
|
8
|
+
/** Public hosts the site is served from: distribution aliases, the reader domain. Bare hosts become https origins. */
|
|
9
|
+
hosts?: readonly (string | undefined)[];
|
|
10
|
+
/** URLs whose origin is the site's own: the revalidate base URL, the sign-in redirect URLs. https only, never localhost. */
|
|
11
|
+
urls?: readonly (string | undefined)[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function httpsOrigin(value: string): string | undefined {
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(value.includes("://") ? value : `https://${value}`);
|
|
17
|
+
return url.protocol === "https:" && url.hostname !== "localhost" ? url.origin : undefined;
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function derivePublicFormOrigins(sources: PublicFormsOriginSources): string[] {
|
|
24
|
+
const origins = new Set<string>();
|
|
25
|
+
for (const value of sources.explicit ?? []) {
|
|
26
|
+
try {
|
|
27
|
+
const url = new URL(value);
|
|
28
|
+
if (url.protocol === "https:" || (url.protocol === "http:" && url.hostname === "localhost")) origins.add(url.origin);
|
|
29
|
+
else throw new Error("not allowed");
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error(`publicForms.allowedOrigins entry ${JSON.stringify(value)} must be an https origin (or http://localhost).`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const host of sources.hosts ?? []) {
|
|
35
|
+
const origin = host === undefined || host.trim() === "" ? undefined : httpsOrigin(host.trim());
|
|
36
|
+
if (origin !== undefined) origins.add(origin);
|
|
37
|
+
}
|
|
38
|
+
for (const value of sources.urls ?? []) {
|
|
39
|
+
const origin = value === undefined || value.trim() === "" ? undefined : httpsOrigin(value.trim());
|
|
40
|
+
if (origin !== undefined) origins.add(origin);
|
|
41
|
+
}
|
|
42
|
+
return [...origins].sort();
|
|
43
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
@@ -35,6 +35,9 @@ import { knowledgeQuery } from "./functions/knowledge-query/resource.js";
|
|
|
35
35
|
import { manageUserRole } from "./functions/manage-user-role/resource.js";
|
|
36
36
|
import { modelAttachmentUpload } from "./functions/model-attachment-upload/resource.js";
|
|
37
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";
|
|
38
41
|
import { procedureAction } from "./functions/procedure-action/resource.js";
|
|
39
42
|
import { readerSettings } from "./functions/reader-settings/resource.js";
|
|
40
43
|
import { emailSubmissionProcessor } from "./functions/email-submission-processor/resource.js";
|
|
@@ -101,6 +104,7 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
101
104
|
modelAttachmentUpload,
|
|
102
105
|
newsroomSummary,
|
|
103
106
|
procedureAction,
|
|
107
|
+
publicForms,
|
|
104
108
|
readerSettings,
|
|
105
109
|
emailSubmissionProcessor,
|
|
106
110
|
sesInboundReceive,
|
|
@@ -564,8 +568,17 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
564
568
|
: []),
|
|
565
569
|
],
|
|
566
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
|
+
});
|
|
567
579
|
backend.addOutput({
|
|
568
580
|
custom: {
|
|
581
|
+
publicForms: { endpoint: publicFormsEndpoint },
|
|
569
582
|
prospectusSignIn: prospectusGoogleOnlyClient
|
|
570
583
|
? { clientId: prospectusGoogleOnlyClient.ref, callbackPath: PROSPECTUS_SIGN_IN_CALLBACK_PATH }
|
|
571
584
|
: null,
|
package/amplify/site-backend.ts
CHANGED
|
@@ -42,6 +42,9 @@ import { knowledgeQuery } from "./functions/knowledge-query/resource";
|
|
|
42
42
|
import { manageUserRole } from "./functions/manage-user-role/resource";
|
|
43
43
|
import { modelAttachmentUpload } from "./functions/model-attachment-upload/resource";
|
|
44
44
|
import { newsroomSummary } from "./functions/newsroom-summary/resource";
|
|
45
|
+
import { derivePublicFormOrigins } from "./functions/public-forms/origins";
|
|
46
|
+
import { publicForms } from "./functions/public-forms/resource";
|
|
47
|
+
import { wirePublicForms } from "./functions/public-forms/wire";
|
|
45
48
|
import { procedureAction } from "./functions/procedure-action/resource";
|
|
46
49
|
import { readerSettings } from "./functions/reader-settings/resource";
|
|
47
50
|
import { emailSubmissionProcessor } from "./functions/email-submission-processor/resource";
|
|
@@ -122,6 +125,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
122
125
|
modelAttachmentUpload,
|
|
123
126
|
newsroomSummary,
|
|
124
127
|
procedureAction,
|
|
128
|
+
publicForms,
|
|
125
129
|
readerSettings,
|
|
126
130
|
emailSubmissionProcessor,
|
|
127
131
|
sesInboundReceive,
|
|
@@ -707,8 +711,18 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
707
711
|
}),
|
|
708
712
|
);
|
|
709
713
|
|
|
714
|
+
const publicFormsEndpoint = wirePublicForms(backend, {
|
|
715
|
+
allowedOrigins: derivePublicFormOrigins({
|
|
716
|
+
explicit: site.publicForms?.allowedOrigins,
|
|
717
|
+
hosts: [activeBrand?.readerDeployment?.domain, ...(publication?.distribution?.aliases ?? [])],
|
|
718
|
+
urls: [site.revalidateBaseUrl, ...siteAuthRedirectUrls(authConfig)],
|
|
719
|
+
}),
|
|
720
|
+
honeypotFields: site.publicForms?.honeypotFields,
|
|
721
|
+
});
|
|
722
|
+
|
|
710
723
|
backend.addOutput({
|
|
711
724
|
custom: {
|
|
725
|
+
publicForms: { endpoint: publicFormsEndpoint },
|
|
712
726
|
prospectusSignIn: prospectusGoogleOnlyClient
|
|
713
727
|
? { clientId: prospectusGoogleOnlyClient.ref, callbackPath: PROSPECTUS_SIGN_IN_CALLBACK_PATH }
|
|
714
728
|
: null,
|
package/package.json
CHANGED
|
@@ -406,11 +406,12 @@
|
|
|
406
406
|
"optional": true
|
|
407
407
|
}
|
|
408
408
|
},
|
|
409
|
-
"version": "5.
|
|
409
|
+
"version": "5.6.0",
|
|
410
410
|
"dependencies": {
|
|
411
411
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
412
412
|
"@aws-crypto/sha256-js": "5.2.0",
|
|
413
413
|
"@aws-sdk/client-cognito-identity-provider": "3.1045.0",
|
|
414
|
+
"@aws-sdk/client-dynamodb": "^3.1045.0",
|
|
414
415
|
"@aws-sdk/client-lambda": "^3.1045.0",
|
|
415
416
|
"@aws-sdk/client-s3": "^3.1045.0",
|
|
416
417
|
"@aws-sdk/client-secrets-manager": "3.1045.0",
|
package/routes.manifest.json
CHANGED