mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
374 lines
11 KiB
Go
374 lines
11 KiB
Go
// Package api implements the resource bootstrap and distribution HTTP surface of bat-api.
|
|
//
|
|
// bat-api serves already-published official resources (CDN-shaped paths) and
|
|
// discovers version/manifest state and the current resource_root through the
|
|
// Rust bat daemon RPC socket.
|
|
// Automatic resource pull/sync remains the responsibility of the Rust bat
|
|
// binary; this package does not download or mutate release contents.
|
|
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Default listen address and related defaults for the resource API.
|
|
const (
|
|
DefaultListen = ":18080"
|
|
DefaultPublicBaseURL = "http://127.0.0.1:18080"
|
|
DefaultStateDir = "/tmp/bat-pid"
|
|
DefaultAuthSkew = 5 * time.Minute
|
|
ClientPatchHost = "prod-clientpatch.bluearchiveyostar.com"
|
|
ServerInfoHost = "yostar-serverinfo.bluearchiveyostar.com"
|
|
EnvFileName = ".env"
|
|
)
|
|
|
|
// Config holds bat-api process configuration.
|
|
//
|
|
// Values come from (highest wins): CLI flags > process environment > .env file
|
|
// > built-in defaults. Database-related keys are reserved for future API
|
|
// persistence and are loaded but not required for the resource CDN surface.
|
|
type Config struct {
|
|
// Listen is the HTTP listen address, e.g. ":18080" or "127.0.0.1:18080".
|
|
Listen string
|
|
// PublicBaseURL is used when rewriting AddressablesCatalogUrlRoot.
|
|
PublicBaseURL string
|
|
// StateDir is used only to derive the default RPC socket path.
|
|
StateDir string
|
|
// SocketPath is the bat.sock Unix socket used for JSON-RPC.
|
|
SocketPath string
|
|
// ResourceRoot overrides the release root discovered via RPC (tests / emergency).
|
|
ResourceRoot string
|
|
// ServerInfoFile is an optional path to a server-info JSON document.
|
|
ServerInfoFile string
|
|
// RequireIndexed rejects CDN paths that are not in the release index.
|
|
RequireIndexed bool
|
|
// VerifySize rejects CDN files whose size differs from the index.
|
|
VerifySize bool
|
|
// RPCTimeout is the per-call timeout for daemon RPC.
|
|
RPCTimeout time.Duration
|
|
// RefreshInterval periodically re-discovers the release through RPC. Zero disables it.
|
|
RefreshInterval time.Duration
|
|
// SkipEnvFile disables loading .env when true (BAT_API_SKIP_ENV_FILE=1).
|
|
SkipEnvFile bool
|
|
// AuthToken enables HTTP token authentication when non-empty.
|
|
AuthToken string
|
|
// AuthQueryParam is the optional query parameter accepted for token auth.
|
|
AuthQueryParam string
|
|
// AuthExemptPaths lists exact paths or slash-prefixes that bypass token auth.
|
|
AuthExemptPaths []string
|
|
// TrustProxyHeaders allows X-Forwarded-For / X-Real-IP for client identity.
|
|
TrustProxyHeaders bool
|
|
// AccessLog enables structured per-request access logs.
|
|
AccessLog bool
|
|
// RateLimitRPS limits requests per client identity. Zero disables in-process limiting.
|
|
RateLimitRPS float64
|
|
// RateLimitBurst is the token bucket burst size when rate limiting is enabled.
|
|
RateLimitBurst int
|
|
// MaxResourcePageLimit caps /v1/resources limit.
|
|
MaxResourcePageLimit int
|
|
|
|
// Reserved for a future persistent API layer (not used by CDN MVP).
|
|
DatabaseURL string
|
|
DatabasePassword string
|
|
RedisURL string
|
|
RedisPassword string
|
|
}
|
|
|
|
// DefaultConfig returns built-in defaults.
|
|
func DefaultConfig() Config {
|
|
return Config{
|
|
Listen: DefaultListen,
|
|
PublicBaseURL: DefaultPublicBaseURL,
|
|
StateDir: DefaultStateDir,
|
|
RequireIndexed: true,
|
|
VerifySize: true,
|
|
RPCTimeout: 30 * time.Second,
|
|
RefreshInterval: time.Minute,
|
|
AuthQueryParam: "bat_token",
|
|
MaxResourcePageLimit: 1000,
|
|
}
|
|
}
|
|
|
|
// Normalize fills derived fields and validates required values.
|
|
func (c *Config) Normalize() error {
|
|
if c.Listen == "" {
|
|
c.Listen = DefaultListen
|
|
}
|
|
if c.PublicBaseURL == "" {
|
|
c.PublicBaseURL = DefaultPublicBaseURL
|
|
}
|
|
c.PublicBaseURL = strings.TrimRight(c.PublicBaseURL, "/")
|
|
if _, err := url.ParseRequestURI(c.PublicBaseURL); err != nil {
|
|
return fmt.Errorf("invalid public base URL %q: %w", c.PublicBaseURL, err)
|
|
}
|
|
if c.StateDir == "" {
|
|
c.StateDir = DefaultStateDir
|
|
}
|
|
if c.SocketPath == "" {
|
|
c.SocketPath = filepath.Join(c.StateDir, "bat.sock")
|
|
}
|
|
if c.RPCTimeout <= 0 {
|
|
c.RPCTimeout = 30 * time.Second
|
|
}
|
|
if c.RefreshInterval < 0 {
|
|
return fmt.Errorf("refresh interval must be >= 0")
|
|
}
|
|
if c.AuthQueryParam == "" {
|
|
c.AuthQueryParam = "bat_token"
|
|
}
|
|
c.AuthExemptPaths = normalizePathList(append(c.AuthExemptPaths, dashboardAuthExemptPaths()...))
|
|
if c.RateLimitRPS < 0 {
|
|
return fmt.Errorf("rate limit rps must be >= 0")
|
|
}
|
|
if c.RateLimitRPS > 0 && c.RateLimitBurst <= 0 {
|
|
c.RateLimitBurst = int(c.RateLimitRPS)
|
|
if c.RateLimitBurst <= 0 {
|
|
c.RateLimitBurst = 1
|
|
}
|
|
}
|
|
if c.RateLimitBurst < 0 {
|
|
return fmt.Errorf("rate limit burst must be >= 0")
|
|
}
|
|
if c.MaxResourcePageLimit <= 0 {
|
|
c.MaxResourcePageLimit = 1000
|
|
}
|
|
if c.ResourceRoot != "" {
|
|
abs, err := filepath.Abs(c.ResourceRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve resource root: %w", err)
|
|
}
|
|
c.ResourceRoot = abs
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LoadEnvFile reads KEY=VALUE lines from path into the process environment
|
|
// without overriding variables that are already set.
|
|
func LoadEnvFile(path string) error {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
value = strings.TrimSpace(value)
|
|
value = strings.Trim(value, `"'`)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, exists := os.LookupEnv(key); exists {
|
|
continue
|
|
}
|
|
if err := os.Setenv(key, value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ApplyEnv overlays environment variables onto cfg.
|
|
func ApplyEnv(cfg *Config) {
|
|
if v := os.Getenv("BAT_API_LISTEN"); v != "" {
|
|
cfg.Listen = v
|
|
}
|
|
// Alias common port-only style.
|
|
if v := os.Getenv("BAT_API_PORT"); v != "" && os.Getenv("BAT_API_LISTEN") == "" {
|
|
if strings.HasPrefix(v, ":") {
|
|
cfg.Listen = v
|
|
} else {
|
|
cfg.Listen = ":" + v
|
|
}
|
|
}
|
|
if v := os.Getenv("BAT_API_PUBLIC_BASE_URL"); v != "" {
|
|
cfg.PublicBaseURL = v
|
|
}
|
|
if v := os.Getenv("BAT_API_STATE_DIR"); v != "" {
|
|
cfg.StateDir = v
|
|
}
|
|
if v := os.Getenv("BAT_API_SOCKET"); v != "" {
|
|
cfg.SocketPath = v
|
|
}
|
|
if v := os.Getenv("BAT_API_RESOURCE_ROOT"); v != "" {
|
|
cfg.ResourceRoot = v
|
|
}
|
|
if v := os.Getenv("BAT_API_SERVER_INFO_FILE"); v != "" {
|
|
cfg.ServerInfoFile = v
|
|
}
|
|
if v := os.Getenv("BAT_API_REQUIRE_INDEXED"); v != "" {
|
|
cfg.RequireIndexed = parseBool(v, cfg.RequireIndexed)
|
|
}
|
|
if v := os.Getenv("BAT_API_VERIFY_SIZE"); v != "" {
|
|
cfg.VerifySize = parseBool(v, cfg.VerifySize)
|
|
}
|
|
if v := os.Getenv("BAT_API_RPC_TIMEOUT"); v != "" {
|
|
if d, err := time.ParseDuration(v); err == nil {
|
|
cfg.RPCTimeout = d
|
|
}
|
|
}
|
|
if v := os.Getenv("BAT_API_REFRESH_INTERVAL"); v != "" {
|
|
if d, err := time.ParseDuration(v); err == nil {
|
|
cfg.RefreshInterval = d
|
|
}
|
|
}
|
|
if v := os.Getenv("BAT_API_DATABASE_URL"); v != "" {
|
|
cfg.DatabaseURL = v
|
|
}
|
|
if v := os.Getenv("BAT_API_DATABASE_PASSWORD"); v != "" {
|
|
cfg.DatabasePassword = v
|
|
}
|
|
if v := os.Getenv("BAT_API_REDIS_URL"); v != "" {
|
|
cfg.RedisURL = v
|
|
}
|
|
if v := os.Getenv("BAT_API_REDIS_PASSWORD"); v != "" {
|
|
cfg.RedisPassword = v
|
|
}
|
|
if v := os.Getenv("BAT_API_SKIP_ENV_FILE"); v != "" {
|
|
cfg.SkipEnvFile = parseBool(v, false)
|
|
}
|
|
if v := os.Getenv("BAT_API_AUTH_TOKEN"); v != "" {
|
|
cfg.AuthToken = v
|
|
}
|
|
if v := os.Getenv("BAT_API_AUTH_QUERY_PARAM"); v != "" {
|
|
cfg.AuthQueryParam = v
|
|
}
|
|
if v := os.Getenv("BAT_API_AUTH_EXEMPT_PATHS"); v != "" {
|
|
cfg.AuthExemptPaths = splitCSV(v)
|
|
}
|
|
if v := os.Getenv("BAT_API_TRUST_PROXY_HEADERS"); v != "" {
|
|
cfg.TrustProxyHeaders = parseBool(v, cfg.TrustProxyHeaders)
|
|
}
|
|
if v := os.Getenv("BAT_API_ACCESS_LOG"); v != "" {
|
|
cfg.AccessLog = parseBool(v, cfg.AccessLog)
|
|
}
|
|
if v := os.Getenv("BAT_API_RATE_LIMIT_RPS"); v != "" {
|
|
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
|
cfg.RateLimitRPS = f
|
|
}
|
|
}
|
|
if v := os.Getenv("BAT_API_RATE_LIMIT_BURST"); v != "" {
|
|
if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
|
cfg.RateLimitBurst = i
|
|
}
|
|
}
|
|
if v := os.Getenv("BAT_API_MAX_RESOURCE_LIMIT"); v != "" {
|
|
if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
|
cfg.MaxResourcePageLimit = i
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseBool(raw string, fallback bool) bool {
|
|
b, err := strconv.ParseBool(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return b
|
|
}
|
|
|
|
func splitCSV(raw string) []string {
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizePathList(paths []string) []string {
|
|
out := make([]string, 0, len(paths))
|
|
seen := map[string]struct{}{}
|
|
for _, path := range paths {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
if _, ok := seen[path]; ok {
|
|
continue
|
|
}
|
|
seen[path] = struct{}{}
|
|
out = append(out, path)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// EnvTemplate is written next to the bat-api binary on first run when missing.
|
|
const EnvTemplate = `# bat-api configuration
|
|
# Priority: CLI flags > process environment > this file > built-in defaults.
|
|
# Resource auto-pull/sync is owned by the Rust bat daemon, not bat-api.
|
|
# bat-api only bootstraps/distributes already-published resources and exposes
|
|
# a small management surface for release inspection.
|
|
|
|
# HTTP listen address (host:port or :port)
|
|
BAT_API_LISTEN=:18080
|
|
# Public base URL used when rewriting AddressablesCatalogUrlRoot
|
|
BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080
|
|
|
|
# Rust bat daemon RPC socket (primary discovery path)
|
|
# Prefer BAT_API_SOCKET; BAT_API_STATE_DIR only derives the default socket path.
|
|
BAT_API_STATE_DIR=/tmp/bat-pid
|
|
# BAT_API_SOCKET=/tmp/bat-pid/bat.sock
|
|
|
|
# Optional override of the published release root (fixtures / emergency only).
|
|
# Production obtains resource_root from daemon RPC (release.attestation +
|
|
# generation-bound resource.manifest).
|
|
# BAT_API_RESOURCE_ROOT=
|
|
|
|
# Optional server-info JSON for Addressables root rewrite
|
|
# BAT_API_SERVER_INFO_FILE=
|
|
|
|
# CDN safety
|
|
BAT_API_REQUIRE_INDEXED=true
|
|
BAT_API_VERIFY_SIZE=true
|
|
BAT_API_RPC_TIMEOUT=30s
|
|
# Periodically re-read bat.sock so bat-api follows Rust bat release switches.
|
|
# Set to 0 to disable in fixture-only local development.
|
|
BAT_API_REFRESH_INTERVAL=1m
|
|
|
|
# Player-facing HTTP controls.
|
|
# Prefer setting BAT_API_AUTH_TOKEN through a secret manager or process
|
|
# environment. Reverse proxies may inject Authorization: Bearer <token> or
|
|
# X-BAT-Token to authenticated upstream requests. Query token fallback uses
|
|
# BAT_API_AUTH_QUERY_PARAM and is supported for clients that cannot set headers.
|
|
# BAT_API_AUTH_TOKEN=
|
|
BAT_API_AUTH_QUERY_PARAM=bat_token
|
|
# BAT_API_AUTH_EXEMPT_PATHS=
|
|
BAT_API_TRUST_PROXY_HEADERS=false
|
|
BAT_API_ACCESS_LOG=false
|
|
# Zero disables in-process limiting; production may still enforce edge limits.
|
|
BAT_API_RATE_LIMIT_RPS=0
|
|
BAT_API_RATE_LIMIT_BURST=0
|
|
BAT_API_MAX_RESOURCE_LIMIT=1000
|
|
|
|
# Reserved for future API persistence (not required for resource CDN)
|
|
# BAT_API_DATABASE_URL=postgres://bat:@127.0.0.1:5432/bat?sslmode=disable
|
|
# BAT_API_DATABASE_PASSWORD=
|
|
# BAT_API_REDIS_URL=redis://127.0.0.1:6379/0
|
|
# BAT_API_REDIS_PASSWORD=
|
|
|
|
# Set to 1 to ignore this file entirely
|
|
# BAT_API_SKIP_ENV_FILE=0
|
|
`
|