package api import ( "context" "encoding/json" "log" "net/http" "strconv" "strings" "sync" "time" ) // Server is the bat-api HTTP server for resource bootstrap and distribution. type Server struct { cfg Config backend Backend logger *log.Logger limiter *tokenBucketLimiter mu sync.RWMutex idx *ReleaseIndex meta DiscoverResult refreshInProgress bool lastRefreshStart time.Time lastRefreshFinish time.Time lastRefreshOK time.Time lastRefreshError string lastRefreshWarns []string lastRefreshDur time.Duration } // NewServer constructs a server. Call Refresh before Listen when possible. func NewServer(cfg Config, backend Backend, logger *log.Logger) *Server { if logger == nil { logger = log.Default() } var limiter *tokenBucketLimiter if cfg.RateLimitRPS > 0 { limiter = newTokenBucketLimiter(cfg.RateLimitRPS, cfg.RateLimitBurst) } return &Server{cfg: cfg, backend: backend, logger: logger, limiter: limiter} } // Handler returns the root HTTP handler. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", s.handleHealthz) mux.HandleFunc("/readyz", s.handleReadyz) mux.HandleFunc("/v1/bootstrap", s.handleBootstrap) mux.HandleFunc("/v1/launcher/bootstrap", s.handleLauncherBootstrap) mux.HandleFunc("/v1/release", s.handleRelease) mux.HandleFunc("/v1/resources", s.handleResources) mux.HandleFunc("/v1/server-info", s.handleServerInfoDebug) mux.HandleFunc("/api/launcher/game/config", s.handleLauncherGameConfig) mux.HandleFunc("/api/launcher/game/config/json", s.handleLauncherGameConfigJSON) mux.HandleFunc("/api/launcher/advanced/game/download/cdn", s.handleLauncherCdnConfig) mux.HandleFunc(launcherHostPath("/api/launcher/game/config"), s.handleLauncherGameConfig) mux.HandleFunc(launcherHostPath("/api/launcher/game/config/json"), s.handleLauncherGameConfigJSON) mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig) mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap) mux.HandleFunc("/openapi.yaml", s.handleOpenAPI) mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard) mux.HandleFunc("/admin/dashboard/", s.handleAdminDashboard) mux.HandleFunc("/admin/diagnostics", s.handleAdminDiagnostics) mux.HandleFunc("/admin/logs", s.handleAdminLogs) mux.HandleFunc("/admin/tasks", s.handleAdminTasks) mux.HandleFunc("/admin/tasks/status", s.handleAdminTaskStatus) mux.HandleFunc("/admin/tasks/logs", s.handleAdminTaskLogs) mux.HandleFunc("/admin/parse/status", s.handleAdminParseStatus) mux.HandleFunc("/admin/parse/text-units", s.handleAdminParseTextUnits) mux.HandleFunc("/admin/parse/errors", s.handleAdminParseErrors) mux.HandleFunc("/admin/schedules", s.handleAdminSchedules) mux.HandleFunc("/admin/translation/tasks", s.handleAdminTranslationTasks) mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff) mux.HandleFunc("/admin/translation/memory/summary", s.handleAdminTranslationMemorySummary) mux.HandleFunc("/admin/translation/memory/query", s.handleAdminTranslationMemoryQuery) mux.HandleFunc("/admin/translation/glossary/summary", s.handleAdminGlossarySummary) mux.HandleFunc("/admin/translation/glossary/query", s.handleAdminGlossaryQuery) mux.HandleFunc("/admin/translation/glossary/diagnose", s.handleAdminGlossaryDiagnose) mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus) mux.HandleFunc("/admin/control/", s.handleAdminControl) mux.HandleFunc("/admin/", s.handleAdminIndex) mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN) mux.HandleFunc("/"+ClientPatchHost+"/", s.serveCDN) // Catch-all for other official host prefixes and 404. mux.HandleFunc("/", s.handleRoot) return s.wrapHandler(mux) } // Refresh rebuilds the release index via RPC (and optional resource-root override). func (s *Server) Refresh(ctx context.Context) error { started := s.beginRefresh() result, err := DiscoverAndIndex(ctx, s.backend, s.cfg.ResourceRoot) if err != nil { s.finishRefresh(started, err, nil) return err } s.mu.Lock() s.meta = *result s.idx = result.Index s.finishRefreshLocked(started, nil, result.Warnings) s.mu.Unlock() for _, w := range result.Warnings { s.logger.Printf("bat-api discover warning: %s", w) } return nil } func (s *Server) index() *ReleaseIndex { s.mu.RLock() defer s.mu.RUnlock() return s.idx } func (s *Server) discoverMeta() DiscoverResult { s.mu.RLock() defer s.mu.RUnlock() return s.meta } func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { writeNoStoreJSON(w, http.StatusOK, RootResponse{ Service: "bat-api", Role: "resource_bootstrap_and_distribution", Note: "resource auto-pull is owned by Rust bat; this service bootstraps and distributes published resources", Endpoints: []string{ "/healthz", "/readyz", "/v1/bootstrap", "/v1/launcher/bootstrap", "/v1/release", "/v1/resources", "/v1/server-info", "/api/launcher/game/config", "/api/launcher/game/config/json", "/api/launcher/advanced/game/download/cdn", "/" + LauncherAPIHost + "/api/launcher/game/config", "/" + LauncherAPIHost + "/api/launcher/game/config/json", "/" + LauncherAPIHost + "/api/launcher/advanced/game/download/cdn", "/" + ClientPatchHost + "/...", "/" + ServerInfoHost + "/...", "/openapi.yaml", "/admin/dashboard/", "/admin/", "/admin/diagnostics", "/admin/logs", "/admin/tasks", "/admin/tasks/status", "/admin/tasks/logs", "/admin/parse/status", "/admin/parse/text-units", "/admin/parse/errors", "/admin/schedules", "/admin/translation/tasks", "/admin/translation/handoff", "/admin/translation/memory/summary", "/admin/translation/memory/query", "/admin/translation/glossary/summary", "/admin/translation/glossary/query", "/admin/translation/glossary/diagnose", "/admin/translation/status", "/admin/control/{action}", }, }) return } // Attempt CDN for known hosts not registered above. if strings.HasPrefix(r.URL.Path, "/"+ClientPatchHost+"/") || strings.HasPrefix(r.URL.Path, "/"+ServerInfoHost+"/") { if strings.HasPrefix(r.URL.Path, "/"+ServerInfoHost+"/") { s.handleServerInfoCDN(w, r) return } s.serveCDN(w, r) return } http.NotFound(w, r) } func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } meta := s.discoverMeta() sum := ReleaseSummary{} if idx := s.index(); idx != nil { sum = idx.Summary() } publicBase := strings.TrimRight(s.cfg.PublicBaseURL, "/") addressablesRoot := "" if sum.Snapshot != nil && sum.Snapshot.AddressablesRoot != "" { if rewritten, ok := rewriteAddressablesRoot(sum.Snapshot.AddressablesRoot, publicBase); ok { addressablesRoot = rewritten } } status := http.StatusOK if !sum.Ready { status = http.StatusServiceUnavailable } writeNoStoreJSON(w, status, BootstrapResponse{ Service: "bat-api", Ready: sum.Ready, Bat: BootstrapBat{ Role: "sync_daemon_and_release_producer", Socket: s.cfg.SocketPath, RPCAvailable: meta.RPCAvailable || sum.RPCAvailable, DoctorHealthy: sum.DoctorHealthy, }, BatAPI: BootstrapAPI{ Role: "resource_bootstrap_and_distribution", PublicBase: publicBase, RefreshIntervalSeconds: int64(s.cfg.RefreshInterval.Seconds()), Refresh: s.refreshSnapshot(), }, Resource: BootstrapResource{ Release: sum.Snapshot, ResourceRoot: sum.ResourceRoot, Source: sum.Source, ManifestVersion: sum.ManifestVersion, EntryCount: sum.EntryCount, PresentCount: sum.PresentCount, MissingCount: sum.MissingCount, ServerInfoURL: publicBase + "/" + ServerInfoHost + "/server-info.json", ClientPatchBaseURL: publicBase + "/" + ClientPatchHost, AddressablesCatalogURLRoot: addressablesRoot, }, Policy: BootstrapPolicy{ PullOwner: "rust_bat", ResourceRootDiscovery: "bat_sock_rpc", Deployment: "co_located_with_rust_bat_or_shared_filesystem", ResourceRootOverride: s.cfg.ResourceRoot != "", ServesOnlyPublishedRelease: true, WritesReleaseState: false, FullGameBusinessAPI: false, }, Endpoints: []string{ "/v1/launcher/bootstrap", "/api/launcher/game/config", "/api/launcher/game/config/json", "/api/launcher/advanced/game/download/cdn", "/v1/server-info", "/" + ServerInfoHost + "/server-info.json", "/" + ClientPatchHost + "/...", }, Warnings: meta.Warnings, }) } func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } meta := s.discoverMeta() sum := ReleaseSummary{} if idx := s.index(); idx != nil { sum = idx.Summary() } writeNoStoreJSON(w, http.StatusOK, map[string]any{ "ok": true, "service": "bat-api", "listen": s.cfg.Listen, "socket": s.cfg.SocketPath, "public_base": s.cfg.PublicBaseURL, "rpc_available": meta.RPCAvailable || sum.RPCAvailable, "doctor_healthy": sum.DoctorHealthy, "refresh_interval_seconds": int64(s.cfg.RefreshInterval.Seconds()), "refresh": s.refreshSnapshot(), "resource_root": sum.ResourceRoot, "resource_root_override_configured": s.cfg.ResourceRoot != "", "ready": sum.Ready, "entry_count": sum.EntryCount, "present_count": sum.PresentCount, "missing_count": sum.MissingCount, "source": sum.Source, "warnings": meta.Warnings, // Database/redis are reserved config surface for a normal API process. "database_configured": s.cfg.DatabaseURL != "", "redis_configured": s.cfg.RedisURL != "", }) } func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } meta := s.discoverMeta() sum := ReleaseSummary{} if idx := s.index(); idx != nil { sum = idx.Summary() } status := http.StatusOK if !sum.Ready { status = http.StatusServiceUnavailable } body := map[string]any{ "ready": sum.Ready, "service": "bat-api", "rpc_available": meta.RPCAvailable || sum.RPCAvailable, "resource_root": sum.ResourceRoot, "entry_count": sum.EntryCount, "present_count": sum.PresentCount, "missing_count": sum.MissingCount, "source": sum.Source, "refresh": s.refreshSnapshot(), } if r.Method == http.MethodHead { w.WriteHeader(status) return } writeNoStoreJSON(w, status, body) } func (s *Server) handleRelease(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } idx := s.index() if idx == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "release_not_ready", "release index not ready") return } writeNoStoreJSON(w, http.StatusOK, idx.Summary()) } func (s *Server) handleResources(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } idx := s.index() if idx == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "release_not_ready", "index not ready") return } offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if limit <= 0 { limit = 100 } if limit > s.cfg.MaxResourcePageLimit { limit = s.cfg.MaxResourcePageLimit } items, total := idx.List(offset, limit) writeNoStoreJSON(w, http.StatusOK, ResourceListResponse{ Total: total, Offset: offset, Limit: limit, Items: items, }) } func (s *Server) handleServerInfoDebug(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } s.writeServerInfo(w, r, "") } func (s *Server) handleServerInfoCDN(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } name := strings.TrimPrefix(r.URL.Path, "/"+ServerInfoHost+"/") name = strings.TrimPrefix(name, "/") s.writeServerInfo(w, r, name) } func (s *Server) writeServerInfo(w http.ResponseWriter, r *http.Request, name string) { raw, err := LoadServerInfoBytes(s.cfg, s.index(), name) if err != nil { writeErrorJSON(w, http.StatusNotFound, "server_info_not_found", err.Error()) return } rewritten, err := RewriteServerInfoAddressables(raw, s.cfg.PublicBaseURL) if err != nil { writeErrorJSON(w, http.StatusInternalServerError, "server_info_invalid", err.Error()) return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Length", strconv.Itoa(len(rewritten))) if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) return } w.WriteHeader(http.StatusOK) _, _ = w.Write(rewritten) } func writeJSON(w http.ResponseWriter, status int, body any) { data, err := json.Marshal(body) if err != nil { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusInternalServerError) fallback, _ := json.Marshal(ErrorResponse{ Error: ErrorBody{Code: "internal_error", Message: err.Error(), Status: http.StatusInternalServerError}, }) _, _ = w.Write(fallback) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _, _ = w.Write(data) } func (s *Server) beginRefresh() time.Time { now := time.Now() s.mu.Lock() s.refreshInProgress = true s.lastRefreshStart = now s.mu.Unlock() return now } func (s *Server) finishRefresh(started time.Time, err error, warnings []string) { s.mu.Lock() defer s.mu.Unlock() s.finishRefreshLocked(started, err, warnings) } func (s *Server) finishRefreshLocked(started time.Time, err error, warnings []string) { now := time.Now() s.refreshInProgress = false s.lastRefreshFinish = now s.lastRefreshDur = now.Sub(started) s.lastRefreshWarns = append([]string(nil), warnings...) if err != nil { s.lastRefreshError = err.Error() return } s.lastRefreshError = "" s.lastRefreshOK = now } func (s *Server) refreshSnapshot() RefreshDiagnostics { s.mu.RLock() defer s.mu.RUnlock() warnings := append([]string(nil), s.lastRefreshWarns...) return RefreshDiagnostics{ InProgress: s.refreshInProgress, LastAttemptUnixSeconds: unixPtr(s.lastRefreshStart), LastFinishedUnixSeconds: unixPtr(s.lastRefreshFinish), LastSuccessUnixSeconds: unixPtr(s.lastRefreshOK), LastDurationMilliseconds: s.lastRefreshDur.Milliseconds(), LastError: s.lastRefreshError, LastWarningCount: len(warnings), LastWarnings: warnings, RefreshIntervalSeconds: int64(s.cfg.RefreshInterval.Seconds()), ResourceRootOverrideActive: s.cfg.ResourceRoot != "", } } func unixPtr(t time.Time) *int64 { if t.IsZero() { return nil } v := t.Unix() return &v } // StartRefreshLoop keeps the in-memory release index aligned with the Rust bat daemon. func (s *Server) StartRefreshLoop(ctx context.Context) { if s.cfg.RefreshInterval <= 0 { s.logger.Printf("bat-api periodic release discovery disabled") return } go func() { ticker := time.NewTicker(s.cfg.RefreshInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: refreshCtx, cancel := context.WithTimeout(ctx, s.cfg.RPCTimeout+5*time.Second) if err := s.Refresh(refreshCtx); err != nil { s.logger.Printf("periodic discover failed: %v", err) } cancel() } } }() } // ListenAndServe starts the HTTP server until ctx is cancelled. func (s *Server) ListenAndServe(ctx context.Context) error { httpServer := &http.Server{ Addr: s.cfg.Listen, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, MaxHeaderBytes: 1 << 20, } errCh := make(chan error, 1) go func() { s.logger.Printf("bat-api listening on %s (socket=%s public=%s)", s.cfg.Listen, s.cfg.SocketPath, s.cfg.PublicBaseURL) errCh <- httpServer.ListenAndServe() }() select { case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = httpServer.Shutdown(shutdownCtx) return ctx.Err() case err := <-errCh: if err == http.ErrServerClosed { return nil } return err } }