// Package callscore is the shared, UI-free Go client for Calls. It owns signaling // and room state plus browser sign-in handoff; the host owns browser launch, // credential storage and media. package callscore import ( "bufio" "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "amadan.net/oficina/calls/protocol" ) const maxResponse = 128 * 1024 // HTTPError intentionally excludes response bodies and URLs: those may contain // session material, meeting names, or peer-supplied payloads. type HTTPError struct { Status int Reason string RetryAfter time.Duration } func (e *HTTPError) Error() string { return fmt.Sprintf("Calls request refused (%d)", e.Status) } type transport struct { origin string room string http *http.Client } // checkOrigin is the one place an origin is judged, so a request made // outside a transport — StartMeeting is the only one — cannot reach a // server this package would otherwise refuse. It answers the origin // without its trailing slash, which is also the value the Origin header // has to carry for csrf.Protect to recognise it. func checkOrigin(origin string) (string, error) { u, err := url.Parse(origin) if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { return "", errors.New("enter a Calls server origin") } if u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost" || u.Hostname() == "::1")) { return "", errors.New("Calls requires HTTPS (HTTP is allowed only on loopback)") } return strings.TrimSuffix(origin, "/"), nil } // validRoom is the meeting identifier's shape, checked both on the way // in from a person and on the way back from the server: a sqid this app // cannot open is a bad answer, not a meeting. func validRoom(room string) bool { return room != "" && len(room) <= 32 && strings.IndexFunc(room, func(r rune) bool { return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') }) < 0 } // safeClient wraps the caller's client for every request this package // makes. Never follow a redirect with a credential or accidentally decode a // sign-in page as protocol JSON. The caller's transport and cookie jar are // retained. func safeClient(client *http.Client) (*http.Client, error) { if client == nil { return nil, errors.New("an authenticated HTTP client is required") } safe := *client if safe.Transport == nil { // The default transport, with a deadline on the response's // headers. A process that has replaced it (internal/callstest's // TestMain installs keymailtest's, which answers DNS in-process) // keeps its own: asserting *http.Transport panicked there. if base, ok := http.DefaultTransport.(*http.Transport); ok { transport := base.Clone() transport.ResponseHeaderTimeout = 15 * time.Second safe.Transport = transport } else { safe.Transport = http.DefaultTransport } } safe.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } safe.Timeout = 0 // streams live for five minutes; individual calls have deadlines return &safe, nil } func newTransport(origin, room string, client *http.Client) (*transport, error) { trimmed, err := checkOrigin(origin) if err != nil { return nil, err } if !validRoom(room) { return nil, errors.New("invalid meeting identifier") } safe, err := safeClient(client) if err != nil { return nil, err } return &transport{trimmed, room, safe}, nil } func (t *transport) request(ctx context.Context, method, endpoint string, q url.Values, body []byte) (*http.Response, error) { path := t.origin + "/m/" + t.room + "/" + endpoint if len(q) > 0 { path += "?" + q.Encode() } req, err := http.NewRequestWithContext(ctx, method, path, bytes.NewReader(body)) if err != nil { return nil, errors.New("cannot build Calls request") } req.Header.Set("Accept", "application/json") if endpoint == "stream" { req.Header.Set("Accept", "text/event-stream") } if method == http.MethodPost { req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", t.origin) } res, err := t.http.Do(req) if err != nil { return nil, errors.New("cannot reach Calls") } if res.StatusCode >= 200 && res.StatusCode < 300 { return res, nil } defer res.Body.Close() var b struct { Reason string `json:"reason"` } _ = json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&b) // Only protocol-defined terminal reasons may reach the UI. switch b.Reason { case "replaced", "removed", "expired", "standing", "ended": default: b.Reason = "" } retry := time.Second if n, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && n > 0 { retry = min(time.Duration(n)*time.Second, 30*time.Second) } return nil, &HTTPError{res.StatusCode, b.Reason, retry} } func (t *transport) post(ctx context.Context, s protocol.Signal) error { b, err := json.Marshal(s) if err != nil || len(b) > 64*1024 { return errors.New("signal exceeds Calls' message limit") } ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() res, err := t.request(ctx, http.MethodPost, "signal", nil, b) if err != nil { return err } defer res.Body.Close() _, err = io.Copy(io.Discard, io.LimitReader(res.Body, maxResponse)) return err } // readEvents bounds both individual lines and complete events, accepts SSE // comments and multiline data, and never dispatches an incomplete final event. func readEvents(r io.Reader, receive func(protocol.Message) error) error { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 4096), maxResponse) var data []string size := 0 for scanner.Scan() { line := scanner.Text() if line == "" { if len(data) > 0 { var m protocol.Message if err := json.Unmarshal([]byte(strings.Join(data, "\n")), &m); err != nil { return errors.New("invalid Calls event") } if err := receive(m); err != nil { return err } } data = nil size = 0 continue } if strings.HasPrefix(line, "data:") { value := strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ") size += len(value) + 1 if size > maxResponse { return errors.New("Calls event exceeds limit") } data = append(data, value) } } if err := scanner.Err(); err != nil { return errors.New("Calls event stream interrupted") } return io.EOF } // ICEServers is the server's short-lived TURN credential response. It stays in // memory and must never be logged or persisted by a native host. type ICEServers struct { Servers []ICEServer `json:"iceServers"` ExpiresAt int64 `json:"expiresAt"` RenewAt int64 `json:"renewAt"` } type ICEServer struct { URLs []string `json:"urls"` Username string `json:"username,omitempty"` Credential string `json:"credential,omitempty"` } func (t *transport) ice(ctx context.Context, peer protocol.PeerID) (ICEServers, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() res, err := t.request(ctx, http.MethodGet, "ice", url.Values{"peer": {string(peer)}}, nil) if err != nil { return ICEServers{}, err } defer res.Body.Close() var ice ICEServers if err := json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&ice); err != nil { return ice, errors.New("invalid Calls ICE response") } return ice, nil } // Info is the same metadata the server renders on the holding screen. type Info struct { Title string `json:"title"` // Named is a title someone chose rather than one Calls wrote ("Call, // 2 Oct 15:40"): the apps show it over the call. A server from before // 0.4.7 does not say, so its titles are left off — the default for // every call now. Named bool `json:"named"` Name string `json:"name"` Standing string `json:"standing"` Floor bool `json:"floor"` } func (t *transport) info(ctx context.Context) (Info, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() res, err := t.request(ctx, http.MethodGet, "info", nil, nil) if err != nil { return Info{}, err } defer res.Body.Close() var info Info if err := json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&info); err != nil { return info, errors.New("invalid meeting information") } return info, nil } // takeover ends this session's own live participation in the room — the // web's "Use this tab" (POST /m/{sqid}/takeover), which answers 303 back // to the room when it has done so. Any other answer, a redirect to // sign-in among them, is a refusal. func (t *transport) takeover(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() room := "/m/" + t.room req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.origin+room+"/takeover", nil) if err != nil { return errors.New("cannot build Calls request") } req.Header.Set("Origin", t.origin) res, err := t.http.Do(req) if err != nil { return errors.New("cannot reach Calls") } defer res.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(res.Body, maxResponse)) if res.StatusCode != http.StatusSeeOther { return &HTTPError{Status: res.StatusCode} } if u, err := url.Parse(res.Header.Get("Location")); err != nil || u.Path != room { return &HTTPError{Status: res.StatusCode} } return nil } func (t *transport) shareLink(ctx context.Context) (string, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() res, err := t.request(ctx, http.MethodPost, "share", nil, []byte(`{}`)) if err != nil { return "", err } defer res.Body.Close() var result struct { URL string `json:"url"` } if err := json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&result); err != nil { return "", errors.New("invalid meeting invitation") } prefix := t.origin + "/j/" if !strings.HasPrefix(result.URL, prefix) { return "", errors.New("invalid meeting invitation") } token := strings.TrimPrefix(result.URL, prefix) decoded, err := base64.RawURLEncoding.DecodeString(token) if err != nil || len(decoded) != 32 || base64.RawURLEncoding.EncodeToString(decoded) != token { return "", errors.New("invalid meeting invitation") } return result.URL, nil }