基于全量源码审计(electron/ 70+ 文件、src/ 45+ 文件完整读取)的 四阶段迭代,修复 1 项安全缺陷、8 项功能缺陷、依赖与文档系统性脱节。 【P0 安全与数据正确性】 - S-1 数据导出泄露明文密钥:data:export 全量导出直接透传 configService.getAll()(敏感 key 解密返回),导出文件含明文 API Key。 新增 sanitizeExportConfig(shared.ts)逐 key 脱敏 + 5 项回归测试 - F-3 session_summaries 无级联删除:删会话后摘要永久残留。建表语句补 FOREIGN KEY ON DELETE CASCADE + 迁移 8 重建存量表(幂等检测)+ 2 项测试 - F-4 macOS activate 重建窗口后确认弹框失效:窗口创建收敛为 createMainWindow 单一入口(beforeLoad 补 setMainWindow/IPC 注册),TrayManager 补 rebindWindow,app:selectFolder 改 event.sender 动态解析窗口 【P1 死代码激活与功能补全】 - F-1 会话右键菜单挂载:ContextMenu session 分支(重命名/置顶/归档/ 导出/删除)约 200 行此前无任何触发点,Sidebar SessionItem 挂载 onContextMenu;置顶/归档 label 随状态切换显示 - F-2 归档会话不可找回:Sidebar 新增「已归档」折叠面板(恢复入口), 归档功能形成完整闭环 - F-7 Provider 白名单校验:createAdapter/buildFallbackAdapter 未知 provider 显式拒绝(此前静默落入 DeepSeekAdapter 以空配置失败) - F-8 死配置治理:接通 5 项(llm.temperature/llm.maxTokens 注入引擎、 security.promptInjectionDefense 控制 SecurityScanHook+消息检测、 logging.auditEnabled 控制 AuditLogHook、logging.traceEnabled 控制 SessionRecorder,均 fail-secure 仅显式 false 关闭);删除 4 项 无消费者配置(requireWriteConfirmation/maxFileWriteSizeKB/fontSize/ animationMode) 【P2 依赖治理与 UX 修缮】 - D-1 移除僵尸依赖 electron-store/zod/rehype-raw(源码零引用, 共裁 24 包);README 技术栈表同步删除虚假宣称 - metona-toast 升级 0.2.1 → 0.5.0(API 全兼容:107 种图标类型、 配置项超集,default/configure/use 接口不变) - F-5 工作空间手输路径实时落库中间态:改 pendingPath 草稿 + 显式 「校验」按钮(选择文件夹与手输共用 validatePath 流程) - F-6 确认弹框超时滞留:倒计时归零时主动 refreshPending(后端超时 已删条目,拉取后弹框自然消解) - D-3 ChatInput accept 移除 .pdf(分类器不识别,误导性入口); Onboarding 切换 Provider 自动填充默认 URL(与 LLMSettings 一致) 【P3 文档口径收敛】 - README:工具数 30+→28、版本 0.6.0、测试数 252、配置表补 F-8 接通项 - built-in/index.ts 计数注释 30→28 - docs/网络工具 v2 存储键名统一为点号口径(searxng.enabled) - docs/完整设计指南修正 db.handlers.ts 失效路径引用为 data.ts 【验证】 - lint 0 error / 0 warning - typecheck 双工程(node+web)0 错误 - test:electron 24 文件 252 用例全通过(+7 新增:导出脱敏 ×5、 级联删除 ×2) - electron-vite build 成功(metona-toast 0.5.0 chunk 正常) - 系统 Node 模式 npm test 225 通过 + 27 ABI skip(符合预期)
193 lines
7.3 KiB
TypeScript
193 lines
7.3 KiB
TypeScript
/**
|
||
* IPC Data Handlers — 数据导出与清理域(P2-9 从 handlers.ts 拆分)
|
||
*/
|
||
|
||
import { ipcMain } from 'electron';
|
||
import type { IPCContext } from './context';
|
||
import log from 'electron-log';
|
||
import { sanitizeExportConfig } from './shared';
|
||
|
||
/**
|
||
* 导出限流常量:单会话导出的最大消息条数。
|
||
*
|
||
* 背景:导出会把所有会话×所有消息×完整 toolResult 一次性 JSON.stringify 成单个 Blob
|
||
* 传到渲染进程。含 view_image 的会话单条 tool_result 就有 ~6.7MB base64 dataUrl,
|
||
* 全量导出会直接撑爆渲染进程堆导致 OOM 崩溃。
|
||
*
|
||
* 双重防护:
|
||
* 1. 剥离每条 tool 消息中的 dataUrl 等超大 base64(sanitizeExportMessage)
|
||
* 2. 全量导出时按会话限制消息条数(MAX_EXPORT_MESSAGES_PER_SESSION)
|
||
*/
|
||
const MAX_EXPORT_MESSAGES_PER_SESSION = 2000;
|
||
|
||
/**
|
||
* 剥离导出消息中的超大 base64 字段(如 view_image 的 dataUrl)。
|
||
* 仅用于导出快照——原数据库数据不受影响。
|
||
*/
|
||
const sanitizeExportMessage = (msg: Record<string, unknown>): Record<string, unknown> => {
|
||
const toolResult = msg.toolResult as Record<string, unknown> | undefined;
|
||
if (toolResult && typeof toolResult === 'object' && 'result' in toolResult) {
|
||
const result = toolResult.result;
|
||
// result 含 dataUrl(view_image 等图片结果):剥离 dataUrl,保留 path/size/mimeType 等小字段
|
||
if (result && typeof result === 'object' && !Array.isArray(result) && 'dataUrl' in result) {
|
||
const { dataUrl: _omit, ...rest } = result as Record<string, unknown>;
|
||
void _omit;
|
||
return {
|
||
...msg,
|
||
toolResult: {
|
||
...toolResult,
|
||
result: { ...rest, _displayNote: '[image base64 omitted in export]' },
|
||
},
|
||
};
|
||
}
|
||
}
|
||
// attachments[].preview(用户上传图片的 base64 缩略图)也一并剥离,避免导出文件膨胀
|
||
const attachments = msg.attachments;
|
||
if (Array.isArray(attachments)) {
|
||
const sanitized = attachments.map((att) => {
|
||
if (att && typeof att === 'object' && 'preview' in att) {
|
||
const { preview: _omit, ...rest } = att as Record<string, unknown>;
|
||
void _omit;
|
||
return { ...rest, _displayNote: '[preview omitted in export]' };
|
||
}
|
||
return att;
|
||
});
|
||
return { ...msg, attachments: sanitized };
|
||
}
|
||
return msg;
|
||
};
|
||
|
||
export function registerDataHandlers(ctx: IPCContext): void {
|
||
const { sessionService, configService } = ctx;
|
||
|
||
ipcMain.handle('data:export', async (_event, sessionId?: string) => {
|
||
try {
|
||
if (sessionId) {
|
||
// 导出单个会话:剥离超大 base64 后返回
|
||
const messages = sessionService.getMessages(sessionId);
|
||
const sanitized = messages.map((m) =>
|
||
sanitizeExportMessage(m as unknown as Record<string, unknown>),
|
||
);
|
||
return { success: true, data: sanitized };
|
||
}
|
||
// 导出所有会话:剥离 dataUrl + 每会话限条数,防渲染进程 Blob 序列化 OOM
|
||
// S-1 修复:config 走 sanitizeExportConfig 脱敏 —— getAll() 对敏感 key
|
||
// 解密返回明文,直接透传会把明文 API Key 写入用户下载的导出文件
|
||
const sessions = sessionService.list();
|
||
const allData: Record<string, unknown> = {
|
||
sessions: [],
|
||
config: sanitizeExportConfig(configService.getAll()),
|
||
};
|
||
for (const session of sessions) {
|
||
const rawMessages = sessionService.getMessages(session.id, {
|
||
limit: MAX_EXPORT_MESSAGES_PER_SESSION,
|
||
});
|
||
const sanitizedMessages = rawMessages.map((m) =>
|
||
sanitizeExportMessage(m as unknown as Record<string, unknown>),
|
||
);
|
||
(allData.sessions as Array<Record<string, unknown>>).push({
|
||
...session,
|
||
messages: sanitizedMessages,
|
||
truncated: rawMessages.length >= MAX_EXPORT_MESSAGES_PER_SESSION,
|
||
});
|
||
}
|
||
return { success: true, data: allData };
|
||
} catch (error) {
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('data:clearSessions', async () => {
|
||
// M-40 修复: 危险操作审计日志,便于追踪异常调用
|
||
log.warn('[DATA] DANGER: clearSessions invoked — all sessions and messages will be deleted');
|
||
const db = sessionService.getDB();
|
||
try {
|
||
db.exec('BEGIN');
|
||
const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages').get() as { c: number };
|
||
const sessCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get() as { c: number };
|
||
db.exec('DELETE FROM messages');
|
||
db.exec('DELETE FROM sessions');
|
||
db.exec('COMMIT');
|
||
log.info(
|
||
`[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`,
|
||
);
|
||
return { success: true, deletedSessions: sessCount.c, deletedMessages: msgCount.c };
|
||
} catch (error) {
|
||
try {
|
||
db.exec('ROLLBACK');
|
||
} catch {
|
||
/* 忽略回滚错误 */
|
||
}
|
||
log.error('[DATA] clearSessions failed:', (error as Error).message);
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('data:clearMemories', async () => {
|
||
// M-40 修复: 危险操作审计日志
|
||
log.warn('[DATA] DANGER: clearMemories invoked — all memories will be deleted');
|
||
const db = sessionService.getDB();
|
||
try {
|
||
// M-41 修复: 三个 DELETE 操作用事务包裹,防止部分失败导致三类记忆数据不一致
|
||
const epiCount = db.prepare('SELECT COUNT(*) as c FROM episodic_memories').get() as {
|
||
c: number;
|
||
};
|
||
const semCount = db.prepare('SELECT COUNT(*) as c FROM semantic_memories').get() as {
|
||
c: number;
|
||
};
|
||
const workCount = db.prepare('SELECT COUNT(*) as c FROM working_memories').get() as {
|
||
c: number;
|
||
};
|
||
db.exec('BEGIN');
|
||
db.exec('DELETE FROM episodic_memories');
|
||
db.exec('DELETE FROM semantic_memories');
|
||
db.exec('DELETE FROM working_memories');
|
||
db.exec('COMMIT');
|
||
log.info(
|
||
`[DATA] All memories cleared: ${epiCount.c} episodic, ${semCount.c} semantic, ${workCount.c} working memories deleted`,
|
||
);
|
||
return {
|
||
success: true,
|
||
deletedEpisodic: epiCount.c,
|
||
deletedSemantic: semCount.c,
|
||
deletedWorking: workCount.c,
|
||
};
|
||
} catch (error) {
|
||
// M-41 修复: 失败时回滚事务,确保数据一致性
|
||
try {
|
||
db.exec('ROLLBACK');
|
||
} catch {
|
||
/* 忽略回滚错误 */
|
||
}
|
||
log.error('[DATA] clearMemories failed:', (error as Error).message);
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('data:clearAuditLogs', async () => {
|
||
// 审计日志是 INSERT-ONLY,需要先禁用触发器
|
||
const db = sessionService.getDB();
|
||
try {
|
||
db.exec('BEGIN');
|
||
db.exec('DROP TRIGGER IF EXISTS audit_no_delete');
|
||
db.exec('DELETE FROM audit_logs');
|
||
db.exec(`
|
||
CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_logs
|
||
BEGIN
|
||
SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY. Deletion is not allowed.');
|
||
END
|
||
`);
|
||
db.exec('COMMIT');
|
||
log.info('[DATA] Audit logs cleared');
|
||
return { success: true };
|
||
} catch (error) {
|
||
try {
|
||
db.exec('ROLLBACK');
|
||
} catch {
|
||
/* 忽略回滚错误 */
|
||
}
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
}
|