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 "time"
5
6 "gorm.io/gorm"
7)
8
9// Two budgets, both persisted, because the process this runs in hibernates as
10// its normal state and an in-memory counter forgets everything on the next
11// wake — which is exactly when somebody attacking it would like it to.
12//
13// They exist because proof of work is a speed bump, not a wall: 2^18 SHA-256
14// is under a millisecond on a commodity GPU. What a determined bulk attack
15// actually costs us is not junk rows, it is thousands of messages to
16// strangers from our own verified sending identity, and then the sending
17// domain suspended. These are the thing standing in front of that.
18
19const (
20 // PerAddressPerDay bounds how often one address can be mailed. Three is
21 // room for a genuine retry or two after a transient failure, and no room
22 // for using someone's inbox as a weapon.
23 PerAddressPerDay = 3
24
25 // GlobalPerHour bounds everything the app sends, our own confirmations
26 // and auth's sign-in links together.
27 GlobalPerHour = 200
28
29 perAddressWindow = 24 * time.Hour
30)
31
32// hourBucket names the global counter's row. Fixed buckets rather than a
33// sliding window: a burst straddling a boundary can send up to twice the
34// ceiling within one rolling hour, which at these numbers is acceptable and
35// is written down here rather than discovered later.
36func hourBucket(t time.Time) string {
37 return t.UTC().Format("2006-01-02T15")
38}
39
40// addressAllowed reports whether row may be mailed now.
41//
42// The window rolls from the last message rather than from a fixed clock
43// boundary, so someone mailed three times in a minute waits a full day rather
44// than until the top of the hour.
45func addressAllowed(row *Signup, now time.Time) bool {
46 if row.LastMailedAt == nil || now.Sub(*row.LastMailedAt) > perAddressWindow {
47 return true
48 }
49 return row.MailCount < PerAddressPerDay
50}
51
52// nextAddressCount returns what MailCount becomes after this send.
53func nextAddressCount(row *Signup, now time.Time) int {
54 if row.LastMailedAt == nil || now.Sub(*row.LastMailedAt) > perAddressWindow {
55 return 1
56 }
57 return row.MailCount + 1
58}
59
60// globalAllowed reads the hour's count and, when there is room, claims one.
61//
62// Read and increment are separate statements, which is safe here and only
63// here: db.Open caps the writer pool at a single connection and this runs
64// inside that connection's transaction, so no second writer can interleave.
65// The increment is still expressed as count = count + 1 rather than a value
66// computed in Go, so it stays correct if that ever stops being true.
67func globalAllowed(tx *gorm.DB, now time.Time) (bool, error) {
68 bucket := hourBucket(now)
69
70 var row MailCounter
71 err := tx.Where("hour_bucket = ?", bucket).First(&row).Error
72 switch {
73 case err == gorm.ErrRecordNotFound:
74 row = MailCounter{HourBucket: bucket, Count: 0}
75 case err != nil:
76 return false, err
77 }
78
79 if row.Count >= GlobalPerHour {
80 return false, nil
81 }
82
83 res := tx.Exec(
84 `INSERT INTO mail_counters (hour_bucket, count) VALUES (?, 1)
85 ON CONFLICT(hour_bucket) DO UPDATE SET count = count + 1`, bucket)
86 return true, res.Error
87}
88