mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-10 17:26:38 +08:00
refactor(backend): rename OpenFlare directory to lowercase openflare
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user