feat(web): upload routes -- form, multipart intake, honest status (ADR-033)
GET/POST /uploads, GET /uploads/{id}, GET /uploads/{id}/status, mounted only
when an Uploader is wired. The multipart body is streamed to Intake, never
buffered. Rejections are synchronous: 422 (video, too long, unreadable) or 413
with the user-facing message and no row. Another user's id is a 404 that does
not leak the title. Unfinished states poll; done/failed render without
hx-trigger so nothing polls forever (ADR-025). A done transcript names the
endpoint that served it.
handlers.go, uploads.go and the templ pair are one commit: the App fields and
Store methods are what make the handlers compile.
Mutation-checked: dropping the 413 mapping, the not-found mapping, or letting
a done card poll each turn a test red.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||
"git.d-ma.be/mathias/tapir/internal/uploads"
|
||||
)
|
||||
|
||||
// Uploader accepts an uploaded recording for transcription (ADR-033).
|
||||
// *uploads.Intake satisfies it. It is the whole of what the handlers know about
|
||||
// validation, spooling and queueing.
|
||||
type Uploader interface {
|
||||
Accept(ctx context.Context, userID, title string, src io.Reader) (id string, err error)
|
||||
}
|
||||
|
||||
const (
|
||||
// uploadOverhead is headroom above the file-size cap for the multipart framing
|
||||
// and the title field; the cap itself is enforced on the file part by Intake.
|
||||
uploadOverhead = 1 << 20
|
||||
maxTitleRunes = 200
|
||||
// uploadPollEvery is how often an unfinished upload re-asks for its status.
|
||||
uploadPollEvery = "5s"
|
||||
)
|
||||
|
||||
// uploadsView is what the /uploads page renders.
|
||||
type uploadsView struct {
|
||||
Limits uploads.Limits
|
||||
Rows []domain.UploadedTranscript
|
||||
Error string // a synchronous rejection message, empty when none
|
||||
}
|
||||
|
||||
// handleUploadsPage renders the upload form and the caller's own uploads.
|
||||
func (a *App) handleUploadsPage(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := a.currentUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.renderUploadsPage(w, r, userID, http.StatusOK, "")
|
||||
}
|
||||
|
||||
func (a *App) renderUploadsPage(w http.ResponseWriter, r *http.Request, userID string, status int, msg string) {
|
||||
rows, err := a.Store.ListUploads(r.Context(), userID)
|
||||
if err != nil {
|
||||
a.serverError(w, r, "list uploads", err)
|
||||
return
|
||||
}
|
||||
a.renderStatus(w, r, status, UploadsPage(uploadsView{Limits: a.UploadLimits, Rows: rows, Error: msg}))
|
||||
}
|
||||
|
||||
// handleUploadPost streams the multipart body: the optional title field, then the
|
||||
// file part, which goes straight to Intake without being buffered in memory. A
|
||||
// rejection is synchronous — a status code and a message on the upload page, no
|
||||
// row, no timeout (ADR-033).
|
||||
func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := a.currentUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, a.UploadLimits.MaxBytes+uploadOverhead)
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
|
||||
return
|
||||
}
|
||||
title := ""
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
a.uploadFailure(w, r, userID, err)
|
||||
return
|
||||
}
|
||||
switch part.FormName() {
|
||||
case "title":
|
||||
b, _ := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax))
|
||||
title = clipRunes(strings.TrimSpace(string(b)), maxTitleRunes)
|
||||
case "file":
|
||||
if title == "" {
|
||||
title = titleFromFilename(part.FileName())
|
||||
}
|
||||
id, err := a.Uploads.Accept(r.Context(), userID, title, part)
|
||||
if err != nil {
|
||||
a.uploadFailure(w, r, userID, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/uploads/"+id, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
|
||||
}
|
||||
|
||||
// uploadFailure maps an Accept (or multipart) error to a response: a validation
|
||||
// rejection is the user's to fix (422, or 413 for size) and carries its own
|
||||
// message; anything else is ours.
|
||||
func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) {
|
||||
var rej *uploads.RejectError
|
||||
var tooBig *http.MaxBytesError
|
||||
switch {
|
||||
case errors.As(err, &rej):
|
||||
status := http.StatusUnprocessableEntity
|
||||
if rej.Reason == uploads.ReasonTooLarge {
|
||||
status = http.StatusRequestEntityTooLarge
|
||||
}
|
||||
a.renderUploadsPage(w, r, userID, status, rej.Message)
|
||||
case errors.As(err, &tooBig):
|
||||
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
|
||||
"This file is larger than the upload limit.")
|
||||
default:
|
||||
a.serverError(w, r, "upload", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUploadDetail renders one upload's page (GET /uploads/{id}). The row is
|
||||
// read through the user-scoped store, so another user's id is a 404 — existence is
|
||||
// not disclosed, and the title never appears in the response.
|
||||
func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := a.loadOwnedUpload(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.render(w, r, UploadPage(*u))
|
||||
}
|
||||
|
||||
// handleUploadStatus renders the status fragment HTMX polls. Unfinished states
|
||||
// keep polling; terminal states render without hx-trigger so the page stops
|
||||
// asking (ADR-025: never a stuck spinner).
|
||||
func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := a.loadOwnedUpload(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.render(w, r, uploadStatus(*u))
|
||||
}
|
||||
|
||||
func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) {
|
||||
userID, ok := a.currentUserID(w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
u, err := a.Store.GetUpload(r.Context(), userID, r.PathValue("id"))
|
||||
if errors.Is(err, store.ErrUploadNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return nil, false
|
||||
}
|
||||
if err != nil {
|
||||
a.serverError(w, r, "get upload", err)
|
||||
return nil, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
// titleFromFilename is the default title: the file's name without its extension.
|
||||
func titleFromFilename(name string) string {
|
||||
base := filepath.Base(name)
|
||||
t := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
if t == "" || t == "." {
|
||||
return "Untitled recording"
|
||||
}
|
||||
return clipRunes(t, maxTitleRunes)
|
||||
}
|
||||
|
||||
func clipRunes(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:n])
|
||||
}
|
||||
|
||||
// uploadFailureMessage turns a typed failure kind into words for the user. It is
|
||||
// keyed on OUR kinds, never on a provider's error string (ADR-033).
|
||||
func uploadFailureMessage(k domain.UploadErrorKind) string {
|
||||
switch k {
|
||||
case domain.UploadErrInterrupted:
|
||||
return "The server restarted before this recording was transcribed. Please upload it again."
|
||||
case domain.UploadErrTranscriptionFailed:
|
||||
return "Transcription failed on every available service. Please upload it again later."
|
||||
default:
|
||||
return "This upload failed. Please upload it again."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user