@anthusai/papyrus 2.0.0-next.5 → 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.
- package/amplify/data/resource.js +23 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +2028 -1979
- package/amplify/data/schema.ts +72 -14
- package/amplify/functions/assignment-action/data-access.js +90 -0
- package/amplify/functions/assignment-action/data-access.ts +89 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +53 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- 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 +260 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -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 +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +162 -0
- package/amplify/plugins/reader-steering/config.ts +248 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1363 -0
- 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 +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +175 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +201 -0
- package/amplify/plugins/reader-steering/schema.js +140 -0
- package/amplify/plugins/reader-steering/schema.ts +142 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +17 -0
- package/amplify/site-backend.js +36 -1
- package/amplify/site-backend.ts +44 -1
- 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/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -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/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-article-editor.tsx +2 -4
- package/components/newsroom-board-view.tsx +4 -1
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-organizer.css +31 -0
- package/components/newsroom-references-view.tsx +14 -14
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +64 -20
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +90 -16
- package/infra/amplify-app-shell.ts +102 -18
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +85 -6
- package/infra/site-config.ts +123 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsletter-api.ts +435 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsletter-pages.ts +364 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +54 -13
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/relevance-status.ts +2 -2
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +42 -4
- package/routes.manifest.json +191 -1
- package/amplify/plugins/editorial-checks/function/handler.py +0 -11
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -50
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -63
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Shapes and wording for the newsroom Monitoring page. The function in src/papyrus_content/monitoring_view.py produces them. */
|
|
2
|
+
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
3
|
+
|
|
4
|
+
export type MonitoringDeliveryState = "delivered" | "failed" | "pending" | "not_applicable" | null;
|
|
5
|
+
|
|
6
|
+
export type MonitoringRelevance = {
|
|
7
|
+
publicationId: string;
|
|
8
|
+
site: string | null;
|
|
9
|
+
relevant: boolean | null;
|
|
10
|
+
label: string | null;
|
|
11
|
+
confidence: number | null;
|
|
12
|
+
delivery: {
|
|
13
|
+
state: MonitoringDeliveryState;
|
|
14
|
+
referenceId?: string | null;
|
|
15
|
+
referenceLineageId?: string | null;
|
|
16
|
+
deliveredAt?: string | null;
|
|
17
|
+
error?: string | null;
|
|
18
|
+
attempts?: number | null;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type MonitoringItem = {
|
|
23
|
+
sourceId: string;
|
|
24
|
+
revision: number;
|
|
25
|
+
title: string;
|
|
26
|
+
url: string | null;
|
|
27
|
+
discussionUrl: string | null;
|
|
28
|
+
observedAt: string | null;
|
|
29
|
+
routing: string;
|
|
30
|
+
routingNote: string | null;
|
|
31
|
+
decidedAt: string | null;
|
|
32
|
+
relevance: MonitoringRelevance[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type MonitoringRun = {
|
|
36
|
+
runId: string;
|
|
37
|
+
startedAt: string;
|
|
38
|
+
status: string;
|
|
39
|
+
new: number;
|
|
40
|
+
revised: number;
|
|
41
|
+
unchanged: number;
|
|
42
|
+
itemCount: number | null;
|
|
43
|
+
errorCount: number;
|
|
44
|
+
errors: (string | null)[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type MonitoringRouterRun = {
|
|
48
|
+
runId: string;
|
|
49
|
+
startedAt: string;
|
|
50
|
+
mode: string;
|
|
51
|
+
status: string;
|
|
52
|
+
stoppedBy: string | null;
|
|
53
|
+
waitingForClassifier?: string[];
|
|
54
|
+
counts: Record<string, number>;
|
|
55
|
+
errorCount: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type MonitoringFeed = {
|
|
59
|
+
id: string;
|
|
60
|
+
sourceUrl: string | null;
|
|
61
|
+
intervalSeconds: number | null;
|
|
62
|
+
limit: number | null;
|
|
63
|
+
enabled: boolean;
|
|
64
|
+
lastStatus: string | null;
|
|
65
|
+
lastFinishedAt: string | null;
|
|
66
|
+
nextDueAt: string | null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type MonitoringOverview = {
|
|
70
|
+
state: "ready" | "empty";
|
|
71
|
+
asOf: string;
|
|
72
|
+
notice?: "publication_not_routed";
|
|
73
|
+
scope: { mode: "publication" | "all"; publicationId: string | null; site: string | null; canViewAll: boolean };
|
|
74
|
+
feeds: MonitoringFeed[];
|
|
75
|
+
runs: MonitoringRun[];
|
|
76
|
+
routerRuns: MonitoringRouterRun[];
|
|
77
|
+
router: { mode: string; feedback: boolean; enabled: boolean; maxItemsPerRun: number | null } | null;
|
|
78
|
+
usage: {
|
|
79
|
+
day: string;
|
|
80
|
+
decisionsToday: number;
|
|
81
|
+
decisionsTotal: number;
|
|
82
|
+
ceilings: { decisionsTotal?: number; decisionsPerDay?: number; items?: number } | null;
|
|
83
|
+
} | null;
|
|
84
|
+
items: MonitoringItem[];
|
|
85
|
+
hiddenNotRelevant: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type MonitoringResult =
|
|
89
|
+
| MonitoringOverview
|
|
90
|
+
| { state: "not_configured" }
|
|
91
|
+
| { state: "unavailable" }
|
|
92
|
+
| { error: "forbidden" | "bad_request" };
|
|
93
|
+
|
|
94
|
+
const ROUTING_WORDS: Record<string, string> = {
|
|
95
|
+
collected: "Collected, waiting for the router",
|
|
96
|
+
"dry-run-validated": "Checked in a dry run, not sent",
|
|
97
|
+
decided: "Judged, delivery not finished",
|
|
98
|
+
routed: "Routed",
|
|
99
|
+
refused: "Refused by the shared Cyclotron",
|
|
100
|
+
uncertain: "Uncertain, needs a person",
|
|
101
|
+
error: "Error",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function routingLabel(routing: string): string {
|
|
105
|
+
return ROUTING_WORDS[routing] ?? routing;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Fixed-format UTC time so the page reads the same everywhere. */
|
|
109
|
+
export function formatUtc(iso: string | null | undefined): string {
|
|
110
|
+
if (!iso) return "never";
|
|
111
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
|
|
112
|
+
return match ? `${match[1]} ${match[2]} UTC` : iso;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function confidenceText(confidence: number | null): string {
|
|
116
|
+
return confidence === null ? "confidence not reported" : `${Math.round(confidence * 100)}% confidence`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function usageText(used: number, ceiling: number | undefined): string {
|
|
120
|
+
return ceiling === undefined ? `${used} used, no ceiling set` : `${used} of ${ceiling}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function everyText(seconds: number | null): string {
|
|
124
|
+
if (!seconds) return "on a schedule";
|
|
125
|
+
return seconds % 3600 === 0 ? `every ${seconds / 3600} h` : seconds % 60 === 0 ? `every ${seconds / 60} min` : `every ${seconds} s`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function referenceHref(lineageId: string, demo?: boolean): string {
|
|
129
|
+
return getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function describeRelevance(entry: MonitoringRelevance): string {
|
|
133
|
+
if (entry.relevant === null) return "Not judged yet";
|
|
134
|
+
return entry.relevant ? "Relevant" : "Not relevant";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function describeDelivery(entry: MonitoringRelevance): string {
|
|
138
|
+
switch (entry.delivery.state) {
|
|
139
|
+
case "delivered": return "In the References queue";
|
|
140
|
+
case "failed": return "Not in the References queue: delivery failed";
|
|
141
|
+
case "pending": return "Not in the References queue yet";
|
|
142
|
+
case "not_applicable": return "Nothing to deliver";
|
|
143
|
+
default: return "";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,435 @@
|
|
|
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
|
+
crossSitePage,
|
|
9
|
+
deleteConfirmPage,
|
|
10
|
+
deletedPage,
|
|
11
|
+
expiredPage,
|
|
12
|
+
invalidManagePage,
|
|
13
|
+
loadShell,
|
|
14
|
+
pageResponse,
|
|
15
|
+
pageTheme,
|
|
16
|
+
preferencesHref,
|
|
17
|
+
preferencesPage,
|
|
18
|
+
unavailablePage,
|
|
19
|
+
unsubscribePromptPage,
|
|
20
|
+
unsubscribedPage,
|
|
21
|
+
type PageContent,
|
|
22
|
+
type PreferenceList,
|
|
23
|
+
type ShellFetcher,
|
|
24
|
+
} from "./newsletter-pages";
|
|
25
|
+
import { PROSPECTUS_SESSION_COOKIE, loadProspectusSessionSealingSecret } from "./prospectus-session";
|
|
26
|
+
import { signedInProspectusSession } from "./prospectus-sign-in-routes";
|
|
27
|
+
import { readerSubject } from "./reader-subject";
|
|
28
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin, publicSiteUrl, siteOriginNotConfiguredResponse } from "./site-origin";
|
|
29
|
+
|
|
30
|
+
export const NEWSLETTER_FUNCTION_ENV = "PAPYRUS_NEWSLETTER_FUNCTION";
|
|
31
|
+
export const NEWSLETTER_SUBSCRIBE_MAXIMUM_BODY_BYTES = 8192;
|
|
32
|
+
export const NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES = 4096;
|
|
33
|
+
export const NEWSLETTER_USER_AGENT_CHARACTERS = 300;
|
|
34
|
+
export const NEWSLETTER_TOKEN = /^[A-Za-z0-9._-]{1,2048}$/;
|
|
35
|
+
const MAXIMUM_LISTS = 20;
|
|
36
|
+
const LIST_ID = /^[a-z][a-z0-9-]{0,39}$/;
|
|
37
|
+
|
|
38
|
+
export type NewsletterEvent = { action: string } & Record<string, unknown>;
|
|
39
|
+
export interface NewsletterResult {
|
|
40
|
+
statusCode: number;
|
|
41
|
+
body: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
export type NewsletterInvoker = (functionName: string, event: NewsletterEvent) => Promise<NewsletterResult>;
|
|
44
|
+
|
|
45
|
+
export interface NewsletterApiOptions {
|
|
46
|
+
environment?: Record<string, string | undefined>;
|
|
47
|
+
invoke?: NewsletterInvoker;
|
|
48
|
+
/** The parsed `amplify_outputs.json`; read from disk when absent. */
|
|
49
|
+
outputs?: Record<string, unknown> | null;
|
|
50
|
+
loadSealingSecret?: () => Promise<string | null>;
|
|
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;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
59
|
+
|
|
60
|
+
function failure(status: number, error: string, message: string): NextResponse {
|
|
61
|
+
return NextResponse.json({ error, message }, { status, headers: NO_STORE });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function invokeNewsletterLambda(functionName: string, event: NewsletterEvent): Promise<NewsletterResult> {
|
|
65
|
+
const { InvokeCommand, LambdaClient } = await import("@aws-sdk/client-lambda");
|
|
66
|
+
const response = await new LambdaClient({}).send(
|
|
67
|
+
new InvokeCommand({ FunctionName: functionName, InvocationType: "RequestResponse", Payload: Buffer.from(JSON.stringify(event)) }),
|
|
68
|
+
);
|
|
69
|
+
if (response.FunctionError || !response.Payload) throw new Error("newsletter failed");
|
|
70
|
+
const result = JSON.parse(Buffer.from(response.Payload).toString("utf8")) as Partial<NewsletterResult>;
|
|
71
|
+
if (typeof result.statusCode !== "number" || typeof result.body !== "object" || result.body === null) {
|
|
72
|
+
throw new Error("newsletter returned an unexpected shape");
|
|
73
|
+
}
|
|
74
|
+
return { statusCode: result.statusCode, body: result.body as Record<string, unknown> };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Enabled = { settings: Record<string, unknown>; functionName: string | null };
|
|
78
|
+
|
|
79
|
+
function enabledPlugin(options: NewsletterApiOptions, environment: Record<string, string | undefined>): Enabled | null {
|
|
80
|
+
let outputs: Record<string, unknown> | null;
|
|
81
|
+
try {
|
|
82
|
+
outputs = options.outputs !== undefined ? options.outputs : readAmplifyOutputsFile();
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const custom = (outputs as { custom?: { papyrusPlugins?: Record<string, unknown>; newsletter?: { actionsFunction?: unknown } } } | null)?.custom;
|
|
87
|
+
const settings = custom?.papyrusPlugins?.newsletter;
|
|
88
|
+
if (typeof settings !== "object" || settings === null) return null;
|
|
89
|
+
const fromEnvironment = (environment[NEWSLETTER_FUNCTION_ENV] ?? "").trim();
|
|
90
|
+
const fromOutputs = custom?.newsletter?.actionsFunction;
|
|
91
|
+
const functionName = fromEnvironment || (typeof fromOutputs === "string" && fromOutputs.trim() ? fromOutputs.trim() : null);
|
|
92
|
+
return { settings: settings as Record<string, unknown>, functionName };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function notFoundJson(): NextResponse {
|
|
96
|
+
return failure(404, "not_found", "Not found.");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function notFoundPage(): NextResponse {
|
|
100
|
+
return new NextResponse("Not found", { status: 404, headers: { ...NO_STORE, "content-type": "text/plain; charset=utf-8" } });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The visitor's address from CloudFront's `ip:port` header (IPv6 may be bracketed), else the first x-forwarded-for entry. */
|
|
104
|
+
export function clientAddress(headers: Pick<Headers, "get">): string {
|
|
105
|
+
const viewer = (headers.get("cloudfront-viewer-address") ?? "").trim();
|
|
106
|
+
if (viewer) {
|
|
107
|
+
let candidate: string;
|
|
108
|
+
if (viewer.startsWith("[")) candidate = viewer.slice(1, Math.max(viewer.indexOf("]"), 1));
|
|
109
|
+
else if (isIP(viewer) === 4) candidate = viewer;
|
|
110
|
+
else candidate = viewer.slice(0, Math.max(viewer.lastIndexOf(":"), 0));
|
|
111
|
+
if (isIP(candidate)) return candidate;
|
|
112
|
+
}
|
|
113
|
+
const forwarded = (headers.get("x-forwarded-for") ?? "").split(",")[0]?.trim() ?? "";
|
|
114
|
+
return isIP(forwarded) ? forwarded : "unknown";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readBody(request: NextRequest, maximum: number, contentTypes: string[] | null): Promise<{ text: string } | { response: NextResponse }> {
|
|
118
|
+
const contentType = (request.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
119
|
+
if (contentTypes !== null && !contentTypes.includes(contentType)) return { response: failure(415, "unsupported_media_type", `Send ${(contentTypes ?? []).join(" or ")}.`) };
|
|
120
|
+
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
121
|
+
if (Number.isFinite(declared) && declared > maximum) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
122
|
+
const text = await request.text();
|
|
123
|
+
if (Buffer.byteLength(text, "utf8") > maximum) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
124
|
+
return { text };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function listIds(raw: unknown): string[] {
|
|
128
|
+
const entries = typeof raw === "string" ? raw.slice(0, 1000).split(",") : Array.isArray(raw) ? raw : [];
|
|
129
|
+
return entries
|
|
130
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
131
|
+
.map((entry) => entry.trim())
|
|
132
|
+
.filter((entry) => LIST_ID.test(entry))
|
|
133
|
+
.slice(0, MAXIMUM_LISTS);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function limited(value: unknown, maximum: number): string {
|
|
137
|
+
return typeof value === "string" ? value.slice(0, maximum) : "";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The reader's pairwise site subject when they happen to be signed in; never required, never logged, never an error. */
|
|
141
|
+
async function optionalSiteSubject(request: NextRequest, options: NewsletterApiOptions, environment: Record<string, string | undefined>): Promise<string | undefined> {
|
|
142
|
+
if (!request.cookies.has(PROSPECTUS_SESSION_COOKIE)) return undefined;
|
|
143
|
+
try {
|
|
144
|
+
const sealingSecret = await (options.loadSealingSecret ?? (() => loadProspectusSessionSealingSecret({ environment })))();
|
|
145
|
+
if (!sealingSecret) return undefined;
|
|
146
|
+
const session = signedInProspectusSession(request, sealingSecret, options.nowInSeconds);
|
|
147
|
+
return readerSubject(session, environment) ?? undefined;
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function siteOriginOrResponse(request: NextRequest, environment: Record<string, string | undefined>): { origin: string } | { response: NextResponse } {
|
|
154
|
+
try {
|
|
155
|
+
return { origin: publicSiteOrigin(request, environment) };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error instanceof SiteOriginNotConfiguredError) return { response: siteOriginNotConfiguredResponse(error) };
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
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
|
+
|
|
182
|
+
/** POST /api/newsletter/subscribe */
|
|
183
|
+
export async function handleNewsletterSubscribe(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
184
|
+
const environment = options.environment ?? process.env;
|
|
185
|
+
const plugin = enabledPlugin(options, environment);
|
|
186
|
+
if (!plugin) return notFoundJson();
|
|
187
|
+
const site = siteOriginOrResponse(request, environment);
|
|
188
|
+
if ("response" in site) return site.response;
|
|
189
|
+
if (!isSameSiteRequest(request.headers, site.origin)) return failure(CROSS_SITE.status, "origin_mismatch", "This request did not come from this site.");
|
|
190
|
+
const body = await readBody(request, NEWSLETTER_SUBSCRIBE_MAXIMUM_BODY_BYTES, ["application/json"]);
|
|
191
|
+
if ("response" in body) return body.response;
|
|
192
|
+
let payload: Record<string, unknown>;
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(body.text) as unknown;
|
|
195
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
|
|
196
|
+
payload = parsed as Record<string, unknown>;
|
|
197
|
+
} catch {
|
|
198
|
+
return failure(400, "invalid_json", "The request body is not a JSON object.");
|
|
199
|
+
}
|
|
200
|
+
if (!plugin.functionName) return failure(503, "newsletter_unavailable", "Newsletter signup is not available right now.");
|
|
201
|
+
|
|
202
|
+
const siteSubject = await optionalSiteSubject(request, options, environment);
|
|
203
|
+
const event: NewsletterEvent = {
|
|
204
|
+
action: "subscribe",
|
|
205
|
+
email: limited(payload.email, 320),
|
|
206
|
+
lists: listIds(payload.lists),
|
|
207
|
+
page: limited(payload.page, 500),
|
|
208
|
+
consent: limited(payload.consent, 500),
|
|
209
|
+
formId: limited(payload.formId, 100),
|
|
210
|
+
honeypot: limited(payload.website, 200),
|
|
211
|
+
renderedAt: typeof payload.renderedAt === "number" || typeof payload.renderedAt === "string" ? payload.renderedAt : null,
|
|
212
|
+
ip: clientAddress(request.headers),
|
|
213
|
+
userAgent: limited(request.headers.get("user-agent"), NEWSLETTER_USER_AGENT_CHARACTERS),
|
|
214
|
+
...(siteSubject ? { siteSubject } : {}),
|
|
215
|
+
};
|
|
216
|
+
try {
|
|
217
|
+
const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, event);
|
|
218
|
+
const status = typeof result.body.status === "string" ? result.body.status : "error";
|
|
219
|
+
return NextResponse.json({ status }, { status: result.statusCode, headers: NO_STORE });
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error(`Newsletter subscribe failed (${error instanceof Error ? error.name : "error"}).`);
|
|
222
|
+
return failure(502, "newsletter_unavailable", "Newsletter signup could not be reached.");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function pageBase(plugin: Enabled): Pick<PageContent, "siteName" | "theme"> {
|
|
227
|
+
const fromName = plugin.settings.fromName;
|
|
228
|
+
return { siteName: typeof fromName === "string" && fromName ? fromName : "this site", theme: pageTheme(plugin.settings.theme) };
|
|
229
|
+
}
|
|
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
|
+
|
|
244
|
+
function configuredListNames(plugin: Enabled, ids: string[]): string[] {
|
|
245
|
+
const known = Array.isArray(plugin.settings.lists) ? (plugin.settings.lists as Array<{ id?: unknown; name?: unknown }>) : [];
|
|
246
|
+
return ids.flatMap((id) => {
|
|
247
|
+
const match = known.find((entry) => entry.id === id);
|
|
248
|
+
return typeof match?.name === "string" ? [match.name] : [];
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** GET /api/newsletter/confirm?t=... shows the confirm button and never changes state; ?done=1&l=... shows the result. */
|
|
253
|
+
export async function handleNewsletterConfirmPage(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
254
|
+
const environment = options.environment ?? process.env;
|
|
255
|
+
const plugin = enabledPlugin(options, environment);
|
|
256
|
+
if (!plugin) return notFoundPage();
|
|
257
|
+
const base = pageBase(plugin);
|
|
258
|
+
const reply = replier(request, options, environment);
|
|
259
|
+
const params = request.nextUrl.searchParams;
|
|
260
|
+
if (params.get("done") === "1") {
|
|
261
|
+
const manage = params.get("m") ?? "";
|
|
262
|
+
return reply(
|
|
263
|
+
confirmedPage(base, configuredListNames(plugin, listIds(params.get("l") ?? "")), NEWSLETTER_TOKEN.test(manage) ? preferencesHref(manage) : undefined),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const token = params.get("t") ?? "";
|
|
267
|
+
if (!NEWSLETTER_TOKEN.test(token)) return reply(expiredPage(base), 410);
|
|
268
|
+
if (!plugin.functionName) return reply(unavailablePage(base), 503);
|
|
269
|
+
try {
|
|
270
|
+
const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, { action: "confirm_page_state", token });
|
|
271
|
+
if (result.body.status !== "ready") return reply(expiredPage(base), 410);
|
|
272
|
+
const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ name?: unknown }>) : [];
|
|
273
|
+
const names = lists.flatMap((entry) => (typeof entry.name === "string" ? [entry.name] : []));
|
|
274
|
+
return reply(confirmPromptPage(base, names, token));
|
|
275
|
+
} catch (error) {
|
|
276
|
+
console.error(`Newsletter confirm page failed (${error instanceof Error ? error.name : "error"}).`);
|
|
277
|
+
return reply(unavailablePage(base), 502);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** POST /api/newsletter/confirm (form field `t`) confirms, then redirects with 303 to the result page. */
|
|
282
|
+
export async function handleNewsletterConfirmPost(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
283
|
+
const environment = options.environment ?? process.env;
|
|
284
|
+
const plugin = enabledPlugin(options, environment);
|
|
285
|
+
if (!plugin) return notFoundPage();
|
|
286
|
+
const base = pageBase(plugin);
|
|
287
|
+
const reply = replier(request, options, environment);
|
|
288
|
+
const site = siteOriginOrResponse(request, environment);
|
|
289
|
+
if ("response" in site) return site.response;
|
|
290
|
+
if (!isSameSiteRequest(request.headers, site.origin)) return reply(crossSitePage(base), CROSS_SITE.status);
|
|
291
|
+
const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, ["application/x-www-form-urlencoded"]);
|
|
292
|
+
if ("response" in body) return body.response;
|
|
293
|
+
const token = new URLSearchParams(body.text).get("t") ?? "";
|
|
294
|
+
if (!NEWSLETTER_TOKEN.test(token)) return reply(expiredPage(base), 410);
|
|
295
|
+
if (!plugin.functionName) return reply(unavailablePage(base), 503);
|
|
296
|
+
try {
|
|
297
|
+
const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, {
|
|
298
|
+
action: "confirm",
|
|
299
|
+
token,
|
|
300
|
+
ip: clientAddress(request.headers),
|
|
301
|
+
userAgent: limited(request.headers.get("user-agent"), NEWSLETTER_USER_AGENT_CHARACTERS),
|
|
302
|
+
});
|
|
303
|
+
if (result.body.status !== "confirmed") return reply(expiredPage(base), 410);
|
|
304
|
+
const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ id?: unknown }>) : [];
|
|
305
|
+
const ids = lists.flatMap((entry) => (typeof entry.id === "string" && LIST_ID.test(entry.id) ? [entry.id] : []));
|
|
306
|
+
const manage = typeof result.body.manageToken === "string" && NEWSLETTER_TOKEN.test(result.body.manageToken) ? result.body.manageToken : "";
|
|
307
|
+
const target = publicSiteUrl(
|
|
308
|
+
`/api/newsletter/confirm?done=1&l=${encodeURIComponent(ids.join(","))}${manage ? `&m=${encodeURIComponent(manage)}` : ""}`,
|
|
309
|
+
request,
|
|
310
|
+
environment,
|
|
311
|
+
);
|
|
312
|
+
return NextResponse.redirect(target, { status: 303, headers: { ...NO_STORE, "referrer-policy": "no-referrer" } });
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.error(`Newsletter confirm failed (${error instanceof Error ? error.name : "error"}).`);
|
|
315
|
+
return reply(unavailablePage(base), 502);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function call(options: NewsletterApiOptions, functionName: string, event: NewsletterEvent): Promise<NewsletterResult> {
|
|
320
|
+
return (options.invoke ?? invokeNewsletterLambda)(functionName, event);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function textResponse(body: string, status: number): NextResponse {
|
|
324
|
+
return new NextResponse(body, { status, headers: { ...NO_STORE, "content-type": "text/plain; charset=utf-8" } });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function manageTokenFrom(...values: Array<string | null>): string {
|
|
328
|
+
const found = values.find((value) => value !== null && value !== "") ?? "";
|
|
329
|
+
return NEWSLETTER_TOKEN.test(found) ? found : "";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function listParameter(value: string | null): string {
|
|
333
|
+
return value !== null && LIST_ID.test(value) ? value : "";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* /api/newsletter/unsubscribe. GET shows a page with a button and changes nothing. POST unsubscribes and is idempotent.
|
|
338
|
+
* RFC 8058 one-click: mail providers POST `List-Unsubscribe=One-Click` to the link in the message from their own
|
|
339
|
+
* servers, with no Origin, so this route has no same-origin check. A POST must carry that marker or `page=1` from our own page.
|
|
340
|
+
*/
|
|
341
|
+
export async function handleNewsletterUnsubscribe(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
342
|
+
const environment = options.environment ?? process.env;
|
|
343
|
+
const plugin = enabledPlugin(options, environment);
|
|
344
|
+
if (!plugin) return notFoundPage();
|
|
345
|
+
const base = pageBase(plugin);
|
|
346
|
+
const reply = replier(request, options, environment);
|
|
347
|
+
const query = request.nextUrl.searchParams;
|
|
348
|
+
if (request.method === "GET") {
|
|
349
|
+
const token = manageTokenFrom(query.get("t"));
|
|
350
|
+
if (!token) return reply(invalidManagePage(base), 400);
|
|
351
|
+
const listId = listParameter(query.get("l"));
|
|
352
|
+
const listName = listId ? configuredListNames(plugin, [listId])[0] ?? null : null;
|
|
353
|
+
return reply(unsubscribePromptPage(base, token, listName ? listId : null, listName));
|
|
354
|
+
}
|
|
355
|
+
const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, null);
|
|
356
|
+
if ("response" in body) return body.response;
|
|
357
|
+
const form = new URLSearchParams(body.text);
|
|
358
|
+
const oneClick = form.get("List-Unsubscribe") === "One-Click";
|
|
359
|
+
const fromPage = form.get("page") === "1";
|
|
360
|
+
if (!oneClick && !fromPage) return textResponse("Send List-Unsubscribe=One-Click.", 400);
|
|
361
|
+
const token = manageTokenFrom(query.get("t"), form.get("t"));
|
|
362
|
+
const listId = listParameter(query.get("l") ?? form.get("l"));
|
|
363
|
+
const rejected = () => (fromPage ? reply(invalidManagePage(base), 400) : textResponse("Invalid link.", 400));
|
|
364
|
+
if (!token) return rejected();
|
|
365
|
+
if (!plugin.functionName) return fromPage ? reply(unavailablePage(base), 503) : textResponse("Unavailable.", 503);
|
|
366
|
+
try {
|
|
367
|
+
const result = await call(options, plugin.functionName, { action: "unsubscribe", token, ...(listId ? { list: listId } : {}) });
|
|
368
|
+
if (result.body.status !== "unsubscribed") return rejected();
|
|
369
|
+
if (!fromPage) return textResponse("Unsubscribed.", 200);
|
|
370
|
+
const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<{ name?: unknown }>) : [];
|
|
371
|
+
const names = lists.flatMap((entry) => (typeof entry.name === "string" ? [entry.name] : []));
|
|
372
|
+
return reply(unsubscribedPage(base, names, token));
|
|
373
|
+
} catch (error) {
|
|
374
|
+
console.error(`Newsletter unsubscribe failed (${error instanceof Error ? error.name : "error"}).`);
|
|
375
|
+
return fromPage ? reply(unavailablePage(base), 502) : textResponse("Try again later.", 502);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const PREFERENCE_NOTICES: Record<string, string> = { saved: "Your preferences are saved.", unsubscribed: "You are unsubscribed from everything." };
|
|
380
|
+
|
|
381
|
+
/** /api/newsletter/preferences: GET shows the lists; POST (same-origin form) saves, unsubscribes from everything, or deletes after a confirm step. */
|
|
382
|
+
export async function handleNewsletterPreferences(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
383
|
+
const environment = options.environment ?? process.env;
|
|
384
|
+
const plugin = enabledPlugin(options, environment);
|
|
385
|
+
if (!plugin) return notFoundPage();
|
|
386
|
+
const base = pageBase(plugin);
|
|
387
|
+
const reply = replier(request, options, environment);
|
|
388
|
+
if (request.method === "GET") {
|
|
389
|
+
const token = manageTokenFrom(request.nextUrl.searchParams.get("t"));
|
|
390
|
+
if (!token) return reply(invalidManagePage(base), 400);
|
|
391
|
+
if (!plugin.functionName) return reply(unavailablePage(base), 503);
|
|
392
|
+
try {
|
|
393
|
+
const result = await call(options, plugin.functionName, { action: "preferences_state", token });
|
|
394
|
+
if (result.body.status !== "ready") return reply(invalidManagePage(base), 400);
|
|
395
|
+
const lists = (Array.isArray(result.body.lists) ? result.body.lists : []) as PreferenceList[];
|
|
396
|
+
const email = typeof result.body.email === "string" ? result.body.email : "";
|
|
397
|
+
const notice = PREFERENCE_NOTICES[request.nextUrl.searchParams.get("msg") ?? ""];
|
|
398
|
+
return reply(preferencesPage(base, { token, email, notice, lists: lists.map(({ id, name, description, subscribed }) => ({ id, name, description, subscribed })) }));
|
|
399
|
+
} catch (error) {
|
|
400
|
+
console.error(`Newsletter preferences failed (${error instanceof Error ? error.name : "error"}).`);
|
|
401
|
+
return reply(unavailablePage(base), 502);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const site = siteOriginOrResponse(request, environment);
|
|
405
|
+
if ("response" in site) return site.response;
|
|
406
|
+
if (!isSameSiteRequest(request.headers, site.origin)) return reply(crossSitePage(base), CROSS_SITE.status);
|
|
407
|
+
const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, ["application/x-www-form-urlencoded"]);
|
|
408
|
+
if ("response" in body) return body.response;
|
|
409
|
+
const form = new URLSearchParams(body.text);
|
|
410
|
+
const token = manageTokenFrom(form.get("t"));
|
|
411
|
+
if (!token) return reply(invalidManagePage(base), 400);
|
|
412
|
+
if (!plugin.functionName) return reply(unavailablePage(base), 503);
|
|
413
|
+
const action = form.get("action");
|
|
414
|
+
if (action === "delete" && form.get("confirm") !== "1") return reply(deleteConfirmPage(base, token));
|
|
415
|
+
const operation =
|
|
416
|
+
action === "save"
|
|
417
|
+
? { action: "update_preferences", token, lists: form.getAll("l").slice(0, MAXIMUM_LISTS).filter((id) => LIST_ID.test(id)) }
|
|
418
|
+
: action === "unsubscribe_all"
|
|
419
|
+
? { action: "unsubscribe", token }
|
|
420
|
+
: action === "delete"
|
|
421
|
+
? { action: "delete_subscriber", token }
|
|
422
|
+
: null;
|
|
423
|
+
if (!operation) return failure(400, "invalid_action", "Choose save, unsubscribe from everything, or delete.");
|
|
424
|
+
try {
|
|
425
|
+
const result = await call(options, plugin.functionName, operation);
|
|
426
|
+
const status = result.body.status;
|
|
427
|
+
if (status === "deleted") return reply(deletedPage(base));
|
|
428
|
+
if (status !== "saved" && status !== "unsubscribed") return reply(invalidManagePage(base), 400);
|
|
429
|
+
const target = publicSiteUrl(`${PREFERENCES_PATH}?t=${encodeURIComponent(token)}&msg=${status}`, request, environment);
|
|
430
|
+
return NextResponse.redirect(target, { status: 303, headers: { ...NO_STORE, "referrer-policy": "no-referrer" } });
|
|
431
|
+
} catch (error) {
|
|
432
|
+
console.error(`Newsletter preferences save failed (${error instanceof Error ? error.name : "error"}).`);
|
|
433
|
+
return reply(unavailablePage(base), 502);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import type { NewsletterFailure } from "./newsletter-model";
|
|
5
|
+
|
|
6
|
+
/** The operations the newsroom Newsletter panel calls. All take and return AWSJSON; the function behind them checks the role again. */
|
|
7
|
+
export const NEWSLETTER_QUERY_OPERATIONS = [
|
|
8
|
+
"newsletterOverview", "newsletterIssues", "newsletterIssue", "newsletterPreview", "newsletterIssueDeliveries", "newsletterSubscribers", "newsletterExport",
|
|
9
|
+
] as const;
|
|
10
|
+
export const NEWSLETTER_MUTATION_OPERATIONS = [
|
|
11
|
+
"newsletterApproveIssue", "newsletterCancelIssue", "newsletterStopIssue", "newsletterTestSend", "newsletterCreateDraft", "newsletterErase", "newsletterResume",
|
|
12
|
+
] as const;
|
|
13
|
+
export type NewsletterOperation = (typeof NEWSLETTER_QUERY_OPERATIONS)[number] | (typeof NEWSLETTER_MUTATION_OPERATIONS)[number];
|
|
14
|
+
|
|
15
|
+
type GraphQLResult = { data?: Record<string, unknown>; errors?: { message?: string; errorType?: string }[] };
|
|
16
|
+
|
|
17
|
+
export type NewsletterApi = <T extends object = Record<string, unknown>>(operation: NewsletterOperation, input?: Record<string, unknown>) => Promise<({ ok: true } & T) | NewsletterFailure>;
|
|
18
|
+
|
|
19
|
+
export function newsletterOperationDocument(operation: NewsletterOperation): string {
|
|
20
|
+
const kind = (NEWSLETTER_MUTATION_OPERATIONS as readonly string[]).includes(operation) ? "mutation" : "query";
|
|
21
|
+
const title = operation.charAt(0).toUpperCase() + operation.slice(1);
|
|
22
|
+
return `${kind} ${title}($input: AWSJSON) { ${operation}(input: $input) }`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Calls one operation through the site's authenticated API (user pool). The browser holds no table name, key or address list. */
|
|
26
|
+
export const callNewsletter: NewsletterApi = async (operation, input = {}) => {
|
|
27
|
+
const client = generateClient({ authMode: "userPool" });
|
|
28
|
+
let result: GraphQLResult;
|
|
29
|
+
try {
|
|
30
|
+
result = (await client.graphql({ query: newsletterOperationDocument(operation), variables: { input: JSON.stringify(input) } })) as GraphQLResult;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
// Amplify throws the response when AppSync returns errors.
|
|
33
|
+
result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
|
|
34
|
+
}
|
|
35
|
+
if (result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
|
|
36
|
+
return { ok: false, error: "forbidden" };
|
|
37
|
+
}
|
|
38
|
+
const raw = result.data?.[operation];
|
|
39
|
+
if (result.errors?.length || raw === undefined || raw === null) return { ok: false, error: "unavailable" };
|
|
40
|
+
try {
|
|
41
|
+
const parsed = (typeof raw === "string" ? JSON.parse(raw) : raw) as { ok?: boolean };
|
|
42
|
+
return parsed && typeof parsed === "object" && "ok" in parsed ? (parsed as never) : { ok: false, error: "unavailable" };
|
|
43
|
+
} catch {
|
|
44
|
+
return { ok: false, error: "unavailable" };
|
|
45
|
+
}
|
|
46
|
+
};
|