package server import ( "encoding/json" "net/http" "strconv" "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(), "max_upload_bytes": s.cfg.MaxUploadBytes, "chunk_size": s.cfg.ChunkSize, }) } // 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) } // --- resumable chunked uploads --- // // Files too large to fit a single request through a size-capping proxy (e.g. // Cloudflare's 100MB body limit) are uploaded as many small slices instead. // handleCreateUpload starts a session, handleUploadChunk appends one slice, // handleCompleteUpload finalizes it into a task, and handleAbortUpload drops // it. Each slice is <= ASR_CHUNK_SIZE_MB, safely under the proxy cap. // handleCreateUpload starts a resumable chunked upload session. The request // body is a small JSON {filename, size}; the response carries the upload id, // the chunk size, and the total chunk count the client should send. func (s *Server) handleCreateUpload(w http.ResponseWriter, r *http.Request) { var req struct { Filename string `json:"filename"` Size int64 `json:"size"` } dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) if err := dec.Decode(&req); err != nil { renderError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if req.Filename == "" { renderError(w, http.StatusBadRequest, "missing 'filename'") return } if req.Size <= 0 { renderError(w, http.StatusBadRequest, "missing or invalid 'size'") return } if !audio.IsSupported(req.Filename) { renderError(w, http.StatusUnsupportedMediaType, "unsupported extension; supported: "+audio.SupportedExtensions()) return } sess, err := s.store.CreateUpload(req.Filename, req.Size, s.cfg.ChunkSize, s.cfg.MaxUploadBytes) if err != nil { renderError(w, http.StatusBadRequest, "init upload: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{ "upload_id": sess.UploadID, "chunk_size": sess.ChunkSize, "total_chunks": sess.TotalChunks, }) } // handleUploadChunk appends one chunk (#index, the 0-based query param) of a // chunked upload session. The request body is the raw chunk bytes // (application/octet-stream); each slice is capped at ASR_CHUNK_SIZE_MB. func (s *Server) handleUploadChunk(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") idx, err := strconv.Atoi(r.URL.Query().Get("index")) if err != nil || idx < 0 { renderError(w, http.StatusBadRequest, "missing or invalid 'index' query param") return } r.Body = http.MaxBytesReader(w, r.Body, s.cfg.ChunkSize) if err := s.store.AppendChunk(id, idx, r.Body, s.cfg.ChunkSize); err != nil { if err == store.ErrUploadNotFound { renderError(w, http.StatusNotFound, "upload session not found or expired") return } renderError(w, http.StatusBadRequest, "chunk: "+err.Error()) return } received, total, ok := s.store.UploadStatus(id) if !ok { renderError(w, http.StatusNotFound, "upload session not found") return } writeJSON(w, http.StatusOK, map[string]any{ "index": idx, "received": received, "total": total, }) } // handleCompleteUpload finalizes a chunked upload and enqueues the task. The // response mirrors POST /api/tasks (202 + the task) so the client treats the // finalized upload exactly like a single-shot upload from this point on. func (s *Server) handleCompleteUpload(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") t, err := s.store.CompleteUpload(id, s.cfg.MaxRetries) if err != nil { if err == store.ErrUploadNotFound { renderError(w, http.StatusNotFound, "upload session not found or expired") return } renderError(w, http.StatusBadRequest, "complete: "+err.Error()) return } w.Header().Set("Location", "/api/tasks/"+t.ID) writeJSON(w, http.StatusAccepted, t) } // handleAbortUpload cancels a chunked upload session and removes its part // file. Idempotent: a missing session still returns 204. func (s *Server) handleAbortUpload(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") _ = s.store.AbortUpload(id) w.WriteHeader(http.StatusNoContent) } // 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 }