feat(site): sync operation

This commit is contained in:
Jacky 2024-10-26 10:33:57 +08:00
parent 6c137e5229
commit 22e37e4b61
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
43 changed files with 4875 additions and 3712 deletions

View file

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

View file

@ -3,17 +3,25 @@ package sites
import "github.com/gin-gonic/gin" import "github.com/gin-gonic/gin"
func InitRouter(r *gin.RouterGroup) { func InitRouter(r *gin.RouterGroup) {
r.GET("domains", GetSiteList) r.GET("sites", GetSiteList)
r.GET("domains/:name", GetSite) r.GET("sites/:name", GetSite)
r.POST("domains/:name", SaveSite) r.PUT("sites", BatchUpdateSites)
r.PUT("domains", BatchUpdateSites) r.POST("sites/:name/advance", DomainEditByAdvancedMode)
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.POST("auto_cert/:name", AddDomainToAutoCert) r.POST("auto_cert/:name", AddDomainToAutoCert)
r.DELETE("auto_cert/:name", RemoveDomainFromAutoCert) 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) { func InitCategoryRouter(r *gin.RouterGroup) {

View file

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

View file

@ -29,7 +29,7 @@ export interface AutoCertRequest {
key_type: PrivateKeyType key_type: PrivateKeyType
} }
class Domain extends Curd<Site> { class SiteCurd extends Curd<Site> {
// eslint-disable-next-line ts/no-explicit-any // eslint-disable-next-line ts/no-explicit-any
enable(name: string, config?: any) { enable(name: string, config?: any) {
return http.post(`${this.baseUrl}/${name}/enable`, undefined, config) 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`) return http.post(`${this.baseUrl}/${name}/disable`)
} }
rename(oldName: string, newName: string) {
return http.post(`${this.baseUrl}/${oldName}/rename`, { new_name: newName })
}
get_template() { get_template() {
return http.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

View file

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

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Column } from '@/components/StdDesign/types' 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>() const props = defineProps<Props>()
@ -13,18 +14,42 @@ export interface Props {
[key: string]: string [key: string]: string
} }
required?: boolean required?: boolean
noValidate?: boolean
} }
const tag = computed(() => { const tag = computed(() => {
return props.error?.[props.dataIndex!.toString()] ?? '' return props.error?.[props.dataIndex!.toString()] ?? ''
}) })
// const valid_status = computed(() => {
// if (tag.value)
// return 'error'
// else return 'success'
// })
const help = computed(() => { const help = computed(() => {
if (tag.value.includes('required')) const rules = tag.value.split(',')
return $gettext('This field should not be empty')
for (const rule of rules) {
if (FormErrors[rule])
return FormErrors[rule]()
}
return props.hint 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> </script>
<template> <template>
@ -32,7 +57,9 @@ const help = computed(() => {
:name="dataIndex as string" :name="dataIndex as string"
:label="label" :label="label"
:help="help" :help="help"
:required="required" :rules="{ required, validator }"
:validate-status="tag ? 'error' : undefined"
:auto-link="false"
> >
<slot /> <slot />
</AFormItem> </AFormItem>

View file

@ -45,19 +45,29 @@ export interface StdDesignEdit {
config?: { config?: {
label?: string | (() => string) // label for form item 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 placeholder?: string | (() => string) // placeholder for input
generate?: boolean // generate btn for StdPassword 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 min?: number // min value for input number
max?: number // max 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 addonBefore?: string // for inputNumber
addonAfter?: string // for inputNumber addonAfter?: string // for inputNumber
prefix?: string // for inputNumber prefix?: string // for inputNumber
suffix?: string // for inputNumber suffix?: string // for inputNumber
size?: string // class size of Std image upload
error_messages?: Ref
} }
flex?: Flex flex?: Flex

View file

@ -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 -_.'),
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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,
}
}

View file

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

View file

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

View file

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

View file

@ -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>

View file

@ -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>

View file

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

View file

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

View file

@ -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>

View file

@ -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>

View file

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

10
go.mod
View file

@ -17,6 +17,7 @@ require (
github.com/go-acme/lego/v4 v4.19.2 github.com/go-acme/lego/v4 v4.19.2
github.com/go-co-op/gocron/v2 v2.12.1 github.com/go-co-op/gocron/v2 v2.12.1
github.com/go-playground/validator/v10 v10.22.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/go-webauthn/webauthn v0.11.2
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
@ -72,7 +73,7 @@ require (
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect github.com/StackExchange/wmi v1.2.1 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // 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 v1.32.2 // indirect
github.com/aws/aws-sdk-go-v2/config v1.28.0 // 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 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-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.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-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-webauthn/x v0.1.15 // 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-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/hcl v1.0.0 // 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/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect
github.com/itchyny/timefmt-go v0.1.6 // 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/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // 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/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
github.com/sony/gobreaker v1.0.0 // indirect github.com/sony/gobreaker v1.0.0 // indirect
github.com/sony/sonyflake v1.2.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-mysql v0.2.2 // indirect
github.com/uozi-tech/cosy-driver-postgres v0.2.1 // indirect github.com/uozi-tech/cosy-driver-postgres v0.2.1 // indirect
github.com/vinyldns/go-vinyldns v0.9.16 // 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/x448/float16 v0.8.4 // indirect
github.com/yandex-cloud/go-genproto v0.0.0-20241021132621-28bb61d00c2f // 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 github.com/yandex-cloud/go-sdk v0.0.0-20241021153520-213d4c625eca // indirect

16
go.sum
View file

@ -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-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-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= 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.39 h1:zlenrBGDiSEu7YnpWiAPscKNolgIo9Z6jvM5pcWAEL4=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.38/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ= 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/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/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= 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/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 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 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.119 h1:2pi/hbcuv0CNVcsODkTYZY+X9j5uc1GTjSjX1cWMp/4=
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/go.mod h1:JWz2ujO9X3oU5wb6kXp+DpR2UuDj2SldDbX8T0FSuhI=
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= 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-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/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/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 h1:32x+htJCu3aMswhPw3teoJ+PnWPONqdNgaGs6Qt8ZaU=
github.com/smartystreets/gunit v1.1.3/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ= 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.7 h1:SgTL+pQZt1h+5QkAhVmHORM/7N9c1X0sljJhuOIHxWE=
github.com/softlayer/softlayer-go v1.1.6/go.mod h1:WeJrBLoTJcaT8nO1azeyHyNpo/fDLtbpbvh+pzts+Qw= 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 h1:3OgWYFw7jxCZPcvAg+4R8A50GZ+CCkARF10lxu2qDsQ=
github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e/go.mod h1:fKZCUVdirrxrBpwd9wb+lSoVixvpwAu8eHzbQB2tums= 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= 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/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 h1:GZJStDkcCk1F1AcRc64LuuMh+ENL8pHA0CVd4ulRMcQ=
github.com/vinyldns/go-vinyldns v0.9.16/go.mod h1:5qIJOdmzAnatKjurI+Tl4uTus7GJKJxb+zitufjHs3Q= 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.1 h1:Wc6wFTwh/gBZlOqSK1Hn3P9JWoFa7NCf52vGLwQcJOg=
github.com/vultr/govultr/v3 v3.11.0/go.mod h1:q34Wd76upKmf+vxFMgaNMH3A8BbsPBmSYZUGC8oZa5w= 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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=

View file

@ -2,8 +2,17 @@ package helper
import "os" import "os"
func FileExists(filename string) bool { func FileExists(filepath string) bool {
_, err := os.Stat(filename) _, 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) { if os.IsNotExist(err) {
return false return false
} }

85
internal/site/delete.go Normal file
View file

@ -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
internal/site/disable.go Normal file
View file

@ -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()
}

View file

@ -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
internal/site/enable.go Normal file
View file

@ -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
internal/site/rename.go Normal file
View file

@ -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
internal/site/save.go Normal file
View file

@ -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
internal/site/sync.go Normal file
View file

@ -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
}

View file

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

View file

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