From afd891f0f6335c678f7dde51d9b3b596788aa7ee Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 20 Mar 2026 20:01:42 +0800 Subject: [PATCH] =?UTF-8?q?[=E5=8A=9F=E8=83=BD]=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=BA=90=E7=AB=99=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E5=8C=85=E6=8B=AC=E6=BA=90=E7=AB=99=E7=9A=84=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E3=80=81=E6=9B=B4=E6=96=B0=E3=80=81=E5=88=A0=E9=99=A4=E5=8F=8A?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design.md | 5 + docs/development-guidelines.md | 3 + openflare_server/controller/origin.go | 128 ++++++++ .../model/database_schema_version.go | 2 +- openflare_server/model/main.go | 126 +++++++- openflare_server/model/main_test.go | 55 ++++ openflare_server/model/origin.go | 56 ++++ openflare_server/model/proxy_route.go | 7 + openflare_server/router/api-router.go | 9 + openflare_server/service/origin.go | 241 +++++++++++++++ openflare_server/service/origin_helpers.go | 192 ++++++++++++ openflare_server/service/origin_test.go | 105 +++++++ openflare_server/service/proxy_route.go | 85 +++++- .../app/(dashboard)/origin/detail/page.tsx | 11 + .../web/app/(dashboard)/origin/page.tsx | 5 + .../components/layout/dashboard-sidebar.tsx | 7 + .../web/features/origins/api/origins.ts | 35 +++ .../origins/components/origin-detail-page.tsx | 221 ++++++++++++++ .../components/origin-editor-modal.tsx | 171 +++++++++++ .../origins/components/origins-page.tsx | 192 ++++++++++++ .../web/features/origins/types.ts | 27 ++ .../components/proxy-routes-page.tsx | 282 ++++++++++++++++-- .../web/features/proxy-routes/types.ts | 64 ++-- .../web/lib/constants/navigation.ts | 97 +++--- .../web/tests/unit/proxy-routes-page.test.tsx | 24 ++ openflare_server/web/types/navigation.ts | 1 + 26 files changed, 2046 insertions(+), 105 deletions(-) create mode 100644 openflare_server/controller/origin.go create mode 100644 openflare_server/model/origin.go create mode 100644 openflare_server/service/origin.go create mode 100644 openflare_server/service/origin_helpers.go create mode 100644 openflare_server/service/origin_test.go create mode 100644 openflare_server/web/app/(dashboard)/origin/detail/page.tsx create mode 100644 openflare_server/web/app/(dashboard)/origin/page.tsx create mode 100644 openflare_server/web/features/origins/api/origins.ts create mode 100644 openflare_server/web/features/origins/components/origin-detail-page.tsx create mode 100644 openflare_server/web/features/origins/components/origin-editor-modal.tsx create mode 100644 openflare_server/web/features/origins/components/origins-page.tsx create mode 100644 openflare_server/web/features/origins/types.ts diff --git a/docs/design.md b/docs/design.md index d19f2d22..9e1d8c0b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -9,6 +9,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组 当前稳定能力包括: * 反代规则管理 +* 源站管理与复用 * 配置预览、发布、激活与回滚 * Agent 注册、心跳、同步、应用结果上报 * OpenResty 主配置模板、性能参数与缓存参数托管 @@ -40,6 +41,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组 边界补充: * OpenResty 代理缓存只作为当前反代链路优化能力存在,不扩展为独立缓存产品 +* 源站对象只用于沉淀可复用的回源地址,不扩展为带健康检查、权重、策略编排的 `origin_pool` * 主配置文件由 Server 统一渲染并由 Agent 受控写入,不支持节点侧手工编辑后回传合并 * 节点观测聚焦运营与运维排障所需的摘要、趋势和受控窗口数据,不提供长期日志托管 @@ -105,6 +107,7 @@ Origin 当前有效实体: * `proxy_routes` +* `origins` * `config_versions` * `nodes` * `node_system_profiles` @@ -120,6 +123,8 @@ Origin 稳定约束: * 一个域名只对应一条 `proxy_routes` 规则 +* `origins` 只保存源站地址、展示名与备注,不承载协议、端口、路径、权重或健康检查策略 +* `proxy_routes` 可选关联一个 `origins` 记录,用于复用源站地址;规则仍保存完整 `origin_url` 快照以参与渲染与版本快照 * `proxy_routes` 至少包含一个上游地址;为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡 * `proxy_routes` 上游统一渲染为带 keepalive 的 named `upstream`;单上游可附带 base path 或 query 并在 `proxy_pass` 中追加,多上游仍限定为纯 `scheme://host[:port]` * `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名 diff --git a/docs/development-guidelines.md b/docs/development-guidelines.md index 1f6fba09..615726df 100644 --- a/docs/development-guidelines.md +++ b/docs/development-guidelines.md @@ -99,6 +99,7 @@ 当前有效实体: * `proxy_routes` +* `origins` * `config_versions` * `nodes` * `node_system_profiles` @@ -115,7 +116,9 @@ 通用约束: * 不新增平台化对象,除非设计文档明确要求 +* `origins` 仅作为可复用源站地址目录,字段保持轻量;协议、端口、路径与查询参数继续归属具体 `proxy_routes` * `proxy_routes` 维持一条域名对应一条规则;规则内允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool` +* `proxy_routes` 如关联 `origins`,必须同时保存可直接渲染的 `origin_url`;源站地址变更时,由 service 负责同步更新引用该源站的规则快照 * `proxy_routes` 的上游统一使用 named `upstream` + keepalive;单上游如带 base path 或 query,应在 `proxy_pass` 上补回 URI,多上游仅允许纯 `scheme://host[:port]` * `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象 * `config_versions` 必须保存完整快照与渲染结果 diff --git a/openflare_server/controller/origin.go b/openflare_server/controller/origin.go new file mode 100644 index 00000000..16b57a93 --- /dev/null +++ b/openflare_server/controller/origin.go @@ -0,0 +1,128 @@ +package controller + +import ( + "encoding/json" + "net/http" + "openflare/service" + "strconv" + + "github.com/gin-gonic/gin" +) + +func GetOrigins(c *gin.Context) { + origins, err := service.ListOrigins() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": origins, + }) +} + +func GetOrigin(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + origin, err := service.GetOriginDetail(uint(id)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": origin, + }) +} + +func CreateOrigin(c *gin.Context) { + var input service.OriginInput + if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + origin, err := service.CreateOrigin(input) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": origin, + }) +} + +func UpdateOrigin(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + var input service.OriginInput + if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + origin, err := service.UpdateOrigin(uint(id), input) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": origin, + }) +} + +func DeleteOrigin(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if err = service.DeleteOrigin(uint(id)); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} diff --git a/openflare_server/model/database_schema_version.go b/openflare_server/model/database_schema_version.go index 62876a94..0eaeef9e 100644 --- a/openflare_server/model/database_schema_version.go +++ b/openflare_server/model/database_schema_version.go @@ -4,7 +4,7 @@ import "time" const ( legacyDatabaseSchemaVersion = 1 - currentDatabaseSchemaVersion = 3 + currentDatabaseSchemaVersion = 4 databaseSchemaVersionRowID = 1 ) diff --git a/openflare_server/model/main.go b/openflare_server/model/main.go index 60bf8306..bbefea26 100644 --- a/openflare_server/model/main.go +++ b/openflare_server/model/main.go @@ -9,10 +9,13 @@ import ( "gorm.io/gorm" "gorm.io/gorm/schema" "log/slog" + "net" + "net/url" "openflare/common" "openflare/utils/security" "os" "reflect" + "strings" "sync" ) @@ -36,6 +39,7 @@ func registeredModels() []any { &File{}, &User{}, &Option{}, + &Origin{}, &ProxyRoute{}, &ConfigVersion{}, &Node{}, @@ -307,6 +311,19 @@ func validateDatabaseSchemaV3(db *gorm.DB, backend string) error { return nil } +func validateDatabaseSchemaV4(db *gorm.DB, backend string) error { + if err := validateDatabaseSchemaV3(db, backend); err != nil { + return err + } + if !db.Migrator().HasTable(&Origin{}) { + return fmt.Errorf("table origins is missing") + } + if !db.Migrator().HasColumn(&ProxyRoute{}, "origin_id") { + return fmt.Errorf("column proxy_routes.origin_id is missing") + } + return nil +} + func renameLegacyObservabilityShardTables(db *gorm.DB) error { for _, baseTable := range shardedObservabilityBaseTables() { for _, table := range observabilityShardTables(baseTable) { @@ -549,6 +566,91 @@ func migrateObservabilityShardsToID(db *gorm.DB, backend string) error { return dropLegacyObservabilityShardTables(db) } +func normalizeOriginAddressForMigration(raw string) string { + return strings.ToLower(strings.TrimSpace(raw)) +} + +func extractOriginAddressForMigration(rawURL string) string { + parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL)) + if err != nil { + return "" + } + return normalizeOriginAddressForMigration(parsed.Hostname()) +} + +func backfillOriginsFromProxyRoutes(db *gorm.DB) error { + if db == nil { + return fmt.Errorf("database handle is nil") + } + if !db.Migrator().HasTable(&Origin{}) || !db.Migrator().HasTable(&ProxyRoute{}) { + return nil + } + + var routes []ProxyRoute + if err := db.Order("id asc").Find(&routes).Error; err != nil { + return fmt.Errorf("list proxy routes for origin backfill failed: %w", err) + } + + type originSeed struct { + ID uint + Address string + } + + originByAddress := make(map[string]originSeed) + var origins []Origin + if err := db.Order("id asc").Find(&origins).Error; err != nil { + return fmt.Errorf("list origins for backfill failed: %w", err) + } + for _, origin := range origins { + address := normalizeOriginAddressForMigration(origin.Address) + if address == "" { + continue + } + originByAddress[address] = originSeed{ID: origin.ID, Address: address} + } + + for _, route := range routes { + address := extractOriginAddressForMigration(route.OriginURL) + if address == "" { + continue + } + origin, ok := originByAddress[address] + if !ok { + name := address + if ip := net.ParseIP(address); ip != nil { + name = ip.String() + } + record := Origin{ + Name: name, + Address: address, + Remark: "", + } + if err := db.Create(&record).Error; err != nil { + return fmt.Errorf("create origin for address %s failed: %w", address, err) + } + origin = originSeed{ID: record.ID, Address: address} + originByAddress[address] = origin + } + if route.OriginID != nil && *route.OriginID == origin.ID { + continue + } + if err := db.Model(&ProxyRoute{}). + Where("id = ?", route.ID). + Update("origin_id", origin.ID).Error; err != nil { + return fmt.Errorf("backfill proxy route %d origin_id failed: %w", route.ID, err) + } + } + + return nil +} + +func migrateOriginsSchema(db *gorm.DB, backend string) error { + if err := applyCurrentSchema(db, backend); err != nil { + return err + } + return backfillOriginsFromProxyRoutes(db) +} + func databaseSchemaMigrations() []databaseSchemaMigration { return []databaseSchemaMigration{ { @@ -563,6 +665,12 @@ func databaseSchemaMigrations() []databaseSchemaMigration { migrate: migrateObservabilityShardsToID, validate: validateDatabaseSchemaV3, }, + { + fromVersion: 3, + toVersion: 4, + migrate: migrateOriginsSchema, + validate: validateDatabaseSchemaV4, + }, } } @@ -575,6 +683,19 @@ func databaseSchemaMigrationMap() map[int]databaseSchemaMigration { } func runDatabaseSchemaMigration(db *gorm.DB, backend string, migration databaseSchemaMigration) error { + if backend == "sqlite" { + if err := migration.migrate(db, backend); err != nil { + return fmt.Errorf("migrate database schema from v%d to v%d failed: %w", migration.fromVersion, migration.toVersion, err) + } + if err := migration.validate(db, backend); err != nil { + return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err) + } + if err := saveDatabaseSchemaVersion(db, migration.toVersion); err != nil { + return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err) + } + return nil + } + return db.Transaction(func(tx *gorm.DB) error { if err := migration.migrate(tx, backend); err != nil { return fmt.Errorf("migrate database schema from v%d to v%d failed: %w", migration.fromVersion, migration.toVersion, err) @@ -614,10 +735,13 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error { if err := applyCurrentSchema(db, backend); err != nil { return err } + if err := backfillOriginsFromProxyRoutes(db); err != nil { + return err + } if err := migrateSQLiteDataIfNeeded(db, backend); err != nil { return err } - if err := validateDatabaseSchemaV3(db, backend); err != nil { + if err := validateDatabaseSchemaV4(db, backend); err != nil { return err } return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion) diff --git a/openflare_server/model/main_test.go b/openflare_server/model/main_test.go index 088bc210..d1a0360e 100644 --- a/openflare_server/model/main_test.go +++ b/openflare_server/model/main_test.go @@ -409,6 +409,61 @@ func TestEnsureDatabaseSchemaUpToDateMigratesObservabilityShardsToID(t *testing. } } +func TestMigrateOriginsSchemaBackfillsOrigins(t *testing.T) { + db := openBareTestSQLiteDB(t, "legacy-origins.db") + if err := registerSharding(db, "sqlite"); err != nil { + t.Fatalf("register sharding: %v", err) + } + if err := applyCurrentSchema(db, "sqlite"); err != nil { + t.Fatalf("applyCurrentSchema: %v", err) + } + now := time.Now().UTC() + route := &ProxyRoute{ + Domain: "app.example.com", + OriginURL: "https://origin-a.internal:8443/api", + Upstreams: `["https://origin-a.internal:8443/api"]`, + Enabled: true, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.Create(route).Error; err != nil { + t.Fatalf("seed proxy route: %v", err) + } + if err := db.Exec(`DELETE FROM origins`).Error; err != nil { + t.Fatalf("clear origins: %v", err) + } + if err := db.Model(&ProxyRoute{}).Where("id = ?", route.ID).Update("origin_id", nil).Error; err != nil { + t.Fatalf("clear route origin_id: %v", err) + } + + if err := backfillOriginsFromProxyRoutes(db); err != nil { + t.Fatalf("backfillOriginsFromProxyRoutes: %v", err) + } + + if !db.Migrator().HasTable(&Origin{}) { + t.Fatal("expected origins table to exist") + } + if !db.Migrator().HasColumn(&ProxyRoute{}, "origin_id") { + t.Fatal("expected proxy_routes.origin_id column to exist") + } + + reloadedRoute := &ProxyRoute{} + if err := db.First(reloadedRoute, route.ID).Error; err != nil { + t.Fatalf("query proxy route: %v", err) + } + if reloadedRoute.OriginID == nil || *reloadedRoute.OriginID == 0 { + t.Fatal("expected migrated route to be linked to a backfilled origin") + } + + origin := &Origin{} + if err := db.First(origin, *reloadedRoute.OriginID).Error; err != nil { + t.Fatalf("query origin: %v", err) + } + if origin.Address != "origin-a.internal" { + t.Fatalf("unexpected backfilled origin address: %s", origin.Address) + } +} + func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *testing.T) { db := openBareTestSQLiteDB(t, "failed-validation.db") diff --git a/openflare_server/model/origin.go b/openflare_server/model/origin.go new file mode 100644 index 00000000..43d488b8 --- /dev/null +++ b/openflare_server/model/origin.go @@ -0,0 +1,56 @@ +package model + +import "time" + +type Origin struct { + ID uint `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"size:255;not null"` + Address string `json:"address" gorm:"uniqueIndex;size:255;not null"` + Remark string `json:"remark" gorm:"size:255"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OriginRouteCount struct { + OriginID uint `json:"origin_id"` + RouteCount int64 `json:"route_count"` +} + +func ListOrigins() (origins []*Origin, err error) { + err = DB.Order("id desc").Find(&origins).Error + return origins, err +} + +func GetOriginByID(id uint) (*Origin, error) { + origin := &Origin{} + err := DB.First(origin, id).Error + return origin, err +} + +func GetOriginByAddress(address string) (*Origin, error) { + origin := &Origin{} + err := DB.Where("address = ?", address).First(origin).Error + return origin, err +} + +func ListOriginRouteCounts() ([]OriginRouteCount, error) { + result := make([]OriginRouteCount, 0) + err := DB.Model(&ProxyRoute{}). + Select("origin_id, COUNT(*) AS route_count"). + Where("origin_id IS NOT NULL"). + Group("origin_id"). + Scan(&result).Error + return result, err +} + +func (origin *Origin) Insert() error { + return DB.Create(origin).Error +} + +func (origin *Origin) Update() error { + return DB.Save(origin).Error +} + +func (origin *Origin) Delete() error { + return DB.Delete(origin).Error +} diff --git a/openflare_server/model/proxy_route.go b/openflare_server/model/proxy_route.go index dd75f983..40bf8110 100644 --- a/openflare_server/model/proxy_route.go +++ b/openflare_server/model/proxy_route.go @@ -5,6 +5,7 @@ import "time" type ProxyRoute struct { ID uint `json:"id" gorm:"primaryKey"` Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"` + OriginID *uint `json:"origin_id" gorm:"index"` OriginURL string `json:"origin_url" gorm:"size:2048;not null"` OriginHost string `json:"origin_host" gorm:"size:255"` Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"` @@ -37,6 +38,11 @@ func GetProxyRouteByID(id uint) (*ProxyRoute, error) { return route, err } +func ListProxyRoutesByOriginID(originID uint) (routes []*ProxyRoute, err error) { + err = DB.Where("origin_id = ?", originID).Order("id desc").Find(&routes).Error + return routes, err +} + func (route *ProxyRoute) Insert() error { return DB.Create(route).Error } @@ -44,6 +50,7 @@ func (route *ProxyRoute) Insert() error { func (route *ProxyRoute) Update() error { return DB.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(map[string]any{ "domain": route.Domain, + "origin_id": route.OriginID, "origin_url": route.OriginURL, "origin_host": route.OriginHost, "upstreams": route.Upstreams, diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index 2d903c2f..c0105ab2 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -82,6 +82,15 @@ func SetApiRouter(router *gin.Engine) { proxyRoute.POST("/:id/update", controller.UpdateProxyRoute) proxyRoute.POST("/:id/delete", controller.DeleteProxyRoute) } + originRoute := apiRouter.Group("/origins") + originRoute.Use(middleware.AdminAuth()) + { + originRoute.GET("/", controller.GetOrigins) + originRoute.GET("/:id", controller.GetOrigin) + originRoute.POST("/", controller.CreateOrigin) + originRoute.POST("/:id/update", controller.UpdateOrigin) + originRoute.POST("/:id/delete", controller.DeleteOrigin) + } managedDomainRoute := apiRouter.Group("/managed-domains") managedDomainRoute.Use(middleware.AdminAuth()) { diff --git a/openflare_server/service/origin.go b/openflare_server/service/origin.go new file mode 100644 index 00000000..ed95e0e4 --- /dev/null +++ b/openflare_server/service/origin.go @@ -0,0 +1,241 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "openflare/model" + "sort" + "strings" + "time" + + "gorm.io/gorm" +) + +type OriginInput struct { + Name string `json:"name"` + Address string `json:"address"` + Remark string `json:"remark"` +} + +type OriginRouteSummary struct { + ID uint `json:"id"` + Domain string `json:"domain"` + OriginURL string `json:"origin_url"` + Enabled bool `json:"enabled"` + UpdatedAt string `json:"updated_at"` +} + +type OriginView struct { + ID uint `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Remark string `json:"remark"` + RouteCount int64 `json:"route_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OriginDetailView struct { + OriginView + Routes []OriginRouteSummary `json:"routes"` +} + +func ListOrigins() ([]OriginView, error) { + origins, err := model.ListOrigins() + if err != nil { + return nil, err + } + return buildOriginViews(origins) +} + +func GetOriginDetail(id uint) (*OriginDetailView, error) { + origin, err := model.GetOriginByID(id) + if err != nil { + return nil, err + } + views, err := buildOriginViews([]*model.Origin{origin}) + if err != nil { + return nil, err + } + routes, err := model.ListProxyRoutesByOriginID(id) + if err != nil { + return nil, err + } + items := make([]OriginRouteSummary, 0, len(routes)) + for _, route := range routes { + items = append(items, OriginRouteSummary{ + ID: route.ID, + Domain: route.Domain, + OriginURL: route.OriginURL, + Enabled: route.Enabled, + UpdatedAt: route.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) + } + sort.Slice(items, func(i int, j int) bool { + return items[i].Domain < items[j].Domain + }) + detail := &OriginDetailView{ + OriginView: views[0], + Routes: items, + } + return detail, nil +} + +func CreateOrigin(input OriginInput) (*model.Origin, error) { + origin, err := buildOrigin(nil, input) + if err != nil { + return nil, err + } + if err = origin.Insert(); err != nil { + if isUniqueConstraintError(err) { + return nil, errors.New("源站地址已存在") + } + return nil, err + } + return origin, nil +} + +func UpdateOrigin(id uint, input OriginInput) (*model.Origin, error) { + origin, err := model.GetOriginByID(id) + if err != nil { + return nil, err + } + previousAddress := origin.Address + nextOrigin, err := buildOrigin(origin, input) + if err != nil { + return nil, err + } + err = model.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Save(nextOrigin).Error; err != nil { + if isUniqueConstraintError(err) { + return errors.New("源站地址已存在") + } + return err + } + if previousAddress == nextOrigin.Address { + return nil + } + return updateRoutesForOriginAddress(tx, nextOrigin.ID, nextOrigin.Address) + }) + if err != nil { + return nil, err + } + return nextOrigin, nil +} + +func DeleteOrigin(id uint) error { + routes, err := model.ListProxyRoutesByOriginID(id) + if err != nil { + return err + } + if len(routes) > 0 { + return errors.New("该源站仍被规则引用,无法删除") + } + origin, err := model.GetOriginByID(id) + if err != nil { + return err + } + return origin.Delete() +} + +func buildOrigin(existing *model.Origin, input OriginInput) (*model.Origin, error) { + address := normalizeOriginAddress(input.Address) + if err := validateOriginAddress(address); err != nil { + return nil, err + } + if existing == nil { + existing = &model.Origin{} + } + existing.Address = address + existing.Name = normalizeOriginName(input.Name, address) + existing.Remark = strings.TrimSpace(input.Remark) + return existing, nil +} + +func getOrCreateOriginByAddress(address string) (*model.Origin, error) { + normalizedAddress := normalizeOriginAddress(address) + if err := validateOriginAddress(normalizedAddress); err != nil { + return nil, err + } + existing, err := model.GetOriginByAddress(normalizedAddress) + if err == nil { + return existing, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + origin := &model.Origin{ + Name: normalizedAddress, + Address: normalizedAddress, + Remark: "", + } + if err := origin.Insert(); err != nil { + if isUniqueConstraintError(err) { + return model.GetOriginByAddress(normalizedAddress) + } + return nil, err + } + return origin, nil +} + +func updateRoutesForOriginAddress(tx *gorm.DB, originID uint, address string) error { + var routes []*model.ProxyRoute + if err := tx.Where("origin_id = ?", originID).Order("id asc").Find(&routes).Error; err != nil { + return fmt.Errorf("query routes for origin update failed: %w", err) + } + for _, route := range routes { + rewrittenOriginURL, err := rewriteOriginURLAddress(route.OriginURL, address) + if err != nil { + return fmt.Errorf("rewrite route %d origin failed: %w", route.ID, err) + } + upstreams := make([]string, 0) + if strings.TrimSpace(route.Upstreams) != "" { + if err := json.Unmarshal([]byte(route.Upstreams), &upstreams); err != nil { + return fmt.Errorf("decode route %d upstreams failed: %w", route.ID, err) + } + } + if len(upstreams) == 0 { + upstreams = append(upstreams, rewrittenOriginURL) + } else { + upstreams[0] = rewrittenOriginURL + } + upstreamsJSON, err := json.Marshal(upstreams) + if err != nil { + return fmt.Errorf("encode route %d upstreams failed: %w", route.ID, err) + } + if err := tx.Model(&model.ProxyRoute{}). + Where("id = ?", route.ID). + Updates(map[string]any{ + "origin_url": rewrittenOriginURL, + "upstreams": string(upstreamsJSON), + }).Error; err != nil { + return fmt.Errorf("update route %d origin address failed: %w", route.ID, err) + } + } + return nil +} + +func buildOriginViews(origins []*model.Origin) ([]OriginView, error) { + countRows, err := model.ListOriginRouteCounts() + if err != nil { + return nil, err + } + countMap := make(map[uint]int64, len(countRows)) + for _, row := range countRows { + countMap[row.OriginID] = row.RouteCount + } + views := make([]OriginView, 0, len(origins)) + for _, origin := range origins { + views = append(views, OriginView{ + ID: origin.ID, + Name: origin.Name, + Address: origin.Address, + Remark: origin.Remark, + RouteCount: countMap[origin.ID], + CreatedAt: origin.CreatedAt, + UpdatedAt: origin.UpdatedAt, + }) + } + return views, nil +} diff --git a/openflare_server/service/origin_helpers.go b/openflare_server/service/origin_helpers.go new file mode 100644 index 00000000..0deeee49 --- /dev/null +++ b/openflare_server/service/origin_helpers.go @@ -0,0 +1,192 @@ +package service + +import ( + "errors" + "fmt" + "net" + "net/url" + "strconv" + "strings" + "unicode" +) + +func normalizeOriginAddress(raw string) string { + return strings.ToLower(strings.TrimSpace(raw)) +} + +func validateOriginAddress(address string) error { + if address == "" { + return errors.New("源站地址不能为空") + } + if strings.Contains(address, "://") || strings.ContainsAny(address, "/?#") { + return errors.New("源站地址格式不合法") + } + if strings.HasPrefix(address, "[") || strings.HasSuffix(address, "]") { + return errors.New("源站地址无需包含 IPv6 方括号") + } + if ip := net.ParseIP(address); ip != nil { + return nil + } + if len(address) > 253 { + return errors.New("源站地址格式不合法") + } + labels := strings.Split(address, ".") + for _, label := range labels { + if len(label) == 0 || len(label) > 63 { + return errors.New("源站地址格式不合法") + } + if label[0] == '-' || label[len(label)-1] == '-' { + return errors.New("源站地址格式不合法") + } + for _, r := range label { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' { + continue + } + return errors.New("源站地址格式不合法") + } + } + return nil +} + +func normalizeOriginName(name string, address string) string { + normalized := strings.TrimSpace(name) + if normalized != "" { + return normalized + } + return address +} + +func normalizeOriginPort(raw string) (string, error) { + port := strings.TrimSpace(raw) + if port == "" { + return "", errors.New("端口不能为空") + } + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > 65535 { + return "", errors.New("端口格式不合法") + } + return strconv.Itoa(value), nil +} + +func normalizeOriginScheme(raw string) (string, error) { + scheme := strings.ToLower(strings.TrimSpace(raw)) + switch scheme { + case "http", "https": + return scheme, nil + default: + return "", errors.New("源站协议仅支持 http 或 https") + } +} + +func normalizeOriginURI(raw string) (string, error) { + uri := strings.TrimSpace(raw) + if uri == "" { + return "", nil + } + if strings.Contains(uri, "://") { + return "", errors.New("源站路径不能包含协议") + } + if !strings.HasPrefix(uri, "/") && !strings.HasPrefix(uri, "?") { + return "", errors.New("源站路径需以 / 或 ? 开头") + } + return uri, nil +} + +func formatOriginHost(address string, port string) string { + if ip := net.ParseIP(address); ip != nil && strings.Contains(address, ":") { + return net.JoinHostPort(address, port) + } + return net.JoinHostPort(address, port) +} + +func buildOriginURLFromParts( + scheme string, + address string, + port string, + uri string, +) (string, error) { + normalizedScheme, err := normalizeOriginScheme(scheme) + if err != nil { + return "", err + } + normalizedAddress := normalizeOriginAddress(address) + if err := validateOriginAddress(normalizedAddress); err != nil { + return "", err + } + normalizedPort, err := normalizeOriginPort(port) + if err != nil { + return "", err + } + normalizedURI, err := normalizeOriginURI(uri) + if err != nil { + return "", err + } + + parsed := &url.URL{ + Scheme: normalizedScheme, + Host: formatOriginHost(normalizedAddress, normalizedPort), + } + if normalizedURI != "" { + if strings.HasPrefix(normalizedURI, "?") { + parsed.RawQuery = strings.TrimPrefix(normalizedURI, "?") + } else { + pathQuery := strings.SplitN(normalizedURI, "?", 2) + parsed.Path = pathQuery[0] + if len(pathQuery) > 1 { + parsed.RawQuery = pathQuery[1] + } + } + } + return parsed.String(), nil +} + +func extractOriginAddress(rawURL string) (string, error) { + parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL)) + if err != nil { + return "", fmt.Errorf("源站地址格式不合法: %w", err) + } + address := normalizeOriginAddress(parsed.Hostname()) + if err := validateOriginAddress(address); err != nil { + return "", err + } + return address, nil +} + +func rewriteOriginURLAddress(rawURL string, newAddress string) (string, error) { + parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL)) + if err != nil { + return "", fmt.Errorf("源站地址格式不合法: %w", err) + } + address := normalizeOriginAddress(newAddress) + if err := validateOriginAddress(address); err != nil { + return "", err + } + port := parsed.Port() + if port == "" { + return "", errors.New("源站地址缺少端口") + } + parsed.Host = formatOriginHost(address, port) + return parsed.String(), nil +} + +func splitOriginURL(rawURL string) (scheme string, address string, port string, uri string, err error) { + parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL)) + if err != nil { + return "", "", "", "", err + } + scheme = parsed.Scheme + address = normalizeOriginAddress(parsed.Hostname()) + port = parsed.Port() + uri = parsed.EscapedPath() + if uri == "" { + uri = parsed.Path + } + if parsed.RawQuery != "" { + if uri == "" { + uri = "?" + parsed.RawQuery + } else { + uri = uri + "?" + parsed.RawQuery + } + } + return scheme, address, port, uri, nil +} diff --git a/openflare_server/service/origin_test.go b/openflare_server/service/origin_test.go new file mode 100644 index 00000000..6f090a41 --- /dev/null +++ b/openflare_server/service/origin_test.go @@ -0,0 +1,105 @@ +package service + +import ( + "testing" + + "openflare/model" +) + +func TestCreateProxyRouteStructuredOriginAutoCreatesOrigin(t *testing.T) { + setupServiceTestDB(t) + + route, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "app.example.com", + OriginScheme: "https", + OriginAddress: "origin.internal", + OriginPort: "8443", + Enabled: true, + }) + if err != nil { + t.Fatalf("CreateProxyRoute failed: %v", err) + } + if route.OriginID == nil || *route.OriginID == 0 { + t.Fatal("expected route to be linked with an auto-created origin") + } + if route.OriginURL != "https://origin.internal:8443" { + t.Fatalf("unexpected route origin url: %s", route.OriginURL) + } + + origin, err := model.GetOriginByID(*route.OriginID) + if err != nil { + t.Fatalf("GetOriginByID failed: %v", err) + } + if origin.Address != "origin.internal" { + t.Fatalf("unexpected origin address: %s", origin.Address) + } +} + +func TestUpdateOriginRewritesLinkedRouteOriginURL(t *testing.T) { + setupServiceTestDB(t) + + origin, err := CreateOrigin(OriginInput{ + Name: "primary-origin", + Address: "origin-a.internal", + }) + if err != nil { + t.Fatalf("CreateOrigin failed: %v", err) + } + route, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "app.example.com", + OriginID: &origin.ID, + OriginScheme: "https", + OriginPort: "8443", + OriginURI: "/api", + Enabled: true, + }) + if err != nil { + t.Fatalf("CreateProxyRoute failed: %v", err) + } + + updatedOrigin, err := UpdateOrigin(origin.ID, OriginInput{ + Name: origin.Name, + Address: "origin-c.internal", + }) + if err != nil { + t.Fatalf("UpdateOrigin failed: %v", err) + } + if updatedOrigin.Address != "origin-c.internal" { + t.Fatalf("unexpected updated origin address: %s", updatedOrigin.Address) + } + + reloadedRoute, err := model.GetProxyRouteByID(route.ID) + if err != nil { + t.Fatalf("GetProxyRouteByID failed: %v", err) + } + if reloadedRoute.OriginURL != "https://origin-c.internal:8443/api" { + t.Fatalf("expected route origin url to be rewritten, got %s", reloadedRoute.OriginURL) + } + if reloadedRoute.Upstreams == "" || reloadedRoute.Upstreams == "[]" { + t.Fatalf("expected route upstreams to be preserved, got %s", reloadedRoute.Upstreams) + } +} + +func TestDeleteOriginRejectsReferencedOrigin(t *testing.T) { + setupServiceTestDB(t) + + origin, err := CreateOrigin(OriginInput{ + Address: "origin-a.internal", + }) + if err != nil { + t.Fatalf("CreateOrigin failed: %v", err) + } + if _, err = CreateProxyRoute(ProxyRouteInput{ + Domain: "app.example.com", + OriginID: &origin.ID, + OriginScheme: "https", + OriginPort: "443", + Enabled: true, + }); err != nil { + t.Fatalf("CreateProxyRoute failed: %v", err) + } + + if err = DeleteOrigin(origin.ID); err == nil { + t.Fatal("expected referenced origin deletion to fail") + } +} diff --git a/openflare_server/service/proxy_route.go b/openflare_server/service/proxy_route.go index 11cf0f32..b690265a 100644 --- a/openflare_server/service/proxy_route.go +++ b/openflare_server/service/proxy_route.go @@ -7,6 +7,8 @@ import ( "openflare/model" "regexp" "strings" + + "gorm.io/gorm" ) var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) @@ -25,7 +27,12 @@ type ProxyRouteCustomHeaderInput struct { type ProxyRouteInput struct { Domain string `json:"domain"` + OriginID *uint `json:"origin_id"` OriginURL string `json:"origin_url"` + OriginScheme string `json:"origin_scheme"` + OriginAddress string `json:"origin_address"` + OriginPort string `json:"origin_port"` + OriginURI string `json:"origin_uri"` OriginHost string `json:"origin_host"` Upstreams []string `json:"upstreams"` Enabled bool `json:"enabled"` @@ -85,7 +92,10 @@ func DeleteProxyRoute(id uint) error { func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.ProxyRoute, error) { domain := strings.ToLower(strings.TrimSpace(input.Domain)) - originURL := strings.TrimSpace(input.OriginURL) + originURL, originID, err := resolveProxyRoutePrimaryOrigin(input) + if err != nil { + return nil, err + } originHost := strings.TrimSpace(input.OriginHost) remark := strings.TrimSpace(input.Remark) upstreams, err := normalizeUpstreams(originURL, input.Upstreams) @@ -141,6 +151,7 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro route = &model.ProxyRoute{} } route.Domain = domain + route.OriginID = originID route.OriginURL = upstreams[0] route.OriginHost = originHost route.Upstreams = string(upstreamsJSON) @@ -156,6 +167,78 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro return route, nil } +func resolveProxyRoutePrimaryOrigin(input ProxyRouteInput) (string, *uint, error) { + if hasStructuredOriginInput(input) { + scheme, err := normalizeOriginScheme(input.OriginScheme) + if err != nil { + return "", nil, err + } + port, err := normalizeOriginPort(input.OriginPort) + if err != nil { + return "", nil, err + } + uri, err := normalizeOriginURI(input.OriginURI) + if err != nil { + return "", nil, err + } + if input.OriginID != nil && *input.OriginID != 0 { + origin, err := model.GetOriginByID(*input.OriginID) + if err != nil { + return "", nil, errors.New("所选源站不存在") + } + originURL, err := buildOriginURLFromParts( + scheme, + origin.Address, + port, + uri, + ) + if err != nil { + return "", nil, err + } + return originURL, &origin.ID, nil + } + + address := normalizeOriginAddress(input.OriginAddress) + if err := validateOriginAddress(address); err != nil { + return "", nil, err + } + originURL, err := buildOriginURLFromParts(scheme, address, port, uri) + if err != nil { + return "", nil, err + } + origin, err := getOrCreateOriginByAddress(address) + if err != nil { + return "", nil, err + } + return originURL, &origin.ID, nil + } + + originURL := strings.TrimSpace(input.OriginURL) + if originURL == "" { + return "", nil, errors.New("源站地址不能为空") + } + address, err := extractOriginAddress(originURL) + if err != nil { + return "", nil, err + } + origin, findErr := model.GetOriginByAddress(address) + if findErr == nil { + return originURL, &origin.ID, nil + } + if !errors.Is(findErr, gorm.ErrRecordNotFound) { + return "", nil, findErr + } + return originURL, nil, nil +} + +func hasStructuredOriginInput(input ProxyRouteInput) bool { + return (input.OriginID != nil && *input.OriginID != 0) || + strings.TrimSpace(input.OriginScheme) != "" || + strings.TrimSpace(input.OriginAddress) != "" || + strings.TrimSpace(input.OriginPort) != "" || + strings.TrimSpace(input.OriginURI) != "" +} + func normalizeCustomHeaders(headers []ProxyRouteCustomHeaderInput) ([]ProxyRouteCustomHeaderInput, error) { if len(headers) == 0 { return []ProxyRouteCustomHeaderInput{}, nil diff --git a/openflare_server/web/app/(dashboard)/origin/detail/page.tsx b/openflare_server/web/app/(dashboard)/origin/detail/page.tsx new file mode 100644 index 00000000..3aa9e57f --- /dev/null +++ b/openflare_server/web/app/(dashboard)/origin/detail/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { OriginDetailPage } from '@/features/origins/components/origin-detail-page'; + +export default function OriginDetailRoute() { + const searchParams = useSearchParams(); + + return ; +} diff --git a/openflare_server/web/app/(dashboard)/origin/page.tsx b/openflare_server/web/app/(dashboard)/origin/page.tsx new file mode 100644 index 00000000..f386243d --- /dev/null +++ b/openflare_server/web/app/(dashboard)/origin/page.tsx @@ -0,0 +1,5 @@ +import { OriginsPage } from '@/features/origins/components/origins-page'; + +export default function OriginRoute() { + return ; +} diff --git a/openflare_server/web/components/layout/dashboard-sidebar.tsx b/openflare_server/web/components/layout/dashboard-sidebar.tsx index 7f0af0e3..99209220 100644 --- a/openflare_server/web/components/layout/dashboard-sidebar.tsx +++ b/openflare_server/web/components/layout/dashboard-sidebar.tsx @@ -47,6 +47,13 @@ function SidebarIcon({ icon }: { icon: NavigationIconKey }) { ); + case 'origin': + return ( + + + + + ); case 'domain': return ( diff --git a/openflare_server/web/features/origins/api/origins.ts b/openflare_server/web/features/origins/api/origins.ts new file mode 100644 index 00000000..976048d9 --- /dev/null +++ b/openflare_server/web/features/origins/api/origins.ts @@ -0,0 +1,35 @@ +import { apiRequest } from '@/lib/api/client'; + +import type { + OriginDetail, + OriginItem, + OriginMutationPayload, +} from '@/features/origins/types'; + +export function getOrigins() { + return apiRequest('/origins/'); +} + +export function getOrigin(id: number) { + return apiRequest(`/origins/${id}`); +} + +export function createOrigin(payload: OriginMutationPayload) { + return apiRequest('/origins/', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function updateOrigin(id: number, payload: OriginMutationPayload) { + return apiRequest(`/origins/${id}/update`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function deleteOrigin(id: number) { + return apiRequest(`/origins/${id}/delete`, { + method: 'POST', + }); +} diff --git a/openflare_server/web/features/origins/components/origin-detail-page.tsx b/openflare_server/web/features/origins/components/origin-detail-page.tsx new file mode 100644 index 00000000..fbc35c9c --- /dev/null +++ b/openflare_server/web/features/origins/components/origin-detail-page.tsx @@ -0,0 +1,221 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { PageHeader } from '@/components/layout/page-header'; +import { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { deleteOrigin, getOrigin } from '@/features/origins/api/origins'; +import { OriginEditorModal } from '@/features/origins/components/origin-editor-modal'; +import { + DangerButton, + PrimaryButton, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime } from '@/lib/utils/date'; + +export function OriginDetailPage({ originId }: { originId: string }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [feedback, setFeedback] = useState<{ + tone: 'success' | 'danger'; + message: string; + } | null>(null); + const [isEditorOpen, setIsEditorOpen] = useState(false); + + const originQuery = useQuery({ + queryKey: ['origins', originId], + queryFn: () => getOrigin(Number(originId)), + enabled: originId !== '', + }); + + const deleteMutation = useMutation({ + mutationFn: deleteOrigin, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['origins'] }); + router.push('/origin'); + }, + onError: (error) => { + setFeedback({ + tone: 'danger', + message: + error instanceof Error ? error.message : '请求失败,请稍后重试。', + }); + }, + }); + + if (originQuery.isLoading) { + return ; + } + + if (originQuery.isError) { + return ( + + ); + } + + const origin = originQuery.data; + if (!origin) { + return ( + + ); + } + + const handleDelete = () => { + if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) { + return; + } + deleteMutation.mutate(origin.id); + }; + + return ( + <> +
+ + + 返回 + + setIsEditorOpen(true)} + > + 编辑源站 + + + 删除源站 + + + } + /> + + {feedback ? ( + + ) : null} + +
+ +

+ {origin.address} +

+
+ +
+ 0 ? 'success' : 'warning'} + /> +

+ 编辑地址后,绑定规则的主源站地址会一起更新。 +

+
+
+ +

+ {formatDateTime(origin.created_at)} +

+
+ +

+ {formatDateTime(origin.updated_at)} +

+
+
+ + +

+ {origin.remark || '暂无备注'} +

+
+ + + {origin.routes.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + {origin.routes.map((route) => ( + + + + + + + ))} + +
域名源站地址状态更新时间
+ {route.domain} + + {route.origin_url} + + + + {formatDateTime(route.updated_at)} +
+
+ )} +
+
+ + {isEditorOpen ? ( + setIsEditorOpen(false)} + onSaved={() => { + setFeedback({ tone: 'success', message: '源站已更新。' }); + void queryClient.invalidateQueries({ + queryKey: ['origins', origin.id], + }); + void queryClient.invalidateQueries({ queryKey: ['origins'] }); + }} + /> + ) : null} + + ); +} diff --git a/openflare_server/web/features/origins/components/origin-editor-modal.tsx b/openflare_server/web/features/origins/components/origin-editor-modal.tsx new file mode 100644 index 00000000..acfed78d --- /dev/null +++ b/openflare_server/web/features/origins/components/origin-editor-modal.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { AppModal } from '@/components/ui/app-modal'; +import { createOrigin, updateOrigin } from '@/features/origins/api/origins'; +import type { + OriginItem, + OriginMutationPayload, +} from '@/features/origins/types'; +import { + PrimaryButton, + ResourceField, + ResourceInput, + ResourceTextarea, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; + +const originSchema = z.object({ + name: z.string().max(255, '源站名不能超过 255 个字符'), + address: z + .string() + .trim() + .min(1, '请输入源站地址') + .refine( + (value) => !/[/?#]/.test(value) && !value.includes('://'), + '源站地址格式不合法', + ), + remark: z.string().max(255, '备注不能超过 255 个字符'), +}); + +type OriginFormValues = z.infer; + +function toPayload(values: OriginFormValues): OriginMutationPayload { + return { + name: values.name.trim(), + address: values.address.trim(), + remark: values.remark.trim(), + }; +} + +function toFormValues(origin?: OriginItem | null): OriginFormValues { + if (!origin) { + return { + name: '', + address: '', + remark: '', + }; + } + return { + name: origin.name, + address: origin.address, + remark: origin.remark || '', + }; +} + +export function OriginEditorModal({ + isOpen, + onClose, + origin, + onSaved, +}: { + isOpen: boolean; + onClose: () => void; + origin?: OriginItem | null; + onSaved?: (origin: OriginItem, mode: 'create' | 'update') => void; +}) { + const queryClient = useQueryClient(); + const form = useForm({ + resolver: zodResolver(originSchema), + defaultValues: toFormValues(origin), + }); + + useEffect(() => { + form.reset(toFormValues(origin)); + }, [form, origin, isOpen]); + + const mutation = useMutation({ + mutationFn: async (values: OriginFormValues) => { + const payload = toPayload(values); + return origin ? updateOrigin(origin.id, payload) : createOrigin(payload); + }, + onSuccess: async (savedOrigin) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['origins'] }), + queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }), + ]); + onSaved?.(savedOrigin, origin ? 'update' : 'create'); + onClose(); + }, + }); + + const handleSubmit = form.handleSubmit((values) => { + mutation.mutate(values); + }); + + return ( + + + 取消 + + + {mutation.isPending + ? '保存中...' + : origin + ? '保存修改' + : '新增源站'} + + + } + > +
+
+ + + + + + +
+ + + + + + {mutation.isError ? ( +

+ {mutation.error instanceof Error + ? mutation.error.message + : '请求失败,请稍后重试。'} +

+ ) : null} +
+
+ ); +} diff --git a/openflare_server/web/features/origins/components/origins-page.tsx b/openflare_server/web/features/origins/components/origins-page.tsx new file mode 100644 index 00000000..5892f93f --- /dev/null +++ b/openflare_server/web/features/origins/components/origins-page.tsx @@ -0,0 +1,192 @@ +'use client'; + +import Link from 'next/link'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { PageHeader } from '@/components/layout/page-header'; +import { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { deleteOrigin, getOrigins } from '@/features/origins/api/origins'; +import { OriginEditorModal } from '@/features/origins/components/origin-editor-modal'; +import type { OriginItem } from '@/features/origins/types'; +import { + DangerButton, + PrimaryButton, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime } from '@/lib/utils/date'; + +type FeedbackState = { + tone: 'success' | 'danger'; + message: string; +}; + +export function OriginsPage() { + const queryClient = useQueryClient(); + const [feedback, setFeedback] = useState(null); + const [editingOrigin, setEditingOrigin] = useState(null); + const [isEditorOpen, setIsEditorOpen] = useState(false); + + const originsQuery = useQuery({ + queryKey: ['origins'], + queryFn: getOrigins, + }); + + const deleteMutation = useMutation({ + mutationFn: deleteOrigin, + onSuccess: async () => { + setFeedback({ tone: 'success', message: '源站已删除。' }); + await queryClient.invalidateQueries({ queryKey: ['origins'] }); + }, + onError: (error) => { + setFeedback({ + tone: 'danger', + message: + error instanceof Error ? error.message : '请求失败,请稍后重试。', + }); + }, + }); + + const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]); + + const handleDelete = (origin: OriginItem) => { + if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) { + return; + } + setFeedback(null); + deleteMutation.mutate(origin.id); + }; + + return ( + <> +
+ { + setEditingOrigin(null); + setFeedback(null); + setIsEditorOpen(true); + }} + > + 新增源站 + + } + /> + + {feedback ? ( + + ) : null} + + + {originsQuery.isLoading ? ( + + ) : originsQuery.isError ? ( + + ) : origins.length === 0 ? ( + + ) : ( +
+ {origins.map((origin) => ( +
+
+
+
+
+

+ {origin.name} +

+ 0 ? 'success' : 'warning' + } + /> +
+

+ {origin.address} +

+

+ {origin.remark || '暂无备注'} +

+
+ +
+ 最后更新:{formatDateTime(origin.updated_at)} +
+
+ +
+ + 详情 + + { + setEditingOrigin(origin); + setFeedback(null); + setIsEditorOpen(true); + }} + > + 编辑 + + handleDelete(origin)} + disabled={deleteMutation.isPending} + > + 删除 + +
+
+
+ ))} +
+ )} +
+
+ + {isEditorOpen ? ( + setIsEditorOpen(false)} + onSaved={(origin, mode) => { + setFeedback({ + tone: 'success', + message: mode === 'create' ? '源站已创建。' : '源站已更新。', + }); + setEditingOrigin(origin); + }} + /> + ) : null} + + ); +} diff --git a/openflare_server/web/features/origins/types.ts b/openflare_server/web/features/origins/types.ts new file mode 100644 index 00000000..457b3ca8 --- /dev/null +++ b/openflare_server/web/features/origins/types.ts @@ -0,0 +1,27 @@ +export interface OriginItem { + id: number; + name: string; + address: string; + remark: string; + route_count: number; + created_at: string; + updated_at: string; +} + +export interface OriginRouteSummary { + id: number; + domain: string; + origin_url: string; + enabled: boolean; + updated_at: string; +} + +export interface OriginDetail extends OriginItem { + routes: OriginRouteSummary[]; +} + +export interface OriginMutationPayload { + name: string; + address: string; + remark: string; +} diff --git a/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx index 9ef9ab36..43f19c42 100644 --- a/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx +++ b/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx @@ -20,6 +20,8 @@ import { } from '@/features/config-versions/api/config-versions'; import { getManagedDomains } from '@/features/managed-domains/api/managed-domains'; import type { ManagedDomainItem } from '@/features/managed-domains/types'; +import { getOrigins } from '@/features/origins/api/origins'; +import type { OriginItem } from '@/features/origins/types'; import { createProxyRoute, deleteProxyRoute, @@ -63,27 +65,17 @@ const cachePolicyValues = [ 'path_prefix', 'path_exact', ] as const; +const originProtocolValues = ['http', 'https'] as const; const proxyRouteSchema = z .object({ managed_domain_id: z.string().trim().min(1, '请选择网站'), subdomain_label: z.string(), - origin_url: z - .string() - .trim() - .min(1, '请输入源站地址') - .refine( - (value) => /^https?:\/\//.test(value), - '源站地址必须以 http:// 或 https:// 开头', - ) - .refine((value) => { - try { - new URL(value); - return true; - } catch { - return false; - } - }, '请输入合法的源站地址'), + origin_id: z.string(), + origin_scheme: z.enum(originProtocolValues), + origin_address: z.string().trim().min(1, '请输入源站地址'), + origin_port: z.string().trim().min(1, '请输入端口'), + origin_uri: z.string(), origin_host: z .string() .trim() @@ -146,14 +138,70 @@ const proxyRouteSchema = z }); } + const normalizedOriginAddress = value.origin_address.trim(); + if ( + normalizedOriginAddress && + (/[/?#]/.test(normalizedOriginAddress) || + normalizedOriginAddress.includes('://')) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_address'], + message: '源站地址仅支持 IP、域名或主机名', + }); + } + + const normalizedOriginPort = value.origin_port.trim(); + const portNumber = Number(normalizedOriginPort); + if ( + normalizedOriginPort && + (!/^\d+$/.test(normalizedOriginPort) || + !Number.isInteger(portNumber) || + portNumber < 1 || + portNumber > 65535) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_port'], + message: '端口需为 1 到 65535 的整数', + }); + } + + const normalizedOriginURI = value.origin_uri.trim(); + if ( + normalizedOriginURI && + !normalizedOriginURI.startsWith('/') && + !normalizedOriginURI.startsWith('?') + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_uri'], + message: '源站路径需以 / 或 ? 开头', + }); + } + + const primaryOriginURL = buildOriginUrl( + value.origin_scheme, + value.origin_address, + value.origin_port, + value.origin_uri, + ); + if (!primaryOriginURL) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_address'], + message: '请输入完整的源站信息', + }); + } + const upstreams = parseUpstreamsText( - value.origin_url, + primaryOriginURL, value.upstreams_text, ); if (upstreams.length === 0) { context.addIssue({ code: z.ZodIssueCode.custom, - path: ['origin_url'], + path: ['origin_address'], message: '至少需要一个上游地址', }); } @@ -213,7 +261,11 @@ type FeedbackState = { const defaultValues: ProxyRouteFormValues = { managed_domain_id: '', subdomain_label: '', - origin_url: '', + origin_id: '', + origin_scheme: 'https', + origin_address: '', + origin_port: '', + origin_uri: '', origin_host: '', upstreams_text: '', enabled: true, @@ -230,6 +282,7 @@ const defaultValues: ProxyRouteFormValues = { const routesQueryKey = ['proxy-routes']; const certificatesQueryKey = ['tls-certificates']; const managedDomainsQueryKey = ['managed-domains']; +const originsQueryKey = ['origins']; const versionsQueryKey = ['config-versions']; function hasConfigChanges(diff: { @@ -338,13 +391,66 @@ function buildCertificateLabel(certificate: TlsCertificateItem) { : certificate.name; } +function buildOriginUrl( + scheme: 'http' | 'https', + address: string, + port: string, + uri: string, +) { + const normalizedAddress = address.trim(); + const normalizedPort = port.trim(); + if (!normalizedAddress || !normalizedPort) { + return ''; + } + + const host = + normalizedAddress.includes(':') && !normalizedAddress.startsWith('[') + ? `[${normalizedAddress}]` + : normalizedAddress; + const normalizedURI = uri.trim(); + + return `${scheme}://${host}:${normalizedPort}${normalizedURI}`; +} + +function parseOriginUrl(rawValue: string) { + try { + const parsed = new URL(rawValue); + const uri = parsed.pathname === '/' ? '' : parsed.pathname; + return { + scheme: (parsed.protocol.replace(':', '') || 'https') as 'http' | 'https', + address: parsed.hostname, + port: parsed.port || (parsed.protocol === 'http:' ? '80' : '443'), + uri: parsed.search ? `${uri}${parsed.search}` || parsed.search : uri, + }; + } catch { + return { + scheme: 'https' as const, + address: '', + port: '', + uri: '', + }; + } +} + function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload { + const primaryOriginUrl = buildOriginUrl( + values.origin_scheme, + values.origin_address, + values.origin_port, + values.origin_uri, + ); + return { domain: buildRouteDomain(values.managed_domain_id, values.subdomain_label), - origin_url: values.origin_url.trim(), + origin_id: values.origin_id ? Number(values.origin_id) : null, + origin_url: primaryOriginUrl, + origin_scheme: values.origin_scheme, + origin_address: values.origin_address.trim(), + origin_port: values.origin_port.trim(), + origin_uri: values.origin_uri.trim(), origin_host: values.origin_host.trim(), upstreams: parseUpstreamsText( - values.origin_url, + primaryOriginUrl, values.upstreams_text, ).slice(1), enabled: values.enabled, @@ -382,10 +488,16 @@ function toFormValues( ); } + const parsedOrigin = parseOriginUrl(route.origin_url); + return { managed_domain_id: managedDomainMatch.managedDomainId, subdomain_label: managedDomainMatch.subdomainLabel, - origin_url: route.origin_url, + origin_id: route.origin_id ? String(route.origin_id) : '', + origin_scheme: parsedOrigin.scheme, + origin_address: parsedOrigin.address, + origin_port: parsedOrigin.port, + origin_uri: parsedOrigin.uri, origin_host: route.origin_host || '', upstreams_text: upstreams.slice(1).join('\n'), enabled: route.enabled, @@ -453,6 +565,22 @@ export function ProxyRoutesPage() { control: form.control, name: 'subdomain_label', }); + const watchedOriginAddress = useWatch({ + control: form.control, + name: 'origin_address', + }); + const watchedOriginScheme = useWatch({ + control: form.control, + name: 'origin_scheme', + }); + const watchedOriginPort = useWatch({ + control: form.control, + name: 'origin_port', + }); + const watchedOriginURI = useWatch({ + control: form.control, + name: 'origin_uri', + }); const watchedEnabled = useWatch({ control: form.control, name: 'enabled' }); const watchedEnableHttps = useWatch({ control: form.control, @@ -487,6 +615,11 @@ export function ProxyRoutesPage() { queryFn: getManagedDomains, }); + const originsQuery = useQuery({ + queryKey: originsQueryKey, + queryFn: getOrigins, + }); + const saveMutation = useMutation({ mutationFn: async (values: ProxyRouteFormValues) => { const payload = toPayload(values); @@ -506,6 +639,7 @@ export function ProxyRoutesPage() { await Promise.all([ queryClient.invalidateQueries({ queryKey: routesQueryKey }), queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey }), + queryClient.invalidateQueries({ queryKey: originsQueryKey }), ]); }, onError: (error) => { @@ -561,6 +695,17 @@ export function ProxyRoutesPage() { () => managedDomainsQuery.data ?? [], [managedDomainsQuery.data], ); + const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]); + + const matchedOrigin = useMemo( + () => + origins.find( + (item) => + item.address.toLowerCase() === + watchedOriginAddress.trim().toLowerCase(), + ) ?? null, + [origins, watchedOriginAddress], + ); const selectedManagedDomain = useMemo( () => @@ -578,6 +723,19 @@ export function ProxyRoutesPage() { selectedManagedDomainValue, watchedSubdomainLabel, ); + const primaryOriginPreview = buildOriginUrl( + watchedOriginScheme, + watchedOriginAddress, + watchedOriginPort, + watchedOriginURI, + ); + + useEffect(() => { + form.setValue('origin_id', matchedOrigin ? String(matchedOrigin.id) : '', { + shouldDirty: true, + shouldValidate: false, + }); + }, [form, matchedOrigin]); useEffect(() => { if (!watchedEnableHttps) { @@ -911,13 +1069,57 @@ export function ProxyRoutesPage() { + + form.setValue( + 'origin_scheme', + event.target.value as ProxyRouteFormValues['origin_scheme'], + { + shouldDirty: true, + shouldValidate: true, + }, + ) + } + > + + + + + + +
+ + + {origins.map((origin) => ( + + + +
@@ -958,6 +1160,32 @@ export function ProxyRoutesPage() { ) ) : null} +
+ + + + +

+ {primaryOriginPreview || + '选择协议、输入源站和端口后,这里会显示最终主源站地址。'} +

+
+
+ { ); } + if (url.includes('/origins/')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: [], + }), + ), + ); + } + if (url.includes('/tls-certificates/')) { return Promise.resolve( new Response( @@ -166,6 +178,18 @@ describe('ProxyRoutesPage', () => { ); } + if (url.includes('/origins/')) { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: [], + }), + ), + ); + } + if (url.includes('/tls-certificates/')) { return Promise.resolve( new Response( diff --git a/openflare_server/web/types/navigation.ts b/openflare_server/web/types/navigation.ts index b7147358..c12b4028 100644 --- a/openflare_server/web/types/navigation.ts +++ b/openflare_server/web/types/navigation.ts @@ -2,6 +2,7 @@ export type NavigationIconKey = | 'home' | 'node' | 'website' + | 'origin' | 'domain' | 'certificate' | 'proxy'