fix: marked兼容、保存竞态、关闭死锁、XSS防护、行号虚拟化、CSP加固
This commit is contained in:
@@ -8,6 +8,7 @@ let pendingFilePath = null;
|
||||
let fileWatcher = null;
|
||||
let closeTimeout = null;
|
||||
let isClosing = false; // Prevent re-entrant close
|
||||
let isSelfWriting = false; // Skip fs.watch events triggered by our own writes
|
||||
|
||||
// ===== Single Instance Lock =====
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
@@ -81,7 +82,15 @@ function createWindow() {
|
||||
isClosing = true;
|
||||
e.preventDefault();
|
||||
try {
|
||||
mainWindow.webContents.send('window:confirmClose');
|
||||
// 检查 webContents 是否仍可用
|
||||
if (!mainWindow.webContents.isDestroyed()) {
|
||||
mainWindow.webContents.send('window:confirmClose');
|
||||
} else {
|
||||
// 渲染进程已销毁,直接关闭
|
||||
mainWindow.removeAllListeners('close');
|
||||
mainWindow.close();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
mainWindow.removeAllListeners('close');
|
||||
mainWindow.close();
|
||||
@@ -89,6 +98,7 @@ function createWindow() {
|
||||
}
|
||||
// 5s safety timeout in case renderer is unresponsive
|
||||
closeTimeout = setTimeout(() => {
|
||||
closeTimeout = null;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.removeAllListeners('close');
|
||||
mainWindow.close();
|
||||
@@ -109,6 +119,8 @@ function startWatching(filePath) {
|
||||
try {
|
||||
fileWatcher = fs.watch(filePath, (eventType) => {
|
||||
if (eventType === 'change' && mainWindow && !mainWindow.isDestroyed()) {
|
||||
// 跳过自身写入触发的 change 事件
|
||||
if (isSelfWriting) return;
|
||||
mainWindow.webContents.send('file:externallyModified', filePath);
|
||||
}
|
||||
});
|
||||
@@ -174,13 +186,22 @@ function switchActiveFile(filePath) {
|
||||
function saveToPath(filePath, content) {
|
||||
stopWatching();
|
||||
try {
|
||||
isSelfWriting = true;
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
// 写入后短暂延迟再恢复监听,让 fs.watch 错过自身写入的事件
|
||||
isSelfWriting = false;
|
||||
} catch (err) {
|
||||
isSelfWriting = false;
|
||||
startWatching(activeFilePath); // Restore watcher on failure
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
activeFilePath = filePath;
|
||||
startWatching(filePath);
|
||||
// 延迟 300ms 重新监听,确保文件系统的 change 事件已过期
|
||||
setTimeout(() => {
|
||||
if (activeFilePath === filePath) {
|
||||
startWatching(filePath);
|
||||
}
|
||||
}, 300);
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`);
|
||||
}
|
||||
@@ -283,11 +304,11 @@ ipcMain.handle('window:forceClose', () => {
|
||||
clearTimeout(closeTimeout);
|
||||
closeTimeout = null;
|
||||
}
|
||||
// 窗口已销毁则无需操作(超时兜底可能已经关闭了窗口)
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
stopWatching();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.removeAllListeners('close');
|
||||
mainWindow.close();
|
||||
}
|
||||
mainWindow.removeAllListeners('close');
|
||||
mainWindow.close();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:cancelClose', () => {
|
||||
|
||||
+4
-1
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:;">
|
||||
<!-- CSP: script-src 只允许 self,阻断内联脚本执行 -->
|
||||
<!-- style-src 保留 unsafe-inline:highlight.js 代码高亮依赖 CSS class,
|
||||
但已通过 sanitizeHTML 移除恶意 style 属性和 expression() -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self';">
|
||||
<title>MarkLite</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="../lib/highlight-github.css">
|
||||
|
||||
+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