| 1 | package movement |
| 2 | |
| 3 | import "time" |
| 4 | |
| 5 | // Models is every model the schema generator manages. Keep it in step |
| 6 | // with the structs below: `rastrillo migration generate` reads |
| 7 | // it to work out what the database should look like, and |
| 8 | // `rastrillo migration check` fails CI when it and the |
| 9 | // migrations disagree. |
| 10 | var Models = []any{ |
| 11 | &Signup{}, |
| 12 | &SpentNonce{}, |
| 13 | &MailCounter{}, |
| 14 | } |
| 15 | |
| 16 | // Stance is which of the three positions somebody picked. The stored value is |
| 17 | // an internal key, never the label they read: labels are copy, copy changes, |
| 18 | // and a column that changes with it is a migration for every wording tweak |
| 19 | // and a corrupted history for the rows already written. |
| 20 | type Stance string |
| 21 | |
| 22 | const ( |
| 23 | StanceNever Stance = "never" |
| 24 | StanceCurious Stance = "curious" |
| 25 | StanceEnthusiast Stance = "enthusiast" |
| 26 | ) |
| 27 | |
| 28 | // Stances is the closed set, in the order the landing page offers them. |
| 29 | var Stances = []Stance{StanceNever, StanceCurious, StanceEnthusiast} |
| 30 | |
| 31 | // Slugs are the web addresses people land on and share, reviewed alongside |
| 32 | // the rest of the copy. They are separate from the stored keys on purpose: |
| 33 | // a slug is copy and copy changes, while the value in the database has to |
| 34 | // stay put or every stored row means something different afterwards. |
| 35 | var slugs = map[string]Stance{ |
| 36 | "no-ai": StanceNever, |
| 37 | "uneasy": StanceCurious, |
| 38 | "all-in": StanceEnthusiast, |
| 39 | } |
| 40 | |
| 41 | // Slug returns the address for a stance. |
| 42 | func (s Stance) Slug() string { |
| 43 | for slug, k := range slugs { |
| 44 | if k == s { |
| 45 | return slug |
| 46 | } |
| 47 | } |
| 48 | return "" |
| 49 | } |
| 50 | |
| 51 | // StanceForSlug resolves an incoming address to a stored key. |
| 52 | func StanceForSlug(slug string) (Stance, bool) { |
| 53 | s, ok := slugs[slug] |
| 54 | return s, ok |
| 55 | } |
| 56 | |
| 57 | // ValidStance reports whether s is one of the three. Anything else renders |
| 58 | // the same page as every other rejection — a made-up stance is a submission |
| 59 | // we refuse, not an error we explain. |
| 60 | func ValidStance(s string) bool { |
| 61 | for _, k := range Stances { |
| 62 | if string(k) == s { |
| 63 | return true |
| 64 | } |
| 65 | } |
| 66 | return false |
| 67 | } |
| 68 | |
| 69 | // Signup is one person's answer. |
| 70 | // |
| 71 | // Name joined on 2026-09-03. It widens what a row is: an address and a |
| 72 | // stance was already a stated position attached to a contactable person, and |
| 73 | // a name makes that person identifiable rather than merely reachable. Every |
| 74 | // rule about the sensitive pair applies to it, and it never reaches a log. |
| 75 | // |
| 76 | // There is no owner column and no User model, because nothing here is owned: |
| 77 | // the public surface writes rows nobody is signed in for, and the admin view |
| 78 | // reads all of them. Worth naming, because rastrillo's most expensive trap is |
| 79 | // sessions.UserID silently returning (0, false) under the auth plugin, where |
| 80 | // the session subject is a verified address rather than a numeric id. This |
| 81 | // app never calls it. Reading the whole table is correct here and would be a |
| 82 | // severe bug in an app with tenants. |
| 83 | type Signup struct { |
| 84 | ID int64 |
| 85 | Email string `gorm:"uniqueIndex"` // lowercased; the index is the dedupe |
| 86 | |
| 87 | Name string // what they call themselves; capped and stripped |
| 88 | Stance string |
| 89 | Background string // capped and stripped of control characters on the way in |
| 90 | |
| 91 | // A row is inert until Confirmed. It is never exported, never counted, |
| 92 | // and never mailed beyond its own confirmation — because proof of work |
| 93 | // and honeypots say nothing about whether an address is real or whether |
| 94 | // its owner agreed. Only a click from inside that inbox does. |
| 95 | Confirmed bool |
| 96 | ConfirmedAt *time.Time |
| 97 | |
| 98 | TokenHash []byte // SHA-256 of a random token; cleared when it is burned |
| 99 | TokenExpiry time.Time |
| 100 | |
| 101 | // The per-address mail budget. These two columns are deliberately |
| 102 | // excluded from the pending-row update allowlist: a counter somebody can |
| 103 | // reset by submitting the form again is not a budget. |
| 104 | LastMailedAt *time.Time |
| 105 | MailCount int |
| 106 | |
| 107 | CreatedAt time.Time |
| 108 | UpdatedAt time.Time |
| 109 | } |
| 110 | |
| 111 | // MailCounter is the global hourly budget. |
| 112 | // |
| 113 | // It exists because the real damage from a bulk attack is not junk rows, it |
| 114 | // is thousands of messages to strangers from our own verified sending |
| 115 | // identity, followed by the sending domain being suspended. It is persisted |
| 116 | // rather than in memory because this process hibernates as its normal state, |
| 117 | // and an in-memory counter would forget everything on the next wake — which |
| 118 | // is precisely when an attacker would like it to. |
| 119 | type MailCounter struct { |
| 120 | HourBucket string `gorm:"primaryKey"` // UTC, "2006-01-02T15" |
| 121 | Count int |
| 122 | } |
| 123 | |
| 124 | // SpentNonce records a challenge that has already been accepted. |
| 125 | // |
| 126 | // Nothing is written when a challenge is minted — only when one is spent. |
| 127 | // Written at mint time, this table would take a row per page view, which |
| 128 | // means a crawler, a link scanner or a browser prefetching links becomes a |
| 129 | // stream of serialised writes against a single-writer SQLite file, starving |
| 130 | // the one resource every signup needs. Written on acceptance, every row has |
| 131 | // already cost somebody a proof-of-work solve, so the table is bounded by |
| 132 | // work an attacker had to do rather than by traffic anyone can generate. |
| 133 | // |
| 134 | // Without it, a single solved challenge is replayable for its whole two-hour |
| 135 | // window, and each replay sends another confirmation mail to whatever |
| 136 | // address it names — a mail-bombing primitive pointed at a stranger's inbox, |
| 137 | // sent from our own verified identity, with the sending reputation as the |
| 138 | // collateral. |
| 139 | // |
| 140 | // It lives in the database rather than in memory because this process |
| 141 | // hibernates as its normal state, and an in-memory set would forget |
| 142 | // everything on the next wake. |
| 143 | type SpentNonce struct { |
| 144 | Nonce string `gorm:"primaryKey"` |
| 145 | ExpiresAt time.Time `gorm:"index"` |
| 146 | } |
| 147 | |