The owner surface: queries, handlers, views, routes
Four files as tabs, in dependency order: documents.sql → documents.go → documents.templ → router.go — the queries, the handlers, the views, the routes.
1. internal/db/queries/documents.sql (modify) — two new queries alongside last lesson's: GetDocumentForOwner (WHERE id = $1 AND owner_id = $2) and DeleteDraftDocument (WHERE id = $1 AND owner_id = $2 AND status = 'draft', :execrows). Run sqlc generate.
2. internal/handlers/documents.go (modify) — three handlers and two helpers:
ViewDocumentandDownloadDocumentboth start withownedDocument, which runsGetDocumentForOwnerand writes a 404 onErrNoRows. One helper, so the owner check cannot be forgotten on either route.DownloadDocumentopens the blob withStore.Openand streams it with aContent-Disposition: attachmentheader — the original filename, ASCII and UTF-8 both, per RFC 6266.DeleteDocumentruns the pre-check (foreign → 404, sent → 422) and thenDeleteDraftDocument;n == 1means it really deleted, so the blob is removed and the list refreshed.parseUUIDturns the{id}path param into apgtype.UUID, 404-ing on a malformed id before it ever reaches the database.
func (h *Handlers) ownedDocument(w http.ResponseWriter, r *http.Request) (db.Document, bool) {
user := mustUser(r)
id, ok := parseUUID(chiURLParam(r, "id"))
if !ok {
http.NotFound(w, r)
return db.Document{}, false
}
doc, err := h.Queries.GetDocumentForOwner(r.Context(), db.GetDocumentForOwnerParams{ID: id, OwnerID: user.ID})
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r) // foreign or missing — indistinguishable, on purpose
return db.Document{}, false
}
// ...
return doc, true
}
3. internal/web/documents.templ (modify) — the list grows actions: the filename and a View button link to /documents/{id}, and a draft row gets a Delete button (hx-post, hx-confirm, targeting #doc-list). And a new DocumentDetail page: metadata, the SHA-256, a Download button, and — only for a draft — a Delete form. The delete controls need no hidden CSRF field: last lesson's X-CSRF-Token header wiring covers every htmx request, this one included.
4. internal/handlers/router.go (modify) — three routes in the authenticated group:
pr.Get("/documents/{id}", h.ViewDocument)
pr.Get("/documents/{id}/download", h.DownloadDocument)
pr.Post("/documents/{id}/delete", h.DeleteDocument)
Verify — reseed, run, log in, upload a file or two, then:
- Click a filename → the detail page shows type, size, upload time, and the full SHA-256.
- Click Download → the file comes back with its original name;
sha256sumof the downloaded file matches thefile_hashon the page. - Click Delete on a draft → confirm, and the row vanishes as the list swaps in place. The terminal logs
document deleted.
Now prove the two security rules.
404, not 403. Grab a document id from your account, then register a second account and, as that user, request the first user's document. A GET needs no CSRF token, so it is a one-liner:
$ curl -si http://localhost:8080/documents/<first-users-id> \
-H "Cookie: session=<second-users-session>"
HTTP/1.1 404 Not Found
The second user cannot tell whether that id exists — 404 for a foreign document is the same answer as 404 for a fake one.
Draft-only delete. A sent document shows no Delete button — it is behind if d.IsDraft. And the rule holds on the server too. Flip a document to sent by hand and the delete deletes nothing:
$ psql "$DATABASE_URL" -c "update documents set status='sent' where id='<id>';"
DeleteDraftDocument now matches zero rows for that id. Comment out the handler's if doc.Status != "draft" check and it still refuses — because the real guard is the WHERE status = 'draft' in the query, not the Go if.