浏览代码

feat(site): sync operation

Jacky 6 月之前
父节点
当前提交
22e37e4b61
共有 43 个文件被更改,包括 2775 次插入2235 次删除
  1. 4 16
      api/sites/duplicate.go
  2. 17 9
      api/sites/router.go
  3. 23 179
      api/sites/site.go
  4. 7 3
      app/src/api/site.ts
  5. 17 19
      app/src/components/StdDesign/StdDataEntry/StdDataEntry.vue
  6. 31 4
      app/src/components/StdDesign/StdDataEntry/StdFormItem.vue
  7. 15 5
      app/src/components/StdDesign/types.d.ts
  8. 7 0
      app/src/constants/form_errors.ts
  9. 183 168
      app/src/language/en/app.po
  10. 195 178
      app/src/language/es/app.po
  11. 183 168
      app/src/language/fr_FR/app.po
  12. 195 178
      app/src/language/ko_KR/app.po
  13. 179 176
      app/src/language/messages.pot
  14. 195 178
      app/src/language/ru_RU/app.po
  15. 191 176
      app/src/language/tr_TR/app.po
  16. 183 168
      app/src/language/vi_VN/app.po
  17. 二进制
      app/src/language/zh_CN/app.mo
  18. 194 178
      app/src/language/zh_CN/app.po
  19. 195 178
      app/src/language/zh_TW/app.po
  20. 6 4
      app/src/lib/http/index.ts
  21. 16 0
      app/src/lib/nprogress/nprogress.ts
  22. 6 7
      app/src/routes/index.ts
  23. 4 4
      app/src/views/site/SiteAdd.vue
  24. 3 3
      app/src/views/site/cert/components/ObtainCert.vue
  25. 0 32
      app/src/views/site/components/Deploy.vue
  26. 0 149
      app/src/views/site/components/SiteDuplicate.vue
  27. 13 13
      app/src/views/site/site_edit/RightSettings.vue
  28. 8 14
      app/src/views/site/site_edit/SiteEdit.vue
  29. 63 0
      app/src/views/site/site_edit/components/ConfigName.vue
  30. 92 0
      app/src/views/site/site_list/SiteDuplicate.vue
  31. 9 9
      app/src/views/site/site_list/SiteList.vue
  32. 5 5
      go.mod
  33. 8 8
      go.sum
  34. 11 2
      internal/helper/file.go
  35. 85 0
      internal/site/delete.go
  36. 80 0
      internal/site/disable.go
  37. 24 0
      internal/site/duplicate.go
  38. 86 0
      internal/site/enable.go
  39. 103 0
      internal/site/rename.go
  40. 100 0
      internal/site/save.go
  41. 35 0
      internal/site/sync.go
  42. 2 2
      model/cert.go
  43. 2 2
      model/site_category.go

+ 4 - 16
api/sites/duplicate.go

@@ -2,15 +2,14 @@ package sites
 
 import (
 	"github.com/0xJacky/Nginx-UI/api"
-	"github.com/0xJacky/Nginx-UI/internal/helper"
-	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/internal/site"
 	"github.com/gin-gonic/gin"
 	"net/http"
 )
 
 func DuplicateSite(c *gin.Context) {
 	// Source name
-	name := c.Param("name")
+	src := c.Param("name")
 
 	// Destination name
 	var json struct {
@@ -21,24 +20,13 @@ func DuplicateSite(c *gin.Context) {
 		return
 	}
 
-	src := nginx.GetConfPath("sites-available", name)
-	dst := nginx.GetConfPath("sites-available", json.Name)
-
-	if helper.FileExists(dst) {
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "File exists",
-		})
-		return
-	}
-
-	_, err := helper.CopyFile(src, dst)
-
+	err := site.Duplicate(src, json.Name)
 	if err != nil {
 		api.ErrHandler(c, err)
 		return
 	}
 
 	c.JSON(http.StatusOK, gin.H{
-		"dst": dst,
+		"message": "ok",
 	})
 }

+ 17 - 9
api/sites/router.go

@@ -3,17 +3,25 @@ package sites
 import "github.com/gin-gonic/gin"
 
 func InitRouter(r *gin.RouterGroup) {
-	r.GET("domains", GetSiteList)
-	r.GET("domains/:name", GetSite)
-	r.POST("domains/:name", SaveSite)
-	r.PUT("domains", BatchUpdateSites)
-	r.POST("domains/:name/enable", EnableSite)
-	r.POST("domains/:name/disable", DisableSite)
-	r.POST("domains/:name/advance", DomainEditByAdvancedMode)
-	r.DELETE("domains/:name", DeleteSite)
-	r.POST("domains/:name/duplicate", DuplicateSite)
+	r.GET("sites", GetSiteList)
+	r.GET("sites/:name", GetSite)
+	r.PUT("sites", BatchUpdateSites)
+	r.POST("sites/:name/advance", DomainEditByAdvancedMode)
 	r.POST("auto_cert/:name", AddDomainToAutoCert)
 	r.DELETE("auto_cert/:name", RemoveDomainFromAutoCert)
+
+	// rename site
+	r.POST("sites/:name/rename", RenameSite)
+	// enable site
+	r.POST("sites/:name/enable", EnableSite)
+	// disable site
+	r.POST("sites/:name/disable", DisableSite)
+	// save site
+	r.POST("sites/:name", SaveSite)
+	// delete site
+	r.DELETE("sites/:name", DeleteSite)
+	// duplicate site
+	r.POST("sites/:name/duplicate", DuplicateSite)
 }
 
 func InitCategoryRouter(r *gin.RouterGroup) {

+ 23 - 179
api/sites/site.go

@@ -3,8 +3,8 @@ package sites
 import (
 	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/internal/cert"
-	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/internal/site"
 	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
@@ -17,14 +17,8 @@ import (
 )
 
 func GetSite(c *gin.Context) {
-	rewriteName, ok := c.Get("rewriteConfigFileName")
 	name := c.Param("name")
 
-	// for modify filename
-	if ok {
-		name = rewriteName.(string)
-	}
-
 	path := nginx.GetConfPath("sites-available", name)
 	file, err := os.Stat(path)
 	if os.IsNotExist(err) {
@@ -51,7 +45,7 @@ func GetSite(c *gin.Context) {
 	}
 
 	s := query.Site
-	site, err := s.Where(s.Path.Eq(path)).FirstOrCreate()
+	siteModel, err := s.Where(s.Path.Eq(path)).FirstOrCreate()
 	if err != nil {
 		api.ErrHandler(c, err)
 		return
@@ -62,7 +56,7 @@ func GetSite(c *gin.Context) {
 		logger.Warn(err)
 	}
 
-	if site.Advanced {
+	if siteModel.Advanced {
 		origContent, err := os.ReadFile(path)
 		if err != nil {
 			api.ErrHandler(c, err)
@@ -71,7 +65,7 @@ func GetSite(c *gin.Context) {
 
 		c.JSON(http.StatusOK, Site{
 			ModifiedAt:      file.ModTime(),
-			Site:            site,
+			Site:            siteModel,
 			Enabled:         enabled,
 			Name:            name,
 			Config:          string(origContent),
@@ -103,8 +97,8 @@ func GetSite(c *gin.Context) {
 	}
 
 	c.JSON(http.StatusOK, Site{
+		Site:            siteModel,
 		ModifiedAt:      file.ModTime(),
-		Site:            site,
 		Enabled:         enabled,
 		Name:            name,
 		Config:          nginxConfig.FmtCode(),
@@ -119,15 +113,7 @@ func GetSite(c *gin.Context) {
 func SaveSite(c *gin.Context) {
 	name := c.Param("name")
 
-	if name == "" {
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "param name is empty",
-		})
-		return
-	}
-
 	var json struct {
-		Name           string   `json:"name" binding:"required"`
 		Content        string   `json:"content" binding:"required"`
 		SiteCategoryID uint64   `json:"site_category_id"`
 		SyncNodeIDs    []uint64 `json:"sync_node_ids"`
@@ -138,129 +124,27 @@ func SaveSite(c *gin.Context) {
 		return
 	}
 
-	path := nginx.GetConfPath("sites-available", name)
-
-	if !json.Overwrite && helper.FileExists(path) {
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "File exists",
-		})
-		return
-	}
-
-	err := os.WriteFile(path, []byte(json.Content), 0644)
+	err := site.Save(name, json.Content, json.Overwrite, json.SiteCategoryID, json.SyncNodeIDs)
 	if err != nil {
 		api.ErrHandler(c, err)
 		return
 	}
-	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", name)
-	s := query.Site
-
-	_, err = s.Where(s.Path.Eq(path)).
-		Select(s.SiteCategoryID, s.SyncNodeIDs).
-		Updates(&model.Site{
-			SiteCategoryID: json.SiteCategoryID,
-			SyncNodeIDs:    json.SyncNodeIDs,
-		})
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	// rename the config file if needed
-	if name != json.Name {
-		newPath := nginx.GetConfPath("sites-available", json.Name)
-		_, _ = s.Where(s.Path.Eq(path)).Update(s.Path, newPath)
-
-		// check if dst file exists, do not rename
-		if helper.FileExists(newPath) {
-			c.JSON(http.StatusNotAcceptable, gin.H{
-				"message": "File exists",
-			})
-			return
-		}
-		// recreate a soft link
-		if helper.FileExists(enabledConfigFilePath) {
-			_ = os.Remove(enabledConfigFilePath)
-			enabledConfigFilePath = nginx.GetConfPath("sites-enabled", json.Name)
-			err = os.Symlink(newPath, enabledConfigFilePath)
-
-			if err != nil {
-				api.ErrHandler(c, err)
-				return
-			}
-		}
-
-		err = os.Rename(path, newPath)
-		if err != nil {
-			api.ErrHandler(c, err)
-			return
-		}
-
-		name = json.Name
-		c.Set("rewriteConfigFileName", name)
-	}
-
-	enabledConfigFilePath = nginx.GetConfPath("sites-enabled", name)
-	if helper.FileExists(enabledConfigFilePath) {
-		// Test nginx configuration
-		output := nginx.TestConf()
-
-		if nginx.GetLogLevel(output) > nginx.Warn {
-			c.JSON(http.StatusInternalServerError, gin.H{
-				"message": output,
-			})
-			return
-		}
-
-		output = nginx.Reload()
-
-		if nginx.GetLogLevel(output) > nginx.Warn {
-			c.JSON(http.StatusInternalServerError, gin.H{
-				"message": output,
-			})
-			return
-		}
-	}
 
 	GetSite(c)
 }
 
-func EnableSite(c *gin.Context) {
-	configFilePath := nginx.GetConfPath("sites-available", c.Param("name"))
-	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", c.Param("name"))
-
-	_, err := os.Stat(configFilePath)
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	if _, err = os.Stat(enabledConfigFilePath); os.IsNotExist(err) {
-		err = os.Symlink(configFilePath, enabledConfigFilePath)
-
-		if err != nil {
-			api.ErrHandler(c, err)
-			return
-		}
+func RenameSite(c *gin.Context) {
+	oldName := c.Param("name")
+	var json struct {
+		NewName string `json:"new_name"`
 	}
-
-	// Test nginx config, if not pass, then disable the site.
-	output := nginx.TestConf()
-	if nginx.GetLogLevel(output) > nginx.Warn {
-		_ = os.Remove(enabledConfigFilePath)
-		c.JSON(http.StatusInternalServerError, gin.H{
-			"message": output,
-		})
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 
-	output = nginx.Reload()
-
-	if nginx.GetLogLevel(output) > nginx.Warn {
-		c.JSON(http.StatusInternalServerError, gin.H{
-			"message": output,
-		})
+	err := site.Rename(oldName, json.NewName)
+	if err != nil {
+		api.ErrHandler(c, err)
 		return
 	}
 
@@ -269,72 +153,32 @@ func EnableSite(c *gin.Context) {
 	})
 }
 
-func DisableSite(c *gin.Context) {
-	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", c.Param("name"))
-	_, err := os.Stat(enabledConfigFilePath)
+func EnableSite(c *gin.Context) {
+	err := site.Enable(c.Param("name"))
 	if err != nil {
 		api.ErrHandler(c, err)
 		return
 	}
 
-	err = os.Remove(enabledConfigFilePath)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
+	c.JSON(http.StatusOK, gin.H{
+		"message": "ok",
+	})
+}
 
-	// delete auto cert record
-	certModel := model.Cert{Filename: c.Param("name")}
-	err = certModel.Remove()
+func DisableSite(c *gin.Context) {
+	err := site.Disable(c.Param("name"))
 	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
-	}
-
 	c.JSON(http.StatusOK, gin.H{
 		"message": "ok",
 	})
 }
 
 func DeleteSite(c *gin.Context) {
-	var err error
-	name := c.Param("name")
-	availablePath := nginx.GetConfPath("sites-available", name)
-
-	s := query.Site
-	_, err = s.Where(s.Path.Eq(availablePath)).Unscoped().Delete(&model.Site{})
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	enabledPath := nginx.GetConfPath("sites-enabled", name)
-	if _, err = os.Stat(availablePath); os.IsNotExist(err) {
-		c.JSON(http.StatusNotFound, gin.H{
-			"message": "site not found",
-		})
-		return
-	}
-
-	if _, err = os.Stat(enabledPath); err == nil {
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "site is enabled",
-		})
-		return
-	}
-
-	certModel := model.Cert{Filename: name}
-	_ = certModel.Remove()
-
-	err = os.Remove(availablePath)
+	err := site.Delete(c.Param("name"))
 	if err != nil {
 		api.ErrHandler(c, err)
 		return

+ 7 - 3
app/src/api/domain.ts → app/src/api/site.ts

@@ -29,7 +29,7 @@ export interface AutoCertRequest {
   key_type: PrivateKeyType
 }
 
-class Domain extends Curd<Site> {
+class SiteCurd extends Curd<Site> {
   // eslint-disable-next-line ts/no-explicit-any
   enable(name: string, config?: any) {
     return http.post(`${this.baseUrl}/${name}/enable`, undefined, config)
@@ -39,6 +39,10 @@ class Domain extends Curd<Site> {
     return http.post(`${this.baseUrl}/${name}/disable`)
   }
 
+  rename(oldName: string, newName: string) {
+    return http.post(`${this.baseUrl}/${oldName}/rename`, { new_name: newName })
+  }
+
   get_template() {
     return http.get('template')
   }
@@ -60,6 +64,6 @@ class Domain extends Curd<Site> {
   }
 }
 
-const domain = new Domain('/domains')
+const site = new SiteCurd('/sites')
 
-export default domain
+export default site

+ 17 - 19
app/src/components/StdDesign/StdDataEntry/StdDataEntry.vue

@@ -1,5 +1,6 @@
 <script setup lang="tsx">
 import type { Column, JSXElements, StdDesignEdit } from '@/components/StdDesign/types'
+import type { FormInstance } from 'ant-design-vue'
 import type { Ref } from 'vue'
 import { labelRender } from '@/components/StdDesign/StdDataEntry'
 import StdFormItem from '@/components/StdDesign/StdDataEntry/StdFormItem.vue'
@@ -7,26 +8,13 @@ import { Form } from 'ant-design-vue'
 
 const props = defineProps<{
   dataList: Column[]
-  // eslint-disable-next-line ts/no-explicit-any
-  dataSource: Record<string, any>
   errors?: Record<string, string>
   type?: 'search' | 'edit'
   layout?: 'horizontal' | 'vertical' | 'inline'
 }>()
 
-const emit = defineEmits<{
-  // eslint-disable-next-line ts/no-explicit-any
-  'update:dataSource': [data: Record<string, any>]
-}>()
-
-const dataSource = computed({
-  get() {
-    return props.dataSource
-  },
-  set(v) {
-    emit('update:dataSource', v)
-  },
-})
+// eslint-disable-next-line ts/no-explicit-any
+const dataSource = defineModel<Record<string, any>>('dataSource')
 
 const slots = useSlots()
 
@@ -37,7 +25,7 @@ function extraRender(extra?: string | (() => string)) {
   return extra
 }
 
-const formRef = ref<InstanceType<typeof Form>>()
+const formRef = ref<FormInstance>()
 
 defineExpose({
   formRef,
@@ -50,7 +38,7 @@ function Render() {
   props.dataList.forEach((v: Column) => {
     const dataIndex = (v.edit?.actualDataIndex ?? v.dataIndex) as string
 
-    dataSource.value[dataIndex] = props.dataSource[dataIndex]
+    dataSource.value![dataIndex] = dataSource.value![dataIndex]
     if (props.type === 'search') {
       if (v.search) {
         const type = (v.search as StdDesignEdit)?.type || v.edit?.type
@@ -75,7 +63,7 @@ function Render() {
 
     let show = true
     if (v.edit?.show && typeof v.edit.show === 'function')
-      show = v.edit.show(props.dataSource)
+      show = v.edit.show(dataSource.value)
 
     if (v.edit?.type && show) {
       template.push(
@@ -87,6 +75,7 @@ function Render() {
           error={props.errors}
           required={v.edit?.config?.required}
           hint={v.edit?.hint}
+          noValidate={v.edit?.config?.noValidate}
         >
           {v.edit.type(v.edit, dataSource.value, dataIndex)}
         </StdFormItem>,
@@ -97,7 +86,16 @@ function Render() {
   if (slots.action)
     template.push(<div class="std-data-entry-action">{slots.action()}</div>)
 
-  return <Form ref={formRef} model={dataSource.value} layout={props.layout || 'vertical'}>{template}</Form>
+  return (
+    <Form
+      class="my-10px!"
+      ref={formRef}
+      model={dataSource.value}
+      layout={props.layout || 'vertical'}
+    >
+      {template}
+    </Form>
+  )
 }
 </script>
 

+ 31 - 4
app/src/components/StdDesign/StdDataEntry/StdFormItem.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import type { Column } from '@/components/StdDesign/types'
-import { computed } from 'vue'
+import type { Rule } from 'ant-design-vue/es/form'
+import FormErrors from '@/constants/form_errors'
 
 const props = defineProps<Props>()
 
@@ -13,18 +14,42 @@ export interface Props {
     [key: string]: string
   }
   required?: boolean
+  noValidate?: boolean
 }
 
 const tag = computed(() => {
   return props.error?.[props.dataIndex!.toString()] ?? ''
 })
 
+// const valid_status = computed(() => {
+//   if (tag.value)
+//     return 'error'
+//   else return 'success'
+// })
+
 const help = computed(() => {
-  if (tag.value.includes('required'))
-    return $gettext('This field should not be empty')
+  const rules = tag.value.split(',')
+
+  for (const rule of rules) {
+    if (FormErrors[rule])
+      return FormErrors[rule]()
+  }
 
   return props.hint
 })
+
+// eslint-disable-next-line ts/no-explicit-any
+async function validator(_: Rule, value: any): Promise<any> {
+  return new Promise((resolve, reject) => {
+    if (props.required && !props.noValidate && (!value && value !== 0)) {
+      reject(help.value ?? $gettext('This field should not be empty'))
+
+      return
+    }
+
+    resolve(true)
+  })
+}
 </script>
 
 <template>
@@ -32,7 +57,9 @@ const help = computed(() => {
     :name="dataIndex as string"
     :label="label"
     :help="help"
-    :required="required"
+    :rules="{ required, validator }"
+    :validate-status="tag ? 'error' : undefined"
+    :auto-link="false"
   >
     <slot />
   </AFormItem>

+ 15 - 5
app/src/components/StdDesign/types.d.ts

@@ -45,19 +45,29 @@ export interface StdDesignEdit {
 
   config?: {
     label?: string | (() => string) // label for form item
-    size?: string // class size of Std image upload
+    recordValueIndex?: any // relative to api return
     placeholder?: string | (() => string) // placeholder for input
     generate?: boolean // generate btn for StdPassword
+    selectionType?: any
+    api?: Curd
+    valueApi?: Curd
+    columns?: any
+    disableSearch?: boolean
+    description?: string
+    bind?: any
+    itemKey?: any // default is id
+    dataSourceValueIndex?: any // relative to dataSource
+    defaultValue?: any
+    required?: boolean
+    noValidate?: boolean
     min?: number // min value for input number
     max?: number // max value for input number
-    error_messages?: Ref
-    required?: boolean
-    // eslint-disable-next-line ts/no-explicit-any
-    defaultValue?: any
     addonBefore?: string // for inputNumber
     addonAfter?: string // for inputNumber
     prefix?: string // for inputNumber
     suffix?: string // for inputNumber
+    size?: string // class size of Std image upload
+    error_messages?: Ref
   }
 
   flex?: Flex

+ 7 - 0
app/src/constants/form_errors.ts

@@ -0,0 +1,7 @@
+export default {
+  required: () => $gettext('This field should not be empty'),
+  email: () => $gettext('This field should be a valid email address'),
+  db_unique: () => $gettext('This value is already taken'),
+  hostname: () => $gettext('This field should be a valid hostname'),
+  safety_text: () => $gettext('This field should only contain letters, unicode characters, numbers, and -_.'),
+}

文件差异内容过多而无法显示
+ 183 - 168
app/src/language/en/app.po


文件差异内容过多而无法显示
+ 195 - 178
app/src/language/es/app.po


文件差异内容过多而无法显示
+ 183 - 168
app/src/language/fr_FR/app.po


文件差异内容过多而无法显示
+ 195 - 178
app/src/language/ko_KR/app.po


文件差异内容过多而无法显示
+ 179 - 176
app/src/language/messages.pot


文件差异内容过多而无法显示
+ 195 - 178
app/src/language/ru_RU/app.po


文件差异内容过多而无法显示
+ 191 - 176
app/src/language/tr_TR/app.po


文件差异内容过多而无法显示
+ 183 - 168
app/src/language/vi_VN/app.po


二进制
app/src/language/zh_CN/app.mo


文件差异内容过多而无法显示
+ 194 - 178
app/src/language/zh_CN/app.po


文件差异内容过多而无法显示
+ 195 - 178
app/src/language/zh_TW/app.po


+ 6 - 4
app/src/lib/http/index.ts

@@ -1,9 +1,9 @@
 import type { AxiosRequestConfig } from 'axios'
 import use2FAModal from '@/components/TwoFA/use2FAModal'
+import { useNProgress } from '@/lib/nprogress/nprogress'
 import { useSettingsStore, useUserStore } from '@/pinia'
 import router from '@/routes'
 import axios from 'axios'
-import NProgress from 'nprogress'
 
 import { storeToRefs } from 'pinia'
 import 'nprogress/nprogress.css'
@@ -26,9 +26,11 @@ const instance = axios.create({
   }],
 })
 
+const nprogress = useNProgress()
+
 instance.interceptors.request.use(
   config => {
-    NProgress.start()
+    nprogress.start()
     if (token.value) {
       // eslint-disable-next-line ts/no-explicit-any
       (config.headers as any).Authorization = token.value
@@ -53,12 +55,12 @@ instance.interceptors.request.use(
 
 instance.interceptors.response.use(
   response => {
-    NProgress.done()
+    nprogress.done()
 
     return Promise.resolve(response.data)
   },
   async error => {
-    NProgress.done()
+    nprogress.done()
 
     const otpModal = use2FAModal()
     switch (error.response.status) {

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

@@ -0,0 +1,16 @@
+import _ from 'lodash'
+import NProgress from 'nprogress'
+
+NProgress.configure({ showSpinner: false, trickleSpeed: 300 })
+
+const done = _.debounce(NProgress.done, 300, {
+  leading: false,
+  trailing: true,
+})
+
+export function useNProgress() {
+  return {
+    start: NProgress.start,
+    done,
+  }
+}

+ 6 - 7
app/src/routes/index.ts

@@ -1,6 +1,7 @@
 import type { RouteRecordRaw } from 'vue-router'
-import { useSettingsStore, useUserStore } from '@/pinia'
+import { useNProgress } from '@/lib/nprogress/nprogress'
 
+import { useSettingsStore, useUserStore } from '@/pinia'
 import {
   BellOutlined,
   CloudOutlined,
@@ -15,10 +16,8 @@ import {
   ShareAltOutlined,
   UserOutlined,
 } from '@ant-design/icons-vue'
-import NProgress from 'nprogress'
 
 import { createRouter, createWebHashHistory } from 'vue-router'
-
 import 'nprogress/nprogress.css'
 
 export const routes: RouteRecordRaw[] = [
@@ -74,7 +73,7 @@ export const routes: RouteRecordRaw[] = [
         }, {
           path: ':name',
           name: 'Edit Site',
-          component: () => import('@/views/site/SiteEdit.vue'),
+          component: () => import('@/views/site/site_edit/SiteEdit.vue'),
           meta: {
             name: () => $gettext('Edit Site'),
             hiddenInSidebar: true,
@@ -324,12 +323,12 @@ const router = createRouter({
   routes,
 })
 
-NProgress.configure({ showSpinner: false })
+const nprogress = useNProgress()
 
 router.beforeEach((to, _, next) => {
   document.title = `${to?.meta.name?.() ?? ''} | Nginx UI`
 
-  NProgress.start()
+  nprogress.start()
 
   const user = useUserStore()
 
@@ -340,7 +339,7 @@ router.beforeEach((to, _, next) => {
 })
 
 router.afterEach(() => {
-  NProgress.done()
+  nprogress.done()
 })
 
 export default router

+ 4 - 4
app/src/views/site/SiteAdd.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import type { NgxConfig } from '@/api/ngx'
-import domain from '@/api/domain'
 import ngx from '@/api/ngx'
+import site from '@/api/site'
 import DirectiveEditor from '@/views/site/ngx_conf/directive/DirectiveEditor.vue'
 import LocationEditor from '@/views/site/ngx_conf/LocationEditor.vue'
 import NgxConfigEditor from '@/views/site/ngx_conf/NgxConfigEditor.vue'
@@ -26,17 +26,17 @@ onMounted(() => {
 })
 
 function init() {
-  domain.get_template().then(r => {
+  site.get_template().then(r => {
     Object.assign(ngx_config, r.tokenized)
   })
 }
 
 async function save() {
   return ngx.build_config(ngx_config).then(r => {
-    domain.save(ngx_config.name, { name: ngx_config.name, content: r.content, overwrite: true }).then(() => {
+    site.save(ngx_config.name, { name: ngx_config.name, content: r.content, overwrite: true }).then(() => {
       message.success($gettext('Saved successfully'))
 
-      domain.enable(ngx_config.name).then(() => {
+      site.enable(ngx_config.name).then(() => {
         message.success($gettext('Enabled successfully'))
         window.scroll({ top: 0, left: 0, behavior: 'smooth' })
       }).catch(e => {

+ 3 - 3
app/src/views/site/cert/components/ObtainCert.vue

@@ -4,7 +4,7 @@ import type { CertificateResult } from '@/api/cert'
 import type { NgxConfig, NgxDirective } from '@/api/ngx'
 import type { PrivateKeyType } from '@/constants'
 import type { ComputedRef, Ref } from 'vue'
-import domain from '@/api/domain'
+import site from '@/api/site'
 import AutoCertStepOne from '@/views/site/cert/components/AutoCertStepOne.vue'
 import ObtainCertLive from '@/views/site/cert/components/ObtainCertLive.vue'
 import { message, Modal } from 'ant-design-vue'
@@ -59,7 +59,7 @@ async function resolveCert({ ssl_certificate, ssl_certificate_key, key_type }: C
 
 function change_auto_cert(status: boolean, key_type?: PrivateKeyType) {
   if (status) {
-    domain.add_auto_cert(props.configName, {
+    site.add_auto_cert(props.configName, {
       domains: name.value.trim().split(' '),
       challenge_method: data.value.challenge_method!,
       dns_credential_id: data.value.dns_credential_id!,
@@ -71,7 +71,7 @@ function change_auto_cert(status: boolean, key_type?: PrivateKeyType) {
     })
   }
   else {
-    domain.remove_auto_cert(props.configName).then(() => {
+    site.remove_auto_cert(props.configName).then(() => {
       message.success($gettext('Auto-renewal disabled for %{name}', { name: name.value }))
     }).catch(e => {
       message.error(e.message ?? $gettext('Disable auto-renewal failed for %{name}', { name: name.value }))

+ 0 - 32
app/src/views/site/components/Deploy.vue

@@ -1,32 +0,0 @@
-<script setup lang="ts">
-import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
-
-const node_map = ref({})
-const target = ref([])
-</script>
-
-<template>
-  <NodeSelector
-    v-model:target="target"
-    v-model:map="node_map"
-    class="mb-4"
-    hidden-local
-  />
-</template>
-
-<style scoped lang="less">
-.overwrite {
-  margin-right: 15px;
-
-  span {
-    color: #9b9b9b;
-  }
-}
-
-.node-deploy-control {
-  display: flex;
-  justify-content: flex-end;
-  margin-top: 10px;
-  align-items: center;
-}
-</style>

+ 0 - 149
app/src/views/site/components/SiteDuplicate.vue

@@ -1,149 +0,0 @@
-<script setup lang="ts">
-import domain from '@/api/domain'
-
-import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
-import gettext from '@/gettext'
-import { useSettingsStore } from '@/pinia'
-import { Form, message, notification } from 'ant-design-vue'
-
-const props = defineProps<{
-  visible: boolean
-  name: string
-}>()
-
-const emit = defineEmits(['update:visible', 'duplicated'])
-
-const settings = useSettingsStore()
-
-const show = computed({
-  get() {
-    return props.visible
-  },
-  set(v) {
-    emit('update:visible', v)
-  },
-})
-
-interface Model {
-  name: string // site name
-  target: number[] // ids of deploy targets
-}
-
-const modelRef: Model = reactive({ name: '', target: [] })
-
-const rulesRef = reactive({
-  name: [
-    {
-      required: true,
-      message: () => $gettext('Please input name, '
-        + 'this will be used as the filename of the new configuration!'),
-    },
-  ],
-  target: [
-    {
-      required: true,
-      message: () => $gettext('Please select at least one node!'),
-    },
-  ],
-})
-
-const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
-
-const loading = ref(false)
-
-const node_map: Record<number, string> = reactive({})
-
-function onSubmit() {
-  validate().then(async () => {
-    loading.value = true
-
-    modelRef.target.forEach(id => {
-      if (id === 0) {
-        domain.duplicate(props.name, { name: modelRef.name }).then(() => {
-          message.success($gettext('Duplicate to local successfully'))
-          show.value = false
-          emit('duplicated')
-        }).catch(e => {
-          message.error($gettext(e?.message ?? 'Server error'))
-        })
-      }
-      else {
-        // get source content
-
-        domain.get(props.name).then(r => {
-          domain.save(modelRef.name, {
-            name: modelRef.name,
-            content: r.config,
-
-          }, { headers: { 'X-Node-ID': id } }).then(() => {
-            notification.success({
-              message: $gettext('Duplicate successfully'),
-              description:
-                $gettext('Duplicate %{conf_name} to %{node_name} successfully', { conf_name: props.name, node_name: node_map[id] }),
-            })
-          }).catch(e => {
-            notification.error({
-              message: $gettext('Duplicate failed'),
-              description: $gettext(e?.message ?? 'Server error'),
-            })
-          })
-          if (r.enabled) {
-            domain.enable(modelRef.name, { headers: { 'X-Node-ID': id } }).then(() => {
-              notification.success({
-                message: $gettext('Enabled successfully'),
-              })
-            })
-          }
-        })
-      }
-    })
-
-    loading.value = false
-  })
-}
-
-watch(() => props.visible, v => {
-  if (v) {
-    modelRef.name = props.name // default with source name
-    modelRef.target = [0]
-    nextTick(() => clearValidate())
-  }
-})
-
-watch(() => gettext.current, () => {
-  clearValidate()
-})
-</script>
-
-<template>
-  <AModal
-    v-model:open="show"
-    :title="$gettext('Duplicate')"
-    :confirm-loading="loading"
-    :mask="false"
-    @ok="onSubmit"
-  >
-    <AForm layout="vertical">
-      <AFormItem
-        :label="$gettext('Name')"
-        v-bind="validateInfos.name"
-      >
-        <AInput v-model:value="modelRef.name" />
-      </AFormItem>
-      <AFormItem
-        v-if="!settings.is_remote"
-        :label="$gettext('Target')"
-        v-bind="validateInfos.target"
-      >
-        <NodeSelector
-          v-model:target="modelRef.target"
-          v-model:map="node_map"
-        />
-      </AFormItem>
-    </AForm>
-  </AModal>
-</template>
-
-<style lang="less" scoped>
-
-</style>

+ 13 - 13
app/src/views/site/components/RightSettings.vue → app/src/views/site/site_edit/RightSettings.vue

@@ -1,9 +1,9 @@
 <script setup lang="ts">
-import type { Site } from '@/api/domain'
 import type { ChatComplicationMessage } from '@/api/openai'
+import type { Site } from '@/api/site'
 import type { CheckedType } from '@/types'
 import type { Ref } from 'vue'
-import domain from '@/api/domain'
+import site from '@/api/site'
 import site_category from '@/api/site_category'
 import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
 import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
@@ -11,6 +11,7 @@ import StdSelector from '@/components/StdDesign/StdDataEntry/components/StdSelec
 import { formatDateTime } from '@/lib/helper'
 import { useSettingsStore } from '@/pinia'
 import siteCategoryColumns from '@/views/site/site_category/columns'
+import ConfigName from '@/views/site/site_edit/components/ConfigName.vue'
 import { InfoCircleOutlined } from '@ant-design/icons-vue'
 import { message, Modal } from 'ant-design-vue'
 
@@ -18,18 +19,17 @@ const settings = useSettingsStore()
 
 const configText = inject('configText') as Ref<string>
 const enabled = inject('enabled') as Ref<boolean>
-const name = inject('name') as Ref<string>
+const name = inject('name') as ComputedRef<string>
 const filepath = inject('filepath') as Ref<string>
-const history_chatgpt_record = inject('history_chatgpt_record') as Ref<ChatComplicationMessage[]>
-const filename = inject('filename') as Ref<string | number | undefined>
+const historyChatgptRecord = inject('history_chatgpt_record') as Ref<ChatComplicationMessage[]>
 const data = inject('data') as Ref<Site>
 
 const [modal, ContextHolder] = Modal.useModal()
 
-const active_key = ref(['1', '2', '3'])
+const activeKey = ref(['1', '2', '3'])
 
 function enable() {
-  domain.enable(name.value).then(() => {
+  site.enable(name.value).then(() => {
     message.success($gettext('Enabled successfully'))
     enabled.value = true
   }).catch(r => {
@@ -38,7 +38,7 @@ function enable() {
 }
 
 function disable() {
-  domain.disable(name.value).then(() => {
+  site.disable(name.value).then(() => {
     message.success($gettext('Disabled successfully'))
     enabled.value = false
   }).catch(r => {
@@ -46,7 +46,7 @@ function disable() {
   })
 }
 
-function on_change_enabled(checked: CheckedType) {
+function onChangeEnabled(checked: CheckedType) {
   modal.confirm({
     title: checked ? $gettext('Do you want to enable this site?') : $gettext('Do you want to disable this site?'),
     mask: false,
@@ -70,7 +70,7 @@ function on_change_enabled(checked: CheckedType) {
   >
     <ContextHolder />
     <ACollapse
-      v-model:active-key="active_key"
+      v-model:active-key="activeKey"
       ghost
       collapsible="header"
     >
@@ -82,11 +82,11 @@ function on_change_enabled(checked: CheckedType) {
           <AFormItem :label="$gettext('Enabled')">
             <ASwitch
               :checked="enabled"
-              @change="on_change_enabled"
+              @change="onChangeEnabled"
             />
           </AFormItem>
           <AFormItem :label="$gettext('Name')">
-            <AInput v-model:value="filename" />
+            <ConfigName v-if="name" :name />
           </AFormItem>
           <AFormItem :label="$gettext('Category')">
             <StdSelector
@@ -138,7 +138,7 @@ function on_change_enabled(checked: CheckedType) {
         header="ChatGPT"
       >
         <ChatGPT
-          v-model:history-messages="history_chatgpt_record"
+          v-model:history-messages="historyChatgptRecord"
           :content="configText"
           :path="filepath"
         />

+ 8 - 14
app/src/views/site/SiteEdit.vue → app/src/views/site/site_edit/SiteEdit.vue

@@ -1,27 +1,23 @@
 <script setup lang="ts">
 import type { CertificateInfo } from '@/api/cert'
-import type { Site } from '@/api/domain'
 import type { NgxConfig } from '@/api/ngx'
-
 import type { ChatComplicationMessage } from '@/api/openai'
+
+import type { Site } from '@/api/site'
 import type { CheckedType } from '@/types'
 import config from '@/api/config'
-import domain from '@/api/domain'
 import ngx from '@/api/ngx'
+import site from '@/api/site'
 import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
 import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
-import RightSettings from '@/views/site/components/RightSettings.vue'
 import NgxConfigEditor from '@/views/site/ngx_conf/NgxConfigEditor.vue'
+import RightSettings from '@/views/site/site_edit/RightSettings.vue'
 import { message } from 'ant-design-vue'
 
 const route = useRoute()
 const router = useRouter()
 
-const name = ref(route.params.name.toString())
-
-watch(route, () => {
-  name.value = route.params?.name?.toString() ?? ''
-})
+const name = computed(() => route.params?.name?.toString() ?? '')
 
 const ngx_config: NgxConfig = reactive({
   name: '',
@@ -77,7 +73,7 @@ function handle_response(r: Site) {
 
 function init() {
   if (name.value) {
-    domain.get(name.value).then(r => {
+    site.get(name.value).then(r => {
       handle_response(r)
     }).catch(handle_parse_error)
   }
@@ -96,7 +92,7 @@ function handle_parse_error(e: { error?: string, message: string }) {
 }
 
 function on_mode_change(advanced: CheckedType) {
-  domain.advance_mode(name.value, { advanced: advanced as boolean }).then(() => {
+  site.advance_mode(name.value, { advanced: advanced as boolean }).then(() => {
     advanceMode.value = advanced as boolean
     if (advanced) {
       build_config()
@@ -130,8 +126,7 @@ async function save() {
     }
   }
 
-  return domain.save(name.value, {
-    name: filename.value || name.value,
+  return site.save(name.value, {
     content: configText.value,
     overwrite: true,
     site_category_id: data.value.site_category_id,
@@ -154,7 +149,6 @@ provide('ngx_config', ngx_config)
 provide('history_chatgpt_record', history_chatgpt_record)
 provide('enabled', enabled)
 provide('name', name)
-provide('filename', filename)
 provide('filepath', filepath)
 provide('data', data)
 </script>

+ 63 - 0
app/src/views/site/site_edit/components/ConfigName.vue

@@ -0,0 +1,63 @@
+<script setup lang="ts">
+import site from '@/api/site'
+import { message } from 'ant-design-vue'
+
+const props = defineProps<{
+  name: string
+}>()
+
+const router = useRouter()
+
+const modify = ref(false)
+const buffer = ref('')
+const loading = ref(false)
+
+onMounted(() => {
+  buffer.value = props.name
+})
+
+function clickModify() {
+  modify.value = true
+}
+
+function save() {
+  loading.value = true
+  site.rename(props.name, buffer.value).then(() => {
+    modify.value = false
+    message.success($gettext('Renamed successfully'))
+    router.push({
+      path: `/sites/${buffer.value}`,
+    })
+  }).catch(e => {
+    message.error($gettext(e?.message ?? 'Server error'))
+  }).finally(() => {
+    loading.value = false
+  })
+}
+</script>
+
+<template>
+  <div v-if="!modify" class="flex items-center">
+    <div class="mr-2">
+      {{ buffer }}
+    </div>
+    <div>
+      <AButton type="link" size="small" @click="clickModify">
+        {{ $gettext('Rename') }}
+      </AButton>
+    </div>
+  </div>
+  <div v-else>
+    <AInput v-model:value="buffer">
+      <template #suffix>
+        <AButton :disabled="buffer === name" type="link" size="small" :loading @click="save">
+          {{ $gettext('Save') }}
+        </AButton>
+      </template>
+    </AInput>
+  </div>
+</template>
+
+<style scoped lang="less">
+
+</style>

+ 92 - 0
app/src/views/site/site_list/SiteDuplicate.vue

@@ -0,0 +1,92 @@
+<script setup lang="ts">
+import site from '@/api/site'
+
+import gettext from '@/gettext'
+import { Form, message } from 'ant-design-vue'
+
+const props = defineProps<{
+  visible: boolean
+  name: string
+}>()
+
+const emit = defineEmits(['update:visible', 'duplicated'])
+
+const show = computed({
+  get() {
+    return props.visible
+  },
+  set(v) {
+    emit('update:visible', v)
+  },
+})
+
+interface Model {
+  name: string // site name
+}
+
+const modelRef: Model = reactive({ name: '' })
+
+const rulesRef = reactive({
+  name: [
+    {
+      required: true,
+      message: () => $gettext('Please input name, '
+        + 'this will be used as the filename of the new configuration.'),
+    },
+  ],
+})
+
+const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
+
+const loading = ref(false)
+
+function onSubmit() {
+  validate().then(async () => {
+    loading.value = true
+
+    site.duplicate(props.name, { name: modelRef.name }).then(() => {
+      message.success($gettext('Duplicate to local successfully'))
+      show.value = false
+      emit('duplicated')
+    }).catch(e => {
+      message.error($gettext(e?.message ?? 'Server error'))
+    })
+
+    loading.value = false
+  })
+}
+
+watch(() => props.visible, v => {
+  if (v) {
+    modelRef.name = props.name // default with source name
+    nextTick(() => clearValidate())
+  }
+})
+
+watch(() => gettext.current, () => {
+  clearValidate()
+})
+</script>
+
+<template>
+  <AModal
+    v-model:open="show"
+    :title="$gettext('Duplicate')"
+    :confirm-loading="loading"
+    :mask="false"
+    @ok="onSubmit"
+  >
+    <AForm layout="vertical">
+      <AFormItem
+        :label="$gettext('Name')"
+        v-bind="validateInfos.name"
+      >
+        <AInput v-model:value="modelRef.name" />
+      </AFormItem>
+    </AForm>
+  </AModal>
+</template>
+
+<style lang="less" scoped>
+
+</style>

+ 9 - 9
app/src/views/site/site_list/SiteList.vue

@@ -1,15 +1,15 @@
 <script setup lang="tsx">
-import type { Site } from '@/api/domain'
+import type { Site } from '@/api/site'
 import type { SiteCategory } from '@/api/site_category'
 import type { Column } from '@/components/StdDesign/types'
-import domain from '@/api/domain'
+import site from '@/api/site'
 import site_category from '@/api/site_category'
+import StdBatchEdit from '@/components/StdDesign/StdDataDisplay/StdBatchEdit.vue'
 import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
 import InspectConfig from '@/views/config/InspectConfig.vue'
-import SiteDuplicate from '@/views/site/components/SiteDuplicate.vue'
 import columns from '@/views/site/site_list/columns'
+import SiteDuplicate from '@/views/site/site_list/SiteDuplicate.vue'
 import { message } from 'ant-design-vue'
-import StdBatchEdit from '../../../components/StdDesign/StdDataDisplay/StdBatchEdit.vue'
 
 const route = useRoute()
 const router = useRouter()
@@ -43,7 +43,7 @@ onMounted(async () => {
 })
 
 function enable(name: string) {
-  domain.enable(name).then(() => {
+  site.enable(name).then(() => {
     message.success($gettext('Enabled successfully'))
     table.value?.get_list()
     inspect_config.value?.test()
@@ -53,7 +53,7 @@ function enable(name: string) {
 }
 
 function disable(name: string) {
-  domain.disable(name).then(() => {
+  site.disable(name).then(() => {
     message.success($gettext('Disabled successfully'))
     table.value?.get_list()
     inspect_config.value?.test()
@@ -63,7 +63,7 @@ function disable(name: string) {
 }
 
 function destroy(site_name: string) {
-  domain.destroy(site_name).then(() => {
+  site.destroy(site_name).then(() => {
     table.value.get_list()
     message.success($gettext('Delete site: %{site_name}', { site_name }))
     inspect_config.value?.test()
@@ -104,7 +104,7 @@ function handleBatchUpdated() {
 
     <StdTable
       ref="table"
-      :api="domain"
+      :api="site"
       :columns="columns"
       row-key="name"
       disable-delete
@@ -162,7 +162,7 @@ function handleBatchUpdated() {
     </StdTable>
     <StdBatchEdit
       ref="stdBatchEditRef"
-      :api="domain"
+      :api="site"
       :columns
       @save="handleBatchUpdated"
     />

+ 5 - 5
go.mod

@@ -17,6 +17,7 @@ require (
 	github.com/go-acme/lego/v4 v4.19.2
 	github.com/go-co-op/gocron/v2 v2.12.1
 	github.com/go-playground/validator/v10 v10.22.1
+	github.com/go-resty/resty/v2 v2.15.3
 	github.com/go-webauthn/webauthn v0.11.2
 	github.com/golang-jwt/jwt/v5 v5.2.1
 	github.com/google/uuid v1.6.0
@@ -72,7 +73,7 @@ require (
 	github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
 	github.com/StackExchange/wmi v1.2.1 // indirect
 	github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
-	github.com/aliyun/alibaba-cloud-sdk-go v1.63.38 // indirect
+	github.com/aliyun/alibaba-cloud-sdk-go v1.63.39 // indirect
 	github.com/aws/aws-sdk-go-v2 v1.32.2 // indirect
 	github.com/aws/aws-sdk-go-v2/config v1.28.0 // indirect
 	github.com/aws/aws-sdk-go-v2/credentials v1.17.41 // indirect
@@ -120,7 +121,6 @@ require (
 	github.com/go-ole/go-ole v1.3.0 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
-	github.com/go-resty/resty/v2 v2.15.3 // indirect
 	github.com/go-sql-driver/mysql v1.8.1 // indirect
 	github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
 	github.com/go-webauthn/x v0.1.15 // indirect
@@ -143,7 +143,7 @@ require (
 	github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
 	github.com/hashicorp/go-uuid v1.0.3 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
-	github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.118 // indirect
+	github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.119 // indirect
 	github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
 	github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect
 	github.com/itchyny/timefmt-go v0.1.6 // indirect
@@ -215,7 +215,7 @@ require (
 	github.com/shopspring/decimal v1.4.0 // indirect
 	github.com/sirupsen/logrus v1.9.3 // indirect
 	github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
-	github.com/softlayer/softlayer-go v1.1.6 // indirect
+	github.com/softlayer/softlayer-go v1.1.7 // indirect
 	github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
 	github.com/sony/gobreaker v1.0.0 // indirect
 	github.com/sony/sonyflake v1.2.0 // indirect
@@ -236,7 +236,7 @@ require (
 	github.com/uozi-tech/cosy-driver-mysql v0.2.2 // indirect
 	github.com/uozi-tech/cosy-driver-postgres v0.2.1 // indirect
 	github.com/vinyldns/go-vinyldns v0.9.16 // indirect
-	github.com/vultr/govultr/v3 v3.11.0 // indirect
+	github.com/vultr/govultr/v3 v3.11.1 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/yandex-cloud/go-genproto v0.0.0-20241021132621-28bb61d00c2f // indirect
 	github.com/yandex-cloud/go-sdk v0.0.0-20241021153520-213d4c625eca // indirect

+ 8 - 8
go.sum

@@ -683,8 +683,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
-github.com/aliyun/alibaba-cloud-sdk-go v1.63.38 h1:MVTkZJ63DE8XMVLQ5a0M1Elv+RHePK8UPrKjDdgbzDM=
-github.com/aliyun/alibaba-cloud-sdk-go v1.63.38/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
+github.com/aliyun/alibaba-cloud-sdk-go v1.63.39 h1:zlenrBGDiSEu7YnpWiAPscKNolgIo9Z6jvM5pcWAEL4=
+github.com/aliyun/alibaba-cloud-sdk-go v1.63.39/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
@@ -1161,8 +1161,8 @@ github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/J
 github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
 github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.118 h1:YHcixaT7Le4PxuxN07KQ5j9nPeH4ZdyXtMTSgA+Whh8=
-github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.118/go.mod h1:JWz2ujO9X3oU5wb6kXp+DpR2UuDj2SldDbX8T0FSuhI=
+github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.119 h1:2pi/hbcuv0CNVcsODkTYZY+X9j5uc1GTjSjX1cWMp/4=
+github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.119/go.mod h1:JWz2ujO9X3oU5wb6kXp+DpR2UuDj2SldDbX8T0FSuhI=
 github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@@ -1580,8 +1580,8 @@ github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h
 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/smartystreets/gunit v1.1.3 h1:32x+htJCu3aMswhPw3teoJ+PnWPONqdNgaGs6Qt8ZaU=
 github.com/smartystreets/gunit v1.1.3/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ=
-github.com/softlayer/softlayer-go v1.1.6 h1:VRNXiXZTpb7cfKjimU5E7W9zzKYzWMr/xtqlJ0pHwkQ=
-github.com/softlayer/softlayer-go v1.1.6/go.mod h1:WeJrBLoTJcaT8nO1azeyHyNpo/fDLtbpbvh+pzts+Qw=
+github.com/softlayer/softlayer-go v1.1.7 h1:SgTL+pQZt1h+5QkAhVmHORM/7N9c1X0sljJhuOIHxWE=
+github.com/softlayer/softlayer-go v1.1.7/go.mod h1:WeJrBLoTJcaT8nO1azeyHyNpo/fDLtbpbvh+pzts+Qw=
 github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e h1:3OgWYFw7jxCZPcvAg+4R8A50GZ+CCkARF10lxu2qDsQ=
 github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e/go.mod h1:fKZCUVdirrxrBpwd9wb+lSoVixvpwAu8eHzbQB2tums=
 github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
@@ -1682,8 +1682,8 @@ github.com/uozi-tech/cosy-driver-sqlite v0.2.0 h1:eTpIMyGoFUK4JcaiKfJHD5AyiM6vtC
 github.com/uozi-tech/cosy-driver-sqlite v0.2.0/go.mod h1:87a6mzn5IuEtIR4z7U4Ey8eKLGfNEOSkv7kPQlbNQgM=
 github.com/vinyldns/go-vinyldns v0.9.16 h1:GZJStDkcCk1F1AcRc64LuuMh+ENL8pHA0CVd4ulRMcQ=
 github.com/vinyldns/go-vinyldns v0.9.16/go.mod h1:5qIJOdmzAnatKjurI+Tl4uTus7GJKJxb+zitufjHs3Q=
-github.com/vultr/govultr/v3 v3.11.0 h1:YlAal70AaJ0k848RqcmjAzFcmLS9n8VtPgU68UxvVm8=
-github.com/vultr/govultr/v3 v3.11.0/go.mod h1:q34Wd76upKmf+vxFMgaNMH3A8BbsPBmSYZUGC8oZa5w=
+github.com/vultr/govultr/v3 v3.11.1 h1:Wc6wFTwh/gBZlOqSK1Hn3P9JWoFa7NCf52vGLwQcJOg=
+github.com/vultr/govultr/v3 v3.11.1/go.mod h1:q34Wd76upKmf+vxFMgaNMH3A8BbsPBmSYZUGC8oZa5w=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=

+ 11 - 2
internal/helper/file.go

@@ -2,8 +2,17 @@ package helper
 
 import "os"
 
-func FileExists(filename string) bool {
-	_, err := os.Stat(filename)
+func FileExists(filepath string) bool {
+	_, err := os.Stat(filepath)
+	if os.IsNotExist(err) {
+		return false
+	}
+
+	return true
+}
+
+func SymbolLinkExists(filepath string) bool {
+	_, err := os.Lstat(filepath)
 	if os.IsNotExist(err) {
 		return false
 	}

+ 85 - 0
internal/site/delete.go

@@ -0,0 +1,85 @@
+package site
+
+import (
+	"fmt"
+	"github.com/0xJacky/Nginx-UI/internal/helper"
+	"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/go-resty/resty/v2"
+	"github.com/uozi-tech/cosy/logger"
+	"net/http"
+	"os"
+	"runtime"
+	"sync"
+)
+
+// Delete deletes a site by removing the file in sites-available
+func Delete(name string) (err error) {
+	availablePath := nginx.GetConfPath("sites-available", name)
+
+	s := query.Site
+	_, err = s.Where(s.Path.Eq(availablePath)).Unscoped().Delete(&model.Site{})
+	if err != nil {
+		return
+	}
+
+	enabledPath := nginx.GetConfPath("sites-enabled", name)
+
+	if !helper.FileExists(availablePath) {
+		return fmt.Errorf("site not found")
+	}
+
+	if helper.FileExists(enabledPath) {
+		return fmt.Errorf("site is enabled")
+	}
+
+	certModel := model.Cert{Filename: name}
+	_ = certModel.Remove()
+
+	err = os.Remove(availablePath)
+	if err != nil {
+		return
+	}
+
+	go syncDelete(name)
+
+	return
+}
+
+func syncDelete(name string) {
+	nodes := getSyncNodes(name)
+
+	wg := &sync.WaitGroup{}
+	wg.Add(len(nodes))
+
+	for _, node := range nodes {
+		go func() {
+			defer func() {
+				if err := recover(); err != nil {
+					buf := make([]byte, 1024)
+					runtime.Stack(buf, false)
+					logger.Error(err)
+				}
+			}()
+			defer wg.Done()
+
+			client := resty.New()
+			client.SetBaseURL(node.URL)
+			resp, err := client.R().
+				Delete(fmt.Sprintf("/api/sites/%s", name))
+			if err != nil {
+				notification.Error("Delete Remote Site Error", err.Error())
+				return
+			}
+			if resp.StatusCode() != http.StatusOK {
+				notification.Error("Delete Remote Site Error", string(resp.Body()))
+				return
+			}
+			notification.Success("Delete Remote Site Success", string(resp.Body()))
+		}()
+	}
+
+	wg.Wait()
+}

+ 80 - 0
internal/site/disable.go

@@ -0,0 +1,80 @@
+package site
+
+import (
+	"fmt"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/internal/notification"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/go-resty/resty/v2"
+	"github.com/uozi-tech/cosy/logger"
+	"net/http"
+	"os"
+	"runtime"
+	"sync"
+)
+
+// Disable disables a site by removing the symlink in sites-enabled
+func Disable(name string) (err error) {
+	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", name)
+	_, err = os.Stat(enabledConfigFilePath)
+	if err != nil {
+		return
+	}
+
+	err = os.Remove(enabledConfigFilePath)
+	if err != nil {
+		return
+	}
+
+	// delete auto cert record
+	certModel := model.Cert{Filename: name}
+	err = certModel.Remove()
+	if err != nil {
+		return
+	}
+
+	output := nginx.Reload()
+	if nginx.GetLogLevel(output) > nginx.Warn {
+		return fmt.Errorf(output)
+	}
+
+	go syncDisable(name)
+
+	return
+}
+
+func syncDisable(name string) {
+	nodes := getSyncNodes(name)
+
+	wg := &sync.WaitGroup{}
+	wg.Add(len(nodes))
+
+	for _, node := range nodes {
+		go func() {
+			defer func() {
+				if err := recover(); err != nil {
+					buf := make([]byte, 1024)
+					runtime.Stack(buf, false)
+					logger.Error(err)
+				}
+			}()
+			defer wg.Done()
+
+			client := resty.New()
+			client.SetBaseURL(node.URL)
+			resp, err := client.R().
+				Post(fmt.Sprintf("/api/sites/%s/disable", name))
+			if err != nil {
+				notification.Error("Disable Remote Site Error", err.Error())
+				return
+			}
+			if resp.StatusCode() != http.StatusOK {
+				notification.Error("Disable Remote Site Error", string(resp.Body()))
+				return
+			}
+			notification.Success("Disable Remote Site Success", string(resp.Body()))
+		}()
+	}
+
+	wg.Wait()
+}

+ 24 - 0
internal/site/duplicate.go

@@ -0,0 +1,24 @@
+package site
+
+import (
+	"github.com/0xJacky/Nginx-UI/internal/helper"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/pkg/errors"
+)
+
+// Duplicate duplicates a site by copying the file
+func Duplicate(src, dst string) (err error) {
+	src = nginx.GetConfPath("sites-available", src)
+	dst = nginx.GetConfPath("sites-available", dst)
+
+	if helper.FileExists(dst) {
+		return errors.New("file exists")
+	}
+
+	_, err = helper.CopyFile(src, dst)
+	if err != nil {
+		return
+	}
+
+	return
+}

+ 86 - 0
internal/site/enable.go

@@ -0,0 +1,86 @@
+package site
+
+import (
+	"fmt"
+	"github.com/0xJacky/Nginx-UI/internal/helper"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/internal/notification"
+	"github.com/go-resty/resty/v2"
+	"github.com/uozi-tech/cosy/logger"
+	"net/http"
+	"os"
+	"runtime"
+	"sync"
+)
+
+// Enable enables a site by creating a symlink in sites-enabled
+func Enable(name string) (err error) {
+	configFilePath := nginx.GetConfPath("sites-available", name)
+	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", name)
+
+	_, err = os.Stat(configFilePath)
+	if err != nil {
+		return
+	}
+
+	if helper.FileExists(enabledConfigFilePath) {
+		return
+	}
+
+	err = os.Symlink(configFilePath, enabledConfigFilePath)
+	if err != nil {
+		return
+	}
+
+	// Test nginx config, if not pass, then disable the site.
+	output := nginx.TestConf()
+	if nginx.GetLogLevel(output) > nginx.Warn {
+		_ = os.Remove(enabledConfigFilePath)
+		return fmt.Errorf(output)
+	}
+
+	output = nginx.Reload()
+	if nginx.GetLogLevel(output) > nginx.Warn {
+		return fmt.Errorf(output)
+	}
+
+	go syncEnable(name)
+
+	return
+}
+
+func syncEnable(name string) {
+	nodes := getSyncNodes(name)
+
+	wg := &sync.WaitGroup{}
+	wg.Add(len(nodes))
+
+	for _, node := range nodes {
+		go func() {
+			defer func() {
+				if err := recover(); err != nil {
+					buf := make([]byte, 1024)
+					runtime.Stack(buf, false)
+					logger.Error(err)
+				}
+			}()
+			defer wg.Done()
+
+			client := resty.New()
+			client.SetBaseURL(node.URL)
+			resp, err := client.R().
+				Post(fmt.Sprintf("/api/sites/%s/enable", name))
+			if err != nil {
+				notification.Error("Enable Remote Site Error", err.Error())
+				return
+			}
+			if resp.StatusCode() != http.StatusOK {
+				notification.Error("Enable Remote Site Error", string(resp.Body()))
+				return
+			}
+			notification.Success("Enable Remote Site Success", string(resp.Body()))
+		}()
+	}
+
+	wg.Wait()
+}

+ 103 - 0
internal/site/rename.go

@@ -0,0 +1,103 @@
+package site
+
+import (
+	"fmt"
+	"github.com/0xJacky/Nginx-UI/internal/helper"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/internal/notification"
+	"github.com/0xJacky/Nginx-UI/query"
+	"github.com/go-resty/resty/v2"
+	"github.com/uozi-tech/cosy/logger"
+	"net/http"
+	"os"
+	"runtime"
+	"sync"
+)
+
+func Rename(oldName string, newName string) (err error) {
+	oldPath := nginx.GetConfPath("sites-available", oldName)
+	newPath := nginx.GetConfPath("sites-available", newName)
+
+	if oldPath == newPath {
+		return
+	}
+
+	// check if dst file exists, do not rename
+	if helper.FileExists(newPath) {
+		return fmt.Errorf("file exists")
+	}
+
+	s := query.Site
+	_, _ = s.Where(s.Path.Eq(oldPath)).Update(s.Path, newPath)
+
+	err = os.Rename(oldPath, newPath)
+	if err != nil {
+		return
+	}
+
+	// recreate a soft link
+	oldEnabledConfigFilePath := nginx.GetConfPath("sites-enabled", oldName)
+	if helper.SymbolLinkExists(oldEnabledConfigFilePath) {
+		_ = os.Remove(oldEnabledConfigFilePath)
+		newEnabledConfigFilePath := nginx.GetConfPath("sites-enabled", newName)
+		err = os.Symlink(newPath, newEnabledConfigFilePath)
+		if err != nil {
+			return
+		}
+	}
+
+	// test nginx configuration
+	output := nginx.TestConf()
+	if nginx.GetLogLevel(output) > nginx.Warn {
+		return fmt.Errorf(output)
+	}
+
+	// reload nginx
+	output = nginx.Reload()
+	if nginx.GetLogLevel(output) > nginx.Warn {
+		return fmt.Errorf(output)
+	}
+
+	go syncRename(oldName, newName)
+
+	return
+}
+
+func syncRename(oldName, newName string) {
+	nodes := getSyncNodes(newName)
+
+	wg := &sync.WaitGroup{}
+	wg.Add(len(nodes))
+
+	for _, node := range nodes {
+		go func() {
+			defer func() {
+				if err := recover(); err != nil {
+					buf := make([]byte, 1024)
+					runtime.Stack(buf, false)
+					logger.Error(err)
+				}
+			}()
+			defer wg.Done()
+
+			client := resty.New()
+			client.SetBaseURL(node.URL)
+			resp, err := client.R().
+				SetBody(map[string]string{
+					"new_name": newName,
+				}).
+				Post(fmt.Sprintf("/api/sites/%s/rename", oldName))
+			if err != nil {
+				notification.Error("Rename Remote Site Error", err.Error())
+				return
+			}
+			if resp.StatusCode() != http.StatusOK {
+				notification.Error("Rename Remote Site Error", string(resp.Body()))
+				return
+			}
+			notification.Success("Rename Remote Site Success", string(resp.Body()))
+		}()
+	}
+
+	wg.Wait()
+}

+ 100 - 0
internal/site/save.go

@@ -0,0 +1,100 @@
+package site
+
+import (
+	"fmt"
+	"github.com/0xJacky/Nginx-UI/internal/helper"
+	"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/go-resty/resty/v2"
+	"github.com/uozi-tech/cosy/logger"
+	"net/http"
+	"os"
+	"runtime"
+	"sync"
+)
+
+// Save saves a site configuration file
+func Save(name string, content string, overwrite bool, siteCategoryId uint64, syncNodeIds []uint64) (err error) {
+	path := nginx.GetConfPath("sites-available", name)
+	if !overwrite && helper.FileExists(path) {
+		return fmt.Errorf("file exists")
+	}
+
+	err = os.WriteFile(path, []byte(content), 0644)
+	if err != nil {
+		return
+	}
+
+	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", name)
+	if helper.FileExists(enabledConfigFilePath) {
+		// Test nginx configuration
+		output := nginx.TestConf()
+
+		if nginx.GetLogLevel(output) > nginx.Warn {
+			return fmt.Errorf(output)
+		}
+
+		output = nginx.Reload()
+
+		if nginx.GetLogLevel(output) > nginx.Warn {
+			return fmt.Errorf(output)
+		}
+	}
+
+	s := query.Site
+	_, err = s.Where(s.Path.Eq(path)).
+		Select(s.SiteCategoryID, s.SyncNodeIDs).
+		Updates(&model.Site{
+			SiteCategoryID: siteCategoryId,
+			SyncNodeIDs:    syncNodeIds,
+		})
+	if err != nil {
+		return
+	}
+
+	go syncSave(name, content)
+
+	return
+}
+
+func syncSave(name string, content string) {
+	nodes := getSyncNodes(name)
+
+	wg := &sync.WaitGroup{}
+	wg.Add(len(nodes))
+
+	for _, node := range nodes {
+		go func() {
+			defer func() {
+				if err := recover(); err != nil {
+					buf := make([]byte, 1024)
+					runtime.Stack(buf, false)
+					logger.Error(err)
+				}
+			}()
+			defer wg.Done()
+
+			client := resty.New()
+			client.SetBaseURL(node.URL)
+			resp, err := client.R().
+				SetBody(map[string]interface{}{
+					"content":   content,
+					"overwrite": true,
+				}).
+				Post(fmt.Sprintf("/api/sites/%s", name))
+			if err != nil {
+				notification.Error("Save Remote Site Error", err.Error())
+				return
+			}
+			if resp.StatusCode() != http.StatusOK {
+				notification.Error("Save Remote Site Error", string(resp.Body()))
+				return
+			}
+			notification.Success("Save Remote Site Success", string(resp.Body()))
+		}()
+	}
+
+	wg.Wait()
+}

+ 35 - 0
internal/site/sync.go

@@ -0,0 +1,35 @@
+package site
+
+import (
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/0xJacky/Nginx-UI/query"
+	"github.com/samber/lo"
+	"github.com/uozi-tech/cosy/logger"
+)
+
+func getSyncNodes(name string) (nodes []*model.Environment) {
+	configFilePath := nginx.GetConfPath("sites-available", name)
+	s := query.Site
+	site, err := s.Where(s.Path.Eq(configFilePath)).
+		Preload(s.SiteCategory).First()
+	if err != nil {
+		logger.Error(err)
+		return
+	}
+
+	syncNodeIds := site.SyncNodeIDs
+	// inherit sync node ids from site category
+	if site.SiteCategory != nil {
+		syncNodeIds = append(syncNodeIds, site.SiteCategory.SyncNodeIds...)
+	}
+	syncNodeIds = lo.Uniq(syncNodeIds)
+
+	e := query.Environment
+	nodes, err = e.Where(e.ID.In(syncNodeIds...)).Find()
+	if err != nil {
+		logger.Error(err)
+		return
+	}
+	return
+}

+ 2 - 2
model/cert.go

@@ -50,9 +50,9 @@ type Cert struct {
 }
 
 func FirstCert(confName string) (c Cert, err error) {
-	err = db.First(&c, &Cert{
+	err = db.Limit(1).Where(&Cert{
 		Filename: confName,
-	}).Error
+	}).Find(&c).Error
 
 	return
 }

+ 2 - 2
model/site_category.go

@@ -2,6 +2,6 @@ package model
 
 type SiteCategory struct {
 	Model
-	Name        string `json:"name"`
-	SyncNodeIds []int  `json:"sync_node_ids" gorm:"serializer:json"`
+	Name        string   `json:"name"`
+	SyncNodeIds []uint64 `json:"sync_node_ids" gorm:"serializer:json"`
 }

部分文件因为文件数量过多而无法显示