| 1 | package callscore |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | // ErrSignedOut is the one refusal the app acts on rather than shows: the |
| 12 | // session it is holding no longer signs anyone in, so the copy it kept |
| 13 | // is forgotten and the person signs in again. |
| 14 | var ErrSignedOut = errors.New("sign in to Calls again") |
| 15 | |
| 16 | // ErrNoAccess is OpenLink's answer for a /m/ address the held session |
| 17 | // cannot enter: it is signed in, as somebody who is not a member and has |
| 18 | // no invitation to that meeting. The app's sign-in is somebody else's — |
| 19 | // a guest signed in on the web, say, while the app is signed in as a |
| 20 | // member of another team on the same server — and the meeting needs |
| 21 | // this person's own sign-in, or their invitation (2026-10-02). |
| 22 | var ErrNoAccess = errors.New("this sign-in cannot join that meeting") |
| 23 | |
| 24 | // Standing is what a held session is on its instance, as far as the app |
| 25 | // needs to know before it decides whether to keep it. |
| 26 | type Standing string |
| 27 | |
| 28 | const ( |
| 29 | // Member: a person on the team. This is the session worth keeping |
| 30 | // across launches — it may start meetings and follow any link. |
| 31 | Member Standing = "member" |
| 32 | // Participant: a live session that is not a member's — a guest who |
| 33 | // gave a name, or a verified address that was only invited. It is |
| 34 | // good for the meeting it came in through and nothing the app would |
| 35 | // want to wake up holding next week. |
| 36 | Participant Standing = "participant" |
| 37 | ) |
| 38 | |
| 39 | // CheckSession asks the instance what the held session is, with one GET |
| 40 | // of a member-only page whose body is never read. |
| 41 | // |
| 42 | // There is no endpoint for the question, and none is added: the page |
| 43 | // already answers it by status, on every server that has ever run the |
| 44 | // native sign-in. /settings sits behind requireParticipant and then |
| 45 | // requireMember, so a member's session gets the page, anyone else's |
| 46 | // the 404 both refuse with, and a session that has gone the sign-in |
| 47 | // redirect. Redirects are never followed (safeClient), so that 303 |
| 48 | // arrives as itself. |
| 49 | // |
| 50 | // Anything else is an answer this function cannot read — a network |
| 51 | // failure, a 5xx, a proxy's page — and is reported as an error, never |
| 52 | // as ErrSignedOut: the caller keeps its credential through a bad |
| 53 | // connection and forgets it only when the instance says so. |
| 54 | func CheckSession(ctx context.Context, origin string, client *http.Client) (Standing, error) { |
| 55 | trimmed, err := checkOrigin(origin) |
| 56 | if err != nil { |
| 57 | return "", err |
| 58 | } |
| 59 | safe, err := safeClient(client) |
| 60 | if err != nil { |
| 61 | return "", err |
| 62 | } |
| 63 | ctx, cancel := context.WithTimeout(ctx, 15*time.Second) |
| 64 | defer cancel() |
| 65 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, trimmed+"/settings", nil) |
| 66 | if err != nil { |
| 67 | return "", errors.New("cannot build Calls request") |
| 68 | } |
| 69 | res, err := safe.Do(req) |
| 70 | if err != nil { |
| 71 | return "", errors.New("cannot reach Calls") |
| 72 | } |
| 73 | res.Body.Close() |
| 74 | switch { |
| 75 | case res.StatusCode == http.StatusOK: |
| 76 | return Member, nil |
| 77 | case res.StatusCode == http.StatusNotFound: |
| 78 | return Participant, nil |
| 79 | case res.StatusCode == http.StatusUnauthorized, res.StatusCode >= 300 && res.StatusCode < 400: |
| 80 | return "", ErrSignedOut |
| 81 | default: |
| 82 | return "", errors.New("cannot check your Calls sign-in just now") |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // OpenLink turns a meeting or invitation address on the instance the |
| 87 | // app is already signed in to into the meeting to join, without the |
| 88 | // browser: the session in hand is the person's own, and a second |
| 89 | // sign-in would only mint another one like it. |
| 90 | // |
| 91 | // An address with no path answers no meeting, which is the instance |
| 92 | // itself. A /m/ address is asked about first, with one GET of its info |
| 93 | // (the same request the room makes): until 2026-10-02 it answered its |
| 94 | // identifier without asking, and a session without standing found out |
| 95 | // only when the join failed, with nothing to offer instead. A session |
| 96 | // that cannot enter it is ErrNoAccess. A /j/ address is followed exactly |
| 97 | // as the browser would follow it with this session — which is what |
| 98 | // consumes the invitation and, for someone who is not a member, grants |
| 99 | // the meeting — and the room is read off the redirect. |
| 100 | // |
| 101 | // An address on another instance is refused. That instance is a |
| 102 | // different sign-in, and the caller runs it. |
| 103 | func OpenLink(ctx context.Context, origin, address string, client *http.Client) (string, error) { |
| 104 | trimmed, err := checkOrigin(origin) |
| 105 | if err != nil { |
| 106 | return "", err |
| 107 | } |
| 108 | linkOrigin, target, err := meetingTarget(address) |
| 109 | if err != nil { |
| 110 | return "", err |
| 111 | } |
| 112 | if linkOrigin != trimmed { |
| 113 | return "", errors.New("that link is for another Calls server") |
| 114 | } |
| 115 | if target == "/" { |
| 116 | return "", nil |
| 117 | } |
| 118 | safe, err := safeClient(client) |
| 119 | if err != nil { |
| 120 | return "", err |
| 121 | } |
| 122 | ctx, cancel := context.WithTimeout(ctx, 15*time.Second) |
| 123 | defer cancel() |
| 124 | if room, ok := strings.CutPrefix(target, "/m/"); ok { |
| 125 | if !validRoom(room) { |
| 126 | return "", errors.New("invalid meeting identifier") |
| 127 | } |
| 128 | return room, meetingAccess(ctx, safe, trimmed+target+"/info") |
| 129 | } |
| 130 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, trimmed+target, nil) |
| 131 | if err != nil { |
| 132 | return "", errors.New("cannot build Calls request") |
| 133 | } |
| 134 | res, err := safe.Do(req) |
| 135 | if err != nil { |
| 136 | return "", errors.New("cannot reach Calls") |
| 137 | } |
| 138 | res.Body.Close() |
| 139 | // followLink answers a gone session with the guest name form (200) |
| 140 | // or the sign-in redirect, never with an error: the link itself is |
| 141 | // fine, it is the person who has to say who they are. |
| 142 | if res.StatusCode == http.StatusOK || strings.HasPrefix(res.Header.Get("Location"), "/signin") { |
| 143 | return "", ErrSignedOut |
| 144 | } |
| 145 | room, err := invitationRoom(res) |
| 146 | if err != nil { |
| 147 | return "", err |
| 148 | } |
| 149 | if !validRoom(room) { |
| 150 | return "", errors.New("invitation is unavailable") |
| 151 | } |
| 152 | return room, nil |
| 153 | } |
| 154 | |
| 155 | // SignOut revokes the held session on its instance: the same POST |
| 156 | // /signout the web's button makes, carrying the Origin csrf.Protect |
| 157 | // reads. Either sign-out route answers with a redirect once the row is |
| 158 | // gone: 303 standalone, 307 in Home mode. |
| 159 | // |
| 160 | // Best effort, and the caller forgets its copy whatever this answers: a |
| 161 | // person who pressed Sign out is signed out of this app, and a session |
| 162 | // the server could not be told about still lapses at its thirty days. |
| 163 | func SignOut(ctx context.Context, origin string, client *http.Client) error { |
| 164 | trimmed, err := checkOrigin(origin) |
| 165 | if err != nil { |
| 166 | return err |
| 167 | } |
| 168 | safe, err := safeClient(client) |
| 169 | if err != nil { |
| 170 | return err |
| 171 | } |
| 172 | ctx, cancel := context.WithTimeout(ctx, 15*time.Second) |
| 173 | defer cancel() |
| 174 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/signout", strings.NewReader("")) |
| 175 | if err != nil { |
| 176 | return errors.New("cannot build Calls request") |
| 177 | } |
| 178 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 179 | req.Header.Set("Origin", trimmed) |
| 180 | res, err := safe.Do(req) |
| 181 | if err != nil { |
| 182 | return errors.New("cannot reach Calls") |
| 183 | } |
| 184 | res.Body.Close() |
| 185 | // 303 to /signed-out standalone; in Home mode, 307 on to the issuer's |
| 186 | // /signout for the browser's id.* session, which this client neither |
| 187 | // holds nor follows (safeClient). Both come after the row is gone. |
| 188 | if res.StatusCode != http.StatusSeeOther && res.StatusCode != http.StatusTemporaryRedirect { |
| 189 | return errors.New("Calls did not confirm the sign-out") |
| 190 | } |
| 191 | return nil |
| 192 | } |
| 193 | |
| 194 | // meetingAccess reads whether the held session may enter a meeting, by |
| 195 | // its info: 200 is yes; the sign-in redirect or 401 is a session that |
| 196 | // has gone; 404 is a session that is fine but not for this meeting — |
| 197 | // or a meeting that has ended, which the app can do nothing about |
| 198 | // either way and says the same of. |
| 199 | func meetingAccess(ctx context.Context, client *http.Client, info string) error { |
| 200 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, info, nil) |
| 201 | if err != nil { |
| 202 | return errors.New("cannot build Calls request") |
| 203 | } |
| 204 | req.Header.Set("Accept", "application/json") |
| 205 | res, err := client.Do(req) |
| 206 | if err != nil { |
| 207 | return errors.New("cannot reach Calls") |
| 208 | } |
| 209 | res.Body.Close() |
| 210 | switch { |
| 211 | case res.StatusCode == http.StatusOK: |
| 212 | return nil |
| 213 | case res.StatusCode == http.StatusUnauthorized, res.StatusCode >= 300 && res.StatusCode < 400: |
| 214 | return ErrSignedOut |
| 215 | case res.StatusCode == http.StatusNotFound, res.StatusCode == http.StatusForbidden: |
| 216 | return ErrNoAccess |
| 217 | default: |
| 218 | return errors.New("cannot open that meeting just now") |
| 219 | } |
| 220 | } |
| 221 | |