Преглед изворни кода

feat: deploy config to remote nodes #359

Jacky пре 9 месеци
родитељ
комит
1c1da92363
46 измењених фајлова са 1481 додато и 606 уклоњено
  1. 84 52
      api/config/add.go
  2. 23 7
      api/config/get.go
  3. 1 0
      api/config/mkdir.go
  4. 27 1
      api/config/modify.go
  5. 35 6
      api/config/rename.go
  6. 10 3
      api/config/router.go
  7. 7 0
      api/user/otp.go
  8. 7 1
      api/user/router.go
  9. 9 2
      app/src/api/config.ts
  10. 3 0
      app/src/api/otp.ts
  11. 18 0
      app/src/components/Notification/cert.ts
  12. 37 0
      app/src/components/Notification/config.ts
  13. 15 19
      app/src/components/Notification/detailRender.ts
  14. 56 57
      app/src/components/OTP/useOTPModal.ts
  15. 17 11
      app/src/language/en/app.po
  16. 17 11
      app/src/language/es/app.po
  17. 17 11
      app/src/language/fr_FR/app.po
  18. 18 11
      app/src/language/ko_KR/app.po
  19. 13 7
      app/src/language/messages.pot
  20. 17 11
      app/src/language/ru_RU/app.po
  21. 17 11
      app/src/language/vi_VN/app.po
  22. BIN
      app/src/language/zh_CN/app.mo
  23. 17 11
      app/src/language/zh_CN/app.po
  24. 17 11
      app/src/language/zh_TW/app.po
  25. 8 0
      app/src/lib/http/index.ts
  26. 1 1
      app/src/routes/index.ts
  27. 1 1
      app/src/version.json
  28. 49 2
      app/src/views/config/ConfigEditor.vue
  29. 49 19
      app/src/views/config/ConfigList.vue
  30. 8 10
      app/src/views/config/components/Mkdir.vue
  31. 24 12
      app/src/views/config/components/Rename.vue
  32. 22 22
      app/src/views/pty/Terminal.vue
  33. 1 1
      app/version.json
  34. 292 0
      internal/config/sync.go
  35. 2 2
      internal/middleware/ip_whitelist.go
  36. 8 44
      internal/middleware/middleware.go
  37. 2 2
      internal/middleware/proxy.go
  38. 2 2
      internal/middleware/proxy_ws.go
  39. 43 0
      internal/middleware/secure_session.go
  40. 9 0
      model/config.go
  41. 1 0
      model/model.go
  42. 28 20
      query/certs.gen.go
  43. 370 0
      query/configs.gen.go
  44. 8 0
      query/gen.go
  45. 0 154
      router/operation_sync.go
  46. 71 71
      router/routers.go

+ 84 - 52
api/config/add.go

@@ -1,65 +1,97 @@
 package config
 
 import (
-	"github.com/0xJacky/Nginx-UI/api"
-	"github.com/0xJacky/Nginx-UI/internal/config"
-	"github.com/0xJacky/Nginx-UI/internal/helper"
-	"github.com/0xJacky/Nginx-UI/internal/nginx"
-	"github.com/gin-gonic/gin"
-	"github.com/sashabaranov/go-openai"
-	"net/http"
-	"os"
-	"time"
+    "github.com/0xJacky/Nginx-UI/api"
+    "github.com/0xJacky/Nginx-UI/internal/config"
+    "github.com/0xJacky/Nginx-UI/internal/helper"
+    "github.com/0xJacky/Nginx-UI/internal/nginx"
+    "github.com/0xJacky/Nginx-UI/model"
+    "github.com/0xJacky/Nginx-UI/query"
+    "github.com/gin-gonic/gin"
+    "github.com/sashabaranov/go-openai"
+    "net/http"
+    "os"
+    "path/filepath"
+    "time"
 )
 
 func AddConfig(c *gin.Context) {
-	var json struct {
-		Name        string `json:"name" binding:"required"`
-		NewFilepath string `json:"new_filepath" binding:"required"`
-		Content     string `json:"content"`
-		Overwrite   bool   `json:"overwrite"`
-	}
+    var json struct {
+        Name        string `json:"name" binding:"required"`
+        NewFilepath string `json:"new_filepath" binding:"required"`
+        Content     string `json:"content"`
+        Overwrite   bool   `json:"overwrite"`
+        SyncNodeIds []int  `json:"sync_node_ids"`
+    }
 
-	if !api.BindAndValid(c, &json) {
-		return
-	}
+    if !api.BindAndValid(c, &json) {
+        return
+    }
 
-	name := json.Name
-	content := json.Content
-	path := json.NewFilepath
-	if !helper.IsUnderDirectory(path, nginx.GetConfPath()) {
-		c.JSON(http.StatusForbidden, gin.H{
-			"message": "new filepath is not under the nginx conf path",
-		})
-		return
-	}
+    name := json.Name
+    content := json.Content
+    path := json.NewFilepath
+    if !helper.IsUnderDirectory(path, nginx.GetConfPath()) {
+        c.JSON(http.StatusForbidden, gin.H{
+            "message": "new filepath is not under the nginx conf path",
+        })
+        return
+    }
 
-	if !json.Overwrite && helper.FileExists(path) {
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "File exists",
-		})
-		return
-	}
+    if !json.Overwrite && helper.FileExists(path) {
+        c.JSON(http.StatusNotAcceptable, gin.H{
+            "message": "File exists",
+        })
+        return
+    }
 
-	err := os.WriteFile(path, []byte(content), 0644)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
+    // check if the dir exists, if not, use mkdirAll to create the dir
+    dir := filepath.Dir(path)
+    if !helper.FileExists(dir) {
+        err := os.MkdirAll(dir, 0755)
+        if err != nil {
+            api.ErrHandler(c, err)
+            return
+        }
+    }
 
-	output := nginx.Reload()
-	if nginx.GetLogLevel(output) >= nginx.Warn {
-		c.JSON(http.StatusInternalServerError, gin.H{
-			"message": output,
-		})
-		return
-	}
+    err := os.WriteFile(path, []byte(content), 0644)
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
 
-	c.JSON(http.StatusOK, config.Config{
-		Name:            name,
-		Content:         content,
-		ChatGPTMessages: make([]openai.ChatCompletionMessage, 0),
-		FilePath:        path,
-		ModifiedAt:      time.Now(),
-	})
+    output := nginx.Reload()
+    if nginx.GetLogLevel(output) >= nginx.Warn {
+        c.JSON(http.StatusInternalServerError, gin.H{
+            "message": output,
+        })
+        return
+    }
+
+    q := query.Config
+    _, err = q.Where(q.Filepath.Eq(path)).Delete()
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+
+    err = q.Create(&model.Config{
+        Name:          name,
+        Filepath:      path,
+        SyncNodeIds:   json.SyncNodeIds,
+        SyncOverwrite: json.Overwrite,
+    })
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+
+    c.JSON(http.StatusOK, config.Config{
+        Name:            name,
+        Content:         content,
+        ChatGPTMessages: make([]openai.ChatCompletionMessage, 0),
+        FilePath:        path,
+        ModifiedAt:      time.Now(),
+    })
 }

+ 23 - 7
api/config/get.go

@@ -12,6 +12,12 @@ import (
 	"os"
 )
 
+type APIConfigResp struct {
+	config.Config
+	SyncNodeIds   []int `json:"sync_node_ids" gorm:"serializer:json"`
+	SyncOverwrite bool  `json:"sync_overwrite"`
+}
+
 func GetConfig(c *gin.Context) {
 	name := c.Param("name")
 
@@ -34,7 +40,7 @@ func GetConfig(c *gin.Context) {
 		api.ErrHandler(c, err)
 		return
 	}
-
+	q := query.Config
 	g := query.ChatGPTLog
 	chatgpt, err := g.Where(g.Name.Eq(path)).FirstOrCreate()
 	if err != nil {
@@ -46,11 +52,21 @@ func GetConfig(c *gin.Context) {
 		chatgpt.Content = make([]openai.ChatCompletionMessage, 0)
 	}
 
-	c.JSON(http.StatusOK, config.Config{
-		Name:            stat.Name(),
-		Content:         string(content),
-		ChatGPTMessages: chatgpt.Content,
-		FilePath:        path,
-		ModifiedAt:      stat.ModTime(),
+	cfg, err := q.Where(q.Filepath.Eq(path)).FirstOrInit()
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, APIConfigResp{
+		Config: config.Config{
+			Name:            stat.Name(),
+			Content:         string(content),
+			ChatGPTMessages: chatgpt.Content,
+			FilePath:        path,
+			ModifiedAt:      stat.ModTime(),
+		},
+		SyncNodeIds:   cfg.SyncNodeIds,
+		SyncOverwrite: cfg.SyncOverwrite,
 	})
 }

+ 1 - 0
api/config/folder.go → api/config/mkdir.go

@@ -30,6 +30,7 @@ func Mkdir(c *gin.Context) {
 		api.ErrHandler(c, err)
 		return
 	}
+
 	c.JSON(http.StatusOK, gin.H{
 		"message": "ok",
 	})

+ 27 - 1
api/config/modify.go

@@ -5,6 +5,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/internal/config"
 	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
 	"github.com/sashabaranov/go-openai"
@@ -24,6 +25,8 @@ func EditConfig(c *gin.Context) {
 		Filepath    string `json:"filepath" binding:"required"`
 		NewFilepath string `json:"new_filepath" binding:"required"`
 		Content     string `json:"content"`
+		Overwrite   bool   `json:"overwrite"`
+		SyncNodeIds []int  `json:"sync_node_ids"`
 	}
 	if !api.BindAndValid(c, &json) {
 		return
@@ -66,8 +69,25 @@ func EditConfig(c *gin.Context) {
 		}
 	}
 
-	g := query.ChatGPTLog
+	q := query.Config
+	cfg, err := q.Where(q.Filepath.Eq(json.Filepath)).FirstOrCreate()
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
 
+	_, err = q.Where(q.Filepath.Eq(json.Filepath)).Updates(&model.Config{
+		Name:          json.Name,
+		Filepath:      json.NewFilepath,
+		SyncNodeIds:   json.SyncNodeIds,
+		SyncOverwrite: json.Overwrite,
+	})
+
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+	g := query.ChatGPTLog
 	// handle rename
 	if path != json.NewFilepath {
 		if helper.FileExists(json.NewFilepath) {
@@ -87,6 +107,12 @@ func EditConfig(c *gin.Context) {
 		_, _ = g.Where(g.Name.Eq(path)).Update(g.Name, json.NewFilepath)
 	}
 
+	err = config.SyncToRemoteServer(cfg, json.NewFilepath)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
 	output := nginx.Reload()
 	if nginx.GetLogLevel(output) >= nginx.Warn {
 		c.JSON(http.StatusInternalServerError, gin.H{

+ 35 - 6
api/config/rename.go

@@ -2,7 +2,9 @@ package config
 
 import (
 	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/internal/config"
 	"github.com/0xJacky/Nginx-UI/internal/helper"
+	"github.com/0xJacky/Nginx-UI/internal/logger"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
@@ -12,14 +14,16 @@ import (
 
 func Rename(c *gin.Context) {
 	var json struct {
-		BasePath string `json:"base_path"`
-		OrigName string `json:"orig_name"`
-		NewName  string `json:"new_name"`
+		BasePath    string `json:"base_path"`
+		OrigName    string `json:"orig_name"`
+		NewName     string `json:"new_name"`
+		SyncNodeIds []int  `json:"sync_node_ids" gorm:"serializer:json"`
 	}
 	if !api.BindAndValid(c, &json) {
 		return
 	}
-	if json.OrigName == json.OrigName {
+	logger.Debug(json)
+	if json.OrigName == json.NewName {
 		c.JSON(http.StatusOK, gin.H{
 			"message": "ok",
 		})
@@ -55,11 +59,36 @@ func Rename(c *gin.Context) {
 		return
 	}
 
+	// update ChatGPT records
+	g := query.ChatGPTLog
+	q := query.Config
+	cfg, err := q.Where(q.Filepath.Eq(origFullPath)).FirstOrInit()
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
 	if !stat.IsDir() {
-		// update ChatGPT records
-		g := query.ChatGPTLog
 		_, _ = g.Where(g.Name.Eq(newFullPath)).Delete()
 		_, _ = g.Where(g.Name.Eq(origFullPath)).Update(g.Name, newFullPath)
+		// for file, the sync policy for this file is used
+		json.SyncNodeIds = cfg.SyncNodeIds
+	} else {
+		// is directory, update all records under the directory
+		_, _ = g.Where(g.Name.Like(origFullPath+"%")).Update(g.Name, g.Name.Replace(origFullPath, newFullPath))
+	}
+
+	_, err = q.Where(q.Filepath.Eq(origFullPath)).Update(q.Filepath, newFullPath)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	if len(json.SyncNodeIds) > 0 {
+		err = config.SyncRenameOnRemoteServer(origFullPath, newFullPath, json.SyncNodeIds)
+		if err != nil {
+			api.ErrHandler(c, err)
+			return
+		}
 	}
 
 	c.JSON(http.StatusOK, gin.H{

+ 10 - 3
api/config/router.go

@@ -1,6 +1,9 @@
 package config
 
-import "github.com/gin-gonic/gin"
+import (
+	"github.com/0xJacky/Nginx-UI/internal/middleware"
+	"github.com/gin-gonic/gin"
+)
 
 func InitRouter(r *gin.RouterGroup) {
 	r.GET("config_base_path", GetBasePath)
@@ -9,6 +12,10 @@ func InitRouter(r *gin.RouterGroup) {
 	r.GET("config/*name", GetConfig)
 	r.POST("config", AddConfig)
 	r.POST("config/*name", EditConfig)
-	r.POST("config_mkdir", Mkdir)
-	r.POST("config_rename", Rename)
+
+	o := r.Group("", middleware.RequireSecureSession())
+	{
+		o.POST("config_mkdir", Mkdir)
+		o.POST("config_rename", Rename)
+	}
 }

+ 7 - 0
api/user/otp.go

@@ -170,6 +170,13 @@ func OTPStatus(c *gin.Context) {
 	})
 }
 
+func SecureSessionStatus(c *gin.Context) {
+	// if you can visit this endpoint, you are already in a secure session
+	c.JSON(http.StatusOK, gin.H{
+		"status": true,
+	})
+}
+
 func StartSecure2FASession(c *gin.Context) {
 	var json struct {
 		OTP          string `json:"otp"`

+ 7 - 1
api/user/router.go

@@ -1,6 +1,9 @@
 package user
 
-import "github.com/gin-gonic/gin"
+import (
+    "github.com/0xJacky/Nginx-UI/internal/middleware"
+    "github.com/gin-gonic/gin"
+)
 
 func InitAuthRouter(r *gin.RouterGroup) {
 	r.POST("/login", Login)
@@ -23,5 +26,8 @@ func InitUserRouter(r *gin.RouterGroup) {
 	r.GET("/otp_secret", GenerateTOTP)
 	r.POST("/otp_enroll", EnrollTOTP)
 	r.POST("/otp_reset", ResetOTP)
+
+    r.GET("/otp_secure_session_status",
+        middleware.RequireSecureSession(), SecureSessionStatus)
 	r.POST("/otp_secure_session", StartSecure2FASession)
 }

+ 9 - 2
app/src/api/config.ts

@@ -8,6 +8,8 @@ export interface Config {
   chatgpt_messages: ChatComplicationMessage[]
   filepath: string
   modified_at: string
+  sync_node_ids?: number[]
+  sync_overwrite?: false
 }
 
 class ConfigCurd extends Curd<Config> {
@@ -23,8 +25,13 @@ class ConfigCurd extends Curd<Config> {
     return http.post('/config_mkdir', { base_path: basePath, folder_name: name })
   }
 
-  rename(basePath: string, origName: string, newName: string) {
-    return http.post('/config_rename', { base_path: basePath, orig_name: origName, new_name: newName })
+  rename(basePath: string, origName: string, newName: string, syncNodeIds: number[]) {
+    return http.post('/config_rename', {
+      base_path: basePath,
+      orig_name: origName,
+      new_name: newName,
+      sync_node_ids: syncNodeIds,
+    })
   }
 }
 

+ 3 - 0
app/src/api/otp.ts

@@ -24,6 +24,9 @@ const otp = {
       recovery_code,
     })
   },
+  secure_session_status() {
+    return http.get('/otp_secure_session_status')
+  },
 }
 
 export default otp

+ 18 - 0
app/src/components/Notification/cert.ts

@@ -0,0 +1,18 @@
+export function syncCertificateSuccess(text: string) {
+  const data = JSON.parse(text)
+
+  return $gettext('Sync Certificate %{cert_name} to %{env_name} successfully',
+    { cert_name: data.cert_name, env_name: data.env_name })
+}
+
+export function syncCertificateError(text: string) {
+  const data = JSON.parse(text)
+
+  if (data.status_code === 404) {
+    return $gettext('Sync Certificate %{cert_name} to %{env_name} failed, please upgrade the remote Nginx UI to the latest version',
+      { cert_name: data.cert_name, env_name: data.env_name }, true)
+  }
+
+  return $gettext('Sync Certificate %{cert_name} to %{env_name} failed, response: %{resp}',
+    { cert_name: data.cert_name, env_name: data.env_name, resp: data.resp_body }, true)
+}

+ 37 - 0
app/src/components/Notification/config.ts

@@ -0,0 +1,37 @@
+export function syncConfigSuccess(text: string) {
+  const data = JSON.parse(text)
+
+  return $gettext('Sync Config %{config_name} to %{env_name} successfully',
+    { config_name: data.config_name, env_name: data.env_name })
+}
+
+export function syncConfigError(text: string) {
+  const data = JSON.parse(text)
+
+  if (data.status_code === 404) {
+    return $gettext('Sync config %{cert_name} to %{env_name} failed, please upgrade the remote Nginx UI to the latest version',
+      { config_name: data.config_name, env_name: data.env_name }, true)
+  }
+
+  return $gettext('Sync config %{config_name} to %{env_name} failed, response: %{resp}',
+    { cert_name: data.cert_name, env_name: data.env_name, resp: data.resp_body }, true)
+}
+
+export function syncRenameConfigSuccess(text: string) {
+  const data = JSON.parse(text)
+
+  return $gettext('Rename %{orig_path} to %{new_path} on %{env_name} successfully',
+    { orig_path: data.orig_path, new_path: data.orig_path, env_name: data.env_name })
+}
+
+export function syncRenameConfigError(text: string) {
+  const data = JSON.parse(text)
+
+  if (data.status_code === 404) {
+    return $gettext('Rename %{orig_path} to %{new_path} on %{env_name} failed, please upgrade the remote Nginx UI to the latest version',
+      { orig_path: data.orig_path, new_path: data.orig_path, env_name: data.env_name }, true)
+  }
+
+  return $gettext('Rename %{orig_path} to %{new_path} on %{env_name} failed, response: %{resp}',
+    { orig_path: data.orig_path, new_path: data.orig_path, resp: data.resp_body, env_name: data.env_name }, true)
+}

+ 15 - 19
app/src/components/Notification/detailRender.ts

@@ -1,4 +1,11 @@
 import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import { syncCertificateError, syncCertificateSuccess } from '@/components/Notification/cert'
+import {
+  syncConfigError,
+  syncConfigSuccess,
+  syncRenameConfigError,
+  syncRenameConfigSuccess,
+} from '@/components/Notification/config'
 
 export const detailRender = (args: customRender) => {
   switch (args.record.title) {
@@ -6,26 +13,15 @@ export const detailRender = (args: customRender) => {
       return syncCertificateSuccess(args.text)
     case 'Sync Certificate Error':
       return syncCertificateError(args.text)
+    case 'Sync Rename Configuration Success':
+      return syncRenameConfigSuccess(args.text)
+    case 'Sync Rename Configuration Error':
+      return syncRenameConfigError(args.text)
+    case 'Sync Configuration Success':
+      return syncConfigSuccess(args.text)
+    case 'Sync Configuration Error':
+      return syncConfigError(args.text)
     default:
       return args.text
   }
 }
-
-function syncCertificateSuccess(text: string) {
-  const data = JSON.parse(text)
-
-  return $gettext('Sync Certificate %{cert_name} to %{env_name} successfully',
-    { cert_name: data.cert_name, env_name: data.env_name })
-}
-
-function syncCertificateError(text: string) {
-  const data = JSON.parse(text)
-
-  if (data.status_code === 404) {
-    return $gettext('Sync Certificate %{cert_name} to %{env_name} failed, please upgrade the remote Nginx UI to the latest version',
-      { cert_name: data.cert_name, env_name: data.env_name }, true)
-  }
-
-  return $gettext('Sync Certificate %{cert_name} to %{env_name} failed, response: %{resp}',
-    { cert_name: data.cert_name, env_name: data.env_name, resp: data.resp_body }, true)
-}

+ 56 - 57
app/src/components/OTP/useOTPModal.ts

@@ -5,11 +5,6 @@ import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
 import otp from '@/api/otp'
 import { useUserStore } from '@/pinia'
 
-export interface OTPModalProps {
-  onOk?: (secureSessionId: string) => void
-  onCancel?: () => void
-}
-
 const useOTPModal = () => {
   const refOTPAuthorization = ref<typeof OTPAuthorization>()
   const randomId = Math.random().toString(36).substring(2, 8)
@@ -26,68 +21,72 @@ const useOTPModal = () => {
     document.head.appendChild(style)
   }
 
-  const open = async ({ onOk, onCancel }: OTPModalProps) => {
+  const open = async (): Promise<string> => {
     const { status } = await otp.status()
-    if (!status) {
-      onOk?.('')
 
-      return
-    }
+    return new Promise((resolve, reject) => {
+      if (!status) {
+        resolve('')
 
-    const cookies = useCookies(['nginx-ui-2fa'])
-    const ssid = cookies.get('secure_session_id')
-    if (ssid) {
-      onOk?.(ssid)
-      secureSessionId.value = ssid
+        return
+      }
 
-      return
-    }
+      const cookies = useCookies(['nginx-ui-2fa'])
+      const ssid = cookies.get('secure_session_id')
+      if (ssid) {
+        resolve(ssid)
+        secureSessionId.value = ssid
 
-    injectStyles()
-    let container: HTMLDivElement | null = document.createElement('div')
-    document.body.appendChild(container)
+        return
+      }
 
-    const close = () => {
-      render(null, container!)
-      document.body.removeChild(container!)
-      container = null
-    }
+      injectStyles()
+      let container: HTMLDivElement | null = document.createElement('div')
+      document.body.appendChild(container)
 
-    const verify = (passcode: string, recovery: string) => {
-      otp.start_secure_session(passcode, recovery).then(r => {
-        cookies.set('secure_session_id', r.session_id, { maxAge: 60 * 3 })
-        onOk?.(r.session_id)
-        close()
-        secureSessionId.value = r.session_id
-      }).catch(async () => {
-        refOTPAuthorization.value?.clearInput()
-        await message.error($gettext('Invalid passcode or recovery code'))
-      })
-    }
+      const close = () => {
+        render(null, container!)
+        document.body.removeChild(container!)
+        container = null
+      }
 
-    const vnode = createVNode(Modal, {
-      open: true,
-      title: $gettext('Two-factor authentication required'),
-      centered: true,
-      maskClosable: false,
-      class: randomId,
-      footer: false,
-      onCancel: () => {
-        close()
-        onCancel?.()
-      },
-    }, {
-      default: () => h(
-        OTPAuthorization,
-        {
-          ref: refOTPAuthorization,
-          class: 'mt-3',
-          onOnSubmit: verify,
+      const verify = (passcode: string, recovery: string) => {
+        otp.start_secure_session(passcode, recovery).then(r => {
+          cookies.set('secure_session_id', r.session_id, { maxAge: 60 * 3 })
+          resolve(r.session_id)
+          close()
+          secureSessionId.value = r.session_id
+        }).catch(async () => {
+          refOTPAuthorization.value?.clearInput()
+          await message.error($gettext('Invalid passcode or recovery code'))
+        })
+      }
+
+      const vnode = createVNode(Modal, {
+        open: true,
+        title: $gettext('Two-factor authentication required'),
+        centered: true,
+        maskClosable: false,
+        class: randomId,
+        footer: false,
+        onCancel: () => {
+          close()
+          // eslint-disable-next-line prefer-promise-reject-errors
+          reject()
         },
-      ),
-    })
+      }, {
+        default: () => h(
+          OTPAuthorization,
+          {
+            ref: refOTPAuthorization,
+            class: 'mt-3',
+            onOnSubmit: verify,
+          },
+        ),
+      })
 
-    render(vnode, container)
+      render(vnode, container!)
+    })
   }
 
   return { open }

+ 17 - 11
app/src/language/en/app.po

@@ -194,9 +194,9 @@ msgid "Auto-renewal enabled for %{name}"
 msgstr "Auto-renewal enabled for %{name}"
 
 #: src/views/certificate/CertificateEditor.vue:247
-#: src/views/config/Config.vue:143 src/views/config/ConfigEditor.vue:196
-#: src/views/domain/DomainEdit.vue:253 src/views/nginx_log/NginxLog.vue:168
-#: src/views/stream/StreamEdit.vue:245
+#: src/views/config/ConfigEditor.vue:196 src/views/config/ConfigList.vue:173
+#: src/views/config/ConfigList.vue:99 src/views/domain/DomainEdit.vue:253
+#: src/views/nginx_log/NginxLog.vue:168 src/views/stream/StreamEdit.vue:245
 msgid "Back"
 msgstr "Back"
 
@@ -369,7 +369,7 @@ msgstr ""
 msgid "Configuration Name"
 msgstr "Configuration Name"
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgstr "Configurations"
 
@@ -420,12 +420,12 @@ msgstr "Created at"
 msgid "Create Another"
 msgstr "Create Another"
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 msgid "Create File"
 msgstr "Created at"
 
-#: src/views/config/components/Mkdir.vue:50 src/views/config/Config.vue:100
+#: src/views/config/components/Mkdir.vue:50 src/views/config/ConfigList.vue:116
 #, fuzzy
 msgid "Create Folder"
 msgstr "Create Another"
@@ -474,8 +474,8 @@ msgid ""
 "indicator."
 msgstr ""
 
-#: src/routes/index.ts:39 src/views/config/Config.vue:57
-#: src/views/config/ConfigEditor.vue:118 src/views/config/ConfigEditor.vue:79
+#: src/routes/index.ts:39 src/views/config/ConfigEditor.vue:118
+#: src/views/config/ConfigEditor.vue:79 src/views/config/ConfigList.vue:57
 msgid "Dashboard"
 msgstr "Dashboard"
 
@@ -803,6 +803,10 @@ msgstr "Enabled successfully"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "Encrypt website with Let's Encrypt"
 
+#: src/views/config/ConfigList.vue:151
+msgid "Enter"
+msgstr ""
+
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgstr ""
@@ -1191,8 +1195,8 @@ msgstr ""
 "Make sure you have configured a reverse proxy for .well-known directory to "
 "HTTPChallengePort (default: 9180) before getting the certificate."
 
-#: src/routes/index.ts:102 src/views/config/Config.vue:62
-#: src/views/config/ConfigEditor.vue:123 src/views/config/ConfigEditor.vue:84
+#: src/routes/index.ts:102 src/views/config/ConfigEditor.vue:123
+#: src/views/config/ConfigEditor.vue:84 src/views/config/ConfigList.vue:62
 msgid "Manage Configs"
 msgstr "Manage Configs"
 
@@ -1239,6 +1243,7 @@ msgstr "Advance Mode"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 #, fuzzy
 msgid "Modify"
 msgstr "Modify Config"
@@ -1703,7 +1708,8 @@ msgstr "Saved successfully"
 msgid "Removed successfully"
 msgstr "Saved successfully"
 
-#: src/views/config/components/Rename.vue:52 src/views/config/Config.vue:130
+#: src/views/config/components/Rename.vue:52
+#: src/views/config/ConfigList.vue:159
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 #, fuzzy
 msgid "Rename"

+ 17 - 11
app/src/language/es/app.po

@@ -194,9 +194,9 @@ msgid "Auto-renewal enabled for %{name}"
 msgstr "Renovación automática habilitada por %{name}"
 
 #: src/views/certificate/CertificateEditor.vue:247
-#: src/views/config/Config.vue:143 src/views/config/ConfigEditor.vue:196
-#: src/views/domain/DomainEdit.vue:253 src/views/nginx_log/NginxLog.vue:168
-#: src/views/stream/StreamEdit.vue:245
+#: src/views/config/ConfigEditor.vue:196 src/views/config/ConfigList.vue:173
+#: src/views/config/ConfigList.vue:99 src/views/domain/DomainEdit.vue:253
+#: src/views/nginx_log/NginxLog.vue:168 src/views/stream/StreamEdit.vue:245
 msgid "Back"
 msgstr "Volver"
 
@@ -362,7 +362,7 @@ msgstr "El archivo de configuración se probó exitosamente"
 msgid "Configuration Name"
 msgstr "Nombre de la configuración"
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgstr "Configuraciones"
 
@@ -412,12 +412,12 @@ msgstr "Crear"
 msgid "Create Another"
 msgstr "Crear otro"
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 msgid "Create File"
 msgstr "Crear"
 
-#: src/views/config/components/Mkdir.vue:50 src/views/config/Config.vue:100
+#: src/views/config/components/Mkdir.vue:50 src/views/config/ConfigList.vue:116
 #, fuzzy
 msgid "Create Folder"
 msgstr "Crear otro"
@@ -466,8 +466,8 @@ msgid ""
 "indicator."
 msgstr ""
 
-#: src/routes/index.ts:39 src/views/config/Config.vue:57
-#: src/views/config/ConfigEditor.vue:118 src/views/config/ConfigEditor.vue:79
+#: src/routes/index.ts:39 src/views/config/ConfigEditor.vue:118
+#: src/views/config/ConfigEditor.vue:79 src/views/config/ConfigList.vue:57
 msgid "Dashboard"
 msgstr "Panel"
 
@@ -778,6 +778,10 @@ msgstr "Habilitado con éxito"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "Encriptar sitio web con Let's Encrypt"
 
+#: src/views/config/ConfigList.vue:151
+msgid "Enter"
+msgstr ""
+
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgstr "Entorno"
@@ -1152,8 +1156,8 @@ msgstr ""
 "Asegúrese de haber configurado un proxy reverso para el directorio .well-"
 "known en HTTPChallengePort antes de obtener el certificado."
 
-#: src/routes/index.ts:102 src/views/config/Config.vue:62
-#: src/views/config/ConfigEditor.vue:123 src/views/config/ConfigEditor.vue:84
+#: src/routes/index.ts:102 src/views/config/ConfigEditor.vue:123
+#: src/views/config/ConfigEditor.vue:84 src/views/config/ConfigList.vue:62
 msgid "Manage Configs"
 msgstr "Administrar configuraciones"
 
@@ -1198,6 +1202,7 @@ msgstr "Modo de ejecución"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 msgid "Modify"
 msgstr "Modificar"
 
@@ -1660,7 +1665,8 @@ msgstr "Eliminado con éxito"
 msgid "Removed successfully"
 msgstr "Eliminado con éxito"
 
-#: src/views/config/components/Rename.vue:52 src/views/config/Config.vue:130
+#: src/views/config/components/Rename.vue:52
+#: src/views/config/ConfigList.vue:159
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 msgid "Rename"
 msgstr "Renombrar"

+ 17 - 11
app/src/language/fr_FR/app.po

@@ -197,9 +197,9 @@ msgid "Auto-renewal enabled for %{name}"
 msgstr "Renouvellement automatique activé pour %{name}"
 
 #: src/views/certificate/CertificateEditor.vue:247
-#: src/views/config/Config.vue:143 src/views/config/ConfigEditor.vue:196
-#: src/views/domain/DomainEdit.vue:253 src/views/nginx_log/NginxLog.vue:168
-#: src/views/stream/StreamEdit.vue:245
+#: src/views/config/ConfigEditor.vue:196 src/views/config/ConfigList.vue:173
+#: src/views/config/ConfigList.vue:99 src/views/domain/DomainEdit.vue:253
+#: src/views/nginx_log/NginxLog.vue:168 src/views/stream/StreamEdit.vue:245
 msgid "Back"
 msgstr "Retour"
 
@@ -369,7 +369,7 @@ msgstr "Le fichier de configuration est testé avec succès"
 msgid "Configuration Name"
 msgstr "Nom de la configuration"
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgstr "Configurations"
 
@@ -420,12 +420,12 @@ msgstr "Créé le"
 msgid "Create Another"
 msgstr "Créer un autre"
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 msgid "Create File"
 msgstr "Créé le"
 
-#: src/views/config/components/Mkdir.vue:50 src/views/config/Config.vue:100
+#: src/views/config/components/Mkdir.vue:50 src/views/config/ConfigList.vue:116
 #, fuzzy
 msgid "Create Folder"
 msgstr "Créer un autre"
@@ -474,8 +474,8 @@ msgid ""
 "indicator."
 msgstr ""
 
-#: src/routes/index.ts:39 src/views/config/Config.vue:57
-#: src/views/config/ConfigEditor.vue:118 src/views/config/ConfigEditor.vue:79
+#: src/routes/index.ts:39 src/views/config/ConfigEditor.vue:118
+#: src/views/config/ConfigEditor.vue:79 src/views/config/ConfigList.vue:57
 msgid "Dashboard"
 msgstr "Dashboard"
 
@@ -803,6 +803,10 @@ msgstr "Activé avec succès"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "Crypter le site Web avec Let's Encrypt"
 
+#: src/views/config/ConfigList.vue:151
+msgid "Enter"
+msgstr ""
+
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgstr ""
@@ -1193,8 +1197,8 @@ msgstr ""
 "Assurez vous d'avoir configuré un reverse proxy pour le répertoire .well-"
 "known vers HTTPChallengePort avant d'obtenir le certificat."
 
-#: src/routes/index.ts:102 src/views/config/Config.vue:62
-#: src/views/config/ConfigEditor.vue:123 src/views/config/ConfigEditor.vue:84
+#: src/routes/index.ts:102 src/views/config/ConfigEditor.vue:123
+#: src/views/config/ConfigEditor.vue:84 src/views/config/ConfigList.vue:62
 msgid "Manage Configs"
 msgstr "Gérer les configurations"
 
@@ -1241,6 +1245,7 @@ msgstr "Mode d'exécution"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 msgid "Modify"
 msgstr "Modifier"
 
@@ -1710,7 +1715,8 @@ msgstr "Enregistré avec succès"
 msgid "Removed successfully"
 msgstr "Enregistré avec succès"
 
-#: src/views/config/components/Rename.vue:52 src/views/config/Config.vue:130
+#: src/views/config/components/Rename.vue:52
+#: src/views/config/ConfigList.vue:159
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 #, fuzzy
 msgid "Rename"

+ 18 - 11
app/src/language/ko_KR/app.po

@@ -193,9 +193,9 @@ msgid "Auto-renewal enabled for %{name}"
 msgstr "%{name}에 대한 자동 갱신 활성화됨"
 
 #: src/views/certificate/CertificateEditor.vue:247
-#: src/views/config/Config.vue:143 src/views/config/ConfigEditor.vue:196
-#: src/views/domain/DomainEdit.vue:253 src/views/nginx_log/NginxLog.vue:168
-#: src/views/stream/StreamEdit.vue:245
+#: src/views/config/ConfigEditor.vue:196 src/views/config/ConfigList.vue:173
+#: src/views/config/ConfigList.vue:99 src/views/domain/DomainEdit.vue:253
+#: src/views/nginx_log/NginxLog.vue:168 src/views/stream/StreamEdit.vue:245
 msgid "Back"
 msgstr "뒤로"
 
@@ -360,7 +360,7 @@ msgstr "구성 파일 테스트 성공"
 msgid "Configuration Name"
 msgstr "구성 이름"
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgstr "구성들"
 
@@ -410,12 +410,12 @@ msgstr "생성"
 msgid "Create Another"
 msgstr "다른 것 생성하기"
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 msgid "Create File"
 msgstr "생성"
 
-#: src/views/config/components/Mkdir.vue:50 src/views/config/Config.vue:100
+#: src/views/config/components/Mkdir.vue:50 src/views/config/ConfigList.vue:116
 #, fuzzy
 msgid "Create Folder"
 msgstr "다른 것 생성하기"
@@ -464,8 +464,8 @@ msgid ""
 "indicator."
 msgstr ""
 
-#: src/routes/index.ts:39 src/views/config/Config.vue:57
-#: src/views/config/ConfigEditor.vue:118 src/views/config/ConfigEditor.vue:79
+#: src/routes/index.ts:39 src/views/config/ConfigEditor.vue:118
+#: src/views/config/ConfigEditor.vue:79 src/views/config/ConfigList.vue:57
 msgid "Dashboard"
 msgstr "대시보드"
 
@@ -776,6 +776,11 @@ msgstr "성공적으로 활성화됨"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "Let's Encrypt로 웹사이트 암호화"
 
+#: src/views/config/ConfigList.vue:151
+#, fuzzy
+msgid "Enter"
+msgstr "간격"
+
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgstr "환경"
@@ -1170,8 +1175,8 @@ msgstr ""
 "인증서를 획득하기 전에 .well-known 디렉토리에 대한역방향 프록시를 "
 "HTTPChallengePort(기본값: 9180)로 구성했는지 확인하세요."
 
-#: src/routes/index.ts:102 src/views/config/Config.vue:62
-#: src/views/config/ConfigEditor.vue:123 src/views/config/ConfigEditor.vue:84
+#: src/routes/index.ts:102 src/views/config/ConfigEditor.vue:123
+#: src/views/config/ConfigEditor.vue:84 src/views/config/ConfigList.vue:62
 msgid "Manage Configs"
 msgstr "구성 관리"
 
@@ -1218,6 +1223,7 @@ msgstr "실행 모드"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 #, fuzzy
 msgid "Modify"
 msgstr "설정 수정"
@@ -1686,7 +1692,8 @@ msgstr "성공적으로 제거됨"
 msgid "Removed successfully"
 msgstr "성공적으로 제거됨"
 
-#: src/views/config/components/Rename.vue:52 src/views/config/Config.vue:130
+#: src/views/config/components/Rename.vue:52
+#: src/views/config/ConfigList.vue:159
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 #, fuzzy
 msgid "Rename"

+ 13 - 7
app/src/language/messages.pot

@@ -181,8 +181,9 @@ msgid "Auto-renewal enabled for %{name}"
 msgstr ""
 
 #: src/views/certificate/CertificateEditor.vue:247
-#: src/views/config/Config.vue:143
 #: src/views/config/ConfigEditor.vue:196
+#: src/views/config/ConfigList.vue:173
+#: src/views/config/ConfigList.vue:99
 #: src/views/domain/DomainEdit.vue:253
 #: src/views/nginx_log/NginxLog.vue:168
 #: src/views/stream/StreamEdit.vue:245
@@ -347,7 +348,7 @@ msgstr ""
 msgid "Configuration Name"
 msgstr ""
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgstr ""
 
@@ -397,12 +398,12 @@ msgstr ""
 msgid "Create Another"
 msgstr ""
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 msgid "Create File"
 msgstr ""
 
 #: src/views/config/components/Mkdir.vue:50
-#: src/views/config/Config.vue:100
+#: src/views/config/ConfigList.vue:116
 msgid "Create Folder"
 msgstr ""
 
@@ -449,9 +450,9 @@ msgid "Customize the name of local server to be displayed in the environment ind
 msgstr ""
 
 #: src/routes/index.ts:39
-#: src/views/config/Config.vue:57
 #: src/views/config/ConfigEditor.vue:118
 #: src/views/config/ConfigEditor.vue:79
+#: src/views/config/ConfigList.vue:57
 msgid "Dashboard"
 msgstr ""
 
@@ -768,6 +769,10 @@ msgstr ""
 msgid "Encrypt website with Let's Encrypt"
 msgstr ""
 
+#: src/views/config/ConfigList.vue:151
+msgid "Enter"
+msgstr ""
+
 #: src/routes/index.ts:228
 #: src/views/environment/Environment.vue:34
 msgid "Environment"
@@ -1129,9 +1134,9 @@ msgid "Make sure you have configured a reverse proxy for .well-known directory t
 msgstr ""
 
 #: src/routes/index.ts:102
-#: src/views/config/Config.vue:62
 #: src/views/config/ConfigEditor.vue:123
 #: src/views/config/ConfigEditor.vue:84
+#: src/views/config/ConfigList.vue:62
 msgid "Manage Configs"
 msgstr ""
 
@@ -1178,6 +1183,7 @@ msgstr ""
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 msgid "Modify"
 msgstr ""
 
@@ -1623,7 +1629,7 @@ msgid "Removed successfully"
 msgstr ""
 
 #: src/views/config/components/Rename.vue:52
-#: src/views/config/Config.vue:130
+#: src/views/config/ConfigList.vue:159
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 msgid "Rename"
 msgstr ""

+ 17 - 11
app/src/language/ru_RU/app.po

@@ -195,9 +195,9 @@ msgid "Auto-renewal enabled for %{name}"
 msgstr "Автообновление включено для %{name}"
 
 #: src/views/certificate/CertificateEditor.vue:247
-#: src/views/config/Config.vue:143 src/views/config/ConfigEditor.vue:196
-#: src/views/domain/DomainEdit.vue:253 src/views/nginx_log/NginxLog.vue:168
-#: src/views/stream/StreamEdit.vue:245
+#: src/views/config/ConfigEditor.vue:196 src/views/config/ConfigList.vue:173
+#: src/views/config/ConfigList.vue:99 src/views/domain/DomainEdit.vue:253
+#: src/views/nginx_log/NginxLog.vue:168 src/views/stream/StreamEdit.vue:245
 msgid "Back"
 msgstr "Назад"
 
@@ -371,7 +371,7 @@ msgstr "Проверка конфигурации успешна"
 msgid "Configuration Name"
 msgstr "Название конфигурации"
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgstr "Конфигурации"
 
@@ -422,12 +422,12 @@ msgstr "Создан в"
 msgid "Create Another"
 msgstr "Создать еще"
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 msgid "Create File"
 msgstr "Создан в"
 
-#: src/views/config/components/Mkdir.vue:50 src/views/config/Config.vue:100
+#: src/views/config/components/Mkdir.vue:50 src/views/config/ConfigList.vue:116
 #, fuzzy
 msgid "Create Folder"
 msgstr "Создать еще"
@@ -476,8 +476,8 @@ msgid ""
 "indicator."
 msgstr ""
 
-#: src/routes/index.ts:39 src/views/config/Config.vue:57
-#: src/views/config/ConfigEditor.vue:118 src/views/config/ConfigEditor.vue:79
+#: src/routes/index.ts:39 src/views/config/ConfigEditor.vue:118
+#: src/views/config/ConfigEditor.vue:79 src/views/config/ConfigList.vue:57
 msgid "Dashboard"
 msgstr "Доска"
 
@@ -807,6 +807,10 @@ msgstr "Активировано успешно"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "Использовать для сайта Let's Encrypt"
 
+#: src/views/config/ConfigList.vue:151
+msgid "Enter"
+msgstr ""
+
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgstr "Окружение"
@@ -1199,8 +1203,8 @@ msgstr ""
 "Убедитесь, что вы настроили обратный прокси-сервер для каталога .well-known "
 "на HTTPChallengePort перед получением сертификата»."
 
-#: src/routes/index.ts:102 src/views/config/Config.vue:62
-#: src/views/config/ConfigEditor.vue:123 src/views/config/ConfigEditor.vue:84
+#: src/routes/index.ts:102 src/views/config/ConfigEditor.vue:123
+#: src/views/config/ConfigEditor.vue:84 src/views/config/ConfigList.vue:62
 msgid "Manage Configs"
 msgstr "Конфигурации"
 
@@ -1247,6 +1251,7 @@ msgstr "Расширенный режим"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 #, fuzzy
 msgid "Modify"
 msgstr "Изменить"
@@ -1716,7 +1721,8 @@ msgstr "Успешно сохранено"
 msgid "Removed successfully"
 msgstr "Успешно сохранено"
 
-#: src/views/config/components/Rename.vue:52 src/views/config/Config.vue:130
+#: src/views/config/components/Rename.vue:52
+#: src/views/config/ConfigList.vue:159
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 #, fuzzy
 msgid "Rename"

+ 17 - 11
app/src/language/vi_VN/app.po

@@ -195,9 +195,9 @@ msgid "Auto-renewal enabled for %{name}"
 msgstr "Đã bật tự động gia hạn SSL cho %{name}"
 
 #: src/views/certificate/CertificateEditor.vue:247
-#: src/views/config/Config.vue:143 src/views/config/ConfigEditor.vue:196
-#: src/views/domain/DomainEdit.vue:253 src/views/nginx_log/NginxLog.vue:168
-#: src/views/stream/StreamEdit.vue:245
+#: src/views/config/ConfigEditor.vue:196 src/views/config/ConfigList.vue:173
+#: src/views/config/ConfigList.vue:99 src/views/domain/DomainEdit.vue:253
+#: src/views/nginx_log/NginxLog.vue:168 src/views/stream/StreamEdit.vue:245
 msgid "Back"
 msgstr "Quay lại"
 
@@ -371,7 +371,7 @@ msgstr "Tệp cấu hình được kiểm tra thành công"
 msgid "Configuration Name"
 msgstr "Tên cấu hình"
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgstr "Cấu hình"
 
@@ -422,12 +422,12 @@ msgstr "Ngày tạo"
 msgid "Create Another"
 msgstr "Tạo thêm"
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 msgid "Create File"
 msgstr "Ngày tạo"
 
-#: src/views/config/components/Mkdir.vue:50 src/views/config/Config.vue:100
+#: src/views/config/components/Mkdir.vue:50 src/views/config/ConfigList.vue:116
 #, fuzzy
 msgid "Create Folder"
 msgstr "Tạo thêm"
@@ -476,8 +476,8 @@ msgid ""
 "indicator."
 msgstr ""
 
-#: src/routes/index.ts:39 src/views/config/Config.vue:57
-#: src/views/config/ConfigEditor.vue:118 src/views/config/ConfigEditor.vue:79
+#: src/routes/index.ts:39 src/views/config/ConfigEditor.vue:118
+#: src/views/config/ConfigEditor.vue:79 src/views/config/ConfigList.vue:57
 msgid "Dashboard"
 msgstr "Bảng điều khiển"
 
@@ -808,6 +808,10 @@ msgstr "Đã bật"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "Bảo mật trang web với Let's Encrypt"
 
+#: src/views/config/ConfigList.vue:151
+msgid "Enter"
+msgstr ""
+
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgstr "Environment"
@@ -1201,8 +1205,8 @@ msgstr ""
 "Đảm bảo rằng bạn đã định cấu hình proxy ngược (reverse proxy) thư mục .well-"
 "known tới HTTPChallengePort (default: 9180) trước khi ký chứng chỉ SSL."
 
-#: src/routes/index.ts:102 src/views/config/Config.vue:62
-#: src/views/config/ConfigEditor.vue:123 src/views/config/ConfigEditor.vue:84
+#: src/routes/index.ts:102 src/views/config/ConfigEditor.vue:123
+#: src/views/config/ConfigEditor.vue:84 src/views/config/ConfigList.vue:62
 msgid "Manage Configs"
 msgstr "Quản lý cấu hình"
 
@@ -1248,6 +1252,7 @@ msgstr "Run Mode"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 #, fuzzy
 msgid "Modify"
 msgstr "Sửa"
@@ -1718,7 +1723,8 @@ msgstr "Xoá thành công"
 msgid "Removed successfully"
 msgstr "Xoá thành công"
 
-#: src/views/config/components/Rename.vue:52 src/views/config/Config.vue:130
+#: src/views/config/components/Rename.vue:52
+#: src/views/config/ConfigList.vue:159
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 #, fuzzy
 msgid "Rename"

BIN
app/src/language/zh_CN/app.mo


+ 17 - 11
app/src/language/zh_CN/app.po

@@ -184,9 +184,9 @@ msgid "Auto-renewal enabled for %{name}"
 msgstr "成功启用 %{name} 自动续签"
 
 #: src/views/certificate/CertificateEditor.vue:247
-#: src/views/config/Config.vue:143 src/views/config/ConfigEditor.vue:196
-#: src/views/domain/DomainEdit.vue:253 src/views/nginx_log/NginxLog.vue:168
-#: src/views/stream/StreamEdit.vue:245
+#: src/views/config/ConfigEditor.vue:196 src/views/config/ConfigList.vue:173
+#: src/views/config/ConfigList.vue:99 src/views/domain/DomainEdit.vue:253
+#: src/views/nginx_log/NginxLog.vue:168 src/views/stream/StreamEdit.vue:245
 msgid "Back"
 msgstr "返回"
 
@@ -344,7 +344,7 @@ msgstr "配置文件测试成功"
 msgid "Configuration Name"
 msgstr "配置名称"
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgstr "配置"
 
@@ -394,11 +394,11 @@ msgstr "创建"
 msgid "Create Another"
 msgstr "再创建一个"
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 msgid "Create File"
 msgstr "创建文件"
 
-#: src/views/config/components/Mkdir.vue:50 src/views/config/Config.vue:100
+#: src/views/config/components/Mkdir.vue:50 src/views/config/ConfigList.vue:116
 msgid "Create Folder"
 msgstr "创建文件夹"
 
@@ -445,8 +445,8 @@ msgid ""
 "indicator."
 msgstr "自定义显示在环境指示器中的本地服务器名称。"
 
-#: src/routes/index.ts:39 src/views/config/Config.vue:57
-#: src/views/config/ConfigEditor.vue:118 src/views/config/ConfigEditor.vue:79
+#: src/routes/index.ts:39 src/views/config/ConfigEditor.vue:118
+#: src/views/config/ConfigEditor.vue:79 src/views/config/ConfigList.vue:57
 msgid "Dashboard"
 msgstr "仪表盘"
 
@@ -751,6 +751,10 @@ msgstr "启用成功"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "用 Let's Encrypt 对网站进行加密"
 
+#: src/views/config/ConfigList.vue:151
+msgid "Enter"
+msgstr "进入"
+
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgstr "环境"
@@ -1123,8 +1127,8 @@ msgstr ""
 "在获取签发证书前,请确保配置文件中已将 .well-known 目录反向代理到 "
 "HTTPChallengePort。"
 
-#: src/routes/index.ts:102 src/views/config/Config.vue:62
-#: src/views/config/ConfigEditor.vue:123 src/views/config/ConfigEditor.vue:84
+#: src/routes/index.ts:102 src/views/config/ConfigEditor.vue:123
+#: src/views/config/ConfigEditor.vue:84 src/views/config/ConfigList.vue:62
 msgid "Manage Configs"
 msgstr "配置管理"
 
@@ -1168,6 +1172,7 @@ msgstr "模型"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 msgid "Modify"
 msgstr "修改"
 
@@ -1612,7 +1617,8 @@ msgstr "移除成功"
 msgid "Removed successfully"
 msgstr "删除成功"
 
-#: src/views/config/components/Rename.vue:52 src/views/config/Config.vue:130
+#: src/views/config/components/Rename.vue:52
+#: src/views/config/ConfigList.vue:159
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 msgid "Rename"
 msgstr "重命名"

+ 17 - 11
app/src/language/zh_TW/app.po

@@ -197,9 +197,9 @@ msgid "Auto-renewal enabled for %{name}"
 msgstr "已啟用 %{name} 的自動續簽"
 
 #: src/views/certificate/CertificateEditor.vue:247
-#: src/views/config/Config.vue:143 src/views/config/ConfigEditor.vue:196
-#: src/views/domain/DomainEdit.vue:253 src/views/nginx_log/NginxLog.vue:168
-#: src/views/stream/StreamEdit.vue:245
+#: src/views/config/ConfigEditor.vue:196 src/views/config/ConfigList.vue:173
+#: src/views/config/ConfigList.vue:99 src/views/domain/DomainEdit.vue:253
+#: src/views/nginx_log/NginxLog.vue:168 src/views/stream/StreamEdit.vue:245
 msgid "Back"
 msgstr "返回"
 
@@ -366,7 +366,7 @@ msgstr "設定檔案測試成功"
 msgid "Configuration Name"
 msgstr "設定名稱"
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgstr "設定"
 
@@ -417,12 +417,12 @@ msgstr "建立時間"
 msgid "Create Another"
 msgstr "再建立一個"
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 msgid "Create File"
 msgstr "建立時間"
 
-#: src/views/config/components/Mkdir.vue:50 src/views/config/Config.vue:100
+#: src/views/config/components/Mkdir.vue:50 src/views/config/ConfigList.vue:116
 #, fuzzy
 msgid "Create Folder"
 msgstr "再建立一個"
@@ -471,8 +471,8 @@ msgid ""
 "indicator."
 msgstr ""
 
-#: src/routes/index.ts:39 src/views/config/Config.vue:57
-#: src/views/config/ConfigEditor.vue:118 src/views/config/ConfigEditor.vue:79
+#: src/routes/index.ts:39 src/views/config/ConfigEditor.vue:118
+#: src/views/config/ConfigEditor.vue:79 src/views/config/ConfigList.vue:57
 msgid "Dashboard"
 msgstr "儀表板"
 
@@ -788,6 +788,10 @@ msgstr "成功啟用"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "用 Let's Encrypt 對網站進行加密"
 
+#: src/views/config/ConfigList.vue:151
+msgid "Enter"
+msgstr ""
+
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgstr "環境"
@@ -1172,8 +1176,8 @@ msgid ""
 msgstr ""
 "在取得憑證前,請確保您已將 .well-known 目錄反向代理到 HTTPChallengePort。"
 
-#: src/routes/index.ts:102 src/views/config/Config.vue:62
-#: src/views/config/ConfigEditor.vue:123 src/views/config/ConfigEditor.vue:84
+#: src/routes/index.ts:102 src/views/config/ConfigEditor.vue:123
+#: src/views/config/ConfigEditor.vue:84 src/views/config/ConfigList.vue:62
 msgid "Manage Configs"
 msgstr "管理設定"
 
@@ -1220,6 +1224,7 @@ msgstr "執行模式"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 msgid "Modify"
 msgstr "修改"
 
@@ -1681,7 +1686,8 @@ msgstr "儲存成功"
 msgid "Removed successfully"
 msgstr "儲存成功"
 
-#: src/views/config/components/Rename.vue:52 src/views/config/Config.vue:130
+#: src/views/config/components/Rename.vue:52
+#: src/views/config/ConfigList.vue:159
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 #, fuzzy
 msgid "Rename"

+ 8 - 0
app/src/lib/http/index.ts

@@ -1,11 +1,13 @@
 import type { AxiosRequestConfig } from 'axios'
 import axios from 'axios'
+import { useCookies } from '@vueuse/integrations/useCookies'
 import { storeToRefs } from 'pinia'
 import NProgress from 'nprogress'
 import { useSettingsStore, useUserStore } from '@/pinia'
 import 'nprogress/nprogress.css'
 
 import router from '@/routes'
+import useOTPModal from '@/components/OTP/useOTPModal'
 
 const user = useUserStore()
 const settings = useSettingsStore()
@@ -58,8 +60,14 @@ instance.interceptors.response.use(
   },
   async error => {
     NProgress.done()
+
+    const otpModal = useOTPModal()
+    const cookies = useCookies(['nginx-ui-2fa'])
     switch (error.response.status) {
       case 401:
+        cookies.remove('secure_session_id')
+        await otpModal.open()
+        break
       case 403:
         user.logout()
         await router.push('/login')

+ 1 - 1
app/src/routes/index.ts

@@ -97,7 +97,7 @@ export const routes: RouteRecordRaw[] = [
       {
         path: 'config',
         name: 'Manage Configs',
-        component: () => import('@/views/config/Config.vue'),
+        component: () => import('@/views/config/ConfigList.vue'),
         meta: {
           name: () => $gettext('Manage Configs'),
           icon: FileOutlined,

+ 1 - 1
app/src/version.json

@@ -1 +1 @@
-{"version":"2.0.0-beta.28","build_id":147,"total_build":351}
+{"version":"2.0.0-beta.28","build_id":149,"total_build":353}

+ 49 - 2
app/src/views/config/ConfigEditor.vue

@@ -1,8 +1,10 @@
 <script setup lang="ts">
 import { message } from 'ant-design-vue'
 import type { Ref } from 'vue'
+import { InfoCircleOutlined } from '@ant-design/icons-vue'
 import { formatDateTime } from '@/lib/helper'
 import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
+import type { Config } from '@/api/config'
 import config from '@/api/config'
 import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
 import ngx from '@/api/ngx'
@@ -10,7 +12,10 @@ import InspectConfig from '@/views/config/InspectConfig.vue'
 import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
 import type { ChatComplicationMessage } from '@/api/openai'
 import { useBreadcrumbs } from '@/composables/useBreadcrumbs'
+import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
+import { useSettingsStore } from '@/pinia'
 
+const settings = useSettingsStore()
 const route = useRoute()
 const router = useRouter()
 const refForm = ref()
@@ -32,10 +37,12 @@ const data = ref({
   name: '',
   content: '',
   filepath: '',
-})
+  sync_node_ids: [] as number[],
+  sync_overwrite: false,
+} as Config)
 
 const historyChatgptRecord = ref([]) as Ref<ChatComplicationMessage[]>
-const activeKey = ref(['basic', 'chatgpt'])
+const activeKey = ref(['basic', 'deploy', 'chatgpt'])
 const modifiedAt = ref('')
 const nginxConfigBase = ref('')
 
@@ -145,6 +152,8 @@ function save() {
       filepath: data.value.filepath,
       new_filepath: newPath.value,
       content: data.value.content,
+      sync_node_ids: data.value.sync_node_ids,
+      sync_overwrite: data.value.sync_overwrite,
     }).then(r => {
       data.value.content = r.content
       message.success($gettext('Saved successfully'))
@@ -261,6 +270,29 @@ function goBack() {
               </AFormItem>
             </AForm>
           </ACollapsePanel>
+          <ACollapsePanel
+            v-if="!settings.is_remote"
+            key="deploy"
+            :header="$gettext('Deploy')"
+          >
+            <NodeSelector
+              v-model:target="data.sync_node_ids"
+              hidden-local
+            />
+            <div class="node-deploy-control">
+              <div class="overwrite">
+                <ACheckbox v-model:checked="data.sync_overwrite">
+                  {{ $gettext('Overwrite') }}
+                </ACheckbox>
+                <ATooltip placement="bottom">
+                  <template #title>
+                    {{ $gettext('Overwrite exist file') }}
+                  </template>
+                  <InfoCircleOutlined />
+                </ATooltip>
+              </div>
+            </div>
+          </ACollapsePanel>
           <ACollapsePanel
             key="chatgpt"
             header="ChatGPT"
@@ -295,4 +327,19 @@ function goBack() {
 :deep(.ant-collapse > .ant-collapse-item > .ant-collapse-header) {
   padding: 0 0 10px 0;
 }
+
+.overwrite {
+  margin-right: 15px;
+
+  span {
+    color: #9b9b9b;
+  }
+}
+
+.node-deploy-control {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 10px;
+  align-items: center;
+}
 </style>

+ 49 - 19
app/src/views/config/Config.vue → app/src/views/config/ConfigList.vue

@@ -90,14 +90,31 @@ const refRename = ref()
 <template>
   <ACard :title="$gettext('Configurations')">
     <template #extra>
-      <a
-        class="mr-4"
+      <AButton
+        v-if="basePath"
+        type="link"
+        size="small"
+        @click="goBack"
+      >
+        {{ $gettext('Back') }}
+      </AButton>
+      <AButton
+        type="link"
+        size="small"
         @click="router.push({
           path: '/config/add',
           query: { basePath: basePath || undefined },
         })"
-      >{{ $gettext('Create File') }}</a>
-      <a @click="() => refMkdir.open(basePath)">{{ $gettext('Create Folder') }}</a>
+      >
+        {{ $gettext('Create File') }}
+      </AButton>
+      <AButton
+        type="link"
+        size="small"
+        @click="() => refMkdir.open(basePath)"
+      >
+        {{ $gettext('Create Folder') }}
+      </AButton>
     </template>
     <InspectConfig ref="refInspectConfig" />
     <StdTable
@@ -110,24 +127,37 @@ const refRename = ref()
       row-key="name"
       :get-params="getParams"
       disable-query-params
-      @click-edit="(r, row) => {
-        if (!row.is_dir) {
-          $router.push({
-            path: `/config/${basePath}${r}/edit`,
-          })
-        }
-        else {
-          $router.push({
-            query: {
-              dir: basePath + r,
-            },
-          })
-        }
-      }"
+      disable-modify
     >
       <template #actions="{ record }">
+        <AButton
+          type="link"
+          size="small"
+          @click="() => {
+            if (!record.is_dir) {
+              $router.push({
+                path: `/config/${basePath}${record.name}/edit`,
+              })
+            }
+            else {
+              $router.push({
+                query: {
+                  dir: basePath + record.name,
+                },
+              })
+            }
+          }"
+        >
+          {{ $gettext('Modify') }}
+        </AButton>
         <ADivider type="vertical" />
-        <a @click="() => refRename.open(basePath, record.name)">{{ $gettext('Rename') }}</a>
+        <AButton
+          type="link"
+          size="small"
+          @click="() => refRename.open(basePath, record.name, record.is_dir)"
+        >
+          {{ $gettext('Rename') }}
+        </AButton>
       </template>
     </StdTable>
     <Mkdir

+ 8 - 10
app/src/views/config/components/Mkdir.vue

@@ -27,17 +27,15 @@ function ok() {
   refForm.value.validate().then(() => {
     const otpModal = useOTPModal()
 
-    otpModal.open({
-      onOk() {
-        config.mkdir(data.value.basePath, data.value.name).then(() => {
-          visible.value = false
+    otpModal.open().then(() => {
+      config.mkdir(data.value.basePath, data.value.name).then(() => {
+        visible.value = false
 
-          message.success($gettext('Created successfully'))
-          emit('created')
-        }).catch(e => {
-          message.error(`${$gettext('Server error')} ${e?.message}`)
-        })
-      },
+        message.success($gettext('Created successfully'))
+        emit('created')
+      }).catch(e => {
+        message.error(`${$gettext('Server error')} ${e?.message}`)
+      })
     })
   })
 }

+ 24 - 12
app/src/views/config/components/Rename.vue

@@ -2,22 +2,27 @@
 import { message } from 'ant-design-vue'
 import config from '@/api/config'
 import useOTPModal from '@/components/OTP/useOTPModal'
+import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
 
 const emit = defineEmits(['renamed'])
 const visible = ref(false)
+const isDirFlag = ref(false)
 
 const data = ref({
   basePath: '',
   orig_name: '',
   new_name: '',
+  sync_node_ids: [] as number[],
 })
 
 const refForm = ref()
-function open(basePath: string, origName: string) {
+
+function open(basePath: string, origName: string, isDir: boolean) {
   visible.value = true
   data.value.orig_name = origName
   data.value.new_name = origName
   data.value.basePath = basePath
+  isDirFlag.value = isDir
 }
 
 defineExpose({
@@ -26,20 +31,18 @@ defineExpose({
 
 function ok() {
   refForm.value.validate().then(() => {
-    const { basePath, orig_name, new_name } = data.value
+    const { basePath, orig_name, new_name, sync_node_ids } = data.value
 
     const otpModal = useOTPModal()
 
-    otpModal.open({
-      onOk() {
-        config.rename(basePath, orig_name, new_name).then(() => {
-          visible.value = false
-          message.success($gettext('Rename successfully'))
-          emit('renamed')
-        }).catch(e => {
-          message.error(`${$gettext('Server error')} ${e?.message}`)
-        })
-      },
+    otpModal.open().then(() => {
+      config.rename(basePath, orig_name, new_name, sync_node_ids).then(() => {
+        visible.value = false
+        message.success($gettext('Rename successfully'))
+        emit('renamed')
+      }).catch(e => {
+        message.error(`${$gettext('Server error')} ${e?.message}`)
+      })
     })
   })
 }
@@ -72,6 +75,15 @@ function ok() {
       >
         <AInput v-model:value="data.new_name" />
       </AFormItem>
+      <AFormItem
+        v-if="isDirFlag"
+        :label="$gettext('Sync')"
+      >
+        <NodeSelector
+          v-model:target="data.sync_node_ids"
+          hidden-local
+        />
+      </AFormItem>
     </AForm>
   </AModal>
 </template>

+ 22 - 22
app/src/views/pty/Terminal.vue

@@ -5,6 +5,7 @@ import { FitAddon } from '@xterm/addon-fit'
 import _ from 'lodash'
 import ws from '@/lib/websocket'
 import useOTPModal from '@/components/OTP/useOTPModal'
+import otp from '@/api/otp'
 
 let term: Terminal | null
 let ping: NodeJS.Timeout
@@ -14,30 +15,29 @@ const websocket = shallowRef()
 const lostConnection = ref(false)
 
 onMounted(() => {
+  otp.secure_session_status()
+
   const otpModal = useOTPModal()
 
-  otpModal.open({
-    onOk(secureSessionId: string) {
-      websocket.value = ws(`/api/pty?X-Secure-Session-ID=${secureSessionId}`, false)
-
-      nextTick(() => {
-        initTerm()
-        websocket.value.onmessage = wsOnMessage
-        websocket.value.onopen = wsOnOpen
-        websocket.value.onerror = () => {
-          lostConnection.value = true
-        }
-        websocket.value.onclose = () => {
-          lostConnection.value = true
-        }
-      })
-    },
-    onCancel() {
-      if (window.history.length > 1)
-        router.go(-1)
-      else
-        router.push('/')
-    },
+  otpModal.open().then(secureSessionId => {
+    websocket.value = ws(`/api/pty?X-Secure-Session-ID=${secureSessionId}`, false)
+
+    nextTick(() => {
+      initTerm()
+      websocket.value.onmessage = wsOnMessage
+      websocket.value.onopen = wsOnOpen
+      websocket.value.onerror = () => {
+        lostConnection.value = true
+      }
+      websocket.value.onclose = () => {
+        lostConnection.value = true
+      }
+    })
+  }).catch(() => {
+    if (window.history.length > 1)
+      router.go(-1)
+    else
+      router.push('/')
   })
 })
 

+ 1 - 1
app/version.json

@@ -1 +1 @@
-{"version":"2.0.0-beta.28","build_id":147,"total_build":351}
+{"version":"2.0.0-beta.28","build_id":149,"total_build":353}

+ 292 - 0
internal/config/sync.go

@@ -0,0 +1,292 @@
+package config
+
+import (
+	"bytes"
+	"crypto/tls"
+	"encoding/json"
+	"fmt"
+	"github.com/0xJacky/Nginx-UI/internal/helper"
+	"github.com/0xJacky/Nginx-UI/internal/logger"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/internal/notification"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/0xJacky/Nginx-UI/query"
+	"github.com/gin-gonic/gin"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+type SyncConfigPayload struct {
+	Name        string `json:"name"`
+	Filepath    string `json:"filepath"`
+	NewFilepath string `json:"new_filepath"`
+	Content     string `json:"content"`
+	Overwrite   bool   `json:"overwrite"`
+}
+
+func SyncToRemoteServer(c *model.Config, newFilepath string) (err error) {
+	if c.Filepath == "" || len(c.SyncNodeIds) == 0 {
+		return
+	}
+
+	nginxConfPath := nginx.GetConfPath()
+	if !helper.IsUnderDirectory(c.Filepath, nginxConfPath) {
+		return fmt.Errorf("config: %s is not under the nginx conf path: %s",
+			c.Filepath, nginxConfPath)
+	}
+
+	if newFilepath != "" && !helper.IsUnderDirectory(newFilepath, nginxConfPath) {
+		return fmt.Errorf("config: %s is not under the nginx conf path: %s",
+			c.Filepath, nginxConfPath)
+	}
+
+	currentPath := c.Filepath
+	if newFilepath != "" {
+		currentPath = newFilepath
+	}
+	configBytes, err := os.ReadFile(currentPath)
+	if err != nil {
+		return
+	}
+
+	payload := &SyncConfigPayload{
+		Name:        c.Name,
+		Filepath:    c.Filepath,
+		NewFilepath: newFilepath,
+		Content:     string(configBytes),
+		Overwrite:   c.SyncOverwrite,
+	}
+	payloadBytes, err := json.Marshal(payload)
+	if err != nil {
+		return
+	}
+
+	q := query.Environment
+	envs, _ := q.Where(q.ID.In(c.SyncNodeIds...)).Find()
+	for _, env := range envs {
+		go func() {
+			err := payload.deploy(env, c, payloadBytes)
+			if err != nil {
+				logger.Error(err)
+			}
+		}()
+	}
+
+	return
+}
+
+func SyncRenameOnRemoteServer(origPath, newPath string, syncNodeIds []int) (err error) {
+	if origPath == "" || newPath == "" || len(syncNodeIds) == 0 {
+		return
+	}
+
+	nginxConfPath := nginx.GetConfPath()
+	if !helper.IsUnderDirectory(origPath, nginxConfPath) {
+		return fmt.Errorf("config: %s is not under the nginx conf path: %s",
+			origPath, nginxConfPath)
+	}
+
+	if !helper.IsUnderDirectory(newPath, nginxConfPath) {
+		return fmt.Errorf("config: %s is not under the nginx conf path: %s",
+			newPath, nginxConfPath)
+	}
+
+	payload := &SyncConfigPayload{
+		Filepath:    origPath,
+		NewFilepath: newPath,
+	}
+
+	q := query.Environment
+	envs, _ := q.Where(q.ID.In(syncNodeIds...)).Find()
+	for _, env := range envs {
+		go func() {
+			err := payload.rename(env)
+			if err != nil {
+				logger.Error(err)
+			}
+		}()
+	}
+
+	return
+}
+
+type SyncNotificationPayload struct {
+	StatusCode int    `json:"status_code"`
+	ConfigName string `json:"config_name"`
+	EnvName    string `json:"env_name"`
+	RespBody   string `json:"resp_body"`
+}
+
+func (p *SyncConfigPayload) deploy(env *model.Environment, c *model.Config, payloadBytes []byte) (err error) {
+	client := http.Client{
+		Transport: &http.Transport{
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+		},
+	}
+	url, err := env.GetUrl("/api/config")
+	if err != nil {
+		return
+	}
+	req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payloadBytes))
+	if err != nil {
+		return
+	}
+	req.Header.Set("X-Node-Secret", env.Token)
+	resp, err := client.Do(req)
+	if err != nil {
+		return
+	}
+	defer resp.Body.Close()
+
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return
+	}
+
+	notificationPayload := &SyncNotificationPayload{
+		StatusCode: resp.StatusCode,
+		ConfigName: c.Name,
+		EnvName:    env.Name,
+		RespBody:   string(respBody),
+	}
+
+	notificationPayloadBytes, err := json.Marshal(notificationPayload)
+	if err != nil {
+		return
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		notification.Error("Sync Configuration Error", string(notificationPayloadBytes))
+		return
+	}
+
+	notification.Success("Sync Configuration Success", string(notificationPayloadBytes))
+
+	// handle rename
+	if p.NewFilepath == "" || p.Filepath == p.NewFilepath {
+		return
+	}
+
+	payloadBytes, err = json.Marshal(gin.H{
+		"base_path":    filepath.Dir(p.Filepath),
+		"old_filepath": filepath.Base(p.Filepath),
+		"new_filepath": filepath.Base(p.NewFilepath),
+	})
+	if err != nil {
+		return
+	}
+	url, err = env.GetUrl("/api/config_rename")
+	if err != nil {
+		return
+	}
+	req, err = http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payloadBytes))
+	if err != nil {
+		return
+	}
+	req.Header.Set("X-Node-Secret", env.Token)
+	resp, err = client.Do(req)
+	if err != nil {
+		return
+	}
+	defer resp.Body.Close()
+
+	respBody, err = io.ReadAll(resp.Body)
+	if err != nil {
+		return
+	}
+
+	notificationPayload = &SyncNotificationPayload{
+		StatusCode: resp.StatusCode,
+		ConfigName: c.Name,
+		EnvName:    env.Name,
+		RespBody:   string(respBody),
+	}
+
+	notificationPayloadBytes, err = json.Marshal(notificationPayload)
+	if err != nil {
+		return
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		notification.Error("Sync Rename Configuration Error", string(notificationPayloadBytes))
+		return
+	}
+
+	notification.Success("Sync Rename Configuration Success", string(notificationPayloadBytes))
+
+	return
+}
+
+type SyncRenameNotificationPayload struct {
+	StatusCode int    `json:"status_code"`
+	OrigPath   string `json:"orig_path"`
+	NewPath    string `json:"new_path"`
+	EnvName    string `json:"env_name"`
+	RespBody   string `json:"resp_body"`
+}
+
+func (p *SyncConfigPayload) rename(env *model.Environment) (err error) {
+	// handle rename
+	if p.NewFilepath == "" || p.Filepath == p.NewFilepath {
+		return
+	}
+
+	client := http.Client{
+		Transport: &http.Transport{
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+		},
+	}
+
+	payloadBytes, err := json.Marshal(gin.H{
+		"base_path": strings.ReplaceAll(filepath.Dir(p.Filepath), nginx.GetConfPath(), ""),
+		"orig_name": filepath.Base(p.Filepath),
+		"new_name":  filepath.Base(p.NewFilepath),
+	})
+	if err != nil {
+		return
+	}
+	url, err := env.GetUrl("/api/config_rename")
+	if err != nil {
+		return
+	}
+	req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payloadBytes))
+	if err != nil {
+		return
+	}
+	req.Header.Set("X-Node-Secret", env.Token)
+	resp, err := client.Do(req)
+	if err != nil {
+		return
+	}
+	defer resp.Body.Close()
+
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return
+	}
+
+	notificationPayload := &SyncRenameNotificationPayload{
+		StatusCode: resp.StatusCode,
+		OrigPath:   p.Filepath,
+		NewPath:    p.NewFilepath,
+		EnvName:    env.Name,
+		RespBody:   string(respBody),
+	}
+
+	notificationPayloadBytes, err := json.Marshal(notificationPayload)
+	if err != nil {
+		return
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		notification.Error("Sync Rename Configuration Error", string(notificationPayloadBytes))
+		return
+	}
+
+	notification.Success("Sync Rename Configuration Success", string(notificationPayloadBytes))
+
+	return
+}

+ 2 - 2
router/ip.go → internal/middleware/ip_whitelist.go

@@ -1,4 +1,4 @@
-package router
+package middleware
 
 import (
 	"github.com/0xJacky/Nginx-UI/settings"
@@ -7,7 +7,7 @@ import (
 	"net/http"
 )
 
-func ipWhiteList() gin.HandlerFunc {
+func IPWhiteList() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		clientIP := c.ClientIP()
 		if len(settings.AuthSettings.IPWhiteList) == 0 || clientIP == "127.0.0.1" {

+ 8 - 44
router/middleware.go → internal/middleware/middleware.go

@@ -1,11 +1,10 @@
-package router
+package middleware
 
 import (
 	"encoding/base64"
 	"github.com/0xJacky/Nginx-UI/app"
 	"github.com/0xJacky/Nginx-UI/internal/logger"
 	"github.com/0xJacky/Nginx-UI/internal/user"
-	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-contrib/static"
 	"github.com/gin-gonic/gin"
@@ -16,7 +15,7 @@ import (
 	"strings"
 )
 
-func recovery() gin.HandlerFunc {
+func Recovery() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		defer func() {
 			if err := recover(); err != nil {
@@ -34,7 +33,7 @@ func recovery() gin.HandlerFunc {
 	}
 }
 
-func authRequired() gin.HandlerFunc {
+func AuthRequired() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		abortWithAuthFailure := func() {
 			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
@@ -75,46 +74,11 @@ func authRequired() gin.HandlerFunc {
 	}
 }
 
-func required2FA() gin.HandlerFunc {
-	return func(c *gin.Context) {
-		u, ok := c.Get("user")
-		if !ok {
-			c.Next()
-			return
-		}
-		cUser := u.(*model.Auth)
-		if !cUser.EnabledOTP() {
-			c.Next()
-			return
-		}
-		ssid := c.GetHeader("X-Secure-Session-ID")
-		if ssid == "" {
-			ssid = c.Query("X-Secure-Session-ID")
-		}
-		if ssid == "" {
-			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
-				"message": "Secure Session ID is empty",
-			})
-			return
-		}
-
-		if user.VerifySecureSessionID(ssid, cUser.ID) {
-			c.Next()
-			return
-		}
-
-		c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
-			"message": "Secure Session ID is invalid",
-		})
-		return
-	}
-}
-
-type serverFileSystemType struct {
+type ServerFileSystemType struct {
 	http.FileSystem
 }
 
-func (f serverFileSystemType) Exists(prefix string, _path string) bool {
+func (f ServerFileSystemType) Exists(prefix string, _path string) bool {
 	file, err := f.Open(path.Join(prefix, _path))
 	if file != nil {
 		defer func(file http.File) {
@@ -127,7 +91,7 @@ func (f serverFileSystemType) Exists(prefix string, _path string) bool {
 	return err == nil
 }
 
-func mustFS(dir string) (serverFileSystem static.ServeFileSystem) {
+func MustFs(dir string) (serverFileSystem static.ServeFileSystem) {
 
 	sub, err := fs.Sub(app.DistFS, path.Join("dist", dir))
 
@@ -136,14 +100,14 @@ func mustFS(dir string) (serverFileSystem static.ServeFileSystem) {
 		return
 	}
 
-	serverFileSystem = serverFileSystemType{
+	serverFileSystem = ServerFileSystemType{
 		http.FS(sub),
 	}
 
 	return
 }
 
-func cacheJs() gin.HandlerFunc {
+func CacheJs() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		if strings.Contains(c.Request.URL.String(), "js") {
 			c.Header("Cache-Control", "max-age: 1296000")

+ 2 - 2
router/proxy.go → internal/middleware/proxy.go

@@ -1,4 +1,4 @@
-package router
+package middleware
 
 import (
 	"crypto/tls"
@@ -11,7 +11,7 @@ import (
 	"net/url"
 )
 
-func proxy() gin.HandlerFunc {
+func Proxy() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		nodeID, ok := c.Get("ProxyNodeID")
 		if !ok {

+ 2 - 2
router/proxy_ws.go → internal/middleware/proxy_ws.go

@@ -1,4 +1,4 @@
-package router
+package middleware
 
 import (
 	"github.com/0xJacky/Nginx-UI/internal/logger"
@@ -9,7 +9,7 @@ import (
 	"net/http"
 )
 
-func proxyWs() gin.HandlerFunc {
+func ProxyWs() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		nodeID, ok := c.Get("ProxyNodeID")
 		if !ok {

+ 43 - 0
internal/middleware/secure_session.go

@@ -0,0 +1,43 @@
+package middleware
+
+import (
+	"github.com/0xJacky/Nginx-UI/internal/user"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/gin-gonic/gin"
+	"net/http"
+)
+
+func RequireSecureSession() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		u, ok := c.Get("user")
+		if !ok {
+			c.Next()
+			return
+		}
+		cUser := u.(*model.Auth)
+		if !cUser.EnabledOTP() {
+			c.Next()
+			return
+		}
+		ssid := c.GetHeader("X-Secure-Session-ID")
+		if ssid == "" {
+			ssid = c.Query("X-Secure-Session-ID")
+		}
+		if ssid == "" {
+			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
+				"message": "Secure Session ID is empty",
+			})
+			return
+		}
+
+		if user.VerifySecureSessionID(ssid, cUser.ID) {
+			c.Next()
+			return
+		}
+
+		c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
+			"message": "Secure Session ID is invalid",
+		})
+		return
+	}
+}

+ 9 - 0
model/config.go

@@ -0,0 +1,9 @@
+package model
+
+type Config struct {
+	Model
+	Name        string `json:"name"`
+	Filepath    string `json:"filepath"`
+	SyncNodeIds []int  `json:"sync_node_ids" gorm:"serializer:json"`
+    SyncOverwrite  bool `json:"sync_overwrite"`
+}

+ 1 - 0
model/model.go

@@ -36,6 +36,7 @@ func GenerateAllModel() []any {
 		Notification{},
 		AcmeUser{},
 		BanIP{},
+		Config{},
 	}
 }
 

+ 28 - 20
query/certs.gen.go

@@ -45,6 +45,8 @@ func newCert(db *gorm.DB, opts ...gen.DOOption) cert {
 	_cert.Log = field.NewString(tableName, "log")
 	_cert.Resource = field.NewField(tableName, "resource")
 	_cert.SyncNodeIds = field.NewField(tableName, "sync_node_ids")
+	_cert.MustStaple = field.NewBool(tableName, "must_staple")
+	_cert.LegoDisableCNAMESupport = field.NewBool(tableName, "lego_disable_cname_support")
 	_cert.DnsCredential = certBelongsToDnsCredential{
 		db: db.Session(&gorm.Session{}),
 
@@ -65,25 +67,27 @@ func newCert(db *gorm.DB, opts ...gen.DOOption) cert {
 type cert struct {
 	certDo
 
-	ALL                   field.Asterisk
-	ID                    field.Int
-	CreatedAt             field.Time
-	UpdatedAt             field.Time
-	DeletedAt             field.Field
-	Name                  field.String
-	Domains               field.Field
-	Filename              field.String
-	SSLCertificatePath    field.String
-	SSLCertificateKeyPath field.String
-	AutoCert              field.Int
-	ChallengeMethod       field.String
-	DnsCredentialID       field.Int
-	ACMEUserID            field.Int
-	KeyType               field.String
-	Log                   field.String
-	Resource              field.Field
-	SyncNodeIds           field.Field
-	DnsCredential         certBelongsToDnsCredential
+	ALL                     field.Asterisk
+	ID                      field.Int
+	CreatedAt               field.Time
+	UpdatedAt               field.Time
+	DeletedAt               field.Field
+	Name                    field.String
+	Domains                 field.Field
+	Filename                field.String
+	SSLCertificatePath      field.String
+	SSLCertificateKeyPath   field.String
+	AutoCert                field.Int
+	ChallengeMethod         field.String
+	DnsCredentialID         field.Int
+	ACMEUserID              field.Int
+	KeyType                 field.String
+	Log                     field.String
+	Resource                field.Field
+	SyncNodeIds             field.Field
+	MustStaple              field.Bool
+	LegoDisableCNAMESupport field.Bool
+	DnsCredential           certBelongsToDnsCredential
 
 	ACMEUser certBelongsToACMEUser
 
@@ -119,6 +123,8 @@ func (c *cert) updateTableName(table string) *cert {
 	c.Log = field.NewString(table, "log")
 	c.Resource = field.NewField(table, "resource")
 	c.SyncNodeIds = field.NewField(table, "sync_node_ids")
+	c.MustStaple = field.NewBool(table, "must_staple")
+	c.LegoDisableCNAMESupport = field.NewBool(table, "lego_disable_cname_support")
 
 	c.fillFieldMap()
 
@@ -135,7 +141,7 @@ func (c *cert) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
 }
 
 func (c *cert) fillFieldMap() {
-	c.fieldMap = make(map[string]field.Expr, 19)
+	c.fieldMap = make(map[string]field.Expr, 21)
 	c.fieldMap["id"] = c.ID
 	c.fieldMap["created_at"] = c.CreatedAt
 	c.fieldMap["updated_at"] = c.UpdatedAt
@@ -153,6 +159,8 @@ func (c *cert) fillFieldMap() {
 	c.fieldMap["log"] = c.Log
 	c.fieldMap["resource"] = c.Resource
 	c.fieldMap["sync_node_ids"] = c.SyncNodeIds
+	c.fieldMap["must_staple"] = c.MustStaple
+	c.fieldMap["lego_disable_cname_support"] = c.LegoDisableCNAMESupport
 
 }
 

+ 370 - 0
query/configs.gen.go

@@ -0,0 +1,370 @@
+// Code generated by gorm.io/gen. DO NOT EDIT.
+// Code generated by gorm.io/gen. DO NOT EDIT.
+// Code generated by gorm.io/gen. DO NOT EDIT.
+
+package query
+
+import (
+	"context"
+	"strings"
+
+	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
+	"gorm.io/gorm/schema"
+
+	"gorm.io/gen"
+	"gorm.io/gen/field"
+
+	"gorm.io/plugin/dbresolver"
+
+	"github.com/0xJacky/Nginx-UI/model"
+)
+
+func newConfig(db *gorm.DB, opts ...gen.DOOption) config {
+	_config := config{}
+
+	_config.configDo.UseDB(db, opts...)
+	_config.configDo.UseModel(&model.Config{})
+
+	tableName := _config.configDo.TableName()
+	_config.ALL = field.NewAsterisk(tableName)
+	_config.ID = field.NewInt(tableName, "id")
+	_config.CreatedAt = field.NewTime(tableName, "created_at")
+	_config.UpdatedAt = field.NewTime(tableName, "updated_at")
+	_config.DeletedAt = field.NewField(tableName, "deleted_at")
+	_config.Filepath = field.NewString(tableName, "filepath")
+	_config.SyncNodeIds = field.NewField(tableName, "sync_node_ids")
+
+	_config.fillFieldMap()
+
+	return _config
+}
+
+type config struct {
+	configDo
+
+	ALL         field.Asterisk
+	ID          field.Int
+	CreatedAt   field.Time
+	UpdatedAt   field.Time
+	DeletedAt   field.Field
+	Filepath    field.String
+	SyncNodeIds field.Field
+
+	fieldMap map[string]field.Expr
+}
+
+func (c config) Table(newTableName string) *config {
+	c.configDo.UseTable(newTableName)
+	return c.updateTableName(newTableName)
+}
+
+func (c config) As(alias string) *config {
+	c.configDo.DO = *(c.configDo.As(alias).(*gen.DO))
+	return c.updateTableName(alias)
+}
+
+func (c *config) updateTableName(table string) *config {
+	c.ALL = field.NewAsterisk(table)
+	c.ID = field.NewInt(table, "id")
+	c.CreatedAt = field.NewTime(table, "created_at")
+	c.UpdatedAt = field.NewTime(table, "updated_at")
+	c.DeletedAt = field.NewField(table, "deleted_at")
+	c.Filepath = field.NewString(table, "filepath")
+	c.SyncNodeIds = field.NewField(table, "sync_node_ids")
+
+	c.fillFieldMap()
+
+	return c
+}
+
+func (c *config) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
+	_f, ok := c.fieldMap[fieldName]
+	if !ok || _f == nil {
+		return nil, false
+	}
+	_oe, ok := _f.(field.OrderExpr)
+	return _oe, ok
+}
+
+func (c *config) fillFieldMap() {
+	c.fieldMap = make(map[string]field.Expr, 6)
+	c.fieldMap["id"] = c.ID
+	c.fieldMap["created_at"] = c.CreatedAt
+	c.fieldMap["updated_at"] = c.UpdatedAt
+	c.fieldMap["deleted_at"] = c.DeletedAt
+	c.fieldMap["filepath"] = c.Filepath
+	c.fieldMap["sync_node_ids"] = c.SyncNodeIds
+}
+
+func (c config) clone(db *gorm.DB) config {
+	c.configDo.ReplaceConnPool(db.Statement.ConnPool)
+	return c
+}
+
+func (c config) replaceDB(db *gorm.DB) config {
+	c.configDo.ReplaceDB(db)
+	return c
+}
+
+type configDo struct{ gen.DO }
+
+// FirstByID Where("id=@id")
+func (c configDo) FirstByID(id int) (result *model.Config, err error) {
+	var params []interface{}
+
+	var generateSQL strings.Builder
+	params = append(params, id)
+	generateSQL.WriteString("id=? ")
+
+	var executeSQL *gorm.DB
+	executeSQL = c.UnderlyingDB().Where(generateSQL.String(), params...).Take(&result) // ignore_security_alert
+	err = executeSQL.Error
+
+	return
+}
+
+// DeleteByID update @@table set deleted_at=strftime('%Y-%m-%d %H:%M:%S','now') where id=@id
+func (c configDo) DeleteByID(id int) (err error) {
+	var params []interface{}
+
+	var generateSQL strings.Builder
+	params = append(params, id)
+	generateSQL.WriteString("update configs set deleted_at=strftime('%Y-%m-%d %H:%M:%S','now') where id=? ")
+
+	var executeSQL *gorm.DB
+	executeSQL = c.UnderlyingDB().Exec(generateSQL.String(), params...) // ignore_security_alert
+	err = executeSQL.Error
+
+	return
+}
+
+func (c configDo) Debug() *configDo {
+	return c.withDO(c.DO.Debug())
+}
+
+func (c configDo) WithContext(ctx context.Context) *configDo {
+	return c.withDO(c.DO.WithContext(ctx))
+}
+
+func (c configDo) ReadDB() *configDo {
+	return c.Clauses(dbresolver.Read)
+}
+
+func (c configDo) WriteDB() *configDo {
+	return c.Clauses(dbresolver.Write)
+}
+
+func (c configDo) Session(config *gorm.Session) *configDo {
+	return c.withDO(c.DO.Session(config))
+}
+
+func (c configDo) Clauses(conds ...clause.Expression) *configDo {
+	return c.withDO(c.DO.Clauses(conds...))
+}
+
+func (c configDo) Returning(value interface{}, columns ...string) *configDo {
+	return c.withDO(c.DO.Returning(value, columns...))
+}
+
+func (c configDo) Not(conds ...gen.Condition) *configDo {
+	return c.withDO(c.DO.Not(conds...))
+}
+
+func (c configDo) Or(conds ...gen.Condition) *configDo {
+	return c.withDO(c.DO.Or(conds...))
+}
+
+func (c configDo) Select(conds ...field.Expr) *configDo {
+	return c.withDO(c.DO.Select(conds...))
+}
+
+func (c configDo) Where(conds ...gen.Condition) *configDo {
+	return c.withDO(c.DO.Where(conds...))
+}
+
+func (c configDo) Order(conds ...field.Expr) *configDo {
+	return c.withDO(c.DO.Order(conds...))
+}
+
+func (c configDo) Distinct(cols ...field.Expr) *configDo {
+	return c.withDO(c.DO.Distinct(cols...))
+}
+
+func (c configDo) Omit(cols ...field.Expr) *configDo {
+	return c.withDO(c.DO.Omit(cols...))
+}
+
+func (c configDo) Join(table schema.Tabler, on ...field.Expr) *configDo {
+	return c.withDO(c.DO.Join(table, on...))
+}
+
+func (c configDo) LeftJoin(table schema.Tabler, on ...field.Expr) *configDo {
+	return c.withDO(c.DO.LeftJoin(table, on...))
+}
+
+func (c configDo) RightJoin(table schema.Tabler, on ...field.Expr) *configDo {
+	return c.withDO(c.DO.RightJoin(table, on...))
+}
+
+func (c configDo) Group(cols ...field.Expr) *configDo {
+	return c.withDO(c.DO.Group(cols...))
+}
+
+func (c configDo) Having(conds ...gen.Condition) *configDo {
+	return c.withDO(c.DO.Having(conds...))
+}
+
+func (c configDo) Limit(limit int) *configDo {
+	return c.withDO(c.DO.Limit(limit))
+}
+
+func (c configDo) Offset(offset int) *configDo {
+	return c.withDO(c.DO.Offset(offset))
+}
+
+func (c configDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *configDo {
+	return c.withDO(c.DO.Scopes(funcs...))
+}
+
+func (c configDo) Unscoped() *configDo {
+	return c.withDO(c.DO.Unscoped())
+}
+
+func (c configDo) Create(values ...*model.Config) error {
+	if len(values) == 0 {
+		return nil
+	}
+	return c.DO.Create(values)
+}
+
+func (c configDo) CreateInBatches(values []*model.Config, batchSize int) error {
+	return c.DO.CreateInBatches(values, batchSize)
+}
+
+// Save : !!! underlying implementation is different with GORM
+// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
+func (c configDo) Save(values ...*model.Config) error {
+	if len(values) == 0 {
+		return nil
+	}
+	return c.DO.Save(values)
+}
+
+func (c configDo) First() (*model.Config, error) {
+	if result, err := c.DO.First(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.Config), nil
+	}
+}
+
+func (c configDo) Take() (*model.Config, error) {
+	if result, err := c.DO.Take(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.Config), nil
+	}
+}
+
+func (c configDo) Last() (*model.Config, error) {
+	if result, err := c.DO.Last(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.Config), nil
+	}
+}
+
+func (c configDo) Find() ([]*model.Config, error) {
+	result, err := c.DO.Find()
+	return result.([]*model.Config), err
+}
+
+func (c configDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.Config, err error) {
+	buf := make([]*model.Config, 0, batchSize)
+	err = c.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
+		defer func() { results = append(results, buf...) }()
+		return fc(tx, batch)
+	})
+	return results, err
+}
+
+func (c configDo) FindInBatches(result *[]*model.Config, batchSize int, fc func(tx gen.Dao, batch int) error) error {
+	return c.DO.FindInBatches(result, batchSize, fc)
+}
+
+func (c configDo) Attrs(attrs ...field.AssignExpr) *configDo {
+	return c.withDO(c.DO.Attrs(attrs...))
+}
+
+func (c configDo) Assign(attrs ...field.AssignExpr) *configDo {
+	return c.withDO(c.DO.Assign(attrs...))
+}
+
+func (c configDo) Joins(fields ...field.RelationField) *configDo {
+	for _, _f := range fields {
+		c = *c.withDO(c.DO.Joins(_f))
+	}
+	return &c
+}
+
+func (c configDo) Preload(fields ...field.RelationField) *configDo {
+	for _, _f := range fields {
+		c = *c.withDO(c.DO.Preload(_f))
+	}
+	return &c
+}
+
+func (c configDo) FirstOrInit() (*model.Config, error) {
+	if result, err := c.DO.FirstOrInit(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.Config), nil
+	}
+}
+
+func (c configDo) FirstOrCreate() (*model.Config, error) {
+	if result, err := c.DO.FirstOrCreate(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.Config), nil
+	}
+}
+
+func (c configDo) FindByPage(offset int, limit int) (result []*model.Config, count int64, err error) {
+	result, err = c.Offset(offset).Limit(limit).Find()
+	if err != nil {
+		return
+	}
+
+	if size := len(result); 0 < limit && 0 < size && size < limit {
+		count = int64(size + offset)
+		return
+	}
+
+	count, err = c.Offset(-1).Limit(-1).Count()
+	return
+}
+
+func (c configDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
+	count, err = c.Count()
+	if err != nil {
+		return
+	}
+
+	err = c.Offset(offset).Limit(limit).Scan(result)
+	return
+}
+
+func (c configDo) Scan(result interface{}) (err error) {
+	return c.DO.Scan(result)
+}
+
+func (c configDo) Delete(models ...*model.Config) (result gen.ResultInfo, err error) {
+	return c.DO.Delete(models)
+}
+
+func (c *configDo) withDO(do gen.Dao) *configDo {
+	c.DO = *do.(*gen.DO)
+	return c
+}

+ 8 - 0
query/gen.go

@@ -23,6 +23,7 @@ var (
 	BanIP         *banIP
 	Cert          *cert
 	ChatGPTLog    *chatGPTLog
+	Config        *config
 	ConfigBackup  *configBackup
 	DnsCredential *dnsCredential
 	Environment   *environment
@@ -39,6 +40,7 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
 	BanIP = &Q.BanIP
 	Cert = &Q.Cert
 	ChatGPTLog = &Q.ChatGPTLog
+	Config = &Q.Config
 	ConfigBackup = &Q.ConfigBackup
 	DnsCredential = &Q.DnsCredential
 	Environment = &Q.Environment
@@ -56,6 +58,7 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
 		BanIP:         newBanIP(db, opts...),
 		Cert:          newCert(db, opts...),
 		ChatGPTLog:    newChatGPTLog(db, opts...),
+		Config:        newConfig(db, opts...),
 		ConfigBackup:  newConfigBackup(db, opts...),
 		DnsCredential: newDnsCredential(db, opts...),
 		Environment:   newEnvironment(db, opts...),
@@ -74,6 +77,7 @@ type Query struct {
 	BanIP         banIP
 	Cert          cert
 	ChatGPTLog    chatGPTLog
+	Config        config
 	ConfigBackup  configBackup
 	DnsCredential dnsCredential
 	Environment   environment
@@ -93,6 +97,7 @@ func (q *Query) clone(db *gorm.DB) *Query {
 		BanIP:         q.BanIP.clone(db),
 		Cert:          q.Cert.clone(db),
 		ChatGPTLog:    q.ChatGPTLog.clone(db),
+		Config:        q.Config.clone(db),
 		ConfigBackup:  q.ConfigBackup.clone(db),
 		DnsCredential: q.DnsCredential.clone(db),
 		Environment:   q.Environment.clone(db),
@@ -119,6 +124,7 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
 		BanIP:         q.BanIP.replaceDB(db),
 		Cert:          q.Cert.replaceDB(db),
 		ChatGPTLog:    q.ChatGPTLog.replaceDB(db),
+		Config:        q.Config.replaceDB(db),
 		ConfigBackup:  q.ConfigBackup.replaceDB(db),
 		DnsCredential: q.DnsCredential.replaceDB(db),
 		Environment:   q.Environment.replaceDB(db),
@@ -135,6 +141,7 @@ type queryCtx struct {
 	BanIP         *banIPDo
 	Cert          *certDo
 	ChatGPTLog    *chatGPTLogDo
+	Config        *configDo
 	ConfigBackup  *configBackupDo
 	DnsCredential *dnsCredentialDo
 	Environment   *environmentDo
@@ -151,6 +158,7 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx {
 		BanIP:         q.BanIP.WithContext(ctx),
 		Cert:          q.Cert.WithContext(ctx),
 		ChatGPTLog:    q.ChatGPTLog.WithContext(ctx),
+		Config:        q.Config.WithContext(ctx),
 		ConfigBackup:  q.ConfigBackup.WithContext(ctx),
 		DnsCredential: q.DnsCredential.WithContext(ctx),
 		Environment:   q.Environment.WithContext(ctx),

+ 0 - 154
router/operation_sync.go

@@ -1,154 +0,0 @@
-package router
-
-import (
-	"bytes"
-	"crypto/tls"
-	"encoding/json"
-	"fmt"
-	"github.com/0xJacky/Nginx-UI/internal/analytic"
-	"github.com/0xJacky/Nginx-UI/internal/logger"
-	"github.com/gin-gonic/gin"
-	"github.com/pkg/errors"
-	"io"
-	"net/http"
-	"net/url"
-	"regexp"
-	"sync"
-)
-
-type ErrorRes struct {
-	Message string `json:"message"`
-}
-
-type toolBodyWriter struct {
-	gin.ResponseWriter
-	body *bytes.Buffer
-}
-
-func (r toolBodyWriter) Write(b []byte) (int, error) {
-	return r.body.Write(b)
-}
-
-// OperationSync 针对配置了vip的环境操作进行同步
-func OperationSync() gin.HandlerFunc {
-	return func(c *gin.Context) {
-		bodyBytes, _ := PeekRequest(c.Request)
-		wb := &toolBodyWriter{
-			body:           &bytes.Buffer{},
-			ResponseWriter: c.Writer,
-		}
-		c.Writer = wb
-
-		c.Next()
-		if c.Request.Method == "GET" || !statusValid(c.Writer.Status()) { // 请求有问题,无需执行同步操作
-			return
-		}
-
-		totalCount := 0
-		successCount := 0
-		detailMsg := ""
-		// 后置处理操作同步
-		wg := sync.WaitGroup{}
-		for _, node := range analytic.NodeMap {
-			wg.Add(1)
-			node := node
-			go func(data analytic.Node) {
-				defer wg.Done()
-				if node.OperationSync && node.Status && requestUrlMatch(c.Request.URL.Path, data) { // 开启操作同步且当前状态正常
-					totalCount++
-					if err := syncNodeOperation(c, data, bodyBytes); err != nil {
-						detailMsg += fmt.Sprintf("node_name: %s, err_msg: %s; ", data.Name, err)
-						return
-					}
-					successCount++
-				}
-			}(*node)
-		}
-		wg.Wait()
-		if successCount < totalCount { // 如果有错误,替换原来的消息内容
-			originBytes := wb.body
-			logger.Infof("origin response body: %s", originBytes)
-			// clear Origin Buffer
-			wb.body = &bytes.Buffer{}
-			wb.ResponseWriter.WriteHeader(http.StatusInternalServerError)
-
-			errorRes := ErrorRes{
-				Message: fmt.Sprintf("operation sync failed, total: %d, success: %d, fail: %d, detail: %s", totalCount, successCount, totalCount-successCount, detailMsg),
-			}
-			byts, _ := json.Marshal(errorRes)
-			_, err := wb.Write(byts)
-
-			if err != nil {
-				logger.Error(err)
-			}
-		}
-		_, err := wb.ResponseWriter.Write(wb.body.Bytes())
-		if err != nil {
-			logger.Error(err)
-		}
-	}
-}
-
-func PeekRequest(request *http.Request) ([]byte, error) {
-	if request.Body != nil {
-		byts, err := io.ReadAll(request.Body) // io.ReadAll as Go 1.16, below please use ioutil.ReadAll
-		if err != nil {
-			return nil, err
-		}
-		request.Body = io.NopCloser(bytes.NewReader(byts))
-		return byts, nil
-	}
-	return make([]byte, 0), nil
-}
-
-func requestUrlMatch(url string, node analytic.Node) bool {
-	p, _ := regexp.Compile(node.SyncApiRegex)
-	result := p.FindAllString(url, -1)
-	if len(result) > 0 && result[0] == url {
-		return true
-	}
-	return false
-}
-
-func statusValid(code int) bool {
-	return code < http.StatusMultipleChoices
-}
-
-func syncNodeOperation(c *gin.Context, node analytic.Node, bodyBytes []byte) error {
-	u, err := url.JoinPath(node.URL, c.Request.RequestURI)
-	if err != nil {
-		return err
-	}
-	decodedUri, err := url.QueryUnescape(u)
-	if err != nil {
-		return err
-	}
-	logger.Debugf("syncNodeOperation request: %s, node_id: %d, node_name: %s", decodedUri, node.ID, node.Name)
-	client := http.Client{
-		Transport: &http.Transport{
-			TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
-		},
-	}
-
-	req, err := http.NewRequest(c.Request.Method, decodedUri, bytes.NewReader(bodyBytes))
-	req.Header.Set("X-Node-Secret", node.Token)
-
-	res, err := client.Do(req)
-	if err != nil {
-		return err
-	}
-	defer res.Body.Close()
-	byts, err := io.ReadAll(res.Body)
-	if err != nil {
-		return err
-	}
-	if !statusValid(res.StatusCode) {
-		errRes := ErrorRes{}
-		if err = json.Unmarshal(byts, &errRes); err != nil {
-			return err
-		}
-		return errors.New(errRes.Message)
-	}
-	logger.Debug("syncNodeOperation result: ", string(byts))
-	return nil
-}

+ 71 - 71
router/routers.go

@@ -1,83 +1,83 @@
 package router
 
 import (
-	"github.com/0xJacky/Nginx-UI/api/analytic"
-	"github.com/0xJacky/Nginx-UI/api/certificate"
-	"github.com/0xJacky/Nginx-UI/api/cluster"
-	"github.com/0xJacky/Nginx-UI/api/config"
-	"github.com/0xJacky/Nginx-UI/api/nginx"
-	"github.com/0xJacky/Nginx-UI/api/notification"
-	"github.com/0xJacky/Nginx-UI/api/openai"
-	"github.com/0xJacky/Nginx-UI/api/settings"
-	"github.com/0xJacky/Nginx-UI/api/sites"
-	"github.com/0xJacky/Nginx-UI/api/streams"
-	"github.com/0xJacky/Nginx-UI/api/system"
-	"github.com/0xJacky/Nginx-UI/api/template"
-	"github.com/0xJacky/Nginx-UI/api/terminal"
-	"github.com/0xJacky/Nginx-UI/api/upstream"
-	"github.com/0xJacky/Nginx-UI/api/user"
-	"github.com/gin-contrib/static"
-	"github.com/gin-gonic/gin"
-	"net/http"
+    "github.com/0xJacky/Nginx-UI/api/analytic"
+    "github.com/0xJacky/Nginx-UI/api/certificate"
+    "github.com/0xJacky/Nginx-UI/api/cluster"
+    "github.com/0xJacky/Nginx-UI/api/config"
+    "github.com/0xJacky/Nginx-UI/api/nginx"
+    "github.com/0xJacky/Nginx-UI/api/notification"
+    "github.com/0xJacky/Nginx-UI/api/openai"
+    "github.com/0xJacky/Nginx-UI/api/settings"
+    "github.com/0xJacky/Nginx-UI/api/sites"
+    "github.com/0xJacky/Nginx-UI/api/streams"
+    "github.com/0xJacky/Nginx-UI/api/system"
+    "github.com/0xJacky/Nginx-UI/api/template"
+    "github.com/0xJacky/Nginx-UI/api/terminal"
+    "github.com/0xJacky/Nginx-UI/api/upstream"
+    "github.com/0xJacky/Nginx-UI/api/user"
+    "github.com/0xJacky/Nginx-UI/internal/middleware"
+    "github.com/gin-contrib/static"
+    "github.com/gin-gonic/gin"
+    "net/http"
 )
 
 func InitRouter() *gin.Engine {
-	r := gin.New()
-	r.Use(gin.Logger())
-	r.Use(recovery())
-	r.Use(cacheJs())
-	r.Use(ipWhiteList())
+    r := gin.New()
+    r.Use(
+        gin.Logger(),
+        middleware.Recovery(),
+        middleware.CacheJs(),
+        middleware.IPWhiteList(),
+        static.Serve("/", middleware.MustFs("")),
+    )
 
-	//r.Use(OperationSync())
+    r.NoRoute(func(c *gin.Context) {
+        c.JSON(http.StatusNotFound, gin.H{
+            "message": "not found",
+        })
+    })
 
-	r.Use(static.Serve("/", mustFS("")))
+    root := r.Group("/api")
+    {
+        system.InitPublicRouter(root)
+        user.InitAuthRouter(root)
 
-	r.NoRoute(func(c *gin.Context) {
-		c.JSON(http.StatusNotFound, gin.H{
-			"message": "not found",
-		})
-	})
+        // Authorization required not websocket request
+        g := root.Group("/", middleware.AuthRequired(), middleware.Proxy())
+        {
+            user.InitUserRouter(g)
+            analytic.InitRouter(g)
+            user.InitManageUserRouter(g)
+            nginx.InitRouter(g)
+            sites.InitRouter(g)
+            streams.InitRouter(g)
+            config.InitRouter(g)
+            template.InitRouter(g)
+            certificate.InitCertificateRouter(g)
+            certificate.InitDNSCredentialRouter(g)
+            certificate.InitAcmeUserRouter(g)
+            system.InitPrivateRouter(g)
+            settings.InitRouter(g)
+            openai.InitRouter(g)
+            cluster.InitRouter(g)
+            notification.InitRouter(g)
+        }
 
-	root := r.Group("/api")
-	{
-		system.InitPublicRouter(root)
-		user.InitAuthRouter(root)
+        // Authorization required and websocket request
+        w := root.Group("/", middleware.AuthRequired(), middleware.ProxyWs())
+        {
+            analytic.InitWebSocketRouter(w)
+            certificate.InitCertificateWebSocketRouter(w)
+            o := w.Group("", middleware.RequireSecureSession())
+            {
+                terminal.InitRouter(o)
+            }
+            nginx.InitNginxLogRouter(w)
+            upstream.InitRouter(w)
+            system.InitWebSocketRouter(w)
+        }
+    }
 
-		// Authorization required not websocket request
-		g := root.Group("/", authRequired(), proxy())
-		{
-			user.InitUserRouter(g)
-			analytic.InitRouter(g)
-			user.InitManageUserRouter(g)
-			nginx.InitRouter(g)
-			sites.InitRouter(g)
-			streams.InitRouter(g)
-			config.InitRouter(g)
-			template.InitRouter(g)
-			certificate.InitCertificateRouter(g)
-			certificate.InitDNSCredentialRouter(g)
-			certificate.InitAcmeUserRouter(g)
-			system.InitPrivateRouter(g)
-			settings.InitRouter(g)
-			openai.InitRouter(g)
-			cluster.InitRouter(g)
-			notification.InitRouter(g)
-		}
-
-		// Authorization required and websocket request
-		w := root.Group("/", authRequired(), proxyWs())
-		{
-			analytic.InitWebSocketRouter(w)
-			certificate.InitCertificateWebSocketRouter(w)
-			o := w.Group("", required2FA())
-			{
-				terminal.InitRouter(o)
-			}
-			nginx.InitNginxLogRouter(w)
-			upstream.InitRouter(w)
-			system.InitWebSocketRouter(w)
-		}
-	}
-
-	return r
+    return r
 }