Files
nyaKazuha 13b0bd5b45
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
fix(release):完善当前分发证明与质量门禁
2026-09-14 06:39:47 +08:00

1424 lines
45 KiB
Go

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/translation-glossary-delete",
"/admin/control/localized-publish",
"/admin/control/localized-rollback",
"/admin/control/release-cleanup",
},
}
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
}
if action == "release-cleanup" {
s.handleAdminReleaseCleanup(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, &params) {
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, &params) {
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, &params) {
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), &params.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, &params) {
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, &params) {
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,
})
case "translation-glossary-delete":
var params backendrpc.GlossaryDeleteParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if strings.TrimSpace(params.TermID) == "" ||
strings.TrimSpace(params.Reviewer) == "" ||
strings.TrimSpace(params.Reason) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id, reviewer and reason are required for delete")
return
}
result, err := backend.GlossaryDelete(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"
case "translation-glossary-delete":
return "translation.glossary.delete"
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, &params) {
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, &params) {
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) handleAdminReleaseCleanup(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
return
}
var params backendrpc.ReleaseCleanupParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if params.Execute && strings.TrimSpace(params.PlanID) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_release_cleanup_params", "execute cleanup requires plan_id from a dry run")
return
}
result, err := backend.ReleaseCleanup(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "release-cleanup", err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: "release-cleanup",
RPCMethod: "release.cleanup",
Status: "accepted",
Result: 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, &params) {
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, &params) {
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, &params) {
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) != ""
hasManifest := strings.TrimSpace(params.PatchManifest) != ""
inputs := 0
if hasFile {
inputs++
}
if params.FromWorker {
inputs++
}
if hasManifest {
inputs++
}
if inputs != 1 {
return errors.New("localized publish requires exactly one of translation_file, from_worker, or patch_manifest")
}
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)
}