oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "net"
5 "net/http"
6 "strconv"
7 "strings"
8 "sync"
9 "time"
10
11 "amadan.net/oficina/calendar/internal/calendar/seal"
12)
13
14// What every public booking route shares (booking spec §2.7, §4.2, §5,
15// §7.2): the client's IP, the cheap gate in front of the expensive
16// work, the two headers, the zone the page offers, and the semaphore on
17// slot computation.
18
19// clientIP is the LAST element of X-Forwarded-For — the platform edge
20// REPLACES that header with the dialling client, and the app's socket
21// is reachable only from the edge — or the host of RemoteAddr when the
22// header is absent (tests, rastrillo dev). Canonicalised; unparseable
23// is the literal "unknown", one bucket for all of it. Never logged.
24func clientIP(r *http.Request) string {
25 raw := ""
26 if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
27 parts := strings.Split(xff, ",")
28 raw = strings.TrimSpace(parts[len(parts)-1])
29 } else if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
30 raw = host
31 } else {
32 raw = r.RemoteAddr
33 }
34 if ip := net.ParseIP(raw); ip != nil {
35 return ip.String()
36 }
37 return "unknown"
38}
39
40// ipFingerprint is the keyed fingerprint the abuse caps are counted by.
41func (h *handlers) ipFingerprint(r *http.Request) string {
42 fp, err := h.store.seal.Fingerprint(seal.PurposeBookingIP, clientIP(r))
43 if err != nil {
44 return "unknown"
45 }
46 return fp
47}
48
49// publicHeaders: the token is in the path.
50func publicHeaders(w http.ResponseWriter) {
51 w.Header().Set("Cache-Control", "no-store")
52 w.Header().Set("Referrer-Policy", "no-referrer")
53}
54
55// notFoundLikeTheRouter is the SAME 404 an unknown route gets, so a
56// wrong token and a wrong path are indistinguishable (B10). chi's
57// default NotFound is http.NotFound.
58func notFoundLikeTheRouter(w http.ResponseWriter, r *http.Request) {
59 http.NotFound(w, r)
60}
61
62// zoneChoices is CommonZones plus the host's own zone when it is not
63// among them — the two sources a booker's zone may come from (§5.1).
64func zoneChoices(host Person) []string {
65 out := append([]string(nil), CommonZones...)
66 if host.Zone != "" && !IsCommonZone(host.Zone) {
67 out = append(out, host.Zone)
68 }
69 return out
70}
71
72// pickZone reads ?zone= / the form's zone, bounded to zoneChoices;
73// anything else is the host's zone. chosen says whether the request
74// carried one, so the page can tell booking.js not to guess.
75func pickZone(r *http.Request, host Person) (zone string, chosen bool) {
76 _ = r.ParseForm()
77 want := strings.TrimSpace(r.FormValue("zone"))
78 for _, z := range zoneChoices(host) {
79 if z == want {
80 return z, true
81 }
82 }
83 if host.Zone == "" {
84 return defaultZone, false
85 }
86 return host.Zone, false
87}
88
89// The gate: an in-process token bucket per IP fingerprint on every
90// public route (§7.2). 60 a minute, refilled one a second. Bounded to
91// gateMaxKeys; a key idle for gateIdle is evicted on the next insert;
92// full with no idle key, a NEW key is refused. Memory only.
93const (
94 gateBurst = 60
95 gateRefill = time.Second
96 gateMaxKeys = 10000
97 gateIdle = 5 * time.Minute
98)
99
100type gateEntry struct {
101 tokens float64
102 last time.Time
103}
104
105type gate struct {
106 mu sync.Mutex
107 keys map[string]*gateEntry
108 now func() time.Time
109}
110
111func newGate() *gate { return &gate{keys: map[string]*gateEntry{}, now: time.Now} }
112
113// allow spends one token for key, or says why not: false with full=true
114// when the bucket had no room for a new key at all.
115func (g *gate) allow(key string) (ok bool) {
116 g.mu.Lock()
117 defer g.mu.Unlock()
118 now := g.now()
119 e := g.keys[key]
120 if e == nil {
121 if len(g.keys) >= gateMaxKeys {
122 g.evictIdle(now)
123 if len(g.keys) >= gateMaxKeys {
124 return false
125 }
126 }
127 e = &gateEntry{tokens: gateBurst, last: now}
128 g.keys[key] = e
129 }
130 e.tokens += now.Sub(e.last).Seconds() / gateRefill.Seconds()
131 if e.tokens > gateBurst {
132 e.tokens = gateBurst
133 }
134 e.last = now
135 if e.tokens < 1 {
136 return false
137 }
138 e.tokens--
139 return true
140}
141
142func (g *gate) evictIdle(now time.Time) {
143 for k, e := range g.keys {
144 if now.Sub(e.last) > gateIdle {
145 delete(g.keys, k)
146 }
147 }
148}
149
150// size is for the test that proves the bound.
151func (g *gate) size() int {
152 g.mu.Lock()
153 defer g.mu.Unlock()
154 return len(g.keys)
155}
156
157// publicGate wraps every public route: the bucket, then the headers.
158func (h *handlers) publicGate(next http.HandlerFunc) http.HandlerFunc {
159 return func(w http.ResponseWriter, r *http.Request) {
160 publicHeaders(w)
161 if !h.gate.allow(h.ipFingerprint(r)) {
162 w.Header().Set("Retry-After", "60")
163 http.Error(w, "Too many requests. Try again in a minute.", http.StatusTooManyRequests)
164 return
165 }
166 next(w, r)
167 }
168}
169
170// slotSem bounds concurrent public slot computation (§4.2). Full → 503,
171// never a page computed from less than the whole busy list.
172var slotSem = make(chan struct{}, 4)
173
174func acquireSlots() (release func(), ok bool) {
175 select {
176 case slotSem <- struct{}{}:
177 return func() { <-slotSem }, true
178 default:
179 return nil, false
180 }
181}
182
183func busyNow(w http.ResponseWriter) {
184 w.Header().Set("Retry-After", "5")
185 http.Error(w, "Busy just now. Try again in a moment.", http.StatusServiceUnavailable)
186}
187
188// parseUnix reads a form's instant.
189func parseUnix(s string) (int64, bool) {
190 n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
191 return n, err == nil && n > 0
192}
193
194func hostNameOf(host Person) string {
195 if host.Handle != nil {
196 return *host.Handle
197 }
198 return ""
199}
200
201// hasControl reports a control character (a line break, a NUL) in a
202// single-line field.
203func hasControl(s string) bool {
204 for _, r := range s {
205 if r < 0x20 || r == 0x7f {
206 return true
207 }
208 }
209 return false
210}
211