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 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "net/http"
10 "time"
11)
12
13// StartMeeting creates a meeting on the instance this client is signed
14// in to and answers its identifier. It is the app's half of the meeting
15// list's one-click button, and it exists because that button's web
16// answer is the link-once *page* — a screen, and not something a Swift
17// app can read without pretending to be a browser.
18//
19// The identifier is all it answers. A link is a credential, and the
20// room already has a share flow that mints one when a member asks for
21// it (Client.ShareLink), so nothing that merely starts a meeting has to
22// hold one, log one, or decide when to forget it.
23//
24// The session is the caller's: an ordinary browser session carried in
25// the client's cookie jar, exactly as Room uses. Nothing here mints or
26// accepts an agent bearer token.
27func StartMeeting(ctx context.Context, origin string, client *http.Client) (string, error) {
28 return StartMeetingIn(ctx, origin, "", client)
29}
30
31// StartMeetingIn is StartMeeting naming the app's IANA time zone, so the
32// title the server writes ("Call, 2 Oct 15:40") is the starter's own time
33// rather than the instance's. An empty or unknown zone is the instance's.
34func StartMeetingIn(ctx context.Context, origin, zone string, client *http.Client) (string, error) {
35 trimmed, err := checkOrigin(origin)
36 if err != nil {
37 return "", err
38 }
39 safe, err := safeClient(client)
40 if err != nil {
41 return "", err
42 }
43 ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
44 defer cancel()
45 req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/native/meetings/start", bytes.NewReader(startBody(zone)))
46 if err != nil {
47 return "", errors.New("cannot build Calls request")
48 }
49 req.Header.Set("Accept", "application/json")
50 // Both locks the server asks for: the content type an HTML form
51 // cannot send, and the origin csrf.Protect reads. A POST carrying
52 // neither is refused before the handler sees it.
53 req.Header.Set("Content-Type", "application/json")
54 req.Header.Set("Origin", trimmed)
55 res, err := safe.Do(req)
56 if err != nil {
57 return "", errors.New("cannot reach Calls")
58 }
59 defer res.Body.Close()
60 switch {
61 case res.StatusCode == http.StatusOK:
62 case res.StatusCode == http.StatusUnauthorized, res.StatusCode == http.StatusSeeOther:
63 // The session went while the app was holding it. Redirects are
64 // never followed here, so a sign-in page arrives as its 303 and
65 // is read as one rather than decoded as JSON.
66 return "", errors.New("sign in to Calls again")
67 case res.StatusCode == http.StatusForbidden, res.StatusCode == http.StatusNotFound:
68 // requireMember's refusal. A guest may join a meeting and may
69 // not start one, and saying which would tell an outsider
70 // something about the team.
71 return "", errors.New("this account cannot start meetings on that Calls server")
72 default:
73 return "", errors.New("cannot start a meeting just now")
74 }
75 var out struct {
76 Sqid string `json:"sqid"`
77 }
78 if json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&out) != nil || !validRoom(out.Sqid) {
79 return "", errors.New("Calls answered with a meeting this app cannot open")
80 }
81 return out.Sqid, nil
82}
83
84// RegisterDevice tells the server where this device can be reached when
85// the app is not running.
86//
87// Best effort by design, and the caller treats a failure as nothing
88// happening: a device that could not register is a device that will not
89// be told about a knock, which is exactly where every device was before
90// push existed. It is never a reason to fail a join.
91func RegisterDevice(ctx context.Context, origin string, client *http.Client, token, platform string) error {
92 trimmed, err := checkOrigin(origin)
93 if err != nil {
94 return err
95 }
96 safe, err := safeClient(client)
97 if err != nil {
98 return err
99 }
100 if platform != "ios" && platform != "macos" {
101 return errors.New("unknown device platform")
102 }
103 body, err := json.Marshal(map[string]string{"token": token, "platform": platform})
104 if err != nil {
105 return errors.New("cannot build Calls request")
106 }
107 ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
108 defer cancel()
109 req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/native/devices", bytes.NewReader(body))
110 if err != nil {
111 return errors.New("cannot build Calls request")
112 }
113 req.Header.Set("Accept", "application/json")
114 req.Header.Set("Content-Type", "application/json")
115 req.Header.Set("Origin", trimmed)
116 res, err := safe.Do(req)
117 if err != nil {
118 return errors.New("cannot reach Calls")
119 }
120 defer res.Body.Close()
121 switch {
122 case res.StatusCode == http.StatusNoContent:
123 return nil
124 case res.StatusCode == http.StatusNotFound:
125 // An older server without the endpoint. Not an error worth
126 // showing anyone: it simply has no push.
127 return errors.New("this Calls server does not take device registrations")
128 default:
129 return errors.New("Calls refused this device registration")
130 }
131}
132
133// startBody is the start request: an empty object, or the zone when the
134// app knows it. A zone too long to be an IANA name is left out rather
135// than sent for the server to refuse.
136func startBody(zone string) []byte {
137 if zone == "" || len(zone) > 64 {
138 return []byte("{}")
139 }
140 b, _ := json.Marshal(map[string]string{"zone": zone})
141 return b
142}
143