Files
BlueArchiveToolkit/internal/api/config.go
T

278 lines
8.1 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"
ConfigFileName = "config.toml"
ConfigExampleName = "config.toml.example"
)
// Config holds bat-api process configuration.
//
// Values come from (highest wins): CLI flags > process environment >
// config.toml > 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
// 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
}
// 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_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
}