mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 15:26:36 +08:00
[新增] 添加 API 反向代理功能支持
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
package goose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
presslygoose "github.com/pressly/goose/v3"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const versionPagesAPIProxy int64 = 202606030004
|
||||||
|
|
||||||
|
// migration202606030004 adds API proxying fields (enabled, path, pass, rewrite)
|
||||||
|
// to Pages projects.
|
||||||
|
func migration202606030004(backend string, ctx Context) *presslygoose.Migration {
|
||||||
|
return newGORMMigration(
|
||||||
|
versionPagesAPIProxy,
|
||||||
|
"202606030004_add_pages_api_proxy.go",
|
||||||
|
backend,
|
||||||
|
ctx,
|
||||||
|
migratePagesAPIProxy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migratePagesAPIProxy(ctx Context, db *gorm.DB, backend string) error {
|
||||||
|
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Verify that the columns exist
|
||||||
|
for _, col := range []string{"api_proxy_enabled", "api_proxy_path", "api_proxy_pass", "api_proxy_rewrite"} {
|
||||||
|
if !db.Migrator().HasColumn("pages_projects", col) {
|
||||||
|
return fmt.Errorf("column pages_projects.%s is missing", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration
|
|||||||
migration202606030001(backend, ctx),
|
migration202606030001(backend, ctx),
|
||||||
migration202606030002(backend, ctx),
|
migration202606030002(backend, ctx),
|
||||||
migration202606030003(backend, ctx),
|
migration202606030003(backend, ctx),
|
||||||
|
migration202606030004(backend, ctx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ type PagesProject struct {
|
|||||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||||
SPAFallbackEnabled bool `json:"spa_fallback_enabled" gorm:"not null;default:false"`
|
SPAFallbackEnabled bool `json:"spa_fallback_enabled" gorm:"not null;default:false"`
|
||||||
SPAFallbackPath string `json:"spa_fallback_path" gorm:"size:512;not null;default:'/index.html'"`
|
SPAFallbackPath string `json:"spa_fallback_path" gorm:"size:512;not null;default:'/index.html'"`
|
||||||
|
APIProxyEnabled bool `json:"api_proxy_enabled" gorm:"not null;default:false"`
|
||||||
|
APIProxyPath string `json:"api_proxy_path" gorm:"size:255;not null;default:''"`
|
||||||
|
APIProxyPass string `json:"api_proxy_pass" gorm:"size:2048;not null;default:''"`
|
||||||
|
APIProxyRewrite string `json:"api_proxy_rewrite" gorm:"size:255;not null;default:''"`
|
||||||
ActiveDeploymentID *uint `json:"active_deployment_id" gorm:"index"`
|
ActiveDeploymentID *uint `json:"active_deployment_id" gorm:"index"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ type snapshotPagesDeployment struct {
|
|||||||
EntryFile string `json:"entry_file"`
|
EntryFile string `json:"entry_file"`
|
||||||
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||||
SPAFallbackPath string `json:"spa_fallback_path"`
|
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||||
|
APIProxyEnabled bool `json:"api_proxy_enabled"`
|
||||||
|
APIProxyPath string `json:"api_proxy_path"`
|
||||||
|
APIProxyPass string `json:"api_proxy_pass"`
|
||||||
|
APIProxyRewrite string `json:"api_proxy_rewrite"`
|
||||||
LocalRoot string `json:"local_root"`
|
LocalRoot string `json:"local_root"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,6 +619,10 @@ func buildSnapshotPagesDeployment(projectID *uint) (*snapshotPagesDeployment, er
|
|||||||
EntryFile: deployment.EntryFile,
|
EntryFile: deployment.EntryFile,
|
||||||
SPAFallbackEnabled: project.SPAFallbackEnabled,
|
SPAFallbackEnabled: project.SPAFallbackEnabled,
|
||||||
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
|
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
|
||||||
|
APIProxyEnabled: project.APIProxyEnabled,
|
||||||
|
APIProxyPath: project.APIProxyPath,
|
||||||
|
APIProxyPass: project.APIProxyPass,
|
||||||
|
APIProxyRewrite: project.APIProxyRewrite,
|
||||||
LocalRoot: fmt.Sprintf("%s/deployments/%d/current", openrestyrender.PagesDirPlaceholder, deployment.ID),
|
LocalRoot: fmt.Sprintf("%s/deployments/%d/current", openrestyrender.PagesDirPlaceholder, deployment.ID),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
"net/url"
|
||||||
"openflare/common"
|
"openflare/common"
|
||||||
"openflare/model"
|
"openflare/model"
|
||||||
"os"
|
"os"
|
||||||
@@ -36,6 +37,10 @@ type PagesProjectInput struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||||
SPAFallbackPath string `json:"spa_fallback_path"`
|
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||||
|
APIProxyEnabled bool `json:"api_proxy_enabled"`
|
||||||
|
APIProxyPath string `json:"api_proxy_path"`
|
||||||
|
APIProxyPass string `json:"api_proxy_pass"`
|
||||||
|
APIProxyRewrite string `json:"api_proxy_rewrite"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PagesProjectView struct {
|
type PagesProjectView struct {
|
||||||
@@ -46,6 +51,10 @@ type PagesProjectView struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||||
SPAFallbackPath string `json:"spa_fallback_path"`
|
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||||
|
APIProxyEnabled bool `json:"api_proxy_enabled"`
|
||||||
|
APIProxyPath string `json:"api_proxy_path"`
|
||||||
|
APIProxyPass string `json:"api_proxy_pass"`
|
||||||
|
APIProxyRewrite string `json:"api_proxy_rewrite"`
|
||||||
ActiveDeploymentID *uint `json:"active_deployment_id"`
|
ActiveDeploymentID *uint `json:"active_deployment_id"`
|
||||||
ActiveDeployment *PagesDeploymentView `json:"active_deployment,omitempty"`
|
ActiveDeployment *PagesDeploymentView `json:"active_deployment,omitempty"`
|
||||||
DeploymentCount int64 `json:"deployment_count"`
|
DeploymentCount int64 `json:"deployment_count"`
|
||||||
@@ -137,6 +146,10 @@ func UpdatePagesProject(id uint, input PagesProjectInput) (*PagesProjectView, er
|
|||||||
"enabled": project.Enabled,
|
"enabled": project.Enabled,
|
||||||
"spa_fallback_enabled": project.SPAFallbackEnabled,
|
"spa_fallback_enabled": project.SPAFallbackEnabled,
|
||||||
"spa_fallback_path": project.SPAFallbackPath,
|
"spa_fallback_path": project.SPAFallbackPath,
|
||||||
|
"api_proxy_enabled": project.APIProxyEnabled,
|
||||||
|
"api_proxy_path": project.APIProxyPath,
|
||||||
|
"api_proxy_pass": project.APIProxyPass,
|
||||||
|
"api_proxy_rewrite": project.APIProxyRewrite,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
if model.IsUniqueConstraintError(err) {
|
if model.IsUniqueConstraintError(err) {
|
||||||
return nil, errors.New("Pages 项目标识已存在")
|
return nil, errors.New("Pages 项目标识已存在")
|
||||||
@@ -413,6 +426,31 @@ func buildPagesProject(project *model.PagesProject, input PagesProjectInput) (*m
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
project.SPAFallbackPath = fallbackPath
|
project.SPAFallbackPath = fallbackPath
|
||||||
|
|
||||||
|
project.APIProxyEnabled = input.APIProxyEnabled
|
||||||
|
apiProxyPath := strings.TrimSpace(input.APIProxyPath)
|
||||||
|
apiProxyPass := strings.TrimSpace(input.APIProxyPass)
|
||||||
|
apiProxyRewrite := strings.TrimSpace(input.APIProxyRewrite)
|
||||||
|
|
||||||
|
if project.APIProxyEnabled {
|
||||||
|
if apiProxyPath == "" {
|
||||||
|
return nil, errors.New("启用 API 反代时,匹配路径不能为空")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(apiProxyPath, "/") {
|
||||||
|
return nil, errors.New("API 反代匹配路径必须以 '/' 开头")
|
||||||
|
}
|
||||||
|
if apiProxyPass == "" {
|
||||||
|
return nil, errors.New("启用 API 反代时,后端服务地址不能为空")
|
||||||
|
}
|
||||||
|
parsedURL, err := url.Parse(apiProxyPass)
|
||||||
|
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" {
|
||||||
|
return nil, errors.New("API 反代后端服务地址必须是有效的 HTTP/HTTPS URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
project.APIProxyPath = apiProxyPath
|
||||||
|
project.APIProxyPass = apiProxyPass
|
||||||
|
project.APIProxyRewrite = apiProxyRewrite
|
||||||
|
|
||||||
return project, nil
|
return project, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +466,10 @@ func buildPagesProjectView(project *model.PagesProject) (*PagesProjectView, erro
|
|||||||
Enabled: project.Enabled,
|
Enabled: project.Enabled,
|
||||||
SPAFallbackEnabled: project.SPAFallbackEnabled,
|
SPAFallbackEnabled: project.SPAFallbackEnabled,
|
||||||
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
|
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
|
||||||
|
APIProxyEnabled: project.APIProxyEnabled,
|
||||||
|
APIProxyPath: project.APIProxyPath,
|
||||||
|
APIProxyPass: project.APIProxyPass,
|
||||||
|
APIProxyRewrite: project.APIProxyRewrite,
|
||||||
ActiveDeploymentID: project.ActiveDeploymentID,
|
ActiveDeploymentID: project.ActiveDeploymentID,
|
||||||
CreatedAt: project.CreatedAt,
|
CreatedAt: project.CreatedAt,
|
||||||
UpdatedAt: project.UpdatedAt,
|
UpdatedAt: project.UpdatedAt,
|
||||||
|
|||||||
@@ -271,3 +271,57 @@ func TestUploadPagesDeploymentWithTopLevelFolder(t *testing.T) {
|
|||||||
t.Fatalf("expected EntryFile to be index.html, got %q", deployment.EntryFile)
|
t.Fatalf("expected EntryFile to be index.html, got %q", deployment.EntryFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPagesProjectAPIProxyValidation(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
// 1. Invalid configuration: enabled but empty fields
|
||||||
|
_, err := CreatePagesProject(PagesProjectInput{
|
||||||
|
Name: "API Proxy 1",
|
||||||
|
Enabled: true,
|
||||||
|
APIProxyEnabled: true,
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "匹配路径不能为空") {
|
||||||
|
t.Fatalf("expected error for empty match path, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Invalid path: must start with '/'
|
||||||
|
_, err = CreatePagesProject(PagesProjectInput{
|
||||||
|
Name: "API Proxy 2",
|
||||||
|
Enabled: true,
|
||||||
|
APIProxyEnabled: true,
|
||||||
|
APIProxyPath: "api",
|
||||||
|
APIProxyPass: "http://127.0.0.1:8080",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "必须以 '/' 开头") {
|
||||||
|
t.Fatalf("expected error for path not starting with /, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Invalid target URL
|
||||||
|
_, err = CreatePagesProject(PagesProjectInput{
|
||||||
|
Name: "API Proxy 3",
|
||||||
|
Enabled: true,
|
||||||
|
APIProxyEnabled: true,
|
||||||
|
APIProxyPath: "/api",
|
||||||
|
APIProxyPass: "127.0.0.1:8080",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "有效的 HTTP/HTTPS URL") {
|
||||||
|
t.Fatalf("expected error for invalid pass URL, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Valid configuration
|
||||||
|
project, err := CreatePagesProject(PagesProjectInput{
|
||||||
|
Name: "API Proxy Valid",
|
||||||
|
Enabled: true,
|
||||||
|
APIProxyEnabled: true,
|
||||||
|
APIProxyPath: "/api",
|
||||||
|
APIProxyPass: "http://127.0.0.1:8080",
|
||||||
|
APIProxyRewrite: "/",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error creating valid project: %v", err)
|
||||||
|
}
|
||||||
|
if !project.APIProxyEnabled || project.APIProxyPath != "/api" || project.APIProxyPass != "http://127.0.0.1:8080" || project.APIProxyRewrite != "/" {
|
||||||
|
t.Fatalf("unexpected project state: %+v", project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -421,8 +421,50 @@ func renderHTTPProxyServer(serverNames string, siteName string, originURL string
|
|||||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
|
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderPagesAPIProxyLocationBlock(deployment *PagesDeployment) string {
|
||||||
|
if deployment == nil || !deployment.APIProxyEnabled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(deployment.APIProxyPath)
|
||||||
|
pass := strings.TrimSpace(deployment.APIProxyPass)
|
||||||
|
rewrite := strings.TrimSpace(deployment.APIProxyRewrite)
|
||||||
|
if path == "" || pass == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
cleanPath := strings.TrimSuffix(path, "/")
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.WriteString(fmt.Sprintf("\n location %s {\n", cleanPath))
|
||||||
|
if rewrite != "" {
|
||||||
|
if !strings.HasPrefix(rewrite, "/") {
|
||||||
|
rewrite = "/" + rewrite
|
||||||
|
}
|
||||||
|
cleanRewrite := strings.TrimSuffix(rewrite, "/")
|
||||||
|
if cleanRewrite == "" {
|
||||||
|
builder.WriteString(fmt.Sprintf(" rewrite ^%s/(.*)$ /$1 break;\n", regexp.QuoteMeta(cleanPath)))
|
||||||
|
builder.WriteString(fmt.Sprintf(" rewrite ^%s$ / break;\n", regexp.QuoteMeta(cleanPath)))
|
||||||
|
} else {
|
||||||
|
builder.WriteString(fmt.Sprintf(" rewrite ^%s/(.*)$ %s/$1 break;\n", regexp.QuoteMeta(cleanPath), cleanRewrite))
|
||||||
|
builder.WriteString(fmt.Sprintf(" rewrite ^%s$ %s break;\n", regexp.QuoteMeta(cleanPath), cleanRewrite))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.WriteString(fmt.Sprintf(" proxy_pass %s;\n", pass))
|
||||||
|
builder.WriteString(" proxy_http_version 1.1;\n")
|
||||||
|
builder.WriteString(" proxy_set_header Host $http_host;\n")
|
||||||
|
builder.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n")
|
||||||
|
builder.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
|
||||||
|
builder.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
|
||||||
|
builder.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
|
||||||
|
builder.WriteString(" proxy_set_header Connection $connection_upgrade;\n")
|
||||||
|
builder.WriteString(" }\n")
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
func renderHTTPPagesServer(serverNames string, siteName string, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string) string {
|
func renderHTTPPagesServer(serverNames string, siteName string, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string) string {
|
||||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s root %s;\n index %s;\n\n location / {\n%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s root %s;\n index %s;%s\n\n location / {\n%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderHTTPRedirectServer(serverNames string) string {
|
func renderHTTPRedirectServer(serverNames string) string {
|
||||||
@@ -450,7 +492,7 @@ func renderHTTPSPagesServer(serverNames string, siteName string, certificateID u
|
|||||||
h3Listen = " listen 443 quic;\n"
|
h3Listen = " listen 443 quic;\n"
|
||||||
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
|
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;\n\n location / {\n%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;%s\n\n location / {\n%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderPagesLocationBlock(deployment *PagesDeployment, limitConfig routeLimitConfig) string {
|
func renderPagesLocationBlock(deployment *PagesDeployment, limitConfig routeLimitConfig) string {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package openresty
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderPagesAPIProxyLocationBlock(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
deployment *PagesDeployment
|
||||||
|
expected []string
|
||||||
|
unexpected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil deployment",
|
||||||
|
deployment: nil,
|
||||||
|
expected: []string{""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disabled proxy",
|
||||||
|
deployment: &PagesDeployment{
|
||||||
|
APIProxyEnabled: false,
|
||||||
|
APIProxyPath: "/api",
|
||||||
|
APIProxyPass: "http://127.0.0.1:8080",
|
||||||
|
},
|
||||||
|
expected: []string{""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled proxy without rewrite",
|
||||||
|
deployment: &PagesDeployment{
|
||||||
|
APIProxyEnabled: true,
|
||||||
|
APIProxyPath: "/api",
|
||||||
|
APIProxyPass: "http://127.0.0.1:8080",
|
||||||
|
APIProxyRewrite: "",
|
||||||
|
},
|
||||||
|
expected: []string{
|
||||||
|
"location /api {",
|
||||||
|
"proxy_pass http://127.0.0.1:8080;",
|
||||||
|
"proxy_http_version 1.1;",
|
||||||
|
"proxy_set_header Host $http_host;",
|
||||||
|
},
|
||||||
|
unexpected: []string{
|
||||||
|
"rewrite",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled proxy with rewrite to root",
|
||||||
|
deployment: &PagesDeployment{
|
||||||
|
APIProxyEnabled: true,
|
||||||
|
APIProxyPath: "/api",
|
||||||
|
APIProxyPass: "http://127.0.0.1:8080",
|
||||||
|
APIProxyRewrite: "/",
|
||||||
|
},
|
||||||
|
expected: []string{
|
||||||
|
"location /api {",
|
||||||
|
"rewrite ^/api/(.*)$ /$1 break;",
|
||||||
|
"rewrite ^/api$ / break;",
|
||||||
|
"proxy_pass http://127.0.0.1:8080;",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled proxy with rewrite to subpath",
|
||||||
|
deployment: &PagesDeployment{
|
||||||
|
APIProxyEnabled: true,
|
||||||
|
APIProxyPath: "/api",
|
||||||
|
APIProxyPass: "http://127.0.0.1:8080",
|
||||||
|
APIProxyRewrite: "/v2",
|
||||||
|
},
|
||||||
|
expected: []string{
|
||||||
|
"location /api {",
|
||||||
|
"rewrite ^/api/(.*)$ /v2/$1 break;",
|
||||||
|
"rewrite ^/api$ /v2 break;",
|
||||||
|
"proxy_pass http://127.0.0.1:8080;",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := renderPagesAPIProxyLocationBlock(tt.deployment)
|
||||||
|
if len(tt.expected) == 1 && tt.expected[0] == "" {
|
||||||
|
if got != "" {
|
||||||
|
t.Fatalf("expected empty output, got: %q", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, exp := range tt.expected {
|
||||||
|
if !strings.Contains(got, exp) {
|
||||||
|
t.Errorf("expected output to contain %q, but got:\n%s", exp, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unexp := range tt.unexpected {
|
||||||
|
if strings.Contains(got, unexp) {
|
||||||
|
t.Errorf("expected output NOT to contain %q, but got:\n%s", unexp, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,6 +130,10 @@ type PagesDeployment struct {
|
|||||||
EntryFile string `json:"entry_file"`
|
EntryFile string `json:"entry_file"`
|
||||||
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||||
SPAFallbackPath string `json:"spa_fallback_path"`
|
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||||
|
APIProxyEnabled bool `json:"api_proxy_enabled"`
|
||||||
|
APIProxyPath string `json:"api_proxy_path"`
|
||||||
|
APIProxyPass string `json:"api_proxy_pass"`
|
||||||
|
APIProxyRewrite string `json:"api_proxy_rewrite"`
|
||||||
LocalRoot string `json:"local_root"`
|
LocalRoot string `json:"local_root"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -431,6 +431,10 @@ function PagesProjectEditModal({
|
|||||||
const [description, setDescription] = useState(project.description || '');
|
const [description, setDescription] = useState(project.description || '');
|
||||||
const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(project.spa_fallback_enabled);
|
const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(project.spa_fallback_enabled);
|
||||||
const [spaFallbackPath, setSpaFallbackPath] = useState(project.spa_fallback_path);
|
const [spaFallbackPath, setSpaFallbackPath] = useState(project.spa_fallback_path);
|
||||||
|
const [apiProxyEnabled, setApiProxyEnabled] = useState(project.api_proxy_enabled || false);
|
||||||
|
const [apiProxyPath, setApiProxyPath] = useState(project.api_proxy_path || '');
|
||||||
|
const [apiProxyPass, setApiProxyPass] = useState(project.api_proxy_pass || '');
|
||||||
|
const [apiProxyRewrite, setApiProxyRewrite] = useState(project.api_proxy_rewrite || '');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setName(project.name);
|
setName(project.name);
|
||||||
@@ -438,6 +442,10 @@ function PagesProjectEditModal({
|
|||||||
setDescription(project.description || '');
|
setDescription(project.description || '');
|
||||||
setSpaFallbackEnabled(project.spa_fallback_enabled);
|
setSpaFallbackEnabled(project.spa_fallback_enabled);
|
||||||
setSpaFallbackPath(project.spa_fallback_path);
|
setSpaFallbackPath(project.spa_fallback_path);
|
||||||
|
setApiProxyEnabled(project.api_proxy_enabled || false);
|
||||||
|
setApiProxyPath(project.api_proxy_path || '');
|
||||||
|
setApiProxyPass(project.api_proxy_pass || '');
|
||||||
|
setApiProxyRewrite(project.api_proxy_rewrite || '');
|
||||||
}, [project]);
|
}, [project]);
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
@@ -449,6 +457,10 @@ function PagesProjectEditModal({
|
|||||||
enabled: project.enabled,
|
enabled: project.enabled,
|
||||||
spa_fallback_enabled: spaFallbackEnabled,
|
spa_fallback_enabled: spaFallbackEnabled,
|
||||||
spa_fallback_path: spaFallbackPath,
|
spa_fallback_path: spaFallbackPath,
|
||||||
|
api_proxy_enabled: apiProxyEnabled,
|
||||||
|
api_proxy_path: apiProxyPath,
|
||||||
|
api_proxy_pass: apiProxyPass,
|
||||||
|
api_proxy_rewrite: apiProxyRewrite,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -527,6 +539,48 @@ function PagesProjectEditModal({
|
|||||||
onChange={(event) => setSpaFallbackPath(event.target.value)}
|
onChange={(event) => setSpaFallbackPath(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
<ToggleField
|
||||||
|
label="启用 API 反向代理"
|
||||||
|
description="允许为该静态站点配置反代后端(例如反代指定 API 路径至您的后端服务)。"
|
||||||
|
checked={apiProxyEnabled}
|
||||||
|
onChange={setApiProxyEnabled}
|
||||||
|
/>
|
||||||
|
{apiProxyEnabled && (
|
||||||
|
<>
|
||||||
|
<ResourceField
|
||||||
|
label="反代匹配路径"
|
||||||
|
hint="以 / 开头,例如 /api 或 /api/v1。匹配该前缀的请求将被转发。"
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
value={apiProxyPath}
|
||||||
|
placeholder="/api"
|
||||||
|
onChange={(event) => setApiProxyPath(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField
|
||||||
|
label="后端服务地址"
|
||||||
|
hint="包含协议和主机的完整 URL,例如 http://127.0.0.1:8080。"
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
value={apiProxyPass}
|
||||||
|
placeholder="http://127.0.0.1:8080"
|
||||||
|
onChange={(event) => setApiProxyPass(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField
|
||||||
|
label="路径重写目标"
|
||||||
|
hint="可选。如果配置为 /,请求 /api/users 将被重写转发至后端 /users 路径。"
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
value={apiProxyRewrite}
|
||||||
|
placeholder="/"
|
||||||
|
onChange={(event) => setApiProxyRewrite(event.target.value)}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{updateMutation.error ? (
|
{updateMutation.error ? (
|
||||||
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
|
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
|
||||||
{updateMutation.error.message}
|
{updateMutation.error.message}
|
||||||
@@ -573,6 +627,10 @@ function PagesProjectCreateModal({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
spa_fallback_enabled: spaFallbackEnabled,
|
spa_fallback_enabled: spaFallbackEnabled,
|
||||||
spa_fallback_path: spaFallbackPath,
|
spa_fallback_path: spaFallbackPath,
|
||||||
|
api_proxy_enabled: false,
|
||||||
|
api_proxy_path: '',
|
||||||
|
api_proxy_pass: '',
|
||||||
|
api_proxy_rewrite: '',
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
resetForm();
|
resetForm();
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export interface PagesProject {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
spa_fallback_enabled: boolean;
|
spa_fallback_enabled: boolean;
|
||||||
spa_fallback_path: string;
|
spa_fallback_path: string;
|
||||||
|
api_proxy_enabled: boolean;
|
||||||
|
api_proxy_path: string;
|
||||||
|
api_proxy_pass: string;
|
||||||
|
api_proxy_rewrite: string;
|
||||||
active_deployment_id?: number | null;
|
active_deployment_id?: number | null;
|
||||||
active_deployment?: PagesDeployment | null;
|
active_deployment?: PagesDeployment | null;
|
||||||
deployment_count: number;
|
deployment_count: number;
|
||||||
@@ -34,4 +38,8 @@ export interface PagesProjectPayload {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
spa_fallback_enabled: boolean;
|
spa_fallback_enabled: boolean;
|
||||||
spa_fallback_path: string;
|
spa_fallback_path: string;
|
||||||
|
api_proxy_enabled: boolean;
|
||||||
|
api_proxy_path: string;
|
||||||
|
api_proxy_pass: string;
|
||||||
|
api_proxy_rewrite: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user