From 4c2487580e696663febee1deddaec7f4d43d4b12 Mon Sep 17 00:00:00 2001 From: Jacky Date: Sun, 30 Mar 2025 01:52:03 +0000 Subject: [PATCH] feat: update restore process with countdown modal and improved symlink handling --- api/system/restore.go | 2 +- .../SystemRestore/SystemRestoreContent.vue | 92 +++++++++++++--- app/src/version.json | 2 +- app/src/views/other/Install.vue | 13 ++- app/src/views/system/Backup/SystemRestore.vue | 15 ++- internal/backup/restore.go | 103 ++++++++++++------ 6 files changed, 168 insertions(+), 59 deletions(-) diff --git a/api/system/restore.go b/api/system/restore.go index b54faf0b..f92c7e85 100644 --- a/api/system/restore.go +++ b/api/system/restore.go @@ -120,7 +120,7 @@ func RestoreBackup(c *gin.Context) { if restoreNginxUI { go func() { - time.Sleep(3 * time.Second) + time.Sleep(2 * time.Second) // gracefully restart overseer.Restart() }() diff --git a/app/src/components/SystemRestore/SystemRestoreContent.vue b/app/src/components/SystemRestore/SystemRestoreContent.vue index c93f0b40..9b28276e 100644 --- a/app/src/components/SystemRestore/SystemRestoreContent.vue +++ b/app/src/components/SystemRestore/SystemRestoreContent.vue @@ -3,26 +3,25 @@ import type { RestoreOptions, RestoreResponse } from '@/api/backup' import type { UploadFile } from 'ant-design-vue' import backup from '@/api/backup' import { InboxOutlined } from '@ant-design/icons-vue' -import { message, Modal } from 'ant-design-vue' +import { message } from 'ant-design-vue' // Define props using TypeScript interface interface SystemRestoreProps { showTitle?: boolean showNginxOptions?: boolean - onRestoreSuccess?: (data: RestoreResponse) => void } // Define emits using TypeScript interface interface SystemRestoreEmits { - (e: 'restoreSuccess', data: RestoreResponse): void + (e: 'restoreSuccess', options: { restoreNginx: boolean, restoreNginxUI: boolean }): void (e: 'restoreError', error: Error): void } -const props = withDefaults(defineProps(), { +withDefaults(defineProps(), { showTitle: true, showNginxOptions: true, - onRestoreSuccess: () => null, }) + const emit = defineEmits() // Use UploadFile from ant-design-vue @@ -36,6 +35,42 @@ const formModel = reactive({ verifyHash: true, }) +// 添加两个变量控制模态框显示和倒计时 +const showRestoreModal = ref(false) +const countdown = ref(5) +const countdownTimer = ref | null>(null) + +// Reset countdown function +function resetCountdown() { + countdown.value = 5 + showRestoreModal.value = true + + // Clear any existing timer + if (countdownTimer.value) { + clearInterval(countdownTimer.value) + } + + // Start countdown timer + countdownTimer.value = setInterval(() => { + countdown.value-- + if (countdown.value <= 0 && countdownTimer.value) { + clearInterval(countdownTimer.value) + } + }, 1000) +} + +// Handle OK button click +function handleModalOk() { + if (countdownTimer.value) { + clearInterval(countdownTimer.value) + } + // Emit success event with restore options + emit('restoreSuccess', { + restoreNginx: formModel.restoreNginx, + restoreNginxUI: formModel.restoreNginxUI, + }) +} + function handleBeforeUpload(file: File) { // Check if file type is zip const isZip = file.name.toLowerCase().endsWith('.zip') @@ -105,13 +140,14 @@ async function doRestore() { if (data.nginx_ui_restored) { message.info($gettext('Nginx UI configuration has been restored')) - - // Show warning modal about restart - Modal.warning({ - title: $gettext('Automatic Restart'), - content: $gettext('Nginx UI configuration has been restored and will restart automatically in a few seconds.'), - okText: $gettext('OK'), - maskClosable: false, + // If UI was restored, show the countdown modal + resetCountdown() + } + else { + // If UI was not restored, emit success event directly + emit('restoreSuccess', { + restoreNginx: formModel.restoreNginx, + restoreNginxUI: formModel.restoreNginxUI, }) } @@ -122,12 +158,6 @@ async function doRestore() { // Reset form after successful restore uploadFiles.value = [] formModel.securityToken = '' - // Emit success event - emit('restoreSuccess', data) - // Call the callback function if provided - if (props.onRestoreSuccess) { - props.onRestoreSuccess(data) - } } catch (error) { console.error('Restore failed:', error) @@ -295,5 +325,31 @@ async function doRestore() { + + + +

+ {{ $gettext('Nginx UI configuration has been restored and will restart automatically in a few seconds.') }} +

+

+ {{ $gettext('You can close this dialog in') }} {{ countdown }} {{ $gettext('seconds') }} +

+

+ {{ $gettext('You can close this dialog now') }} +

+ +
diff --git a/app/src/version.json b/app/src/version.json index b7b0f496..de8bc9ed 100644 --- a/app/src/version.json +++ b/app/src/version.json @@ -1 +1 @@ -{"version":"2.0.0-rc.4","build_id":2,"total_build":387} \ No newline at end of file +{"version":"2.0.0-rc.4","build_id":6,"total_build":391} \ No newline at end of file diff --git a/app/src/views/other/Install.vue b/app/src/views/other/Install.vue index 93f13ed6..4437f23b 100644 --- a/app/src/views/other/Install.vue +++ b/app/src/views/other/Install.vue @@ -102,9 +102,14 @@ function onSubmit() { }) } -function handleRestoreSuccess(): void { - message.success($gettext('System restored successfully. Please log in.')) - router.push('/login') +function handleRestoreSuccess(options: { restoreNginx: boolean, restoreNginxUI: boolean }): void { + message.success($gettext('System restored successfully.')) + + // Only redirect to login page if Nginx UI was restored + if (options.restoreNginxUI) { + message.info($gettext('Please log in.')) + router.push('/login') + } } @@ -185,7 +190,7 @@ function handleRestoreSuccess(): void { diff --git a/app/src/views/system/Backup/SystemRestore.vue b/app/src/views/system/Backup/SystemRestore.vue index 8acb39ca..5dd9507c 100644 --- a/app/src/views/system/Backup/SystemRestore.vue +++ b/app/src/views/system/Backup/SystemRestore.vue @@ -1,7 +1,20 @@ diff --git a/internal/backup/restore.go b/internal/backup/restore.go index 5ebf4d4e..a2a1a40e 100644 --- a/internal/backup/restore.go +++ b/internal/backup/restore.go @@ -207,44 +207,61 @@ func extractZipFile(file *zip.File, destDir string) error { return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("invalid symlink target: %s", linkTarget)) } - // Get nginx modules path + // Get allowed paths for symlinks + confPath := nginx.GetConfPath() modulesPath := nginx.GetModulesPath() - // Handle system directory symlinks - if strings.HasPrefix(cleanLinkTarget, modulesPath) { - // For nginx modules, we'll create a relative symlink to the modules directory - relPath, err := filepath.Rel(filepath.Dir(filePath), modulesPath) - if err != nil { - return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("failed to convert modules path to relative: %v", err)) + // Check if symlink target is to an allowed path (conf path or modules path) + isAllowedSymlink := false + + // Check if link points to modules path + if filepath.IsAbs(cleanLinkTarget) && (cleanLinkTarget == modulesPath || strings.HasPrefix(cleanLinkTarget, modulesPath+string(filepath.Separator))) { + isAllowedSymlink = true + } + + // Check if link points to nginx conf path + if filepath.IsAbs(cleanLinkTarget) && (cleanLinkTarget == confPath || strings.HasPrefix(cleanLinkTarget, confPath+string(filepath.Separator))) { + isAllowedSymlink = true + } + + // Handle absolute paths + if filepath.IsAbs(cleanLinkTarget) { + // Remove any existing file/link at the target path + if err := os.RemoveAll(filePath); err != nil && !os.IsNotExist(err) { + // Ignoring error, continue creating symlink } - cleanLinkTarget = relPath - } else if filepath.IsAbs(cleanLinkTarget) { - // For other absolute paths, we'll create a directory instead of a symlink + + // If this is a symlink to an allowed path, create it + if isAllowedSymlink { + if err := os.Symlink(cleanLinkTarget, filePath); err != nil { + return cosy.WrapErrorWithParams(ErrCreateSymlink, fmt.Sprintf("failed to create symlink %s -> %s: %v", filePath, cleanLinkTarget, err)) + } + return nil + } + + // Otherwise, fallback to creating a directory if err := os.MkdirAll(filePath, 0755); err != nil { return cosy.WrapErrorWithParams(ErrCreateDir, fmt.Sprintf("failed to create directory %s: %v", filePath, err)) } return nil } - // Verify the link target doesn't escape the destination directory + // For relative symlinks, verify they don't escape the destination directory absLinkTarget := filepath.Clean(filepath.Join(filepath.Dir(filePath), cleanLinkTarget)) if !strings.HasPrefix(absLinkTarget, destDirAbs+string(os.PathSeparator)) { - // For nginx modules, we'll create a directory instead of a symlink - if strings.HasPrefix(linkTarget, modulesPath) { - if err := os.MkdirAll(filePath, 0755); err != nil { - return cosy.WrapErrorWithParams(ErrCreateDir, fmt.Sprintf("failed to create modules directory %s: %v", filePath, err)) - } - return nil + // Create directory instead of symlink if the target is outside destination + if err := os.MkdirAll(filePath, 0755); err != nil { + return cosy.WrapErrorWithParams(ErrCreateDir, fmt.Sprintf("failed to create directory %s: %v", filePath, err)) } - return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("symlink target %s is outside destination directory %s", absLinkTarget, destDirAbs)) + return nil } // Remove any existing file/link at the target path - if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + if err := os.RemoveAll(filePath); err != nil && !os.IsNotExist(err) { // Ignoring error, continue creating symlink } - // Create the symlink + // Create the symlink for relative paths within destination if err := os.Symlink(cleanLinkTarget, filePath); err != nil { return cosy.WrapErrorWithParams(ErrCreateSymlink, fmt.Sprintf("failed to create symlink %s -> %s: %v", filePath, cleanLinkTarget, err)) } @@ -361,20 +378,9 @@ func restoreNginxConfigs(nginxBackupDir string) error { return ErrNginxConfigDirEmpty } - // Remove all contents in the destination directory first - // Read directory entries - entries, err := os.ReadDir(destDir) - if err != nil { - return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, "failed to read directory: "+err.Error()) - } - - // Remove each entry - for _, entry := range entries { - entryPath := filepath.Join(destDir, entry.Name()) - err := os.RemoveAll(entryPath) - if err != nil { - return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, "failed to remove: "+err.Error()) - } + // Recursively clean destination directory preserving the directory structure + if err := cleanDirectoryPreservingStructure(destDir); err != nil { + return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, "failed to clean directory: "+err.Error()) } // Copy files from backup to nginx config directory @@ -385,6 +391,35 @@ func restoreNginxConfigs(nginxBackupDir string) error { return nil } +// cleanDirectoryPreservingStructure removes all files and symlinks in a directory +// but preserves the directory structure itself +func cleanDirectoryPreservingStructure(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + + for _, entry := range entries { + path := filepath.Join(dir, entry.Name()) + info, err := entry.Info() + if err != nil { + return err + } + + // Preserve symlinks - they will be handled separately during restore + if info.Mode()&os.ModeSymlink != 0 { + continue + } + + err = os.RemoveAll(path) + if err != nil { + return err + } + } + + return nil +} + // restoreNginxUIConfig restores nginx-ui configuration files func restoreNginxUIConfig(nginxUIBackupDir string) error { // Get config directory