mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-09-03 07:24:52 +08:00
Merge pull request #1811 from ugurcsen/feature/site-quick-setup
Add quick setup feature and improve map emission
This commit is contained in:
483
api/template/quick_config.go
Normal file
483
api/template/quick_config.go
Normal 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,
|
||||
})
|
||||
}
|
||||
428
api/template/quick_config_test.go
Normal file
428
api/template/quick_config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
150
app/src/views/site/components/QuickSetup/QuickSetupForm.vue
Normal file
150
app/src/views/site/components/QuickSetup/QuickSetupForm.vue
Normal 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>
|
||||
159
app/src/views/site/components/QuickSetup/useQuickConfig.ts
Normal file
159
app/src/views/site/components/QuickSetup/useQuickConfig.ts
Normal 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>
|
||||
@@ -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') }}
|
||||
|
||||
110
app/src/views/site/site_edit/components/QuickSetupModal.vue
Normal file
110
app/src/views/site/site_edit/components/QuickSetupModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -321,6 +321,7 @@ export const useSiteEditorStore = defineStore('siteEditor', () => {
|
||||
hasServers,
|
||||
getTLSServerIssues,
|
||||
getConfigWithoutIncompleteTLSServers,
|
||||
buildConfig,
|
||||
dnsLinked,
|
||||
linkedDNSName,
|
||||
init,
|
||||
|
||||
21
e2e/bun.lock
Normal file
21
e2e/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
199
e2e/tests/quick-config.spec.ts
Normal file
199
e2e/tests/quick-config.spec.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
20
template/block/redirect.conf
Normal file
20
template/block/redirect.conf
Normal 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 }};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user