@bygghammar/cms-platform 0.95.0 → 0.97.0
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 +121 -0
- package/docs/PAGES.md +45 -2
- package/package.json +3 -1
- package/src/admin/describe-log.ts +2 -0
- package/src/backoffice/BookingLandingSection.tsx +672 -0
- package/src/backoffice/BookingSettingsCard.tsx +34 -4
- package/src/backoffice/PageEditorView.tsx +47 -24
- package/src/backoffice/routes/host.ts +7 -0
- package/src/backoffice/routes/index.tsx +22 -3
- package/src/blocks/components/CountdownBlock.tsx +3 -1
- package/src/blocks/components/CtaBannerBlock.tsx +8 -2
- package/src/blocks/components/HeroFullBlock.tsx +7 -1
- package/src/blocks/components/IconGridBlock.tsx +5 -1
- package/src/blocks/components/InstagramBlock.tsx +5 -1
- package/src/blocks/components/InstagramTiles.tsx +3 -1
- package/src/blocks/components/LinkTilesBlock.tsx +8 -1
- package/src/blocks/components/MediaSplitBlock.tsx +8 -3
- package/src/blocks/components/PriceTableBlock.tsx +6 -5
- package/src/blocks/components/TripListBlock.tsx +5 -1
- package/src/cms/booking-landing.ts +359 -0
- package/src/cms/booking-links.ts +24 -0
- package/src/cms/page-visibility.ts +67 -1
- package/src/forms/form-audit.ts +22 -2
- package/src/html/html-tokenizer.ts +254 -0
- package/src/html/sanitize-html.ts +71 -36
- package/src/navigation/visibility.ts +65 -3
- package/src/popups/runtime.tsx +75 -32
- package/src/server/forms-core.ts +9 -0
- package/src/server/forms-delivery-core.ts +29 -22
- package/src/server/forms-submissions-core.ts +8 -5
- package/src/server/media-usage-core.ts +53 -13
- package/src/server/outbound-address.ts +131 -0
- package/src/server/outbound-http.ts +565 -0
- package/src/server/page-visibility-core.ts +231 -5
- package/src/server/popup-optin-webhook-core.ts +79 -140
- package/src/server/schemas.ts +45 -0
- package/src/site/BookingOffNotice.tsx +73 -0
- package/src/site/PageRenderer.tsx +59 -3
- package/src/site/link-visibility.tsx +21 -2
- package/src/site/preview-messaging.ts +232 -0
- package/src/site/routes/preview-page.tsx +37 -29
- package/src/util/redact-url.ts +18 -0
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* | `page.listing.<page uuid>` | `{ "listing": "link-only" }` | listed |
|
|
12
12
|
* | `booking` | `{ "enabled": false }` | booking on |
|
|
13
13
|
* | `booking.exception.<uuid>` | `{ "exception": true }` | no exception |
|
|
14
|
+
* | `booking.landing.<uuid>` | `{ "landing": true, "notice"?: … }` | not a landing page |
|
|
15
|
+
* | `booking.landing.default` | `{ "notice": … }` | built-in copy |
|
|
16
|
+
*
|
|
17
|
+
* A landing page leaves the site's menus and page lists while booking is off
|
|
18
|
+
* and stays live and indexable (see `cms/booking-landing`).
|
|
14
19
|
*
|
|
15
20
|
* Keys use the page's id, never its key or address, so every alias and locale
|
|
16
21
|
* of a page shares one decision, a restored page keeps its own, and a page
|
|
@@ -25,6 +30,14 @@
|
|
|
25
30
|
* None of these rows is an `OVERRIDABLE_SETTING_KEYS` key, so the generic
|
|
26
31
|
* settings save and reset cannot read, overwrite or erase them.
|
|
27
32
|
*/
|
|
33
|
+
import {
|
|
34
|
+
NoticeValidationError,
|
|
35
|
+
noticeLinkHrefs,
|
|
36
|
+
normalizeNoticeContent,
|
|
37
|
+
normalizePageNoticeOverride,
|
|
38
|
+
type NoticeContent,
|
|
39
|
+
type PageNoticeOverride,
|
|
40
|
+
} from "../cms/booking-landing";
|
|
28
41
|
import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
|
|
29
42
|
import {
|
|
30
43
|
nextPublicationBoundary,
|
|
@@ -49,6 +62,8 @@ export const SITE_SETTINGS = "site_settings";
|
|
|
49
62
|
export const PAGE_LISTING_PREFIX = "page.listing.";
|
|
50
63
|
export const BOOKING_SETTING_KEY = "booking";
|
|
51
64
|
export const BOOKING_EXCEPTION_PREFIX = "booking.exception.";
|
|
65
|
+
export const BOOKING_LANDING_PREFIX = "booking.landing.";
|
|
66
|
+
export const BOOKING_LANDING_DEFAULT_KEY = "booking.landing.default";
|
|
52
67
|
|
|
53
68
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
54
69
|
|
|
@@ -63,8 +78,28 @@ export type VisibilityDeps = {
|
|
|
63
78
|
* by the browser.
|
|
64
79
|
*/
|
|
65
80
|
protectedPageKeys: readonly string[];
|
|
81
|
+
/**
|
|
82
|
+
* The host's check of a booking-off notice link against its own routes:
|
|
83
|
+
* why the link may not be used (it leads to the host's booking page, or to a
|
|
84
|
+
* page hidden while booking is hidden), or null. Supplied by the host's
|
|
85
|
+
* server code. Without it only the platform's own rules apply (no booking
|
|
86
|
+
* site, safe address forms).
|
|
87
|
+
*/
|
|
88
|
+
noticeTargetProblem?: (href: string) => string | null | Promise<string | null>;
|
|
66
89
|
};
|
|
67
90
|
|
|
91
|
+
/** Refuse a notice whose link the host says leads to booking or to a hidden page. */
|
|
92
|
+
async function assertNoticeTargets(
|
|
93
|
+
deps: Pick<VisibilityDeps, "noticeTargetProblem">,
|
|
94
|
+
notice: NoticeContent | PageNoticeOverride,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
if (!deps.noticeTargetProblem) return;
|
|
97
|
+
for (const href of noticeLinkHrefs(notice)) {
|
|
98
|
+
const problem = await deps.noticeTargetProblem(href);
|
|
99
|
+
if (problem) throw new NoticeValidationError(problem);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
68
103
|
/** Someone else changed the same decision since the editor last looked. */
|
|
69
104
|
export class VisibilityConflictError extends Error {
|
|
70
105
|
readonly code = "visibility_conflict";
|
|
@@ -96,10 +131,31 @@ export async function readVisibilitySettingsCore(client: any): Promise<Visibilit
|
|
|
96
131
|
booking: { enabled: true, version: null },
|
|
97
132
|
listings: new Map(),
|
|
98
133
|
exceptions: new Map(),
|
|
134
|
+
landings: new Map(),
|
|
135
|
+
landingDefault: { notice: null, version: null },
|
|
99
136
|
};
|
|
100
137
|
for (const row of (data ?? []) as SettingRow[]) {
|
|
101
138
|
const version = row.updated_at ?? null;
|
|
102
|
-
if (row.key ===
|
|
139
|
+
if (row.key === BOOKING_LANDING_DEFAULT_KEY) {
|
|
140
|
+
// Stored copy is read leniently: a field that no longer validates is
|
|
141
|
+
// left out rather than failing every public page.
|
|
142
|
+
const notice = record(row.value)["notice"];
|
|
143
|
+
settings.landingDefault = {
|
|
144
|
+
notice: notice == null ? null : normalizeNoticeContent(notice, false),
|
|
145
|
+
version,
|
|
146
|
+
};
|
|
147
|
+
} else if (row.key.startsWith(BOOKING_LANDING_PREFIX)) {
|
|
148
|
+
const pageId = row.key.slice(BOOKING_LANDING_PREFIX.length);
|
|
149
|
+
if (UUID.test(pageId)) {
|
|
150
|
+
const value = record(row.value);
|
|
151
|
+
settings.landings.set(pageId.toLowerCase(), {
|
|
152
|
+
// Only an explicit `true` makes a landing page.
|
|
153
|
+
landing: value["landing"] === true,
|
|
154
|
+
notice: normalizePageNoticeOverride(value["notice"], false),
|
|
155
|
+
version,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
} else if (row.key === BOOKING_SETTING_KEY) {
|
|
103
159
|
// Only an explicit `false` turns booking off; anything else is today's behaviour.
|
|
104
160
|
settings.booking = { enabled: record(row.value)["enabled"] !== false, version };
|
|
105
161
|
} else if (row.key.startsWith(PAGE_LISTING_PREFIX)) {
|
|
@@ -127,6 +183,8 @@ export type VisibilitySettings = {
|
|
|
127
183
|
booking: { enabled: boolean; version: VersionToken };
|
|
128
184
|
listings: Map<string, { listing: PageListing; version: VersionToken }>;
|
|
129
185
|
exceptions: Map<string, { exception: boolean; version: VersionToken }>;
|
|
186
|
+
landings: Map<string, { landing: boolean; notice: PageNoticeOverride; version: VersionToken }>;
|
|
187
|
+
landingDefault: { notice: NoticeContent | null; version: VersionToken };
|
|
130
188
|
};
|
|
131
189
|
|
|
132
190
|
/**
|
|
@@ -304,7 +362,14 @@ export async function setBookingExceptionCore(
|
|
|
304
362
|
if (!UUID.test(input.pageId)) throw new Error("Page not found");
|
|
305
363
|
// Adding needs a page that exists; removing works even for one in the trash.
|
|
306
364
|
let name = "this page";
|
|
307
|
-
if (input.exception)
|
|
365
|
+
if (input.exception) {
|
|
366
|
+
name = (await readLivePage(context.supabase, input.pageId)).name;
|
|
367
|
+
// A page cannot both keep its booking links and be a booking-off landing page.
|
|
368
|
+
const settings = await readVisibilitySettingsCore(context.supabase);
|
|
369
|
+
if (settings.landings.get(input.pageId.toLowerCase())?.landing === true) {
|
|
370
|
+
throw new BookingConflictError(name, "landing");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
308
373
|
invalidatePublishedCache();
|
|
309
374
|
const version = await compareAndSwap(
|
|
310
375
|
context.supabase,
|
|
@@ -329,6 +394,114 @@ export async function setBookingExceptionCore(
|
|
|
329
394
|
return { exception: input.exception, version };
|
|
330
395
|
}
|
|
331
396
|
|
|
397
|
+
/** Thrown when one page would both keep its booking links and be a booking-off landing page. */
|
|
398
|
+
export class BookingConflictError extends Error {
|
|
399
|
+
readonly code = "booking_conflict";
|
|
400
|
+
constructor(name: string, already: "exception" | "landing") {
|
|
401
|
+
super(
|
|
402
|
+
already === "exception"
|
|
403
|
+
? `“${name}” keeps its booking links while booking is hidden, so it cannot also be hidden ` +
|
|
404
|
+
"from the website. Remove it from “Pages that keep their booking links” first."
|
|
405
|
+
: `“${name}” is hidden from the website while booking is hidden, so it cannot also keep ` +
|
|
406
|
+
"its booking links. Remove it from “Pages hidden from the website while booking is hidden” first.",
|
|
407
|
+
);
|
|
408
|
+
this.name = "BookingConflictError";
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Add a page to, or remove it from, the booking-off landing pages, or change
|
|
414
|
+
* its own notice.
|
|
415
|
+
*
|
|
416
|
+
* Adding needs a page that exists, is not protected (the start page and the
|
|
417
|
+
* contact page always stay in the menus) and does not keep its booking links.
|
|
418
|
+
* Removing works even for a page in the trash, and drops its own notice.
|
|
419
|
+
* Listing, publication, schedules and indexing are not touched: the page stays
|
|
420
|
+
* exactly as reachable and indexable as it was.
|
|
421
|
+
*/
|
|
422
|
+
export async function setBookingLandingCore(
|
|
423
|
+
deps: VisibilityDeps,
|
|
424
|
+
input: {
|
|
425
|
+
pageId: string;
|
|
426
|
+
landing: boolean;
|
|
427
|
+
notice?: unknown;
|
|
428
|
+
expectedVersion: VersionToken;
|
|
429
|
+
},
|
|
430
|
+
): Promise<{ landing: boolean; version: string }> {
|
|
431
|
+
const { context } = deps;
|
|
432
|
+
await assertStaff(context);
|
|
433
|
+
if (!UUID.test(input.pageId)) throw new Error("Page not found");
|
|
434
|
+
const pageId = input.pageId.toLowerCase();
|
|
435
|
+
let name = "this page";
|
|
436
|
+
let value: Record<string, unknown> = { landing: false };
|
|
437
|
+
if (input.landing) {
|
|
438
|
+
const page = await readLivePage(context.supabase, pageId);
|
|
439
|
+
name = page.name;
|
|
440
|
+
assertNotProtected(deps.protectedPageKeys, page, "hidden from the website while booking is hidden");
|
|
441
|
+
// Validated here as well as in the request schema: the core is the authority.
|
|
442
|
+
const notice = normalizePageNoticeOverride(input.notice ?? null, true);
|
|
443
|
+
await assertNoticeTargets(deps, notice);
|
|
444
|
+
const settings = await readVisibilitySettingsCore(context.supabase);
|
|
445
|
+
if (settings.exceptions.get(pageId)?.exception === true) {
|
|
446
|
+
throw new BookingConflictError(name, "exception");
|
|
447
|
+
}
|
|
448
|
+
value = Object.keys(notice).length > 0 ? { landing: true, notice } : { landing: true };
|
|
449
|
+
}
|
|
450
|
+
invalidatePublishedCache();
|
|
451
|
+
const version = await compareAndSwap(
|
|
452
|
+
context.supabase,
|
|
453
|
+
`${BOOKING_LANDING_PREFIX}${pageId}`,
|
|
454
|
+
value,
|
|
455
|
+
input.expectedVersion,
|
|
456
|
+
context.userId,
|
|
457
|
+
`whether ${input.landing ? `“${name}”` : name} is hidden while booking is hidden`,
|
|
458
|
+
);
|
|
459
|
+
invalidatePublishedCache();
|
|
460
|
+
await auditAs(context, {
|
|
461
|
+
action: "booking.landing",
|
|
462
|
+
entity: "pages",
|
|
463
|
+
entityId: pageId,
|
|
464
|
+
meta: {
|
|
465
|
+
landing: input.landing,
|
|
466
|
+
ownNotice: input.landing && "notice" in value,
|
|
467
|
+
description: input.landing
|
|
468
|
+
? `Set "${name}" to leave the website's menus and page lists while booking is hidden; it stays reachable and indexable.`
|
|
469
|
+
: "Removed a page from the pages hidden from the website while booking is hidden.",
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
return { landing: input.landing, version };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Save the site-wide booking-off notice. */
|
|
476
|
+
export async function setBookingLandingDefaultCore(
|
|
477
|
+
deps: Pick<VisibilityDeps, "context" | "noticeTargetProblem">,
|
|
478
|
+
input: { notice: unknown; expectedVersion: VersionToken },
|
|
479
|
+
): Promise<{ version: string }> {
|
|
480
|
+
const { context } = deps;
|
|
481
|
+
await assertStaff(context);
|
|
482
|
+
const notice = normalizeNoticeContent(input.notice, true);
|
|
483
|
+
await assertNoticeTargets(deps, notice);
|
|
484
|
+
invalidatePublishedCache();
|
|
485
|
+
const version = await compareAndSwap(
|
|
486
|
+
context.supabase,
|
|
487
|
+
BOOKING_LANDING_DEFAULT_KEY,
|
|
488
|
+
{ notice },
|
|
489
|
+
input.expectedVersion,
|
|
490
|
+
context.userId,
|
|
491
|
+
"the booking-off notice",
|
|
492
|
+
);
|
|
493
|
+
invalidatePublishedCache();
|
|
494
|
+
await auditAs(context, {
|
|
495
|
+
action: "booking.landing.default",
|
|
496
|
+
entity: SITE_SETTINGS,
|
|
497
|
+
entityId: BOOKING_LANDING_DEFAULT_KEY,
|
|
498
|
+
meta: {
|
|
499
|
+
description: "Changed the notice booking-off landing pages show while booking is hidden.",
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
return { version };
|
|
503
|
+
}
|
|
504
|
+
|
|
332
505
|
/** Booking setting and its exception pages, for the settings screen. */
|
|
333
506
|
export type BookingSettingsView = {
|
|
334
507
|
enabled: boolean;
|
|
@@ -346,6 +519,28 @@ export type BookingSettingsView = {
|
|
|
346
519
|
* ones: adding a page back has to send the version of the row it left.
|
|
347
520
|
*/
|
|
348
521
|
rowVersions: Record<string, VersionToken>;
|
|
522
|
+
/** Booking-off landing pages and their notice. */
|
|
523
|
+
landing: {
|
|
524
|
+
/** The stored default notice (empty fields use the built-in copy), and its version. */
|
|
525
|
+
default: { notice: NoticeContent | null; version: VersionToken };
|
|
526
|
+
pages: Array<{
|
|
527
|
+
pageId: string;
|
|
528
|
+
key: string | null;
|
|
529
|
+
name: string | null;
|
|
530
|
+
/** The page is in the trash or gone: the setting does nothing until restored. */
|
|
531
|
+
missing: boolean;
|
|
532
|
+
notice: PageNoticeOverride;
|
|
533
|
+
version: VersionToken;
|
|
534
|
+
/**
|
|
535
|
+
* Stored as a booking exception too. It is treated as an exception only
|
|
536
|
+
* — it keeps its booking links and stays in the menus — until somebody
|
|
537
|
+
* removes it from one of the lists.
|
|
538
|
+
*/
|
|
539
|
+
conflict: boolean;
|
|
540
|
+
}>;
|
|
541
|
+
/** Versions of every stored landing row by page id, removed ones included. */
|
|
542
|
+
rowVersions: Record<string, VersionToken>;
|
|
543
|
+
};
|
|
349
544
|
};
|
|
350
545
|
|
|
351
546
|
export async function readBookingSettingsCore(
|
|
@@ -355,17 +550,40 @@ export async function readBookingSettingsCore(
|
|
|
355
550
|
await assertStaff(context);
|
|
356
551
|
const settings = await readVisibilitySettingsCore(context.supabase);
|
|
357
552
|
const ids = [...settings.exceptions].filter(([, row]) => row.exception).map(([id]) => id);
|
|
553
|
+
const landingIds = [...settings.landings].filter(([, row]) => row.landing).map(([id]) => id);
|
|
358
554
|
const pages = new Map<string, PageRow>();
|
|
359
|
-
|
|
555
|
+
const wanted = [...new Set([...ids, ...landingIds])];
|
|
556
|
+
if (wanted.length > 0) {
|
|
360
557
|
const { data, error } = await context.supabase
|
|
361
558
|
.from("pages")
|
|
362
559
|
.select("id,key,name")
|
|
363
|
-
.in("id",
|
|
560
|
+
.in("id", wanted)
|
|
364
561
|
.is("deleted_at", null);
|
|
365
562
|
if (error) throw new Error(error.message);
|
|
366
563
|
for (const row of (data ?? []) as PageRow[]) pages.set(row.id.toLowerCase(), row);
|
|
367
564
|
}
|
|
565
|
+
const byName = (a: { name: string | null }, b: { name: string | null }) =>
|
|
566
|
+
(a.name ?? "~").localeCompare(b.name ?? "~");
|
|
368
567
|
return {
|
|
568
|
+
landing: {
|
|
569
|
+
default: settings.landingDefault,
|
|
570
|
+
pages: landingIds
|
|
571
|
+
.map((pageId) => {
|
|
572
|
+
const page = pages.get(pageId);
|
|
573
|
+
const row = settings.landings.get(pageId)!;
|
|
574
|
+
return {
|
|
575
|
+
pageId,
|
|
576
|
+
key: page?.key ?? null,
|
|
577
|
+
name: page?.name ?? null,
|
|
578
|
+
missing: !page,
|
|
579
|
+
notice: row.notice,
|
|
580
|
+
version: row.version,
|
|
581
|
+
conflict: settings.exceptions.get(pageId)?.exception === true,
|
|
582
|
+
};
|
|
583
|
+
})
|
|
584
|
+
.sort(byName),
|
|
585
|
+
rowVersions: Object.fromEntries([...settings.landings].map(([id, row]) => [id, row.version])),
|
|
586
|
+
},
|
|
369
587
|
enabled: settings.booking.enabled,
|
|
370
588
|
version: settings.booking.version,
|
|
371
589
|
exceptions: ids
|
|
@@ -443,8 +661,16 @@ async function readPublicPolicy(client: any): Promise<PublicPolicySnapshot> {
|
|
|
443
661
|
live: publicVersionId(byPage.get(row.id) ?? [], now) !== null,
|
|
444
662
|
listing: settings.listings.get(id)?.listing ?? "listed",
|
|
445
663
|
bookingException: settings.exceptions.get(id)?.exception === true,
|
|
664
|
+
bookingLanding: settings.landings.get(id)?.landing === true,
|
|
665
|
+
landingNotice: settings.landings.get(id)?.landing ? settings.landings.get(id)!.notice : null,
|
|
446
666
|
};
|
|
447
667
|
});
|
|
448
668
|
|
|
449
|
-
return {
|
|
669
|
+
return {
|
|
670
|
+
known: true,
|
|
671
|
+
bookingEnabled: settings.booking.enabled,
|
|
672
|
+
pages,
|
|
673
|
+
revalidateAt,
|
|
674
|
+
landingDefault: settings.landingDefault.notice,
|
|
675
|
+
};
|
|
450
676
|
}
|
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
* Server-only. Never import from client code.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import {
|
|
29
|
+
fetchTransport,
|
|
30
|
+
injectedFetchResolver,
|
|
31
|
+
safeRequest,
|
|
32
|
+
WEBHOOK_POLICY,
|
|
33
|
+
type Resolver,
|
|
34
|
+
type Transport,
|
|
35
|
+
} from "./outbound-http";
|
|
28
36
|
import { checkWebhookUrl } from "./webhook-url-guard";
|
|
29
37
|
|
|
30
38
|
export const POPUP_WEBHOOK_SCHEMA_VERSION = 1;
|
|
@@ -56,8 +64,15 @@ export interface OptInWebhookInput {
|
|
|
56
64
|
readonly timeoutMs?: number;
|
|
57
65
|
readonly maxResponseBytes?: number;
|
|
58
66
|
readonly maxRedirects?: number;
|
|
59
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Test seam: requests go through this fetch instead of the network. Every
|
|
69
|
+
* hop is still checked and followed by the outbound policy, but names are
|
|
70
|
+
* not looked up and the connection is not pinned. Production passes none.
|
|
71
|
+
*/
|
|
60
72
|
readonly fetchImpl?: typeof fetch;
|
|
73
|
+
/** Test seams for the pinned path: a fake resolver and transport. */
|
|
74
|
+
readonly resolver?: Resolver;
|
|
75
|
+
readonly transport?: Transport;
|
|
61
76
|
readonly now?: () => number;
|
|
62
77
|
}
|
|
63
78
|
|
|
@@ -147,71 +162,6 @@ export async function signBody(
|
|
|
147
162
|
.digest("hex");
|
|
148
163
|
}
|
|
149
164
|
|
|
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
165
|
/**
|
|
216
166
|
* Deliver one opt-in. Never throws.
|
|
217
167
|
*
|
|
@@ -224,9 +174,6 @@ export async function deliverOptInWebhook(
|
|
|
224
174
|
): Promise<DeliveryOutcome> {
|
|
225
175
|
const now = input.now ?? Date.now;
|
|
226
176
|
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
177
|
|
|
231
178
|
const check = checkWebhookUrl(input.endpoint);
|
|
232
179
|
if (!check.ok) {
|
|
@@ -242,84 +189,76 @@ export async function deliverOptInWebhook(
|
|
|
242
189
|
const timestamp = String(Math.floor(now() / 1000));
|
|
243
190
|
const signature = await signBody(input.signingSecret, timestamp, body);
|
|
244
191
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
input.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
192
|
+
// Every hop is checked, looked up, pinned and capped by the shared outbound
|
|
193
|
+
// policy; redirects are followed there, never by the runtime.
|
|
194
|
+
const network = input.fetchImpl
|
|
195
|
+
? { transport: fetchTransport(input.fetchImpl), resolver: injectedFetchResolver }
|
|
196
|
+
: { transport: input.transport, resolver: input.resolver };
|
|
197
|
+
const result = await safeRequest({
|
|
198
|
+
url: check.url.toString(),
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: {
|
|
201
|
+
"content-type": "application/json",
|
|
202
|
+
"user-agent": "cms-platform-webhooks/1",
|
|
203
|
+
"x-webhook-event": input.test === true ? "popup.opt_in.test" : "popup.opt_in",
|
|
204
|
+
"x-webhook-event-id": input.eventId,
|
|
205
|
+
// The receiver deduplicates on this. Retries of the same opt-in
|
|
206
|
+
// reuse the same value, which is what makes a retry safe.
|
|
207
|
+
"x-webhook-idempotency-key": input.eventId,
|
|
208
|
+
"x-webhook-timestamp": timestamp,
|
|
209
|
+
"x-webhook-signature": `v1=${signature}`,
|
|
210
|
+
},
|
|
211
|
+
body,
|
|
212
|
+
policy: {
|
|
213
|
+
...WEBHOOK_POLICY,
|
|
214
|
+
timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
215
|
+
maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_BYTES,
|
|
216
|
+
maxRedirects: input.maxRedirects ?? DEFAULT_MAX_REDIRECTS,
|
|
217
|
+
},
|
|
218
|
+
...network,
|
|
219
|
+
});
|
|
220
|
+
const durationMs = now() - startedAt;
|
|
273
221
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
222
|
+
if (!result.ok) {
|
|
223
|
+
switch (result.failure) {
|
|
224
|
+
case "blocked-url":
|
|
225
|
+
case "blocked-address":
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
errorCategory: "blocked-endpoint",
|
|
229
|
+
error: "The endpoint, or an address it redirected to, is not a public https address.",
|
|
230
|
+
durationMs,
|
|
231
|
+
};
|
|
232
|
+
case "cross-origin-redirect":
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
errorCategory: "blocked-endpoint",
|
|
236
|
+
error: "The endpoint redirected to another site; an opt-in is only sent to the configured address.",
|
|
237
|
+
durationMs,
|
|
238
|
+
};
|
|
239
|
+
case "too-many-redirects":
|
|
240
|
+
return { ok: false, errorCategory: "too-many-redirects", error: "The endpoint redirected too many times.", durationMs };
|
|
241
|
+
case "too-large":
|
|
242
|
+
return { ok: false, errorCategory: "too-large", error: "The endpoint's reply was too large.", durationMs };
|
|
243
|
+
case "timeout":
|
|
244
|
+
return { ok: false, errorCategory: "timeout", error: "The endpoint did not answer in time.", durationMs };
|
|
245
|
+
default:
|
|
246
|
+
return { ok: false, errorCategory: "network", error: "The endpoint could not be reached.", durationMs };
|
|
284
247
|
}
|
|
248
|
+
}
|
|
285
249
|
|
|
286
|
-
|
|
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"));
|
|
250
|
+
if (result.status < 200 || result.status >= 300) {
|
|
312
251
|
return {
|
|
313
252
|
ok: false,
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
253
|
+
status: result.status,
|
|
254
|
+
errorCategory: "http-error",
|
|
255
|
+
// Deliberately not the response body: it is attacker-influenced text
|
|
256
|
+
// that would end up in an administrator's screen and the logs.
|
|
257
|
+
error: `The endpoint answered ${result.status}.`,
|
|
258
|
+
durationMs,
|
|
319
259
|
};
|
|
320
|
-
} finally {
|
|
321
|
-
clearTimeout(timer);
|
|
322
260
|
}
|
|
261
|
+
return { ok: true, status: result.status, durationMs };
|
|
323
262
|
}
|
|
324
263
|
|
|
325
264
|
// ---------------------------------------------------------------------------
|
package/src/server/schemas.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
|
|
14
|
+
import { NOTICE_LIMITS, parseNoticeCtaHref } from "../cms/booking-landing";
|
|
15
|
+
|
|
14
16
|
/* ------------------------------------------------------------------ shared */
|
|
15
17
|
|
|
16
18
|
export const uuid = z.string().uuid();
|
|
@@ -33,6 +35,37 @@ export const cmsKey = z
|
|
|
33
35
|
|
|
34
36
|
export const listLimit = z.object({ limit: z.number().min(1).max(500).default(200) });
|
|
35
37
|
|
|
38
|
+
/* ------------------------------------------------------- booking-off notice */
|
|
39
|
+
|
|
40
|
+
/** Text per language, bounded. Content rules (limits, links) are re-checked by the core. */
|
|
41
|
+
const noticeText = (max: number) =>
|
|
42
|
+
z.record(z.string().regex(/^[a-z]{2}(-[a-z0-9]{2,8})?$/i), z.string().max(max)).refine(
|
|
43
|
+
(value) => Object.keys(value).length <= 8,
|
|
44
|
+
"Too many languages",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const noticeCtaSchema = z.object({
|
|
48
|
+
label: noticeText(NOTICE_LIMITS.ctaLabel),
|
|
49
|
+
href: z
|
|
50
|
+
.string()
|
|
51
|
+
.max(NOTICE_LIMITS.href)
|
|
52
|
+
.refine((href) => parseNoticeCtaHref(href).ok, {
|
|
53
|
+
message: "Use an address on this site (/…) or an https:// address that does not lead to booking.",
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const noticeSchema = z.object({
|
|
58
|
+
heading: noticeText(NOTICE_LIMITS.heading).optional(),
|
|
59
|
+
body: noticeText(NOTICE_LIMITS.body).optional(),
|
|
60
|
+
cta: noticeCtaSchema.nullable().optional(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const noticeOverrideSchema = z.object({
|
|
64
|
+
heading: noticeText(NOTICE_LIMITS.heading).optional(),
|
|
65
|
+
body: noticeText(NOTICE_LIMITS.body).optional(),
|
|
66
|
+
cta: z.union([noticeCtaSchema, z.literal("none")]).optional(),
|
|
67
|
+
});
|
|
68
|
+
|
|
36
69
|
/* ---------------------------------------------------------------- edit locks */
|
|
37
70
|
|
|
38
71
|
/**
|
|
@@ -148,6 +181,18 @@ export const pageSchemas = {
|
|
|
148
181
|
exception: z.boolean(),
|
|
149
182
|
expectedVersion: z.string().min(1).max(64).nullable(),
|
|
150
183
|
}),
|
|
184
|
+
/** A booking-off landing page, added, removed, or given its own notice. */
|
|
185
|
+
setBookingLanding: z.object({
|
|
186
|
+
pageId: uuid,
|
|
187
|
+
landing: z.boolean(),
|
|
188
|
+
notice: noticeOverrideSchema.nullable().optional(),
|
|
189
|
+
expectedVersion: z.string().min(1).max(64).nullable(),
|
|
190
|
+
}),
|
|
191
|
+
/** The site-wide booking-off notice. */
|
|
192
|
+
setBookingLandingDefault: z.object({
|
|
193
|
+
notice: noticeSchema,
|
|
194
|
+
expectedVersion: z.string().min(1).max(64).nullable(),
|
|
195
|
+
}),
|
|
151
196
|
duplicatePage: z.object({
|
|
152
197
|
pageId: uuid,
|
|
153
198
|
name: z.string().min(1).max(120),
|