Merge pull request #1811 from ugurcsen/feature/site-quick-setup

Add quick setup feature and improve map emission
This commit is contained in:
Jacky
2026-08-16 22:48:49 +08:00
committed by GitHub
15 changed files with 1847 additions and 66 deletions

View File

@@ -0,0 +1,483 @@
package template
import (
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/0xJacky/Nginx-UI/internal/nginx"
internalTemplate "github.com/0xJacky/Nginx-UI/internal/template"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"github.com/uozi-tech/cosy"
)
const (
QuickConfigTypeReverseProxy = "reverse_proxy"
QuickConfigTypeStatic = "static"
QuickConfigTypeRedirect = "redirect"
)
type QuickConfigRequest struct {
Type string `json:"type" binding:"required,oneof=reverse_proxy static redirect"`
Domains []string `json:"domains"`
EnableTLS bool `json:"enable_tls"`
RedirectHTTPToHTTPS bool `json:"redirect_http_to_https"`
// Reverse proxy
Scheme string `json:"scheme"`
Host string `json:"host"`
Port string `json:"port"`
EnableWebSocket bool `json:"enable_websocket"`
ClientMaxBodySize string `json:"client_max_body_size"`
// Static site
WebRoot string `json:"web_root"`
Index string `json:"index"`
SpaFallback bool `json:"spa_fallback"`
// Redirect
TargetURL string `json:"target_url"`
RedirectStatus string `json:"redirect_status"`
}
func (r *QuickConfigRequest) fillDefaults() {
if r.Scheme == "" {
r.Scheme = "http"
}
if r.Host == "" {
r.Host = "127.0.0.1"
}
if r.Port == "" {
r.Port = "9000"
}
if r.ClientMaxBodySize == "" {
r.ClientMaxBodySize = "1000m"
}
if r.Index == "" {
r.Index = "index.html"
}
if r.RedirectStatus == "" {
r.RedirectStatus = "301"
}
}
func (r *QuickConfigRequest) validate() error {
if len(r.Domains) == 0 {
return errors.New("domains is required")
}
for _, domain := range r.Domains {
if strings.TrimSpace(domain) == "" {
return errors.New("domain cannot be empty")
}
if !isSafeNginxToken(domain) {
return errors.New("domain contains invalid characters")
}
}
switch r.Type {
case QuickConfigTypeReverseProxy:
if r.Scheme != "http" && r.Scheme != "https" {
return errors.New("scheme must be http or https")
}
if !isSafeNginxToken(r.Host) {
return errors.New("host contains invalid characters")
}
port, err := strconv.Atoi(r.Port)
if err != nil || port < 1 || port > 65535 {
return errors.New("port must be between 1 and 65535")
}
if !reNginxSize.MatchString(r.ClientMaxBodySize) {
return errors.New("client_max_body_size must be a non-negative integer with an optional k, m, or g suffix")
}
case QuickConfigTypeStatic:
if strings.TrimSpace(r.WebRoot) == "" {
return errors.New("web_root is required")
}
if !isSafeNginxToken(r.WebRoot) {
return errors.New("web_root contains invalid characters")
}
if !isSafeNginxValue(r.Index) {
return errors.New("index contains invalid characters")
}
case QuickConfigTypeRedirect:
if strings.TrimSpace(r.TargetURL) == "" {
return errors.New("target_url is required")
}
if !isSafeNginxToken(r.TargetURL) {
return errors.New("target_url contains invalid characters")
}
target, err := url.ParseRequestURI(r.TargetURL)
if err != nil || target.Host == "" || (target.Scheme != "http" && target.Scheme != "https") {
return errors.New("target_url must be an absolute HTTP or HTTPS URL")
}
if r.RedirectStatus != "301" && r.RedirectStatus != "302" && r.RedirectStatus != "308" {
return errors.New("redirect_status must be 301, 302, or 308")
}
}
return nil
}
var (
reUnsafeNginxValue = regexp.MustCompile(`[;{}\r\n]`)
reNginxSize = regexp.MustCompile(`^\d+[kKmMgG]?$`)
)
func isSafeNginxValue(value string) bool {
return value == strings.TrimSpace(value) && value != "" && !reUnsafeNginxValue.MatchString(value)
}
func isSafeNginxToken(value string) bool {
return isSafeNginxValue(value) && !strings.ContainsAny(value, " \t")
}
// quickApp holds the per-type server-level content of a quick config.
type quickApp struct {
directives []*nginx.NgxDirective
locations []*nginx.NgxLocation
custom string
}
func buildQuickApp(r *QuickConfigRequest) (app *quickApp, err error) {
app = &quickApp{}
switch r.Type {
case QuickConfigTypeReverseProxy:
block, err := internalTemplate.ParseTemplate("block", "reverse-proxy.conf", map[string]internalTemplate.Variable{
"enableWebSocket": {Value: r.EnableWebSocket},
"clientMaxBodySize": {Value: r.ClientMaxBodySize},
"scheme": {Value: r.Scheme},
"host": {Value: r.Host},
"port": {Value: r.Port},
})
if err != nil {
return nil, err
}
app.directives = block.Directives
app.locations = block.Locations
app.custom = block.Custom
case QuickConfigTypeStatic:
app.directives = append(app.directives,
&nginx.NgxDirective{Directive: "root", Params: r.WebRoot},
&nginx.NgxDirective{Directive: "index", Params: r.Index},
)
if r.SpaFallback {
block, err := internalTemplate.ParseTemplate("block", "vue-router-history-mode.conf", nil)
if err != nil {
return nil, err
}
app.locations = block.Locations
}
case QuickConfigTypeRedirect:
block, err := internalTemplate.ParseTemplate("block", "redirect.conf", map[string]internalTemplate.Variable{
"status": {Value: r.RedirectStatus},
"target": {Value: r.TargetURL},
})
if err != nil {
return nil, err
}
app.locations = block.Locations
}
return app, nil
}
// letsEncryptLocation returns the HTTP challenge location block parsed from the
// letsencrypt.conf template.
func letsEncryptLocation() (*nginx.NgxLocation, error) {
block, err := internalTemplate.ParseTemplate("block", "letsencrypt.conf", nil)
if err != nil {
return nil, err
}
if len(block.Locations) == 0 {
return nil, errors.New("letsencrypt.conf contains no location")
}
return block.Locations[0], nil
}
func buildQuickConfig(r *QuickConfigRequest) (ngxConfig *nginx.NgxConfig, err error) {
app, err := buildQuickApp(r)
if err != nil {
return nil, err
}
serverName := strings.Join(r.Domains, " ")
port80 := nginx.NewNgxServer()
port80.Directives = []*nginx.NgxDirective{
{Directive: "listen", Params: "80"},
{Directive: "listen", Params: "[::]:80"},
{Directive: "server_name", Params: serverName},
}
var tlsServer *nginx.NgxServer
if r.EnableTLS {
tlsServer = nginx.NewNgxServer()
tlsServer.Directives = []*nginx.NgxDirective{
{Directive: "listen", Params: "443 ssl"},
{Directive: "listen", Params: "[::]:443 ssl"},
{Directive: "server_name", Params: serverName},
{Directive: "ssl_certificate"},
{Directive: "ssl_certificate_key"},
}
tlsServer.Directives = append(tlsServer.Directives, app.directives...)
tlsServer.Locations = append(tlsServer.Locations, app.locations...)
challengeLocation, err := letsEncryptLocation()
if err != nil {
return nil, err
}
tlsServer.Locations = append(tlsServer.Locations, challengeLocation)
if r.RedirectHTTPToHTTPS {
port80.Directives = append(port80.Directives,
&nginx.NgxDirective{Directive: "return", Params: "301 https://$host$request_uri"})
port80.Locations = append(port80.Locations, challengeLocation)
} else {
port80.Directives = append(port80.Directives, app.directives...)
port80.Locations = append(port80.Locations, app.locations...)
port80.Locations = append(port80.Locations, challengeLocation)
}
} else {
port80.Directives = append(port80.Directives, app.directives...)
port80.Locations = append(port80.Locations, app.locations...)
}
servers := []*nginx.NgxServer{port80}
if tlsServer != nil {
servers = append(servers, tlsServer)
}
return &nginx.NgxConfig{
Name: r.Domains[0],
Custom: app.custom,
Upstreams: make([]*nginx.NgxUpstream, 0),
Servers: servers,
}, nil
}
func GetQuickConfig(c *gin.Context) {
var req QuickConfigRequest
if !cosy.BindAndValid(c, &req) {
return
}
req.fillDefaults()
if err := req.validate(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()})
return
}
ngxConfig, err := buildQuickConfig(&req)
if err != nil {
cosy.ErrHandler(c, err)
return
}
content, err := ngxConfig.BuildConfig()
if err != nil {
cosy.ErrHandler(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"message": "ok",
"template": content,
"tokenized": ngxConfig,
})
}
var (
reProxyPass = regexp.MustCompile(`proxy_pass\s+(\S+)`)
reReturn = regexp.MustCompile(`return\s+(\d{3})\s+(\S+)`)
reRoot = regexp.MustCompile(`root\s+(\S+)`)
reIndex = regexp.MustCompile(`index\s+(\S+)`)
reClientBodySize = regexp.MustCompile(`client_max_body_size\s+(\S+)`)
reTryFilesSpa = regexp.MustCompile(`try_files\s+.*\s+/index\.html`)
reProxyPassURL = regexp.MustCompile(`^(https?)://([^:/]+)(?::(\d+))?`)
)
func findDirective(directives []*nginx.NgxDirective, name string) string {
for _, d := range directives {
if d.Directive == name {
return d.Params
}
}
return ""
}
func cleanParam(raw string) string {
return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(raw), ";"))
}
func serverListensSSL(directives []*nginx.NgxDirective) bool {
for _, d := range directives {
if d.Directive == "listen" && strings.Contains(d.Params, "ssl") {
return true
}
}
return false
}
func parseProxyPass(raw string) (scheme, host, port string) {
m := reProxyPassURL.FindStringSubmatch(raw)
if m == nil {
return
}
return m[1], m[2], m[3]
}
// analyzeNgxConfig best-effort derives a QuickConfigRequest from an existing
// nginx config so the quick-setup wizard can pre-fill the edit form. Configs
// that do not match the quick-setup shapes fall back to safe defaults.
func analyzeNgxConfig(ngxConfig *nginx.NgxConfig) (req QuickConfigRequest) {
req.Type = QuickConfigTypeReverseProxy
var domains []string
seenDomains := make(map[string]struct{})
var hasTLS, redirectHTTPToHTTPS bool
var rpProxyPass, rpHasWebSocket bool
var staticRoot, staticIndex string
var staticSpa bool
var bodySize string
var redirectStatus, redirectTarget string
for _, server := range ngxConfig.Servers {
if server == nil {
continue
}
if params := findDirective(server.Directives, "server_name"); params != "" {
for _, name := range strings.Fields(params) {
if name == "_" {
continue
}
if _, ok := seenDomains[name]; ok {
continue
}
seenDomains[name] = struct{}{}
domains = append(domains, name)
}
}
if serverListensSSL(server.Directives) {
hasTLS = true
}
if params := findDirective(server.Directives, "return"); params != "" &&
strings.Contains(params, "https://$host$request_uri") {
redirectHTTPToHTTPS = true
}
if staticRoot == "" {
staticRoot = cleanParam(findDirective(server.Directives, "root"))
}
if staticIndex == "" {
staticIndex = cleanParam(findDirective(server.Directives, "index"))
}
if bodySize == "" {
bodySize = cleanParam(findDirective(server.Directives, "client_max_body_size"))
}
for _, location := range server.Locations {
// The ACME HTTP-01 challenge location proxies to the local
// challenge port; ignore it when detecting the quick-setup type.
if strings.Contains(location.Path, "acme-challenge") {
continue
}
content := location.Content
if m := reReturn.FindStringSubmatch(content); m != nil && m[2] != "https://$host$request_uri" {
redirectStatus = cleanParam(m[1])
redirectTarget = cleanParam(m[2])
}
if !rpProxyPass {
if m := reProxyPass.FindStringSubmatch(content); m != nil {
rpProxyPass = true
req.Scheme, req.Host, req.Port = parseProxyPass(cleanParam(m[1]))
}
}
if strings.Contains(content, "proxy_http_version 1.1") ||
strings.Contains(content, "proxy_set_header Upgrade") {
rpHasWebSocket = true
}
if staticRoot == "" {
if m := reRoot.FindStringSubmatch(content); m != nil {
staticRoot = cleanParam(m[1])
}
}
if staticIndex == "" {
if m := reIndex.FindStringSubmatch(content); m != nil {
staticIndex = cleanParam(m[1])
}
}
if reTryFilesSpa.MatchString(content) {
staticSpa = true
}
if bodySize == "" {
if m := reClientBodySize.FindStringSubmatch(content); m != nil {
bodySize = cleanParam(m[1])
}
}
}
}
req.Domains = domains
req.EnableTLS = hasTLS
req.RedirectHTTPToHTTPS = redirectHTTPToHTTPS
switch {
case redirectStatus != "" && redirectTarget != "":
req.Type = QuickConfigTypeRedirect
req.RedirectStatus = redirectStatus
req.TargetURL = redirectTarget
case rpProxyPass:
req.Type = QuickConfigTypeReverseProxy
req.EnableWebSocket = rpHasWebSocket
default:
if staticRoot != "" {
req.Type = QuickConfigTypeStatic
req.WebRoot = staticRoot
req.Index = staticIndex
req.SpaFallback = staticSpa
}
}
if bodySize != "" {
req.ClientMaxBodySize = bodySize
}
req.fillDefaults()
return req
}
func AnalyzeQuickConfig(c *gin.Context) {
var json struct {
Config string `json:"config" binding:"required"`
}
if !cosy.BindAndValid(c, &json) {
return
}
ngxConfig, err := nginx.ParseNgxConfigByContent(json.Config)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()})
return
}
req := analyzeNgxConfig(ngxConfig)
c.JSON(http.StatusOK, gin.H{
"message": "ok",
"request": req,
})
}

View File

@@ -0,0 +1,428 @@
package template
import (
"strings"
"testing"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/stretchr/testify/assert"
)
func TestBuildQuickConfigReverseProxy(t *testing.T) {
req := QuickConfigRequest{
Type: QuickConfigTypeReverseProxy,
Domains: []string{"example.com", "www.example.com"},
EnableWebSocket: true,
ClientMaxBodySize: "100m",
Scheme: "http",
Host: "127.0.0.1",
Port: "8080",
EnableTLS: true,
RedirectHTTPToHTTPS: true,
}
req.fillDefaults()
assert.NoError(t, req.validate())
cfg, err := buildQuickConfig(&req)
assert.NoError(t, err)
assert.Len(t, cfg.Servers, 2)
assert.Equal(t, "example.com", cfg.Name)
port80 := cfg.Servers[0]
// HTTP -> HTTPS redirect
returnDirectives := findServerDirectives(port80, "return")
if assert.Len(t, returnDirectives, 1) {
assert.Equal(t, "301 https://$host$request_uri", returnDirectives[0].Params)
}
// Challenge location must be available on port 80 for HTTP-01.
assertLocation(t, port80, "~ /.well-known/acme-challenge")
tls := cfg.Servers[1]
assert.Equal(t, "443 ssl", findServerDirectives(tls, "listen")[0].Params)
assert.Equal(t, "example.com www.example.com", findServerDirectives(tls, "server_name")[0].Params)
// Empty ssl_certificate placeholders, filled in by the cert flow.
assert.Empty(t, findServerDirectives(tls, "ssl_certificate")[0].Params)
assert.Empty(t, findServerDirectives(tls, "ssl_certificate_key")[0].Params)
// Reverse proxy location / must proxy to the target.
location := findLocation(tls, "/")
assert.Contains(t, location.Content, "proxy_pass http://127.0.0.1:8080/;")
assert.Contains(t, location.Content, "client_max_body_size 100m;")
// WebSocket upgrade headers.
assert.Contains(t, location.Content, "proxy_set_header Upgrade $http_upgrade;")
assert.Contains(t, cfg.Custom, "map $http_upgrade $connection_upgrade")
// The built config must be re-parseable.
content, err := cfg.BuildConfig()
assert.NoError(t, err)
assert.NotEmpty(t, content)
parsed, err := nginx.ParseNgxConfigByContent(content)
assert.NoError(t, err)
assert.Len(t, parsed.Servers, 2)
}
func TestBuildQuickConfigReverseProxyNoTLS(t *testing.T) {
req := QuickConfigRequest{
Type: QuickConfigTypeReverseProxy,
Domains: []string{"example.com"},
Scheme: "https",
Host: "10.0.0.5",
Port: "8443",
EnableWebSocket: false,
ClientMaxBodySize: "10m",
}
req.fillDefaults()
assert.NoError(t, req.validate())
cfg, err := buildQuickConfig(&req)
assert.NoError(t, err)
assert.Len(t, cfg.Servers, 1)
// Only the proxy location, no challenge location when TLS is disabled.
assertLocation(t, cfg.Servers[0], "/")
assert.Nil(t, findLocation(cfg.Servers[0], "~ /.well-known/acme-challenge"))
location := findLocation(cfg.Servers[0], "/")
assert.Contains(t, location.Content, "proxy_pass https://10.0.0.5:8443/;")
// WebSocket headers only rendered when enabled.
assert.NotContains(t, location.Content, "proxy_set_header Upgrade $http_upgrade;")
// The Forwarded maps must stay even without WebSocket because the
// unconditional proxy_set_header Forwarded line depends on them.
assert.Contains(t, cfg.Custom, "map $http_forwarded $proxy_add_forwarded")
assert.Contains(t, cfg.Custom, "map $remote_addr $proxy_forwarded_elem")
assert.NotContains(t, cfg.Custom, "map $http_upgrade $connection_upgrade")
}
func TestBuildQuickConfigStatic(t *testing.T) {
req := QuickConfigRequest{
Type: QuickConfigTypeStatic,
Domains: []string{"static.example.com"},
WebRoot: "/var/www/html",
SpaFallback: true,
}
req.fillDefaults()
assert.NoError(t, req.validate())
cfg, err := buildQuickConfig(&req)
assert.NoError(t, err)
assert.Len(t, cfg.Servers, 1)
assert.Equal(t, "/var/www/html", findServerDirectives(cfg.Servers[0], "root")[0].Params)
assert.Equal(t, "index.html", findServerDirectives(cfg.Servers[0], "index")[0].Params)
assertLocation(t, cfg.Servers[0], "/")
content, err := cfg.BuildConfig()
assert.NoError(t, err)
assert.Contains(t, content, "root /var/www/html;")
assert.Contains(t, content, "try_files $uri $uri/ /index.html;")
_, err = nginx.ParseNgxConfigByContent(content)
assert.NoError(t, err)
}
func TestBuildQuickConfigStaticTLSWithoutRedirect(t *testing.T) {
req := QuickConfigRequest{
Type: QuickConfigTypeStatic,
Domains: []string{"static.example.com"},
WebRoot: "/srv/www",
EnableTLS: true,
RedirectHTTPToHTTPS: false,
}
req.fillDefaults()
assert.NoError(t, req.validate())
cfg, err := buildQuickConfig(&req)
assert.NoError(t, err)
assert.Len(t, cfg.Servers, 2)
// Port 80 keeps serving the site and exposes the challenge location.
assert.NotNil(t, findServerDirectives(cfg.Servers[0], "root"))
assertLocation(t, cfg.Servers[0], "~ /.well-known/acme-challenge")
assertLocation(t, cfg.Servers[1], "~ /.well-known/acme-challenge")
}
func TestBuildQuickConfigRedirect(t *testing.T) {
req := QuickConfigRequest{
Type: QuickConfigTypeRedirect,
Domains: []string{"old.example.com"},
TargetURL: "https://new.example.com",
RedirectStatus: "308",
}
req.fillDefaults()
assert.NoError(t, req.validate())
cfg, err := buildQuickConfig(&req)
assert.NoError(t, err)
assert.Len(t, cfg.Servers, 1)
location := findLocation(cfg.Servers[0], "/")
assert.Contains(t, location.Content, "return 308 https://new.example.com;")
content, err := cfg.BuildConfig()
assert.NoError(t, err)
assert.Contains(t, content, "return 308 https://new.example.com;")
_, err = nginx.ParseNgxConfigByContent(content)
assert.NoError(t, err)
}
func TestBuildQuickConfigValidation(t *testing.T) {
t.Run("missing domains", func(t *testing.T) {
req := QuickConfigRequest{Type: QuickConfigTypeStatic, WebRoot: "/var/www"}
assert.Error(t, req.validate())
})
t.Run("invalid scheme", func(t *testing.T) {
req := QuickConfigRequest{Type: QuickConfigTypeReverseProxy, Domains: []string{"a.com"}, Scheme: "ftp"}
assert.Error(t, req.validate())
})
t.Run("static missing web_root", func(t *testing.T) {
req := QuickConfigRequest{Type: QuickConfigTypeStatic, Domains: []string{"a.com"}}
assert.Error(t, req.validate())
})
t.Run("redirect missing target", func(t *testing.T) {
req := QuickConfigRequest{Type: QuickConfigTypeRedirect, Domains: []string{"a.com"}}
assert.Error(t, req.validate())
})
t.Run("defaults applied", func(t *testing.T) {
req := QuickConfigRequest{Type: QuickConfigTypeRedirect, Domains: []string{"a.com"}, TargetURL: "https://b.com"}
req.fillDefaults()
assert.Equal(t, "301", req.RedirectStatus)
})
tests := []struct {
name string
req QuickConfigRequest
}{
{
name: "domain directive injection",
req: QuickConfigRequest{Type: QuickConfigTypeStatic, Domains: []string{"example.com;\nreturn 200"}, WebRoot: "/var/www"},
},
{
name: "reverse proxy host directive injection",
req: QuickConfigRequest{Type: QuickConfigTypeReverseProxy, Domains: []string{"example.com"}, Host: "127.0.0.1;", Port: "9000"},
},
{
name: "reverse proxy non-numeric port",
req: QuickConfigRequest{Type: QuickConfigTypeReverseProxy, Domains: []string{"example.com"}, Host: "127.0.0.1", Port: "http"},
},
{
name: "reverse proxy out-of-range port",
req: QuickConfigRequest{Type: QuickConfigTypeReverseProxy, Domains: []string{"example.com"}, Host: "127.0.0.1", Port: "65536"},
},
{
name: "reverse proxy invalid body size",
req: QuickConfigRequest{Type: QuickConfigTypeReverseProxy, Domains: []string{"example.com"}, Host: "127.0.0.1", Port: "9000", ClientMaxBodySize: "100m;"},
},
{
name: "static root directive injection",
req: QuickConfigRequest{Type: QuickConfigTypeStatic, Domains: []string{"example.com"}, WebRoot: "/var/www;\nreturn 200"},
},
{
name: "static index directive injection",
req: QuickConfigRequest{Type: QuickConfigTypeStatic, Domains: []string{"example.com"}, WebRoot: "/var/www", Index: "index.html;"},
},
{
name: "redirect target directive injection",
req: QuickConfigRequest{Type: QuickConfigTypeRedirect, Domains: []string{"example.com"}, TargetURL: "https://new.example.com;return", RedirectStatus: "301"},
},
{
name: "redirect non-http target",
req: QuickConfigRequest{Type: QuickConfigTypeRedirect, Domains: []string{"example.com"}, TargetURL: "javascript:alert(1)", RedirectStatus: "301"},
},
{
name: "redirect unsupported status",
req: QuickConfigRequest{Type: QuickConfigTypeRedirect, Domains: []string{"example.com"}, TargetURL: "https://new.example.com", RedirectStatus: "307"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.req.fillDefaults()
assert.Error(t, tt.req.validate())
})
}
t.Run("bracketed IPv6 reverse proxy host", func(t *testing.T) {
req := QuickConfigRequest{
Type: QuickConfigTypeReverseProxy,
Domains: []string{"example.com"},
Host: "[::1]",
Port: "9000",
}
req.fillDefaults()
assert.NoError(t, req.validate())
})
}
func assertQuickConfigEqual(t *testing.T, expected, actual QuickConfigRequest) {
t.Helper()
assert.Equal(t, expected.Type, actual.Type)
assert.Equal(t, expected.Domains, actual.Domains)
assert.Equal(t, expected.EnableTLS, actual.EnableTLS)
assert.Equal(t, expected.RedirectHTTPToHTTPS, actual.RedirectHTTPToHTTPS)
assert.Equal(t, expected.Scheme, actual.Scheme)
assert.Equal(t, expected.Host, actual.Host)
assert.Equal(t, expected.Port, actual.Port)
assert.Equal(t, expected.EnableWebSocket, actual.EnableWebSocket)
assert.Equal(t, expected.ClientMaxBodySize, actual.ClientMaxBodySize)
assert.Equal(t, expected.WebRoot, actual.WebRoot)
assert.Equal(t, expected.Index, actual.Index)
assert.Equal(t, expected.SpaFallback, actual.SpaFallback)
assert.Equal(t, expected.TargetURL, actual.TargetURL)
assert.Equal(t, expected.RedirectStatus, actual.RedirectStatus)
}
// assertAnalyzeRoundTrip builds a config from the request, re-parses it and
// asserts that analysis recovers the exact same request. This guarantees the
// edit-page pre-fill mirrors what the wizard generated.
func assertAnalyzeRoundTrip(t *testing.T, req QuickConfigRequest) {
t.Helper()
req.fillDefaults()
assert.NoError(t, req.validate())
cfg, err := buildQuickConfig(&req)
assert.NoError(t, err)
content, err := cfg.BuildConfig()
assert.NoError(t, err)
parsed, err := nginx.ParseNgxConfigByContent(content)
assert.NoError(t, err)
analyzed := analyzeNgxConfig(parsed)
assertQuickConfigEqual(t, req, analyzed)
}
func TestAnalyzeQuickConfigRoundTrip(t *testing.T) {
t.Run("reverse proxy with TLS and redirect", func(t *testing.T) {
assertAnalyzeRoundTrip(t, QuickConfigRequest{
Type: QuickConfigTypeReverseProxy,
Domains: []string{"example.com", "www.example.com"},
EnableWebSocket: true,
ClientMaxBodySize: "100m",
Scheme: "http",
Host: "127.0.0.1",
Port: "8080",
EnableTLS: true,
RedirectHTTPToHTTPS: true,
})
})
t.Run("reverse proxy no TLS", func(t *testing.T) {
assertAnalyzeRoundTrip(t, QuickConfigRequest{
Type: QuickConfigTypeReverseProxy,
Domains: []string{"example.com"},
Scheme: "https",
Host: "10.0.0.5",
Port: "8443",
EnableWebSocket: false,
ClientMaxBodySize: "10m",
})
})
t.Run("static with SPA", func(t *testing.T) {
assertAnalyzeRoundTrip(t, QuickConfigRequest{
Type: QuickConfigTypeStatic,
Domains: []string{"static.example.com"},
WebRoot: "/var/www/html",
SpaFallback: true,
})
})
t.Run("static TLS without redirect", func(t *testing.T) {
assertAnalyzeRoundTrip(t, QuickConfigRequest{
Type: QuickConfigTypeStatic,
Domains: []string{"static.example.com"},
WebRoot: "/srv/www",
EnableTLS: true,
RedirectHTTPToHTTPS: false,
})
})
t.Run("redirect", func(t *testing.T) {
assertAnalyzeRoundTrip(t, QuickConfigRequest{
Type: QuickConfigTypeRedirect,
Domains: []string{"old.example.com"},
TargetURL: "https://new.example.com",
RedirectStatus: "308",
})
})
t.Run("redirect with TLS and redirect to https", func(t *testing.T) {
assertAnalyzeRoundTrip(t, QuickConfigRequest{
Type: QuickConfigTypeRedirect,
Domains: []string{"old.example.com"},
TargetURL: "https://new.example.com",
RedirectStatus: "301",
EnableTLS: true,
RedirectHTTPToHTTPS: true,
})
})
}
func TestAnalyzeQuickConfigBestEffort(t *testing.T) {
t.Run("unrecognized config falls back to defaults", func(t *testing.T) {
cfg, err := buildQuickConfig(&QuickConfigRequest{
Type: QuickConfigTypeStatic,
Domains: []string{"a.com"},
WebRoot: "/tmp/site",
})
assert.NoError(t, err)
var kept []*nginx.NgxDirective
for _, d := range cfg.Servers[0].Directives {
if d.Directive == "root" || d.Directive == "index" {
continue
}
kept = append(kept, d)
}
cfg.Servers[0].Directives = kept
req := analyzeNgxConfig(cfg)
assert.Equal(t, QuickConfigTypeReverseProxy, req.Type)
assert.Equal(t, []string{"a.com"}, req.Domains)
})
t.Run("server name default underscore is ignored", func(t *testing.T) {
req := QuickConfigRequest{
Type: QuickConfigTypeStatic,
Domains: []string{"a.com"},
WebRoot: "/tmp/site",
}
req.fillDefaults()
cfg, err := buildQuickConfig(&req)
assert.NoError(t, err)
cfg.Servers[0].Directives = append(cfg.Servers[0].Directives,
&nginx.NgxDirective{Directive: "listen", Params: "80 default_server"})
cfg.Servers[0].Directives = append(cfg.Servers[0].Directives,
&nginx.NgxDirective{Directive: "server_name", Params: "_"})
analyzed := analyzeNgxConfig(cfg)
assert.Equal(t, []string{"a.com"}, analyzed.Domains)
})
}
func findServerDirectives(server *nginx.NgxServer, name string) []*nginx.NgxDirective {
var result []*nginx.NgxDirective
for _, d := range server.Directives {
if d.Directive == name {
result = append(result, d)
}
}
return result
}
func findLocation(server *nginx.NgxServer, path string) *nginx.NgxLocation {
for _, l := range server.Locations {
if strings.TrimSpace(l.Path) == path {
return l
}
}
return nil
}
func assertLocation(t *testing.T, server *nginx.NgxServer, path string) {
t.Helper()
l := findLocation(server, path)
if assert.NotNil(t, l, "expected location %q", path) {
assert.NotEmpty(t, l.Content)
}
}

View File

@@ -8,4 +8,6 @@ func InitRouter(r *gin.RouterGroup) {
r.GET("templates/blocks", GetTemplateBlockList)
r.GET("templates/block/:name", GetTemplateBlock)
r.POST("templates/block/:name", GetTemplateBlock)
r.POST("templates/quick_config", GetQuickConfig)
r.POST("templates/quick_config/analyze", AnalyzeQuickConfig)
}

View File

@@ -1,4 +1,4 @@
import type { NgxDirective, NgxLocation, NgxServer } from '@/api/ngx'
import type { NgxConfig, NgxDirective, NgxLocation, NgxServer } from '@/api/ngx'
import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request'
export interface Variable {
@@ -20,6 +20,33 @@ export interface Template extends NgxServer {
directives?: NgxDirective[]
}
export type QuickConfigType = 'reverse_proxy' | 'static' | 'redirect'
export interface QuickConfigRequest {
type: QuickConfigType
domains: string[]
enable_tls?: boolean
redirect_http_to_https?: boolean
// reverse_proxy
scheme?: 'http' | 'https'
host?: string
port?: string
enable_websocket?: boolean
client_max_body_size?: string
// static
web_root?: string
index?: string
spa_fallback?: boolean
// redirect
target_url?: string
redirect_status?: '301' | '302' | '308'
}
export interface QuickConfigResponse {
template: string
tokenized: NgxConfig
}
const baseUrl = '/templates'
const template = extendCurdApi(useCurdApi<Template>(baseUrl), {
@@ -28,6 +55,8 @@ const template = extendCurdApi(useCurdApi<Template>(baseUrl), {
get_config: (name: string) => http.get(`${baseUrl}/config/${name}`),
get_block: (name: string) => http.get(`${baseUrl}/block/${name}`),
build_block: (name: string, data: Variable) => http.post(`${baseUrl}/block/${name}`, data),
get_quick_config: (data: QuickConfigRequest): Promise<QuickConfigResponse> => http.post(`${baseUrl}/quick_config`, data),
analyze_quick_config: (config: string): Promise<{ request: QuickConfigRequest }> => http.post(`${baseUrl}/quick_config/analyze`, { config }),
})
export default template

View File

@@ -399,7 +399,7 @@ msgstr ""
#: src/views/preference/components/AuthSettings/Passkey.vue:131
#: src/views/preference/Preference.vue:147
#: src/views/site/site_edit/components/ConfigName/ConfigName.vue:52
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:196
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:208
#: src/views/stream/components/ConfigName.vue:52
#: src/views/stream/components/StreamEditor.vue:154
msgid "Save"
@@ -425,6 +425,8 @@ msgstr ""
#: src/views/site/site_edit/components/Cert/IssueCert.vue:53
#: src/views/site/site_edit/components/Cert/ObtainCert.vue:165
#: src/views/site/site_edit/components/EnableTLS/EnableTLS.vue:21
#: src/views/site/site_edit/components/QuickSetupModal.vue:55
#: src/views/site/site_edit/components/QuickSetupModal.vue:97
#: src/views/site/site_list/SiteList.vue:138
#: src/views/stream/components/StreamStatusSelect.vue:62
msgid "Cancel"
@@ -711,8 +713,8 @@ msgstr ""
#: src/components/NgxConfigEditor/directive/DirectiveEditorItem.vue:54
#: src/language/curd.ts:28
#: src/views/config/components/ConfigLeftPanel.vue:198
#: src/views/site/site_add/SiteAdd.vue:67
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:57
#: src/views/site/site_add/SiteAdd.vue:110
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:60
#: src/views/stream/components/StreamEditor.vue:30
msgid "Saved successfully"
msgstr ""
@@ -1340,6 +1342,7 @@ msgstr ""
#: src/components/PortScanner/PortScannerCompact.vue:31
#: src/views/preference/tabs/ServerSettings.vue:22
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:74
msgid "Port"
msgstr ""
@@ -1910,6 +1913,7 @@ msgstr ""
#: src/views/nginx_log/NginxLogList.vue:147
#: src/views/notification/notificationColumns.tsx:8
#: src/views/preference/components/ExternalNotify/columns.tsx:19
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:27
msgid "Type"
msgstr ""
@@ -3263,7 +3267,7 @@ msgid "Reloading nginx"
msgstr ""
#: src/language/constants.ts:19
#: src/views/site/site_add/SiteAdd.vue:186
#: src/views/site/site_add/SiteAdd.vue:232
msgid "Finished"
msgstr ""
@@ -3950,7 +3954,7 @@ msgid "Sites List"
msgstr ""
#: src/routes/modules/sites.ts:26
#: src/views/site/site_add/SiteAdd.vue:177
#: src/views/site/site_add/SiteAdd.vue:212
msgid "Add Site"
msgstr ""
@@ -4067,7 +4071,7 @@ msgstr ""
#: src/views/preference/tabs/NodeSettings.vue:28
#: src/views/preference/tabs/NodeSettings.vue:33
#: src/views/site/components/SiteStatusSelect.vue:159
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:75
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:78
#: src/views/site/site_list/columns.tsx:142
#: src/views/stream/columns.tsx:108
#: src/views/stream/components/RightPanel/Basic.vue:24
@@ -4086,7 +4090,7 @@ msgstr ""
#: src/views/preference/tabs/NodeSettings.vue:28
#: src/views/preference/tabs/NodeSettings.vue:33
#: src/views/site/components/SiteStatusSelect.vue:162
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:81
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:84
#: src/views/site/site_list/columns.tsx:146
#: src/views/stream/columns.tsx:112
#: src/views/stream/components/StreamEditor.vue:55
@@ -4611,8 +4615,8 @@ msgstr[1] ""
#: src/views/dns/DNSGroupRecordManager.vue:1546
#: src/views/dns/DNSRecordManager.vue:258
#: src/views/nginx_log/NginxLog.vue:129
#: src/views/site/site_add/SiteAdd.vue:260
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:189
#: src/views/site/site_add/SiteAdd.vue:324
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:201
#: src/views/stream/components/StreamEditor.vue:147
msgid "Back"
msgstr ""
@@ -4826,8 +4830,8 @@ msgstr ""
#: src/views/certificate/components/DNSIssueCertificate.vue:223
#: src/views/install/components/InstallView.vue:212
#: src/views/site/site_add/SiteAdd.vue:247
#: src/views/site/site_add/SiteAdd.vue:254
#: src/views/site/site_add/SiteAdd.vue:310
#: src/views/site/site_add/SiteAdd.vue:318
#: src/views/site/site_edit/components/Cert/ObtainCert.vue:260
msgid "Next"
msgstr ""
@@ -4916,6 +4920,7 @@ msgstr ""
#: src/views/certificate/components/SelfSignedCertFields.vue:36
#: src/views/dns/DNSGroupList.vue:30
#: src/views/dns/DNSGroupList.vue:266
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:45
msgid "Domains"
msgstr ""
@@ -4952,7 +4957,7 @@ msgid "Format successfully"
msgstr ""
#: src/views/config/components/ConfigLeftPanel.vue:255
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:100
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:103
#: src/views/stream/components/StreamEditor.vue:68
msgid "History"
msgstr ""
@@ -5240,6 +5245,7 @@ msgid "Server names hash table size"
msgstr ""
#: src/views/dashboard/components/ParamsOpt/PerformanceConfig.vue:110
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:84
msgid "Client Max Body Size"
msgstr ""
@@ -6025,6 +6031,7 @@ msgstr ""
#: src/views/dns/components/DNSRecordFilter.vue:42
#: src/views/preference/tabs/ServerSettings.vue:19
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:67
msgid "Host"
msgstr ""
@@ -8113,6 +8120,7 @@ msgid "Node"
msgstr ""
#: src/views/preference/Preference.vue:87
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:58
msgid "HTTP"
msgstr ""
@@ -8702,8 +8710,70 @@ msgstr ""
msgid "Terminal Start Command"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:18
#: src/views/site/site_add/SiteAdd.vue:243
msgid "Configuration Name"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:33
msgid "Reverse Proxy"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:36
msgid "Static Site"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:39
msgid "Redirect"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:50
msgid "example.com www.example.com"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:55
msgid "Scheme"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:61
msgid "HTTPS"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:80
msgid "Enable WebSocket"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:91
msgid "Web Root"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:100
msgid "Index"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:104
msgid "Single Page Application Fallback"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:111
msgid "Target URL"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:120
msgid "Status Code"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:136
#: src/views/site/site_edit/components/EnableTLS/EnableTLS.vue:97
msgid "Enable TLS"
msgstr ""
#: src/views/site/components/QuickSetup/QuickSetupForm.vue:142
msgid "Redirect HTTP to HTTPS"
msgstr ""
#: src/views/site/components/SiteStatusSelect.vue:54
#: src/views/site/site_add/SiteAdd.vue:70
#: src/views/site/site_add/SiteAdd.vue:113
#: src/views/stream/components/StreamStatusSelect.vue:27
msgid "Enabled successfully"
msgstr ""
@@ -8758,7 +8828,7 @@ msgid "Do you want to %{action} this site?"
msgstr ""
#: src/views/site/components/SiteStatusSelect.vue:165
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:87
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:90
#: src/views/site/site_list/columns.tsx:150
msgid "Maintenance"
msgstr ""
@@ -8802,7 +8872,7 @@ msgid "Select DNS domain"
msgstr ""
#: src/views/site/site_add/components/DNSRecordIntegration.vue:286
#: src/views/site/site_add/SiteAdd.vue:184
#: src/views/site/site_add/SiteAdd.vue:230
#: src/views/site/site_edit/components/RightPanel/DNS.vue:618
msgid "DNS Record"
msgstr ""
@@ -8876,44 +8946,55 @@ msgstr ""
msgid "Please add a DNS domain first in the DNS management section."
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:151
#: src/views/site/site_add/SiteAdd.vue:64
#: src/views/site/site_add/SiteAdd.vue:284
msgid "Issue a certificate to enable TLS before continuing."
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:194
msgid "DNS record selected: %{name}"
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:159
#: src/views/site/site_add/SiteAdd.vue:202
msgid "DNS record created and linked successfully"
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:183
#: src/views/site/site_add/SiteAdd.vue:217
#: src/views/site/site_edit/components/QuickSetupModal.vue:76
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:112
msgid "Quick Setup"
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:218
msgid "Advanced"
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:229
msgid "Base information"
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:185
#: src/views/site/site_add/SiteAdd.vue:231
msgid "Configure SSL"
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:190
msgid "Configuration Name"
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:200
#: src/views/site/site_add/SiteAdd.vue:253
#: src/views/site/site_edit/components/RightPanel/DNS.vue:491
msgid "The parameter of server_name is required"
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:266
#: src/views/site/site_add/SiteAdd.vue:330
msgid "Site Config Created Successfully"
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:267
#: src/views/site/site_add/SiteAdd.vue:331
msgid "DNS record has been linked: %{name}"
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:274
#: src/views/site/site_add/SiteAdd.vue:338
msgid "Modify Config"
msgstr ""
#: src/views/site/site_add/SiteAdd.vue:277
#: src/views/site/site_add/SiteAdd.vue:341
msgid "Create Another"
msgstr ""
@@ -9031,8 +9112,28 @@ msgstr ""
msgid "Author"
msgstr ""
#: src/views/site/site_edit/components/EnableTLS/EnableTLS.vue:97
msgid "Enable TLS"
#: src/views/site/site_edit/components/QuickSetupModal.vue:52
msgid "Replace configuration?"
msgstr ""
#: src/views/site/site_edit/components/QuickSetupModal.vue:53
msgid "The generated configuration will replace the current one. Any custom directives or locations will be lost."
msgstr ""
#: src/views/site/site_edit/components/QuickSetupModal.vue:54
msgid "Replace"
msgstr ""
#: src/views/site/site_edit/components/QuickSetupModal.vue:67
msgid "Configuration regenerated"
msgstr ""
#: src/views/site/site_edit/components/QuickSetupModal.vue:86
msgid "Issue a certificate to enable TLS before saving."
msgstr ""
#: src/views/site/site_edit/components/QuickSetupModal.vue:105
msgid "Generate Config"
msgstr ""
#: src/views/site/site_edit/components/RightPanel/Basic.vue:42
@@ -9117,22 +9218,22 @@ msgstr ""
msgid "Port Scanner"
msgstr ""
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:70
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:73
#: src/views/stream/components/StreamEditor.vue:44
msgid "Edit %{n}"
msgstr ""
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:113
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:125
#: src/views/stream/components/StreamEditor.vue:80
msgid "Advance Mode"
msgstr ""
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:116
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:128
#: src/views/stream/components/StreamEditor.vue:83
msgid "Basic Mode"
msgstr ""
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:141
#: src/views/site/site_edit/components/SiteEditor/SiteEditor.vue:153
#: src/views/stream/components/StreamEditor.vue:105
msgid "Nginx Configuration Parse Error"
msgstr ""

View File

@@ -0,0 +1,150 @@
<script setup lang="ts">
import type { QuickConfig } from './useQuickConfig'
defineOptions({ name: 'QuickSetupForm' })
const props = withDefaults(defineProps<{
quick: QuickConfig
showName?: boolean
}>(), {
showName: true,
})
const { state, quickDerivedName, quickNameTouched } = props.quick
</script>
<template>
<AForm layout="vertical">
<AFormItem
v-if="showName !== false"
:label="$gettext('Configuration Name')"
>
<AInput
v-model:value="state.name"
:placeholder="quickDerivedName"
@change="quickNameTouched = true"
/>
</AFormItem>
<AFormItem :label="$gettext('Type')">
<ARadioGroup
v-model:value="state.type"
button-style="solid"
>
<ARadioButton value="reverse_proxy">
{{ $gettext('Reverse Proxy') }}
</ARadioButton>
<ARadioButton value="static">
{{ $gettext('Static Site') }}
</ARadioButton>
<ARadioButton value="redirect">
{{ $gettext('Redirect') }}
</ARadioButton>
</ARadioGroup>
</AFormItem>
<AFormItem
:label="$gettext('Domains')"
required
>
<AInput
v-model:value="state.domains"
:placeholder="$gettext('example.com www.example.com')"
/>
</AFormItem>
<template v-if="state.type === 'reverse_proxy'">
<AFormItem :label="$gettext('Scheme')">
<ASelect v-model:value="state.rpScheme">
<ASelectOption value="http">
{{ $gettext('HTTP') }}
</ASelectOption>
<ASelectOption value="https">
{{ $gettext('HTTPS') }}
</ASelectOption>
</ASelect>
</AFormItem>
<AFormItem
:label="$gettext('Host')"
required
>
<AInput v-model:value="state.rpHost" />
</AFormItem>
<AFormItem
:label="$gettext('Port')"
required
>
<AInput v-model:value="state.rpPort" />
</AFormItem>
<AFormItem :label="$gettext('Enable WebSocket')">
<ASwitch v-model:checked="state.rpWebSocket" />
</AFormItem>
<AFormItem :label="$gettext('Client Max Body Size')">
<AInput v-model:value="state.rpMaxBodySize" />
</AFormItem>
</template>
<template v-else-if="state.type === 'static'">
<AFormItem
:label="$gettext('Web Root')"
required
>
<AInput
v-model:value="state.stWebRoot"
placeholder="/var/www/html"
/>
</AFormItem>
<AFormItem :label="$gettext('Index')">
<AInput v-model:value="state.stIndex" />
</AFormItem>
<AFormItem :label="$gettext('Single Page Application Fallback')">
<ASwitch v-model:checked="state.stSpa" />
</AFormItem>
</template>
<template v-else>
<AFormItem
:label="$gettext('Target URL')"
required
>
<AInput
v-model:value="state.rdTarget"
placeholder="https://new.example.com"
/>
</AFormItem>
<AFormItem :label="$gettext('Status Code')">
<ASelect v-model:value="state.rdStatus">
<ASelectOption value="301">
301 Moved Permanently
</ASelectOption>
<ASelectOption value="302">
302 Found
</ASelectOption>
<ASelectOption value="308">
308 Permanent Redirect
</ASelectOption>
</ASelect>
</AFormItem>
</template>
<template v-if="state.type !== 'redirect'">
<AFormItem :label="$gettext('Enable TLS')">
<ASwitch v-model:checked="state.enableTLS" />
</AFormItem>
<AFormItem
v-if="state.enableTLS"
:label="$gettext('Redirect HTTP to HTTPS')"
>
<ASwitch v-model:checked="state.redirectHTTPToHTTPS" />
</AFormItem>
</template>
</AForm>
</template>

View File

@@ -0,0 +1,159 @@
import type { QuickConfigRequest, QuickConfigResponse, QuickConfigType } from '@/api/template'
import template from '@/api/template'
export interface QuickConfigState {
name: string
type: QuickConfigType
domains: string
enableTLS: boolean
redirectHTTPToHTTPS: boolean
rpScheme: 'http' | 'https'
rpHost: string
rpPort: string
rpWebSocket: boolean
rpMaxBodySize: string
stWebRoot: string
stIndex: string
stSpa: boolean
rdTarget: string
rdStatus: '301' | '302' | '308'
}
export function createDefaultQuickConfigState(): QuickConfigState {
return {
name: '',
type: 'reverse_proxy',
domains: '',
enableTLS: false,
redirectHTTPToHTTPS: true,
rpScheme: 'http',
rpHost: '127.0.0.1',
rpPort: '9000',
rpWebSocket: true,
rpMaxBodySize: '1000m',
stWebRoot: '',
stIndex: 'index.html',
stSpa: false,
rdTarget: '',
rdStatus: '301',
}
}
export function useQuickConfig() {
const state = reactive<QuickConfigState>(createDefaultQuickConfigState())
const quickGenerating = ref(false)
const quickNameTouched = ref(false)
const quickDomainsList = computed(() =>
state.domains
.split(/[\s,]+/)
.map(domain => domain.trim())
.filter(Boolean),
)
const quickDerivedName = computed(() => quickDomainsList.value[0]?.replace(/^\*\./, '') ?? '')
const quickFormValid = computed(() => {
if (!state.name.trim() || quickDomainsList.value.length === 0)
return false
switch (state.type) {
case 'reverse_proxy':
return state.rpHost.trim() !== '' && state.rpPort.trim() !== ''
case 'static':
return state.stWebRoot.trim() !== ''
case 'redirect':
return state.rdTarget.trim() !== ''
default:
return false
}
})
// Auto-suggest the config name from the first domain until the user edits it.
watch(quickDerivedName, domain => {
if (domain && !quickNameTouched.value)
state.name = domain
})
function buildPayload(): QuickConfigRequest {
const payload: QuickConfigRequest = {
type: state.type,
domains: quickDomainsList.value,
enable_tls: state.type !== 'redirect' && state.enableTLS,
redirect_http_to_https: state.enableTLS && state.redirectHTTPToHTTPS,
}
if (state.type === 'reverse_proxy') {
payload.scheme = state.rpScheme
payload.host = state.rpHost.trim()
payload.port = state.rpPort.trim()
payload.enable_websocket = state.rpWebSocket
payload.client_max_body_size = state.rpMaxBodySize.trim()
}
else if (state.type === 'static') {
payload.web_root = state.stWebRoot.trim()
payload.index = state.stIndex.trim() || 'index.html'
payload.spa_fallback = state.stSpa
}
else {
payload.target_url = state.rdTarget.trim()
payload.redirect_status = state.rdStatus
}
return payload
}
async function generate(): Promise<QuickConfigResponse> {
quickGenerating.value = true
try {
return await template.get_quick_config(buildPayload())
}
finally {
quickGenerating.value = false
}
}
function applyInitial(initial: QuickConfigRequest | null | undefined) {
if (!initial)
return
state.type = initial.type ?? 'reverse_proxy'
state.domains = (initial.domains ?? []).join(' ')
state.name = state.domains.split(/\s+/)[0] || state.name
state.enableTLS = !!initial.enable_tls
state.redirectHTTPToHTTPS = !!initial.redirect_http_to_https
state.rpScheme = initial.scheme ?? 'http'
state.rpHost = initial.host ?? ''
state.rpPort = initial.port ?? ''
state.rpWebSocket = !!initial.enable_websocket
state.rpMaxBodySize = initial.client_max_body_size ?? '1000m'
state.stWebRoot = initial.web_root ?? ''
state.stIndex = initial.index ?? 'index.html'
state.stSpa = !!initial.spa_fallback
state.rdTarget = initial.target_url ?? ''
state.rdStatus = (initial.redirect_status as '301' | '302' | '308') ?? '301'
}
function reset() {
Object.assign(state, createDefaultQuickConfigState())
quickNameTouched.value = false
}
return {
state,
quickGenerating,
quickNameTouched,
quickDomainsList,
quickDerivedName,
quickFormValid,
buildPayload,
generate,
applyInitial,
reset,
}
}
export type QuickConfig = ReturnType<typeof useQuickConfig>

View File

@@ -5,6 +5,8 @@ import ngx from '@/api/ngx'
import site from '@/api/site'
import NgxConfigEditor, { DirectiveEditor, LocationEditor, useNgxConfigStore } from '@/components/NgxConfigEditor'
import { ConfigStatus } from '@/constants'
import QuickSetupForm from '../components/QuickSetup/QuickSetupForm.vue'
import { useQuickConfig } from '../components/QuickSetup/useQuickConfig'
import Cert from '../site_edit/components/Cert'
import EnableTLS from '../site_edit/components/EnableTLS'
import { useSiteEditorStore } from '../site_edit/components/SiteEditor/store'
@@ -13,6 +15,12 @@ import DNSRecordIntegration from './components/DNSRecordIntegration.vue'
const currentStep = ref(0)
const { message } = useGlobalApp()
// Quick setup mode
const currentMode = ref<'quick' | 'advanced'>('quick')
const quickMode = computed(() => currentMode.value === 'quick')
const quick = useQuickConfig()
const { quickGenerating, quickFormValid } = quick
// DNS record integration state
const selectedDNSRecords = ref<{ records: DNSRecord[], domain: DNSDomain } | null>(null)
const selectedDNSRecordNames = computed(() => {
@@ -42,6 +50,41 @@ function init() {
})
}
const quickTLSMissingCert = computed(() => {
if (!quickMode.value || quick.state.type === 'redirect')
return false
return editorStore.getTLSServerIssues().length > 0
})
async function next() {
if (quickMode.value && currentStep.value === 0) {
const r = await quick.generate()
ngxConfigStore.setNgxConfig(r.tokenized)
ngxConfig.value.name = quick.state.name.trim()
// Select the TLS server so the certificate flow targets the 443 block.
if (r.tokenized.servers.length > 1)
ngxConfigStore.curServerIdx = 1
}
// Block leaving the SSL step until a certificate is issued for the TLS server.
if (currentStep.value === 2 && quickTLSMissingCert.value) {
message.warning($gettext('Issue a certificate to enable TLS before continuing.'))
return
}
// Only save on the final step (step 2 -> step 3)
if (currentStep.value === 2) {
await save()
}
currentStep.value++
}
function onModeChange(mode: string | number) {
currentMode.value = mode as 'quick' | 'advanced'
selectedDNSRecords.value = null
if (currentStep.value === 0)
init()
}
async function save() {
const r = await ngx.build_config(ngxConfig.value)
@@ -163,19 +206,22 @@ function onDNSRecordCreated(record: DNSRecord, domain: DNSDomain) {
function onDNSRecordCleared() {
selectedDNSRecords.value = null
}
async function next() {
// Only save on the final step (step 2 -> step 3)
if (currentStep.value === 2) {
await save()
}
currentStep.value++
}
</script>
<template>
<ACard :title="$gettext('Add Site')">
<div class="domain-add-container">
<ASegmented
:value="currentMode"
:options="[
{ label: $gettext('Quick Setup'), value: 'quick' },
{ label: $gettext('Advanced'), value: 'advanced' },
]"
class="mb-6"
block
@change="onModeChange"
/>
<ASteps
:current="currentStep"
size="small"
@@ -185,29 +231,37 @@ async function next() {
<AStep :title="$gettext('Configure SSL')" />
<AStep :title="$gettext('Finished')" />
</ASteps>
<div v-if="currentStep === 0" class="mb-6">
<AForm layout="vertical">
<AFormItem :label="$gettext('Configuration Name')">
<AInput v-model:value="ngxConfig.name" />
</AFormItem>
</AForm>
<AAlert
v-if="!hasServerName"
type="warning"
class="mb-4"
show-icon
:message="$gettext('The parameter of server_name is required')"
<QuickSetupForm
v-if="quickMode"
:quick="quick"
/>
<DirectiveEditor
v-model:directives="curServerDirectives"
class="mb-4"
/>
<LocationEditor
v-model:locations="curServerLocations"
:current-server-index="0"
/>
<template v-else>
<AForm layout="vertical">
<AFormItem :label="$gettext('Configuration Name')">
<AInput v-model:value="ngxConfig.name" />
</AFormItem>
</AForm>
<AAlert
v-if="!hasServerName"
type="warning"
class="mb-4"
show-icon
:message="$gettext('The parameter of server_name is required')"
/>
<DirectiveEditor
v-model:directives="curServerDirectives"
class="mb-4"
/>
<LocationEditor
v-model:locations="curServerLocations"
:current-server-index="0"
/>
</template>
</div>
<!-- DNS Record Integration Step -->
@@ -222,6 +276,14 @@ async function next() {
</div>
<template v-else-if="currentStep === 2">
<AAlert
v-if="quickTLSMissingCert"
type="warning"
class="mb-4"
show-icon
:message="$gettext('Issue a certificate to enable TLS before continuing.')"
/>
<EnableTLS />
<NgxConfigEditor>
@@ -241,7 +303,8 @@ async function next() {
<AButton
v-if="currentStep === 0"
type="primary"
:disabled="!ngxConfig.name || !hasServerName"
:disabled="quickMode ? !quickFormValid : !ngxConfig.name || !hasServerName"
:loading="quickMode && quickGenerating"
@click="next"
>
{{ $gettext('Next') }}
@@ -249,6 +312,7 @@ async function next() {
<AButton
v-else
type="primary"
:disabled="currentStep === 2 && quickTLSMissingCert"
@click="next"
>
{{ $gettext('Next') }}

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import template from '@/api/template'
import { useNgxConfigStore } from '@/components/NgxConfigEditor'
import QuickSetupForm from '@/views/site/components/QuickSetup/QuickSetupForm.vue'
import { useQuickConfig } from '@/views/site/components/QuickSetup/useQuickConfig'
import { useSiteEditorStore } from './SiteEditor/store'
const open = defineModel<boolean>('open', { default: false })
const { message, modal } = useGlobalApp()
const route = useRoute()
const siteName = computed(() => decodeURIComponent(route.params?.name?.toString() ?? ''))
const editorStore = useSiteEditorStore()
const { advanceMode, configText } = storeToRefs(editorStore)
const ngxConfigStore = useNgxConfigStore()
const { ngxConfig } = storeToRefs(ngxConfigStore)
const quick = useQuickConfig()
const { quickFormValid, quickGenerating } = quick
const quickAnalyzing = ref(false)
watch(open, async isOpen => {
if (!isOpen)
return
quick.reset()
quickAnalyzing.value = true
try {
const content = advanceMode.value
? configText.value
: await editorStore.buildConfig()
const r = await template.analyze_quick_config(content)
quick.applyInitial(r.request)
}
catch {
// Fall back to default form values when the existing config cannot be analyzed.
}
finally {
// The name is derived from the site itself and is not editable here.
quick.state.name = siteName.value
quickAnalyzing.value = false
}
})
async function generateConfig() {
const r = await quick.generate()
modal.confirm({
title: $gettext('Replace configuration?'),
content: $gettext('The generated configuration will replace the current one. Any custom directives or locations will be lost.'),
okText: $gettext('Replace'),
cancelText: $gettext('Cancel'),
onOk: async () => {
ngxConfigStore.setNgxConfig(r.tokenized)
// Keep the site file name; regeneration must not rename the site.
ngxConfig.value.name = siteName.value
// Select the TLS server so the certificate flow targets the 443 block.
if (r.tokenized.servers.length > 1)
ngxConfigStore.curServerIdx = 1
// In advance mode the editor shows the raw text, keep it in sync.
if (advanceMode.value)
configText.value = r.template
open.value = false
message.success($gettext('Configuration regenerated'))
},
})
}
</script>
<template>
<AModal
v-model:open="open"
:title="$gettext('Quick Setup')"
:width="640"
:mask-closable="false"
:footer="null"
>
<AAlert
v-if="quick.state.enableTLS && editorStore.getTLSServerIssues().length > 0"
type="warning"
class="mb-4"
show-icon
:message="$gettext('Issue a certificate to enable TLS before saving.')"
/>
<QuickSetupForm
:quick="quick"
:show-name="false"
/>
<div class="modal-footer mt-4 text-right">
<ASpace>
<AButton @click="open = false">
{{ $gettext('Cancel') }}
</AButton>
<AButton
type="primary"
:loading="quickGenerating || quickAnalyzing"
:disabled="!quickFormValid"
@click="generateConfig"
>
{{ $gettext('Generate Config') }}
</AButton>
</ASpace>
</div>
</AModal>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { HistoryOutlined } from '@ant-design/icons-vue'
import { HistoryOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
import ConfigHistory from '@/components/ConfigHistory'
import FooterToolBar from '@/components/FooterToolbar'
@@ -9,6 +9,7 @@ import UpstreamCards from '@/components/UpstreamCards/UpstreamCards.vue'
import { ConfigStatus } from '@/constants'
import Cert from '@/views/site/site_edit/components/Cert'
import EnableTLS from '@/views/site/site_edit/components/EnableTLS'
import QuickSetupModal from '@/views/site/site_edit/components/QuickSetupModal.vue'
import { useSiteEditorStore } from './store'
const { message } = App.useApp()
@@ -44,6 +45,8 @@ const upstreamTargets = computed(() => {
const showHistory = ref(false)
const quickSetupOpen = ref(false)
// Use Vue 3.4+ useTemplateRef for InspectConfig component
const inspectConfigRef = useTemplateRef<InstanceType<typeof InspectConfig>>('inspectConfig')
@@ -99,6 +102,15 @@ async function save() {
</template>
{{ $gettext('History') }}
</AButton>
<AButton
type="primary"
@click="quickSetupOpen = true"
>
<template #icon>
<ThunderboltOutlined />
</template>
{{ $gettext('Quick Setup') }}
</AButton>
<div class="mode-switch">
<div class="switch">
<ASwitch
@@ -203,6 +215,8 @@ async function save() {
v-model:current-content="configText"
:filepath="filepath"
/>
<QuickSetupModal v-model:open="quickSetupOpen" />
</ACard>
</template>

View File

@@ -321,6 +321,7 @@ export const useSiteEditorStore = defineStore('siteEditor', () => {
hasServers,
getTLSServerIssues,
getConfigWithoutIncompleteTLSServers,
buildConfig,
dnsLinked,
linkedDNSName,
init,

21
e2e/bun.lock Normal file
View File

@@ -0,0 +1,21 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "nginx-ui-demo-e2e",
"devDependencies": {
"@playwright/test": "1.62.1",
},
},
},
"packages": {
"@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
"playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
}
}

View File

@@ -0,0 +1,199 @@
import { expect, test } from '@playwright/test'
import { authHeaders, gotoRoute, waitForApiResponse } from './helpers'
interface QuickConfigResponse {
template: string
tokenized: {
name?: string
}
}
async function fillFormItem(page: import('@playwright/test').Page, label: string, value: string) {
const item = page.locator('.ant-form-item').filter({ has: page.getByText(label, { exact: true }) }).first()
await item.locator('input, textarea').first().fill(value)
}
async function toggleSwitch(page: import('@playwright/test').Page, label: string) {
const item = page.locator('.ant-form-item').filter({ has: page.getByText(label, { exact: true }) }).first()
await item.locator('.ant-switch').click()
}
async function selectType(page: import('@playwright/test').Page, type: 'Reverse Proxy' | 'Static Site' | 'Redirect') {
await page.locator('.ant-radio-button-wrapper').filter({ hasText: type }).click()
}
async function submitQuickConfig(page: import('@playwright/test').Page): Promise<QuickConfigResponse> {
const responsePromise = waitForApiResponse(page, '/api/templates/quick_config', 'POST')
await page.getByRole('button', { name: 'Next', exact: true }).click()
const response = await responsePromise
expect(response.ok()).toBe(true)
return await response.json() as QuickConfigResponse
}
test('quick setup requires name and domains before enabling Next', async ({ page }) => {
await gotoRoute(page, '/sites/add')
await expect(page.getByText('Quick Setup', { exact: true })).toBeVisible()
const next = page.getByRole('button', { name: 'Next', exact: true })
await expect(next).toBeDisabled()
await fillFormItem(page, 'Configuration Name', 'e2e-empty-domains')
await expect(next).toBeDisabled()
await fillFormItem(page, 'Domains', 'e2e-empty-domains.example.com')
await expect(next).toBeEnabled()
})
test('quick setup reverse proxy without TLS saves a site end to end', async ({ page }) => {
const name = 'e2e-rp-plain'
const domain = `${name}.example.com`
await gotoRoute(page, '/sites/add')
await fillFormItem(page, 'Configuration Name', name)
await fillFormItem(page, 'Domains', domain)
const result = await submitQuickConfig(page)
expect(result.tokenized.name).toBe(domain)
expect(result.template).toContain('proxy_pass http://127.0.0.1:9000/')
expect(result.template).toContain('client_max_body_size 1000m')
expect(result.template).not.toContain('return 301')
expect(result.template).not.toContain('listen 443 ssl')
await expect(page.locator('.ant-steps-item-active')).toContainText('DNS Record')
await page.getByRole('button', { name: 'Next', exact: true }).click()
await expect(page.locator('.ant-steps-item-active')).toContainText('Configure SSL')
await expect(page.getByText('Issue a certificate to enable TLS before continuing.')).not.toBeVisible()
const savePromise = waitForApiResponse(page, `/api/sites/${name}`, 'POST')
await page.getByRole('button', { name: 'Next', exact: true }).click()
const saveResponse = await savePromise
expect(saveResponse.ok()).toBe(true)
await expect(page.getByText('Site Config Created Successfully', { exact: true })).toBeVisible()
const headers = await authHeaders(page)
const disableResponse = await page.request.post(`/api/sites/${name}/disable`, { headers })
expect(disableResponse.ok()).toBe(true)
const deleteResponse = await page.request.delete(`/api/sites/${name}`, { headers })
expect(deleteResponse.ok()).toBe(true)
})
test('quick setup reverse proxy with TLS emits redirect, websocket and acme-challenge blocks and gates on a missing certificate', async ({ page }) => {
await gotoRoute(page, '/sites/add')
await fillFormItem(page, 'Configuration Name', 'e2e-rp-tls')
await fillFormItem(page, 'Domains', 'e2e-rp-tls.example.com www.e2e-rp-tls.example.com')
await fillFormItem(page, 'Client Max Body Size', '100m')
await toggleSwitch(page, 'Enable TLS')
const result = await submitQuickConfig(page)
expect(result.template).toContain('return 301 https://$host$request_uri')
expect(result.template).toContain('listen 443 ssl')
expect(result.template).toContain('proxy_set_header Upgrade $http_upgrade')
expect(result.template).toContain('client_max_body_size 100m')
expect(result.template).toContain('location ~ /.well-known/acme-challenge')
expect(result.tokenized.name).toBe('e2e-rp-tls.example.com')
await expect(page.locator('.ant-steps-item-active')).toContainText('DNS Record')
await page.getByRole('button', { name: 'Next', exact: true }).click()
await expect(page.locator('.ant-steps-item-active')).toContainText('Configure SSL')
await expect(page.getByText('Issue a certificate to enable TLS before continuing.', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Next', exact: true })).toBeDisabled()
})
test('quick setup static site emits root, index and SPA fallback', async ({ page }) => {
await gotoRoute(page, '/sites/add')
await fillFormItem(page, 'Configuration Name', 'e2e-static')
await selectType(page, 'Static Site')
await fillFormItem(page, 'Domains', 'e2e-static.example.com')
await fillFormItem(page, 'Web Root', '/var/www/e2e-static')
await toggleSwitch(page, 'Single Page Application Fallback')
const result = await submitQuickConfig(page)
expect(result.template).toContain('root /var/www/e2e-static;')
expect(result.template).toContain('index index.html;')
expect(result.template).toContain('try_files $uri $uri/ /index.html;')
})
test('quick setup redirect emits the chosen status code', async ({ page }) => {
await gotoRoute(page, '/sites/add')
await fillFormItem(page, 'Configuration Name', 'e2e-redirect')
await selectType(page, 'Redirect')
await fillFormItem(page, 'Domains', 'e2e-redirect.example.com')
await fillFormItem(page, 'Target URL', 'https://new.example.com')
const statusItem = page.locator('.ant-form-item').filter({ has: page.getByText('Status Code', { exact: true }) }).first()
await statusItem.locator('.ant-select-selector').click()
await page.locator('.ant-select-item-option').filter({ hasText: '308 Permanent Redirect' }).click()
const result = await submitQuickConfig(page)
expect(result.template).toContain('return 308 https://new.example.com;')
})
test('quick setup on the edit page prefills and regenerates an existing site', async ({ page }) => {
const name = 'e2e-edit-rp'
const domain = `${name}.example.com`
// Create the site through the API using quick config generation.
await gotoRoute(page, '/')
const headers = await authHeaders(page)
const genResponse = await page.request.post('/api/templates/quick_config', {
headers,
data: {
type: 'reverse_proxy',
domains: [domain],
host: '127.0.0.1',
port: '9000',
enable_websocket: true,
client_max_body_size: '1000m',
},
})
expect(genResponse.ok()).toBe(true)
const gen = await genResponse.json() as QuickConfigResponse
const saveResponse = await page.request.post(`/api/sites/${name}`, {
headers,
data: { name, content: gen.template, overwrite: true },
})
expect(saveResponse.ok()).toBe(true)
try {
await gotoRoute(page, `/sites/${name}`)
const quickSetupButton = page.getByRole('button', { name: /Quick Setup/ })
await expect(quickSetupButton).toBeVisible()
await quickSetupButton.click()
// The modal prefills from the existing config and enables generation.
const modal = page.locator('.ant-modal').filter({ has: page.getByText('Generate Config') })
await expect(modal).toBeVisible()
await expect(page.getByRole('button', { name: 'Generate Config', exact: true })).toBeEnabled()
// Change the proxy port and regenerate.
const portItem = modal.locator('.ant-form-item').filter({ has: page.getByText('Port', { exact: true }) }).first()
await portItem.locator('input').fill('8080')
const regenPromise = waitForApiResponse(page, '/api/templates/quick_config', 'POST')
await page.getByRole('button', { name: 'Generate Config', exact: true }).click()
const regen = await regenPromise
expect(regen.ok()).toBe(true)
// Confirm the destructive replace, then wait for the save before reading it back.
await page.getByRole('button', { name: 'Replace', exact: true }).click()
await expect(modal).toBeHidden()
const updatePromise = waitForApiResponse(page, `/api/sites/${name}`, 'POST')
await page.getByRole('button', { name: 'Save', exact: true }).click()
const updateResponse = await updatePromise
expect(updateResponse.ok()).toBe(true)
const getResponse = await page.request.get(`/api/sites/${name}`, { headers })
expect(getResponse.ok()).toBe(true)
const site = await getResponse.json() as { config: string }
expect(site.config).toContain('proxy_pass http://127.0.0.1:8080/')
}
finally {
await page.request.post(`/api/sites/${name}/disable`, { headers })
await page.request.delete(`/api/sites/${name}`, { headers })
}
})

View File

@@ -0,0 +1,20 @@
# Nginx UI Template Start
name = "Redirect"
author = "@0xJacky"
description = { en = "Redirect Config", zh_CN = "重定向配置" }
[variables.status]
type = "select"
name = { en = "Status Code", zh_CN = "状态码" }
value = "301"
mask = { "301" = { en = "301 Moved Permanently", zh_CN = "301 永久移动" }, "302" = { en = "302 Found", zh_CN = "302 临时移动" }, "308" = { en = "308 Permanent Redirect", zh_CN = "308 永久重定向" } }
[variables.target]
type = "string"
name = { en = "Target URL", zh_CN = "目标地址" }
value = "https://example.com"
# Nginx UI Template End
location / {
return {{ .status }} {{ .target }};
}

View File

@@ -36,6 +36,7 @@ map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
{{- end }}
map $remote_addr $proxy_forwarded_elem {
# IPv4 addresses can be sent as-is
~^[0-9.]+$ "for=$remote_addr";
@@ -54,7 +55,6 @@ map $http_forwarded $proxy_add_forwarded {
# Otherwise, replace it
default "$proxy_forwarded_elem";
}
{{- end }}
# Nginx UI Custom End
if ($host != $server_name) {