mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2025-05-11 02:15:48 +02:00
feat: implement encrypted form handling and refactor backup restore logic
This commit is contained in:
parent
ed320f32cc
commit
8860f71bc7
15 changed files with 643 additions and 368 deletions
|
@ -2,26 +2,14 @@ package system
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/0xJacky/Nginx-UI/internal/backup"
|
"github.com/0xJacky/Nginx-UI/internal/backup"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jpillora/overseer"
|
|
||||||
"github.com/uozi-tech/cosy"
|
"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
|
// CreateBackup creates a backup of nginx-ui and nginx configurations
|
||||||
// and sends files directly for download
|
// and sends files directly for download
|
||||||
func CreateBackup(c *gin.Context) {
|
func CreateBackup(c *gin.Context) {
|
||||||
|
@ -52,109 +40,3 @@ func CreateBackup(c *gin.Context) {
|
||||||
// Send file content
|
// Send file content
|
||||||
http.ServeContent(c.Writer, c.Request, fileName, modTime, reader)
|
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
api/system/restore.go
Normal file
134
api/system/restore.go
Normal file
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
|
@ -19,7 +19,7 @@ func InitPrivateRouter(r *gin.RouterGroup) {
|
||||||
|
|
||||||
// Backup and restore endpoints
|
// Backup and restore endpoints
|
||||||
r.GET("system/backup", CreateBackup)
|
r.GET("system/backup", CreateBackup)
|
||||||
r.POST("system/backup/restore", RestoreBackup)
|
r.POST("system/backup/restore", middleware.EncryptedForm(), RestoreBackup)
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitWebSocketRouter(r *gin.RouterGroup) {
|
func InitWebSocketRouter(r *gin.RouterGroup) {
|
||||||
|
|
|
@ -49,6 +49,7 @@ const backup = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data;charset=UTF-8',
|
'Content-Type': 'multipart/form-data;charset=UTF-8',
|
||||||
},
|
},
|
||||||
|
crypto: true,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
38
app/src/lib/http/client.ts
Normal file
38
app/src/lib/http/client.ts
Normal file
|
@ -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
app/src/lib/http/error.ts
Normal file
78
app/src/lib/http/error.ts
Normal file
|
@ -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'))
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,221 +1,19 @@
|
||||||
import type { AxiosRequestConfig } from 'axios'
|
import type { CosyError, CosyErrorRecord, HttpConfig } from './types'
|
||||||
import use2FAModal from '@/components/TwoFA/use2FAModal'
|
import { http } from './client'
|
||||||
import { useNProgress } from '@/lib/nprogress/nprogress'
|
import { registerError, useMessageDedupe } from './error'
|
||||||
import { useSettingsStore, useUserStore } from '@/pinia'
|
import { setupInterceptors } from './interceptors'
|
||||||
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'
|
|
||||||
|
|
||||||
const user = useUserStore()
|
// Initialize interceptors
|
||||||
const settings = useSettingsStore()
|
setupInterceptors()
|
||||||
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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Export everything needed from this module
|
||||||
export default http
|
export default http
|
||||||
|
export type {
|
||||||
const nprogress = useNProgress()
|
CosyError,
|
||||||
|
CosyErrorRecord,
|
||||||
// Add new dedupe utility at the top
|
HttpConfig,
|
||||||
interface MessageDedupe {
|
|
||||||
error: (content: string, duration?: number) => void
|
|
||||||
}
|
}
|
||||||
|
export {
|
||||||
function useMessageDedupe(interval = 5000): MessageDedupe {
|
registerError,
|
||||||
const lastMessages = new Map<string, number>()
|
useMessageDedupe,
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
app/src/lib/http/interceptors.ts
Normal file
160
app/src/lib/http/interceptors.ts
Normal file
|
@ -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
app/src/lib/http/types.ts
Normal file
25
app/src/lib/http/types.ts
Normal file
|
@ -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,5 +1,6 @@
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
import NProgress from 'nprogress'
|
import NProgress from 'nprogress'
|
||||||
|
import 'nprogress/nprogress.css'
|
||||||
|
|
||||||
NProgress.configure({ showSpinner: false, trickleSpeed: 300 })
|
NProgress.configure({ showSpinner: false, trickleSpeed: 300 })
|
||||||
|
|
||||||
|
|
|
@ -90,7 +90,7 @@ function handleCopy(copy) {
|
||||||
|
|
||||||
<!-- Security Token Modal Component -->
|
<!-- Security Token Modal Component -->
|
||||||
<AModal
|
<AModal
|
||||||
v-model:visible="showSecurityModal"
|
v-model:open="showSecurityModal"
|
||||||
:title="$gettext('Security Token Information')"
|
:title="$gettext('Security Token Information')"
|
||||||
:mask-closable="false"
|
:mask-closable="false"
|
||||||
:centered="true"
|
:centered="true"
|
||||||
|
|
|
@ -100,18 +100,18 @@ func decryptFile(filePath string, key []byte, iv []byte) error {
|
||||||
// Read encrypted file content
|
// Read encrypted file content
|
||||||
encryptedData, err := os.ReadFile(filePath)
|
encryptedData, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cosy.WrapErrorWithParams(ErrReadEncryptedFile, filePath)
|
return cosy.WrapErrorWithParams(ErrReadEncryptedFile, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt file content
|
// Decrypt file content
|
||||||
decryptedData, err := AESDecrypt(encryptedData, key, iv)
|
decryptedData, err := AESDecrypt(encryptedData, key, iv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cosy.WrapErrorWithParams(ErrDecryptFile, filePath)
|
return cosy.WrapErrorWithParams(ErrDecryptFile, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write decrypted content back
|
// Write decrypted content back
|
||||||
if err := os.WriteFile(filePath, decryptedData, 0644); err != nil {
|
if err := os.WriteFile(filePath, decryptedData, 0644); err != nil {
|
||||||
return cosy.WrapErrorWithParams(ErrWriteDecryptedFile, filePath)
|
return cosy.WrapErrorWithParams(ErrWriteDecryptedFile, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -2,6 +2,7 @@ package backup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
@ -51,7 +52,7 @@ func Restore(options RestoreOptions) (RestoreResult, error) {
|
||||||
|
|
||||||
// Decrypt hash info file
|
// Decrypt hash info file
|
||||||
if err := decryptFile(hashInfoPath, options.AESKey, options.AESIv); err != nil {
|
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
|
// Decrypt nginx-ui.zip
|
||||||
|
@ -69,21 +70,21 @@ func Restore(options RestoreOptions) (RestoreResult, error) {
|
||||||
nginxDir := filepath.Join(options.RestoreDir, NginxDir)
|
nginxDir := filepath.Join(options.RestoreDir, NginxDir)
|
||||||
|
|
||||||
if err := os.MkdirAll(nginxUIDir, 0755); err != nil {
|
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 {
|
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
|
// Extract nginx-ui.zip to nginx-ui directory
|
||||||
if err := extractZipArchive(nginxUIZipPath, nginxUIDir); err != nil {
|
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
|
// Extract nginx.zip to nginx directory
|
||||||
if err := extractZipArchive(nginxZipPath, nginxDir); err != nil {
|
if err := extractZipArchive(nginxZipPath, nginxDir); err != nil {
|
||||||
return RestoreResult{}, cosy.WrapErrorWithParams(ErrExtractArchive, "nginx.zip")
|
return RestoreResult{}, cosy.WrapErrorWithParams(ErrExtractArchive, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
result := RestoreResult{
|
result := RestoreResult{
|
||||||
|
@ -125,14 +126,14 @@ func Restore(options RestoreOptions) (RestoreResult, error) {
|
||||||
func extractZipArchive(zipPath, destDir string) error {
|
func extractZipArchive(zipPath, destDir string) error {
|
||||||
reader, err := zip.OpenReader(zipPath)
|
reader, err := zip.OpenReader(zipPath)
|
||||||
if err != nil {
|
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()
|
defer reader.Close()
|
||||||
|
|
||||||
for _, file := range reader.File {
|
for _, file := range reader.File {
|
||||||
err := extractZipFile(file, destDir)
|
err := extractZipFile(file, destDir)
|
||||||
if err != nil {
|
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 {
|
func extractZipFile(file *zip.File, destDir string) error {
|
||||||
// Check for directory traversal elements in the file name
|
// Check for directory traversal elements in the file name
|
||||||
if strings.Contains(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
|
// 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
|
// Ensure the resulting file path is within the destination directory
|
||||||
destDirAbs, err := filepath.Abs(destDir)
|
destDirAbs, err := filepath.Abs(destDir)
|
||||||
if err != nil {
|
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)
|
filePathAbs, err := filepath.Abs(filePath)
|
||||||
if err != nil {
|
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)) {
|
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 file.FileInfo().IsDir() {
|
||||||
if err := os.MkdirAll(filePath, file.Mode()); err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create parent directory if needed
|
// Create parent directory if needed
|
||||||
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
parentDir := filepath.Dir(filePath)
|
||||||
return cosy.WrapErrorWithParams(ErrCreateParentDir, 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
|
// 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
|
// Open source file in zip to read the link target
|
||||||
srcFile, err := file.Open()
|
srcFile, err := file.Open()
|
||||||
if err != nil {
|
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()
|
defer srcFile.Close()
|
||||||
|
|
||||||
// Read the link target
|
// Read the link target
|
||||||
linkTargetBytes, err := io.ReadAll(srcFile)
|
linkTargetBytes, err := io.ReadAll(srcFile)
|
||||||
if err != nil {
|
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)
|
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
|
// 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)) {
|
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
|
// 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
|
// Create the symlink
|
||||||
if err := os.Symlink(linkTarget, filePath); err != nil {
|
if err := os.Symlink(cleanLinkTarget, filePath); err != nil {
|
||||||
return cosy.WrapErrorWithParams(ErrCreateSymlink, file.Name)
|
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
|
// Verify the resolved symlink path is within destination directory
|
||||||
resolvedPath, err := filepath.EvalSymlinks(filePath)
|
resolvedPath, err := filepath.EvalSymlinks(filePath)
|
||||||
if err != nil {
|
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)
|
resolvedPathAbs, err := filepath.Abs(resolvedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cosy.WrapErrorWithParams(ErrInvalidFilePath, resolvedPath)
|
// Not a critical error, continue
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(resolvedPathAbs, destDirAbs+string(os.PathSeparator)) {
|
if !strings.HasPrefix(resolvedPathAbs, destDirAbs+string(os.PathSeparator)) {
|
||||||
// Remove the symlink if it points outside the destination directory
|
// Remove the symlink if it points outside the destination directory
|
||||||
_ = os.Remove(filePath)
|
_ = 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
|
return nil
|
||||||
|
@ -229,20 +275,20 @@ func extractZipFile(file *zip.File, destDir string) error {
|
||||||
// Create file
|
// Create file
|
||||||
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
|
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
|
||||||
if err != nil {
|
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()
|
defer destFile.Close()
|
||||||
|
|
||||||
// Open source file in zip
|
// Open source file in zip
|
||||||
srcFile, err := file.Open()
|
srcFile, err := file.Open()
|
||||||
if err != nil {
|
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()
|
defer srcFile.Close()
|
||||||
|
|
||||||
// Copy content
|
// Copy content
|
||||||
if _, err := io.Copy(destFile, srcFile); err != nil {
|
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
|
return nil
|
||||||
|
|
|
@ -4,17 +4,22 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/0xJacky/Nginx-UI/internal/crypto"
|
"github.com/0xJacky/Nginx-UI/internal/crypto"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/uozi-tech/cosy"
|
"github.com/uozi-tech/cosy"
|
||||||
|
"github.com/uozi-tech/cosy/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
e = cosy.NewErrorScope("middleware")
|
e = cosy.NewErrorScope("middleware")
|
||||||
ErrInvalidRequestFormat = e.New(40000, "invalid request format")
|
ErrInvalidRequestFormat = e.New(40000, "invalid request format")
|
||||||
ErrDecryptionFailed = e.New(40001, "decryption failed")
|
ErrDecryptionFailed = e.New(40001, "decryption failed")
|
||||||
|
ErrFormParseFailed = e.New(40002, "form parse failed")
|
||||||
)
|
)
|
||||||
|
|
||||||
func EncryptedParams() gin.HandlerFunc {
|
func EncryptedParams() gin.HandlerFunc {
|
||||||
|
@ -44,3 +49,86 @@ func EncryptedParams() gin.HandlerFunc {
|
||||||
c.Next()
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
package nginx
|
package nginx
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/0xJacky/Nginx-UI/settings"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/0xJacky/Nginx-UI/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -74,6 +76,28 @@ func GetLastOutput() string {
|
||||||
return lastOutput
|
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) {
|
func execShell(cmd string) (out string) {
|
||||||
bytes, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput()
|
bytes, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput()
|
||||||
out = string(bytes)
|
out = string(bytes)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue