Pārlūkot izejas kodu

feat: implement encrypted form handling and refactor backup restore logic

Jacky 1 mēnesi atpakaļ
vecāks
revīzija
8860f71bc7

+ 0 - 118
api/system/backup.go

@@ -2,26 +2,14 @@ package system
 
 import (
 	"bytes"
-	"encoding/base64"
 	"net/http"
-	"os"
-	"path/filepath"
-	"strings"
 	"time"
 
 	"github.com/0xJacky/Nginx-UI/internal/backup"
 	"github.com/gin-gonic/gin"
-	"github.com/jpillora/overseer"
 	"github.com/uozi-tech/cosy"
 )
 
-// RestoreResponse contains the response data for restore operation
-type RestoreResponse struct {
-	NginxUIRestored bool `json:"nginx_ui_restored"`
-	NginxRestored   bool `json:"nginx_restored"`
-	HashMatch       bool `json:"hash_match"`
-}
-
 // CreateBackup creates a backup of nginx-ui and nginx configurations
 // and sends files directly for download
 func CreateBackup(c *gin.Context) {
@@ -52,109 +40,3 @@ func CreateBackup(c *gin.Context) {
 	// Send file content
 	http.ServeContent(c.Writer, c.Request, fileName, modTime, reader)
 }
-
-// RestoreBackup restores from uploaded backup and security info
-func RestoreBackup(c *gin.Context) {
-	// Get restore options
-	restoreNginx := c.PostForm("restore_nginx") == "true"
-	restoreNginxUI := c.PostForm("restore_nginx_ui") == "true"
-	verifyHash := c.PostForm("verify_hash") == "true"
-	securityToken := c.PostForm("security_token") // Get concatenated key and IV
-
-	// Get backup file
-	backupFile, err := c.FormFile("backup_file")
-	if err != nil {
-		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrBackupFileNotFound, err.Error()))
-		return
-	}
-
-	// Validate security token
-	if securityToken == "" {
-		cosy.ErrHandler(c, backup.ErrInvalidSecurityToken)
-		return
-	}
-
-	// Split security token to get Key and IV
-	parts := strings.Split(securityToken, ":")
-	if len(parts) != 2 {
-		cosy.ErrHandler(c, backup.ErrInvalidSecurityToken)
-		return
-	}
-
-	aesKey := parts[0]
-	aesIv := parts[1]
-
-	// Decode Key and IV from base64
-	key, err := base64.StdEncoding.DecodeString(aesKey)
-	if err != nil {
-		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESKey, err.Error()))
-		return
-	}
-
-	iv, err := base64.StdEncoding.DecodeString(aesIv)
-	if err != nil {
-		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESIV, err.Error()))
-		return
-	}
-
-	// Create temporary directory for files
-	tempDir, err := os.MkdirTemp("", "nginx-ui-restore-upload-*")
-	if err != nil {
-		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrCreateTempDir, err.Error()))
-		return
-	}
-	defer os.RemoveAll(tempDir)
-
-	// Save backup file
-	backupPath := filepath.Join(tempDir, backupFile.Filename)
-	if err := c.SaveUploadedFile(backupFile, backupPath); err != nil {
-		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrCreateBackupFile, err.Error()))
-		return
-	}
-
-	// Create temporary directory for restore operation
-	restoreDir, err := os.MkdirTemp("", "nginx-ui-restore-*")
-	if err != nil {
-		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrCreateRestoreDir, err.Error()))
-		return
-	}
-
-	// Set restore options
-	options := backup.RestoreOptions{
-		BackupPath:     backupPath,
-		AESKey:         key,
-		AESIv:          iv,
-		RestoreDir:     restoreDir,
-		RestoreNginx:   restoreNginx,
-		RestoreNginxUI: restoreNginxUI,
-		VerifyHash:     verifyHash,
-	}
-
-	// Perform restore
-	result, err := backup.Restore(options)
-	if err != nil {
-		// Clean up temporary directory on error
-		os.RemoveAll(restoreDir)
-		cosy.ErrHandler(c, err)
-		return
-	}
-
-	// If not actually restoring anything, clean up directory to avoid disk space waste
-	if !restoreNginx && !restoreNginxUI {
-		defer os.RemoveAll(restoreDir)
-	}
-
-	if restoreNginxUI {
-		go func() {
-			time.Sleep(3 * time.Second)
-			// gracefully restart
-			overseer.Restart()
-		}()
-	}
-
-	c.JSON(http.StatusOK, RestoreResponse{
-		NginxUIRestored: result.NginxUIRestored,
-		NginxRestored:   result.NginxRestored,
-		HashMatch:       result.HashMatch,
-	})
-}

+ 134 - 0
api/system/restore.go

@@ -0,0 +1,134 @@
+package system
+
+import (
+	"encoding/base64"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/0xJacky/Nginx-UI/internal/backup"
+	"github.com/gin-gonic/gin"
+	"github.com/jpillora/overseer"
+	"github.com/uozi-tech/cosy"
+	"github.com/uozi-tech/cosy/logger"
+)
+
+// RestoreResponse contains the response data for restore operation
+type RestoreResponse struct {
+	NginxUIRestored bool `json:"nginx_ui_restored"`
+	NginxRestored   bool `json:"nginx_restored"`
+	HashMatch       bool `json:"hash_match"`
+}
+
+// RestoreBackup restores from uploaded backup and security info
+func RestoreBackup(c *gin.Context) {
+	// Get restore options
+	restoreNginx := c.PostForm("restore_nginx") == "true"
+	restoreNginxUI := c.PostForm("restore_nginx_ui") == "true"
+	verifyHash := c.PostForm("verify_hash") == "true"
+	securityToken := c.PostForm("security_token") // Get concatenated key and IV
+	logger.Debug("restoreNginx", restoreNginx)
+	logger.Debug("restoreNginxUI", restoreNginxUI)
+	logger.Debug("verifyHash", verifyHash)
+	logger.Debug("securityToken", securityToken)
+	// Get backup file
+	backupFile, err := c.FormFile("backup_file")
+	if err != nil {
+		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrBackupFileNotFound, err.Error()))
+		return
+	}
+
+	logger.Debug("backupFile", backupFile.Size)
+
+	// Validate security token
+	if securityToken == "" {
+		cosy.ErrHandler(c, backup.ErrInvalidSecurityToken)
+		return
+	}
+
+	// Split security token to get Key and IV
+	parts := strings.Split(securityToken, ":")
+	if len(parts) != 2 {
+		cosy.ErrHandler(c, backup.ErrInvalidSecurityToken)
+		return
+	}
+
+	aesKey := parts[0]
+	aesIv := parts[1]
+
+	// Decode Key and IV from base64
+	key, err := base64.StdEncoding.DecodeString(aesKey)
+	if err != nil {
+		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESKey, err.Error()))
+		return
+	}
+
+	iv, err := base64.StdEncoding.DecodeString(aesIv)
+	if err != nil {
+		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESIV, err.Error()))
+		return
+	}
+
+	// Create temporary directory for files
+	tempDir, err := os.MkdirTemp("", "nginx-ui-restore-upload-*")
+	if err != nil {
+		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrCreateTempDir, err.Error()))
+		return
+	}
+	defer os.RemoveAll(tempDir)
+
+	// Save backup file
+	backupPath := filepath.Join(tempDir, backupFile.Filename)
+	if err := c.SaveUploadedFile(backupFile, backupPath); err != nil {
+		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrCreateBackupFile, err.Error()))
+		return
+	}
+
+	// Create temporary directory for restore operation
+	restoreDir, err := os.MkdirTemp("", "nginx-ui-restore-*")
+	if err != nil {
+		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrCreateRestoreDir, err.Error()))
+		return
+	}
+
+	// Set restore options
+	options := backup.RestoreOptions{
+		BackupPath:     backupPath,
+		AESKey:         key,
+		AESIv:          iv,
+		RestoreDir:     restoreDir,
+		RestoreNginx:   restoreNginx,
+		RestoreNginxUI: restoreNginxUI,
+		VerifyHash:     verifyHash,
+	}
+
+	// Perform restore
+	result, err := backup.Restore(options)
+	if err != nil {
+		// Clean up temporary directory on error
+		os.RemoveAll(restoreDir)
+		cosy.ErrHandler(c, err)
+		return
+	}
+
+	// If not actually restoring anything, clean up directory to avoid disk space waste
+	if !restoreNginx && !restoreNginxUI {
+		defer os.RemoveAll(restoreDir)
+	}
+
+	if restoreNginxUI {
+		go func() {
+			time.Sleep(3 * time.Second)
+			// gracefully restart
+			overseer.Restart()
+		}()
+	}
+
+	c.JSON(http.StatusOK, RestoreResponse{
+		NginxUIRestored: result.NginxUIRestored,
+		NginxRestored:   result.NginxRestored,
+		HashMatch:       result.HashMatch,
+	})
+}

+ 1 - 1
api/system/router.go

@@ -19,7 +19,7 @@ func InitPrivateRouter(r *gin.RouterGroup) {
 
 	// Backup and restore endpoints
 	r.GET("system/backup", CreateBackup)
-	r.POST("system/backup/restore", RestoreBackup)
+	r.POST("system/backup/restore", middleware.EncryptedForm(), RestoreBackup)
 }
 
 func InitWebSocketRouter(r *gin.RouterGroup) {

+ 1 - 0
app/src/api/backup.ts

@@ -49,6 +49,7 @@ const backup = {
       headers: {
         'Content-Type': 'multipart/form-data;charset=UTF-8',
       },
+      crypto: true,
     })
   },
 }

+ 38 - 0
app/src/lib/http/client.ts

@@ -0,0 +1,38 @@
+import type { HttpConfig } from './types'
+import axios from 'axios'
+
+const instance = axios.create({
+  baseURL: import.meta.env.VITE_API_ROOT,
+  timeout: 50000,
+  headers: { 'Content-Type': 'application/json' },
+})
+
+const http = {
+  // eslint-disable-next-line ts/no-explicit-any
+  get<T = any>(url: string, config: HttpConfig = {}) {
+    // eslint-disable-next-line ts/no-explicit-any
+    return instance.get<any, T>(url, config)
+  },
+  // eslint-disable-next-line ts/no-explicit-any
+  post<T = any>(url: string, data: any = undefined, config: HttpConfig = {}) {
+    // eslint-disable-next-line ts/no-explicit-any
+    return instance.post<any, T>(url, data, config)
+  },
+  // eslint-disable-next-line ts/no-explicit-any
+  put<T = any>(url: string, data: any = undefined, config: HttpConfig = {}) {
+    // eslint-disable-next-line ts/no-explicit-any
+    return instance.put<any, T>(url, data, config)
+  },
+  // eslint-disable-next-line ts/no-explicit-any
+  delete<T = any>(url: string, config: HttpConfig = {}) {
+    // eslint-disable-next-line ts/no-explicit-any
+    return instance.delete<any, T>(url, config)
+  },
+  // eslint-disable-next-line ts/no-explicit-any
+  patch<T = any>(url: string, config: HttpConfig = {}) {
+    // eslint-disable-next-line ts/no-explicit-any
+    return instance.patch<any, T>(url, config)
+  },
+}
+
+export { http, instance }

+ 78 - 0
app/src/lib/http/error.ts

@@ -0,0 +1,78 @@
+import type { CosyError, CosyErrorRecord } from './types'
+import { message } from 'ant-design-vue'
+
+const errors: Record<string, CosyErrorRecord> = {}
+
+export function registerError(scope: string, record: CosyErrorRecord) {
+  errors[scope] = record
+}
+
+// Add new dedupe utility
+interface MessageDedupe {
+  error: (content: string, duration?: number) => void
+}
+
+export function useMessageDedupe(interval = 5000): MessageDedupe {
+  const lastMessages = new Map<string, number>()
+
+  return {
+    async error(content, duration = 5) {
+      const now = Date.now()
+      if (!lastMessages.has(content) || (now - (lastMessages.get(content) || 0)) > interval) {
+        lastMessages.set(content, now)
+        message.error(content, duration)
+      }
+    },
+  }
+}
+
+export function handleApiError(err: CosyError, dedupe: MessageDedupe) {
+  if (err?.scope) {
+    // check if already register
+    if (!errors[err.scope]) {
+      try {
+        // Dynamic import error files
+        import(`@/constants/errors/${err.scope}.ts`)
+          .then(error => {
+            registerError(err.scope!, error.default)
+            displayErrorMessage(err, dedupe)
+          })
+          .catch(() => {
+            dedupe.error($gettext(err?.message ?? 'Server error'))
+          })
+      }
+      catch {
+        dedupe.error($gettext(err?.message ?? 'Server error'))
+      }
+    }
+    else {
+      displayErrorMessage(err, dedupe)
+    }
+  }
+  else {
+    dedupe.error($gettext(err?.message ?? 'Server error'))
+  }
+}
+
+function displayErrorMessage(err: CosyError, dedupe: MessageDedupe) {
+  const msg = errors?.[err.scope ?? '']?.[err.code ?? '']
+
+  if (msg) {
+    // if err has params
+    if (err?.params && err.params.length > 0) {
+      let res = msg()
+
+      err.params.forEach((param, index) => {
+        res = res.replaceAll(`{${index}}`, param)
+      })
+
+      dedupe.error(res)
+    }
+    else {
+      dedupe.error(msg())
+    }
+  }
+  else {
+    dedupe.error($gettext(err?.message ?? 'Server error'))
+  }
+}

+ 14 - 216
app/src/lib/http/index.ts

@@ -1,221 +1,19 @@
-import type { AxiosRequestConfig } from 'axios'
-import use2FAModal from '@/components/TwoFA/use2FAModal'
-import { useNProgress } from '@/lib/nprogress/nprogress'
-import { useSettingsStore, useUserStore } from '@/pinia'
-import router from '@/routes'
-import { message } from 'ant-design-vue'
-import axios from 'axios'
-import JSEncrypt from 'jsencrypt'
-import { storeToRefs } from 'pinia'
-import 'nprogress/nprogress.css'
+import type { CosyError, CosyErrorRecord, HttpConfig } from './types'
+import { http } from './client'
+import { registerError, useMessageDedupe } from './error'
+import { setupInterceptors } from './interceptors'
 
-const user = useUserStore()
-const settings = useSettingsStore()
-const { token, secureSessionId } = storeToRefs(user)
-
-// server response
-export interface CosyError {
-  scope?: string
-  code: string
-  message: string
-  params?: string[]
-}
-
-// code, message translation
-export type CosyErrorRecord = Record<number, () => string>
-
-const errors: Record<string, CosyErrorRecord> = {}
-
-function registerError(scope: string, record: CosyErrorRecord) {
-  errors[scope] = record
-}
-
-export interface HttpConfig extends AxiosRequestConfig {
-  returnFullResponse?: boolean
-  crypto?: boolean
-}
-
-// Extend InternalAxiosRequestConfig type
-declare module 'axios' {
-  interface InternalAxiosRequestConfig {
-    returnFullResponse?: boolean
-    crypto?: boolean
-  }
-}
-
-const instance = axios.create({
-  baseURL: import.meta.env.VITE_API_ROOT,
-  timeout: 50000,
-  headers: { 'Content-Type': 'application/json' },
-})
-
-const http = {
-  // eslint-disable-next-line ts/no-explicit-any
-  get<T = any>(url: string, config: HttpConfig = {}) {
-    // eslint-disable-next-line ts/no-explicit-any
-    return instance.get<any, T>(url, config)
-  },
-  // eslint-disable-next-line ts/no-explicit-any
-  post<T = any>(url: string, data: any = undefined, config: HttpConfig = {}) {
-    // eslint-disable-next-line ts/no-explicit-any
-    return instance.post<any, T>(url, data, config)
-  },
-  // eslint-disable-next-line ts/no-explicit-any
-  put<T = any>(url: string, data: any = undefined, config: HttpConfig = {}) {
-    // eslint-disable-next-line ts/no-explicit-any
-    return instance.put<any, T>(url, data, config)
-  },
-  // eslint-disable-next-line ts/no-explicit-any
-  delete<T = any>(url: string, config: HttpConfig = {}) {
-    // eslint-disable-next-line ts/no-explicit-any
-    return instance.delete<any, T>(url, config)
-  },
-  // eslint-disable-next-line ts/no-explicit-any
-  patch<T = any>(url: string, config: HttpConfig = {}) {
-    // eslint-disable-next-line ts/no-explicit-any
-    return instance.patch<any, T>(url, config)
-  },
-}
+// Initialize interceptors
+setupInterceptors()
 
+// Export everything needed from this module
 export default http
-
-const nprogress = useNProgress()
-
-// Add new dedupe utility at the top
-interface MessageDedupe {
-  error: (content: string, duration?: number) => void
+export type {
+  CosyError,
+  CosyErrorRecord,
+  HttpConfig,
 }
-
-function useMessageDedupe(interval = 5000): MessageDedupe {
-  const lastMessages = new Map<string, number>()
-
-  return {
-    async error(content, duration = 5) {
-      const now = Date.now()
-      if (!lastMessages.has(content) || (now - (lastMessages.get(content) || 0)) > interval) {
-        lastMessages.set(content, now)
-        message.error(content, duration)
-      }
-    },
-  }
+export {
+  registerError,
+  useMessageDedupe,
 }
-
-instance.interceptors.request.use(
-  async config => {
-    nprogress.start()
-    if (token.value) {
-      config.headers.Authorization = token.value
-    }
-
-    if (settings.environment.id) {
-      config.headers['X-Node-ID'] = settings.environment.id
-    }
-
-    if (secureSessionId.value) {
-      config.headers['X-Secure-Session-ID'] = secureSessionId.value
-    }
-
-    if (config.headers?.['Content-Type'] !== 'multipart/form-data;charset=UTF-8') {
-      config.headers['Content-Type'] = 'application/json'
-
-      if (config.crypto) {
-        const cryptoParams = await http.get('/crypto/public_key')
-        const { public_key } = await cryptoParams
-
-        // Encrypt data with RSA public key
-        const encrypt = new JSEncrypt()
-        encrypt.setPublicKey(public_key)
-
-        config.data = JSON.stringify({
-          encrypted_params: encrypt.encrypt(JSON.stringify(config.data)),
-        })
-      }
-    }
-    return config
-  },
-  err => {
-    return Promise.reject(err)
-  },
-)
-
-const dedupe = useMessageDedupe()
-
-instance.interceptors.response.use(
-  response => {
-    nprogress.done()
-    // Check if full response is requested in config
-    if (response.config?.returnFullResponse) {
-      return Promise.resolve(response)
-    }
-    return Promise.resolve(response.data)
-  },
-  // eslint-disable-next-line sonarjs/cognitive-complexity
-  async error => {
-    nprogress.done()
-    const otpModal = use2FAModal()
-    switch (error.response.status) {
-      case 401:
-        secureSessionId.value = ''
-        await otpModal.open()
-        break
-      case 403:
-        user.logout()
-        await router.push('/login')
-        break
-    }
-
-    // Handle JSON error that comes back as Blob for blob request type
-    if (error.response.data instanceof Blob && error.response.data.type === 'application/json') {
-      try {
-        const text = await error.response.data.text()
-        error.response.data = JSON.parse(text)
-      }
-      catch (e) {
-        // If parsing fails, we'll continue with the original error.response.data
-        console.error('Failed to parse blob error response as JSON', e)
-      }
-    }
-
-    const err = error.response.data as CosyError
-
-    if (err?.scope) {
-      // check if already register
-      if (!errors[err.scope]) {
-        try {
-          const error = await import(`@/constants/errors/${err.scope}.ts`)
-
-          registerError(err.scope, error.default)
-        }
-        catch {
-          /* empty */
-        }
-      }
-
-      const msg = errors?.[err.scope]?.[err.code]
-
-      if (msg) {
-        // if err has params
-        if (err?.params && err.params.length > 0) {
-          let res = msg()
-
-          err.params.forEach((param, index) => {
-            res = res.replaceAll(`{${index}}`, param)
-          })
-
-          dedupe.error(res)
-        }
-        else {
-          dedupe.error(msg())
-        }
-      }
-      else {
-        dedupe.error($gettext(err?.message ?? 'Server error'))
-      }
-    }
-    else {
-      dedupe.error($gettext(err?.message ?? 'Server error'))
-    }
-
-    return Promise.reject(error.response.data)
-  },
-)

+ 160 - 0
app/src/lib/http/interceptors.ts

@@ -0,0 +1,160 @@
+import type { CosyError } from './types'
+import use2FAModal from '@/components/TwoFA/use2FAModal'
+import { useNProgress } from '@/lib/nprogress/nprogress'
+import { useSettingsStore, useUserStore } from '@/pinia'
+import router from '@/routes'
+import JSEncrypt from 'jsencrypt'
+import { storeToRefs } from 'pinia'
+import { http, instance } from './client'
+import { handleApiError, useMessageDedupe } from './error'
+
+// Setup stores and refs
+const user = useUserStore()
+const settings = useSettingsStore()
+const { token, secureSessionId } = storeToRefs(user)
+const nprogress = useNProgress()
+const dedupe = useMessageDedupe()
+
+// Helper function for encrypting JSON data
+// eslint-disable-next-line ts/no-explicit-any
+async function encryptJsonData(data: any): Promise<string> {
+  const cryptoParams = await http.get('/crypto/public_key')
+  const { public_key } = await cryptoParams
+
+  // Encrypt data with RSA public key
+  const encrypt = new JSEncrypt()
+  encrypt.setPublicKey(public_key)
+
+  return JSON.stringify({
+    encrypted_params: encrypt.encrypt(JSON.stringify(data)),
+  })
+}
+
+// Helper function for handling encrypted form data
+async function handleEncryptedFormData(formData: FormData): Promise<FormData> {
+  const cryptoParams = await http.get('/crypto/public_key')
+  const { public_key } = await cryptoParams
+
+  // Extract form parameters that are not files
+  // eslint-disable-next-line ts/no-explicit-any
+  const formParams: Record<string, any> = {}
+  const newFormData = new FormData()
+
+  // Copy all files to new FormData
+  for (const [key, value] of formData.entries()) {
+    // Check if value is a File or Blob
+    // eslint-disable-next-line ts/no-explicit-any
+    if (typeof value !== 'string' && ((value as any) instanceof File || (value as any) instanceof Blob)) {
+      newFormData.append(key, value)
+    }
+    else {
+      // Collect non-file fields to encrypt
+      formParams[key] = value
+    }
+  }
+
+  // Encrypt the form parameters
+  const encrypt = new JSEncrypt()
+  encrypt.setPublicKey(public_key)
+
+  // Add encrypted params to form data
+  const encryptedData = encrypt.encrypt(JSON.stringify(formParams))
+  if (encryptedData) {
+    newFormData.append('encrypted_params', encryptedData)
+  }
+
+  return newFormData
+}
+
+// Setup request interceptor
+export function setupRequestInterceptor() {
+  instance.interceptors.request.use(
+    async config => {
+      nprogress.start()
+      if (token.value) {
+        config.headers.Authorization = token.value
+      }
+
+      if (settings.environment.id) {
+        config.headers['X-Node-ID'] = settings.environment.id
+      }
+
+      if (secureSessionId.value) {
+        config.headers['X-Secure-Session-ID'] = secureSessionId.value
+      }
+
+      // Handle JSON encryption
+      if (config.headers?.['Content-Type'] !== 'multipart/form-data;charset=UTF-8') {
+        config.headers['Content-Type'] = 'application/json'
+
+        if (config.crypto) {
+          config.data = await encryptJsonData(config.data)
+        }
+      }
+      // Handle form data with encryption
+      else if (config.crypto && config.data instanceof FormData) {
+        config.data = await handleEncryptedFormData(config.data)
+      }
+
+      return config
+    },
+    err => {
+      return Promise.reject(err)
+    },
+  )
+}
+
+// Setup response interceptor
+export function setupResponseInterceptor() {
+  instance.interceptors.response.use(
+    response => {
+      nprogress.done()
+      // Check if full response is requested in config
+      if (response.config?.returnFullResponse) {
+        return Promise.resolve(response)
+      }
+      return Promise.resolve(response.data)
+    },
+
+    async error => {
+      nprogress.done()
+      const otpModal = use2FAModal()
+
+      // Handle authentication errors
+      if (error.response) {
+        switch (error.response.status) {
+          case 401:
+            secureSessionId.value = ''
+            await otpModal.open()
+            break
+          case 403:
+            user.logout()
+            await router.push('/login')
+            break
+        }
+      }
+
+      // Handle JSON error that comes back as Blob for blob request type
+      if (error.response?.data instanceof Blob && error.response.data.type === 'application/json') {
+        try {
+          const text = await error.response.data.text()
+          error.response.data = JSON.parse(text)
+        }
+        catch (e) {
+          // If parsing fails, we'll continue with the original error.response.data
+          console.error('Failed to parse blob error response as JSON', e)
+        }
+      }
+
+      const err = error.response?.data as CosyError
+      handleApiError(err, dedupe)
+
+      return Promise.reject(error.response?.data)
+    },
+  )
+}
+
+export function setupInterceptors() {
+  setupRequestInterceptor()
+  setupResponseInterceptor()
+}

+ 25 - 0
app/src/lib/http/types.ts

@@ -0,0 +1,25 @@
+import type { AxiosRequestConfig } from 'axios'
+
+// server response
+export interface CosyError {
+  scope?: string
+  code: string
+  message: string
+  params?: string[]
+}
+
+// code, message translation
+export type CosyErrorRecord = Record<number, () => string>
+
+export interface HttpConfig extends AxiosRequestConfig {
+  returnFullResponse?: boolean
+  crypto?: boolean
+}
+
+// Extend InternalAxiosRequestConfig type
+declare module 'axios' {
+  interface InternalAxiosRequestConfig {
+    returnFullResponse?: boolean
+    crypto?: boolean
+  }
+}

+ 1 - 0
app/src/lib/nprogress/nprogress.ts

@@ -1,5 +1,6 @@
 import _ from 'lodash'
 import NProgress from 'nprogress'
+import 'nprogress/nprogress.css'
 
 NProgress.configure({ showSpinner: false, trickleSpeed: 300 })
 

+ 1 - 1
app/src/views/system/Backup/BackupCreator.vue

@@ -90,7 +90,7 @@ function handleCopy(copy) {
 
     <!-- Security Token Modal Component -->
     <AModal
-      v-model:visible="showSecurityModal"
+      v-model:open="showSecurityModal"
       :title="$gettext('Security Token Information')"
       :mask-closable="false"
       :centered="true"

+ 3 - 3
internal/backup/backup_crypto.go

@@ -100,18 +100,18 @@ func decryptFile(filePath string, key []byte, iv []byte) error {
 	// Read encrypted file content
 	encryptedData, err := os.ReadFile(filePath)
 	if err != nil {
-		return cosy.WrapErrorWithParams(ErrReadEncryptedFile, filePath)
+		return cosy.WrapErrorWithParams(ErrReadEncryptedFile, err.Error())
 	}
 
 	// Decrypt file content
 	decryptedData, err := AESDecrypt(encryptedData, key, iv)
 	if err != nil {
-		return cosy.WrapErrorWithParams(ErrDecryptFile, filePath)
+		return cosy.WrapErrorWithParams(ErrDecryptFile, err.Error())
 	}
 
 	// Write decrypted content back
 	if err := os.WriteFile(filePath, decryptedData, 0644); err != nil {
-		return cosy.WrapErrorWithParams(ErrWriteDecryptedFile, filePath)
+		return cosy.WrapErrorWithParams(ErrWriteDecryptedFile, err.Error())
 	}
 
 	return nil

+ 74 - 28
internal/backup/restore.go

@@ -2,6 +2,7 @@ package backup
 
 import (
 	"archive/zip"
+	"fmt"
 	"io"
 	"os"
 	"path/filepath"
@@ -51,7 +52,7 @@ func Restore(options RestoreOptions) (RestoreResult, error) {
 
 	// Decrypt hash info file
 	if err := decryptFile(hashInfoPath, options.AESKey, options.AESIv); err != nil {
-		return RestoreResult{}, cosy.WrapErrorWithParams(ErrDecryptFile, HashInfoFile)
+		return RestoreResult{}, cosy.WrapErrorWithParams(ErrDecryptFile, err.Error())
 	}
 
 	// Decrypt nginx-ui.zip
@@ -69,21 +70,21 @@ func Restore(options RestoreOptions) (RestoreResult, error) {
 	nginxDir := filepath.Join(options.RestoreDir, NginxDir)
 
 	if err := os.MkdirAll(nginxUIDir, 0755); err != nil {
-		return RestoreResult{}, cosy.WrapErrorWithParams(ErrCreateDir, nginxUIDir)
+		return RestoreResult{}, cosy.WrapErrorWithParams(ErrCreateDir, err.Error())
 	}
 
 	if err := os.MkdirAll(nginxDir, 0755); err != nil {
-		return RestoreResult{}, cosy.WrapErrorWithParams(ErrCreateDir, nginxDir)
+		return RestoreResult{}, cosy.WrapErrorWithParams(ErrCreateDir, err.Error())
 	}
 
 	// Extract nginx-ui.zip to nginx-ui directory
 	if err := extractZipArchive(nginxUIZipPath, nginxUIDir); err != nil {
-		return RestoreResult{}, cosy.WrapErrorWithParams(ErrExtractArchive, "nginx-ui.zip")
+		return RestoreResult{}, cosy.WrapErrorWithParams(ErrExtractArchive, err.Error())
 	}
 
 	// Extract nginx.zip to nginx directory
 	if err := extractZipArchive(nginxZipPath, nginxDir); err != nil {
-		return RestoreResult{}, cosy.WrapErrorWithParams(ErrExtractArchive, "nginx.zip")
+		return RestoreResult{}, cosy.WrapErrorWithParams(ErrExtractArchive, err.Error())
 	}
 
 	result := RestoreResult{
@@ -125,14 +126,14 @@ func Restore(options RestoreOptions) (RestoreResult, error) {
 func extractZipArchive(zipPath, destDir string) error {
 	reader, err := zip.OpenReader(zipPath)
 	if err != nil {
-		return cosy.WrapErrorWithParams(ErrOpenZipFile, err.Error())
+		return cosy.WrapErrorWithParams(ErrOpenZipFile, fmt.Sprintf("failed to open zip file %s: %v", zipPath, err))
 	}
 	defer reader.Close()
 
 	for _, file := range reader.File {
 		err := extractZipFile(file, destDir)
 		if err != nil {
-			return err
+			return cosy.WrapErrorWithParams(ErrExtractArchive, fmt.Sprintf("failed to extract file %s: %v", file.Name, err))
 		}
 	}
 
@@ -143,37 +144,45 @@ func extractZipArchive(zipPath, destDir string) error {
 func extractZipFile(file *zip.File, destDir string) error {
 	// Check for directory traversal elements in the file name
 	if strings.Contains(file.Name, "..") {
-		return cosy.WrapErrorWithParams(ErrInvalidFilePath, file.Name)
+		return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("file name contains directory traversal: %s", file.Name))
+	}
+
+	// Clean and normalize the file path
+	cleanName := filepath.Clean(file.Name)
+	if cleanName == "." || cleanName == ".." {
+		return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("invalid file name after cleaning: %s", file.Name))
 	}
 
 	// Create directory path if needed
-	filePath := filepath.Join(destDir, file.Name)
+	filePath := filepath.Join(destDir, cleanName)
 
 	// Ensure the resulting file path is within the destination directory
 	destDirAbs, err := filepath.Abs(destDir)
 	if err != nil {
-		return cosy.WrapErrorWithParams(ErrInvalidFilePath, "cannot resolve destination path")
+		return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("cannot resolve destination path %s: %v", destDir, err))
 	}
 
 	filePathAbs, err := filepath.Abs(filePath)
 	if err != nil {
-		return cosy.WrapErrorWithParams(ErrInvalidFilePath, file.Name)
+		return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("cannot resolve file path %s: %v", filePath, err))
 	}
 
+	// Check if the file path is within the destination directory
 	if !strings.HasPrefix(filePathAbs, destDirAbs+string(os.PathSeparator)) {
-		return cosy.WrapErrorWithParams(ErrInvalidFilePath, file.Name)
+		return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("file path %s is outside destination directory %s", filePathAbs, destDirAbs))
 	}
 
 	if file.FileInfo().IsDir() {
 		if err := os.MkdirAll(filePath, file.Mode()); err != nil {
-			return cosy.WrapErrorWithParams(ErrCreateDir, filePath)
+			return cosy.WrapErrorWithParams(ErrCreateDir, fmt.Sprintf("failed to create directory %s: %v", filePath, err))
 		}
 		return nil
 	}
 
 	// Create parent directory if needed
-	if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
-		return cosy.WrapErrorWithParams(ErrCreateParentDir, filePath)
+	parentDir := filepath.Dir(filePath)
+	if err := os.MkdirAll(parentDir, 0755); err != nil {
+		return cosy.WrapErrorWithParams(ErrCreateParentDir, fmt.Sprintf("failed to create parent directory %s: %v", parentDir, err))
 	}
 
 	// Check if this is a symlink by examining mode bits
@@ -181,46 +190,83 @@ func extractZipFile(file *zip.File, destDir string) error {
 		// Open source file in zip to read the link target
 		srcFile, err := file.Open()
 		if err != nil {
-			return cosy.WrapErrorWithParams(ErrOpenZipEntry, file.Name)
+			return cosy.WrapErrorWithParams(ErrOpenZipEntry, fmt.Sprintf("failed to open symlink source %s: %v", file.Name, err))
 		}
 		defer srcFile.Close()
 
 		// Read the link target
 		linkTargetBytes, err := io.ReadAll(srcFile)
 		if err != nil {
-			return cosy.WrapErrorWithParams(ErrReadSymlink, file.Name)
+			return cosy.WrapErrorWithParams(ErrReadSymlink, fmt.Sprintf("failed to read symlink target for %s: %v", file.Name, err))
 		}
 		linkTarget := string(linkTargetBytes)
 
+		// Clean and normalize the link target
+		cleanLinkTarget := filepath.Clean(linkTarget)
+		if cleanLinkTarget == "." || cleanLinkTarget == ".." {
+			return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("invalid symlink target: %s", linkTarget))
+		}
+
+		// Get nginx modules path
+		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))
+			}
+			cleanLinkTarget = relPath
+		} else if filepath.IsAbs(cleanLinkTarget) {
+			// For other absolute paths, we'll create a directory instead of a symlink
+			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
-		absLinkTarget := filepath.Clean(filepath.Join(filepath.Dir(filePath), linkTarget))
+		absLinkTarget := filepath.Clean(filepath.Join(filepath.Dir(filePath), cleanLinkTarget))
 		if !strings.HasPrefix(absLinkTarget, destDirAbs+string(os.PathSeparator)) {
-			return cosy.WrapErrorWithParams(ErrInvalidFilePath, linkTarget)
+			// 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
+			}
+			return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("symlink target %s is outside destination directory %s", absLinkTarget, destDirAbs))
 		}
 
 		// Remove any existing file/link at the target path
-		_ = os.Remove(filePath)
+		if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
+			// Ignoring error, continue creating symlink
+		}
 
 		// Create the symlink
-		if err := os.Symlink(linkTarget, filePath); err != nil {
-			return cosy.WrapErrorWithParams(ErrCreateSymlink, file.Name)
+		if err := os.Symlink(cleanLinkTarget, filePath); err != nil {
+			return cosy.WrapErrorWithParams(ErrCreateSymlink, fmt.Sprintf("failed to create symlink %s -> %s: %v", filePath, cleanLinkTarget, err))
 		}
 
 		// Verify the resolved symlink path is within destination directory
 		resolvedPath, err := filepath.EvalSymlinks(filePath)
 		if err != nil {
-			return cosy.WrapErrorWithParams(ErrEvalSymlinks, filePath)
+			// If we can't resolve the symlink, it's not a critical error
+			// Just continue
+			return nil
 		}
 
 		resolvedPathAbs, err := filepath.Abs(resolvedPath)
 		if err != nil {
-			return cosy.WrapErrorWithParams(ErrInvalidFilePath, resolvedPath)
+			// Not a critical error, continue
+			return nil
 		}
 
 		if !strings.HasPrefix(resolvedPathAbs, destDirAbs+string(os.PathSeparator)) {
 			// Remove the symlink if it points outside the destination directory
 			_ = os.Remove(filePath)
-			return cosy.WrapErrorWithParams(ErrInvalidFilePath, resolvedPath)
+			return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("resolved symlink path %s is outside destination directory %s", resolvedPathAbs, destDirAbs))
 		}
 
 		return nil
@@ -229,20 +275,20 @@ func extractZipFile(file *zip.File, destDir string) error {
 	// Create file
 	destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
 	if err != nil {
-		return cosy.WrapErrorWithParams(ErrCreateFile, filePath)
+		return cosy.WrapErrorWithParams(ErrCreateFile, fmt.Sprintf("failed to create file %s: %v", filePath, err))
 	}
 	defer destFile.Close()
 
 	// Open source file in zip
 	srcFile, err := file.Open()
 	if err != nil {
-		return cosy.WrapErrorWithParams(ErrOpenZipEntry, file.Name)
+		return cosy.WrapErrorWithParams(ErrOpenZipEntry, fmt.Sprintf("failed to open zip entry %s: %v", file.Name, err))
 	}
 	defer srcFile.Close()
 
 	// Copy content
 	if _, err := io.Copy(destFile, srcFile); err != nil {
-		return cosy.WrapErrorWithParams(ErrCopyContent, file.Name)
+		return cosy.WrapErrorWithParams(ErrCopyContent, fmt.Sprintf("failed to copy content for file %s: %v", file.Name, err))
 	}
 
 	return nil

+ 88 - 0
internal/middleware/encrypted_params.go

@@ -4,17 +4,22 @@ import (
 	"bytes"
 	"encoding/json"
 	"io"
+	"mime/multipart"
 	"net/http"
+	"net/url"
+	"strings"
 
 	"github.com/0xJacky/Nginx-UI/internal/crypto"
 	"github.com/gin-gonic/gin"
 	"github.com/uozi-tech/cosy"
+	"github.com/uozi-tech/cosy/logger"
 )
 
 var (
 	e                       = cosy.NewErrorScope("middleware")
 	ErrInvalidRequestFormat = e.New(40000, "invalid request format")
 	ErrDecryptionFailed     = e.New(40001, "decryption failed")
+	ErrFormParseFailed      = e.New(40002, "form parse failed")
 )
 
 func EncryptedParams() gin.HandlerFunc {
@@ -44,3 +49,86 @@ func EncryptedParams() gin.HandlerFunc {
 		c.Next()
 	}
 }
+
+// EncryptedForm handles multipart/form-data with encrypted fields while preserving file uploads
+func EncryptedForm() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		// Only process if the content type is multipart/form-data
+		if !strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
+			c.Next()
+			return
+		}
+
+		// Parse the multipart form
+		if err := c.Request.ParseMultipartForm(512 << 20); err != nil { // 512MB max memory
+			c.AbortWithStatusJSON(http.StatusBadRequest, ErrFormParseFailed)
+			return
+		}
+
+		// Check if encrypted_params field exists
+		encryptedParams := c.Request.FormValue("encrypted_params")
+		if encryptedParams == "" {
+			// No encryption, continue normally
+			c.Next()
+			return
+		}
+
+		// Decrypt the parameters
+		params, err := crypto.Decrypt(encryptedParams)
+		if err != nil {
+			c.AbortWithStatusJSON(http.StatusBadRequest, ErrDecryptionFailed)
+			return
+		}
+
+		// Create a new multipart form with the decrypted data
+		newForm := &multipart.Form{
+			Value: make(map[string][]string),
+			File:  c.Request.MultipartForm.File, // Keep original file uploads
+		}
+
+		// Add decrypted values to the new form
+		for key, val := range params {
+			strVal, ok := val.(string)
+			if ok {
+				newForm.Value[key] = []string{strVal}
+			} else {
+				// Handle other types if necessary
+				jsonVal, _ := json.Marshal(val)
+				newForm.Value[key] = []string{string(jsonVal)}
+			}
+		}
+
+		// Also copy original non-encrypted form values (except encrypted_params)
+		for key, vals := range c.Request.MultipartForm.Value {
+			if key != "encrypted_params" && newForm.Value[key] == nil {
+				newForm.Value[key] = vals
+			}
+		}
+
+		logger.Debug("newForm values", newForm.Value)
+		logger.Debug("newForm files", newForm.File)
+
+		// Replace the original form with our modified one
+		c.Request.MultipartForm = newForm
+
+		// Remove the encrypted_params field from the form
+		delete(c.Request.MultipartForm.Value, "encrypted_params")
+
+		// Reset ContentLength as form structure has changed
+		c.Request.ContentLength = -1
+
+		// Sync the form values to the request PostForm to ensure Gin can access them
+		if c.Request.PostForm == nil {
+			c.Request.PostForm = make(url.Values)
+		}
+
+		// Copy all values from MultipartForm to PostForm
+		for k, v := range newForm.Value {
+			c.Request.PostForm[k] = v
+		}
+
+		logger.Debug("PostForm after sync", c.Request.PostForm)
+
+		c.Next()
+	}
+}

+ 25 - 1
internal/nginx/nginx.go

@@ -1,10 +1,12 @@
 package nginx
 
 import (
-	"github.com/0xJacky/Nginx-UI/settings"
 	"os/exec"
+	"strings"
 	"sync"
 	"time"
+
+	"github.com/0xJacky/Nginx-UI/settings"
 )
 
 var (
@@ -74,6 +76,28 @@ func GetLastOutput() string {
 	return lastOutput
 }
 
+// GetModulesPath returns the nginx modules path
+func GetModulesPath() string {
+	// First try to get from nginx -V output
+	output := execCommand("nginx", "-V")
+	if output != "" {
+		// Look for --modules-path in the output
+		if strings.Contains(output, "--modules-path=") {
+			parts := strings.Split(output, "--modules-path=")
+			if len(parts) > 1 {
+				// Extract the path
+				path := strings.Split(parts[1], " ")[0]
+				// Remove quotes if present
+				path = strings.Trim(path, "\"")
+				return path
+			}
+		}
+	}
+
+	// Default path if not found
+	return "/usr/lib/nginx/modules"
+}
+
 func execShell(cmd string) (out string) {
 	bytes, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput()
 	out = string(bytes)