carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1package movement
2
3import (
4 "errors"
5 "net/http"
6 "time"
7
8 "gorm.io/gorm"
9)
10
11// confirmData drives the one confirmation template through its three states.
12type confirmData struct {
13 Token string
14 State string // "offer", "done", "dead"
15}
16
17// handleConfirmPage renders a page with a single Confirm button. It burns
18// nothing.
19//
20// This is the whole reason confirmation is a POST. Link-scanning gateways —
21// Outlook Safe Links, corporate security proxies, some mail clients
22// prefetching — fetch every URL in a message as it arrives. Confirming on GET
23// would put people on the list who never clicked, which breaks the first half
24// of the one rule using the very mechanism meant to enforce it, and would
25// then show the actual human "this link is no longer valid".
26//
27// The lookup here is read-only, so a scanner still cannot change anything. It
28// exists only so somebody arriving with a dead link is told before they press
29// a button that was never going to work.
30func (a *app) handleConfirmPage(w http.ResponseWriter, r *http.Request) {
31 token := r.URL.Query().Get("token")
32
33 if _, err := a.liveRow(token, time.Now()); err != nil {
34 render(w, "confirm", confirmData{State: "dead"})
35 return
36 }
37 render(w, "confirm", confirmData{Token: token, State: "offer"})
38}
39
40// handleConfirm burns the token and puts the address on the list.
41func (a *app) handleConfirm(w http.ResponseWriter, r *http.Request) {
42 now := time.Now()
43 token := r.PostFormValue("token")
44
45 row, err := a.liveRow(token, now)
46 if err != nil {
47 // Unknown, expired and already-burned all render the same page. There
48 // is nothing useful to tell apart here, and plenty to leak by trying.
49 render(w, "confirm", confirmData{State: "dead"})
50 return
51 }
52
53 // Clearing the hash is what makes the link single-use.
54 err = a.db.G.Model(&Signup{}).
55 Where("id = ? AND confirmed = ?", row.ID, false).
56 Select("Confirmed", "ConfirmedAt", "TokenHash", "UpdatedAt").
57 Updates(map[string]any{
58 "confirmed": true,
59 "confirmed_at": now,
60 "token_hash": nil,
61 "updated_at": now,
62 }).Error
63 if err != nil {
64 render(w, "confirm", confirmData{State: "dead"})
65 return
66 }
67
68 render(w, "confirm", confirmData{State: "done"})
69}
70
71// liveRow finds the unconfirmed row a token belongs to, if the token is still
72// good. Only the hash is stored, so the presented token is hashed and matched
73// rather than compared.
74func (a *app) liveRow(token string, now time.Time) (Signup, error) {
75 if token == "" {
76 return Signup{}, errors.New("movement: no token")
77 }
78 var row Signup
79 err := a.db.G.
80 Where("token_hash = ? AND confirmed = ? AND token_expiry > ?", hashToken(token), false, now).
81 First(&row).Error
82 if err != nil {
83 if errors.Is(err, gorm.ErrRecordNotFound) {
84 return Signup{}, err
85 }
86 return Signup{}, err
87 }
88 return row, nil
89}
90