Code

Upload end to end, with a live progress bar

Eight files, presentation on top of the layer you just built: the view model, its mapper, the list partial, the upload handler, and the four small wiring changes that make it live. Order: documents_web.godocuments_view.godocuments.templdocuments.godashboard.templauth.gorouter.golayout.templ.

1. internal/web/documents_web.go (new) — DocumentView, the template-facing shape: plain strings and a bool, no pgtype. Templates never touch a db.Document directly.

2. internal/handlers/documents_view.go (new) — viewDocument maps a db.Document into a DocumentView (formatting the timestamp, humanizing the byte count), viewDocuments maps a slice, and humanizeBytes renders 1.4 MB.

3. internal/web/documents.templ (new) — DocumentList(docs), the partial with id="doc-list", and documentRow. This is the piece HTMX swaps: the upload form targets #doc-list and replaces it with the fresh list. (View and delete actions arrive next lesson, with the routes that back them.)

4. internal/handlers/documents.go (new) — Dashboard (moved here from auth.go, now listing the owner's documents) and UploadDocument:

r.Body = http.MaxBytesReader(w, r.Body, h.Cfg.MaxUploadBytes+1024)
file, header, err := r.FormFile("file")
// ...
key, size, hash, err := h.Store.Save(r.Context(), file) // stream + hash, one pass
doc, err := h.Queries.CreateDocument(r.Context(), db.CreateDocumentParams{ /* ... */ })
if err != nil {
	_ = h.Store.Delete(r.Context(), key) // don't orphan the blob
	// ...
}

If the row insert fails after the blob is written, Store.Delete removes the orphan — storage and database are kept in step. respondWithDocList returns the refreshed partial to HTMX callers and redirects a plain form post. sanitizeFilename strips path separators and control characters from the client-supplied name.

5. internal/web/dashboard.templ (modify) — the placeholder dashboard grows into the real thing: a hero, the upload card (hx-post="/documents", hx-encoding="multipart/form-data", hx-target="#doc-list", a <progress> bar), and @DocumentList(docs). A small script wires htmx's xhr:progress events to the progress element. Dashboard now takes docs []DocumentView.

6. internal/handlers/auth.go (modify) — the old placeholder Dashboard is removed here; it moved to documents.go, where it can list documents. Nothing else in auth.go changes.

7. internal/handlers/router.go (modify) — one route inside the authenticated group: pr.Post("/documents", h.UploadDocument).

8. internal/web/layout.templ (modify) — two additions that let HTMX posts pass CSRF: a <meta name="csrf-token"> and a tiny htmx:configRequest listener that copies the token into an X-CSRF-Token header. The upload form carries no hidden field, so the header is how gorilla/csrf sees the token — and it reads the header before parsing the body, so the multipart stream is not consumed twice.

The upload form has no hidden CSRF field

Every other form embeds gorilla.csrf.Token as a hidden input. A multipart upload cannot — the token would arrive after the file in the stream, and gorilla/csrf needs it first. The X-CSRF-Token header solves it: htmx attaches it to every request from the meta tag, and it is read before the body. Without this wiring the upload POST returns 403 Forbidden.

Verify — reseed, go run ./cmd/signflow, log in, and open /dashboard:

  • Choose a file and click Upload. The progress bar fills, and the document list swaps in place with your file — no full-page reload.
  • The row shows a humanized size and a draft pill.
  • Check the terminal:
level=INFO msg="document uploaded" id=… owner=you@example.com size=1048576 sha256=4a7d1ed414474e4033ac29ccb8653d9b...
  • Confirm the bytes and the hash landed:
$ ls uploads/
7f3a9c1e0b2d4f6a8c5e1b3d7f9a2c4e
$ psql "$DATABASE_URL" -c "select filename, size, status, storage_key from documents;"
   filename   |  size   | status |           storage_key
--------------+---------+--------+----------------------------------
 contract.pdf | 1048576 | draft  | 7f3a9c1e0b2d4f6a8c5e1b3d7f9a2c4e

The storage_key names the file on disk; it is not the row id. The file_hash was computed in the same pass that wrote those bytes.

  • Try a file over 25 MiB: the upload is refused with 413, and nothing is written to uploads/ or the table.