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 (
"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",
})
}

View file

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

View file

@ -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,39 @@ 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)
func RenameSite(c *gin.Context) {
oldName := c.Param("name")
var json struct {
NewName string `json:"new_name"`
}
if !cosy.BindAndValid(c, &json) {
return
}
err := site.Rename(oldName, json.NewName)
if err != nil {
api.ErrHandler(c, err)
return
}
if _, err = os.Stat(enabledConfigFilePath); os.IsNotExist(err) {
err = os.Symlink(configFilePath, enabledConfigFilePath)
c.JSON(http.StatusOK, gin.H{
"message": "ok",
})
}
if err != nil {
api.ErrHandler(c, err)
return
}
}
// 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,
})
func EnableSite(c *gin.Context) {
err := site.Enable(c.Param("name"))
if err != nil {
api.ErrHandler(c, err)
return
}
@ -270,71 +166,19 @@ func EnableSite(c *gin.Context) {
}
func DisableSite(c *gin.Context) {
enabledConfigFilePath := nginx.GetConfPath("sites-enabled", c.Param("name"))
_, err := os.Stat(enabledConfigFilePath)
err := site.Disable(c.Param("name"))
if err != nil {
api.ErrHandler(c, err)
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{
"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

View file

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

View file

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

View file

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

View file

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

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 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) {

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

View file

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

View file

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

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">
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"
/>

View file

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

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">
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"
/>

10
go.mod
View file

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

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

View file

@ -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
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) {
err = db.First(&c, &Cert{
err = db.Limit(1).Where(&Cert{
Filename: confName,
}).Error
}).Find(&c).Error
return
}

View file

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