mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 05:31:44 +08:00
Add redirect and advanced settings tabs to website editor (#1267)
* Initial plan * Add redirect tab to website editor with drag-and-drop support Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * Add advanced settings tab with rate limiting and basic auth Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * Add htpasswd file support for basic auth (nginx and apache compatible) Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * Fix code review issues: prevent duplicate usernames in basic auth Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * fix: bug * fix: bug * feat: 支持real ip设置 * feat: 支持real ip设置 * fix: lint * fix: lint --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> Co-authored-by: 耗子 <haozi@loli.email>
This commit is contained in:
7
go.sum
7
go.sum
@@ -164,8 +164,6 @@ github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXi
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
@@ -320,7 +318,6 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
@@ -433,8 +430,6 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -508,8 +503,6 @@ golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtn
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/leonelquinteros/gotext"
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/acepanel/panel/internal/app"
|
||||
@@ -207,6 +209,17 @@ func (r *websiteRepo) Get(id uint) (*types.WebsiteSetting, error) {
|
||||
setting.Proxies = proxyVhost.Proxies()
|
||||
}
|
||||
|
||||
// 重定向配置
|
||||
if redirectVhost, ok := vhost.(webservertypes.VhostRedirect); ok {
|
||||
setting.Redirects = redirectVhost.Redirects()
|
||||
}
|
||||
|
||||
// 高级设置(限流限速、真实 IP、基本认证)
|
||||
setting.RateLimit = vhost.RateLimit()
|
||||
setting.RealIP = vhost.RealIP()
|
||||
// 读取基本认证用户列表(从 htpasswd 文件)
|
||||
setting.BasicAuth = r.readBasicAuthUsers(website.Name)
|
||||
|
||||
// 自定义配置
|
||||
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
|
||||
setting.CustomConfigs = r.getCustomConfigs(configDir)
|
||||
@@ -655,6 +668,51 @@ func (r *websiteRepo) Update(ctx context.Context, req *request.WebsiteUpdate) er
|
||||
}
|
||||
}
|
||||
|
||||
// 重定向配置
|
||||
if redirectVhost, ok := vhost.(webservertypes.VhostRedirect); ok {
|
||||
if err = redirectVhost.SetRedirects(req.Redirects); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 高级设置(限流限速、真实 IP、基本认证)
|
||||
if req.RateLimit != nil {
|
||||
if err = vhost.SetRateLimit(req.RateLimit); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err = vhost.ClearRateLimit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 真实 IP 配置
|
||||
if req.RealIP != nil {
|
||||
if err = vhost.SetRealIP(req.RealIP); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err = vhost.ClearRealIP(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 基本认证创建 htpasswd 文件
|
||||
if len(req.BasicAuth) > 0 {
|
||||
htpasswdPath := filepath.Join(app.Root, "sites", website.Name, "config", "htpasswd")
|
||||
if err = r.writeBasicAuthUsers(htpasswdPath, req.BasicAuth); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = vhost.SetBasicAuth(map[string]string{"user_file": htpasswdPath}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// 清除基本认证配置和 htpasswd 文件
|
||||
htpasswdPath := filepath.Join(app.Root, "sites", website.Name, "config", "htpasswd")
|
||||
_ = io.Remove(htpasswdPath)
|
||||
if err = vhost.ClearBasicAuth(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义配置
|
||||
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
|
||||
if err = r.saveCustomConfigs(configDir, req.CustomConfigs); err != nil {
|
||||
@@ -1097,3 +1155,91 @@ func (r *websiteRepo) reloadWebServer() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readBasicAuthUsers 读取 htpasswd 文件中的用户列表
|
||||
func (r *websiteRepo) readBasicAuthUsers(siteName string) map[string]string {
|
||||
htpasswdPath := filepath.Join(app.Root, "sites", siteName, "config", "htpasswd")
|
||||
if !io.Exists(htpasswdPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(htpasswdPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer func(file *os.File) { _ = file.Close() }(file)
|
||||
|
||||
users := make(map[string]string)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
// htpasswd 格式: username:encrypted_password
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
// 返回空密码,前端显示为占位符
|
||||
users[parts[0]] = ""
|
||||
}
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
return nil
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
// writeBasicAuthUsers 将用户凭证写入 htpasswd 文件
|
||||
func (r *websiteRepo) writeBasicAuthUsers(htpasswdPath string, users map[string]string) error {
|
||||
// 读取现有用户密码
|
||||
existingUsers := make(map[string]string)
|
||||
if io.Exists(htpasswdPath) {
|
||||
file, err := os.Open(htpasswdPath)
|
||||
if err == nil {
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
existingUsers[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
_ = file.Close()
|
||||
}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for username, password := range users {
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
var hashedPassword string
|
||||
if password == "" {
|
||||
// 密码为空,保留现有密码
|
||||
if existing, ok := existingUsers[username]; ok {
|
||||
hashedPassword = existing
|
||||
} else {
|
||||
// 新用户但没有密码,跳过
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// 新密码,使用 bcrypt 加密
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password for user %s: %w", username, err)
|
||||
}
|
||||
hashedPassword = string(hash)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s:%s", username, hashedPassword))
|
||||
}
|
||||
|
||||
content := strings.Join(lines, "\n")
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
return io.Write(htpasswdPath, content, 0600)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,14 @@ type WebsiteUpdate struct {
|
||||
Upstreams []types.Upstream `json:"upstreams"`
|
||||
Proxies []types.Proxy `json:"proxies"`
|
||||
|
||||
// 重定向
|
||||
Redirects []types.Redirect `json:"redirects"`
|
||||
|
||||
// 高级设置
|
||||
RateLimit *types.RateLimit `json:"rate_limit"` // 限流限速配置
|
||||
RealIP *types.RealIP `json:"real_ip"` // 真实 IP 配置
|
||||
BasicAuth map[string]string `json:"basic_auth"` // 基本认证配置
|
||||
|
||||
// 自定义配置
|
||||
CustomConfigs []WebsiteCustomConfig `json:"custom_configs"`
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@ type WebsiteSetting struct {
|
||||
Upstreams []types.Upstream `json:"upstreams"`
|
||||
Proxies []types.Proxy `json:"proxies"`
|
||||
|
||||
// 重定向
|
||||
Redirects []types.Redirect `json:"redirects"`
|
||||
|
||||
// 高级设置
|
||||
RateLimit *types.RateLimit `json:"rate_limit"` // 限流限速配置
|
||||
RealIP *types.RealIP `json:"real_ip"` // 真实 IP 配置
|
||||
BasicAuth map[string]string `json:"basic_auth"` // 基本认证配置
|
||||
|
||||
// 自定义配置
|
||||
CustomConfigs []WebsiteCustomConfig `json:"custom_configs"`
|
||||
}
|
||||
|
||||
@@ -78,11 +78,21 @@ func parseRedirectFile(filePath string) (*types.Redirect, error) {
|
||||
redirectMatchPattern := regexp.MustCompile(`RedirectMatch\s+(\d+)\s+(\S+)\s+(\S+)`)
|
||||
if matches := redirectMatchPattern.FindStringSubmatch(contentStr); matches != nil {
|
||||
statusCode, _ := strconv.Atoi(matches[1])
|
||||
to := matches[3]
|
||||
keepURI := strings.Contains(to, "$1")
|
||||
if keepURI {
|
||||
to = strings.TrimSuffix(to, "$1")
|
||||
}
|
||||
// 还原 from 为简单路径格式
|
||||
from := matches[2]
|
||||
from = strings.TrimPrefix(from, "^")
|
||||
from = strings.TrimSuffix(from, "(.*)$")
|
||||
from = strings.TrimSuffix(from, "$")
|
||||
return &types.Redirect{
|
||||
Type: types.RedirectTypeURL,
|
||||
From: matches[2],
|
||||
To: matches[3],
|
||||
KeepURI: strings.Contains(matches[3], "$1"),
|
||||
From: from,
|
||||
To: to,
|
||||
KeepURI: keepURI,
|
||||
StatusCode: statusCode,
|
||||
}, nil
|
||||
}
|
||||
@@ -94,11 +104,16 @@ func parseRedirectFile(filePath string) (*types.Redirect, error) {
|
||||
if matches := hostRewritePattern.FindStringSubmatch(contentStr); matches != nil {
|
||||
statusCode, _ := strconv.Atoi(matches[3])
|
||||
host := strings.ReplaceAll(matches[1], `\.`, ".")
|
||||
to := matches[2]
|
||||
keepURI := strings.Contains(to, "$1")
|
||||
if keepURI {
|
||||
to = strings.TrimSuffix(to, "$1")
|
||||
}
|
||||
return &types.Redirect{
|
||||
Type: types.RedirectTypeHost,
|
||||
From: host,
|
||||
To: matches[2],
|
||||
KeepURI: strings.Contains(matches[2], "$1"),
|
||||
To: to,
|
||||
KeepURI: keepURI,
|
||||
StatusCode: statusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -541,24 +541,25 @@ func (v *baseVhost) RateLimit() *types.RateLimit {
|
||||
return nil
|
||||
}
|
||||
|
||||
rateLimit := &types.RateLimit{
|
||||
Zone: make(map[string]string),
|
||||
}
|
||||
rateLimit := &types.RateLimit{}
|
||||
|
||||
// 获取速率限制值
|
||||
rateValue := v.vhost.GetDirectiveValue("SetEnv")
|
||||
if rateValue != "" {
|
||||
rateLimit.Rate = rateValue
|
||||
// 获取速率限制值 (SetEnv rate-limit 512)
|
||||
args := v.vhost.GetDirectiveValues("SetEnv")
|
||||
if len(args) >= 2 && args[0] == "rate-limit" {
|
||||
_, _ = fmt.Sscanf(args[1], "%d", &rateLimit.Rate)
|
||||
}
|
||||
|
||||
return rateLimit
|
||||
}
|
||||
|
||||
func (v *baseVhost) SetRateLimit(limit *types.RateLimit) error {
|
||||
// 设置 mod_ratelimit
|
||||
v.vhost.SetDirective("SetOutputFilter", "RATE_LIMIT")
|
||||
if limit.Rate != "" {
|
||||
v.vhost.SetDirective("SetEnv", "rate-limit", limit.Rate)
|
||||
// Apache mod_ratelimit 只支持流量限制,不支持并发连接限制
|
||||
if limit.Rate > 0 {
|
||||
v.vhost.SetDirective("SetOutputFilter", "RATE_LIMIT")
|
||||
v.vhost.SetDirective("SetEnv", "rate-limit", fmt.Sprintf("%d", limit.Rate))
|
||||
} else {
|
||||
v.vhost.RemoveDirective("SetOutputFilter")
|
||||
v.vhost.RemoveDirectives("SetEnv")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -606,6 +607,58 @@ func (v *baseVhost) ClearBasicAuth() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *baseVhost) RealIP() *types.RealIP {
|
||||
// Apache 使用 mod_remoteip
|
||||
// RemoteIPHeader X-Forwarded-For
|
||||
// RemoteIPTrustedProxy 127.0.0.1
|
||||
header := v.vhost.GetDirectiveValue("RemoteIPHeader")
|
||||
if header == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var from []string
|
||||
for _, dir := range v.vhost.GetDirectives("RemoteIPTrustedProxy") {
|
||||
if len(dir.Args) > 0 {
|
||||
from = append(from, dir.Args[0])
|
||||
}
|
||||
}
|
||||
|
||||
return &types.RealIP{
|
||||
From: from,
|
||||
Header: header,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *baseVhost) SetRealIP(realIP *types.RealIP) error {
|
||||
// 清除现有配置
|
||||
v.vhost.RemoveDirective("RemoteIPHeader")
|
||||
v.vhost.RemoveDirectives("RemoteIPTrustedProxy")
|
||||
|
||||
if realIP == nil || (len(realIP.From) == 0 && realIP.Header == "") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 设置 RemoteIPHeader
|
||||
if realIP.Header != "" {
|
||||
v.vhost.SetDirective("RemoteIPHeader", realIP.Header)
|
||||
}
|
||||
|
||||
// 设置 RemoteIPTrustedProxy
|
||||
for _, ip := range realIP.From {
|
||||
if ip != "" {
|
||||
v.vhost.AddDirective("RemoteIPTrustedProxy", ip)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *baseVhost) ClearRealIP() error {
|
||||
v.vhost.RemoveDirective("RemoteIPHeader")
|
||||
v.vhost.RemoveDirectives("RemoteIPTrustedProxy")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *baseVhost) Redirects() []types.Redirect {
|
||||
siteDir := filepath.Join(v.configDir, "site")
|
||||
redirects, _ := parseRedirectFiles(siteDir)
|
||||
|
||||
@@ -237,12 +237,13 @@ func (s *VhostTestSuite) TestRateLimit() {
|
||||
s.Nil(s.vhost.RateLimit())
|
||||
|
||||
limit := &types.RateLimit{
|
||||
Rate: "512",
|
||||
Rate: 512,
|
||||
}
|
||||
s.NoError(s.vhost.SetRateLimit(limit))
|
||||
|
||||
got := s.vhost.RateLimit()
|
||||
s.NotNil(got)
|
||||
s.Equal(512, got.Rate)
|
||||
|
||||
s.NoError(s.vhost.ClearRateLimit())
|
||||
s.Nil(s.vhost.RateLimit())
|
||||
|
||||
@@ -40,15 +40,19 @@ var order = map[string]int{
|
||||
|
||||
"client_max_body_size": 100,
|
||||
"client_body_buffer_size": 101,
|
||||
"limit_except": 102,
|
||||
"limit_req_zone": 103,
|
||||
"limit_req": 104,
|
||||
"limit_conn_zone": 105,
|
||||
"limit_conn": 106,
|
||||
"allow": 107,
|
||||
"deny": 108,
|
||||
"auth_basic": 109,
|
||||
"auth_basic_user_file": 110,
|
||||
"limit_rate": 102,
|
||||
"limit_except": 103,
|
||||
"limit_req_zone": 104,
|
||||
"limit_req": 105,
|
||||
"limit_conn_zone": 106,
|
||||
"limit_conn": 107,
|
||||
"allow": 108,
|
||||
"deny": 109,
|
||||
"auth_basic": 110,
|
||||
"auth_basic_user_file": 111,
|
||||
"set_real_ip_from": 112,
|
||||
"real_ip_header": 113,
|
||||
"real_ip_recursive": 114,
|
||||
|
||||
"ssl": 200,
|
||||
"ssl_certificate": 201,
|
||||
|
||||
@@ -66,11 +66,16 @@ func parseRedirectFile(filePath string) (*types.Redirect, error) {
|
||||
urlPattern := regexp.MustCompile(`location\s*=\s*(\S+)\s*\{[^}]*return\s+(\d+)\s+([^;]+);`)
|
||||
if matches := urlPattern.FindStringSubmatch(contentStr); matches != nil {
|
||||
statusCode, _ := strconv.Atoi(matches[2])
|
||||
to := strings.TrimSpace(matches[3])
|
||||
keepURI := strings.Contains(to, "$request_uri")
|
||||
if keepURI {
|
||||
to = strings.TrimSuffix(to, "$request_uri")
|
||||
}
|
||||
return &types.Redirect{
|
||||
Type: types.RedirectTypeURL,
|
||||
From: matches[1],
|
||||
To: strings.TrimSpace(matches[3]),
|
||||
KeepURI: strings.Contains(matches[3], "$request_uri"),
|
||||
To: to,
|
||||
KeepURI: keepURI,
|
||||
StatusCode: statusCode,
|
||||
}, nil
|
||||
}
|
||||
@@ -79,11 +84,16 @@ func parseRedirectFile(filePath string) (*types.Redirect, error) {
|
||||
hostPattern := regexp.MustCompile(`if\s*\(\s*\$host\s*=\s*"?([^")\s]+)"?\s*\)\s*\{[^}]*return\s+(\d+)\s+([^;]+);`)
|
||||
if matches := hostPattern.FindStringSubmatch(contentStr); matches != nil {
|
||||
statusCode, _ := strconv.Atoi(matches[2])
|
||||
to := strings.TrimSpace(matches[3])
|
||||
keepURI := strings.Contains(to, "$request_uri")
|
||||
if keepURI {
|
||||
to = strings.TrimSuffix(to, "$request_uri")
|
||||
}
|
||||
return &types.Redirect{
|
||||
Type: types.RedirectTypeHost,
|
||||
From: matches[1],
|
||||
To: strings.TrimSpace(matches[3]),
|
||||
KeepURI: strings.Contains(matches[3], "$request_uri"),
|
||||
To: to,
|
||||
KeepURI: keepURI,
|
||||
StatusCode: statusCode,
|
||||
}, nil
|
||||
}
|
||||
@@ -92,11 +102,16 @@ func parseRedirectFile(filePath string) (*types.Redirect, error) {
|
||||
errorPattern := regexp.MustCompile(`error_page\s+404\s*=\s*@redirect_404;[^@]*location\s+@redirect_404\s*\{[^}]*return\s+(\d+)\s+([^;]+);`)
|
||||
if matches := errorPattern.FindStringSubmatch(contentStr); matches != nil {
|
||||
statusCode, _ := strconv.Atoi(matches[1])
|
||||
to := strings.TrimSpace(matches[2])
|
||||
keepURI := strings.Contains(to, "$request_uri")
|
||||
if keepURI {
|
||||
to = strings.TrimSuffix(to, "$request_uri")
|
||||
}
|
||||
return &types.Redirect{
|
||||
Type: types.RedirectType404,
|
||||
From: "",
|
||||
To: strings.TrimSpace(matches[2]),
|
||||
KeepURI: strings.Contains(matches[2], "$request_uri"),
|
||||
To: to,
|
||||
KeepURI: keepURI,
|
||||
StatusCode: statusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -576,70 +576,84 @@ func (v *baseVhost) ClearSSL() error {
|
||||
}
|
||||
|
||||
func (v *baseVhost) RateLimit() *types.RateLimit {
|
||||
rate := ""
|
||||
var perServer, perIP, rate int
|
||||
|
||||
// 解析 limit_rate 配置
|
||||
directive, err := v.parser.FindOne("server.limit_rate")
|
||||
if err == nil {
|
||||
if len(v.parser.parameters2Slices(directive.GetParameters())) != 0 {
|
||||
rate = directive.GetParameters()[0].GetValue()
|
||||
params := v.parser.parameters2Slices(directive.GetParameters())
|
||||
if len(params) > 0 {
|
||||
// 解析 limit_rate 值,如 "512k" -> 512
|
||||
rateStr := params[0]
|
||||
rateStr = strings.TrimSuffix(rateStr, "k")
|
||||
rateStr = strings.TrimSuffix(rateStr, "K")
|
||||
_, _ = fmt.Sscanf(rateStr, "%d", &rate)
|
||||
}
|
||||
}
|
||||
directives, _ := v.parser.Find("server.limit_conn")
|
||||
var limitConn [][]string
|
||||
for _, dir := range directives {
|
||||
limitConn = append(limitConn, v.parser.parameters2Slices(dir.GetParameters()))
|
||||
}
|
||||
|
||||
if rate == "" && len(limitConn) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rateLimit := &types.RateLimit{
|
||||
Rate: rate,
|
||||
Zone: make(map[string]string),
|
||||
}
|
||||
|
||||
// 解析 limit_conn 配置
|
||||
for _, limit := range limitConn {
|
||||
if len(limit) >= 2 {
|
||||
// limit_conn zone connections
|
||||
// 例如: limit_conn perip 10
|
||||
rateLimit.Zone[limit[0]] = limit[1]
|
||||
directives, _ := v.parser.Find("server.limit_conn")
|
||||
for _, dir := range directives {
|
||||
params := v.parser.parameters2Slices(dir.GetParameters())
|
||||
if len(params) >= 2 {
|
||||
var val int
|
||||
_, _ = fmt.Sscanf(params[1], "%d", &val)
|
||||
switch params[0] {
|
||||
case "perserver":
|
||||
perServer = val
|
||||
case "perip":
|
||||
perIP = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rateLimit
|
||||
if perServer == 0 && perIP == 0 && rate == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &types.RateLimit{
|
||||
PerServer: perServer,
|
||||
PerIP: perIP,
|
||||
Rate: rate,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *baseVhost) SetRateLimit(limit *types.RateLimit) error {
|
||||
var limitConns [][]string
|
||||
for zone, connections := range limit.Zone {
|
||||
limitConns = append(limitConns, []string{zone, connections})
|
||||
}
|
||||
|
||||
// 设置限速
|
||||
// 设置限速 limit_rate
|
||||
_ = v.parser.Clear("server.limit_rate")
|
||||
if err := v.parser.Set("server", []*config.Directive{
|
||||
{
|
||||
Name: "limit_rate",
|
||||
Parameters: []config.Parameter{{Value: limit.Rate}},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 设置并发连接数限制
|
||||
_ = v.parser.Clear("server.limit_conn")
|
||||
var directives []*config.Directive
|
||||
for _, lim := range limitConns {
|
||||
if len(lim) >= 2 {
|
||||
directives = append(directives, &config.Directive{
|
||||
Name: "limit_conn",
|
||||
Parameters: v.parser.slices2Parameters(lim),
|
||||
})
|
||||
if limit.Rate > 0 {
|
||||
if err := v.parser.Set("server", []*config.Directive{
|
||||
{
|
||||
Name: "limit_rate",
|
||||
Parameters: []config.Parameter{{Value: fmt.Sprintf("%dk", limit.Rate)}},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return v.parser.Set("server", directives)
|
||||
// 设置并发连接数限制 limit_conn
|
||||
_ = v.parser.Clear("server.limit_conn")
|
||||
var directives []*config.Directive
|
||||
if limit.PerServer > 0 {
|
||||
directives = append(directives, &config.Directive{
|
||||
Name: "limit_conn",
|
||||
Parameters: []config.Parameter{{Value: "perserver"}, {Value: fmt.Sprintf("%d", limit.PerServer)}},
|
||||
})
|
||||
}
|
||||
if limit.PerIP > 0 {
|
||||
directives = append(directives, &config.Directive{
|
||||
Name: "limit_conn",
|
||||
Parameters: []config.Parameter{{Value: "perip"}, {Value: fmt.Sprintf("%d", limit.PerIP)}},
|
||||
})
|
||||
}
|
||||
if len(directives) > 0 {
|
||||
if err := v.parser.Set("server", directives); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *baseVhost) ClearRateLimit() error {
|
||||
@@ -706,6 +720,100 @@ func (v *baseVhost) ClearBasicAuth() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *baseVhost) RealIP() *types.RealIP {
|
||||
// 解析 set_real_ip_from 配置
|
||||
var from []string
|
||||
directives, _ := v.parser.Find("server.set_real_ip_from")
|
||||
for _, dir := range directives {
|
||||
params := v.parser.parameters2Slices(dir.GetParameters())
|
||||
if len(params) > 0 {
|
||||
from = append(from, params[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 real_ip_header 配置
|
||||
header := ""
|
||||
directive, err := v.parser.FindOne("server.real_ip_header")
|
||||
if err == nil {
|
||||
params := v.parser.parameters2Slices(directive.GetParameters())
|
||||
if len(params) > 0 {
|
||||
header = params[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 real_ip_recursive 配置
|
||||
recursive := false
|
||||
recursiveDir, err := v.parser.FindOne("server.real_ip_recursive")
|
||||
if err == nil {
|
||||
params := v.parser.parameters2Slices(recursiveDir.GetParameters())
|
||||
if len(params) > 0 && params[0] == "on" {
|
||||
recursive = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(from) == 0 && header == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &types.RealIP{
|
||||
From: from,
|
||||
Header: header,
|
||||
Recursive: recursive,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *baseVhost) SetRealIP(realIP *types.RealIP) error {
|
||||
// 清除现有配置
|
||||
_ = v.parser.Clear("server.set_real_ip_from")
|
||||
_ = v.parser.Clear("server.real_ip_header")
|
||||
_ = v.parser.Clear("server.real_ip_recursive")
|
||||
|
||||
if realIP == nil || (len(realIP.From) == 0 && realIP.Header == "") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var directives []*config.Directive
|
||||
|
||||
// 添加 set_real_ip_from 配置
|
||||
for _, ip := range realIP.From {
|
||||
if ip != "" {
|
||||
directives = append(directives, &config.Directive{
|
||||
Name: "set_real_ip_from",
|
||||
Parameters: []config.Parameter{{Value: ip}},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 real_ip_header 配置
|
||||
if realIP.Header != "" {
|
||||
directives = append(directives, &config.Directive{
|
||||
Name: "real_ip_header",
|
||||
Parameters: []config.Parameter{{Value: realIP.Header}},
|
||||
})
|
||||
}
|
||||
|
||||
// 添加 real_ip_recursive 配置
|
||||
if realIP.Recursive {
|
||||
directives = append(directives, &config.Directive{
|
||||
Name: "real_ip_recursive",
|
||||
Parameters: []config.Parameter{{Value: "on"}},
|
||||
})
|
||||
}
|
||||
|
||||
if len(directives) > 0 {
|
||||
return v.parser.Set("server", directives)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *baseVhost) ClearRealIP() error {
|
||||
_ = v.parser.Clear("server.set_real_ip_from")
|
||||
_ = v.parser.Clear("server.real_ip_header")
|
||||
_ = v.parser.Clear("server.real_ip_recursive")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *baseVhost) Redirects() []types.Redirect {
|
||||
siteDir := filepath.Join(v.configDir, "site")
|
||||
redirects, _ := parseRedirectFiles(siteDir)
|
||||
|
||||
@@ -249,16 +249,17 @@ func (s *VhostTestSuite) TestRateLimit() {
|
||||
s.Nil(s.vhost.RateLimit())
|
||||
|
||||
limit := &types.RateLimit{
|
||||
Rate: "512k",
|
||||
Zone: map[string]string{
|
||||
"perip": "10",
|
||||
},
|
||||
PerServer: 300,
|
||||
PerIP: 25,
|
||||
Rate: 512,
|
||||
}
|
||||
s.NoError(s.vhost.SetRateLimit(limit))
|
||||
|
||||
got := s.vhost.RateLimit()
|
||||
s.NotNil(got)
|
||||
s.Equal("512k", got.Rate)
|
||||
s.Equal(300, got.PerServer)
|
||||
s.Equal(25, got.PerIP)
|
||||
s.Equal(512, got.Rate)
|
||||
|
||||
s.NoError(s.vhost.ClearRateLimit())
|
||||
s.Nil(s.vhost.RateLimit())
|
||||
|
||||
@@ -76,6 +76,13 @@ type Vhost interface {
|
||||
// ClearBasicAuth 清除基本认证
|
||||
ClearBasicAuth() error
|
||||
|
||||
// RealIP 取真实 IP 配置
|
||||
RealIP() *RealIP
|
||||
// SetRealIP 设置真实 IP 配置
|
||||
SetRealIP(realIP *RealIP) error
|
||||
// ClearRealIP 清除真实 IP 配置
|
||||
ClearRealIP() error
|
||||
|
||||
// Config 取指定名称的配置内容
|
||||
// type 可选值: "site", "shared"
|
||||
Config(name string, typ string) string
|
||||
@@ -162,9 +169,16 @@ type SSLConfig struct {
|
||||
|
||||
// RateLimit 限流限速配置
|
||||
type RateLimit struct {
|
||||
Rate string `json:"rate"` // 速率限制,如: "512k", "10r/s"
|
||||
Concurrent int `json:"concurrent"` // 并发连接数限制
|
||||
Zone map[string]string `json:"zone"` // 条件配置,如: map["perip"] = "10"
|
||||
PerServer int `json:"per_server"` // 站点最大并发数 (limit_conn perserver X)
|
||||
PerIP int `json:"per_ip"` // 单 IP 最大并发数 (limit_conn perip X)
|
||||
Rate int `json:"rate"` // 流量限制,单位 KB (limit_rate Xk)
|
||||
}
|
||||
|
||||
// RealIP 真实 IP 配置
|
||||
type RealIP struct {
|
||||
From []string `json:"from"` // 可信 IP 来源列表 (set_real_ip_from)
|
||||
Header string `json:"header"` // 真实 IP 头 (real_ip_header),如: X-Real-IP, X-Forwarded-For
|
||||
Recursive bool `json:"recursive"` // 递归搜索 (real_ip_recursive)
|
||||
}
|
||||
|
||||
// IncludeFile 包含文件配置
|
||||
|
||||
@@ -48,6 +48,10 @@ const { data: setting, send: fetchSetting } = useRequest(website.config(Number(i
|
||||
open_basedir: false,
|
||||
upstreams: [],
|
||||
proxies: [],
|
||||
redirects: [],
|
||||
rate_limit: null,
|
||||
real_ip: null,
|
||||
basic_auth: {},
|
||||
custom_configs: []
|
||||
}
|
||||
})
|
||||
@@ -385,6 +389,108 @@ const updateTimeoutUnit = (proxy: any, unit: string) => {
|
||||
proxy.resolver_timeout = buildDuration(parsed.value, unit)
|
||||
}
|
||||
|
||||
// ========== 重定向相关 ==========
|
||||
// 重定向类型选项
|
||||
const redirectTypeOptions = [
|
||||
{ label: $gettext('URL Redirect'), value: 'url' },
|
||||
{ label: $gettext('Host Redirect'), value: 'host' },
|
||||
{ label: $gettext('404 Redirect'), value: '404' }
|
||||
]
|
||||
|
||||
// 状态码选项
|
||||
const redirectStatusCodeOptions = [
|
||||
{ label: '301 - ' + $gettext('Moved Permanently'), value: 301 },
|
||||
{ label: '302 - ' + $gettext('Found'), value: 302 },
|
||||
{ label: '307 - ' + $gettext('Temporary Redirect'), value: 307 },
|
||||
{ label: '308 - ' + $gettext('Permanent Redirect'), value: 308 }
|
||||
]
|
||||
|
||||
// 添加重定向规则
|
||||
const addRedirect = () => {
|
||||
if (!setting.value.redirects) {
|
||||
setting.value.redirects = []
|
||||
}
|
||||
setting.value.redirects.push({
|
||||
type: 'url',
|
||||
from: '/',
|
||||
to: '/new',
|
||||
keep_uri: true,
|
||||
status_code: 308
|
||||
})
|
||||
}
|
||||
|
||||
// 删除重定向规则
|
||||
const removeRedirect = (index: number) => {
|
||||
if (setting.value.redirects) {
|
||||
setting.value.redirects.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取重定向类型的标签
|
||||
const getRedirectTypeLabel = (type: string) => {
|
||||
const option = redirectTypeOptions.find((opt) => opt.value === type)
|
||||
return option ? option.label : type
|
||||
}
|
||||
|
||||
// ========== 高级设置相关(限流限速、真实 IP、基本认证)==========
|
||||
// 限流限速是否启用
|
||||
const rateLimitEnabled = computed({
|
||||
get: () => setting.value.rate_limit !== null,
|
||||
set: (value: boolean) => {
|
||||
if (value) {
|
||||
setting.value.rate_limit = {
|
||||
per_server: 0,
|
||||
per_ip: 0,
|
||||
rate: 0
|
||||
}
|
||||
} else {
|
||||
setting.value.rate_limit = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 真实 IP 是否启用
|
||||
const realIPEnabled = computed({
|
||||
get: () => setting.value.real_ip !== null,
|
||||
set: (value: boolean) => {
|
||||
if (value) {
|
||||
setting.value.real_ip = {
|
||||
from: [],
|
||||
header: 'X-Real-IP',
|
||||
recursive: false
|
||||
}
|
||||
} else {
|
||||
setting.value.real_ip = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 真实 IP Header 选项
|
||||
const realIPHeaderOptions = [
|
||||
{ label: 'X-Real-IP', value: 'X-Real-IP' },
|
||||
{ label: 'X-Forwarded-For', value: 'X-Forwarded-For' },
|
||||
{ label: 'CF-Connecting-IP', value: 'CF-Connecting-IP' },
|
||||
{ label: 'True-Client-IP', value: 'True-Client-IP' },
|
||||
{ label: 'Ali-Cdn-Real-Ip', value: 'Ali-Cdn-Real-Ip' },
|
||||
{ label: 'EO-Connecting-IP', value: 'EO-Connecting-IP' }
|
||||
]
|
||||
|
||||
// 添加基本认证用户
|
||||
const addBasicAuthUser = () => {
|
||||
if (!setting.value.basic_auth) {
|
||||
setting.value.basic_auth = {}
|
||||
}
|
||||
const index = Object.keys(setting.value.basic_auth).length + 1
|
||||
setting.value.basic_auth[`user${index}`] = ''
|
||||
}
|
||||
|
||||
// 删除基本认证用户
|
||||
const removeBasicAuthUser = (username: string) => {
|
||||
if (setting.value.basic_auth) {
|
||||
delete setting.value.basic_auth[username]
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 自定义配置相关 ==========
|
||||
// 作用域选项
|
||||
const scopeOptions = [
|
||||
@@ -505,7 +611,7 @@ const removeCustomConfig = (index: number) => {
|
||||
ghost-class="ghost-card"
|
||||
>
|
||||
<template #item="{ element: upstream, index }">
|
||||
<n-card closable @close="removeUpstream(index)" style="margin-bottom: 16px">
|
||||
<n-card closable @close="removeUpstream(index)" mb-16>
|
||||
<template #header>
|
||||
<n-flex align="center" :size="8">
|
||||
<!-- 拖拽手柄 -->
|
||||
@@ -548,7 +654,7 @@ const removeCustomConfig = (index: number) => {
|
||||
v-model:value="upstream.keepalive"
|
||||
:min="0"
|
||||
:max="1000"
|
||||
style="width: 100%"
|
||||
w-full
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi v-if="isNginx" :span="12" :label="$gettext('DNS Resolver')">
|
||||
@@ -567,7 +673,7 @@ const removeCustomConfig = (index: number) => {
|
||||
:value="parseDuration(upstream.resolver_timeout).value"
|
||||
:min="1"
|
||||
:max="3600"
|
||||
style="flex: 1"
|
||||
flex-1
|
||||
@update:value="
|
||||
(v: number | null) => updateUpstreamTimeoutValue(upstream, v ?? 5)
|
||||
"
|
||||
@@ -582,7 +688,7 @@ const removeCustomConfig = (index: number) => {
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-form-item :label="$gettext('Backend Servers')">
|
||||
<n-flex vertical :size="8" style="width: 100%">
|
||||
<n-flex vertical :size="8" w-full>
|
||||
<n-flex
|
||||
v-for="(options, address) in upstream.servers"
|
||||
:key="String(address)"
|
||||
@@ -592,7 +698,7 @@ const removeCustomConfig = (index: number) => {
|
||||
<n-input
|
||||
:default-value="String(address)"
|
||||
:placeholder="$gettext('Server address, e.g., 127.0.0.1:8080')"
|
||||
style="flex: 1"
|
||||
flex-1
|
||||
@change="
|
||||
(newAddr: string) => {
|
||||
const oldAddr = String(address)
|
||||
@@ -606,14 +712,14 @@ const removeCustomConfig = (index: number) => {
|
||||
<n-input
|
||||
:value="String(options)"
|
||||
:placeholder="$gettext('Options, e.g., weight=5 backup')"
|
||||
style="flex: 1"
|
||||
flex-1
|
||||
@update:value="(v: string) => (upstream.servers[String(address)] = v)"
|
||||
/>
|
||||
<n-button
|
||||
type="error"
|
||||
secondary
|
||||
size="small"
|
||||
style="flex-shrink: 0"
|
||||
flex-shrink-0
|
||||
@click="delete upstream.servers[String(address)]"
|
||||
>
|
||||
{{ $gettext('Remove') }}
|
||||
@@ -651,7 +757,7 @@ const removeCustomConfig = (index: number) => {
|
||||
ghost-class="ghost-card"
|
||||
>
|
||||
<template #item="{ element: proxy, index }">
|
||||
<n-card closable @close="removeProxy(index)" style="margin-bottom: 16px">
|
||||
<n-card closable @close="removeProxy(index)" mb-16>
|
||||
<template #header>
|
||||
<n-flex align="center" :size="8">
|
||||
<!-- 拖拽手柄 -->
|
||||
@@ -727,7 +833,7 @@ const removeCustomConfig = (index: number) => {
|
||||
:value="parseDuration(proxy.resolver_timeout).value"
|
||||
:min="1"
|
||||
:max="3600"
|
||||
style="flex: 1"
|
||||
flex-1
|
||||
@update:value="(v: number | null) => updateTimeoutValue(proxy, v ?? 5)"
|
||||
/>
|
||||
<n-select
|
||||
@@ -750,7 +856,7 @@ const removeCustomConfig = (index: number) => {
|
||||
<n-input
|
||||
:value="String(fromValue)"
|
||||
:placeholder="$gettext('Original content')"
|
||||
style="flex: 1"
|
||||
flex-1
|
||||
@blur="
|
||||
(e: FocusEvent) => {
|
||||
const newFrom = (e.target as HTMLInputElement).value
|
||||
@@ -762,18 +868,18 @@ const removeCustomConfig = (index: number) => {
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span style="flex-shrink: 0">=></span>
|
||||
<span flex-shrink-0>=></span>
|
||||
<n-input
|
||||
:value="String(toValue)"
|
||||
:placeholder="$gettext('Replacement content')"
|
||||
style="flex: 1"
|
||||
flex-1
|
||||
@update:value="(v: string) => (proxy.replaces[String(fromValue)] = v)"
|
||||
/>
|
||||
<n-button
|
||||
type="error"
|
||||
secondary
|
||||
size="small"
|
||||
style="flex-shrink: 0"
|
||||
flex-shrink-0
|
||||
@click="delete proxy.replaces[String(fromValue)]"
|
||||
>
|
||||
{{ $gettext('Remove') }}
|
||||
@@ -925,6 +1031,246 @@ const removeCustomConfig = (index: number) => {
|
||||
<common-editor v-if="setting" v-model:value="setting.rewrite" height="60vh" />
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="redirects" :tab="$gettext('Redirects')">
|
||||
<n-flex vertical>
|
||||
<!-- 重定向卡片列表 -->
|
||||
<draggable
|
||||
v-model="setting.redirects"
|
||||
item-key="from"
|
||||
handle=".drag-handle"
|
||||
:animation="200"
|
||||
ghost-class="ghost-card"
|
||||
>
|
||||
<template #item="{ element: redirect, index }">
|
||||
<n-card closable @close="removeRedirect(index)" mb-16>
|
||||
<template #header>
|
||||
<n-flex align="center" :size="8">
|
||||
<!-- 拖拽手柄 -->
|
||||
<div class="drag-handle" cursor-grab>
|
||||
<the-icon icon="mdi:drag" :size="20" />
|
||||
</div>
|
||||
<span>{{ $gettext('Rule') }} #{{ index + 1 }}</span>
|
||||
<n-tag size="small" :type="redirect.type === '404' ? 'warning' : 'default'">
|
||||
{{ getRedirectTypeLabel(redirect.type) }}
|
||||
</n-tag>
|
||||
<template v-if="redirect.type !== '404'">
|
||||
<n-tag size="small">{{ redirect.from }}</n-tag>
|
||||
<the-icon icon="mdi:arrow-right-bold" :size="20" />
|
||||
</template>
|
||||
<n-tag size="small" type="success">{{ redirect.to }}</n-tag>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-form label-placement="left" label-width="140px">
|
||||
<n-grid :cols="24" :x-gap="16">
|
||||
<n-form-item-gi :span="12" :label="$gettext('Redirect Type')">
|
||||
<n-select v-model:value="redirect.type" :options="redirectTypeOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="12" :label="$gettext('Status Code')">
|
||||
<n-select
|
||||
v-model:value="redirect.status_code"
|
||||
:options="redirectStatusCodeOptions"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
v-if="redirect.type !== '404'"
|
||||
:span="12"
|
||||
:label="$gettext('Source')"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="redirect.from"
|
||||
:placeholder="
|
||||
redirect.type === 'url'
|
||||
? $gettext('Source path, e.g., /old')
|
||||
: $gettext('Source host, e.g., example.com')
|
||||
"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
:span="redirect.type === '404' ? 24 : 12"
|
||||
:label="$gettext('Target')"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="redirect.to"
|
||||
:placeholder="
|
||||
redirect.type === 'url'
|
||||
? $gettext('Target path, e.g., /new')
|
||||
: $gettext('Target URL, e.g., https://example.com')
|
||||
"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="12" :label="$gettext('Keep URI')">
|
||||
<n-switch v-model:value="redirect.keep_uri" />
|
||||
<n-text depth="3" class="ml-8">
|
||||
{{ $gettext('Keep the original request path and query parameters') }}
|
||||
</n-text>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<n-empty v-if="!setting.redirects || setting.redirects.length === 0">
|
||||
{{ $gettext('No redirect rules configured') }}
|
||||
</n-empty>
|
||||
|
||||
<!-- 添加按钮 -->
|
||||
<n-button type="primary" dashed @click="addRedirect" mb-20>
|
||||
{{ $gettext('Add Redirect Rule') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="advanced" :tab="$gettext('Advanced Settings')">
|
||||
<n-flex vertical>
|
||||
<!-- 限流限速设置 -->
|
||||
<n-card :title="$gettext('Rate Limiting')" mb-16>
|
||||
<n-form label-placement="left" label-width="140px">
|
||||
<n-form-item :label="$gettext('Enable Rate Limiting')">
|
||||
<n-switch v-model:value="rateLimitEnabled" />
|
||||
</n-form-item>
|
||||
<template v-if="rateLimitEnabled && setting.rate_limit">
|
||||
<n-form-item :label="$gettext('Concurrent Limit')">
|
||||
<n-input-number
|
||||
v-model:value="setting.rate_limit.per_server"
|
||||
:min="0"
|
||||
:max="100000"
|
||||
w-full
|
||||
/>
|
||||
<template #feedback>
|
||||
{{ $gettext('Limit the maximum concurrent connections for this site') }}
|
||||
</template>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Per IP Limit')">
|
||||
<n-input-number
|
||||
v-model:value="setting.rate_limit.per_ip"
|
||||
:min="0"
|
||||
:max="10000"
|
||||
w-full
|
||||
/>
|
||||
<template #feedback>
|
||||
{{ $gettext('Limit the maximum concurrent connections per IP') }}
|
||||
</template>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Rate Limit')">
|
||||
<n-input-number
|
||||
v-model:value="setting.rate_limit.rate"
|
||||
:min="0"
|
||||
:max="1000000"
|
||||
w-full
|
||||
/>
|
||||
<template #feedback>
|
||||
{{ $gettext('Limit the rate of each request (unit: KB)') }}
|
||||
</template>
|
||||
</n-form-item>
|
||||
</template>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 真实 IP 设置 -->
|
||||
<n-card :title="$gettext('Real IP')" mb-16>
|
||||
<n-alert type="info" mb-16>
|
||||
{{
|
||||
$gettext(
|
||||
'Configure trusted proxy IPs (e.g., CDN or Frp) to identify real visitor IPs.'
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
<n-alert type="warning" mb-16>
|
||||
{{
|
||||
$gettext(
|
||||
'If using Frp, fill in the Frp IP address (e.g., 127.0.0.1). If using CDN, fill in the CDN IP ranges. If unsure, you can fill in 0.0.0.0/0 (ipv4) or ::/0 (ipv6) [insecure].'
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
<n-form label-placement="left" label-width="140px">
|
||||
<n-form-item :label="$gettext('Enable')">
|
||||
<n-switch v-model:value="realIPEnabled" />
|
||||
</n-form-item>
|
||||
<template v-if="realIPEnabled && setting.real_ip">
|
||||
<n-form-item :label="$gettext('IP Sources')">
|
||||
<n-dynamic-input
|
||||
v-model:value="setting.real_ip.from"
|
||||
:placeholder="$gettext('e.g., 127.0.0.1 or 10.0.0.0/8')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('IP Header')">
|
||||
<n-select v-model:value="setting.real_ip.header" :options="realIPHeaderOptions" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Recursive')">
|
||||
<n-switch v-model:value="setting.real_ip.recursive" />
|
||||
<template #feedback>
|
||||
{{ $gettext('Recursively search for real IP in X-Forwarded-For header') }}
|
||||
</template>
|
||||
</n-form-item>
|
||||
</template>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 基本认证设置 -->
|
||||
<n-card :title="$gettext('Basic Authentication')" mb-16>
|
||||
<n-form label-placement="left" label-width="140px">
|
||||
<n-form-item :label="$gettext('User Credentials')">
|
||||
<n-flex vertical :size="8" w-full>
|
||||
<n-flex
|
||||
v-for="(password, username) in setting.basic_auth"
|
||||
:key="String(username)"
|
||||
:size="8"
|
||||
align="center"
|
||||
>
|
||||
<n-input
|
||||
:default-value="String(username)"
|
||||
:placeholder="$gettext('Username')"
|
||||
flex-1
|
||||
@change="
|
||||
(newUsername: string) => {
|
||||
const oldUsername = String(username)
|
||||
if (newUsername && newUsername !== oldUsername) {
|
||||
// 检查新用户名是否已存在
|
||||
if (setting.basic_auth[newUsername] !== undefined) {
|
||||
window.$message.error($gettext('Username already exists'))
|
||||
return
|
||||
}
|
||||
setting.basic_auth[newUsername] = setting.basic_auth[oldUsername]
|
||||
delete setting.basic_auth[oldUsername]
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<n-input
|
||||
:value="String(password)"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="$gettext('Password')"
|
||||
flex-1
|
||||
@update:value="(v: string) => (setting.basic_auth[String(username)] = v)"
|
||||
/>
|
||||
<n-button
|
||||
type="error"
|
||||
secondary
|
||||
size="small"
|
||||
flex-shrink-0
|
||||
@click="removeBasicAuthUser(String(username))"
|
||||
>
|
||||
{{ $gettext('Remove') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-button dashed size="small" @click="addBasicAuthUser">
|
||||
{{ $gettext('Add User') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-alert v-if="Object.keys(setting.basic_auth || {}).length > 0" type="info">
|
||||
{{
|
||||
$gettext(
|
||||
'Visitors will need to enter a username and password to access this website.'
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="custom_configs" :tab="$gettext('Custom Configs')">
|
||||
<n-flex vertical>
|
||||
<!-- 自定义配置列表 -->
|
||||
@@ -936,7 +1282,7 @@ const removeCustomConfig = (index: number) => {
|
||||
ghost-class="ghost-card"
|
||||
>
|
||||
<template #item="{ element: config, index }">
|
||||
<n-card closable @close="removeCustomConfig(index)" style="margin-bottom: 16px">
|
||||
<n-card closable @close="removeCustomConfig(index)" mb-16>
|
||||
<template #header>
|
||||
<n-flex align="center" :size="8">
|
||||
<!-- 拖拽手柄 -->
|
||||
|
||||
Reference in New Issue
Block a user