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 "crypto/rand"
6 "encoding/json"
7 "errors"
8 "io"
9 "log/slog"
10 "math"
11 "net/http"
12 "reflect"
13 "regexp"
14 "slices"
15 "strconv"
16 "strings"
17 "time"
18 "unicode/utf8"
19
20 "github.com/go-chi/chi/v5"
21
22 "amadan.net/oficina/calls/internal/calls/hub"
23 "amadan.net/oficina/calls/protocol"
24)
25
26// Quality and problem reports, spec §15.26 (Paul's ruling, 2026-10-03).
27//
28// Two endpoints, both authorized by the report token a client was given
29// in its hello (hub/report.go), presented by the session it was given to,
30// for the room it was given for — during the participation and for ten
31// minutes after it ends:
32//
33// - POST /m/{sqid}/quality: the automatic report, every thirty seconds
34// and once at leave. A fixed schema (protocol/quality.go), validated
35// strictly, re-encoded from what was validated, and written as one
36// `calls: quality` line. CALLS_QUALITY_REPORTS=off answers 204 before
37// anything else is looked at, and a client that is answered 204 stops
38// sending for the rest of the call.
39// - POST /m/{sqid}/problem: "Report a problem". The person's note and
40// their client's call diagnostics, written as one `calls: problem
41// report` line and as many `calls: problem report diagnostics` lines as
42// the diagnostics need, under a reference the client shows.
43//
44// Neither touches the database: the log is the only store. Neither log
45// line carries the meeting's sqid, a peer id, a person, an email, a name
46// or an address; the call and the participation are named only by the
47// hub's random tags.
48
49const (
50 // reportTokenHeader carries the token: a header rather than a body
51 // member so the body stays exactly the schema.
52 reportTokenHeader = "X-Calls-Report"
53 // maxReportBuckets bounds both report limiters' maps, as
54 // maxSignalBuckets does the signal limiter's.
55 maxReportBuckets = 4096
56 // diagnosticsChunk is how much escaped diagnostics text goes in one
57 // log line. The platform keeps 8 KB of a line and truncates the rest
58 // (carlos/platform internal/activator/backend_exec.go execLogLineMax),
59 // so a chunk leaves room for the line's own fields.
60 diagnosticsChunk = 6000
61)
62
63var (
64 reportTokenPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
65 versionPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.+-]{0,23}$`)
66 numericVersion = regexp.MustCompile(`^[0-9]{1,4}([._][0-9]{1,6}){0,3}$`)
67
68 reportApps = []string{"web", "mac", "iphone", "ipad"}
69 reportBrowsers = []string{"chrome", "safari", "firefox", "edge", "other"}
70 reportOSes = []string{"macos", "ios", "ipados", "windows", "linux", "android", "chromeos", "other"}
71)
72
73// qualityReportsOn is the kill switch: CALLS_QUALITY_REPORTS=off turns the
74// automatic reports off. Anything else, including unset, is on.
75func qualityReportsOn(v string) bool {
76 return !strings.EqualFold(strings.TrimSpace(v), "off")
77}
78
79// reporter resolves the request's report token, or answers 404 itself.
80func (a *app) reporter(w http.ResponseWriter, r *http.Request) (hub.Reporter, string, bool) {
81 sqid := chi.URLParam(r, "sqid")
82 token := r.Header.Get(reportTokenHeader)
83 session, ok := a.sessionKey(r)
84 if !ok || !validSqid(sqid) || !reportTokenPattern.MatchString(token) {
85 streamJSON(w, http.StatusNotFound, map[string]any{"error": "not-found"})
86 return hub.Reporter{}, "", false
87 }
88 rep, ok := a.hub.Reporter(sqid, session, token)
89 if !ok {
90 streamJSON(w, http.StatusNotFound, map[string]any{"error": "not-found"})
91 return hub.Reporter{}, "", false
92 }
93 return rep, sqid, true
94}
95
96// readReport reads a body up to limit, answering 413 or 400 itself.
97func readReport(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, bool) {
98 r.Body = http.MaxBytesReader(w, r.Body, limit)
99 raw, err := io.ReadAll(r.Body)
100 if err != nil {
101 var tooBig *http.MaxBytesError
102 if errors.As(err, &tooBig) {
103 streamJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "too-large"})
104 } else {
105 streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad-request"})
106 }
107 return nil, false
108 }
109 return raw, true
110}
111
112func rateRefused(w http.ResponseWriter, retry time.Duration) {
113 w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
114 streamJSON(w, http.StatusTooManyRequests, map[string]any{"error": "rate"})
115}
116
117// quality is POST /m/{sqid}/quality.
118func (a *app) quality(w http.ResponseWriter, r *http.Request) {
119 if !a.cfg.qualityReports {
120 w.WriteHeader(http.StatusNoContent)
121 return
122 }
123 rep, _, ok := a.reporter(w, r)
124 if !ok {
125 return
126 }
127 if allowed, retry := a.qualityLimiter.allow(rep.PeerTag, a.store.now()); !allowed {
128 rateRefused(w, retry)
129 return
130 }
131 raw, ok := readReport(w, r, protocol.MaxQualityBody)
132 if !ok {
133 return
134 }
135 q, ok := validateQuality(raw)
136 if !ok {
137 streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad-request"})
138 return
139 }
140 a.logQuality(rep, q)
141 w.WriteHeader(http.StatusAccepted)
142}
143
144// problem is POST /m/{sqid}/problem.
145func (a *app) problem(w http.ResponseWriter, r *http.Request) {
146 rep, sqid, ok := a.reporter(w, r)
147 if !ok {
148 return
149 }
150 if allowed, retry := a.problemLimiter.allow(rep.PeerTag, a.store.now()); !allowed {
151 rateRefused(w, retry)
152 return
153 }
154 raw, ok := readReport(w, r, protocol.MaxProblemBody)
155 if !ok {
156 return
157 }
158 p, ok := validateProblem(raw)
159 if !ok {
160 streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad-request"})
161 return
162 }
163 ref := newReportRef()
164 a.logProblem(rep, sqid, ref, p)
165 streamJSON(w, http.StatusOK, map[string]any{"ref": ref})
166}
167
168// validateQuality is the strict half: the schema's types (a mistyped
169// member fails the decode), its enums, its bounds and its version. A
170// member the schema does not name is ignored, never logged: the log line
171// is re-encoded from this value, not from the request's bytes.
172func validateQuality(raw []byte) (protocol.QualityReport, bool) {
173 var q protocol.QualityReport
174 if !utf8.Valid(raw) || json.Unmarshal(raw, &q) != nil {
175 return q, false
176 }
177 if q.V != protocol.QualityVersion || q.Seconds == nil || *q.Seconds <= 0 {
178 return q, false
179 }
180 if !validClient(&q.Client) {
181 return q, false
182 }
183 if d := q.Devices; d != nil && (!enumOrEmpty(d.Input, protocol.DeviceKinds) || !enumOrEmpty(d.Output, protocol.DeviceKinds)) {
184 return q, false
185 }
186 if len(q.Peers) > protocol.MaxQualityPeers {
187 return q, false
188 }
189 seen := map[protocol.PeerID]bool{}
190 for i := range q.Peers {
191 p := &q.Peers[i]
192 if !validPeerID(p.Peer) || seen[p.Peer] {
193 return q, false
194 }
195 seen[p.Peer] = true
196 if p.Path != nil && (!enumOrEmpty(p.Path.Local, protocol.CandidateKinds) ||
197 !enumOrEmpty(p.Path.Remote, protocol.CandidateKinds) || !enumOrEmpty(p.Path.Protocol, protocol.PathProtocols)) {
198 return q, false
199 }
200 if p.Out != nil {
201 for _, s := range []*protocol.OutStream{p.Out.Audio, p.Out.Video, p.Out.Screen} {
202 if s != nil && !enumOrEmpty(s.Limit, protocol.QualityLimits) {
203 return q, false
204 }
205 }
206 }
207 }
208 // Every number, wherever it is: finite, non-negative, under its
209 // ceiling, and rounded to a tenth for the line.
210 if !boundNumbers(reflect.ValueOf(&q).Elem(), "") {
211 return q, false
212 }
213 return q, true
214}
215
216func validClient(c *protocol.ReportClient) bool {
217 if !slices.Contains(reportApps, c.App) {
218 return false
219 }
220 if c.Version != "" && !versionPattern.MatchString(c.Version) {
221 return false
222 }
223 if !enumOrEmpty(c.Browser, reportBrowsers) || !enumOrEmpty(c.OS, reportOSes) {
224 return false
225 }
226 if c.BrowserVersion != "" && !numericVersion.MatchString(c.BrowserVersion) {
227 return false
228 }
229 if c.OSVersion != "" && !numericVersion.MatchString(c.OSVersion) {
230 return false
231 }
232 return true
233}
234
235func enumOrEmpty(v string, allowed []string) bool { return v == "" || slices.Contains(allowed, v) }
236
237// numberCeiling is a number's bound, by its JSON name.
238func numberCeiling(name string) float64 {
239 switch name {
240 case "seconds", "reconnectingSeconds", "cantHearSeconds", "freezeSeconds":
241 return protocol.MaxQualitySeconds
242 case "rtt", "jitter", "jitterBufferMs":
243 return protocol.MaxQualityMillis
244 case "kbps":
245 return protocol.MaxQualityKbps
246 case "width", "height":
247 return protocol.MaxQualityPixels
248 case "fps":
249 return protocol.MaxQualityFPS
250 }
251 return protocol.MaxQualityCount
252}
253
254// boundNumbers walks the report and checks every *float64 against its
255// ceiling, rounding each to one decimal place.
256func boundNumbers(v reflect.Value, name string) bool {
257 switch v.Kind() {
258 case reflect.Pointer:
259 if v.IsNil() {
260 return true
261 }
262 if f, ok := v.Interface().(*float64); ok {
263 x := *f
264 if math.IsNaN(x) || math.IsInf(x, 0) || x < 0 || x > numberCeiling(name) {
265 return false
266 }
267 *f = math.Round(x*10) / 10
268 return true
269 }
270 return boundNumbers(v.Elem(), name)
271 case reflect.Struct:
272 t := v.Type()
273 for i := 0; i < v.NumField(); i++ {
274 tag, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
275 if !boundNumbers(v.Field(i), tag) {
276 return false
277 }
278 }
279 case reflect.Slice:
280 for i := 0; i < v.Len(); i++ {
281 if !boundNumbers(v.Index(i), name) {
282 return false
283 }
284 }
285 }
286 return true
287}
288
289// validateProblem bounds the note (characters) and the whole body (bytes,
290// by the reader), and the client as the quality report does.
291func validateProblem(raw []byte) (protocol.ProblemReport, bool) {
292 var p protocol.ProblemReport
293 if !utf8.Valid(raw) || json.Unmarshal(raw, &p) != nil {
294 return p, false
295 }
296 if utf8.RuneCountInString(p.Note) > protocol.MaxProblemNote || !validClient(&p.Client) {
297 return p, false
298 }
299 return p, true
300}
301
302// logQuality writes the one `calls: quality` line. The top level carries
303// what the platform can filter on (carlos logs --field): the tags, the
304// client, and a few headline numbers; `report` is the rest, as validated,
305// with each remote peer's id replaced by its tag.
306func (a *app) logQuality(rep hub.Reporter, q protocol.QualityReport) {
307 sum := summarize(q)
308 for i := range q.Peers {
309 q.Peers[i].Peer = protocol.PeerID(tagOrUnknown(rep, q.Peers[i].Peer))
310 }
311 body := struct {
312 Seconds *float64 `json:"seconds"`
313 Final bool `json:"final,omitempty"`
314 Devices *protocol.Devices `json:"devices,omitempty"`
315 Events *protocol.CallEvents `json:"events,omitempty"`
316 Peers []protocol.PeerQuality `json:"peers,omitempty"`
317 }{q.Seconds, q.Final, q.Devices, q.Events, q.Peers}
318 attrs := append(clientAttrs(rep, q.Client),
319 slog.Bool("final", q.Final),
320 slog.Float64("seconds", *q.Seconds),
321 slog.Int("remotes", len(q.Peers)),
322 slog.Bool("relay", sum.relay),
323 slog.Float64("maxLossPct", sum.lossPct),
324 slog.Float64("maxRtt", sum.rtt),
325 slog.Float64("freezes", sum.freezes),
326 slog.Float64("reconnecting", sum.reconnecting),
327 slog.Any("report", body),
328 )
329 a.reportLog.LogAttrs(context.Background(), slog.LevelInfo, "calls: quality", attrs...)
330}
331
332func clientAttrs(rep hub.Reporter, c protocol.ReportClient) []slog.Attr {
333 attrs := []slog.Attr{slog.String("call", rep.CallTag), slog.String("peer", rep.PeerTag), slog.String("app", c.App)}
334 for _, kv := range [][2]string{{"version", c.Version}, {"browser", c.Browser}, {"browserVersion", c.BrowserVersion}, {"os", c.OS}, {"osVersion", c.OSVersion}} {
335 if kv[1] != "" {
336 attrs = append(attrs, slog.String(kv[0], kv[1]))
337 }
338 }
339 return attrs
340}
341
342func tagOrUnknown(rep hub.Reporter, id protocol.PeerID) string {
343 if tag, ok := rep.Peers[id]; ok {
344 return tag
345 }
346 return "unknown"
347}
348
349type qualitySummary struct {
350 relay bool
351 lossPct, rtt, freezes, reconnecting float64
352}
353
354// summarize is the line's headline: whether any connection went through
355// the relay, the worst inbound loss as a percentage of packets, the worst
356// round trip, the freezes and the "Reconnecting…" notices.
357func summarize(q protocol.QualityReport) qualitySummary {
358 var s qualitySummary
359 val := func(p *float64) float64 {
360 if p == nil {
361 return 0
362 }
363 return *p
364 }
365 loss := func(packets, lost *float64) {
366 n, l := val(packets), val(lost)
367 if n+l > 0 {
368 s.lossPct = max(s.lossPct, math.Round(1000*l/(n+l))/10)
369 }
370 }
371 if q.Events != nil {
372 s.reconnecting += val(q.Events.Reconnecting)
373 }
374 for _, p := range q.Peers {
375 if p.Path != nil && (p.Path.Local == "relay" || p.Path.Remote == "relay") {
376 s.relay = true
377 }
378 s.rtt = max(s.rtt, val(p.RTT))
379 if p.In != nil {
380 if p.In.Audio != nil {
381 loss(p.In.Audio.Packets, p.In.Audio.Lost)
382 }
383 for _, v := range []*protocol.InVideo{p.In.Video, p.In.Screen} {
384 if v != nil {
385 loss(v.Packets, v.Lost)
386 s.freezes += val(v.Freezes)
387 }
388 }
389 }
390 if p.Out != nil {
391 for _, o := range []*protocol.OutStream{p.Out.Audio, p.Out.Video, p.Out.Screen} {
392 if o != nil {
393 s.rtt = max(s.rtt, val(o.RTT))
394 }
395 }
396 }
397 if p.Events != nil {
398 s.reconnecting += val(p.Events.Reconnecting)
399 }
400 }
401 return s
402}
403
404// logProblem writes the report: one head line with the note, and the
405// diagnostics in as many lines as they take, each under the reference.
406//
407// The diagnostics are the client's own text, and are scrubbed here of
408// what the client could not know to leave out: every peer id this call
409// has held, in full or as the six-character prefix the diagnostics use,
410// becomes "peer:<tag>", and the meeting's sqid becomes "[meeting]". A
411// client already writes "peer abc123" for a person instead of their
412// name; device names stay, which the spec says.
413func (a *app) logProblem(rep hub.Reporter, sqid, ref string, p protocol.ProblemReport) {
414 scrub := func(s string) string {
415 s = strings.ReplaceAll(s, sqid, "[meeting]")
416 for id, tag := range rep.Peers {
417 s = strings.ReplaceAll(s, string(id), "peer:"+tag)
418 }
419 for id, tag := range rep.Peers {
420 s = strings.ReplaceAll(s, string(id)[:6], "peer:"+tag)
421 }
422 return s
423 }
424 note := scrub(p.Note)
425 chunks := chunkEscaped(scrub(p.Diagnostics), diagnosticsChunk)
426 attrs := append([]slog.Attr{slog.String("ref", ref)}, clientAttrs(rep, p.Client)...)
427 attrs = append(attrs, slog.Bool("live", rep.Live), slog.String("note", note), slog.Int("parts", len(chunks)))
428 a.reportLog.LogAttrs(context.Background(), slog.LevelWarn, "calls: problem report", attrs...)
429 for i, c := range chunks {
430 a.reportLog.LogAttrs(context.Background(), slog.LevelWarn, "calls: problem report diagnostics",
431 slog.String("ref", ref), slog.String("call", rep.CallTag), slog.String("peer", rep.PeerTag),
432 slog.Int("part", i+1), slog.Int("of", len(chunks)), slog.String("text", c))
433 }
434}
435
436// chunkEscaped splits s into pieces whose JSON-escaped length is at most
437// limit bytes, never inside a rune.
438func chunkEscaped(s string, limit int) []string {
439 if s == "" {
440 return nil
441 }
442 var out []string
443 var b strings.Builder
444 size := 0
445 for _, r := range s {
446 enc, _ := json.Marshal(string(r))
447 n := len(enc) - 2
448 if size+n > limit && b.Len() > 0 {
449 out = append(out, b.String())
450 b.Reset()
451 size = 0
452 }
453 b.WriteRune(r)
454 size += n
455 }
456 if b.Len() > 0 {
457 out = append(out, b.String())
458 }
459 return out
460}
461
462// refAlphabet is Crockford's: no I, L, O or U, so a reference read aloud
463// or copied by hand comes back the same.
464const refAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
465
466// newReportRef is a problem report's reference: eight characters, forty
467// random bits.
468func newReportRef() string {
469 b := make([]byte, 8)
470 if _, err := rand.Read(b); err != nil {
471 panic(err)
472 }
473 for i := range b {
474 b[i] = refAlphabet[int(b[i])%len(refAlphabet)]
475 }
476 return string(b)
477}
478