Code

The owner surface: queries, handlers, views, routes

Four files as tabs, in dependency order: documents.sqldocuments.godocuments.templrouter.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:

  • ViewDocument and DownloadDocument both start with ownedDocument, which runs GetDocumentForOwner and writes a 404 on ErrNoRows. One helper, so the owner check cannot be forgotten on either route.
  • DownloadDocument opens the blob with Store.Open and streams it with a Content-Disposition: attachment header — the original filename, ASCII and UTF-8 both, per RFC 6266.
  • DeleteDocument runs the pre-check (foreign → 404, sent → 422) and then DeleteDraftDocument; n == 1 means it really deleted, so the blob is removed and the list refreshed.
  • parseUUID turns the {id} path param into a pgtype.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; sha256sum of the downloaded file matches the file_hash on 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.