| 1 | package callscore |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "net/http" |
| 10 | "regexp" |
| 11 | "time" |
| 12 | |
| 13 | "amadan.net/oficina/calls/protocol" |
| 14 | ) |
| 15 | |
| 16 | // Quality and problem reports (spec §15.26). The host builds the report — |
| 17 | // it holds the media statistics — and the core carries it, because the |
| 18 | // core holds the two things the request needs: the session the hello was |
| 19 | // given to, and the hello's report token. |
| 20 | // |
| 21 | // Both requests run on a context of their own rather than the room's, so |
| 22 | // they still work once the room is closed: the final quality report goes |
| 23 | // at leave, and "Report a problem" is offered for ten minutes after the |
| 24 | // call (the server keeps the token that long past the participation). |
| 25 | |
| 26 | // reportTimeout bounds one report request. Ten seconds, as every other |
| 27 | // request this package makes; a report that cannot get through in that |
| 28 | // time is not worth holding a thread for. |
| 29 | const reportTimeout = 10 * time.Second |
| 30 | |
| 31 | // reportTokenHeader is where the token goes (internal/calls/quality.go). |
| 32 | const reportTokenHeader = "X-Calls-Report" |
| 33 | |
| 34 | var reportToken = regexp.MustCompile(`^[0-9a-f]{32}$`) |
| 35 | |
| 36 | // ReportOutcome is what became of a report, in words the host can turn |
| 37 | // into a sentence for a person, or act on. |
| 38 | type ReportOutcome string |
| 39 | |
| 40 | const ( |
| 41 | // ReportSent: the server took it. |
| 42 | ReportSent ReportOutcome = "sent" |
| 43 | // ReportStop: the server's kill switch (CALLS_QUALITY_REPORTS=off) |
| 44 | // answered 204. The host sends no more quality reports this call. |
| 45 | ReportStop ReportOutcome = "stop" |
| 46 | // ReportTooMany: 429, the server's per-participation limit. |
| 47 | ReportTooMany ReportOutcome = "too-many" |
| 48 | // ReportGone: 404. The token is unknown — the participation ended |
| 49 | // more than ten minutes ago, or a new hello replaced it — or there |
| 50 | // was never one (a server from before §15.26). |
| 51 | ReportGone ReportOutcome = "gone" |
| 52 | // ReportFailed: anything else — no answer, a refusal, a bad body. |
| 53 | ReportFailed ReportOutcome = "failed" |
| 54 | ) |
| 55 | |
| 56 | // ReportError is a report that did not get through, and why. |
| 57 | type ReportError struct{ Outcome ReportOutcome } |
| 58 | |
| 59 | func (e *ReportError) Error() string { return "Calls report not sent (" + string(e.Outcome) + ")" } |
| 60 | |
| 61 | // reportTokenNow is the latest hello's token, or "". |
| 62 | func (r *Room) reportTokenNow() string { |
| 63 | r.mu.Lock() |
| 64 | defer r.mu.Unlock() |
| 65 | return r.report |
| 66 | } |
| 67 | |
| 68 | // SendQuality posts one quality report (protocol.QualityReport, as JSON). |
| 69 | // stop is the server's 204: send no more for the rest of the call. Every |
| 70 | // other refusal is a skipped report, and the next window tries again. It |
| 71 | // blocks for up to ten seconds and belongs on the host's background |
| 72 | // executor. |
| 73 | func (r *Room) SendQuality(report []byte) (stop bool, err error) { |
| 74 | if len(report) > protocol.MaxQualityBody || !json.Valid(report) { |
| 75 | return false, &ReportError{ReportFailed} |
| 76 | } |
| 77 | status, _, err := r.postReport("quality", report) |
| 78 | if err != nil { |
| 79 | return false, err |
| 80 | } |
| 81 | switch status { |
| 82 | case http.StatusAccepted, http.StatusOK: |
| 83 | return false, nil |
| 84 | case http.StatusNoContent: |
| 85 | return true, nil |
| 86 | } |
| 87 | return false, &ReportError{outcomeOf(status)} |
| 88 | } |
| 89 | |
| 90 | // SendProblem posts "Report a problem" (protocol.ProblemReport, as JSON) |
| 91 | // and answers the reference the server gave it, which the host shows. |
| 92 | func (r *Room) SendProblem(report []byte) (ref string, err error) { |
| 93 | if len(report) > protocol.MaxProblemBody || !json.Valid(report) { |
| 94 | return "", &ReportError{ReportFailed} |
| 95 | } |
| 96 | status, body, err := r.postReport("problem", report) |
| 97 | if err != nil { |
| 98 | return "", err |
| 99 | } |
| 100 | if status != http.StatusOK { |
| 101 | return "", &ReportError{outcomeOf(status)} |
| 102 | } |
| 103 | var receipt protocol.ProblemReceipt |
| 104 | if json.Unmarshal(body, &receipt) != nil || !validRef(receipt.Ref) { |
| 105 | return "", &ReportError{ReportFailed} |
| 106 | } |
| 107 | return receipt.Ref, nil |
| 108 | } |
| 109 | |
| 110 | func outcomeOf(status int) ReportOutcome { |
| 111 | switch status { |
| 112 | case http.StatusTooManyRequests: |
| 113 | return ReportTooMany |
| 114 | case http.StatusNotFound: |
| 115 | return ReportGone |
| 116 | } |
| 117 | return ReportFailed |
| 118 | } |
| 119 | |
| 120 | // validRef is the server's reference shape: eight of Crockford's |
| 121 | // characters. Anything else is not shown to a person as one. |
| 122 | func validRef(ref string) bool { |
| 123 | if len(ref) != 8 { |
| 124 | return false |
| 125 | } |
| 126 | for _, c := range ref { |
| 127 | if !(c >= '0' && c <= '9' || c >= 'A' && c <= 'Z') { |
| 128 | return false |
| 129 | } |
| 130 | } |
| 131 | return true |
| 132 | } |
| 133 | |
| 134 | // postReport makes the request: the same session, Origin and redirect |
| 135 | // rules as every other request from the transport, plus the token. |
| 136 | func (r *Room) postReport(endpoint string, body []byte) (int, []byte, error) { |
| 137 | token := r.reportTokenNow() |
| 138 | if !reportToken.MatchString(token) { |
| 139 | return 0, nil, &ReportError{ReportGone} |
| 140 | } |
| 141 | ctx, cancel := context.WithTimeout(context.Background(), reportTimeout) |
| 142 | defer cancel() |
| 143 | t := r.t |
| 144 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.origin+"/m/"+t.room+"/"+endpoint, bytes.NewReader(body)) |
| 145 | if err != nil { |
| 146 | return 0, nil, &ReportError{ReportFailed} |
| 147 | } |
| 148 | req.Header.Set("Accept", "application/json") |
| 149 | req.Header.Set("Content-Type", "application/json") |
| 150 | req.Header.Set("Origin", t.origin) |
| 151 | req.Header.Set(reportTokenHeader, token) |
| 152 | res, err := t.http.Do(req) |
| 153 | if err != nil { |
| 154 | return 0, nil, &ReportError{ReportFailed} |
| 155 | } |
| 156 | defer res.Body.Close() |
| 157 | out, err := io.ReadAll(io.LimitReader(res.Body, maxResponse)) |
| 158 | if err != nil { |
| 159 | return 0, nil, &ReportError{ReportFailed} |
| 160 | } |
| 161 | return res.StatusCode, out, nil |
| 162 | } |
| 163 | |
| 164 | // OutcomeOf is a report error's outcome, ReportFailed for any other |
| 165 | // error, and ReportSent for none. |
| 166 | func OutcomeOf(err error) ReportOutcome { |
| 167 | if err == nil { |
| 168 | return ReportSent |
| 169 | } |
| 170 | var refusal *ReportError |
| 171 | if errors.As(err, &refusal) { |
| 172 | return refusal.Outcome |
| 173 | } |
| 174 | return ReportFailed |
| 175 | } |
| 176 | |