Files
opsxshell/electron/ipc/file.ipc.ts
T
Logan.Li 514d62644e v2.0.0: 主题系统+SSH私钥解析修复+会话持久化+连接池优化+UI打磨
核心修复:
- ssh-service.ts: 新增 resolvePrivateKey(),将私钥文件路径(~/.ssh/id_rsa)解析为内容后传给 ssh2,
  解决 'Cannot parse privateKey: Unsupported key format' 错误
- useTerminalConnection.ts + sessionHelper.ts: 根据 authType 过滤字段,password/agent 认证不再传 privateKey
- sessionStore.ts: addSession() 现在先调用 sessionCreate() 持久化到 SQLite,解决恢复标签'无可用连接信息'问题
- database-service.ts: updateSession() 补充 private_key_path 字段持久化
- SessionDialog.tsx: 默认 username='root'、privateKey='~/.ssh/id_rsa'

UI/UX:
- 语义化主题系统(app-theme.ts),强制深色模式,全局无边框设计
- macOS 标题栏支持双击最大化(window:toggleMaximize IPC)
- macOS 菜单栏('OpsXShell' 应用菜单 + Settings Cmd+,)
- WelcomeView 4卡片布局,移除 Settings 按钮
- TabBar/TabItem 背景色统一(bg-dark-800/900),消除白色边框线

架构优化:
- SFTP 生命周期修复(useSFTP.ts useRef + autoConnectAttempted guard)
- 连接池竞态修复,支持快速多标签连接
- IPC 接口规范化,安全错误处理
- Vite 代码分割优化

质量: lint零错误 | tsc零错误 | build成功 | 49 tests pass
2026-04-22 11:15:41 +08:00

271 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* File IPC Handlers
* 处理本地文件操作和远程文件读写
* 安全增强:防止路径遍历攻击
*/
import { ipcMain, IpcMainInvokeEvent, app } from 'electron'
import fs from 'fs'
import path from 'path'
// IPC channel names
export const FILE_CHANNELS = {
// Remote file operations (for zmodem)
READ: 'file:read',
WRITE: 'file:write',
// Local file system operations
LOCAL_GET_HOME: 'local:getHome',
LOCAL_READ_DIR: 'local:readDir',
LOCAL_GET_STATS: 'local:getStats',
LOCAL_DELETE: 'local:delete',
LOCAL_RENAME: 'local:rename',
LOCAL_MKDIR: 'local:mkdir',
} as const
// 允许的文件操作基础目录白名单
const ALLOWED_BASE_PATHS: string[] = []
/**
* 初始化允许的路径白名单
* 防止路径遍历攻击
*/
export function initializeAllowedPaths(): void {
ALLOWED_BASE_PATHS.push(
app.getPath('home'),
app.getPath('downloads'),
app.getPath('documents'),
app.getPath('desktop'),
app.getPath('temp'),
app.getPath('userData'),
)
// 添加平台特定的常用目录
if (process.platform === 'darwin') {
ALLOWED_BASE_PATHS.push('/tmp', '/var/tmp')
} else if (process.platform === 'linux') {
ALLOWED_BASE_PATHS.push('/tmp', '/var/tmp', '/home')
} else if (process.platform === 'win32') {
ALLOWED_BASE_PATHS.push(process.env.LOCALAPPDATA || '', process.env.TEMP || '')
}
}
/**
* 验证文件路径是否在允许的范围内
* 防止路径遍历攻击(如 ../../../etc/passwd)
*/
function isPathAllowed(filePath: string): boolean {
// 规范化路径(解析 .. 和 .)
const resolved = path.resolve(filePath)
// 检查是否在允许的基目录下
const isUnderAllowedPath = ALLOWED_BASE_PATHS.some((base) => {
if (!base) return false
const relative = path.relative(base, resolved)
return !relative.startsWith('..') && !path.isAbsolute(relative)
})
if (isUnderAllowedPath) return true
// 如果路径存在,检查其实际位置(处理符号链接)
try {
const real = fs.realpathSync(resolved)
return ALLOWED_BASE_PATHS.some((base) => {
if (!base) return false
const relative = path.relative(base, real)
return !relative.startsWith('..') && !path.isAbsolute(relative)
})
} catch {
return false
}
}
/**
* 安全地规范化路径
*/
function sanitizePath(inputPath: string): string {
// 移除 null 字节
const cleaned = inputPath.replace(/\0/g, '')
// 规范化路径
return path.resolve(cleaned)
}
export function registerFileIPCHandlers(): void {
// 确保白名单已初始化
if (ALLOWED_BASE_PATHS.length === 0) {
initializeAllowedPaths()
}
// Read file (for rz)
ipcMain.handle(FILE_CHANNELS.READ, async (_event: IpcMainInvokeEvent, filePath: string) => {
try {
const safePath = sanitizePath(filePath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const data = fs.readFileSync(safePath)
return { success: true, data: data.toString('base64') }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Write file (for sz)
ipcMain.handle(FILE_CHANNELS.WRITE, async (_event: IpcMainInvokeEvent, filePath: string, data: string) => {
try {
const safePath = sanitizePath(filePath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const buffer = Buffer.from(data, 'base64')
fs.writeFileSync(safePath, buffer)
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Get user home directory
ipcMain.handle(FILE_CHANNELS.LOCAL_GET_HOME, async () => {
try {
const homePath = app.getPath('home')
return { success: true, path: homePath }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Read directory contents
ipcMain.handle(FILE_CHANNELS.LOCAL_READ_DIR, async (_event: IpcMainInvokeEvent, dirPath: string) => {
try {
const safePath = sanitizePath(dirPath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const entries = fs.readdirSync(safePath, { withFileTypes: true })
const files = entries.map((entry) => {
const fullPath = path.join(dirPath, entry.name)
let stats: fs.Stats | null = null
try {
stats = fs.lstatSync(fullPath)
} catch {
// Skip files we can't stat
}
return {
name: entry.name,
path: fullPath,
type: entry.isDirectory() ? 'directory' : entry.isSymbolicLink() ? 'symlink' : 'file',
size: stats?.size || 0,
modified: stats?.mtime ? Math.floor(stats.mtime.getTime() / 1000) : 0,
permissions: formatPermissions(stats?.mode || 0),
}
})
// Sort: directories first, then by name
files.sort((a, b) => {
if (a.type !== 'file' && b.type === 'file') return -1
if (a.type === 'file' && b.type !== 'file') return 1
return a.name.localeCompare(b.name)
})
return { success: true, files }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Get file stats
ipcMain.handle(FILE_CHANNELS.LOCAL_GET_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => {
try {
const safePath = sanitizePath(filePath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const stats = fs.lstatSync(safePath)
return {
success: true,
stats: {
name: path.basename(safePath),
path: safePath,
type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file',
size: stats.size,
modified: Math.floor(stats.mtime.getTime() / 1000),
permissions: formatPermissions(stats.mode),
},
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Delete file or directory
ipcMain.handle(FILE_CHANNELS.LOCAL_DELETE, async (_event: IpcMainInvokeEvent, filePath: string) => {
try {
const safePath = sanitizePath(filePath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const stats = fs.lstatSync(safePath)
if (stats.isDirectory()) {
fs.rmSync(safePath, { recursive: true, force: true })
} else {
fs.unlinkSync(safePath)
}
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Rename file or directory
ipcMain.handle(FILE_CHANNELS.LOCAL_RENAME, async (_event: IpcMainInvokeEvent, oldPath: string, newPath: string) => {
try {
const safeOld = sanitizePath(oldPath)
const safeNew = sanitizePath(newPath)
if (!isPathAllowed(safeOld) || !isPathAllowed(safeNew)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
fs.renameSync(safeOld, safeNew)
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Create directory
ipcMain.handle(FILE_CHANNELS.LOCAL_MKDIR, async (_event: IpcMainInvokeEvent, dirPath: string) => {
try {
const safePath = sanitizePath(dirPath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
fs.mkdirSync(safePath, { recursive: true })
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
}
// Helper function to format Unix permissions
function formatPermissions(mode: number): string {
const perms: string[] = []
const types = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']
// Owner
perms.push(types[(mode >> 6) & 7])
// Group
perms.push(types[(mode >> 3) & 7])
// Other
perms.push(types[mode & 7])
return perms.join('')
}