forked from d33mobile/dday
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathserver.go
More file actions
442 lines (403 loc) · 14.2 KB
/
Copy pathserver.go
File metadata and controls
442 lines (403 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/mail"
"strings"
"time"
"github.com/d33mobile/dday/internal/matrixbot"
"github.com/d33mobile/dday/internal/regwindow"
"github.com/d33mobile/dday/internal/store"
"filippo.io/age"
)
// openStart is the human-readable moment registration opens, shown on the
// "closed"/"expired" pages. Sourced from regwindow so the web server, the bot
// and index.html all state the same date. Matches the countdown in index.html.
const openStart = regwindow.OpenStartText
// tokenTTL bounds how long a registration link stays valid after it was issued.
// A token older than this (or issued in the future beyond a small clock-skew
// tolerance) is rejected in decode().
const tokenTTL = 48 * time.Hour
// tokenFutureSkew tolerates a small amount of clock drift when the token's
// Issued time is ahead of the server's clock.
const tokenFutureSkew = 5 * time.Minute
// Server-side input bounds. A submission exceeding either is re-rendered with an
// error instead of being stored — a cheap guard against oversized bot payloads.
const (
maxCityLen = 120 // bytes, after TrimSpace
maxEmailLen = 254 // bytes, the practical SMTP address maximum
)
// deps carries the runtime dependencies of the registration handlers, so the
// mux can be built in tests with an in-memory store, an ephemeral key and an
// injectable time gate.
type deps struct {
store *store.Store
identity age.Identity
seatLimit int // confirmed participant places (numbers 1..seatLimit)
waitlistLimit int // waiting-list places (numbers seatLimit+1..seatLimit+waitlistLimit)
isOpen func() bool
files http.FileSystem // static files for GET /
internalToken string // bearer token guarding /api/registered; empty disables it
tokenSecret string // shared HMAC key authenticating registration tokens
}
// total is the overall capacity: confirmed seats plus waiting-list places. A
// registration is refused only once total is reached.
func (d deps) total() int { return d.seatLimit + d.waitlistLimit }
// formView is the data model for the registration form template.
type formView struct {
Title string
Token string
Nick string
City string
Email string
Error string
Count int
Limit int
Waitlist bool // true when confirmed seats are gone: this signup joins the waiting list
}
// resultView backs the success/duplicate/waitlist/message pages.
type resultView struct {
Title string
Nick string
Number int
WaitlistPos int // position on the waiting list (number-seatLimit); 0 for confirmed participants
Message string
Detail string
}
// newMux builds the HTTP handler with every route, wrapped in the security
// middleware. It is the single place both main() and the tests construct.
func newMux(d deps) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n"))
})
mux.HandleFunc("/register", d.handleRegister)
mux.HandleFunc("/api/count", d.handleCount)
mux.HandleFunc("/api/registered", d.handleRegistered)
mux.HandleFunc("/privacy", d.handlePrivacy)
mux.HandleFunc("/", d.handleRoot)
return secure(mux)
}
// handleRoot serves the landing page for "/" only. Unlike http.FileServer it
// never walks the static directory, so STATIC_DIR=. (a dev convenience that
// points at the repo root) can never leak matrix.env, the age key or the SQLite
// DB via an arbitrary path — anything other than "/" is a 404.
func (d deps) handleRoot(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
d.serveStatic(w, "index.html")
}
// serveStatic writes one of the fixed, known HTML files from d.files. Only the
// names the handlers reference can be served — there is no path input, so a
// STATIC_DIR pointing at a directory with secrets cannot expose them.
func (d deps) serveStatic(w http.ResponseWriter, name string) {
f, err := d.files.Open(name)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
defer f.Close()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := io.Copy(w, f); err != nil {
log.Printf("serve %s: %v", name, err)
}
}
// methodNotAllowed writes a 405 with an Allow: GET header, for the GET-only
// read endpoints.
func methodNotAllowed(w http.ResponseWriter) {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
// ready reports whether registration can run (key loaded and DB open). When it
// is false the site still serves the landing page; only registration degrades.
func (d deps) ready() bool { return d.store != nil && d.identity != nil }
// handleRegister dispatches GET (render form) and POST (process submission).
func (d deps) handleRegister(w http.ResponseWriter, r *http.Request) {
if !d.ready() {
d.renderMessage(w, http.StatusServiceUnavailable, "Rejestracja chwilowo niedostępna",
"Zapisy są tymczasowo niedostępne.",
"Spróbuj ponownie za chwilę lub napisz na czacie Matrix.")
return
}
switch r.Method {
case http.MethodGet:
d.registerGet(w, r)
case http.MethodPost:
d.registerPost(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (d deps) registerGet(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("t")
payload, ok := d.decode(w, token)
if !ok {
return
}
nick := nickFromHandle(payload.Handle)
// Link expiry: once this handle is registered the link is spent. Show the
// confirmation instead of the form, so a re-used link cannot open a second
// registration attempt (the POST path already dedupes on ErrDuplicate).
number, registered, err := d.store.Number(payload.Handle)
if err != nil {
d.serverError(w, "number", err)
return
}
if registered {
d.renderMessage(w, http.StatusOK, "Link już wykorzystany",
fmt.Sprintf("Jesteś już zapisany (#%d).", number),
"Twoja rejestracja jest kompletna — ten link został już wykorzystany.")
return
}
if !d.isOpen() {
d.renderMessage(w, http.StatusOK, "Zapisy jeszcze nieotwarte",
"Zapisy na D-Day nie są jeszcze otwarte.",
"Start zapisów: "+openStart+". Wróć tutaj przez ten sam link.")
return
}
count, err := d.store.Count()
if err != nil {
d.serverError(w, "count", err)
return
}
if count >= d.total() {
d.renderMessage(w, http.StatusOK, "Brak miejsc",
"Niestety, brak wolnych miejsc.",
"Lista uczestników i lista rezerwowa zostały wyczerpane.")
return
}
// Confirmed seats gone but waiting-list places remain: warn that this signup
// joins the waiting list, not the confirmed roster.
d.renderForm(w, formView{Nick: nick, Token: token, Count: count,
Limit: d.total(), Waitlist: count >= d.seatLimit})
}
func (d deps) registerPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
token := r.PostFormValue("t")
// The token is the source of truth for identity, never the form fields.
payload, ok := d.decode(w, token)
if !ok {
return
}
handle := payload.Handle
nick := nickFromHandle(handle)
city := strings.TrimSpace(r.PostFormValue("city"))
email := strings.TrimSpace(r.PostFormValue("email"))
if !d.isOpen() {
d.renderMessage(w, http.StatusOK, "Zapisy jeszcze nieotwarte",
"Zapisy na D-Day nie są jeszcze otwarte.",
"Start zapisów: "+openStart+".")
return
}
count, err := d.store.Count()
if err != nil {
d.serverError(w, "count", err)
return
}
// Validate user input before touching the store; re-render the form on error.
reject := func(msg string) {
d.renderForm(w, formView{Nick: nick, Token: token, City: city, Email: email,
Count: count, Limit: d.total(), Waitlist: count >= d.seatLimit, Error: msg})
}
switch {
case city == "":
reject("Podaj miejscowość.")
return
case len(city) > maxCityLen:
reject("Miejscowość jest za długa.")
return
case len(email) > maxEmailLen:
reject("Adres e-mail jest za długi.")
return
}
addr, err := mail.ParseAddress(email)
if err != nil {
reject("Podaj poprawny adres e-mail.")
return
}
// Store the bare address, not the raw "Name <addr>" form the parser accepts.
email = addr.Address
number, err := d.store.Register(handle, nick, city, email, d.total())
switch {
case errors.Is(err, store.ErrDuplicate):
// Neutral wording — an existing registration may be confirmed or on the
// waiting list; either way the link is spent.
v := resultView{Title: "Już zapisany", Nick: nick, Number: number}
if number > d.seatLimit {
v.WaitlistPos = number - d.seatLimit
}
d.renderResult(w, "duplicate", v)
case errors.Is(err, store.ErrFull):
d.renderMessage(w, http.StatusOK, "Brak miejsc",
"Niestety, brak wolnych miejsc.",
"Lista uczestników i lista rezerwowa zostały wyczerpane.")
case err != nil:
d.serverError(w, "register", err)
case number > d.seatLimit:
// Confirmed seats were full: this registration landed on the waiting list.
d.renderResult(w, "waitlist", resultView{Title: "Lista rezerwowa", Nick: nick,
Number: number, WaitlistPos: number - d.seatLimit})
default:
d.renderResult(w, "success", resultView{Title: "Zapisano", Nick: nick, Number: number})
}
}
func (d deps) handleCount(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
count := 0
if d.store != nil {
var err error
count, err = d.store.Count()
if err != nil {
d.serverError(w, "count", err)
return
}
}
confirmed := count
if confirmed > d.seatLimit {
confirmed = d.seatLimit
}
waitlistCount := count - d.seatLimit
if waitlistCount < 0 {
waitlistCount = 0
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
// count/limit are kept for backward compatibility; confirmed/waitlist*
// expose the two-tier capacity so the landing page can render both bars.
_ = json.NewEncoder(w).Encode(map[string]any{
"count": count,
"limit": d.seatLimit,
"waitlist": d.waitlistLimit,
"confirmed": confirmed,
"waitlistCount": waitlistCount,
"full": count >= d.total(),
"open": d.isOpen(),
})
}
// handleRegistered answers the bot's internal "is this handle registered?"
// query. It is guarded by a shared bearer token: when internalToken is empty
// the endpoint is disabled (404) so the registration list can never leak; a
// missing or wrong token is 401; a missing handle is 400. On success it returns
// {"registered": bool, "number": int}.
func (d deps) handleRegistered(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
if d.internalToken == "" {
http.NotFound(w, r)
return
}
want := "Bearer " + d.internalToken
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte(want)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
handle := strings.TrimSpace(r.URL.Query().Get("h"))
if handle == "" {
http.Error(w, "missing handle", http.StatusBadRequest)
return
}
if d.store == nil {
http.Error(w, "registration unavailable", http.StatusServiceUnavailable)
return
}
number, registered, err := d.store.Number(handle)
if err != nil {
d.serverError(w, "registered", err)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"registered": registered,
"number": number,
})
}
func (d deps) handlePrivacy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
d.serveStatic(w, "privacy.html")
}
// decode validates a token; on failure it writes a 400 page and returns ok=false.
func (d deps) decode(w http.ResponseWriter, token string) (matrixbot.RegPayload, bool) {
if strings.TrimSpace(token) == "" {
d.renderMessage(w, http.StatusBadRequest, "Nieprawidłowy link",
"Brak tokenu rejestracji.",
"Skorzystaj z linku otrzymanego od bota na czacie Matrix.")
return matrixbot.RegPayload{}, false
}
payload, err := matrixbot.DecodeRegToken(d.identity, d.tokenSecret, token)
if err != nil {
d.renderMessage(w, http.StatusBadRequest, "Nieprawidłowy link",
"Ten link rejestracyjny jest nieprawidłowy lub uszkodzony.",
"Poproś bota o nowy link na czacie Matrix.")
return matrixbot.RegPayload{}, false
}
// TTL: reject a stale link, or one whose Issued time is too far in the
// future (beyond a small clock-skew tolerance).
elapsed := time.Now().Unix() - payload.Issued
if elapsed > int64(tokenTTL/time.Second) || elapsed < -int64(tokenFutureSkew/time.Second) {
d.renderMessage(w, http.StatusBadRequest, "Link wygasł",
"Ten link rejestracyjny wygasł.",
"Poproś bota o nowy link na czacie Matrix.")
return matrixbot.RegPayload{}, false
}
return payload, true
}
func (d deps) renderForm(w http.ResponseWriter, v formView) {
v.Title = "Zapis"
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "form", v); err != nil {
log.Printf("render form: %v", err)
}
}
func (d deps) renderResult(w http.ResponseWriter, name string, v resultView) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, name, v); err != nil {
log.Printf("render %s: %v", name, err)
}
}
func (d deps) renderMessage(w http.ResponseWriter, status int, title, msg, detail string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := tmpl.ExecuteTemplate(w, "message", resultView{Title: title, Message: msg, Detail: detail}); err != nil {
log.Printf("render message: %v", err)
}
}
func (d deps) serverError(w http.ResponseWriter, ctx string, err error) {
log.Printf("%s: %v", ctx, err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
// nickFromHandle turns a Matrix MXID "@alice:hs.org" into the localpart "alice".
// Any string that does not match the @local:server shape is returned unchanged.
func nickFromHandle(handle string) string {
if !strings.HasPrefix(handle, "@") {
return handle
}
rest := handle[1:]
i := strings.IndexByte(rest, ':')
if i <= 0 {
return handle
}
return rest[:i]
}