@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.1
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/CHANGELOG.md +245 -0
- package/docs/POPUPS.md +102 -0
- package/package.json +23 -3
- package/src/backoffice/SiteSettingsView.tsx +14 -15
- package/src/backoffice/routes/host.ts +30 -7
- package/src/backoffice/shell/nav-model.ts +32 -23
- package/src/config/modules.ts +184 -0
- package/src/config/site-config.ts +9 -31
- package/src/forms/FormRenderer.tsx +40 -3
- package/src/forms/submission-result.ts +35 -0
- package/src/forms/success-listeners.ts +46 -0
- package/src/host/index.tsx +10 -1
- package/src/modules/index.ts +28 -0
- package/src/modules/public-runtime.tsx +159 -0
- package/src/popups/admin.tsx +72 -0
- package/src/popups/cms-bridges.ts +200 -0
- package/src/popups/destinations.tsx +141 -0
- package/src/popups/host.ts +82 -0
- package/src/popups/media-bridge.tsx +84 -0
- package/src/popups/register.ts +23 -0
- package/src/popups/runtime.tsx +94 -0
- package/src/server/popup-optin-trigger.ts +111 -0
- package/src/server/popup-optin-webhook-core.ts +363 -0
- package/src/server/webhook-url-guard.ts +199 -0
- package/src/types/css-modules.d.ts +6 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CMS media library, offered to the popup builder.
|
|
3
|
+
*
|
|
4
|
+
* This is the whole bridge: the popup package asks a host to render a picker,
|
|
5
|
+
* and the host renders the same `MediaPickerDialog` every other CMS screen
|
|
6
|
+
* uses. Nothing is reimplemented — the library's browsing, searching and
|
|
7
|
+
* uploading all come along, because it is literally the same component.
|
|
8
|
+
*
|
|
9
|
+
* What crosses the boundary is a URL, a filename and (when the library knows
|
|
10
|
+
* one) alt text. The popup package never learns which storage provider the
|
|
11
|
+
* site uses, and never uploads anything itself.
|
|
12
|
+
*/
|
|
13
|
+
import type { ReactNode } from "react";
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
HostMediaItem,
|
|
17
|
+
MediaPickerPort,
|
|
18
|
+
} from "@bygghammar/lightweight-popup/adapters";
|
|
19
|
+
|
|
20
|
+
import { MediaPickerDialog } from "../backoffice/inspectors/fields";
|
|
21
|
+
|
|
22
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
23
|
+
/**
|
|
24
|
+
* What the picker hands back.
|
|
25
|
+
*
|
|
26
|
+
* Structural rather than the library's own type: the shape has grown fields
|
|
27
|
+
* over time and a popup needs three of them.
|
|
28
|
+
*/
|
|
29
|
+
type PickedItem = {
|
|
30
|
+
url?: unknown;
|
|
31
|
+
name?: unknown;
|
|
32
|
+
path?: unknown;
|
|
33
|
+
alt?: unknown;
|
|
34
|
+
} & Record<string, any>;
|
|
35
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
36
|
+
|
|
37
|
+
function text(value: unknown): string | undefined {
|
|
38
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Turn one library item into the neutral shape the popup package stores.
|
|
43
|
+
*
|
|
44
|
+
* `url` is the stable public address the media library already serves to the
|
|
45
|
+
* site — the same value every other block stores — so a popup configured in
|
|
46
|
+
* one environment keeps working in the next. A signed or expiring link would
|
|
47
|
+
* render today and 404 later, which the package's own reference check refuses
|
|
48
|
+
* anyway.
|
|
49
|
+
*/
|
|
50
|
+
export function toHostMediaItem(item: PickedItem): HostMediaItem | null {
|
|
51
|
+
const src = text(item.url) ?? text(item.path);
|
|
52
|
+
if (src === undefined) return null;
|
|
53
|
+
const name = text(item.name);
|
|
54
|
+
const alt = text(item.alt);
|
|
55
|
+
return {
|
|
56
|
+
src,
|
|
57
|
+
...(name === undefined ? {} : { name }),
|
|
58
|
+
...(alt === undefined ? {} : { alt }),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The site's media library as a popup media port.
|
|
64
|
+
*
|
|
65
|
+
* Images only: a popup's picture is a picture, and offering video here would
|
|
66
|
+
* put a file in an `<img>` that cannot render it.
|
|
67
|
+
*/
|
|
68
|
+
export function cmsMediaPicker(): MediaPickerPort {
|
|
69
|
+
return {
|
|
70
|
+
renderPicker: ({ onPick, onClose }): ReactNode => (
|
|
71
|
+
<MediaPickerDialog
|
|
72
|
+
accept="image"
|
|
73
|
+
onPick={(item) => {
|
|
74
|
+
const media = toHostMediaItem(item as PickedItem);
|
|
75
|
+
// A library row with no address is not something to hand on; closing
|
|
76
|
+
// is honest, and the editor keeps whatever they had.
|
|
77
|
+
if (media !== null) onPick(media);
|
|
78
|
+
onClose();
|
|
79
|
+
}}
|
|
80
|
+
onClose={onClose}
|
|
81
|
+
/>
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers the Popups module's loaders.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately tiny and free of popup imports: a site calls this once, and
|
|
5
|
+
* nothing about the popup runtime — not the package, not its CSS, not the
|
|
6
|
+
* builder — is evaluated until the module is both enabled and hydrated. The
|
|
7
|
+
* `import()` calls below are ordinary local imports the host's bundler turns
|
|
8
|
+
* into separate chunks; nothing is fetched from a remote origin.
|
|
9
|
+
*
|
|
10
|
+
* An explicit function rather than a module-scope side effect: a host whose
|
|
11
|
+
* `sideEffects` field is an allowlist tree-shakes a bare
|
|
12
|
+
* `import "…/popups/register"` away, taking the registration with it, and the
|
|
13
|
+
* build still succeeds. That failure is silent — no popup ever appears.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
registerModuleAdmin,
|
|
17
|
+
registerModuleRuntime,
|
|
18
|
+
} from "../modules/public-runtime";
|
|
19
|
+
|
|
20
|
+
export function registerPopupModule(): void {
|
|
21
|
+
registerModuleRuntime("popups", () => import("./runtime"));
|
|
22
|
+
registerModuleAdmin("popups", () => import("./admin"));
|
|
23
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Popups module's public-site integration.
|
|
3
|
+
*
|
|
4
|
+
* Importing this file registers the popup runtime with the module host, so a
|
|
5
|
+
* site enables popups by importing it once and switching the module on. It is
|
|
6
|
+
* the only popup entry a public bundle may reach: the builder, the statistics
|
|
7
|
+
* screen and the validator all live behind `./popups/admin`.
|
|
8
|
+
*
|
|
9
|
+
* Everything CMS-shaped is bridged here — the site's forms, its routing, its
|
|
10
|
+
* design tokens — so neither the popup package nor the site has to know about
|
|
11
|
+
* the other.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
14
|
+
import type { ReactNode } from "react";
|
|
15
|
+
import { useRouterState } from "@tanstack/react-router";
|
|
16
|
+
import { PopupRuntime } from "@bygghammar/lightweight-popup/runtime";
|
|
17
|
+
import type { RuntimeAdapters } from "@bygghammar/lightweight-popup/adapters";
|
|
18
|
+
|
|
19
|
+
import { ManagedForm } from "../forms/FormRenderer";
|
|
20
|
+
import { onFormSubmissionSuccess } from "../forms/success-listeners";
|
|
21
|
+
import { siteConfig } from "../config/site-config";
|
|
22
|
+
import { popupPublicHost } from "./host";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Renders one of the site's own forms inside a popup.
|
|
26
|
+
*
|
|
27
|
+
* Success comes from the CMS form seam rather than from this component, which
|
|
28
|
+
* is what makes a honeypot rejection impossible to miscount: the form runtime
|
|
29
|
+
* only announces submissions the host actually stored.
|
|
30
|
+
*/
|
|
31
|
+
function PopupForm({
|
|
32
|
+
formKey,
|
|
33
|
+
onSuccess,
|
|
34
|
+
}: {
|
|
35
|
+
formKey: string;
|
|
36
|
+
onSuccess: () => void;
|
|
37
|
+
}): ReactNode {
|
|
38
|
+
const latest = useRef(onSuccess);
|
|
39
|
+
latest.current = onSuccess;
|
|
40
|
+
|
|
41
|
+
useEffect(
|
|
42
|
+
() =>
|
|
43
|
+
onFormSubmissionSuccess((event) => {
|
|
44
|
+
if (event.formKey === formKey) latest.current();
|
|
45
|
+
}),
|
|
46
|
+
[formKey],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return <ManagedForm formKey={formKey} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Paths the runtime never displays on.
|
|
54
|
+
*
|
|
55
|
+
* Derived from the site's configured backoffice path rather than a hardcoded
|
|
56
|
+
* `/kontoret`, plus the CMS preview routes — a popup over the editor's own
|
|
57
|
+
* live preview would end up in screenshots.
|
|
58
|
+
*/
|
|
59
|
+
function excludedPaths(): readonly string[] {
|
|
60
|
+
const base = siteConfig().backofficePath.replace(/\/+$/, "");
|
|
61
|
+
return [`${base}/**`, base, "/preview/**"];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function PopupModuleRuntime(): ReactNode {
|
|
65
|
+
const path = useRouterState({ select: (state) => state.location.pathname });
|
|
66
|
+
const host = popupPublicHost();
|
|
67
|
+
|
|
68
|
+
const renderForm = useCallback(
|
|
69
|
+
({ formKey, onSuccess }: { formKey: string; onSuccess: () => void }) => (
|
|
70
|
+
<PopupForm formKey={formKey} onSuccess={onSuccess} />
|
|
71
|
+
),
|
|
72
|
+
[],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const adapters = useMemo<RuntimeAdapters>(
|
|
76
|
+
() => ({
|
|
77
|
+
configs: host.configs,
|
|
78
|
+
analytics: host.analytics,
|
|
79
|
+
forms: { renderForm },
|
|
80
|
+
}),
|
|
81
|
+
[host, renderForm],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<PopupRuntime
|
|
86
|
+
path={path}
|
|
87
|
+
adapters={adapters}
|
|
88
|
+
excludedPaths={excludedPaths()}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Default export so the module host can load this file lazily.
|
|
94
|
+
export default PopupModuleRuntime;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a popup outcome is a genuine opt-in worth delivering.
|
|
3
|
+
*
|
|
4
|
+
* One predicate, in one file, because getting it wrong is the difference
|
|
5
|
+
* between "we sent the customer's email to their CRM" and "we sent a stranger's
|
|
6
|
+
* empty form to their CRM". Every caller goes through here; nothing decides
|
|
7
|
+
* this inline.
|
|
8
|
+
*
|
|
9
|
+
* The rule: only a stored submission counts. The honeypot path answers
|
|
10
|
+
* `{ ok: true, skipped: true }` — a deliberate lie to a bot, which the form
|
|
11
|
+
* system returns so the bot believes it succeeded — and treating that as an
|
|
12
|
+
* opt-in would deliver junk and inflate every conversion figure. Everything
|
|
13
|
+
* else a popup can produce (a "yes" click on its own, a "no", a dismissal, a
|
|
14
|
+
* "maybe later") is a decision, not a submission.
|
|
15
|
+
*
|
|
16
|
+
* Pure and host-agnostic. Server-only by convention, but safe anywhere.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What a form submit answers with. Deliberately loose: input is untrusted. */
|
|
20
|
+
export interface SubmissionOutcome {
|
|
21
|
+
readonly ok?: unknown;
|
|
22
|
+
readonly skipped?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type OptInRefusal =
|
|
26
|
+
| "not-a-submission"
|
|
27
|
+
| "submission-failed"
|
|
28
|
+
| "honeypot-skipped"
|
|
29
|
+
| "preview"
|
|
30
|
+
| "test-mode";
|
|
31
|
+
|
|
32
|
+
export type OptInDecision =
|
|
33
|
+
| { readonly deliver: true }
|
|
34
|
+
| { readonly deliver: false; readonly reason: OptInRefusal };
|
|
35
|
+
|
|
36
|
+
export interface OptInContext {
|
|
37
|
+
/** The result the form system returned. */
|
|
38
|
+
readonly outcome: SubmissionOutcome | null | undefined;
|
|
39
|
+
/** True inside the builder preview, which performs nothing. */
|
|
40
|
+
readonly preview?: boolean;
|
|
41
|
+
/** A popup marked test mode records nothing and delivers nothing. */
|
|
42
|
+
readonly testMode?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* An administrator pressing "send a test delivery". The only way a test-mode
|
|
45
|
+
* popup ever produces a request, and it is labelled as a test in the payload.
|
|
46
|
+
*/
|
|
47
|
+
readonly explicitTestDelivery?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A genuine, stored submission — the only thing that may be delivered.
|
|
52
|
+
*
|
|
53
|
+
* `ok === true && skipped !== true`, checked structurally rather than by type
|
|
54
|
+
* assertion, because this value comes back across a request boundary and an
|
|
55
|
+
* unknown shape must fail closed.
|
|
56
|
+
*/
|
|
57
|
+
export function isGenuineSubmission(
|
|
58
|
+
outcome: SubmissionOutcome | null | undefined,
|
|
59
|
+
): boolean {
|
|
60
|
+
if (outcome === null || outcome === undefined || typeof outcome !== "object")
|
|
61
|
+
return false;
|
|
62
|
+
return outcome.ok === true && outcome.skipped !== true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function shouldDeliverOptIn(context: OptInContext): OptInDecision {
|
|
66
|
+
// An explicit test delivery is an administrator action, not a visitor's, and
|
|
67
|
+
// carries no submission at all.
|
|
68
|
+
if (context.explicitTestDelivery === true) return { deliver: true };
|
|
69
|
+
|
|
70
|
+
if (context.preview === true) return { deliver: false, reason: "preview" };
|
|
71
|
+
|
|
72
|
+
const outcome = context.outcome;
|
|
73
|
+
if (
|
|
74
|
+
outcome === null ||
|
|
75
|
+
outcome === undefined ||
|
|
76
|
+
typeof outcome !== "object"
|
|
77
|
+
) {
|
|
78
|
+
return { deliver: false, reason: "not-a-submission" };
|
|
79
|
+
}
|
|
80
|
+
if (outcome.skipped === true)
|
|
81
|
+
return { deliver: false, reason: "honeypot-skipped" };
|
|
82
|
+
if (outcome.ok !== true)
|
|
83
|
+
return { deliver: false, reason: "submission-failed" };
|
|
84
|
+
|
|
85
|
+
// Test mode is checked last so the reason reported is the most specific one:
|
|
86
|
+
// an administrator debugging a test-mode popup wants to know the submission
|
|
87
|
+
// itself was fine.
|
|
88
|
+
if (context.testMode === true) return { deliver: false, reason: "test-mode" };
|
|
89
|
+
|
|
90
|
+
return { deliver: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The fields an administrator chose, and nothing else.
|
|
95
|
+
*
|
|
96
|
+
* A submission holds everything the visitor typed. A destination receives only
|
|
97
|
+
* what was explicitly mapped — so adding a "personal number" field to a form
|
|
98
|
+
* never silently starts sending it to a third party. Unmapped keys, absent
|
|
99
|
+
* keys and non-string values are all dropped rather than coerced.
|
|
100
|
+
*/
|
|
101
|
+
export function selectMappedFields(
|
|
102
|
+
submitted: Readonly<Record<string, unknown>>,
|
|
103
|
+
mapping: readonly string[],
|
|
104
|
+
): Record<string, string> {
|
|
105
|
+
const out: Record<string, string> = {};
|
|
106
|
+
for (const key of mapping) {
|
|
107
|
+
const value = submitted[key];
|
|
108
|
+
if (typeof value === "string" && value !== "") out[key] = value;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivering a genuine popup opt-in to somebody else's system.
|
|
3
|
+
*
|
|
4
|
+
* Host-agnostic: the site supplies a privileged database client, the signing
|
|
5
|
+
* secret resolved from its own environment, and the submitted values it
|
|
6
|
+
* already holds. Everything about *how* the delivery is made and secured lives
|
|
7
|
+
* here so every site gets the same guarantees.
|
|
8
|
+
*
|
|
9
|
+
* The rules this file exists to enforce:
|
|
10
|
+
*
|
|
11
|
+
* - It is sent only for a genuine, stored submission. A "yes" click, a
|
|
12
|
+
* dismissal or a honeypot hit is not an opt-in and never reaches here —
|
|
13
|
+
* the caller decides that, and `popup-optin-trigger.ts` is the one place
|
|
14
|
+
* allowed to.
|
|
15
|
+
* - The endpoint is re-validated on every hop, including redirects, so a
|
|
16
|
+
* destination that passes review cannot later redirect into the private
|
|
17
|
+
* network. `fetch` follows redirects invisibly, so redirects are followed
|
|
18
|
+
* manually here — that is the only way to see each hop.
|
|
19
|
+
* - The body is signed with HMAC-SHA256 over a timestamped string, so a
|
|
20
|
+
* receiver can verify origin AND reject a replay.
|
|
21
|
+
* - Only fields an administrator explicitly mapped are sent. Nothing else
|
|
22
|
+
* from the submission, and nothing at all from the browser's headers.
|
|
23
|
+
* - The secret never appears in a return value, an error, or a log line.
|
|
24
|
+
*
|
|
25
|
+
* Server-only. Never import from client code.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { checkWebhookUrl } from "./webhook-url-guard";
|
|
29
|
+
|
|
30
|
+
export const POPUP_WEBHOOK_SCHEMA_VERSION = 1;
|
|
31
|
+
|
|
32
|
+
/** Everything a delivery needs, with no database and no environment in sight. */
|
|
33
|
+
export interface OptInWebhookInput {
|
|
34
|
+
readonly endpoint: string;
|
|
35
|
+
/** Resolved by the host from its own environment. Never read from the database. */
|
|
36
|
+
readonly signingSecret: string;
|
|
37
|
+
/** Stable id for this delivery. The receiver uses it to deduplicate. */
|
|
38
|
+
readonly eventId: string;
|
|
39
|
+
readonly occurredAt: string;
|
|
40
|
+
readonly campaign: {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly key: string;
|
|
43
|
+
readonly name: string;
|
|
44
|
+
};
|
|
45
|
+
readonly form: { readonly id: string; readonly key?: string };
|
|
46
|
+
/** Only the fields the administrator mapped. Already filtered by the caller. */
|
|
47
|
+
readonly fields: Readonly<Record<string, string>>;
|
|
48
|
+
readonly context: {
|
|
49
|
+
readonly pageUrl?: string | null;
|
|
50
|
+
readonly referrer?: string | null;
|
|
51
|
+
readonly utmSource?: string | null;
|
|
52
|
+
readonly device?: string | null;
|
|
53
|
+
};
|
|
54
|
+
/** A test delivery an administrator asked for, clearly marked as such. */
|
|
55
|
+
readonly test?: boolean;
|
|
56
|
+
readonly timeoutMs?: number;
|
|
57
|
+
readonly maxResponseBytes?: number;
|
|
58
|
+
readonly maxRedirects?: number;
|
|
59
|
+
/** Injected in tests. */
|
|
60
|
+
readonly fetchImpl?: typeof fetch;
|
|
61
|
+
readonly now?: () => number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type DeliveryOutcome = {
|
|
65
|
+
readonly ok: boolean;
|
|
66
|
+
readonly status?: number;
|
|
67
|
+
/** A category, never a raw upstream body — that could echo back anything. */
|
|
68
|
+
readonly errorCategory?:
|
|
69
|
+
| "blocked-endpoint"
|
|
70
|
+
| "timeout"
|
|
71
|
+
| "network"
|
|
72
|
+
| "http-error"
|
|
73
|
+
| "too-large"
|
|
74
|
+
| "too-many-redirects";
|
|
75
|
+
/** One sentence for an administrator. Never contains the secret. */
|
|
76
|
+
readonly error?: string;
|
|
77
|
+
readonly durationMs: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
81
|
+
const DEFAULT_MAX_BYTES = 64 * 1024;
|
|
82
|
+
const DEFAULT_MAX_REDIRECTS = 3;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A URL safe to record and show.
|
|
86
|
+
*
|
|
87
|
+
* Query strings routinely carry tokens, so only the origin and path are kept.
|
|
88
|
+
*/
|
|
89
|
+
export function safeUrl(raw: string | null | undefined): string | null {
|
|
90
|
+
if (raw == null || raw.trim() === "") return null;
|
|
91
|
+
try {
|
|
92
|
+
const url = new URL(raw);
|
|
93
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
94
|
+
return `${url.origin}${url.pathname}`;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The versioned body. Changing its shape means bumping the schema version. */
|
|
101
|
+
export function buildOptInPayload(
|
|
102
|
+
input: OptInWebhookInput,
|
|
103
|
+
): Record<string, unknown> {
|
|
104
|
+
return {
|
|
105
|
+
schema_version: POPUP_WEBHOOK_SCHEMA_VERSION,
|
|
106
|
+
event_id: input.eventId,
|
|
107
|
+
event: input.test === true ? "popup.opt_in.test" : "popup.opt_in",
|
|
108
|
+
occurred_at: input.occurredAt,
|
|
109
|
+
campaign: {
|
|
110
|
+
id: input.campaign.id,
|
|
111
|
+
key: input.campaign.key,
|
|
112
|
+
name: input.campaign.name,
|
|
113
|
+
},
|
|
114
|
+
form:
|
|
115
|
+
input.form.key === undefined
|
|
116
|
+
? { id: input.form.id }
|
|
117
|
+
: { id: input.form.id, key: input.form.key },
|
|
118
|
+
fields: input.fields,
|
|
119
|
+
context: {
|
|
120
|
+
page_url: safeUrl(input.context.pageUrl),
|
|
121
|
+
referrer: safeUrl(input.context.referrer),
|
|
122
|
+
utm_source: input.context.utmSource ?? null,
|
|
123
|
+
device: input.context.device ?? null,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The string that gets signed.
|
|
130
|
+
*
|
|
131
|
+
* The timestamp is inside the signed material, not merely a header beside it:
|
|
132
|
+
* signing the body alone would let anyone who captured one delivery replay it
|
|
133
|
+
* forever, because the signature would still verify.
|
|
134
|
+
*/
|
|
135
|
+
export function signingBase(timestamp: string, body: string): string {
|
|
136
|
+
return `${POPUP_WEBHOOK_SCHEMA_VERSION}.${timestamp}.${body}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function signBody(
|
|
140
|
+
secret: string,
|
|
141
|
+
timestamp: string,
|
|
142
|
+
body: string,
|
|
143
|
+
): Promise<string> {
|
|
144
|
+
const { createHmac } = await import("node:crypto");
|
|
145
|
+
return createHmac("sha256", secret)
|
|
146
|
+
.update(signingBase(timestamp, body))
|
|
147
|
+
.digest("hex");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read at most `limit` bytes of a response, then stop.
|
|
152
|
+
*
|
|
153
|
+
* A receiver that answers with a gigabyte would otherwise hold this server's
|
|
154
|
+
* memory hostage. Nothing is done with the body but the read has to be bounded.
|
|
155
|
+
*/
|
|
156
|
+
async function drainBounded(
|
|
157
|
+
response: Response,
|
|
158
|
+
limit: number,
|
|
159
|
+
): Promise<boolean> {
|
|
160
|
+
const body = response.body;
|
|
161
|
+
if (body === null) return true;
|
|
162
|
+
const reader = body.getReader();
|
|
163
|
+
let total = 0;
|
|
164
|
+
try {
|
|
165
|
+
for (;;) {
|
|
166
|
+
const { done, value } = await reader.read();
|
|
167
|
+
if (done) return true;
|
|
168
|
+
total += value?.byteLength ?? 0;
|
|
169
|
+
if (total > limit) return false;
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
await reader.cancel().catch(() => undefined);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* POST with every redirect hop re-validated.
|
|
178
|
+
*
|
|
179
|
+
* `redirect: "manual"` is what makes the guard meaningful: with the default
|
|
180
|
+
* `follow`, the runtime chases a 302 into the private network before any of
|
|
181
|
+
* this code sees it.
|
|
182
|
+
*/
|
|
183
|
+
async function postWithGuardedRedirects(
|
|
184
|
+
url: string,
|
|
185
|
+
init: RequestInit,
|
|
186
|
+
options: { fetchImpl: typeof fetch; maxRedirects: number; maxBytes: number },
|
|
187
|
+
): Promise<
|
|
188
|
+
| { response: Response; oversize: boolean }
|
|
189
|
+
| { blocked: DeliveryOutcome["errorCategory"] }
|
|
190
|
+
> {
|
|
191
|
+
let target = url;
|
|
192
|
+
for (let hop = 0; hop <= options.maxRedirects; hop += 1) {
|
|
193
|
+
const check = checkWebhookUrl(target);
|
|
194
|
+
if (!check.ok) return { blocked: "blocked-endpoint" };
|
|
195
|
+
|
|
196
|
+
const response = await options.fetchImpl(target, {
|
|
197
|
+
...init,
|
|
198
|
+
redirect: "manual",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (response.status >= 300 && response.status < 400) {
|
|
202
|
+
const location = response.headers.get("location");
|
|
203
|
+
if (location === null) return { response, oversize: false };
|
|
204
|
+
// Resolve relative redirects against the hop we actually made.
|
|
205
|
+
target = new URL(location, target).toString();
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const withinLimit = await drainBounded(response, options.maxBytes);
|
|
210
|
+
return { response, oversize: !withinLimit };
|
|
211
|
+
}
|
|
212
|
+
return { blocked: "too-many-redirects" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Deliver one opt-in. Never throws.
|
|
217
|
+
*
|
|
218
|
+
* A delivery failure must not take down the request that produced it: the
|
|
219
|
+
* visitor already submitted the form, and their submission is stored whatever
|
|
220
|
+
* the receiver does.
|
|
221
|
+
*/
|
|
222
|
+
export async function deliverOptInWebhook(
|
|
223
|
+
input: OptInWebhookInput,
|
|
224
|
+
): Promise<DeliveryOutcome> {
|
|
225
|
+
const now = input.now ?? Date.now;
|
|
226
|
+
const startedAt = now();
|
|
227
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
228
|
+
const maxBytes = input.maxResponseBytes ?? DEFAULT_MAX_BYTES;
|
|
229
|
+
const maxRedirects = input.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
230
|
+
|
|
231
|
+
const check = checkWebhookUrl(input.endpoint);
|
|
232
|
+
if (!check.ok) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
errorCategory: "blocked-endpoint",
|
|
236
|
+
error: check.message,
|
|
237
|
+
durationMs: now() - startedAt,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const body = JSON.stringify(buildOptInPayload(input));
|
|
242
|
+
const timestamp = String(Math.floor(now() / 1000));
|
|
243
|
+
const signature = await signBody(input.signingSecret, timestamp, body);
|
|
244
|
+
|
|
245
|
+
const controller = new AbortController();
|
|
246
|
+
const timer = setTimeout(
|
|
247
|
+
() => controller.abort(),
|
|
248
|
+
input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const result = await postWithGuardedRedirects(
|
|
253
|
+
check.url.toString(),
|
|
254
|
+
{
|
|
255
|
+
method: "POST",
|
|
256
|
+
headers: {
|
|
257
|
+
"content-type": "application/json",
|
|
258
|
+
"user-agent": "cms-platform-webhooks/1",
|
|
259
|
+
"x-webhook-event":
|
|
260
|
+
input.test === true ? "popup.opt_in.test" : "popup.opt_in",
|
|
261
|
+
"x-webhook-event-id": input.eventId,
|
|
262
|
+
// The receiver deduplicates on this. Retries of the same opt-in
|
|
263
|
+
// reuse the same value, which is what makes a retry safe.
|
|
264
|
+
"x-webhook-idempotency-key": input.eventId,
|
|
265
|
+
"x-webhook-timestamp": timestamp,
|
|
266
|
+
"x-webhook-signature": `v1=${signature}`,
|
|
267
|
+
},
|
|
268
|
+
body,
|
|
269
|
+
signal: controller.signal,
|
|
270
|
+
},
|
|
271
|
+
{ fetchImpl, maxRedirects, maxBytes },
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
if ("blocked" in result) {
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
errorCategory: result.blocked,
|
|
278
|
+
error:
|
|
279
|
+
result.blocked === "too-many-redirects"
|
|
280
|
+
? "The endpoint redirected too many times."
|
|
281
|
+
: "The endpoint redirected to an address inside this server's network.",
|
|
282
|
+
durationMs: now() - startedAt,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const { response, oversize } = result;
|
|
287
|
+
if (oversize) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
status: response.status,
|
|
291
|
+
errorCategory: "too-large",
|
|
292
|
+
error: "The endpoint's reply was too large.",
|
|
293
|
+
durationMs: now() - startedAt,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (!response.ok) {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
status: response.status,
|
|
300
|
+
errorCategory: "http-error",
|
|
301
|
+
// Deliberately not the response body: it is attacker-influenced text
|
|
302
|
+
// that would end up in an administrator's screen and the logs.
|
|
303
|
+
error: `The endpoint answered ${response.status}.`,
|
|
304
|
+
durationMs: now() - startedAt,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return { ok: true, status: response.status, durationMs: now() - startedAt };
|
|
308
|
+
} catch (caught) {
|
|
309
|
+
const aborted =
|
|
310
|
+
caught instanceof Error &&
|
|
311
|
+
(caught.name === "AbortError" || caught.message.includes("aborted"));
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
errorCategory: aborted ? "timeout" : "network",
|
|
315
|
+
error: aborted
|
|
316
|
+
? "The endpoint did not answer in time."
|
|
317
|
+
: "The endpoint could not be reached.",
|
|
318
|
+
durationMs: now() - startedAt,
|
|
319
|
+
};
|
|
320
|
+
} finally {
|
|
321
|
+
clearTimeout(timer);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Retry schedule
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
export const MAX_DELIVERY_ATTEMPTS = 5;
|
|
330
|
+
|
|
331
|
+
/** Which failures are worth trying again. */
|
|
332
|
+
export function isRetryable(outcome: DeliveryOutcome): boolean {
|
|
333
|
+
if (outcome.ok) return false;
|
|
334
|
+
switch (outcome.errorCategory) {
|
|
335
|
+
case "timeout":
|
|
336
|
+
case "network":
|
|
337
|
+
return true;
|
|
338
|
+
case "http-error":
|
|
339
|
+
// 4xx means the receiver understood and refused: sending it again changes
|
|
340
|
+
// nothing. 5xx and 429 are the receiver's problem, and may pass.
|
|
341
|
+
return (
|
|
342
|
+
outcome.status === undefined ||
|
|
343
|
+
outcome.status >= 500 ||
|
|
344
|
+
outcome.status === 429
|
|
345
|
+
);
|
|
346
|
+
// A blocked endpoint, an oversized reply or a redirect loop are
|
|
347
|
+
// configuration faults. Retrying is just repeating the same mistake.
|
|
348
|
+
default:
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Exponential backoff with a ceiling: 1m, 5m, 25m, 2h05.
|
|
355
|
+
*
|
|
356
|
+
* Returns `null` when the attempt budget is spent, which is the caller's signal
|
|
357
|
+
* to mark the delivery terminally failed and stop holding its payload.
|
|
358
|
+
*/
|
|
359
|
+
export function nextAttemptDelayMs(attempt: number): number | null {
|
|
360
|
+
if (attempt >= MAX_DELIVERY_ATTEMPTS) return null;
|
|
361
|
+
const minutes = 60_000;
|
|
362
|
+
return Math.min(minutes * 5 ** (attempt - 1), 4 * 60 * minutes);
|
|
363
|
+
}
|