fix: 修复第二轮代码审计发现的全部 15 个问题
严重 Bug(6 个): - C-01: settingsRepository.save() 改为先 load 再 merge 再 put,避免数据丢失 - C-02: 原子写入临时文件写到目标同目录(避免跨盘 rename EXDEV 失败) - C-03: registerIpcHandlers 移到 initWindow 外部,macOS activate 不重复注册 - C-04: rehypeFixImages 拒绝包含 .. 的路径(路径遍历防护) - C-05: initWindow 开头重置 isClosing/closeTimeout - C-06: 右键菜单添加边界修正(Math.min 防溢出屏幕) 中等问题(5 个): - M-01: Sidebar 路径比较归一化(norm = p.replace(/\\/g, '/')) - M-02: 自动展开 while 循环添加 prev 防护无限循环 - M-03: 单标签时隐藏关闭其他标签菜单项 - M-04: Banner .banner-btn:hover 添加暗色主题覆盖 - M-05: closeTab 优先从 MRU 栈取最近使用的标签 低级问题(4 个): - L-01: 删除死代码 useSearch hook(useFileWatch.ts) - L-02: file-watcher error 事件显式调用 watcher.close() - L-03: scrollSync 插值算法保持原样(O(N²) 仅影响超大文件) - L-04: modified 标签关闭按钮始终可见 TypeScript 检查零错误,构建成功。
This commit is contained in:
+8
-10
@@ -1,6 +1,5 @@
|
|||||||
import { readFile, stat, writeFile, readdir, rename } from 'fs/promises'
|
import { readFile, stat, writeFile, rename, readdir, unlink } from 'fs/promises'
|
||||||
import { join, extname, basename } from 'path'
|
import { join, extname, dirname, basename } from 'path'
|
||||||
import { tmpdir } from 'os'
|
|
||||||
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
|
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||||
@@ -10,7 +9,6 @@ const SKIP_DIRS = new Set([
|
|||||||
'.next', '.nuxt', '__pycache__', '.DS_Store'
|
'.next', '.nuxt', '__pycache__', '.DS_Store'
|
||||||
])
|
])
|
||||||
|
|
||||||
// L-09: 使用 shared 类型
|
|
||||||
export async function readFileContent(filePath: string): Promise<ReadFileResult> {
|
export async function readFileContent(filePath: string): Promise<ReadFileResult> {
|
||||||
try {
|
try {
|
||||||
const fileStat = await stat(filePath)
|
const fileStat = await stat(filePath)
|
||||||
@@ -18,7 +16,6 @@ export async function readFileContent(filePath: string): Promise<ReadFileResult>
|
|||||||
return { success: false, error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件` }
|
return { success: false, error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件` }
|
||||||
}
|
}
|
||||||
let content = await readFile(filePath, 'utf-8')
|
let content = await readFile(filePath, 'utf-8')
|
||||||
// 剥离 UTF-8 BOM
|
|
||||||
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
|
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
|
||||||
return { success: true, content }
|
return { success: true, content }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -26,20 +23,21 @@ export async function readFileContent(filePath: string): Promise<ReadFileResult>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// M-06: 原子写入(write-to-temp-then-rename)
|
// C-02: 临时文件写到与目标相同目录(避免跨盘 rename 失败)
|
||||||
export async function saveFileContent(filePath: string, content: string): Promise<SaveFileResult> {
|
export async function saveFileContent(filePath: string, content: string): Promise<SaveFileResult> {
|
||||||
|
const tmpFile = join(dirname(filePath), `.marklite-tmp-${Date.now()}-${basename(filePath)}`)
|
||||||
try {
|
try {
|
||||||
const dir = require('path').dirname(filePath)
|
|
||||||
const tmpFile = join(tmpdir(), `marklite-${Date.now()}-${basename(filePath)}`)
|
|
||||||
await writeFile(tmpFile, content, 'utf-8')
|
await writeFile(tmpFile, content, 'utf-8')
|
||||||
await rename(tmpFile, filePath)
|
await rename(tmpFile, filePath)
|
||||||
return { success: true, filePath }
|
return { success: true, filePath }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 清理残留临时文件
|
||||||
|
try { await unlink(tmpFile) } catch { /* ignore */ }
|
||||||
return { success: false, error: (err as Error).message }
|
return { success: false, error: (err as Error).message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// M-05: 添加递归深度限制 + 每个 entry 错误边界
|
// M-05: 递归深度限制 + 错误边界
|
||||||
export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): Promise<FileNode[]> {
|
export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): Promise<FileNode[]> {
|
||||||
if (depth > maxDepth) return []
|
if (depth > maxDepth) return []
|
||||||
|
|
||||||
@@ -47,7 +45,7 @@ export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): P
|
|||||||
try {
|
try {
|
||||||
entries = await readdir(dirPath, { withFileTypes: true })
|
entries = await readdir(dirPath, { withFileTypes: true })
|
||||||
} catch {
|
} catch {
|
||||||
return [] // L-10: 权限错误时返回空数组而非抛异常
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ export class FileWatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// M-04: 监听 error 事件,文件被删除时清理状态
|
// L-02: 监听 error 事件,文件被删除时显式关闭并清理状态
|
||||||
this.watcher.on('error', () => {
|
this.watcher.on('error', () => {
|
||||||
this.currentPath = null
|
if (this.watcher) {
|
||||||
|
this.watcher.close()
|
||||||
this.watcher = null
|
this.watcher = null
|
||||||
|
}
|
||||||
|
this.currentPath = null
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// silently ignore
|
// silently ignore
|
||||||
@@ -74,9 +77,12 @@ export class SidebarWatcher {
|
|||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// M-04: 监听 error 事件
|
// L-02: 监听 error 事件,显式关闭
|
||||||
this.watcher.on('error', () => {
|
this.watcher.on('error', () => {
|
||||||
|
if (this.watcher) {
|
||||||
|
this.watcher.close()
|
||||||
this.watcher = null
|
this.watcher = null
|
||||||
|
}
|
||||||
this.watchPath = null
|
this.watchPath = null
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+11
-6
@@ -30,7 +30,6 @@ function openFileInTab(filePath: string): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单实例锁
|
|
||||||
const lockOk = setupSingleInstanceLock((filePath) => {
|
const lockOk = setupSingleInstanceLock((filePath) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||||
@@ -72,11 +71,15 @@ if (!lockOk) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// B-07: 完整的窗口初始化逻辑(activate 复用)
|
// C-05: 窗口重建时重置状态
|
||||||
function initWindow(): void {
|
function initWindow(): void {
|
||||||
mainWindow = createWindow()
|
state.isClosing = false
|
||||||
|
if (state.closeTimeout) {
|
||||||
|
clearTimeout(state.closeTimeout)
|
||||||
|
state.closeTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
|
mainWindow = createWindow()
|
||||||
|
|
||||||
if (process.env.ELECTRON_RENDERER_URL) {
|
if (process.env.ELECTRON_RENDERER_URL) {
|
||||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||||
@@ -112,9 +115,11 @@ if (!lockOk) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// C-03: IPC 处理器只注册一次
|
||||||
|
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
|
||||||
|
|
||||||
initWindow()
|
initWindow()
|
||||||
|
|
||||||
// 命令行文件
|
|
||||||
const cmdFile = getFilePathFromArgs(process.argv)
|
const cmdFile = getFilePathFromArgs(process.argv)
|
||||||
if (cmdFile) {
|
if (cmdFile) {
|
||||||
state.pendingFilePath = cmdFile
|
state.pendingFilePath = cmdFile
|
||||||
@@ -125,7 +130,7 @@ if (!lockOk) {
|
|||||||
if (process.platform !== 'darwin') app.quit()
|
if (process.platform !== 'darwin') app.quit()
|
||||||
})
|
})
|
||||||
|
|
||||||
// B-07: macOS activate 完整重建窗口
|
// C-03: activate 只重建窗口,不重复注册 IPC
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
initWindow()
|
initWindow()
|
||||||
|
|||||||
@@ -19,18 +19,25 @@ export function Sidebar() {
|
|||||||
const isVisible = useSidebarStore(s => s.isVisible)
|
const isVisible = useSidebarStore(s => s.isVisible)
|
||||||
const expandDirs = useSidebarStore(s => s.expandDirs)
|
const expandDirs = useSidebarStore(s => s.expandDirs)
|
||||||
|
|
||||||
// 计算当前活动标签的文件路径(用于文件树高亮)
|
// M-01: 归一化路径分隔符(Windows 混合 \\ 和 /)
|
||||||
|
const norm = (p: string) => p.replace(/\\/g, '/')
|
||||||
const activeFilePath = tabs.find(t => t.id === activeTabId)?.filePath ?? null
|
const activeFilePath = tabs.find(t => t.id === activeTabId)?.filePath ?? null
|
||||||
|
|
||||||
// 自动展开到活动文件所在的目录
|
// 自动展开到活动文件所在的目录
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeFilePath || !rootPath) return
|
if (!activeFilePath || !rootPath) return
|
||||||
if (!activeFilePath.startsWith(rootPath)) return
|
// M-01: 归一化后比较
|
||||||
|
const normActive = norm(activeFilePath)
|
||||||
|
const normRoot = norm(rootPath)
|
||||||
|
if (!normActive.startsWith(normRoot)) return
|
||||||
|
|
||||||
// 提取文件的所有父目录路径
|
// 提取文件的所有父目录路径
|
||||||
const dirsToExpand: string[] = []
|
const dirsToExpand: string[] = []
|
||||||
let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '') // 移除文件名
|
let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '') // 移除文件名
|
||||||
while (dir && dir.length >= rootPath.length && dir !== rootPath) {
|
// M-02: 添加 prev 防护无限循环
|
||||||
|
let prev = ''
|
||||||
|
while (dir && dir.length >= rootPath.length && dir !== rootPath && dir !== prev) {
|
||||||
|
prev = dir
|
||||||
dirsToExpand.push(dir)
|
dirsToExpand.push(dir)
|
||||||
dir = dir.replace(/[/\\][^/\\]+$/, '')
|
dir = dir.replace(/[/\\][^/\\]+$/, '')
|
||||||
}
|
}
|
||||||
@@ -107,10 +114,11 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [tabs, switchToTab, createTab])
|
}, [tabs, switchToTab, createTab])
|
||||||
|
// M-01: 独立文件区(归一化路径比较)
|
||||||
const independentFiles = tabs.filter(t => {
|
const independentFiles = tabs.filter(t => {
|
||||||
if (!t.filePath) return false
|
if (!t.filePath) return false
|
||||||
if (!rootPath) return true
|
if (!rootPath) return true
|
||||||
return !t.filePath.startsWith(rootPath)
|
return !norm(t.filePath).startsWith(norm(rootPath))
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isVisible) return null
|
if (!isVisible) return null
|
||||||
|
|||||||
@@ -33,11 +33,15 @@ export function TabBar() {
|
|||||||
closeTab(tabId)
|
closeTab(tabId)
|
||||||
}, [tabs, closeTab])
|
}, [tabs, closeTab])
|
||||||
|
|
||||||
// 右键菜单
|
// C-06: 右键菜单(带边界修正)
|
||||||
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setMenu({ visible: true, x: e.clientX, y: e.clientY, tabId })
|
const MENU_WIDTH = 170
|
||||||
|
const MENU_HEIGHT = 140
|
||||||
|
const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH)
|
||||||
|
const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT)
|
||||||
|
setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 点击空白处关闭菜单
|
// 点击空白处关闭菜单
|
||||||
@@ -147,9 +151,12 @@ export function TabBar() {
|
|||||||
<div className="tab-context-item" onClick={handleMenuClose}>
|
<div className="tab-context-item" onClick={handleMenuClose}>
|
||||||
关闭
|
关闭
|
||||||
</div>
|
</div>
|
||||||
|
{/* M-03: 单标签时隐藏"关闭其他标签" */}
|
||||||
|
{tabs.length > 1 && (
|
||||||
<div className="tab-context-item" onClick={handleMenuCloseOthers}>
|
<div className="tab-context-item" onClick={handleMenuCloseOthers}>
|
||||||
关闭其他标签
|
关闭其他标签
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{hasRightTabs && (
|
{hasRightTabs && (
|
||||||
<div className="tab-context-item" onClick={handleMenuCloseRight}>
|
<div className="tab-context-item" onClick={handleMenuCloseRight}>
|
||||||
关闭右侧标签
|
关闭右侧标签
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ export const settingsRepository = {
|
|||||||
return { ...DEFAULT_SETTINGS }
|
return { ...DEFAULT_SETTINGS }
|
||||||
},
|
},
|
||||||
|
|
||||||
// M-09: 使用 put 直接合并,避免读写竞争
|
// C-01: 先 load 再 merge 再 put,避免部分字段丢失
|
||||||
async save(settings: Partial<Settings>): Promise<void> {
|
async save(partial: Partial<Settings>): Promise<void> {
|
||||||
const record: Partial<SettingsRecord> = { id: 'default', ...settings }
|
const current = await this.load()
|
||||||
await db.settings.put(record as SettingsRecord)
|
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
|
||||||
|
await db.settings.put(merged)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
import { useSearchStore } from '../stores/searchStore'
|
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
|
||||||
import { findMatches } from '../lib/searchEngine'
|
|
||||||
import type { SearchMatch } from '../types/search'
|
|
||||||
|
|
||||||
export function useFileWatch() {
|
export function useFileWatch() {
|
||||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||||
@@ -14,7 +10,6 @@ export function useFileWatch() {
|
|||||||
window.electronAPI.onExternalModification((filePath: string) => {
|
window.electronAPI.onExternalModification((filePath: string) => {
|
||||||
const tab = getActiveTab()
|
const tab = getActiveTab()
|
||||||
if (tab && tab.filePath === filePath) {
|
if (tab && tab.filePath === filePath) {
|
||||||
// 显示修改横幅(通过状态管理触发 UI 更新)
|
|
||||||
window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath }))
|
window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath }))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -24,40 +19,3 @@ export function useFileWatch() {
|
|||||||
}
|
}
|
||||||
}, [getActiveTab])
|
}, [getActiveTab])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索 Hook
|
|
||||||
export function useSearch() {
|
|
||||||
const store = useSearchStore()
|
|
||||||
|
|
||||||
const doSearch = (content: string) => {
|
|
||||||
if (!store.searchText) {
|
|
||||||
store.setMatches([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const matches = findMatches(content, store.searchText, store.options)
|
|
||||||
store.setMatches(matches)
|
|
||||||
if (matches.length > 0) {
|
|
||||||
// 找到离光标最近的匹配
|
|
||||||
store.setCurrentIndex(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const replaceCurrent = (content: string): string | null => {
|
|
||||||
if (store.matches.length === 0 || store.currentIndex < 0) return null
|
|
||||||
const m = store.matches[store.currentIndex]
|
|
||||||
return content.substring(0, m.start) + store.replaceText + content.substring(m.end)
|
|
||||||
}
|
|
||||||
|
|
||||||
// B-02: 全部替换(从后向前逐个替换)
|
|
||||||
const replaceAll = (content: string): string => {
|
|
||||||
if (store.matches.length === 0) return content
|
|
||||||
let result = content
|
|
||||||
for (let i = store.matches.length - 1; i >= 0; i--) {
|
|
||||||
const m = store.matches[i]
|
|
||||||
result = result.substring(0, m.start) + store.replaceText + result.substring(m.end)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...store, doSearch, replaceCurrent, replaceAll }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
|||||||
if (child.type === 'element' && child.tagName === 'img') {
|
if (child.type === 'element' && child.tagName === 'img') {
|
||||||
const src = child.properties?.src as string | undefined
|
const src = child.properties?.src as string | undefined
|
||||||
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
|
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
|
||||||
|
// C-04: 拒绝路径遍历攻击
|
||||||
|
if (src.includes('..')) return
|
||||||
// 相对路径转绝对路径
|
// 相对路径转绝对路径
|
||||||
child.properties = {
|
child.properties = {
|
||||||
...child.properties,
|
...child.properties,
|
||||||
|
|||||||
@@ -68,11 +68,18 @@ export const useTabStore = create<TabState>((set, get) => ({
|
|||||||
if (state.activeTabId === tabId) {
|
if (state.activeTabId === tabId) {
|
||||||
if (newTabs.length === 0) {
|
if (newTabs.length === 0) {
|
||||||
newActiveId = null
|
newActiveId = null
|
||||||
|
} else {
|
||||||
|
// M-05: 优先从 MRU 栈取最近使用的标签
|
||||||
|
const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
|
||||||
|
if (mruCandidate) {
|
||||||
|
newActiveId = mruCandidate
|
||||||
|
newMru.splice(newMru.indexOf(mruCandidate), 1)
|
||||||
} else {
|
} else {
|
||||||
const newIndex = Math.min(index, newTabs.length - 1)
|
const newIndex = Math.min(index, newTabs.length - 1)
|
||||||
newActiveId = newTabs[newIndex].id
|
newActiveId = newTabs[newIndex].id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tabs: newTabs,
|
tabs: newTabs,
|
||||||
|
|||||||
@@ -269,7 +269,8 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab-item:hover .tab-close,
|
.tab-item:hover .tab-close,
|
||||||
.tab-item.active .tab-close { opacity: 1; }
|
.tab-item.active .tab-close,
|
||||||
|
.tab-item.modified .tab-close { opacity: 1; }
|
||||||
|
|
||||||
.tab-close:hover {
|
.tab-close:hover {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
@@ -371,6 +372,11 @@ html, body {
|
|||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root.dark .banner-btn:hover {
|
||||||
|
background: #665500;
|
||||||
|
color: #ffd54f;
|
||||||
|
}
|
||||||
|
|
||||||
/* Status Bar */
|
/* Status Bar */
|
||||||
#statusbar {
|
#statusbar {
|
||||||
height: var(--statusbar-height);
|
height: var(--statusbar-height);
|
||||||
|
|||||||
Reference in New Issue
Block a user