package pushserver import ( "context" "encoding/json" "fmt" "net/url" "github.com/dariuszjaniak/warp/server/internal/storage" ) // PusherKindHTTP is the one kind of pusher this server pushes to. The // spec's other kind, email, needs a mail server and templates, which are // not something a homeserver should ship by default; a client asking for // it is told so rather than given a pusher that never fires. const PusherKindHTTP = "http" // gatewayPath is the path every push gateway is reached at. const gatewayPath = "/_matrix/push/v1/notify" // Limits the spec sets on a pusher's identity. const ( maxAppIDLength = 64 maxPushKeyLength = 512 ) // Pusher is a pusher in the shape the Client-Server API uses for it. type Pusher struct { PushKey string `json:"pushkey"` Kind string `json:"kind"` AppID string `json:"app_id"` AppDisplayName string `json:"app_display_name"` DeviceDisplayName string `json:"device_display_name"` ProfileTag string `json:"profile_tag,omitempty"` Lang string `json:"lang"` Data json.RawMessage `json:"data"` } // SetPusherRequest is POST /pushers/set. type SetPusherRequest struct { PushKey string `json:"pushkey"` // Kind null removes the pusher. Kind *string `json:"kind"` AppID string `json:"app_id"` AppDisplayName string `json:"app_display_name"` DeviceDisplayName string `json:"device_display_name"` ProfileTag string `json:"profile_tag"` Lang string `json:"lang"` Data json.RawMessage `json:"data"` Append bool `json:"append"` } // pusherData is the part of a pusher's data the server reads. type pusherData struct { URL string `json:"url"` Format string `json:"format"` } // Pushers lists a user's pushers. func (a *API) Pushers(ctx context.Context, localpart string) ([]Pusher, error) { stored, err := a.store.Pushers(ctx, localpart) if err != nil { return nil, err } out := make([]Pusher, 0, len(stored)) for _, p := range stored { out = append(out, Pusher{ PushKey: p.PushKey, Kind: p.Kind, AppID: p.AppID, AppDisplayName: p.AppDisplayName, DeviceDisplayName: p.DeviceDisplayName, ProfileTag: p.ProfileTag, Lang: p.Lang, Data: p.Data, }) } return out, nil } // SetPusher creates, replaces or removes a pusher for the session // deviceID. func (a *API) SetPusher(ctx context.Context, localpart, deviceID string, req SetPusherRequest) error { switch { case req.PushKey == "": return fmt.Errorf("%w: pushkey", ErrMissingPusherField) case req.AppID == "": return fmt.Errorf("%w: app_id", ErrMissingPusherField) case len(req.AppID) > maxAppIDLength: return fmt.Errorf("%w: app_id must be at most %d characters", ErrInvalidPusher, maxAppIDLength) case len(req.PushKey) > maxPushKeyLength: return fmt.Errorf("%w: pushkey must be at most %d bytes", ErrInvalidPusher, maxPushKeyLength) } if req.Kind == nil { return a.store.DeletePusher(ctx, localpart, req.AppID, req.PushKey) } switch { case *req.Kind != PusherKindHTTP: return fmt.Errorf("%w: this server only supports pushers of kind %q", ErrInvalidPusher, PusherKindHTTP) case req.AppDisplayName == "": return fmt.Errorf("%w: app_display_name", ErrMissingPusherField) case req.DeviceDisplayName == "": return fmt.Errorf("%w: device_display_name", ErrMissingPusherField) case req.Lang == "": return fmt.Errorf("%w: lang", ErrMissingPusherField) case len(req.Data) == 0: return fmt.Errorf("%w: data", ErrMissingPusherField) } var data pusherData if err := json.Unmarshal(req.Data, &data); err != nil { return fmt.Errorf("%w: data must be an object", ErrInvalidPusher) } if data.URL == "" { return fmt.Errorf("%w: data.url", ErrMissingPusherField) } if err := validateGatewayURL(data.URL); err != nil { return err } return a.store.SetPusher(ctx, storage.Pusher{ Localpart: localpart, DeviceID: deviceID, Kind: *req.Kind, AppID: req.AppID, PushKey: req.PushKey, AppDisplayName: req.AppDisplayName, DeviceDisplayName: req.DeviceDisplayName, ProfileTag: req.ProfileTag, Lang: req.Lang, Data: req.Data, PushKeyTSMS: a.now().UnixMilli(), }, req.Append) } // validateGatewayURL checks a pusher's gateway URL. The spec asks for // HTTPS; plain HTTP is accepted too, because the usual deployment runs the // gateway next to the homeserver on a private network, where requiring a // certificate would only push operators to a worse workaround. func validateGatewayURL(raw string) error { u, err := url.Parse(raw) switch { case err != nil || u.Host == "": return fmt.Errorf("%w: data.url must be an absolute URL", ErrInvalidPusher) case u.Scheme != "https" && u.Scheme != "http": return fmt.Errorf("%w: data.url must be an http or https URL", ErrInvalidPusher) case u.Path != gatewayPath: return fmt.Errorf("%w: data.url must have the path %s", ErrInvalidPusher, gatewayPath) case u.User != nil: return fmt.Errorf("%w: data.url must not carry credentials", ErrInvalidPusher) } return nil } // DevicesDeleted removes the pushers of deleted sessions: a phone that // has logged out must stop being woken for the account. It implements // userapi.DeviceObserver, and like every observer it logs a failure // rather than returning it, because the device is already gone. func (a *API) DevicesDeleted(ctx context.Context, localpart string, deviceIDs []string) { if err := a.store.DeletePushersForDevices(ctx, localpart, deviceIDs); err != nil { a.log.Warn("removing the pushers of deleted devices failed", "localpart", localpart, "devices", deviceIDs, "error", err) } } // DeviceRenamed implements userapi.DeviceObserver. A pusher's device // display name is the one the app registered it with, not the session's. func (a *API) DeviceRenamed(context.Context, string, string) {} // pushKeyRejected removes a pusher whose gateway said its push key is no // longer valid — the app was uninstalled, or its registration expired — // so that it is not pushed to again. func (a *API) pushKeyRejected(localpart, appID, pushKey string) { if err := a.store.DeletePusher(a.ctx, localpart, appID, pushKey); err != nil && a.ctx.Err() == nil { a.log.Warn("removing a rejected pusher failed", "localpart", localpart, "app_id", appID, "error", err) return } a.log.Info("removed a pusher its gateway rejected", "localpart", localpart, "app_id", appID) }