| 1 | package calls |
| 2 | |
| 3 | import ( |
| 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 | |
| 49 | const ( |
| 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 | |
| 63 | var ( |
| 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. |
| 75 | func 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. |
| 80 | func (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. |
| 97 | func 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 | |
| 112 | func 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. |
| 118 | func (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. |
| 145 | func (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. |
| 172 | func 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 | |
| 216 | func 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 | |
| 235 | func enumOrEmpty(v string, allowed []string) bool { return v == "" || slices.Contains(allowed, v) } |
| 236 | |
| 237 | // numberCeiling is a number's bound, by its JSON name. |
| 238 | func 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. |
| 256 | func 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. |
| 291 | func 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. |
| 306 | func (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 | |
| 332 | func 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 | |
| 342 | func 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 | |
| 349 | type 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. |
| 357 | func 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. |
| 413 | func (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. |
| 438 | func 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. |
| 464 | const refAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" |
| 465 | |
| 466 | // newReportRef is a problem report's reference: eight characters, forty |
| 467 | // random bits. |
| 468 | func 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 | |