@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
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page listing and the public policy built from it.
|
|
3
|
+
*
|
|
4
|
+
* Two independent properties decide how a page appears to visitors:
|
|
5
|
+
*
|
|
6
|
+
* - **Publication** — can a visitor open it at all? Publication windows answer
|
|
7
|
+
* that (`cms/page-publication`); Unpublish closes them.
|
|
8
|
+
* - **Listing** — is it advertised? A `listed` page appears in menus, footers
|
|
9
|
+
* and the sitemap like any other. A `link-only` page is just as reachable,
|
|
10
|
+
* but only for someone who has its address: it is left out of navigation and
|
|
11
|
+
* the sitemap and asks search engines not to index it.
|
|
12
|
+
*
|
|
13
|
+
* Listing never publishes, unpublishes, replaces or reschedules anything, and
|
|
14
|
+
* nothing that publishes changes listing. It is not access control either:
|
|
15
|
+
* a link-only address can be shared, guessed or found, and `noindex` is a
|
|
16
|
+
* request to crawlers, not a removal from results that already exist.
|
|
17
|
+
*/
|
|
18
|
+
import type { PublicationState } from "./page-publication";
|
|
19
|
+
import { isPubliclyVisible } from "./page-publication";
|
|
20
|
+
|
|
21
|
+
export const PAGE_LISTINGS = ["listed", "link-only"] as const;
|
|
22
|
+
export type PageListing = (typeof PAGE_LISTINGS)[number];
|
|
23
|
+
|
|
24
|
+
/** Pages are listed unless somebody decided otherwise. */
|
|
25
|
+
export const DEFAULT_PAGE_LISTING: PageListing = "listed";
|
|
26
|
+
|
|
27
|
+
export function normalizePageListing(raw: unknown): PageListing {
|
|
28
|
+
return raw === "link-only" ? "link-only" : "listed";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The three answers the screens lead with, in a visitor's terms. */
|
|
32
|
+
export type PageVisibility = "published" | "link-only" | "offline";
|
|
33
|
+
|
|
34
|
+
export const PAGE_VISIBILITY_LABELS: Record<PageVisibility, string> = {
|
|
35
|
+
published: "Published",
|
|
36
|
+
"link-only": "Link only",
|
|
37
|
+
offline: "Offline",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const PAGE_VISIBILITY_DESCRIPTIONS: Record<PageVisibility, string> = {
|
|
41
|
+
published: "Visible on the public website and listed in menus, footer and sitemap.",
|
|
42
|
+
"link-only":
|
|
43
|
+
"Visible to anyone who has the exact address, but left out of menus, footer and sitemap, " +
|
|
44
|
+
"and search engines are asked not to index it. It is not private: the address can be shared.",
|
|
45
|
+
offline: "Not on the public website. Visitors get “page not found”.",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The headline answer for one page, from both properties.
|
|
50
|
+
*
|
|
51
|
+
* Only the headline: schedules and editorial status keep their own badges,
|
|
52
|
+
* because "offline" says nothing about a window that opens tomorrow.
|
|
53
|
+
*/
|
|
54
|
+
export function pageVisibility(publication: PublicationState, listing: PageListing): PageVisibility {
|
|
55
|
+
if (!isPubliclyVisible(publication)) return "offline";
|
|
56
|
+
return listing === "link-only" ? "link-only" : "published";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ------------------------------------------------------------------------ */
|
|
60
|
+
/* Confirmation copy */
|
|
61
|
+
/* ------------------------------------------------------------------------ */
|
|
62
|
+
|
|
63
|
+
export type VisibilityConfirmation = {
|
|
64
|
+
title: string;
|
|
65
|
+
body: string[];
|
|
66
|
+
confirmLabel: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** What Make link-only does to this page, said before it happens. */
|
|
70
|
+
export function linkOnlyConfirmation(page: {
|
|
71
|
+
name: string;
|
|
72
|
+
path: string;
|
|
73
|
+
live: boolean;
|
|
74
|
+
}): VisibilityConfirmation {
|
|
75
|
+
return {
|
|
76
|
+
title: `Make “${page.name}” link-only?`,
|
|
77
|
+
body: [
|
|
78
|
+
page.live
|
|
79
|
+
? `${page.path} stays online for anyone who has the address.`
|
|
80
|
+
: `${page.path} is offline now; once published it will only be reachable by its address.`,
|
|
81
|
+
"It disappears from the menu, the footer and the sitemap, and search engines are asked not to index it.",
|
|
82
|
+
"This is not a password: anyone with the link can open it, and it can be shared.",
|
|
83
|
+
],
|
|
84
|
+
confirmLabel: "Make link-only",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What Show on website does: listing only, never a publish. */
|
|
89
|
+
export function showOnWebsiteConfirmation(page: {
|
|
90
|
+
name: string;
|
|
91
|
+
path: string;
|
|
92
|
+
live: boolean;
|
|
93
|
+
}): VisibilityConfirmation {
|
|
94
|
+
return {
|
|
95
|
+
title: `Show “${page.name}” on the website?`,
|
|
96
|
+
body: [
|
|
97
|
+
"Menu and footer links to it reappear, it is added to the sitemap, and search engines may index it.",
|
|
98
|
+
page.live
|
|
99
|
+
? `${page.path} is already online; nothing about its content changes.`
|
|
100
|
+
: `${page.path} is offline: this does not publish it. It is listed again once it is published.`,
|
|
101
|
+
],
|
|
102
|
+
confirmLabel: "Show on website",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The extra sentences Unpublish adds to the existing schedule-aware warning. */
|
|
107
|
+
export function unpublishConsequences(page: { path: string; listing: PageListing }): string[] {
|
|
108
|
+
const lines = [
|
|
109
|
+
`Visitors to ${page.path} will get “page not found”.`,
|
|
110
|
+
"Menu and footer links to it are hidden until it is published again; the menu itself is not changed.",
|
|
111
|
+
];
|
|
112
|
+
if (page.listing === "link-only") {
|
|
113
|
+
lines.push("It stays link-only, so publishing it again will not list it.");
|
|
114
|
+
}
|
|
115
|
+
return lines;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Why a protected page offers neither action. */
|
|
119
|
+
export const PROTECTED_PAGE_REASON =
|
|
120
|
+
"This page is protected: the start page and the contact page always stay published and listed.";
|
|
121
|
+
|
|
122
|
+
/* ------------------------------------------------------------------------ */
|
|
123
|
+
/* Public policy */
|
|
124
|
+
/* ------------------------------------------------------------------------ */
|
|
125
|
+
|
|
126
|
+
/** One page, as public rendering needs to know it. */
|
|
127
|
+
export type PolicyPage = {
|
|
128
|
+
id: string;
|
|
129
|
+
key: string;
|
|
130
|
+
/** Can a visitor open it right now (publication windows, trash excluded)? */
|
|
131
|
+
live: boolean;
|
|
132
|
+
listing: PageListing;
|
|
133
|
+
/** Booking links in this page's own content stay while booking is off. */
|
|
134
|
+
bookingException: boolean;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Everything public rendering decides from, read once on the server.
|
|
139
|
+
*
|
|
140
|
+
* `known: false` is a read that failed. It is never a default: every question
|
|
141
|
+
* below then gets its restrictive answer — booking hidden, nothing indexable,
|
|
142
|
+
* no page reported offline (a failed read is not proof a page is gone).
|
|
143
|
+
*/
|
|
144
|
+
export type PublicPolicySnapshot = {
|
|
145
|
+
known: boolean;
|
|
146
|
+
bookingEnabled: boolean;
|
|
147
|
+
pages: PolicyPage[];
|
|
148
|
+
/** The next instant any of this changes on its own (a publication boundary). */
|
|
149
|
+
revalidateAt: number | null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const UNKNOWN_PUBLIC_POLICY: PublicPolicySnapshot = Object.freeze({
|
|
153
|
+
known: false,
|
|
154
|
+
bookingEnabled: false,
|
|
155
|
+
pages: [],
|
|
156
|
+
revalidateAt: null,
|
|
157
|
+
}) as PublicPolicySnapshot;
|
|
158
|
+
|
|
159
|
+
export function policyPage(policy: PublicPolicySnapshot, key: string): PolicyPage | null {
|
|
160
|
+
return policy.pages.find((page) => page.key === key) ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** May search engines index this page? Only a live, listed page, read successfully. */
|
|
164
|
+
export function pageIsIndexable(policy: PublicPolicySnapshot, key: string): boolean {
|
|
165
|
+
const page = policyPage(policy, key);
|
|
166
|
+
return policy.known && page !== null && page.live && page.listing === "listed";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Is this page positively known to be offline?
|
|
171
|
+
*
|
|
172
|
+
* Only a successful read that found the page and found it not live says so.
|
|
173
|
+
* An unknown page (no CMS row) or a failed read is not offline — the caller
|
|
174
|
+
* renders what it always rendered, or reports the error.
|
|
175
|
+
*/
|
|
176
|
+
export function pageIsKnownOffline(policy: PublicPolicySnapshot, key: string): boolean {
|
|
177
|
+
if (!policy.known) return false;
|
|
178
|
+
const page = policyPage(policy, key);
|
|
179
|
+
return page !== null && !page.live;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Booking in site-wide chrome: header button, menu, footer, popups. */
|
|
183
|
+
export function chromeBookingVisible(policy: PublicPolicySnapshot): boolean {
|
|
184
|
+
return policy.known && policy.bookingEnabled;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Booking in one page's own content: on when booking is on, or the page is an exception. */
|
|
188
|
+
export function pageBookingVisible(policy: PublicPolicySnapshot, key: string | null): boolean {
|
|
189
|
+
if (!policy.known) return false;
|
|
190
|
+
if (policy.bookingEnabled) return true;
|
|
191
|
+
return key !== null && policyPage(policy, key)?.bookingException === true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type HiddenPageReason = "offline" | "link-only";
|
|
195
|
+
|
|
196
|
+
/** Pages navigation must not point at, and why. */
|
|
197
|
+
export function unlistedPages(
|
|
198
|
+
policy: PublicPolicySnapshot,
|
|
199
|
+
): Array<{ key: string; reason: HiddenPageReason }> {
|
|
200
|
+
const out: Array<{ key: string; reason: HiddenPageReason }> = [];
|
|
201
|
+
for (const page of policy.pages) {
|
|
202
|
+
if (!page.live) out.push({ key: page.key, reason: "offline" });
|
|
203
|
+
else if (page.listing === "link-only") out.push({ key: page.key, reason: "link-only" });
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
package/src/forms/form-audit.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Every backoffice change to a form (settings + fields) is turned into a list
|
|
3
3
|
// of short change descriptions so the Activity view answers who/what/when.
|
|
4
4
|
|
|
5
|
+
import { redactUrl } from "../util/redact-url";
|
|
6
|
+
|
|
5
7
|
export type FormAuditChange = {
|
|
6
8
|
/** Machine scope: "form" | "settings" | "field" */
|
|
7
9
|
scope: "form" | "settings" | "field";
|
|
@@ -26,6 +28,24 @@ export type FormSnapshotInput = {
|
|
|
26
28
|
|
|
27
29
|
const MAX_LEN = 160;
|
|
28
30
|
|
|
31
|
+
/** Delivery settings whose value is a credential: the audit says it changed, never what to. */
|
|
32
|
+
const SECRET_SETTING = /secret|token|password|apikey|api_key/i;
|
|
33
|
+
|
|
34
|
+
/** An address as the audit shows it: its origin only — keys travel in paths and query strings. */
|
|
35
|
+
function addressOnly(v: unknown): unknown {
|
|
36
|
+
if (typeof v !== "string" || v.trim() === "") return v;
|
|
37
|
+
const redacted = redactUrl(v.trim());
|
|
38
|
+
return redacted === "(invalid address)" ? "(not a valid address)" : redacted;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A delivery setting's value as the audit may record it. */
|
|
42
|
+
function auditable(group: string, key: string, v: unknown): unknown {
|
|
43
|
+
if (group !== "private_settings") return v;
|
|
44
|
+
if (SECRET_SETTING.test(key)) return v == null || v === "" ? v : "(hidden)";
|
|
45
|
+
if (/url$/i.test(key)) return addressOnly(v);
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
function short(v: unknown): string {
|
|
30
50
|
if (v == null) return "—";
|
|
31
51
|
if (typeof v === "string") return v.length > MAX_LEN ? `${v.slice(0, MAX_LEN)}…` : v || "—";
|
|
@@ -143,8 +163,8 @@ export function diffForms(
|
|
|
143
163
|
out.push({
|
|
144
164
|
scope: "settings",
|
|
145
165
|
label: `${label}: ${humanKey(k)}`,
|
|
146
|
-
from: short(a[k]),
|
|
147
|
-
to: short(b[k]),
|
|
166
|
+
from: short(auditable(group, k, a[k])),
|
|
167
|
+
to: short(auditable(group, k, b[k])),
|
|
148
168
|
});
|
|
149
169
|
}
|
|
150
170
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An HTML tokenizer for the sanitizer, following the WHATWG tokenizer states
|
|
3
|
+
* that matter for a fragment of body content. No DOM is needed, so it runs the
|
|
4
|
+
* same on the server and in the browser.
|
|
5
|
+
*
|
|
6
|
+
* It splits markup into text, start tags and end tags, the way a browser
|
|
7
|
+
* would. Comments, DOCTYPEs, CDATA sections and processing instructions are
|
|
8
|
+
* consumed and produce nothing. A tag that runs into the end of the input is
|
|
9
|
+
* dropped, as a browser drops it. The content of raw-text and RCDATA elements
|
|
10
|
+
* (`<script>`, `<style>`, `<textarea>`, …) is returned as one text token, as
|
|
11
|
+
* the browser's tree builder makes the tokenizer read it.
|
|
12
|
+
*
|
|
13
|
+
* Values are returned exactly as written — text and attribute values are not
|
|
14
|
+
* decoded here. The sanitizer re-serializes everything it keeps, so nothing
|
|
15
|
+
* from the input reaches its output without being re-encoded.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type HtmlToken =
|
|
19
|
+
| { kind: "text"; raw: string }
|
|
20
|
+
| {
|
|
21
|
+
kind: "start";
|
|
22
|
+
name: string;
|
|
23
|
+
/** In source order; a repeated name keeps its first value, as browsers do. */
|
|
24
|
+
attrs: Array<{ name: string; raw: string }>;
|
|
25
|
+
selfClosing: boolean;
|
|
26
|
+
}
|
|
27
|
+
| { kind: "end"; name: string };
|
|
28
|
+
|
|
29
|
+
/** Elements whose content the browser reads as text up to their own end tag. */
|
|
30
|
+
const RAW_TEXT = new Set([
|
|
31
|
+
"script",
|
|
32
|
+
"style",
|
|
33
|
+
"xmp",
|
|
34
|
+
"iframe",
|
|
35
|
+
"noembed",
|
|
36
|
+
"noframes",
|
|
37
|
+
"noscript",
|
|
38
|
+
"title",
|
|
39
|
+
"textarea",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const isWhitespace = (c: string) =>
|
|
43
|
+
c === " " || c === "\t" || c === "\n" || c === "\f" || c === "\r";
|
|
44
|
+
const isAlpha = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
|
|
45
|
+
|
|
46
|
+
/** Lower-cases ASCII only, as the HTML tokenizer does for names. */
|
|
47
|
+
function asciiLower(value: string): string {
|
|
48
|
+
return value.replace(/[A-Z]+/g, (m) => m.toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function tokenizeHtml(input: string): HtmlToken[] {
|
|
52
|
+
const html = input.replace(/\r\n?/g, "\n").replace(/\u0000/g, "�");
|
|
53
|
+
const tokens: HtmlToken[] = [];
|
|
54
|
+
const n = html.length;
|
|
55
|
+
let lower: string | undefined;
|
|
56
|
+
let i = 0;
|
|
57
|
+
let textStart = 0;
|
|
58
|
+
|
|
59
|
+
const flushText = (end: number) => {
|
|
60
|
+
if (end > textStart) tokens.push({ kind: "text", raw: html.slice(textStart, end) });
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Skips a bogus comment: everything up to and including the next `>`. */
|
|
64
|
+
const skipBogusComment = (from: number): number => {
|
|
65
|
+
const end = html.indexOf(">", from);
|
|
66
|
+
return end < 0 ? n : end + 1;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Skips a comment whose `<!--` ends at `from`. */
|
|
70
|
+
const skipComment = (from: number): number => {
|
|
71
|
+
if (html.startsWith(">", from)) return from + 1; // <!-->
|
|
72
|
+
if (html.startsWith("->", from)) return from + 2; // <!--->
|
|
73
|
+
let j = from;
|
|
74
|
+
while (j < n) {
|
|
75
|
+
if (html.startsWith("-->", j)) return j + 3;
|
|
76
|
+
if (html.startsWith("--!>", j)) return j + 4;
|
|
77
|
+
j++;
|
|
78
|
+
}
|
|
79
|
+
return n;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reads a tag whose name starts at `from`. Returns the token and the index
|
|
84
|
+
* after the tag, or null when the input ends inside it.
|
|
85
|
+
*/
|
|
86
|
+
const readTag = (
|
|
87
|
+
from: number,
|
|
88
|
+
closing: boolean,
|
|
89
|
+
): { token: HtmlToken; next: number } | null => {
|
|
90
|
+
let j = from;
|
|
91
|
+
while (j < n && !isWhitespace(html[j]!) && html[j] !== "/" && html[j] !== ">") j++;
|
|
92
|
+
const name = asciiLower(html.slice(from, j));
|
|
93
|
+
const attrs: Array<{ name: string; raw: string }> = [];
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
let selfClosing = false;
|
|
96
|
+
|
|
97
|
+
for (;;) {
|
|
98
|
+
// Before attribute name.
|
|
99
|
+
while (j < n && (isWhitespace(html[j]!) || html[j] === "/")) {
|
|
100
|
+
if (html[j] === "/") {
|
|
101
|
+
// Self-closing start tag state.
|
|
102
|
+
if (html[j + 1] === ">") {
|
|
103
|
+
selfClosing = true;
|
|
104
|
+
j++;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
j++;
|
|
109
|
+
}
|
|
110
|
+
if (j >= n) return null;
|
|
111
|
+
if (html[j] === ">") {
|
|
112
|
+
j++;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
// Attribute name: a leading `=` is part of the name.
|
|
116
|
+
const nameStart = j;
|
|
117
|
+
j++;
|
|
118
|
+
while (j < n && !isWhitespace(html[j]!) && html[j] !== "/" && html[j] !== ">" && html[j] !== "=") {
|
|
119
|
+
j++;
|
|
120
|
+
}
|
|
121
|
+
const attrName = asciiLower(html.slice(nameStart, j));
|
|
122
|
+
// After attribute name.
|
|
123
|
+
while (j < n && isWhitespace(html[j]!)) j++;
|
|
124
|
+
let raw = "";
|
|
125
|
+
if (j < n && html[j] === "=") {
|
|
126
|
+
j++;
|
|
127
|
+
while (j < n && isWhitespace(html[j]!)) j++;
|
|
128
|
+
if (j >= n) return null;
|
|
129
|
+
const quote = html[j];
|
|
130
|
+
if (quote === '"' || quote === "'") {
|
|
131
|
+
const end = html.indexOf(quote, j + 1);
|
|
132
|
+
if (end < 0) return null;
|
|
133
|
+
raw = html.slice(j + 1, end);
|
|
134
|
+
j = end + 1;
|
|
135
|
+
} else if (html[j] !== ">") {
|
|
136
|
+
const start = j;
|
|
137
|
+
while (j < n && !isWhitespace(html[j]!) && html[j] !== ">") j++;
|
|
138
|
+
raw = html.slice(start, j);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (j >= n) return null;
|
|
142
|
+
if (!seen.has(attrName)) {
|
|
143
|
+
seen.add(attrName);
|
|
144
|
+
attrs.push({ name: attrName, raw });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const token: HtmlToken = closing
|
|
149
|
+
? { kind: "end", name }
|
|
150
|
+
: { kind: "start", name, attrs, selfClosing };
|
|
151
|
+
return { token, next: j };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
while (i < n) {
|
|
155
|
+
const lt = html.indexOf("<", i);
|
|
156
|
+
if (lt < 0) break;
|
|
157
|
+
const c = html[lt + 1] ?? "";
|
|
158
|
+
|
|
159
|
+
if (isAlpha(c)) {
|
|
160
|
+
const tag = readTag(lt + 1, false);
|
|
161
|
+
flushText(lt);
|
|
162
|
+
if (!tag) {
|
|
163
|
+
// The input ends inside the tag: the browser drops it.
|
|
164
|
+
i = textStart = n;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
tokens.push(tag.token);
|
|
168
|
+
i = textStart = tag.next;
|
|
169
|
+
const name = tag.token.kind === "start" ? tag.token.name : "";
|
|
170
|
+
if (name === "plaintext") {
|
|
171
|
+
flushText(n);
|
|
172
|
+
textStart = n;
|
|
173
|
+
i = n;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
if (RAW_TEXT.has(name)) {
|
|
177
|
+
// Read the content as text, up to `</name` followed by a delimiter.
|
|
178
|
+
// ASCII-only lower-casing keeps every index the same as in `html`.
|
|
179
|
+
lower ??= asciiLower(html);
|
|
180
|
+
let j = i;
|
|
181
|
+
let end = -1;
|
|
182
|
+
while ((j = lower.indexOf(`</${name}`, j)) >= 0) {
|
|
183
|
+
const after = html[j + 2 + name.length] ?? "";
|
|
184
|
+
if (after === "" || isWhitespace(after) || after === "/" || after === ">") {
|
|
185
|
+
end = j;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
j += 2 + name.length;
|
|
189
|
+
}
|
|
190
|
+
if (end < 0) {
|
|
191
|
+
if (i < n) tokens.push({ kind: "text", raw: html.slice(i) });
|
|
192
|
+
i = textStart = n;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
if (end > i) tokens.push({ kind: "text", raw: html.slice(i, end) });
|
|
196
|
+
const close = readTag(end + 2, true);
|
|
197
|
+
tokens.push({ kind: "end", name });
|
|
198
|
+
i = textStart = close ? close.next : n;
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (c === "/") {
|
|
204
|
+
const d = html[lt + 2] ?? "";
|
|
205
|
+
if (isAlpha(d)) {
|
|
206
|
+
const tag = readTag(lt + 2, true);
|
|
207
|
+
flushText(lt);
|
|
208
|
+
if (!tag) {
|
|
209
|
+
i = textStart = n;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
tokens.push(tag.token);
|
|
213
|
+
i = textStart = tag.next;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (d === ">") {
|
|
217
|
+
flushText(lt);
|
|
218
|
+
i = textStart = lt + 3;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (d === "") {
|
|
222
|
+
// `</` at the end of the input is text.
|
|
223
|
+
i = n;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
flushText(lt);
|
|
227
|
+
i = textStart = skipBogusComment(lt + 2);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (c === "!") {
|
|
232
|
+
flushText(lt);
|
|
233
|
+
if (html.startsWith("--", lt + 2)) {
|
|
234
|
+
i = textStart = skipComment(lt + 4);
|
|
235
|
+
} else {
|
|
236
|
+
// DOCTYPE, CDATA and anything else after `<!` end at the next `>`.
|
|
237
|
+
i = textStart = skipBogusComment(lt + 2);
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (c === "?") {
|
|
243
|
+
flushText(lt);
|
|
244
|
+
i = textStart = skipBogusComment(lt + 1);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// A `<` that starts nothing is text.
|
|
249
|
+
i = lt + 1;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
flushText(n);
|
|
253
|
+
return tokens;
|
|
254
|
+
}
|
|
@@ -5,7 +5,16 @@
|
|
|
5
5
|
* only staff can author it, the output is sanitized on every render so a
|
|
6
6
|
* pasted `<script>`, inline event handler or `javascript:` URL can never end
|
|
7
7
|
* up in a page.
|
|
8
|
+
*
|
|
9
|
+
* The input is tokenized the way a browser tokenizes it (`html-tokenizer`),
|
|
10
|
+
* and the output is written from scratch: allowed elements with allowed,
|
|
11
|
+
* re-encoded attributes, and text with every `<` and `>` escaped. Nothing from
|
|
12
|
+
* the input is ever copied through as written, so markup the sanitizer does
|
|
13
|
+
* not recognise — an unterminated tag, a stray `<` — reaches the page as
|
|
14
|
+
* visible text and can never be completed into an element by the markup that
|
|
15
|
+
* follows it.
|
|
8
16
|
*/
|
|
17
|
+
import { tokenizeHtml } from "./html-tokenizer";
|
|
9
18
|
|
|
10
19
|
const ALLOWED_TAGS = new Set([
|
|
11
20
|
"p", "br", "hr", "span", "div",
|
|
@@ -19,9 +28,13 @@ const ALLOWED_TAGS = new Set([
|
|
|
19
28
|
|
|
20
29
|
/** Tags whose entire contents are dropped. */
|
|
21
30
|
const DROP_WITH_CONTENT = new Set([
|
|
22
|
-
"script", "style", "iframe", "object", "
|
|
31
|
+
"script", "style", "iframe", "object", "noscript", "template", "svg", "math",
|
|
32
|
+
"textarea", "title", "xmp", "noembed", "noframes", "plaintext",
|
|
23
33
|
]);
|
|
24
34
|
|
|
35
|
+
/** Dropped elements that can contain themselves, so their end tags are counted. */
|
|
36
|
+
const NESTING = new Set(["svg", "math", "object", "template"]);
|
|
37
|
+
|
|
25
38
|
const VOID_TAGS = new Set(["br", "hr", "img", "col"]);
|
|
26
39
|
|
|
27
40
|
const GLOBAL_ATTRS = new Set(["class", "style", "id", "title", "dir", "lang"]);
|
|
@@ -58,10 +71,13 @@ const ALLOWED_CSS = new Set([
|
|
|
58
71
|
|
|
59
72
|
const SAFE_URL = /^(https?:\/\/|mailto:|tel:|\/|#|\.\/|\.\.\/)/i;
|
|
60
73
|
|
|
61
|
-
function safeUrl(raw: string): string | null {
|
|
74
|
+
function safeUrl(raw: string, attribute: string): string | null {
|
|
62
75
|
const value = raw.trim().replace(/[\u0000-\u001f]/g, "");
|
|
63
76
|
if (!value) return null;
|
|
64
|
-
|
|
77
|
+
// An inline image is an image source, never a link target.
|
|
78
|
+
if (/^data:image\/(png|jpe?g|gif|webp|avif);base64,/i.test(value)) {
|
|
79
|
+
return attribute === "src" ? value : null;
|
|
80
|
+
}
|
|
65
81
|
if (!SAFE_URL.test(value)) return null;
|
|
66
82
|
if (/^\s*javascript:/i.test(value)) return null;
|
|
67
83
|
return value;
|
|
@@ -114,7 +130,7 @@ const NAMED_REFERENCES: Record<string, string> = {
|
|
|
114
130
|
* was checked: `javascript:` is judged as `javascript:`, and an
|
|
115
131
|
* undecoded `javascript:` never becomes one.
|
|
116
132
|
*/
|
|
117
|
-
function decodeAttr(value: string): string {
|
|
133
|
+
export function decodeAttr(value: string): string {
|
|
118
134
|
return value.replace(
|
|
119
135
|
/&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{0,31}));/g,
|
|
120
136
|
(reference, decimal?: string, hex?: string, name?: string) => {
|
|
@@ -127,21 +143,17 @@ function decodeAttr(value: string): string {
|
|
|
127
143
|
);
|
|
128
144
|
}
|
|
129
145
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
function sanitizeAttrs(tag: string, raw: string): string {
|
|
133
|
-
const allowed = TAG_ATTRS[tag];
|
|
146
|
+
function sanitizeAttrs(tag: string, attrs: ReadonlyArray<{ name: string; raw: string }>): string {
|
|
147
|
+
const allowed = Object.prototype.hasOwnProperty.call(TAG_ATTRS, tag) ? TAG_ATTRS[tag] : undefined;
|
|
134
148
|
const parts: string[] = [];
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const name = m[1].toLowerCase();
|
|
139
|
-
let value = decodeAttr(m[3] ?? m[4] ?? m[5] ?? "");
|
|
149
|
+
for (const attr of attrs) {
|
|
150
|
+
const name = attr.name;
|
|
151
|
+
let value = decodeAttr(attr.raw);
|
|
140
152
|
if (name.startsWith("on")) continue;
|
|
141
153
|
if (!GLOBAL_ATTRS.has(name) && !allowed?.has(name)) continue;
|
|
142
154
|
if (name === "href" || name === "src" || name === "srcset") {
|
|
143
155
|
if (name === "srcset") continue;
|
|
144
|
-
const url = safeUrl(value);
|
|
156
|
+
const url = safeUrl(value, name);
|
|
145
157
|
if (!url) continue;
|
|
146
158
|
value = url;
|
|
147
159
|
}
|
|
@@ -164,30 +176,56 @@ function sanitizeAttrs(tag: string, raw: string): string {
|
|
|
164
176
|
return parts.length ? " " + parts.join(" ") : "";
|
|
165
177
|
}
|
|
166
178
|
|
|
179
|
+
/** A character reference as written in text: kept, since it can only decode to text. */
|
|
180
|
+
const CHAR_REF = /^&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{0,31});/;
|
|
181
|
+
|
|
182
|
+
/** Text as written, made safe to emit: no `<` or `>`, and every `&` a reference. */
|
|
183
|
+
function escapeText(raw: string): string {
|
|
184
|
+
let out = "";
|
|
185
|
+
for (let i = 0; i < raw.length; i++) {
|
|
186
|
+
const c = raw[i]!;
|
|
187
|
+
if (c === "<") out += "<";
|
|
188
|
+
else if (c === ">") out += ">";
|
|
189
|
+
else if (c === "&") out += CHAR_REF.test(raw.slice(i, i + 42)) ? "&" : "&";
|
|
190
|
+
else out += c;
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
167
195
|
/** Sanitize an HTML fragment down to the editor's allowlist. */
|
|
168
196
|
export function sanitizeHtml(input: string | undefined | null): string {
|
|
169
197
|
if (!input) return "";
|
|
170
|
-
// Remove comments and any element whose content must go with it.
|
|
171
|
-
let html = input.replace(/<!--[\s\S]*?-->/g, "");
|
|
172
|
-
for (const tag of DROP_WITH_CONTENT) {
|
|
173
|
-
html = html.replace(new RegExp(`<${tag}[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), "");
|
|
174
|
-
html = html.replace(new RegExp(`<${tag}\\b[^>]*\\/?>`, "gi"), "");
|
|
175
|
-
}
|
|
176
|
-
|
|
177
198
|
const open: string[] = [];
|
|
178
199
|
let out = "";
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
200
|
+
/** The dropped element being skipped, and how deep inside it we are. */
|
|
201
|
+
let skipping: { tag: string; depth: number } | null = null;
|
|
202
|
+
|
|
203
|
+
for (const token of tokenizeHtml(input)) {
|
|
204
|
+
if (skipping) {
|
|
205
|
+
if (token.kind === "start" && token.name === skipping.tag && NESTING.has(token.name)) {
|
|
206
|
+
if (!token.selfClosing) skipping.depth++;
|
|
207
|
+
} else if (token.kind === "end" && token.name === skipping.tag) {
|
|
208
|
+
if (--skipping.depth === 0) skipping = null;
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (token.kind === "text") {
|
|
214
|
+
out += escapeText(token.raw);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const tag = token.name;
|
|
219
|
+
if (token.kind === "start" && DROP_WITH_CONTENT.has(tag)) {
|
|
220
|
+
// A self-closing <svg/> or <math/> is empty; every other one has content.
|
|
221
|
+
if (!(token.selfClosing && (tag === "svg" || tag === "math"))) {
|
|
222
|
+
skipping = { tag, depth: 1 };
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
188
226
|
if (!ALLOWED_TAGS.has(tag)) continue;
|
|
189
227
|
|
|
190
|
-
if (
|
|
228
|
+
if (token.kind === "end") {
|
|
191
229
|
if (VOID_TAGS.has(tag)) continue;
|
|
192
230
|
const at = open.lastIndexOf(tag);
|
|
193
231
|
if (at < 0) continue;
|
|
@@ -197,22 +235,19 @@ export function sanitizeHtml(input: string | undefined | null): string {
|
|
|
197
235
|
}
|
|
198
236
|
|
|
199
237
|
if (VOID_TAGS.has(tag)) {
|
|
200
|
-
out += `<${tag}${sanitizeAttrs(tag,
|
|
238
|
+
out += `<${tag}${sanitizeAttrs(tag, token.attrs)} />`;
|
|
201
239
|
continue;
|
|
202
240
|
}
|
|
203
|
-
out += `<${tag}${sanitizeAttrs(tag,
|
|
241
|
+
out += `<${tag}${sanitizeAttrs(tag, token.attrs)}>`;
|
|
204
242
|
open.push(tag);
|
|
205
243
|
}
|
|
206
|
-
out += html.slice(last);
|
|
207
244
|
while (open.length) out += `</${open.pop()}>`;
|
|
208
245
|
return out;
|
|
209
246
|
}
|
|
210
247
|
|
|
211
248
|
/** Plain-text preview of rich text (used for thumbnails and summaries). */
|
|
212
249
|
export function htmlToText(input: string | undefined | null): string {
|
|
213
|
-
return sanitizeHtml(input)
|
|
214
|
-
.replace(/<[^>]+>/g, " ")
|
|
215
|
-
.replace(/ /g, " ")
|
|
250
|
+
return decodeAttr(sanitizeHtml(input).replace(/<[^>]+>/g, " "))
|
|
216
251
|
.replace(/\s+/g, " ")
|
|
217
252
|
.trim();
|
|
218
253
|
}
|