Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add WNS comment, drop GCM/FCM direct support #163

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
60 changes: 49 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).

Expand All @@ -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
Expand Down Expand Up @@ -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`

Expand All @@ -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")
Expand All @@ -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")`
Expand All @@ -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.

Expand All @@ -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:

Expand All @@ -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.
50 changes: 4 additions & 46 deletions pywebpush/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand All @@ -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...")
Expand Down Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions pywebpush/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import json
import logging
import math

from requests import JSONDecodeError

Expand All @@ -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",
Expand Down Expand Up @@ -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")
Expand Down
30 changes: 0 additions & 30 deletions pywebpush/tests/test_webpush.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from setuptools import find_packages, setup

__version__ = "2.0.0"
__version__ = "2.1.0"


def read_from(file):
Expand Down