// Command bat-api is the resource bootstrap and distribution HTTP service for BlueArchiveToolkit. // // Responsibility boundary: // - bat (Rust): official resource auto-discover, pull, verify, publish, daemon RPC // - bat-api (Go): startup resource bootstrap, server-info rewrite, // read-only distribution of published resources (CDN-shaped paths), release // inspection APIs, and normal process configuration (.env / flags for listen // port, RPC socket, reserved database/redis settings) // // bat-api discovers and periodically refreshes the current release through the // bat.sock JSON-RPC contract (daemon.status first, then daemon.doctor, then // catalog/resource methods). The production resource root comes from RPC; the // resource-root override is for local fixtures or emergency diagnostics. package main import ( "context" "flag" "fmt" "log" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "bat-api/internal/api" "bat-api/internal/backendrpc" ) func main() { log.SetFlags(log.LstdFlags | log.Lmsgprefix) log.SetPrefix("bat-api ") cfg := api.DefaultConfig() if os.Getenv("BAT_API_SKIP_ENV_FILE") != "1" { envPath := envFilePath() if err := ensureEnvTemplate(envPath); err != nil { log.Printf("warn: env template: %v", err) } if err := api.LoadEnvFile(envPath); err != nil { log.Fatalf("load .env: %v", err) } } api.ApplyEnv(&cfg) listen := flag.String("listen", cfg.Listen, "HTTP listen address") publicBase := flag.String("public-base-url", cfg.PublicBaseURL, "public base URL for Addressables rewrite") stateDir := flag.String("state-dir", cfg.StateDir, "bat daemon state dir (derives default socket)") socket := flag.String("socket", cfg.SocketPath, "path to bat.sock JSON-RPC socket (primary discovery)") resourceRoot := flag.String("resource-root", cfg.ResourceRoot, "override published release root (tests/emergency)") serverInfo := flag.String("server-info-file", cfg.ServerInfoFile, "optional server-info JSON path") requireIndexed := flag.Bool("require-indexed", cfg.RequireIndexed, "only serve files present in the release index") verifySize := flag.Bool("verify-size", cfg.VerifySize, "reject CDN files whose size differs from the index") rpcTimeout := flag.Duration("rpc-timeout", cfg.RPCTimeout, "daemon RPC timeout") refreshInterval := flag.Duration("refresh-interval", cfg.RefreshInterval, "periodic release discovery interval (0 disables)") authQueryParam := flag.String("auth-query-param", cfg.AuthQueryParam, "query parameter accepted for token auth fallback") authExemptPaths := flag.String("auth-exempt-paths", strings.Join(cfg.AuthExemptPaths, ","), "comma-separated auth-exempt exact paths or slash-prefixes") trustProxyHeaders := flag.Bool("trust-proxy-headers", cfg.TrustProxyHeaders, "trust X-Forwarded-For and X-Real-IP from reverse proxy") accessLog := flag.Bool("access-log", cfg.AccessLog, "enable per-request access logs without query strings") rateLimitRPS := flag.Float64("rate-limit-rps", cfg.RateLimitRPS, "per-client request rate limit; 0 disables") rateLimitBurst := flag.Int("rate-limit-burst", cfg.RateLimitBurst, "per-client rate limit burst") maxResourceLimit := flag.Int("max-resource-limit", cfg.MaxResourcePageLimit, "maximum /v1/resources page size") flag.Parse() cfg.Listen = *listen cfg.PublicBaseURL = *publicBase cfg.StateDir = *stateDir cfg.SocketPath = *socket cfg.ResourceRoot = *resourceRoot cfg.ServerInfoFile = *serverInfo cfg.RequireIndexed = *requireIndexed cfg.VerifySize = *verifySize cfg.RPCTimeout = *rpcTimeout cfg.RefreshInterval = *refreshInterval cfg.AuthQueryParam = *authQueryParam cfg.AuthExemptPaths = splitFlagCSV(*authExemptPaths) cfg.TrustProxyHeaders = *trustProxyHeaders cfg.AccessLog = *accessLog cfg.RateLimitRPS = *rateLimitRPS cfg.RateLimitBurst = *rateLimitBurst cfg.MaxResourcePageLimit = *maxResourceLimit // If socket still empty after flags, derive from state-dir. if cfg.SocketPath == "" { cfg.SocketPath = filepath.Join(cfg.StateDir, "bat.sock") } if err := cfg.Normalize(); err != nil { log.Fatalf("config: %v", err) } var backend api.Backend client := backendrpc.New(cfg.SocketPath) client.Timeout = cfg.RPCTimeout backend = api.RPCClient{Client: client} server := api.NewServer(cfg, backend, log.Default()) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() refreshCtx, refreshCancel := context.WithTimeout(ctx, cfg.RPCTimeout+5*time.Second) if err := server.Refresh(refreshCtx); err != nil { log.Printf("initial discover failed: %v (serving with empty/partial index)", err) } refreshCancel() server.StartRefreshLoop(ctx) if err := server.ListenAndServe(ctx); err != nil && err != context.Canceled { log.Fatalf("serve: %v", err) } } func envFilePath() string { exe, err := os.Executable() if err != nil { return api.EnvFileName } return filepath.Join(filepath.Dir(exe), api.EnvFileName) } func ensureEnvTemplate(path string) error { if _, err := os.Stat(path); err == nil { return nil } else if !os.IsNotExist(err) { return err } if err := os.WriteFile(path, []byte(api.EnvTemplate), 0o600); err != nil { return fmt.Errorf("write %s: %w", path, err) } log.Printf("wrote config template %s", path) return nil } func splitFlagCSV(raw string) []string { var out []string for _, part := range strings.Split(raw, ",") { part = strings.TrimSpace(part) if part != "" { out = append(out, part) } } return out }