| 1 | package movement |
| 2 | |
| 3 | import ( |
| 4 | "encoding/csv" |
| 5 | "net/http" |
| 6 | "time" |
| 7 | |
| 8 | "github.com/carlosframework/rastrillo/auth" |
| 9 | "github.com/go-chi/chi/v5" |
| 10 | ) |
| 11 | |
| 12 | // adminSessionTTL caps how long an admin stays signed in. The framework's |
| 13 | // default is thirty days, which is far too long for a surface that lists |
| 14 | // named people's stated positions on AI. |
| 15 | // |
| 16 | // Not RequireFreshSession, which is a step-up mechanism for sensitive routes |
| 17 | // layered on top of a longer session — that is a different tool for a |
| 18 | // different job. |
| 19 | const adminSessionTTL = 8 * time.Hour |
| 20 | |
| 21 | // mountAdmin registers every admin route, or none of them. |
| 22 | // |
| 23 | // With MOVEMENT_ADMINS empty the whole surface is absent: not 403, not an |
| 24 | // open page, but genuinely not there, indistinguishable from an app that has |
| 25 | // no admin at all. Sign-in is included in that. Leaving it live under an |
| 26 | // empty allowlist would let anyone make this app email a link from our |
| 27 | // verified sending identity to any address they named. |
| 28 | func (a *app) mountAdmin(r chi.Router) error { |
| 29 | if len(a.env.Admins) == 0 { |
| 30 | Info("no MOVEMENT_ADMINS set; the admin surface is not mounted") |
| 31 | return nil |
| 32 | } |
| 33 | |
| 34 | au, err := auth.New(auth.Config{ |
| 35 | DB: a.db.Writer(), |
| 36 | Origin: a.env.Origin, |
| 37 | InstanceKey: a.env.InstanceKey, |
| 38 | // Explicit, always. A nil Mailer makes auth substitute mail.Logged, |
| 39 | // which writes the magic link into carlos logs — the same leak |
| 40 | // config.go refuses at boot, arriving through another door. |
| 41 | Mailer: a.mailer, |
| 42 | Authorize: a.env.IsAdmin, |
| 43 | SigninPath: "/signin", |
| 44 | // A fresh session lands on the list, not on the bill. Signing in here |
| 45 | // has exactly one purpose, and the default "/" sends an admin who |
| 46 | // just authenticated back to the public page they came from. |
| 47 | SignedInPath: "/admin", |
| 48 | SessionTTL: adminSessionTTL, |
| 49 | Logger: slogFor(), |
| 50 | }) |
| 51 | if err != nil { |
| 52 | return err |
| 53 | } |
| 54 | a.auth = au |
| 55 | |
| 56 | r.Get("/signin", a.signinPage) |
| 57 | // Ours, not auth.Begin directly. See handleSignin. |
| 58 | r.Post("/signin", a.handleSignin) |
| 59 | r.Get("/auth/callback", au.Callback) |
| 60 | r.Get("/auth/verify", au.Verify) |
| 61 | r.Post("/signout", au.Signout) |
| 62 | |
| 63 | r.Group(func(r chi.Router) { |
| 64 | r.Use(au.RequireSession) |
| 65 | r.Use(a.requireAdmin) |
| 66 | r.Get("/admin", a.adminIndex) |
| 67 | r.Get("/admin/export.csv", a.adminExport) |
| 68 | }) |
| 69 | return nil |
| 70 | } |
| 71 | |
| 72 | func (a *app) signinPage(w http.ResponseWriter, r *http.Request) { |
| 73 | render(w, "signin", map[string]any{"Sent": r.URL.Query().Get("sent") == "1"}) |
| 74 | } |
| 75 | |
| 76 | // handleSignin gates POST /signin on the allowlist before auth mints and |
| 77 | // mails anything. |
| 78 | // |
| 79 | // auth checks Authorize at admission — the moment a verified address would |
| 80 | // receive a session — which is the right place for the access decision, and |
| 81 | // access is safe without this. What is not safe is delivery: without this |
| 82 | // gate, anyone who can reach the instance can make it email a live sign-in |
| 83 | // link to any address they name. The recipient gets unsolicited mail and our |
| 84 | // sending identity wears it. |
| 85 | // |
| 86 | // The refusal is deliberately invisible. Saying "that address cannot sign in" |
| 87 | // would turn this endpoint into a membership oracle for the admin list, so a |
| 88 | // refused address gets exactly the response an accepted one gets, and no |
| 89 | // mail. The cost is real and accepted: an admin who mistypes their own |
| 90 | // address is told to check their inbox and nothing arrives. |
| 91 | func (a *app) handleSignin(w http.ResponseWriter, r *http.Request) { |
| 92 | if err := r.ParseForm(); err != nil { |
| 93 | http.Error(w, "bad form", http.StatusBadRequest) |
| 94 | return |
| 95 | } |
| 96 | if !a.env.IsAdmin(r.FormValue("address")) { |
| 97 | http.Redirect(w, r, "/signin?sent=1", http.StatusSeeOther) |
| 98 | return |
| 99 | } |
| 100 | a.auth.Begin(w, r) |
| 101 | } |
| 102 | |
| 103 | // requireAdmin re-checks the allowlist on every request. |
| 104 | // |
| 105 | // auth.Config.Authorize runs once, when a magic link is redeemed; |
| 106 | // RequireSession thereafter only checks that a session exists. Without this, |
| 107 | // removing somebody from MOVEMENT_ADMINS would do nothing until their session |
| 108 | // expired. The list itself is read at boot — a process's environment is fixed |
| 109 | // at exec — so what makes a removal take effect is the platform bouncing the |
| 110 | // instance when the config converges, and this middleware then refusing the |
| 111 | // session that survived. |
| 112 | func (a *app) requireAdmin(next http.Handler) http.Handler { |
| 113 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 114 | id, ok := auth.From(r) |
| 115 | if !ok || !a.env.IsAdmin(id.Address) { |
| 116 | http.NotFound(w, r) |
| 117 | return |
| 118 | } |
| 119 | next.ServeHTTP(w, r) |
| 120 | }) |
| 121 | } |
| 122 | |
| 123 | type adminRow struct { |
| 124 | Name string |
| 125 | Email string |
| 126 | Stance string |
| 127 | Background string |
| 128 | When time.Time |
| 129 | } |
| 130 | |
| 131 | // adminIndex lists confirmed rows and counts the rest. |
| 132 | // |
| 133 | // Pending rows are not shown. One is an address nobody has proven owns it, |
| 134 | // joined to a stated position on AI — the pair the logging rules exist to |
| 135 | // keep apart — and showing it to satisfy curiosity about the funnel is the |
| 136 | // wrong trade. The count is there so the funnel is not invisible. |
| 137 | func (a *app) adminIndex(w http.ResponseWriter, r *http.Request) { |
| 138 | var rows []Signup |
| 139 | if err := a.db.G.Where("confirmed = ?", true). |
| 140 | Order("confirmed_at desc").Find(&rows).Error; err != nil { |
| 141 | http.Error(w, "something went wrong", http.StatusInternalServerError) |
| 142 | return |
| 143 | } |
| 144 | |
| 145 | var pending int64 |
| 146 | if err := a.db.G.Model(&Signup{}).Where("confirmed = ?", false). |
| 147 | Count(&pending).Error; err != nil { |
| 148 | http.Error(w, "something went wrong", http.StatusInternalServerError) |
| 149 | return |
| 150 | } |
| 151 | |
| 152 | out := make([]adminRow, 0, len(rows)) |
| 153 | for _, row := range rows { |
| 154 | when := row.CreatedAt |
| 155 | if row.ConfirmedAt != nil { |
| 156 | when = *row.ConfirmedAt |
| 157 | } |
| 158 | out = append(out, adminRow{row.Name, row.Email, row.Stance, row.Background, when}) |
| 159 | } |
| 160 | |
| 161 | render(w, "admin", map[string]any{ |
| 162 | "Rows": out, |
| 163 | "Pending": pending, |
| 164 | "Counts": a.stanceCounts(rows), |
| 165 | }) |
| 166 | } |
| 167 | |
| 168 | func (a *app) stanceCounts(rows []Signup) map[string]int { |
| 169 | counts := map[string]int{} |
| 170 | for _, s := range Stances { |
| 171 | counts[string(s)] = 0 |
| 172 | } |
| 173 | for _, row := range rows { |
| 174 | counts[row.Stance]++ |
| 175 | } |
| 176 | return counts |
| 177 | } |
| 178 | |
| 179 | // adminExport writes confirmed rows only. That restriction is the one rule's |
| 180 | // first half made mechanical, and it is a test rather than a convention. |
| 181 | func (a *app) adminExport(w http.ResponseWriter, r *http.Request) { |
| 182 | var rows []Signup |
| 183 | if err := a.db.G.Where("confirmed = ?", true). |
| 184 | Order("confirmed_at desc").Find(&rows).Error; err != nil { |
| 185 | http.Error(w, "something went wrong", http.StatusInternalServerError) |
| 186 | return |
| 187 | } |
| 188 | |
| 189 | w.Header().Set("Content-Type", "text/csv; charset=utf-8") |
| 190 | w.Header().Set("Content-Disposition", `attachment; filename="movement.csv"`) |
| 191 | |
| 192 | cw := csv.NewWriter(w) |
| 193 | _ = cw.Write([]string{"name", "email", "stance", "confirmed_at", "background"}) |
| 194 | for _, row := range rows { |
| 195 | when := "" |
| 196 | if row.ConfirmedAt != nil { |
| 197 | when = row.ConfirmedAt.UTC().Format(time.RFC3339) |
| 198 | } |
| 199 | _ = cw.Write([]string{ |
| 200 | csvSafe(row.Name), |
| 201 | csvSafe(row.Email), |
| 202 | csvSafe(row.Stance), |
| 203 | when, |
| 204 | csvSafe(row.Background), |
| 205 | }) |
| 206 | } |
| 207 | cw.Flush() |
| 208 | } |
| 209 | |
| 210 | // csvSafe defuses formula injection. |
| 211 | // |
| 212 | // This export exists to be opened in a spreadsheet, and a cell beginning |
| 213 | // =, +, -, @, tab or carriage return is executed when it is. The background |
| 214 | // field is free text somebody else wrote, which makes it the obvious carrier. |
| 215 | // Quoting is the csv writer's job; this is about what the receiving |
| 216 | // application does with the value afterwards. |
| 217 | func csvSafe(s string) string { |
| 218 | if s == "" { |
| 219 | return s |
| 220 | } |
| 221 | switch s[0] { |
| 222 | case '=', '+', '-', '@', '\t', '\r': |
| 223 | return "'" + s |
| 224 | } |
| 225 | return s |
| 226 | } |
| 227 | |