oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calls
2
3import (
4 "context"
5 "encoding/hex"
6 "encoding/json"
7 "io"
8 "net/http"
9 "strings"
10 "time"
11
12 "amadan.net/oficina/calls/internal/calls/apns"
13)
14
15// maxDeviceToken is Apple's token length with room to spare: 32 bytes
16// today, 100 allowed since the format is not promised to stay put.
17const maxDeviceToken = 200
18
19// knockPushDeadline bounds one round of notifications. A knock is only
20// interesting for as long as somebody is still standing at the door.
21const knockPushDeadline = 20 * time.Second
22
23// registerDevice is POST /native/devices: the app saying where it can be
24// reached when it is not running.
25//
26// Behind the same two locks as the other native write — the JSON content
27// type an HTML form cannot send, and the Origin csrf.Protect reads —
28// because like that one it runs on the session the app is holding rather
29// than on no authority at all.
30func (a *app) registerDevice(w http.ResponseWriter, r *http.Request) {
31 nativeHeaders(w)
32 if r.Header.Get("Content-Type") != "application/json" {
33 http.Error(w, "expected JSON", http.StatusUnsupportedMediaType)
34 return
35 }
36 var input struct {
37 Token string `json:"token"`
38 Platform string `json:"platform"`
39 }
40 if json.NewDecoder(io.LimitReader(r.Body, maxAPIBody)).Decode(&input) != nil {
41 http.Error(w, "invalid request", http.StatusBadRequest)
42 return
43 }
44 // Apple's tokens are hex. Insisting on that here means nothing
45 // arbitrary is ever stored, and a token is never logged: it is an
46 // address for a person's device and does not belong in a log line.
47 token := strings.ToLower(strings.TrimSpace(input.Token))
48 if token == "" || len(token) > maxDeviceToken {
49 http.Error(w, "invalid request", http.StatusBadRequest)
50 return
51 }
52 if _, err := hex.DecodeString(token); err != nil {
53 http.Error(w, "invalid request", http.StatusBadRequest)
54 return
55 }
56 if input.Platform != "ios" && input.Platform != "macos" {
57 http.Error(w, "invalid request", http.StatusBadRequest)
58 return
59 }
60 p, ok := currentPerson(r)
61 if !ok {
62 http.Error(w, "sign in again", http.StatusUnauthorized)
63 return
64 }
65 if err := a.store.saveDeviceToken(r.Context(), p.ID, token, input.Platform); err != nil {
66 a.logger.Error("calls: register device", "err", err)
67 http.Error(w, "cannot register this device", http.StatusInternalServerError)
68 return
69 }
70 w.WriteHeader(http.StatusNoContent)
71}
72
73// knocked is the hub's Knocked callback: somebody has begun waiting, and
74// the members who are not in the room have no other way to find out.
75//
76// `watching` is who was admitted at the moment of the knock. They are
77// skipped, because the queue is already on their screen and a push would
78// be a second telling of something they can see. That check is the whole
79// reason the hub gathers the list under its own lock and passes it in —
80// asking again here would race the room.
81//
82// Everything is best effort and nothing is retried. A knock is worth
83// saying once, quickly; a person who has stopped waiting is not worth
84// telling anyone about a minute later.
85func (a *app) knocked(room string, watching []int64, name string) {
86 if a.push == nil {
87 return
88 }
89 ctx, cancel := context.WithTimeout(context.Background(), knockPushDeadline)
90 defer cancel()
91 members, err := a.store.memberPersonIDs(ctx, a.allowNow())
92 if err != nil {
93 a.logger.Error("calls: knock push members", "err", err, "room", room)
94 return
95 }
96 looking := make(map[int64]bool, len(watching))
97 for _, id := range watching {
98 looking[id] = true
99 }
100 absent := members[:0:0]
101 for _, id := range members {
102 if !looking[id] {
103 absent = append(absent, id)
104 }
105 }
106 tokens, err := a.store.deviceTokensFor(ctx, absent)
107 if err != nil {
108 a.logger.Error("calls: knock push tokens", "err", err, "room", room)
109 return
110 }
111 for _, device := range tokens {
112 verdict, err := a.push.Push(ctx, device.Token, device.Platform, name)
113 switch verdict {
114 case apns.Gone:
115 // Apple says this device will never be reachable again. The
116 // row is wrong and deleting it is the only hygiene this
117 // table gets. The token is not logged.
118 if err := a.store.forgetDeviceToken(ctx, device.Token); err != nil {
119 a.logger.Error("calls: forget device token", "err", err)
120 }
121 case apns.Retry:
122 a.logger.Warn("calls: knock push refused", "err", err, "room", room, "platform", device.Platform)
123 }
124 }
125}
126
127// knockPusher is the narrow part of apns.Client this app uses, named as
128// an interface so a test can watch what would have been sent to Apple
129// without an Apple to send it to.
130type knockPusher interface {
131 Push(ctx context.Context, token, platform, name string) (apns.Verdict, error)
132}
133