diff --git a/CHANGELOG.md b/CHANGELOG.md index 0999302..e4406e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # I am terrible at keeping this up-to-date. +## 2.1.0 (2024-04-19) +Add comment discussing additional work for Windows Notification Service (WNS) +* Update the README.md file to mention the required, non-standard headers. + +*BREAKING_CHANGE* +This version also drops legacy support for GCM/FCM authorization keys, since those items +are obsolete according to Google. +See https://firebase.google.com/docs/cloud-messaging/auth-server#authorize-legacy-protocol-send-requests ## 2.0.0 (2024-01-02) chore: Update to modern python practices diff --git a/README.md b/README.md index f20d3ec..3525945 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,11 @@ referenced to the user who requested it, and recall it when there's a new push subscription update is left as an exercise for the reader. +_*Note:*_ Some platforms (like Microsoft Windows) require additional +headers specified for the Push call. These additional headers are not +standard and may be rejected by other Push services. Please see +[Special Instructions](#special-instructions) below. + ### Sending Data using `webpush()` One Call In many cases, your code will be sending a single message to many @@ -56,7 +61,7 @@ webpush(subscription_info, This will encode `data`, add the appropriate VAPID auth headers if required and send it to the push server identified in the `subscription_info` block. -##### Parameters +#### Parameters _subscription_info_ - The `dict` of the subscription info (described above). @@ -81,7 +86,7 @@ e.g. the output of: openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem ``` -##### Example +#### Example ```python from pywebpush import webpush, WebPushException @@ -119,7 +124,7 @@ can pass just `wp = WebPusher(subscription_info)`. This will return a `WebPusher The following methods are available: -#### `.send(data, headers={}, ttl=0, gcm_key="", reg_id="", content_encoding="aes128gcm", curl=False, timeout=None)` +#### `.send(data, headers={}, ttl=0, reg_id="", content_encoding="aes128gcm", curl=False, timeout=None)` Send the data using additional parameters. On error, returns a `WebPushException` @@ -131,9 +136,6 @@ _headers_ A `dict` containing any additional headers to send _ttl_ Message Time To Live on Push Server waiting for the client to reconnect (in seconds) -_gcm_key_ Google Cloud Messaging key (if using the older GCM push system) This is the API key obtained from the Google -Developer Console. - _reg_id_ Google Cloud Messaging registration ID (will be extracted from endpoint if not specified) _content_encoding_ ECE content encoding type (defaults to "aes128gcm") @@ -146,10 +148,10 @@ See [requests documentation](http://docs.python-requests.org/en/master/user/quic ##### Example -to send from Chrome using the old GCM mode: +to send to a user on Chrome: ```python -WebPusher(subscription_info).send(data, headers, ttl, gcm_key) +WebPusher(subscription_info).send(data, headers, ttl) ``` #### `.encode(data, content_encoding="aes128gcm")` @@ -162,7 +164,7 @@ _data_ Binary string of data to send _content_encoding_ ECE content encoding type (defaults to "aes128gcm") -*Note* This will return a `NoData` exception if the data is not present or empty. It is completely +_*Note*_ This will return a `NoData` exception if the data is not present or empty. It is completely valid to send a WebPush notification with no data, but encoding is a no-op in that case. Best not to call it if you don't have data. @@ -174,8 +176,7 @@ encoded_data = WebPush(subscription_info).encode(data) ## Stand Alone Webpush -If you're not really into coding your own solution, there's also a "stand-alone" `pywebpush` command in the -./bin directory. +If you're not really into coding your own solution, there's also a "stand-alone" `pywebpush` command in the `./bin` directory. This uses two files: @@ -201,3 +202,40 @@ If you're interested in just testing your applications WebPush interface, you co which will encrypt and send the contents of `stuff_to_send.data`. See `./bin/pywebpush --help` for available commands and options. + +## Special Instructions + +### Windows + +[Microsoft requires](https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/push-request-response-headers#request-parameters) +one extra header and suggests several additional headers. Users have reported that not including +these headers can cause notifications to fail to appear. These additional headers are non-standard +and may be rejected by other platforms. You should be cautious including them with calls to +non-Microsoft platforms, or to non-Microsoft destinations. + +As of 2024-Apr-19, Microsoft requires an `X-WNS-Type` header. As an example, you can include this +header within the command line call: + +Content of the `windows_headers.json` file: + +```json +{"X-WNS-Type":"wns/toast", "TTL":600, "Content-Type": "text/xml"} +``` + +_*Note*_ : This includes both the `TTL` set to 10 minutes and the required matching `Content-Type` header for `wns/toast`. + +```bash +pywebpush --data stuff_to_send.xml \ + --info edge_user_info.json \ + --head windows_headers.json \ + --claims vapid_claims.json +``` + +### Google Cloud Messaging (GCM) + +Please note that GCM has been sunset by Google. Providers should +use [Firebase Cloud Messaging](https://firebase.google.com/support/troubleshooter/fcm/tokens/gcm) instead. + +Please also note that sending messages directly to FCM is not supported by this library. In the past, you could use an insecure `gcm_key` as a proxy for the authentication service. [This was disabled in June 2024](https://firebase.google.com/docs/cloud-messaging/auth-server#authorize-legacy-protocol-send-requests). + +Sending WebPush messages to Google Chrome users should not be impacted by this change. diff --git a/pywebpush/__init__.py b/pywebpush/__init__.py index e58cbd3..6a784be 100644 --- a/pywebpush/__init__.py +++ b/pywebpush/__init__.py @@ -304,10 +304,7 @@ def _prepare_send_data( data: Union[None, bytes] = None, headers: Union[None, Dict[str, str]] = None, ttl: int = 0, - gcm_key: Union[None, str] = None, - reg_id: Union[None, str] = None, content_encoding: str = "aes128gcm", - curl: bool = False, ) -> dict: """Encode and send the data to the Push Service. @@ -319,16 +316,8 @@ def _prepare_send_data( recipient is not online. (Defaults to "0", which discards the message immediately if the recipient is unavailable.) :type ttl: int - :param gcm_key: API key obtained from the Google Developer Console. - Needed if endpoint is https://android.googleapis.com/gcm/send - :type gcm_key: string - :param reg_id: registration id of the recipient. If not provided, - it will be extracted from the endpoint. - :type reg_id: str :param content_encoding: ECE content encoding (defaults to "aes128gcm") :type content_encoding: str - :param curl: Display output as `curl` command instead of sending - :type curl: bool """ # Encode the data. if headers is None: @@ -355,40 +344,8 @@ def _prepare_send_data( "content-encoding": content_encoding, } ) - if gcm_key: - # guess if it is a legacy GCM project key or actual FCM key - # gcm keys are all about 40 chars (use 100 for confidence), - # fcm keys are 153-175 chars - if len(gcm_key) < 100: - self.verb("Guessing this is legacy GCM...") - endpoint = "https://android.googleapis.com/gcm/send" - else: - self.verb("Guessing this is FCM...") - endpoint = "https://fcm.googleapis.com/fcm/send" - reg_ids = [] - if not reg_id: - reg_id = cast(str, self.subscription_info["endpoint"]).rsplit("/", 1)[ - -1 - ] - self.verb("Fetching out registration id: {}", reg_id) - reg_ids.append(reg_id) - gcm_data = dict() - gcm_data["registration_ids"] = reg_ids - if data: - buffer = encoded.get("body") - if buffer: - gcm_data["raw_data"] = base64.b64encode(buffer).decode("utf8") - gcm_data["time_to_live"] = int(headers["ttl"] if "ttl" in headers else ttl) - encoded_data = json.dumps(gcm_data) - headers.update( - { - "Authorization": "key=" + gcm_key, - "Content-Type": "application/json", - } - ) - else: - encoded_data = encoded.get("body") - endpoint = self.subscription_info["endpoint"] + encoded_data = encoded.get("body") + endpoint = self.subscription_info["endpoint"] if "ttl" not in headers or ttl: self.verb("Generating TTL of 0...") @@ -424,9 +381,10 @@ def send(self, *args, **kwargs) -> Union[Response, str]: **params, ) self.verb( - "\nResponse:\n\tcode: {}\n\tbody: {}\n", + "\nResponse:\n\tcode: {}\n\tbody: {}\n\theaders: {}", resp.status_code, resp.text or "Empty", + resp.headers or "None" ) return resp diff --git a/pywebpush/__main__.py b/pywebpush/__main__.py index 0a88859..c2f0270 100644 --- a/pywebpush/__main__.py +++ b/pywebpush/__main__.py @@ -2,6 +2,7 @@ import os import json import logging +import math from requests import JSONDecodeError @@ -15,6 +16,11 @@ def get_config(): parser.add_argument("--head", help="Header Info JSON file") parser.add_argument("--claims", help="Vapid claim file") parser.add_argument("--key", help="Vapid private key file path") + parser.add_argument( + "--wns", + help="Include WNS cache header based on TTL", + default=False, + action="store_true") parser.add_argument( "--curl", help="Don't send, display as curl command", @@ -53,6 +59,15 @@ def get_config(): args.head = json.loads(r.read()) except JSONDecodeError as e: raise WebPushException("Could not read the header arguments: {}", e) + # Set the default "TTL" + args.head["ttl"] = args.head.get("ttl", "0") + if args.wns: + # NOTE: Microsoft also requires `X-WNS-Type` as + # `tile`, `toast`, `badge` or `raw`. This is not provided by this code. + if int(args.head.get("ttl", "0")) > 0: + args.head["x-wns-cache-policy"] = "cache" + else: + args.head["x-wns-cache-policy"] = "no-cache" if args.claims: if not args.key: raise WebPushException("No private --key specified for claims") diff --git a/pywebpush/tests/test_webpush.py b/pywebpush/tests/test_webpush.py index 6f8378b..d358def 100644 --- a/pywebpush/tests/test_webpush.py +++ b/pywebpush/tests/test_webpush.py @@ -322,21 +322,6 @@ def test_ci_dict(self): del ci["FOO"] assert ci.get("Foo") is None - @patch("requests.post") - def test_gcm(self, mock_post): - subscription_info = self._gen_subscription_info( - None, endpoint="https://android.googleapis.com/gcm/send/regid123" - ) - headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} - data = "Mary had a little lamb" - wp = WebPusher(subscription_info) - wp.send(data, headers, gcm_key="gcm_key_value") - pdata = json.loads(mock_post.call_args[1].get("data")) - pheaders = mock_post.call_args[1].get("headers") - assert pdata["registration_ids"][0] == "regid123" - assert pheaders.get("authorization") == "key=gcm_key_value" - assert pheaders.get("content-type") == "application/json" - @patch("requests.post") def test_timeout(self, mock_post): mock_post.return_value.status_code = 200 @@ -399,21 +384,6 @@ async def test_send_no_headers(self, mock_post): assert pheaders.get("ttl") == "0" assert pheaders.get("content-encoding") == "aes128gcm" - @patch("aiohttp.ClientSession.post", new_callable=AsyncMock) - async def test_fcm(self, mock_post): - subscription_info = self._gen_subscription_info( - None, endpoint="https://android.googleapis.com/fcm/send/regid123" - ) - headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} - data = "Mary had a little lamb" - wp = WebPusher(subscription_info) - await wp.send_async(data, headers, gcm_key="gcm_key_value") - pdata = json.loads(mock_post.call_args[1].get("data")) - pheaders = mock_post.call_args[1].get("headers") - assert pdata["registration_ids"][0] == "regid123" - assert pheaders.get("authorization") == "key=gcm_key_value" - assert pheaders.get("content-type") == "application/json" - @patch("aiohttp.ClientSession.post", new_callable=AsyncMock) async def test_timeout(self, mock_post): mock_post.return_value.status_code = 200 diff --git a/setup.py b/setup.py index f8756d1..2b7825f 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ from setuptools import find_packages, setup -__version__ = "2.0.0" +__version__ = "2.1.0" def read_from(file):