@bygghammar/cms-platform 0.94.0 → 0.96.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 +146 -0
- package/docs/PAGES.md +72 -4
- package/package.json +8 -1
- package/src/admin/describe-log.ts +3 -0
- package/src/backoffice/BookingSettingsCard.tsx +252 -0
- package/src/backoffice/NavigationView.tsx +38 -0
- package/src/backoffice/PageBuilderView.tsx +149 -3
- package/src/backoffice/PageEditorView.tsx +47 -24
- package/src/backoffice/PagesListView.tsx +173 -57
- package/src/backoffice/SiteSettingsView.tsx +4 -0
- package/src/backoffice/StatusBadge.tsx +38 -2
- package/src/backoffice/media/ConfirmDialog.tsx +19 -1
- package/src/backoffice/routes/host.ts +24 -0
- package/src/backoffice/routes/index.tsx +54 -0
- package/src/backoffice/routes/pages.tsx +13 -0
- package/src/cms/booking-links.ts +176 -0
- package/src/cms/page-publication.ts +11 -6
- package/src/cms/page-visibility.ts +206 -0
- package/src/forms/form-audit.ts +22 -2
- package/src/html/html-tokenizer.ts +254 -0
- package/src/html/sanitize-html.ts +72 -37
- package/src/navigation/visibility.ts +235 -0
- package/src/popups/runtime.tsx +155 -4
- 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 +450 -0
- package/src/server/pages-core.ts +238 -9
- package/src/server/popup-optin-webhook-core.ts +79 -140
- package/src/server/published-cache.ts +23 -3
- package/src/server/schemas.ts +17 -0
- package/src/site/PageRenderer.tsx +34 -5
- package/src/site/SiteFooter.tsx +11 -2
- package/src/site/SiteHeader.tsx +41 -21
- package/src/site/link-visibility.tsx +28 -0
- package/src/site/preview-messaging.ts +232 -0
- package/src/site/route-aliases.ts +74 -0
- package/src/site/routes/preview-page.tsx +75 -31
- package/src/site/routes/public-page.tsx +81 -9
- package/src/util/redact-url.ts +18 -0
|
@@ -10,6 +10,15 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { variantSiblings } from "../media/srcset";
|
|
12
12
|
import { cleanFileName, splitExt, uniqueFileName } from "../media/filename";
|
|
13
|
+
import {
|
|
14
|
+
MEDIA_IMPORT_POLICY,
|
|
15
|
+
redactUrl,
|
|
16
|
+
safeRequest,
|
|
17
|
+
sniffMedia,
|
|
18
|
+
type MediaKind,
|
|
19
|
+
type Resolver,
|
|
20
|
+
type Transport,
|
|
21
|
+
} from "./outbound-http";
|
|
13
22
|
import type { MediaImportResult, MediaUsage, MediaUsageReport } from "../media/usage-types";
|
|
14
23
|
|
|
15
24
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
@@ -42,6 +51,8 @@ export type MediaImportHost = MediaUsageHost & {
|
|
|
42
51
|
admin: any;
|
|
43
52
|
/** User-Agent sent when fetching remote media. */
|
|
44
53
|
userAgent?: string;
|
|
54
|
+
/** Test seams: a fake resolver and transport instead of the network. */
|
|
55
|
+
network?: { resolver?: Resolver; transport?: Transport };
|
|
45
56
|
};
|
|
46
57
|
|
|
47
58
|
const URL_RE = /https?:\/\/[^\s"'<>)]+/gi;
|
|
@@ -154,28 +165,55 @@ export function remoteUrlCandidates(rawUrl: string): string[] {
|
|
|
154
165
|
return Array.from(candidates);
|
|
155
166
|
}
|
|
156
167
|
|
|
157
|
-
|
|
168
|
+
/**
|
|
169
|
+
* Download one remote media file, under the shared outbound policy: public
|
|
170
|
+
* addresses only (looked up, pinned, every redirect hop checked), one
|
|
171
|
+
* deadline, a capped size. What it is comes from its first bytes — never from
|
|
172
|
+
* the server's content type or the address's extension.
|
|
173
|
+
*/
|
|
174
|
+
async function fetchRemoteMedia(
|
|
175
|
+
rawUrl: string,
|
|
176
|
+
userAgent: string,
|
|
177
|
+
network: MediaImportHost["network"],
|
|
178
|
+
): Promise<{ bytes: Uint8Array; kind: MediaKind; resolvedUrl: string } | { error: string }> {
|
|
158
179
|
let lastStatus: number | null = null;
|
|
159
180
|
let lastError: string | null = null;
|
|
160
181
|
|
|
161
182
|
const headers = {
|
|
162
183
|
"User-Agent": userAgent,
|
|
163
|
-
Accept:
|
|
164
|
-
"image/avif,image/webp,image/apng,image/svg+xml,image/*,video/*,application/pdf,*/*;q=0.8",
|
|
184
|
+
Accept: "image/avif,image/webp,image/apng,image/*,video/*,audio/*,application/pdf;q=0.9",
|
|
165
185
|
};
|
|
166
186
|
|
|
167
187
|
for (const url of remoteUrlCandidates(rawUrl)) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
188
|
+
const res = await safeRequest({
|
|
189
|
+
url,
|
|
190
|
+
method: "GET",
|
|
191
|
+
headers,
|
|
192
|
+
policy: MEDIA_IMPORT_POLICY,
|
|
193
|
+
resolver: network?.resolver,
|
|
194
|
+
transport: network?.transport,
|
|
195
|
+
});
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
// Every candidate is a spelling of the same address: a refused one is refused for all.
|
|
198
|
+
if (res.failure === "blocked-url" || res.failure === "blocked-address") {
|
|
199
|
+
return { error: `${res.message} (${redactUrl(rawUrl)})` };
|
|
200
|
+
}
|
|
201
|
+
lastError = res.message;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (res.status < 200 || res.status >= 300) {
|
|
205
|
+
lastStatus = res.status;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const kind = sniffMedia(res.body);
|
|
209
|
+
if (!kind) {
|
|
210
|
+
return { error: `Not an image, video, audio or PDF file (${redactUrl(rawUrl)}).` };
|
|
174
211
|
}
|
|
212
|
+
return { bytes: res.body, kind, resolvedUrl: url };
|
|
175
213
|
}
|
|
176
214
|
|
|
177
215
|
const reason = lastStatus ? `Fetch failed: ${lastStatus}` : (lastError ?? "Fetch failed");
|
|
178
|
-
return { error: `${reason}
|
|
216
|
+
return { error: `${reason} (${redactUrl(rawUrl)})` };
|
|
179
217
|
}
|
|
180
218
|
|
|
181
219
|
/** Scan every CMS content column and report which library files are used where. */
|
|
@@ -417,19 +455,21 @@ export async function importRemoteUrlCore(
|
|
|
417
455
|
const remote = await fetchRemoteMedia(
|
|
418
456
|
data.url,
|
|
419
457
|
host.userAgent ?? "Mozilla/5.0 (compatible; CmsMediaImporter/1.0)",
|
|
458
|
+
host.network,
|
|
420
459
|
);
|
|
421
460
|
if ("error" in remote) {
|
|
422
461
|
return { path: "", url: "", skipped: true, error: remote.error ?? "Fetch failed" };
|
|
423
462
|
}
|
|
424
463
|
|
|
425
|
-
|
|
426
|
-
const
|
|
464
|
+
// Stored as what the bytes are: their sniffed type and its extension.
|
|
465
|
+
const contentType = remote.kind.mime;
|
|
466
|
+
const buffer = remote.bytes;
|
|
427
467
|
const urlObj = new URL(remote.resolvedUrl);
|
|
428
468
|
const rawName =
|
|
429
469
|
data.filename ||
|
|
430
470
|
urlObj.pathname.split("/").filter(Boolean).map(safeDecodeURIComponent).pop() ||
|
|
431
471
|
"imported-file";
|
|
432
|
-
const clean = cleanFileName(rawName, "imported-file")
|
|
472
|
+
const clean = `${splitExt(cleanFileName(rawName.replace(/[\\/]/g, "-"), "imported-file")).base}${remote.kind.ext}`;
|
|
433
473
|
|
|
434
474
|
const all = await host.storage.list();
|
|
435
475
|
const existing = all
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which network addresses a server-side request may connect to.
|
|
3
|
+
*
|
|
4
|
+
* Only globally routable unicast addresses. Everything that names this
|
|
5
|
+
* machine, its private network, a cloud metadata service, a multicast group
|
|
6
|
+
* or a reserved block is refused — in IPv4 and IPv6, and in every IPv6 form
|
|
7
|
+
* that carries an IPv4 address inside it (mapped, compatible, NAT64, 6to4).
|
|
8
|
+
*
|
|
9
|
+
* Pure and runtime-independent: it only reads address text.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** IPv4 blocks that are not public destinations: [network, prefix length]. */
|
|
13
|
+
const BLOCKED_V4: ReadonlyArray<readonly [string, number]> = [
|
|
14
|
+
["0.0.0.0", 8], // "this network"
|
|
15
|
+
["10.0.0.0", 8], // private
|
|
16
|
+
["100.64.0.0", 10], // carrier-grade NAT
|
|
17
|
+
["127.0.0.0", 8], // loopback
|
|
18
|
+
["169.254.0.0", 16], // link-local, incl. cloud metadata 169.254.169.254
|
|
19
|
+
["172.16.0.0", 12], // private
|
|
20
|
+
["192.0.0.0", 24], // IETF protocol assignments
|
|
21
|
+
["192.0.2.0", 24], // documentation
|
|
22
|
+
["192.88.99.0", 24], // 6to4 relay anycast
|
|
23
|
+
["192.168.0.0", 16], // private
|
|
24
|
+
["198.18.0.0", 15], // benchmarking
|
|
25
|
+
["198.51.100.0", 24], // documentation
|
|
26
|
+
["203.0.113.0", 24], // documentation
|
|
27
|
+
["224.0.0.0", 4], // multicast
|
|
28
|
+
["240.0.0.0", 4], // reserved, incl. broadcast
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** IPv6 blocks that are not public destinations. */
|
|
32
|
+
const BLOCKED_V6: ReadonlyArray<readonly [string, number]> = [
|
|
33
|
+
["::", 128], // unspecified
|
|
34
|
+
["::1", 128], // loopback
|
|
35
|
+
["::ffff:0:0", 96], // IPv4-mapped
|
|
36
|
+
["::", 96], // IPv4-compatible (deprecated)
|
|
37
|
+
["64:ff9b::", 96], // NAT64
|
|
38
|
+
["64:ff9b:1::", 48], // local-use NAT64
|
|
39
|
+
["100::", 64], // discard
|
|
40
|
+
["2001::", 23], // IETF protocol assignments, incl. Teredo
|
|
41
|
+
["2001:db8::", 32], // documentation
|
|
42
|
+
["2002::", 16], // 6to4
|
|
43
|
+
["3fff::", 20], // documentation
|
|
44
|
+
["5f00::", 16], // segment routing
|
|
45
|
+
["fc00::", 7], // unique local, incl. fd00:ec2::254 (cloud metadata)
|
|
46
|
+
["fe80::", 10], // link-local
|
|
47
|
+
["fec0::", 10], // site-local (deprecated)
|
|
48
|
+
["ff00::", 8], // multicast
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** An IPv4 address as a 32-bit number, or null. Dotted decimal only (as DNS and URL produce). */
|
|
52
|
+
export function parseIPv4(text: string): number | null {
|
|
53
|
+
const parts = text.split(".");
|
|
54
|
+
if (parts.length !== 4) return null;
|
|
55
|
+
let value = 0;
|
|
56
|
+
for (const part of parts) {
|
|
57
|
+
if (!/^(0|[1-9][0-9]{0,2})$/.test(part)) return null;
|
|
58
|
+
const octet = Number(part);
|
|
59
|
+
if (octet > 255) return null;
|
|
60
|
+
value = value * 256 + octet;
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** An IPv6 address as a 128-bit number, or null. Zone ids are refused. */
|
|
66
|
+
export function parseIPv6(text: string): bigint | null {
|
|
67
|
+
let address = text.trim().toLowerCase();
|
|
68
|
+
if (address.startsWith("[") && address.endsWith("]")) address = address.slice(1, -1);
|
|
69
|
+
if (address === "" || address.includes("%")) return null;
|
|
70
|
+
|
|
71
|
+
// An IPv4 tail ("::ffff:1.2.3.4") becomes two hex groups.
|
|
72
|
+
const lastColon = address.lastIndexOf(":");
|
|
73
|
+
const tail = address.slice(lastColon + 1);
|
|
74
|
+
if (tail.includes(".")) {
|
|
75
|
+
const v4 = parseIPv4(tail);
|
|
76
|
+
if (v4 === null) return null;
|
|
77
|
+
address = `${address.slice(0, lastColon + 1)}${(v4 >>> 16).toString(16)}:${(v4 & 0xffff).toString(16)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const halves = address.split("::");
|
|
81
|
+
if (halves.length > 2) return null;
|
|
82
|
+
const parse = (half: string) => (half === "" ? [] : half.split(":"));
|
|
83
|
+
const head = parse(halves[0] ?? "");
|
|
84
|
+
const rest = halves.length === 2 ? parse(halves[1] ?? "") : [];
|
|
85
|
+
const zeros = 8 - head.length - rest.length;
|
|
86
|
+
// "::" stands for at least one group of zeros; without it there are exactly eight groups.
|
|
87
|
+
if (halves.length === 2 ? zeros < 1 : zeros !== 0) return null;
|
|
88
|
+
const groups = [...head, ...Array<string>(halves.length === 2 ? zeros : 0).fill("0"), ...rest];
|
|
89
|
+
|
|
90
|
+
let value = 0n;
|
|
91
|
+
for (const group of groups) {
|
|
92
|
+
if (!/^[0-9a-f]{1,4}$/.test(group)) return null;
|
|
93
|
+
value = (value << 16n) | BigInt(Number.parseInt(group, 16));
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function inV4Block(address: number, [network, prefix]: readonly [string, number]): boolean {
|
|
99
|
+
const base = parseIPv4(network)!;
|
|
100
|
+
if (prefix === 0) return true;
|
|
101
|
+
const shift = 32 - prefix;
|
|
102
|
+
return Math.floor(address / 2 ** shift) === Math.floor(base / 2 ** shift);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function inV6Block(address: bigint, [network, prefix]: readonly [string, number]): boolean {
|
|
106
|
+
const base = parseIPv6(network)!;
|
|
107
|
+
const shift = BigInt(128 - prefix);
|
|
108
|
+
return address >> shift === base >> shift;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type AddressFamily = 4 | 6;
|
|
112
|
+
|
|
113
|
+
/** The family of an address literal, or null when it is not one. */
|
|
114
|
+
export function addressFamily(text: string): AddressFamily | null {
|
|
115
|
+
if (parseIPv4(text) !== null) return 4;
|
|
116
|
+
if (parseIPv6(text) !== null) return 6;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True only for a globally routable unicast address. Anything unparseable is refused. */
|
|
121
|
+
export function isPublicAddress(text: string): boolean {
|
|
122
|
+
const v4 = parseIPv4(text);
|
|
123
|
+
if (v4 !== null) return !BLOCKED_V4.some((block) => inV4Block(v4, block));
|
|
124
|
+
const v6 = parseIPv6(text);
|
|
125
|
+
if (v6 !== null) {
|
|
126
|
+
if (BLOCKED_V6.some((block) => inV6Block(v6, block))) return false;
|
|
127
|
+
// Only the global unicast range 2000::/3 is public.
|
|
128
|
+
return v6 >> 125n === 1n;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|