Jelajahi Sumber

refactor: config management

Jacky 9 bulan lalu
induk
melakukan
53ae1a1ef9

+ 29 - 20
api/config/add.go

@@ -3,42 +3,48 @@ package config
 import (
 	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/internal/config"
+	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/gin-gonic/gin"
+	"github.com/sashabaranov/go-openai"
 	"net/http"
 	"os"
+	"time"
 )
 
 func AddConfig(c *gin.Context) {
-	var request struct {
-		Name    string `json:"name" binding:"required"`
-		Content string `json:"content" binding:"required"`
+	var json struct {
+		Name        string `json:"name" binding:"required"`
+		NewFilepath string `json:"new_filepath" binding:"required"`
+		Content     string `json:"content"`
+		Overwrite   bool   `json:"overwrite"`
 	}
 
-	err := c.BindJSON(&request)
-	if err != nil {
-		api.ErrHandler(c, err)
+	if !api.BindAndValid(c, &json) {
 		return
 	}
 
-	name := request.Name
-	content := request.Content
-
-	path := nginx.GetConfPath("/", name)
+	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 _, err = os.Stat(path); err == nil {
+	if !json.Overwrite && helper.FileExists(path) {
 		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "config exist",
+			"message": "File exists",
 		})
 		return
 	}
 
-	if content != "" {
-		err = os.WriteFile(path, []byte(content), 0644)
-		if err != nil {
-			api.ErrHandler(c, err)
-			return
-		}
+	err := os.WriteFile(path, []byte(content), 0644)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
 	}
 
 	output := nginx.Reload()
@@ -50,7 +56,10 @@ func AddConfig(c *gin.Context) {
 	}
 
 	c.JSON(http.StatusOK, config.Config{
-		Name:    name,
-		Content: content,
+		Name:            name,
+		Content:         content,
+		ChatGPTMessages: make([]openai.ChatCompletionMessage, 0),
+		FilePath:        path,
+		ModifiedAt:      time.Now(),
 	})
 }

+ 13 - 0
api/config/base_path.go

@@ -0,0 +1,13 @@
+package config
+
+import (
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/gin-gonic/gin"
+	"net/http"
+)
+
+func GetBasePath(c *gin.Context) {
+	c.JSON(http.StatusOK, gin.H{
+		"base_path": nginx.GetConfPath(),
+	})
+}

+ 7 - 3
api/config/get.go

@@ -3,6 +3,7 @@ package config
 import (
 	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/internal/config"
+	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
@@ -15,16 +16,20 @@ func GetConfig(c *gin.Context) {
 	name := c.Param("name")
 
 	path := nginx.GetConfPath("/", name)
+	if !helper.IsUnderDirectory(path, nginx.GetConfPath()) {
+		c.JSON(http.StatusForbidden, gin.H{
+			"message": "path is not under the nginx conf path",
+		})
+		return
+	}
 
 	stat, err := os.Stat(path)
-
 	if err != nil {
 		api.ErrHandler(c, err)
 		return
 	}
 
 	content, err := os.ReadFile(path)
-
 	if err != nil {
 		api.ErrHandler(c, err)
 		return
@@ -32,7 +37,6 @@ func GetConfig(c *gin.Context) {
 
 	g := query.ChatGPTLog
 	chatgpt, err := g.Where(g.Name.Eq(path)).FirstOrCreate()
-
 	if err != nil {
 		api.ErrHandler(c, err)
 		return

+ 73 - 8
api/config/modify.go

@@ -2,10 +2,15 @@ package config
 
 import (
 	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/internal/config"
+	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
+	"github.com/sashabaranov/go-openai"
 	"net/http"
 	"os"
+	"time"
 )
 
 type EditConfigJson struct {
@@ -14,15 +19,39 @@ type EditConfigJson struct {
 
 func EditConfig(c *gin.Context) {
 	name := c.Param("name")
-	var request EditConfigJson
-	err := c.BindJSON(&request)
-	if err != nil {
-		api.ErrHandler(c, err)
+	var json struct {
+		Name        string `json:"name" binding:"required"`
+		Filepath    string `json:"filepath" binding:"required"`
+		NewFilepath string `json:"new_filepath" binding:"required"`
+		Content     string `json:"content"`
+	}
+	if !api.BindAndValid(c, &json) {
 		return
 	}
-	path := nginx.GetConfPath("/", name)
-	content := request.Content
 
+	path := json.Filepath
+	if !helper.IsUnderDirectory(path, nginx.GetConfPath()) {
+		c.JSON(http.StatusForbidden, gin.H{
+			"message": "filepath is not under the nginx conf path",
+		})
+		return
+	}
+
+	if !helper.IsUnderDirectory(json.NewFilepath, nginx.GetConfPath()) {
+		c.JSON(http.StatusForbidden, gin.H{
+			"message": "new filepath is not under the nginx conf path",
+		})
+		return
+	}
+
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		c.JSON(http.StatusNotFound, gin.H{
+			"message": "file not found",
+		})
+		return
+	}
+
+	content := json.Content
 	origContent, err := os.ReadFile(path)
 	if err != nil {
 		api.ErrHandler(c, err)
@@ -37,8 +66,28 @@ func EditConfig(c *gin.Context) {
 		}
 	}
 
-	output := nginx.Reload()
+	g := query.ChatGPTLog
 
+	// handle rename
+	if path != json.NewFilepath {
+		if helper.FileExists(json.NewFilepath) {
+			c.JSON(http.StatusNotAcceptable, gin.H{
+				"message": "File exists",
+			})
+			return
+		}
+		err := os.Rename(json.Filepath, json.NewFilepath)
+		if err != nil {
+			api.ErrHandler(c, err)
+			return
+		}
+
+		// update ChatGPT record
+		_, _ = g.Where(g.Name.Eq(json.NewFilepath)).Delete()
+		_, _ = g.Where(g.Name.Eq(path)).Update(g.Name, json.NewFilepath)
+	}
+
+	output := nginx.Reload()
 	if nginx.GetLogLevel(output) >= nginx.Warn {
 		c.JSON(http.StatusInternalServerError, gin.H{
 			"message": output,
@@ -46,5 +95,21 @@ func EditConfig(c *gin.Context) {
 		return
 	}
 
-	GetConfig(c)
+	chatgpt, err := g.Where(g.Name.Eq(json.NewFilepath)).FirstOrCreate()
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	if chatgpt.Content == nil {
+		chatgpt.Content = make([]openai.ChatCompletionMessage, 0)
+	}
+
+	c.JSON(http.StatusOK, config.Config{
+		Name:            name,
+		Content:         content,
+		ChatGPTMessages: chatgpt.Content,
+		FilePath:        json.NewFilepath,
+		ModifiedAt:      time.Now(),
+	})
 }

+ 1 - 0
api/config/router.go

@@ -7,4 +7,5 @@ func InitRouter(r *gin.RouterGroup) {
 	r.GET("config/*name", GetConfig)
 	r.POST("config", AddConfig)
 	r.POST("config/*name", EditConfig)
+	r.GET("config_base_path", GetBasePath)
 }

+ 12 - 1
app/src/api/config.ts

@@ -1,5 +1,6 @@
 import Curd from '@/api/curd'
 import type { ChatComplicationMessage } from '@/api/openai'
+import http from '@/lib/http'
 
 export interface Config {
   name: string
@@ -9,6 +10,16 @@ export interface Config {
   modified_at: string
 }
 
-const config: Curd<Config> = new Curd('/config')
+class ConfigCurd extends Curd<Config> {
+  constructor() {
+    super('/config')
+  }
+
+  get_base_path() {
+    return http.get('/config_base_path')
+  }
+}
+
+const config: ConfigCurd = new ConfigCurd()
 
 export default config

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

@@ -104,13 +104,24 @@ export const routes: RouteRecordRaw[] = [
           hideChildren: true,
         },
       },
+      {
+        path: 'config/add',
+        name: 'Add Configuration',
+        component: () => import('@/views/config/ConfigEditor.vue'),
+        meta: {
+          name: () => $gettext('Add Configuration'),
+          hiddenInSidebar: true,
+          lastRouteName: 'Manage Configs',
+        },
+      },
       {
         path: 'config/:name+/edit',
         name: 'Edit Configuration',
-        component: () => import('@/views/config/ConfigEdit.vue'),
+        component: () => import('@/views/config/ConfigEditor.vue'),
         meta: {
           name: () => $gettext('Edit Configuration'),
           hiddenInSidebar: true,
+          lastRouteName: 'Manage Configs',
         },
       },
       {

+ 22 - 7
app/src/views/config/Config.vue

@@ -3,20 +3,18 @@ import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
 import config from '@/api/config'
 import configColumns from '@/views/config/config'
 import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
-import router from '@/routes'
 import InspectConfig from '@/views/config/InspectConfig.vue'
 
-const api = config
-
-const table = ref(null)
+const table = ref()
 const route = useRoute()
+const router = useRouter()
 
 const basePath = computed(() => {
   let dir = route?.query?.dir ?? ''
   if (dir)
     dir += '/'
 
-  return dir
+  return dir as string
 })
 
 const getParams = computed(() => {
@@ -36,15 +34,32 @@ const refInspectConfig = ref()
 watch(route, () => {
   refInspectConfig.value?.test()
 })
+
+function goBack() {
+  router.push({
+    path: '/config',
+    query: {
+      dir: `${basePath.value.split('/').slice(0, -2).join('/')}` || undefined,
+    },
+  })
+}
 </script>
 
 <template>
   <ACard :title="$gettext('Configurations')">
+    <template #extra>
+      <a
+        @click="router.push({
+          path: '/config/add',
+          query: { basePath: basePath || undefined },
+        })"
+      >{{ $gettext('Add') }}</a>
+    </template>
     <InspectConfig ref="refInspectConfig" />
     <StdTable
       :key="update"
       ref="table"
-      :api="api"
+      :api="config"
       :columns="configColumns"
       disable-delete
       disable-search
@@ -68,7 +83,7 @@ watch(route, () => {
       }"
     />
     <FooterToolBar v-if="basePath">
-      <AButton @click="router.go(-1)">
+      <AButton @click="goBack">
         {{ $gettext('Back') }}
       </AButton>
     </FooterToolBar>

+ 0 - 160
app/src/views/config/ConfigEdit.vue

@@ -1,160 +0,0 @@
-<script setup lang="ts">
-import { useRoute } from 'vue-router'
-import { message } from 'ant-design-vue'
-import type { Ref } from 'vue'
-import { formatDateTime } from '@/lib/helper'
-import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
-import config from '@/api/config'
-import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
-import ngx from '@/api/ngx'
-import InspectConfig from '@/views/config/InspectConfig.vue'
-import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
-import type { ChatComplicationMessage } from '@/api/openai'
-
-const route = useRoute()
-
-const inspect_config = ref()
-
-const name = computed(() => {
-  const n = route.params.name
-  if (typeof n === 'string')
-    return n
-
-  return n?.join('/')
-})
-
-const configText = ref('')
-const history_chatgpt_record = ref([]) as Ref<ChatComplicationMessage[]>
-const filepath = ref('')
-const active_key = ref(['1', '2'])
-const modified_at = ref('')
-
-function init() {
-  if (name.value) {
-    config.get(name.value).then(r => {
-      configText.value = r.content
-      history_chatgpt_record.value = r.chatgpt_messages
-      filepath.value = r.filepath
-      modified_at.value = r.modified_at
-    }).catch(r => {
-      message.error(r.message ?? $gettext('Server error'))
-    })
-  }
-  else {
-    configText.value = ''
-    history_chatgpt_record.value = []
-    filepath.value = ''
-  }
-}
-
-init()
-
-function save() {
-  config.save(name.value, { content: configText.value }).then(r => {
-    configText.value = r.content
-    message.success($gettext('Saved successfully'))
-  }).catch(r => {
-    message.error($gettext('Save error %{msg}', { msg: r.message ?? '' }))
-  }).finally(() => {
-    inspect_config.value.test()
-  })
-}
-
-function format_code() {
-  ngx.format_code(configText.value).then(r => {
-    configText.value = r.content
-    message.success($gettext('Format successfully'))
-  }).catch(r => {
-    message.error($gettext('Format error %{msg}', { msg: r.message ?? '' }))
-  })
-}
-
-</script>
-
-<template>
-  <ARow :gutter="16">
-    <ACol
-      :xs="24"
-      :sm="24"
-      :md="18"
-    >
-      <ACard :title="$gettext('Edit Configuration')">
-        <InspectConfig ref="inspect_config" />
-        <CodeEditor v-model:content="configText" />
-        <FooterToolBar>
-          <ASpace>
-            <AButton @click="$router.go(-1)">
-              {{ $gettext('Back') }}
-            </AButton>
-            <AButton @click="format_code">
-              {{ $gettext('Format Code') }}
-            </AButton>
-            <AButton
-              type="primary"
-              @click="save"
-            >
-              {{ $gettext('Save') }}
-            </AButton>
-          </ASpace>
-        </FooterToolBar>
-      </ACard>
-    </ACol>
-
-    <ACol
-      :xs="24"
-      :sm="24"
-      :md="6"
-    >
-      <ACard class="col-right">
-        <ACollapse
-          v-model:activeKey="active_key"
-          ghost
-        >
-          <ACollapsePanel
-            key="1"
-            :header="$gettext('Basic')"
-          >
-            <AForm layout="vertical">
-              <AFormItem :label="$gettext('Path')">
-                {{ filepath }}
-              </AFormItem>
-              <AFormItem :label="$gettext('Updated at')">
-                {{ formatDateTime(modified_at) }}
-              </AFormItem>
-            </AForm>
-          </ACollapsePanel>
-          <ACollapsePanel
-            key="2"
-            header="ChatGPT"
-          >
-            <ChatGPT
-              v-model:history-messages="history_chatgpt_record"
-              :content="configText"
-              :path="filepath"
-            />
-          </ACollapsePanel>
-        </ACollapse>
-      </ACard>
-    </ACol>
-  </ARow>
-</template>
-
-<style lang="less" scoped>
-.col-right {
-  position: sticky;
-  top: 78px;
-
-  :deep(.ant-card-body) {
-    max-height: 100vh;
-    overflow-y: scroll;
-  }
-}
-
-:deep(.ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box) {
-  padding: 0;
-}
-
-:deep(.ant-collapse > .ant-collapse-item > .ant-collapse-header) {
-  padding: 0 0 10px 0;
-}
-</style>

+ 232 - 0
app/src/views/config/ConfigEditor.vue

@@ -0,0 +1,232 @@
+<script setup lang="ts">
+import { message } from 'ant-design-vue'
+import type { Ref } from 'vue'
+import { formatDateTime } from '@/lib/helper'
+import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
+import config from '@/api/config'
+import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
+import ngx from '@/api/ngx'
+import InspectConfig from '@/views/config/InspectConfig.vue'
+import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
+import type { ChatComplicationMessage } from '@/api/openai'
+
+const route = useRoute()
+const router = useRouter()
+const refForm = ref()
+const refInspectConfig = ref()
+const origName = ref('')
+const addMode = computed(() => !route.params.name)
+const errors = ref({})
+
+const basePath = computed(() => {
+  if (route.query.basePath)
+    return route?.query?.basePath?.toString().replaceAll('/', '')
+  else if (typeof route.params.name === 'object')
+    return (route.params.name as string[]).slice(0, -1).join('/')
+  else
+    return ''
+})
+
+const data = ref({
+  name: '',
+  content: '',
+  filepath: '',
+})
+
+const historyChatgptRecord = ref([]) as Ref<ChatComplicationMessage[]>
+const activeKey = ref(['basic', 'chatgpt'])
+const modifiedAt = ref('')
+const nginxConfigBase = ref('')
+
+const newPath = computed(() => [nginxConfigBase.value, basePath.value, data.value.name]
+  .filter(v => v)
+  .join('/'))
+
+const relativePath = computed(() => (route.params.name as string[]).join('/'))
+
+async function init() {
+  const { name } = route.params
+
+  data.value.name = name?.[name?.length - 1] ?? ''
+  origName.value = data.value.name
+  if (data.value.name) {
+    config.get(relativePath.value).then(r => {
+      data.value = r
+      historyChatgptRecord.value = r.chatgpt_messages
+      modifiedAt.value = r.modified_at
+    }).catch(r => {
+      message.error(r.message ?? $gettext('Server error'))
+    })
+  }
+  else {
+    data.value.content = ''
+    historyChatgptRecord.value = []
+    data.value.filepath = ''
+  }
+}
+
+onMounted(async () => {
+  await config.get_base_path().then(r => {
+    nginxConfigBase.value = r.base_path
+  })
+  await init()
+})
+
+function save() {
+  refForm.value.validate().then(() => {
+    config.save(addMode.value ? null : relativePath.value, {
+      name: data.value.name,
+      filepath: data.value.filepath,
+      new_filepath: newPath.value,
+      content: data.value.content,
+    }).then(r => {
+      data.value.content = r.content
+      message.success($gettext('Saved successfully'))
+      router.push(`/config/${r.filepath.replaceAll(`${nginxConfigBase.value}/`, '')}/edit`)
+    }).catch(e => {
+      errors.value = e.errors
+      message.error($gettext('Save error %{msg}', { msg: e.message ?? '' }))
+    }).finally(() => {
+      refInspectConfig.value.test()
+    })
+  })
+}
+
+function formatCode() {
+  ngx.format_code(data.value.content).then(r => {
+    data.value.content = r.content
+    message.success($gettext('Format successfully'))
+  }).catch(r => {
+    message.error($gettext('Format error %{msg}', { msg: r.message ?? '' }))
+  })
+}
+
+function goBack() {
+  router.push({
+    path: '/config',
+    query: {
+      dir: basePath.value || undefined,
+    },
+  })
+}
+</script>
+
+<template>
+  <ARow :gutter="16">
+    <ACol
+      :xs="24"
+      :sm="24"
+      :md="18"
+    >
+      <ACard :title="addMode ? $gettext('Add Configuration') : $gettext('Edit Configuration')">
+        <InspectConfig
+          v-show="!addMode"
+          ref="refInspectConfig"
+        />
+        <CodeEditor v-model:content="data.content" />
+        <FooterToolBar>
+          <ASpace>
+            <AButton @click="goBack">
+              {{ $gettext('Back') }}
+            </AButton>
+            <AButton @click="formatCode">
+              {{ $gettext('Format Code') }}
+            </AButton>
+            <AButton
+              type="primary"
+              @click="save"
+            >
+              {{ $gettext('Save') }}
+            </AButton>
+          </ASpace>
+        </FooterToolBar>
+      </ACard>
+    </ACol>
+
+    <ACol
+      :xs="24"
+      :sm="24"
+      :md="6"
+    >
+      <ACard class="col-right">
+        <ACollapse
+          v-model:activeKey="activeKey"
+          ghost
+        >
+          <ACollapsePanel
+            key="basic"
+            :header="$gettext('Basic')"
+          >
+            <AForm
+              ref="refForm"
+              layout="vertical"
+              :model="data"
+              :rules="{
+                name: [
+                  { required: true, message: $gettext('Please input a filename') },
+                  { pattern: /^[^\\/]+$/, message: $gettext('Invalid filename') },
+                ],
+              }"
+            >
+              <AFormItem
+                name="name"
+                :label="$gettext('Name')"
+              >
+                <AInput v-model:value="data.name" />
+              </AFormItem>
+              <AFormItem
+                v-if="!addMode"
+                :label="$gettext('Path')"
+              >
+                {{ data.filepath }}
+              </AFormItem>
+              <AFormItem
+                v-show="data.name !== origName"
+                :label="addMode ? $gettext('New Path') : $gettext('Changed Path')"
+                required
+              >
+                {{ newPath }}
+              </AFormItem>
+              <AFormItem
+                v-if="!addMode"
+                :label="$gettext('Updated at')"
+              >
+                {{ formatDateTime(modifiedAt) }}
+              </AFormItem>
+            </AForm>
+          </ACollapsePanel>
+          <ACollapsePanel
+            key="chatgpt"
+            header="ChatGPT"
+          >
+            <ChatGPT
+              v-model:history-messages="historyChatgptRecord"
+              :content="data.content"
+              :path="data.filepath"
+            />
+          </ACollapsePanel>
+        </ACollapse>
+      </ACard>
+    </ACol>
+  </ARow>
+</template>
+
+<style lang="less" scoped>
+.col-right {
+  position: sticky;
+  top: 78px;
+
+  :deep(.ant-card-body) {
+    max-height: 100vh;
+    overflow-y: scroll;
+  }
+}
+
+:deep(.ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box) {
+  padding: 0;
+}
+
+:deep(.ant-collapse > .ant-collapse-item > .ant-collapse-header) {
+  padding: 0 0 10px 0;
+}
+</style>

+ 1 - 1
internal/config/config.go

@@ -7,7 +7,7 @@ import (
 
 type Config struct {
 	Name            string                         `json:"name"`
-	Content         string                         `json:"content,omitempty"`
+	Content         string                         `json:"content"`
 	ChatGPTMessages []openai.ChatCompletionMessage `json:"chatgpt_messages,omitempty"`
 	FilePath        string                         `json:"filepath,omitempty"`
 	ModifiedAt      time.Time                      `json:"modified_at"`

+ 1 - 0
internal/helper/directory_test.go

@@ -8,4 +8,5 @@ import (
 func TestIsUnderDirectory(t *testing.T) {
 	assert.Equal(t, true, IsUnderDirectory("/etc/nginx/nginx.conf", "/etc/nginx"))
 	assert.Equal(t, false, IsUnderDirectory("../../root/nginx.conf", "/etc/nginx"))
+	assert.Equal(t, false, IsUnderDirectory("/etc/nginx/../../root/nginx.conf", "/etc/nginx"))
 }