| 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. |
| 4 | package callscore |
| 5 | |
| 6 | import ( |
| 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 | |
| 24 | const maxResponse = 128 * 1024 |
| 25 | |
| 26 | // HTTPError intentionally excludes response bodies and URLs: those may contain |
| 27 | // session material, meeting names, or peer-supplied payloads. |
| 28 | type HTTPError struct { |
| 29 | Status int |
| 30 | Reason string |
| 31 | RetryAfter time.Duration |
| 32 | } |
| 33 | |
| 34 | func (e *HTTPError) Error() string { return fmt.Sprintf("Calls request refused (%d)", e.Status) } |
| 35 | |
| 36 | type 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. |
| 47 | func 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. |
| 61 | func 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. |
| 71 | func 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 | |
| 86 | func 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 | |
| 101 | func (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 | |
| 143 | func (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. |
| 161 | func 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. |
| 199 | type ICEServers struct { |
| 200 | Servers []ICEServer `json:"iceServers"` |
| 201 | ExpiresAt int64 `json:"expiresAt"` |
| 202 | RenewAt int64 `json:"renewAt"` |
| 203 | } |
| 204 | type ICEServer struct { |
| 205 | URLs []string `json:"urls"` |
| 206 | Username string `json:"username,omitempty"` |
| 207 | Credential string `json:"credential,omitempty"` |
| 208 | } |
| 209 | |
| 210 | func (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. |
| 226 | type 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 | |
| 238 | func (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. |
| 257 | func (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 | |
| 281 | func (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 | |