oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calls
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "net/http"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/go-chi/chi/v5"
14)
15
16// maxAPIBody is the drive surface's request cap: 16 KB, refused before
17// anything is decoded. An agent posts a title and a timestamp; nothing
18// legitimate comes close.
19const maxAPIBody = 16 * 1024
20
21// The per-agent budget (spec §8, Global Constraints): sixty requests a
22// minute, burst sixty — one token a second into a bucket of sixty, so
23// a run of sixty back to back is fine and a loop is not. maxAgentBuckets
24// bounds the map for the same reason maxSignalBuckets does.
25const (
26 apiRatePerSecond = 1
27 apiBurst = 60
28 maxAgentBuckets = 4096
29)
30
31// agentCtxKey carries the resolved agent and its operator from
32// requireAgent to the handler. Nothing else reads it, and no handler
33// re-derives either.
34type agentCtxKey struct{}
35
36type agentPrincipal struct {
37 agent Agent
38 operator Person
39}
40
41func currentAgent(r *http.Request) (agentPrincipal, bool) {
42 ap, ok := r.Context().Value(agentCtxKey{}).(agentPrincipal)
43 return ap, ok
44}
45
46// apiJSON is every answer the drive surface makes. no-store because a
47// response can carry a link token (§8.2) and because an agent's cache
48// is not a place a meeting's state should live.
49func apiJSON(w http.ResponseWriter, status int, body any) {
50 w.Header().Set("Content-Type", "application/json")
51 w.Header().Set("Cache-Control", "no-store")
52 w.WriteHeader(status)
53 if body != nil {
54 _ = json.NewEncoder(w).Encode(body)
55 }
56}
57
58// apiNotFound is the one not-found shape this surface ever answers: an
59// unknown token, a revoked token, a token whose operator has left the
60// team, and a meeting that does not exist are all these same bytes, so
61// nothing about which it was is observable (spec §8.1, §2.5).
62func apiNotFound() map[string]any { return map[string]any{"error": "not-found"} }
63
64// apiRoutes is the drive surface, mounted at the router root OUTSIDE
65// csrf.Protect and outside the session middlewares (see build).
66//
67// Outside csrf.Protect because rastrillo's csrf.SameOrigin refuses a
68// state-changing request carrying none of Sec-Fetch-Site, Origin or
69// Referer, and an agent — which is not a browser — sends none of the
70// three; a POST from one would be a 403 before it reached a handler.
71// That is safe precisely because this surface honours no cookie: the
72// attack CSRF defends against is a browser spending a session it holds
73// ambiently, and there is no ambient credential here to spend. The
74// bearer is the only thing that authenticates a request, and a page on
75// another origin cannot read it.
76//
77// Outside the session middlewares because RequireSession would redirect
78// a bearer-only request to the sign-in page and requirePerson would
79// never run.
80func (a *app) apiRoutes(r chi.Router) {
81 r.Route("/api/v1", func(r chi.Router) {
82 r.Use(capAPIBody)
83 r.Use(a.requireAgent)
84 // A path or method this surface does not have is the same
85 // not-found body everything else answers — and it is answered
86 // behind requireAgent, so an unauthenticated caller cannot map
87 // the routes at all.
88 r.NotFound(func(w http.ResponseWriter, r *http.Request) {
89 a.refuseAPI(w, r, http.StatusNotFound, apiNotFound(), "no-route")
90 })
91 r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
92 a.refuseAPI(w, r, http.StatusNotFound, apiNotFound(), "no-route")
93 })
94 r.Get("/meetings", a.apiListMeetings)
95 r.Post("/meetings", a.apiCreateMeeting)
96 r.Get("/meetings/{sqid}", a.apiGetMeeting)
97 r.Post("/meetings/{sqid}/links", a.apiReplaceLink)
98 r.Post("/meetings/{sqid}/end", a.apiEndMeeting)
99 })
100}
101
102// capAPIBody puts every request on this surface under the 16 KB cap
103// before a handler runs — the operations that read a body and the ones
104// that do not alike. A cap applied inside the decoder only would leave
105// the bodyless writes (`/links`, `/end`) reading nothing and refusing
106// nothing, so a megabyte posted at `/end` would perform the write
107// (review S5-1). MaxBytesReader takes the ResponseWriter so an
108// over-cap body also stops the connection being reused for a body the
109// server never read.
110func capAPIBody(next http.Handler) http.Handler {
111 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112 if r.Body != nil {
113 r.Body = http.MaxBytesReader(w, r.Body, maxAPIBody)
114 }
115 next.ServeHTTP(w, r)
116 })
117}
118
119// requireAgent is spec §2.5's `/api/v1/*` row: the bearer names an
120// unrevoked agent AND its operator holds member standing right now
121// (§8.1). Both halves are re-derived per request, so an operator
122// dropped from the team takes their agent's reach with them at the next
123// call, with no revocation step (§13's slice-5 gate).
124//
125// A cookie is never read here — not for identity, not for anything.
126func (a *app) requireAgent(next http.Handler) http.Handler {
127 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
128 token, ok := bearerToken(r.Header.Get("Authorization"))
129 if !ok {
130 // The header is missing or is not a bearer at all. This is
131 // the one refusal that is not the not-found body: it says
132 // "you presented no credential", which is true of every
133 // caller and tells an attacker nothing they did not send.
134 w.Header().Set("WWW-Authenticate", "Bearer")
135 a.refuseAPI(w, r, http.StatusUnauthorized, map[string]any{"error": "unauthorized"}, "unauthorized")
136 return
137 }
138 ag, operator, found, err := a.store.agentByToken(r.Context(), token)
139 if err != nil {
140 a.apiServerError(w, r, "resolve agent", err)
141 return
142 }
143 if !found {
144 a.refuseAPI(w, r, http.StatusNotFound, apiNotFound(), "token")
145 return
146 }
147 if !a.isMember(operator) {
148 a.refuseAPI(w, r.WithContext(withAgent(r.Context(), ag, operator)),
149 http.StatusNotFound, apiNotFound(), "reach")
150 return
151 }
152 r = r.WithContext(withAgent(r.Context(), ag, operator))
153 if ok, retry := a.apiLimiter.allow(actorForAgent(ag), a.store.now()); !ok {
154 w.Header().Set("Retry-After", strconv.Itoa(retryAfterSeconds(retry)))
155 a.refuseAPI(w, r, http.StatusTooManyRequests, map[string]any{"error": "rate"}, "rate")
156 return
157 }
158 next.ServeHTTP(w, r)
159 })
160}
161
162func withAgent(ctx context.Context, ag Agent, operator Person) context.Context {
163 return context.WithValue(ctx, agentCtxKey{}, agentPrincipal{agent: ag, operator: operator})
164}
165
166// bearerToken reads RFC 6750's Authorization: Bearer <token>. The
167// scheme is matched case-insensitively, as HTTP requires; the token
168// itself is taken exactly as sent and is not inspected for shape —
169// whether it looks like one of ours is not this function's question,
170// and answering it here would make a made-up token distinguishable
171// from an unknown one by status alone.
172func bearerToken(header string) (string, bool) {
173 scheme, rest, found := strings.Cut(strings.TrimSpace(header), " ")
174 if !found || !strings.EqualFold(scheme, "bearer") {
175 return "", false
176 }
177 token := strings.TrimSpace(rest)
178 return token, token != ""
179}
180
181// retryAfterSeconds rounds a wait up to whole seconds, never below one:
182// Retry-After is expressed in seconds, and a zero would invite an
183// immediate retry that is certain to be refused again.
184func retryAfterSeconds(d time.Duration) int {
185 s := int((d + time.Second - 1) / time.Second)
186 if s < 1 {
187 return 1
188 }
189 return s
190}
191
192// refuseAPI is every refusal this surface makes: one log line carrying
193// the agent (when the bearer resolved to one) and the outcome, and
194// nothing else — never the token, never its hash, never a link token,
195// never a title (spec §0.1, §8.1). Nothing at all is logged on success.
196func (a *app) refuseAPI(w http.ResponseWriter, r *http.Request, status int, body map[string]any, outcome string) {
197 if ap, ok := currentAgent(r); ok {
198 a.logger.Warn("calls: api refused", "agent_id", ap.agent.ID, "outcome", outcome)
199 } else {
200 a.logger.Warn("calls: api refused", "outcome", outcome)
201 }
202 apiJSON(w, status, body)
203}
204
205func (a *app) apiServerError(w http.ResponseWriter, r *http.Request, what string, err error) {
206 a.logger.Error("calls: api: "+what, "err", err)
207 apiJSON(w, http.StatusInternalServerError, map[string]any{"error": "server"})
208}
209
210// apiMeetingBody is one meeting on the wire (spec §8.2). It is built
211// field by field from the stored row: no model is ever marshalled.
212type apiMeetingBody struct {
213 Sqid string `json:"sqid"`
214 Title string `json:"title"`
215 StartsAt *string `json:"starts_at"`
216 EndedAt *string `json:"ended_at"`
217 LinkPresent bool `json:"link_present"`
218 CreatedBy string `json:"created_by"`
219}
220
221// apiTime is RFC 3339 in UTC, or null. Every moment on this surface is
222// UTC: an agent has no instance zone to render in.
223func apiTime(t *time.Time) *string {
224 if t == nil {
225 return nil
226 }
227 s := t.UTC().Format(time.RFC3339)
228 return &s
229}
230
231// apiBody renders one view. created_by is v.CreatedByPrincipal,
232// already resolved by meetingViewOfCached (a list shares one label
233// cache across its rows; a single-row read passes its own throwaway
234// one) — apiBody used to re-resolve it a second time through its own
235// labels map, which cost every single-row API path two lookups where
236// one would do.
237func (a *app) apiBody(v meetingView) apiMeetingBody {
238 return apiMeetingBody{
239 Sqid: v.Sqid, Title: v.Title,
240 StartsAt: apiTime(v.StartsAt), EndedAt: apiTime(v.EndedAt),
241 LinkPresent: v.LinkPresent, CreatedBy: v.CreatedByPrincipal,
242 }
243}
244
245func (a *app) apiListMeetings(w http.ResponseWriter, r *http.Request) {
246 views, err := a.store.listMeetings(r.Context())
247 if err != nil {
248 a.apiServerError(w, r, "list meetings", err)
249 return
250 }
251 out := make([]apiMeetingBody, 0, len(views))
252 for _, v := range views {
253 out = append(out, a.apiBody(v))
254 }
255 apiJSON(w, http.StatusOK, map[string]any{"meetings": out})
256}
257
258// apiCreateRequest is the only shape POST /api/v1/meetings accepts.
259// StartsAt is a *string rather than a time so an unparseable moment is
260// this surface's own 400 rather than encoding/json's.
261type apiCreateRequest struct {
262 Title string `json:"title"`
263 StartsAt *string `json:"starts_at"`
264}
265
266func (a *app) apiCreateMeeting(w http.ResponseWriter, r *http.Request) {
267 var req apiCreateRequest
268 if !a.apiDecode(w, r, &req) {
269 return
270 }
271 // Copied out field by field, never bound onto a model.
272 title := strings.TrimSpace(req.Title)
273 if title == "" || titleTooLong(title) {
274 a.refuseAPI(w, r, http.StatusBadRequest, map[string]any{"error": "bad-request"}, "title")
275 return
276 }
277 var start *time.Time
278 if req.StartsAt != nil {
279 t, err := time.Parse(time.RFC3339, *req.StartsAt)
280 if err != nil {
281 a.refuseAPI(w, r, http.StatusBadRequest, map[string]any{"error": "bad-request"}, "starts-at")
282 return
283 }
284 u := t.UTC()
285 start = &u
286 }
287
288 ap, _ := currentAgent(r)
289 m, token, err := a.store.createMeeting(r.Context(), title, start, actorForAgent(ap.agent))
290 if err != nil {
291 a.apiServerError(w, r, "create meeting", err)
292 return
293 }
294 view, err := a.store.meetingViewOf(r.Context(), m)
295 if err != nil {
296 a.apiServerError(w, r, "read the new meeting", err)
297 return
298 }
299 apiJSON(w, http.StatusCreated, map[string]any{
300 "meeting": a.apiBody(view),
301 "link": a.linkURL(token),
302 })
303}
304
305func (a *app) apiGetMeeting(w http.ResponseWriter, r *http.Request) {
306 m, ok := a.apiMeetingFor(w, r)
307 if !ok {
308 return
309 }
310 view, err := a.store.meetingViewOf(r.Context(), m)
311 if err != nil {
312 a.apiServerError(w, r, "read meeting", err)
313 return
314 }
315 body := map[string]any{"meeting": a.apiBody(view)}
316 // While it runs, and only while it runs: how many peers are in the
317 // call, never who they are or anything they said (spec §8.2).
318 if m.EndedAt == nil {
319 body["admitted"] = a.hub.AdmittedCount(m.Sqid)
320 }
321 apiJSON(w, http.StatusOK, body)
322}
323
324func (a *app) apiReplaceLink(w http.ResponseWriter, r *http.Request) {
325 m, ok := a.apiMeetingFor(w, r)
326 if !ok {
327 return
328 }
329 if !a.apiNoBody(w, r) {
330 return
331 }
332 // No ended check on the snapshot read above. A meeting can end
333 // between that read and this write (review S5-2); the authority is
334 // store.replaceLink's own re-read of ended_at inside its own
335 // transaction, which answers errMeetingEnded.
336 ap, _ := currentAgent(r)
337 token, err := a.store.replaceLink(r.Context(), m.ID, actorForAgent(ap.agent))
338 if errors.Is(err, errMeetingEnded) {
339 a.refuseAPI(w, r, http.StatusConflict, map[string]any{"error": "ended"}, "ended")
340 return
341 }
342 if err != nil {
343 a.apiServerError(w, r, "replace link", err)
344 return
345 }
346 view, err := a.store.meetingViewOf(r.Context(), m)
347 if err != nil {
348 a.apiServerError(w, r, "read meeting", err)
349 return
350 }
351 apiJSON(w, http.StatusOK, map[string]any{
352 "meeting": a.apiBody(view),
353 "link": a.linkURL(token),
354 })
355}
356
357func (a *app) apiEndMeeting(w http.ResponseWriter, r *http.Request) {
358 m, ok := a.apiMeetingFor(w, r)
359 if !ok {
360 return
361 }
362 if !a.apiNoBody(w, r) {
363 return
364 }
365 // As with replace: the ended check is the conditional UPDATE inside
366 // store.endMeeting, not the snapshot read above (review S5-2). The
367 // end that changed no row is the 409, so exactly one of two racing
368 // ends gets the 200.
369 ap, _ := currentAgent(r)
370 ended, err := a.store.endMeeting(r.Context(), m.ID, actorForAgent(ap.agent))
371 if err != nil {
372 a.apiServerError(w, r, "end meeting", err)
373 return
374 }
375 if !ended {
376 a.refuseAPI(w, r, http.StatusConflict, map[string]any{"error": "ended"}, "ended")
377 return
378 }
379 // The row is the authority; the hub is told so every open stream
380 // gets lost{ended} now rather than at the next tick (spec §2.4),
381 // exactly as the screen's own end does.
382 a.hub.End(m.Sqid)
383
384 row, ok, err := a.store.meetingBySqid(r.Context(), m.Sqid)
385 if err != nil {
386 a.apiServerError(w, r, "read the ended meeting", err)
387 return
388 }
389 if !ok {
390 a.refuseAPI(w, r, http.StatusNotFound, apiNotFound(), "meeting")
391 return
392 }
393 view, err := a.store.meetingViewOf(r.Context(), row)
394 if err != nil {
395 a.apiServerError(w, r, "read the ended meeting", err)
396 return
397 }
398 apiJSON(w, http.StatusOK, map[string]any{"meeting": a.apiBody(view)})
399}
400
401// apiMeetingFor loads the meeting named in the path, or answers the
402// one not-found body. The id is checked for shape first, as every
403// other sqid-taking endpoint does: chi hands back whatever the request
404// line contained, and an unbounded string has no business reaching a
405// query or a log line.
406func (a *app) apiMeetingFor(w http.ResponseWriter, r *http.Request) (Meeting, bool) {
407 sqid := chi.URLParam(r, "sqid")
408 if !validSqid(sqid) {
409 a.refuseAPI(w, r, http.StatusNotFound, apiNotFound(), "bad-room")
410 return Meeting{}, false
411 }
412 m, ok, err := a.store.meetingBySqid(r.Context(), sqid)
413 if err != nil {
414 a.apiServerError(w, r, "load meeting", err)
415 return Meeting{}, false
416 }
417 if !ok {
418 a.refuseAPI(w, r, http.StatusNotFound, apiNotFound(), "meeting")
419 return Meeting{}, false
420 }
421 return m, true
422}
423
424// apiDecode reads the body as dst: under capAPIBody's 16 KB cap,
425// unknown fields refused, and EOF required after the one object — so a
426// second document, a stray `]` or `}`, or any other tail behind the
427// first value is a 400 rather than a silently ignored remainder.
428//
429// EOF is required rather than dec.More() asked (review S5-1). More()
430// answers "is another array or object element waiting", which is not
431// the same question: it is false for a trailing `]`, false for a
432// trailing `}`, and — worse — it swallows the decoder's stored read
433// error, so a valid object followed by 17 KB of whitespace came back as
434// a clean decode rather than the 413 the cap promises. A second Decode
435// asks the honest question and hands back the honest error.
436func (a *app) apiDecode(w http.ResponseWriter, r *http.Request, dst any) bool {
437 dec := json.NewDecoder(r.Body)
438 dec.DisallowUnknownFields()
439 if err := dec.Decode(dst); err != nil {
440 return a.refuseBody(w, r, err)
441 }
442 // Anything at all after the first value — a second value (err nil),
443 // a syntax error, or bytes past the cap — is a refusal. Only io.EOF
444 // means "that was the whole body".
445 var tail json.RawMessage
446 if err := dec.Decode(&tail); !errors.Is(err, io.EOF) {
447 return a.refuseBody(w, r, err)
448 }
449 return true
450}
451
452// refuseBody is the one place a body error becomes a status: a cap
453// breach is 413 and everything else — malformed JSON, an unknown field,
454// a trailing value, an empty body, a read that failed — is 400. Always
455// returns false, so a caller can `return a.refuseBody(...)`.
456func (a *app) refuseBody(w http.ResponseWriter, r *http.Request, err error) bool {
457 var tooBig *http.MaxBytesError
458 if errors.As(err, &tooBig) {
459 a.refuseAPI(w, r, http.StatusRequestEntityTooLarge, map[string]any{"error": "too-large"}, "size")
460 return false
461 }
462 a.refuseAPI(w, r, http.StatusBadRequest, map[string]any{"error": "bad-request"}, "shape")
463 return false
464}
465
466// apiNoBody is the bodyless writes' half of the same rule (review
467// S5-1): `/links` and `/end` take no body at all, so the body is read
468// to its end — under capAPIBody's cap — and refused if it carried
469// anything, before the write rather than after it. Over the cap is 413;
470// any other content is 400. An absent body and a Content-Length: 0 body
471// are the two shapes that pass.
472func (a *app) apiNoBody(w http.ResponseWriter, r *http.Request) bool {
473 if r.Body == nil {
474 return true
475 }
476 n, err := io.Copy(io.Discard, r.Body)
477 if err != nil {
478 return a.refuseBody(w, r, err)
479 }
480 if n > 0 {
481 a.refuseAPI(w, r, http.StatusBadRequest, map[string]any{"error": "bad-request"}, "body")
482 return false
483 }
484 return true
485}
486