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