package web import ( "context" "errors" "io" "net/http" "path/filepath" "strings" "unicode/utf8" "github.com/a-h/templ" "git.d-ma.be/mathias/tapir/internal/adapters/chat" "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, a.Chat != nil)) } // 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, a.Chat != nil)) } 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." } } // handleUploadChat renders the chat page for a FINISHED upload (GET). The row is // read through the user-scoped store, so another user's id is a 404 before any // model is involved. An unfinished upload has no transcript to ask about yet, so // it goes back to its status page. func (a *App) handleUploadChat(w http.ResponseWriter, r *http.Request) { u, ok := a.loadOwnedUpload(w, r) if !ok { return } if u.Status != domain.UploadDone { http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther) return } a.render(w, r, UploadChatPage(*u, a.uploadChatView(*u, a.Chat.DefaultModel(""), nil, false, ""))) } // handleUploadChatMessage answers one question against the owner's uploaded // transcript (POST), reusing the ADR-027 Chatter. Nothing is persisted: the prior // turns ride in the form. The transcript goes to the model with the UPLOAD budget, // not the caption default. A model failure is inline, not a 500. func (a *App) handleUploadChatMessage(w http.ResponseWriter, r *http.Request) { u, ok := a.loadOwnedUpload(w, r) if !ok { return } if u.Status != domain.UploadDone { http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther) return } if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } model := a.resolveModel(r.FormValue("model"), "") history := parseHistory(r.Form["hq"], r.Form["ha"]) question := strings.TrimSpace(r.FormValue("question")) view := a.uploadChatView(*u, model, history, false, "") if question != "" { reply, err := a.Chat.Answer(r.Context(), chat.Request{ Model: model, Transcript: u.Content, History: history, Question: question, MaxChars: a.UploadChatMaxChars, }) if err != nil { a.logger().Error("upload chat answer", "upload", u.ID, "model", model, "err", err) view.Error = "That model couldn't answer just now. Try again, or switch models." } else { view.History = appendTurn(history, chat.Turn{Question: question, Answer: reply.Answer}) view.Truncated = reply.Truncated } } if isHTMX(r) { a.render(w, r, chatPanel(view)) return } a.render(w, r, UploadChatPage(*u, view)) } func (a *App) uploadChatView(u domain.UploadedTranscript, model string, history []chat.Turn, truncated bool, errMsg string) chatView { return chatView{ VideoID: u.ID, Title: u.Title, Available: true, Models: a.Chat.Models(), Selected: model, History: history, Truncated: truncated, Error: errMsg, PostURL: templ.SafeURL("/uploads/" + u.ID + "/chat"), Subject: "recording", } }