package calls import ( "context" "encoding/hex" "encoding/json" "io" "net/http" "strings" "time" "amadan.net/oficina/calls/internal/calls/apns" ) // maxDeviceToken is Apple's token length with room to spare: 32 bytes // today, 100 allowed since the format is not promised to stay put. const maxDeviceToken = 200 // knockPushDeadline bounds one round of notifications. A knock is only // interesting for as long as somebody is still standing at the door. const knockPushDeadline = 20 * time.Second // registerDevice is POST /native/devices: the app saying where it can be // reached when it is not running. // // Behind the same two locks as the other native write — the JSON content // type an HTML form cannot send, and the Origin csrf.Protect reads — // because like that one it runs on the session the app is holding rather // than on no authority at all. func (a *app) registerDevice(w http.ResponseWriter, r *http.Request) { nativeHeaders(w) if r.Header.Get("Content-Type") != "application/json" { http.Error(w, "expected JSON", http.StatusUnsupportedMediaType) return } var input struct { Token string `json:"token"` Platform string `json:"platform"` } if json.NewDecoder(io.LimitReader(r.Body, maxAPIBody)).Decode(&input) != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } // Apple's tokens are hex. Insisting on that here means nothing // arbitrary is ever stored, and a token is never logged: it is an // address for a person's device and does not belong in a log line. token := strings.ToLower(strings.TrimSpace(input.Token)) if token == "" || len(token) > maxDeviceToken { http.Error(w, "invalid request", http.StatusBadRequest) return } if _, err := hex.DecodeString(token); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if input.Platform != "ios" && input.Platform != "macos" { http.Error(w, "invalid request", http.StatusBadRequest) return } p, ok := currentPerson(r) if !ok { http.Error(w, "sign in again", http.StatusUnauthorized) return } if err := a.store.saveDeviceToken(r.Context(), p.ID, token, input.Platform); err != nil { a.logger.Error("calls: register device", "err", err) http.Error(w, "cannot register this device", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // knocked is the hub's Knocked callback: somebody has begun waiting, and // the members who are not in the room have no other way to find out. // // `watching` is who was admitted at the moment of the knock. They are // skipped, because the queue is already on their screen and a push would // be a second telling of something they can see. That check is the whole // reason the hub gathers the list under its own lock and passes it in — // asking again here would race the room. // // Everything is best effort and nothing is retried. A knock is worth // saying once, quickly; a person who has stopped waiting is not worth // telling anyone about a minute later. func (a *app) knocked(room string, watching []int64, name string) { if a.push == nil { return } ctx, cancel := context.WithTimeout(context.Background(), knockPushDeadline) defer cancel() members, err := a.store.memberPersonIDs(ctx, a.allowNow()) if err != nil { a.logger.Error("calls: knock push members", "err", err, "room", room) return } looking := make(map[int64]bool, len(watching)) for _, id := range watching { looking[id] = true } absent := members[:0:0] for _, id := range members { if !looking[id] { absent = append(absent, id) } } tokens, err := a.store.deviceTokensFor(ctx, absent) if err != nil { a.logger.Error("calls: knock push tokens", "err", err, "room", room) return } for _, device := range tokens { verdict, err := a.push.Push(ctx, device.Token, device.Platform, name) switch verdict { case apns.Gone: // Apple says this device will never be reachable again. The // row is wrong and deleting it is the only hygiene this // table gets. The token is not logged. if err := a.store.forgetDeviceToken(ctx, device.Token); err != nil { a.logger.Error("calls: forget device token", "err", err) } case apns.Retry: a.logger.Warn("calls: knock push refused", "err", err, "room", room, "platform", device.Platform) } } } // knockPusher is the narrow part of apns.Client this app uses, named as // an interface so a test can watch what would have been sent to Apple // without an Apple to send it to. type knockPusher interface { Push(ctx context.Context, token, platform, name string) (apns.Verdict, error) }