@anthusai/papyrus 1.0.0-next.50 → 1.0.0-next.51
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 +16 -10
- package/amplify/data/resource.ts +16 -10
- package/amplify/data/schema.js +14 -2
- package/amplify/data/schema.ts +16 -2
- 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/site-backend-config.ts +7 -0
- package/amplify/site-backend.js +9 -2
- package/amplify/site-backend.ts +10 -2
- package/components/signup-entries-panel.tsx +94 -0
- package/components/topic-steering-workspace.tsx +21 -1
- package/lib/plugin-settings.ts +11 -0
- package/lib/signup-csv.ts +24 -0
- package/package.json +4 -1
- package/routes.manifest.json +1 -1
package/amplify/data/resource.js
CHANGED
|
@@ -12,7 +12,7 @@ import { emailSubmissionProcessor } from "../functions/email-submission-processo
|
|
|
12
12
|
import { sesInboundReceive } from "../functions/ses-inbound-receive/resource.js";
|
|
13
13
|
import { slackDelivery } from "../functions/slack-delivery/resource.js";
|
|
14
14
|
import { slackEvents } from "../functions/slack-events/resource.js";
|
|
15
|
-
import { schema as leanSchema } from "./schema.js";
|
|
15
|
+
import { schema as leanSchema, schemaWithPlugins } from "./schema.js";
|
|
16
16
|
const authoringOperations = [
|
|
17
17
|
"read",
|
|
18
18
|
"create",
|
|
@@ -2009,13 +2009,19 @@ const schema = a.schema({
|
|
|
2009
2009
|
allow.resource(slackEvents),
|
|
2010
2010
|
allow.resource(slackDelivery),
|
|
2011
2011
|
]);
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2012
|
+
/**
|
|
2013
|
+
* The site's data API. Amplify allows one `defineData` per backend and registers it when called, so this is
|
|
2014
|
+
* a function: a site without plugins passes none and gets exactly the base schema.
|
|
2015
|
+
*/
|
|
2016
|
+
export function defineSiteData(pluginDefinitions = {}) {
|
|
2017
|
+
return defineData({
|
|
2018
|
+
name: "PapyrusCmsApi",
|
|
2019
|
+
schema: Object.keys(pluginDefinitions).length === 0 ? leanSchema : schemaWithPlugins(pluginDefinitions),
|
|
2020
|
+
authorizationModes: {
|
|
2021
|
+
defaultAuthorizationMode: "userPool",
|
|
2022
|
+
apiKeyAuthorizationMode: {
|
|
2023
|
+
expiresInDays: 365,
|
|
2024
|
+
},
|
|
2019
2025
|
},
|
|
2020
|
-
}
|
|
2021
|
-
}
|
|
2026
|
+
});
|
|
2027
|
+
}
|
package/amplify/data/resource.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { emailSubmissionProcessor } from "../functions/email-submission-processo
|
|
|
12
12
|
import { sesInboundReceive } from "../functions/ses-inbound-receive/resource";
|
|
13
13
|
import { slackDelivery } from "../functions/slack-delivery/resource";
|
|
14
14
|
import { slackEvents } from "../functions/slack-events/resource";
|
|
15
|
-
import { schema as leanSchema } from "./schema";
|
|
15
|
+
import { schema as leanSchema, schemaWithPlugins } from "./schema";
|
|
16
16
|
const authoringOperations: ("read" | "create" | "update" | "delete")[] = [
|
|
17
17
|
"read",
|
|
18
18
|
"create",
|
|
@@ -2125,13 +2125,19 @@ const schema = a.schema({
|
|
|
2125
2125
|
|
|
2126
2126
|
export type Schema = ClientSchema<typeof schema>;
|
|
2127
2127
|
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2128
|
+
/**
|
|
2129
|
+
* The site's data API. Amplify allows one `defineData` per backend and registers it when called, so this is
|
|
2130
|
+
* a function: a site without plugins passes none and gets exactly the base schema.
|
|
2131
|
+
*/
|
|
2132
|
+
export function defineSiteData(pluginDefinitions: Record<string, unknown> = {}) {
|
|
2133
|
+
return defineData({
|
|
2134
|
+
name: "PapyrusCmsApi",
|
|
2135
|
+
schema: Object.keys(pluginDefinitions).length === 0 ? leanSchema : schemaWithPlugins(pluginDefinitions),
|
|
2136
|
+
authorizationModes: {
|
|
2137
|
+
defaultAuthorizationMode: "userPool",
|
|
2138
|
+
apiKeyAuthorizationMode: {
|
|
2139
|
+
expiresInDays: 365,
|
|
2140
|
+
},
|
|
2135
2141
|
},
|
|
2136
|
-
}
|
|
2137
|
-
}
|
|
2142
|
+
});
|
|
2143
|
+
}
|
package/amplify/data/schema.js
CHANGED
|
@@ -20,7 +20,7 @@ const categoryWriteGroups = ["editor", "admin"];
|
|
|
20
20
|
const categoryAppendOnlyOperations = ["read", "create"];
|
|
21
21
|
const modelWithoutSubscriptions = (fields) => a.model(fields).disableOperations(["subscriptions"]);
|
|
22
22
|
const realtimeModel = (fields) => a.model(fields);
|
|
23
|
-
|
|
23
|
+
const baseDefinitions = {
|
|
24
24
|
UserProfile: modelWithoutSubscriptions({
|
|
25
25
|
id: a.id().required(),
|
|
26
26
|
email: a.email(),
|
|
@@ -1841,7 +1841,8 @@ export const schema = a.schema({
|
|
|
1841
1841
|
allow.guest().to(["read"]),
|
|
1842
1842
|
allow.groups(contentWriteGroups),
|
|
1843
1843
|
]),
|
|
1844
|
-
}
|
|
1844
|
+
};
|
|
1845
|
+
export const schema = a.schema(baseDefinitions).authorization((allow) => [
|
|
1845
1846
|
allow.resource(categoryAction),
|
|
1846
1847
|
allow.resource(contentActions),
|
|
1847
1848
|
allow.resource(manageUserRole),
|
|
@@ -1849,3 +1850,14 @@ export const schema = a.schema({
|
|
|
1849
1850
|
allow.resource(newsroomSummary),
|
|
1850
1851
|
allow.resource(procedureAction),
|
|
1851
1852
|
]);
|
|
1853
|
+
/** The deployed schema of a site that enables plugins: the base definitions plus what each plugin contributes (docs/plugins.md). */
|
|
1854
|
+
export function schemaWithPlugins(pluginDefinitions) {
|
|
1855
|
+
return a.schema({ ...baseDefinitions, ...pluginDefinitions }).authorization((allow) => [
|
|
1856
|
+
allow.resource(categoryAction),
|
|
1857
|
+
allow.resource(contentActions),
|
|
1858
|
+
allow.resource(manageUserRole),
|
|
1859
|
+
allow.resource(modelAttachmentUpload),
|
|
1860
|
+
allow.resource(newsroomSummary),
|
|
1861
|
+
allow.resource(procedureAction),
|
|
1862
|
+
]);
|
|
1863
|
+
}
|
package/amplify/data/schema.ts
CHANGED
|
@@ -23,7 +23,7 @@ const modelWithoutSubscriptions = <Fields extends Parameters<typeof a.model>[0]>
|
|
|
23
23
|
a.model(fields).disableOperations(["subscriptions"]);
|
|
24
24
|
const realtimeModel = <Fields extends Parameters<typeof a.model>[0]>(fields: Fields) => a.model(fields);
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
const baseDefinitions = {
|
|
27
27
|
UserProfile: modelWithoutSubscriptions({
|
|
28
28
|
id: a.id().required(),
|
|
29
29
|
email: a.email(),
|
|
@@ -1947,7 +1947,9 @@ export const schema = a.schema({
|
|
|
1947
1947
|
allow.guest().to(["read"]),
|
|
1948
1948
|
allow.groups(contentWriteGroups),
|
|
1949
1949
|
]),
|
|
1950
|
-
}
|
|
1950
|
+
};
|
|
1951
|
+
|
|
1952
|
+
export const schema = a.schema(baseDefinitions).authorization((allow) => [
|
|
1951
1953
|
allow.resource(categoryAction),
|
|
1952
1954
|
allow.resource(contentActions),
|
|
1953
1955
|
allow.resource(manageUserRole),
|
|
@@ -1956,4 +1958,16 @@ export const schema = a.schema({
|
|
|
1956
1958
|
allow.resource(procedureAction),
|
|
1957
1959
|
]);
|
|
1958
1960
|
|
|
1961
|
+
/** The deployed schema of a site that enables plugins: the base definitions plus what each plugin contributes (docs/plugins.md). */
|
|
1962
|
+
export function schemaWithPlugins(pluginDefinitions: Record<string, any>) {
|
|
1963
|
+
return a.schema({ ...baseDefinitions, ...pluginDefinitions } as typeof baseDefinitions).authorization((allow) => [
|
|
1964
|
+
allow.resource(categoryAction),
|
|
1965
|
+
allow.resource(contentActions),
|
|
1966
|
+
allow.resource(manageUserRole),
|
|
1967
|
+
allow.resource(modelAttachmentUpload),
|
|
1968
|
+
allow.resource(newsroomSummary),
|
|
1969
|
+
allow.resource(procedureAction),
|
|
1970
|
+
]);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1959
1973
|
export type Schema = ClientSchema<typeof schema>;
|
|
@@ -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" });
|
|
@@ -0,0 +1,35 @@
|
|
|
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 type { Construct } from "constructs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
export const signupSubmit = defineFunction(
|
|
13
|
+
(scope: Construct) => {
|
|
14
|
+
const rateTable = new Table(scope, "PapyrusSignupRateCounters", {
|
|
15
|
+
partitionKey: { name: "pk", type: AttributeType.STRING },
|
|
16
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
17
|
+
timeToLiveAttribute: "expiresAt",
|
|
18
|
+
removalPolicy: RemovalPolicy.DESTROY,
|
|
19
|
+
});
|
|
20
|
+
const lambda = new Function(scope, "papyrus-signup-submit", {
|
|
21
|
+
runtime: Runtime.PYTHON_3_12,
|
|
22
|
+
architecture: Architecture.ARM_64,
|
|
23
|
+
handler: "handler.handler",
|
|
24
|
+
timeout: Duration.seconds(10),
|
|
25
|
+
memorySize: 256,
|
|
26
|
+
code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
|
|
27
|
+
environment: { PAPYRUS_SIGNUP_RATE_TABLE: rateTable.tableName },
|
|
28
|
+
});
|
|
29
|
+
lambda.addToRolePolicy(
|
|
30
|
+
new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem"], resources: [rateTable.tableArn] }),
|
|
31
|
+
);
|
|
32
|
+
return lambda;
|
|
33
|
+
},
|
|
34
|
+
{ resourceGroupName: "data" },
|
|
35
|
+
);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Stack } from "aws-cdk-lib";
|
|
2
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import { signupSubmit } from "./function/resource.js";
|
|
4
|
+
import { signupSchemaDefinitions } from "./schema.js";
|
|
5
|
+
import { SIGNUP_MUTATION_FIELD, WAF_MINIMUM_RATE_LIMIT, addSignupRateLimit } from "./waf.js";
|
|
6
|
+
const FORM_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
7
|
+
const KNOWN_KEYS = new Set(["name", "forms", "addressLimitPerMinute", "waf"]);
|
|
8
|
+
export const signupManifest = {
|
|
9
|
+
name: "signup",
|
|
10
|
+
contract: 1,
|
|
11
|
+
models: ["SignupEntry"],
|
|
12
|
+
mutations: [SIGNUP_MUTATION_FIELD],
|
|
13
|
+
guest: { mutations: [SIGNUP_MUTATION_FIELD] },
|
|
14
|
+
newsroomPanels: [{ id: "signups", label: "Signups" }],
|
|
15
|
+
staticAssets: ["_plugins/papyrus-signup@1.js"],
|
|
16
|
+
};
|
|
17
|
+
export function resolveSignupConfig(request) {
|
|
18
|
+
const raw = typeof request === "string" ? { name: request } : request;
|
|
19
|
+
const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.has(key));
|
|
20
|
+
if (unknown.length > 0)
|
|
21
|
+
throw new Error(`Unknown signup plugin settings: ${unknown.join(", ")}`);
|
|
22
|
+
const forms = raw.forms ?? ["newsletter"];
|
|
23
|
+
if (!Array.isArray(forms) || forms.length === 0 || !forms.every((form) => typeof form === "string" && FORM_NAME.test(form))) {
|
|
24
|
+
throw new Error("signup plugin forms must be a non-empty list of lowercase names (letters, digits, hyphens; at most 40 characters)");
|
|
25
|
+
}
|
|
26
|
+
const addressLimitPerMinute = raw.addressLimitPerMinute ?? 20;
|
|
27
|
+
if (!Number.isInteger(addressLimitPerMinute) || addressLimitPerMinute < WAF_MINIMUM_RATE_LIMIT || addressLimitPerMinute > 1000) {
|
|
28
|
+
throw new Error(`signup plugin addressLimitPerMinute must be an integer from ${WAF_MINIMUM_RATE_LIMIT} to 1000`);
|
|
29
|
+
}
|
|
30
|
+
const waf = raw.waf ?? true;
|
|
31
|
+
if (typeof waf !== "boolean")
|
|
32
|
+
throw new Error("signup plugin waf must be a boolean");
|
|
33
|
+
return { forms: [...new Set(forms)], addressLimitPerMinute, waf };
|
|
34
|
+
}
|
|
35
|
+
export const signupPlugin = {
|
|
36
|
+
manifest: signupManifest,
|
|
37
|
+
resolveConfig: resolveSignupConfig,
|
|
38
|
+
functions: () => ({ signupSubmit }),
|
|
39
|
+
schemaDefinitions: signupSchemaDefinitions,
|
|
40
|
+
publicSettings: (config) => ({ contract: signupManifest.contract, forms: config.forms }),
|
|
41
|
+
wire(backend, config, context) {
|
|
42
|
+
const lambda = backend.signupSubmit.resources.lambda;
|
|
43
|
+
const stack = Stack.of(lambda);
|
|
44
|
+
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", context.graphqlEndpoint);
|
|
45
|
+
lambda.addEnvironment("PAPYRUS_SIGNUP_FORMS", config.forms.join(","));
|
|
46
|
+
lambda.addEnvironment("PAPYRUS_SIGNUP_ADDRESS_LIMIT", String(config.addressLimitPerMinute));
|
|
47
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
48
|
+
effect: Effect.ALLOW,
|
|
49
|
+
actions: ["appsync:GraphQL"],
|
|
50
|
+
resources: [`arn:aws:appsync:${stack.region}:${stack.account}:apis/${context.graphqlApiId}/types/Mutation/fields/createSignupEntry`],
|
|
51
|
+
}));
|
|
52
|
+
if (config.waf) {
|
|
53
|
+
addSignupRateLimit(context.createStack("signup-protection"), {
|
|
54
|
+
graphqlApiArn: `arn:aws:appsync:${stack.region}:${stack.account}:apis/${context.graphqlApiId}`,
|
|
55
|
+
limit: config.addressLimitPerMinute,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Stack } from "aws-cdk-lib";
|
|
2
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
|
|
4
|
+
import type { PapyrusPlugin, PluginManifest, PluginRequest, PluginWiringContext } from "../types";
|
|
5
|
+
import { signupSubmit } from "./function/resource";
|
|
6
|
+
import { signupSchemaDefinitions } from "./schema";
|
|
7
|
+
import { SIGNUP_MUTATION_FIELD, WAF_MINIMUM_RATE_LIMIT, addSignupRateLimit } from "./waf";
|
|
8
|
+
|
|
9
|
+
export type SignupPluginConfig = {
|
|
10
|
+
/** Form names the site accepts. A submission naming any other form is rejected. */
|
|
11
|
+
forms: string[];
|
|
12
|
+
/** Most submissions one address may make in a minute: enforced by the function and, with `waf`, by the web ACL. */
|
|
13
|
+
addressLimitPerMinute: number;
|
|
14
|
+
/** A regional web ACL with a rate-based rule on the API (about 6 USD a month). Off: the function's counters only. */
|
|
15
|
+
waf: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const FORM_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
19
|
+
const KNOWN_KEYS = new Set(["name", "forms", "addressLimitPerMinute", "waf"]);
|
|
20
|
+
|
|
21
|
+
export const signupManifest: PluginManifest = {
|
|
22
|
+
name: "signup",
|
|
23
|
+
contract: 1,
|
|
24
|
+
models: ["SignupEntry"],
|
|
25
|
+
mutations: [SIGNUP_MUTATION_FIELD],
|
|
26
|
+
guest: { mutations: [SIGNUP_MUTATION_FIELD] },
|
|
27
|
+
newsroomPanels: [{ id: "signups", label: "Signups" }],
|
|
28
|
+
staticAssets: ["_plugins/papyrus-signup@1.js"],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function resolveSignupConfig(request: PluginRequest): SignupPluginConfig {
|
|
32
|
+
const raw = typeof request === "string" ? { name: request } : request;
|
|
33
|
+
const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.has(key));
|
|
34
|
+
if (unknown.length > 0) throw new Error(`Unknown signup plugin settings: ${unknown.join(", ")}`);
|
|
35
|
+
const forms = (raw.forms as string[] | undefined) ?? ["newsletter"];
|
|
36
|
+
if (!Array.isArray(forms) || forms.length === 0 || !forms.every((form) => typeof form === "string" && FORM_NAME.test(form))) {
|
|
37
|
+
throw new Error("signup plugin forms must be a non-empty list of lowercase names (letters, digits, hyphens; at most 40 characters)");
|
|
38
|
+
}
|
|
39
|
+
const addressLimitPerMinute = (raw.addressLimitPerMinute as number | undefined) ?? 20;
|
|
40
|
+
if (!Number.isInteger(addressLimitPerMinute) || addressLimitPerMinute < WAF_MINIMUM_RATE_LIMIT || addressLimitPerMinute > 1000) {
|
|
41
|
+
throw new Error(`signup plugin addressLimitPerMinute must be an integer from ${WAF_MINIMUM_RATE_LIMIT} to 1000`);
|
|
42
|
+
}
|
|
43
|
+
const waf = (raw.waf as boolean | undefined) ?? true;
|
|
44
|
+
if (typeof waf !== "boolean") throw new Error("signup plugin waf must be a boolean");
|
|
45
|
+
return { forms: [...new Set(forms)], addressLimitPerMinute, waf };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const signupPlugin: PapyrusPlugin<SignupPluginConfig, { signupSubmit: typeof signupSubmit }> = {
|
|
49
|
+
manifest: signupManifest,
|
|
50
|
+
resolveConfig: resolveSignupConfig,
|
|
51
|
+
functions: () => ({ signupSubmit }),
|
|
52
|
+
schemaDefinitions: signupSchemaDefinitions,
|
|
53
|
+
publicSettings: (config) => ({ contract: signupManifest.contract, forms: config.forms }),
|
|
54
|
+
wire(backend, config, context: PluginWiringContext) {
|
|
55
|
+
const lambda = backend.signupSubmit.resources.lambda as LambdaFunction;
|
|
56
|
+
const stack = Stack.of(lambda);
|
|
57
|
+
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", context.graphqlEndpoint);
|
|
58
|
+
lambda.addEnvironment("PAPYRUS_SIGNUP_FORMS", config.forms.join(","));
|
|
59
|
+
lambda.addEnvironment("PAPYRUS_SIGNUP_ADDRESS_LIMIT", String(config.addressLimitPerMinute));
|
|
60
|
+
lambda.addToRolePolicy(
|
|
61
|
+
new PolicyStatement({
|
|
62
|
+
effect: Effect.ALLOW,
|
|
63
|
+
actions: ["appsync:GraphQL"],
|
|
64
|
+
resources: [`arn:aws:appsync:${stack.region}:${stack.account}:apis/${context.graphqlApiId}/types/Mutation/fields/createSignupEntry`],
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
if (config.waf) {
|
|
68
|
+
addSignupRateLimit(context.createStack("signup-protection"), {
|
|
69
|
+
graphqlApiArn: `arn:aws:appsync:${stack.region}:${stack.account}:apis/${context.graphqlApiId}`,
|
|
70
|
+
limit: config.addressLimitPerMinute,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { a } from "@aws-amplify/backend";
|
|
2
|
+
import { signupSubmit } from "./function/resource.js";
|
|
3
|
+
/**
|
|
4
|
+
* SignupEntry is never guest-readable and has no create rule for anyone: only the
|
|
5
|
+
* submitSignup function (IAM) writes it. Editors and admins list it; admins delete.
|
|
6
|
+
*/
|
|
7
|
+
export function signupSchemaDefinitions() {
|
|
8
|
+
return {
|
|
9
|
+
SignupEntry: a
|
|
10
|
+
.model({
|
|
11
|
+
id: a.id().required(),
|
|
12
|
+
form: a.string().required(),
|
|
13
|
+
value: a.string().required(),
|
|
14
|
+
submittedAt: a.datetime().required(),
|
|
15
|
+
})
|
|
16
|
+
.disableOperations(["subscriptions"])
|
|
17
|
+
.secondaryIndexes((index) => [
|
|
18
|
+
index("form").sortKeys(["submittedAt"]).queryField("listSignupEntriesByFormAndSubmittedAt"),
|
|
19
|
+
])
|
|
20
|
+
.authorization((allow) => [
|
|
21
|
+
allow.group("editor").to(["read"]),
|
|
22
|
+
allow.group("admin").to(["read", "delete"]),
|
|
23
|
+
]),
|
|
24
|
+
SignupReceipt: a.customType({
|
|
25
|
+
status: a.string().required(),
|
|
26
|
+
message: a.string(),
|
|
27
|
+
}),
|
|
28
|
+
submitSignup: a
|
|
29
|
+
.mutation()
|
|
30
|
+
.arguments({
|
|
31
|
+
form: a.string().required(),
|
|
32
|
+
value: a.string().required(),
|
|
33
|
+
renderedAt: a.string().required(),
|
|
34
|
+
website: a.string(),
|
|
35
|
+
})
|
|
36
|
+
.returns(a.ref("SignupReceipt"))
|
|
37
|
+
.authorization((allow) => [allow.guest()])
|
|
38
|
+
.handler(a.handler.function(signupSubmit)),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { a } from "@aws-amplify/backend";
|
|
2
|
+
import { signupSubmit } from "./function/resource";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SignupEntry is never guest-readable and has no create rule for anyone: only the
|
|
6
|
+
* submitSignup function (IAM) writes it. Editors and admins list it; admins delete.
|
|
7
|
+
*/
|
|
8
|
+
export function signupSchemaDefinitions() {
|
|
9
|
+
return {
|
|
10
|
+
SignupEntry: a
|
|
11
|
+
.model({
|
|
12
|
+
id: a.id().required(),
|
|
13
|
+
form: a.string().required(),
|
|
14
|
+
value: a.string().required(),
|
|
15
|
+
submittedAt: a.datetime().required(),
|
|
16
|
+
})
|
|
17
|
+
.disableOperations(["subscriptions"])
|
|
18
|
+
.secondaryIndexes((index) => [
|
|
19
|
+
index("form").sortKeys(["submittedAt"]).queryField("listSignupEntriesByFormAndSubmittedAt"),
|
|
20
|
+
])
|
|
21
|
+
.authorization((allow) => [
|
|
22
|
+
allow.group("editor").to(["read"]),
|
|
23
|
+
allow.group("admin").to(["read", "delete"]),
|
|
24
|
+
]),
|
|
25
|
+
SignupReceipt: a.customType({
|
|
26
|
+
status: a.string().required(),
|
|
27
|
+
message: a.string(),
|
|
28
|
+
}),
|
|
29
|
+
submitSignup: a
|
|
30
|
+
.mutation()
|
|
31
|
+
.arguments({
|
|
32
|
+
form: a.string().required(),
|
|
33
|
+
value: a.string().required(),
|
|
34
|
+
renderedAt: a.string().required(),
|
|
35
|
+
website: a.string(),
|
|
36
|
+
})
|
|
37
|
+
.returns(a.ref("SignupReceipt"))
|
|
38
|
+
.authorization((allow) => [allow.guest()])
|
|
39
|
+
.handler(a.handler.function(signupSubmit)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { CfnWebACL, CfnWebACLAssociation } from "aws-cdk-lib/aws-wafv2";
|
|
2
|
+
export const SIGNUP_MUTATION_FIELD = "submitSignup";
|
|
3
|
+
export const WAF_MINIMUM_RATE_LIMIT = 10;
|
|
4
|
+
export const WAF_EVALUATION_WINDOW_SECONDS = 60;
|
|
5
|
+
/**
|
|
6
|
+
* A regional web ACL on the AppSync API with one rate-based rule: more than `limit`
|
|
7
|
+
* submitSignup requests from one address in a minute are blocked with a 429 before
|
|
8
|
+
* the function runs. Only requests whose body names the mutation count, so reads,
|
|
9
|
+
* newsroom saves and CLI imports from the same address are never limited.
|
|
10
|
+
*/
|
|
11
|
+
export function addSignupRateLimit(scope, input) {
|
|
12
|
+
if (!Number.isInteger(input.limit) || input.limit < WAF_MINIMUM_RATE_LIMIT) {
|
|
13
|
+
throw new Error(`signup waf rate limit must be an integer of at least ${WAF_MINIMUM_RATE_LIMIT} (AWS WAF minimum)`);
|
|
14
|
+
}
|
|
15
|
+
const acl = new CfnWebACL(scope, "PapyrusSignupWebAcl", {
|
|
16
|
+
scope: "REGIONAL",
|
|
17
|
+
defaultAction: { allow: {} },
|
|
18
|
+
visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "PapyrusSignupWebAcl", sampledRequestsEnabled: true },
|
|
19
|
+
rules: [
|
|
20
|
+
{
|
|
21
|
+
name: "SignupRateLimitPerAddress",
|
|
22
|
+
priority: 0,
|
|
23
|
+
action: { block: { customResponse: { responseCode: 429 } } },
|
|
24
|
+
visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "SignupRateLimitPerAddress", sampledRequestsEnabled: true },
|
|
25
|
+
statement: {
|
|
26
|
+
rateBasedStatement: {
|
|
27
|
+
limit: input.limit,
|
|
28
|
+
evaluationWindowSec: WAF_EVALUATION_WINDOW_SECONDS,
|
|
29
|
+
aggregateKeyType: "IP",
|
|
30
|
+
scopeDownStatement: {
|
|
31
|
+
byteMatchStatement: {
|
|
32
|
+
searchString: SIGNUP_MUTATION_FIELD,
|
|
33
|
+
positionalConstraint: "CONTAINS",
|
|
34
|
+
fieldToMatch: { body: { oversizeHandling: "NO_MATCH" } },
|
|
35
|
+
textTransformations: [{ priority: 0, type: "NONE" }],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
new CfnWebACLAssociation(scope, "PapyrusSignupWebAclAssociation", {
|
|
44
|
+
resourceArn: input.graphqlApiArn,
|
|
45
|
+
webAclArn: acl.attrArn,
|
|
46
|
+
});
|
|
47
|
+
return acl;
|
|
48
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { CfnWebACL, CfnWebACLAssociation } from "aws-cdk-lib/aws-wafv2";
|
|
2
|
+
import type { Construct } from "constructs";
|
|
3
|
+
|
|
4
|
+
export const SIGNUP_MUTATION_FIELD = "submitSignup";
|
|
5
|
+
export const WAF_MINIMUM_RATE_LIMIT = 10;
|
|
6
|
+
export const WAF_EVALUATION_WINDOW_SECONDS = 60;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A regional web ACL on the AppSync API with one rate-based rule: more than `limit`
|
|
10
|
+
* submitSignup requests from one address in a minute are blocked with a 429 before
|
|
11
|
+
* the function runs. Only requests whose body names the mutation count, so reads,
|
|
12
|
+
* newsroom saves and CLI imports from the same address are never limited.
|
|
13
|
+
*/
|
|
14
|
+
export function addSignupRateLimit(scope: Construct, input: { graphqlApiArn: string; limit: number }): CfnWebACL {
|
|
15
|
+
if (!Number.isInteger(input.limit) || input.limit < WAF_MINIMUM_RATE_LIMIT) {
|
|
16
|
+
throw new Error(`signup waf rate limit must be an integer of at least ${WAF_MINIMUM_RATE_LIMIT} (AWS WAF minimum)`);
|
|
17
|
+
}
|
|
18
|
+
const acl = new CfnWebACL(scope, "PapyrusSignupWebAcl", {
|
|
19
|
+
scope: "REGIONAL",
|
|
20
|
+
defaultAction: { allow: {} },
|
|
21
|
+
visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "PapyrusSignupWebAcl", sampledRequestsEnabled: true },
|
|
22
|
+
rules: [
|
|
23
|
+
{
|
|
24
|
+
name: "SignupRateLimitPerAddress",
|
|
25
|
+
priority: 0,
|
|
26
|
+
action: { block: { customResponse: { responseCode: 429 } } },
|
|
27
|
+
visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "SignupRateLimitPerAddress", sampledRequestsEnabled: true },
|
|
28
|
+
statement: {
|
|
29
|
+
rateBasedStatement: {
|
|
30
|
+
limit: input.limit,
|
|
31
|
+
evaluationWindowSec: WAF_EVALUATION_WINDOW_SECONDS,
|
|
32
|
+
aggregateKeyType: "IP",
|
|
33
|
+
scopeDownStatement: {
|
|
34
|
+
byteMatchStatement: {
|
|
35
|
+
searchString: SIGNUP_MUTATION_FIELD,
|
|
36
|
+
positionalConstraint: "CONTAINS",
|
|
37
|
+
fieldToMatch: { body: { oversizeHandling: "NO_MATCH" } },
|
|
38
|
+
textTransformations: [{ priority: 0, type: "NONE" }],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
new CfnWebACLAssociation(scope, "PapyrusSignupWebAclAssociation", {
|
|
47
|
+
resourceArn: input.graphqlApiArn,
|
|
48
|
+
webAclArn: acl.attrArn,
|
|
49
|
+
});
|
|
50
|
+
return acl;
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Construct } from "constructs";
|
|
2
|
+
|
|
3
|
+
/** How a site asks for a plugin in `papyrus.config.ts`: `plugins: ["signup"]` or `plugins: [{ name: "signup", forms: ["news"] }]`. */
|
|
4
|
+
export type PluginRequest = string | ({ name: string } & Record<string, unknown>);
|
|
5
|
+
|
|
6
|
+
/** What a plugin declares about itself. A test pins the schema, the guest rules and the static files to this. */
|
|
7
|
+
export type PluginManifest = {
|
|
8
|
+
name: string;
|
|
9
|
+
/** Public contract version. Bump it when a mutation input, a model field, an element name or an attribute changes. No compatibility shims. */
|
|
10
|
+
contract: number;
|
|
11
|
+
models: string[];
|
|
12
|
+
mutations: string[];
|
|
13
|
+
/** What an anonymous visitor may call. Everything else is closed to guests. */
|
|
14
|
+
guest: { mutations: string[] };
|
|
15
|
+
newsroomPanels: { id: string; label: string }[];
|
|
16
|
+
/** Keys the projector writes into the site bucket when the plugin is enabled. */
|
|
17
|
+
staticAssets: string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Where the deploy-time wiring puts its resources. Filled in by `defineSiteBackend`. */
|
|
21
|
+
export type PluginWiringContext = {
|
|
22
|
+
createStack: (name: string) => Construct;
|
|
23
|
+
graphqlApiId: string;
|
|
24
|
+
graphqlEndpoint: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type PapyrusPlugin<Config, Functions = unknown> = {
|
|
28
|
+
manifest: PluginManifest;
|
|
29
|
+
/** Validates the request and fills in defaults. Throws a readable error. */
|
|
30
|
+
resolveConfig: (request: PluginRequest) => Config;
|
|
31
|
+
/** Amplify function resources the plugin adds to `defineBackend`. */
|
|
32
|
+
functions: () => Functions;
|
|
33
|
+
/** Models and custom operations merged into the site schema. */
|
|
34
|
+
schemaDefinitions: () => Record<string, unknown>;
|
|
35
|
+
/** The settings the newsroom and the page component may read from `amplify_outputs.json` (`custom.papyrusPlugins.<name>`). Never secrets. */
|
|
36
|
+
publicSettings: (config: Config) => Record<string, unknown>;
|
|
37
|
+
/** Everything that needs the backend to exist: IAM, environment, WAF, outputs. */
|
|
38
|
+
wire: (backend: any, config: Config, context: PluginWiringContext) => void;
|
|
39
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const PLUGINS_ENVIRONMENT_VARIABLE = "PAPYRUS_PLUGINS";
|
|
2
|
+
export const PLUGINS_OUTPUT_KEY = "papyrusPlugins";
|
|
3
|
+
/**
|
|
4
|
+
* Deploy-time wiring for every enabled plugin. A site without plugins reaches no line of this:
|
|
5
|
+
* nothing is added to the stacks, the environment or the outputs.
|
|
6
|
+
*/
|
|
7
|
+
export function wirePlugins(backend, plugins, contentActionsLambda) {
|
|
8
|
+
if (plugins.length === 0)
|
|
9
|
+
return;
|
|
10
|
+
const graphqlApi = backend.data.resources.graphqlApi;
|
|
11
|
+
const context = {
|
|
12
|
+
createStack: (name) => backend.createStack(name),
|
|
13
|
+
graphqlApiId: graphqlApi.apiId,
|
|
14
|
+
graphqlEndpoint: backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl,
|
|
15
|
+
};
|
|
16
|
+
for (const { plugin, config } of plugins)
|
|
17
|
+
plugin.wire(backend, config, context);
|
|
18
|
+
contentActionsLambda.addEnvironment(PLUGINS_ENVIRONMENT_VARIABLE, plugins.map(({ plugin }) => plugin.manifest.name).join(","));
|
|
19
|
+
backend.addOutput({
|
|
20
|
+
custom: {
|
|
21
|
+
[PLUGINS_OUTPUT_KEY]: Object.fromEntries(plugins.map(({ plugin, config }) => [plugin.manifest.name, plugin.publicSettings(config)])),
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
|
|
2
|
+
import type { ResolvedPlugin } from "./registry";
|
|
3
|
+
|
|
4
|
+
export const PLUGINS_ENVIRONMENT_VARIABLE = "PAPYRUS_PLUGINS";
|
|
5
|
+
export const PLUGINS_OUTPUT_KEY = "papyrusPlugins";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Deploy-time wiring for every enabled plugin. A site without plugins reaches no line of this:
|
|
9
|
+
* nothing is added to the stacks, the environment or the outputs.
|
|
10
|
+
*/
|
|
11
|
+
export function wirePlugins(backend: any, plugins: ResolvedPlugin[], contentActionsLambda: LambdaFunction): void {
|
|
12
|
+
if (plugins.length === 0) return;
|
|
13
|
+
const graphqlApi = backend.data.resources.graphqlApi;
|
|
14
|
+
const context = {
|
|
15
|
+
createStack: (name: string) => backend.createStack(name),
|
|
16
|
+
graphqlApiId: graphqlApi.apiId as string,
|
|
17
|
+
graphqlEndpoint: backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string,
|
|
18
|
+
};
|
|
19
|
+
for (const { plugin, config } of plugins) plugin.wire(backend, config, context);
|
|
20
|
+
contentActionsLambda.addEnvironment(PLUGINS_ENVIRONMENT_VARIABLE, plugins.map(({ plugin }) => plugin.manifest.name).join(","));
|
|
21
|
+
backend.addOutput({
|
|
22
|
+
custom: {
|
|
23
|
+
[PLUGINS_OUTPUT_KEY]: Object.fromEntries(plugins.map(({ plugin, config }) => [plugin.manifest.name, plugin.publicSettings(config)])),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { PluginRequest } from "./plugins/types";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Per-site backend configuration: the argument of `defineSiteBackend(site)`.
|
|
3
5
|
* Config is passed in; the environment is only a fallback so Papyrus's own
|
|
@@ -171,6 +173,11 @@ export type PapyrusSiteBackendConfig = {
|
|
|
171
173
|
* site out. See `resolveSitePublication`.
|
|
172
174
|
*/
|
|
173
175
|
publication?: PapyrusPublicationConfig | false;
|
|
176
|
+
/**
|
|
177
|
+
* Deploy-time plugins (docs/plugins.md): `["signup"]`, or `[{ name: "signup", forms: ["news"] }]`.
|
|
178
|
+
* Unset: no plugin, and the synthesized backend is exactly what it was before plugins existed.
|
|
179
|
+
*/
|
|
180
|
+
plugins?: PluginRequest[];
|
|
174
181
|
features?: {
|
|
175
182
|
consoleResponder?: boolean;
|
|
176
183
|
inboundEmail?: boolean;
|
package/amplify/site-backend.js
CHANGED
|
@@ -18,7 +18,9 @@ import { revalidateSecretReadStatement } from "./revalidate-secret-policy.js";
|
|
|
18
18
|
import { revalidateDistributionEnvironment, revalidateInvalidationStatement, validateRevalidateDistributionIds, } from "./revalidate-invalidation-settings.js";
|
|
19
19
|
import { addInvalidationPathAlarm } from "./publication/invalidation-alarm.js";
|
|
20
20
|
import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
|
|
21
|
-
import {
|
|
21
|
+
import { defineSiteData } from "./data/resource.js";
|
|
22
|
+
import { resolvePlugins } from "./plugins/registry.js";
|
|
23
|
+
import { wirePlugins } from "./plugins/wire.js";
|
|
22
24
|
import { assignmentAction } from "./functions/assignment-action/resource.js";
|
|
23
25
|
import { categoryAction } from "./functions/category-action/resource.js";
|
|
24
26
|
import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource.js";
|
|
@@ -76,12 +78,16 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
76
78
|
const publicationPlan = planS3ProjectionBackend(publication);
|
|
77
79
|
const renditionsPlan = planRenditions(publication, process.cwd());
|
|
78
80
|
setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
|
|
81
|
+
const plugins = resolvePlugins(site.plugins);
|
|
82
|
+
const pluginDefinitions = Object.assign({}, ...plugins.map(({ plugin }) => plugin.schemaDefinitions()));
|
|
83
|
+
const pluginFunctions = Object.assign({}, ...plugins.map(({ plugin }) => plugin.functions()));
|
|
79
84
|
const backend = defineBackend({
|
|
85
|
+
...pluginFunctions,
|
|
80
86
|
assignmentAction,
|
|
81
87
|
auth: defineSiteAuth(authConfig),
|
|
82
88
|
categoryAction,
|
|
83
89
|
contentActions,
|
|
84
|
-
data,
|
|
90
|
+
data: defineSiteData(pluginDefinitions),
|
|
85
91
|
knowledgeQuery,
|
|
86
92
|
manageUserRole,
|
|
87
93
|
modelAttachmentUpload,
|
|
@@ -571,5 +577,6 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
571
577
|
: null,
|
|
572
578
|
},
|
|
573
579
|
});
|
|
580
|
+
wirePlugins(backend, plugins, contentActionsLambda);
|
|
574
581
|
return backend;
|
|
575
582
|
}
|
package/amplify/site-backend.ts
CHANGED
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
} from "./revalidate-invalidation-settings";
|
|
26
26
|
import { addInvalidationPathAlarm } from "./publication/invalidation-alarm";
|
|
27
27
|
import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource";
|
|
28
|
-
import {
|
|
28
|
+
import { defineSiteData } from "./data/resource";
|
|
29
|
+
import { resolvePlugins } from "./plugins/registry";
|
|
30
|
+
import { wirePlugins } from "./plugins/wire";
|
|
29
31
|
import { assignmentAction } from "./functions/assignment-action/resource";
|
|
30
32
|
import { categoryAction } from "./functions/category-action/resource";
|
|
31
33
|
import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource";
|
|
@@ -97,12 +99,16 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
97
99
|
const publicationPlan = planS3ProjectionBackend(publication);
|
|
98
100
|
const renditionsPlan = planRenditions(publication, process.cwd());
|
|
99
101
|
setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
|
|
102
|
+
const plugins = resolvePlugins(site.plugins);
|
|
103
|
+
const pluginDefinitions = Object.assign({}, ...plugins.map(({ plugin }) => plugin.schemaDefinitions()));
|
|
104
|
+
const pluginFunctions: Record<never, never> = Object.assign({}, ...plugins.map(({ plugin }) => plugin.functions()));
|
|
100
105
|
const backend = defineBackend({
|
|
106
|
+
...pluginFunctions,
|
|
101
107
|
assignmentAction,
|
|
102
108
|
auth: defineSiteAuth(authConfig),
|
|
103
109
|
categoryAction,
|
|
104
110
|
contentActions,
|
|
105
|
-
data,
|
|
111
|
+
data: defineSiteData(pluginDefinitions),
|
|
106
112
|
knowledgeQuery,
|
|
107
113
|
manageUserRole,
|
|
108
114
|
modelAttachmentUpload,
|
|
@@ -707,5 +713,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
707
713
|
: null,
|
|
708
714
|
},
|
|
709
715
|
});
|
|
716
|
+
wirePlugins(backend, plugins, contentActionsLambda);
|
|
717
|
+
|
|
710
718
|
return backend;
|
|
711
719
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { signupForms, type PluginOutputs } from "../lib/plugin-settings";
|
|
7
|
+
import { signupEntriesToCsv, type SignupEntryRecord } from "../lib/signup-csv";
|
|
8
|
+
|
|
9
|
+
const LIST_QUERY = `query ListSignupEntries($form: String!, $nextToken: String) {
|
|
10
|
+
listSignupEntriesByFormAndSubmittedAt(form: $form, sortDirection: DESC, limit: 200, nextToken: $nextToken) {
|
|
11
|
+
items { id form value submittedAt }
|
|
12
|
+
nextToken
|
|
13
|
+
}
|
|
14
|
+
}`;
|
|
15
|
+
|
|
16
|
+
type ListResult = { data?: { listSignupEntriesByFormAndSubmittedAt?: { items: SignupEntryRecord[]; nextToken?: string | null } }; errors?: { message?: string }[] };
|
|
17
|
+
|
|
18
|
+
async function loadAllEntries(form: string): Promise<SignupEntryRecord[]> {
|
|
19
|
+
const client = generateClient({ authMode: "userPool" });
|
|
20
|
+
const entries: SignupEntryRecord[] = [];
|
|
21
|
+
let nextToken: string | null | undefined;
|
|
22
|
+
do {
|
|
23
|
+
const result = (await client.graphql({ query: LIST_QUERY, variables: { form, nextToken } })) as ListResult;
|
|
24
|
+
if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load signups.");
|
|
25
|
+
const page = result.data?.listSignupEntriesByFormAndSubmittedAt;
|
|
26
|
+
entries.push(...(page?.items ?? []));
|
|
27
|
+
nextToken = page?.nextToken;
|
|
28
|
+
} while (nextToken);
|
|
29
|
+
return entries;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SignupEntriesPanel() {
|
|
33
|
+
const forms = useMemo(() => signupForms(amplifyOutputs as PluginOutputs), []);
|
|
34
|
+
const [form, setForm] = useState(forms[0] ?? "");
|
|
35
|
+
const [entries, setEntries] = useState<SignupEntryRecord[] | null>(null);
|
|
36
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
const refresh = useCallback(async () => {
|
|
39
|
+
if (!form) return;
|
|
40
|
+
setEntries(null);
|
|
41
|
+
setProblem(null);
|
|
42
|
+
try {
|
|
43
|
+
setEntries(await loadAllEntries(form));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
setProblem(error instanceof Error ? error.message : "Could not load signups.");
|
|
46
|
+
}
|
|
47
|
+
}, [form]);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
void refresh();
|
|
51
|
+
}, [refresh]);
|
|
52
|
+
|
|
53
|
+
const exportCsv = () => {
|
|
54
|
+
if (!entries) return;
|
|
55
|
+
const url = URL.createObjectURL(new Blob([signupEntriesToCsv(entries)], { type: "text/csv;charset=utf-8" }));
|
|
56
|
+
const link = document.createElement("a");
|
|
57
|
+
link.href = url;
|
|
58
|
+
link.download = `signups-${form}.csv`;
|
|
59
|
+
link.click();
|
|
60
|
+
URL.revokeObjectURL(url);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<section aria-labelledby="newsroom-signups-title" data-news-desk-admin-panel="signups">
|
|
65
|
+
<h3 id="newsroom-signups-title">Signups</h3>
|
|
66
|
+
<p>Addresses people entered in the site's signup forms. Only editors and admins can see them.</p>
|
|
67
|
+
<label>
|
|
68
|
+
Form{" "}
|
|
69
|
+
<select value={form} onChange={(event) => setForm(event.target.value)} disabled={forms.length < 2}>
|
|
70
|
+
{forms.map((name) => (
|
|
71
|
+
<option key={name} value={name}>{name}</option>
|
|
72
|
+
))}
|
|
73
|
+
</select>
|
|
74
|
+
</label>{" "}
|
|
75
|
+
<button type="button" onClick={() => void refresh()}>Refresh</button>{" "}
|
|
76
|
+
<button type="button" onClick={exportCsv} disabled={!entries || entries.length === 0}>Export CSV</button>
|
|
77
|
+
<p role="status" aria-live="polite">
|
|
78
|
+
{problem ? `Error: ${problem}` : entries === null ? "Loading..." : `${entries.length} ${entries.length === 1 ? "entry" : "entries"}`}
|
|
79
|
+
</p>
|
|
80
|
+
{entries && entries.length > 0 ? (
|
|
81
|
+
<table>
|
|
82
|
+
<thead>
|
|
83
|
+
<tr><th scope="col">Submitted</th><th scope="col">Address</th></tr>
|
|
84
|
+
</thead>
|
|
85
|
+
<tbody>
|
|
86
|
+
{entries.map((entry) => (
|
|
87
|
+
<tr key={entry.id}><td>{entry.submittedAt}</td><td>{entry.value}</td></tr>
|
|
88
|
+
))}
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
) : null}
|
|
92
|
+
</section>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -10,6 +10,9 @@ import Link from "next/link";
|
|
|
10
10
|
import { usePathname, useRouter } from "next/navigation";
|
|
11
11
|
import type { ReactNode, RefObject } from "react";
|
|
12
12
|
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useTransition } from "react";
|
|
13
|
+
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
14
|
+
import { SignupEntriesPanel } from "./signup-entries-panel";
|
|
15
|
+
import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
|
|
13
16
|
import { generateClient } from "aws-amplify/data";
|
|
14
17
|
import type { Schema } from "../amplify/data/resource";
|
|
15
18
|
import {
|
|
@@ -235,7 +238,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
|
|
|
235
238
|
type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
|
|
236
239
|
type AssignmentDeskViewMode = "queue" | "budget";
|
|
237
240
|
type UserRoleAction = "grant" | "revoke";
|
|
238
|
-
type AdministrationPanel = "users" | "policies" | "sections" | "procedures";
|
|
241
|
+
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "signups";
|
|
239
242
|
export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "insights" | "messages" | "assignments" | "administration" | "search";
|
|
240
243
|
type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
|
|
241
244
|
type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
|
|
@@ -4452,8 +4455,20 @@ function AdministrationDeskView({
|
|
|
4452
4455
|
<strong>Procedures</strong>
|
|
4453
4456
|
<span>Procedure registry and versioned Tactus code</span>
|
|
4454
4457
|
</Link>
|
|
4458
|
+
{signupPluginEnabled() ? (
|
|
4459
|
+
<Link
|
|
4460
|
+
href={administrationPanelHref("signups", isDemo)}
|
|
4461
|
+
data-news-desk-admin-nav="signups"
|
|
4462
|
+
data-active={administrationPanel === "signups" || undefined}
|
|
4463
|
+
onClick={() => onPanelChange("signups")}
|
|
4464
|
+
>
|
|
4465
|
+
<strong>Signups</strong>
|
|
4466
|
+
<span>Addresses from the site's signup forms</span>
|
|
4467
|
+
</Link>
|
|
4468
|
+
) : null}
|
|
4455
4469
|
</nav>
|
|
4456
4470
|
<div className="news-desk-settings-panel">
|
|
4471
|
+
{administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
|
|
4457
4472
|
{administrationPanel === "users" ? (
|
|
4458
4473
|
<AdministrationUsersPanel
|
|
4459
4474
|
canManageUsers={canManageUsers}
|
|
@@ -13646,8 +13661,13 @@ function semanticVersionKey(kind: string, id: string): string {
|
|
|
13646
13661
|
return `${kind}#${id}`;
|
|
13647
13662
|
}
|
|
13648
13663
|
|
|
13664
|
+
function signupPluginEnabled(): boolean {
|
|
13665
|
+
return pluginSettings(amplifyOutputs as PluginOutputs, "signup") !== null;
|
|
13666
|
+
}
|
|
13667
|
+
|
|
13649
13668
|
function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
|
|
13650
13669
|
if (value === "policies" || value === "sections" || value === "procedures") return value;
|
|
13670
|
+
if (value === "signups" && signupPluginEnabled()) return value;
|
|
13651
13671
|
return "users";
|
|
13652
13672
|
}
|
|
13653
13673
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type PluginOutputs = { custom?: { papyrusPlugins?: Record<string, Record<string, unknown>> } };
|
|
2
|
+
|
|
3
|
+
/** The settings a deployed plugin published in `amplify_outputs.json`, or null when the site did not enable it. */
|
|
4
|
+
export function pluginSettings(outputs: PluginOutputs | null | undefined, name: string): Record<string, unknown> | null {
|
|
5
|
+
return outputs?.custom?.papyrusPlugins?.[name] ?? null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function signupForms(outputs: PluginOutputs | null | undefined): string[] {
|
|
9
|
+
const forms = pluginSettings(outputs, "signup")?.forms;
|
|
10
|
+
return Array.isArray(forms) ? forms.filter((form): form is string => typeof form === "string") : [];
|
|
11
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type SignupEntryRecord = {
|
|
2
|
+
id: string;
|
|
3
|
+
form: string;
|
|
4
|
+
value: string;
|
|
5
|
+
submittedAt: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const SIGNUP_CSV_HEADER = ["form", "value", "submittedAt"] as const;
|
|
9
|
+
const FORMULA_STARTS = new Set(["=", "+", "-", "@", "\t", "\r"]);
|
|
10
|
+
|
|
11
|
+
/** A spreadsheet runs a cell that starts with one of these as a formula; a leading quote makes it text. */
|
|
12
|
+
export function neutralizeFormula(cell: string): string {
|
|
13
|
+
return cell !== "" && FORMULA_STARTS.has(cell[0]) ? `'${cell}` : cell;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function csvCell(cell: string): string {
|
|
17
|
+
const safe = neutralizeFormula(cell);
|
|
18
|
+
return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function signupEntriesToCsv(entries: SignupEntryRecord[]): string {
|
|
22
|
+
const rows = entries.map((entry) => SIGNUP_CSV_HEADER.map((column) => csvCell(String(entry[column] ?? ""))).join(","));
|
|
23
|
+
return `${[SIGNUP_CSV_HEADER.join(","), ...rows].join("\r\n")}\r\n`;
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -104,6 +104,7 @@
|
|
|
104
104
|
"./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
|
|
105
105
|
"./components/reference-source-preview": "./components/reference-source-preview.tsx",
|
|
106
106
|
"./components/settings-page": "./components/settings-page.tsx",
|
|
107
|
+
"./components/signup-entries-panel": "./components/signup-entries-panel.tsx",
|
|
107
108
|
"./components/staging-banner": "./components/staging-banner.tsx",
|
|
108
109
|
"./components/topic-steering-workspace": "./components/topic-steering-workspace.tsx",
|
|
109
110
|
"./components/ui/badge": "./components/ui/badge.tsx",
|
|
@@ -190,6 +191,7 @@
|
|
|
190
191
|
"./lib/papyrus-object-kinds": "./lib/papyrus-object-kinds.ts",
|
|
191
192
|
"./lib/papyrus-web-locations": "./lib/papyrus-web-locations.ts",
|
|
192
193
|
"./lib/pilobol-sample": "./lib/pilobol-sample.ts",
|
|
194
|
+
"./lib/plugin-settings": "./lib/plugin-settings.ts",
|
|
193
195
|
"./lib/presentation-footer": "./lib/presentation-footer.ts",
|
|
194
196
|
"./lib/pretext-layout": "./lib/pretext-layout.ts",
|
|
195
197
|
"./lib/public-placeholder-config": "./lib/public-placeholder-config.ts",
|
|
@@ -211,6 +213,7 @@
|
|
|
211
213
|
"./lib/robots-policy": "./lib/robots-policy.ts",
|
|
212
214
|
"./lib/root-route-redirect": "./lib/root-route-redirect.ts",
|
|
213
215
|
"./lib/semantic-graph": "./lib/semantic-graph.ts",
|
|
216
|
+
"./lib/signup-csv": "./lib/signup-csv.ts",
|
|
214
217
|
"./lib/site-brand-tenant": "./lib/site-brand-tenant.ts",
|
|
215
218
|
"./lib/site-brand": "./lib/site-brand.ts",
|
|
216
219
|
"./lib/site-env": "./lib/site-env.ts",
|
|
@@ -303,7 +306,7 @@
|
|
|
303
306
|
"optional": true
|
|
304
307
|
}
|
|
305
308
|
},
|
|
306
|
-
"version": "1.0.0-next.
|
|
309
|
+
"version": "1.0.0-next.51",
|
|
307
310
|
"dependencies": {
|
|
308
311
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
309
312
|
"@aws-sdk/client-cognito-identity-provider": "3.1045.0",
|
package/routes.manifest.json
CHANGED