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.go → documents_view.go → documents.templ → documents.go → dashboard.templ → auth.go → router.go → layout.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.
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
draftpill. - 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.