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
112 lines
3.9 KiB
TypeScript
112 lines
3.9 KiB
TypeScript
import { readFile, stat, writeFile, rename, readdir, unlink, lstat, realpath } from 'fs/promises'
|
||
import { join, extname, dirname, basename } from 'path'
|
||
import { randomBytes } from 'crypto'
|
||
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
|
||
import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants'
|
||
|
||
const ALLOWED_EXTENSIONS_SET = new Set<string>(ALLOWED_EXTENSIONS)
|
||
|
||
export async function readFileContent(filePath: string): Promise<ReadFileResult> {
|
||
try {
|
||
const fileStat = await stat(filePath)
|
||
if (fileStat.size > MAX_FILE_SIZE) {
|
||
return { success: false, error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件` }
|
||
}
|
||
// L-01: 检测并剥离 BOM(UTF-8 FEFF / UTF-16 LE FFFE / UTF-16 BE FEFF)
|
||
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 }
|
||
} catch (err) {
|
||
return { success: false, error: (err as Error).message }
|
||
}
|
||
}
|
||
|
||
// C-02: 临时文件写到与目标相同目录(避免跨盘 rename 失败)
|
||
// M-01: 使用随机 hex 后缀替代 Date.now(),防止本地攻击者预创建 symlink
|
||
export async function saveFileContent(filePath: string, content: string): Promise<SaveFileResult> {
|
||
const randomSuffix = randomBytes(8).toString('hex')
|
||
const tmpFile = join(dirname(filePath), `.marklite-tmp-${randomSuffix}-${basename(filePath)}`)
|
||
try {
|
||
await writeFile(tmpFile, content, 'utf-8')
|
||
await rename(tmpFile, filePath)
|
||
return { success: true, filePath }
|
||
} catch (err) {
|
||
// 清理残留临时文件
|
||
try { await unlink(tmpFile) } catch { /* ignore */ }
|
||
return { success: false, error: (err as Error).message }
|
||
}
|
||
}
|
||
|
||
// M-05: 递归深度限制 + 错误边界 + 符号链接循环检测
|
||
export async function buildDirTree(
|
||
dirPath: string,
|
||
depth = 0,
|
||
maxDepth = 10,
|
||
visited?: Set<string>
|
||
): Promise<FileNode[]> {
|
||
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
|
||
try {
|
||
entries = await readdir(dirPath, { withFileTypes: true })
|
||
} catch {
|
||
return []
|
||
}
|
||
|
||
entries.sort((a, b) => {
|
||
if (a.isDirectory() && !b.isDirectory()) return -1
|
||
if (!a.isDirectory() && b.isDirectory()) return 1
|
||
return a.name.localeCompare(b.name)
|
||
})
|
||
|
||
const children: FileNode[] = []
|
||
for (const entry of entries) {
|
||
if (SKIP_DIRS.has(entry.name)) continue
|
||
if (entry.name.startsWith('.')) continue
|
||
|
||
const childPath = join(dirPath, entry.name)
|
||
if (entry.isDirectory()) {
|
||
const subChildren = await buildDirTree(childPath, depth + 1, maxDepth, visited)
|
||
if (subChildren.length > 0) {
|
||
children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren })
|
||
}
|
||
} else {
|
||
const ext = extname(entry.name).toLowerCase()
|
||
if (ALLOWED_EXTENSIONS_SET.has(ext)) {
|
||
children.push({ name: entry.name, path: childPath, type: 'file' })
|
||
}
|
||
}
|
||
}
|
||
return children
|
||
}
|