-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathutil.js
More file actions
301 lines (283 loc) · 12.7 KB
/
Copy pathutil.js
File metadata and controls
301 lines (283 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
// Shared client-side utilities for the Microsoft Communications Portal.
// Loaded synchronously in <head> by every page, so its globals are available
// to inline page scripts.
//
// Exposes:
// window.escapeHtml(value) — string -> HTML-safe string
// window.safeUrl(value) — URL -> safe URL (http/https/mailto/tel only)
// window.sanitizeHtml(html) — untrusted HTML -> allow-list-sanitized HTML
// window.toggleTheme() — flip data-theme, persist, update button label
// window.applyThemeButtonLabel() — set the #theme-btn label from current theme
// window.CPUtil.{escapeHtml, safeUrl, sanitizeHtml} — namespaced accessors
// window.CPActions.register(map) — register data-act event delegation handlers
//
// Theme is also persisted in localStorage so reloads keep the user's choice.
(function () {
'use strict';
var THEME_KEY = 'mcp.theme';
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// ── Hardened URL guard ─────────────────────────────────────────────────────
// Allow only http(s), mailto, and tel schemes. Protocol-relative URLs (//host)
// are rejected so they cannot inherit an attacker-controlled scheme.
function safeUrl(value) {
if (!value) return '#';
var trimmed = String(value).trim();
if (/^\/\//.test(trimmed)) return '#'; // protocol-relative
if (/[\u0000-\u001f]/.test(trimmed)) return '#'; // control chars used to smuggle schemes
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
if (!/^(https?|mailto|tel):/i.test(trimmed)) return '#';
}
return trimmed;
}
// ── Hardened allow-list HTML sanitizer ─────────────────────────────────────
// Replaces the per-page deny-list sanitizers. Parses untrusted feed/Graph HTML
// with DOMParser (no script execution), then keeps ONLY an explicit allow-list
// of tags/attributes. Unknown-but-benign tags are unwrapped (their text is
// preserved); known-dangerous tags are dropped entirely. This resists mutation
// XSS far better than a blacklist because anything not on the list cannot survive.
var ALLOWED_TAGS = {
A: 1, ABBR: 1, B: 1, BLOCKQUOTE: 1, BR: 1, CAPTION: 1, CODE: 1, DD: 1,
DIV: 1, DL: 1, DT: 1, EM: 1, FIGCAPTION: 1, FIGURE: 1, H1: 1, H2: 1, H3: 1,
H4: 1, H5: 1, H6: 1, HR: 1, I: 1, IMG: 1, LI: 1, OL: 1, P: 1, PRE: 1,
SMALL: 1, SPAN: 1, STRONG: 1, SUB: 1, SUP: 1, TABLE: 1, TBODY: 1, TD: 1,
TFOOT: 1, TH: 1, THEAD: 1, TR: 1, U: 1, UL: 1
};
var ALLOWED_ATTRS = {
href: 1, src: 1, alt: 1, title: 1, colspan: 1, rowspan: 1,
lang: 1, dir: 1, width: 1, height: 1
};
// Dropped entirely (element + subtree) because their content is executable or
// can re-introduce script through parser mutation.
var DANGEROUS_TAGS = {
SCRIPT: 1, STYLE: 1, IFRAME: 1, OBJECT: 1, EMBED: 1, FORM: 1, INPUT: 1,
BUTTON: 1, TEXTAREA: 1, SELECT: 1, OPTION: 1, BASE: 1, META: 1, LINK: 1,
SVG: 1, MATH: 1, TEMPLATE: 1, NOSCRIPT: 1, VIDEO: 1, AUDIO: 1, SOURCE: 1,
TRACK: 1, CANVAS: 1, FRAME: 1, FRAMESET: 1, APPLET: 1, PORTAL: 1
};
function sanitizeHtml(html) {
if (!html) return '';
var doc;
try {
doc = new DOMParser().parseFromString(String(html), 'text/html');
} catch (_e) {
return '';
}
var els = doc.body ? doc.body.querySelectorAll('*') : [];
var toRemove = [];
var toUnwrap = [];
for (var i = 0; i < els.length; i++) {
var node = els[i];
var tag = node.tagName;
if (!ALLOWED_TAGS[tag]) {
if (DANGEROUS_TAGS[tag]) toRemove.push(node);
else toUnwrap.push(node);
continue;
}
// Strip every attribute not explicitly allowed (kills on*, style, srcdoc, etc.).
for (var a = node.attributes.length - 1; a >= 0; a--) {
var attr = node.attributes[a];
var name = attr.name.toLowerCase();
if (!ALLOWED_ATTRS[name]) { node.removeAttribute(attr.name); continue; }
if (name === 'href' || name === 'src') {
var cleaned = safeUrl(attr.value);
// src must be http(s) only — no mailto/tel and no data:/blob: images.
if (cleaned === '#' || (name === 'src' && !/^https?:/i.test(cleaned))) {
node.removeAttribute(attr.name);
} else {
node.setAttribute(name, cleaned);
}
}
}
if (tag === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
if (tag === 'IMG') {
node.setAttribute('referrerpolicy', 'no-referrer');
node.setAttribute('loading', 'lazy');
if (!node.getAttribute('src')) toRemove.push(node);
}
}
for (var r = 0; r < toRemove.length; r++) {
var rem = toRemove[r];
if (rem.parentNode) rem.parentNode.removeChild(rem);
}
for (var u = 0; u < toUnwrap.length; u++) {
var un = toUnwrap[u];
if (!un.parentNode) continue;
while (un.firstChild) un.parentNode.insertBefore(un.firstChild, un);
un.parentNode.removeChild(un);
}
return doc.body ? doc.body.innerHTML : '';
}
function applyThemeButtonLabel() {
var btn = document.getElementById('theme-btn');
if (!btn) return;
var theme = document.documentElement.getAttribute('data-theme');
btn.textContent = theme === 'dark' ? '\u2600\uFE0F Light' : '\uD83C\uDF19 Dark';
}
function toggleTheme() {
var html = document.documentElement;
var next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
try { localStorage.setItem(THEME_KEY, next); } catch (_) { /* private mode, ignore */ }
applyThemeButtonLabel();
}
// Reapply persisted theme (only if the URL didn't override it).
// The FOUC-prevention IIFE in each <head> already set data-theme based on
// ?clawpilotTheme=… or prefers-color-scheme. If localStorage has a saved
// choice and no URL override, prefer the saved value.
try {
var urlOverride = new URLSearchParams(window.location.search).get('clawpilotTheme');
if (!urlOverride) {
var saved = localStorage.getItem(THEME_KEY);
if (saved === 'light' || saved === 'dark') {
document.documentElement.setAttribute('data-theme', saved);
}
}
} catch (_) { /* ignore */ }
// Set the initial button label once the DOM has the button rendered.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyThemeButtonLabel, { once: true });
} else {
applyThemeButtonLabel();
}
// ── Safe HTML-to-text extraction ────────────────────────────────────────────
// Uses DOMParser so the untrusted HTML is parsed in an inert document that
// will NOT load sub-resources (<img src>, <script>, etc.) — unlike the legacy
// detached-div approach (document.createElement + innerHTML) which triggers
// resource loads in the live document context.
function stripHtml(html) {
if (!html) return '';
try {
var doc = new DOMParser().parseFromString(String(html), 'text/html');
return (doc.body ? doc.body.textContent : '').trim();
} catch (_e) {
return '';
}
}
// ── Focus trap for modals ────────────────────────────────────────────────
var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
var _focusTrapState = null;
function trapFocus(modalEl, returnFocusEl) {
_focusTrapState = { modal: modalEl, returnTo: returnFocusEl || document.activeElement };
var focusable = modalEl.querySelectorAll(FOCUSABLE_SELECTOR);
if (!focusable.length) return;
function handleTab(e) {
if (e.key !== 'Tab') return;
var els = modalEl.querySelectorAll(FOCUSABLE_SELECTOR);
if (!els.length) return;
var f = els[0], l = els[els.length - 1];
if (e.shiftKey) {
if (document.activeElement === f) { e.preventDefault(); l.focus(); }
} else {
if (document.activeElement === l) { e.preventDefault(); f.focus(); }
}
}
modalEl._trapHandler = handleTab;
modalEl.addEventListener('keydown', handleTab);
focusable[0].focus();
}
function releaseFocus() {
if (!_focusTrapState) return;
var modal = _focusTrapState.modal;
var returnTo = _focusTrapState.returnTo;
if (modal._trapHandler) {
modal.removeEventListener('keydown', modal._trapHandler);
delete modal._trapHandler;
}
_focusTrapState = null;
if (returnTo && returnTo.focus) {
try { returnTo.focus(); } catch (_) {}
}
}
// ── Standard loading / error / empty state helpers ─────────────────────
function renderLoading(message) {
return '<div class="cp-state cp-state-loading" role="status" aria-live="polite">' +
'<div class="spinner" aria-hidden="true"></div>' +
'<span>' + escapeHtml(message || 'Loading\u2026') + '</span>' +
'</div>';
}
function renderError(message, retryLabel) {
return '<div class="cp-state cp-state-error" role="alert">' +
'<span class="cp-state-icon" aria-hidden="true">\u26A0\uFE0F</span>' +
'<span>' + escapeHtml(message || 'Something went wrong.') + '</span>' +
(retryLabel ? ' <button class="cp-state-retry" data-act="retry">' + escapeHtml(retryLabel) + '</button>' : '') +
'</div>';
}
function renderEmpty(message) {
return '<div class="cp-state cp-state-empty" role="status">' +
'<span class="cp-state-icon" aria-hidden="true">\uD83D\uDCED</span>' +
'<span>' + escapeHtml(message || 'No results found.') + '</span>' +
'</div>';
}
// Export globals.
window.escapeHtml = escapeHtml;
window.safeUrl = safeUrl;
window.sanitizeHtml = sanitizeHtml;
window.stripHtml = stripHtml;
window.toggleTheme = toggleTheme;
window.applyThemeButtonLabel = applyThemeButtonLabel;
window.trapFocus = trapFocus;
window.releaseFocus = releaseFocus;
// Namespaced accessors — safe to call even when a page defines its own
// top-level function named sanitizeHtml/safeUrl (which would otherwise shadow
// the window globals above and cause recursion).
window.CPUtil = {
escapeHtml: escapeHtml,
safeUrl: safeUrl,
sanitizeHtml: sanitizeHtml,
stripHtml: stripHtml,
renderLoading: renderLoading,
renderError: renderError,
renderEmpty: renderEmpty
};
// ── Event delegation (replaces inline on* handlers for CSP compliance) ──────
// Elements declare data-act="actionName" (and optional data-on="click|change|
// input"; default "click"). Actions resolve first from an explicitly registered
// map, then fall back to a same-named global function. Handlers are invoked as
// fn.call(element, event, element). This lets us drop script-src 'unsafe-inline'.
var CP_ACTIONS = Object.create(null);
function registerActions(map) {
if (!map) return;
for (var k in map) {
if (Object.prototype.hasOwnProperty.call(map, k)) CP_ACTIONS[k] = map[k];
}
}
function runAction(el, e) {
if (!el) return;
var act = el.getAttribute('data-act');
if (!act) return;
var fn = CP_ACTIONS[act] || (typeof window[act] === 'function' ? window[act] : null);
if (fn) fn.call(el, e, el);
}
function delegatedHandler(e) {
var el = (e.target && e.target.closest) ? e.target.closest('[data-act]') : null;
if (!el) return;
var on = el.getAttribute('data-on') || 'click';
if (on !== e.type) return;
runAction(el, e);
}
document.addEventListener('click', delegatedHandler, false);
document.addEventListener('change', delegatedHandler, false);
document.addEventListener('input', delegatedHandler, false);
// Keyboard activation (Enter/Space) for non-native controls (role="button").
document.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
var el = (e.target && e.target.closest) ? e.target.closest('[data-act]') : null;
if (!el) return;
var tag = el.tagName;
if (tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
if ((el.getAttribute('data-on') || 'click') !== 'click') return;
e.preventDefault();
runAction(el, e);
}, false);
window.CPActions = { register: registerActions, run: runAction };
})();