mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-19 07:36:59 +00:00
feat: maintenance mode #739
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/0xJacky/Nginx-UI/settings"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed *.tmpl
|
||||
var tmplFS embed.FS
|
||||
|
||||
// MaintenancePageData maintenance page data structure
|
||||
type MaintenancePageData struct {
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description"`
|
||||
ICPNumber string `json:"icp_number"`
|
||||
PublicSecurityNumber string `json:"public_security_number"`
|
||||
}
|
||||
|
||||
const (
|
||||
Title = "System Maintenance"
|
||||
Message = "We are currently performing system maintenance to improve your experience."
|
||||
Description = "Please check back later. Thank you for your understanding and patience."
|
||||
)
|
||||
|
||||
// MaintenancePage returns a maintenance page
|
||||
func MaintenancePage(c *gin.Context) {
|
||||
// Prepare template data
|
||||
data := MaintenancePageData{
|
||||
Title: Title,
|
||||
Message: Message,
|
||||
Description: Description,
|
||||
ICPNumber: settings.NodeSettings.ICPNumber,
|
||||
PublicSecurityNumber: settings.NodeSettings.PublicSecurityNumber,
|
||||
}
|
||||
|
||||
// Check User-Agent
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
isBrowser := len(userAgent) > 0 && (contains(userAgent, "Mozilla") ||
|
||||
contains(userAgent, "Chrome") ||
|
||||
contains(userAgent, "Safari") ||
|
||||
contains(userAgent, "Edge") ||
|
||||
contains(userAgent, "Firefox") ||
|
||||
contains(userAgent, "Opera"))
|
||||
|
||||
if !isBrowser {
|
||||
c.JSON(http.StatusServiceUnavailable, data)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse template
|
||||
tmpl, err := template.ParseFS(tmplFS, "maintenance.tmpl")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "503 Service Unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
// Set content type
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Status(http.StatusServiceUnavailable)
|
||||
|
||||
// Render template
|
||||
err = tmpl.Execute(c.Writer, data)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "503 Service Unavailable")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a string contains a substring
|
||||
func contains(s, substr string) bool {
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0">
|
||||
<title>{{.Title}} | Nginx UI</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
background-color: #f4f5f7;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.maintenance-container {
|
||||
max-width: 600px;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
@media (max-width: 768px) {
|
||||
border-radius: 0;
|
||||
}
|
||||
box-shadow: 0 0 30px #c8c8c840;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
color:rgb(0, 128, 247);
|
||||
margin-bottom: 20px;
|
||||
font-size: 28px;
|
||||
}
|
||||
a {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
p {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.beian-info {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.beian-info p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
.beian-info img {
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<div class="maintenance-container">
|
||||
<div class="icon">🛠️</div>
|
||||
<h1>{{.Title}}</h1>
|
||||
<p>{{.Message}}</p>
|
||||
<p>{{.Description}}</p>
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://nginxui.com" target="_blank">Nginx UI</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="beian-info">
|
||||
{{if .ICPNumber}}
|
||||
<p><a href="https://beian.miit.gov.cn/" target="_blank">{{.ICPNumber}}</a></p>
|
||||
{{end}}
|
||||
{{if .PublicSecurityNumber}}
|
||||
<p><img src="//www.beian.gov.cn/img/new/gongan.png" alt="公安备案"><a href="http://www.beian.gov.cn/portal/index" target="_blank">{{.PublicSecurityNumber}}</a></p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// InitRouter initializes the pages routes
|
||||
func InitRouter(r *gin.Engine) {
|
||||
// Register maintenance page route
|
||||
r.GET("/pages/maintenance", MaintenancePage)
|
||||
}
|
||||
+22
-12
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
func GetSiteList(c *gin.Context) {
|
||||
name := c.Query("name")
|
||||
enabled := c.Query("enabled")
|
||||
status := c.Query("status")
|
||||
orderBy := c.Query("sort_by")
|
||||
sort := c.DefaultQuery("order", "desc")
|
||||
queryEnvGroupId := cast.ToUint64(c.Query("env_group_id"))
|
||||
@@ -50,9 +50,23 @@ func GetSiteList(c *gin.Context) {
|
||||
return filepath.Base(item.Path), item
|
||||
})
|
||||
|
||||
enabledConfigMap := make(map[string]bool)
|
||||
for i := range enabledConfig {
|
||||
enabledConfigMap[enabledConfig[i].Name()] = true
|
||||
configStatusMap := make(map[string]config.ConfigStatus)
|
||||
for _, site := range configFiles {
|
||||
configStatusMap[site.Name()] = config.StatusDisabled
|
||||
}
|
||||
|
||||
// Check for enabled sites and maintenance mode sites
|
||||
for _, enabledSite := range enabledConfig {
|
||||
name := enabledSite.Name()
|
||||
|
||||
// Check if this is a maintenance mode configuration
|
||||
if strings.HasSuffix(name, site.MaintenanceSuffix) {
|
||||
// Extract the original site name by removing maintenance suffix
|
||||
originalName := strings.TrimSuffix(name, site.MaintenanceSuffix)
|
||||
configStatusMap[originalName] = config.StatusMaintenance
|
||||
} else {
|
||||
configStatusMap[name] = config.StatusEnabled
|
||||
}
|
||||
}
|
||||
|
||||
var configs []config.Config
|
||||
@@ -68,14 +82,10 @@ func GetSiteList(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
// status filter
|
||||
if enabled != "" {
|
||||
if enabled == "true" && !enabledConfigMap[file.Name()] {
|
||||
continue
|
||||
}
|
||||
if enabled == "false" && enabledConfigMap[file.Name()] {
|
||||
continue
|
||||
}
|
||||
if status != "" && configStatusMap[file.Name()] != config.ConfigStatus(status) {
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
envGroupId uint64
|
||||
envGroup *model.EnvGroup
|
||||
@@ -98,7 +108,7 @@ func GetSiteList(c *gin.Context) {
|
||||
ModifiedAt: fileInfo.ModTime(),
|
||||
Size: fileInfo.Size(),
|
||||
IsDir: fileInfo.IsDir(),
|
||||
Enabled: enabledConfigMap[file.Name()],
|
||||
Status: configStatusMap[file.Name()],
|
||||
EnvGroupID: envGroupId,
|
||||
EnvGroup: envGroup,
|
||||
Urls: indexedSite.Urls,
|
||||
|
||||
@@ -22,4 +22,8 @@ func InitRouter(r *gin.RouterGroup) {
|
||||
r.DELETE("sites/:name", DeleteSite)
|
||||
// duplicate site
|
||||
r.POST("sites/:name/duplicate", DuplicateSite)
|
||||
// enable maintenance mode for site
|
||||
r.POST("sites/:name/maintenance/enable", EnableMaintenanceSite)
|
||||
// disable maintenance mode for site
|
||||
r.POST("sites/:name/maintenance/disable", DisableMaintenanceSite)
|
||||
}
|
||||
|
||||
@@ -215,3 +215,27 @@ func BatchUpdateSites(c *gin.Context) {
|
||||
ctx.BatchEffectedIDs = effectedPath
|
||||
}).BatchModify()
|
||||
}
|
||||
|
||||
func EnableMaintenanceSite(c *gin.Context) {
|
||||
err := site.EnableMaintenance(c.Param("name"))
|
||||
if err != nil {
|
||||
cosy.ErrHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func DisableMaintenanceSite(c *gin.Context) {
|
||||
err := site.DisableMaintenance(c.Param("name"))
|
||||
if err != nil {
|
||||
cosy.ErrHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
+9
-11
@@ -36,7 +36,7 @@ type Stream struct {
|
||||
|
||||
func GetStreams(c *gin.Context) {
|
||||
name := c.Query("name")
|
||||
enabled := c.Query("enabled")
|
||||
status := c.Query("status")
|
||||
orderBy := c.Query("order_by")
|
||||
sort := c.DefaultQuery("sort", "desc")
|
||||
queryEnvGroupId := cast.ToUint64(c.Query("env_group_id"))
|
||||
@@ -53,9 +53,12 @@ func GetStreams(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
enabledConfigMap := make(map[string]bool)
|
||||
enabledConfigMap := make(map[string]config.ConfigStatus)
|
||||
for _, file := range configFiles {
|
||||
enabledConfigMap[file.Name()] = config.StatusDisabled
|
||||
}
|
||||
for i := range enabledConfig {
|
||||
enabledConfigMap[enabledConfig[i].Name()] = true
|
||||
enabledConfigMap[enabledConfig[i].Name()] = config.StatusEnabled
|
||||
}
|
||||
|
||||
var configs []config.Config
|
||||
@@ -107,13 +110,8 @@ func GetStreams(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Apply enabled status filter if specified
|
||||
if enabled != "" {
|
||||
if enabled == "true" && !enabledConfigMap[file.Name()] {
|
||||
continue
|
||||
}
|
||||
if enabled == "false" && enabledConfigMap[file.Name()] {
|
||||
continue
|
||||
}
|
||||
if status != "" && enabledConfigMap[file.Name()] != config.ConfigStatus(status) {
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -138,7 +136,7 @@ func GetStreams(c *gin.Context) {
|
||||
ModifiedAt: fileInfo.ModTime(),
|
||||
Size: fileInfo.Size(),
|
||||
IsDir: fileInfo.IsDir(),
|
||||
Enabled: enabledConfigMap[file.Name()],
|
||||
Status: enabledConfigMap[file.Name()],
|
||||
EnvGroupID: envGroupId,
|
||||
EnvGroup: envGroup,
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface Site extends ModelBase {
|
||||
env_group?: EnvGroup
|
||||
sync_node_ids: number[]
|
||||
urls?: string[]
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface AutoCertRequest {
|
||||
@@ -65,6 +66,14 @@ class SiteCurd extends Curd<Site> {
|
||||
advance_mode(name: string, data: { advanced: boolean }) {
|
||||
return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/advance`, data)
|
||||
}
|
||||
|
||||
enableMaintenance(name: string) {
|
||||
return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/maintenance/enable`)
|
||||
}
|
||||
|
||||
disableMaintenance(name: string) {
|
||||
return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/maintenance/disable`)
|
||||
}
|
||||
}
|
||||
|
||||
const site = new SiteCurd('/sites')
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
|
||||
const notifications: Record<string, { title: () => string, content: (args: any) => string }> = {
|
||||
|
||||
// user module notifications
|
||||
'All Recovery Codes Have Been Used': {
|
||||
title: () => $gettext('All Recovery Codes Have Been Used'),
|
||||
content: (args: any) => $gettext('Please generate new recovery codes in the preferences immediately to prevent lockout.', args),
|
||||
},
|
||||
|
||||
// cluster module notifications
|
||||
'Reload Remote Nginx Error': {
|
||||
title: () => $gettext('Reload Remote Nginx Error'),
|
||||
@@ -81,6 +75,22 @@ const notifications: Record<string, { title: () => string, content: (args: any)
|
||||
title: () => $gettext('Enable Remote Site Success'),
|
||||
content: (args: any) => $gettext('Enable site %{name} on %{node} successfully', args),
|
||||
},
|
||||
'Enable Remote Site Maintenance Error': {
|
||||
title: () => $gettext('Enable Remote Site Maintenance Error'),
|
||||
content: (args: any) => $gettext('Enable site %{name} maintenance on %{node} failed', args),
|
||||
},
|
||||
'Enable Remote Site Maintenance Success': {
|
||||
title: () => $gettext('Enable Remote Site Maintenance Success'),
|
||||
content: (args: any) => $gettext('Enable site %{name} maintenance on %{node} successfully', args),
|
||||
},
|
||||
'Disable Remote Site Maintenance Error': {
|
||||
title: () => $gettext('Disable Remote Site Maintenance Error'),
|
||||
content: (args: any) => $gettext('Disable site %{name} maintenance on %{node} failed', args),
|
||||
},
|
||||
'Disable Remote Site Maintenance Success': {
|
||||
title: () => $gettext('Disable Remote Site Maintenance Success'),
|
||||
content: (args: any) => $gettext('Disable site %{name} maintenance on %{node} successfully', args),
|
||||
},
|
||||
'Rename Remote Site Error': {
|
||||
title: () => $gettext('Rename Remote Site Error'),
|
||||
content: (args: any) => $gettext('Rename site %{name} to %{new_name} on %{node} failed', args),
|
||||
@@ -139,6 +149,12 @@ const notifications: Record<string, { title: () => string, content: (args: any)
|
||||
title: () => $gettext('Save Remote Stream Success'),
|
||||
content: (args: any) => $gettext('Save stream %{name} to %{node} successfully', args),
|
||||
},
|
||||
|
||||
// user module notifications
|
||||
'All Recovery Codes Have Been Used': {
|
||||
title: () => $gettext('All Recovery Codes Have Been Used'),
|
||||
content: (args: any) => $gettext('Please generate new recovery codes in the preferences immediately to prevent lockout.', args),
|
||||
},
|
||||
}
|
||||
|
||||
export default notifications
|
||||
|
||||
@@ -5,4 +5,5 @@ export default {
|
||||
50004: () => $gettext('Certificate parse error'),
|
||||
50005: () => $gettext('Payload resource is nil'),
|
||||
50006: () => $gettext('Path: {0} is not under the nginx conf dir: {1}'),
|
||||
50007: () => $gettext('Certificate path is empty'),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export const DATE_FORMAT = 'YYYY-MM-DD'
|
||||
|
||||
export enum ConfigStatus {
|
||||
Enabled = 'enabled',
|
||||
Disabled = 'disabled',
|
||||
Maintenance = 'maintenance',
|
||||
}
|
||||
|
||||
export enum AutoCertState {
|
||||
Disable = 0,
|
||||
Enable = 1,
|
||||
|
||||
+248
-179
File diff suppressed because it is too large
Load Diff
+248
-179
File diff suppressed because it is too large
Load Diff
+248
-179
File diff suppressed because it is too large
Load Diff
+248
-179
File diff suppressed because it is too large
Load Diff
+248
-179
File diff suppressed because it is too large
Load Diff
+248
-179
File diff suppressed because it is too large
Load Diff
+242
-188
File diff suppressed because it is too large
Load Diff
+248
-179
File diff suppressed because it is too large
Load Diff
+256
-179
File diff suppressed because it is too large
Load Diff
+248
-179
File diff suppressed because it is too large
Load Diff
+238
-180
File diff suppressed because it is too large
Load Diff
+248
-179
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import site from '@/api/site'
|
||||
import EnvGroupTabs from '@/components/EnvGroupTabs/EnvGroupTabs.vue'
|
||||
import StdBatchEdit from '@/components/StdDesign/StdDataDisplay/StdBatchEdit.vue'
|
||||
import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
|
||||
import { ConfigStatus } from '@/constants'
|
||||
import InspectConfig from '@/views/config/InspectConfig.vue'
|
||||
import columns from '@/views/site/site_list/columns'
|
||||
import SiteDuplicate from '@/views/site/site_list/SiteDuplicate.vue'
|
||||
@@ -94,8 +95,6 @@ function enable(name: string) {
|
||||
message.success($gettext('Enabled successfully'))
|
||||
table.value?.get_list()
|
||||
inspect_config.value?.test()
|
||||
}).catch(r => {
|
||||
message.error($gettext('Failed to enable %{msg}', { msg: r.message ?? '' }), 10)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,8 +103,22 @@ function disable(name: string) {
|
||||
message.success($gettext('Disabled successfully'))
|
||||
table.value?.get_list()
|
||||
inspect_config.value?.test()
|
||||
}).catch(r => {
|
||||
message.error($gettext('Failed to disable %{msg}', { msg: r.message ?? '' }))
|
||||
})
|
||||
}
|
||||
|
||||
function enableMaintenance(name: string) {
|
||||
site.enableMaintenance(name).then(() => {
|
||||
message.success($gettext('Maintenance mode enabled successfully'))
|
||||
table.value?.get_list()
|
||||
inspect_config.value?.test()
|
||||
})
|
||||
}
|
||||
|
||||
function disableMaintenance(name: string) {
|
||||
site.disableMaintenance(name).then(() => {
|
||||
message.success($gettext('Maintenance mode disabled successfully'))
|
||||
table.value?.get_list()
|
||||
inspect_config.value?.test()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -172,7 +185,7 @@ function handleBatchUpdated() {
|
||||
>
|
||||
<template #actions="{ record }">
|
||||
<AButton
|
||||
v-if="record.enabled"
|
||||
v-if="record.status !== ConfigStatus.Disabled"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="disable(record.name)"
|
||||
@@ -180,13 +193,29 @@ function handleBatchUpdated() {
|
||||
{{ $gettext('Disable') }}
|
||||
</AButton>
|
||||
<AButton
|
||||
v-else
|
||||
v-else-if="record.status !== ConfigStatus.Enabled"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="enable(record.name)"
|
||||
>
|
||||
{{ $gettext('Enable') }}
|
||||
</AButton>
|
||||
<AButton
|
||||
v-if="record.status === ConfigStatus.Maintenance"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="disableMaintenance(record.name)"
|
||||
>
|
||||
{{ $gettext('Exit Maintenance') }}
|
||||
</AButton>
|
||||
<AButton
|
||||
v-else-if="record.status !== ConfigStatus.Maintenance"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="enableMaintenance(record.name)"
|
||||
>
|
||||
{{ $gettext('Enter Maintenance') }}
|
||||
</AButton>
|
||||
<AButton
|
||||
type="link"
|
||||
size="small"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
datetime,
|
||||
} from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
|
||||
import { input, select, selector } from '@/components/StdDesign/StdDataEntry'
|
||||
import { ConfigStatus } from '@/constants'
|
||||
import envGroupColumns from '@/views/environments/group/columns'
|
||||
import { Badge, Tag } from 'ant-design-vue'
|
||||
|
||||
@@ -64,26 +65,31 @@ const columns: Column[] = [{
|
||||
width: 100,
|
||||
}, {
|
||||
title: () => $gettext('Status'),
|
||||
dataIndex: 'enabled',
|
||||
dataIndex: 'status',
|
||||
customRender: (args: CustomRender) => {
|
||||
const template: JSXElements = []
|
||||
const { text } = args
|
||||
if (text === true || text > 0) {
|
||||
if (text === ConfigStatus.Enabled) {
|
||||
template.push(<Badge status="success" />)
|
||||
template.push($gettext('Enabled'))
|
||||
}
|
||||
else {
|
||||
else if (text === ConfigStatus.Disabled) {
|
||||
template.push(<Badge status="warning" />)
|
||||
template.push($gettext('Disabled'))
|
||||
}
|
||||
else if (text === ConfigStatus.Maintenance) {
|
||||
template.push(<Badge color="volcano" />)
|
||||
template.push($gettext('Maintenance'))
|
||||
}
|
||||
|
||||
return h('div', template)
|
||||
},
|
||||
search: {
|
||||
type: select,
|
||||
mask: {
|
||||
true: $gettext('Enabled'),
|
||||
false: $gettext('Disabled'),
|
||||
[ConfigStatus.Enabled]: $gettext('Enabled'),
|
||||
[ConfigStatus.Disabled]: $gettext('Disabled'),
|
||||
[ConfigStatus.Maintenance]: $gettext('Maintenance'),
|
||||
},
|
||||
},
|
||||
sorter: true,
|
||||
|
||||
@@ -10,6 +10,7 @@ import StdBatchEdit from '@/components/StdDesign/StdDataDisplay/StdBatchEdit.vue
|
||||
import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
|
||||
import { actualValueRender, datetime } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
|
||||
import { input, selector } from '@/components/StdDesign/StdDataEntry'
|
||||
import { ConfigStatus } from '@/constants'
|
||||
import InspectConfig from '@/views/config/InspectConfig.vue'
|
||||
import envGroupColumns from '@/views/environments/group/columns'
|
||||
import StreamDuplicate from '@/views/stream/components/StreamDuplicate.vue'
|
||||
@@ -44,15 +45,15 @@ const columns: Column[] = [{
|
||||
width: 150,
|
||||
}, {
|
||||
title: () => $gettext('Status'),
|
||||
dataIndex: 'enabled',
|
||||
dataIndex: 'status',
|
||||
customRender: (args: CustomRender) => {
|
||||
const template: JSXElements = []
|
||||
const { text } = args
|
||||
if (text === true || text > 0) {
|
||||
if (text === ConfigStatus.Enabled) {
|
||||
template.push(<Badge status="success" />)
|
||||
template.push($gettext('Enabled'))
|
||||
}
|
||||
else {
|
||||
else if (text === ConfigStatus.Disabled) {
|
||||
template.push(<Badge status="warning" />)
|
||||
template.push($gettext('Disabled'))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,14 @@ import (
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
type ConfigStatus string
|
||||
|
||||
const (
|
||||
StatusEnabled ConfigStatus = "enabled"
|
||||
StatusDisabled ConfigStatus = "disabled"
|
||||
StatusMaintenance ConfigStatus = "maintenance"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
@@ -17,7 +25,7 @@ type Config struct {
|
||||
IsDir bool `json:"is_dir"`
|
||||
EnvGroupID uint64 `json:"env_group_id"`
|
||||
EnvGroup *model.EnvGroup `json:"env_group,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status ConfigStatus `json:"status"`
|
||||
Dir string `json:"dir"`
|
||||
Urls []string `json:"urls,omitempty"`
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ func (c ConfigsSort) Less(i, j int) bool {
|
||||
flag = c.ConfigList[i].ModifiedAt.After(c.ConfigList[j].ModifiedAt)
|
||||
case "is_dir":
|
||||
flag = boolToInt(c.ConfigList[i].IsDir) > boolToInt(c.ConfigList[j].IsDir)
|
||||
case "enabled":
|
||||
flag = boolToInt(c.ConfigList[i].Enabled) > boolToInt(c.ConfigList[j].Enabled)
|
||||
case "status":
|
||||
flag = c.ConfigList[i].Status > c.ConfigList[j].Status
|
||||
case "env_group_id":
|
||||
flag = c.ConfigList[i].EnvGroupID > c.ConfigList[j].EnvGroupID
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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/go-resty/resty/v2"
|
||||
"github.com/tufanbarisyildirim/gonginx/config"
|
||||
"github.com/tufanbarisyildirim/gonginx/parser"
|
||||
"github.com/uozi-tech/cosy/logger"
|
||||
"github.com/uozi-tech/cosy/settings"
|
||||
)
|
||||
|
||||
const MaintenanceSuffix = "_nginx_ui_maintenance"
|
||||
|
||||
// EnableMaintenance enables maintenance mode for a site
|
||||
func EnableMaintenance(name string) (err error) {
|
||||
// Check if the site exists in sites-available
|
||||
configFilePath := nginx.GetConfPath("sites-available", name)
|
||||
_, err = os.Stat(configFilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Path for the maintenance configuration file
|
||||
maintenanceConfigPath := nginx.GetConfPath("sites-enabled", name+MaintenanceSuffix)
|
||||
|
||||
// Path for original configuration in sites-enabled
|
||||
originalEnabledPath := nginx.GetConfPath("sites-enabled", name)
|
||||
|
||||
// Check if the site is already in maintenance mode
|
||||
if helper.FileExists(maintenanceConfigPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// Read the original configuration file
|
||||
content, err := os.ReadFile(configFilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the nginx configuration
|
||||
p := parser.NewStringParser(string(content), parser.WithSkipValidDirectivesErr())
|
||||
conf, err := p.Parse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse nginx configuration: %s", err)
|
||||
}
|
||||
|
||||
// Create new maintenance configuration
|
||||
maintenanceConfig := createMaintenanceConfig(conf)
|
||||
|
||||
// Write maintenance configuration to file
|
||||
err = os.WriteFile(maintenanceConfigPath, []byte(maintenanceConfig), 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the original symlink from sites-enabled if it exists
|
||||
if helper.FileExists(originalEnabledPath) {
|
||||
err = os.Remove(originalEnabledPath)
|
||||
if err != nil {
|
||||
// If we couldn't remove the original, remove the maintenance file and return the error
|
||||
_ = os.Remove(maintenanceConfigPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Test nginx config, if not pass, then restore original configuration
|
||||
output := nginx.TestConf()
|
||||
if nginx.GetLogLevel(output) > nginx.Warn {
|
||||
// Configuration error, cleanup and revert
|
||||
_ = os.Remove(maintenanceConfigPath)
|
||||
if helper.FileExists(originalEnabledPath + "_backup") {
|
||||
_ = os.Rename(originalEnabledPath+"_backup", originalEnabledPath)
|
||||
}
|
||||
return fmt.Errorf("%s", output)
|
||||
}
|
||||
|
||||
// Reload nginx
|
||||
output = nginx.Reload()
|
||||
if nginx.GetLogLevel(output) > nginx.Warn {
|
||||
return fmt.Errorf("%s", output)
|
||||
}
|
||||
|
||||
// Synchronize with other nodes
|
||||
go syncEnableMaintenance(name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableMaintenance disables maintenance mode for a site
|
||||
func DisableMaintenance(name string) (err error) {
|
||||
// Check if the site is in maintenance mode
|
||||
maintenanceConfigPath := nginx.GetConfPath("sites-enabled", name+MaintenanceSuffix)
|
||||
_, err = os.Stat(maintenanceConfigPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Original configuration paths
|
||||
configFilePath := nginx.GetConfPath("sites-available", name)
|
||||
enabledConfigFilePath := nginx.GetConfPath("sites-enabled", name)
|
||||
|
||||
// Check if the original configuration exists
|
||||
_, err = os.Stat(configFilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create symlink to original configuration
|
||||
err = os.Symlink(configFilePath, enabledConfigFilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove maintenance configuration
|
||||
err = os.Remove(maintenanceConfigPath)
|
||||
if err != nil {
|
||||
// If we couldn't remove the maintenance file, remove the new symlink and return the error
|
||||
_ = os.Remove(enabledConfigFilePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Test nginx config, if not pass, then revert
|
||||
output := nginx.TestConf()
|
||||
if nginx.GetLogLevel(output) > nginx.Warn {
|
||||
// Configuration error, cleanup and revert
|
||||
_ = os.Remove(enabledConfigFilePath)
|
||||
_ = os.Symlink(configFilePath, maintenanceConfigPath)
|
||||
return fmt.Errorf("%s", output)
|
||||
}
|
||||
|
||||
// Reload nginx
|
||||
output = nginx.Reload()
|
||||
if nginx.GetLogLevel(output) > nginx.Warn {
|
||||
return fmt.Errorf("%s", output)
|
||||
}
|
||||
|
||||
// Synchronize with other nodes
|
||||
go syncDisableMaintenance(name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createMaintenanceConfig creates a maintenance configuration based on the original config
|
||||
func createMaintenanceConfig(conf *config.Config) string {
|
||||
nginxUIPort := settings.ServerSettings.Port
|
||||
schema := "http"
|
||||
if settings.ServerSettings.EnableHTTPS {
|
||||
schema = "https"
|
||||
}
|
||||
|
||||
// Create new configuration
|
||||
ngxConfig := nginx.NewNgxConfig("")
|
||||
|
||||
// Find all server blocks in the original configuration
|
||||
serverBlocks := findServerBlocks(conf.Block)
|
||||
|
||||
// Create maintenance mode configuration for each server block
|
||||
for _, server := range serverBlocks {
|
||||
ngxServer := nginx.NewNgxServer()
|
||||
|
||||
// Copy listen directives
|
||||
listenDirectives := extractDirectives(server, "listen")
|
||||
for _, directive := range listenDirectives {
|
||||
ngxDirective := &nginx.NgxDirective{
|
||||
Directive: directive.GetName(),
|
||||
Params: strings.Join(extractParams(directive), " "),
|
||||
}
|
||||
ngxServer.Directives = append(ngxServer.Directives, ngxDirective)
|
||||
}
|
||||
|
||||
// Copy server_name directives
|
||||
serverNameDirectives := extractDirectives(server, "server_name")
|
||||
for _, directive := range serverNameDirectives {
|
||||
ngxDirective := &nginx.NgxDirective{
|
||||
Directive: directive.GetName(),
|
||||
Params: strings.Join(extractParams(directive), " "),
|
||||
}
|
||||
ngxServer.Directives = append(ngxServer.Directives, ngxDirective)
|
||||
}
|
||||
|
||||
// Copy SSL certificate directives
|
||||
sslCertDirectives := extractDirectives(server, "ssl_certificate")
|
||||
for _, directive := range sslCertDirectives {
|
||||
ngxDirective := &nginx.NgxDirective{
|
||||
Directive: directive.GetName(),
|
||||
Params: strings.Join(extractParams(directive), " "),
|
||||
}
|
||||
ngxServer.Directives = append(ngxServer.Directives, ngxDirective)
|
||||
}
|
||||
|
||||
// Copy SSL certificate key directives
|
||||
sslKeyDirectives := extractDirectives(server, "ssl_certificate_key")
|
||||
for _, directive := range sslKeyDirectives {
|
||||
ngxDirective := &nginx.NgxDirective{
|
||||
Directive: directive.GetName(),
|
||||
Params: strings.Join(extractParams(directive), " "),
|
||||
}
|
||||
ngxServer.Directives = append(ngxServer.Directives, ngxDirective)
|
||||
}
|
||||
|
||||
// Copy http2 directives
|
||||
http2Directives := extractDirectives(server, "http2")
|
||||
for _, directive := range http2Directives {
|
||||
ngxDirective := &nginx.NgxDirective{
|
||||
Directive: directive.GetName(),
|
||||
Params: strings.Join(extractParams(directive), " "),
|
||||
}
|
||||
ngxServer.Directives = append(ngxServer.Directives, ngxDirective)
|
||||
}
|
||||
|
||||
// Add maintenance mode location
|
||||
location := &nginx.NgxLocation{
|
||||
Path: "~ .*",
|
||||
}
|
||||
|
||||
// Build location content using string builder
|
||||
var locationContent strings.Builder
|
||||
locationContent.WriteString("proxy_set_header Host $host;\n")
|
||||
locationContent.WriteString("proxy_set_header X-Real-IP $remote_addr;\n")
|
||||
locationContent.WriteString("proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
|
||||
locationContent.WriteString("proxy_set_header X-Forwarded-Proto $scheme;\n")
|
||||
locationContent.WriteString(fmt.Sprintf("rewrite ^ /pages/maintenance break;\n"))
|
||||
locationContent.WriteString(fmt.Sprintf("proxy_pass %s://127.0.0.1:%d;\n", schema, nginxUIPort))
|
||||
|
||||
location.Content = locationContent.String()
|
||||
ngxServer.Locations = append(ngxServer.Locations, location)
|
||||
|
||||
// Add to configuration
|
||||
ngxConfig.Servers = append(ngxConfig.Servers, ngxServer)
|
||||
}
|
||||
|
||||
// Generate configuration file content
|
||||
content, err := ngxConfig.BuildConfig()
|
||||
if err != nil {
|
||||
logger.Error("Failed to build maintenance config", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// findServerBlocks finds all server blocks in a configuration
|
||||
func findServerBlocks(block config.IBlock) []config.IDirective {
|
||||
var servers []config.IDirective
|
||||
|
||||
if block == nil {
|
||||
return servers
|
||||
}
|
||||
|
||||
for _, directive := range block.GetDirectives() {
|
||||
if directive.GetName() == "server" {
|
||||
servers = append(servers, directive)
|
||||
}
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
// extractDirectives extracts all directives with a specific name from a server block
|
||||
func extractDirectives(server config.IDirective, name string) []config.IDirective {
|
||||
var directives []config.IDirective
|
||||
|
||||
if server.GetBlock() == nil {
|
||||
return directives
|
||||
}
|
||||
|
||||
for _, directive := range server.GetBlock().GetDirectives() {
|
||||
if directive.GetName() == name {
|
||||
directives = append(directives, directive)
|
||||
}
|
||||
}
|
||||
|
||||
return directives
|
||||
}
|
||||
|
||||
// extractParams extracts all parameters from a directive
|
||||
func extractParams(directive config.IDirective) []string {
|
||||
var params []string
|
||||
|
||||
for _, param := range directive.GetParameters() {
|
||||
params = append(params, param.Value)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// syncEnableMaintenance synchronizes enabling maintenance mode with other nodes
|
||||
func syncEnableMaintenance(name string) {
|
||||
nodes := getSyncNodes(name)
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(len(nodes))
|
||||
|
||||
for _, node := range nodes {
|
||||
go func(node *model.Environment) {
|
||||
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().
|
||||
SetHeader("X-Node-Secret", node.Token).
|
||||
Post(fmt.Sprintf("/api/sites/%s/maintenance/enable", name))
|
||||
if err != nil {
|
||||
notification.Error("Enable Remote Site Maintenance Error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
notification.Error("Enable Remote Site Maintenance Error", "Enable site %{name} maintenance on %{node} failed", NewSyncResult(node.Name, name, resp))
|
||||
return
|
||||
}
|
||||
notification.Success("Enable Remote Site Maintenance Success", "Enable site %{name} maintenance on %{node} successfully", NewSyncResult(node.Name, name, resp))
|
||||
}(node)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// syncDisableMaintenance synchronizes disabling maintenance mode with other nodes
|
||||
func syncDisableMaintenance(name string) {
|
||||
nodes := getSyncNodes(name)
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(len(nodes))
|
||||
|
||||
for _, node := range nodes {
|
||||
go func(node *model.Environment) {
|
||||
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().
|
||||
SetHeader("X-Node-Secret", node.Token).
|
||||
Post(fmt.Sprintf("/api/sites/%s/maintenance/disable", name))
|
||||
if err != nil {
|
||||
notification.Error("Disable Remote Site Maintenance Error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
notification.Error("Disable Remote Site Maintenance Error", "Disable site %{name} maintenance on %{node} failed", NewSyncResult(node.Name, name, resp))
|
||||
return
|
||||
}
|
||||
notification.Success("Disable Remote Site Maintenance Success", "Disable site %{name} maintenance on %{node} successfully", NewSyncResult(node.Name, name, resp))
|
||||
}(node)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func newConfigBackup(db *gorm.DB, opts ...gen.DOOption) configBackup {
|
||||
_configBackup.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||
_configBackup.DeletedAt = field.NewField(tableName, "deleted_at")
|
||||
_configBackup.Name = field.NewString(tableName, "name")
|
||||
_configBackup.FilePath = field.NewString(tableName, "file_path")
|
||||
_configBackup.FilePath = field.NewString(tableName, "filepath")
|
||||
_configBackup.Content = field.NewString(tableName, "content")
|
||||
|
||||
_configBackup.fillFieldMap()
|
||||
@@ -73,7 +73,7 @@ func (c *configBackup) updateTableName(table string) *configBackup {
|
||||
c.UpdatedAt = field.NewTime(table, "updated_at")
|
||||
c.DeletedAt = field.NewField(table, "deleted_at")
|
||||
c.Name = field.NewString(table, "name")
|
||||
c.FilePath = field.NewString(table, "file_path")
|
||||
c.FilePath = field.NewString(table, "filepath")
|
||||
c.Content = field.NewString(table, "content")
|
||||
|
||||
c.fillFieldMap()
|
||||
@@ -97,7 +97,7 @@ func (c *configBackup) fillFieldMap() {
|
||||
c.fieldMap["updated_at"] = c.UpdatedAt
|
||||
c.fieldMap["deleted_at"] = c.DeletedAt
|
||||
c.fieldMap["name"] = c.Name
|
||||
c.fieldMap["file_path"] = c.FilePath
|
||||
c.fieldMap["filepath"] = c.FilePath
|
||||
c.fieldMap["content"] = c.Content
|
||||
}
|
||||
|
||||
|
||||
+13
-9
@@ -35,6 +35,7 @@ func newEnvGroup(db *gorm.DB, opts ...gen.DOOption) envGroup {
|
||||
_envGroup.Name = field.NewString(tableName, "name")
|
||||
_envGroup.SyncNodeIds = field.NewField(tableName, "sync_node_ids")
|
||||
_envGroup.OrderID = field.NewInt(tableName, "order_id")
|
||||
_envGroup.PostSyncAction = field.NewString(tableName, "post_sync_action")
|
||||
|
||||
_envGroup.fillFieldMap()
|
||||
|
||||
@@ -44,14 +45,15 @@ func newEnvGroup(db *gorm.DB, opts ...gen.DOOption) envGroup {
|
||||
type envGroup struct {
|
||||
envGroupDo
|
||||
|
||||
ALL field.Asterisk
|
||||
ID field.Uint64
|
||||
CreatedAt field.Time
|
||||
UpdatedAt field.Time
|
||||
DeletedAt field.Field
|
||||
Name field.String
|
||||
SyncNodeIds field.Field
|
||||
OrderID field.Int
|
||||
ALL field.Asterisk
|
||||
ID field.Uint64
|
||||
CreatedAt field.Time
|
||||
UpdatedAt field.Time
|
||||
DeletedAt field.Field
|
||||
Name field.String
|
||||
SyncNodeIds field.Field
|
||||
OrderID field.Int
|
||||
PostSyncAction field.String
|
||||
|
||||
fieldMap map[string]field.Expr
|
||||
}
|
||||
@@ -75,6 +77,7 @@ func (e *envGroup) updateTableName(table string) *envGroup {
|
||||
e.Name = field.NewString(table, "name")
|
||||
e.SyncNodeIds = field.NewField(table, "sync_node_ids")
|
||||
e.OrderID = field.NewInt(table, "order_id")
|
||||
e.PostSyncAction = field.NewString(table, "post_sync_action")
|
||||
|
||||
e.fillFieldMap()
|
||||
|
||||
@@ -91,7 +94,7 @@ func (e *envGroup) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
|
||||
}
|
||||
|
||||
func (e *envGroup) fillFieldMap() {
|
||||
e.fieldMap = make(map[string]field.Expr, 7)
|
||||
e.fieldMap = make(map[string]field.Expr, 8)
|
||||
e.fieldMap["id"] = e.ID
|
||||
e.fieldMap["created_at"] = e.CreatedAt
|
||||
e.fieldMap["updated_at"] = e.UpdatedAt
|
||||
@@ -99,6 +102,7 @@ func (e *envGroup) fillFieldMap() {
|
||||
e.fieldMap["name"] = e.Name
|
||||
e.fieldMap["sync_node_ids"] = e.SyncNodeIds
|
||||
e.fieldMap["order_id"] = e.OrderID
|
||||
e.fieldMap["post_sync_action"] = e.PostSyncAction
|
||||
}
|
||||
|
||||
func (e envGroup) clone(db *gorm.DB) envGroup {
|
||||
|
||||
+4
-1
@@ -15,6 +15,7 @@ import (
|
||||
nginxLog "github.com/0xJacky/Nginx-UI/api/nginx_log"
|
||||
"github.com/0xJacky/Nginx-UI/api/notification"
|
||||
"github.com/0xJacky/Nginx-UI/api/openai"
|
||||
"github.com/0xJacky/Nginx-UI/api/pages"
|
||||
"github.com/0xJacky/Nginx-UI/api/public"
|
||||
"github.com/0xJacky/Nginx-UI/api/settings"
|
||||
"github.com/0xJacky/Nginx-UI/api/sites"
|
||||
@@ -35,13 +36,15 @@ func InitRouter() {
|
||||
|
||||
initEmbedRoute(r)
|
||||
|
||||
pages.InitRouter(r)
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"message": "not found",
|
||||
})
|
||||
})
|
||||
|
||||
root := r.Group("/api")
|
||||
root := r.Group("/api", middleware.IPWhiteList())
|
||||
{
|
||||
public.InitRouter(root)
|
||||
crypto.InitPublicRouter(root)
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func initEmbedRoute(r *gin.Engine) {
|
||||
r.Use(
|
||||
middleware.CacheJs(),
|
||||
middleware.IPWhiteList(),
|
||||
static.Serve("/", middleware.MustFs("")),
|
||||
)
|
||||
r.Use(middleware.CacheJs())
|
||||
|
||||
r.GET("/", middleware.IPWhiteList(), static.Serve("/", middleware.MustFs("")))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user