package callscore import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "regexp" "time" "amadan.net/oficina/calls/protocol" ) // Quality and problem reports (spec §15.26). The host builds the report — // it holds the media statistics — and the core carries it, because the // core holds the two things the request needs: the session the hello was // given to, and the hello's report token. // // Both requests run on a context of their own rather than the room's, so // they still work once the room is closed: the final quality report goes // at leave, and "Report a problem" is offered for ten minutes after the // call (the server keeps the token that long past the participation). // reportTimeout bounds one report request. Ten seconds, as every other // request this package makes; a report that cannot get through in that // time is not worth holding a thread for. const reportTimeout = 10 * time.Second // reportTokenHeader is where the token goes (internal/calls/quality.go). const reportTokenHeader = "X-Calls-Report" var reportToken = regexp.MustCompile(`^[0-9a-f]{32}$`) // ReportOutcome is what became of a report, in words the host can turn // into a sentence for a person, or act on. type ReportOutcome string const ( // ReportSent: the server took it. ReportSent ReportOutcome = "sent" // ReportStop: the server's kill switch (CALLS_QUALITY_REPORTS=off) // answered 204. The host sends no more quality reports this call. ReportStop ReportOutcome = "stop" // ReportTooMany: 429, the server's per-participation limit. ReportTooMany ReportOutcome = "too-many" // ReportGone: 404. The token is unknown — the participation ended // more than ten minutes ago, or a new hello replaced it — or there // was never one (a server from before §15.26). ReportGone ReportOutcome = "gone" // ReportFailed: anything else — no answer, a refusal, a bad body. ReportFailed ReportOutcome = "failed" ) // ReportError is a report that did not get through, and why. type ReportError struct{ Outcome ReportOutcome } func (e *ReportError) Error() string { return "Calls report not sent (" + string(e.Outcome) + ")" } // reportTokenNow is the latest hello's token, or "". func (r *Room) reportTokenNow() string { r.mu.Lock() defer r.mu.Unlock() return r.report } // SendQuality posts one quality report (protocol.QualityReport, as JSON). // stop is the server's 204: send no more for the rest of the call. Every // other refusal is a skipped report, and the next window tries again. It // blocks for up to ten seconds and belongs on the host's background // executor. func (r *Room) SendQuality(report []byte) (stop bool, err error) { if len(report) > protocol.MaxQualityBody || !json.Valid(report) { return false, &ReportError{ReportFailed} } status, _, err := r.postReport("quality", report) if err != nil { return false, err } switch status { case http.StatusAccepted, http.StatusOK: return false, nil case http.StatusNoContent: return true, nil } return false, &ReportError{outcomeOf(status)} } // SendProblem posts "Report a problem" (protocol.ProblemReport, as JSON) // and answers the reference the server gave it, which the host shows. func (r *Room) SendProblem(report []byte) (ref string, err error) { if len(report) > protocol.MaxProblemBody || !json.Valid(report) { return "", &ReportError{ReportFailed} } status, body, err := r.postReport("problem", report) if err != nil { return "", err } if status != http.StatusOK { return "", &ReportError{outcomeOf(status)} } var receipt protocol.ProblemReceipt if json.Unmarshal(body, &receipt) != nil || !validRef(receipt.Ref) { return "", &ReportError{ReportFailed} } return receipt.Ref, nil } func outcomeOf(status int) ReportOutcome { switch status { case http.StatusTooManyRequests: return ReportTooMany case http.StatusNotFound: return ReportGone } return ReportFailed } // validRef is the server's reference shape: eight of Crockford's // characters. Anything else is not shown to a person as one. func validRef(ref string) bool { if len(ref) != 8 { return false } for _, c := range ref { if !(c >= '0' && c <= '9' || c >= 'A' && c <= 'Z') { return false } } return true } // postReport makes the request: the same session, Origin and redirect // rules as every other request from the transport, plus the token. func (r *Room) postReport(endpoint string, body []byte) (int, []byte, error) { token := r.reportTokenNow() if !reportToken.MatchString(token) { return 0, nil, &ReportError{ReportGone} } ctx, cancel := context.WithTimeout(context.Background(), reportTimeout) defer cancel() t := r.t req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.origin+"/m/"+t.room+"/"+endpoint, bytes.NewReader(body)) if err != nil { return 0, nil, &ReportError{ReportFailed} } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", t.origin) req.Header.Set(reportTokenHeader, token) res, err := t.http.Do(req) if err != nil { return 0, nil, &ReportError{ReportFailed} } defer res.Body.Close() out, err := io.ReadAll(io.LimitReader(res.Body, maxResponse)) if err != nil { return 0, nil, &ReportError{ReportFailed} } return res.StatusCode, out, nil } // OutcomeOf is a report error's outcome, ReportFailed for any other // error, and ReportSent for none. func OutcomeOf(err error) ReportOutcome { if err == nil { return ReportSent } var refusal *ReportError if errors.As(err, &refusal) { return refusal.Outcome } return ReportFailed }