[新增] 添加 API 反向代理功能支持

This commit is contained in:
ryan
2026-06-03 22:48:04 +08:00
parent 68d70bbbe8
commit 3eecd31868
11 changed files with 358 additions and 2 deletions
@@ -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),
migration202606030002(backend, ctx),
migration202606030003(backend, ctx),
migration202606030004(backend, ctx),
}
}
+4
View File
@@ -15,6 +15,10 @@ type PagesProject struct {
Enabled bool `json:"enabled" gorm:"not null;default:true"`
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'"`
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"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -104,6 +104,10 @@ type snapshotPagesDeployment struct {
EntryFile string `json:"entry_file"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
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"`
}
@@ -615,6 +619,10 @@ func buildSnapshotPagesDeployment(projectID *uint) (*snapshotPagesDeployment, er
EntryFile: deployment.EntryFile,
SPAFallbackEnabled: project.SPAFallbackEnabled,
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),
}, nil
}
+42
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"mime/multipart"
"net/url"
"openflare/common"
"openflare/model"
"os"
@@ -36,6 +37,10 @@ type PagesProjectInput struct {
Enabled bool `json:"enabled"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
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 {
@@ -46,6 +51,10 @@ type PagesProjectView struct {
Enabled bool `json:"enabled"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
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"`
ActiveDeployment *PagesDeploymentView `json:"active_deployment,omitempty"`
DeploymentCount int64 `json:"deployment_count"`
@@ -137,6 +146,10 @@ func UpdatePagesProject(id uint, input PagesProjectInput) (*PagesProjectView, er
"enabled": project.Enabled,
"spa_fallback_enabled": project.SPAFallbackEnabled,
"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 {
if model.IsUniqueConstraintError(err) {
return nil, errors.New("Pages 项目标识已存在")
@@ -413,6 +426,31 @@ func buildPagesProject(project *model.PagesProject, input PagesProjectInput) (*m
return nil, err
}
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
}
@@ -428,6 +466,10 @@ func buildPagesProjectView(project *model.PagesProject) (*PagesProjectView, erro
Enabled: project.Enabled,
SPAFallbackEnabled: project.SPAFallbackEnabled,
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
APIProxyEnabled: project.APIProxyEnabled,
APIProxyPath: project.APIProxyPath,
APIProxyPass: project.APIProxyPass,
APIProxyRewrite: project.APIProxyRewrite,
ActiveDeploymentID: project.ActiveDeploymentID,
CreatedAt: project.CreatedAt,
UpdatedAt: project.UpdatedAt,
+54
View File
@@ -271,3 +271,57 @@ func TestUploadPagesDeploymentWithTopLevelFolder(t *testing.T) {
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))
}
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 {
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 {
@@ -450,7 +492,7 @@ func renderHTTPSPagesServer(serverNames string, siteName string, certificateID u
h3Listen = " listen 443 quic;\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 {
@@ -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"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
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"`
}
@@ -431,6 +431,10 @@ function PagesProjectEditModal({
const [description, setDescription] = useState(project.description || '');
const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(project.spa_fallback_enabled);
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(() => {
setName(project.name);
@@ -438,6 +442,10 @@ function PagesProjectEditModal({
setDescription(project.description || '');
setSpaFallbackEnabled(project.spa_fallback_enabled);
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]);
const updateMutation = useMutation({
@@ -449,6 +457,10 @@ function PagesProjectEditModal({
enabled: project.enabled,
spa_fallback_enabled: spaFallbackEnabled,
spa_fallback_path: spaFallbackPath,
api_proxy_enabled: apiProxyEnabled,
api_proxy_path: apiProxyPath,
api_proxy_pass: apiProxyPass,
api_proxy_rewrite: apiProxyRewrite,
}),
onSuccess: () => {
onClose();
@@ -527,6 +539,48 @@ function PagesProjectEditModal({
onChange={(event) => setSpaFallbackPath(event.target.value)}
/>
</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 ? (
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
{updateMutation.error.message}
@@ -573,6 +627,10 @@ function PagesProjectCreateModal({
enabled: true,
spa_fallback_enabled: spaFallbackEnabled,
spa_fallback_path: spaFallbackPath,
api_proxy_enabled: false,
api_proxy_path: '',
api_proxy_pass: '',
api_proxy_rewrite: '',
}),
onSuccess: () => {
resetForm();
@@ -20,6 +20,10 @@ export interface PagesProject {
enabled: boolean;
spa_fallback_enabled: boolean;
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?: PagesDeployment | null;
deployment_count: number;
@@ -34,4 +38,8 @@ export interface PagesProjectPayload {
enabled: boolean;
spa_fallback_enabled: boolean;
spa_fallback_path: string;
api_proxy_enabled: boolean;
api_proxy_path: string;
api_proxy_pass: string;
api_proxy_rewrite: string;
}