diff --git a/electron/main/paths.ts b/electron/main/paths.ts index a486d010..c14ad990 100644 --- a/electron/main/paths.ts +++ b/electron/main/paths.ts @@ -11,6 +11,16 @@ import { app } from 'electron' import * as fs from 'fs' import * as path from 'path' +import { + copyDirMerge, + copyDirRecursive, + ensureMarkerFile, + isDirectorySafeToUse, + isExistingChatLabDir, + isPathSafe, + isSubPath, + writeMigrationLog, +} from './utils/pathUtils' // 缓存路径,避免重复计算 let _appDataDir: string | null = null @@ -19,8 +29,10 @@ let _legacyDataDir: string | null = null // 存储配置文件名(放在 userData 根目录,避免受自定义数据目录影响) const STORAGE_CONFIG_FILE = 'storage.json' -// ChatLab 数据目录的标志性子目录列表(用于识别是否为 ChatLab 数据目录) -const CHATLAB_DATA_DIRS = ['databases', 'ai', 'settings', 'logs', 'temp'] +// ChatLab 数据目录标记文件(用于更严格的目录识别) +const CHATLAB_MARKER_FILE = '.chatlab' +// ChatLab 数据目录关键子目录(用于识别已有数据) +const CHATLAB_REQUIRED_DIRS = ['databases', 'settings'] /** * 获取应用数据根目录 @@ -123,113 +135,6 @@ export function getCustomDataDir(): string | null { return dataDir } -/** - * 检查路径是否安全(不在系统关键目录下) - */ -function isPathSafe(targetPath: string): boolean { - // 系统关键目录列表(Windows 和 Unix) - const dangerousPaths = [ - // Windows 系统目录 - 'C:\\Windows', - 'C:\\Program Files', - 'C:\\Program Files (x86)', - 'C:\\ProgramData', - // Unix 系统目录 - '/usr', - '/etc', - '/bin', - '/sbin', - '/lib', - '/var', - '/boot', - '/root', - '/System', - '/Library', - ] - - const normalizedTarget = targetPath.toLowerCase().replace(/\//g, '\\') - - for (const dangerous of dangerousPaths) { - const normalizedDangerous = dangerous.toLowerCase().replace(/\//g, '\\') - if (normalizedTarget.startsWith(normalizedDangerous)) { - return false - } - } - - return true -} - -/** - * 检查目录是否为空或仅包含 ChatLab 数据 - */ -function isDirectorySafeToUse(dirPath: string): boolean { - if (!fs.existsSync(dirPath)) { - return true // 目录不存在,可以安全使用 - } - - try { - const entries = fs.readdirSync(dirPath) - // 如果目录为空,可以安全使用 - if (entries.length === 0) return true - - // 如果包含 ChatLab 的标志性子目录,认为是之前的数据目录 - const hasChatlabStructure = CHATLAB_DATA_DIRS.some((d) => entries.includes(d)) - return hasChatlabStructure - } catch { - return false - } -} - -/** - * 递归合并复制目录(仅复制目标不存在的文件) - * @returns 复制结果统计 - */ -function copyDirMerge( - src: string, - dest: string, - stats: { copied: number; skipped: number; errors: string[] } = { copied: 0, skipped: 0, errors: [] } -): { copied: number; skipped: number; errors: string[] } { - if (!fs.existsSync(src)) return stats - - try { - ensureDir(dest) - const entries = fs.readdirSync(src, { withFileTypes: true }) - - for (const entry of entries) { - const srcPath = path.join(src, entry.name) - const destPath = path.join(dest, entry.name) - - try { - if (entry.isDirectory()) { - if (!fs.existsSync(destPath)) { - copyDirRecursive(srcPath, destPath) - stats.copied++ - } else { - copyDirMerge(srcPath, destPath, stats) - } - } else { - if (!fs.existsSync(destPath)) { - fs.copyFileSync(srcPath, destPath) - stats.copied++ - } else { - stats.skipped++ - } - } - } catch (error) { - const errorMsg = `复制失败: ${srcPath} -> ${error instanceof Error ? error.message : String(error)}` - console.error('[Paths]', errorMsg) - stats.errors.push(errorMsg) - } - } - } catch (error) { - const errorMsg = `读取目录失败: ${src} -> ${error instanceof Error ? error.message : String(error)}` - console.error('[Paths]', errorMsg) - stats.errors.push(errorMsg) - } - - return stats -} - /** * 设置自定义数据目录 * @param dataDir 目标目录(为空则恢复默认) @@ -245,21 +150,48 @@ export function setCustomDataDir( try { if (!normalized) { // 恢复默认路径 - // 记录旧目录,下次启动时删除 - writeStorageConfig({ pendingDeleteDir: oldDir }) - _appDataDir = null - const newDir = getAppDataDir() + const newDir = getDefaultAppDataDir() + // 防止目标目录是当前目录的子目录,避免递归复制 + if (migrate && oldDir !== newDir && isSubPath(oldDir, newDir)) { + return { success: false, error: '目标目录不能是当前数据目录的子目录' } + } + + // 先清除自定义配置,切回默认目录 + writeStorageConfig({}) + _appDataDir = newDir + + let canDeleteOldDir = false if (migrate && oldDir !== newDir) { - const migrateResult = copyDirMerge(oldDir, newDir) + const migrateResult = copyDirMerge(oldDir, newDir, ensureDir) console.log( `[Paths] 数据迁移完成: 复制 ${migrateResult.copied} 项, 跳过 ${migrateResult.skipped} 项, 错误 ${migrateResult.errors.length} 项` ) if (migrateResult.errors.length > 0) { console.warn('[Paths] 迁移过程中出现错误:', migrateResult.errors) + console.warn('[Paths] 迁移失败,旧数据目录将不会自动删除') + // 迁移失败日志写入 app.log + writeMigrationLog( + getLogsDir(), + `切换为默认目录迁移失败: 从 ${oldDir} 到 ${newDir},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项,错误 ${migrateResult.errors.length} 项`, + ensureDir + ) + } else { + canDeleteOldDir = true + // 迁移成功日志写入 app.log + writeMigrationLog( + getLogsDir(), + `切换为默认目录迁移成功: 从 ${oldDir} 到 ${newDir},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项`, + ensureDir + ) } } + // 迁移成功才标记删除旧目录,避免数据丢失 + if (canDeleteOldDir) { + writeStorageConfig({ pendingDeleteDir: oldDir }) + } + return { success: true, from: oldDir, to: newDir } } @@ -267,33 +199,59 @@ export function setCustomDataDir( return { success: false, error: '数据目录必须是绝对路径' } } + // 防止目标目录是当前目录的子目录,避免递归复制 + if (migrate && oldDir !== normalized && isSubPath(oldDir, normalized)) { + return { success: false, error: '目标目录不能是当前数据目录的子目录' } + } + // 安全检查:不能使用系统关键目录 if (!isPathSafe(normalized)) { return { success: false, error: '不能使用系统关键目录作为数据目录' } } // 安全检查:目标目录应为空或已有 ChatLab 数据 - if (!isDirectorySafeToUse(normalized)) { + if (!isDirectorySafeToUse(normalized, CHATLAB_MARKER_FILE, CHATLAB_REQUIRED_DIRS)) { return { success: false, error: '目标目录不为空且不包含 ChatLab 数据,请选择空目录或已有数据目录' } } // 确保目录存在 ensureDir(normalized) - // 记录旧目录,下次启动时删除 - writeStorageConfig({ dataDir: normalized, pendingDeleteDir: oldDir }) _appDataDir = normalized + let canDeleteOldDir = false if (migrate && oldDir !== normalized) { - const migrateResult = copyDirMerge(oldDir, normalized) + const migrateResult = copyDirMerge(oldDir, normalized, ensureDir) console.log( `[Paths] 数据迁移完成: 复制 ${migrateResult.copied} 项, 跳过 ${migrateResult.skipped} 项, 错误 ${migrateResult.errors.length} 项` ) if (migrateResult.errors.length > 0) { console.warn('[Paths] 迁移过程中出现错误:', migrateResult.errors) + console.warn('[Paths] 迁移失败,旧数据目录将不会自动删除') + // 迁移失败日志写入 app.log + writeMigrationLog( + getLogsDir(), + `切换目录迁移失败: 从 ${oldDir} 到 ${normalized},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项,错误 ${migrateResult.errors.length} 项`, + ensureDir + ) + } else { + canDeleteOldDir = true + // 迁移成功日志写入 app.log + writeMigrationLog( + getLogsDir(), + `切换目录迁移成功: 从 ${oldDir} 到 ${normalized},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项`, + ensureDir + ) } } + // 迁移成功才标记删除旧目录,避免数据丢失 + const config: StorageConfig = { dataDir: normalized } + if (canDeleteOldDir) { + config.pendingDeleteDir = oldDir + } + writeStorageConfig(config) + return { success: true, from: oldDir, to: normalized } } catch (error) { console.error('[Paths] Error setting custom data dir:', error) @@ -330,16 +288,12 @@ export function cleanupPendingDeleteDir(): void { return } - // 安全检查:确保待删除目录确实是 ChatLab 数据目录(包含标志性子目录) - if (fs.existsSync(pendingDir)) { - const entries = fs.readdirSync(pendingDir) - const hasChatlabStructure = CHATLAB_DATA_DIRS.some((d) => entries.includes(d)) - if (!hasChatlabStructure) { - console.log('[Paths] 跳过清理:待删除目录不是 ChatLab 数据目录:', pendingDir) - // 清除待删除标记 - writeStorageConfig({ dataDir: config.dataDir }) - return - } + // 安全检查:确保待删除目录确实是 ChatLab 数据目录(标记文件 + 关键子目录) + if (fs.existsSync(pendingDir) && !isExistingChatLabDir(pendingDir, CHATLAB_MARKER_FILE, CHATLAB_REQUIRED_DIRS)) { + console.log('[Paths] 跳过清理:待删除目录不是 ChatLab 数据目录:', pendingDir) + // 清除待删除标记 + writeStorageConfig({ dataDir: config.dataDir }) + return } // 检查目录是否存在 @@ -446,6 +400,8 @@ export function ensureAppDirs(): void { ensureDir(getSettingsDir()) ensureDir(getTempDir()) ensureDir(getLogsDir()) + // 写入数据目录标记文件 + ensureMarkerFile(getAppDataDir(), CHATLAB_MARKER_FILE) } // ==================== 数据迁移 ==================== @@ -513,7 +469,7 @@ function migrateDirectory( const stat = fs.statSync(srcPath) if (stat.isDirectory()) { - copyDirRecursive(srcPath, destPath) + copyDirRecursive(srcPath, destPath, ensureDir) } else { fs.copyFileSync(srcPath, destPath) } @@ -532,24 +488,6 @@ function migrateDirectory( return { migratedDirs, skippedDirs } } -/** - * 写入迁移日志到 app.log - * 使用内联实现避免循环依赖 - */ -function writeMigrationLog(message: string): void { - try { - const logDir = getLogsDir() - ensureDir(logDir) - const logPath = path.join(logDir, 'app.log') - const now = new Date() - const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}` - const logLine = `[${timestamp}] [MIGRATION] ${message}\n` - fs.appendFileSync(logPath, logLine, 'utf-8') - } catch { - // 日志写入失败时静默处理 - } -} - /** * 执行从 Documents/ChatLab 到新目录的数据迁移 * 迁移整个目录的所有内容,采用合并策略:只复制不存在的文件,不覆盖已存在的文件 @@ -597,13 +535,13 @@ export function migrateFromLegacyDir(): { success: boolean; migratedDirs: string } // 写入迁移日志 - writeMigrationLog(summary.join(' | ')) + writeMigrationLog(getLogsDir(), summary.join(' | '), ensureDir) return { success: true, migratedDirs: result.migratedDirs } } catch (error) { console.error('[Paths] Migration failed:', error) const errorMsg = error instanceof Error ? error.message : String(error) - writeMigrationLog(`Migration failed: ${errorMsg}`) + writeMigrationLog(getLogsDir(), `Migration failed: ${errorMsg}`, ensureDir) return { success: false, migratedDirs: [], @@ -612,25 +550,6 @@ export function migrateFromLegacyDir(): { success: boolean; migratedDirs: string } } -/** - * 递归复制目录 - */ -function copyDirRecursive(src: string, dest: string): void { - ensureDir(dest) - const entries = fs.readdirSync(src, { withFileTypes: true }) - - for (const entry of entries) { - const srcPath = path.join(src, entry.name) - const destPath = path.join(dest, entry.name) - - if (entry.isDirectory()) { - copyDirRecursive(srcPath, destPath) - } else { - fs.copyFileSync(srcPath, destPath) - } - } -} - /** * 删除旧版数据目录(可选,供用户确认后调用) */ diff --git a/electron/main/update.ts b/electron/main/update.ts index 5e9fd87f..1d8c9f40 100644 --- a/electron/main/update.ts +++ b/electron/main/update.ts @@ -45,7 +45,6 @@ function switchToR2Mirror(): void { provider: 'generic', url: R2_MIRROR_URL, }) - logger.info(`[Update] 使用 R2 镜像源: ${R2_MIRROR_URL}`) } /** diff --git a/electron/main/utils/pathUtils.ts b/electron/main/utils/pathUtils.ts new file mode 100644 index 00000000..a173472b --- /dev/null +++ b/electron/main/utils/pathUtils.ts @@ -0,0 +1,205 @@ +import * as fs from 'fs' +import * as path from 'path' + +// 系统关键目录列表(用于安全校验) +const DANGEROUS_PATHS = [ + // Windows 系统目录 + 'C:\\Windows', + 'C:\\Program Files', + 'C:\\Program Files (x86)', + 'C:\\ProgramData', + // Unix 系统目录 + '/usr', + '/etc', + '/bin', + '/sbin', + '/lib', + '/var', + '/boot', + '/root', + '/System', + '/Library', +] + +// 统一路径标准化(兼容 Windows 大小写差异) +function normalizePathForCompare(input: string): string { + const resolved = path.resolve(input) + const normalized = path.normalize(resolved) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +/** + * 判断 child 是否为 parent 的子目录 + */ +export function isSubPath(parent: string, child: string): boolean { + const parentPath = normalizePathForCompare(parent) + const childPath = normalizePathForCompare(child) + + if (parentPath === childPath) return false + return childPath.startsWith(`${parentPath}${path.sep}`) +} + +/** + * 检查路径是否安全(不在系统关键目录下) + */ +export function isPathSafe(targetPath: string): boolean { + const normalizedTarget = targetPath.toLowerCase().replace(/\//g, '\\') + + for (const dangerous of DANGEROUS_PATHS) { + const normalizedDangerous = dangerous.toLowerCase().replace(/\//g, '\\') + if (normalizedTarget.startsWith(normalizedDangerous)) { + return false + } + } + + return true +} + +/** + * 检查目录是否为空或包含 ChatLab 标记与关键结构 + */ +export function isDirectorySafeToUse(dirPath: string, markerFile: string, requiredDirs: string[]): boolean { + if (!fs.existsSync(dirPath)) { + return true // 目录不存在,可以安全使用 + } + + try { + const entries = fs.readdirSync(dirPath) + // 如果目录为空,可以安全使用 + if (entries.length === 0) return true + + return hasChatLabStructure(entries, markerFile, requiredDirs) + } catch { + return false + } +} + +/** + * 检查目录是否为已存在的 ChatLab 数据目录 + */ +export function isExistingChatLabDir(dirPath: string, markerFile: string, requiredDirs: string[]): boolean { + if (!fs.existsSync(dirPath)) return false + + try { + const entries = fs.readdirSync(dirPath) + return hasChatLabStructure(entries, markerFile, requiredDirs) + } catch { + return false + } +} + +/** + * 确保数据目录标记文件存在 + */ +export function ensureMarkerFile(dirPath: string, markerFile: string): void { + try { + const markerPath = path.join(dirPath, markerFile) + if (!fs.existsSync(markerPath)) { + fs.writeFileSync(markerPath, 'ChatLab Data Directory', 'utf-8') + } + } catch { + // 标记文件写入失败时静默处理 + } +} + +/** + * 递归复制目录 + */ +export function copyDirRecursive(src: string, dest: string, ensureDir: (dirPath: string) => void): void { + ensureDir(dest) + const entries = fs.readdirSync(src, { withFileTypes: true }) + + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + + if (entry.isDirectory()) { + copyDirRecursive(srcPath, destPath, ensureDir) + } else { + fs.copyFileSync(srcPath, destPath) + } + } +} + +export interface CopyStats { + copied: number + skipped: number + errors: string[] +} + +/** + * 递归合并复制目录(仅复制目标不存在的文件) + * @returns 复制结果统计 + */ +export function copyDirMerge( + src: string, + dest: string, + ensureDir: (dirPath: string) => void, + stats: CopyStats = { copied: 0, skipped: 0, errors: [] } +): CopyStats { + if (!fs.existsSync(src)) return stats + + try { + ensureDir(dest) + const entries = fs.readdirSync(src, { withFileTypes: true }) + + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + + try { + if (entry.isDirectory()) { + if (!fs.existsSync(destPath)) { + copyDirRecursive(srcPath, destPath, ensureDir) + stats.copied++ + } else { + copyDirMerge(srcPath, destPath, ensureDir, stats) + } + } else { + if (!fs.existsSync(destPath)) { + fs.copyFileSync(srcPath, destPath) + stats.copied++ + } else { + stats.skipped++ + } + } + } catch (error) { + const errorMsg = `复制失败: ${srcPath} -> ${error instanceof Error ? error.message : String(error)}` + console.error('[Paths]', errorMsg) + stats.errors.push(errorMsg) + } + } + } catch (error) { + const errorMsg = `读取目录失败: ${src} -> ${error instanceof Error ? error.message : String(error)}` + console.error('[Paths]', errorMsg) + stats.errors.push(errorMsg) + } + + return stats +} + +/** + * 写入迁移日志到 app.log + */ +export function writeMigrationLog( + logDir: string, + message: string, + ensureDir: (dirPath: string) => void +): void { + try { + ensureDir(logDir) + const logPath = path.join(logDir, 'app.log') + const now = new Date() + const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}` + const logLine = `[${timestamp}] [MIGRATION] ${message}\n` + fs.appendFileSync(logPath, logLine, 'utf-8') + } catch { + // 日志写入失败时静默处理 + } +} + +function hasChatLabStructure(entries: string[], markerFile: string, requiredDirs: string[]): boolean { + const hasMarker = entries.includes(markerFile) + const hasRequiredDirs = requiredDirs.every((dir) => entries.includes(dir)) + return hasMarker && hasRequiredDirs +} diff --git a/src/components/common/settings/DataStorage/StorageManageSection.vue b/src/components/common/settings/DataStorage/StorageManageSection.vue index a9c9b6c3..cae028d8 100644 --- a/src/components/common/settings/DataStorage/StorageManageSection.vue +++ b/src/components/common/settings/DataStorage/StorageManageSection.vue @@ -203,54 +203,6 @@ defineExpose({ - -
- {{ t('settings.storage.dataLocation.title') }} -
-- {{ t('settings.storage.dataLocation.description') }} -
-- {{ t('settings.storage.dataLocation.restartTip') }} -
-- {{ dataDirError }} -
-+ {{ t('settings.storage.dataLocation.title') }} +
++ {{ t('settings.storage.dataLocation.description') }} +
++ {{ t('settings.storage.dataLocation.restartTip') }} +
++ {{ dataDirError }} +
+{{ t('settings.storage.dataLocation.confirmWarning') }}