Skip to content

Commit 3c6cbdd

Browse files
committed
MSC4174: add support for WebPush pusher kind
1 parent ecbc0b7 commit 3c6cbdd

File tree

11 files changed

+1114
-28
lines changed

11 files changed

+1114
-28
lines changed

changelog.d/17987.feature

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
MSC4174: add support for WebPush pusher kind.
2+

docs/webpush.md

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
2+
# WebPush
3+
4+
## Setup & configuration
5+
6+
In the synapse virtualenv, generate the server key pair by running
7+
`vapid --gen --applicationServerKey`. This will generate a `private_key.pem`
8+
(which you'll refer to in the config file with `vapid_private_key`)
9+
and `public_key.pem` file, and also a string labeled `Application Server Key`.
10+
11+
You'll copy the Application Server Key to `vapid_app_server_key` so that
12+
web applications can fetch it through `/capabilities` and use it to subscribe
13+
to the push manager:
14+
15+
```js
16+
serviceWorkerRegistration.pushManager.subscribe({
17+
userVisibleOnly: true,
18+
applicationServerKey: "...",
19+
});
20+
```
21+
22+
You also need to set an e-mail address in `vapid_contact_email` in the config file,
23+
where the push server operator can reach you in case they need to notify you
24+
about your usage of their API.
25+
26+
Since for webpush, the push server endpoint is variable and comes from the browser
27+
through the push data, you may not want to have your synapse instance connect to any
28+
random addressable server.
29+
You can use the global options `ip_range_blacklist` and `ip_range_allowlist` to manage that.
30+
31+
A default time-to-live of 15 minutes is set for webpush, but you can adjust this by setting
32+
the `ttl: <number of seconds>` configuration option for the pusher.
33+
If notifications can't be delivered by the push server aftet this time, they are dropped.
34+
35+
## Push key and expected push data
36+
37+
In your web application, [the push manager subscribe method](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)
38+
will return
39+
[a subscription](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription)
40+
with an `endpoint` and `keys` property, the latter containing a `p256dh` and `auth`
41+
property. The `p256dh` key is used as the push key, and the push data must contain
42+
`endpoint` and `auth`. You can also set `default_payload` in the push data;
43+
any properties set in it will be present in the push messages you receive,
44+
so it can be used to pass identifiers specific to your client
45+
(like which account the notification is for).
46+
47+
### events_only
48+
49+
As of the time of writing, all webpush-supporting browsers require you to set
50+
`userVisibleOnly: true` when calling (`pushManager.subscribe`)
51+
[https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe], to
52+
(prevent abusing webpush to track users)[https://goo.gl/yqv4Q4] without their
53+
knowledge. With this (mandatory) flag, the browser will show a "site has been
54+
updated in the background" notification if no notifications are visible after
55+
your service worker processes a `push` event. This can easily happen when synapse
56+
sends a push message to clear the unread count, which is not specific
57+
to an event. With `events_only: true` in the pusher data, synapse won't forward
58+
any push message without a event id. This prevents your service worker being
59+
forced to show a notification to push messages that clear the unread count.
60+
61+
### only_last_per_room
62+
63+
You can opt in to only receive the last notification per room by setting
64+
`only_last_per_room: true` in the push data. Note that if the first notification
65+
can be delivered before the second one is sent, you will still get both;
66+
it only has an effect when notifications are queued up on the gateway.
67+
68+
### Multiple pushers on one origin
69+
70+
Also note that because you can only have one push subscription per service worker,
71+
and hence per origin, you might create pushers for different accounts with the same
72+
p256dh push key. To prevent the server from removing other pushers with the same
73+
push key for your other users, you should set `append` to `true` when uploading
74+
your pusher.
75+
76+
## Notification format
77+
78+
The notification as received by your web application will contain the following keys
79+
(assuming non-null values were sent by the homeserver). These are the
80+
same as specified in [the push gateway spec](https://matrix.org/docs/spec/push_gateway/r0.1.0#post-matrix-push-v1-notify),
81+
but the sub-keys of `counts` (`unread` and `missed_calls`) are flattened into
82+
the notification object.
83+
84+
```
85+
room_id
86+
room_name
87+
room_alias
88+
membership
89+
event_id
90+
sender
91+
sender_display_name
92+
user_is_target
93+
type
94+
content
95+
unread
96+
missed_calls
97+
```

mypy.ini

+6
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,9 @@ ignore_missing_imports = True
9999

100100
[mypy-multipart.*]
101101
ignore_missing_imports = True
102+
103+
[mypy-pywebpush.*]
104+
ignore_missing_imports = True
105+
106+
[mypy-py_vapid.*]
107+
ignore_missing_imports = True

poetry.lock

+579-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+6
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ Pympler = { version = "*", optional = true }
251251
parameterized = { version = ">=0.7.4", optional = true }
252252
idna = { version = ">=2.5", optional = true }
253253
pyicu = { version = ">=2.10.2", optional = true }
254+
pywebpush = { version = ">=2.0", optional = true }
255+
py-vapid = { version = ">=1.9", optional = true }
254256

255257
[tool.poetry.extras]
256258
# NB: Packages that should be part of `pip install matrix-synapse[all]` need to be specified
@@ -277,6 +279,7 @@ test = ["parameterized", "idna"]
277279
# requires libicu's development headers installed on the system (e.g. libicu-dev on
278280
# Debian-based distributions).
279281
user-search = ["pyicu"]
282+
webpush = ["pywebpush", "py-vapid"]
280283

281284
# The duplication here is awful. I hate hate hate hate hate it. However, for now I want
282285
# to ensure you can still `pip install matrix-synapse[all]` like today. Two motivations:
@@ -310,6 +313,9 @@ all = [
310313
"pympler",
311314
# improved user search
312315
"pyicu",
316+
# WebPush support
317+
"pywebpush",
318+
"py-vapid",
313319
# omitted:
314320
# - test: it's useful to have this separate from dev deps in the olddeps job
315321
# - systemd: this is a system-based requirement

synapse/config/experimental.py

+50
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@
3838
except ImportError:
3939
HAS_AUTHLIB = False
4040

41+
# Determine whether pywebpush is installed.
42+
try:
43+
import pywebpush # noqa: F401
44+
45+
HAS_PYWEBPUSH = True
46+
except ImportError:
47+
HAS_PYWEBPUSH = False
48+
4149
if TYPE_CHECKING:
4250
# Only import this if we're type checking, as it might not be installed at runtime.
4351
from authlib.jose.rfc7517 import JsonWebKey
@@ -256,6 +264,28 @@ class MSC3866Config:
256264
require_approval_for_new_accounts: bool = False
257265

258266

267+
@attr.s(auto_attribs=True, frozen=True, slots=True)
268+
class MSC4174Config:
269+
"""Configuration for MSC4174"""
270+
271+
enabled: bool = attr.ib(default=False, validator=attr.validators.instance_of(bool))
272+
273+
@enabled.validator
274+
def _check_enabled(self, attribute: attr.Attribute, value: bool) -> None:
275+
# Only allow enabling MSC4174 if pywebpush is installed
276+
if value and not HAS_PYWEBPUSH:
277+
raise ConfigError(
278+
"MSC4174 is enabled but pywebpush is not installed. "
279+
"Please install pywebpush to use MSC4174.",
280+
("experimental", "msc4174", "enabled"),
281+
)
282+
283+
vapid_contact_email: str = ""
284+
vapid_private_key: str = ""
285+
vapid_app_server_key: str = ""
286+
ttl: int = 15 * 60
287+
288+
259289
class ExperimentalConfig(Config):
260290
"""Config section for enabling experimental features"""
261291

@@ -447,3 +477,23 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
447477

448478
# MSC4076: Add `disable_badge_count`` to pusher configuration
449479
self.msc4076_enabled: bool = experimental.get("msc4076_enabled", False)
480+
481+
# MSC4174: webpush push kind
482+
raw_msc4174_config = experimental.get("msc4174", {})
483+
self.msc4174 = MSC4174Config(**raw_msc4174_config)
484+
if self.msc4174.enabled:
485+
if not self.msc4174.vapid_contact_email:
486+
raise ConfigError(
487+
"'vapid_contact_email' must be provided when enabling WebPush support",
488+
("experimental", "msc4174", "vapid_contact_email"),
489+
)
490+
if not self.msc4174.vapid_private_key:
491+
raise ConfigError(
492+
"'vapid_private_key' must be provided when enabling WebPush support",
493+
("experimental", "msc4174", "vapid_private_key"),
494+
)
495+
if not self.msc4174.vapid_app_server_key:
496+
raise ConfigError(
497+
"'vapid_app_server_key' must be provided when enabling WebPush support",
498+
("experimental", "msc4174", "vapid_app_server_key"),
499+
)

synapse/push/httppusher.py

+30-25
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ def __init__(self, hs: "HomeServer", pusher_config: PusherConfig):
111111
self.device_display_name = pusher_config.device_display_name
112112
self.device_id = pusher_config.device_id
113113
self.pushkey_ts = pusher_config.ts
114-
self.data = pusher_config.data
115114
self.backoff_delay = HttpPusher.INITIAL_BACKOFF_SEC
116115
self.failing_since = pusher_config.failing_since
117116
self.timed_call: Optional[IDelayedCall] = None
@@ -123,9 +122,9 @@ def __init__(self, hs: "HomeServer", pusher_config: PusherConfig):
123122

124123
self.push_jitter_delay_ms = hs.config.push.push_jitter_delay_ms
125124

126-
self.data = pusher_config.data
127-
if self.data is None:
125+
if pusher_config.data is None:
128126
raise PusherConfigException("'data' key can not be null for HTTP pusher")
127+
self.data = pusher_config.data
129128

130129
# Check if badge counts should be disabled for this push gateway
131130
self.disable_badge_count = self.hs.config.experimental.msc4076_enabled and bool(
@@ -138,26 +137,29 @@ def __init__(self, hs: "HomeServer", pusher_config: PusherConfig):
138137
pusher_config.pushkey,
139138
)
140139

141-
# Validate that there's a URL and it is of the proper form.
142-
if "url" not in self.data:
143-
raise PusherConfigException("'url' required in data for HTTP pusher")
144-
145-
url = self.data["url"]
146-
if not isinstance(url, str):
147-
raise PusherConfigException("'url' must be a string")
148-
url_parts = urllib.parse.urlparse(url)
149-
# Note that the specification also says the scheme must be HTTPS, but
150-
# it isn't up to the homeserver to verify that.
151-
if url_parts.path != "/_matrix/push/v1/notify":
152-
raise PusherConfigException(
153-
"'url' must have a path of '/_matrix/push/v1/notify'"
154-
)
140+
self.url = ""
141+
if pusher_config.kind == "http":
142+
# Validate that there's a URL and it is of the proper form.
143+
if "url" not in self.data:
144+
raise PusherConfigException("'url' required in data for HTTP pusher")
145+
146+
url = self.data["url"]
147+
if not isinstance(url, str):
148+
raise PusherConfigException("'url' must be a string")
149+
url_parts = urllib.parse.urlparse(url)
150+
# Note that the specification also says the scheme must be HTTPS, but
151+
# it isn't up to the homeserver to verify that.
152+
if url_parts.path != "/_matrix/push/v1/notify":
153+
raise PusherConfigException(
154+
"'url' must have a path of '/_matrix/push/v1/notify'"
155+
)
156+
self.url = url
157+
158+
self.data_minus_url = {}
159+
self.data_minus_url.update(self.data)
160+
del self.data_minus_url["url"]
155161

156-
self.url = url
157162
self.http_client = hs.get_proxied_blocklisted_http_client()
158-
self.data_minus_url = {}
159-
self.data_minus_url.update(self.data)
160-
del self.data_minus_url["url"]
161163
self.badge_count_last_call: Optional[int] = None
162164

163165
def on_started(self, should_check_for_notifs: bool) -> None:
@@ -188,7 +190,10 @@ async def _update_badge(self) -> None:
188190
)
189191
if self.badge_count_last_call is None or self.badge_count_last_call != badge:
190192
self.badge_count_last_call = badge
191-
await self._send_badge(badge)
193+
if await self.send_badge(badge):
194+
http_badges_processed_counter.inc()
195+
else:
196+
http_badges_failed_counter.inc()
192197

193198
def on_timer(self) -> None:
194199
self._start_processing()
@@ -510,7 +515,7 @@ async def dispatch_push_event(
510515

511516
return res
512517

513-
async def _send_badge(self, badge: int) -> None:
518+
async def send_badge(self, badge: int) -> bool:
514519
"""
515520
Args:
516521
badge: number of unread messages
@@ -534,9 +539,9 @@ async def _send_badge(self, badge: int) -> None:
534539
}
535540
try:
536541
await self.http_client.post_json_get_json(self.url, d)
537-
http_badges_processed_counter.inc()
542+
return True
538543
except Exception as e:
539544
logger.warning(
540545
"Failed to send badge count to %s: %s %s", self.name, type(e), e
541546
)
542-
http_badges_failed_counter.inc()
547+
return False

synapse/push/pusher.py

+9
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import logging
2323
from typing import TYPE_CHECKING, Callable, Dict, Optional
2424

25+
import synapse.config.experimental
2526
from synapse.push import Pusher, PusherConfig
2627
from synapse.push.emailpusher import EmailPusher
2728
from synapse.push.httppusher import HttpPusher
@@ -42,6 +43,14 @@ def __init__(self, hs: "HomeServer"):
4243
"http": HttpPusher
4344
}
4445

46+
if (
47+
synapse.config.experimental.HAS_PYWEBPUSH
48+
and self.config.experimental.msc4174.enabled
49+
):
50+
from synapse.push.webpushpusher import WebPushPusher
51+
52+
self.pusher_types["webpush"] = WebPushPusher
53+
4554
logger.info("email enable notifs: %r", hs.config.email.email_enable_notifs)
4655
if hs.config.email.email_enable_notifs:
4756
self.mailers: Dict[str, Mailer] = {}

0 commit comments

Comments
 (0)