mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +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),
|
||||
migration202606030002(backend, ctx),
|
||||
migration202606030003(backend, ctx),
|
||||
migration202606030004(backend, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user