Browse Source

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

Jacky 3 months ago
parent
commit
4c2487580e

+ 1 - 1
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()
 		}()

+ 74 - 18
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<SystemRestoreProps>(), {
+withDefaults(defineProps<SystemRestoreProps>(), {
   showTitle: true,
   showNginxOptions: true,
-  onRestoreSuccess: () => null,
 })
+
 const emit = defineEmits<SystemRestoreEmits>()
 
 // 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<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) {
   // 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() {
         </AFormItem>
       </AForm>
     </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>
 </template>

+ 1 - 1
app/src/version.json

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

+ 9 - 4
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')
+  }
 }
 </script>
 
@@ -185,7 +190,7 @@ function handleRestoreSuccess(): void {
               <TabPane key="2" :tab="$gettext('Restore from Backup')">
                 <SystemRestoreContent
                   :show-title="false"
-                  :on-restore-success="handleRestoreSuccess"
+                  @restore-success="handleRestoreSuccess"
                 />
               </TabPane>
             </Tabs>

+ 14 - 1
app/src/views/system/Backup/SystemRestore.vue

@@ -1,7 +1,20 @@
 <script setup lang="ts">
 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>
 
 <template>
-  <SystemRestoreContent :show-title="true" />
+  <SystemRestoreContent :show-title="true" @restore-success="handleRestoreSuccess" />
 </template>

+ 67 - 32
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,25 +378,43 @@ func restoreNginxConfigs(nginxBackupDir string) error {
 		return ErrNginxConfigDirEmpty
 	}
 
-	// Remove all contents in the destination directory first
-	// Read directory entries
-	entries, err := os.ReadDir(destDir)
+	// 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
+	if err := copyDirectory(nginxBackupDir, destDir); err != nil {
+		return err
+	}
+
+	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 cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, "failed to read directory: "+err.Error())
+		return err
 	}
 
-	// Remove each entry
 	for _, entry := range entries {
-		entryPath := filepath.Join(destDir, entry.Name())
-		err := os.RemoveAll(entryPath)
+		path := filepath.Join(dir, entry.Name())
+		info, err := entry.Info()
 		if err != nil {
-			return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, "failed to remove: "+err.Error())
+			return err
 		}
-	}
 
-	// Copy files from backup to nginx config directory
-	if err := copyDirectory(nginxBackupDir, destDir); 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