package server import ( "encoding/json" "net/http" "strings" "git.misaka.ren/m1saka/record-asr/internal/audio" "git.misaka.ren/m1saka/record-asr/internal/store" ) // writeJSON marshals v as JSON with the given status. func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // handleHealthz reports liveness and model readiness (public). func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "models": s.manager != nil && s.manager.Ready(), "version": s.modelVersion(), }) } // handleRoot returns a tiny service descriptor (public). func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } writeJSON(w, http.StatusOK, map[string]any{ "name": "record-asr", "engine": "SenseVoiceSmall (int8) + silero VAD", "version": s.modelVersion(), "endpoints": []string{ "POST /api/tasks (multipart: file) -> enqueue", "GET /api/tasks -> list", "GET /api/tasks/{id} -> detail", "DELETE /api/tasks/{id} -> delete", "POST /api/tasks/{id}/retry -> re-run failed task", "POST /api/tasks/{id}/skip -> abandon failed task", "GET /api/tasks/{id}/transcript -> transcript", }, }) } // handleCreateTask accepts a multipart upload and enqueues a transcription task. func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes) if err := r.ParseMultipartForm(s.cfg.MaxUploadBytes); err != nil { renderError(w, http.StatusRequestEntityTooLarge, "upload rejected: "+err.Error()) return } file, hdr, err := r.FormFile("file") if err != nil { renderError(w, http.StatusBadRequest, "missing 'file' multipart field") return } defer file.Close() name := hdr.Filename if name == "" { name = "upload.wav" } if !audio.IsSupported(name) { renderError(w, http.StatusUnsupportedMediaType, "unsupported extension; supported: "+audio.SupportedExtensions()) return } t, err := s.store.Create(name, file, s.cfg.MaxUploadBytes, s.cfg.MaxRetries) if err != nil { renderError(w, http.StatusInternalServerError, "store: "+err.Error()) return } w.Header().Set("Location", "/api/tasks/"+t.ID) writeJSON(w, http.StatusAccepted, t) } // handleListTasks returns all tasks newest-first. func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"tasks": s.store.List()}) } // handleGetTask returns one task. func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") t := s.store.Get(id) if t == nil { renderError(w, http.StatusNotFound, "task not found") return } writeJSON(w, http.StatusOK, t) } // handleDeleteTask removes a task and its audio. func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := s.store.Delete(id); err != nil { if err == store.ErrNotFound { renderError(w, http.StatusNotFound, "task not found") return } renderError(w, http.StatusInternalServerError, "delete: "+err.Error()) return } w.WriteHeader(http.StatusNoContent) } // handleRetryTask requeues a failed/skipped task for another attempt. func (s *Server) handleRetryTask(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := s.store.Retry(id); err != nil { if err == store.ErrNotFound { renderError(w, http.StatusNotFound, "task not found") return } renderError(w, http.StatusInternalServerError, "retry: "+err.Error()) return } t := s.store.Get(id) writeJSON(w, http.StatusAccepted, t) } // handleSkipTask abandons a task so the queue moves on (the "continue" action). func (s *Server) handleSkipTask(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := s.store.Skip(id); err != nil { if err == store.ErrNotFound { renderError(w, http.StatusNotFound, "task not found") return } renderError(w, http.StatusInternalServerError, "skip: "+err.Error()) return } t := s.store.Get(id) writeJSON(w, http.StatusAccepted, t) } // handleTranscript returns just the transcript of a completed task. func (s *Server) handleTranscript(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") t := s.store.Get(id) if t == nil { renderError(w, http.StatusNotFound, "task not found") return } if t.Status != store.StatusCompleted || t.Result == nil { renderError(w, http.StatusConflict, "transcript not ready (status: "+t.Status+")") return } writeJSON(w, http.StatusOK, t.Result) } // --- auth --- // authMiddleware wraps a handler with the password check. Auth is enforced // for paths under /api/ and /v1/ (the OpenAI-compatible endpoint); /healthz // and / stay public. When ASR_PASSWORD is unset, auth is disabled (a warning // was logged at startup). func (s *Server) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if (!strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasPrefix(r.URL.Path, "/v1/")) || !s.cfg.AuthEnabled() { next.ServeHTTP(w, r) return } if !constantTimeEqual(extractToken(r), s.cfg.Password) { w.Header().Set("WWW-Authenticate", `Bearer realm="record-asr"`) renderError(w, http.StatusUnauthorized, "unauthorized") return } next.ServeHTTP(w, r) }) } // extractToken pulls the shared secret from, in order: X-Api-Key header, // Authorization: Bearer , or ?token= query parameter. func extractToken(r *http.Request) string { if v := strings.TrimSpace(r.Header.Get("X-Api-Key")); v != "" { return v } if v := r.Header.Get("Authorization"); v != "" { if strings.HasPrefix(strings.ToLower(v), "bearer ") { return strings.TrimSpace(v[7:]) } return strings.TrimSpace(strings.TrimPrefix(v, "Bearer ")) } return strings.TrimSpace(r.URL.Query().Get("token")) } // constantTimeEqual compares two strings in constant time. func constantTimeEqual(a, b string) bool { if len(a) != len(b) { return false } var v byte for i := 0; i < len(a); i++ { v |= a[i] ^ b[i] } return v == 0 }