| 1 | package movement |
| 2 | |
| 3 | import ( |
| 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. |
| 21 | const 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. |
| 25 | const 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. |
| 29 | const maxBackground = 2000 |
| 30 | |
| 31 | // maxBody bounds the whole request. Without it, the free-text field is an |
| 32 | // invitation to post a gigabyte. |
| 33 | const maxBody = 64 << 10 |
| 34 | |
| 35 | var 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. |
| 44 | func (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. |
| 209 | type 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. |
| 218 | func (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. |
| 239 | func 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. |
| 255 | func 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 | |
| 273 | func atoi(s string) int { |
| 274 | n, _ := strconv.Atoi(s) |
| 275 | return n |
| 276 | } |
| 277 | |
| 278 | func atoi64(s string) int64 { |
| 279 | n, _ := strconv.ParseInt(s, 10, 64) |
| 280 | return n |
| 281 | } |
| 282 | |