package calls import ( "context" "crypto/rand" "encoding/json" "errors" "io" "log/slog" "math" "net/http" "reflect" "regexp" "slices" "strconv" "strings" "time" "unicode/utf8" "github.com/go-chi/chi/v5" "amadan.net/oficina/calls/internal/calls/hub" "amadan.net/oficina/calls/protocol" ) // Quality and problem reports, spec §15.26 (Paul's ruling, 2026-10-03). // // Two endpoints, both authorized by the report token a client was given // in its hello (hub/report.go), presented by the session it was given to, // for the room it was given for — during the participation and for ten // minutes after it ends: // // - POST /m/{sqid}/quality: the automatic report, every thirty seconds // and once at leave. A fixed schema (protocol/quality.go), validated // strictly, re-encoded from what was validated, and written as one // `calls: quality` line. CALLS_QUALITY_REPORTS=off answers 204 before // anything else is looked at, and a client that is answered 204 stops // sending for the rest of the call. // - POST /m/{sqid}/problem: "Report a problem". The person's note and // their client's call diagnostics, written as one `calls: problem // report` line and as many `calls: problem report diagnostics` lines as // the diagnostics need, under a reference the client shows. // // Neither touches the database: the log is the only store. Neither log // line carries the meeting's sqid, a peer id, a person, an email, a name // or an address; the call and the participation are named only by the // hub's random tags. const ( // reportTokenHeader carries the token: a header rather than a body // member so the body stays exactly the schema. reportTokenHeader = "X-Calls-Report" // maxReportBuckets bounds both report limiters' maps, as // maxSignalBuckets does the signal limiter's. maxReportBuckets = 4096 // diagnosticsChunk is how much escaped diagnostics text goes in one // log line. The platform keeps 8 KB of a line and truncates the rest // (carlos/platform internal/activator/backend_exec.go execLogLineMax), // so a chunk leaves room for the line's own fields. diagnosticsChunk = 6000 ) var ( reportTokenPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) versionPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.+-]{0,23}$`) numericVersion = regexp.MustCompile(`^[0-9]{1,4}([._][0-9]{1,6}){0,3}$`) reportApps = []string{"web", "mac", "iphone", "ipad"} reportBrowsers = []string{"chrome", "safari", "firefox", "edge", "other"} reportOSes = []string{"macos", "ios", "ipados", "windows", "linux", "android", "chromeos", "other"} ) // qualityReportsOn is the kill switch: CALLS_QUALITY_REPORTS=off turns the // automatic reports off. Anything else, including unset, is on. func qualityReportsOn(v string) bool { return !strings.EqualFold(strings.TrimSpace(v), "off") } // reporter resolves the request's report token, or answers 404 itself. func (a *app) reporter(w http.ResponseWriter, r *http.Request) (hub.Reporter, string, bool) { sqid := chi.URLParam(r, "sqid") token := r.Header.Get(reportTokenHeader) session, ok := a.sessionKey(r) if !ok || !validSqid(sqid) || !reportTokenPattern.MatchString(token) { streamJSON(w, http.StatusNotFound, map[string]any{"error": "not-found"}) return hub.Reporter{}, "", false } rep, ok := a.hub.Reporter(sqid, session, token) if !ok { streamJSON(w, http.StatusNotFound, map[string]any{"error": "not-found"}) return hub.Reporter{}, "", false } return rep, sqid, true } // readReport reads a body up to limit, answering 413 or 400 itself. func readReport(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, bool) { r.Body = http.MaxBytesReader(w, r.Body, limit) raw, err := io.ReadAll(r.Body) if err != nil { var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { streamJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "too-large"}) } else { streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad-request"}) } return nil, false } return raw, true } func rateRefused(w http.ResponseWriter, retry time.Duration) { w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1)) streamJSON(w, http.StatusTooManyRequests, map[string]any{"error": "rate"}) } // quality is POST /m/{sqid}/quality. func (a *app) quality(w http.ResponseWriter, r *http.Request) { if !a.cfg.qualityReports { w.WriteHeader(http.StatusNoContent) return } rep, _, ok := a.reporter(w, r) if !ok { return } if allowed, retry := a.qualityLimiter.allow(rep.PeerTag, a.store.now()); !allowed { rateRefused(w, retry) return } raw, ok := readReport(w, r, protocol.MaxQualityBody) if !ok { return } q, ok := validateQuality(raw) if !ok { streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad-request"}) return } a.logQuality(rep, q) w.WriteHeader(http.StatusAccepted) } // problem is POST /m/{sqid}/problem. func (a *app) problem(w http.ResponseWriter, r *http.Request) { rep, sqid, ok := a.reporter(w, r) if !ok { return } if allowed, retry := a.problemLimiter.allow(rep.PeerTag, a.store.now()); !allowed { rateRefused(w, retry) return } raw, ok := readReport(w, r, protocol.MaxProblemBody) if !ok { return } p, ok := validateProblem(raw) if !ok { streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad-request"}) return } ref := newReportRef() a.logProblem(rep, sqid, ref, p) streamJSON(w, http.StatusOK, map[string]any{"ref": ref}) } // validateQuality is the strict half: the schema's types (a mistyped // member fails the decode), its enums, its bounds and its version. A // member the schema does not name is ignored, never logged: the log line // is re-encoded from this value, not from the request's bytes. func validateQuality(raw []byte) (protocol.QualityReport, bool) { var q protocol.QualityReport if !utf8.Valid(raw) || json.Unmarshal(raw, &q) != nil { return q, false } if q.V != protocol.QualityVersion || q.Seconds == nil || *q.Seconds <= 0 { return q, false } if !validClient(&q.Client) { return q, false } if d := q.Devices; d != nil && (!enumOrEmpty(d.Input, protocol.DeviceKinds) || !enumOrEmpty(d.Output, protocol.DeviceKinds)) { return q, false } if len(q.Peers) > protocol.MaxQualityPeers { return q, false } seen := map[protocol.PeerID]bool{} for i := range q.Peers { p := &q.Peers[i] if !validPeerID(p.Peer) || seen[p.Peer] { return q, false } seen[p.Peer] = true if p.Path != nil && (!enumOrEmpty(p.Path.Local, protocol.CandidateKinds) || !enumOrEmpty(p.Path.Remote, protocol.CandidateKinds) || !enumOrEmpty(p.Path.Protocol, protocol.PathProtocols)) { return q, false } if p.Out != nil { for _, s := range []*protocol.OutStream{p.Out.Audio, p.Out.Video, p.Out.Screen} { if s != nil && !enumOrEmpty(s.Limit, protocol.QualityLimits) { return q, false } } } } // Every number, wherever it is: finite, non-negative, under its // ceiling, and rounded to a tenth for the line. if !boundNumbers(reflect.ValueOf(&q).Elem(), "") { return q, false } return q, true } func validClient(c *protocol.ReportClient) bool { if !slices.Contains(reportApps, c.App) { return false } if c.Version != "" && !versionPattern.MatchString(c.Version) { return false } if !enumOrEmpty(c.Browser, reportBrowsers) || !enumOrEmpty(c.OS, reportOSes) { return false } if c.BrowserVersion != "" && !numericVersion.MatchString(c.BrowserVersion) { return false } if c.OSVersion != "" && !numericVersion.MatchString(c.OSVersion) { return false } return true } func enumOrEmpty(v string, allowed []string) bool { return v == "" || slices.Contains(allowed, v) } // numberCeiling is a number's bound, by its JSON name. func numberCeiling(name string) float64 { switch name { case "seconds", "reconnectingSeconds", "cantHearSeconds", "freezeSeconds": return protocol.MaxQualitySeconds case "rtt", "jitter", "jitterBufferMs": return protocol.MaxQualityMillis case "kbps": return protocol.MaxQualityKbps case "width", "height": return protocol.MaxQualityPixels case "fps": return protocol.MaxQualityFPS } return protocol.MaxQualityCount } // boundNumbers walks the report and checks every *float64 against its // ceiling, rounding each to one decimal place. func boundNumbers(v reflect.Value, name string) bool { switch v.Kind() { case reflect.Pointer: if v.IsNil() { return true } if f, ok := v.Interface().(*float64); ok { x := *f if math.IsNaN(x) || math.IsInf(x, 0) || x < 0 || x > numberCeiling(name) { return false } *f = math.Round(x*10) / 10 return true } return boundNumbers(v.Elem(), name) case reflect.Struct: t := v.Type() for i := 0; i < v.NumField(); i++ { tag, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",") if !boundNumbers(v.Field(i), tag) { return false } } case reflect.Slice: for i := 0; i < v.Len(); i++ { if !boundNumbers(v.Index(i), name) { return false } } } return true } // validateProblem bounds the note (characters) and the whole body (bytes, // by the reader), and the client as the quality report does. func validateProblem(raw []byte) (protocol.ProblemReport, bool) { var p protocol.ProblemReport if !utf8.Valid(raw) || json.Unmarshal(raw, &p) != nil { return p, false } if utf8.RuneCountInString(p.Note) > protocol.MaxProblemNote || !validClient(&p.Client) { return p, false } return p, true } // logQuality writes the one `calls: quality` line. The top level carries // what the platform can filter on (carlos logs --field): the tags, the // client, and a few headline numbers; `report` is the rest, as validated, // with each remote peer's id replaced by its tag. func (a *app) logQuality(rep hub.Reporter, q protocol.QualityReport) { sum := summarize(q) for i := range q.Peers { q.Peers[i].Peer = protocol.PeerID(tagOrUnknown(rep, q.Peers[i].Peer)) } body := struct { Seconds *float64 `json:"seconds"` Final bool `json:"final,omitempty"` Devices *protocol.Devices `json:"devices,omitempty"` Events *protocol.CallEvents `json:"events,omitempty"` Peers []protocol.PeerQuality `json:"peers,omitempty"` }{q.Seconds, q.Final, q.Devices, q.Events, q.Peers} attrs := append(clientAttrs(rep, q.Client), slog.Bool("final", q.Final), slog.Float64("seconds", *q.Seconds), slog.Int("remotes", len(q.Peers)), slog.Bool("relay", sum.relay), slog.Float64("maxLossPct", sum.lossPct), slog.Float64("maxRtt", sum.rtt), slog.Float64("freezes", sum.freezes), slog.Float64("reconnecting", sum.reconnecting), slog.Any("report", body), ) a.reportLog.LogAttrs(context.Background(), slog.LevelInfo, "calls: quality", attrs...) } func clientAttrs(rep hub.Reporter, c protocol.ReportClient) []slog.Attr { attrs := []slog.Attr{slog.String("call", rep.CallTag), slog.String("peer", rep.PeerTag), slog.String("app", c.App)} for _, kv := range [][2]string{{"version", c.Version}, {"browser", c.Browser}, {"browserVersion", c.BrowserVersion}, {"os", c.OS}, {"osVersion", c.OSVersion}} { if kv[1] != "" { attrs = append(attrs, slog.String(kv[0], kv[1])) } } return attrs } func tagOrUnknown(rep hub.Reporter, id protocol.PeerID) string { if tag, ok := rep.Peers[id]; ok { return tag } return "unknown" } type qualitySummary struct { relay bool lossPct, rtt, freezes, reconnecting float64 } // summarize is the line's headline: whether any connection went through // the relay, the worst inbound loss as a percentage of packets, the worst // round trip, the freezes and the "Reconnecting…" notices. func summarize(q protocol.QualityReport) qualitySummary { var s qualitySummary val := func(p *float64) float64 { if p == nil { return 0 } return *p } loss := func(packets, lost *float64) { n, l := val(packets), val(lost) if n+l > 0 { s.lossPct = max(s.lossPct, math.Round(1000*l/(n+l))/10) } } if q.Events != nil { s.reconnecting += val(q.Events.Reconnecting) } for _, p := range q.Peers { if p.Path != nil && (p.Path.Local == "relay" || p.Path.Remote == "relay") { s.relay = true } s.rtt = max(s.rtt, val(p.RTT)) if p.In != nil { if p.In.Audio != nil { loss(p.In.Audio.Packets, p.In.Audio.Lost) } for _, v := range []*protocol.InVideo{p.In.Video, p.In.Screen} { if v != nil { loss(v.Packets, v.Lost) s.freezes += val(v.Freezes) } } } if p.Out != nil { for _, o := range []*protocol.OutStream{p.Out.Audio, p.Out.Video, p.Out.Screen} { if o != nil { s.rtt = max(s.rtt, val(o.RTT)) } } } if p.Events != nil { s.reconnecting += val(p.Events.Reconnecting) } } return s } // logProblem writes the report: one head line with the note, and the // diagnostics in as many lines as they take, each under the reference. // // The diagnostics are the client's own text, and are scrubbed here of // what the client could not know to leave out: every peer id this call // has held, in full or as the six-character prefix the diagnostics use, // becomes "peer:", and the meeting's sqid becomes "[meeting]". A // client already writes "peer abc123" for a person instead of their // name; device names stay, which the spec says. func (a *app) logProblem(rep hub.Reporter, sqid, ref string, p protocol.ProblemReport) { scrub := func(s string) string { s = strings.ReplaceAll(s, sqid, "[meeting]") for id, tag := range rep.Peers { s = strings.ReplaceAll(s, string(id), "peer:"+tag) } for id, tag := range rep.Peers { s = strings.ReplaceAll(s, string(id)[:6], "peer:"+tag) } return s } note := scrub(p.Note) chunks := chunkEscaped(scrub(p.Diagnostics), diagnosticsChunk) attrs := append([]slog.Attr{slog.String("ref", ref)}, clientAttrs(rep, p.Client)...) attrs = append(attrs, slog.Bool("live", rep.Live), slog.String("note", note), slog.Int("parts", len(chunks))) a.reportLog.LogAttrs(context.Background(), slog.LevelWarn, "calls: problem report", attrs...) for i, c := range chunks { a.reportLog.LogAttrs(context.Background(), slog.LevelWarn, "calls: problem report diagnostics", slog.String("ref", ref), slog.String("call", rep.CallTag), slog.String("peer", rep.PeerTag), slog.Int("part", i+1), slog.Int("of", len(chunks)), slog.String("text", c)) } } // chunkEscaped splits s into pieces whose JSON-escaped length is at most // limit bytes, never inside a rune. func chunkEscaped(s string, limit int) []string { if s == "" { return nil } var out []string var b strings.Builder size := 0 for _, r := range s { enc, _ := json.Marshal(string(r)) n := len(enc) - 2 if size+n > limit && b.Len() > 0 { out = append(out, b.String()) b.Reset() size = 0 } b.WriteRune(r) size += n } if b.Len() > 0 { out = append(out, b.String()) } return out } // refAlphabet is Crockford's: no I, L, O or U, so a reference read aloud // or copied by hand comes back the same. const refAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" // newReportRef is a problem report's reference: eight characters, forty // random bits. func newReportRef() string { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { panic(err) } for i := range b { b[i] = refAlphabet[int(b[i])%len(refAlphabet)] } return string(b) }