oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package callscore
2
3import (
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.
14var 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).
22var 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.
26type Standing string
27
28const (
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.
54func 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.
103func 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.
163func 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.
199func 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