Browse Source

feat: deploy config to remote nodes #359

Jacky 9 months ago
parent
commit
1c1da92363
46 changed files with 1481 additions and 606 deletions
  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
 package config
 
 
 import (
 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) {
 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"
 	"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) {
 func GetConfig(c *gin.Context) {
 	name := c.Param("name")
 	name := c.Param("name")
 
 
@@ -34,7 +40,7 @@ func GetConfig(c *gin.Context) {
 		api.ErrHandler(c, err)
 		api.ErrHandler(c, err)
 		return
 		return
 	}
 	}
-
+	q := query.Config
 	g := query.ChatGPTLog
 	g := query.ChatGPTLog
 	chatgpt, err := g.Where(g.Name.Eq(path)).FirstOrCreate()
 	chatgpt, err := g.Where(g.Name.Eq(path)).FirstOrCreate()
 	if err != nil {
 	if err != nil {
@@ -46,11 +52,21 @@ func GetConfig(c *gin.Context) {
 		chatgpt.Content = make([]openai.ChatCompletionMessage, 0)
 		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)
 		api.ErrHandler(c, err)
 		return
 		return
 	}
 	}
+
 	c.JSON(http.StatusOK, gin.H{
 	c.JSON(http.StatusOK, gin.H{
 		"message": "ok",
 		"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/config"
 	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
 	"github.com/gin-gonic/gin"
 	"github.com/sashabaranov/go-openai"
 	"github.com/sashabaranov/go-openai"
@@ -24,6 +25,8 @@ func EditConfig(c *gin.Context) {
 		Filepath    string `json:"filepath" binding:"required"`
 		Filepath    string `json:"filepath" binding:"required"`
 		NewFilepath string `json:"new_filepath" binding:"required"`
 		NewFilepath string `json:"new_filepath" binding:"required"`
 		Content     string `json:"content"`
 		Content     string `json:"content"`
+		Overwrite   bool   `json:"overwrite"`
+		SyncNodeIds []int  `json:"sync_node_ids"`
 	}
 	}
 	if !api.BindAndValid(c, &json) {
 	if !api.BindAndValid(c, &json) {
 		return
 		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
 	// handle rename
 	if path != json.NewFilepath {
 	if path != json.NewFilepath {
 		if helper.FileExists(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)
 		_, _ = 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()
 	output := nginx.Reload()
 	if nginx.GetLogLevel(output) >= nginx.Warn {
 	if nginx.GetLogLevel(output) >= nginx.Warn {
 		c.JSON(http.StatusInternalServerError, gin.H{
 		c.JSON(http.StatusInternalServerError, gin.H{

+ 35 - 6
api/config/rename.go

@@ -2,7 +2,9 @@ package config
 
 
 import (
 import (
 	"github.com/0xJacky/Nginx-UI/api"
 	"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/helper"
+	"github.com/0xJacky/Nginx-UI/internal/logger"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
 	"github.com/gin-gonic/gin"
@@ -12,14 +14,16 @@ import (
 
 
 func Rename(c *gin.Context) {
 func Rename(c *gin.Context) {
 	var json struct {
 	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) {
 	if !api.BindAndValid(c, &json) {
 		return
 		return
 	}
 	}
-	if json.OrigName == json.OrigName {
+	logger.Debug(json)
+	if json.OrigName == json.NewName {
 		c.JSON(http.StatusOK, gin.H{
 		c.JSON(http.StatusOK, gin.H{
 			"message": "ok",
 			"message": "ok",
 		})
 		})
@@ -55,11 +59,36 @@ func Rename(c *gin.Context) {
 		return
 		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() {
 	if !stat.IsDir() {
-		// update ChatGPT records
-		g := query.ChatGPTLog
 		_, _ = g.Where(g.Name.Eq(newFullPath)).Delete()
 		_, _ = g.Where(g.Name.Eq(newFullPath)).Delete()
 		_, _ = g.Where(g.Name.Eq(origFullPath)).Update(g.Name, newFullPath)
 		_, _ = 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{
 	c.JSON(http.StatusOK, gin.H{

+ 10 - 3
api/config/router.go

@@ -1,6 +1,9 @@
 package config
 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) {
 func InitRouter(r *gin.RouterGroup) {
 	r.GET("config_base_path", GetBasePath)
 	r.GET("config_base_path", GetBasePath)
@@ -9,6 +12,10 @@ func InitRouter(r *gin.RouterGroup) {
 	r.GET("config/*name", GetConfig)
 	r.GET("config/*name", GetConfig)
 	r.POST("config", AddConfig)
 	r.POST("config", AddConfig)
 	r.POST("config/*name", EditConfig)
 	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) {
 func StartSecure2FASession(c *gin.Context) {
 	var json struct {
 	var json struct {
 		OTP          string `json:"otp"`
 		OTP          string `json:"otp"`

+ 7 - 1
api/user/router.go

@@ -1,6 +1,9 @@
 package user
 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) {
 func InitAuthRouter(r *gin.RouterGroup) {
 	r.POST("/login", Login)
 	r.POST("/login", Login)
@@ -23,5 +26,8 @@ func InitUserRouter(r *gin.RouterGroup) {
 	r.GET("/otp_secret", GenerateTOTP)
 	r.GET("/otp_secret", GenerateTOTP)
 	r.POST("/otp_enroll", EnrollTOTP)
 	r.POST("/otp_enroll", EnrollTOTP)
 	r.POST("/otp_reset", ResetOTP)
 	r.POST("/otp_reset", ResetOTP)
+
+    r.GET("/otp_secure_session_status",
+        middleware.RequireSecureSession(), SecureSessionStatus)
 	r.POST("/otp_secure_session", StartSecure2FASession)
 	r.POST("/otp_secure_session", StartSecure2FASession)
 }
 }

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

@@ -8,6 +8,8 @@ export interface Config {
   chatgpt_messages: ChatComplicationMessage[]
   chatgpt_messages: ChatComplicationMessage[]
   filepath: string
   filepath: string
   modified_at: string
   modified_at: string
+  sync_node_ids?: number[]
+  sync_overwrite?: false
 }
 }
 
 
 class ConfigCurd extends Curd<Config> {
 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 })
     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,
       recovery_code,
     })
     })
   },
   },
+  secure_session_status() {
+    return http.get('/otp_secure_session_status')
+  },
 }
 }
 
 
 export default otp
 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 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) => {
 export const detailRender = (args: customRender) => {
   switch (args.record.title) {
   switch (args.record.title) {
@@ -6,26 +13,15 @@ export const detailRender = (args: customRender) => {
       return syncCertificateSuccess(args.text)
       return syncCertificateSuccess(args.text)
     case 'Sync Certificate Error':
     case 'Sync Certificate Error':
       return syncCertificateError(args.text)
       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:
     default:
       return args.text
       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 otp from '@/api/otp'
 import { useUserStore } from '@/pinia'
 import { useUserStore } from '@/pinia'
 
 
-export interface OTPModalProps {
-  onOk?: (secureSessionId: string) => void
-  onCancel?: () => void
-}
-
 const useOTPModal = () => {
 const useOTPModal = () => {
   const refOTPAuthorization = ref<typeof OTPAuthorization>()
   const refOTPAuthorization = ref<typeof OTPAuthorization>()
   const randomId = Math.random().toString(36).substring(2, 8)
   const randomId = Math.random().toString(36).substring(2, 8)
@@ -26,68 +21,72 @@ const useOTPModal = () => {
     document.head.appendChild(style)
     document.head.appendChild(style)
   }
   }
 
 
-  const open = async ({ onOk, onCancel }: OTPModalProps) => {
+  const open = async (): Promise<string> => {
     const { status } = await otp.status()
     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 }
   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}"
 msgstr "Auto-renewal enabled for %{name}"
 
 
 #: src/views/certificate/CertificateEditor.vue:247
 #: 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"
 msgid "Back"
 msgstr "Back"
 msgstr "Back"
 
 
@@ -369,7 +369,7 @@ msgstr ""
 msgid "Configuration Name"
 msgid "Configuration Name"
 msgstr "Configuration Name"
 msgstr "Configuration Name"
 
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgid "Configurations"
 msgstr "Configurations"
 msgstr "Configurations"
 
 
@@ -420,12 +420,12 @@ msgstr "Created at"
 msgid "Create Another"
 msgid "Create Another"
 msgstr "Create Another"
 msgstr "Create Another"
 
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 #, fuzzy
 msgid "Create File"
 msgid "Create File"
 msgstr "Created at"
 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
 #, fuzzy
 msgid "Create Folder"
 msgid "Create Folder"
 msgstr "Create Another"
 msgstr "Create Another"
@@ -474,8 +474,8 @@ msgid ""
 "indicator."
 "indicator."
 msgstr ""
 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"
 msgid "Dashboard"
 msgstr "Dashboard"
 msgstr "Dashboard"
 
 
@@ -803,6 +803,10 @@ msgstr "Enabled successfully"
 msgid "Encrypt website with Let's Encrypt"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "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
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgid "Environment"
 msgstr ""
 msgstr ""
@@ -1191,8 +1195,8 @@ msgstr ""
 "Make sure you have configured a reverse proxy for .well-known directory to "
 "Make sure you have configured a reverse proxy for .well-known directory to "
 "HTTPChallengePort (default: 9180) before getting the certificate."
 "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"
 msgid "Manage Configs"
 msgstr "Manage Configs"
 msgstr "Manage Configs"
 
 
@@ -1239,6 +1243,7 @@ msgstr "Advance Mode"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 #, fuzzy
 #, fuzzy
 msgid "Modify"
 msgid "Modify"
 msgstr "Modify Config"
 msgstr "Modify Config"
@@ -1703,7 +1708,8 @@ msgstr "Saved successfully"
 msgid "Removed successfully"
 msgid "Removed successfully"
 msgstr "Saved 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
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 #, fuzzy
 #, fuzzy
 msgid "Rename"
 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}"
 msgstr "Renovación automática habilitada por %{name}"
 
 
 #: src/views/certificate/CertificateEditor.vue:247
 #: 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"
 msgid "Back"
 msgstr "Volver"
 msgstr "Volver"
 
 
@@ -362,7 +362,7 @@ msgstr "El archivo de configuración se probó exitosamente"
 msgid "Configuration Name"
 msgid "Configuration Name"
 msgstr "Nombre de la configuración"
 msgstr "Nombre de la configuración"
 
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgid "Configurations"
 msgstr "Configuraciones"
 msgstr "Configuraciones"
 
 
@@ -412,12 +412,12 @@ msgstr "Crear"
 msgid "Create Another"
 msgid "Create Another"
 msgstr "Crear otro"
 msgstr "Crear otro"
 
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 #, fuzzy
 msgid "Create File"
 msgid "Create File"
 msgstr "Crear"
 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
 #, fuzzy
 msgid "Create Folder"
 msgid "Create Folder"
 msgstr "Crear otro"
 msgstr "Crear otro"
@@ -466,8 +466,8 @@ msgid ""
 "indicator."
 "indicator."
 msgstr ""
 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"
 msgid "Dashboard"
 msgstr "Panel"
 msgstr "Panel"
 
 
@@ -778,6 +778,10 @@ msgstr "Habilitado con éxito"
 msgid "Encrypt website with Let's Encrypt"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "Encriptar sitio web con 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
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgid "Environment"
 msgstr "Entorno"
 msgstr "Entorno"
@@ -1152,8 +1156,8 @@ msgstr ""
 "Asegúrese de haber configurado un proxy reverso para el directorio .well-"
 "Asegúrese de haber configurado un proxy reverso para el directorio .well-"
 "known en HTTPChallengePort antes de obtener el certificado."
 "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"
 msgid "Manage Configs"
 msgstr "Administrar configuraciones"
 msgstr "Administrar configuraciones"
 
 
@@ -1198,6 +1202,7 @@ msgstr "Modo de ejecución"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 msgid "Modify"
 msgid "Modify"
 msgstr "Modificar"
 msgstr "Modificar"
 
 
@@ -1660,7 +1665,8 @@ msgstr "Eliminado con éxito"
 msgid "Removed successfully"
 msgid "Removed successfully"
 msgstr "Eliminado con éxito"
 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
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 msgid "Rename"
 msgid "Rename"
 msgstr "Renombrar"
 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}"
 msgstr "Renouvellement automatique activé pour %{name}"
 
 
 #: src/views/certificate/CertificateEditor.vue:247
 #: 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"
 msgid "Back"
 msgstr "Retour"
 msgstr "Retour"
 
 
@@ -369,7 +369,7 @@ msgstr "Le fichier de configuration est testé avec succès"
 msgid "Configuration Name"
 msgid "Configuration Name"
 msgstr "Nom de la configuration"
 msgstr "Nom de la configuration"
 
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgid "Configurations"
 msgstr "Configurations"
 msgstr "Configurations"
 
 
@@ -420,12 +420,12 @@ msgstr "Créé le"
 msgid "Create Another"
 msgid "Create Another"
 msgstr "Créer un autre"
 msgstr "Créer un autre"
 
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 #, fuzzy
 msgid "Create File"
 msgid "Create File"
 msgstr "Créé le"
 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
 #, fuzzy
 msgid "Create Folder"
 msgid "Create Folder"
 msgstr "Créer un autre"
 msgstr "Créer un autre"
@@ -474,8 +474,8 @@ msgid ""
 "indicator."
 "indicator."
 msgstr ""
 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"
 msgid "Dashboard"
 msgstr "Dashboard"
 msgstr "Dashboard"
 
 
@@ -803,6 +803,10 @@ msgstr "Activé avec succès"
 msgid "Encrypt website with Let's Encrypt"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "Crypter le site Web avec 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
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgid "Environment"
 msgstr ""
 msgstr ""
@@ -1193,8 +1197,8 @@ msgstr ""
 "Assurez vous d'avoir configuré un reverse proxy pour le répertoire .well-"
 "Assurez vous d'avoir configuré un reverse proxy pour le répertoire .well-"
 "known vers HTTPChallengePort avant d'obtenir le certificat."
 "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"
 msgid "Manage Configs"
 msgstr "Gérer les configurations"
 msgstr "Gérer les configurations"
 
 
@@ -1241,6 +1245,7 @@ msgstr "Mode d'exécution"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 msgid "Modify"
 msgid "Modify"
 msgstr "Modifier"
 msgstr "Modifier"
 
 
@@ -1710,7 +1715,8 @@ msgstr "Enregistré avec succès"
 msgid "Removed successfully"
 msgid "Removed successfully"
 msgstr "Enregistré avec succès"
 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
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 #, fuzzy
 #, fuzzy
 msgid "Rename"
 msgid "Rename"

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

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

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

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

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

@@ -195,9 +195,9 @@ msgid "Auto-renewal enabled for %{name}"
 msgstr "Автообновление включено для %{name}"
 msgstr "Автообновление включено для %{name}"
 
 
 #: src/views/certificate/CertificateEditor.vue:247
 #: 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"
 msgid "Back"
 msgstr "Назад"
 msgstr "Назад"
 
 
@@ -371,7 +371,7 @@ msgstr "Проверка конфигурации успешна"
 msgid "Configuration Name"
 msgid "Configuration Name"
 msgstr "Название конфигурации"
 msgstr "Название конфигурации"
 
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgid "Configurations"
 msgstr "Конфигурации"
 msgstr "Конфигурации"
 
 
@@ -422,12 +422,12 @@ msgstr "Создан в"
 msgid "Create Another"
 msgid "Create Another"
 msgstr "Создать еще"
 msgstr "Создать еще"
 
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 #, fuzzy
 msgid "Create File"
 msgid "Create File"
 msgstr "Создан в"
 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
 #, fuzzy
 msgid "Create Folder"
 msgid "Create Folder"
 msgstr "Создать еще"
 msgstr "Создать еще"
@@ -476,8 +476,8 @@ msgid ""
 "indicator."
 "indicator."
 msgstr ""
 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"
 msgid "Dashboard"
 msgstr "Доска"
 msgstr "Доска"
 
 
@@ -807,6 +807,10 @@ msgstr "Активировано успешно"
 msgid "Encrypt website with Let's Encrypt"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "Использовать для сайта 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
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgid "Environment"
 msgstr "Окружение"
 msgstr "Окружение"
@@ -1199,8 +1203,8 @@ msgstr ""
 "Убедитесь, что вы настроили обратный прокси-сервер для каталога .well-known "
 "Убедитесь, что вы настроили обратный прокси-сервер для каталога .well-known "
 "на HTTPChallengePort перед получением сертификата»."
 "на 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"
 msgid "Manage Configs"
 msgstr "Конфигурации"
 msgstr "Конфигурации"
 
 
@@ -1247,6 +1251,7 @@ msgstr "Расширенный режим"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 #, fuzzy
 #, fuzzy
 msgid "Modify"
 msgid "Modify"
 msgstr "Изменить"
 msgstr "Изменить"
@@ -1716,7 +1721,8 @@ msgstr "Успешно сохранено"
 msgid "Removed successfully"
 msgid "Removed successfully"
 msgstr "Успешно сохранено"
 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
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 #, fuzzy
 #, fuzzy
 msgid "Rename"
 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}"
 msgstr "Đã bật tự động gia hạn SSL cho %{name}"
 
 
 #: src/views/certificate/CertificateEditor.vue:247
 #: 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"
 msgid "Back"
 msgstr "Quay lại"
 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"
 msgid "Configuration Name"
 msgstr "Tên cấu hình"
 msgstr "Tên cấu hình"
 
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgid "Configurations"
 msgstr "Cấu hình"
 msgstr "Cấu hình"
 
 
@@ -422,12 +422,12 @@ msgstr "Ngày tạo"
 msgid "Create Another"
 msgid "Create Another"
 msgstr "Tạo thêm"
 msgstr "Tạo thêm"
 
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 #, fuzzy
 msgid "Create File"
 msgid "Create File"
 msgstr "Ngày tạo"
 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
 #, fuzzy
 msgid "Create Folder"
 msgid "Create Folder"
 msgstr "Tạo thêm"
 msgstr "Tạo thêm"
@@ -476,8 +476,8 @@ msgid ""
 "indicator."
 "indicator."
 msgstr ""
 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"
 msgid "Dashboard"
 msgstr "Bảng điều khiển"
 msgstr "Bảng điều khiển"
 
 
@@ -808,6 +808,10 @@ msgstr "Đã bật"
 msgid "Encrypt website with Let's Encrypt"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "Bảo mật trang web với 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
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgid "Environment"
 msgstr "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-"
 "Đả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."
 "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"
 msgid "Manage Configs"
 msgstr "Quản lý cấu hình"
 msgstr "Quản lý cấu hình"
 
 
@@ -1248,6 +1252,7 @@ msgstr "Run Mode"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 #, fuzzy
 #, fuzzy
 msgid "Modify"
 msgid "Modify"
 msgstr "Sửa"
 msgstr "Sửa"
@@ -1718,7 +1723,8 @@ msgstr "Xoá thành công"
 msgid "Removed successfully"
 msgid "Removed successfully"
 msgstr "Xoá thành công"
 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
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 #, fuzzy
 #, fuzzy
 msgid "Rename"
 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} 自动续签"
 msgstr "成功启用 %{name} 自动续签"
 
 
 #: src/views/certificate/CertificateEditor.vue:247
 #: 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"
 msgid "Back"
 msgstr "返回"
 msgstr "返回"
 
 
@@ -344,7 +344,7 @@ msgstr "配置文件测试成功"
 msgid "Configuration Name"
 msgid "Configuration Name"
 msgstr "配置名称"
 msgstr "配置名称"
 
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgid "Configurations"
 msgstr "配置"
 msgstr "配置"
 
 
@@ -394,11 +394,11 @@ msgstr "创建"
 msgid "Create Another"
 msgid "Create Another"
 msgstr "再创建一个"
 msgstr "再创建一个"
 
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 msgid "Create File"
 msgid "Create File"
 msgstr "创建文件"
 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"
 msgid "Create Folder"
 msgstr "创建文件夹"
 msgstr "创建文件夹"
 
 
@@ -445,8 +445,8 @@ msgid ""
 "indicator."
 "indicator."
 msgstr "自定义显示在环境指示器中的本地服务器名称。"
 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"
 msgid "Dashboard"
 msgstr "仪表盘"
 msgstr "仪表盘"
 
 
@@ -751,6 +751,10 @@ msgstr "启用成功"
 msgid "Encrypt website with Let's Encrypt"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "用 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
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgid "Environment"
 msgstr "环境"
 msgstr "环境"
@@ -1123,8 +1127,8 @@ msgstr ""
 "在获取签发证书前,请确保配置文件中已将 .well-known 目录反向代理到 "
 "在获取签发证书前,请确保配置文件中已将 .well-known 目录反向代理到 "
 "HTTPChallengePort。"
 "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"
 msgid "Manage Configs"
 msgstr "配置管理"
 msgstr "配置管理"
 
 
@@ -1168,6 +1172,7 @@ msgstr "模型"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 msgid "Modify"
 msgid "Modify"
 msgstr "修改"
 msgstr "修改"
 
 
@@ -1612,7 +1617,8 @@ msgstr "移除成功"
 msgid "Removed successfully"
 msgid "Removed successfully"
 msgstr "删除成功"
 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
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 msgid "Rename"
 msgid "Rename"
 msgstr "重命名"
 msgstr "重命名"

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

@@ -197,9 +197,9 @@ msgid "Auto-renewal enabled for %{name}"
 msgstr "已啟用 %{name} 的自動續簽"
 msgstr "已啟用 %{name} 的自動續簽"
 
 
 #: src/views/certificate/CertificateEditor.vue:247
 #: 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"
 msgid "Back"
 msgstr "返回"
 msgstr "返回"
 
 
@@ -366,7 +366,7 @@ msgstr "設定檔案測試成功"
 msgid "Configuration Name"
 msgid "Configuration Name"
 msgstr "設定名稱"
 msgstr "設定名稱"
 
 
-#: src/views/config/Config.vue:91
+#: src/views/config/ConfigList.vue:91
 msgid "Configurations"
 msgid "Configurations"
 msgstr "設定"
 msgstr "設定"
 
 
@@ -417,12 +417,12 @@ msgstr "建立時間"
 msgid "Create Another"
 msgid "Create Another"
 msgstr "再建立一個"
 msgstr "再建立一個"
 
 
-#: src/views/config/Config.vue:99
+#: src/views/config/ConfigList.vue:109
 #, fuzzy
 #, fuzzy
 msgid "Create File"
 msgid "Create File"
 msgstr "建立時間"
 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
 #, fuzzy
 msgid "Create Folder"
 msgid "Create Folder"
 msgstr "再建立一個"
 msgstr "再建立一個"
@@ -471,8 +471,8 @@ msgid ""
 "indicator."
 "indicator."
 msgstr ""
 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"
 msgid "Dashboard"
 msgstr "儀表板"
 msgstr "儀表板"
 
 
@@ -788,6 +788,10 @@ msgstr "成功啟用"
 msgid "Encrypt website with Let's Encrypt"
 msgid "Encrypt website with Let's Encrypt"
 msgstr "用 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
 #: src/routes/index.ts:228 src/views/environment/Environment.vue:34
 msgid "Environment"
 msgid "Environment"
 msgstr "環境"
 msgstr "環境"
@@ -1172,8 +1176,8 @@ msgid ""
 msgstr ""
 msgstr ""
 "在取得憑證前,請確保您已將 .well-known 目錄反向代理到 HTTPChallengePort。"
 "在取得憑證前,請確保您已將 .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"
 msgid "Manage Configs"
 msgstr "管理設定"
 msgstr "管理設定"
 
 
@@ -1220,6 +1224,7 @@ msgstr "執行模式"
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/ChatGPT/ChatGPT.vue:248
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:181
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
 #: src/components/StdDesign/StdDataDisplay/StdTable.vue:532
+#: src/views/config/ConfigList.vue:151
 msgid "Modify"
 msgid "Modify"
 msgstr "修改"
 msgstr "修改"
 
 
@@ -1681,7 +1686,8 @@ msgstr "儲存成功"
 msgid "Removed successfully"
 msgid "Removed successfully"
 msgstr "儲存成功"
 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
 #: src/views/domain/ngx_conf/NgxUpstream.vue:123
 #, fuzzy
 #, fuzzy
 msgid "Rename"
 msgid "Rename"

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

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

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

@@ -97,7 +97,7 @@ export const routes: RouteRecordRaw[] = [
       {
       {
         path: 'config',
         path: 'config',
         name: 'Manage Configs',
         name: 'Manage Configs',
-        component: () => import('@/views/config/Config.vue'),
+        component: () => import('@/views/config/ConfigList.vue'),
         meta: {
         meta: {
           name: () => $gettext('Manage Configs'),
           name: () => $gettext('Manage Configs'),
           icon: FileOutlined,
           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">
 <script setup lang="ts">
 import { message } from 'ant-design-vue'
 import { message } from 'ant-design-vue'
 import type { Ref } from 'vue'
 import type { Ref } from 'vue'
+import { InfoCircleOutlined } from '@ant-design/icons-vue'
 import { formatDateTime } from '@/lib/helper'
 import { formatDateTime } from '@/lib/helper'
 import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
 import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
+import type { Config } from '@/api/config'
 import config from '@/api/config'
 import config from '@/api/config'
 import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
 import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
 import ngx from '@/api/ngx'
 import ngx from '@/api/ngx'
@@ -10,7 +12,10 @@ import InspectConfig from '@/views/config/InspectConfig.vue'
 import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
 import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
 import type { ChatComplicationMessage } from '@/api/openai'
 import type { ChatComplicationMessage } from '@/api/openai'
 import { useBreadcrumbs } from '@/composables/useBreadcrumbs'
 import { useBreadcrumbs } from '@/composables/useBreadcrumbs'
+import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
+import { useSettingsStore } from '@/pinia'
 
 
+const settings = useSettingsStore()
 const route = useRoute()
 const route = useRoute()
 const router = useRouter()
 const router = useRouter()
 const refForm = ref()
 const refForm = ref()
@@ -32,10 +37,12 @@ const data = ref({
   name: '',
   name: '',
   content: '',
   content: '',
   filepath: '',
   filepath: '',
-})
+  sync_node_ids: [] as number[],
+  sync_overwrite: false,
+} as Config)
 
 
 const historyChatgptRecord = ref([]) as Ref<ChatComplicationMessage[]>
 const historyChatgptRecord = ref([]) as Ref<ChatComplicationMessage[]>
-const activeKey = ref(['basic', 'chatgpt'])
+const activeKey = ref(['basic', 'deploy', 'chatgpt'])
 const modifiedAt = ref('')
 const modifiedAt = ref('')
 const nginxConfigBase = ref('')
 const nginxConfigBase = ref('')
 
 
@@ -145,6 +152,8 @@ function save() {
       filepath: data.value.filepath,
       filepath: data.value.filepath,
       new_filepath: newPath.value,
       new_filepath: newPath.value,
       content: data.value.content,
       content: data.value.content,
+      sync_node_ids: data.value.sync_node_ids,
+      sync_overwrite: data.value.sync_overwrite,
     }).then(r => {
     }).then(r => {
       data.value.content = r.content
       data.value.content = r.content
       message.success($gettext('Saved successfully'))
       message.success($gettext('Saved successfully'))
@@ -261,6 +270,29 @@ function goBack() {
               </AFormItem>
               </AFormItem>
             </AForm>
             </AForm>
           </ACollapsePanel>
           </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
           <ACollapsePanel
             key="chatgpt"
             key="chatgpt"
             header="ChatGPT"
             header="ChatGPT"
@@ -295,4 +327,19 @@ function goBack() {
 :deep(.ant-collapse > .ant-collapse-item > .ant-collapse-header) {
 :deep(.ant-collapse > .ant-collapse-item > .ant-collapse-header) {
   padding: 0 0 10px 0;
   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>
 </style>

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

@@ -90,14 +90,31 @@ const refRename = ref()
 <template>
 <template>
   <ACard :title="$gettext('Configurations')">
   <ACard :title="$gettext('Configurations')">
     <template #extra>
     <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({
         @click="router.push({
           path: '/config/add',
           path: '/config/add',
           query: { basePath: basePath || undefined },
           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>
     </template>
     <InspectConfig ref="refInspectConfig" />
     <InspectConfig ref="refInspectConfig" />
     <StdTable
     <StdTable
@@ -110,24 +127,37 @@ const refRename = ref()
       row-key="name"
       row-key="name"
       :get-params="getParams"
       :get-params="getParams"
       disable-query-params
       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 }">
       <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" />
         <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>
       </template>
     </StdTable>
     </StdTable>
     <Mkdir
     <Mkdir

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

@@ -27,17 +27,15 @@ function ok() {
   refForm.value.validate().then(() => {
   refForm.value.validate().then(() => {
     const otpModal = useOTPModal()
     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 { message } from 'ant-design-vue'
 import config from '@/api/config'
 import config from '@/api/config'
 import useOTPModal from '@/components/OTP/useOTPModal'
 import useOTPModal from '@/components/OTP/useOTPModal'
+import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
 
 
 const emit = defineEmits(['renamed'])
 const emit = defineEmits(['renamed'])
 const visible = ref(false)
 const visible = ref(false)
+const isDirFlag = ref(false)
 
 
 const data = ref({
 const data = ref({
   basePath: '',
   basePath: '',
   orig_name: '',
   orig_name: '',
   new_name: '',
   new_name: '',
+  sync_node_ids: [] as number[],
 })
 })
 
 
 const refForm = ref()
 const refForm = ref()
-function open(basePath: string, origName: string) {
+
+function open(basePath: string, origName: string, isDir: boolean) {
   visible.value = true
   visible.value = true
   data.value.orig_name = origName
   data.value.orig_name = origName
   data.value.new_name = origName
   data.value.new_name = origName
   data.value.basePath = basePath
   data.value.basePath = basePath
+  isDirFlag.value = isDir
 }
 }
 
 
 defineExpose({
 defineExpose({
@@ -26,20 +31,18 @@ defineExpose({
 
 
 function ok() {
 function ok() {
   refForm.value.validate().then(() => {
   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()
     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" />
         <AInput v-model:value="data.new_name" />
       </AFormItem>
       </AFormItem>
+      <AFormItem
+        v-if="isDirFlag"
+        :label="$gettext('Sync')"
+      >
+        <NodeSelector
+          v-model:target="data.sync_node_ids"
+          hidden-local
+        />
+      </AFormItem>
     </AForm>
     </AForm>
   </AModal>
   </AModal>
 </template>
 </template>

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

@@ -5,6 +5,7 @@ import { FitAddon } from '@xterm/addon-fit'
 import _ from 'lodash'
 import _ from 'lodash'
 import ws from '@/lib/websocket'
 import ws from '@/lib/websocket'
 import useOTPModal from '@/components/OTP/useOTPModal'
 import useOTPModal from '@/components/OTP/useOTPModal'
+import otp from '@/api/otp'
 
 
 let term: Terminal | null
 let term: Terminal | null
 let ping: NodeJS.Timeout
 let ping: NodeJS.Timeout
@@ -14,30 +15,29 @@ const websocket = shallowRef()
 const lostConnection = ref(false)
 const lostConnection = ref(false)
 
 
 onMounted(() => {
 onMounted(() => {
+  otp.secure_session_status()
+
   const otpModal = useOTPModal()
   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 (
 import (
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/0xJacky/Nginx-UI/settings"
@@ -7,7 +7,7 @@ import (
 	"net/http"
 	"net/http"
 )
 )
 
 
-func ipWhiteList() gin.HandlerFunc {
+func IPWhiteList() gin.HandlerFunc {
 	return func(c *gin.Context) {
 	return func(c *gin.Context) {
 		clientIP := c.ClientIP()
 		clientIP := c.ClientIP()
 		if len(settings.AuthSettings.IPWhiteList) == 0 || clientIP == "127.0.0.1" {
 		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 (
 import (
 	"encoding/base64"
 	"encoding/base64"
 	"github.com/0xJacky/Nginx-UI/app"
 	"github.com/0xJacky/Nginx-UI/app"
 	"github.com/0xJacky/Nginx-UI/internal/logger"
 	"github.com/0xJacky/Nginx-UI/internal/logger"
 	"github.com/0xJacky/Nginx-UI/internal/user"
 	"github.com/0xJacky/Nginx-UI/internal/user"
-	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-contrib/static"
 	"github.com/gin-contrib/static"
 	"github.com/gin-gonic/gin"
 	"github.com/gin-gonic/gin"
@@ -16,7 +15,7 @@ import (
 	"strings"
 	"strings"
 )
 )
 
 
-func recovery() gin.HandlerFunc {
+func Recovery() gin.HandlerFunc {
 	return func(c *gin.Context) {
 	return func(c *gin.Context) {
 		defer func() {
 		defer func() {
 			if err := recover(); err != nil {
 			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) {
 	return func(c *gin.Context) {
 		abortWithAuthFailure := func() {
 		abortWithAuthFailure := func() {
 			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
 			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
 	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))
 	file, err := f.Open(path.Join(prefix, _path))
 	if file != nil {
 	if file != nil {
 		defer func(file http.File) {
 		defer func(file http.File) {
@@ -127,7 +91,7 @@ func (f serverFileSystemType) Exists(prefix string, _path string) bool {
 	return err == nil
 	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))
 	sub, err := fs.Sub(app.DistFS, path.Join("dist", dir))
 
 
@@ -136,14 +100,14 @@ func mustFS(dir string) (serverFileSystem static.ServeFileSystem) {
 		return
 		return
 	}
 	}
 
 
-	serverFileSystem = serverFileSystemType{
+	serverFileSystem = ServerFileSystemType{
 		http.FS(sub),
 		http.FS(sub),
 	}
 	}
 
 
 	return
 	return
 }
 }
 
 
-func cacheJs() gin.HandlerFunc {
+func CacheJs() gin.HandlerFunc {
 	return func(c *gin.Context) {
 	return func(c *gin.Context) {
 		if strings.Contains(c.Request.URL.String(), "js") {
 		if strings.Contains(c.Request.URL.String(), "js") {
 			c.Header("Cache-Control", "max-age: 1296000")
 			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 (
 import (
 	"crypto/tls"
 	"crypto/tls"
@@ -11,7 +11,7 @@ import (
 	"net/url"
 	"net/url"
 )
 )
 
 
-func proxy() gin.HandlerFunc {
+func Proxy() gin.HandlerFunc {
 	return func(c *gin.Context) {
 	return func(c *gin.Context) {
 		nodeID, ok := c.Get("ProxyNodeID")
 		nodeID, ok := c.Get("ProxyNodeID")
 		if !ok {
 		if !ok {

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

@@ -1,4 +1,4 @@
-package router
+package middleware
 
 
 import (
 import (
 	"github.com/0xJacky/Nginx-UI/internal/logger"
 	"github.com/0xJacky/Nginx-UI/internal/logger"
@@ -9,7 +9,7 @@ import (
 	"net/http"
 	"net/http"
 )
 )
 
 
-func proxyWs() gin.HandlerFunc {
+func ProxyWs() gin.HandlerFunc {
 	return func(c *gin.Context) {
 	return func(c *gin.Context) {
 		nodeID, ok := c.Get("ProxyNodeID")
 		nodeID, ok := c.Get("ProxyNodeID")
 		if !ok {
 		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{},
 		Notification{},
 		AcmeUser{},
 		AcmeUser{},
 		BanIP{},
 		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.Log = field.NewString(tableName, "log")
 	_cert.Resource = field.NewField(tableName, "resource")
 	_cert.Resource = field.NewField(tableName, "resource")
 	_cert.SyncNodeIds = field.NewField(tableName, "sync_node_ids")
 	_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{
 	_cert.DnsCredential = certBelongsToDnsCredential{
 		db: db.Session(&gorm.Session{}),
 		db: db.Session(&gorm.Session{}),
 
 
@@ -65,25 +67,27 @@ func newCert(db *gorm.DB, opts ...gen.DOOption) cert {
 type cert struct {
 type cert struct {
 	certDo
 	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
 	ACMEUser certBelongsToACMEUser
 
 
@@ -119,6 +123,8 @@ func (c *cert) updateTableName(table string) *cert {
 	c.Log = field.NewString(table, "log")
 	c.Log = field.NewString(table, "log")
 	c.Resource = field.NewField(table, "resource")
 	c.Resource = field.NewField(table, "resource")
 	c.SyncNodeIds = field.NewField(table, "sync_node_ids")
 	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()
 	c.fillFieldMap()
 
 
@@ -135,7 +141,7 @@ func (c *cert) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
 }
 }
 
 
 func (c *cert) fillFieldMap() {
 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["id"] = c.ID
 	c.fieldMap["created_at"] = c.CreatedAt
 	c.fieldMap["created_at"] = c.CreatedAt
 	c.fieldMap["updated_at"] = c.UpdatedAt
 	c.fieldMap["updated_at"] = c.UpdatedAt
@@ -153,6 +159,8 @@ func (c *cert) fillFieldMap() {
 	c.fieldMap["log"] = c.Log
 	c.fieldMap["log"] = c.Log
 	c.fieldMap["resource"] = c.Resource
 	c.fieldMap["resource"] = c.Resource
 	c.fieldMap["sync_node_ids"] = c.SyncNodeIds
 	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
 	BanIP         *banIP
 	Cert          *cert
 	Cert          *cert
 	ChatGPTLog    *chatGPTLog
 	ChatGPTLog    *chatGPTLog
+	Config        *config
 	ConfigBackup  *configBackup
 	ConfigBackup  *configBackup
 	DnsCredential *dnsCredential
 	DnsCredential *dnsCredential
 	Environment   *environment
 	Environment   *environment
@@ -39,6 +40,7 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
 	BanIP = &Q.BanIP
 	BanIP = &Q.BanIP
 	Cert = &Q.Cert
 	Cert = &Q.Cert
 	ChatGPTLog = &Q.ChatGPTLog
 	ChatGPTLog = &Q.ChatGPTLog
+	Config = &Q.Config
 	ConfigBackup = &Q.ConfigBackup
 	ConfigBackup = &Q.ConfigBackup
 	DnsCredential = &Q.DnsCredential
 	DnsCredential = &Q.DnsCredential
 	Environment = &Q.Environment
 	Environment = &Q.Environment
@@ -56,6 +58,7 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
 		BanIP:         newBanIP(db, opts...),
 		BanIP:         newBanIP(db, opts...),
 		Cert:          newCert(db, opts...),
 		Cert:          newCert(db, opts...),
 		ChatGPTLog:    newChatGPTLog(db, opts...),
 		ChatGPTLog:    newChatGPTLog(db, opts...),
+		Config:        newConfig(db, opts...),
 		ConfigBackup:  newConfigBackup(db, opts...),
 		ConfigBackup:  newConfigBackup(db, opts...),
 		DnsCredential: newDnsCredential(db, opts...),
 		DnsCredential: newDnsCredential(db, opts...),
 		Environment:   newEnvironment(db, opts...),
 		Environment:   newEnvironment(db, opts...),
@@ -74,6 +77,7 @@ type Query struct {
 	BanIP         banIP
 	BanIP         banIP
 	Cert          cert
 	Cert          cert
 	ChatGPTLog    chatGPTLog
 	ChatGPTLog    chatGPTLog
+	Config        config
 	ConfigBackup  configBackup
 	ConfigBackup  configBackup
 	DnsCredential dnsCredential
 	DnsCredential dnsCredential
 	Environment   environment
 	Environment   environment
@@ -93,6 +97,7 @@ func (q *Query) clone(db *gorm.DB) *Query {
 		BanIP:         q.BanIP.clone(db),
 		BanIP:         q.BanIP.clone(db),
 		Cert:          q.Cert.clone(db),
 		Cert:          q.Cert.clone(db),
 		ChatGPTLog:    q.ChatGPTLog.clone(db),
 		ChatGPTLog:    q.ChatGPTLog.clone(db),
+		Config:        q.Config.clone(db),
 		ConfigBackup:  q.ConfigBackup.clone(db),
 		ConfigBackup:  q.ConfigBackup.clone(db),
 		DnsCredential: q.DnsCredential.clone(db),
 		DnsCredential: q.DnsCredential.clone(db),
 		Environment:   q.Environment.clone(db),
 		Environment:   q.Environment.clone(db),
@@ -119,6 +124,7 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
 		BanIP:         q.BanIP.replaceDB(db),
 		BanIP:         q.BanIP.replaceDB(db),
 		Cert:          q.Cert.replaceDB(db),
 		Cert:          q.Cert.replaceDB(db),
 		ChatGPTLog:    q.ChatGPTLog.replaceDB(db),
 		ChatGPTLog:    q.ChatGPTLog.replaceDB(db),
+		Config:        q.Config.replaceDB(db),
 		ConfigBackup:  q.ConfigBackup.replaceDB(db),
 		ConfigBackup:  q.ConfigBackup.replaceDB(db),
 		DnsCredential: q.DnsCredential.replaceDB(db),
 		DnsCredential: q.DnsCredential.replaceDB(db),
 		Environment:   q.Environment.replaceDB(db),
 		Environment:   q.Environment.replaceDB(db),
@@ -135,6 +141,7 @@ type queryCtx struct {
 	BanIP         *banIPDo
 	BanIP         *banIPDo
 	Cert          *certDo
 	Cert          *certDo
 	ChatGPTLog    *chatGPTLogDo
 	ChatGPTLog    *chatGPTLogDo
+	Config        *configDo
 	ConfigBackup  *configBackupDo
 	ConfigBackup  *configBackupDo
 	DnsCredential *dnsCredentialDo
 	DnsCredential *dnsCredentialDo
 	Environment   *environmentDo
 	Environment   *environmentDo
@@ -151,6 +158,7 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx {
 		BanIP:         q.BanIP.WithContext(ctx),
 		BanIP:         q.BanIP.WithContext(ctx),
 		Cert:          q.Cert.WithContext(ctx),
 		Cert:          q.Cert.WithContext(ctx),
 		ChatGPTLog:    q.ChatGPTLog.WithContext(ctx),
 		ChatGPTLog:    q.ChatGPTLog.WithContext(ctx),
+		Config:        q.Config.WithContext(ctx),
 		ConfigBackup:  q.ConfigBackup.WithContext(ctx),
 		ConfigBackup:  q.ConfigBackup.WithContext(ctx),
 		DnsCredential: q.DnsCredential.WithContext(ctx),
 		DnsCredential: q.DnsCredential.WithContext(ctx),
 		Environment:   q.Environment.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
 package router
 
 
 import (
 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 {
 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
 }
 }