@anthusai/papyrus 2.0.0-next.52 → 2.0.0-next.54

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,
@@ -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,31 +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
- crossSitePage,
9
- deleteConfirmPage,
10
- deletedPage,
11
- expiredPage,
12
- invalidManagePage,
13
- loadShell,
14
- pageResponse,
15
- pageTheme,
16
- preferencesHref,
17
- preferencesPage,
18
- unavailablePage,
19
- unsubscribePromptPage,
20
- unsubscribedPage,
21
- type PageContent,
22
- type PreferenceList,
23
- type ShellFetcher,
24
- } from "./newsletter-pages";
25
4
  import { PROSPECTUS_SESSION_COOKIE, loadProspectusSessionSealingSecret } from "./prospectus-session";
26
5
  import { signedInProspectusSession } from "./prospectus-sign-in-routes";
27
6
  import { readerSubject } from "./reader-subject";
28
- import { SiteOriginNotConfiguredError, publicSiteOrigin, publicSiteUrl, siteOriginNotConfiguredResponse } from "./site-origin";
7
+ import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
29
8
 
30
9
  export const NEWSLETTER_FUNCTION_ENV = "PAPYRUS_NEWSLETTER_FUNCTION";
31
10
  export const NEWSLETTER_SUBSCRIBE_MAXIMUM_BODY_BYTES = 8192;
@@ -49,10 +28,6 @@ export interface NewsletterApiOptions {
49
28
  outputs?: Record<string, unknown> | null;
50
29
  loadSealingSecret?: () => Promise<string | null>;
51
30
  nowInSeconds?: number;
52
- /** Fetches the site's chrome shell; the global fetch when absent. */
53
- fetchShell?: ShellFetcher;
54
- /** Milliseconds, for the shell cache; Date.now when absent. */
55
- now?: () => number;
56
31
  }
57
32
 
58
33
  const NO_STORE = { "cache-control": "no-store" };
@@ -176,7 +151,7 @@ export function isSameSiteRequest(headers: Pick<Headers, "get">, siteOrigin: str
176
151
  }
177
152
  }
178
153
 
179
- /** The edge turns an origin answer of 403 or 404 into the site's 404 page, so user-facing failures here answer 400. */
154
+ /** The edge turns an origin answer of 403 or 404 into the site's 404 page, so failures here answer 400. */
180
155
  const CROSS_SITE = { status: 400 } as const;
181
156
 
182
157
  /** POST /api/newsletter/subscribe */
@@ -223,134 +198,116 @@ export async function handleNewsletterSubscribe(request: NextRequest, options: N
223
198
  }
224
199
  }
225
200
 
226
- function pageBase(plugin: Enabled): Pick<PageContent, "siteName" | "theme"> {
227
- const fromName = plugin.settings.fromName;
228
- 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}` : "";
229
205
  }
230
206
 
231
- /** Sends a page inside the site's chrome shell, or as the self-contained page when the shell is not available. */
232
- function replier(request: NextRequest, options: NewsletterApiOptions, environment: Record<string, string | undefined>) {
233
- return async (page: PageContent, status = 200): Promise<NextResponse> => {
234
- let shell: string | null = null;
235
- try {
236
- shell = await loadShell(publicSiteOrigin(request, environment), { fetchShell: options.fetchShell, now: options.now });
237
- } catch {
238
- shell = null;
239
- }
240
- return pageResponse(page, status, shell);
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
+ },
241
226
  };
242
227
  }
243
228
 
244
- function configuredListNames(plugin: Enabled, ids: string[]): string[] {
229
+ function configuredLists(plugin: Enabled, ids: string[]): Array<{ id: string; name: string }> {
245
230
  const known = Array.isArray(plugin.settings.lists) ? (plugin.settings.lists as Array<{ id?: unknown; name?: unknown }>) : [];
246
231
  return ids.flatMap((id) => {
247
232
  const match = known.find((entry) => entry.id === id);
248
- return typeof match?.name === "string" ? [match.name] : [];
233
+ return typeof match?.name === "string" ? [{ id, name: match.name }] : [];
249
234
  });
250
235
  }
251
236
 
252
- /** GET /api/newsletter/confirm?t=... shows the confirm button and never changes state; ?done=1&l=... shows the result. */
253
- export async function handleNewsletterConfirmPage(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
254
- const environment = options.environment ?? process.env;
255
- const plugin = enabledPlugin(options, environment);
256
- if (!plugin) return notFoundPage();
257
- const base = pageBase(plugin);
258
- const reply = replier(request, options, environment);
259
- const params = request.nextUrl.searchParams;
260
- if (params.get("done") === "1") {
261
- const manage = params.get("m") ?? "";
262
- return reply(
263
- confirmedPage(base, configuredListNames(plugin, listIds(params.get("l") ?? "")), NEWSLETTER_TOKEN.test(manage) ? preferencesHref(manage) : undefined),
264
- );
265
- }
266
- const token = params.get("t") ?? "";
267
- if (!NEWSLETTER_TOKEN.test(token)) return reply(expiredPage(base), 410);
268
- if (!plugin.functionName) return reply(unavailablePage(base), 503);
269
- try {
270
- const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, { action: "confirm_page_state", token });
271
- if (result.body.status !== "ready") return reply(expiredPage(base), 410);
272
- const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ name?: unknown }>) : [];
273
- const names = lists.flatMap((entry) => (typeof entry.name === "string" ? [entry.name] : []));
274
- return reply(confirmPromptPage(base, names, token));
275
- } catch (error) {
276
- console.error(`Newsletter confirm page failed (${error instanceof Error ? error.name : "error"}).`);
277
- return reply(unavailablePage(base), 502);
278
- }
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" } });
279
243
  }
280
244
 
281
- /** POST /api/newsletter/confirm (form field `t`) confirms, then redirects with 303 to the result page. */
282
- export async function handleNewsletterConfirmPost(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
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] : []));
256
+ }
257
+
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> {
283
264
  const environment = options.environment ?? process.env;
284
265
  const plugin = enabledPlugin(options, environment);
285
266
  if (!plugin) return notFoundPage();
286
- const base = pageBase(plugin);
287
- const reply = replier(request, options, environment);
288
- const site = siteOriginOrResponse(request, environment);
289
- if ("response" in site) return site.response;
290
- if (!isSameSiteRequest(request.headers, site.origin)) return reply(crossSitePage(base), CROSS_SITE.status);
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");
291
274
  const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, ["application/x-www-form-urlencoded"]);
292
275
  if ("response" in body) return body.response;
293
- const token = new URLSearchParams(body.text).get("t") ?? "";
294
- if (!NEWSLETTER_TOKEN.test(token)) return reply(expiredPage(base), 410);
295
- if (!plugin.functionName) return reply(unavailablePage(base), 503);
276
+ const token = manageTokenFrom(new URLSearchParams(body.text).get("t"));
277
+ if (!token || !plugin.functionName) return to.go("expired");
296
278
  try {
297
- const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, {
279
+ const result = await call(options, plugin.functionName, {
298
280
  action: "confirm",
299
281
  token,
300
282
  ip: clientAddress(request.headers),
301
283
  userAgent: limited(request.headers.get("user-agent"), NEWSLETTER_USER_AGENT_CHARACTERS),
302
284
  });
303
- if (result.body.status !== "confirmed") return reply(expiredPage(base), 410);
304
- const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ id?: unknown }>) : [];
305
- const ids = lists.flatMap((entry) => (typeof entry.id === "string" && LIST_ID.test(entry.id) ? [entry.id] : []));
306
- const manage = typeof result.body.manageToken === "string" && NEWSLETTER_TOKEN.test(result.body.manageToken) ? result.body.manageToken : "";
307
- const target = publicSiteUrl(
308
- `/api/newsletter/confirm?done=1&l=${encodeURIComponent(ids.join(","))}${manage ? `&m=${encodeURIComponent(manage)}` : ""}`,
309
- request,
310
- environment,
311
- );
312
- 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 } : {}) });
313
288
  } catch (error) {
314
289
  console.error(`Newsletter confirm failed (${error instanceof Error ? error.name : "error"}).`);
315
- return reply(unavailablePage(base), 502);
290
+ return to.go("expired");
316
291
  }
317
292
  }
318
293
 
319
- function call(options: NewsletterApiOptions, functionName: string, event: NewsletterEvent): Promise<NewsletterResult> {
320
- return (options.invoke ?? invokeNewsletterLambda)(functionName, event);
321
- }
322
-
323
- function textResponse(body: string, status: number): NextResponse {
324
- return new NextResponse(body, { status, headers: { ...NO_STORE, "content-type": "text/plain; charset=utf-8" } });
325
- }
326
-
327
- function manageTokenFrom(...values: Array<string | null>): string {
328
- const found = values.find((value) => value !== null && value !== "") ?? "";
329
- return NEWSLETTER_TOKEN.test(found) ? found : "";
330
- }
331
-
332
- function listParameter(value: string | null): string {
333
- return value !== null && LIST_ID.test(value) ? value : "";
334
- }
335
-
336
294
  /**
337
- * /api/newsletter/unsubscribe. GET shows a page with a button and changes nothing. POST unsubscribes and is idempotent.
338
- * RFC 8058 one-click: mail providers POST `List-Unsubscribe=One-Click` to the link in the message from their own
339
- * 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.
340
299
  */
341
300
  export async function handleNewsletterUnsubscribe(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
342
301
  const environment = options.environment ?? process.env;
343
302
  const plugin = enabledPlugin(options, environment);
344
303
  if (!plugin) return notFoundPage();
345
- const base = pageBase(plugin);
346
- const reply = replier(request, options, environment);
304
+ const to = redirector(request, environment, plugin);
305
+ if ("response" in to) return to.response;
347
306
  const query = request.nextUrl.searchParams;
348
307
  if (request.method === "GET") {
349
308
  const token = manageTokenFrom(query.get("t"));
350
- if (!token) return reply(invalidManagePage(base), 400);
351
309
  const listId = listParameter(query.get("l"));
352
- const listName = listId ? configuredListNames(plugin, [listId])[0] ?? null : null;
353
- return reply(unsubscribePromptPage(base, token, listName ? listId : null, listName));
310
+ return token ? to.go("unsubscribe", { token, ...(listId ? { list: listId } : {}) }, 302) : to.go("expired", {}, 302);
354
311
  }
355
312
  const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, null);
356
313
  if ("response" in body) return body.response;
@@ -360,58 +317,45 @@ export async function handleNewsletterUnsubscribe(request: NextRequest, options:
360
317
  if (!oneClick && !fromPage) return textResponse("Send List-Unsubscribe=One-Click.", 400);
361
318
  const token = manageTokenFrom(query.get("t"), form.get("t"));
362
319
  const listId = listParameter(query.get("l") ?? form.get("l"));
363
- const rejected = () => (fromPage ? reply(invalidManagePage(base), 400) : textResponse("Invalid link.", 400));
320
+ const rejected = () => (fromPage ? to.go("expired") : textResponse("Invalid link.", 400));
364
321
  if (!token) return rejected();
365
- if (!plugin.functionName) return fromPage ? reply(unavailablePage(base), 503) : textResponse("Unavailable.", 503);
322
+ if (!plugin.functionName) return fromPage ? to.go("expired") : textResponse("Unavailable.", 503);
366
323
  try {
367
324
  const result = await call(options, plugin.functionName, { action: "unsubscribe", token, ...(listId ? { list: listId } : {}) });
368
325
  if (result.body.status !== "unsubscribed") return rejected();
369
326
  if (!fromPage) return textResponse("Unsubscribed.", 200);
370
- const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ name?: unknown }>) : [];
371
- const names = lists.flatMap((entry) => (typeof entry.name === "string" ? [entry.name] : []));
372
- return reply(unsubscribedPage(base, names, token));
327
+ return to.go("unsubscribed", { lists: idsOf(result.body.lists).join(","), token });
373
328
  } catch (error) {
374
329
  console.error(`Newsletter unsubscribe failed (${error instanceof Error ? error.name : "error"}).`);
375
- return fromPage ? reply(unavailablePage(base), 502) : textResponse("Try again later.", 502);
330
+ return fromPage ? to.go("expired") : textResponse("Try again later.", 502);
376
331
  }
377
332
  }
378
333
 
379
- 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"]);
380
335
 
381
- /** /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
+ */
382
341
  export async function handleNewsletterPreferences(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
383
342
  const environment = options.environment ?? process.env;
384
343
  const plugin = enabledPlugin(options, environment);
385
344
  if (!plugin) return notFoundPage();
386
- const base = pageBase(plugin);
387
- const reply = replier(request, options, environment);
345
+ const to = redirector(request, environment, plugin);
346
+ if ("response" in to) return to.response;
388
347
  if (request.method === "GET") {
389
348
  const token = manageTokenFrom(request.nextUrl.searchParams.get("t"));
390
- if (!token) return reply(invalidManagePage(base), 400);
391
- if (!plugin.functionName) return reply(unavailablePage(base), 503);
392
- try {
393
- const result = await call(options, plugin.functionName, { action: "preferences_state", token });
394
- if (result.body.status !== "ready") return reply(invalidManagePage(base), 400);
395
- const lists = (Array.isArray(result.body.lists) ? result.body.lists : []) as PreferenceList[];
396
- const email = typeof result.body.email === "string" ? result.body.email : "";
397
- const notice = PREFERENCE_NOTICES[request.nextUrl.searchParams.get("msg") ?? ""];
398
- return reply(preferencesPage(base, { token, email, notice, lists: lists.map(({ id, name, description, subscribed }) => ({ id, name, description, subscribed })) }));
399
- } catch (error) {
400
- console.error(`Newsletter preferences failed (${error instanceof Error ? error.name : "error"}).`);
401
- return reply(unavailablePage(base), 502);
402
- }
349
+ return token ? to.go("preferences", { token }, 302) : to.go("expired", {}, 302);
403
350
  }
404
- const site = siteOriginOrResponse(request, environment);
405
- if ("response" in site) return site.response;
406
- if (!isSameSiteRequest(request.headers, site.origin)) return reply(crossSitePage(base), CROSS_SITE.status);
351
+ if (!isSameSiteRequest(request.headers, to.origin)) return to.go("expired");
407
352
  const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, ["application/x-www-form-urlencoded"]);
408
353
  if ("response" in body) return body.response;
409
354
  const form = new URLSearchParams(body.text);
410
355
  const token = manageTokenFrom(form.get("t"));
411
- if (!token) return reply(invalidManagePage(base), 400);
412
- if (!plugin.functionName) return reply(unavailablePage(base), 503);
356
+ if (!token || !plugin.functionName) return to.go("expired");
413
357
  const action = form.get("action");
414
- if (action === "delete" && form.get("confirm") !== "1") return reply(deleteConfirmPage(base, token));
358
+ if (action === "delete" && form.get("confirm") !== "1") return to.go("preferences", { token, step: "delete" });
415
359
  const operation =
416
360
  action === "save"
417
361
  ? { action: "update_preferences", token, lists: form.getAll("l").slice(0, MAXIMUM_LISTS).filter((id) => LIST_ID.test(id)) }
@@ -422,14 +366,45 @@ export async function handleNewsletterPreferences(request: NextRequest, options:
422
366
  : null;
423
367
  if (!operation) return failure(400, "invalid_action", "Choose save, unsubscribe from everything, or delete.");
424
368
  try {
425
- const result = await call(options, plugin.functionName, operation);
426
- const status = result.body.status;
427
- if (status === "deleted") return reply(deletedPage(base));
428
- if (status !== "saved" && status !== "unsubscribed") return reply(invalidManagePage(base), 400);
429
- const target = publicSiteUrl(`${PREFERENCES_PATH}?t=${encodeURIComponent(token)}&msg=${status}`, request, environment);
430
- 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 });
431
373
  } catch (error) {
432
374
  console.error(`Newsletter preferences save failed (${error instanceof Error ? error.name : "error"}).`);
433
- return reply(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" });
434
409
  }
435
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": "2.0.0-next.52",
436
+ "version": "2.0.0-next.54",
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": "2.0.0-next.52",
2
+ "version": "2.0.0-next.54",
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,364 +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. They are drawn inside the site's own chrome (the projected newsletter shell: the site's head,
6
- * stylesheets, header and footer) and fall back to a self-contained page themed from the plugin's public settings. Every
7
- * dynamic value is escaped, the token never reaches a link, and the pages are never cached or indexed.
8
- */
9
- export type NewsletterPageTheme = { accent: string; text: string; background: string; surface: string; muted: string; fontFamily: string };
10
-
11
- export const DEFAULT_PAGE_THEME: NewsletterPageTheme = {
12
- accent: "#1a4d8f",
13
- text: "#1b1b1b",
14
- background: "#ffffff",
15
- surface: "#f4f4f4",
16
- muted: "#555555",
17
- fontFamily: "Georgia, 'Times New Roman', serif",
18
- };
19
-
20
- const UNSAFE_CSS = /[<>{};"\\`]|url\(|expression\(|@import/i;
21
- const MAXIMUM_LISTS_SHOWN = 20;
22
-
23
- export function escapeHtml(value: string): string {
24
- return value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
25
- }
26
-
27
- /** Theme tokens from `custom.papyrusPlugins.newsletter.theme`; a missing or unsafe value falls back to the default. */
28
- export function pageTheme(raw: unknown): NewsletterPageTheme {
29
- const theme: NewsletterPageTheme = { ...DEFAULT_PAGE_THEME };
30
- if (typeof raw !== "object" || raw === null) return theme;
31
- for (const key of Object.keys(theme) as Array<keyof NewsletterPageTheme>) {
32
- const value = (raw as Record<string, unknown>)[key];
33
- if (typeof value === "string" && value.length > 0 && value.length <= 200 && !UNSAFE_CSS.test(value)) theme[key] = value;
34
- }
35
- return theme;
36
- }
37
-
38
- export type FormSpec = {
39
- action: string;
40
- /** Hidden fields, such as the token. */
41
- hidden: Record<string, string>;
42
- checkboxes?: Array<{ name: string; value: string; label: string; checked: boolean }>;
43
- /** Every button says what it does in words. `secondary` only changes how it looks. */
44
- buttons: Array<{ label: string; name?: string; value?: string; secondary?: boolean }>;
45
- };
46
-
47
- export type PageContent = {
48
- siteName: string;
49
- theme: NewsletterPageTheme;
50
- heading: string;
51
- /** A short status line shown above the text (for example "Saved"), as words. */
52
- notice?: string;
53
- paragraphs: string[];
54
- lists?: string[];
55
- forms?: FormSpec[];
56
- links?: Array<{ href: string; label: string }>;
57
- };
58
-
59
- function renderForm(form: FormSpec): string {
60
- const hidden = Object.entries(form.hidden)
61
- .map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
62
- .join("");
63
- const boxes = (form.checkboxes ?? [])
64
- .map(
65
- (box, index) =>
66
- `<p class="markus-newsletter-list"><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>`,
67
- )
68
- .join("");
69
- const buttons = form.buttons
70
- .map(
71
- (button) =>
72
- `<button type="submit" class="markus-button"${button.secondary ? ' data-variant="secondary"' : ""}${button.name ? ` name="${escapeHtml(button.name)}" value="${escapeHtml(button.value ?? "")}"` : ""}>${escapeHtml(button.label)}</button>`,
73
- )
74
- .join(" ");
75
- return `<form class="markus-newsletter-form" method="post" action="${escapeHtml(form.action)}">${hidden}${boxes}<p class="markus-actions">${buttons}</p></form>`;
76
- }
77
-
78
- /**
79
- * The content of a page, in the standard front end's own classes (`markus-document`, `markus-button`, `markus-actions`), so
80
- * inside the site's chrome it takes the site's typography, colours, buttons and focus rings from the site's stylesheets.
81
- */
82
- export function renderBody(page: PageContent): string {
83
- const lists = (page.lists ?? []).slice(0, MAXIMUM_LISTS_SHOWN);
84
- const links = (page.links ?? []).map((link) => `<p><a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a></p>`).join("\n");
85
- return `<div class="markus-document markus-newsletter-page" data-newsletter-page>
86
- <h1>${escapeHtml(page.heading)}</h1>
87
- ${page.notice ? `<p class="markus-newsletter-notice" role="status">${escapeHtml(page.notice)}</p>` : ""}
88
- ${page.paragraphs.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join("\n")}
89
- ${lists.length > 0 ? `<ul>${lists.map((name) => `<li>${escapeHtml(name)}</li>`).join("")}</ul>` : ""}
90
- ${(page.forms ?? []).map(renderForm).join("\n")}
91
- ${links}
92
- </div>`;
93
- }
94
-
95
- /** The self-contained page, for a site whose chrome shell cannot be fetched. Themed from the plugin's settings. */
96
- export function renderPage(page: PageContent): string {
97
- const { theme } = page;
98
- return `<!doctype html>
99
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
100
- <meta name="robots" content="noindex">
101
- <title>${escapeHtml(page.heading)} - ${escapeHtml(page.siteName)}</title>
102
- <style>
103
- body{margin:0;background:${theme.background};color:${theme.text};font-family:${theme.fontFamily};line-height:1.5}
104
- main{max-width:34rem;margin:0 auto;padding:3rem 1.25rem}
105
- .site{color:${theme.muted};font-size:.95rem;margin:0 0 1.5rem}
106
- h1{font-size:1.6rem;line-height:1.2;margin:0 0 1rem}
107
- .markus-newsletter-notice{background:${theme.surface};border-left:4px solid ${theme.accent};padding:.5rem .75rem;font-weight:bold}
108
- ul{background:${theme.surface};margin:1rem 0;padding:.75rem 1.25rem 1rem 2rem}
109
- .markus-newsletter-list{margin:.6rem 0}
110
- input[type=checkbox]{width:1.1rem;height:1.1rem;vertical-align:middle}
111
- .markus-actions{margin:1.25rem 0}
112
- .markus-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}
113
- .markus-button[data-variant=secondary]{background:${theme.background};color:${theme.accent}}
114
- .markus-button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid ${theme.text};outline-offset:2px}
115
- a{color:${theme.accent}}
116
- </style></head><body><main>
117
- <p class="site">${escapeHtml(page.siteName)}</p>
118
- ${renderBody(page)}
119
- </main></body></html>`;
120
- }
121
-
122
- /** Where the standard projector writes the chrome shell when the newsletter plugin is enabled. */
123
- export const SHELL_PATH = "/_plugins/newsletter-shell/index.html";
124
- export const SHELL_MARKER = "<!--papyrus:newsletter-content-->";
125
- export const SHELL_TTL_MS = 5 * 60 * 1000;
126
- const SHELL_FAILURE_TTL_MS = 30 * 1000;
127
- const SHELL_MAXIMUM_CHARACTERS = 512 * 1024;
128
- const SHELL_TIMEOUT_MS = 3000;
129
-
130
- export type ShellFetcher = (url: string) => Promise<{ status: number; contentType: string; text: string }>;
131
-
132
- export interface ShellOptions {
133
- fetchShell?: ShellFetcher;
134
- /** Milliseconds; for tests. */
135
- now?: () => number;
136
- }
137
-
138
- async function fetchOverHttp(url: string): Promise<{ status: number; contentType: string; text: string }> {
139
- const response = await fetch(url, { headers: { accept: "text/html" }, redirect: "manual", signal: AbortSignal.timeout(SHELL_TIMEOUT_MS) });
140
- return { status: response.status, contentType: response.headers.get("content-type") ?? "", text: await response.text() };
141
- }
142
-
143
- /**
144
- * The shell as a template: the site's own page (full head, header, footer) with one content marker. Scripts are removed
145
- * (these pages run none, and their content security policy forbids them), and the canonical and og:url links, which name
146
- * the shell, are removed. Null when the page is not a usable shell.
147
- */
148
- export function prepareShell(html: string): string | null {
149
- if (html.length > SHELL_MAXIMUM_CHARACTERS || html.split(SHELL_MARKER).length !== 2) return null;
150
- return html
151
- .replace(/<script\b[\s\S]*?<\/script\s*>/gi, "")
152
- .replace(/<link\b[^>]*\brel=["']canonical["'][^>]*>\s*/gi, "")
153
- .replace(/<meta\b[^>]*\bproperty=["']og:url["'][^>]*>\s*/gi, "");
154
- }
155
-
156
- type ShellEntry = { template: string | null; at: number };
157
- const shells = new Map<string, ShellEntry>();
158
- const fetching = new Map<string, Promise<string | null>>();
159
-
160
- export function clearShellCache(): void {
161
- shells.clear();
162
- fetching.clear();
163
- }
164
-
165
- /**
166
- * The prepared shell of the site at `origin`, cached in memory for five minutes. A failed fetch keeps serving the last good
167
- * shell, or answers null (the caller falls back to the self-contained page), and is not retried for 30 seconds.
168
- */
169
- export async function loadShell(origin: string, options: ShellOptions = {}): Promise<string | null> {
170
- const now = (options.now ?? Date.now)();
171
- const cached = shells.get(origin);
172
- if (cached && now - cached.at < (cached.template ? SHELL_TTL_MS : SHELL_FAILURE_TTL_MS)) return cached.template;
173
- const running = fetching.get(origin);
174
- if (running) return running;
175
- const attempt = (async () => {
176
- let template: string | null = null;
177
- try {
178
- const response = await (options.fetchShell ?? fetchOverHttp)(new URL(SHELL_PATH, origin).toString());
179
- if (response.status === 200 && /html/i.test(response.contentType)) template = prepareShell(response.text);
180
- } catch {
181
- template = null;
182
- }
183
- // A failure keeps the last good shell; the retry clock restarts either way.
184
- const kept = template ?? cached?.template ?? null;
185
- shells.set(origin, { template: kept, at: now });
186
- return kept;
187
- })().finally(() => fetching.delete(origin));
188
- fetching.set(origin, attempt);
189
- return attempt;
190
- }
191
-
192
- /** The page inside the site's chrome: the shell's title replaced and its marker replaced by the page content. */
193
- export function renderShellPage(template: string, page: PageContent): string {
194
- const title = `${escapeHtml(page.heading)} - ${escapeHtml(page.siteName)}`;
195
- return template.replace(/<title>[\s\S]*?<\/title>/i, () => `<title>${title}</title>`).replace(SHELL_MARKER, () => renderBody(page));
196
- }
197
-
198
- export const PAGE_HEADERS = {
199
- "content-type": "text/html; charset=utf-8",
200
- "cache-control": "no-store",
201
- "referrer-policy": "same-origin",
202
- "x-content-type-options": "nosniff",
203
- "x-robots-tag": "noindex",
204
- "content-security-policy":
205
- "default-src 'none'; style-src 'self' 'unsafe-inline' https:; font-src 'self' data: https:; img-src 'self' data: https:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
206
- } as const;
207
-
208
- /** `shell` is the prepared shell of the site (see `loadShell`); without one the self-contained page is sent. */
209
- export function pageResponse(page: PageContent, status = 200, shell: string | null = null): NextResponse {
210
- return new NextResponse(shell ? renderShellPage(shell, page) : renderPage(page), { status, headers: PAGE_HEADERS });
211
- }
212
-
213
- export const CONFIRM_PATH = "/api/newsletter/confirm";
214
-
215
- type Base = Pick<PageContent, "siteName" | "theme">;
216
- const home = (base: Base) => ({ href: "/", label: `Back to ${base.siteName}` });
217
-
218
- export function confirmPromptPage(base: Base, lists: string[], token: string): PageContent {
219
- return {
220
- ...base,
221
- heading: "Confirm your subscription",
222
- paragraphs: [`Press the button to start getting email from ${base.siteName}.${lists.length > 0 ? " You are signing up for:" : ""}`],
223
- lists,
224
- forms: [{ action: CONFIRM_PATH, hidden: { t: token }, buttons: [{ label: "Confirm subscription" }] }],
225
- };
226
- }
227
-
228
- export function confirmedPage(base: Base, lists: string[], preferencesHref?: string): PageContent {
229
- return {
230
- ...base,
231
- heading: "You are subscribed",
232
- paragraphs: [lists.length > 0 ? "Thank you. You will get:" : "Thank you. Your subscription is confirmed."],
233
- lists,
234
- links: [...(preferencesHref ? [{ href: preferencesHref, label: "Manage your subscriptions" }] : []), home(base)],
235
- };
236
- }
237
-
238
- export function expiredPage(base: Base): PageContent {
239
- return {
240
- ...base,
241
- heading: "This link has expired",
242
- paragraphs: [
243
- "This confirmation link has expired or was already used. If you already confirmed, you are all set.",
244
- "If you did not finish signing up, go back to the site and sign up again to get a new link.",
245
- ],
246
- links: [home(base)],
247
- };
248
- }
249
-
250
- export function crossSitePage(base: Base): PageContent {
251
- return {
252
- ...base,
253
- heading: "That did not go through",
254
- paragraphs: ["This form was not sent from this site, so nothing was changed. Open the link in your email again and use the button on the page."],
255
- links: [home(base)],
256
- };
257
- }
258
-
259
- export function unavailablePage(base: Base): PageContent {
260
- return {
261
- ...base,
262
- heading: "Something went wrong",
263
- paragraphs: ["We could not complete this just now. Open the link in your email again in a minute."],
264
- links: [home(base)],
265
- };
266
- }
267
-
268
- export const UNSUBSCRIBE_PATH = "/api/newsletter/unsubscribe";
269
- export const PREFERENCES_PATH = "/api/newsletter/preferences";
270
-
271
- export function preferencesHref(token: string): string {
272
- return `${PREFERENCES_PATH}?t=${encodeURIComponent(token)}`;
273
- }
274
-
275
- export function invalidManagePage(base: Base): PageContent {
276
- return {
277
- ...base,
278
- heading: "This link is not valid",
279
- paragraphs: ["This link is not valid, or the subscription it belongs to has been deleted. Use the link in a recent email from us."],
280
- links: [home(base)],
281
- };
282
- }
283
-
284
- export function unsubscribePromptPage(base: Base, token: string, listId: string | null, listName: string | null): PageContent {
285
- return {
286
- ...base,
287
- heading: "Unsubscribe",
288
- paragraphs: [listName ? `Stop getting "${listName}" from ${base.siteName}?` : `Stop getting all email from ${base.siteName}?`],
289
- forms: [
290
- {
291
- action: UNSUBSCRIBE_PATH,
292
- hidden: { t: token, ...(listId ? { l: listId } : {}), page: "1" },
293
- buttons: [{ label: listName ? "Unsubscribe from this list" : "Unsubscribe from everything" }],
294
- },
295
- ],
296
- links: [{ href: preferencesHref(token), label: "Choose which lists you get instead" }],
297
- };
298
- }
299
-
300
- export function unsubscribedPage(base: Base, names: string[], token: string | null): PageContent {
301
- return {
302
- ...base,
303
- heading: "You are unsubscribed",
304
- paragraphs: [names.length > 0 ? `You will no longer get these from ${base.siteName}:` : `You will no longer get this email from ${base.siteName}.`],
305
- lists: names,
306
- links: [...(token ? [{ href: preferencesHref(token), label: "Change your mind? Manage your subscriptions" }] : []), home(base)],
307
- };
308
- }
309
-
310
- export type PreferenceList = { id: string; name: string; description: string; subscribed: boolean };
311
-
312
- export function preferencesPage(base: Base, input: { token: string; email: string; lists: PreferenceList[]; notice?: string }): PageContent {
313
- return {
314
- ...base,
315
- heading: "Your subscriptions",
316
- notice: input.notice,
317
- paragraphs: [`Email preferences for ${input.email}. Tick the lists you want.`],
318
- forms: [
319
- {
320
- action: PREFERENCES_PATH,
321
- hidden: { t: input.token },
322
- checkboxes: input.lists.map((list) => ({
323
- name: "l",
324
- value: list.id,
325
- label: list.description ? `${list.name}: ${list.description}` : list.name,
326
- checked: list.subscribed,
327
- })),
328
- buttons: [
329
- { label: "Save preferences", name: "action", value: "save" },
330
- { label: "Unsubscribe from everything", name: "action", value: "unsubscribe_all", secondary: true },
331
- { label: "Delete my data", name: "action", value: "delete", secondary: true },
332
- ],
333
- },
334
- ],
335
- links: [home(base)],
336
- };
337
- }
338
-
339
- export function deleteConfirmPage(base: Base, token: string): PageContent {
340
- return {
341
- ...base,
342
- heading: "Delete your data?",
343
- paragraphs: [
344
- `This erases your address and everything ${base.siteName} holds about your subscriptions. You will not get any more email. This cannot be undone.`,
345
- ],
346
- forms: [
347
- {
348
- action: PREFERENCES_PATH,
349
- hidden: { t: token, action: "delete", confirm: "1" },
350
- buttons: [{ label: "Yes, delete my data" }],
351
- },
352
- ],
353
- links: [{ href: preferencesHref(token), label: "No, go back" }],
354
- };
355
- }
356
-
357
- export function deletedPage(base: Base): PageContent {
358
- return {
359
- ...base,
360
- heading: "Your data is deleted",
361
- paragraphs: ["Your address and subscriptions have been erased. You will not get any more email from us."],
362
- links: [home(base)],
363
- };
364
- }