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

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.
@@ -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"}
@@ -5,10 +5,12 @@ import {
5
5
  PREFERENCES_PATH,
6
6
  confirmPromptPage,
7
7
  confirmedPage,
8
+ crossSitePage,
8
9
  deleteConfirmPage,
9
10
  deletedPage,
10
11
  expiredPage,
11
12
  invalidManagePage,
13
+ loadShell,
12
14
  pageResponse,
13
15
  pageTheme,
14
16
  preferencesHref,
@@ -18,6 +20,7 @@ import {
18
20
  unsubscribedPage,
19
21
  type PageContent,
20
22
  type PreferenceList,
23
+ type ShellFetcher,
21
24
  } from "./newsletter-pages";
22
25
  import { PROSPECTUS_SESSION_COOKIE, loadProspectusSessionSealingSecret } from "./prospectus-session";
23
26
  import { signedInProspectusSession } from "./prospectus-sign-in-routes";
@@ -46,6 +49,10 @@ export interface NewsletterApiOptions {
46
49
  outputs?: Record<string, unknown> | null;
47
50
  loadSealingSecret?: () => Promise<string | null>;
48
51
  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;
49
56
  }
50
57
 
51
58
  const NO_STORE = { "cache-control": "no-store" };
@@ -152,6 +159,26 @@ function siteOriginOrResponse(request: NextRequest, environment: Record<string,
152
159
  }
153
160
  }
154
161
 
162
+ /**
163
+ * Whether a state-changing request came from the site's own pages: its `Origin` is the public site origin or, when the browser
164
+ * sent none, its `Referer` is on it. `Origin: null` (what a browser sends when the page said `Referrer-Policy: no-referrer`,
165
+ * and what a sandboxed or cross-origin redirect chain produces) is never accepted.
166
+ */
167
+ export function isSameSiteRequest(headers: Pick<Headers, "get">, siteOrigin: string): boolean {
168
+ const origin = headers.get("origin");
169
+ if (origin !== null) return origin === siteOrigin;
170
+ const referer = headers.get("referer");
171
+ if (!referer) return false;
172
+ try {
173
+ return new URL(referer).origin === siteOrigin;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ /** The edge turns an origin answer of 403 or 404 into the site's 404 page, so user-facing failures here answer 400. */
180
+ const CROSS_SITE = { status: 400 } as const;
181
+
155
182
  /** POST /api/newsletter/subscribe */
156
183
  export async function handleNewsletterSubscribe(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
157
184
  const environment = options.environment ?? process.env;
@@ -159,7 +186,7 @@ export async function handleNewsletterSubscribe(request: NextRequest, options: N
159
186
  if (!plugin) return notFoundJson();
160
187
  const site = siteOriginOrResponse(request, environment);
161
188
  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.");
189
+ if (!isSameSiteRequest(request.headers, site.origin)) return failure(CROSS_SITE.status, "origin_mismatch", "This request did not come from this site.");
163
190
  const body = await readBody(request, NEWSLETTER_SUBSCRIBE_MAXIMUM_BODY_BYTES, ["application/json"]);
164
191
  if ("response" in body) return body.response;
165
192
  let payload: Record<string, unknown>;
@@ -201,6 +228,19 @@ function pageBase(plugin: Enabled): Pick<PageContent, "siteName" | "theme"> {
201
228
  return { siteName: typeof fromName === "string" && fromName ? fromName : "this site", theme: pageTheme(plugin.settings.theme) };
202
229
  }
203
230
 
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);
241
+ };
242
+ }
243
+
204
244
  function configuredListNames(plugin: Enabled, ids: string[]): string[] {
205
245
  const known = Array.isArray(plugin.settings.lists) ? (plugin.settings.lists as Array<{ id?: unknown; name?: unknown }>) : [];
206
246
  return ids.flatMap((id) => {
@@ -215,25 +255,26 @@ export async function handleNewsletterConfirmPage(request: NextRequest, options:
215
255
  const plugin = enabledPlugin(options, environment);
216
256
  if (!plugin) return notFoundPage();
217
257
  const base = pageBase(plugin);
258
+ const reply = replier(request, options, environment);
218
259
  const params = request.nextUrl.searchParams;
219
260
  if (params.get("done") === "1") {
220
261
  const manage = params.get("m") ?? "";
221
- return pageResponse(
262
+ return reply(
222
263
  confirmedPage(base, configuredListNames(plugin, listIds(params.get("l") ?? "")), NEWSLETTER_TOKEN.test(manage) ? preferencesHref(manage) : undefined),
223
264
  );
224
265
  }
225
266
  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);
267
+ if (!NEWSLETTER_TOKEN.test(token)) return reply(expiredPage(base), 410);
268
+ if (!plugin.functionName) return reply(unavailablePage(base), 503);
228
269
  try {
229
270
  const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, { action: "confirm_page_state", token });
230
- if (result.body.status !== "ready") return pageResponse(expiredPage(base), 410);
271
+ if (result.body.status !== "ready") return reply(expiredPage(base), 410);
231
272
  const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ name?: unknown }>) : [];
232
273
  const names = lists.flatMap((entry) => (typeof entry.name === "string" ? [entry.name] : []));
233
- return pageResponse(confirmPromptPage(base, names, token));
274
+ return reply(confirmPromptPage(base, names, token));
234
275
  } catch (error) {
235
276
  console.error(`Newsletter confirm page failed (${error instanceof Error ? error.name : "error"}).`);
236
- return pageResponse(unavailablePage(base), 502);
277
+ return reply(unavailablePage(base), 502);
237
278
  }
238
279
  }
239
280
 
@@ -243,14 +284,15 @@ export async function handleNewsletterConfirmPost(request: NextRequest, options:
243
284
  const plugin = enabledPlugin(options, environment);
244
285
  if (!plugin) return notFoundPage();
245
286
  const base = pageBase(plugin);
287
+ const reply = replier(request, options, environment);
246
288
  const site = siteOriginOrResponse(request, environment);
247
289
  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.");
290
+ if (!isSameSiteRequest(request.headers, site.origin)) return reply(crossSitePage(base), CROSS_SITE.status);
249
291
  const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, ["application/x-www-form-urlencoded"]);
250
292
  if ("response" in body) return body.response;
251
293
  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);
294
+ if (!NEWSLETTER_TOKEN.test(token)) return reply(expiredPage(base), 410);
295
+ if (!plugin.functionName) return reply(unavailablePage(base), 503);
254
296
  try {
255
297
  const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, {
256
298
  action: "confirm",
@@ -258,7 +300,7 @@ export async function handleNewsletterConfirmPost(request: NextRequest, options:
258
300
  ip: clientAddress(request.headers),
259
301
  userAgent: limited(request.headers.get("user-agent"), NEWSLETTER_USER_AGENT_CHARACTERS),
260
302
  });
261
- if (result.body.status !== "confirmed") return pageResponse(expiredPage(base), 410);
303
+ if (result.body.status !== "confirmed") return reply(expiredPage(base), 410);
262
304
  const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ id?: unknown }>) : [];
263
305
  const ids = lists.flatMap((entry) => (typeof entry.id === "string" && LIST_ID.test(entry.id) ? [entry.id] : []));
264
306
  const manage = typeof result.body.manageToken === "string" && NEWSLETTER_TOKEN.test(result.body.manageToken) ? result.body.manageToken : "";
@@ -270,7 +312,7 @@ export async function handleNewsletterConfirmPost(request: NextRequest, options:
270
312
  return NextResponse.redirect(target, { status: 303, headers: { ...NO_STORE, "referrer-policy": "no-referrer" } });
271
313
  } catch (error) {
272
314
  console.error(`Newsletter confirm failed (${error instanceof Error ? error.name : "error"}).`);
273
- return pageResponse(unavailablePage(base), 502);
315
+ return reply(unavailablePage(base), 502);
274
316
  }
275
317
  }
276
318
 
@@ -301,13 +343,14 @@ export async function handleNewsletterUnsubscribe(request: NextRequest, options:
301
343
  const plugin = enabledPlugin(options, environment);
302
344
  if (!plugin) return notFoundPage();
303
345
  const base = pageBase(plugin);
346
+ const reply = replier(request, options, environment);
304
347
  const query = request.nextUrl.searchParams;
305
348
  if (request.method === "GET") {
306
349
  const token = manageTokenFrom(query.get("t"));
307
- if (!token) return pageResponse(invalidManagePage(base), 400);
350
+ if (!token) return reply(invalidManagePage(base), 400);
308
351
  const listId = listParameter(query.get("l"));
309
352
  const listName = listId ? configuredListNames(plugin, [listId])[0] ?? null : null;
310
- return pageResponse(unsubscribePromptPage(base, token, listName ? listId : null, listName));
353
+ return reply(unsubscribePromptPage(base, token, listName ? listId : null, listName));
311
354
  }
312
355
  const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, null);
313
356
  if ("response" in body) return body.response;
@@ -317,19 +360,19 @@ export async function handleNewsletterUnsubscribe(request: NextRequest, options:
317
360
  if (!oneClick && !fromPage) return textResponse("Send List-Unsubscribe=One-Click.", 400);
318
361
  const token = manageTokenFrom(query.get("t"), form.get("t"));
319
362
  const listId = listParameter(query.get("l") ?? form.get("l"));
320
- const rejected = () => (fromPage ? pageResponse(invalidManagePage(base), 400) : textResponse("Invalid link.", 400));
363
+ const rejected = () => (fromPage ? reply(invalidManagePage(base), 400) : textResponse("Invalid link.", 400));
321
364
  if (!token) return rejected();
322
- if (!plugin.functionName) return fromPage ? pageResponse(unavailablePage(base), 503) : textResponse("Unavailable.", 503);
365
+ if (!plugin.functionName) return fromPage ? reply(unavailablePage(base), 503) : textResponse("Unavailable.", 503);
323
366
  try {
324
367
  const result = await call(options, plugin.functionName, { action: "unsubscribe", token, ...(listId ? { list: listId } : {}) });
325
368
  if (result.body.status !== "unsubscribed") return rejected();
326
369
  if (!fromPage) return textResponse("Unsubscribed.", 200);
327
370
  const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ name?: unknown }>) : [];
328
371
  const names = lists.flatMap((entry) => (typeof entry.name === "string" ? [entry.name] : []));
329
- return pageResponse(unsubscribedPage(base, names, token));
372
+ return reply(unsubscribedPage(base, names, token));
330
373
  } catch (error) {
331
374
  console.error(`Newsletter unsubscribe failed (${error instanceof Error ? error.name : "error"}).`);
332
- return fromPage ? pageResponse(unavailablePage(base), 502) : textResponse("Try again later.", 502);
375
+ return fromPage ? reply(unavailablePage(base), 502) : textResponse("Try again later.", 502);
333
376
  }
334
377
  }
335
378
 
@@ -341,33 +384,34 @@ export async function handleNewsletterPreferences(request: NextRequest, options:
341
384
  const plugin = enabledPlugin(options, environment);
342
385
  if (!plugin) return notFoundPage();
343
386
  const base = pageBase(plugin);
387
+ const reply = replier(request, options, environment);
344
388
  if (request.method === "GET") {
345
389
  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);
390
+ if (!token) return reply(invalidManagePage(base), 400);
391
+ if (!plugin.functionName) return reply(unavailablePage(base), 503);
348
392
  try {
349
393
  const result = await call(options, plugin.functionName, { action: "preferences_state", token });
350
- if (result.body.status !== "ready") return pageResponse(invalidManagePage(base), 400);
394
+ if (result.body.status !== "ready") return reply(invalidManagePage(base), 400);
351
395
  const lists = (Array.isArray(result.body.lists) ? result.body.lists : []) as PreferenceList[];
352
396
  const email = typeof result.body.email === "string" ? result.body.email : "";
353
397
  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 })) }));
398
+ return reply(preferencesPage(base, { token, email, notice, lists: lists.map(({ id, name, description, subscribed }) => ({ id, name, description, subscribed })) }));
355
399
  } catch (error) {
356
400
  console.error(`Newsletter preferences failed (${error instanceof Error ? error.name : "error"}).`);
357
- return pageResponse(unavailablePage(base), 502);
401
+ return reply(unavailablePage(base), 502);
358
402
  }
359
403
  }
360
404
  const site = siteOriginOrResponse(request, environment);
361
405
  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.");
406
+ if (!isSameSiteRequest(request.headers, site.origin)) return reply(crossSitePage(base), CROSS_SITE.status);
363
407
  const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, ["application/x-www-form-urlencoded"]);
364
408
  if ("response" in body) return body.response;
365
409
  const form = new URLSearchParams(body.text);
366
410
  const token = manageTokenFrom(form.get("t"));
367
- if (!token) return pageResponse(invalidManagePage(base), 400);
368
- if (!plugin.functionName) return pageResponse(unavailablePage(base), 503);
411
+ if (!token) return reply(invalidManagePage(base), 400);
412
+ if (!plugin.functionName) return reply(unavailablePage(base), 503);
369
413
  const action = form.get("action");
370
- if (action === "delete" && form.get("confirm") !== "1") return pageResponse(deleteConfirmPage(base, token));
414
+ if (action === "delete" && form.get("confirm") !== "1") return reply(deleteConfirmPage(base, token));
371
415
  const operation =
372
416
  action === "save"
373
417
  ? { action: "update_preferences", token, lists: form.getAll("l").slice(0, MAXIMUM_LISTS).filter((id) => LIST_ID.test(id)) }
@@ -380,12 +424,12 @@ export async function handleNewsletterPreferences(request: NextRequest, options:
380
424
  try {
381
425
  const result = await call(options, plugin.functionName, operation);
382
426
  const status = result.body.status;
383
- if (status === "deleted") return pageResponse(deletedPage(base));
384
- if (status !== "saved" && status !== "unsubscribed") return pageResponse(invalidManagePage(base), 400);
427
+ if (status === "deleted") return reply(deletedPage(base));
428
+ if (status !== "saved" && status !== "unsubscribed") return reply(invalidManagePage(base), 400);
385
429
  const target = publicSiteUrl(`${PREFERENCES_PATH}?t=${encodeURIComponent(token)}&msg=${status}`, request, environment);
386
430
  return NextResponse.redirect(target, { status: 303, headers: { ...NO_STORE, "referrer-policy": "no-referrer" } });
387
431
  } catch (error) {
388
432
  console.error(`Newsletter preferences save failed (${error instanceof Error ? error.name : "error"}).`);
389
- return pageResponse(unavailablePage(base), 502);
433
+ return reply(unavailablePage(base), 502);
390
434
  }
391
435
  }
@@ -2,8 +2,9 @@ import { NextResponse } from "next/server";
2
2
 
3
3
  /**
4
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.
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.
7
8
  */
8
9
  export type NewsletterPageTheme = { accent: string; text: string; background: string; surface: string; muted: string; fontFamily: string };
9
10
 
@@ -62,61 +63,151 @@ function renderForm(form: FormSpec): string {
62
63
  const boxes = (form.checkboxes ?? [])
63
64
  .map(
64
65
  (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
+ `<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>`,
66
67
  )
67
68
  .join("");
68
69
  const buttons = form.buttons
69
70
  .map(
70
71
  (button) =>
71
- `<button type="submit"${button.name ? ` name="${escapeHtml(button.name)}" value="${escapeHtml(button.value ?? "")}"` : ""}${button.secondary ? ' class="secondary"' : ""}>${escapeHtml(button.label)}</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>`,
72
73
  )
73
74
  .join(" ");
74
- return `<form method="post" action="${escapeHtml(form.action)}">${hidden}${boxes}<p class="actions">${buttons}</p></form>`;
75
+ return `<form class="markus-newsletter-form" method="post" action="${escapeHtml(form.action)}">${hidden}${boxes}<p class="markus-actions">${buttons}</p></form>`;
75
76
  }
76
77
 
77
- export function renderPage(page: PageContent): string {
78
- const { theme } = page;
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 {
79
83
  const lists = (page.lists ?? []).slice(0, MAXIMUM_LISTS_SHOWN);
80
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;
81
98
  return `<!doctype html>
82
99
  <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">
100
+ <meta name="robots" content="noindex">
84
101
  <title>${escapeHtml(page.heading)} - ${escapeHtml(page.siteName)}</title>
85
102
  <style>
86
103
  body{margin:0;background:${theme.background};color:${theme.text};font-family:${theme.fontFamily};line-height:1.5}
87
104
  main{max-width:34rem;margin:0 auto;padding:3rem 1.25rem}
88
105
  .site{color:${theme.muted};font-size:.95rem;margin:0 0 1.5rem}
89
106
  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}
107
+ .markus-newsletter-notice{background:${theme.surface};border-left:4px solid ${theme.accent};padding:.5rem .75rem;font-weight:bold}
91
108
  ul{background:${theme.surface};margin:1rem 0;padding:.75rem 1.25rem 1rem 2rem}
92
- .box{margin:.6rem 0}
109
+ .markus-newsletter-list{margin:.6rem 0}
93
110
  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}
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}
98
115
  a{color:${theme.accent}}
99
116
  </style></head><body><main>
100
117
  <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}
118
+ ${renderBody(page)}
107
119
  </main></body></html>`;
108
120
  }
109
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
+
110
198
  export const PAGE_HEADERS = {
111
199
  "content-type": "text/html; charset=utf-8",
112
200
  "cache-control": "no-store",
113
- "referrer-policy": "no-referrer",
201
+ "referrer-policy": "same-origin",
114
202
  "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'",
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'",
116
206
  } as const;
117
207
 
118
- export function pageResponse(page: PageContent, status = 200): NextResponse {
119
- return new NextResponse(renderPage(page), { status, headers: PAGE_HEADERS });
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 });
120
211
  }
121
212
 
122
213
  export const CONFIRM_PATH = "/api/newsletter/confirm";
@@ -156,6 +247,15 @@ export function expiredPage(base: Base): PageContent {
156
247
  };
157
248
  }
158
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
+
159
259
  export function unavailablePage(base: Base): PageContent {
160
260
  return {
161
261
  ...base,
package/package.json CHANGED
@@ -433,7 +433,7 @@
433
433
  "optional": true
434
434
  }
435
435
  },
436
- "version": "2.0.0-next.50",
436
+ "version": "2.0.0-next.52",
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.50",
2
+ "version": "2.0.0-next.52",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",