package callscore import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "time" ) // StartMeeting creates a meeting on the instance this client is signed // in to and answers its identifier. It is the app's half of the meeting // list's one-click button, and it exists because that button's web // answer is the link-once *page* — a screen, and not something a Swift // app can read without pretending to be a browser. // // The identifier is all it answers. A link is a credential, and the // room already has a share flow that mints one when a member asks for // it (Client.ShareLink), so nothing that merely starts a meeting has to // hold one, log one, or decide when to forget it. // // The session is the caller's: an ordinary browser session carried in // the client's cookie jar, exactly as Room uses. Nothing here mints or // accepts an agent bearer token. func StartMeeting(ctx context.Context, origin string, client *http.Client) (string, error) { return StartMeetingIn(ctx, origin, "", client) } // StartMeetingIn is StartMeeting naming the app's IANA time zone, so the // title the server writes ("Call, 2 Oct 15:40") is the starter's own time // rather than the instance's. An empty or unknown zone is the instance's. func StartMeetingIn(ctx context.Context, origin, zone string, client *http.Client) (string, error) { trimmed, err := checkOrigin(origin) if err != nil { return "", err } safe, err := safeClient(client) if err != nil { return "", err } ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/native/meetings/start", bytes.NewReader(startBody(zone))) if err != nil { return "", errors.New("cannot build Calls request") } req.Header.Set("Accept", "application/json") // Both locks the server asks for: the content type an HTML form // cannot send, and the origin csrf.Protect reads. A POST carrying // neither is refused before the handler sees it. req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", trimmed) res, err := safe.Do(req) if err != nil { return "", errors.New("cannot reach Calls") } defer res.Body.Close() switch { case res.StatusCode == http.StatusOK: case res.StatusCode == http.StatusUnauthorized, res.StatusCode == http.StatusSeeOther: // The session went while the app was holding it. Redirects are // never followed here, so a sign-in page arrives as its 303 and // is read as one rather than decoded as JSON. return "", errors.New("sign in to Calls again") case res.StatusCode == http.StatusForbidden, res.StatusCode == http.StatusNotFound: // requireMember's refusal. A guest may join a meeting and may // not start one, and saying which would tell an outsider // something about the team. return "", errors.New("this account cannot start meetings on that Calls server") default: return "", errors.New("cannot start a meeting just now") } var out struct { Sqid string `json:"sqid"` } if json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&out) != nil || !validRoom(out.Sqid) { return "", errors.New("Calls answered with a meeting this app cannot open") } return out.Sqid, nil } // RegisterDevice tells the server where this device can be reached when // the app is not running. // // Best effort by design, and the caller treats a failure as nothing // happening: a device that could not register is a device that will not // be told about a knock, which is exactly where every device was before // push existed. It is never a reason to fail a join. func RegisterDevice(ctx context.Context, origin string, client *http.Client, token, platform string) error { trimmed, err := checkOrigin(origin) if err != nil { return err } safe, err := safeClient(client) if err != nil { return err } if platform != "ios" && platform != "macos" { return errors.New("unknown device platform") } body, err := json.Marshal(map[string]string{"token": token, "platform": platform}) if err != nil { return errors.New("cannot build Calls request") } ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/native/devices", bytes.NewReader(body)) if err != nil { return errors.New("cannot build Calls request") } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", trimmed) res, err := safe.Do(req) if err != nil { return errors.New("cannot reach Calls") } defer res.Body.Close() switch { case res.StatusCode == http.StatusNoContent: return nil case res.StatusCode == http.StatusNotFound: // An older server without the endpoint. Not an error worth // showing anyone: it simply has no push. return errors.New("this Calls server does not take device registrations") default: return errors.New("Calls refused this device registration") } } // startBody is the start request: an empty object, or the zone when the // app knows it. A zone too long to be an IANA name is left out rather // than sent for the server to refuse. func startBody(zone string) []byte { if zone == "" || len(zone) > 64 { return []byte("{}") } b, _ := json.Marshal(map[string]string{"zone": zone}) return b }