pywebpush 2.1.0__1-py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pywebpush/__init__.py +726 -0
- pywebpush/__main__.py +93 -0
- pywebpush/foo.py +51 -0
- pywebpush/tests/__init__.py +0 -0
- pywebpush/tests/test_webpush.py +619 -0
- pywebpush-2.1.0.dist-info/METADATA +229 -0
- pywebpush-2.1.0.dist-info/RECORD +11 -0
- pywebpush-2.1.0.dist-info/WHEEL +5 -0
- pywebpush-2.1.0.dist-info/entry_points.txt +2 -0
- pywebpush-2.1.0.dist-info/licenses/LICENSE +373 -0
- pywebpush-2.1.0.dist-info/top_level.txt +1 -0
pywebpush/__init__.py
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
import logging
|
|
11
|
+
from copy import deepcopy
|
|
12
|
+
from typing import cast, Union, Dict
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from urlparse import urlparse
|
|
16
|
+
except ImportError: # pragma nocover
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
import aiohttp
|
|
20
|
+
import http_ece
|
|
21
|
+
import requests
|
|
22
|
+
import six
|
|
23
|
+
from cryptography.hazmat.backends import default_backend
|
|
24
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
25
|
+
from cryptography.hazmat.primitives import serialization
|
|
26
|
+
from functools import partial
|
|
27
|
+
from py_vapid import Vapid, Vapid01
|
|
28
|
+
from requests import Response
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WebPushException(Exception):
|
|
32
|
+
"""Web Push failure.
|
|
33
|
+
|
|
34
|
+
This may contain the requests.Response
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message, response=None):
|
|
39
|
+
self.message = message
|
|
40
|
+
self.response = response
|
|
41
|
+
|
|
42
|
+
def __str__(self):
|
|
43
|
+
extra = ""
|
|
44
|
+
if self.response is not None:
|
|
45
|
+
try:
|
|
46
|
+
extra = ", Response {}".format(
|
|
47
|
+
self.response.text,
|
|
48
|
+
)
|
|
49
|
+
except AttributeError:
|
|
50
|
+
extra = ", Response {}".format(self.response)
|
|
51
|
+
return "WebPushException: {}{}".format(self.message, extra)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NoData(Exception):
|
|
55
|
+
"""Message contained No Data, no encoding required."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CaseInsensitiveDict(dict):
|
|
59
|
+
"""A dictionary that has case-insensitive keys"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, data={}, **kwargs):
|
|
62
|
+
for key in data:
|
|
63
|
+
dict.__setitem__(self, key.lower(), data[key])
|
|
64
|
+
self.update(kwargs)
|
|
65
|
+
|
|
66
|
+
def __contains__(self, key):
|
|
67
|
+
return dict.__contains__(self, key.lower())
|
|
68
|
+
|
|
69
|
+
def __setitem__(self, key, value):
|
|
70
|
+
dict.__setitem__(self, key.lower(), value)
|
|
71
|
+
|
|
72
|
+
def __getitem__(self, key):
|
|
73
|
+
return dict.__getitem__(self, key.lower())
|
|
74
|
+
|
|
75
|
+
def __delitem__(self, key):
|
|
76
|
+
dict.__delitem__(self, key.lower())
|
|
77
|
+
|
|
78
|
+
def get(self, key, default=None):
|
|
79
|
+
try:
|
|
80
|
+
return self.__getitem__(key)
|
|
81
|
+
except KeyError:
|
|
82
|
+
return default
|
|
83
|
+
|
|
84
|
+
def update(self, data):
|
|
85
|
+
for key in data:
|
|
86
|
+
self.__setitem__(key, data[key])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class WebPusher:
|
|
90
|
+
"""WebPusher encrypts a data block using HTTP Encrypted Content Encoding
|
|
91
|
+
for WebPush.
|
|
92
|
+
|
|
93
|
+
See https://tools.ietf.org/html/draft-ietf-webpush-protocol-04
|
|
94
|
+
for the current specification, and
|
|
95
|
+
https://developer.mozilla.org/en-US/docs/Web/API/Push_API for an
|
|
96
|
+
overview of Web Push.
|
|
97
|
+
|
|
98
|
+
Example of use:
|
|
99
|
+
|
|
100
|
+
The javascript promise handler for PushManager.subscribe()
|
|
101
|
+
receives a subscription_info object. subscription_info.getJSON()
|
|
102
|
+
will return a JSON representation.
|
|
103
|
+
(e.g.
|
|
104
|
+
.. code-block:: javascript
|
|
105
|
+
subscription_info.getJSON() ==
|
|
106
|
+
{"endpoint": "https://push.server.com/...",
|
|
107
|
+
"keys":{"auth": "...", "p256dh": "..."}
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
This subscription_info block can be stored.
|
|
112
|
+
|
|
113
|
+
To send a subscription update:
|
|
114
|
+
|
|
115
|
+
.. code-block:: python
|
|
116
|
+
# Optional
|
|
117
|
+
# headers = py_vapid.sign({"aud": "https://push.server.com/",
|
|
118
|
+
"sub": "mailto:your_admin@your.site.com"})
|
|
119
|
+
data = "Mary had a little lamb, with a nice mint jelly"
|
|
120
|
+
WebPusher(subscription_info).send(data, headers)
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
subscription_info = {}
|
|
125
|
+
valid_encodings = [
|
|
126
|
+
# "aesgcm128", # this is draft-0, but DO NOT USE.
|
|
127
|
+
"aesgcm", # draft-httpbis-encryption-encoding-01
|
|
128
|
+
"aes128gcm", # RFC8188 Standard encoding
|
|
129
|
+
]
|
|
130
|
+
verbose = False
|
|
131
|
+
|
|
132
|
+
# Note: the type declarations are not valid under python 3.8,
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
subscription_info: Dict[
|
|
136
|
+
str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
|
|
137
|
+
],
|
|
138
|
+
requests_session: Union[None, requests.Session] = None,
|
|
139
|
+
aiohttp_session: Union[None, aiohttp.client.ClientSession] = None,
|
|
140
|
+
verbose: bool = False,
|
|
141
|
+
):
|
|
142
|
+
"""Initialize using the info provided by the client PushSubscription
|
|
143
|
+
object (See
|
|
144
|
+
https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)
|
|
145
|
+
|
|
146
|
+
:param subscription_info: a dict containing the subscription_info from
|
|
147
|
+
the client.
|
|
148
|
+
:type subscription_info: dict
|
|
149
|
+
|
|
150
|
+
:param requests_session: a requests.Session object to optimize requests
|
|
151
|
+
to the same client.
|
|
152
|
+
:type requests_session: requests.Session
|
|
153
|
+
|
|
154
|
+
:param verbose: provide verbose feedback
|
|
155
|
+
:type verbose: bool
|
|
156
|
+
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
self.verbose = verbose
|
|
160
|
+
if requests_session is None:
|
|
161
|
+
self.requests_method = requests
|
|
162
|
+
else:
|
|
163
|
+
self.requests_method = requests_session
|
|
164
|
+
|
|
165
|
+
self.aiohttp_session = aiohttp_session
|
|
166
|
+
|
|
167
|
+
if "endpoint" not in subscription_info:
|
|
168
|
+
raise WebPushException("subscription_info missing endpoint URL")
|
|
169
|
+
self.subscription_info = deepcopy(subscription_info)
|
|
170
|
+
self.auth_key = self.receiver_key = None
|
|
171
|
+
if "keys" in subscription_info:
|
|
172
|
+
keys: Dict[str, Union[str, bytes]] = cast(
|
|
173
|
+
Dict[str, Union[str, bytes]], self.subscription_info["keys"]
|
|
174
|
+
)
|
|
175
|
+
for k in ["p256dh", "auth"]:
|
|
176
|
+
if keys.get(k) is None:
|
|
177
|
+
raise WebPushException("Missing keys value: {}".format(k))
|
|
178
|
+
if isinstance(keys[k], six.text_type):
|
|
179
|
+
keys[k] = bytes(cast(str, keys[k]).encode("utf8"))
|
|
180
|
+
receiver_raw = base64.urlsafe_b64decode(
|
|
181
|
+
self._repad(cast(bytes, keys["p256dh"]))
|
|
182
|
+
)
|
|
183
|
+
if len(receiver_raw) != 65 and receiver_raw[0] != "\x04":
|
|
184
|
+
raise WebPushException("Invalid p256dh key specified")
|
|
185
|
+
self.receiver_key = receiver_raw
|
|
186
|
+
self.auth_key = base64.urlsafe_b64decode(
|
|
187
|
+
self._repad(cast(bytes, keys["auth"]))
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def verb(self, msg: str, *args, **kwargs):
|
|
191
|
+
if self.verbose:
|
|
192
|
+
logging.info(msg.format(*args, **kwargs))
|
|
193
|
+
|
|
194
|
+
def _repad(self, data: bytes):
|
|
195
|
+
"""Add base64 padding to the end of a string, if required"""
|
|
196
|
+
return data + b"===="[: len(data) % 4]
|
|
197
|
+
|
|
198
|
+
def encode(
|
|
199
|
+
self, data: bytes, content_encoding: str = "aes128gcm"
|
|
200
|
+
) -> CaseInsensitiveDict:
|
|
201
|
+
"""Encrypt the data.
|
|
202
|
+
|
|
203
|
+
:param data: A serialized block of byte data (String, JSON, bit array,
|
|
204
|
+
etc.) Make sure that whatever you send, your client knows how
|
|
205
|
+
to understand it.
|
|
206
|
+
:type data: str
|
|
207
|
+
:param content_encoding: The content_encoding type to use to encrypt
|
|
208
|
+
the data. Defaults to RFC8188 "aes128gcm". The previous draft-01 is
|
|
209
|
+
"aesgcm", however this format is now deprecated.
|
|
210
|
+
:type content_encoding: enum("aesgcm", "aes128gcm")
|
|
211
|
+
|
|
212
|
+
"""
|
|
213
|
+
reply = CaseInsensitiveDict()
|
|
214
|
+
# Salt is a random 16 byte array.
|
|
215
|
+
if not data:
|
|
216
|
+
self.verb("No data found...")
|
|
217
|
+
raise NoData()
|
|
218
|
+
if not self.auth_key or not self.receiver_key:
|
|
219
|
+
raise WebPushException("No keys specified in subscription info")
|
|
220
|
+
self.verb("Encoding data...")
|
|
221
|
+
salt = None
|
|
222
|
+
if content_encoding not in self.valid_encodings:
|
|
223
|
+
raise WebPushException(
|
|
224
|
+
"Invalid content encoding specified. "
|
|
225
|
+
"Select from " + json.dumps(self.valid_encodings)
|
|
226
|
+
)
|
|
227
|
+
if content_encoding == "aesgcm":
|
|
228
|
+
self.verb("Generating salt for aesgcm...")
|
|
229
|
+
salt = os.urandom(16)
|
|
230
|
+
logging.debug("Salt: {}".format(salt))
|
|
231
|
+
# The server key is an ephemeral ECDH key used only for this
|
|
232
|
+
# transaction
|
|
233
|
+
server_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
|
234
|
+
crypto_key = server_key.public_key().public_bytes(
|
|
235
|
+
encoding=serialization.Encoding.X962,
|
|
236
|
+
format=serialization.PublicFormat.UncompressedPoint,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if isinstance(data, six.text_type):
|
|
240
|
+
data = bytes(data.encode("utf8"))
|
|
241
|
+
if content_encoding == "aes128gcm":
|
|
242
|
+
self.verb("Encrypting to aes128gcm...")
|
|
243
|
+
encrypted = http_ece.encrypt(
|
|
244
|
+
data,
|
|
245
|
+
salt=salt,
|
|
246
|
+
private_key=server_key,
|
|
247
|
+
dh=self.receiver_key,
|
|
248
|
+
auth_secret=self.auth_key,
|
|
249
|
+
version=content_encoding,
|
|
250
|
+
)
|
|
251
|
+
reply["body"] = encrypted
|
|
252
|
+
else:
|
|
253
|
+
self.verb("Encrypting to aesgcm...")
|
|
254
|
+
crypto_key = base64.urlsafe_b64encode(crypto_key).strip(b"=")
|
|
255
|
+
encrypted = http_ece.encrypt(
|
|
256
|
+
data,
|
|
257
|
+
salt=salt,
|
|
258
|
+
private_key=server_key,
|
|
259
|
+
keyid=crypto_key.decode(),
|
|
260
|
+
dh=self.receiver_key,
|
|
261
|
+
auth_secret=self.auth_key,
|
|
262
|
+
version=content_encoding,
|
|
263
|
+
)
|
|
264
|
+
reply["crypto_key"] = crypto_key
|
|
265
|
+
reply["body"] = encrypted
|
|
266
|
+
if salt:
|
|
267
|
+
reply["salt"] = base64.urlsafe_b64encode(salt).strip(b"=")
|
|
268
|
+
return reply
|
|
269
|
+
|
|
270
|
+
def as_curl(self, endpoint: str, encoded_data: bytes, headers: Dict[str, str]):
|
|
271
|
+
"""Return the send as a curl command.
|
|
272
|
+
|
|
273
|
+
Useful for debugging. This will write out the encoded data to a local
|
|
274
|
+
file named `encrypted.data`
|
|
275
|
+
|
|
276
|
+
:param endpoint: Push service endpoint URL
|
|
277
|
+
:type endpoint: basestring
|
|
278
|
+
:param encoded_data: byte array of encoded data
|
|
279
|
+
:type encoded_data: bytearray
|
|
280
|
+
:param headers: Additional headers for the send
|
|
281
|
+
:type headers: dict
|
|
282
|
+
:returns string
|
|
283
|
+
|
|
284
|
+
"""
|
|
285
|
+
header_list = [
|
|
286
|
+
'-H "{}: {}" \\ \n'.format(key.lower(), val) for key, val in headers.items()
|
|
287
|
+
]
|
|
288
|
+
data = ""
|
|
289
|
+
if encoded_data:
|
|
290
|
+
with open("encrypted.data", "wb") as f:
|
|
291
|
+
f.write(encoded_data)
|
|
292
|
+
data = "--data-binary @encrypted.data"
|
|
293
|
+
if "content-length" not in headers:
|
|
294
|
+
self.verb("Generating content-length header...")
|
|
295
|
+
header_list.append(
|
|
296
|
+
'-H "content-length: {}" \\ \n'.format(len(encoded_data))
|
|
297
|
+
)
|
|
298
|
+
return """curl -vX POST {url} \\\n{headers}{data}""".format(
|
|
299
|
+
url=endpoint, headers="".join(header_list), data=data
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def _prepare_send_data(
|
|
303
|
+
self,
|
|
304
|
+
data: Union[None, bytes] = None,
|
|
305
|
+
headers: Union[None, Dict[str, str]] = None,
|
|
306
|
+
ttl: int = 0,
|
|
307
|
+
gcm_key: Union[None, str] = None,
|
|
308
|
+
reg_id: Union[None, str] = None,
|
|
309
|
+
content_encoding: str = "aes128gcm",
|
|
310
|
+
curl: bool = False,
|
|
311
|
+
) -> dict:
|
|
312
|
+
"""Encode and send the data to the Push Service.
|
|
313
|
+
|
|
314
|
+
:param data: A serialized block of data (see encode() ).
|
|
315
|
+
:type data: str
|
|
316
|
+
:param headers: A dictionary containing any additional HTTP headers.
|
|
317
|
+
:type headers: dict
|
|
318
|
+
:param ttl: The Time To Live in seconds for this message if the
|
|
319
|
+
recipient is not online. (Defaults to "0", which discards the
|
|
320
|
+
message immediately if the recipient is unavailable.)
|
|
321
|
+
:type ttl: int
|
|
322
|
+
:param gcm_key: API key obtained from the Google Developer Console.
|
|
323
|
+
Needed if endpoint is https://android.googleapis.com/gcm/send
|
|
324
|
+
:type gcm_key: string
|
|
325
|
+
:param reg_id: registration id of the recipient. If not provided,
|
|
326
|
+
it will be extracted from the endpoint.
|
|
327
|
+
:type reg_id: str
|
|
328
|
+
:param content_encoding: ECE content encoding (defaults to "aes128gcm")
|
|
329
|
+
:type content_encoding: str
|
|
330
|
+
:param curl: Display output as `curl` command instead of sending
|
|
331
|
+
:type curl: bool
|
|
332
|
+
"""
|
|
333
|
+
# Encode the data.
|
|
334
|
+
if headers is None:
|
|
335
|
+
headers = dict()
|
|
336
|
+
encoded = CaseInsensitiveDict()
|
|
337
|
+
headers = CaseInsensitiveDict(headers)
|
|
338
|
+
if data:
|
|
339
|
+
encoded = self.encode(data, content_encoding)
|
|
340
|
+
if "crypto_key" in encoded:
|
|
341
|
+
# Append the p256dh to the end of any existing crypto-key
|
|
342
|
+
crypto_key = headers.get("crypto-key", "")
|
|
343
|
+
if crypto_key:
|
|
344
|
+
# due to some confusion by a push service provider, we
|
|
345
|
+
# should use ';' instead of ',' to append the headers.
|
|
346
|
+
# see
|
|
347
|
+
# https://github.com/webpush-wg/webpush-encryption/issues/6
|
|
348
|
+
crypto_key += ";"
|
|
349
|
+
crypto_key += "dh=" + encoded["crypto_key"].decode("utf8")
|
|
350
|
+
headers.update({"crypto-key": crypto_key})
|
|
351
|
+
if "salt" in encoded:
|
|
352
|
+
headers.update({"encryption": "salt=" + encoded["salt"].decode("utf8")})
|
|
353
|
+
headers.update(
|
|
354
|
+
{
|
|
355
|
+
"content-encoding": content_encoding,
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
if gcm_key:
|
|
359
|
+
# guess if it is a legacy GCM project key or actual FCM key
|
|
360
|
+
# gcm keys are all about 40 chars (use 100 for confidence),
|
|
361
|
+
# fcm keys are 153-175 chars
|
|
362
|
+
if len(gcm_key) < 100:
|
|
363
|
+
self.verb("Guessing this is legacy GCM...")
|
|
364
|
+
endpoint = "https://android.googleapis.com/gcm/send"
|
|
365
|
+
else:
|
|
366
|
+
self.verb("Guessing this is FCM...")
|
|
367
|
+
endpoint = "https://fcm.googleapis.com/fcm/send"
|
|
368
|
+
reg_ids = []
|
|
369
|
+
if not reg_id:
|
|
370
|
+
reg_id = cast(str, self.subscription_info["endpoint"]).rsplit("/", 1)[
|
|
371
|
+
-1
|
|
372
|
+
]
|
|
373
|
+
self.verb("Fetching out registration id: {}", reg_id)
|
|
374
|
+
reg_ids.append(reg_id)
|
|
375
|
+
gcm_data = dict()
|
|
376
|
+
gcm_data["registration_ids"] = reg_ids
|
|
377
|
+
if data:
|
|
378
|
+
buffer = encoded.get("body")
|
|
379
|
+
if buffer:
|
|
380
|
+
gcm_data["raw_data"] = base64.b64encode(buffer).decode("utf8")
|
|
381
|
+
gcm_data["time_to_live"] = int(headers["ttl"] if "ttl" in headers else ttl)
|
|
382
|
+
encoded_data = json.dumps(gcm_data)
|
|
383
|
+
headers.update(
|
|
384
|
+
{
|
|
385
|
+
"Authorization": "key=" + gcm_key,
|
|
386
|
+
"Content-Type": "application/json",
|
|
387
|
+
}
|
|
388
|
+
)
|
|
389
|
+
else:
|
|
390
|
+
encoded_data = encoded.get("body")
|
|
391
|
+
endpoint = self.subscription_info["endpoint"]
|
|
392
|
+
|
|
393
|
+
if "ttl" not in headers or ttl:
|
|
394
|
+
self.verb("Generating TTL of 0...")
|
|
395
|
+
headers["ttl"] = str(ttl or 0)
|
|
396
|
+
# Additionally useful headers:
|
|
397
|
+
# Authorization / Crypto-Key (VAPID headers)
|
|
398
|
+
|
|
399
|
+
self.verb(
|
|
400
|
+
"\nSending request to" "\n\thost: {}\n\theaders: {}\n\tdata: {}",
|
|
401
|
+
endpoint,
|
|
402
|
+
headers,
|
|
403
|
+
encoded_data,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
return {"endpoint": endpoint, "data": encoded_data, "headers": headers}
|
|
407
|
+
|
|
408
|
+
def send(self, *args, **kwargs) -> Union[Response, str]:
|
|
409
|
+
"""Encode and send the data to the Push Service"""
|
|
410
|
+
timeout = kwargs.pop("timeout", 10000)
|
|
411
|
+
curl = kwargs.pop("curl", False)
|
|
412
|
+
|
|
413
|
+
params = self._prepare_send_data(*args, **kwargs)
|
|
414
|
+
endpoint = params.pop("endpoint")
|
|
415
|
+
|
|
416
|
+
if curl:
|
|
417
|
+
encoded_data = params["data"]
|
|
418
|
+
headers = params["headers"]
|
|
419
|
+
return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers)
|
|
420
|
+
|
|
421
|
+
resp = self.requests_method.post(
|
|
422
|
+
endpoint,
|
|
423
|
+
timeout=timeout,
|
|
424
|
+
**params,
|
|
425
|
+
)
|
|
426
|
+
self.verb(
|
|
427
|
+
"\nResponse:\n\tcode: {}\n\tbody: {}\n",
|
|
428
|
+
resp.status_code,
|
|
429
|
+
resp.text or "Empty",
|
|
430
|
+
)
|
|
431
|
+
return resp
|
|
432
|
+
|
|
433
|
+
async def send_async(self, *args, **kwargs) -> Union[aiohttp.ClientResponse, str]:
|
|
434
|
+
timeout = kwargs.pop("timeout", 10000)
|
|
435
|
+
curl = kwargs.pop("curl", False)
|
|
436
|
+
|
|
437
|
+
params = self._prepare_send_data(*args, **kwargs)
|
|
438
|
+
endpoint = params.pop("endpoint")
|
|
439
|
+
|
|
440
|
+
if curl:
|
|
441
|
+
encoded_data = params["data"]
|
|
442
|
+
headers = params["headers"]
|
|
443
|
+
return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers)
|
|
444
|
+
if self.aiohttp_session:
|
|
445
|
+
resp = await self.aiohttp_session.post(endpoint, timeout=timeout, **params)
|
|
446
|
+
resp_text = await resp.text()
|
|
447
|
+
else:
|
|
448
|
+
async with aiohttp.ClientSession() as session:
|
|
449
|
+
resp = await session.post(endpoint, timeout=timeout, **params)
|
|
450
|
+
resp_text = await resp.text()
|
|
451
|
+
self.verb(
|
|
452
|
+
"\nResponse:\n\tcode: {}\n\tbody: {}\n",
|
|
453
|
+
resp.status,
|
|
454
|
+
resp_text or "Empty",
|
|
455
|
+
)
|
|
456
|
+
return resp
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def webpush(
|
|
460
|
+
subscription_info: Dict[
|
|
461
|
+
str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
|
|
462
|
+
],
|
|
463
|
+
data: Union[None, str] = None,
|
|
464
|
+
vapid_private_key: Union[None, Vapid, str] = None,
|
|
465
|
+
vapid_claims: Union[None, Dict[str, Union[str, int]]] = None,
|
|
466
|
+
content_encoding: str = "aes128gcm",
|
|
467
|
+
curl: bool = False,
|
|
468
|
+
timeout: Union[None, float] = None,
|
|
469
|
+
ttl: int = 0,
|
|
470
|
+
verbose: bool = False,
|
|
471
|
+
headers: Union[None, Dict[str, Union[str, int, float]]] = None,
|
|
472
|
+
requests_session: Union[None, requests.Session] = None,
|
|
473
|
+
) -> Union[str, requests.Response]:
|
|
474
|
+
"""
|
|
475
|
+
One call solution to endcode and send `data` to the endpoint
|
|
476
|
+
contained in `subscription_info` using optional VAPID auth headers.
|
|
477
|
+
|
|
478
|
+
in example:
|
|
479
|
+
|
|
480
|
+
.. code-block:: python
|
|
481
|
+
|
|
482
|
+
from pywebpush import python
|
|
483
|
+
|
|
484
|
+
webpush(
|
|
485
|
+
subscription_info={
|
|
486
|
+
"endpoint": "https://push.example.com/v1/abcd",
|
|
487
|
+
"keys": {"p256dh": "0123abcd...",
|
|
488
|
+
"auth": "001122..."}
|
|
489
|
+
},
|
|
490
|
+
data="Mary had a little lamb, with a nice mint jelly",
|
|
491
|
+
vapid_private_key="path/to/key.pem",
|
|
492
|
+
vapid_claims={"sub": "YourNameHere@example.com"}
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
No additional method call is required. Any non-success will throw a
|
|
496
|
+
`WebPushException`.
|
|
497
|
+
|
|
498
|
+
:param subscription_info: Provided by the client call
|
|
499
|
+
:type subscription_info: dict
|
|
500
|
+
:param data: Serialized data to send
|
|
501
|
+
:type data: str
|
|
502
|
+
:param vapid_private_key: Vapid instance or path to vapid private key PEM \
|
|
503
|
+
or encoded str
|
|
504
|
+
:type vapid_private_key: Union[Vapid, str]
|
|
505
|
+
:param vapid_claims: Dictionary of claims ('sub' required)
|
|
506
|
+
:type vapid_claims: dict
|
|
507
|
+
:param content_encoding: Optional content type string
|
|
508
|
+
:type content_encoding: str
|
|
509
|
+
:param curl: Return as "curl" string instead of sending
|
|
510
|
+
:type curl: bool
|
|
511
|
+
:param timeout: POST requests timeout
|
|
512
|
+
:type timeout: float
|
|
513
|
+
:param ttl: Time To Live
|
|
514
|
+
:type ttl: int
|
|
515
|
+
:param verbose: Provide verbose feedback
|
|
516
|
+
:type verbose: bool
|
|
517
|
+
:return requests.Response or string
|
|
518
|
+
:param headers: Dictionary of extra HTTP headers to include
|
|
519
|
+
:type headers: dict
|
|
520
|
+
|
|
521
|
+
"""
|
|
522
|
+
if headers is None:
|
|
523
|
+
headers = dict()
|
|
524
|
+
else:
|
|
525
|
+
# Ensure we don't leak VAPID headers by mutating the passed in dict.
|
|
526
|
+
headers = headers.copy()
|
|
527
|
+
|
|
528
|
+
vapid_headers = None
|
|
529
|
+
if vapid_claims:
|
|
530
|
+
if verbose:
|
|
531
|
+
logging.info("Generating VAPID headers...")
|
|
532
|
+
if not vapid_claims.get("aud"):
|
|
533
|
+
url = urlparse(cast(str, subscription_info.get("endpoint")))
|
|
534
|
+
aud = "{}://{}".format(url.scheme, url.netloc)
|
|
535
|
+
vapid_claims["aud"] = aud
|
|
536
|
+
# Remember, passed structures are mutable in python.
|
|
537
|
+
# It's possible that a previously set `exp` field is no longer valid.
|
|
538
|
+
if not vapid_claims.get("exp") or int(vapid_claims.get("exp") or 0) < int(
|
|
539
|
+
time.time()
|
|
540
|
+
):
|
|
541
|
+
# encryption lives for 12 hours
|
|
542
|
+
vapid_claims["exp"] = int(time.time()) + (12 * 60 * 60)
|
|
543
|
+
if verbose:
|
|
544
|
+
logging.info("Setting VAPID expry to {}...".format(vapid_claims["exp"]))
|
|
545
|
+
if not vapid_private_key:
|
|
546
|
+
raise WebPushException("VAPID dict missing 'private_key'")
|
|
547
|
+
if isinstance(vapid_private_key, Vapid01):
|
|
548
|
+
if verbose:
|
|
549
|
+
logging.info("Looks like we already have a valid VAPID key")
|
|
550
|
+
vv = vapid_private_key
|
|
551
|
+
elif os.path.isfile(vapid_private_key):
|
|
552
|
+
# Presume that key from file is handled correctly by
|
|
553
|
+
# py_vapid.
|
|
554
|
+
if verbose:
|
|
555
|
+
logging.info("Reading VAPID key from file {}".format(vapid_private_key))
|
|
556
|
+
vv = Vapid.from_file(private_key_file=vapid_private_key) # pragma no cover
|
|
557
|
+
else:
|
|
558
|
+
if verbose:
|
|
559
|
+
logging.info("Reading VAPID key from arguments")
|
|
560
|
+
vv = Vapid.from_string(private_key=vapid_private_key)
|
|
561
|
+
if verbose:
|
|
562
|
+
logging.info("\t claims: {}".format(vapid_claims))
|
|
563
|
+
vapid_headers = vv.sign(vapid_claims)
|
|
564
|
+
if verbose:
|
|
565
|
+
logging.info("\t headers: {}".format(vapid_headers))
|
|
566
|
+
headers.update(vapid_headers)
|
|
567
|
+
|
|
568
|
+
response = WebPusher(
|
|
569
|
+
subscription_info, requests_session=requests_session, verbose=verbose
|
|
570
|
+
).send(
|
|
571
|
+
data,
|
|
572
|
+
headers,
|
|
573
|
+
ttl=ttl,
|
|
574
|
+
content_encoding=content_encoding,
|
|
575
|
+
curl=curl,
|
|
576
|
+
timeout=timeout,
|
|
577
|
+
)
|
|
578
|
+
if not curl and cast(Response, response).status_code > 202:
|
|
579
|
+
response = cast(Response, response)
|
|
580
|
+
raise WebPushException(
|
|
581
|
+
"Push failed: {} {}\nResponse body:{}".format(
|
|
582
|
+
response.status_code, response.reason, response.text
|
|
583
|
+
),
|
|
584
|
+
response=response,
|
|
585
|
+
)
|
|
586
|
+
return response
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
async def webpush_async(
|
|
590
|
+
subscription_info: Dict[
|
|
591
|
+
str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
|
|
592
|
+
],
|
|
593
|
+
data: Union[None, str] = None,
|
|
594
|
+
vapid_private_key: Union[None, Vapid, str] = None,
|
|
595
|
+
vapid_claims: Union[None, Dict[str, Union[str, int]]] = None,
|
|
596
|
+
content_encoding: str = "aes128gcm",
|
|
597
|
+
curl: bool = False,
|
|
598
|
+
timeout: Union[None, float] = None,
|
|
599
|
+
ttl: int = 0,
|
|
600
|
+
verbose: bool = False,
|
|
601
|
+
headers: Union[None, Dict[str, Union[str, int, float]]] = None,
|
|
602
|
+
aiohttp_session: Union[None, aiohttp.ClientSession] = None,
|
|
603
|
+
) -> Union[str, aiohttp.ClientResponse]:
|
|
604
|
+
"""
|
|
605
|
+
Async version of webpush function. One call solution to encode and send
|
|
606
|
+
`data` to the endpoint contained in `subscription_info` using optional
|
|
607
|
+
VAPID auth headers.
|
|
608
|
+
|
|
609
|
+
Example:
|
|
610
|
+
|
|
611
|
+
.. code-block:: python
|
|
612
|
+
|
|
613
|
+
from pywebpush import webpush_async
|
|
614
|
+
import asyncio
|
|
615
|
+
|
|
616
|
+
async def send_notification():
|
|
617
|
+
response = await webpush_async(
|
|
618
|
+
subscription_info={
|
|
619
|
+
"endpoint": "https://push.example.com/v1/abcd",
|
|
620
|
+
"keys": {"p256dh": "0123abcd...",
|
|
621
|
+
"auth": "001122..."}
|
|
622
|
+
},
|
|
623
|
+
data="Mary had a little lamb, with a nice mint jelly",
|
|
624
|
+
vapid_private_key="path/to/key.pem",
|
|
625
|
+
vapid_claims={"sub": "YourNameHere@example.com"}
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
asyncio.run(send_notification())
|
|
629
|
+
|
|
630
|
+
No additional method call is required. Any non-success will throw a
|
|
631
|
+
`WebPushException`.
|
|
632
|
+
|
|
633
|
+
:param subscription_info: Provided by the client call
|
|
634
|
+
:type subscription_info: dict
|
|
635
|
+
:param data: Serialized data to send
|
|
636
|
+
:type data: str
|
|
637
|
+
:param vapid_private_key: Vapid instance or path to vapid private key PEM \
|
|
638
|
+
or encoded str
|
|
639
|
+
:type vapid_private_key: Union[Vapid, str]
|
|
640
|
+
:param vapid_claims: Dictionary of claims ('sub' required)
|
|
641
|
+
:type vapid_claims: dict
|
|
642
|
+
:param content_encoding: Optional content type string
|
|
643
|
+
:type content_encoding: str
|
|
644
|
+
:param curl: Return as "curl" string instead of sending
|
|
645
|
+
:type curl: bool
|
|
646
|
+
:param timeout: POST requests timeout
|
|
647
|
+
:type timeout: float
|
|
648
|
+
:param ttl: Time To Live
|
|
649
|
+
:type ttl: int
|
|
650
|
+
:param verbose: Provide verbose feedback
|
|
651
|
+
:type verbose: bool
|
|
652
|
+
:param headers: Dictionary of extra HTTP headers to include
|
|
653
|
+
:type headers: dict
|
|
654
|
+
:param aiohttp_session: Optional aiohttp ClientSession for connection reuse
|
|
655
|
+
:type aiohttp_session: aiohttp.ClientSession
|
|
656
|
+
:return aiohttp.ClientResponse or string
|
|
657
|
+
|
|
658
|
+
"""
|
|
659
|
+
if headers is None:
|
|
660
|
+
headers = dict()
|
|
661
|
+
else:
|
|
662
|
+
# Ensure we don't leak VAPID headers by mutating the passed in dict.
|
|
663
|
+
headers = headers.copy()
|
|
664
|
+
|
|
665
|
+
vapid_headers = None
|
|
666
|
+
if vapid_claims:
|
|
667
|
+
if verbose:
|
|
668
|
+
logging.info("Generating VAPID headers...")
|
|
669
|
+
if not vapid_claims.get("aud"):
|
|
670
|
+
url = urlparse(cast(str, subscription_info.get("endpoint")))
|
|
671
|
+
aud = "{}://{}".format(url.scheme, url.netloc)
|
|
672
|
+
vapid_claims["aud"] = aud
|
|
673
|
+
# Remember, passed structures are mutable in python.
|
|
674
|
+
# It's possible that a previously set `exp` field is no longer valid.
|
|
675
|
+
if not vapid_claims.get("exp") or int(vapid_claims.get("exp") or 0) < int(
|
|
676
|
+
time.time()
|
|
677
|
+
):
|
|
678
|
+
# encryption lives for 12 hours
|
|
679
|
+
vapid_claims["exp"] = int(time.time()) + (12 * 60 * 60)
|
|
680
|
+
if verbose:
|
|
681
|
+
logging.info(
|
|
682
|
+
"Setting VAPID expiry to {}...".format(vapid_claims["exp"])
|
|
683
|
+
)
|
|
684
|
+
if not vapid_private_key:
|
|
685
|
+
raise WebPushException("VAPID dict missing 'private_key'")
|
|
686
|
+
if isinstance(vapid_private_key, Vapid01):
|
|
687
|
+
if verbose:
|
|
688
|
+
logging.info("Looks like we already have a valid VAPID key")
|
|
689
|
+
vv = vapid_private_key
|
|
690
|
+
elif os.path.isfile(vapid_private_key):
|
|
691
|
+
# Presume that key from file is handled correctly by
|
|
692
|
+
# py_vapid.
|
|
693
|
+
if verbose:
|
|
694
|
+
logging.info("Reading VAPID key from file {}".format(vapid_private_key))
|
|
695
|
+
vv = Vapid.from_file(private_key_file=vapid_private_key) # pragma no cover
|
|
696
|
+
else:
|
|
697
|
+
if verbose:
|
|
698
|
+
logging.info("Reading VAPID key from arguments")
|
|
699
|
+
vv = Vapid.from_string(private_key=vapid_private_key)
|
|
700
|
+
if verbose:
|
|
701
|
+
logging.info("\t claims: {}".format(vapid_claims))
|
|
702
|
+
vapid_headers = vv.sign(vapid_claims)
|
|
703
|
+
if verbose:
|
|
704
|
+
logging.info("\t headers: {}".format(vapid_headers))
|
|
705
|
+
headers.update(vapid_headers)
|
|
706
|
+
|
|
707
|
+
response = await WebPusher(
|
|
708
|
+
subscription_info, aiohttp_session=aiohttp_session, verbose=verbose
|
|
709
|
+
).send_async(
|
|
710
|
+
data,
|
|
711
|
+
headers,
|
|
712
|
+
ttl=ttl,
|
|
713
|
+
content_encoding=content_encoding,
|
|
714
|
+
curl=curl,
|
|
715
|
+
timeout=timeout,
|
|
716
|
+
)
|
|
717
|
+
if not curl and cast(aiohttp.ClientResponse, response).status > 202:
|
|
718
|
+
response = cast(aiohttp.ClientResponse, response)
|
|
719
|
+
response_text = await response.text()
|
|
720
|
+
raise WebPushException(
|
|
721
|
+
"Push failed: {} {}\nResponse body:{}".format(
|
|
722
|
+
response.status, response.reason, response_text
|
|
723
|
+
),
|
|
724
|
+
response=response,
|
|
725
|
+
)
|
|
726
|
+
return response
|