Skip to main content

max / balanced_breakfast

5.1 KB · 138 lines History Blame Raw
1 /**
2 * @fileoverview Shared utility functions: HTML escaping, HTML sanitization, and debounce.
3 */
4 (function() {
5 'use strict';
6
7 /**
8 * Escape a string for safe insertion into HTML.
9 * Uses a detached DOM element to leverage the browser's own escaping.
10 * @param {string} text - Raw text to escape.
11 * @returns {string} HTML-safe string.
12 */
13 function escapeHtml(text) {
14 if (!text) return '';
15 const div = document.createElement('div');
16 div.textContent = text;
17 return div.innerHTML;
18 }
19
20 /** Set of element tag names that are stripped during sanitization. */
21 const DANGEROUS_ELEMENTS = new Set([
22 'script', 'iframe', 'object', 'embed', 'form', 'style', 'base', 'svg', 'math',
23 'meta', 'link',
24 ]);
25
26 /**
27 * Sanitize an HTML string for safe rendering of rich content.
28 *
29 * Allows common formatting tags (p, a, img, strong, em, etc.) while
30 * stripping dangerous elements (script, iframe, form, ...) and dangerous
31 * attributes (on* event handlers, javascript: URLs).
32 *
33 * Uses the browser's own HTML parser via a detached DOM element so the
34 * result is always well-formed HTML.
35 *
36 * @param {string} html - Raw HTML string (e.g. from an RSS body field).
37 * @returns {string} Sanitized HTML safe for innerHTML insertion.
38 */
39 function sanitizeHtml(html) {
40 if (!html) return '';
41
42 const container = document.createElement('div');
43 container.innerHTML = html;
44
45 /** Recursively walk the DOM tree and strip dangerous nodes/attributes. */
46 function clean(node) {
47 // Iterate children in reverse so removals don't shift indices.
48 const children = Array.from(node.children);
49 for (let i = children.length - 1; i >= 0; i--) {
50 const child = children[i];
51 if (DANGEROUS_ELEMENTS.has(child.tagName.toLowerCase())) {
52 child.remove();
53 } else {
54 cleanAttributes(child);
55 clean(child);
56 }
57 }
58 }
59
60 /** Remove dangerous attributes from a single element. */
61 function cleanAttributes(el) {
62 // Collect names first; removing during iteration can skip entries.
63 const attrNames = Array.from(el.attributes).map(a => a.name);
64 for (const name of attrNames) {
65 const lower = name.toLowerCase();
66
67 // Strip all on* event-handler attributes.
68 if (lower.startsWith('on')) {
69 el.removeAttribute(name);
70 continue;
71 }
72
73 // Strip formaction (can override form action URL on buttons/inputs).
74 if (lower === 'formaction') {
75 el.removeAttribute(name);
76 continue;
77 }
78
79 // Strip dangerous URL schemes in href and src.
80 // Normalize by removing whitespace/control chars to defeat obfuscation.
81 if (lower === 'href' || lower === 'src') {
82 const value = (el.getAttribute(name) || '').replace(/[\s\x00-\x1f]/g, '').toLowerCase();
83 if (value.startsWith('javascript:') || value.startsWith('data:') || value.startsWith('vbscript:')) {
84 el.removeAttribute(name);
85 }
86 }
87 }
88 }
89
90 clean(container);
91 return container.innerHTML;
92 }
93
94 /**
95 * Escape a string for safe insertion into an HTML attribute.
96 * Handles backslashes, single quotes, and double quotes.
97 * @param {string} str - Raw string to escape.
98 * @returns {string} Attribute-safe string.
99 */
100 function escapeAttr(str) {
101 if (!str) return '';
102 return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
103 }
104
105 /**
106 * Debounce a function: delays execution until `delay` ms after the last call.
107 * @param {function} fn - Function to debounce.
108 * @param {number} delay - Delay in milliseconds.
109 * @returns {function} Debounced wrapper.
110 */
111 function debounce(fn, delay) {
112 let timeout;
113 return (...args) => {
114 clearTimeout(timeout);
115 timeout = setTimeout(() => fn(...args), delay);
116 };
117 }
118
119 /**
120 * Extract a human-readable message from a Tauri API error.
121 * Tauri commands that return Result<T, ApiError> reject with the serialized
122 * ApiError object ({code, message}), not a plain string.
123 * @param {any} err - Error from a rejected invoke() promise.
124 * @returns {string} The error message string.
125 */
126 function getErrorMessage(err) {
127 if (!err) return 'Unknown error';
128 if (typeof err === 'string') return err;
129 if (err.message) return err.message;
130 if (typeof err === 'object') {
131 try { return JSON.stringify(err); } catch (_) { /* fall through */ }
132 }
133 return String(err);
134 }
135
136 BB.utils = { escapeHtml, escapeAttr, sanitizeHtml, debounce, getErrorMessage };
137 })();
138