autumnnote 2.1.0 → 2.3.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/README.md +74 -6
- package/dist/autumnnote.cjs +27 -22
- package/dist/autumnnote.es.js +474 -591
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.min.js +27 -22
- package/dist/autumnnote.umd.js +27 -22
- package/dist/autumnnote.umd.js.map +1 -1
- package/dist/icon-data-V0Xqv-wX.js +255 -0
- package/dist/icon-data-V0Xqv-wX.js.map +1 -0
- package/package.json +12 -3
- package/types/index.d.ts +8 -0
- package/src/js/Context.js +0 -854
- package/src/js/core/detectLang.js +0 -98
- package/src/js/core/dom.js +0 -372
- package/src/js/core/env.js +0 -38
- package/src/js/core/key.js +0 -66
- package/src/js/core/lists.js +0 -121
- package/src/js/core/markdown.js +0 -695
- package/src/js/core/range.js +0 -194
- package/src/js/core/sanitise.js +0 -304
- package/src/js/editing/History.js +0 -266
- package/src/js/editing/Style.js +0 -812
- package/src/js/editing/Table.js +0 -105
- package/src/js/editing/Typing.js +0 -397
- package/src/js/index.js +0 -193
- package/src/js/index.umd.js +0 -17
- package/src/js/module/AutoSaveRestore.js +0 -125
- package/src/js/module/BaseDialog.js +0 -133
- package/src/js/module/BaseMediaTooltip.js +0 -142
- package/src/js/module/BaseResizer.js +0 -322
- package/src/js/module/BubbleToolbar.js +0 -483
- package/src/js/module/Buttons.js +0 -399
- package/src/js/module/Clipboard.js +0 -579
- package/src/js/module/CodeTooltip.js +0 -493
- package/src/js/module/Codeview.js +0 -125
- package/src/js/module/ContextMenu.js +0 -621
- package/src/js/module/Editor.js +0 -747
- package/src/js/module/EmojiDialog.js +0 -254
- package/src/js/module/FindReplace.js +0 -512
- package/src/js/module/Fullscreen.js +0 -80
- package/src/js/module/IconDialog.js +0 -618
- package/src/js/module/ImageCropOverlay.js +0 -586
- package/src/js/module/ImageDialog.js +0 -193
- package/src/js/module/ImageResizer.js +0 -42
- package/src/js/module/ImageTooltip.js +0 -285
- package/src/js/module/LinkDialog.js +0 -145
- package/src/js/module/LinkTooltip.js +0 -250
- package/src/js/module/MarkdownShortcuts.js +0 -250
- package/src/js/module/Mention.js +0 -365
- package/src/js/module/Placeholder.js +0 -51
- package/src/js/module/ShortcutsDialog.js +0 -111
- package/src/js/module/SlashMenu.js +0 -376
- package/src/js/module/Statusbar.js +0 -246
- package/src/js/module/TableTooltip.js +0 -1392
- package/src/js/module/Toolbar.js +0 -855
- package/src/js/module/VideoDialog.js +0 -193
- package/src/js/module/VideoResizer.js +0 -66
- package/src/js/module/VideoTooltip.js +0 -248
- package/src/js/module/emoji-data.js +0 -496
- package/src/js/module/table-grid.js +0 -102
- package/src/js/module/table-icons.js +0 -33
- package/src/js/renderer.js +0 -120
- package/src/js/settings.js +0 -214
- package/src/styles/_variables.scss +0 -48
- package/src/styles/autumnnote.scss +0 -2877
package/src/js/core/range.js
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* range.js - Selection and Range utilities
|
|
3
|
-
* Inspired by Summernote's range.js — rewritten as vanilla JS
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { isElement, closest } from './dom.js';
|
|
7
|
-
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// WrappedRange — a convenience wrapper over the native Range API
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
|
|
12
|
-
export class WrappedRange {
|
|
13
|
-
/**
|
|
14
|
-
* @param {Node} sc - start container
|
|
15
|
-
* @param {number} so - start offset
|
|
16
|
-
* @param {Node} ec - end container
|
|
17
|
-
* @param {number} eo - end offset
|
|
18
|
-
*/
|
|
19
|
-
constructor(sc, so, ec, eo) {
|
|
20
|
-
this.sc = sc;
|
|
21
|
-
this.so = so;
|
|
22
|
-
this.ec = ec;
|
|
23
|
-
this.eo = eo;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** @returns {boolean} */
|
|
27
|
-
isCollapsed() {
|
|
28
|
-
return this.sc === this.ec && this.so === this.eo;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** @returns {Range} */
|
|
32
|
-
toNativeRange() {
|
|
33
|
-
const range = document.createRange();
|
|
34
|
-
try {
|
|
35
|
-
range.setStart(this.sc, this.so);
|
|
36
|
-
range.setEnd(this.ec, this.eo);
|
|
37
|
-
} catch (_e) {
|
|
38
|
-
void _e; // guard against detached nodes
|
|
39
|
-
}
|
|
40
|
-
return range;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Select this wrapped range in the globalThis.
|
|
45
|
-
*/
|
|
46
|
-
select() {
|
|
47
|
-
const sel = globalThis.getSelection();
|
|
48
|
-
if (!sel) return;
|
|
49
|
-
sel.removeAllRanges();
|
|
50
|
-
sel.addRange(this.toNativeRange());
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Returns the common ancestor element of this range.
|
|
55
|
-
* @returns {Element|null}
|
|
56
|
-
*/
|
|
57
|
-
commonAncestor() {
|
|
58
|
-
const native = this.toNativeRange();
|
|
59
|
-
const ancestor = native.commonAncestorContainer;
|
|
60
|
-
return /** @type {Element|null} */ (isElement(ancestor) ? ancestor : ancestor.parentElement);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Returns the nearest paragraph/block ancestor within the editable area.
|
|
65
|
-
* @param {HTMLElement} editable
|
|
66
|
-
* @returns {Element|null}
|
|
67
|
-
*/
|
|
68
|
-
blockNode(editable) {
|
|
69
|
-
return /** @type {Element|null} */ (closest(this.sc, (n) => isElement(n) && n !== editable, editable));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Returns either the selected text string or empty string.
|
|
74
|
-
* @returns {string}
|
|
75
|
-
*/
|
|
76
|
-
toString() {
|
|
77
|
-
return this.toNativeRange().toString();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Returns the bounding DOMRect of the range (or null).
|
|
82
|
-
* @returns {DOMRect|null}
|
|
83
|
-
*/
|
|
84
|
-
getClientRects() {
|
|
85
|
-
const rects = this.toNativeRange().getClientRects();
|
|
86
|
-
return rects.length > 0 ? rects[rects.length - 1] : null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Inserts a node at the start of this range.
|
|
91
|
-
* @param {Node} node
|
|
92
|
-
*/
|
|
93
|
-
insertNode(node) {
|
|
94
|
-
const native = this.toNativeRange();
|
|
95
|
-
native.insertNode(node);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ---------------------------------------------------------------------------
|
|
101
|
-
// Factory helpers
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Creates a WrappedRange from a native Range object.
|
|
106
|
-
* @param {Range} range
|
|
107
|
-
* @returns {WrappedRange}
|
|
108
|
-
*/
|
|
109
|
-
export function fromNativeRange(range) {
|
|
110
|
-
return new WrappedRange(
|
|
111
|
-
range.startContainer,
|
|
112
|
-
range.startOffset,
|
|
113
|
-
range.endContainer,
|
|
114
|
-
range.endOffset,
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Returns a WrappedRange for the current globalThis selection,
|
|
120
|
-
* optionally restricted to a given editable element.
|
|
121
|
-
* @param {HTMLElement} [editable]
|
|
122
|
-
* @returns {WrappedRange|null}
|
|
123
|
-
*/
|
|
124
|
-
export function currentRange(editable) {
|
|
125
|
-
const sel = globalThis.getSelection();
|
|
126
|
-
if (!sel || sel.rangeCount === 0) return null;
|
|
127
|
-
const native = sel.getRangeAt(0);
|
|
128
|
-
// Optionally check that the selection is inside the editable element
|
|
129
|
-
if (editable && !editable.contains(native.commonAncestorContainer)) {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
return fromNativeRange(native);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Creates a WrappedRange that covers the entire content of an element.
|
|
137
|
-
* @param {HTMLElement} el
|
|
138
|
-
* @returns {WrappedRange}
|
|
139
|
-
*/
|
|
140
|
-
export function rangeFromElement(el) {
|
|
141
|
-
return new WrappedRange(el, 0, el, el.childNodes.length);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Creates a collapsed range (cursor) at the given node / offset.
|
|
146
|
-
* @param {Node} node
|
|
147
|
-
* @param {number} offset
|
|
148
|
-
* @returns {WrappedRange}
|
|
149
|
-
*/
|
|
150
|
-
export function collapsedRange(node, offset = 0) {
|
|
151
|
-
return new WrappedRange(node, offset, node, offset);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// ---------------------------------------------------------------------------
|
|
155
|
-
// Utility helpers
|
|
156
|
-
// ---------------------------------------------------------------------------
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Returns true if the current selection is inside the given element.
|
|
160
|
-
* @param {HTMLElement} el
|
|
161
|
-
* @returns {boolean}
|
|
162
|
-
*/
|
|
163
|
-
export function isSelectionInside(el) {
|
|
164
|
-
const sel = globalThis.getSelection();
|
|
165
|
-
if (!sel || sel.rangeCount === 0) return false;
|
|
166
|
-
return el.contains(sel.getRangeAt(0).commonAncestorContainer);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Saves the current selection, executes fn, then restores the selection.
|
|
171
|
-
* @param {Function} fn
|
|
172
|
-
*/
|
|
173
|
-
export function withSavedRange(fn) {
|
|
174
|
-
const sel = globalThis.getSelection();
|
|
175
|
-
if (!sel || sel.rangeCount === 0) {
|
|
176
|
-
fn(null);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
const saved = sel.getRangeAt(0).cloneRange();
|
|
180
|
-
fn(fromNativeRange(saved));
|
|
181
|
-
sel.removeAllRanges();
|
|
182
|
-
sel.addRange(saved);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Splits the text node at the given offset and returns the two halves.
|
|
187
|
-
* @param {Text} textNode
|
|
188
|
-
* @param {number} offset
|
|
189
|
-
* @returns {[Text, Text]}
|
|
190
|
-
*/
|
|
191
|
-
export function splitText(textNode, offset) {
|
|
192
|
-
const after = textNode.splitText(offset);
|
|
193
|
-
return [textNode, after];
|
|
194
|
-
}
|
package/src/js/core/sanitise.js
DELETED
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* sanitise.js - Shared HTML and URL sanitisation utilities
|
|
3
|
-
*
|
|
4
|
-
* Single source of truth used by Editor, Clipboard, Codeview, and renderer.
|
|
5
|
-
* DOM-parser based — no regex-based stripping of HTML (avoids bypass tricks).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Tags that are unconditionally removed from editor content.
|
|
10
|
-
*
|
|
11
|
-
* Beyond the obvious script hosts this covers two SVG/MathML-specific classes:
|
|
12
|
-
*
|
|
13
|
-
* - SMIL animation (`animate`, `set`, `animateTransform`, `animateMotion`)
|
|
14
|
-
* can rewrite an attribute *after* sanitisation finishes, so
|
|
15
|
-
* `<svg><a><animate attributeName="href" values="javascript:…">` survives an
|
|
16
|
-
* attribute-level filter untouched and still navigates on click. The editor
|
|
17
|
-
* only ever emits static `<svg>` icons, so animation is pure attack surface.
|
|
18
|
-
*
|
|
19
|
-
* - `mglyph` / `malignmark` / `annotation-xml` are HTML integration points
|
|
20
|
-
* inside the MathML namespace. They make the parser switch namespaces
|
|
21
|
-
* mid-tree, which is what lets a crafted fragment re-parse into different
|
|
22
|
-
* markup than it serialised from (mXSS). The rest of MathML is left alone so
|
|
23
|
-
* pasted formulae survive.
|
|
24
|
-
*/
|
|
25
|
-
const PROHIBITED_TAGS = [
|
|
26
|
-
'script', 'style', 'iframe', 'object', 'embed', 'form', 'base', 'template',
|
|
27
|
-
'link', 'meta', 'noscript', 'portal', 'frame', 'frameset', 'applet',
|
|
28
|
-
'animate', 'set', 'animatetransform', 'animatemotion',
|
|
29
|
-
'mglyph', 'malignmark', 'annotation-xml',
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
/** Tags whose element wrapper is stripped but content (child nodes) is preserved. */
|
|
33
|
-
const UNWRAP_TAGS = new Set(['button']);
|
|
34
|
-
|
|
35
|
-
/** Attributes whose values must be sanitised as URLs. */
|
|
36
|
-
const URL_ATTRS = ['href', 'src', 'action', 'formaction', 'xlink:href', 'poster', 'background', 'srcset'];
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* URL attributes that address media rather than navigation, so they are
|
|
40
|
-
* validated against SAFE_MEDIA_PROTOCOLS regardless of which element carries
|
|
41
|
-
* them (unlike `src`, whose meaning depends on the owning tag).
|
|
42
|
-
*/
|
|
43
|
-
const MEDIA_URL_ATTRS = new Set(['poster', 'background', 'srcset']);
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Attributes removed outright: the editor never emits them and their only
|
|
47
|
-
* effect is an outbound request the author did not ask for. `ping` fires a
|
|
48
|
-
* POST beacon to arbitrary hosts when a link is clicked.
|
|
49
|
-
*/
|
|
50
|
-
const BEACON_ATTRS = new Set(['ping']);
|
|
51
|
-
|
|
52
|
-
/** Inline style properties the editor's own toolbar/table features persist on saved content. */
|
|
53
|
-
const ALLOWED_STYLE_PROPS = new Set([
|
|
54
|
-
'color', 'background-color', 'font-size', 'line-height',
|
|
55
|
-
'text-align', 'vertical-align',
|
|
56
|
-
'width', 'min-width', 'height', 'min-height',
|
|
57
|
-
'border-width', 'border-style', 'border-color', 'padding',
|
|
58
|
-
]);
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Value patterns that are never safe regardless of property.
|
|
62
|
-
* `image-set()` and `src()` are covered alongside `url()` — all three fetch an
|
|
63
|
-
* external resource, so allowing them would let pasted content phone home.
|
|
64
|
-
*/
|
|
65
|
-
const DANGEROUS_STYLE_VALUE_RE = /url\s*\(|image-set\s*\(|src\s*\(|expression\s*\(|@import|javascript:|vbscript:|behavior\s*:|-moz-binding/i;
|
|
66
|
-
|
|
67
|
-
/** Trusted hosts for iframe embeds when allowIframes is enabled. */
|
|
68
|
-
const TRUSTED_IFRAME_HOSTS = new Set([
|
|
69
|
-
'www.youtube.com',
|
|
70
|
-
'youtube.com',
|
|
71
|
-
'm.youtube.com',
|
|
72
|
-
'www.youtube-nocookie.com',
|
|
73
|
-
'youtube-nocookie.com',
|
|
74
|
-
'player.vimeo.com',
|
|
75
|
-
]);
|
|
76
|
-
|
|
77
|
-
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
|
|
78
|
-
const SAFE_MEDIA_PROTOCOLS = new Set(['http:', 'https:', 'blob:']);
|
|
79
|
-
const SAFE_RASTER_DATA_RE = /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp);base64,[a-z0-9+/=\s]+$/i;
|
|
80
|
-
const URL_BASE = 'https://autumnnote.invalid/';
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Produce a sanitized HTML string with dangerous elements and attributes removed.
|
|
84
|
-
*
|
|
85
|
-
* Removes disallowed tags and wrappers, strips event-handler attributes, rejects
|
|
86
|
-
* `javascript:`/`vbscript:` URLs and most `data:` URIs, restricts iframe `src`
|
|
87
|
-
* to trusted hosts when enabled, and permits only checklist checkboxes as inputs.
|
|
88
|
-
*
|
|
89
|
-
* @param {string} html - HTML fragment to sanitize.
|
|
90
|
-
* @param {Object} [options]
|
|
91
|
-
* @param {boolean} [options.allowIframes=false] - If true, `iframe` elements are not removed but their `src` is restricted to trusted hosts and `srcdoc` is removed.
|
|
92
|
-
* @returns {string} The sanitized HTML fragment.
|
|
93
|
-
*/
|
|
94
|
-
export function sanitiseHTML(html, { allowIframes = false } = {}) {
|
|
95
|
-
const doc = new DOMParser().parseFromString(`<body>${html || ''}</body>`, 'text/html');
|
|
96
|
-
|
|
97
|
-
// Single querySelectorAll pass — collect all elements once to avoid
|
|
98
|
-
// repeated full-tree traversals for each category of check.
|
|
99
|
-
const allElements = Array.from(doc.querySelectorAll('*'));
|
|
100
|
-
|
|
101
|
-
// Build the prohibited tag set for fast O(1) lookup
|
|
102
|
-
const prohibited = new Set(
|
|
103
|
-
allowIframes ? PROHIBITED_TAGS.filter((t) => t !== 'iframe') : PROHIBITED_TAGS,
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
for (const el of allElements) {
|
|
107
|
-
const tag = el.tagName.toLowerCase();
|
|
108
|
-
|
|
109
|
-
// Unwrap elements whose wrapper is unsafe but whose content should be kept
|
|
110
|
-
if (UNWRAP_TAGS.has(tag)) {
|
|
111
|
-
el.replaceWith(...el.childNodes);
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Remove outright dangerous elements
|
|
116
|
-
if (prohibited.has(tag)) {
|
|
117
|
-
el.remove();
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Invalid embeds are removed rather than retained as empty iframes.
|
|
122
|
-
if (tag === 'iframe') {
|
|
123
|
-
const src = el.getAttribute('src');
|
|
124
|
-
if (!src || !isTrustedIframeSrc(src)) {
|
|
125
|
-
el.remove();
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Strip dangerous attributes
|
|
131
|
-
for (const attr of Array.from(el.attributes)) {
|
|
132
|
-
// Remove all event handlers (onclick, onload, onerror, …)
|
|
133
|
-
if (attr.name.startsWith('on')) {
|
|
134
|
-
el.removeAttribute(attr.name);
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
// Filter the style attribute down to an allowlisted set of safe
|
|
138
|
-
// properties (see ALLOWED_STYLE_PROPS) — not a blanket strip, since
|
|
139
|
-
// the editor's own toolbar/table features persist inline styles
|
|
140
|
-
// (text color/highlight, font size, line height, table alignment/
|
|
141
|
-
// sizing/borders) that must survive sanitisation.
|
|
142
|
-
if (attr.name === 'style') {
|
|
143
|
-
const cleaned = sanitiseStyleValue(attr.value);
|
|
144
|
-
if (cleaned) el.setAttribute('style', cleaned);
|
|
145
|
-
else el.removeAttribute('style');
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
// Drop tracking-beacon attributes outright
|
|
149
|
-
if (BEACON_ATTRS.has(attr.name)) {
|
|
150
|
-
el.removeAttribute(attr.name);
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
// Sanitise URL attributes
|
|
154
|
-
if (URL_ATTRS.includes(attr.name)) {
|
|
155
|
-
const val = attr.value.trim();
|
|
156
|
-
const isMediaSource = MEDIA_URL_ATTRS.has(attr.name) ||
|
|
157
|
-
(attr.name === 'src' && ['IMG', 'VIDEO', 'AUDIO', 'SOURCE'].includes(el.tagName));
|
|
158
|
-
const allowData = el.tagName === 'IMG';
|
|
159
|
-
const safe = attr.name === 'srcset'
|
|
160
|
-
? isSafeSrcset(val, { allowData })
|
|
161
|
-
: isSafeUrl(val, { media: isMediaSource, allowData });
|
|
162
|
-
if (!safe) {
|
|
163
|
-
el.removeAttribute(attr.name);
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
// Strip iframe HTML-injection vectors; limit src to trusted hosts
|
|
168
|
-
if (el.tagName === 'IFRAME') {
|
|
169
|
-
if (attr.name === 'srcdoc') {
|
|
170
|
-
el.removeAttribute(attr.name);
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
if (attr.name === 'src' && !isTrustedIframeSrc(attr.value)) {
|
|
174
|
-
el.removeAttribute(attr.name);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (tag === 'a' && el.getAttribute('target') === '_blank') {
|
|
180
|
-
el.setAttribute('rel', 'noopener noreferrer');
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Allow only input[type="checkbox"] inside ul.an-checklist li
|
|
184
|
-
if (tag === 'input') {
|
|
185
|
-
const inChecklist = el.closest('ul.an-checklist') !== null &&
|
|
186
|
-
el.closest('li') !== null;
|
|
187
|
-
if (!inChecklist || el.getAttribute('type') !== 'checkbox') {
|
|
188
|
-
el.remove();
|
|
189
|
-
} else {
|
|
190
|
-
for (const attr of Array.from(el.attributes)) {
|
|
191
|
-
if (!['type', 'checked', 'contenteditable'].includes(attr.name)) {
|
|
192
|
-
el.removeAttribute(attr.name);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return doc.body.innerHTML;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Filters a style attribute value down to an allowlisted set of CSS
|
|
204
|
-
* properties (ALLOWED_STYLE_PROPS), dropping any declaration whose value
|
|
205
|
-
* contains a dangerous construct — url(), expression(), an "import" rule,
|
|
206
|
-
* javascript:/vbscript:, IE behavior/-moz-binding — regardless of property.
|
|
207
|
-
* @param {string} value
|
|
208
|
-
* @returns {string} The filtered declaration list, or '' if nothing survives.
|
|
209
|
-
*/
|
|
210
|
-
function sanitiseStyleValue(value) {
|
|
211
|
-
const kept = [];
|
|
212
|
-
for (const decl of (value || '').split(';')) {
|
|
213
|
-
const idx = decl.indexOf(':');
|
|
214
|
-
if (idx === -1) continue;
|
|
215
|
-
const prop = decl.slice(0, idx).trim().toLowerCase();
|
|
216
|
-
const val = decl.slice(idx + 1).trim();
|
|
217
|
-
if (!prop || !val) continue;
|
|
218
|
-
if (!ALLOWED_STYLE_PROPS.has(prop)) continue;
|
|
219
|
-
if (DANGEROUS_STYLE_VALUE_RE.test(val)) continue;
|
|
220
|
-
kept.push(`${prop}: ${val}`);
|
|
221
|
-
}
|
|
222
|
-
return kept.join('; ');
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Validates every candidate URL in a `srcset` attribute.
|
|
227
|
-
*
|
|
228
|
-
* Per the HTML srcset grammar a candidate URL is a run of non-whitespace
|
|
229
|
-
* characters — commas may appear *inside* it, which is why `data:` URLs work
|
|
230
|
-
* there — optionally followed by a width (`300w`) or density (`2x`) descriptor.
|
|
231
|
-
* Splitting on whitespace and skipping descriptor tokens therefore yields the
|
|
232
|
-
* URL set. Anything unparseable makes the whole attribute fail, since a
|
|
233
|
-
* partially-trusted candidate list is not something we can express.
|
|
234
|
-
*
|
|
235
|
-
* @param {string} value
|
|
236
|
-
* @param {{ allowData?: boolean }} [options]
|
|
237
|
-
* @returns {boolean}
|
|
238
|
-
*/
|
|
239
|
-
function isSafeSrcset(value, { allowData = false } = {}) {
|
|
240
|
-
const tokens = (value || '').trim().split(/\s+/).filter(Boolean);
|
|
241
|
-
for (const token of tokens) {
|
|
242
|
-
if (/^[\d.]+[xw],?$/i.test(token)) continue; // width/density descriptor
|
|
243
|
-
const url = token.replace(/,+$/, ''); // trailing comma = candidate separator
|
|
244
|
-
if (!url) continue;
|
|
245
|
-
if (!isSafeUrl(url, { media: true, allowData })) return false;
|
|
246
|
-
}
|
|
247
|
-
return true;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Returns true if iframe src points to an approved video host.
|
|
252
|
-
* Relative, protocol-relative and invalid URLs are rejected.
|
|
253
|
-
* @param {string} src
|
|
254
|
-
* @returns {boolean}
|
|
255
|
-
*/
|
|
256
|
-
function isTrustedIframeSrc(src) {
|
|
257
|
-
const trimmed = (src || '').trim();
|
|
258
|
-
if (!trimmed) return false;
|
|
259
|
-
if (trimmed.startsWith('//') || trimmed.startsWith('/')) return false;
|
|
260
|
-
try {
|
|
261
|
-
const url = new URL(trimmed);
|
|
262
|
-
if (url.protocol !== 'https:') return false;
|
|
263
|
-
return TRUSTED_IFRAME_HOSTS.has(url.hostname.toLowerCase());
|
|
264
|
-
} catch {
|
|
265
|
-
return false;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Sanitises a URL string, rejecting dangerous protocols.
|
|
271
|
-
*
|
|
272
|
-
* Blocked protocols: javascript:, vbscript:
|
|
273
|
-
* Optionally blocked: data: (safe to allow for img/src base64 embeds)
|
|
274
|
-
*
|
|
275
|
-
* @param {string} url
|
|
276
|
-
* @param {{ allowData?: boolean, media?: boolean }} [opts]
|
|
277
|
-
* @returns {string|null} The original URL if safe, null if rejected.
|
|
278
|
-
*/
|
|
279
|
-
export function sanitiseUrl(url, { allowData = false, media = allowData } = {}) {
|
|
280
|
-
const trimmed = (url || '').trim();
|
|
281
|
-
if (!trimmed && url == null) return null;
|
|
282
|
-
return isSafeUrl(trimmed, { media, allowData }) ? url : null;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Validate a URL using the browser URL parser so ASCII whitespace/control
|
|
287
|
-
* characters cannot disguise a dangerous protocol (for example java\nscript:).
|
|
288
|
-
* @param {string} value
|
|
289
|
-
* @param {{media?: boolean, allowData?: boolean}} [options]
|
|
290
|
-
* @returns {boolean}
|
|
291
|
-
*/
|
|
292
|
-
function isSafeUrl(value, { media = false, allowData = false } = {}) {
|
|
293
|
-
const trimmed = (value || '').trim();
|
|
294
|
-
if (!trimmed) return true;
|
|
295
|
-
if (allowData && SAFE_RASTER_DATA_RE.test(trimmed)) return true;
|
|
296
|
-
|
|
297
|
-
try {
|
|
298
|
-
const parsed = new URL(trimmed, URL_BASE);
|
|
299
|
-
const protocols = media ? SAFE_MEDIA_PROTOCOLS : SAFE_LINK_PROTOCOLS;
|
|
300
|
-
return protocols.has(parsed.protocol);
|
|
301
|
-
} catch {
|
|
302
|
-
return false;
|
|
303
|
-
}
|
|
304
|
-
}
|