chore: bump version to 0.3.8
Code audit fixes: - CRITICAL: reorder Markdown pipeline (fixImages before sanitize) - CRITICAL: fix path prefix separator check - BLOCKING: remove duplicate useEffect in Editor - BLOCKING: skip onChange when content unchanged - BLOCKING: optimize Sidebar re-render with useMemo - HIGH: cleanup FileWatcher polling intervals - HIGH: improve validatePath segment check - HIGH: fix isExternalUpdate race with counter - HIGH: add will-navigate / setWindowOpenHandler - HIGH: explicit strip in sanitize schema - MEDIUM: random temp file suffix instead of Date.now() - MEDIUM/LOW: add IndexedDB error boundaries - LOW: support UTF-16 BOM detection
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marklite",
|
"name": "marklite",
|
||||||
"version": "0.3.7",
|
"version": "0.3.8",
|
||||||
"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
-3
@@ -1,5 +1,6 @@
|
|||||||
import { readFile, stat, writeFile, rename, readdir, unlink, lstat, realpath } 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 { randomBytes } from 'crypto'
|
||||||
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'
|
||||||
|
|
||||||
@@ -11,8 +12,25 @@ export async function readFileContent(filePath: string): Promise<ReadFileResult>
|
|||||||
if (fileStat.size > MAX_FILE_SIZE) {
|
if (fileStat.size > MAX_FILE_SIZE) {
|
||||||
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')
|
// L-01: 检测并剥离 BOM(UTF-8 FEFF / UTF-16 LE FFFE / UTF-16 BE FEFF)
|
||||||
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
|
const buffer = await readFile(filePath)
|
||||||
|
let content: string
|
||||||
|
if (buffer.length >= 2 && buffer[0] === 0xFE && buffer[1] === 0xFF) {
|
||||||
|
// UTF-16 BE: swap bytes to LE 再解码
|
||||||
|
const swapped = Buffer.allocUnsafe(buffer.length)
|
||||||
|
buffer.copy(swapped)
|
||||||
|
swapped.swap16()
|
||||||
|
content = swapped.toString('utf-16le')
|
||||||
|
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
|
||||||
|
} else if (buffer.length >= 2 && buffer[0] === 0xFF && buffer[1] === 0xFE) {
|
||||||
|
// UTF-16 LE
|
||||||
|
content = buffer.toString('utf-16le')
|
||||||
|
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
|
||||||
|
} else {
|
||||||
|
// UTF-8 (含 FEFF BOM 剥离)
|
||||||
|
content = buffer.toString('utf-8')
|
||||||
|
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
|
||||||
|
}
|
||||||
return { success: true, content }
|
return { success: true, content }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: (err as Error).message }
|
return { success: false, error: (err as Error).message }
|
||||||
@@ -20,8 +38,10 @@ export async function readFileContent(filePath: string): Promise<ReadFileResult>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// C-02: 临时文件写到与目标相同目录(避免跨盘 rename 失败)
|
// C-02: 临时文件写到与目标相同目录(避免跨盘 rename 失败)
|
||||||
|
// M-01: 使用随机 hex 后缀替代 Date.now(),防止本地攻击者预创建 symlink
|
||||||
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)}`)
|
const randomSuffix = randomBytes(8).toString('hex')
|
||||||
|
const tmpFile = join(dirname(filePath), `.marklite-tmp-${randomSuffix}-${basename(filePath)}`)
|
||||||
try {
|
try {
|
||||||
await writeFile(tmpFile, content, 'utf-8')
|
await writeFile(tmpFile, content, 'utf-8')
|
||||||
await rename(tmpFile, filePath)
|
await rename(tmpFile, filePath)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export class FileWatcher {
|
|||||||
private watcher: fs.FSWatcher | null = null
|
private watcher: fs.FSWatcher | null = null
|
||||||
private currentPath: string | null = null
|
private currentPath: string | null = null
|
||||||
private isSelfWriting = false
|
private isSelfWriting = false
|
||||||
|
private pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
private pollTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
constructor(private getMainWindow: () => BrowserWindow | null) {}
|
constructor(private getMainWindow: () => BrowserWindow | null) {}
|
||||||
|
|
||||||
@@ -27,18 +29,18 @@ export class FileWatcher {
|
|||||||
const originalPath = this.currentPath
|
const originalPath = this.currentPath
|
||||||
this.stop()
|
this.stop()
|
||||||
if (originalPath) {
|
if (originalPath) {
|
||||||
const pollInterval = setInterval(() => {
|
this.pollTimer = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(originalPath)) {
|
if (fs.existsSync(originalPath)) {
|
||||||
clearInterval(pollInterval)
|
this.clearPollTimers()
|
||||||
this.start(originalPath)
|
this.start(originalPath)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
clearInterval(pollInterval)
|
this.clearPollTimers()
|
||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
// 最多轮询30秒
|
// 最多轮询30秒
|
||||||
setTimeout(() => clearInterval(pollInterval), 30000)
|
this.pollTimeout = setTimeout(() => this.clearPollTimers(), 30000)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
@@ -47,6 +49,7 @@ export class FileWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
this.clearPollTimers()
|
||||||
if (this.watcher) {
|
if (this.watcher) {
|
||||||
this.watcher.close()
|
this.watcher.close()
|
||||||
this.watcher = null
|
this.watcher = null
|
||||||
@@ -54,6 +57,17 @@ export class FileWatcher {
|
|||||||
this.currentPath = null
|
this.currentPath = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearPollTimers(): void {
|
||||||
|
if (this.pollTimer) {
|
||||||
|
clearInterval(this.pollTimer)
|
||||||
|
this.pollTimer = null
|
||||||
|
}
|
||||||
|
if (this.pollTimeout) {
|
||||||
|
clearTimeout(this.pollTimeout)
|
||||||
|
this.pollTimeout = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getCurrentPath(): string | null {
|
getCurrentPath(): string | null {
|
||||||
return this.currentPath
|
return this.currentPath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ function validatePath(filePath: string): boolean {
|
|||||||
if (!filePath || typeof filePath !== 'string') return false
|
if (!filePath || typeof filePath !== 'string') return false
|
||||||
// 拒绝空字节
|
// 拒绝空字节
|
||||||
if (filePath.includes('\0')) return false
|
if (filePath.includes('\0')) return false
|
||||||
// 在 normalize 之前检查原始路径中的 .. 序列,防止 normalize 解析后绕过
|
// 检查路径遍历:以路径分隔符分割后检查是否存在完整的 '..' 段
|
||||||
// 例如 '/valid/path/../../etc/shadow' normalize 后变为 '/etc/shadow',.. 已消失
|
// H-02: 用段检查替代全局 includes('..'),避免误伤含 '..' 的合法路径
|
||||||
if (filePath.includes('..')) return false
|
const sepPattern = /[/\\]/
|
||||||
|
const parts = filePath.split(sepPattern)
|
||||||
|
if (parts.some(part => part === '..')) return false
|
||||||
// 必须是绝对路径
|
// 必须是绝对路径
|
||||||
if (!isAbsolute(filePath)) return false
|
if (!isAbsolute(filePath)) return false
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export function createWindow(): BrowserWindow {
|
|||||||
|
|
||||||
mainWindow.setMenu(null)
|
mainWindow.setMenu(null)
|
||||||
|
|
||||||
|
// H-04: 阻止窗口导航和弹出窗口,防止渲染进程绕过 CSP
|
||||||
|
mainWindow.webContents.on('will-navigate', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||||
|
|
||||||
mainWindow.once('ready-to-show', () => {
|
mainWindow.once('ready-to-show', () => {
|
||||||
mainWindow.show()
|
mainWindow.show()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
|||||||
content: activeTab?.content ?? '',
|
content: activeTab?.content ?? '',
|
||||||
onChange: useCallback((value: string) => {
|
onChange: useCallback((value: string) => {
|
||||||
if (!activeTabId) return
|
if (!activeTabId) return
|
||||||
|
// B-02: 内容未变时跳过(例如纯选择变更触发的 markdownUpdated),
|
||||||
|
// 避免将文档误标记为已修改
|
||||||
|
const tab = useTabStore.getState().tabs.find(t => t.id === activeTabId)
|
||||||
|
if (tab?.content === value) return
|
||||||
updateTabContent(activeTabId, value)
|
updateTabContent(activeTabId, value)
|
||||||
setModified(activeTabId, true)
|
setModified(activeTabId, true)
|
||||||
}, [activeTabId, updateTabContent, setModified]),
|
}, [activeTabId, updateTabContent, setModified]),
|
||||||
@@ -83,20 +87,6 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
|||||||
return () => setEditorViewGetter(() => null)
|
return () => setEditorViewGetter(() => null)
|
||||||
}, [getView])
|
}, [getView])
|
||||||
|
|
||||||
// Save current tab state on unmount or tab switch
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (!activeTabId) return
|
|
||||||
updateTabScroll(activeTabId, {
|
|
||||||
scrollTop: getScrollTop(),
|
|
||||||
selectionStart: getSelection().from,
|
|
||||||
selectionEnd: getSelection().to
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [activeTabId])
|
|
||||||
|
|
||||||
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
|
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
|
|||||||
const onChangeRef = useRef(onChange)
|
const onChangeRef = useRef(onChange)
|
||||||
const isExternalUpdate = useRef(false)
|
const isExternalUpdate = useRef(false)
|
||||||
const initialContentRef = useRef(content)
|
const initialContentRef = useRef(content)
|
||||||
|
// H-03: 请求计数器 — 每个 setContent 调用自增,仅抑制匹配的 markdownUpdated 事件
|
||||||
|
const setContentRequestId = useRef(0)
|
||||||
|
|
||||||
// Keep onChangeRef fresh
|
// Keep onChangeRef fresh
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,7 +112,9 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
|
|||||||
const lm = ctx.get(listenerCtx)
|
const lm = ctx.get(listenerCtx)
|
||||||
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
||||||
if (markdown === prevMarkdown) return
|
if (markdown === prevMarkdown) return
|
||||||
if (!isExternalUpdate.current) {
|
// H-03: 只有未被外部更新抑制时才触发 onChange。
|
||||||
|
// 计数器匹配确保用户按键不会在 setContent 期间被丢弃。
|
||||||
|
if (setContentRequestId.current === 0 && !isExternalUpdate.current) {
|
||||||
onChangeRef.current(markdown)
|
onChangeRef.current(markdown)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -165,13 +169,19 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
|
|||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
|
// H-03: 递增请求 ID,这样只有本次 replaceAll 触发的 markdownUpdated 会被抑制
|
||||||
|
const requestId = ++setContentRequestId.current
|
||||||
isExternalUpdate.current = true
|
isExternalUpdate.current = true
|
||||||
try {
|
try {
|
||||||
editor.action(milkdownReplaceAll(newContent))
|
editor.action(milkdownReplaceAll(newContent))
|
||||||
} catch {
|
} catch {
|
||||||
// replaceAll may fail if editor is not fully ready
|
// replaceAll may fail if editor is not fully ready
|
||||||
} finally {
|
} finally {
|
||||||
isExternalUpdate.current = false
|
// 仅当没有新的 setContent 启动时才重置标志
|
||||||
|
if (setContentRequestId.current === requestId) {
|
||||||
|
setContentRequestId.current = 0
|
||||||
|
isExternalUpdate.current = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ 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)
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
const activeTabId = useTabStore(s => s.activeTabId)
|
||||||
const activeTab = useTabStore(s => s.getActiveTab())
|
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(),
|
||||||
|
// 后者每次返回新对象引用导致 Zustand 无条件重渲染
|
||||||
|
const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId])
|
||||||
const switchToTab = useTabStore(s => s.switchToTab)
|
const switchToTab = useTabStore(s => s.switchToTab)
|
||||||
const createTab = useTabStore(s => s.createTab)
|
const createTab = useTabStore(s => s.createTab)
|
||||||
const rootPath = useSidebarStore(s => s.rootPath)
|
const rootPath = useSidebarStore(s => s.rootPath)
|
||||||
|
|||||||
@@ -1,35 +1,53 @@
|
|||||||
import { db, type RecentFile } from './schema'
|
import { db, type RecentFile } from './schema'
|
||||||
|
import { logError } from '../lib/errorHandler'
|
||||||
|
|
||||||
export const recentFilesRepository = {
|
export const recentFilesRepository = {
|
||||||
async add(filePath: string): Promise<void> {
|
async add(filePath: string): Promise<void> {
|
||||||
const existing: RecentFile | undefined = await db.recentFiles.where('filePath').equals(filePath).first()
|
try {
|
||||||
if (existing) {
|
const existing: RecentFile | undefined = await db.recentFiles.where('filePath').equals(filePath).first()
|
||||||
await db.recentFiles.update(existing.id!, { lastOpened: Date.now() })
|
if (existing) {
|
||||||
} else {
|
await db.recentFiles.update(existing.id!, { lastOpened: Date.now() })
|
||||||
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
|
} else {
|
||||||
}
|
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
|
||||||
// L-06: 清理超过 50 条的旧记录
|
}
|
||||||
const all: RecentFile[] = await db.recentFiles.orderBy('lastOpened').reverse().toArray()
|
// L-06: 清理超过 50 条的旧记录
|
||||||
if (all.length > 50) {
|
const all: RecentFile[] = await db.recentFiles.orderBy('lastOpened').reverse().toArray()
|
||||||
const toDelete = all.slice(50)
|
if (all.length > 50) {
|
||||||
await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!))
|
const toDelete = all.slice(50)
|
||||||
|
await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError('添加最近文件失败', error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAll(limit: number = 20): Promise<string[]> {
|
async getAll(limit: number = 20): Promise<string[]> {
|
||||||
const files: RecentFile[] = await db.recentFiles
|
try {
|
||||||
.orderBy('lastOpened')
|
const files: RecentFile[] = await db.recentFiles
|
||||||
.reverse()
|
.orderBy('lastOpened')
|
||||||
.limit(limit)
|
.reverse()
|
||||||
.toArray()
|
.limit(limit)
|
||||||
return files.map((f: RecentFile) => f.filePath)
|
.toArray()
|
||||||
|
return files.map((f: RecentFile) => f.filePath)
|
||||||
|
} catch (error) {
|
||||||
|
logError('读取最近文件失败', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async remove(filePath: string): Promise<void> {
|
async remove(filePath: string): Promise<void> {
|
||||||
await db.recentFiles.where('filePath').equals(filePath).delete()
|
try {
|
||||||
|
await db.recentFiles.where('filePath').equals(filePath).delete()
|
||||||
|
} catch (error) {
|
||||||
|
logError('删除最近文件失败', error)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async clear(): Promise<void> {
|
async clear(): Promise<void> {
|
||||||
await db.recentFiles.clear()
|
try {
|
||||||
|
await db.recentFiles.clear()
|
||||||
|
} catch (error) {
|
||||||
|
logError('清空最近文件失败', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,14 @@ export const settingsRepository = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// C-01: 先 load 再 merge 再 put,避免部分字段丢失
|
// C-01: 先 load 再 merge 再 put,避免部分字段丢失
|
||||||
|
// M-04: 添加错误处理与日志
|
||||||
async save(partial: Partial<Settings>): Promise<void> {
|
async save(partial: Partial<Settings>): Promise<void> {
|
||||||
const current: Settings = await this.load()
|
try {
|
||||||
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
|
const current: Settings = await this.load()
|
||||||
await db.settings.put(merged)
|
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
|
||||||
|
await db.settings.put(merged)
|
||||||
|
} catch (error) {
|
||||||
|
logError('保存设置失败', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,50 @@
|
|||||||
import { db, type TabSnapshot } from './schema'
|
import { db, type TabSnapshot } from './schema'
|
||||||
|
import { logError } from '../lib/errorHandler'
|
||||||
|
|
||||||
export const tabRepository = {
|
export const tabRepository = {
|
||||||
async saveAll(tabs: TabSnapshot[]): Promise<void> {
|
async saveAll(tabs: TabSnapshot[]): Promise<void> {
|
||||||
await db.transaction('rw', db.tabSnapshots, async () => {
|
try {
|
||||||
await db.tabSnapshots.clear()
|
await db.transaction('rw', db.tabSnapshots, async () => {
|
||||||
await db.tabSnapshots.bulkAdd(tabs)
|
await db.tabSnapshots.clear()
|
||||||
})
|
await db.tabSnapshots.bulkAdd(tabs)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logError('保存标签快照失败', error)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadAll(): Promise<TabSnapshot[]> {
|
async loadAll(): Promise<TabSnapshot[]> {
|
||||||
return db.tabSnapshots.orderBy('updatedAt').toArray()
|
try {
|
||||||
|
return await db.tabSnapshots.orderBy('updatedAt').toArray()
|
||||||
|
} catch (error) {
|
||||||
|
logError('加载标签快照失败', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async clearAll(): Promise<void> {
|
async clearAll(): Promise<void> {
|
||||||
await db.tabSnapshots.clear()
|
try {
|
||||||
|
await db.tabSnapshots.clear()
|
||||||
|
} catch (error) {
|
||||||
|
logError('清空标签快照失败', error)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveActiveTabId(tabId: string | null): Promise<void> {
|
async saveActiveTabId(tabId: string | null): Promise<void> {
|
||||||
await db.activeTab.put({ id: 'current', activeTabId: tabId })
|
try {
|
||||||
|
await db.activeTab.put({ id: 'current', activeTabId: tabId })
|
||||||
|
} catch (error) {
|
||||||
|
logError('保存活动标签ID失败', error)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadActiveTabId(): Promise<string | null> {
|
async loadActiveTabId(): Promise<string | null> {
|
||||||
const record = await db.activeTab.get('current')
|
try {
|
||||||
return record?.activeTabId ?? null
|
const record = await db.activeTab.get('current')
|
||||||
|
return record?.activeTabId ?? null
|
||||||
|
} catch (error) {
|
||||||
|
logError('加载活动标签ID失败', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
|||||||
const normalizedBase = normalizedDir.includes('\\')
|
const normalizedBase = normalizedDir.includes('\\')
|
||||||
? normalizedDir.replace(/\\/g, '/')
|
? normalizedDir.replace(/\\/g, '/')
|
||||||
: normalizedDir
|
: normalizedDir
|
||||||
if (!normalizedResolved.startsWith(normalizedBase)) return
|
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, '/')
|
||||||
@@ -83,14 +83,26 @@ function buildProcessor(filePath: string | null) {
|
|||||||
.use(remarkGfm)
|
.use(remarkGfm)
|
||||||
.use(remarkRehype, { allowDangerousHtml: true })
|
.use(remarkRehype, { allowDangerousHtml: true })
|
||||||
.use(rehypeRaw)
|
.use(rehypeRaw)
|
||||||
|
// CQ-09: rehypeFixImages 必须在 rehypeSanitize 之前运行,
|
||||||
|
// 以保证 file:// URL 接受 sanitize 协议检查而非绕过。
|
||||||
|
.use(rehypeFixImages(filePath ?? null))
|
||||||
.use(rehypeSanitize, {
|
.use(rehypeSanitize, {
|
||||||
...defaultSchema,
|
...defaultSchema,
|
||||||
attributes: {
|
attributes: {
|
||||||
...defaultSchema.attributes,
|
...defaultSchema.attributes,
|
||||||
|
// 允许 img 的 src 属性(fixImages 注入 file:// 后 sanitize 需要放行)
|
||||||
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
|
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
|
||||||
}
|
},
|
||||||
|
protocols: {
|
||||||
|
...(defaultSchema.protocols ?? {}),
|
||||||
|
src: [
|
||||||
|
...((defaultSchema.protocols as Record<string, string[]> | undefined)?.src ?? []),
|
||||||
|
'file:',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// H-05: 显式 strip 事件处理器属性,纵深防御
|
||||||
|
strip: ['script', 'on*', 'javascript:'],
|
||||||
})
|
})
|
||||||
.use(rehypeFixImages(filePath ?? null))
|
|
||||||
.use(rehypeHighlight)
|
.use(rehypeHighlight)
|
||||||
.use(rehypeStringify)
|
.use(rehypeStringify)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// 共享常量 — 主进程和渲染进程共用
|
// 共享常量 — 主进程和渲染进程共用
|
||||||
export const APP_VERSION = 'v0.3.7'
|
export const APP_VERSION = 'v0.3.8'
|
||||||
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([
|
||||||
|
|||||||
Reference in New Issue
Block a user