oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package callscore
2
3import (
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.
29const reportTimeout = 10 * time.Second
30
31// reportTokenHeader is where the token goes (internal/calls/quality.go).
32const reportTokenHeader = "X-Calls-Report"
33
34var 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.
38type ReportOutcome string
39
40const (
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.
57type ReportError struct{ Outcome ReportOutcome }
58
59func (e *ReportError) Error() string { return "Calls report not sent (" + string(e.Outcome) + ")" }
60
61// reportTokenNow is the latest hello's token, or "".
62func (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.
73func (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.
92func (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
110func 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.
122func 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.
136func (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.
166func 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