@anthusai/papyrus 2.0.0-next.50 → 2.0.0-next.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -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
|
-
|
|
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":
|
|
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
|
-
{"
|
|
27
|
+
{"verdict": "allow" or "hide", "reason": "one plain sentence, at most 200 characters, for the human who reads the log"}
|
package/lib/newsletter-api.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
227
|
-
if (!plugin.functionName) return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
253
|
-
if (!plugin.functionName) return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ?
|
|
363
|
+
const rejected = () => (fromPage ? reply(invalidManagePage(base), 400) : textResponse("Invalid link.", 400));
|
|
321
364
|
if (!token) return rejected();
|
|
322
|
-
if (!plugin.functionName) return fromPage ?
|
|
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
|
|
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 ?
|
|
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
|
|
347
|
-
if (!plugin.functionName) return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
368
|
-
if (!plugin.functionName) return
|
|
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
|
|
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
|
|
384
|
-
if (status !== "saved" && status !== "unsubscribed") return
|
|
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
|
|
433
|
+
return reply(unavailablePage(base), 502);
|
|
390
434
|
}
|
|
391
435
|
}
|
package/lib/newsletter-pages.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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="
|
|
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 ?? "")}"` : ""}
|
|
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
|
-
|
|
78
|
-
|
|
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"
|
|
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
|
-
.
|
|
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
|
|
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
|
-
|
|
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": "
|
|
201
|
+
"referrer-policy": "same-origin",
|
|
114
202
|
"x-content-type-options": "nosniff",
|
|
115
|
-
"
|
|
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
|
-
|
|
119
|
-
|
|
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
package/routes.manifest.json
CHANGED