diff --git a/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx b/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx index 6a0e54d316..cb8c668e8a 100644 --- a/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx @@ -42,8 +42,10 @@ const LocalBackupSettings: React.FC = () => { const [localBackupDir, setLocalBackupDir] = useState(localBackupDirSetting) const [resolvedLocalBackupDir, setResolvedLocalBackupDir] = useState(undefined) const [localBackupSkipBackupFile, setLocalBackupSkipBackupFile] = useState(localBackupSkipBackupFileSetting) - const [localSingleFileOverwrite, setLocalSingleFileOverwrite] = useState(!!localSingleFileOverwriteSetting) - const [localSingleFileName, setLocalSingleFileName] = useState(localSingleFileNameSetting) + const [localSingleFileOverwrite, setLocalSingleFileOverwrite] = useState( + localSingleFileOverwriteSetting ?? false + ) + const [localSingleFileName, setLocalSingleFileName] = useState(localSingleFileNameSetting ?? '') const [backupManagerVisible, setBackupManagerVisible] = useState(false) const [syncInterval, setSyncInterval] = useState(localBackupSyncIntervalSetting) @@ -147,12 +149,65 @@ const LocalBackupSettings: React.FC = () => { } const onSingleFileOverwriteChange = (value: boolean) => { - setLocalSingleFileOverwrite(value) - dispatch(_setLocalSingleFileOverwrite(value)) + // Only show confirmation when enabling + if (value && !localSingleFileOverwrite) { + window.modal.confirm({ + title: t('settings.data.backup.singleFileOverwrite.confirm.title') || '启用覆盖式备份', + content: ( +
+

{t('settings.data.backup.singleFileOverwrite.confirm.content1') || '启用后,自动备份将:'}

+
    +
  • {t('settings.data.backup.singleFileOverwrite.confirm.item1') || '使用固定文件名,不再添加时间戳'}
  • +
  • {t('settings.data.backup.singleFileOverwrite.confirm.item2') || '每次备份都会覆盖同名文件'}
  • +
  • {t('settings.data.backup.singleFileOverwrite.confirm.item3') || '仅保留最新的一个备份文件'}
  • +
+

+ {t('settings.data.backup.singleFileOverwrite.confirm.note') || + '注意:此设置仅在自动备份且保留份数为1时生效'} +

+
+ ), + okText: t('common.confirm') || '确认', + cancelText: t('common.cancel') || '取消', + onOk: () => { + setLocalSingleFileOverwrite(value) + dispatch(_setLocalSingleFileOverwrite(value)) + } + }) + } else { + setLocalSingleFileOverwrite(value) + dispatch(_setLocalSingleFileOverwrite(value)) + } + } + + const onSingleFileNameChange = (value: string) => { + setLocalSingleFileName(value) } const onSingleFileNameBlur = () => { - dispatch(_setLocalSingleFileName(localSingleFileName || '')) + const trimmed = localSingleFileName.trim() + // Validate filename + if (trimmed) { + // Check for invalid characters + const invalidChars = /[<>:"/\\|?*]/ + if (invalidChars.test(trimmed)) { + window.toast.error(t('settings.data.backup.singleFileName.invalid_chars') || '文件名包含无效字符') + return + } + // Check for reserved names (Windows) + const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i + const nameWithoutExt = trimmed.replace(/\.zip$/i, '') + if (reservedNames.test(nameWithoutExt)) { + window.toast.error(t('settings.data.backup.singleFileName.reserved') || '文件名是系统保留名称') + return + } + // Check length + if (trimmed.length > 250) { + window.toast.error(t('settings.data.backup.singleFileName.too_long') || '文件名过长') + return + } + } + dispatch(_setLocalSingleFileName(trimmed)) } const handleBrowseDirectory = async () => { @@ -242,7 +297,7 @@ const LocalBackupSettings: React.FC = () => { t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio...zip' } value={localSingleFileName} - onChange={(e) => setLocalSingleFileName(e.target.value)} + onChange={(e) => onSingleFileNameChange(e.target.value)} onBlur={onSingleFileNameBlur} style={{ width: 300 }} disabled={!localSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)} diff --git a/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx index 1f1bcb70e6..38bc0b05c2 100644 --- a/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx @@ -86,12 +86,65 @@ const S3Settings: FC = () => { } const onSingleFileOverwriteChange = (value: boolean) => { - setSingleFileOverwrite(value) - dispatch(setS3Partial({ singleFileOverwrite: value })) + // Only show confirmation when enabling + if (value && !singleFileOverwrite) { + window.modal.confirm({ + title: t('settings.data.backup.singleFileOverwrite.confirm.title') || '启用覆盖式备份', + content: ( +
+

{t('settings.data.backup.singleFileOverwrite.confirm.content1') || '启用后,自动备份将:'}

+
    +
  • {t('settings.data.backup.singleFileOverwrite.confirm.item1') || '使用固定文件名,不再添加时间戳'}
  • +
  • {t('settings.data.backup.singleFileOverwrite.confirm.item2') || '每次备份都会覆盖同名文件'}
  • +
  • {t('settings.data.backup.singleFileOverwrite.confirm.item3') || '仅保留最新的一个备份文件'}
  • +
+

+ {t('settings.data.backup.singleFileOverwrite.confirm.note') || + '注意:此设置仅在自动备份且保留份数为1时生效'} +

+
+ ), + okText: t('common.confirm') || '确认', + cancelText: t('common.cancel') || '取消', + onOk: () => { + setSingleFileOverwrite(value) + dispatch(setS3Partial({ singleFileOverwrite: value })) + } + }) + } else { + setSingleFileOverwrite(value) + dispatch(setS3Partial({ singleFileOverwrite: value })) + } + } + + const onSingleFileNameChange = (value: string) => { + setSingleFileName(value) } const onSingleFileNameBlur = () => { - dispatch(setS3Partial({ singleFileName: singleFileName || '' })) + const trimmed = singleFileName.trim() + // Validate filename + if (trimmed) { + // Check for invalid characters + const invalidChars = /[<>:"/\\|?*]/ + if (invalidChars.test(trimmed)) { + window.toast.error(t('settings.data.backup.singleFileName.invalid_chars') || '文件名包含无效字符') + return + } + // Check for reserved names (Windows) + const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i + const nameWithoutExt = trimmed.replace(/\.zip$/i, '') + if (reservedNames.test(nameWithoutExt)) { + window.toast.error(t('settings.data.backup.singleFileName.reserved') || '文件名是系统保留名称') + return + } + // Check length + if (trimmed.length > 250) { + window.toast.error(t('settings.data.backup.singleFileName.too_long') || '文件名过长') + return + } + } + dispatch(setS3Partial({ singleFileName: trimmed })) } const renderSyncStatus = () => { @@ -163,7 +216,7 @@ const S3Settings: FC = () => { t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio...zip' } value={singleFileName} - onChange={(e) => setSingleFileName(e.target.value)} + onChange={(e) => onSingleFileNameChange(e.target.value)} onBlur={onSingleFileNameBlur} style={{ width: 300 }} disabled={!singleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)} diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index d045236e1a..6e62662a36 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -47,8 +47,10 @@ const WebDavSettings: FC = () => { const [webdavPath, setWebdavPath] = useState(webDAVPath) const [webdavSkipBackupFile, setWebdavSkipBackupFile] = useState(webdDAVSkipBackupFile) const [webdavDisableStream, setWebdavDisableStream] = useState(webDAVDisableStream) - const [webdavSingleFileOverwrite, setWebdavSingleFileOverwrite] = useState(!!webDAVSingleFileOverwrite) - const [webdavSingleFileName, setWebdavSingleFileName] = useState(webDAVSingleFileName) + const [webdavSingleFileOverwrite, setWebdavSingleFileOverwrite] = useState( + webDAVSingleFileOverwrite ?? false + ) + const [webdavSingleFileName, setWebdavSingleFileName] = useState(webDAVSingleFileName ?? '') const [backupManagerVisible, setBackupManagerVisible] = useState(false) const [syncInterval, setSyncInterval] = useState(webDAVSyncInterval) @@ -91,12 +93,65 @@ const WebDavSettings: FC = () => { } const onSingleFileOverwriteChange = (value: boolean) => { - setWebdavSingleFileOverwrite(value) - dispatch(_setWebdavSingleFileOverwrite(value)) + // Only show confirmation when enabling + if (value && !webdavSingleFileOverwrite) { + window.modal.confirm({ + title: t('settings.data.backup.singleFileOverwrite.confirm.title') || '启用覆盖式备份', + content: ( +
+

{t('settings.data.backup.singleFileOverwrite.confirm.content1') || '启用后,自动备份将:'}

+
    +
  • {t('settings.data.backup.singleFileOverwrite.confirm.item1') || '使用固定文件名,不再添加时间戳'}
  • +
  • {t('settings.data.backup.singleFileOverwrite.confirm.item2') || '每次备份都会覆盖同名文件'}
  • +
  • {t('settings.data.backup.singleFileOverwrite.confirm.item3') || '仅保留最新的一个备份文件'}
  • +
+

+ {t('settings.data.backup.singleFileOverwrite.confirm.note') || + '注意:此设置仅在自动备份且保留份数为1时生效'} +

+
+ ), + okText: t('common.confirm') || '确认', + cancelText: t('common.cancel') || '取消', + onOk: () => { + setWebdavSingleFileOverwrite(value) + dispatch(_setWebdavSingleFileOverwrite(value)) + } + }) + } else { + setWebdavSingleFileOverwrite(value) + dispatch(_setWebdavSingleFileOverwrite(value)) + } + } + + const onSingleFileNameChange = (value: string) => { + setWebdavSingleFileName(value) } const onSingleFileNameBlur = () => { - dispatch(_setWebdavSingleFileName(webdavSingleFileName || '')) + const trimmed = webdavSingleFileName.trim() + // Validate filename + if (trimmed) { + // Check for invalid characters + const invalidChars = /[<>:"/\\|?*]/ + if (invalidChars.test(trimmed)) { + window.toast.error(t('settings.data.backup.singleFileName.invalid_chars') || '文件名包含无效字符') + return + } + // Check for reserved names (Windows) + const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i + const nameWithoutExt = trimmed.replace(/\.zip$/i, '') + if (reservedNames.test(nameWithoutExt)) { + window.toast.error(t('settings.data.backup.singleFileName.reserved') || '文件名是系统保留名称') + return + } + // Check length + if (trimmed.length > 250) { + window.toast.error(t('settings.data.backup.singleFileName.too_long') || '文件名过长') + return + } + } + dispatch(_setWebdavSingleFileName(trimmed)) } const renderSyncStatus = () => { @@ -151,8 +206,14 @@ const WebDavSettings: FC = () => { - {t('settings.data.backup.singleFileOverwrite.help') || - '当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。'} + {t('settings.data.backup.singleFileOverwrite.help') || ( +
+

当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。

+

+ 推荐场景:只需要保留最新备份,节省存储空间 +

+
+ )}
@@ -162,12 +223,26 @@ const WebDavSettings: FC = () => { t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio...zip' } value={webdavSingleFileName} - onChange={(e) => setWebdavSingleFileName(e.target.value)} + onChange={(e) => onSingleFileNameChange(e.target.value)} onBlur={onSingleFileNameBlur} style={{ width: 300 }} disabled={!webdavSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)} /> + + + {t('settings.data.backup.singleFileName.help') || ( +
+

• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip

+

+ • 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型 +

+

• 不支持的字符:{'<>:"/\\|?*'}

+

• 最大长度:250个字符

+
+ )} +
+
{t('settings.data.webdav.host.label')} diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index e78d358554..c78e0ef28b 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -6,10 +6,23 @@ import store from '@renderer/store' import { setLocalBackupSyncState, setS3SyncState, setWebDAVSyncState } from '@renderer/store/backup' import { S3Config, WebDavConfig } from '@renderer/types' import { uuid } from '@renderer/utils' +import { generateOverwriteFilename, generateTimestampedFilename, shouldSkipCleanup } from '@renderer/utils/backupUtils' import dayjs from 'dayjs' import { NotificationService } from './NotificationService' +// Define specific error types for better error handling +export class BackupError extends Error { + constructor( + message: string, + public readonly type: 'network' | 'permission' | 'storage' | 'validation' | 'unknown', + public readonly originalError?: Error + ) { + super(message) + this.name = 'BackupError' + } +} + const logger = loggerService.withContext('BackupService') // 重试删除S3文件的辅助函数 @@ -181,13 +194,14 @@ export async function backupToWebdav({ logger.error('Failed to get device type or hostname:', error as Error) } const timestamp = dayjs().format('YYYYMMDDHHmmss') - let backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + let finalFileName: string + // 覆盖式单文件备份(仅在自动备份流程且保留份数=1时生效) if (autoBackupProcess && webdavMaxBackups === 1 && webdavSingleFileOverwrite) { - const base = (webdavSingleFileName || `cherry-studio.${hostname}.${deviceType}`).trim() - backupFileName = base.endsWith('.zip') ? base : `${base}.zip` + finalFileName = generateOverwriteFilename(webdavSingleFileName, hostname, deviceType) + } else { + finalFileName = generateTimestampedFilename(customFileName, hostname, deviceType, timestamp) } - const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip` const backupData = await getBackupData() // 上传文件 @@ -219,8 +233,8 @@ export async function backupToWebdav({ }) showMessage && window.toast.success(i18n.t('message.backup.success')) - // 覆盖式单文件备份启用时(且=1),不进行清理 - if (webdavMaxBackups > 0 && !(autoBackupProcess && webdavMaxBackups === 1 && webdavSingleFileOverwrite)) { + // 使用工具函数判断是否���过清理 + if (webdavMaxBackups > 0 && !shouldSkipCleanup(autoBackupProcess, webdavMaxBackups, webdavSingleFileOverwrite)) { try { // 获取所有备份文件 const files = await window.api.backup.listWebdavFiles({ @@ -360,13 +374,14 @@ export async function backupToS3({ logger.error('Failed to get device type or hostname:', error as Error) } const timestamp = dayjs().format('YYYYMMDDHHmmss') - let backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + let finalFileName: string + // 覆盖式单文件备份(仅在自动备份流程且保留份数=1时生效) if (autoBackupProcess && s3Config.maxBackups === 1 && s3Config.singleFileOverwrite) { - const base = (s3Config.singleFileName || `cherry-studio.${hostname}.${deviceType}`).trim() - backupFileName = base.endsWith('.zip') ? base : `${base}.zip` + finalFileName = generateOverwriteFilename(s3Config.singleFileName, hostname, deviceType) + } else { + finalFileName = generateTimestampedFilename(customFileName, hostname, deviceType, timestamp) } - const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip` const backupData = await getBackupData() try { @@ -396,10 +411,10 @@ export async function backupToS3({ showMessage && window.toast.success(i18n.t('message.backup.success')) // 清理旧备份文件 - // 覆盖式单文件备份启用时(且=1),不进行清理,避免误删历史。后续不会再增长。 + // 使用工具函数判断是否跳过清理 if ( s3Config.maxBackups > 0 && - !(autoBackupProcess && s3Config.maxBackups === 1 && s3Config.singleFileOverwrite) + !shouldSkipCleanup(autoBackupProcess, s3Config.maxBackups, s3Config.singleFileOverwrite) ) { try { // 获取所有备份文件 @@ -969,12 +984,14 @@ export async function backupToLocal({ logger.error('Failed to get device type or hostname:', error as Error) } const timestamp = dayjs().format('YYYYMMDDHHmmss') - let backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + let finalFileName: string + + // 覆盖式单文件备份(仅在自动备份流程且保留份数=1时生效) if (autoBackupProcess && localBackupMaxBackups === 1 && localSingleFileOverwrite) { - const base = (localSingleFileName || `cherry-studio.${hostname}.${deviceType}`).trim() - backupFileName = base.endsWith('.zip') ? base : `${base}.zip` + finalFileName = generateOverwriteFilename(localSingleFileName, hostname, deviceType) + } else { + finalFileName = generateTimestampedFilename(customFileName, hostname, deviceType, timestamp) } - const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip` const backupData = await getBackupData() try { @@ -1003,10 +1020,10 @@ export async function backupToLocal({ }) } - // 覆盖式单文件备份启用时(且=1),不进行清理 + // 使用工具函数判断是否跳过清理 if ( localBackupMaxBackups > 0 && - !(autoBackupProcess && localBackupMaxBackups === 1 && localSingleFileOverwrite) + !shouldSkipCleanup(autoBackupProcess, localBackupMaxBackups, localSingleFileOverwrite) ) { try { // Get all backup files diff --git a/src/renderer/src/utils/backupUtils.ts b/src/renderer/src/utils/backupUtils.ts new file mode 100644 index 0000000000..20ba9c9df8 --- /dev/null +++ b/src/renderer/src/utils/backupUtils.ts @@ -0,0 +1,117 @@ +/** + * Backup utility functions for validating and processing backup filenames + */ + +/** + * Validates and sanitizes custom backup filename + * @param filename - The custom filename provided by user + * @param defaultName - The default filename to fall back to + * @returns A safe filename with .zip extension + */ +export function validateAndSanitizeFilename(filename: string | undefined, defaultName: string): string { + // If filename is not provided or empty after trimming, use default + if (!filename || filename.trim() === '') { + return ensureZipExtension(defaultName) + } + + const sanitized = filename.trim() + + // Check for invalid characters + const invalidChars = /[<>:"/\\|?*]/ + if (invalidChars.test(sanitized)) { + // Invalid characters, use default name + return ensureZipExtension(defaultName) + } + + // Check for reserved names (Windows) + const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i + const nameWithoutExt = sanitized.replace(/\.zip$/i, '') + if (reservedNames.test(nameWithoutExt)) { + // Reserved name, use default name + return ensureZipExtension(defaultName) + } + + // Check length (limit to 255 characters for most filesystems) + if (sanitized.length > 250) { + // Leave room for .zip extension + // Filename is too long, truncate + return ensureZipExtension(sanitized.substring(0, 250)) + } + + return ensureZipExtension(sanitized) +} + +/** + * Ensures the filename has a .zip extension + * @param filename - The filename to check + * @returns Filename with .zip extension + */ +function ensureZipExtension(filename: string): string { + return filename.toLowerCase().endsWith('.zip') ? filename : `${filename}.zip` +} + +/** + * Checks if backup cleanup should be skipped based on configuration + * @param autoBackupProcess - Whether this is an automatic backup process + * @param maxBackups - Maximum number of backups to keep + * @param singleFileOverwrite - Whether single file overwrite is enabled + * @returns True if cleanup should be skipped + */ +export function shouldSkipCleanup( + autoBackupProcess: boolean, + maxBackups: number, + singleFileOverwrite?: boolean +): boolean { + return autoBackupProcess && maxBackups === 1 && !!singleFileOverwrite +} + +/** + * Generates a default backup filename based on device information + * @param hostname - Device hostname + * @param deviceType - Device type + * @param timestamp - Optional timestamp (for non-overwrite mode) + * @returns Generated filename + */ +export function generateDefaultFilename(hostname: string, deviceType: string, timestamp?: string): string { + const base = `cherry-studio.${hostname}.${deviceType}` + return timestamp ? `${base}.${timestamp}.zip` : `${base}.zip` +} + +/** + * Generates backup filename for overwrite mode + * @param customFileName - Custom filename provided by user + * @param hostname - Device hostname + * @param deviceType - Device type + * @returns Filename for overwrite mode + */ +export function generateOverwriteFilename( + customFileName: string | undefined, + hostname: string, + deviceType: string +): string { + const defaultName = generateDefaultFilename(hostname, deviceType) + return validateAndSanitizeFilename(customFileName, defaultName) +} + +/** + * Generates backup filename for timestamped mode + * @param customFileName - Custom filename provided by user + * @param hostname - Device hostname + * @param deviceType - Device type + * @param timestamp - Timestamp string + * @returns Filename for timestamped mode + */ +export function generateTimestampedFilename( + customFileName: string | undefined, + hostname: string, + deviceType: string, + timestamp: string +): string { + if (customFileName && customFileName.trim()) { + // If custom filename is provided, use it as base and add timestamp + const base = customFileName.trim().replace(/\.zip$/i, '') + return `${base}.${timestamp}.zip` + } + + return generateDefaultFilename(hostname, deviceType, timestamp) +}