@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.10
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 +489 -0
- package/README.md +16 -2
- package/docs/EDIT-LOCKS.md +213 -0
- package/docs/POPUPS.md +102 -0
- package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
- package/package.json +31 -3
- package/scripts/create-cms-site.mjs +5 -4
- package/scripts/sync-migrations.mjs +27 -6
- package/src/backoffice/ContactDetailsView.tsx +19 -5
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/EditLock.tsx +513 -0
- package/src/backoffice/EmailSettingsView.tsx +99 -0
- package/src/backoffice/FormEditorView.tsx +531 -512
- package/src/backoffice/FormSettingsView.tsx +16 -4
- package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
- package/src/backoffice/NavigationView.tsx +35 -11
- package/src/backoffice/PageBuilderView.tsx +51 -16
- package/src/backoffice/RichTextEditor.tsx +50 -14
- package/src/backoffice/SectionEditorView.tsx +18 -6
- package/src/backoffice/SectionFormView.tsx +184 -31
- package/src/backoffice/SiteSettingsView.tsx +14 -15
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/forms/VersionHistory.tsx +4 -2
- package/src/backoffice/inspectors/blocks.tsx +22 -1
- package/src/backoffice/inspectors/fields.tsx +21 -0
- package/src/backoffice/routes/host.ts +58 -7
- package/src/backoffice/routes/index.tsx +117 -15
- package/src/backoffice/routes/pages.tsx +10 -6
- package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/nav-model.ts +32 -23
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/components/CtaBannerBlock.tsx +18 -12
- package/src/blocks/components/HeroFullBlock.tsx +25 -12
- package/src/blocks/components/MediaSplitBlock.tsx +91 -41
- package/src/blocks/components/PriceTableBlock.tsx +123 -89
- package/src/blocks/components/RichTextBlock.tsx +26 -4
- package/src/blocks/index.ts +8 -0
- package/src/blocks/optional-content.ts +82 -0
- package/src/blocks/registry.tsx +1 -0
- package/src/blocks/types.ts +6 -0
- package/src/cms/edit-lock.ts +491 -0
- package/src/config/modules.ts +208 -0
- package/src/config/site-config.ts +9 -31
- package/src/forms/FormRenderer.tsx +62 -3
- package/src/forms/submission-result.ts +35 -0
- package/src/forms/success-listeners.ts +46 -0
- package/src/host/index.tsx +27 -1
- package/src/modules/index.ts +28 -0
- package/src/modules/public-runtime.tsx +159 -0
- package/src/navigation/index.ts +29 -0
- package/src/popups/admin.tsx +123 -0
- package/src/popups/cms-bridges.ts +240 -0
- package/src/popups/destinations.tsx +147 -0
- package/src/popups/host.ts +91 -0
- package/src/popups/media-bridge.tsx +84 -0
- package/src/popups/register.ts +23 -0
- package/src/popups/runtime.tsx +128 -0
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +22 -5
- package/src/server/domains-core.ts +124 -107
- package/src/server/edit-lock-adapter.ts +91 -0
- package/src/server/edit-lock-supabase-adapter.ts +135 -0
- package/src/server/edit-locks-core.ts +286 -0
- package/src/server/forms-core.ts +37 -2
- package/src/server/injections-core.ts +230 -49
- package/src/server/page-seo-save-core.ts +27 -0
- package/src/server/pages-core.ts +108 -11
- package/src/server/popup-optin-trigger.ts +111 -0
- package/src/server/popup-optin-webhook-core.ts +363 -0
- package/src/server/provider-secrets.ts +100 -4
- package/src/server/schemas.ts +74 -9
- package/src/server/webhook-url-guard.ts +199 -0
- package/src/site/SiteHeader.tsx +11 -5
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/tracking/injections.ts +52 -9
- package/src/types/css-modules.d.ts +6 -0
- package/src/ui/Reveal.tsx +74 -9
package/src/server/schemas.ts
CHANGED
|
@@ -33,6 +33,47 @@ export const cmsKey = z
|
|
|
33
33
|
|
|
34
34
|
export const listLimit = z.object({ limit: z.number().min(1).max(500).default(200) });
|
|
35
35
|
|
|
36
|
+
/* ---------------------------------------------------------------- edit locks */
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Identifies one editing session — one browser tab, across reloads. Opaque to
|
|
40
|
+
* the server, which only ever compares it with the one stored on the lock.
|
|
41
|
+
*/
|
|
42
|
+
export const editSessionId = z.string().min(1).max(120);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What kind of thing is locked. Deliberately a validated string rather than an
|
|
46
|
+
* enum so a host can lock a record type this package has never heard of.
|
|
47
|
+
*/
|
|
48
|
+
export const lockResourceType = z
|
|
49
|
+
.string()
|
|
50
|
+
.min(1)
|
|
51
|
+
.max(64)
|
|
52
|
+
.regex(/^[a-z][a-z0-9_]*$/);
|
|
53
|
+
|
|
54
|
+
/** Which one. A uuid for most records, a CMS key for others. */
|
|
55
|
+
export const lockResourceId = z.string().min(1).max(200);
|
|
56
|
+
|
|
57
|
+
export const editLockSchemas = {
|
|
58
|
+
ref: z.object({ resourceType: lockResourceType, resourceId: lockResourceId }),
|
|
59
|
+
acquire: z.object({
|
|
60
|
+
resourceType: lockResourceType,
|
|
61
|
+
resourceId: lockResourceId,
|
|
62
|
+
sessionId: editSessionId,
|
|
63
|
+
ttlSeconds: z.number().int().min(15).max(3600).optional(),
|
|
64
|
+
}),
|
|
65
|
+
release: z.object({
|
|
66
|
+
resourceType: lockResourceType,
|
|
67
|
+
resourceId: lockResourceId,
|
|
68
|
+
sessionId: editSessionId,
|
|
69
|
+
}),
|
|
70
|
+
status: z.object({
|
|
71
|
+
resourceType: lockResourceType,
|
|
72
|
+
resourceId: lockResourceId,
|
|
73
|
+
sessionId: editSessionId.optional(),
|
|
74
|
+
}),
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
36
77
|
/* --------------------------------------------------------------- page builder */
|
|
37
78
|
|
|
38
79
|
/** One block instance as it travels over the wire. */
|
|
@@ -46,17 +87,22 @@ export const blockInstanceSchema = z.object({
|
|
|
46
87
|
|
|
47
88
|
export const pageSchemas = {
|
|
48
89
|
key: z.object({ key: z.string() }),
|
|
49
|
-
pageId: z.object({ pageId: uuid }),
|
|
50
|
-
versionId: z.object({ versionId: uuid }),
|
|
51
|
-
sectionId: z.object({ sectionId: uuid }),
|
|
52
|
-
publicationId: z.object({ publicationId: uuid }),
|
|
90
|
+
pageId: z.object({ pageId: uuid, editSessionId: editSessionId.optional() }),
|
|
91
|
+
versionId: z.object({ versionId: uuid, editSessionId: editSessionId.optional() }),
|
|
92
|
+
sectionId: z.object({ sectionId: uuid, editSessionId: editSessionId.optional() }),
|
|
93
|
+
publicationId: z.object({ publicationId: uuid, editSessionId: editSessionId.optional() }),
|
|
53
94
|
createPage: z.object({ key: cmsKey, name: z.string().min(1).max(120) }),
|
|
54
|
-
renamePage: z.object({
|
|
95
|
+
renamePage: z.object({
|
|
96
|
+
pageId: uuid,
|
|
97
|
+
name: z.string().min(1).max(120),
|
|
98
|
+
editSessionId: editSessionId.optional(),
|
|
99
|
+
}),
|
|
55
100
|
savePageVersion: z.object({
|
|
56
101
|
pageId: uuid,
|
|
57
102
|
blocks: z.array(blockInstanceSchema),
|
|
58
103
|
publish: z.boolean().optional(),
|
|
59
104
|
note: z.string().max(500).optional(),
|
|
105
|
+
editSessionId: editSessionId.optional(),
|
|
60
106
|
}),
|
|
61
107
|
createGlobalSection: z.object({
|
|
62
108
|
key: cmsKey,
|
|
@@ -64,18 +110,28 @@ export const pageSchemas = {
|
|
|
64
110
|
blocks: z.array(blockInstanceSchema).optional(),
|
|
65
111
|
kind: z.enum(["block", "section"]).optional(),
|
|
66
112
|
}),
|
|
67
|
-
setGlobalSectionKind: z.object({
|
|
113
|
+
setGlobalSectionKind: z.object({
|
|
114
|
+
sectionId: uuid,
|
|
115
|
+
kind: z.enum(["block", "section"]),
|
|
116
|
+
editSessionId: editSessionId.optional(),
|
|
117
|
+
}),
|
|
68
118
|
saveGlobalSectionVersion: z.object({
|
|
69
119
|
sectionId: uuid,
|
|
70
120
|
blocks: z.array(blockInstanceSchema),
|
|
71
121
|
note: z.string().max(500).optional(),
|
|
122
|
+
editSessionId: editSessionId.optional(),
|
|
123
|
+
}),
|
|
124
|
+
renameGlobalSection: z.object({
|
|
125
|
+
sectionId: uuid,
|
|
126
|
+
name: z.string().min(1).max(120),
|
|
127
|
+
editSessionId: editSessionId.optional(),
|
|
72
128
|
}),
|
|
73
|
-
renameGlobalSection: z.object({ sectionId: uuid, name: z.string().min(1).max(120) }),
|
|
74
129
|
schedulePageVersion: z.object({
|
|
75
130
|
versionId: uuid,
|
|
76
131
|
publishAt: z.string().datetime(),
|
|
77
132
|
unpublishAt: z.string().datetime().nullable().optional(),
|
|
78
133
|
note: z.string().max(500).optional(),
|
|
134
|
+
editSessionId: editSessionId.optional(),
|
|
79
135
|
}),
|
|
80
136
|
} as const;
|
|
81
137
|
|
|
@@ -233,8 +289,16 @@ export function pageSeoSchemas<P extends string, L extends string>(input: {
|
|
|
233
289
|
return {
|
|
234
290
|
seo,
|
|
235
291
|
slugs,
|
|
236
|
-
save: z.object({
|
|
237
|
-
|
|
292
|
+
save: z.object({
|
|
293
|
+
pageKey: z.enum(input.pageKeys as unknown as [P, ...P[]]),
|
|
294
|
+
slugs,
|
|
295
|
+
seo,
|
|
296
|
+
editSessionId: editSessionId.optional(),
|
|
297
|
+
}),
|
|
298
|
+
delete: z.object({
|
|
299
|
+
pageKey: z.enum(input.pageKeys as unknown as [P, ...P[]]),
|
|
300
|
+
editSessionId: editSessionId.optional(),
|
|
301
|
+
}),
|
|
238
302
|
} as const;
|
|
239
303
|
}
|
|
240
304
|
|
|
@@ -242,4 +306,5 @@ export type PageSeoSaveInput = {
|
|
|
242
306
|
pageKey: string;
|
|
243
307
|
slugs: Record<string, unknown>;
|
|
244
308
|
seo: Record<string, unknown>;
|
|
309
|
+
editSessionId?: string;
|
|
245
310
|
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an outbound webhook is allowed to go.
|
|
3
|
+
*
|
|
4
|
+
* A webhook endpoint is a URL an administrator types in, and the server then
|
|
5
|
+
* fetches it. That is a server-side request forgery primitive unless something
|
|
6
|
+
* stops it: without this guard, "https://169.254.169.254/latest/meta-data/"
|
|
7
|
+
* turns the CMS into a proxy for reading the host's cloud credentials, and
|
|
8
|
+
* "https://127.0.0.1:5432" turns it into a port scanner for the private
|
|
9
|
+
* network the server sits in.
|
|
10
|
+
*
|
|
11
|
+
* So the rule is an allowlist of shapes, not a blocklist of strings:
|
|
12
|
+
* - https only, never http, never file:, gopher:, data: or anything else;
|
|
13
|
+
* - no credentials in the URL;
|
|
14
|
+
* - a hostname that is not a literal IP address in any private, loopback,
|
|
15
|
+
* link-local, unique-local or otherwise special range;
|
|
16
|
+
* - no hostname that is a known cloud-metadata name.
|
|
17
|
+
*
|
|
18
|
+
* DNS is deliberately NOT resolved here. A name can resolve to a public
|
|
19
|
+
* address at validation time and a private one at delivery time, so the
|
|
20
|
+
* defence that matters is re-checking every hop at fetch time — which is what
|
|
21
|
+
* `safeFetch` does, including on each redirect.
|
|
22
|
+
*
|
|
23
|
+
* Server-only. Never import from client code.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type UrlRejection =
|
|
27
|
+
| "not-a-url"
|
|
28
|
+
| "not-https"
|
|
29
|
+
| "has-credentials"
|
|
30
|
+
| "blocked-host"
|
|
31
|
+
| "blocked-ip"
|
|
32
|
+
| "too-long";
|
|
33
|
+
|
|
34
|
+
export type UrlCheck =
|
|
35
|
+
| { readonly ok: true; readonly url: URL }
|
|
36
|
+
| {
|
|
37
|
+
readonly ok: false;
|
|
38
|
+
readonly reason: UrlRejection;
|
|
39
|
+
/** Safe to show an administrator: never contains a resolved address. */
|
|
40
|
+
readonly message: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const MAX_URL_LENGTH = 2048;
|
|
44
|
+
|
|
45
|
+
/** Hostnames that address the machine or its cloud environment, never a customer's server. */
|
|
46
|
+
const BLOCKED_HOSTNAMES: ReadonlySet<string> = new Set([
|
|
47
|
+
"localhost",
|
|
48
|
+
"localhost.localdomain",
|
|
49
|
+
"ip6-localhost",
|
|
50
|
+
"ip6-loopback",
|
|
51
|
+
// Cloud instance metadata. Reaching any of these leaks the host's own
|
|
52
|
+
// credentials, which is the single highest-value target of an SSRF.
|
|
53
|
+
"metadata",
|
|
54
|
+
"metadata.google.internal",
|
|
55
|
+
"metadata.goog",
|
|
56
|
+
"instance-data",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Suffixes that only ever name something inside the network the server is on. */
|
|
60
|
+
const BLOCKED_SUFFIXES: readonly string[] = [
|
|
61
|
+
".localhost",
|
|
62
|
+
".local",
|
|
63
|
+
".internal",
|
|
64
|
+
".intranet",
|
|
65
|
+
".private",
|
|
66
|
+
".corp",
|
|
67
|
+
".home",
|
|
68
|
+
".lan",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
function parseIPv4(host: string): readonly number[] | null {
|
|
72
|
+
const parts = host.split(".");
|
|
73
|
+
if (parts.length !== 4) return null;
|
|
74
|
+
const octets = parts.map((part) => {
|
|
75
|
+
if (!/^\d{1,3}$/.test(part)) return -1;
|
|
76
|
+
const value = Number(part);
|
|
77
|
+
return value >= 0 && value <= 255 ? value : -1;
|
|
78
|
+
});
|
|
79
|
+
return octets.some((o) => o < 0) ? null : octets;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every IPv4 range that is not a routable, public destination. */
|
|
83
|
+
function isBlockedIPv4(octets: readonly number[]): boolean {
|
|
84
|
+
const [a, b] = octets as [number, number, number, number];
|
|
85
|
+
if (a === 0) return true; // "this network"
|
|
86
|
+
if (a === 10) return true; // private
|
|
87
|
+
if (a === 127) return true; // loopback
|
|
88
|
+
if (a === 169 && b === 254) return true; // link-local, incl. cloud metadata
|
|
89
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
90
|
+
if (a === 192 && b === 168) return true; // private
|
|
91
|
+
if (a === 192 && b === 0) return true; // IETF protocol assignments
|
|
92
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
|
|
93
|
+
if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking
|
|
94
|
+
if (a >= 224) return true; // multicast and reserved, incl. 255.255.255.255
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* IPv6, including the forms that smuggle an IPv4 address inside one.
|
|
100
|
+
*
|
|
101
|
+
* `::ffff:127.0.0.1` and `::ffff:7f00:1` are both loopback wearing a different
|
|
102
|
+
* hat, and a guard that only looked at the textual prefix would pass them.
|
|
103
|
+
*/
|
|
104
|
+
function isBlockedIPv6(host: string): boolean {
|
|
105
|
+
const address = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
106
|
+
if (address === "::" || address === "::1") return true;
|
|
107
|
+
|
|
108
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(address);
|
|
109
|
+
if (mapped !== null) {
|
|
110
|
+
const octets = parseIPv4(mapped[1] as string);
|
|
111
|
+
return octets === null || isBlockedIPv4(octets);
|
|
112
|
+
}
|
|
113
|
+
// ::ffff:7f00:1 — the same address written as hex groups.
|
|
114
|
+
const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(address);
|
|
115
|
+
if (hexMapped !== null) {
|
|
116
|
+
const high = Number.parseInt(hexMapped[1] as string, 16);
|
|
117
|
+
const low = Number.parseInt(hexMapped[2] as string, 16);
|
|
118
|
+
const octets = [high >> 8, high & 0xff, low >> 8, low & 0xff];
|
|
119
|
+
return isBlockedIPv4(octets);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (/^fe[89ab][0-9a-f]:/.test(address)) return true; // link-local
|
|
123
|
+
if (/^f[cd][0-9a-f]{2}:/.test(address)) return true; // unique local
|
|
124
|
+
if (/^ff[0-9a-f]{2}:/.test(address)) return true; // multicast
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether this hostname is one the server must never be told to fetch. */
|
|
129
|
+
export function isBlockedHostname(hostname: string): boolean {
|
|
130
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
131
|
+
if (host === "") return true;
|
|
132
|
+
if (BLOCKED_HOSTNAMES.has(host)) return true;
|
|
133
|
+
if (BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
|
|
134
|
+
|
|
135
|
+
const v4 = parseIPv4(host);
|
|
136
|
+
if (v4 !== null) return isBlockedIPv4(v4);
|
|
137
|
+
if (host.includes(":")) return isBlockedIPv6(host);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate a webhook endpoint.
|
|
143
|
+
*
|
|
144
|
+
* Used both when an administrator saves a destination and again on every hop
|
|
145
|
+
* of the actual request — a URL that was fine at save time can redirect
|
|
146
|
+
* somewhere that is not.
|
|
147
|
+
*/
|
|
148
|
+
export function checkWebhookUrl(raw: string): UrlCheck {
|
|
149
|
+
const input = (raw ?? "").trim();
|
|
150
|
+
if (input === "")
|
|
151
|
+
return { ok: false, reason: "not-a-url", message: "Enter an endpoint." };
|
|
152
|
+
if (input.length > MAX_URL_LENGTH) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
reason: "too-long",
|
|
156
|
+
message: "That endpoint is too long.",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let url: URL;
|
|
161
|
+
try {
|
|
162
|
+
url = new URL(input);
|
|
163
|
+
} catch {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: "not-a-url",
|
|
167
|
+
message: "That is not a valid URL.",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (url.protocol !== "https:") {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: "not-https",
|
|
175
|
+
message:
|
|
176
|
+
"The endpoint must start with https:// — an unencrypted webhook would expose the data it carries.",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (url.username !== "" || url.password !== "") {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
reason: "has-credentials",
|
|
183
|
+
message:
|
|
184
|
+
"Remove the username and password from the URL. Use the signing secret instead.",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (isBlockedHostname(url.hostname)) {
|
|
188
|
+
const looksLikeIp =
|
|
189
|
+
parseIPv4(url.hostname) !== null || url.hostname.includes(":");
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
reason: looksLikeIp ? "blocked-ip" : "blocked-host",
|
|
193
|
+
message:
|
|
194
|
+
"That address is inside this server's own network. A webhook must point at a public endpoint.",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { ok: true, url };
|
|
199
|
+
}
|
package/src/site/SiteHeader.tsx
CHANGED
|
@@ -12,10 +12,14 @@ import { ChevronDown, Menu, X } from "lucide-react";
|
|
|
12
12
|
|
|
13
13
|
import { useT } from "../i18n";
|
|
14
14
|
import type { LocalizedString } from "../i18n/types";
|
|
15
|
-
import { navigationPanelId, type NavigationItem } from "../navigation";
|
|
15
|
+
import { navigationPanelId, renderableNavigation, type NavigationItem } from "../navigation";
|
|
16
16
|
|
|
17
17
|
export type SiteHeaderProps = {
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Top-level navigation, optionally with one level of children. Entries with
|
|
20
|
+
* no label or no link in the current locale are left out; see
|
|
21
|
+
* `renderableNavigation`.
|
|
22
|
+
*/
|
|
19
23
|
nav: readonly NavigationItem[];
|
|
20
24
|
/** Brand name, used as the logo link's accessible label. */
|
|
21
25
|
brandName: LocalizedString;
|
|
@@ -176,7 +180,7 @@ export function SiteHeader({
|
|
|
176
180
|
backoffice,
|
|
177
181
|
lightTextPaths = ["/"],
|
|
178
182
|
}: SiteHeaderProps) {
|
|
179
|
-
const { t } = useT();
|
|
183
|
+
const { t, locale } = useT();
|
|
180
184
|
const pathname = useRouterState({ select: (r) => r.location.pathname });
|
|
181
185
|
|
|
182
186
|
const [scrolled, setScrolled] = useState(false);
|
|
@@ -230,6 +234,8 @@ export function SiteHeader({
|
|
|
230
234
|
const logo = onLightSurface && logoDarkSrc ? logoDarkSrc : logoSrc;
|
|
231
235
|
const invertLogo = onLightSurface && !logoDarkSrc && invertLogoOnLightSurface;
|
|
232
236
|
const showBackoffice = Boolean(backoffice?.signedIn);
|
|
237
|
+
// Half-finished entries are the editor's business, not a visitor's.
|
|
238
|
+
const items = renderableNavigation(nav, locale);
|
|
233
239
|
|
|
234
240
|
return (
|
|
235
241
|
<header
|
|
@@ -251,7 +257,7 @@ export function SiteHeader({
|
|
|
251
257
|
</Link>
|
|
252
258
|
|
|
253
259
|
<nav className="hidden md:flex items-center gap-10">
|
|
254
|
-
{
|
|
260
|
+
{items.map((n) => (
|
|
255
261
|
<NavEntry key={n.href} item={n} lightText={lightText} />
|
|
256
262
|
))}
|
|
257
263
|
</nav>
|
|
@@ -301,7 +307,7 @@ export function SiteHeader({
|
|
|
301
307
|
{open && (
|
|
302
308
|
<div ref={panelRef} className="md:hidden bg-background border-t border-border">
|
|
303
309
|
<nav className="container-editorial flex flex-col py-6">
|
|
304
|
-
{
|
|
310
|
+
{items.map((n) => {
|
|
305
311
|
const kids = n.children ?? [];
|
|
306
312
|
if (kids.length === 0) {
|
|
307
313
|
return (
|
package/src/site/host.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One hostname implementation for the whole platform.
|
|
3
|
+
*
|
|
4
|
+
* Locale resolution, tracking domain scope, the admin-domain comparison and
|
|
5
|
+
* every generated origin used to carry their own near-miss normalizer. They
|
|
6
|
+
* disagreed on ports, on whitespace, on casing and — worst — on `www.`, so a
|
|
7
|
+
* domain saved through the backoffice could be unmatchable by construction.
|
|
8
|
+
*
|
|
9
|
+
* Two distinct jobs, kept as two functions rather than one ambiguous one:
|
|
10
|
+
*
|
|
11
|
+
* `normalizeCanonicalDomain` is the STORAGE form. It is what the site
|
|
12
|
+
* advertises: canonical URLs, hreflang, sitemaps. It preserves whether the
|
|
13
|
+
* configured hostname starts with `www.`, because that is a real choice.
|
|
14
|
+
*
|
|
15
|
+
* `hostMatchKey` is the MATCHING form. Apex and exactly one leading `www.`
|
|
16
|
+
* are aliases of each other, so both fold to the same key.
|
|
17
|
+
*
|
|
18
|
+
* Pure module — no database, no filesystem, no network.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Longest a hostname may be, per RFC 1035. */
|
|
22
|
+
const MAX_HOST_LENGTH = 253;
|
|
23
|
+
/** Longest a single label may be, per RFC 1035. */
|
|
24
|
+
const MAX_LABEL_LENGTH = 63;
|
|
25
|
+
|
|
26
|
+
/** A label: alphanumerics and inner hyphens. Underscores are not hostnames. */
|
|
27
|
+
const LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reduce any host-ish string to its canonical stored form.
|
|
31
|
+
*
|
|
32
|
+
* Trims, strips a scheme, strips path/query/fragment, strips a trailing slash,
|
|
33
|
+
* strips a port, lowercases — and leaves a leading `www.` exactly as given.
|
|
34
|
+
*
|
|
35
|
+
* `https://WWW.Voyara.se/foo/` → `www.voyara.se`
|
|
36
|
+
* `voyara.se:443` → `voyara.se`
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeCanonicalDomain(value: string | null | undefined): string {
|
|
39
|
+
return (value ?? "")
|
|
40
|
+
.trim()
|
|
41
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")
|
|
42
|
+
.replace(/[/?#].*$/, "")
|
|
43
|
+
.replace(/\.+$/, "")
|
|
44
|
+
.replace(/:\d*$/, "")
|
|
45
|
+
.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The key two hostnames are compared on.
|
|
50
|
+
*
|
|
51
|
+
* Exactly one leading `www.` is folded away, so `voyara.se`, `www.voyara.se`
|
|
52
|
+
* and `WWW.VOYARA.SE` all resolve to the same mapping. `www.www.voyara.se`
|
|
53
|
+
* does not — only one level is an alias.
|
|
54
|
+
*/
|
|
55
|
+
export function hostMatchKey(value: string | null | undefined): string {
|
|
56
|
+
return normalizeCanonicalDomain(value).replace(/^www\./, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether a host only ever resolves on the machine serving it.
|
|
61
|
+
*
|
|
62
|
+
* Kept here rather than beside the backoffice guard because the server needs
|
|
63
|
+
* it too: a loopback address stored as the admin domain is a lockout nobody
|
|
64
|
+
* outside the box can undo, so it has to be refusable at the write.
|
|
65
|
+
*/
|
|
66
|
+
export function isLoopbackHost(value: string | null | undefined): boolean {
|
|
67
|
+
const host = normalizeCanonicalDomain(value);
|
|
68
|
+
return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "[::1]";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether two host-ish strings name the same site, apex/`www.` included. */
|
|
72
|
+
export function hostsMatch(a: string | null | undefined, b: string | null | undefined): boolean {
|
|
73
|
+
const left = hostMatchKey(a);
|
|
74
|
+
return left !== "" && left === hostMatchKey(b);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `https://<canonical host>`, or "" when there is no usable host.
|
|
79
|
+
*
|
|
80
|
+
* Validity is checked here rather than at each call site because the rows this
|
|
81
|
+
* runs on predate the write validation. A `locale_domains` table filled before
|
|
82
|
+
* the backoffice started refusing bad hostnames still holds them, and without
|
|
83
|
+
* this every caller — canonical, hreflang, the sitemap index, the sitemap
|
|
84
|
+
* urlset — would happily publish `https://not a host/om-oss` as an address.
|
|
85
|
+
*/
|
|
86
|
+
export function domainOrigin(value: string | null | undefined): string {
|
|
87
|
+
if (!isValidCanonicalDomain(value)) return "";
|
|
88
|
+
const host = normalizeCanonicalDomain(value);
|
|
89
|
+
return host ? `https://${host}` : "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type CanonicalDomainResult =
|
|
93
|
+
| { ok: true; domain: string }
|
|
94
|
+
| { ok: false; reason: CanonicalDomainProblem };
|
|
95
|
+
|
|
96
|
+
export type CanonicalDomainProblem =
|
|
97
|
+
| "empty"
|
|
98
|
+
| "credentials"
|
|
99
|
+
| "unsupported-scheme"
|
|
100
|
+
| "invalid-port"
|
|
101
|
+
| "whitespace"
|
|
102
|
+
| "too-long"
|
|
103
|
+
| "invalid-label";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate and normalize a domain a human typed into the backoffice.
|
|
107
|
+
*
|
|
108
|
+
* Normalization is generous about the forms people paste — a full URL, a
|
|
109
|
+
* trailing slash, a port, stray casing. It is deliberately not generous about
|
|
110
|
+
* input that cannot be a hostname at all: embedded credentials, a non-HTTP
|
|
111
|
+
* scheme, internal whitespace or an unusable label are rejected rather than
|
|
112
|
+
* quietly stored as something the matcher will never find.
|
|
113
|
+
*/
|
|
114
|
+
export function parseCanonicalDomain(value: string | null | undefined): CanonicalDomainResult {
|
|
115
|
+
const raw = (value ?? "").trim();
|
|
116
|
+
if (!raw) return { ok: false, reason: "empty" };
|
|
117
|
+
if (/\s/.test(raw)) return { ok: false, reason: "whitespace" };
|
|
118
|
+
|
|
119
|
+
// No hostname contains one, in any of the forms people paste.
|
|
120
|
+
if (raw.includes("@")) return { ok: false, reason: "credentials" };
|
|
121
|
+
|
|
122
|
+
// The slashes are optional so `javascript:...` is named for what it is
|
|
123
|
+
// rather than failing later as a bad port. Two things keep a hostname from
|
|
124
|
+
// reading as a scheme: a colon followed by digits is a port (`example.com:443`),
|
|
125
|
+
// and a dotted prefix is a hostname (`example.com:notaport`), since no scheme
|
|
126
|
+
// anyone types contains a dot.
|
|
127
|
+
const scheme = /^([a-z][a-z0-9+-]*):(?=\/\/|[^\d\s])/i.exec(raw);
|
|
128
|
+
if (scheme && !/^https?$/i.test(scheme[1] as string)) {
|
|
129
|
+
return { ok: false, reason: "unsupported-scheme" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const authority = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/[/?#].*$/, "");
|
|
133
|
+
const port = /:(.*)$/.exec(authority);
|
|
134
|
+
if (port && !/^\d{1,5}$/.test(port[1] as string)) return { ok: false, reason: "invalid-port" };
|
|
135
|
+
|
|
136
|
+
const domain = normalizeCanonicalDomain(raw);
|
|
137
|
+
if (!domain) return { ok: false, reason: "empty" };
|
|
138
|
+
if (domain.length > MAX_HOST_LENGTH) return { ok: false, reason: "too-long" };
|
|
139
|
+
|
|
140
|
+
const labels = domain.split(".");
|
|
141
|
+
const usable = labels.every(
|
|
142
|
+
(label) => label.length > 0 && label.length <= MAX_LABEL_LENGTH && LABEL.test(label),
|
|
143
|
+
);
|
|
144
|
+
if (!usable) return { ok: false, reason: "invalid-label" };
|
|
145
|
+
|
|
146
|
+
return { ok: true, domain };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether a typed domain would be stored as-is. */
|
|
150
|
+
export function isValidCanonicalDomain(value: string | null | undefined): boolean {
|
|
151
|
+
return parseCanonicalDomain(value).ok;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Human-readable reason a domain was rejected. */
|
|
155
|
+
export function canonicalDomainProblemMessage(reason: CanonicalDomainProblem): string {
|
|
156
|
+
switch (reason) {
|
|
157
|
+
case "empty":
|
|
158
|
+
return "Enter a domain.";
|
|
159
|
+
case "credentials":
|
|
160
|
+
return "A domain cannot contain a username or password.";
|
|
161
|
+
case "unsupported-scheme":
|
|
162
|
+
return "Only http:// and https:// addresses are accepted.";
|
|
163
|
+
case "invalid-port":
|
|
164
|
+
return "That port is not a number.";
|
|
165
|
+
case "whitespace":
|
|
166
|
+
return "A domain cannot contain spaces.";
|
|
167
|
+
case "too-long":
|
|
168
|
+
return "That domain is too long.";
|
|
169
|
+
case "invalid-label":
|
|
170
|
+
return "That is not a valid hostname.";
|
|
171
|
+
}
|
|
172
|
+
}
|