Files
root e5a2dc0a8d add chunked upload to bypass 100MB proxy body limit
Resumable /api/uploads flow (init -> append -> complete -> abort) so a
large recording uploads as many small slices (<= ASR_CHUNK_SIZE_MB, default
50MB) instead of one request a size-capping proxy (Cloudflare's 100MB) would
reject. The web console auto-routes files larger than one slice through the
chunked path with cross-slice progress and transient-error retry; smaller
files keep the single-shot upload.

- store: in-memory upload sessions + .part reassembly (offset writes,
  out-of-order/idempotent), reuse upload id as task id, orphan sweep on
  start, idle-session GC
- fix limitedReader rejecting bodies exactly at the cap (broke full chunks)
- fix drag-drop double-firing when a drop lands on the dropzone (its drop
  handler now stopPropagation so the window listener doesn't re-upload)
- tests: store + HTTP flow, plus a 120MB reassembly test (ASR_TEST_LARGE)
2026-09-10 15:12:15 +08:00

312 lines
10 KiB
Go

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 <token>, 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
}