package callscore import ( "context" "errors" "net/http" "strings" "time" ) // ErrSignedOut is the one refusal the app acts on rather than shows: the // session it is holding no longer signs anyone in, so the copy it kept // is forgotten and the person signs in again. var ErrSignedOut = errors.New("sign in to Calls again") // ErrNoAccess is OpenLink's answer for a /m/ address the held session // cannot enter: it is signed in, as somebody who is not a member and has // no invitation to that meeting. The app's sign-in is somebody else's — // a guest signed in on the web, say, while the app is signed in as a // member of another team on the same server — and the meeting needs // this person's own sign-in, or their invitation (2026-10-02). var ErrNoAccess = errors.New("this sign-in cannot join that meeting") // Standing is what a held session is on its instance, as far as the app // needs to know before it decides whether to keep it. type Standing string const ( // Member: a person on the team. This is the session worth keeping // across launches — it may start meetings and follow any link. Member Standing = "member" // Participant: a live session that is not a member's — a guest who // gave a name, or a verified address that was only invited. It is // good for the meeting it came in through and nothing the app would // want to wake up holding next week. Participant Standing = "participant" ) // CheckSession asks the instance what the held session is, with one GET // of a member-only page whose body is never read. // // There is no endpoint for the question, and none is added: the page // already answers it by status, on every server that has ever run the // native sign-in. /settings sits behind requireParticipant and then // requireMember, so a member's session gets the page, anyone else's // the 404 both refuse with, and a session that has gone the sign-in // redirect. Redirects are never followed (safeClient), so that 303 // arrives as itself. // // Anything else is an answer this function cannot read — a network // failure, a 5xx, a proxy's page — and is reported as an error, never // as ErrSignedOut: the caller keeps its credential through a bad // connection and forgets it only when the instance says so. func CheckSession(ctx context.Context, origin string, client *http.Client) (Standing, 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.MethodGet, trimmed+"/settings", nil) if err != nil { return "", errors.New("cannot build Calls request") } res, err := safe.Do(req) if err != nil { return "", errors.New("cannot reach Calls") } res.Body.Close() switch { case res.StatusCode == http.StatusOK: return Member, nil case res.StatusCode == http.StatusNotFound: return Participant, nil case res.StatusCode == http.StatusUnauthorized, res.StatusCode >= 300 && res.StatusCode < 400: return "", ErrSignedOut default: return "", errors.New("cannot check your Calls sign-in just now") } } // OpenLink turns a meeting or invitation address on the instance the // app is already signed in to into the meeting to join, without the // browser: the session in hand is the person's own, and a second // sign-in would only mint another one like it. // // An address with no path answers no meeting, which is the instance // itself. A /m/ address is asked about first, with one GET of its info // (the same request the room makes): until 2026-10-02 it answered its // identifier without asking, and a session without standing found out // only when the join failed, with nothing to offer instead. A session // that cannot enter it is ErrNoAccess. A /j/ address is followed exactly // as the browser would follow it with this session — which is what // consumes the invitation and, for someone who is not a member, grants // the meeting — and the room is read off the redirect. // // An address on another instance is refused. That instance is a // different sign-in, and the caller runs it. func OpenLink(ctx context.Context, origin, address string, client *http.Client) (string, error) { trimmed, err := checkOrigin(origin) if err != nil { return "", err } linkOrigin, target, err := meetingTarget(address) if err != nil { return "", err } if linkOrigin != trimmed { return "", errors.New("that link is for another Calls server") } if target == "/" { return "", nil } safe, err := safeClient(client) if err != nil { return "", err } ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() if room, ok := strings.CutPrefix(target, "/m/"); ok { if !validRoom(room) { return "", errors.New("invalid meeting identifier") } return room, meetingAccess(ctx, safe, trimmed+target+"/info") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, trimmed+target, nil) if err != nil { return "", errors.New("cannot build Calls request") } res, err := safe.Do(req) if err != nil { return "", errors.New("cannot reach Calls") } res.Body.Close() // followLink answers a gone session with the guest name form (200) // or the sign-in redirect, never with an error: the link itself is // fine, it is the person who has to say who they are. if res.StatusCode == http.StatusOK || strings.HasPrefix(res.Header.Get("Location"), "/signin") { return "", ErrSignedOut } room, err := invitationRoom(res) if err != nil { return "", err } if !validRoom(room) { return "", errors.New("invitation is unavailable") } return room, nil } // SignOut revokes the held session on its instance: the same POST // /signout the web's button makes, carrying the Origin csrf.Protect // reads. Either sign-out route answers with a redirect once the row is // gone: 303 standalone, 307 in Home mode. // // Best effort, and the caller forgets its copy whatever this answers: a // person who pressed Sign out is signed out of this app, and a session // the server could not be told about still lapses at its thirty days. func SignOut(ctx context.Context, origin string, client *http.Client) 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+"/signout", strings.NewReader("")) if err != nil { return errors.New("cannot build Calls request") } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", trimmed) res, err := safe.Do(req) if err != nil { return errors.New("cannot reach Calls") } res.Body.Close() // 303 to /signed-out standalone; in Home mode, 307 on to the issuer's // /signout for the browser's id.* session, which this client neither // holds nor follows (safeClient). Both come after the row is gone. if res.StatusCode != http.StatusSeeOther && res.StatusCode != http.StatusTemporaryRedirect { return errors.New("Calls did not confirm the sign-out") } return nil } // meetingAccess reads whether the held session may enter a meeting, by // its info: 200 is yes; the sign-in redirect or 401 is a session that // has gone; 404 is a session that is fine but not for this meeting — // or a meeting that has ended, which the app can do nothing about // either way and says the same of. func meetingAccess(ctx context.Context, client *http.Client, info string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, info, nil) if err != nil { return errors.New("cannot build Calls request") } req.Header.Set("Accept", "application/json") res, err := client.Do(req) if err != nil { return errors.New("cannot reach Calls") } res.Body.Close() switch { case res.StatusCode == http.StatusOK: return nil case res.StatusCode == http.StatusUnauthorized, res.StatusCode >= 300 && res.StatusCode < 400: return ErrSignedOut case res.StatusCode == http.StatusNotFound, res.StatusCode == http.StatusForbidden: return ErrNoAccess default: return errors.New("cannot open that meeting just now") } }