@anthusai/papyrus 2.0.0-next.41 → 2.0.0-next.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/content-actions/handler.py +1 -1
- package/amplify/functions/shared/editorial-context.ts +59 -6
- package/amplify/plugins/newsletter/config.js +195 -0
- package/amplify/plugins/newsletter/config.ts +240 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +246 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +291 -0
- package/amplify/plugins/newsletter/schema.js +37 -0
- package/amplify/plugins/newsletter/schema.ts +42 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +139 -0
- package/amplify/plugins/newsletter/sending.ts +185 -0
- package/amplify/plugins/reader-steering/config.js +55 -3
- package/amplify/plugins/reader-steering/config.ts +98 -3
- package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
- package/amplify/plugins/reader-steering/function/handler.py +829 -10
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/resource.js +1 -1
- package/amplify/plugins/reader-steering/function/resource.ts +1 -1
- package/amplify/plugins/reader-steering/papyrus.plugin.js +78 -6
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +85 -6
- package/amplify/plugins/reader-steering/schema.js +101 -1
- package/amplify/plugins/reader-steering/schema.ts +101 -1
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/plugins/types.ts +2 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/components/moderation-panel.tsx +248 -0
- package/components/topic-steering-workspace.tsx +19 -1
- package/lib/editorial-checks.ts +3 -1
- package/lib/newsletter-api.ts +391 -0
- package/lib/newsletter-pages.ts +264 -0
- package/lib/reader-api.ts +7 -5
- package/lib/reader-moderation.ts +220 -0
- package/package.json +14 -1
- package/routes.manifest.json +103 -1
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
3
|
+
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
4
|
+
import {
|
|
5
|
+
PREFERENCES_PATH,
|
|
6
|
+
confirmPromptPage,
|
|
7
|
+
confirmedPage,
|
|
8
|
+
deleteConfirmPage,
|
|
9
|
+
deletedPage,
|
|
10
|
+
expiredPage,
|
|
11
|
+
invalidManagePage,
|
|
12
|
+
pageResponse,
|
|
13
|
+
pageTheme,
|
|
14
|
+
preferencesHref,
|
|
15
|
+
preferencesPage,
|
|
16
|
+
unavailablePage,
|
|
17
|
+
unsubscribePromptPage,
|
|
18
|
+
unsubscribedPage,
|
|
19
|
+
type PageContent,
|
|
20
|
+
type PreferenceList,
|
|
21
|
+
} from "./newsletter-pages";
|
|
22
|
+
import { PROSPECTUS_SESSION_COOKIE, loadProspectusSessionSealingSecret } from "./prospectus-session";
|
|
23
|
+
import { signedInProspectusSession } from "./prospectus-sign-in-routes";
|
|
24
|
+
import { readerSubject } from "./reader-subject";
|
|
25
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin, publicSiteUrl, siteOriginNotConfiguredResponse } from "./site-origin";
|
|
26
|
+
|
|
27
|
+
export const NEWSLETTER_FUNCTION_ENV = "PAPYRUS_NEWSLETTER_FUNCTION";
|
|
28
|
+
export const NEWSLETTER_SUBSCRIBE_MAXIMUM_BODY_BYTES = 8192;
|
|
29
|
+
export const NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES = 4096;
|
|
30
|
+
export const NEWSLETTER_USER_AGENT_CHARACTERS = 300;
|
|
31
|
+
export const NEWSLETTER_TOKEN = /^[A-Za-z0-9._-]{1,2048}$/;
|
|
32
|
+
const MAXIMUM_LISTS = 20;
|
|
33
|
+
const LIST_ID = /^[a-z][a-z0-9-]{0,39}$/;
|
|
34
|
+
|
|
35
|
+
export type NewsletterEvent = { action: string } & Record<string, unknown>;
|
|
36
|
+
export interface NewsletterResult {
|
|
37
|
+
statusCode: number;
|
|
38
|
+
body: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
export type NewsletterInvoker = (functionName: string, event: NewsletterEvent) => Promise<NewsletterResult>;
|
|
41
|
+
|
|
42
|
+
export interface NewsletterApiOptions {
|
|
43
|
+
environment?: Record<string, string | undefined>;
|
|
44
|
+
invoke?: NewsletterInvoker;
|
|
45
|
+
/** The parsed `amplify_outputs.json`; read from disk when absent. */
|
|
46
|
+
outputs?: Record<string, unknown> | null;
|
|
47
|
+
loadSealingSecret?: () => Promise<string | null>;
|
|
48
|
+
nowInSeconds?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
52
|
+
|
|
53
|
+
function failure(status: number, error: string, message: string): NextResponse {
|
|
54
|
+
return NextResponse.json({ error, message }, { status, headers: NO_STORE });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function invokeNewsletterLambda(functionName: string, event: NewsletterEvent): Promise<NewsletterResult> {
|
|
58
|
+
const { InvokeCommand, LambdaClient } = await import("@aws-sdk/client-lambda");
|
|
59
|
+
const response = await new LambdaClient({}).send(
|
|
60
|
+
new InvokeCommand({ FunctionName: functionName, InvocationType: "RequestResponse", Payload: Buffer.from(JSON.stringify(event)) }),
|
|
61
|
+
);
|
|
62
|
+
if (response.FunctionError || !response.Payload) throw new Error("newsletter failed");
|
|
63
|
+
const result = JSON.parse(Buffer.from(response.Payload).toString("utf8")) as Partial<NewsletterResult>;
|
|
64
|
+
if (typeof result.statusCode !== "number" || typeof result.body !== "object" || result.body === null) {
|
|
65
|
+
throw new Error("newsletter returned an unexpected shape");
|
|
66
|
+
}
|
|
67
|
+
return { statusCode: result.statusCode, body: result.body as Record<string, unknown> };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Enabled = { settings: Record<string, unknown>; functionName: string | null };
|
|
71
|
+
|
|
72
|
+
function enabledPlugin(options: NewsletterApiOptions, environment: Record<string, string | undefined>): Enabled | null {
|
|
73
|
+
let outputs: Record<string, unknown> | null;
|
|
74
|
+
try {
|
|
75
|
+
outputs = options.outputs !== undefined ? options.outputs : readAmplifyOutputsFile();
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const custom = (outputs as { custom?: { papyrusPlugins?: Record<string, unknown>; newsletter?: { actionsFunction?: unknown } } } | null)?.custom;
|
|
80
|
+
const settings = custom?.papyrusPlugins?.newsletter;
|
|
81
|
+
if (typeof settings !== "object" || settings === null) return null;
|
|
82
|
+
const fromEnvironment = (environment[NEWSLETTER_FUNCTION_ENV] ?? "").trim();
|
|
83
|
+
const fromOutputs = custom?.newsletter?.actionsFunction;
|
|
84
|
+
const functionName = fromEnvironment || (typeof fromOutputs === "string" && fromOutputs.trim() ? fromOutputs.trim() : null);
|
|
85
|
+
return { settings: settings as Record<string, unknown>, functionName };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function notFoundJson(): NextResponse {
|
|
89
|
+
return failure(404, "not_found", "Not found.");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function notFoundPage(): NextResponse {
|
|
93
|
+
return new NextResponse("Not found", { status: 404, headers: { ...NO_STORE, "content-type": "text/plain; charset=utf-8" } });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The visitor's address from CloudFront's `ip:port` header (IPv6 may be bracketed), else the first x-forwarded-for entry. */
|
|
97
|
+
export function clientAddress(headers: Pick<Headers, "get">): string {
|
|
98
|
+
const viewer = (headers.get("cloudfront-viewer-address") ?? "").trim();
|
|
99
|
+
if (viewer) {
|
|
100
|
+
let candidate: string;
|
|
101
|
+
if (viewer.startsWith("[")) candidate = viewer.slice(1, Math.max(viewer.indexOf("]"), 1));
|
|
102
|
+
else if (isIP(viewer) === 4) candidate = viewer;
|
|
103
|
+
else candidate = viewer.slice(0, Math.max(viewer.lastIndexOf(":"), 0));
|
|
104
|
+
if (isIP(candidate)) return candidate;
|
|
105
|
+
}
|
|
106
|
+
const forwarded = (headers.get("x-forwarded-for") ?? "").split(",")[0]?.trim() ?? "";
|
|
107
|
+
return isIP(forwarded) ? forwarded : "unknown";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function readBody(request: NextRequest, maximum: number, contentTypes: string[] | null): Promise<{ text: string } | { response: NextResponse }> {
|
|
111
|
+
const contentType = (request.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
112
|
+
if (contentTypes !== null && !contentTypes.includes(contentType)) return { response: failure(415, "unsupported_media_type", `Send ${(contentTypes ?? []).join(" or ")}.`) };
|
|
113
|
+
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
114
|
+
if (Number.isFinite(declared) && declared > maximum) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
115
|
+
const text = await request.text();
|
|
116
|
+
if (Buffer.byteLength(text, "utf8") > maximum) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
117
|
+
return { text };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function listIds(raw: unknown): string[] {
|
|
121
|
+
const entries = typeof raw === "string" ? raw.slice(0, 1000).split(",") : Array.isArray(raw) ? raw : [];
|
|
122
|
+
return entries
|
|
123
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
124
|
+
.map((entry) => entry.trim())
|
|
125
|
+
.filter((entry) => LIST_ID.test(entry))
|
|
126
|
+
.slice(0, MAXIMUM_LISTS);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function limited(value: unknown, maximum: number): string {
|
|
130
|
+
return typeof value === "string" ? value.slice(0, maximum) : "";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The reader's pairwise site subject when they happen to be signed in; never required, never logged, never an error. */
|
|
134
|
+
async function optionalSiteSubject(request: NextRequest, options: NewsletterApiOptions, environment: Record<string, string | undefined>): Promise<string | undefined> {
|
|
135
|
+
if (!request.cookies.has(PROSPECTUS_SESSION_COOKIE)) return undefined;
|
|
136
|
+
try {
|
|
137
|
+
const sealingSecret = await (options.loadSealingSecret ?? (() => loadProspectusSessionSealingSecret({ environment })))();
|
|
138
|
+
if (!sealingSecret) return undefined;
|
|
139
|
+
const session = signedInProspectusSession(request, sealingSecret, options.nowInSeconds);
|
|
140
|
+
return readerSubject(session, environment) ?? undefined;
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function siteOriginOrResponse(request: NextRequest, environment: Record<string, string | undefined>): { origin: string } | { response: NextResponse } {
|
|
147
|
+
try {
|
|
148
|
+
return { origin: publicSiteOrigin(request, environment) };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error instanceof SiteOriginNotConfiguredError) return { response: siteOriginNotConfiguredResponse(error) };
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** POST /api/newsletter/subscribe */
|
|
156
|
+
export async function handleNewsletterSubscribe(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
157
|
+
const environment = options.environment ?? process.env;
|
|
158
|
+
const plugin = enabledPlugin(options, environment);
|
|
159
|
+
if (!plugin) return notFoundJson();
|
|
160
|
+
const site = siteOriginOrResponse(request, environment);
|
|
161
|
+
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.");
|
|
163
|
+
const body = await readBody(request, NEWSLETTER_SUBSCRIBE_MAXIMUM_BODY_BYTES, ["application/json"]);
|
|
164
|
+
if ("response" in body) return body.response;
|
|
165
|
+
let payload: Record<string, unknown>;
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(body.text) as unknown;
|
|
168
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
|
|
169
|
+
payload = parsed as Record<string, unknown>;
|
|
170
|
+
} catch {
|
|
171
|
+
return failure(400, "invalid_json", "The request body is not a JSON object.");
|
|
172
|
+
}
|
|
173
|
+
if (!plugin.functionName) return failure(503, "newsletter_unavailable", "Newsletter signup is not available right now.");
|
|
174
|
+
|
|
175
|
+
const siteSubject = await optionalSiteSubject(request, options, environment);
|
|
176
|
+
const event: NewsletterEvent = {
|
|
177
|
+
action: "subscribe",
|
|
178
|
+
email: limited(payload.email, 320),
|
|
179
|
+
lists: listIds(payload.lists),
|
|
180
|
+
page: limited(payload.page, 500),
|
|
181
|
+
consent: limited(payload.consent, 500),
|
|
182
|
+
formId: limited(payload.formId, 100),
|
|
183
|
+
honeypot: limited(payload.website, 200),
|
|
184
|
+
renderedAt: typeof payload.renderedAt === "number" || typeof payload.renderedAt === "string" ? payload.renderedAt : null,
|
|
185
|
+
ip: clientAddress(request.headers),
|
|
186
|
+
userAgent: limited(request.headers.get("user-agent"), NEWSLETTER_USER_AGENT_CHARACTERS),
|
|
187
|
+
...(siteSubject ? { siteSubject } : {}),
|
|
188
|
+
};
|
|
189
|
+
try {
|
|
190
|
+
const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, event);
|
|
191
|
+
const status = typeof result.body.status === "string" ? result.body.status : "error";
|
|
192
|
+
return NextResponse.json({ status }, { status: result.statusCode, headers: NO_STORE });
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.error(`Newsletter subscribe failed (${error instanceof Error ? error.name : "error"}).`);
|
|
195
|
+
return failure(502, "newsletter_unavailable", "Newsletter signup could not be reached.");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function pageBase(plugin: Enabled): Pick<PageContent, "siteName" | "theme"> {
|
|
200
|
+
const fromName = plugin.settings.fromName;
|
|
201
|
+
return { siteName: typeof fromName === "string" && fromName ? fromName : "this site", theme: pageTheme(plugin.settings.theme) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function configuredListNames(plugin: Enabled, ids: string[]): string[] {
|
|
205
|
+
const known = Array.isArray(plugin.settings.lists) ? (plugin.settings.lists as Array<{ id?: unknown; name?: unknown }>) : [];
|
|
206
|
+
return ids.flatMap((id) => {
|
|
207
|
+
const match = known.find((entry) => entry.id === id);
|
|
208
|
+
return typeof match?.name === "string" ? [match.name] : [];
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** GET /api/newsletter/confirm?t=... shows the confirm button and never changes state; ?done=1&l=... shows the result. */
|
|
213
|
+
export async function handleNewsletterConfirmPage(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
214
|
+
const environment = options.environment ?? process.env;
|
|
215
|
+
const plugin = enabledPlugin(options, environment);
|
|
216
|
+
if (!plugin) return notFoundPage();
|
|
217
|
+
const base = pageBase(plugin);
|
|
218
|
+
const params = request.nextUrl.searchParams;
|
|
219
|
+
if (params.get("done") === "1") {
|
|
220
|
+
const manage = params.get("m") ?? "";
|
|
221
|
+
return pageResponse(
|
|
222
|
+
confirmedPage(base, configuredListNames(plugin, listIds(params.get("l") ?? "")), NEWSLETTER_TOKEN.test(manage) ? preferencesHref(manage) : undefined),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
const token = params.get("t") ?? "";
|
|
226
|
+
if (!NEWSLETTER_TOKEN.test(token)) return pageResponse(expiredPage(base), 410);
|
|
227
|
+
if (!plugin.functionName) return pageResponse(unavailablePage(base), 503);
|
|
228
|
+
try {
|
|
229
|
+
const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, { action: "confirm_page_state", token });
|
|
230
|
+
if (result.body.status !== "ready") return pageResponse(expiredPage(base), 410);
|
|
231
|
+
const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ name?: unknown }>) : [];
|
|
232
|
+
const names = lists.flatMap((entry) => (typeof entry.name === "string" ? [entry.name] : []));
|
|
233
|
+
return pageResponse(confirmPromptPage(base, names, token));
|
|
234
|
+
} catch (error) {
|
|
235
|
+
console.error(`Newsletter confirm page failed (${error instanceof Error ? error.name : "error"}).`);
|
|
236
|
+
return pageResponse(unavailablePage(base), 502);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** POST /api/newsletter/confirm (form field `t`) confirms, then redirects with 303 to the result page. */
|
|
241
|
+
export async function handleNewsletterConfirmPost(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
242
|
+
const environment = options.environment ?? process.env;
|
|
243
|
+
const plugin = enabledPlugin(options, environment);
|
|
244
|
+
if (!plugin) return notFoundPage();
|
|
245
|
+
const base = pageBase(plugin);
|
|
246
|
+
const site = siteOriginOrResponse(request, environment);
|
|
247
|
+
if ("response" in site) return site.response;
|
|
248
|
+
if (request.headers.get("origin") !== site.origin) return failure(403, "origin_mismatch", "This request did not come from this site.");
|
|
249
|
+
const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, ["application/x-www-form-urlencoded"]);
|
|
250
|
+
if ("response" in body) return body.response;
|
|
251
|
+
const token = new URLSearchParams(body.text).get("t") ?? "";
|
|
252
|
+
if (!NEWSLETTER_TOKEN.test(token)) return pageResponse(expiredPage(base), 410);
|
|
253
|
+
if (!plugin.functionName) return pageResponse(unavailablePage(base), 503);
|
|
254
|
+
try {
|
|
255
|
+
const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, {
|
|
256
|
+
action: "confirm",
|
|
257
|
+
token,
|
|
258
|
+
ip: clientAddress(request.headers),
|
|
259
|
+
userAgent: limited(request.headers.get("user-agent"), NEWSLETTER_USER_AGENT_CHARACTERS),
|
|
260
|
+
});
|
|
261
|
+
if (result.body.status !== "confirmed") return pageResponse(expiredPage(base), 410);
|
|
262
|
+
const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ id?: unknown }>) : [];
|
|
263
|
+
const ids = lists.flatMap((entry) => (typeof entry.id === "string" && LIST_ID.test(entry.id) ? [entry.id] : []));
|
|
264
|
+
const manage = typeof result.body.manageToken === "string" && NEWSLETTER_TOKEN.test(result.body.manageToken) ? result.body.manageToken : "";
|
|
265
|
+
const target = publicSiteUrl(
|
|
266
|
+
`/api/newsletter/confirm?done=1&l=${encodeURIComponent(ids.join(","))}${manage ? `&m=${encodeURIComponent(manage)}` : ""}`,
|
|
267
|
+
request,
|
|
268
|
+
environment,
|
|
269
|
+
);
|
|
270
|
+
return NextResponse.redirect(target, { status: 303, headers: { ...NO_STORE, "referrer-policy": "no-referrer" } });
|
|
271
|
+
} catch (error) {
|
|
272
|
+
console.error(`Newsletter confirm failed (${error instanceof Error ? error.name : "error"}).`);
|
|
273
|
+
return pageResponse(unavailablePage(base), 502);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function call(options: NewsletterApiOptions, functionName: string, event: NewsletterEvent): Promise<NewsletterResult> {
|
|
278
|
+
return (options.invoke ?? invokeNewsletterLambda)(functionName, event);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function textResponse(body: string, status: number): NextResponse {
|
|
282
|
+
return new NextResponse(body, { status, headers: { ...NO_STORE, "content-type": "text/plain; charset=utf-8" } });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function manageTokenFrom(...values: Array<string | null>): string {
|
|
286
|
+
const found = values.find((value) => value !== null && value !== "") ?? "";
|
|
287
|
+
return NEWSLETTER_TOKEN.test(found) ? found : "";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function listParameter(value: string | null): string {
|
|
291
|
+
return value !== null && LIST_ID.test(value) ? value : "";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* /api/newsletter/unsubscribe. GET shows a page with a button and changes nothing. POST unsubscribes and is idempotent.
|
|
296
|
+
* RFC 8058 one-click: mail providers POST `List-Unsubscribe=One-Click` to the link in the message from their own
|
|
297
|
+
* servers, with no Origin, so this route has no same-origin check. A POST must carry that marker or `page=1` from our own page.
|
|
298
|
+
*/
|
|
299
|
+
export async function handleNewsletterUnsubscribe(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
300
|
+
const environment = options.environment ?? process.env;
|
|
301
|
+
const plugin = enabledPlugin(options, environment);
|
|
302
|
+
if (!plugin) return notFoundPage();
|
|
303
|
+
const base = pageBase(plugin);
|
|
304
|
+
const query = request.nextUrl.searchParams;
|
|
305
|
+
if (request.method === "GET") {
|
|
306
|
+
const token = manageTokenFrom(query.get("t"));
|
|
307
|
+
if (!token) return pageResponse(invalidManagePage(base), 400);
|
|
308
|
+
const listId = listParameter(query.get("l"));
|
|
309
|
+
const listName = listId ? configuredListNames(plugin, [listId])[0] ?? null : null;
|
|
310
|
+
return pageResponse(unsubscribePromptPage(base, token, listName ? listId : null, listName));
|
|
311
|
+
}
|
|
312
|
+
const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, null);
|
|
313
|
+
if ("response" in body) return body.response;
|
|
314
|
+
const form = new URLSearchParams(body.text);
|
|
315
|
+
const oneClick = form.get("List-Unsubscribe") === "One-Click";
|
|
316
|
+
const fromPage = form.get("page") === "1";
|
|
317
|
+
if (!oneClick && !fromPage) return textResponse("Send List-Unsubscribe=One-Click.", 400);
|
|
318
|
+
const token = manageTokenFrom(query.get("t"), form.get("t"));
|
|
319
|
+
const listId = listParameter(query.get("l") ?? form.get("l"));
|
|
320
|
+
const rejected = () => (fromPage ? pageResponse(invalidManagePage(base), 400) : textResponse("Invalid link.", 400));
|
|
321
|
+
if (!token) return rejected();
|
|
322
|
+
if (!plugin.functionName) return fromPage ? pageResponse(unavailablePage(base), 503) : textResponse("Unavailable.", 503);
|
|
323
|
+
try {
|
|
324
|
+
const result = await call(options, plugin.functionName, { action: "unsubscribe", token, ...(listId ? { list: listId } : {}) });
|
|
325
|
+
if (result.body.status !== "unsubscribed") return rejected();
|
|
326
|
+
if (!fromPage) return textResponse("Unsubscribed.", 200);
|
|
327
|
+
const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ name?: unknown }>) : [];
|
|
328
|
+
const names = lists.flatMap((entry) => (typeof entry.name === "string" ? [entry.name] : []));
|
|
329
|
+
return pageResponse(unsubscribedPage(base, names, token));
|
|
330
|
+
} catch (error) {
|
|
331
|
+
console.error(`Newsletter unsubscribe failed (${error instanceof Error ? error.name : "error"}).`);
|
|
332
|
+
return fromPage ? pageResponse(unavailablePage(base), 502) : textResponse("Try again later.", 502);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const PREFERENCE_NOTICES: Record<string, string> = { saved: "Your preferences are saved.", unsubscribed: "You are unsubscribed from everything." };
|
|
337
|
+
|
|
338
|
+
/** /api/newsletter/preferences: GET shows the lists; POST (same-origin form) saves, unsubscribes from everything, or deletes after a confirm step. */
|
|
339
|
+
export async function handleNewsletterPreferences(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
340
|
+
const environment = options.environment ?? process.env;
|
|
341
|
+
const plugin = enabledPlugin(options, environment);
|
|
342
|
+
if (!plugin) return notFoundPage();
|
|
343
|
+
const base = pageBase(plugin);
|
|
344
|
+
if (request.method === "GET") {
|
|
345
|
+
const token = manageTokenFrom(request.nextUrl.searchParams.get("t"));
|
|
346
|
+
if (!token) return pageResponse(invalidManagePage(base), 400);
|
|
347
|
+
if (!plugin.functionName) return pageResponse(unavailablePage(base), 503);
|
|
348
|
+
try {
|
|
349
|
+
const result = await call(options, plugin.functionName, { action: "preferences_state", token });
|
|
350
|
+
if (result.body.status !== "ready") return pageResponse(invalidManagePage(base), 400);
|
|
351
|
+
const lists = (Array.isArray(result.body.lists) ? result.body.lists : []) as PreferenceList[];
|
|
352
|
+
const email = typeof result.body.email === "string" ? result.body.email : "";
|
|
353
|
+
const notice = PREFERENCE_NOTICES[request.nextUrl.searchParams.get("msg") ?? ""];
|
|
354
|
+
return pageResponse(preferencesPage(base, { token, email, notice, lists: lists.map(({ id, name, description, subscribed }) => ({ id, name, description, subscribed })) }));
|
|
355
|
+
} catch (error) {
|
|
356
|
+
console.error(`Newsletter preferences failed (${error instanceof Error ? error.name : "error"}).`);
|
|
357
|
+
return pageResponse(unavailablePage(base), 502);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const site = siteOriginOrResponse(request, environment);
|
|
361
|
+
if ("response" in site) return site.response;
|
|
362
|
+
if (request.headers.get("origin") !== site.origin) return failure(403, "origin_mismatch", "This request did not come from this site.");
|
|
363
|
+
const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, ["application/x-www-form-urlencoded"]);
|
|
364
|
+
if ("response" in body) return body.response;
|
|
365
|
+
const form = new URLSearchParams(body.text);
|
|
366
|
+
const token = manageTokenFrom(form.get("t"));
|
|
367
|
+
if (!token) return pageResponse(invalidManagePage(base), 400);
|
|
368
|
+
if (!plugin.functionName) return pageResponse(unavailablePage(base), 503);
|
|
369
|
+
const action = form.get("action");
|
|
370
|
+
if (action === "delete" && form.get("confirm") !== "1") return pageResponse(deleteConfirmPage(base, token));
|
|
371
|
+
const operation =
|
|
372
|
+
action === "save"
|
|
373
|
+
? { action: "update_preferences", token, lists: form.getAll("l").slice(0, MAXIMUM_LISTS).filter((id) => LIST_ID.test(id)) }
|
|
374
|
+
: action === "unsubscribe_all"
|
|
375
|
+
? { action: "unsubscribe", token }
|
|
376
|
+
: action === "delete"
|
|
377
|
+
? { action: "delete_subscriber", token }
|
|
378
|
+
: null;
|
|
379
|
+
if (!operation) return failure(400, "invalid_action", "Choose save, unsubscribe from everything, or delete.");
|
|
380
|
+
try {
|
|
381
|
+
const result = await call(options, plugin.functionName, operation);
|
|
382
|
+
const status = result.body.status;
|
|
383
|
+
if (status === "deleted") return pageResponse(deletedPage(base));
|
|
384
|
+
if (status !== "saved" && status !== "unsubscribed") return pageResponse(invalidManagePage(base), 400);
|
|
385
|
+
const target = publicSiteUrl(`${PREFERENCES_PATH}?t=${encodeURIComponent(token)}&msg=${status}`, request, environment);
|
|
386
|
+
return NextResponse.redirect(target, { status: 303, headers: { ...NO_STORE, "referrer-policy": "no-referrer" } });
|
|
387
|
+
} catch (error) {
|
|
388
|
+
console.error(`Newsletter preferences save failed (${error instanceof Error ? error.name : "error"}).`);
|
|
389
|
+
return pageResponse(unavailablePage(base), 502);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The small server-rendered pages of the newsletter plugin (confirm prompt, confirmed, expired).
|
|
5
|
+
* Plain HTML, no scripts, themed from the plugin's public settings. Every dynamic value is escaped, the token never
|
|
6
|
+
* reaches a link, and the pages are never cached or indexed.
|
|
7
|
+
*/
|
|
8
|
+
export type NewsletterPageTheme = { accent: string; text: string; background: string; surface: string; muted: string; fontFamily: string };
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_PAGE_THEME: NewsletterPageTheme = {
|
|
11
|
+
accent: "#1a4d8f",
|
|
12
|
+
text: "#1b1b1b",
|
|
13
|
+
background: "#ffffff",
|
|
14
|
+
surface: "#f4f4f4",
|
|
15
|
+
muted: "#555555",
|
|
16
|
+
fontFamily: "Georgia, 'Times New Roman', serif",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const UNSAFE_CSS = /[<>{};"\\`]|url\(|expression\(|@import/i;
|
|
20
|
+
const MAXIMUM_LISTS_SHOWN = 20;
|
|
21
|
+
|
|
22
|
+
export function escapeHtml(value: string): string {
|
|
23
|
+
return value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Theme tokens from `custom.papyrusPlugins.newsletter.theme`; a missing or unsafe value falls back to the default. */
|
|
27
|
+
export function pageTheme(raw: unknown): NewsletterPageTheme {
|
|
28
|
+
const theme: NewsletterPageTheme = { ...DEFAULT_PAGE_THEME };
|
|
29
|
+
if (typeof raw !== "object" || raw === null) return theme;
|
|
30
|
+
for (const key of Object.keys(theme) as Array<keyof NewsletterPageTheme>) {
|
|
31
|
+
const value = (raw as Record<string, unknown>)[key];
|
|
32
|
+
if (typeof value === "string" && value.length > 0 && value.length <= 200 && !UNSAFE_CSS.test(value)) theme[key] = value;
|
|
33
|
+
}
|
|
34
|
+
return theme;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type FormSpec = {
|
|
38
|
+
action: string;
|
|
39
|
+
/** Hidden fields, such as the token. */
|
|
40
|
+
hidden: Record<string, string>;
|
|
41
|
+
checkboxes?: Array<{ name: string; value: string; label: string; checked: boolean }>;
|
|
42
|
+
/** Every button says what it does in words. `secondary` only changes how it looks. */
|
|
43
|
+
buttons: Array<{ label: string; name?: string; value?: string; secondary?: boolean }>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type PageContent = {
|
|
47
|
+
siteName: string;
|
|
48
|
+
theme: NewsletterPageTheme;
|
|
49
|
+
heading: string;
|
|
50
|
+
/** A short status line shown above the text (for example "Saved"), as words. */
|
|
51
|
+
notice?: string;
|
|
52
|
+
paragraphs: string[];
|
|
53
|
+
lists?: string[];
|
|
54
|
+
forms?: FormSpec[];
|
|
55
|
+
links?: Array<{ href: string; label: string }>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function renderForm(form: FormSpec): string {
|
|
59
|
+
const hidden = Object.entries(form.hidden)
|
|
60
|
+
.map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
|
|
61
|
+
.join("");
|
|
62
|
+
const boxes = (form.checkboxes ?? [])
|
|
63
|
+
.map(
|
|
64
|
+
(box, index) =>
|
|
65
|
+
`<p class="box"><input type="checkbox" id="c${index}" name="${escapeHtml(box.name)}" value="${escapeHtml(box.value)}"${box.checked ? " checked" : ""}> <label for="c${index}">${escapeHtml(box.label)}</label></p>`,
|
|
66
|
+
)
|
|
67
|
+
.join("");
|
|
68
|
+
const buttons = form.buttons
|
|
69
|
+
.map(
|
|
70
|
+
(button) =>
|
|
71
|
+
`<button type="submit"${button.name ? ` name="${escapeHtml(button.name)}" value="${escapeHtml(button.value ?? "")}"` : ""}${button.secondary ? ' class="secondary"' : ""}>${escapeHtml(button.label)}</button>`,
|
|
72
|
+
)
|
|
73
|
+
.join(" ");
|
|
74
|
+
return `<form method="post" action="${escapeHtml(form.action)}">${hidden}${boxes}<p class="actions">${buttons}</p></form>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderPage(page: PageContent): string {
|
|
78
|
+
const { theme } = page;
|
|
79
|
+
const lists = (page.lists ?? []).slice(0, MAXIMUM_LISTS_SHOWN);
|
|
80
|
+
const links = (page.links ?? []).map((link) => `<p><a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a></p>`).join("\n");
|
|
81
|
+
return `<!doctype html>
|
|
82
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
83
|
+
<meta name="robots" content="noindex"><meta name="referrer" content="no-referrer">
|
|
84
|
+
<title>${escapeHtml(page.heading)} - ${escapeHtml(page.siteName)}</title>
|
|
85
|
+
<style>
|
|
86
|
+
body{margin:0;background:${theme.background};color:${theme.text};font-family:${theme.fontFamily};line-height:1.5}
|
|
87
|
+
main{max-width:34rem;margin:0 auto;padding:3rem 1.25rem}
|
|
88
|
+
.site{color:${theme.muted};font-size:.95rem;margin:0 0 1.5rem}
|
|
89
|
+
h1{font-size:1.6rem;line-height:1.2;margin:0 0 1rem}
|
|
90
|
+
.notice{background:${theme.surface};border-left:4px solid ${theme.accent};padding:.5rem .75rem;font-weight:bold}
|
|
91
|
+
ul{background:${theme.surface};margin:1rem 0;padding:.75rem 1.25rem 1rem 2rem}
|
|
92
|
+
.box{margin:.6rem 0}
|
|
93
|
+
input[type=checkbox]{width:1.1rem;height:1.1rem;vertical-align:middle}
|
|
94
|
+
.actions{margin:1.25rem 0}
|
|
95
|
+
button{background:${theme.accent};color:#fff;border:2px solid ${theme.accent};font:inherit;font-weight:bold;padding:.7rem 1.4rem;cursor:pointer;border-radius:4px;margin:0 .5rem .5rem 0}
|
|
96
|
+
button.secondary{background:${theme.background};color:${theme.accent}}
|
|
97
|
+
button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid ${theme.text};outline-offset:2px}
|
|
98
|
+
a{color:${theme.accent}}
|
|
99
|
+
</style></head><body><main>
|
|
100
|
+
<p class="site">${escapeHtml(page.siteName)}</p>
|
|
101
|
+
<h1>${escapeHtml(page.heading)}</h1>
|
|
102
|
+
${page.notice ? `<p class="notice" role="status">${escapeHtml(page.notice)}</p>` : ""}
|
|
103
|
+
${page.paragraphs.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join("\n")}
|
|
104
|
+
${lists.length > 0 ? `<ul>${lists.map((name) => `<li>${escapeHtml(name)}</li>`).join("")}</ul>` : ""}
|
|
105
|
+
${(page.forms ?? []).map(renderForm).join("\n")}
|
|
106
|
+
${links}
|
|
107
|
+
</main></body></html>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const PAGE_HEADERS = {
|
|
111
|
+
"content-type": "text/html; charset=utf-8",
|
|
112
|
+
"cache-control": "no-store",
|
|
113
|
+
"referrer-policy": "no-referrer",
|
|
114
|
+
"x-content-type-options": "nosniff",
|
|
115
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
118
|
+
export function pageResponse(page: PageContent, status = 200): NextResponse {
|
|
119
|
+
return new NextResponse(renderPage(page), { status, headers: PAGE_HEADERS });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const CONFIRM_PATH = "/api/newsletter/confirm";
|
|
123
|
+
|
|
124
|
+
type Base = Pick<PageContent, "siteName" | "theme">;
|
|
125
|
+
const home = (base: Base) => ({ href: "/", label: `Back to ${base.siteName}` });
|
|
126
|
+
|
|
127
|
+
export function confirmPromptPage(base: Base, lists: string[], token: string): PageContent {
|
|
128
|
+
return {
|
|
129
|
+
...base,
|
|
130
|
+
heading: "Confirm your subscription",
|
|
131
|
+
paragraphs: [`Press the button to start getting email from ${base.siteName}.${lists.length > 0 ? " You are signing up for:" : ""}`],
|
|
132
|
+
lists,
|
|
133
|
+
forms: [{ action: CONFIRM_PATH, hidden: { t: token }, buttons: [{ label: "Confirm subscription" }] }],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function confirmedPage(base: Base, lists: string[], preferencesHref?: string): PageContent {
|
|
138
|
+
return {
|
|
139
|
+
...base,
|
|
140
|
+
heading: "You are subscribed",
|
|
141
|
+
paragraphs: [lists.length > 0 ? "Thank you. You will get:" : "Thank you. Your subscription is confirmed."],
|
|
142
|
+
lists,
|
|
143
|
+
links: [...(preferencesHref ? [{ href: preferencesHref, label: "Manage your subscriptions" }] : []), home(base)],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function expiredPage(base: Base): PageContent {
|
|
148
|
+
return {
|
|
149
|
+
...base,
|
|
150
|
+
heading: "This link has expired",
|
|
151
|
+
paragraphs: [
|
|
152
|
+
"This confirmation link has expired or was already used. If you already confirmed, you are all set.",
|
|
153
|
+
"If you did not finish signing up, go back to the site and sign up again to get a new link.",
|
|
154
|
+
],
|
|
155
|
+
links: [home(base)],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function unavailablePage(base: Base): PageContent {
|
|
160
|
+
return {
|
|
161
|
+
...base,
|
|
162
|
+
heading: "Something went wrong",
|
|
163
|
+
paragraphs: ["We could not complete this just now. Open the link in your email again in a minute."],
|
|
164
|
+
links: [home(base)],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const UNSUBSCRIBE_PATH = "/api/newsletter/unsubscribe";
|
|
169
|
+
export const PREFERENCES_PATH = "/api/newsletter/preferences";
|
|
170
|
+
|
|
171
|
+
export function preferencesHref(token: string): string {
|
|
172
|
+
return `${PREFERENCES_PATH}?t=${encodeURIComponent(token)}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function invalidManagePage(base: Base): PageContent {
|
|
176
|
+
return {
|
|
177
|
+
...base,
|
|
178
|
+
heading: "This link is not valid",
|
|
179
|
+
paragraphs: ["This link is not valid, or the subscription it belongs to has been deleted. Use the link in a recent email from us."],
|
|
180
|
+
links: [home(base)],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function unsubscribePromptPage(base: Base, token: string, listId: string | null, listName: string | null): PageContent {
|
|
185
|
+
return {
|
|
186
|
+
...base,
|
|
187
|
+
heading: "Unsubscribe",
|
|
188
|
+
paragraphs: [listName ? `Stop getting "${listName}" from ${base.siteName}?` : `Stop getting all email from ${base.siteName}?`],
|
|
189
|
+
forms: [
|
|
190
|
+
{
|
|
191
|
+
action: UNSUBSCRIBE_PATH,
|
|
192
|
+
hidden: { t: token, ...(listId ? { l: listId } : {}), page: "1" },
|
|
193
|
+
buttons: [{ label: listName ? "Unsubscribe from this list" : "Unsubscribe from everything" }],
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
links: [{ href: preferencesHref(token), label: "Choose which lists you get instead" }],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function unsubscribedPage(base: Base, names: string[], token: string | null): PageContent {
|
|
201
|
+
return {
|
|
202
|
+
...base,
|
|
203
|
+
heading: "You are unsubscribed",
|
|
204
|
+
paragraphs: [names.length > 0 ? `You will no longer get these from ${base.siteName}:` : `You will no longer get this email from ${base.siteName}.`],
|
|
205
|
+
lists: names,
|
|
206
|
+
links: [...(token ? [{ href: preferencesHref(token), label: "Change your mind? Manage your subscriptions" }] : []), home(base)],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type PreferenceList = { id: string; name: string; description: string; subscribed: boolean };
|
|
211
|
+
|
|
212
|
+
export function preferencesPage(base: Base, input: { token: string; email: string; lists: PreferenceList[]; notice?: string }): PageContent {
|
|
213
|
+
return {
|
|
214
|
+
...base,
|
|
215
|
+
heading: "Your subscriptions",
|
|
216
|
+
notice: input.notice,
|
|
217
|
+
paragraphs: [`Email preferences for ${input.email}. Tick the lists you want.`],
|
|
218
|
+
forms: [
|
|
219
|
+
{
|
|
220
|
+
action: PREFERENCES_PATH,
|
|
221
|
+
hidden: { t: input.token },
|
|
222
|
+
checkboxes: input.lists.map((list) => ({
|
|
223
|
+
name: "l",
|
|
224
|
+
value: list.id,
|
|
225
|
+
label: list.description ? `${list.name}: ${list.description}` : list.name,
|
|
226
|
+
checked: list.subscribed,
|
|
227
|
+
})),
|
|
228
|
+
buttons: [
|
|
229
|
+
{ label: "Save preferences", name: "action", value: "save" },
|
|
230
|
+
{ label: "Unsubscribe from everything", name: "action", value: "unsubscribe_all", secondary: true },
|
|
231
|
+
{ label: "Delete my data", name: "action", value: "delete", secondary: true },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
links: [home(base)],
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function deleteConfirmPage(base: Base, token: string): PageContent {
|
|
240
|
+
return {
|
|
241
|
+
...base,
|
|
242
|
+
heading: "Delete your data?",
|
|
243
|
+
paragraphs: [
|
|
244
|
+
`This erases your address and everything ${base.siteName} holds about your subscriptions. You will not get any more email. This cannot be undone.`,
|
|
245
|
+
],
|
|
246
|
+
forms: [
|
|
247
|
+
{
|
|
248
|
+
action: PREFERENCES_PATH,
|
|
249
|
+
hidden: { t: token, action: "delete", confirm: "1" },
|
|
250
|
+
buttons: [{ label: "Yes, delete my data" }],
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
links: [{ href: preferencesHref(token), label: "No, go back" }],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function deletedPage(base: Base): PageContent {
|
|
258
|
+
return {
|
|
259
|
+
...base,
|
|
260
|
+
heading: "Your data is deleted",
|
|
261
|
+
paragraphs: ["Your address and subscriptions have been erased. You will not get any more email from us."],
|
|
262
|
+
links: [home(base)],
|
|
263
|
+
};
|
|
264
|
+
}
|