diff --git a/api/system/backup.go b/api/system/backup.go index c6c940a6..0c90a296 100644 --- a/api/system/backup.go +++ b/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, - }) -} diff --git a/api/system/restore.go b/api/system/restore.go new file mode 100644 index 00000000..b54faf0b --- /dev/null +++ b/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, + }) +} diff --git a/api/system/router.go b/api/system/router.go index 8f27691b..2ec517eb 100644 --- a/api/system/router.go +++ b/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) { diff --git a/app/src/api/backup.ts b/app/src/api/backup.ts index 1b0aff6b..759488e3 100644 --- a/app/src/api/backup.ts +++ b/app/src/api/backup.ts @@ -49,6 +49,7 @@ const backup = { headers: { 'Content-Type': 'multipart/form-data;charset=UTF-8', }, + crypto: true, }) }, } diff --git a/app/src/lib/http/client.ts b/app/src/lib/http/client.ts new file mode 100644 index 00000000..be36325b --- /dev/null +++ b/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(url: string, config: HttpConfig = {}) { + // eslint-disable-next-line ts/no-explicit-any + return instance.get(url, config) + }, + // eslint-disable-next-line ts/no-explicit-any + post(url: string, data: any = undefined, config: HttpConfig = {}) { + // eslint-disable-next-line ts/no-explicit-any + return instance.post(url, data, config) + }, + // eslint-disable-next-line ts/no-explicit-any + put(url: string, data: any = undefined, config: HttpConfig = {}) { + // eslint-disable-next-line ts/no-explicit-any + return instance.put(url, data, config) + }, + // eslint-disable-next-line ts/no-explicit-any + delete(url: string, config: HttpConfig = {}) { + // eslint-disable-next-line ts/no-explicit-any + return instance.delete(url, config) + }, + // eslint-disable-next-line ts/no-explicit-any + patch(url: string, config: HttpConfig = {}) { + // eslint-disable-next-line ts/no-explicit-any + return instance.patch(url, config) + }, +} + +export { http, instance } diff --git a/app/src/lib/http/error.ts b/app/src/lib/http/error.ts new file mode 100644 index 00000000..fce29ef5 --- /dev/null +++ b/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 = {} + +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() + + 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')) + } +} diff --git a/app/src/lib/http/index.ts b/app/src/lib/http/index.ts index 81c80302..170cfa6d 100644 --- a/app/src/lib/http/index.ts +++ b/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 string> - -const errors: Record = {} - -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(url: string, config: HttpConfig = {}) { - // eslint-disable-next-line ts/no-explicit-any - return instance.get(url, config) - }, - // eslint-disable-next-line ts/no-explicit-any - post(url: string, data: any = undefined, config: HttpConfig = {}) { - // eslint-disable-next-line ts/no-explicit-any - return instance.post(url, data, config) - }, - // eslint-disable-next-line ts/no-explicit-any - put(url: string, data: any = undefined, config: HttpConfig = {}) { - // eslint-disable-next-line ts/no-explicit-any - return instance.put(url, data, config) - }, - // eslint-disable-next-line ts/no-explicit-any - delete(url: string, config: HttpConfig = {}) { - // eslint-disable-next-line ts/no-explicit-any - return instance.delete(url, config) - }, - // eslint-disable-next-line ts/no-explicit-any - patch(url: string, config: HttpConfig = {}) { - // eslint-disable-next-line ts/no-explicit-any - return instance.patch(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() - - 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) - }, -) diff --git a/app/src/lib/http/interceptors.ts b/app/src/lib/http/interceptors.ts new file mode 100644 index 00000000..c251cc9f --- /dev/null +++ b/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 { + 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 { + 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 = {} + 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() +} diff --git a/app/src/lib/http/types.ts b/app/src/lib/http/types.ts new file mode 100644 index 00000000..d96d7dc5 --- /dev/null +++ b/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 string> + +export interface HttpConfig extends AxiosRequestConfig { + returnFullResponse?: boolean + crypto?: boolean +} + +// Extend InternalAxiosRequestConfig type +declare module 'axios' { + interface InternalAxiosRequestConfig { + returnFullResponse?: boolean + crypto?: boolean + } +} diff --git a/app/src/lib/nprogress/nprogress.ts b/app/src/lib/nprogress/nprogress.ts index 53cfdf05..a4e58a3f 100644 --- a/app/src/lib/nprogress/nprogress.ts +++ b/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 }) diff --git a/app/src/views/system/Backup/BackupCreator.vue b/app/src/views/system/Backup/BackupCreator.vue index 7074057a..d1f78665 100644 --- a/app/src/views/system/Backup/BackupCreator.vue +++ b/app/src/views/system/Backup/BackupCreator.vue @@ -90,7 +90,7 @@ function handleCopy(copy) { %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 diff --git a/internal/middleware/encrypted_params.go b/internal/middleware/encrypted_params.go index 9a321052..b533c3ef 100644 --- a/internal/middleware/encrypted_params.go +++ b/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() + } +} diff --git a/internal/nginx/nginx.go b/internal/nginx/nginx.go index e778b6cf..e27d0abf 100644 --- a/internal/nginx/nginx.go +++ b/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)