v0.3.7: 全面代码审计修复 - 20项Bug/安全/稳定性改进

This commit is contained in:
thzxx
2026-06-15 15:02:38 +08:00
parent c0e16f2885
commit dd78ff15a9
20 changed files with 158 additions and 67 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "marklite", "name": "marklite",
"version": "0.3.6", "version": "0.3.7",
"description": "Lightweight Markdown Editor for Windows", "description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js", "main": "./dist/main/index.js",
"scripts": { "scripts": {
+23 -4
View File
@@ -1,4 +1,4 @@
import { readFile, stat, writeFile, rename, readdir, unlink } from 'fs/promises' import { readFile, stat, writeFile, rename, readdir, unlink, lstat, realpath } from 'fs/promises'
import { join, extname, dirname, basename } from 'path' import { join, extname, dirname, basename } from 'path'
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types' import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants' import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants'
@@ -33,10 +33,29 @@ export async function saveFileContent(filePath: string, content: string): Promis
} }
} }
// M-05: 递归深度限制 + 错误边界 // M-05: 递归深度限制 + 错误边界 + 符号链接循环检测
export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): Promise<FileNode[]> { export async function buildDirTree(
dirPath: string,
depth = 0,
maxDepth = 10,
visited?: Set<string>
): Promise<FileNode[]> {
if (depth > maxDepth) return [] if (depth > maxDepth) return []
if (!visited) visited = new Set<string>()
// 检测符号链接循环
let realPath: string
try {
const ls = await lstat(dirPath)
if (ls.isSymbolicLink()) return []
realPath = await realpath(dirPath)
} catch {
return []
}
if (visited.has(realPath)) return []
visited.add(realPath)
let entries let entries
try { try {
entries = await readdir(dirPath, { withFileTypes: true }) entries = await readdir(dirPath, { withFileTypes: true })
@@ -57,7 +76,7 @@ export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): P
const childPath = join(dirPath, entry.name) const childPath = join(dirPath, entry.name)
if (entry.isDirectory()) { if (entry.isDirectory()) {
const subChildren = await buildDirTree(childPath, depth + 1, maxDepth) const subChildren = await buildDirTree(childPath, depth + 1, maxDepth, visited)
if (subChildren.length > 0) { if (subChildren.length > 0) {
children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren }) children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren })
} }
+1 -1
View File
@@ -24,8 +24,8 @@ export class FileWatcher {
}) })
// L-02: 监听 error 事件,文件被删除时显式关闭并清理状态 // L-02: 监听 error 事件,文件被删除时显式关闭并清理状态
this.watcher.on('error', () => { this.watcher.on('error', () => {
this.stop()
const originalPath = this.currentPath const originalPath = this.currentPath
this.stop()
if (originalPath) { if (originalPath) {
const pollInterval = setInterval(() => { const pollInterval = setInterval(() => {
try { try {
+4 -2
View File
@@ -66,10 +66,12 @@ export function registerIpcHandlers(
const win = getMainWindow() const win = getMainWindow()
try { try {
if (data.filePath) { if (data.filePath) {
fileWatcher.stop() // Mark self-writing before stopping watcher to suppress any events
// that arrive between stop and start
fileWatcher.setSelfWriting(true) fileWatcher.setSelfWriting(true)
fileWatcher.stop()
const result = await saveFileContent(data.filePath, data.content) const result = await saveFileContent(data.filePath, data.content)
// start watcher while selfWriting is still true so any immediate 'change' // Restart watcher while selfWriting is still true so any immediate 'change'
// event from the restart is suppressed — prevents overwriting editor content // event from the restart is suppressed — prevents overwriting editor content
fileWatcher.start(data.filePath) fileWatcher.start(data.filePath)
fileWatcher.setSelfWriting(false) fileWatcher.setSelfWriting(false)
@@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import { AppIcon, Gitee } from '../Icons' import { AppIcon, Gitee } from '../Icons'
import { APP_VERSION } from '../../lib/constants'
const APP_VERSION = 'v0.3.6'
interface AboutDialogProps { interface AboutDialogProps {
onClose: () => void onClose: () => void
@@ -27,7 +27,10 @@ export const ConfirmDialog = React.memo(function ConfirmDialog({
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点 // 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
previousFocusRef.current?.focus() // Only restore focus if the element is still in the DOM
if (previousFocusRef.current && previousFocusRef.current.isConnected) {
previousFocusRef.current.focus()
}
return return
} }
+23
View File
@@ -44,16 +44,39 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
if (!activeTab) return if (!activeTab) return
if (activeTab.content !== currentContentRef.current) { if (activeTab.content !== currentContentRef.current) {
currentContentRef.current = activeTab.content currentContentRef.current = activeTab.content
// Queue setContent after Milkdown editor has finished async create()
requestAnimationFrame(() => {
setContent(activeTab.content) setContent(activeTab.content)
requestAnimationFrame(() => { requestAnimationFrame(() => {
setScrollTop(activeTab.scrollTop) setScrollTop(activeTab.scrollTop)
setSelection() setSelection()
}) })
})
} }
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change // stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId]) }, [activeTabId])
// Save current tab state on unmount or tab switch
useEffect(() => {
return () => {
if (!activeTabId) return
// Snapshot the current editor state before the new tab replaces content
const scrollPos = getScrollTop()
const sel = getSelection()
// Use a microtask to ensure we save before React re-renders the new tab content
Promise.resolve().then(() => {
updateTabScroll(activeTabId, {
scrollTop: scrollPos,
selectionStart: sel.from,
selectionEnd: sel.to
})
})
}
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// Register getView for OutlinePanel navigation // Register getView for OutlinePanel navigation
useEffect(() => { useEffect(() => {
setEditorViewGetter(getView) setEditorViewGetter(getView)
@@ -35,6 +35,8 @@ export class ErrorBoundary extends Component<Props, State> {
return this.props.fallback return this.props.fallback
} }
const isDev = (typeof import.meta !== 'undefined' && (import.meta as { env?: { DEV?: boolean } }).env?.DEV) ?? false
return ( return (
<div <div
style={{ style={{
@@ -51,6 +53,7 @@ export class ErrorBoundary extends Component<Props, State> {
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}> <h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
</h2> </h2>
{isDev && (
<pre <pre
style={{ style={{
padding: '1rem', padding: '1rem',
@@ -64,6 +67,7 @@ export class ErrorBoundary extends Component<Props, State> {
> >
{this.state.error?.message} {this.state.error?.message}
</pre> </pre>
)}
<button <button
onClick={this.handleReset} onClick={this.handleReset}
style={{ style={{
@@ -7,7 +7,6 @@ interface FileTreeProps {
depth: number depth: number
expandedDirs: string[] expandedDirs: string[]
toggleDir: (path: string) => void toggleDir: (path: string) => void
activeTabId: string | null
activeFilePath: string | null activeFilePath: string | null
onFileClick: (path: string) => void onFileClick: (path: string) => void
} }
@@ -81,7 +80,6 @@ export const FileTree = React.memo(function FileTree({
depth={depth + 1} depth={depth + 1}
expandedDirs={expandedDirs} expandedDirs={expandedDirs}
toggleDir={toggleDir} toggleDir={toggleDir}
activeTabId={null}
activeFilePath={activeFilePath} activeFilePath={activeFilePath}
onFileClick={onFileClick} onFileClick={onFileClick}
/> />
+2 -2
View File
@@ -13,7 +13,7 @@ import type { Heading } from '../OutlinePanel'
import { getEditorView, useEditorStore } from '../../stores/editorStore' import { getEditorView, useEditorStore } from '../../stores/editorStore'
import { TextSelection } from '@milkdown/prose/state' import { TextSelection } from '@milkdown/prose/state'
const norm = (p: string) => p.replace(/\\/g, '/') const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
export const Sidebar = React.memo(function Sidebar() { export const Sidebar = React.memo(function Sidebar() {
const tabs = useTabStore(s => s.tabs) const tabs = useTabStore(s => s.tabs)
@@ -138,7 +138,7 @@ export const Sidebar = React.memo(function Sidebar() {
<FileTree <FileTree
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]} nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir} depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
activeTabId={activeTabId} activeFilePath={activeFilePath} onFileClick={handleFileClick} activeFilePath={activeFilePath} onFileClick={handleFileClick}
/> />
</> </>
)} )}
@@ -23,6 +23,8 @@ export const SourceEditor = React.memo(function SourceEditor({ darkMode }: Sourc
}, [activeTabId, updateTabContent, setModified]) }, [activeTabId, updateTabContent, setModified])
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isCtrl = e.ctrlKey || e.metaKey
// Tab 插入两个空格而非跳转焦点 // Tab 插入两个空格而非跳转焦点
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault() e.preventDefault()
@@ -39,6 +41,40 @@ export const SourceEditor = React.memo(function SourceEditor({ darkMode }: Sourc
setModified(activeTabId, true) setModified(activeTabId, true)
} }
} }
// Ctrl+B: 粗体
if (isCtrl && e.key === 'b') {
e.preventDefault()
const textarea = textareaRef.current
if (!textarea || !activeTabId) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
const selected = value.substring(start, end)
const newValue = value.substring(0, start) + '**' + selected + '**' + value.substring(end)
textarea.value = newValue
textarea.selectionStart = start + 2
textarea.selectionEnd = end + 2
updateTabContent(activeTabId, newValue)
setModified(activeTabId, true)
}
// Ctrl+I: 斜体
if (isCtrl && e.key === 'i') {
e.preventDefault()
const textarea = textareaRef.current
if (!textarea || !activeTabId) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
const selected = value.substring(start, end)
const newValue = value.substring(0, start) + '*' + selected + '*' + value.substring(end)
textarea.value = newValue
textarea.selectionStart = start + 1
textarea.selectionEnd = end + 1
updateTabContent(activeTabId, newValue)
setModified(activeTabId, true)
}
}, [activeTabId, updateTabContent, setModified]) }, [activeTabId, updateTabContent, setModified])
return ( return (
@@ -224,6 +224,7 @@ export const TabBar = React.memo(function TabBar() {
role="menu" role="menu"
aria-label="标签操作" aria-label="标签操作"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
> >
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}> <div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
+3 -2
View File
@@ -10,8 +10,9 @@ export function useAutoExpandDir(activeFilePath: string | null) {
useEffect(() => { useEffect(() => {
if (!activeFilePath || !rootPath) return if (!activeFilePath || !rootPath) return
const norm = (p: string) => p.replace(/\\/g, '/') const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
if (!norm(activeFilePath).startsWith(norm(rootPath))) return const normalizedRoot = norm(rootPath) + '/'
if (!norm(activeFilePath).startsWith(normalizedRoot)) return
const dirsToExpand: string[] = [] const dirsToExpand: string[] = []
let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '') let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '')
+19 -9
View File
@@ -12,6 +12,9 @@ const AUTO_SAVE_DELAY = 2000
* Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to * Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to
* re-render on every keystroke — the effect is triggered once and runs * re-render on every keystroke — the effect is triggered once and runs
* reactively via the store subscription. * reactively via the store subscription.
*
* Captures the tabId at debounce start so the timeout always saves the
* correct tab even if the user switches tabs during the debounce window.
*/ */
export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean; toggleAutoSave: () => void } { export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean; toggleAutoSave: () => void } {
const [isAutoSaving, setIsAutoSaving] = useState(false) const [isAutoSaving, setIsAutoSaving] = useState(false)
@@ -19,6 +22,7 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isSavingRef = useRef(false) const isSavingRef = useRef(false)
const enabledRef = useRef(true) const enabledRef = useRef(true)
const mountedRef = useRef(true)
const toggleAutoSave = useCallback(() => { const toggleAutoSave = useCallback(() => {
setAutoSaveEnabled(prev => { setAutoSaveEnabled(prev => {
@@ -33,6 +37,8 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
}, []) }, [])
useEffect(() => { useEffect(() => {
mountedRef.current = true
// Subscribe to Zustand store — fires on every state change // Subscribe to Zustand store — fires on every state change
const unsub = useTabStore.subscribe((state) => { const unsub = useTabStore.subscribe((state) => {
if (!enabledRef.current) return if (!enabledRef.current) return
@@ -40,6 +46,9 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
const tab = state.getActiveTab() const tab = state.getActiveTab()
if (!tab || !tab.filePath || !tab.isModified) return if (!tab || !tab.filePath || !tab.isModified) return
// Capture the tabId so the timeout saves the correct tab
const tabIdToSave = tab.id
// Debounce: clear previous timer, start new one // Debounce: clear previous timer, start new one
if (timerRef.current) { if (timerRef.current) {
clearTimeout(timerRef.current) clearTimeout(timerRef.current)
@@ -48,33 +57,34 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
timerRef.current = setTimeout(async () => { timerRef.current = setTimeout(async () => {
if (isSavingRef.current) return if (isSavingRef.current) return
// Re-read latest state inside the timeout callback // Re-read latest state; verify the captured tab still exists and is modified
const currentState = useTabStore.getState() const currentState = useTabStore.getState()
const currentTab = currentState.getActiveTab() const tabToSave = currentState.tabs.find(t => t.id === tabIdToSave)
if (!currentTab || !currentTab.filePath || !currentTab.isModified) return if (!tabToSave || !tabToSave.filePath || !tabToSave.isModified) return
isSavingRef.current = true isSavingRef.current = true
setIsAutoSaving(true) if (mountedRef.current) setIsAutoSaving(true)
try { try {
if (!window.electronAPI) return if (!window.electronAPI) return
const result = await window.electronAPI.saveFile({ const result = await window.electronAPI.saveFile({
filePath: currentTab.filePath, filePath: tabToSave.filePath,
content: currentTab.content content: tabToSave.content
}) })
if (result.success) { if (result.success && mountedRef.current) {
currentState.setModified(currentTab.id, false) currentState.setModified(tabToSave.id, false)
} }
} catch (error) { } catch (error) {
logError('自动保存失败', error) logError('自动保存失败', error)
} finally { } finally {
setIsAutoSaving(false) if (mountedRef.current) setIsAutoSaving(false)
isSavingRef.current = false isSavingRef.current = false
} }
}, AUTO_SAVE_DELAY) }, AUTO_SAVE_DELAY)
}) })
return () => { return () => {
mountedRef.current = false
unsub() unsub()
if (timerRef.current) { if (timerRef.current) {
clearTimeout(timerRef.current) clearTimeout(timerRef.current)
+3 -1
View File
@@ -16,7 +16,9 @@ export function useDragDrop(showToast: (msg: string) => void) {
let rejected = 0 let rejected = 0
for (const file of Array.from(files)) { for (const file of Array.from(files)) {
const filePath: string = (file as File & { path?: string }).path || file.name // Electron adds a `path` property to File objects
const electronFile = file as File & { path?: string }
const filePath: string = electronFile.path || file.name
if (!isAllowedFile(filePath)) { if (!isAllowedFile(filePath)) {
rejected++ rejected++
continue continue
+5 -3
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react' import { useState, useCallback } from 'react'
import type { ToastItem, ToastType } from '../components/Toast/Toast' import type { ToastItem, ToastType } from '../components/Toast/Toast'
/** 默认自动消失时间 (ms) */ /** 默认自动消失时间 (ms) */
@@ -7,16 +7,18 @@ const DEFAULT_DURATION = 3000
/** 最大同时显示数量 */ /** 最大同时显示数量 */
const MAX_TOASTS = 5 const MAX_TOASTS = 5
// Module-level counter survives component remounts
let toastCounter = 0
/** /**
* UX-05: 升级版 Toast 状态管理 hook * UX-05: 升级版 Toast 状态管理 hook
* 支持多条堆叠、类型区分、自动消失 * 支持多条堆叠、类型区分、自动消失
*/ */
export function useToast() { export function useToast() {
const [toasts, setToasts] = useState<ToastItem[]>([]) const [toasts, setToasts] = useState<ToastItem[]>([])
const counterRef = useRef(0)
const showToast = useCallback((msg: string, type: ToastType = 'info', duration = DEFAULT_DURATION) => { const showToast = useCallback((msg: string, type: ToastType = 'info', duration = DEFAULT_DURATION) => {
const id = `toast-${++counterRef.current}` const id = `toast-${++toastCounter}`
const newToast: ToastItem = { id, message: msg, type, duration } const newToast: ToastItem = { id, message: msg, type, duration }
setToasts(prev => { setToasts(prev => {
+1 -1
View File
@@ -1,2 +1,2 @@
// 重新导出共享常量 // 重新导出共享常量
export { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../../shared/constants' export { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS, APP_VERSION } from '../../shared/constants'
+9 -2
View File
@@ -32,6 +32,7 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '') const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
const sep: string = dir.includes('\\') ? '\\' : '/' const sep: string = dir.includes('\\') ? '\\' : '/'
const normalizedDir = dir + sep
function visit(node: Element | Root): void { function visit(node: Element | Root): void {
if (!node.children) return if (!node.children) return
@@ -49,7 +50,13 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
} }
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外 // 解析完整路径并检查是否越界到 markdown 文件所在目录之外
const resolvedPath = resolveRelativePath(dir, src) const resolvedPath = resolveRelativePath(dir, src)
if (!resolvedPath.startsWith(dir + sep)) return const normalizedResolved = resolvedPath.includes('\\')
? resolvedPath.replace(/\\/g, '/') + '/'
: resolvedPath + '/'
const normalizedBase = normalizedDir.includes('\\')
? normalizedDir.replace(/\\/g, '/')
: normalizedDir
if (!normalizedResolved.startsWith(normalizedBase)) return
child.properties = { child.properties = {
...child.properties, ...child.properties,
src: 'file://' + (dir + sep + src).replace(/\\/g, '/') src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
@@ -67,7 +74,7 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
} }
// PF-01: Markdown处理器LRU缓存 // PF-01: Markdown处理器LRU缓存
const MAX_CACHE_SIZE = 10 const MAX_CACHE_SIZE = 20
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>() const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
function buildProcessor(filePath: string | null) { function buildProcessor(filePath: string | null) {
+1 -18
View File
@@ -16,11 +16,9 @@ interface SidebarState {
toggleDir: (path: string) => void toggleDir: (path: string) => void
expandDirs: (paths: string[]) => void expandDirs: (paths: string[]) => void
setSidebarWidth: (width: number) => void setSidebarWidth: (width: number) => void
/** @deprecated AR-04: 设置由 useSettingsInit 统一加载,此方法仅作兼容保留 */
loadFromDB: () => Promise<void>
} }
export const useSidebarStore = create<SidebarState>((set, get) => ({ export const useSidebarStore = create<SidebarState>((set) => ({
isVisible: true, isVisible: true,
rootPath: null, rootPath: null,
tree: [], tree: [],
@@ -28,21 +26,6 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
sidebarWidth: 240, sidebarWidth: 240,
_loaded: false, _loaded: false,
// AR-04: 设置由 useSettingsInit 统一加载
loadFromDB: async () => {
if (get()._loaded) return
try {
const settings = await settingsRepository.load()
set({
isVisible: !settings.sidebarCollapsed,
sidebarWidth: settings.sidebarWidth,
_loaded: true
})
} catch {
set({ _loaded: true })
}
},
setVisible: (visible: boolean) => { setVisible: (visible: boolean) => {
set({ isVisible: visible }) set({ isVisible: visible })
settingsRepository.save({ sidebarCollapsed: !visible }) settingsRepository.save({ sidebarCollapsed: !visible })
+1
View File
@@ -1,4 +1,5 @@
// 共享常量 — 主进程和渲染进程共用 // 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.3.7'
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([ export const SKIP_DIRS = new Set([