package api import ( "context" "encoding/json" "errors" "io" "net/http" "net/url" "strconv" "strings" "bat-api/internal/backendrpc" ) const adminControlMaxBodyBytes = 1024 const adminScheduleMaxBodyBytes = 64 * 1024 type adminControlRequest struct { Force bool `json:"force"` } func (s *Server) handleAdminIndex(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 } body := AdminIndexResponse{ Service: "bat-api", Panel: "admin", Status: "available", Links: []string{ "/healthz", "/readyz", "/v1/bootstrap", "/v1/release", "/v1/resources", "/openapi.yaml", "/admin/schedules", "/admin/translation/tasks", "/admin/translation/handoff", "/admin/translation/status", }, Controls: []string{ "/admin/control/reload", "/admin/control/refresh", "/admin/control/restart", "/admin/control/sync", "/admin/control/verify", "/admin/control/repair", "/admin/control/catalog-refresh", "/admin/control/schedule-add", "/admin/control/schedule-update", "/admin/control/schedule-remove", "/admin/control/schedule-run", "/admin/control/translation-task-update", "/admin/control/translation-worker-run", "/admin/control/translation-proofread", "/admin/control/localized-publish", "/admin/control/localized-rollback", }, } if r.Method == http.MethodHead { w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) return } writeNoStoreJSON(w, http.StatusOK, body) } func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } if !s.requireAdminToken(w, r) { return } action := strings.TrimPrefix(r.URL.Path, "/admin/control/") if action == "" || strings.Contains(action, "/") { writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action") return } if strings.HasPrefix(action, "schedule-") { s.handleAdminScheduleControl(w, r, action) return } if action == "translation-task-update" { s.handleAdminTranslationTaskUpdate(w, r) return } if action == "translation-worker-run" { s.handleAdminTranslationWorkerRun(w, r) return } if action == "translation-proofread" { s.handleAdminTranslationProofread(w, r) return } if action == "localized-publish" { s.handleAdminLocalizedPublish(w, r) return } if action == "localized-rollback" { s.handleAdminLocalizedRollback(w, r) return } request, ok := decodeAdminControlRequest(w, r) if !ok { return } backend, ok := s.backend.(ControlBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "control_backend_unavailable", "Rust bat control backend is unavailable") return } var ( method string result any err error ) switch action { case "reload": if request.Force { writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "reload does not accept force") return } method = "daemon.reload" result, err = backend.DaemonReload(r.Context()) case "refresh": method = "daemon.refresh" result, err = backend.DaemonRefresh(r.Context(), request.Force) case "restart": if request.Force { writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "restart does not accept force") return } method = "daemon.restart" result, err = backend.DaemonRestart(r.Context()) case "sync": method = "resource.sync" result, err = backend.ResourceSync(r.Context(), request.Force) case "verify": if request.Force { writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "verify does not accept force") return } method = "resource.verify" result, err = backend.ResourceVerify(r.Context()) case "repair": if request.Force { writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "repair does not accept force") return } method = "resource.repair" result, err = backend.ResourceRepair(r.Context()) case "catalog-refresh": method = "catalog.refresh" result, err = backend.CatalogRefresh(r.Context(), request.Force) case "stop", "clean-stable": writeErrorJSON(w, http.StatusForbidden, "control_not_allowed", "control action is not exposed by bat-api") return default: writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action") return } if err != nil { s.writeControlBackendError(w, action, err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: action, RPCMethod: method, Status: "accepted", Result: result, }) } func (s *Server) handleAdminTranslationTaskUpdate(w http.ResponseWriter, r *http.Request) { backend, ok := s.backend.(TranslationBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "translation_backend_unavailable", "Rust bat translation backend is unavailable") return } var params backendrpc.TranslationTaskUpdateParams if !decodeAdminTranslationJSON(w, r, ¶ms) { return } if strings.TrimSpace(params.TaskID) == "" || strings.TrimSpace(params.Status) == "" { writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", "translation task update requires task_id and status") return } result, err := backend.TranslationTaskUpdate(r.Context(), params) if err != nil { s.writeControlBackendError(w, "translation-task-update", err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: "translation-task-update", RPCMethod: "translation.task.update", Status: "accepted", Result: result, }) } func (s *Server) handleAdminTranslationWorkerRun(w http.ResponseWriter, r *http.Request) { backend, ok := s.backend.(TranslationBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "translation_backend_unavailable", "Rust bat translation backend is unavailable") return } var params backendrpc.TranslationWorkerRunParams if !decodeAdminTranslationJSON(w, r, ¶ms) { return } if err := validateTranslationWorkerRunParams(params); err != nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", err.Error()) return } result, err := backend.TranslationWorkerRun(r.Context(), params) if err != nil { s.writeControlBackendError(w, "translation-worker-run", err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: "translation-worker-run", RPCMethod: "translation.worker.run", Status: "accepted", Result: result, }) } func (s *Server) handleAdminTranslationProofread(w http.ResponseWriter, r *http.Request) { backend, ok := s.backend.(TranslationBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "translation_backend_unavailable", "Rust bat translation backend is unavailable") return } result, err := backend.TranslationProofread(r.Context()) if err != nil { s.writeControlBackendError(w, "translation-proofread", err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: "translation-proofread", RPCMethod: "translation.proofread", Status: "accepted", Result: result, }) } func (s *Server) handleAdminLocalizedPublish(w http.ResponseWriter, r *http.Request) { backend, ok := s.backend.(LocalizedBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "localized_backend_unavailable", "Rust bat localized backend is unavailable") return } var params backendrpc.LocalizedPublishParams if !decodeAdminTranslationJSON(w, r, ¶ms) { return } if err := validateLocalizedPublishParams(params); err != nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_localized_params", err.Error()) return } result, err := backend.LocalizedPublish(r.Context(), params) if err != nil { s.writeControlBackendError(w, "localized-publish", err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: "localized-publish", RPCMethod: "localized.publish", Status: "accepted", Result: result, }) } func (s *Server) handleAdminLocalizedRollback(w http.ResponseWriter, r *http.Request) { backend, ok := s.backend.(LocalizedBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "localized_backend_unavailable", "Rust bat localized backend is unavailable") return } var params backendrpc.LocalizedRollbackParams if !decodeAdminTranslationJSON(w, r, ¶ms) { return } result, err := backend.LocalizedRollback(r.Context(), params) if err != nil { s.writeControlBackendError(w, "localized-rollback", err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: "localized-rollback", RPCMethod: "localized.rollback", Status: "accepted", Result: result, }) } func (s *Server) handleAdminLocalizedStatus(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 } if !s.requireAdminToken(w, r) { return } backend, ok := s.backend.(LocalizedBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "localized_backend_unavailable", "Rust bat localized backend is unavailable") return } result, err := backend.LocalizedStatus(r.Context()) if err != nil { s.writeControlBackendError(w, "localized-status", err) return } if r.Method == http.MethodHead { w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) return } writeNoStoreJSON(w, http.StatusOK, result) } func (s *Server) handleAdminTranslationTasks(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 } if !s.requireAdminToken(w, r) { return } backend, ok := s.backend.(TranslationBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "translation_backend_unavailable", "Rust bat translation backend is unavailable") return } params, err := translationTaskListParams(r) if err != nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_query", err.Error()) return } result, err := backend.TranslationTasks(r.Context(), params) if err != nil { s.writeControlBackendError(w, "translation-tasks", err) return } if r.Method == http.MethodHead { w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) return } writeNoStoreJSON(w, http.StatusOK, result) } func (s *Server) handleAdminTranslationHandoff(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 } if !s.requireAdminToken(w, r) { return } backend, ok := s.backend.(TranslationBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "translation_backend_unavailable", "Rust bat translation backend is unavailable") return } result, err := backend.TranslationHandoff(r.Context()) if err != nil { s.writeControlBackendError(w, "translation-handoff", err) return } if r.Method == http.MethodHead { w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) return } writeNoStoreJSON(w, http.StatusOK, result) } func (s *Server) handleAdminSchedules(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 } if !s.requireAdminToken(w, r) { return } backend, ok := s.backend.(ScheduleBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "schedule_backend_unavailable", "Rust bat schedule backend is unavailable") return } params, err := scheduleListParams(r) if err != nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_schedule_query", err.Error()) return } result, err := backend.ScheduleList(r.Context(), params) if err != nil { s.writeControlBackendError(w, "schedule-list", err) return } if r.Method == http.MethodHead { w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) return } writeNoStoreJSON(w, http.StatusOK, result) } func scheduleListParams(r *http.Request) (backendrpc.ScheduleListParams, error) { query := r.URL.Query() params := backendrpc.ScheduleListParams{ ID: query.Get("id"), Group: query.Get("group"), } if raw := query.Get("enabled"); raw != "" { enabled, err := strconv.ParseBool(raw) if err != nil { return backendrpc.ScheduleListParams{}, errors.New("enabled must be a boolean") } params.Enabled = &enabled } return params, nil } func translationTaskListParams(r *http.Request) (backendrpc.TranslationTaskListParams, error) { query := r.URL.Query() params := backendrpc.TranslationTaskListParams{ TaskID: strings.TrimSpace(query.Get("task_id")), ReleaseID: firstTrimmedQuery(query, "release_id", "official_release_id"), Destination: strings.TrimSpace(query.Get("destination")), PathPattern: strings.TrimSpace(query.Get("path_pattern")), ArchiveEntry: strings.TrimSpace(query.Get("archive_entry")), Status: firstTrimmedQuery(query, "status", "task_status"), WorkerStatus: strings.TrimSpace(query.Get("worker_status")), ParseStatus: strings.TrimSpace(query.Get("parse_status")), Format: firstTrimmedQuery(query, "format", "text_unit_format"), } if raw := strings.TrimSpace(query.Get("offset")); raw != "" { offset, err := strconv.ParseUint(raw, 10, 64) if err != nil { return backendrpc.TranslationTaskListParams{}, errors.New("offset must be a non-negative integer") } params.Offset = &offset } if raw := strings.TrimSpace(query.Get("limit")); raw != "" { limit, err := strconv.ParseUint(raw, 10, 64) if err != nil || limit == 0 || limit > 1000 { return backendrpc.TranslationTaskListParams{}, errors.New("limit must be in 1..=1000") } params.Limit = &limit } if raw := strings.TrimSpace(query.Get("has_reason")); raw != "" { hasReason, err := strconv.ParseBool(raw) if err != nil { return backendrpc.TranslationTaskListParams{}, errors.New("has_reason must be a boolean") } params.HasReason = &hasReason } if raw := strings.TrimSpace(query.Get("has_failure_reason")); raw != "" { hasFailureReason, err := strconv.ParseBool(raw) if err != nil { return backendrpc.TranslationTaskListParams{}, errors.New("has_failure_reason must be a boolean") } params.HasFailureReason = &hasFailureReason } return params, nil } func firstTrimmedQuery(query url.Values, keys ...string) string { for _, key := range keys { values := query[key] if len(values) == 0 { continue } if value := strings.TrimSpace(values[0]); value != "" { return value } } return "" } func (s *Server) handleAdminScheduleControl(w http.ResponseWriter, r *http.Request, action string) { backend, ok := s.backend.(ScheduleBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "schedule_backend_unavailable", "Rust bat schedule backend is unavailable") return } var ( method string result json.RawMessage err error ) switch action { case "schedule-add", "schedule-update", "schedule-remove": var params backendrpc.ScheduleMutationParams if !decodeAdminScheduleJSON(w, r, ¶ms) { return } switch action { case "schedule-add": method = "schedule.add" result, err = backend.ScheduleAdd(r.Context(), params) case "schedule-update": method = "schedule.update" result, err = backend.ScheduleUpdate(r.Context(), params) default: method = "schedule.remove" result, err = backend.ScheduleRemove(r.Context(), params) } case "schedule-run": var params backendrpc.ScheduleRunParams if !decodeAdminScheduleJSON(w, r, ¶ms) { return } method = "schedule.run" result, err = backend.ScheduleRun(r.Context(), params) default: writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action") return } if err != nil { s.writeControlBackendError(w, action, err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: action, RPCMethod: method, Status: "accepted", Result: result, }) } func (s *Server) requireAdminToken(w http.ResponseWriter, r *http.Request) bool { if s.cfg.AuthToken == "" { writeErrorJSON(w, http.StatusForbidden, "admin_auth_required", "admin controls require BAT_API_AUTH_TOKEN") return false } if !constantTimeTokenEqual(s.requestToken(r), s.cfg.AuthToken) { w.Header().Set("WWW-Authenticate", `Bearer realm="bat-api-admin"`) writeErrorJSON(w, http.StatusUnauthorized, "unauthorized", "missing or invalid access token") return false } return true } func decodeAdminScheduleJSON(w http.ResponseWriter, r *http.Request, target any) bool { return decodeAdminJSON(w, r, target, "invalid_schedule_params", "schedule request") } func decodeAdminTranslationJSON(w http.ResponseWriter, r *http.Request, target any) bool { return decodeAdminJSON(w, r, target, "invalid_translation_params", "translation request") } func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunParams) error { if params.Concurrency != nil && (*params.Concurrency < 1 || *params.Concurrency > 256) { return errors.New("translation worker concurrency must be in 1..=256") } if params.MaxAttempts != nil && *params.MaxAttempts == 0 { return errors.New("translation worker max_attempts must be greater than 0") } if params.LeaseSeconds != nil && *params.LeaseSeconds == 0 { return errors.New("translation worker lease_seconds must be greater than 0") } if params.MaxTasks != nil && *params.MaxTasks == 0 { return errors.New("translation worker max_tasks must be greater than 0") } return nil } func validateLocalizedPublishParams(params backendrpc.LocalizedPublishParams) error { hasFile := strings.TrimSpace(params.TranslationFile) != "" if hasFile == params.FromWorker { return errors.New("localized publish requires exactly one of translation_file or from_worker") } return nil } func decodeAdminJSON(w http.ResponseWriter, r *http.Request, target any, errorCode string, subject string) bool { if r.Body == nil { return true } decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, adminScheduleMaxBodyBytes)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { if errors.Is(err, io.EOF) { return true } writeErrorJSON(w, http.StatusBadRequest, errorCode, subject+" must be a JSON object") return false } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { writeErrorJSON(w, http.StatusBadRequest, errorCode, subject+" must contain exactly one JSON object") return false } return true } func decodeAdminControlRequest(w http.ResponseWriter, r *http.Request) (adminControlRequest, bool) { var request adminControlRequest if r.Body == nil { return request, true } decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, adminControlMaxBodyBytes)) decoder.DisallowUnknownFields() if err := decoder.Decode(&request); err != nil { if errors.Is(err, io.EOF) { return request, true } writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "control request must be a JSON object with an optional force boolean") return adminControlRequest{}, false } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "control request must contain exactly one JSON object") return adminControlRequest{}, false } return request, true } func (s *Server) writeControlBackendError(w http.ResponseWriter, action string, err error) { status := http.StatusBadGateway code := "control_backend_failed" message := "Rust bat rejected the control request" switch { case errors.Is(err, context.DeadlineExceeded): status = http.StatusGatewayTimeout code = "control_backend_timeout" message = "Rust bat control request timed out" case errors.Is(err, context.Canceled): status = http.StatusRequestTimeout code = "control_request_canceled" message = "control request was canceled" default: var apiErr *backendrpc.APIError if errors.As(err, &apiErr) && apiErr.Kind == "not_implemented" { status = http.StatusNotImplemented code = "control_not_implemented" message = "Rust bat does not implement this control action" } } s.logger.Printf("bat-api control action=%s error=%v", action, err) writeErrorJSON(w, status, code, message) }