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 "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.
19const 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.
28func (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
72func (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.
91func (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.
112func (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
123type 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.
137func (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
168func (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.
181func (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.
217func 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