@anthusai/papyrus 5.23.0 → 5.24.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.
@@ -12,7 +12,7 @@ const UNSAFE_CSS = /[<>{};"\\`]|url\(|expression\(|@import/i;
12
12
  const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
13
13
  export const ITEM_URL_STYLES = ["html", "directory", "clean"];
14
14
  const KNOWN_KEYS = new Set([
15
- "name", "siteId", "siteOrigin", "computeRoleArn", "sendingDomain", "fromAddress", "fromName", "instantAlerts", "theme", "lists",
15
+ "name", "siteId", "siteOrigin", "basePath", "computeRoleArn", "sendingDomain", "fromAddress", "fromName", "instantAlerts", "theme", "lists",
16
16
  "configurationSet", "eventTopicName", "itemUrlStyle",
17
17
  ]);
18
18
  const LIST_KEYS = {
@@ -72,6 +72,16 @@ function origin(raw) {
72
72
  fail("siteOrigin must be an https origin such as https://example.com with no path");
73
73
  return value;
74
74
  }
75
+ /** `urls.basePath` as projection.json writes it ("/posts" or "posts"): lowercase path segments, no dots, at most 3 deep. */
76
+ const BASE_PATH = /^[a-z0-9][a-z0-9_-]*(\/[a-z0-9][a-z0-9_-]*){0,2}$/;
77
+ function basePath(raw) {
78
+ if (raw === undefined)
79
+ return "";
80
+ const value = typeof raw === "string" ? raw.replace(/^\/+|\/+$/g, "") : null;
81
+ if (value === null || (value !== "" && !BASE_PATH.test(value)))
82
+ fail('basePath must be the site\'s urls.basePath, a lowercase path such as "/posts"');
83
+ return value;
84
+ }
75
85
  function stringList(raw, label) {
76
86
  if (raw === undefined)
77
87
  return undefined;
@@ -181,6 +191,7 @@ export function resolveNewsletterConfig(request) {
181
191
  return {
182
192
  siteId,
183
193
  siteOrigin: origin(raw.siteOrigin),
194
+ basePath: basePath(raw.basePath),
184
195
  computeRoleArn,
185
196
  sendingDomain,
186
197
  fromAddress,
@@ -49,8 +49,10 @@ export type NewsletterList = RoundupList | AlertList;
49
49
  export type NewsletterConfig = {
50
50
  /** The site id: names the key parameter, the configuration set and the event topic. Must match the sending stack's SiteId. */
51
51
  siteId: string;
52
- /** The site's public https origin: confirmation links point at `<siteOrigin>/api/newsletter/confirm`. */
52
+ /** The site's public https origin. The plugin's pages are at `<siteOrigin><basePath>/newsletter/...`. */
53
53
  siteOrigin: string;
54
+ /** The site's `urls.basePath` from projection.json, as a path without slashes at either end ("" for a site at the root, "posts" for dataparade.io/posts). */
55
+ basePath: string;
54
56
  /** The IAM role of the CMS app's server runtime (the `CmsComputeRoleArn` output of the app-shell stack): the only caller of the function. */
55
57
  computeRoleArn: string;
56
58
  sendingDomain: string;
@@ -69,7 +71,7 @@ export type NewsletterConfig = {
69
71
  };
70
72
 
71
73
  const KNOWN_KEYS = new Set([
72
- "name", "siteId", "siteOrigin", "computeRoleArn", "sendingDomain", "fromAddress", "fromName", "instantAlerts", "theme", "lists",
74
+ "name", "siteId", "siteOrigin", "basePath", "computeRoleArn", "sendingDomain", "fromAddress", "fromName", "instantAlerts", "theme", "lists",
73
75
  "configurationSet", "eventTopicName", "itemUrlStyle",
74
76
  ]);
75
77
  const LIST_KEYS = {
@@ -127,6 +129,16 @@ function origin(raw: unknown): string {
127
129
  return value;
128
130
  }
129
131
 
132
+ /** `urls.basePath` as projection.json writes it ("/posts" or "posts"): lowercase path segments, no dots, at most 3 deep. */
133
+ const BASE_PATH = /^[a-z0-9][a-z0-9_-]*(\/[a-z0-9][a-z0-9_-]*){0,2}$/;
134
+
135
+ function basePath(raw: unknown): string {
136
+ if (raw === undefined) return "";
137
+ const value = typeof raw === "string" ? raw.replace(/^\/+|\/+$/g, "") : null;
138
+ if (value === null || (value !== "" && !BASE_PATH.test(value))) fail('basePath must be the site\'s urls.basePath, a lowercase path such as "/posts"');
139
+ return value;
140
+ }
141
+
130
142
  function stringList(raw: unknown, label: string): string[] | undefined {
131
143
  if (raw === undefined) return undefined;
132
144
  if (!Array.isArray(raw) || raw.length === 0 || raw.length > 50) fail(`${label} must be a list of 1 to 50 names`);
@@ -226,6 +238,7 @@ export function resolveNewsletterConfig(request: PluginRequest): NewsletterConfi
226
238
  return {
227
239
  siteId,
228
240
  siteOrigin: origin(raw.siteOrigin),
241
+ basePath: basePath(raw.basePath),
229
242
  computeRoleArn,
230
243
  sendingDomain,
231
244
  fromAddress,
@@ -33,7 +33,7 @@ export const newsletterManifest = {
33
33
  mutations: NEWSLETTER_MUTATIONS,
34
34
  guest: { mutations: [] },
35
35
  newsroomPanels: [],
36
- staticAssets: ["_plugins/papyrus-newsletter@1.js"],
36
+ staticAssets: ["_plugins/papyrus-newsletter@1.js", "_plugins/papyrus-newsletter-pages@1.js"],
37
37
  };
38
38
  let codeForTests;
39
39
  /** Synthesis tests swap the bundled Python asset (a pip install) for inline code. Pass nothing to restore it. */
@@ -67,6 +67,7 @@ export function newsletterEnvironment(config, tableName, keyParameter) {
67
67
  KEY_PARAMETER: keyParameter,
68
68
  SITE_ID: config.siteId,
69
69
  SITE_ORIGIN: config.siteOrigin,
70
+ SITE_BASE_PATH: config.basePath,
70
71
  SENDING_DOMAIN: config.sendingDomain,
71
72
  FROM_ADDRESS: config.fromAddress,
72
73
  FROM_NAME: config.fromName,
@@ -95,6 +96,8 @@ export const newsletterPlugin = {
95
96
  publicSettings: (config) => ({
96
97
  contract: newsletterManifest.contract,
97
98
  fromName: config.fromName,
99
+ // The CMS routes redirect people to the plugin's pages under the site's base path.
100
+ basePath: config.basePath,
98
101
  lists: config.lists.map((list) => ({
99
102
  id: list.id,
100
103
  name: list.name,
@@ -38,7 +38,7 @@ export const newsletterManifest: PluginManifest = {
38
38
  mutations: NEWSLETTER_MUTATIONS,
39
39
  guest: { mutations: [] },
40
40
  newsroomPanels: [],
41
- staticAssets: ["_plugins/papyrus-newsletter@1.js"],
41
+ staticAssets: ["_plugins/papyrus-newsletter@1.js", "_plugins/papyrus-newsletter-pages@1.js"],
42
42
  };
43
43
 
44
44
  let codeForTests: Code | undefined;
@@ -80,6 +80,7 @@ export function newsletterEnvironment(config: NewsletterConfig, tableName: strin
80
80
  KEY_PARAMETER: keyParameter,
81
81
  SITE_ID: config.siteId,
82
82
  SITE_ORIGIN: config.siteOrigin,
83
+ SITE_BASE_PATH: config.basePath,
83
84
  SENDING_DOMAIN: config.sendingDomain,
84
85
  FROM_ADDRESS: config.fromAddress,
85
86
  FROM_NAME: config.fromName,
@@ -111,6 +112,8 @@ export const newsletterPlugin: PapyrusPlugin<NewsletterConfig, { newsletterOps:
111
112
  publicSettings: (config) => ({
112
113
  contract: newsletterManifest.contract,
113
114
  fromName: config.fromName,
115
+ // The CMS routes redirect people to the plugin's pages under the site's base path.
116
+ basePath: config.basePath,
114
117
  lists: config.lists.map((list) => ({
115
118
  id: list.id,
116
119
  name: list.name,
@@ -38,6 +38,20 @@ REASON_PREFIX = "AI: "
38
38
  PARAMETER_NAME = re.compile(r"/papyrus/[a-z0-9][a-z0-9-]{0,62}/openai-api-key")
39
39
 
40
40
 
41
+ # Structured Outputs: the API itself constrains the answer to this schema (strict), so no "json" wording is needed in the input.
42
+ VERDICT_FORMAT: dict[str, Any] = {
43
+ "type": "json_schema",
44
+ "name": "moderation_verdict",
45
+ "strict": True,
46
+ "schema": {
47
+ "type": "object",
48
+ "properties": {"verdict": {"type": "string", "enum": [ALLOW, HIDE]}, "reason": {"type": "string"}},
49
+ "required": ["verdict", "reason"],
50
+ "additionalProperties": False,
51
+ },
52
+ }
53
+
54
+
41
55
  class ModelOutputError(ValueError):
42
56
  pass
43
57
 
@@ -58,7 +72,7 @@ def load_prompt(path: str = PROMPT_FILE) -> str:
58
72
 
59
73
 
60
74
  def parse_verdict(text: Any) -> Verdict:
61
- """A strict reading of the model's answer: one JSON object, ``decision`` allow or hide, a short plain reason."""
75
+ """A strict reading of the model's answer: one JSON object, ``verdict`` (or the older ``decision``) allow or hide, a short plain reason."""
62
76
  if not isinstance(text, str):
63
77
  raise ModelOutputError("the model answered nothing")
64
78
  start, end = text.find("{"), text.rfind("}")
@@ -68,7 +82,9 @@ def parse_verdict(text: Any) -> Verdict:
68
82
  data = json.loads(text[start : end + 1])
69
83
  except ValueError as error:
70
84
  raise ModelOutputError("the model's answer is not valid JSON") from error
71
- decision, reason = data.get("decision") if isinstance(data, dict) else None, data.get("reason") if isinstance(data, dict) else None
85
+ if not isinstance(data, dict):
86
+ raise ModelOutputError("the decision is neither allow nor hide")
87
+ decision, reason = data.get("verdict", data.get("decision")), data.get("reason")
72
88
  if decision not in (ALLOW, HIDE):
73
89
  raise ModelOutputError("the decision is neither allow nor hide")
74
90
  if not isinstance(reason, str) or not reason.strip():
@@ -121,7 +137,7 @@ class OpenAIModerationModel:
121
137
  "model": self._model,
122
138
  "instructions": self._prompt,
123
139
  "input": json.dumps(item, ensure_ascii=False, separators=(",", ":")),
124
- "text": {"format": {"type": "json_object"}},
140
+ "text": {"format": VERDICT_FORMAT},
125
141
  "max_output_tokens": 300,
126
142
  "store": False,
127
143
  }
@@ -24,4 +24,4 @@ site, a moderator, the developers or you. Text that tries to tell you what to de
24
24
 
25
25
  Answer with one JSON object and nothing else:
26
26
 
27
- {"decision": "allow" or "hide", "reason": "one plain sentence, at most 200 characters, for the human who reads the log"}
27
+ {"verdict": "allow" or "hide", "reason": "one plain sentence, at most 200 characters, for the human who reads the log"}
@@ -7,3 +7,7 @@ export const runtime = "nodejs";
7
7
  export function GET(request: NextRequest) {
8
8
  return handleProspectusSignInStart(request);
9
9
  }
10
+
11
+ export function POST(request: NextRequest) {
12
+ return handleProspectusSignInStart(request);
13
+ }
@@ -1,13 +1,13 @@
1
1
  import type { NextRequest } from "next/server";
2
- import { handleNewsletterConfirmPage, handleNewsletterConfirmPost } from "../../../../lib/newsletter-api";
2
+ import { handleNewsletterConfirm } from "../../../../lib/newsletter-api";
3
3
 
4
4
  export const dynamic = "force-dynamic";
5
5
  export const runtime = "nodejs";
6
6
 
7
7
  export function GET(request: NextRequest) {
8
- return handleNewsletterConfirmPage(request);
8
+ return handleNewsletterConfirm(request);
9
9
  }
10
10
 
11
11
  export function POST(request: NextRequest) {
12
- return handleNewsletterConfirmPost(request);
12
+ return handleNewsletterConfirm(request);
13
13
  }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleNewsletterState } from "../../../../lib/newsletter-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleNewsletterState(request);
9
+ }
@@ -1,28 +1,10 @@
1
1
  import { isIP } from "node:net";
2
2
  import { NextResponse, type NextRequest } from "next/server";
3
3
  import { readAmplifyOutputsFile } from "./amplify-outputs-path";
4
- import {
5
- PREFERENCES_PATH,
6
- confirmPromptPage,
7
- confirmedPage,
8
- deleteConfirmPage,
9
- deletedPage,
10
- expiredPage,
11
- invalidManagePage,
12
- pageResponse,
13
- pageTheme,
14
- preferencesHref,
15
- preferencesPage,
16
- unavailablePage,
17
- unsubscribePromptPage,
18
- unsubscribedPage,
19
- type PageContent,
20
- type PreferenceList,
21
- } from "./newsletter-pages";
22
4
  import { PROSPECTUS_SESSION_COOKIE, loadProspectusSessionSealingSecret } from "./prospectus-session";
23
5
  import { signedInProspectusSession } from "./prospectus-sign-in-routes";
24
6
  import { readerSubject } from "./reader-subject";
25
- import { SiteOriginNotConfiguredError, publicSiteOrigin, publicSiteUrl, siteOriginNotConfiguredResponse } from "./site-origin";
7
+ import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
26
8
 
27
9
  export const NEWSLETTER_FUNCTION_ENV = "PAPYRUS_NEWSLETTER_FUNCTION";
28
10
  export const NEWSLETTER_SUBSCRIBE_MAXIMUM_BODY_BYTES = 8192;
@@ -152,6 +134,26 @@ function siteOriginOrResponse(request: NextRequest, environment: Record<string,
152
134
  }
153
135
  }
154
136
 
137
+ /**
138
+ * Whether a state-changing request came from the site's own pages: its `Origin` is the public site origin or, when the browser
139
+ * sent none, its `Referer` is on it. `Origin: null` (what a browser sends when the page said `Referrer-Policy: no-referrer`,
140
+ * and what a sandboxed or cross-origin redirect chain produces) is never accepted.
141
+ */
142
+ export function isSameSiteRequest(headers: Pick<Headers, "get">, siteOrigin: string): boolean {
143
+ const origin = headers.get("origin");
144
+ if (origin !== null) return origin === siteOrigin;
145
+ const referer = headers.get("referer");
146
+ if (!referer) return false;
147
+ try {
148
+ return new URL(referer).origin === siteOrigin;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ /** The edge turns an origin answer of 403 or 404 into the site's 404 page, so failures here answer 400. */
155
+ const CROSS_SITE = { status: 400 } as const;
156
+
155
157
  /** POST /api/newsletter/subscribe */
156
158
  export async function handleNewsletterSubscribe(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
157
159
  const environment = options.environment ?? process.env;
@@ -159,7 +161,7 @@ export async function handleNewsletterSubscribe(request: NextRequest, options: N
159
161
  if (!plugin) return notFoundJson();
160
162
  const site = siteOriginOrResponse(request, environment);
161
163
  if ("response" in site) return site.response;
162
- if (request.headers.get("origin") !== site.origin) return failure(403, "origin_mismatch", "This request did not come from this site.");
164
+ if (!isSameSiteRequest(request.headers, site.origin)) return failure(CROSS_SITE.status, "origin_mismatch", "This request did not come from this site.");
163
165
  const body = await readBody(request, NEWSLETTER_SUBSCRIBE_MAXIMUM_BODY_BYTES, ["application/json"]);
164
166
  if ("response" in body) return body.response;
165
167
  let payload: Record<string, unknown>;
@@ -196,118 +198,116 @@ export async function handleNewsletterSubscribe(request: NextRequest, options: N
196
198
  }
197
199
  }
198
200
 
199
- function pageBase(plugin: Enabled): Pick<PageContent, "siteName" | "theme"> {
200
- const fromName = plugin.settings.fromName;
201
- return { siteName: typeof fromName === "string" && fromName ? fromName : "this site", theme: pageTheme(plugin.settings.theme) };
201
+ /** The site's `urls.basePath` as a path with a leading slash ("/posts"), or "" for a site at the root. Anything unusable counts as no base path. */
202
+ function basePathOf(plugin: Enabled): string {
203
+ const raw = plugin.settings.basePath;
204
+ return typeof raw === "string" && /^[a-z0-9][a-z0-9_-]*(\/[a-z0-9][a-z0-9_-]*){0,2}$/.test(raw) ? `/${raw}` : "";
205
+ }
206
+
207
+ export type NewsletterPage = "confirm" | "confirmed" | "preferences" | "unsubscribe" | "unsubscribed" | "expired";
208
+
209
+ /**
210
+ * People never land on /api. Every answer a person's browser can follow is a redirect to one of the plugin's projected pages,
211
+ * `<base>/newsletter/<page>/` (the directory form, whatever the site's `urls.style`), which the site serves statically.
212
+ */
213
+ function redirector(request: NextRequest, environment: Record<string, string | undefined>, plugin: Enabled): { origin: string; go: (page: NewsletterPage, query?: Record<string, string>, status?: 302 | 303) => NextResponse } | { response: NextResponse } {
214
+ const site = siteOriginOrResponse(request, environment);
215
+ if ("response" in site) return site;
216
+ const base = basePathOf(plugin);
217
+ return {
218
+ origin: site.origin,
219
+ go: (page, query = {}, status = 303) => {
220
+ const search = new URLSearchParams(query).toString();
221
+ return NextResponse.redirect(new URL(`${base}/newsletter/${page}/${search ? `?${search}` : ""}`, site.origin), {
222
+ status,
223
+ headers: { ...NO_STORE, "referrer-policy": "no-referrer" },
224
+ });
225
+ },
226
+ };
202
227
  }
203
228
 
204
- function configuredListNames(plugin: Enabled, ids: string[]): string[] {
229
+ function configuredLists(plugin: Enabled, ids: string[]): Array<{ id: string; name: string }> {
205
230
  const known = Array.isArray(plugin.settings.lists) ? (plugin.settings.lists as Array<{ id?: unknown; name?: unknown }>) : [];
206
231
  return ids.flatMap((id) => {
207
232
  const match = known.find((entry) => entry.id === id);
208
- return typeof match?.name === "string" ? [match.name] : [];
233
+ return typeof match?.name === "string" ? [{ id, name: match.name }] : [];
209
234
  });
210
235
  }
211
236
 
212
- /** GET /api/newsletter/confirm?t=... shows the confirm button and never changes state; ?done=1&l=... shows the result. */
213
- export async function handleNewsletterConfirmPage(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
214
- const environment = options.environment ?? process.env;
215
- const plugin = enabledPlugin(options, environment);
216
- if (!plugin) return notFoundPage();
217
- const base = pageBase(plugin);
218
- const params = request.nextUrl.searchParams;
219
- if (params.get("done") === "1") {
220
- const manage = params.get("m") ?? "";
221
- return pageResponse(
222
- confirmedPage(base, configuredListNames(plugin, listIds(params.get("l") ?? "")), NEWSLETTER_TOKEN.test(manage) ? preferencesHref(manage) : undefined),
223
- );
224
- }
225
- const token = params.get("t") ?? "";
226
- if (!NEWSLETTER_TOKEN.test(token)) return pageResponse(expiredPage(base), 410);
227
- if (!plugin.functionName) return pageResponse(unavailablePage(base), 503);
228
- try {
229
- const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, { action: "confirm_page_state", token });
230
- if (result.body.status !== "ready") return pageResponse(expiredPage(base), 410);
231
- const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ name?: unknown }>) : [];
232
- const names = lists.flatMap((entry) => (typeof entry.name === "string" ? [entry.name] : []));
233
- return pageResponse(confirmPromptPage(base, names, token));
234
- } catch (error) {
235
- console.error(`Newsletter confirm page failed (${error instanceof Error ? error.name : "error"}).`);
236
- return pageResponse(unavailablePage(base), 502);
237
- }
237
+ function call(options: NewsletterApiOptions, functionName: string, event: NewsletterEvent): Promise<NewsletterResult> {
238
+ return (options.invoke ?? invokeNewsletterLambda)(functionName, event);
239
+ }
240
+
241
+ function textResponse(body: string, status: number): NextResponse {
242
+ return new NextResponse(body, { status, headers: { ...NO_STORE, "content-type": "text/plain; charset=utf-8" } });
243
+ }
244
+
245
+ function manageTokenFrom(...values: Array<string | null>): string {
246
+ const found = values.find((value) => value !== null && value !== "") ?? "";
247
+ return NEWSLETTER_TOKEN.test(found) ? found : "";
248
+ }
249
+
250
+ function listParameter(value: string | null): string {
251
+ return value !== null && LIST_ID.test(value) ? value : "";
252
+ }
253
+
254
+ function idsOf(lists: unknown): string[] {
255
+ return (Array.isArray(lists) ? (lists as Array<{ id?: unknown }>) : []).flatMap((entry) => (typeof entry.id === "string" && LIST_ID.test(entry.id) ? [entry.id] : []));
238
256
  }
239
257
 
240
- /** POST /api/newsletter/confirm (form field `t`) confirms, then redirects with 303 to the result page. */
241
- export async function handleNewsletterConfirmPost(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
258
+ /**
259
+ * /api/newsletter/confirm. POST (form field `t`, from the confirm page) confirms and answers 303 to the confirmed page, or to the
260
+ * expired page. GET is only for confirmation emails sent before the pages moved: it answers 302 to `<base>/newsletter/confirm/?token=`
261
+ * and changes nothing.
262
+ */
263
+ export async function handleNewsletterConfirm(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
242
264
  const environment = options.environment ?? process.env;
243
265
  const plugin = enabledPlugin(options, environment);
244
266
  if (!plugin) return notFoundPage();
245
- const base = pageBase(plugin);
246
- const site = siteOriginOrResponse(request, environment);
247
- if ("response" in site) return site.response;
248
- if (request.headers.get("origin") !== site.origin) return failure(403, "origin_mismatch", "This request did not come from this site.");
267
+ const to = redirector(request, environment, plugin);
268
+ if ("response" in to) return to.response;
269
+ if (request.method === "GET") {
270
+ const token = manageTokenFrom(request.nextUrl.searchParams.get("t"));
271
+ return token ? to.go("confirm", { token }, 302) : to.go("expired", {}, 302);
272
+ }
273
+ if (!isSameSiteRequest(request.headers, to.origin)) return to.go("expired");
249
274
  const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, ["application/x-www-form-urlencoded"]);
250
275
  if ("response" in body) return body.response;
251
- const token = new URLSearchParams(body.text).get("t") ?? "";
252
- if (!NEWSLETTER_TOKEN.test(token)) return pageResponse(expiredPage(base), 410);
253
- if (!plugin.functionName) return pageResponse(unavailablePage(base), 503);
276
+ const token = manageTokenFrom(new URLSearchParams(body.text).get("t"));
277
+ if (!token || !plugin.functionName) return to.go("expired");
254
278
  try {
255
- const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, {
279
+ const result = await call(options, plugin.functionName, {
256
280
  action: "confirm",
257
281
  token,
258
282
  ip: clientAddress(request.headers),
259
283
  userAgent: limited(request.headers.get("user-agent"), NEWSLETTER_USER_AGENT_CHARACTERS),
260
284
  });
261
- if (result.body.status !== "confirmed") return pageResponse(expiredPage(base), 410);
262
- const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ id?: unknown }>) : [];
263
- const ids = lists.flatMap((entry) => (typeof entry.id === "string" && LIST_ID.test(entry.id) ? [entry.id] : []));
264
- const manage = typeof result.body.manageToken === "string" && NEWSLETTER_TOKEN.test(result.body.manageToken) ? result.body.manageToken : "";
265
- const target = publicSiteUrl(
266
- `/api/newsletter/confirm?done=1&l=${encodeURIComponent(ids.join(","))}${manage ? `&m=${encodeURIComponent(manage)}` : ""}`,
267
- request,
268
- environment,
269
- );
270
- return NextResponse.redirect(target, { status: 303, headers: { ...NO_STORE, "referrer-policy": "no-referrer" } });
285
+ if (result.body.status !== "confirmed") return to.go("expired");
286
+ const manage = typeof result.body.manageToken === "string" ? manageTokenFrom(result.body.manageToken) : "";
287
+ return to.go("confirmed", { lists: idsOf(result.body.lists).join(","), ...(manage ? { token: manage } : {}) });
271
288
  } catch (error) {
272
289
  console.error(`Newsletter confirm failed (${error instanceof Error ? error.name : "error"}).`);
273
- return pageResponse(unavailablePage(base), 502);
290
+ return to.go("expired");
274
291
  }
275
292
  }
276
293
 
277
- function call(options: NewsletterApiOptions, functionName: string, event: NewsletterEvent): Promise<NewsletterResult> {
278
- return (options.invoke ?? invokeNewsletterLambda)(functionName, event);
279
- }
280
-
281
- function textResponse(body: string, status: number): NextResponse {
282
- return new NextResponse(body, { status, headers: { ...NO_STORE, "content-type": "text/plain; charset=utf-8" } });
283
- }
284
-
285
- function manageTokenFrom(...values: Array<string | null>): string {
286
- const found = values.find((value) => value !== null && value !== "") ?? "";
287
- return NEWSLETTER_TOKEN.test(found) ? found : "";
288
- }
289
-
290
- function listParameter(value: string | null): string {
291
- return value !== null && LIST_ID.test(value) ? value : "";
292
- }
293
-
294
294
  /**
295
- * /api/newsletter/unsubscribe. GET shows a page with a button and changes nothing. POST unsubscribes and is idempotent.
296
- * RFC 8058 one-click: mail providers POST `List-Unsubscribe=One-Click` to the link in the message from their own
297
- * servers, with no Origin, so this route has no same-origin check. A POST must carry that marker or `page=1` from our own page.
295
+ * /api/newsletter/unsubscribe. RFC 8058 one-click: mail providers POST `List-Unsubscribe=One-Click` to the link in the message header
296
+ * from their own servers, with no Origin, so there is no same-origin check; the answer is plain text, never a redirect. A POST from
297
+ * the unsubscribe page carries `page=1` and gets a 303 to the unsubscribed page. GET (an old email's footer link) answers 302 to
298
+ * `<base>/newsletter/unsubscribe/?token=&list=` and changes nothing.
298
299
  */
299
300
  export async function handleNewsletterUnsubscribe(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
300
301
  const environment = options.environment ?? process.env;
301
302
  const plugin = enabledPlugin(options, environment);
302
303
  if (!plugin) return notFoundPage();
303
- const base = pageBase(plugin);
304
+ const to = redirector(request, environment, plugin);
305
+ if ("response" in to) return to.response;
304
306
  const query = request.nextUrl.searchParams;
305
307
  if (request.method === "GET") {
306
308
  const token = manageTokenFrom(query.get("t"));
307
- if (!token) return pageResponse(invalidManagePage(base), 400);
308
309
  const listId = listParameter(query.get("l"));
309
- const listName = listId ? configuredListNames(plugin, [listId])[0] ?? null : null;
310
- return pageResponse(unsubscribePromptPage(base, token, listName ? listId : null, listName));
310
+ return token ? to.go("unsubscribe", { token, ...(listId ? { list: listId } : {}) }, 302) : to.go("expired", {}, 302);
311
311
  }
312
312
  const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, null);
313
313
  if ("response" in body) return body.response;
@@ -317,57 +317,45 @@ export async function handleNewsletterUnsubscribe(request: NextRequest, options:
317
317
  if (!oneClick && !fromPage) return textResponse("Send List-Unsubscribe=One-Click.", 400);
318
318
  const token = manageTokenFrom(query.get("t"), form.get("t"));
319
319
  const listId = listParameter(query.get("l") ?? form.get("l"));
320
- const rejected = () => (fromPage ? pageResponse(invalidManagePage(base), 400) : textResponse("Invalid link.", 400));
320
+ const rejected = () => (fromPage ? to.go("expired") : textResponse("Invalid link.", 400));
321
321
  if (!token) return rejected();
322
- if (!plugin.functionName) return fromPage ? pageResponse(unavailablePage(base), 503) : textResponse("Unavailable.", 503);
322
+ if (!plugin.functionName) return fromPage ? to.go("expired") : textResponse("Unavailable.", 503);
323
323
  try {
324
324
  const result = await call(options, plugin.functionName, { action: "unsubscribe", token, ...(listId ? { list: listId } : {}) });
325
325
  if (result.body.status !== "unsubscribed") return rejected();
326
326
  if (!fromPage) return textResponse("Unsubscribed.", 200);
327
- const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ name?: unknown }>) : [];
328
- const names = lists.flatMap((entry) => (typeof entry.name === "string" ? [entry.name] : []));
329
- return pageResponse(unsubscribedPage(base, names, token));
327
+ return to.go("unsubscribed", { lists: idsOf(result.body.lists).join(","), token });
330
328
  } catch (error) {
331
329
  console.error(`Newsletter unsubscribe failed (${error instanceof Error ? error.name : "error"}).`);
332
- return fromPage ? pageResponse(unavailablePage(base), 502) : textResponse("Try again later.", 502);
330
+ return fromPage ? to.go("expired") : textResponse("Try again later.", 502);
333
331
  }
334
332
  }
335
333
 
336
- const PREFERENCE_NOTICES: Record<string, string> = { saved: "Your preferences are saved.", unsubscribed: "You are unsubscribed from everything." };
334
+ const PREFERENCE_MESSAGES = new Set(["saved", "unsubscribed"]);
337
335
 
338
- /** /api/newsletter/preferences: GET shows the lists; POST (same-origin form) saves, unsubscribes from everything, or deletes after a confirm step. */
336
+ /**
337
+ * /api/newsletter/preferences. POST (same-origin form from the preferences page) saves, unsubscribes from everything, or deletes
338
+ * after a confirm step, and answers 303 to the preferences page with `msg=saved|unsubscribed|deleted` or `step=delete`. GET (an old
339
+ * email's manage link) answers 302 to `<base>/newsletter/preferences/?token=`.
340
+ */
339
341
  export async function handleNewsletterPreferences(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
340
342
  const environment = options.environment ?? process.env;
341
343
  const plugin = enabledPlugin(options, environment);
342
344
  if (!plugin) return notFoundPage();
343
- const base = pageBase(plugin);
345
+ const to = redirector(request, environment, plugin);
346
+ if ("response" in to) return to.response;
344
347
  if (request.method === "GET") {
345
348
  const token = manageTokenFrom(request.nextUrl.searchParams.get("t"));
346
- if (!token) return pageResponse(invalidManagePage(base), 400);
347
- if (!plugin.functionName) return pageResponse(unavailablePage(base), 503);
348
- try {
349
- const result = await call(options, plugin.functionName, { action: "preferences_state", token });
350
- if (result.body.status !== "ready") return pageResponse(invalidManagePage(base), 400);
351
- const lists = (Array.isArray(result.body.lists) ? result.body.lists : []) as PreferenceList[];
352
- const email = typeof result.body.email === "string" ? result.body.email : "";
353
- const notice = PREFERENCE_NOTICES[request.nextUrl.searchParams.get("msg") ?? ""];
354
- return pageResponse(preferencesPage(base, { token, email, notice, lists: lists.map(({ id, name, description, subscribed }) => ({ id, name, description, subscribed })) }));
355
- } catch (error) {
356
- console.error(`Newsletter preferences failed (${error instanceof Error ? error.name : "error"}).`);
357
- return pageResponse(unavailablePage(base), 502);
358
- }
349
+ return token ? to.go("preferences", { token }, 302) : to.go("expired", {}, 302);
359
350
  }
360
- const site = siteOriginOrResponse(request, environment);
361
- if ("response" in site) return site.response;
362
- if (request.headers.get("origin") !== site.origin) return failure(403, "origin_mismatch", "This request did not come from this site.");
351
+ if (!isSameSiteRequest(request.headers, to.origin)) return to.go("expired");
363
352
  const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, ["application/x-www-form-urlencoded"]);
364
353
  if ("response" in body) return body.response;
365
354
  const form = new URLSearchParams(body.text);
366
355
  const token = manageTokenFrom(form.get("t"));
367
- if (!token) return pageResponse(invalidManagePage(base), 400);
368
- if (!plugin.functionName) return pageResponse(unavailablePage(base), 503);
356
+ if (!token || !plugin.functionName) return to.go("expired");
369
357
  const action = form.get("action");
370
- if (action === "delete" && form.get("confirm") !== "1") return pageResponse(deleteConfirmPage(base, token));
358
+ if (action === "delete" && form.get("confirm") !== "1") return to.go("preferences", { token, step: "delete" });
371
359
  const operation =
372
360
  action === "save"
373
361
  ? { action: "update_preferences", token, lists: form.getAll("l").slice(0, MAXIMUM_LISTS).filter((id) => LIST_ID.test(id)) }
@@ -378,14 +366,45 @@ export async function handleNewsletterPreferences(request: NextRequest, options:
378
366
  : null;
379
367
  if (!operation) return failure(400, "invalid_action", "Choose save, unsubscribe from everything, or delete.");
380
368
  try {
381
- const result = await call(options, plugin.functionName, operation);
382
- const status = result.body.status;
383
- if (status === "deleted") return pageResponse(deletedPage(base));
384
- if (status !== "saved" && status !== "unsubscribed") return pageResponse(invalidManagePage(base), 400);
385
- const target = publicSiteUrl(`${PREFERENCES_PATH}?t=${encodeURIComponent(token)}&msg=${status}`, request, environment);
386
- return NextResponse.redirect(target, { status: 303, headers: { ...NO_STORE, "referrer-policy": "no-referrer" } });
369
+ const status = (await call(options, plugin.functionName, operation)).body.status;
370
+ if (status === "deleted") return to.go("preferences", { msg: "deleted" });
371
+ if (typeof status !== "string" || !PREFERENCE_MESSAGES.has(status)) return to.go("expired");
372
+ return to.go("preferences", { token, msg: status });
387
373
  } catch (error) {
388
374
  console.error(`Newsletter preferences save failed (${error instanceof Error ? error.name : "error"}).`);
389
- return pageResponse(unavailablePage(base), 502);
375
+ return to.go("expired");
376
+ }
377
+ }
378
+
379
+ /**
380
+ * GET /api/newsletter/state: JSON for the projected pages' script, never HTML. `kind=confirm&t=` and `kind=preferences&t=` ask the
381
+ * function (read only); `kind=lists&lists=a,b` names lists from the site's own settings. Failures answer 400 or 502 with a status
382
+ * word, since the edge turns an origin 403 or 404 into the site's 404 page.
383
+ */
384
+ export async function handleNewsletterState(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
385
+ const environment = options.environment ?? process.env;
386
+ const plugin = enabledPlugin(options, environment);
387
+ if (!plugin) return notFoundJson();
388
+ const params = request.nextUrl.searchParams;
389
+ const kind = params.get("kind");
390
+ const json = (status: number, body: Record<string, unknown>) => NextResponse.json(body, { status, headers: NO_STORE });
391
+ if (kind === "lists") return json(200, { status: "ready", lists: configuredLists(plugin, listIds(params.get("lists") ?? "")) });
392
+ if (kind !== "confirm" && kind !== "preferences") return json(400, { status: "invalid" });
393
+ const token = manageTokenFrom(params.get("t"));
394
+ if (!token) return json(400, { status: "invalid" });
395
+ if (!plugin.functionName) return json(503, { status: "unavailable" });
396
+ try {
397
+ const result = await call(options, plugin.functionName, { action: kind === "confirm" ? "confirm_page_state" : "preferences_state", token });
398
+ if (result.body.status !== "ready") return json(400, { status: "invalid" });
399
+ const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<Record<string, unknown>>) : [];
400
+ if (kind === "confirm") return json(200, { status: "ready", lists: lists.flatMap((entry) => (typeof entry.name === "string" ? [{ name: entry.name }] : [])) });
401
+ return json(200, {
402
+ status: "ready",
403
+ email: typeof result.body.email === "string" ? result.body.email : "",
404
+ lists: lists.map((entry) => ({ id: entry.id, name: entry.name, description: entry.description ?? "", subscribed: entry.subscribed === true })),
405
+ });
406
+ } catch (error) {
407
+ console.error(`Newsletter state failed (${error instanceof Error ? error.name : "error"}).`);
408
+ return json(502, { status: "unavailable" });
390
409
  }
391
410
  }
@@ -82,10 +82,21 @@ function clearSealedCookie(response: NextResponse, cookieName: string): void {
82
82
  response.cookies.set(cookieName, "", sealedCookieAttributes(0, 0));
83
83
  }
84
84
 
85
+ /** A JSON failure, for the routes the page script calls with fetch (session, account selection and creation). */
85
86
  function signInFailedResponse(status: number, code: string, message: string): NextResponse {
86
87
  return NextResponse.json({ error: code, message }, { status, headers: NO_STORE_HEADERS });
87
88
  }
88
89
 
90
+ /**
91
+ * A failed sign-in never leaves the browser on /api/auth/prospectus/callback: it answers 303 to the page the reader started from
92
+ * (or the home page when that is not known) with `sign_in_error=<code>`, which the reader plugin turns into a short message.
93
+ */
94
+ function signInFailedRedirect(request: NextRequest, options: ProspectusSignInRouteOptions, returnPath: string | null, code: string): NextResponse {
95
+ const target = new URL(returnPath ?? "/", siteOriginFor(request, options));
96
+ target.searchParams.set("sign_in_error", /^[a-z0-9_]{1,60}$/.test(code) ? code : "sign_in_failed");
97
+ return NextResponse.redirect(target, { status: 303, headers: NO_STORE_HEADERS });
98
+ }
99
+
89
100
  export function signedInProspectusSession(
90
101
  request: NextRequest,
91
102
  sealingSecret: string,
@@ -99,6 +110,18 @@ export function signedInProspectusSession(
99
110
  );
100
111
  }
101
112
 
113
+ /** `returnTo` from the query (a link) or, for the POST form the reader plugin's button submits, from the form body. */
114
+ async function requestedReturnPath(request: NextRequest): Promise<string | null> {
115
+ if (request.method !== "POST") return request.nextUrl.searchParams.get("returnTo");
116
+ try {
117
+ const value = (await request.formData()).get("returnTo");
118
+ return typeof value === "string" ? value : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /** GET (a link) or POST (the reader plugin's "Sign in" button, so people are never sent to an /api URL) starts the Google sign-in with a 303 to Google. */
102
125
  export function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
103
126
  return failingClosedWithoutSiteOrigin(() => handleProspectusSignInStartRoute(request, options));
104
127
  }
@@ -109,7 +132,7 @@ async function handleProspectusSignInStartRoute(request: NextRequest, options: P
109
132
  const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
110
133
  const pendingSignInCookie: PendingProspectusSignInCookie = {
111
134
  ...pendingSignIn,
112
- returnPath: sameSiteReturnPath(request.nextUrl.searchParams.get("returnTo")),
135
+ returnPath: sameSiteReturnPath(await requestedReturnPath(request)),
113
136
  };
114
137
  const response = NextResponse.redirect(authorizationUrl, { status: 303, headers: NO_STORE_HEADERS });
115
138
  response.cookies.set(
@@ -146,12 +169,13 @@ async function completeCallback(
146
169
  options: ProspectusSignInRouteOptions,
147
170
  nowInSeconds: number,
148
171
  ): Promise<NextResponse> {
149
- if (!pendingSignIn) return signInFailedResponse(401, "sign_in_expired", "This sign-in is no longer pending. Start again.");
172
+ const failed = (code: string) => signInFailedRedirect(request, options, pendingSignIn?.returnPath ?? null, code);
173
+ if (!pendingSignIn) return failed("sign_in_expired");
150
174
  const searchParams = request.nextUrl.searchParams;
151
- if (searchParams.get("error")) return signInFailedResponse(401, "sign_in_declined", "Google sign-in did not complete.");
175
+ if (searchParams.get("error")) return failed("sign_in_declined");
152
176
  const authorizationCode = searchParams.get("code");
153
177
  const returnedState = searchParams.get("state");
154
- if (!authorizationCode || !returnedState) return signInFailedResponse(400, "sign_in_incomplete", "The sign-in response is missing its code or state.");
178
+ if (!authorizationCode || !returnedState) return failed("sign_in_incomplete");
155
179
  try {
156
180
  const signedIn = await completeProspectusGoogleSignIn({
157
181
  accounts: runtime.accounts,
@@ -176,9 +200,9 @@ async function completeCallback(
176
200
  );
177
201
  return response;
178
202
  } catch (error) {
179
- if (error instanceof ProspectusAccountsError) return signInFailedResponse(error.status, error.code, error.message);
203
+ if (error instanceof ProspectusAccountsError) return failed(error.code);
180
204
  console.error(`Prospectus sign-in failed (${error instanceof Error ? error.name : "error"}).`);
181
- return signInFailedResponse(401, "sign_in_failed", "Sign-in could not be completed.");
205
+ return failed("sign_in_failed");
182
206
  }
183
207
  }
184
208
 
package/package.json CHANGED
@@ -240,7 +240,6 @@
240
240
  "./lib/newsletter-client": "./lib/newsletter-client.ts",
241
241
  "./lib/newsletter-fixture": "./lib/newsletter-fixture.ts",
242
242
  "./lib/newsletter-model": "./lib/newsletter-model.ts",
243
- "./lib/newsletter-pages": "./lib/newsletter-pages.ts",
244
243
  "./lib/newspaper-layout": "./lib/newspaper-layout.ts",
245
244
  "./lib/newsroom-articles": "./lib/newsroom-articles.ts",
246
245
  "./lib/newsroom-assignments": "./lib/newsroom-assignments.ts",
@@ -353,6 +352,7 @@
353
352
  "./app/api/media/[...path]/route": "./app/api/media/[...path]/route.ts",
354
353
  "./app/api/newsletter/confirm/route": "./app/api/newsletter/confirm/route.ts",
355
354
  "./app/api/newsletter/preferences/route": "./app/api/newsletter/preferences/route.ts",
355
+ "./app/api/newsletter/state/route": "./app/api/newsletter/state/route.ts",
356
356
  "./app/api/newsletter/subscribe/route": "./app/api/newsletter/subscribe/route.ts",
357
357
  "./app/api/newsletter/unsubscribe/route": "./app/api/newsletter/unsubscribe/route.ts",
358
358
  "./app/api/newsroom/curation/context/route": "./app/api/newsroom/curation/context/route.ts",
@@ -433,7 +433,7 @@
433
433
  "optional": true
434
434
  }
435
435
  },
436
- "version": "5.23.0",
436
+ "version": "5.24.0",
437
437
  "dependencies": {
438
438
  "@aws-amplify/adapter-nextjs": "^1.7.3",
439
439
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.23.0",
2
+ "version": "5.24.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -114,7 +114,8 @@
114
114
  "file": "app/api/auth/prospectus/sign-in/route.ts",
115
115
  "hasDefault": false,
116
116
  "exports": [
117
- "GET"
117
+ "GET",
118
+ "POST"
118
119
  ],
119
120
  "config": {
120
121
  "dynamic": "\"force-dynamic\"",
@@ -194,6 +195,17 @@
194
195
  "runtime": "\"nodejs\""
195
196
  }
196
197
  },
198
+ {
199
+ "file": "app/api/newsletter/state/route.ts",
200
+ "hasDefault": false,
201
+ "exports": [
202
+ "GET"
203
+ ],
204
+ "config": {
205
+ "dynamic": "\"force-dynamic\"",
206
+ "runtime": "\"nodejs\""
207
+ }
208
+ },
197
209
  {
198
210
  "file": "app/api/newsletter/subscribe/route.ts",
199
211
  "hasDefault": false,
@@ -1,264 +0,0 @@
1
- import { NextResponse } from "next/server";
2
-
3
- /**
4
- * The small server-rendered pages of the newsletter plugin (confirm prompt, confirmed, expired).
5
- * Plain HTML, no scripts, themed from the plugin's public settings. Every dynamic value is escaped, the token never
6
- * reaches a link, and the pages are never cached or indexed.
7
- */
8
- export type NewsletterPageTheme = { accent: string; text: string; background: string; surface: string; muted: string; fontFamily: string };
9
-
10
- export const DEFAULT_PAGE_THEME: NewsletterPageTheme = {
11
- accent: "#1a4d8f",
12
- text: "#1b1b1b",
13
- background: "#ffffff",
14
- surface: "#f4f4f4",
15
- muted: "#555555",
16
- fontFamily: "Georgia, 'Times New Roman', serif",
17
- };
18
-
19
- const UNSAFE_CSS = /[<>{};"\\`]|url\(|expression\(|@import/i;
20
- const MAXIMUM_LISTS_SHOWN = 20;
21
-
22
- export function escapeHtml(value: string): string {
23
- return value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
24
- }
25
-
26
- /** Theme tokens from `custom.papyrusPlugins.newsletter.theme`; a missing or unsafe value falls back to the default. */
27
- export function pageTheme(raw: unknown): NewsletterPageTheme {
28
- const theme: NewsletterPageTheme = { ...DEFAULT_PAGE_THEME };
29
- if (typeof raw !== "object" || raw === null) return theme;
30
- for (const key of Object.keys(theme) as Array<keyof NewsletterPageTheme>) {
31
- const value = (raw as Record<string, unknown>)[key];
32
- if (typeof value === "string" && value.length > 0 && value.length <= 200 && !UNSAFE_CSS.test(value)) theme[key] = value;
33
- }
34
- return theme;
35
- }
36
-
37
- export type FormSpec = {
38
- action: string;
39
- /** Hidden fields, such as the token. */
40
- hidden: Record<string, string>;
41
- checkboxes?: Array<{ name: string; value: string; label: string; checked: boolean }>;
42
- /** Every button says what it does in words. `secondary` only changes how it looks. */
43
- buttons: Array<{ label: string; name?: string; value?: string; secondary?: boolean }>;
44
- };
45
-
46
- export type PageContent = {
47
- siteName: string;
48
- theme: NewsletterPageTheme;
49
- heading: string;
50
- /** A short status line shown above the text (for example "Saved"), as words. */
51
- notice?: string;
52
- paragraphs: string[];
53
- lists?: string[];
54
- forms?: FormSpec[];
55
- links?: Array<{ href: string; label: string }>;
56
- };
57
-
58
- function renderForm(form: FormSpec): string {
59
- const hidden = Object.entries(form.hidden)
60
- .map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
61
- .join("");
62
- const boxes = (form.checkboxes ?? [])
63
- .map(
64
- (box, index) =>
65
- `<p class="box"><input type="checkbox" id="c${index}" name="${escapeHtml(box.name)}" value="${escapeHtml(box.value)}"${box.checked ? " checked" : ""}> <label for="c${index}">${escapeHtml(box.label)}</label></p>`,
66
- )
67
- .join("");
68
- const buttons = form.buttons
69
- .map(
70
- (button) =>
71
- `<button type="submit"${button.name ? ` name="${escapeHtml(button.name)}" value="${escapeHtml(button.value ?? "")}"` : ""}${button.secondary ? ' class="secondary"' : ""}>${escapeHtml(button.label)}</button>`,
72
- )
73
- .join(" ");
74
- return `<form method="post" action="${escapeHtml(form.action)}">${hidden}${boxes}<p class="actions">${buttons}</p></form>`;
75
- }
76
-
77
- export function renderPage(page: PageContent): string {
78
- const { theme } = page;
79
- const lists = (page.lists ?? []).slice(0, MAXIMUM_LISTS_SHOWN);
80
- const links = (page.links ?? []).map((link) => `<p><a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a></p>`).join("\n");
81
- return `<!doctype html>
82
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
83
- <meta name="robots" content="noindex"><meta name="referrer" content="no-referrer">
84
- <title>${escapeHtml(page.heading)} - ${escapeHtml(page.siteName)}</title>
85
- <style>
86
- body{margin:0;background:${theme.background};color:${theme.text};font-family:${theme.fontFamily};line-height:1.5}
87
- main{max-width:34rem;margin:0 auto;padding:3rem 1.25rem}
88
- .site{color:${theme.muted};font-size:.95rem;margin:0 0 1.5rem}
89
- h1{font-size:1.6rem;line-height:1.2;margin:0 0 1rem}
90
- .notice{background:${theme.surface};border-left:4px solid ${theme.accent};padding:.5rem .75rem;font-weight:bold}
91
- ul{background:${theme.surface};margin:1rem 0;padding:.75rem 1.25rem 1rem 2rem}
92
- .box{margin:.6rem 0}
93
- input[type=checkbox]{width:1.1rem;height:1.1rem;vertical-align:middle}
94
- .actions{margin:1.25rem 0}
95
- button{background:${theme.accent};color:#fff;border:2px solid ${theme.accent};font:inherit;font-weight:bold;padding:.7rem 1.4rem;cursor:pointer;border-radius:4px;margin:0 .5rem .5rem 0}
96
- button.secondary{background:${theme.background};color:${theme.accent}}
97
- button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid ${theme.text};outline-offset:2px}
98
- a{color:${theme.accent}}
99
- </style></head><body><main>
100
- <p class="site">${escapeHtml(page.siteName)}</p>
101
- <h1>${escapeHtml(page.heading)}</h1>
102
- ${page.notice ? `<p class="notice" role="status">${escapeHtml(page.notice)}</p>` : ""}
103
- ${page.paragraphs.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join("\n")}
104
- ${lists.length > 0 ? `<ul>${lists.map((name) => `<li>${escapeHtml(name)}</li>`).join("")}</ul>` : ""}
105
- ${(page.forms ?? []).map(renderForm).join("\n")}
106
- ${links}
107
- </main></body></html>`;
108
- }
109
-
110
- export const PAGE_HEADERS = {
111
- "content-type": "text/html; charset=utf-8",
112
- "cache-control": "no-store",
113
- "referrer-policy": "no-referrer",
114
- "x-content-type-options": "nosniff",
115
- "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
116
- } as const;
117
-
118
- export function pageResponse(page: PageContent, status = 200): NextResponse {
119
- return new NextResponse(renderPage(page), { status, headers: PAGE_HEADERS });
120
- }
121
-
122
- export const CONFIRM_PATH = "/api/newsletter/confirm";
123
-
124
- type Base = Pick<PageContent, "siteName" | "theme">;
125
- const home = (base: Base) => ({ href: "/", label: `Back to ${base.siteName}` });
126
-
127
- export function confirmPromptPage(base: Base, lists: string[], token: string): PageContent {
128
- return {
129
- ...base,
130
- heading: "Confirm your subscription",
131
- paragraphs: [`Press the button to start getting email from ${base.siteName}.${lists.length > 0 ? " You are signing up for:" : ""}`],
132
- lists,
133
- forms: [{ action: CONFIRM_PATH, hidden: { t: token }, buttons: [{ label: "Confirm subscription" }] }],
134
- };
135
- }
136
-
137
- export function confirmedPage(base: Base, lists: string[], preferencesHref?: string): PageContent {
138
- return {
139
- ...base,
140
- heading: "You are subscribed",
141
- paragraphs: [lists.length > 0 ? "Thank you. You will get:" : "Thank you. Your subscription is confirmed."],
142
- lists,
143
- links: [...(preferencesHref ? [{ href: preferencesHref, label: "Manage your subscriptions" }] : []), home(base)],
144
- };
145
- }
146
-
147
- export function expiredPage(base: Base): PageContent {
148
- return {
149
- ...base,
150
- heading: "This link has expired",
151
- paragraphs: [
152
- "This confirmation link has expired or was already used. If you already confirmed, you are all set.",
153
- "If you did not finish signing up, go back to the site and sign up again to get a new link.",
154
- ],
155
- links: [home(base)],
156
- };
157
- }
158
-
159
- export function unavailablePage(base: Base): PageContent {
160
- return {
161
- ...base,
162
- heading: "Something went wrong",
163
- paragraphs: ["We could not complete this just now. Open the link in your email again in a minute."],
164
- links: [home(base)],
165
- };
166
- }
167
-
168
- export const UNSUBSCRIBE_PATH = "/api/newsletter/unsubscribe";
169
- export const PREFERENCES_PATH = "/api/newsletter/preferences";
170
-
171
- export function preferencesHref(token: string): string {
172
- return `${PREFERENCES_PATH}?t=${encodeURIComponent(token)}`;
173
- }
174
-
175
- export function invalidManagePage(base: Base): PageContent {
176
- return {
177
- ...base,
178
- heading: "This link is not valid",
179
- paragraphs: ["This link is not valid, or the subscription it belongs to has been deleted. Use the link in a recent email from us."],
180
- links: [home(base)],
181
- };
182
- }
183
-
184
- export function unsubscribePromptPage(base: Base, token: string, listId: string | null, listName: string | null): PageContent {
185
- return {
186
- ...base,
187
- heading: "Unsubscribe",
188
- paragraphs: [listName ? `Stop getting "${listName}" from ${base.siteName}?` : `Stop getting all email from ${base.siteName}?`],
189
- forms: [
190
- {
191
- action: UNSUBSCRIBE_PATH,
192
- hidden: { t: token, ...(listId ? { l: listId } : {}), page: "1" },
193
- buttons: [{ label: listName ? "Unsubscribe from this list" : "Unsubscribe from everything" }],
194
- },
195
- ],
196
- links: [{ href: preferencesHref(token), label: "Choose which lists you get instead" }],
197
- };
198
- }
199
-
200
- export function unsubscribedPage(base: Base, names: string[], token: string | null): PageContent {
201
- return {
202
- ...base,
203
- heading: "You are unsubscribed",
204
- paragraphs: [names.length > 0 ? `You will no longer get these from ${base.siteName}:` : `You will no longer get this email from ${base.siteName}.`],
205
- lists: names,
206
- links: [...(token ? [{ href: preferencesHref(token), label: "Change your mind? Manage your subscriptions" }] : []), home(base)],
207
- };
208
- }
209
-
210
- export type PreferenceList = { id: string; name: string; description: string; subscribed: boolean };
211
-
212
- export function preferencesPage(base: Base, input: { token: string; email: string; lists: PreferenceList[]; notice?: string }): PageContent {
213
- return {
214
- ...base,
215
- heading: "Your subscriptions",
216
- notice: input.notice,
217
- paragraphs: [`Email preferences for ${input.email}. Tick the lists you want.`],
218
- forms: [
219
- {
220
- action: PREFERENCES_PATH,
221
- hidden: { t: input.token },
222
- checkboxes: input.lists.map((list) => ({
223
- name: "l",
224
- value: list.id,
225
- label: list.description ? `${list.name}: ${list.description}` : list.name,
226
- checked: list.subscribed,
227
- })),
228
- buttons: [
229
- { label: "Save preferences", name: "action", value: "save" },
230
- { label: "Unsubscribe from everything", name: "action", value: "unsubscribe_all", secondary: true },
231
- { label: "Delete my data", name: "action", value: "delete", secondary: true },
232
- ],
233
- },
234
- ],
235
- links: [home(base)],
236
- };
237
- }
238
-
239
- export function deleteConfirmPage(base: Base, token: string): PageContent {
240
- return {
241
- ...base,
242
- heading: "Delete your data?",
243
- paragraphs: [
244
- `This erases your address and everything ${base.siteName} holds about your subscriptions. You will not get any more email. This cannot be undone.`,
245
- ],
246
- forms: [
247
- {
248
- action: PREFERENCES_PATH,
249
- hidden: { t: token, action: "delete", confirm: "1" },
250
- buttons: [{ label: "Yes, delete my data" }],
251
- },
252
- ],
253
- links: [{ href: preferencesHref(token), label: "No, go back" }],
254
- };
255
- }
256
-
257
- export function deletedPage(base: Base): PageContent {
258
- return {
259
- ...base,
260
- heading: "Your data is deleted",
261
- paragraphs: ["Your address and subscriptions have been erased. You will not get any more email from us."],
262
- links: [home(base)],
263
- };
264
- }