feat: update restore process with countdown modal and improved symlink handling

This commit is contained in:
Jacky 2025-03-30 01:52:03 +00:00
parent 000e28942a
commit 4c2487580e
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
6 changed files with 168 additions and 59 deletions

View file

@ -120,7 +120,7 @@ func RestoreBackup(c *gin.Context) {
if restoreNginxUI { if restoreNginxUI {
go func() { go func() {
time.Sleep(3 * time.Second) time.Sleep(2 * time.Second)
// gracefully restart // gracefully restart
overseer.Restart() overseer.Restart()
}() }()

View file

@ -3,26 +3,25 @@ import type { RestoreOptions, RestoreResponse } from '@/api/backup'
import type { UploadFile } from 'ant-design-vue' import type { UploadFile } from 'ant-design-vue'
import backup from '@/api/backup' import backup from '@/api/backup'
import { InboxOutlined } from '@ant-design/icons-vue' 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 // Define props using TypeScript interface
interface SystemRestoreProps { interface SystemRestoreProps {
showTitle?: boolean showTitle?: boolean
showNginxOptions?: boolean showNginxOptions?: boolean
onRestoreSuccess?: (data: RestoreResponse) => void
} }
// Define emits using TypeScript interface // Define emits using TypeScript interface
interface SystemRestoreEmits { interface SystemRestoreEmits {
(e: 'restoreSuccess', data: RestoreResponse): void (e: 'restoreSuccess', options: { restoreNginx: boolean, restoreNginxUI: boolean }): void
(e: 'restoreError', error: Error): void (e: 'restoreError', error: Error): void
} }
const props = withDefaults(defineProps<SystemRestoreProps>(), { withDefaults(defineProps<SystemRestoreProps>(), {
showTitle: true, showTitle: true,
showNginxOptions: true, showNginxOptions: true,
onRestoreSuccess: () => null,
}) })
const emit = defineEmits<SystemRestoreEmits>() const emit = defineEmits<SystemRestoreEmits>()
// Use UploadFile from ant-design-vue // Use UploadFile from ant-design-vue
@ -36,6 +35,42 @@ const formModel = reactive({
verifyHash: true, verifyHash: true,
}) })
//
const showRestoreModal = ref(false)
const countdown = ref(5)
const countdownTimer = ref<ReturnType<typeof setInterval> | 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) { function handleBeforeUpload(file: File) {
// Check if file type is zip // Check if file type is zip
const isZip = file.name.toLowerCase().endsWith('.zip') const isZip = file.name.toLowerCase().endsWith('.zip')
@ -105,13 +140,14 @@ async function doRestore() {
if (data.nginx_ui_restored) { if (data.nginx_ui_restored) {
message.info($gettext('Nginx UI configuration has been restored')) message.info($gettext('Nginx UI configuration has been restored'))
// If UI was restored, show the countdown modal
// Show warning modal about restart resetCountdown()
Modal.warning({ }
title: $gettext('Automatic Restart'), else {
content: $gettext('Nginx UI configuration has been restored and will restart automatically in a few seconds.'), // If UI was not restored, emit success event directly
okText: $gettext('OK'), emit('restoreSuccess', {
maskClosable: false, restoreNginx: formModel.restoreNginx,
restoreNginxUI: formModel.restoreNginxUI,
}) })
} }
@ -122,12 +158,6 @@ async function doRestore() {
// Reset form after successful restore // Reset form after successful restore
uploadFiles.value = [] uploadFiles.value = []
formModel.securityToken = '' formModel.securityToken = ''
// Emit success event
emit('restoreSuccess', data)
// Call the callback function if provided
if (props.onRestoreSuccess) {
props.onRestoreSuccess(data)
}
} }
catch (error) { catch (error) {
console.error('Restore failed:', error) console.error('Restore failed:', error)
@ -295,5 +325,31 @@ async function doRestore() {
</AFormItem> </AFormItem>
</AForm> </AForm>
</div> </div>
<!-- Modal for countdown -->
<AModal
v-model:open="showRestoreModal"
:title="$gettext('Automatic Restart')"
:mask-closable="false"
>
<p>
{{ $gettext('Nginx UI configuration has been restored and will restart automatically in a few seconds.') }}
</p>
<p v-if="countdown > 0">
{{ $gettext('You can close this dialog in') }} {{ countdown }} {{ $gettext('seconds') }}
</p>
<p v-else>
{{ $gettext('You can close this dialog now') }}
</p>
<template #footer>
<AButton
type="primary"
:disabled="countdown > 0"
@click="handleModalOk"
>
{{ countdown > 0 ? `OK (${countdown}s)` : 'OK' }}
</AButton>
</template>
</AModal>
</div> </div>
</template> </template>

View file

@ -1 +1 @@
{"version":"2.0.0-rc.4","build_id":2,"total_build":387} {"version":"2.0.0-rc.4","build_id":6,"total_build":391}

View file

@ -102,9 +102,14 @@ function onSubmit() {
}) })
} }
function handleRestoreSuccess(): void { function handleRestoreSuccess(options: { restoreNginx: boolean, restoreNginxUI: boolean }): void {
message.success($gettext('System restored successfully. Please log in.')) message.success($gettext('System restored successfully.'))
router.push('/login')
// Only redirect to login page if Nginx UI was restored
if (options.restoreNginxUI) {
message.info($gettext('Please log in.'))
router.push('/login')
}
} }
</script> </script>
@ -185,7 +190,7 @@ function handleRestoreSuccess(): void {
<TabPane key="2" :tab="$gettext('Restore from Backup')"> <TabPane key="2" :tab="$gettext('Restore from Backup')">
<SystemRestoreContent <SystemRestoreContent
:show-title="false" :show-title="false"
:on-restore-success="handleRestoreSuccess" @restore-success="handleRestoreSuccess"
/> />
</TabPane> </TabPane>
</Tabs> </Tabs>

View file

@ -1,7 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import SystemRestoreContent from '@/components/SystemRestore/SystemRestoreContent.vue' import SystemRestoreContent from '@/components/SystemRestore/SystemRestoreContent.vue'
import { message } from 'ant-design-vue'
const router = useRouter()
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')
}
}
</script> </script>
<template> <template>
<SystemRestoreContent :show-title="true" /> <SystemRestoreContent :show-title="true" @restore-success="handleRestoreSuccess" />
</template> </template>

View file

@ -207,44 +207,61 @@ func extractZipFile(file *zip.File, destDir string) error {
return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("invalid symlink target: %s", linkTarget)) 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() modulesPath := nginx.GetModulesPath()
// Handle system directory symlinks // Check if symlink target is to an allowed path (conf path or modules path)
if strings.HasPrefix(cleanLinkTarget, modulesPath) { isAllowedSymlink := false
// For nginx modules, we'll create a relative symlink to the modules directory
relPath, err := filepath.Rel(filepath.Dir(filePath), modulesPath) // Check if link points to modules path
if err != nil { if filepath.IsAbs(cleanLinkTarget) && (cleanLinkTarget == modulesPath || strings.HasPrefix(cleanLinkTarget, modulesPath+string(filepath.Separator))) {
return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("failed to convert modules path to relative: %v", err)) 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) { // If this is a symlink to an allowed path, create it
// For other absolute paths, we'll create a directory instead of a symlink 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 { 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(ErrCreateDir, fmt.Sprintf("failed to create directory %s: %v", filePath, err))
} }
return nil 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)) absLinkTarget := filepath.Clean(filepath.Join(filepath.Dir(filePath), cleanLinkTarget))
if !strings.HasPrefix(absLinkTarget, destDirAbs+string(os.PathSeparator)) { if !strings.HasPrefix(absLinkTarget, destDirAbs+string(os.PathSeparator)) {
// For nginx modules, we'll create a directory instead of a symlink // Create directory instead of symlink if the target is outside destination
if strings.HasPrefix(linkTarget, modulesPath) { if err := os.MkdirAll(filePath, 0755); err != nil {
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(ErrCreateDir, fmt.Sprintf("failed to create modules directory %s: %v", filePath, err))
}
return nil
} }
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 // 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 // Ignoring error, continue creating symlink
} }
// Create the symlink // Create the symlink for relative paths within destination
if err := os.Symlink(cleanLinkTarget, filePath); err != nil { 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 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 return ErrNginxConfigDirEmpty
} }
// Remove all contents in the destination directory first // Recursively clean destination directory preserving the directory structure
// Read directory entries if err := cleanDirectoryPreservingStructure(destDir); err != nil {
entries, err := os.ReadDir(destDir) return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, "failed to clean directory: "+err.Error())
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())
}
} }
// Copy files from backup to nginx config directory // Copy files from backup to nginx config directory
@ -385,6 +391,35 @@ func restoreNginxConfigs(nginxBackupDir string) error {
return nil 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 // restoreNginxUIConfig restores nginx-ui configuration files
func restoreNginxUIConfig(nginxUIBackupDir string) error { func restoreNginxUIConfig(nginxUIBackupDir string) error {
// Get config directory // Get config directory