feat(settings): add confirmation and validation for WebDAV single-file

Add confirmation dialog when enabling single-file overwrite to ensure the
user understands behavior (fixed filename, overwrites previous backup,
keeps only latest). Initialize related state with safe defaults to avoid
undefined values. Add inline help text recommending use cases.

Introduce filename input handling: trim and validate the entered name,
reject invalid characters, Windows reserved names, and overly long names;
show toast errors for invalid input. Persist validated filename on blur.

Wire up onChange handler for the filename input and disable the field
unless overwrite mode and compatible backup settings are active. These
changes improve UX and prevent accidental data loss or invalid filenames.
This commit is contained in:
GeorgeDong32 2025-10-20 22:01:04 +08:00 committed by GeorgeDong32
parent d74d66dcbf
commit f846a27418
5 changed files with 353 additions and 36 deletions

View File

@ -42,8 +42,10 @@ const LocalBackupSettings: React.FC = () => {
const [localBackupDir, setLocalBackupDir] = useState<string | undefined>(localBackupDirSetting)
const [resolvedLocalBackupDir, setResolvedLocalBackupDir] = useState<string | undefined>(undefined)
const [localBackupSkipBackupFile, setLocalBackupSkipBackupFile] = useState<boolean>(localBackupSkipBackupFileSetting)
const [localSingleFileOverwrite, setLocalSingleFileOverwrite] = useState<boolean>(!!localSingleFileOverwriteSetting)
const [localSingleFileName, setLocalSingleFileName] = useState<string | undefined>(localSingleFileNameSetting)
const [localSingleFileOverwrite, setLocalSingleFileOverwrite] = useState<boolean>(
localSingleFileOverwriteSetting ?? false
)
const [localSingleFileName, setLocalSingleFileName] = useState<string>(localSingleFileNameSetting ?? '')
const [backupManagerVisible, setBackupManagerVisible] = useState(false)
const [syncInterval, setSyncInterval] = useState<number>(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: (
<div>
<p>{t('settings.data.backup.singleFileOverwrite.confirm.content1') || '启用后,自动备份将:'}</p>
<ul style={{ marginLeft: 20, marginTop: 10 }}>
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item1') || '使用固定文件名,不再添加时间戳'}</li>
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item2') || '每次备份都会覆盖同名文件'}</li>
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item3') || '仅保留最新的一个备份文件'}</li>
</ul>
<p style={{ marginTop: 10, color: 'var(--text-secondary)' }}>
{t('settings.data.backup.singleFileOverwrite.confirm.note') ||
'注意此设置仅在自动备份且保留份数为1时生效'}
</p>
</div>
),
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.<hostname>.<device>.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)}

View File

@ -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: (
<div>
<p>{t('settings.data.backup.singleFileOverwrite.confirm.content1') || '启用后,自动备份将:'}</p>
<ul style={{ marginLeft: 20, marginTop: 10 }}>
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item1') || '使用固定文件名,不再添加时间戳'}</li>
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item2') || '每次备份都会覆盖同名文件'}</li>
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item3') || '仅保留最新的一个备份文件'}</li>
</ul>
<p style={{ marginTop: 10, color: 'var(--text-secondary)' }}>
{t('settings.data.backup.singleFileOverwrite.confirm.note') ||
'注意此设置仅在自动备份且保留份数为1时生效'}
</p>
</div>
),
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.<hostname>.<device>.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)}

View File

@ -47,8 +47,10 @@ const WebDavSettings: FC = () => {
const [webdavPath, setWebdavPath] = useState<string | undefined>(webDAVPath)
const [webdavSkipBackupFile, setWebdavSkipBackupFile] = useState<boolean>(webdDAVSkipBackupFile)
const [webdavDisableStream, setWebdavDisableStream] = useState<boolean>(webDAVDisableStream)
const [webdavSingleFileOverwrite, setWebdavSingleFileOverwrite] = useState<boolean>(!!webDAVSingleFileOverwrite)
const [webdavSingleFileName, setWebdavSingleFileName] = useState<string | undefined>(webDAVSingleFileName)
const [webdavSingleFileOverwrite, setWebdavSingleFileOverwrite] = useState<boolean>(
webDAVSingleFileOverwrite ?? false
)
const [webdavSingleFileName, setWebdavSingleFileName] = useState<string>(webDAVSingleFileName ?? '')
const [backupManagerVisible, setBackupManagerVisible] = useState(false)
const [syncInterval, setSyncInterval] = useState<number>(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: (
<div>
<p>{t('settings.data.backup.singleFileOverwrite.confirm.content1') || '启用后,自动备份将:'}</p>
<ul style={{ marginLeft: 20, marginTop: 10 }}>
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item1') || '使用固定文件名,不再添加时间戳'}</li>
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item2') || '每次备份都会覆盖同名文件'}</li>
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item3') || '仅保留最新的一个备份文件'}</li>
</ul>
<p style={{ marginTop: 10, color: 'var(--text-secondary)' }}>
{t('settings.data.backup.singleFileOverwrite.confirm.note') ||
'注意此设置仅在自动备份且保留份数为1时生效'}
</p>
</div>
),
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 = () => {
</SettingRow>
<SettingRow>
<SettingHelpText>
{t('settings.data.backup.singleFileOverwrite.help') ||
'当自动备份开启且保留份数为1时使用固定文件名每次覆盖。'}
{t('settings.data.backup.singleFileOverwrite.help') || (
<div>
<p>1使</p>
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
</p>
</div>
)}
</SettingHelpText>
</SettingRow>
<SettingRow>
@ -162,12 +223,26 @@ const WebDavSettings: FC = () => {
t('settings.data.backup.singleFileName.placeholder') || '如cherry-studio.<hostname>.<device>.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)}
/>
</SettingRow>
<SettingRow>
<SettingHelpText>
{t('settings.data.backup.singleFileName.help') || (
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
<p> 使cherry-studio.[].[].zip</p>
<p>
{`{hostname}`} - {`{device}`} -
</p>
<p> {'<>:"/\\|?*'}</p>
<p> 250</p>
</div>
)}
</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.host.label')}</SettingRowTitle>

View File

@ -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)) {
// 使用工具函数判断是否<EFBFBD><EFBFBD><EFBFBD>清理
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

View File

@ -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)
}