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 const adminDefaultLogTail = 200 const adminMaxLogTail = 2000 type adminControlRequest struct { Force bool `json:"force"` } type adminTaskRequest struct { TaskID string `json:"task_id"` } 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/dashboard/", "/admin/schedules", "/admin/tasks", "/admin/tasks/status", "/admin/tasks/logs", "/admin/diagnostics", "/admin/logs", "/admin/parse/status", "/admin/parse/text-units", "/admin/parse/errors", "/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", }, 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/task-cancel", "/admin/control/translation-task-update", "/admin/control/translation-worker-run", "/admin/control/translation-proofread", "/admin/control/translation-memory-confirm", "/admin/control/translation-glossary-add", "/admin/control/translation-glossary-update", "/admin/control/translation-glossary-approve", "/admin/control/translation-glossary-deprecate", "/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 == "task-cancel" { s.handleAdminTaskCancel(w, r) 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 == "translation-memory-confirm" { s.handleAdminTranslationMemoryConfirm(w, r) return } if strings.HasPrefix(action, "translation-glossary-") { s.handleAdminGlossaryControl(w, r, action) 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 err := validateTranslationTaskUpdateParams(params); err != nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", err.Error()) 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) handleAdminTranslationMemoryConfirm(w http.ResponseWriter, r *http.Request) { backend, ok := s.backend.(TranslationMemoryBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "translation_memory_backend_unavailable", "Rust bat Translation Memory backend is unavailable") return } var params backendrpc.TranslationMemoryConfirmParams if !decodeAdminTranslationJSON(w, r, ¶ms) { return } if err := validateTranslationMemoryConfirmParams(params); err != nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_memory_params", err.Error()) return } result, err := backend.TranslationMemoryConfirm(r.Context(), params) if err != nil { s.writeControlBackendError(w, "translation-memory-confirm", err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: "translation-memory-confirm", RPCMethod: "translation.memory.confirm", Status: "accepted", Result: result, }) } func (s *Server) handleAdminGlossarySummary(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.(GlossaryBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable") return } params := backendrpc.GlossarySummaryParams{ GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"), } result, err := backend.GlossarySummary(r.Context(), params) if err != nil { s.writeControlBackendError(w, "translation-glossary-summary", 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) handleAdminGlossaryQuery(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.(GlossaryBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable") return } params := backendrpc.GlossaryQueryParams{ GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"), SourceText: firstTrimmedQuery(r.URL.Query(), "source_text"), Category: firstTrimmedQuery(r.URL.Query(), "category"), ReviewStatus: firstTrimmedQuery(r.URL.Query(), "review_status"), } if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { limit, err := strconv.ParseUint(raw, 10, 64) if err != nil || limit == 0 || limit > 1000 { writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "limit must be in 1..=1000") return } params.Limit = &limit } result, err := backend.GlossaryQuery(r.Context(), params) if err != nil { s.writeControlBackendError(w, "translation-glossary-query", 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) handleAdminGlossaryDiagnose(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.(GlossaryBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable") return } params := backendrpc.GlossaryDiagnoseParams{ GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"), SourceText: firstTrimmedQuery(r.URL.Query(), "source_text"), } if params.SourceText == "" { writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "source_text is required") return } if raw := firstTrimmedQuery(r.URL.Query(), "context", "source_context"); raw != "" { if err := json.Unmarshal([]byte(raw), ¶ms.Context); err != nil || params.Context == nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "context must be a JSON object with string values") return } } result, err := backend.GlossaryDiagnose(r.Context(), params) if err != nil { s.writeControlBackendError(w, "translation-glossary-diagnose", 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) handleAdminGlossaryControl(w http.ResponseWriter, r *http.Request, action string) { backend, ok := s.backend.(GlossaryBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable") return } switch action { case "translation-glossary-add", "translation-glossary-update": var params backendrpc.GlossaryTermMutationParams if !decodeAdminTranslationJSON(w, r, ¶ms) { return } if strings.TrimSpace(params.TermID) == "" || strings.TrimSpace(params.SourceTerm) == "" || strings.TrimSpace(params.RecommendedTranslation) == "" { writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id, source_term and recommended_translation are required") return } var result *backendrpc.GlossaryMutationReport var err error if action == "translation-glossary-add" { result, err = backend.GlossaryAdd(r.Context(), params) } else { if strings.TrimSpace(params.Reviewer) == "" { writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "reviewer is required for update") return } result, err = backend.GlossaryUpdate(r.Context(), params) } if err != nil { s.writeControlBackendError(w, action, err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: action, RPCMethod: glossaryControlRPCMethod(action), Status: "accepted", Result: result, }) case "translation-glossary-approve", "translation-glossary-deprecate": var params backendrpc.GlossaryReviewParams if !decodeAdminTranslationJSON(w, r, ¶ms) { return } if strings.TrimSpace(params.TermID) == "" || strings.TrimSpace(params.Reviewer) == "" { writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id and reviewer are required") return } var result *backendrpc.GlossaryMutationReport var err error if action == "translation-glossary-approve" { result, err = backend.GlossaryApprove(r.Context(), params) } else { result, err = backend.GlossaryDeprecate(r.Context(), params) } if err != nil { s.writeControlBackendError(w, action, err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: action, RPCMethod: glossaryControlRPCMethod(action), Status: "accepted", Result: result, }) default: writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown glossary control action") } } func glossaryControlRPCMethod(action string) string { switch action { case "translation-glossary-add": return "translation.glossary.add" case "translation-glossary-update": return "translation.glossary.update" case "translation-glossary-approve": return "translation.glossary.approve" case "translation-glossary-deprecate": return "translation.glossary.deprecate" default: return "" } } 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) handleAdminDiagnostics(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 } if s.backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "diagnostics_backend_unavailable", "Rust bat diagnostics backend is unavailable") return } result, err := s.backend.DaemonDoctor(r.Context()) if err != nil { s.writeControlBackendError(w, "diagnostics", 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) handleAdminLogs(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.(DaemonLogsBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "diagnostics_backend_unavailable", "Rust bat log backend is unavailable") return } tail, err := adminLogTail(r) if err != nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_log_query", err.Error()) return } result, err := backend.DaemonLogs(r.Context(), tail) if err != nil { s.writeControlBackendError(w, "daemon-logs", 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) handleAdminTasks(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.(TaskBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable") return } result, err := backend.TaskList(r.Context()) if err != nil { s.writeControlBackendError(w, "task-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 (s *Server) handleAdminTaskStatus(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.(TaskBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable") return } taskID, err := adminTaskIDQuery(r) if err != nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_task_query", err.Error()) return } result, err := backend.TaskStatus(r.Context(), taskID) if err != nil { s.writeControlBackendError(w, "task-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) handleAdminTaskLogs(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.(TaskBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable") return } taskID, err := adminTaskIDQuery(r) if err != nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_task_query", err.Error()) return } result, err := backend.TaskLogs(r.Context(), taskID) if err != nil { s.writeControlBackendError(w, "task-logs", 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) handleAdminParseStatus(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.(ParseBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "parse_backend_unavailable", "Rust bat parse backend is unavailable") return } result, err := backend.ParseStatus(r.Context()) if err != nil { s.writeControlBackendError(w, "parse-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) handleAdminParseTextUnits(w http.ResponseWriter, r *http.Request) { s.handleAdminParseTextUnitQuery(w, r, false) } func (s *Server) handleAdminParseErrors(w http.ResponseWriter, r *http.Request) { s.handleAdminParseTextUnitQuery(w, r, true) } func (s *Server) handleAdminParseTextUnitQuery(w http.ResponseWriter, r *http.Request, errorsOnly bool) { 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.(ParseBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "parse_backend_unavailable", "Rust bat parse backend is unavailable") return } query, err := parseTextUnitQueryParams(r) if err != nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_parse_query", err.Error()) return } var result json.RawMessage if errorsOnly { result, err = backend.ParseErrors(r.Context(), query) } else { result, err = backend.ParseTextUnits(r.Context(), query) } if err != nil { action := "parse-text-units" if errorsOnly { action = "parse-errors" } s.writeControlBackendError(w, action, 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) handleAdminTaskCancel(w http.ResponseWriter, r *http.Request) { backend, ok := s.backend.(TaskBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable") return } var params adminTaskRequest if !decodeAdminTaskJSON(w, r, ¶ms) { return } taskID := strings.TrimSpace(params.TaskID) if taskID == "" { writeErrorJSON(w, http.StatusBadRequest, "invalid_task_params", "task cancel requires task_id") return } result, err := backend.TaskCancel(r.Context(), taskID) if err != nil { s.writeControlBackendError(w, "task-cancel", err) return } writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ Service: "bat-api", Action: "task-cancel", RPCMethod: "task.cancel", Status: "accepted", Result: 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) handleAdminTranslationMemorySummary(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.(TranslationMemoryBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "translation_memory_backend_unavailable", "Rust bat Translation Memory backend is unavailable") return } params := backendrpc.TranslationMemorySummaryParams{ TranslationMemoryPath: firstTrimmedQuery(r.URL.Query(), "translation_memory_path", "tm_path"), } result, err := backend.TranslationMemorySummary(r.Context(), params) if err != nil { s.writeControlBackendError(w, "translation-memory-summary", 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) handleAdminTranslationMemoryQuery(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.(TranslationMemoryBackend) if !ok || backend == nil { writeErrorJSON(w, http.StatusServiceUnavailable, "translation_memory_backend_unavailable", "Rust bat Translation Memory backend is unavailable") return } params, err := translationMemoryQueryParams(r) if err != nil { writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_memory_query", err.Error()) return } result, err := backend.TranslationMemoryQuery(r.Context(), params) if err != nil { s.writeControlBackendError(w, "translation-memory-query", 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 translationMemoryQueryParams(r *http.Request) (backendrpc.TranslationMemoryQueryParams, error) { query := r.URL.Query() sourceText := strings.TrimSpace(query.Get("source_text")) if sourceText == "" { return backendrpc.TranslationMemoryQueryParams{}, errors.New("source_text is required") } params := backendrpc.TranslationMemoryQueryParams{ TranslationMemoryPath: firstTrimmedQuery(query, "translation_memory_path", "tm_path"), SourceText: sourceText, } if raw := firstTrimmedQuery(query, "source_context", "context"); raw != "" { var context backendrpc.TranslationMemoryContext if raw != "null" { if err := json.Unmarshal([]byte(raw), &context); err != nil { return backendrpc.TranslationMemoryQueryParams{}, errors.New("source_context must be a JSON object with string values") } if context == nil { return backendrpc.TranslationMemoryQueryParams{}, errors.New("source_context must be a JSON object") } params.SourceContext = context } } if raw := strings.TrimSpace(query.Get("limit")); raw != "" { limit, err := strconv.ParseUint(raw, 10, 64) if err != nil || limit == 0 || limit > 1000 { return backendrpc.TranslationMemoryQueryParams{}, errors.New("limit must be in 1..=1000") } params.Limit = &limit } return params, nil } func parseTextUnitQueryParams(r *http.Request) (backendrpc.TextUnitQueryParams, error) { query := r.URL.Query() params := backendrpc.TextUnitQueryParams{ Destination: strings.TrimSpace(query.Get("destination")), PathPattern: strings.TrimSpace(query.Get("path_pattern")), ArchiveEntry: strings.TrimSpace(query.Get("archive_entry")), FieldPath: strings.TrimSpace(query.Get("field_path")), Format: strings.TrimSpace(query.Get("format")), } if raw := strings.TrimSpace(query.Get("offset")); raw != "" { offset, err := strconv.ParseInt(raw, 10, 32) if err != nil || offset < 0 { return backendrpc.TextUnitQueryParams{}, errors.New("offset must be a non-negative integer") } params.Offset = int(offset) } if raw := strings.TrimSpace(query.Get("limit")); raw != "" { limit, err := strconv.ParseInt(raw, 10, 32) if err != nil || limit < 1 || limit > 1000 { return backendrpc.TextUnitQueryParams{}, errors.New("limit must be in 1..=1000") } params.Limit = int(limit) } if raw := strings.TrimSpace(query.Get("path_id")); raw != "" { pathID, err := strconv.ParseInt(raw, 10, 64) if err != nil { return backendrpc.TextUnitQueryParams{}, errors.New("path_id must be a signed integer") } params.PathID = &pathID } if raw := strings.TrimSpace(query.Get("class_id")); raw != "" { classID, err := strconv.Atoi(raw) if err != nil { return backendrpc.TextUnitQueryParams{}, errors.New("class_id must be a signed integer") } params.ClassID = &classID } 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 adminLogTail(r *http.Request) (int, error) { raw := strings.TrimSpace(r.URL.Query().Get("tail")) if raw == "" { return adminDefaultLogTail, nil } tail, err := strconv.Atoi(raw) if err != nil || tail < 1 || tail > adminMaxLogTail { return 0, errors.New("tail must be in 1..=2000") } return tail, nil } func adminTaskIDQuery(r *http.Request) (string, error) { taskID := strings.TrimSpace(r.URL.Query().Get("task_id")) if taskID == "" { return "", errors.New("task_id is required") } return taskID, nil } 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 decodeAdminTaskJSON(w http.ResponseWriter, r *http.Request, target any) bool { return decodeAdminJSON(w, r, target, "invalid_task_params", "task request") } func validateTranslationTaskUpdateParams(params backendrpc.TranslationTaskUpdateParams) error { if strings.TrimSpace(params.TaskID) == "" || strings.TrimSpace(params.Status) == "" { return errors.New("translation task update requires task_id and status") } if len(params.TranslationResults) == 0 { return nil } if strings.TrimSpace(params.Status) != "completed" { return errors.New("translation_results can only be submitted with completed status") } seen := make(map[string]struct{}, len(params.TranslationResults)) for _, result := range params.TranslationResults { unitID := strings.TrimSpace(result.UnitID) if unitID == "" { return errors.New("translation_results unit_id is required") } if _, ok := seen[unitID]; ok { return errors.New("translation_results unit_id must be unique") } seen[unitID] = struct{}{} } return nil } 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 validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryConfirmParams) error { if strings.TrimSpace(params.RecordID) == "" || strings.TrimSpace(params.Reviewer) == "" { return errors.New("Translation Memory confirm requires record_id and reviewer") } 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) }