fix: marked兼容、保存竞态、关闭死锁、XSS防护、行号虚拟化、CSP加固
This commit is contained in:
+157
-15
@@ -100,13 +100,20 @@
|
||||
function initMarked() {
|
||||
if (typeof marked !== 'undefined') {
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.code = function (code, lang) {
|
||||
let highlighted = code;
|
||||
// 兼容 marked v4(positional args)和 v9+(token object)
|
||||
renderer.code = function (codeOrToken, legacyLang) {
|
||||
const text = (typeof codeOrToken === 'object' && codeOrToken !== null)
|
||||
? codeOrToken.text
|
||||
: codeOrToken;
|
||||
const lang = (typeof codeOrToken === 'object' && codeOrToken !== null)
|
||||
? codeOrToken.lang
|
||||
: legacyLang;
|
||||
let highlighted = text;
|
||||
if (typeof hljs !== 'undefined') {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try { highlighted = hljs.highlight(code, { language: lang }).value; } catch (e) { /* fallback */ }
|
||||
try { highlighted = hljs.highlight(text, { language: lang }).value; } catch (e) { /* fallback */ }
|
||||
} else {
|
||||
try { highlighted = hljs.highlightAuto(code).value; } catch (e) { /* fallback */ }
|
||||
try { highlighted = hljs.highlightAuto(text).value; } catch (e) { /* fallback */ }
|
||||
}
|
||||
}
|
||||
const langClass = lang ? ` language-${lang}` : '';
|
||||
@@ -117,6 +124,77 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ===== HTML Sanitizer =====
|
||||
// 移除事件处理器、危险元素和 javascript: 协议,防止 XSS
|
||||
const DANGEROUS_TAGS = new Set([
|
||||
'script', 'iframe', 'object', 'embed', 'applet', 'form',
|
||||
'base', 'meta', 'link', 'style'
|
||||
]);
|
||||
const DANGEROUS_ATTRS = new Set([
|
||||
'onabort', 'onanimationend', 'onanimationiteration', 'onanimationstart',
|
||||
'onauxclick', 'onbeforecopy', 'onbeforecut', 'onbeforepaste', 'onblur',
|
||||
'oncancel', 'oncanplay', 'oncanplaythrough', 'onchange', 'onclick',
|
||||
'onclose', 'oncontextmenu', 'oncopy', 'oncuechange', 'oncut',
|
||||
'ondblclick', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave',
|
||||
'ondragover', 'ondragstart', 'ondrop', 'ondurationchange', 'onemptied',
|
||||
'onended', 'onerror', 'onfocus', 'onfocusin', 'onfocusout',
|
||||
'onfullscreenchange', 'onfullscreenerror', 'ongotpointercapture',
|
||||
'oninput', 'oninvalid', 'onkeydown', 'onkeypress', 'onkeyup',
|
||||
'onload', 'onloadeddata', 'onloadedmetadata', 'onloadstart',
|
||||
'onlostpointercapture', 'onmousedown', 'onmouseenter', 'onmouseleave',
|
||||
'onmousemove', 'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel',
|
||||
'onpaste', 'onpause', 'onplay', 'onplaying', 'onpointercancel',
|
||||
'onpointerdown', 'onpointerenter', 'onpointerleave', 'onpointermove',
|
||||
'onpointerout', 'onpointerover', 'onpointerup', 'onprogress',
|
||||
'onratechange', 'onreset', 'onresize', 'onscroll', 'onsearch',
|
||||
'onseeked', 'onseeking', 'onselect', 'onselectionchange',
|
||||
'onselectstart', 'onshow', 'onstalled', 'onsubmit', 'onsuspend',
|
||||
'ontimeupdate', 'ontoggle', 'ontouchcancel', 'ontouchend',
|
||||
'ontouchmove', 'ontouchstart', 'ontransitionend', 'onvolumechange',
|
||||
'onwaiting', 'onwheel', 'onanimationend', 'oncontentvisibilityautostatechange',
|
||||
'onscrollend', 'onscrollsnapchange', 'onscrollsnapchanging'
|
||||
]);
|
||||
|
||||
function sanitizeHTML(html) {
|
||||
// 用 DOMParser 解析(不会执行脚本)
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT);
|
||||
const toRemove = [];
|
||||
|
||||
while (walker.nextNode()) {
|
||||
const el = walker.currentNode;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
// 移除危险元素
|
||||
if (DANGEROUS_TAGS.has(tag)) {
|
||||
toRemove.push(el);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 移除危险属性(on* 事件处理器 + javascript: 协议)
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (DANGEROUS_ATTRS.has(name) || name.startsWith('on')) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else if (
|
||||
(name === 'href' || name === 'action' || name === 'formaction' || name === 'xlink:href') &&
|
||||
/^\s*javascript:/i.test(attr.value)
|
||||
) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else if (name === 'style' && /expression\s*\(|url\s*\(\s*['"]?\s*javascript:/i.test(attr.value)) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量移除危险元素
|
||||
for (const el of toRemove) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
// ===== Markdown Rendering =====
|
||||
function renderMarkdown(text) {
|
||||
if (typeof marked === 'undefined') {
|
||||
@@ -125,7 +203,8 @@
|
||||
return;
|
||||
}
|
||||
try {
|
||||
preview.innerHTML = marked.parse(text);
|
||||
const raw = marked.parse(text);
|
||||
preview.innerHTML = sanitizeHTML(raw);
|
||||
preview.style.color = '';
|
||||
} catch (e) {
|
||||
preview.textContent = '渲染错误: ' + e.message;
|
||||
@@ -165,15 +244,57 @@
|
||||
}
|
||||
|
||||
// ===== Line Numbers =====
|
||||
// 快速计算换行符数量,避免 split 产生大数组
|
||||
function countNewlines(text) {
|
||||
let count = 1;
|
||||
for (let i = 0, len = text.length; i < len; i++) {
|
||||
if (text.charCodeAt(i) === 10) count++; // '\n' = 0x0A
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function updateLineNumbers() {
|
||||
const count = editor.value.split('\n').length;
|
||||
const count = countNewlines(editor.value);
|
||||
if (count === lineCountCache) return;
|
||||
lineCountCache = count;
|
||||
const fragment = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
fragment.push('<div class="line-num">', i, '</div>');
|
||||
|
||||
// 大文件(>2000 行)时用虚拟化:只渲染可视区域的行号
|
||||
const LARGE_FILE_THRESHOLD = 2000;
|
||||
if (count > LARGE_FILE_THRESHOLD) {
|
||||
renderVirtualLineNumbers(count);
|
||||
} else {
|
||||
// 小文件:一次性生成全部行号(使用数组 join 比字符串拼接快)
|
||||
const arr = new Array(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
arr[i] = '<div class="line-num">' + (i + 1) + '</div>';
|
||||
}
|
||||
lineNumbers.innerHTML = arr.join('');
|
||||
}
|
||||
lineNumbers.innerHTML = fragment.join('');
|
||||
}
|
||||
|
||||
// 虚拟化行号:只渲染当前可视区域附近的行号
|
||||
function renderVirtualLineNumbers(totalLines) {
|
||||
const lineHeight = 20.8; // 与 CSS line-height 一致
|
||||
const scrollTop = editor.scrollTop;
|
||||
const viewportHeight = editor.clientHeight;
|
||||
const buffer = 20; // 上下缓冲行数
|
||||
|
||||
const startLine = Math.max(0, Math.floor(scrollTop / lineHeight) - buffer);
|
||||
const endLine = Math.min(totalLines, Math.ceil((scrollTop + viewportHeight) / lineHeight) + buffer);
|
||||
const visibleCount = endLine - startLine;
|
||||
|
||||
// 顶部占位
|
||||
const topPadding = startLine * lineHeight;
|
||||
// 底部占位
|
||||
const bottomPadding = (totalLines - endLine) * lineHeight;
|
||||
|
||||
const arr = new Array(visibleCount + 2);
|
||||
arr[0] = '<div style="height:' + topPadding + 'px"></div>';
|
||||
for (let i = 0; i < visibleCount; i++) {
|
||||
arr[i + 1] = '<div class="line-num">' + (startLine + i + 1) + '</div>';
|
||||
}
|
||||
arr[visibleCount + 1] = '<div style="height:' + bottomPadding + 'px"></div>';
|
||||
lineNumbers.innerHTML = arr.join('');
|
||||
}
|
||||
|
||||
// ===== Cursor Position =====
|
||||
@@ -304,7 +425,15 @@
|
||||
|
||||
// Load a tab's state into the editor
|
||||
function loadTabState(tab) {
|
||||
editor.value = tab.content;
|
||||
// 使用 execCommand('insertText') 替换内容,保留 undo/redo 历史
|
||||
// setRangeText 会清空 undo 栈,所以此处必须用 execCommand
|
||||
editor.focus();
|
||||
editor.select();
|
||||
const inserted = document.execCommand('insertText', false, tab.content);
|
||||
if (!inserted) {
|
||||
// execCommand 被禁用时的 fallback(会丢失 undo 历史)
|
||||
editor.value = tab.content;
|
||||
}
|
||||
lineCountCache = 0;
|
||||
updateLineNumbers();
|
||||
renderMarkdown(tab.content);
|
||||
@@ -640,9 +769,16 @@
|
||||
editor.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (!document.execCommand('insertText', false, ' ')) {
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
// setRangeText 是标准 API,execCommand 已废弃但能保留 undo 历史
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
if (start !== end) {
|
||||
// 多行选区:批量缩进
|
||||
const lines = editor.value.substring(start, end).split('\n');
|
||||
const indented = lines.map(line => ' ' + line).join('\n');
|
||||
editor.setRangeText(indented, start, end, 'end');
|
||||
} else {
|
||||
// 单光标:插入 4 空格
|
||||
editor.setRangeText(' ', start, end, 'end');
|
||||
}
|
||||
scheduleUpdate();
|
||||
@@ -724,7 +860,13 @@
|
||||
scheduleUpdate();
|
||||
});
|
||||
|
||||
editor.addEventListener('scroll', syncScroll);
|
||||
editor.addEventListener('scroll', () => {
|
||||
syncScroll();
|
||||
// 大文件滚动时重新渲染虚拟行号
|
||||
if (lineCountCache > 2000) {
|
||||
renderVirtualLineNumbers(lineCountCache);
|
||||
}
|
||||
});
|
||||
editor.addEventListener('click', updateCursorPosition);
|
||||
editor.addEventListener('keyup', updateCursorPosition);
|
||||
editor.addEventListener('select', updateCursorPosition);
|
||||
|
||||
Reference in New Issue
Block a user