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 "net/mail"
7 "strconv"
8 "strings"
9 "time"
10
11 "gorm.io/gorm"
12 "gorm.io/gorm/clause"
13)
14
15// honeypotField is a real input, hidden with CSS, that a person never fills
16// in. Its name is chosen to be invisible to browser autofill and password
17// managers: anything resembling "company", "organisation" or "url" gets
18// filled for real people, and a filled honeypot silently discards a genuine
19// signup behind a cheerful success page — the same "looks live, does nothing"
20// failure the <noscript> rule exists to prevent.
21const honeypotField = "hp"
22
23// maxName caps the name field. Long enough for anybody's actual name,
24// including the ones English-speaking forms usually break.
25const maxName = 120
26
27// maxBackground caps the free-text field. Somebody with more to say than this
28// can say it in a reply once they are on the list.
29const maxBackground = 2000
30
31// maxBody bounds the whole request. Without it, the free-text field is an
32// invitation to post a gigabyte.
33const maxBody = 64 << 10
34
35var errReplay = errors.New("movement: nonce already spent")
36
37// handleSignup is the one handler all three stance pages post to.
38//
39// Every outcome renders the same page. That is not politeness — it is the
40// second half of the one rule. If a returning visitor were told "you're
41// already signed up", this form would become a way for anybody to check who
42// has taken a public position on AI, one address at a time. The cost is real
43// and is paid here: somebody already on the list is told to check their inbox.
44func (a *app) handleSignup(w http.ResponseWriter, r *http.Request) {
45 r.Body = http.MaxBytesReader(w, r.Body, maxBody)
46
47 // The honeypot goes first: it is free, needs no seal, and must not sit
48 // behind anything that writes. It answers success and stores nothing,
49 // because a crawler that gets an error learns which field to leave alone
50 // next time, while one that gets a confirmation page learns nothing.
51 if strings.TrimSpace(r.PostFormValue(honeypotField)) != "" {
52 Rejected(ReasonHoneypot)
53 a.renderSent(w)
54 return
55 }
56
57 stance := r.PostFormValue("stance")
58 if !ValidStance(stance) {
59 Rejected(ReasonBounds)
60 a.renderSent(w)
61 return
62 }
63
64 c := Challenge{
65 Nonce: r.PostFormValue("nonce"),
66 IssuedAt: atoi64(r.PostFormValue("issued_at")),
67 Difficulty: atoi(r.PostFormValue("difficulty")),
68 Seal: r.PostFormValue("seal"),
69 }
70
71 // The seal before the clock: until the signature holds, IssuedAt is a
72 // number the submitter chose, and checking an unverified timestamp is not
73 // a check.
74 if reason, ok := VerifySeal(a.challengeKey, c, time.Now()); !ok {
75 Rejected(reason)
76 a.renderSent(w)
77 return
78 }
79
80 rawEmail := r.PostFormValue("email")
81 email, ok := normalizeAddress(rawEmail)
82 if !ok {
83 // The one visible error. It leaks nothing: a malformed string is a
84 // property of what this person just typed, not of any address's state
85 // on the list. Silently dropping it would strand somebody who simply
86 // made a typo.
87 Rejected(ReasonBounds)
88 a.renderStance(w, Stance(stance), T("form.bademail"))
89 return
90 }
91
92 background := clampText(r.PostFormValue("background"), maxBackground)
93 name := clampText(r.PostFormValue("name"), maxName)
94 if name == "" {
95 Rejected(ReasonBounds)
96 a.renderStance(w, Stance(stance), T("form.badname"))
97 return
98 }
99
100 if !VerifyPow(c.Nonce, email, r.PostFormValue("counter"), c.Difficulty) {
101 Rejected(ReasonPowShort)
102 a.renderSent(w)
103 return
104 }
105
106 now := time.Now()
107 var send *pendingMail
108
109 err := a.db.G.Transaction(func(tx *gorm.DB) error {
110 // Spending the nonce is the first statement, so the transaction opens
111 // as a writer and nothing needs to upgrade a lock later. Zero rows
112 // affected means this challenge has already been used: without it, one
113 // solved challenge is replayable for its whole two-hour window, and
114 // every replay sends another message to whatever address it names.
115 res := tx.Clauses(clause.OnConflict{DoNothing: true}).
116 Create(&SpentNonce{Nonce: c.Nonce, ExpiresAt: c.Expiry()})
117 if res.Error != nil {
118 return res.Error
119 }
120 if res.RowsAffected == 0 {
121 return errReplay
122 }
123
124 var row Signup
125 lookup := tx.Where("email = ?", email).First(&row).Error
126
127 switch {
128 case errors.Is(lookup, gorm.ErrRecordNotFound):
129 token, hash := mintToken()
130 row = Signup{
131 Email: email,
132 Name: name,
133 Stance: stance,
134 Background: background,
135 TokenHash: hash,
136 TokenExpiry: now.Add(TokenLifetime),
137 }
138 mailed, err := a.claimMail(tx, &row, now)
139 if err != nil {
140 return err
141 }
142 if err := tx.Create(&row).Error; err != nil {
143 return err
144 }
145 if mailed {
146 send = &pendingMail{to: email, token: token}
147 }
148 return nil
149
150 case lookup != nil:
151 return lookup
152
153 case row.Confirmed:
154 // Already on the list: no write to the row, no message, same
155 // page. A blanket upsert here would let anybody who knows an
156 // address knock it off the list, or re-file a named person under
157 // a stance they never chose — which is the exact signal this
158 // whole app exists to keep clean.
159 return nil
160
161 default:
162 token, hash := mintToken()
163 row.Name = name
164 row.Stance = stance
165 row.Background = background
166 row.TokenHash = hash
167 row.TokenExpiry = now.Add(TokenLifetime)
168
169 mailed, err := a.claimMail(tx, &row, now)
170 if err != nil {
171 return err
172 }
173
174 // Named columns, and the budget columns are not among the ones a
175 // resubmission may reset. A counter somebody can clear by filling
176 // the form again is not a budget.
177 cols := []string{"Name", "Stance", "Background", "TokenHash", "TokenExpiry", "UpdatedAt"}
178 if mailed {
179 cols = append(cols, "LastMailedAt", "MailCount")
180 }
181 if err := tx.Model(&row).Select(cols).Updates(&row).Error; err != nil {
182 return err
183 }
184 if mailed {
185 send = &pendingMail{to: email, token: token}
186 }
187 return nil
188 }
189 })
190
191 switch {
192 case errors.Is(err, errReplay):
193 Rejected(ReasonNonceSpent)
194 case err != nil:
195 Rejected(ReasonMailFailed)
196 case send != nil:
197 // After commit, never inside the transaction. mail.Send discards the
198 // context it is given and smtp.SendMail dials with no deadline, so a
199 // hung connection inside the transaction would hold the single writer
200 // and stall every other signup and the scheduled tick.
201 a.sendAfterCommit(*send)
202 }
203
204 a.renderSent(w)
205}
206
207// pendingMail is a message the transaction decided to send, handed out only
208// once the transaction has committed.
209type pendingMail struct {
210 to string
211 token string
212}
213
214// claimMail marks row as mailed if both budgets allow it. When they do not,
215// the row is left with its counters untouched, nothing is sent, and the
216// caller still renders the same page — a suppressed message must be
217// indistinguishable from a delivered one.
218func (a *app) claimMail(tx *gorm.DB, row *Signup, now time.Time) (bool, error) {
219 if !addressAllowed(row, now) {
220 Rejected(ReasonMailBudget)
221 return false, nil
222 }
223 ok, err := globalAllowed(tx, now)
224 if err != nil {
225 return false, err
226 }
227 if !ok {
228 Rejected(ReasonMailBudget)
229 return false, nil
230 }
231 row.MailCount = nextAddressCount(row, now)
232 row.LastMailedAt = &now
233 return true, nil
234}
235
236// normalizeAddress lowercases, bounds and parses. CR and LF are refused
237// before anything else, because this string ends up as an SMTP recipient and
238// a newline in it is a header injection.
239func normalizeAddress(raw string) (string, bool) {
240 s := strings.TrimSpace(raw)
241 if s == "" || len(s) > 254 {
242 return "", false
243 }
244 if strings.ContainsAny(s, "\r\n") {
245 return "", false
246 }
247 if _, err := mail.ParseAddress(s); err != nil {
248 return "", false
249 }
250 return NormalizeEmail(s), true
251}
252
253// clampText truncates rather than refuses, and strips the control characters
254// that would otherwise reach a CSV cell, an SMTP header or a log line.
255func clampText(raw string, max int) string {
256 var b strings.Builder
257 runes := 0
258 for _, r := range raw {
259 if r < 0x20 && r != '\n' && r != '\t' {
260 continue
261 }
262 if r == 0x7f {
263 continue
264 }
265 b.WriteRune(r)
266 if runes++; runes >= max {
267 break
268 }
269 }
270 return strings.TrimSpace(b.String())
271}
272
273func atoi(s string) int {
274 n, _ := strconv.Atoi(s)
275 return n
276}
277
278func atoi64(s string) int64 {
279 n, _ := strconv.ParseInt(s, 10, 64)
280 return n
281}
282