refactor(backend): rename OpenFlare directory to lowercase openflare

This commit is contained in:
ryan
2026-08-30 17:43:23 +08:00
parent 06d5fedbfc
commit c93ff6674f
543 changed files with 819 additions and 819 deletions
@@ -0,0 +1,19 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
const (
errZoneDomainRequired = "域名不能为空"
errZoneRootInvalid = "zone 必须是有效的注册根域"
errDomainInvalid = "域名格式不合法"
errDomainWildcardUnsupported = "不支持通配符域名"
errDomainOutsideZone = "域名不属于该 Zone"
errZoneNotFound = "Zone 不存在"
errDomainNotFound = "域名不存在"
errDomainExists = "域名已存在"
errCertificateNotFound = "所选证书不存在"
errDomainBoundToRoute = "域名已绑定反代路由,请先解除绑定"
errZoneHasDomains = "根域下仍有域名,请先删除全部域名"
errStatsRangeInvalid = "时间范围无效,请选择 24h、7d 或 30d"
)
@@ -0,0 +1,355 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"Wavelet/openflare/plugins/server/domain/site/routeidentity"
)
// ImportReport describes the idempotent legacy migration result.
type ImportReport struct {
Zones int `json:"zones"`
Domains int `json:"domains"`
Conflicts []string `json:"conflicts,omitempty"`
}
// LogAndReturn decorates the import failure with its conflict count.
func (r ImportReport) LogAndReturn(err error) error {
if err != nil {
return fmt.Errorf("迁移 Zone 失败(%d 个冲突): %w", len(r.Conflicts), err)
}
return nil
}
type legacyDomain struct {
Domain string
CertID *uint
ProxyRouteID *uint
}
// ImportLegacyTx imports legacy proxy-route / managed-domain rows into Zone tables
// within an existing SQL transaction (goose runs this on Server upgrade).
// postgres selects $n placeholders; otherwise SQLite-style ? is used.
// Missing legacy columns or tables are skipped so re-runs after phase-2 cleanup are no-ops.
//
//nolint:cyclop,gocyclo // single-pass legacy importer validates every source before write.
func ImportLegacyTx(ctx context.Context, tx *sql.Tx, postgres bool) (report ImportReport, err error) {
if tx == nil {
return report, errors.New("transaction is required")
}
q := func(sqlText string) string { return rebindSQL(sqlText, postgres) }
items := make([]legacyDomain, 0)
hasRouteDomains := false
hasDomainCol, err := hasTableColumn(ctx, tx, q, postgres, "of_proxy_routes", "domain")
if err != nil {
return report, err
}
hasDomainsCol, err := hasTableColumn(ctx, tx, q, postgres, "of_proxy_routes", "domains")
if err != nil {
return report, err
}
if hasDomainCol && hasDomainsCol {
var collectErr error
items, hasRouteDomains, report.Conflicts, collectErr = collectLegacyRouteDomainsImpl(ctx, tx, q)
if collectErr != nil {
return report, collectErr
}
}
if !hasRouteDomains {
exists, tableErr := hasTable(ctx, tx, q, postgres, "of_managed_domains")
if tableErr != nil {
return report, tableErr
}
if exists {
managed, managedErr := collectLegacyManagedDomains(ctx, tx, q)
if managedErr != nil {
return report, managedErr
}
items = append(items, managed...)
}
}
for _, item := range items {
domain, normErr := normalizeDomain(item.Domain)
if normErr != nil {
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: %v", item.Domain, normErr))
continue
}
root, rootErr := zoneRoot(domain)
if rootErr != nil {
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: %v", domain, rootErr))
continue
}
var existingID uint
var existingZoneDomain string
scanErr := tx.QueryRowContext(ctx, q(`
SELECT zd.id, z.domain
FROM of_zone_domains zd
JOIN of_zones z ON z.id = zd.zone_id
WHERE zd.domain = ?
`), domain).Scan(&existingID, &existingZoneDomain)
if scanErr == nil {
if existingZoneDomain != root {
report.Conflicts = append(report.Conflicts, domain+": global domain conflict")
} else if item.ProxyRouteID != nil {
if _, bindErr := tx.ExecContext(ctx, q(`
UPDATE of_zone_domains
SET proxy_route_id = COALESCE(proxy_route_id, ?),
cert_id = COALESCE(cert_id, ?)
WHERE id = ?
`), *item.ProxyRouteID, nullableUint(item.CertID), existingID); bindErr != nil {
return report, bindErr
}
}
continue
}
if !errors.Is(scanErr, sql.ErrNoRows) {
return report, scanErr
}
zoneID, zoneErr := ensureZone(ctx, tx, q, root, &report)
if zoneErr != nil {
return report, zoneErr
}
if item.CertID != nil {
var certID uint
if certErr := tx.QueryRowContext(ctx, q(`SELECT id FROM of_tls_certificates WHERE id = ?`), *item.CertID).
Scan(&certID); certErr != nil {
if errors.Is(certErr, sql.ErrNoRows) {
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: %s", domain, errCertificateNotFound))
continue
}
return report, certErr
}
}
if _, insErr := tx.ExecContext(ctx, q(`
INSERT INTO of_zone_domains (zone_id, proxy_route_id, domain, cert_id, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`), zoneID, nullableUint(item.ProxyRouteID), domain, nullableUint(item.CertID)); insErr != nil {
return report, insErr
}
report.Domains++
}
if len(report.Conflicts) > 0 {
return report, errors.New("legacy data has conflicts")
}
return report, nil
}
func ensureZone(
ctx context.Context,
tx *sql.Tx,
q func(string) string,
root string,
report *ImportReport,
) (uint, error) {
var zoneID uint
err := tx.QueryRowContext(ctx, q(`SELECT id FROM of_zones WHERE domain = ?`), root).Scan(&zoneID)
if err == nil {
return zoneID, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, err
}
if _, execErr := tx.ExecContext(ctx, q(`
INSERT INTO of_zones (domain, created_at, updated_at)
VALUES (?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`), root); execErr != nil {
return 0, execErr
}
if err := tx.QueryRowContext(ctx, q(`SELECT id FROM of_zones WHERE domain = ?`), root).Scan(&zoneID); err != nil {
return 0, err
}
report.Zones++
return zoneID, nil
}
func collectLegacyRouteDomainsImpl(
ctx context.Context,
tx *sql.Tx,
q func(string) string,
) (items []legacyDomain, hasRouteDomains bool, conflicts []string, err error) {
// Probe domain_cert_ids: if SELECT fails, fall back without it.
queryWithCert := q(`SELECT id, domain, domains, COALESCE(domain_cert_ids, '[]') FROM of_proxy_routes`)
rows, err := tx.QueryContext(ctx, queryWithCert)
useCert := true
if err != nil {
useCert = false
rows, err = tx.QueryContext(ctx, q(`SELECT id, domain, domains FROM of_proxy_routes`))
if err != nil {
return nil, false, nil, err
}
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
id uint
domain string
domains string
certIDs string
)
if useCert {
if err := rows.Scan(&id, &domain, &domains, &certIDs); err != nil {
return nil, false, nil, err
}
} else {
if err := rows.Scan(&id, &domain, &domains); err != nil {
return nil, false, nil, err
}
certIDs = "[]"
}
decoded, decodeErr := routeidentity.DecodeDomains(domains, domain)
if decodeErr != nil {
conflicts = append(conflicts, fmt.Sprintf("route %d: %v", id, decodeErr))
continue
}
if len(decoded) > 0 {
hasRouteDomains = true
}
ids := decodeLegacyCertIDs(certIDs, len(decoded))
routeID := id
for i, d := range decoded {
var certID *uint
if i < len(ids) && ids[i] > 0 {
v := ids[i]
certID = &v
}
items = append(items, legacyDomain{
Domain: d,
CertID: certID,
ProxyRouteID: &routeID,
})
}
}
return items, hasRouteDomains, conflicts, rows.Err()
}
func collectLegacyManagedDomains(ctx context.Context, tx *sql.Tx, q func(string) string) ([]legacyDomain, error) {
rows, err := tx.QueryContext(ctx, q(`SELECT domain, cert_id FROM of_managed_domains`))
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
items := make([]legacyDomain, 0)
for rows.Next() {
var (
domain string
certID sql.NullInt64
)
if err := rows.Scan(&domain, &certID); err != nil {
return nil, err
}
item := legacyDomain{Domain: domain}
if certID.Valid && certID.Int64 > 0 {
v := uint(certID.Int64)
item.CertID = &v
}
items = append(items, item)
}
return items, rows.Err()
}
func decodeLegacyCertIDs(raw string, count int) []uint {
var values []uint
if strings.TrimSpace(raw) == "" {
return make([]uint, count)
}
if json.Unmarshal([]byte(raw), &values) != nil {
return make([]uint, count)
}
return values
}
func nullableUint(v *uint) any {
if v == nil {
return nil
}
return *v
}
func rebindSQL(query string, postgres bool) string {
if !postgres {
return query
}
var b strings.Builder
b.Grow(len(query) + len(query)/4)
n := 0
for i := range len(query) {
if query[i] == '?' {
n++
b.WriteByte('$')
b.WriteString(strconv.Itoa(n))
continue
}
b.WriteByte(query[i])
}
return b.String()
}
func hasTable(
ctx context.Context,
tx *sql.Tx,
_ func(string) string,
postgres bool,
table string,
) (bool, error) {
var count int
var err error
if postgres {
err = tx.QueryRowContext(ctx, `
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
`, table).Scan(&count)
} else {
err = tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table,
).Scan(&count)
}
if err != nil {
return false, err
}
return count > 0, nil
}
func hasTableColumn(
ctx context.Context,
tx *sql.Tx,
_ func(string) string,
postgres bool,
table, column string,
) (bool, error) {
var count int
var err error
if postgres {
err = tx.QueryRowContext(ctx, `
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2
`, table, column).Scan(&count)
} else {
err = tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, table, column,
).Scan(&count)
}
if err != nil {
return false, err
}
return count > 0, nil
}
@@ -0,0 +1,151 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
import (
"context"
"database/sql"
"testing"
db "Wavelet/plugins/infra/database"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupLegacyImportDB(t *testing.T) (*sql.DB, func()) {
t.Helper()
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := gormDB.DB()
require.NoError(t, err)
// Pre-phase-2 schema: legacy route columns + managed domains + zone tables.
stmts := []string{
`CREATE TABLE of_zones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE of_zone_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zone_id INTEGER NOT NULL,
proxy_route_id INTEGER,
domain TEXT NOT NULL UNIQUE,
cert_id INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE of_proxy_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_name TEXT NOT NULL DEFAULT '',
domain TEXT NOT NULL DEFAULT '',
domains TEXT NOT NULL DEFAULT '[]',
domain_cert_ids TEXT NOT NULL DEFAULT '[]',
origin_url TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT ''
)`,
`CREATE TABLE of_tls_certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT ''
)`,
`CREATE TABLE of_managed_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL,
cert_id INTEGER,
remark TEXT NOT NULL DEFAULT ''
)`,
}
for _, stmt := range stmts {
_, err := sqlDB.Exec(stmt)
require.NoError(t, err)
}
previous := db.DB(context.Background())
db.SetDB(gormDB)
return sqlDB, func() {
db.SetDB(previous)
_ = sqlDB.Close()
}
}
func TestImportLegacyTxBindsRouteDomains(t *testing.T) {
sqlDB, cleanup := setupLegacyImportDB(t)
defer cleanup()
ctx := context.Background()
_, err := sqlDB.Exec(`INSERT INTO of_tls_certificates (id, name) VALUES (7, 'cert')`)
require.NoError(t, err)
_, err = sqlDB.Exec(`
INSERT INTO of_proxy_routes (id, site_name, domain, domains, domain_cert_ids, origin_url, remark)
VALUES (3, 'api', 'api.example.com', '["api.example.com","www.example.com"]', '[7,7]', 'http://origin', 'r')
`)
require.NoError(t, err)
tx, err := sqlDB.Begin()
require.NoError(t, err)
report, err := ImportLegacyTx(ctx, tx, false)
require.NoError(t, err)
require.NoError(t, tx.Commit())
assert.Equal(t, 1, report.Zones)
assert.Equal(t, 2, report.Domains)
var zoneDomain string
require.NoError(t, sqlDB.QueryRow(`SELECT domain FROM of_zones`).Scan(&zoneDomain))
assert.Equal(t, "example.com", zoneDomain)
var count int
require.NoError(t, sqlDB.QueryRow(`SELECT COUNT(*) FROM of_zone_domains WHERE proxy_route_id = 3`).Scan(&count))
assert.Equal(t, 2, count)
// Idempotent re-run
tx, err = sqlDB.Begin()
require.NoError(t, err)
report2, err := ImportLegacyTx(ctx, tx, false)
require.NoError(t, err)
require.NoError(t, tx.Commit())
assert.Equal(t, 0, report2.Domains)
}
func TestImportLegacyTxNoOpWithoutLegacyColumns(t *testing.T) {
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := gormDB.DB()
require.NoError(t, err)
defer sqlDB.Close()
_, err = sqlDB.Exec(`
CREATE TABLE of_zones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
created_at DATETIME, updated_at DATETIME
);
CREATE TABLE of_zone_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zone_id INTEGER NOT NULL,
proxy_route_id INTEGER,
domain TEXT NOT NULL UNIQUE,
cert_id INTEGER,
created_at DATETIME, updated_at DATETIME
);
CREATE TABLE of_proxy_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_name TEXT NOT NULL DEFAULT '',
origin_url TEXT NOT NULL DEFAULT ''
);
`)
require.NoError(t, err)
tx, err := sqlDB.Begin()
require.NoError(t, err)
report, err := ImportLegacyTx(context.Background(), tx, false)
require.NoError(t, err)
require.NoError(t, tx.Commit())
assert.Equal(t, 0, report.Zones)
assert.Equal(t, 0, report.Domains)
}
@@ -0,0 +1,259 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package zone manages registered roots and their explicit hostnames.
package zone
import (
"context"
"errors"
"strings"
"time"
cf "Wavelet/openflare/plugins/server/domain/cloudflare"
"Wavelet/openflare/plugins/server/kernel/model"
"Wavelet/openflare/plugins/server/kernel/repository"
"Wavelet/pkg/logger"
"golang.org/x/net/publicsuffix"
"gorm.io/gorm"
)
// Input is the mutable Zone payload.
type Input struct {
Domain string `json:"domain"`
}
// DomainInput is the mutable Zone-domain payload.
type DomainInput struct {
Domain string `json:"domain"`
CertID *uint `json:"cert_id"`
}
// Overview joins a Zone with its explicit domains.
type Overview struct {
Zone model.Zone `json:"zone"`
Domains []model.ZoneDomain `json:"domains"`
}
// ListItem is a Zone list row with denormalized domain count for the UI.
type ListItem struct {
ID uint `json:"id"`
Domain string `json:"domain"`
DomainCount int64 `json:"domain_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func zoneRoot(domain string) (string, error) {
return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain)))
}
func normalizeDomain(raw string) (string, error) {
domain := strings.ToLower(strings.TrimSpace(raw))
if domain == "" {
return "", errors.New(errZoneDomainRequired)
}
if strings.Contains(domain, "*") {
return "", errors.New(errDomainWildcardUnsupported)
}
if strings.Contains(domain, "://") || strings.Contains(domain, "/") || strings.Contains(domain, "?") || strings.Contains(domain, "#") || strings.Contains(domain, "@") {
return "", errors.New(errDomainInvalid)
}
if _, err := zoneRoot(domain); err != nil {
return "", errors.New(errDomainInvalid)
}
return domain, nil
}
// Create persists a validated registered root.
func Create(ctx context.Context, input Input) (*model.Zone, error) {
domain, err := normalizeDomain(input.Domain)
if err != nil {
return nil, err
}
root, err := zoneRoot(domain)
if err != nil || root != domain {
return nil, errors.New(errZoneRootInvalid)
}
zone := &model.Zone{Domain: domain}
if err := repository.CreateZone(ctx, zone); err != nil {
if isUnique(err) {
return nil, errors.New(errDomainExists)
}
return nil, err
}
return zone, nil
}
// Update replaces a Zone's mutable fields.
func Update(ctx context.Context, id uint, input Input) (*model.Zone, error) {
zone, err := repository.GetZoneByID(ctx, id)
if err != nil {
return nil, err
}
domain, err := normalizeDomain(input.Domain)
if err != nil {
return nil, err
}
root, err := zoneRoot(domain)
if err != nil || root != domain {
return nil, errors.New(errZoneRootInvalid)
}
zone.Domain = domain
if err := repository.SaveZone(ctx, zone); err != nil {
if isUnique(err) {
return nil, errors.New(errDomainExists)
}
return nil, err
}
return zone, nil
}
// List returns all Zones in stable domain order, with domain counts for list cards.
func List(ctx context.Context) ([]ListItem, error) {
zones, err := repository.ListZones(ctx)
if err != nil {
return nil, err
}
rows, err := repository.ListZoneDomainCounts(ctx)
if err != nil {
return nil, err
}
counts := make(map[uint]int64, len(rows))
for _, row := range rows {
counts[row.ZoneID] = row.Count
}
items := make([]ListItem, 0, len(zones))
for _, zone := range zones {
items = append(items, ListItem{
ID: zone.ID,
Domain: zone.Domain,
DomainCount: counts[zone.ID],
CreatedAt: zone.CreatedAt,
UpdatedAt: zone.UpdatedAt,
})
}
return items, nil
}
// GetOverview returns a Zone and its domains.
func GetOverview(ctx context.Context, id uint) (*Overview, error) {
zone, err := repository.GetZoneByID(ctx, id)
if err != nil {
return nil, err
}
domains, err := repository.ListZoneDomainsByZoneID(ctx, id)
if err != nil {
return nil, err
}
return &Overview{Zone: *zone, Domains: domains}, nil
}
// CreateDomain adds a validated exact hostname to a Zone.
func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.ZoneDomain, error) {
zone, err := repository.GetZoneByID(ctx, zoneID)
if err != nil {
return nil, err
}
domain, err := normalizeDomain(input.Domain)
if err != nil {
return nil, err
}
root, err := zoneRoot(domain)
if err != nil || root != zone.Domain {
return nil, errors.New(errDomainOutsideZone)
}
if input.CertID != nil {
if _, err := repository.GetTLSCertificateByID(ctx, *input.CertID); err != nil {
return nil, errors.New(errCertificateNotFound)
}
}
item := &model.ZoneDomain{ZoneID: zoneID, Domain: domain, CertID: input.CertID}
if err := repository.CreateZoneDomain(ctx, item); err != nil {
if isUnique(err) {
return nil, errors.New(errDomainExists)
}
return nil, err
}
return item, nil
}
// UpdateDomain replaces a Zone-domain's mutable fields.
func UpdateDomain(ctx context.Context, zoneID, id uint, input DomainInput) (*model.ZoneDomain, error) {
item, err := repository.GetZoneDomainByZoneAndID(ctx, zoneID, id)
if err != nil {
return nil, err
}
domain, err := normalizeDomain(input.Domain)
if err != nil {
return nil, err
}
zone, err := repository.GetZoneByID(ctx, zoneID)
if err != nil {
return nil, err
}
root, err := zoneRoot(domain)
if err != nil || root != zone.Domain {
return nil, errors.New(errDomainOutsideZone)
}
if input.CertID != nil {
if _, err = repository.GetTLSCertificateByID(ctx, *input.CertID); err != nil {
return nil, errors.New(errCertificateNotFound)
}
}
item.Domain, item.CertID = domain, input.CertID
if err = repository.SaveZoneDomain(ctx, item); err != nil {
if isUnique(err) {
return nil, errors.New(errDomainExists)
}
return nil, err
}
return item, nil
}
// DeleteDomain removes a Zone domain that is not bound to a proxy route.
func DeleteDomain(ctx context.Context, zoneID, id uint) error {
item, err := repository.GetZoneDomainByZoneAndID(ctx, zoneID, id)
if err != nil {
return err
}
if item.ProxyRouteID != nil {
return errors.New(errDomainBoundToRoute)
}
member, cfErr := repository.GetCFPointingMemberByZoneDomainID(ctx, item.ID)
if cfErr != nil && !errors.Is(cfErr, gorm.ErrRecordNotFound) {
return cfErr
}
if member != nil {
if delErr := cf.DeleteManagedRecord(ctx, member.ID); delErr != nil {
logger.WarnF(ctx, "[Zone] delete managed Cloudflare record failed for domain %s (member_id=%d): %v", item.Domain, member.ID, delErr)
}
if delMemberErr := repository.DeleteCFPointingMember(ctx, member); delMemberErr != nil {
logger.ErrorF(ctx, "[Zone] delete Cloudflare pointing member failed: member_id=%d error=%v", member.ID, delMemberErr)
return delMemberErr
}
}
return repository.DeleteZoneDomain(ctx, item)
}
// Delete removes a Zone that has no remaining domains.
func Delete(ctx context.Context, id uint) error {
if _, err := repository.GetZoneByID(ctx, id); err != nil {
return err
}
count, err := repository.CountZoneDomainsByZoneID(ctx, id)
if err != nil {
return err
}
if count > 0 {
return errors.New(errZoneHasDomains)
}
return repository.DeleteZone(ctx, id)
}
func isUnique(err error) bool {
return errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(strings.ToLower(err.Error()), "unique constraint")
}
@@ -0,0 +1,128 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
import (
"context"
"testing"
"time"
"Wavelet/openflare/plugins/server/kernel/repository"
"Wavelet/openflare/plugins/server/kernel/testhelper"
"Wavelet/openflare/plugins/server/kernel/model"
db "Wavelet/plugins/infra/database"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupZoneDB(t *testing.T) context.Context {
t.Helper()
conn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
require.NoError(t, err)
require.NoError(t, conn.AutoMigrate(&model.Zone{}, &model.ZoneDomain{}, &model.TLSCertificate{}, &model.CFPointingGroup{}, &model.CFPointingMember{}))
db.SetDB(conn)
t.Cleanup(func() { db.SetDB(nil) })
return context.Background()
}
func TestCreateZoneDomainRejectsWildcard(t *testing.T) {
ctx := setupZoneDB(t)
zone, err := Create(ctx, Input{Domain: "example.com"})
require.NoError(t, err)
_, err = CreateDomain(ctx, zone.ID, DomainInput{Domain: "*.example.com"})
require.EqualError(t, err, errDomainWildcardUnsupported)
}
func TestDeleteDomainRejectsBoundRoute(t *testing.T) {
ctx := setupZoneDB(t)
zone, err := Create(ctx, Input{Domain: "example.com"})
require.NoError(t, err)
item, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "api.example.com"})
require.NoError(t, err)
routeID := uint(9)
item.ProxyRouteID = &routeID
require.NoError(t, repository.SaveZoneDomain(ctx, item))
err = DeleteDomain(ctx, zone.ID, item.ID)
require.EqualError(t, err, errDomainBoundToRoute)
item.ProxyRouteID = nil
require.NoError(t, repository.SaveZoneDomain(ctx, item))
require.NoError(t, DeleteDomain(ctx, zone.ID, item.ID))
}
func TestDeleteDomainCleansUpCloudflareMember(t *testing.T) {
ctx := setupZoneDB(t)
zone, err := Create(ctx, Input{Domain: "example.com"})
require.NoError(t, err)
domain, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "api.example.com"})
require.NoError(t, err)
member := model.CFPointingMember{GroupID: 1, ZoneDomainID: domain.ID}
require.NoError(t, repository.CreateCFPointingMember(ctx, &member))
require.NoError(t, DeleteDomain(ctx, zone.ID, domain.ID))
_, err = repository.GetCFPointingMemberByZoneDomainID(ctx, domain.ID)
require.Error(t, err)
}
func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) {
root, err := zoneRoot("api.example.co.uk")
require.NoError(t, err)
require.Equal(t, "example.co.uk", root)
}
func TestGetStatsAggregatesZoneHosts(t *testing.T) {
ctx := setupZoneDB(t)
testhelper.SetupLogStoresForTest(t)
zone, err := Create(ctx, Input{Domain: "example.com"})
require.NoError(t, err)
_, err = CreateDomain(ctx, zone.ID, DomainInput{Domain: "api.example.com"})
require.NoError(t, err)
_, err = CreateDomain(ctx, zone.ID, DomainInput{Domain: "www.example.com"})
require.NoError(t, err)
now := time.Now().UTC()
require.NoError(t, repository.InsertOpenFlareAccessLogsBatch(ctx, []*model.OpenFlareAccessLog{
{NodeID: "n1", LoggedAt: now.Add(-1 * time.Hour), RemoteAddr: "1.1.1.1", Host: "api.example.com", Path: "/", StatusCode: 200, BytesSent: 1000},
{NodeID: "n1", LoggedAt: now.Add(-2 * time.Hour), RemoteAddr: "1.1.1.1", Host: "www.example.com", Path: "/", StatusCode: 200, BytesSent: 500},
{NodeID: "n1", LoggedAt: now.Add(-3 * time.Hour), RemoteAddr: "2.2.2.2", Host: "api.example.com", Path: "/x", StatusCode: 404, BytesSent: 200},
{NodeID: "n1", LoggedAt: now.Add(-3 * time.Hour), RemoteAddr: "3.3.3.3", Host: "other.com", Path: "/", StatusCode: 200, BytesSent: 100},
{NodeID: "n1", LoggedAt: now.Add(-48 * time.Hour), RemoteAddr: "4.4.4.4", Host: "api.example.com", Path: "/", StatusCode: 200, BytesSent: 800},
}))
stats, err := GetStats(ctx, zone.ID, "24h")
require.NoError(t, err)
require.Equal(t, StatsRange24h, stats.Range)
require.Equal(t, int64(3), stats.RequestCount)
require.Equal(t, int64(2), stats.UniqueVisitors)
require.Equal(t, int64(1700), stats.BytesSent)
require.Equal(t, 2, stats.DomainCount)
require.True(t, stats.Available)
require.NotEmpty(t, stats.Series)
require.Equal(t, 60, stats.BucketMinutes)
var seriesRequests int64
var seriesBytes int64
for _, point := range stats.Series {
seriesRequests += point.RequestCount
seriesBytes += point.BytesSent
}
require.Equal(t, int64(3), seriesRequests)
require.Equal(t, int64(1700), seriesBytes)
stats7d, err := GetStats(ctx, zone.ID, "7d")
require.NoError(t, err)
require.Equal(t, int64(4), stats7d.RequestCount)
require.Equal(t, int64(3), stats7d.UniqueVisitors)
require.Equal(t, int64(2500), stats7d.BytesSent)
require.NotEmpty(t, stats7d.Series)
_, err = GetStats(ctx, zone.ID, "1h")
require.EqualError(t, err, errStatsRangeInvalid)
}
@@ -0,0 +1,252 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
import (
"errors"
"net/http"
"Wavelet/openflare/plugins/server/kernel/apiutil"
"Wavelet/pkg/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func abort(c *gin.Context, err error, missing string) bool {
if err == nil {
return false
}
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
response.AbortNotFound(c, missing)
case err.Error() == errDomainExists:
response.AbortConflict(c, err.Error())
default:
response.AbortBadRequest(c, err.Error())
}
return true
}
// ListHandler lists registered Zones.
// @Summary 获取 Zone 列表
// @Tags openflare-zone
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]zone.ListItem}
// @Router /api/v1/d/zones [get]
func ListHandler(c *gin.Context) {
items, err := List(c.Request.Context())
if abort(c, err, errZoneNotFound) {
return
}
c.JSON(http.StatusOK, response.OK(items))
}
// CreateHandler creates a registered root domain.
// @Summary 创建 Zone
// @Tags openflare-zone
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param body body zone.Input true "Zone 参数"
// @Success 200 {object} response.Any{data=model.Zone}
// @Failure 400 {object} response.Any
// @Failure 409 {object} response.Any
// @Router /api/v1/d/zones [post]
func CreateHandler(c *gin.Context) {
var input Input
if !apiutil.BindJSON(c, &input) {
return
}
item, err := Create(c.Request.Context(), input)
if abort(c, err, errZoneNotFound) {
return
}
c.JSON(http.StatusOK, response.OK(item))
}
// GetOverviewHandler returns a Zone and its explicit domains.
// @Summary 获取 Zone 概览
// @Tags openflare-zone
// @Produce json
// @Security SessionCookie
// @Param id path int true "Zone ID"
// @Success 200 {object} response.Any{data=zone.Overview}
// @Failure 404 {object} response.Any
// @Router /api/v1/d/zones/{id}/overview [get]
func GetOverviewHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
item, err := GetOverview(c.Request.Context(), id)
if abort(c, err, errZoneNotFound) {
return
}
c.JSON(http.StatusOK, response.OK(item))
}
// GetStatsHandler returns Zone traffic metrics for a time range.
// @Summary 获取 Zone 流量统计
// @Description 按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。
// @Tags openflare-zone
// @Produce json
// @Security SessionCookie
// @Param id path int true "Zone ID"
// @Param range query string false "时间范围:24h(默认)、7d、30d"
// @Success 200 {object} response.Any{data=zone.Stats}
// @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any
// @Router /api/v1/d/zones/{id}/stats [get]
func GetStatsHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
item, err := GetStats(c.Request.Context(), id, c.Query("range"))
if abort(c, err, errZoneNotFound) {
return
}
c.JSON(http.StatusOK, response.OK(item))
}
// UpdateHandler updates a Zone.
// @Summary 更新 Zone
// @Tags openflare-zone
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "Zone ID"
// @Param body body zone.Input true "Zone 参数"
// @Success 200 {object} response.Any{data=model.Zone}
// @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any
// @Failure 409 {object} response.Any
// @Router /api/v1/d/zones/{id}/update [post]
func UpdateHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input Input
if !apiutil.BindJSON(c, &input) {
return
}
item, err := Update(c.Request.Context(), id, input)
if abort(c, err, errZoneNotFound) {
return
}
c.JSON(http.StatusOK, response.OK(item))
}
// DeleteHandler deletes a Zone with no remaining domains.
// @Summary 删除 Zone
// @Tags openflare-zone
// @Produce json
// @Security SessionCookie
// @Param id path int true "Zone ID"
// @Success 200 {object} response.Any
// @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any
// @Router /api/v1/d/zones/{id}/delete [post]
func DeleteHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := Delete(c.Request.Context(), id); abort(c, err, errZoneNotFound) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// CreateDomainHandler creates an explicit FQDN under a Zone.
// @Summary 创建 Zone 域名
// @Tags openflare-zone
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "Zone ID"
// @Param body body zone.DomainInput true "域名参数"
// @Success 200 {object} response.Any{data=model.ZoneDomain}
// @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any
// @Failure 409 {object} response.Any
// @Router /api/v1/d/zones/{id}/domains [post]
func CreateDomainHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input DomainInput
if !apiutil.BindJSON(c, &input) {
return
}
item, err := CreateDomain(c.Request.Context(), id, input)
if abort(c, err, errZoneNotFound) {
return
}
c.JSON(http.StatusOK, response.OK(item))
}
// UpdateDomainHandler updates a Zone domain.
// @Summary 更新 Zone 域名
// @Tags openflare-zone
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "Zone ID"
// @Param domainId path int true "域名 ID"
// @Param body body zone.DomainInput true "域名参数"
// @Success 200 {object} response.Any{data=model.ZoneDomain}
// @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any
// @Failure 409 {object} response.Any
// @Router /api/v1/d/zones/{id}/domains/{domainId}/update [post]
func UpdateDomainHandler(c *gin.Context) {
zoneID, ok := apiutil.IDParam(c)
if !ok {
return
}
domainID, ok := apiutil.NamedIDParam(c, "domainId")
if !ok {
return
}
var input DomainInput
if !apiutil.BindJSON(c, &input) {
return
}
item, err := UpdateDomain(c.Request.Context(), zoneID, domainID, input)
if abort(c, err, errDomainNotFound) {
return
}
c.JSON(http.StatusOK, response.OK(item))
}
// DeleteDomainHandler deletes a Zone domain not bound to a proxy route.
// @Summary 删除 Zone 域名
// @Tags openflare-zone
// @Produce json
// @Security SessionCookie
// @Param id path int true "Zone ID"
// @Param domainId path int true "域名 ID"
// @Success 200 {object} response.Any
// @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any
// @Router /api/v1/d/zones/{id}/domains/{domainId}/delete [post]
func DeleteDomainHandler(c *gin.Context) {
zoneID, ok := apiutil.IDParam(c)
if !ok {
return
}
domainID, ok := apiutil.NamedIDParam(c, "domainId")
if !ok {
return
}
if err := DeleteDomain(c.Request.Context(), zoneID, domainID); abort(c, err, errDomainNotFound) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
@@ -0,0 +1,209 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
import (
"context"
"errors"
"strings"
"time"
"Wavelet/openflare/plugins/server/kernel/model"
"Wavelet/openflare/plugins/server/kernel/repository"
"gorm.io/gorm"
)
// StatsRange is a supported traffic window for Zone analytics.
type StatsRange string
// StatsRange constants representing supported analytics windows.
const (
// StatsRange24h represents a 24-hour time window.
StatsRange24h StatsRange = "24h"
// StatsRange7d represents a 7-day time window.
StatsRange7d StatsRange = "7d"
// StatsRange30d represents a 30-day time window.
StatsRange30d StatsRange = "30d"
)
const (
hoursPerDay = 24
daysPerWeek = 7
daysPerMonth = 30
minutesPerHour = 60
bucketMinutes24h = 60
bucketMinutes7d = 6 * minutesPerHour
bucketMinutes30d = 24 * minutesPerHour
)
// StatsPoint is one bucket on a Zone traffic chart.
type StatsPoint struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
RequestCount int64 `json:"request_count"`
UniqueVisitors int64 `json:"unique_visitors"`
BytesSent int64 `json:"bytes_sent"`
}
// Stats summarizes edge traffic for all domains under a Zone.
type Stats struct {
Range StatsRange `json:"range"`
RangeHours int `json:"range_hours"`
WindowStartedAt time.Time `json:"window_started_at"`
WindowEndedAt time.Time `json:"window_ended_at"`
BucketMinutes int `json:"bucket_minutes"`
UniqueVisitors int64 `json:"unique_visitors"`
RequestCount int64 `json:"request_count"`
BytesSent int64 `json:"bytes_sent"`
DomainCount int `json:"domain_count"`
Available bool `json:"available"`
Series []StatsPoint `json:"series"`
}
func parseStatsRange(raw string) (StatsRange, time.Duration, int, error) {
switch StatsRange(strings.TrimSpace(raw)) {
case "", StatsRange24h:
return StatsRange24h, hoursPerDay * time.Hour, bucketMinutes24h, nil
case StatsRange7d:
return StatsRange7d, daysPerWeek * hoursPerDay * time.Hour, bucketMinutes7d, nil
case StatsRange30d:
return StatsRange30d, daysPerMonth * hoursPerDay * time.Hour, bucketMinutes30d, nil
default:
return "", 0, 0, errors.New(errStatsRangeInvalid)
}
}
// GetStats aggregates access-log traffic for a Zone over a time range.
func GetStats(ctx context.Context, id uint, rangeRaw string) (*Stats, error) {
statsRange, window, bucketMinutes, err := parseStatsRange(rangeRaw)
if err != nil {
return nil, err
}
if _, err := repository.GetZoneByID(ctx, id); err != nil {
return nil, err
}
domains, err := repository.ListZoneDomainsByZoneID(ctx, id)
if err != nil {
return nil, err
}
now := time.Now().UTC().Truncate(time.Minute)
since := now.Add(-window)
// Align chart window start to bucket boundary for cleaner x-axis labels.
bucket := time.Duration(bucketMinutes) * time.Minute
since = since.Truncate(bucket)
result := &Stats{
Range: statsRange,
RangeHours: int(window / time.Hour),
WindowStartedAt: since,
WindowEndedAt: now,
BucketMinutes: bucketMinutes,
DomainCount: len(domains),
Available: true,
Series: emptyStatsSeries(since, now, bucketMinutes),
}
if len(domains) == 0 {
return result, nil
}
hosts := make([]string, 0, len(domains))
for _, domain := range domains {
if host := strings.TrimSpace(domain.Domain); host != "" {
hosts = append(hosts, host)
}
}
if len(hosts) == 0 {
return result, nil
}
requestCount, uniqueVisitors, totalBytesSent, err := repository.CountOpenFlareAccessLogs(ctx, model.OpenFlareAccessLogQuery{
Hosts: hosts,
Since: since,
Until: now,
})
if err != nil {
if isAnalyticsUnavailable(err) {
result.Available = false
return result, nil
}
return nil, err
}
result.RequestCount = requestCount
result.UniqueVisitors = uniqueVisitors
result.BytesSent = totalBytesSent
buckets, err := repository.ListOpenFlareAccessLogBuckets(ctx, model.OpenFlareAccessLogBucketQuery{
Hosts: hosts,
Since: since,
Until: now,
FoldMinutes: bucketMinutes,
SortBy: "logged_at",
SortOrder: "asc",
})
if err != nil {
if isAnalyticsUnavailable(err) {
result.Available = false
return result, nil
}
return nil, err
}
byEpoch := make(map[int64]model.OpenFlareAccessLogBucketRow, len(buckets))
for _, bucketRow := range buckets {
if bucketRow == nil {
continue
}
byEpoch[bucketRow.BucketEpoch] = *bucketRow
}
series := emptyStatsSeries(since, now, bucketMinutes)
for index := range series {
epoch := series[index].BucketStartedAt.Unix()
if row, ok := byEpoch[epoch]; ok {
series[index].RequestCount = row.RequestCount
series[index].UniqueVisitors = row.UniqueIPCount
series[index].BytesSent = row.BytesSent
}
}
result.Series = series
return result, nil
}
func emptyStatsSeries(since, until time.Time, bucketMinutes int) []StatsPoint {
if bucketMinutes <= 0 {
bucketMinutes = 60
}
bucket := time.Duration(bucketMinutes) * time.Minute
start := since.UTC().Truncate(bucket)
end := until.UTC()
if !end.After(start) {
return []StatsPoint{{BucketStartedAt: start}}
}
// Cap points to keep chart readable.
maxPoints := 120
capacity := min(int(end.Sub(start)/bucket)+1, maxPoints)
points := make([]StatsPoint, 0, capacity)
for cursor := start; !cursor.After(end) && len(points) < maxPoints; cursor = cursor.Add(bucket) {
points = append(points, StatsPoint{BucketStartedAt: cursor})
}
if len(points) == 0 {
points = append(points, StatsPoint{BucketStartedAt: start})
}
return points
}
func isAnalyticsUnavailable(err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrInvalidDB) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "clickhouse connection is not initialized") ||
strings.Contains(msg, "clickhouse is not") ||
strings.Contains(msg, "database is not initialized")
}