mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
import * as fs from 'fs'
|
|
import * as path from 'path'
|
|
import { writeConfigField } from '@openchatlab/config'
|
|
|
|
const CHATLAB_MARKER_FILE = '.chatlab'
|
|
const USER_DATA_REQUIRED_DIRS = ['databases']
|
|
const PENDING_MIGRATION_FILE = 'data-dir-migration.json'
|
|
|
|
const DANGEROUS_PATHS = [
|
|
'C:\\Windows',
|
|
'C:\\Program Files',
|
|
'C:\\Program Files (x86)',
|
|
'C:\\ProgramData',
|
|
'/usr',
|
|
'/etc',
|
|
'/bin',
|
|
'/sbin',
|
|
'/lib',
|
|
'/var',
|
|
'/boot',
|
|
'/root',
|
|
'/System',
|
|
'/Library',
|
|
]
|
|
|
|
export interface CopyStats {
|
|
copied: number
|
|
skipped: number
|
|
errors: string[]
|
|
}
|
|
|
|
export interface PendingDataDirMigration {
|
|
from: string
|
|
to: string
|
|
migrate: boolean
|
|
deleteSourceOnSuccess: boolean
|
|
createdAt: string
|
|
}
|
|
|
|
export interface RunPendingDataDirMigrationDeps {
|
|
copyDirMerge?: typeof copyDirMerge
|
|
ensureDir?: (dirPath: string) => void
|
|
writeUserDataDir: (dir: string) => void
|
|
clearPendingMigration: () => void
|
|
markPendingDeleteDir?: (dir: string) => void
|
|
log?: (message: string) => void
|
|
}
|
|
|
|
export interface RunPendingDataDirMigrationResult {
|
|
success: boolean
|
|
from: string
|
|
to: string
|
|
copied: number
|
|
skipped: number
|
|
errors: string[]
|
|
}
|
|
|
|
export interface DataDirSwitchResult {
|
|
success: boolean
|
|
error?: string
|
|
from?: string
|
|
to?: string
|
|
requiresRelaunch?: boolean
|
|
}
|
|
|
|
export interface ApplyPendingNodeDataDirMigrationDeps {
|
|
writeConfigField?: typeof writeConfigField
|
|
removeDir?: (dir: string) => void
|
|
}
|
|
|
|
function normalizePathForCompare(input: string): string {
|
|
const resolved = path.resolve(input)
|
|
const normalized = path.normalize(resolved)
|
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
|
}
|
|
|
|
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}`)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function ensureDir(dirPath: string): void {
|
|
if (!fs.existsSync(dirPath)) {
|
|
fs.mkdirSync(dirPath, { recursive: true })
|
|
}
|
|
}
|
|
|
|
function hasChatLabUserDataStructure(entries: string[]): boolean {
|
|
return entries.includes(CHATLAB_MARKER_FILE) && USER_DATA_REQUIRED_DIRS.every((dir) => entries.includes(dir))
|
|
}
|
|
|
|
function hasChatLabDatabaseFiles(dirPath: string, entries?: string[]): boolean {
|
|
const dirEntries = entries ?? fs.readdirSync(dirPath)
|
|
if (!dirEntries.includes('databases')) return false
|
|
|
|
const dbDir = path.join(dirPath, 'databases')
|
|
try {
|
|
return fs.existsSync(dbDir) && fs.readdirSync(dbDir).some((file) => file.endsWith('.db'))
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function isExistingUserDataDir(dirPath: string): boolean {
|
|
if (!fs.existsSync(dirPath)) return false
|
|
|
|
try {
|
|
const entries = fs.readdirSync(dirPath)
|
|
return hasChatLabUserDataStructure(entries) || hasChatLabDatabaseFiles(dirPath, entries)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function isUserDataDirSafeToUse(dirPath: string): boolean {
|
|
if (!fs.existsSync(dirPath)) return true
|
|
|
|
try {
|
|
const entries = fs.readdirSync(dirPath)
|
|
if (entries.length === 0) return true
|
|
return hasChatLabUserDataStructure(entries) || hasChatLabDatabaseFiles(dirPath, entries)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function isDirectoryEmptyOrMissing(dirPath: string): boolean {
|
|
if (!fs.existsSync(dirPath)) return true
|
|
|
|
try {
|
|
return fs.readdirSync(dirPath).length === 0
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function copyDirMerge(
|
|
src: string,
|
|
dest: string,
|
|
mkdir: (dirPath: string) => void = ensureDir,
|
|
stats: CopyStats = { copied: 0, skipped: 0, errors: [] }
|
|
): CopyStats {
|
|
if (!fs.existsSync(src)) return stats
|
|
|
|
try {
|
|
mkdir(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()) {
|
|
copyDirMerge(srcPath, destPath, mkdir, stats)
|
|
} else if (!fs.existsSync(destPath)) {
|
|
fs.copyFileSync(srcPath, destPath)
|
|
stats.copied++
|
|
} else {
|
|
stats.skipped++
|
|
}
|
|
} catch (error) {
|
|
stats.errors.push(`复制失败: ${srcPath} -> ${error instanceof Error ? error.message : String(error)}`)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
stats.errors.push(`读取目录失败: ${src} -> ${error instanceof Error ? error.message : String(error)}`)
|
|
}
|
|
|
|
return stats
|
|
}
|
|
|
|
export function createPendingDataDirMigration(input: {
|
|
from: string
|
|
to: string
|
|
migrate: boolean
|
|
targetWasEmpty: boolean
|
|
}): PendingDataDirMigration {
|
|
return {
|
|
from: input.from,
|
|
to: input.to,
|
|
migrate: input.migrate,
|
|
deleteSourceOnSuccess: input.migrate && input.targetWasEmpty,
|
|
createdAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
export function runPendingDataDirMigration(
|
|
pending: PendingDataDirMigration,
|
|
deps: RunPendingDataDirMigrationDeps
|
|
): RunPendingDataDirMigrationResult {
|
|
const copy = deps.copyDirMerge ?? copyDirMerge
|
|
const mkdir = deps.ensureDir ?? ensureDir
|
|
|
|
let stats: CopyStats = { copied: 0, skipped: 0, errors: [] }
|
|
if (pending.migrate && path.resolve(pending.from) !== path.resolve(pending.to)) {
|
|
if (!fs.existsSync(pending.from)) {
|
|
return {
|
|
success: false,
|
|
from: pending.from,
|
|
to: pending.to,
|
|
copied: 0,
|
|
skipped: 0,
|
|
errors: [`源数据目录不存在: ${pending.from}`],
|
|
}
|
|
}
|
|
|
|
stats = copy(pending.from, pending.to, mkdir)
|
|
deps.log?.(
|
|
`数据目录迁移完成: 从 ${pending.from} 到 ${pending.to},复制 ${stats.copied} 项,跳过 ${stats.skipped} 项,错误 ${stats.errors.length} 项`
|
|
)
|
|
if (stats.errors.length > 0) {
|
|
return {
|
|
success: false,
|
|
from: pending.from,
|
|
to: pending.to,
|
|
copied: stats.copied,
|
|
skipped: stats.skipped,
|
|
errors: stats.errors,
|
|
}
|
|
}
|
|
} else {
|
|
mkdir(pending.to)
|
|
}
|
|
|
|
deps.writeUserDataDir(pending.to)
|
|
deps.clearPendingMigration()
|
|
|
|
if (pending.deleteSourceOnSuccess && path.resolve(pending.from) !== path.resolve(pending.to)) {
|
|
deps.markPendingDeleteDir?.(pending.from)
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
from: pending.from,
|
|
to: pending.to,
|
|
copied: stats.copied,
|
|
skipped: stats.skipped,
|
|
errors: [],
|
|
}
|
|
}
|
|
|
|
function getPendingMigrationPath(systemDir: string): string {
|
|
return path.join(systemDir, 'settings', PENDING_MIGRATION_FILE)
|
|
}
|
|
|
|
export function getPendingNodeDataDirMigration(systemDir: string): PendingDataDirMigration | null {
|
|
const filePath = getPendingMigrationPath(systemDir)
|
|
if (!fs.existsSync(filePath)) return null
|
|
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as PendingDataDirMigration
|
|
if (!parsed.from || !parsed.to || typeof parsed.migrate !== 'boolean') return null
|
|
return parsed
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function clearPendingNodeDataDirMigration(systemDir: string): void {
|
|
const filePath = getPendingMigrationPath(systemDir)
|
|
if (fs.existsSync(filePath)) {
|
|
fs.unlinkSync(filePath)
|
|
}
|
|
}
|
|
|
|
// 只删除确认仍是 ChatLab 用户数据目录的旧路径,避免误删用户选择的普通目录。
|
|
export function deleteOldUserDataDirIfSafe(
|
|
dirPath: string,
|
|
currentDir: string,
|
|
removeDir?: (dir: string) => void
|
|
): boolean {
|
|
if (path.resolve(dirPath) === path.resolve(currentDir)) return false
|
|
if (!isPathSafe(dirPath)) return false
|
|
if (!fs.existsSync(dirPath)) return false
|
|
if (!isExistingUserDataDir(dirPath)) return false
|
|
|
|
const remove = removeDir ?? ((dir: string) => fs.rmSync(dir, { recursive: true, force: true }))
|
|
remove(dirPath)
|
|
return true
|
|
}
|
|
|
|
function writePendingNodeDataDirMigration(systemDir: string, pending: PendingDataDirMigration): void {
|
|
const filePath = getPendingMigrationPath(systemDir)
|
|
ensureDir(path.dirname(filePath))
|
|
fs.writeFileSync(filePath, JSON.stringify(pending, null, 2), 'utf-8')
|
|
}
|
|
|
|
export function createNodeDataDirSwitch(input: {
|
|
systemDir: string
|
|
currentDir: string
|
|
targetDir: string | null
|
|
defaultDir?: string
|
|
migrate?: boolean
|
|
envDataDir?: string
|
|
}): DataDirSwitchResult {
|
|
if (input.envDataDir) {
|
|
return { success: false, error: 'CHATLAB_DATA_DIR is set, data directory cannot be changed from Web UI' }
|
|
}
|
|
|
|
const newDir = (input.targetDir?.trim() || input.defaultDir || '').trim()
|
|
if (!newDir) return { success: false, error: 'Data directory is required' }
|
|
if (!path.isAbsolute(newDir)) return { success: false, error: 'Data directory must be an absolute path' }
|
|
if (!isPathSafe(newDir)) return { success: false, error: 'System directories cannot be used as data directory' }
|
|
|
|
const migrate = input.migrate !== false
|
|
if (migrate && path.resolve(input.currentDir) !== path.resolve(newDir) && isSubPath(input.currentDir, newDir)) {
|
|
return { success: false, error: 'Target directory cannot be inside current data directory' }
|
|
}
|
|
|
|
if (!isUserDataDirSafeToUse(newDir)) {
|
|
return { success: false, error: 'Target directory is not empty and is not a ChatLab data directory' }
|
|
}
|
|
|
|
if (path.resolve(input.currentDir) === path.resolve(newDir)) {
|
|
clearPendingNodeDataDirMigration(input.systemDir)
|
|
return { success: true, from: input.currentDir, to: newDir, requiresRelaunch: false }
|
|
}
|
|
|
|
const pending = createPendingDataDirMigration({
|
|
from: input.currentDir,
|
|
to: newDir,
|
|
migrate,
|
|
targetWasEmpty: isDirectoryEmptyOrMissing(newDir),
|
|
})
|
|
writePendingNodeDataDirMigration(input.systemDir, pending)
|
|
|
|
return { success: true, from: input.currentDir, to: newDir, requiresRelaunch: true }
|
|
}
|
|
|
|
export function applyPendingNodeDataDirMigration(
|
|
systemDir: string,
|
|
deps: ApplyPendingNodeDataDirMigrationDeps = {}
|
|
): {
|
|
success: boolean
|
|
skipped?: boolean
|
|
error?: string
|
|
} {
|
|
const pending = getPendingNodeDataDirMigration(systemDir)
|
|
if (!pending) return { success: true, skipped: true }
|
|
const writeConfig = deps.writeConfigField ?? writeConfigField
|
|
|
|
const result = runPendingDataDirMigration(pending, {
|
|
writeUserDataDir(dir) {
|
|
writeConfig('data', 'user_data_dir', dir)
|
|
writeConfig('data', 'electron_migration_done', true)
|
|
},
|
|
clearPendingMigration() {
|
|
clearPendingNodeDataDirMigration(systemDir)
|
|
},
|
|
markPendingDeleteDir(dir) {
|
|
deleteOldUserDataDirIfSafe(dir, pending.to, deps.removeDir)
|
|
},
|
|
})
|
|
|
|
if (!result.success) {
|
|
return { success: false, error: result.errors.join('; ') || 'Data directory migration failed' }
|
|
}
|
|
|
|
return { success: true }
|
|
}
|