oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package callscore is the shared, UI-free Go client for Calls. It owns signaling
2// and room state plus browser sign-in handoff; the host owns browser launch,
3// credential storage and media.
4package callscore
5
6import (
7 "bufio"
8 "bytes"
9 "context"
10 "encoding/base64"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "io"
15 "net/http"
16 "net/url"
17 "strconv"
18 "strings"
19 "time"
20
21 "amadan.net/oficina/calls/protocol"
22)
23
24const maxResponse = 128 * 1024
25
26// HTTPError intentionally excludes response bodies and URLs: those may contain
27// session material, meeting names, or peer-supplied payloads.
28type HTTPError struct {
29 Status int
30 Reason string
31 RetryAfter time.Duration
32}
33
34func (e *HTTPError) Error() string { return fmt.Sprintf("Calls request refused (%d)", e.Status) }
35
36type transport struct {
37 origin string
38 room string
39 http *http.Client
40}
41
42// checkOrigin is the one place an origin is judged, so a request made
43// outside a transport — StartMeeting is the only one — cannot reach a
44// server this package would otherwise refuse. It answers the origin
45// without its trailing slash, which is also the value the Origin header
46// has to carry for csrf.Protect to recognise it.
47func checkOrigin(origin string) (string, error) {
48 u, err := url.Parse(origin)
49 if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
50 return "", errors.New("enter a Calls server origin")
51 }
52 if u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost" || u.Hostname() == "::1")) {
53 return "", errors.New("Calls requires HTTPS (HTTP is allowed only on loopback)")
54 }
55 return strings.TrimSuffix(origin, "/"), nil
56}
57
58// validRoom is the meeting identifier's shape, checked both on the way
59// in from a person and on the way back from the server: a sqid this app
60// cannot open is a bad answer, not a meeting.
61func validRoom(room string) bool {
62 return room != "" && len(room) <= 32 && strings.IndexFunc(room, func(r rune) bool {
63 return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9')
64 }) < 0
65}
66
67// safeClient wraps the caller's client for every request this package
68// makes. Never follow a redirect with a credential or accidentally decode a
69// sign-in page as protocol JSON. The caller's transport and cookie jar are
70// retained.
71func safeClient(client *http.Client) (*http.Client, error) {
72 if client == nil {
73 return nil, errors.New("an authenticated HTTP client is required")
74 }
75 safe := *client
76 if safe.Transport == nil {
77 transport := http.DefaultTransport.(*http.Transport).Clone()
78 transport.ResponseHeaderTimeout = 15 * time.Second
79 safe.Transport = transport
80 }
81 safe.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
82 safe.Timeout = 0 // streams live for five minutes; individual calls have deadlines
83 return &safe, nil
84}
85
86func newTransport(origin, room string, client *http.Client) (*transport, error) {
87 trimmed, err := checkOrigin(origin)
88 if err != nil {
89 return nil, err
90 }
91 if !validRoom(room) {
92 return nil, errors.New("invalid meeting identifier")
93 }
94 safe, err := safeClient(client)
95 if err != nil {
96 return nil, err
97 }
98 return &transport{trimmed, room, safe}, nil
99}
100
101func (t *transport) request(ctx context.Context, method, endpoint string, q url.Values, body []byte) (*http.Response, error) {
102 path := t.origin + "/m/" + t.room + "/" + endpoint
103 if len(q) > 0 {
104 path += "?" + q.Encode()
105 }
106 req, err := http.NewRequestWithContext(ctx, method, path, bytes.NewReader(body))
107 if err != nil {
108 return nil, errors.New("cannot build Calls request")
109 }
110 req.Header.Set("Accept", "application/json")
111 if endpoint == "stream" {
112 req.Header.Set("Accept", "text/event-stream")
113 }
114 if method == http.MethodPost {
115 req.Header.Set("Content-Type", "application/json")
116 req.Header.Set("Origin", t.origin)
117 }
118 res, err := t.http.Do(req)
119 if err != nil {
120 return nil, errors.New("cannot reach Calls")
121 }
122 if res.StatusCode >= 200 && res.StatusCode < 300 {
123 return res, nil
124 }
125 defer res.Body.Close()
126 var b struct {
127 Reason string `json:"reason"`
128 }
129 _ = json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&b)
130 // Only protocol-defined terminal reasons may reach the UI.
131 switch b.Reason {
132 case "replaced", "removed", "expired", "standing", "ended":
133 default:
134 b.Reason = ""
135 }
136 retry := time.Second
137 if n, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && n > 0 {
138 retry = min(time.Duration(n)*time.Second, 30*time.Second)
139 }
140 return nil, &HTTPError{res.StatusCode, b.Reason, retry}
141}
142
143func (t *transport) post(ctx context.Context, s protocol.Signal) error {
144 b, err := json.Marshal(s)
145 if err != nil || len(b) > 64*1024 {
146 return errors.New("signal exceeds Calls' message limit")
147 }
148 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
149 defer cancel()
150 res, err := t.request(ctx, http.MethodPost, "signal", nil, b)
151 if err != nil {
152 return err
153 }
154 defer res.Body.Close()
155 _, err = io.Copy(io.Discard, io.LimitReader(res.Body, maxResponse))
156 return err
157}
158
159// readEvents bounds both individual lines and complete events, accepts SSE
160// comments and multiline data, and never dispatches an incomplete final event.
161func readEvents(r io.Reader, receive func(protocol.Message) error) error {
162 scanner := bufio.NewScanner(r)
163 scanner.Buffer(make([]byte, 4096), maxResponse)
164 var data []string
165 size := 0
166 for scanner.Scan() {
167 line := scanner.Text()
168 if line == "" {
169 if len(data) > 0 {
170 var m protocol.Message
171 if err := json.Unmarshal([]byte(strings.Join(data, "\n")), &m); err != nil {
172 return errors.New("invalid Calls event")
173 }
174 if err := receive(m); err != nil {
175 return err
176 }
177 }
178 data = nil
179 size = 0
180 continue
181 }
182 if strings.HasPrefix(line, "data:") {
183 value := strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")
184 size += len(value) + 1
185 if size > maxResponse {
186 return errors.New("Calls event exceeds limit")
187 }
188 data = append(data, value)
189 }
190 }
191 if err := scanner.Err(); err != nil {
192 return errors.New("Calls event stream interrupted")
193 }
194 return io.EOF
195}
196
197// ICEServers is the server's short-lived TURN credential response. It stays in
198// memory and must never be logged or persisted by a native host.
199type ICEServers struct {
200 Servers []ICEServer `json:"iceServers"`
201 ExpiresAt int64 `json:"expiresAt"`
202 RenewAt int64 `json:"renewAt"`
203}
204type ICEServer struct {
205 URLs []string `json:"urls"`
206 Username string `json:"username,omitempty"`
207 Credential string `json:"credential,omitempty"`
208}
209
210func (t *transport) ice(ctx context.Context, peer protocol.PeerID) (ICEServers, error) {
211 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
212 defer cancel()
213 res, err := t.request(ctx, http.MethodGet, "ice", url.Values{"peer": {string(peer)}}, nil)
214 if err != nil {
215 return ICEServers{}, err
216 }
217 defer res.Body.Close()
218 var ice ICEServers
219 if err := json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&ice); err != nil {
220 return ice, errors.New("invalid Calls ICE response")
221 }
222 return ice, nil
223}
224
225// Info is the same metadata the server renders on the holding screen.
226type Info struct {
227 Title string `json:"title"`
228 // Named is a title someone chose rather than one Calls wrote ("Call,
229 // 2 Oct 15:40"): the apps show it over the call. A server from before
230 // 0.4.7 does not say, so its titles are left off — the default for
231 // every call now.
232 Named bool `json:"named"`
233 Name string `json:"name"`
234 Standing string `json:"standing"`
235 Floor bool `json:"floor"`
236}
237
238func (t *transport) info(ctx context.Context) (Info, error) {
239 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
240 defer cancel()
241 res, err := t.request(ctx, http.MethodGet, "info", nil, nil)
242 if err != nil {
243 return Info{}, err
244 }
245 defer res.Body.Close()
246 var info Info
247 if err := json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&info); err != nil {
248 return info, errors.New("invalid meeting information")
249 }
250 return info, nil
251}
252
253// takeover ends this session's own live participation in the room — the
254// web's "Use this tab" (POST /m/{sqid}/takeover), which answers 303 back
255// to the room when it has done so. Any other answer, a redirect to
256// sign-in among them, is a refusal.
257func (t *transport) takeover(ctx context.Context) error {
258 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
259 defer cancel()
260 room := "/m/" + t.room
261 req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.origin+room+"/takeover", nil)
262 if err != nil {
263 return errors.New("cannot build Calls request")
264 }
265 req.Header.Set("Origin", t.origin)
266 res, err := t.http.Do(req)
267 if err != nil {
268 return errors.New("cannot reach Calls")
269 }
270 defer res.Body.Close()
271 _, _ = io.Copy(io.Discard, io.LimitReader(res.Body, maxResponse))
272 if res.StatusCode != http.StatusSeeOther {
273 return &HTTPError{Status: res.StatusCode}
274 }
275 if u, err := url.Parse(res.Header.Get("Location")); err != nil || u.Path != room {
276 return &HTTPError{Status: res.StatusCode}
277 }
278 return nil
279}
280
281func (t *transport) shareLink(ctx context.Context) (string, error) {
282 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
283 defer cancel()
284 res, err := t.request(ctx, http.MethodPost, "share", nil, []byte(`{}`))
285 if err != nil {
286 return "", err
287 }
288 defer res.Body.Close()
289 var result struct {
290 URL string `json:"url"`
291 }
292 if err := json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&result); err != nil {
293 return "", errors.New("invalid meeting invitation")
294 }
295 prefix := t.origin + "/j/"
296 if !strings.HasPrefix(result.URL, prefix) {
297 return "", errors.New("invalid meeting invitation")
298 }
299 token := strings.TrimPrefix(result.URL, prefix)
300 decoded, err := base64.RawURLEncoding.DecodeString(token)
301 if err != nil || len(decoded) != 32 || base64.RawURLEncoding.EncodeToString(decoded) != token {
302 return "", errors.New("invalid meeting invitation")
303 }
304 return result.URL, nil
305}
306