// 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 }