| 1 | package calls |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | const 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. |
| 21 | const 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. |
| 30 | func (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. |
| 85 | func (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. |
| 130 | type knockPusher interface { |
| 131 | Push(ctx context.Context, token, platform, name string) (apns.Verdict, error) |
| 132 | } |
| 133 | |