Code

The signer page and the signing flow

Nine files as tabs, bottom-up. The queries and view struct first, then the signer pages, the handlers, and the owner detail page that now shows integrity. signers.sqldocuments.sqlweb/signing.gosigning.templsigning_view.gosigning.godocuments.godocuments.templrouter.go.

1. internal/db/queries/signers.sql (modify) — three new queries: GetSignerByToken (the token → one row), SignSigner (the status='pending'-guarded update, :execrows), and CountPendingSigners.

2. internal/db/queries/documents.sql (modify) — two new: GetDocument (unscoped by id — only for token-authorized flows) and MarkDocumentCompleted.

3. internal/web/signing.go (modify) — add the IntegrityView struct (recorded hash vs current hash).

4. internal/web/signing.templ (new) — the signer-facing pages and two shared components: integrityBanner (the loud TAMPER DETECTED / quiet verified banner), signatureCaveat (the honesty note), SignForm, SignDone, SignExpired, SignInvalid. Every signing page embeds the banner and the caveat.

5. internal/handlers/signing_view.go (modify) — add viewIntegrity (maps the recomputed hash into IntegrityView).

6. internal/handlers/signing.go (modify) — the signer flow, alongside last lesson's InviteSigners:

  • resolveSigner — the token maps to one signer + its document (GetSignerByTokenGetDocument); an unknown token renders SignInvalid (404).
  • checkIntegrity — re-hashes the stored bytes and compares to file_hash. Called on every view and before every sign.
  • SignPage / SignDownload / Sign — review, download via the token, and sign. Sign recomputes the hash, records the signature in a transaction, and — if it was the last signer — marks the document completed. A double-post shows the signed state (the :execrows guard).

7. internal/handlers/documents.go (modify) — renderDocumentDetail now also computes integrity and passes it to the page; and DownloadDocument is refactored to call a shared streamDocument helper — the signer download needs the same streaming, so a second caller now exists.

8. internal/web/documents.templ (modify) — the owner detail page grows the integrityBanner, and the signer roster grows Signed as / Signed at / Hash signed columns, with a ⚠ differs flag when a past signature's hash no longer matches the file.

9. internal/handlers/router.go (modify) — three routes outside the authenticated group — the token is the whole authorization:

r.Get("/sign/{token}", h.SignPage)
r.Post("/sign/{token}", h.Sign)
r.Get("/sign/{token}/download", h.SignDownload)

Verify — reseed, run, and send a document to two signers (as in lesson 10). Copy a signing link from the console and open it (a private window is a nice way to feel the "no account" part):

  • The signing page shows a green Integrity verified banner, the document metadata, a Download & review button, and the honesty caveat.
  • Type a name, Sign — the page shows ✓ You've signed, the hash you signed, and the caveat again.
  • Reload the signed link: still the signed state, not a second sign (single-use). Re-POST it by hand — same result, zero rows updated.
  • Sign as the second signer. When the last one signs, the owner's detail page shows the document as completed and the roster as 2 of 2 signed.
level=INFO msg="document signed" doc=… signer=alice@example.com hash=4a7d1ed4… remaining=1
level=INFO msg="document signed" doc=… signer=bob@example.com   hash=4a7d1ed4… remaining=0

remaining=0 is the moment the document completed.