Benachrichtigungen: Web Push und E-Mail als Kanaele der Automatiken
Neuer BenachrichtigungTransport hinter der Aktor-URL "Benachrichtigung": push schickt an alle angemeldeten Geraete (homeMesh.push_abos), mail ueber den SMTP-Zugang aus der config.ini. Ein Abo, das der Push-Dienst mit 404 oder 410 ablehnt, wird geloescht statt weiter angeschrieben. Daneben hoert der Runner auf benachrichtigung/# - darueber schickt die Probe aus den Einstellungen, ohne dass die Weboberflaeche verschluesseln muesste. Zugestellt wird im Haupttakt, nicht im MQTT-Faden. pywebpush, py_vapid und http_ece liegen wie die uebrigen Fremdpakete im Ordner, nicht in site-packages; transports.py haengt ihn an den Suchpfad. Das Schluesselpaar erzeugt vapid_erzeugen.py und bleibt ausserhalb des Git. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,726 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from typing import cast, Union, Dict
|
||||
|
||||
try:
|
||||
from urlparse import urlparse
|
||||
except ImportError: # pragma nocover
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
import http_ece
|
||||
import requests
|
||||
import six
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from functools import partial
|
||||
from py_vapid import Vapid, Vapid01
|
||||
from requests import Response
|
||||
|
||||
|
||||
class WebPushException(Exception):
|
||||
"""Web Push failure.
|
||||
|
||||
This may contain the requests.Response
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, message, response=None):
|
||||
self.message = message
|
||||
self.response = response
|
||||
|
||||
def __str__(self):
|
||||
extra = ""
|
||||
if self.response is not None:
|
||||
try:
|
||||
extra = ", Response {}".format(
|
||||
self.response.text,
|
||||
)
|
||||
except AttributeError:
|
||||
extra = ", Response {}".format(self.response)
|
||||
return "WebPushException: {}{}".format(self.message, extra)
|
||||
|
||||
|
||||
class NoData(Exception):
|
||||
"""Message contained No Data, no encoding required."""
|
||||
|
||||
|
||||
class CaseInsensitiveDict(dict):
|
||||
"""A dictionary that has case-insensitive keys"""
|
||||
|
||||
def __init__(self, data={}, **kwargs):
|
||||
for key in data:
|
||||
dict.__setitem__(self, key.lower(), data[key])
|
||||
self.update(kwargs)
|
||||
|
||||
def __contains__(self, key):
|
||||
return dict.__contains__(self, key.lower())
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
dict.__setitem__(self, key.lower(), value)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return dict.__getitem__(self, key.lower())
|
||||
|
||||
def __delitem__(self, key):
|
||||
dict.__delitem__(self, key.lower())
|
||||
|
||||
def get(self, key, default=None):
|
||||
try:
|
||||
return self.__getitem__(key)
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def update(self, data):
|
||||
for key in data:
|
||||
self.__setitem__(key, data[key])
|
||||
|
||||
|
||||
class WebPusher:
|
||||
"""WebPusher encrypts a data block using HTTP Encrypted Content Encoding
|
||||
for WebPush.
|
||||
|
||||
See https://tools.ietf.org/html/draft-ietf-webpush-protocol-04
|
||||
for the current specification, and
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/Push_API for an
|
||||
overview of Web Push.
|
||||
|
||||
Example of use:
|
||||
|
||||
The javascript promise handler for PushManager.subscribe()
|
||||
receives a subscription_info object. subscription_info.getJSON()
|
||||
will return a JSON representation.
|
||||
(e.g.
|
||||
.. code-block:: javascript
|
||||
subscription_info.getJSON() ==
|
||||
{"endpoint": "https://push.server.com/...",
|
||||
"keys":{"auth": "...", "p256dh": "..."}
|
||||
}
|
||||
)
|
||||
|
||||
This subscription_info block can be stored.
|
||||
|
||||
To send a subscription update:
|
||||
|
||||
.. code-block:: python
|
||||
# Optional
|
||||
# headers = py_vapid.sign({"aud": "https://push.server.com/",
|
||||
"sub": "mailto:your_admin@your.site.com"})
|
||||
data = "Mary had a little lamb, with a nice mint jelly"
|
||||
WebPusher(subscription_info).send(data, headers)
|
||||
|
||||
"""
|
||||
|
||||
subscription_info = {}
|
||||
valid_encodings = [
|
||||
# "aesgcm128", # this is draft-0, but DO NOT USE.
|
||||
"aesgcm", # draft-httpbis-encryption-encoding-01
|
||||
"aes128gcm", # RFC8188 Standard encoding
|
||||
]
|
||||
verbose = False
|
||||
|
||||
# Note: the type declarations are not valid under python 3.8,
|
||||
def __init__(
|
||||
self,
|
||||
subscription_info: Dict[
|
||||
str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
|
||||
],
|
||||
requests_session: Union[None, requests.Session] = None,
|
||||
aiohttp_session: Union[None, aiohttp.client.ClientSession] = None,
|
||||
verbose: bool = False,
|
||||
):
|
||||
"""Initialize using the info provided by the client PushSubscription
|
||||
object (See
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)
|
||||
|
||||
:param subscription_info: a dict containing the subscription_info from
|
||||
the client.
|
||||
:type subscription_info: dict
|
||||
|
||||
:param requests_session: a requests.Session object to optimize requests
|
||||
to the same client.
|
||||
:type requests_session: requests.Session
|
||||
|
||||
:param verbose: provide verbose feedback
|
||||
:type verbose: bool
|
||||
|
||||
"""
|
||||
|
||||
self.verbose = verbose
|
||||
if requests_session is None:
|
||||
self.requests_method = requests
|
||||
else:
|
||||
self.requests_method = requests_session
|
||||
|
||||
self.aiohttp_session = aiohttp_session
|
||||
|
||||
if "endpoint" not in subscription_info:
|
||||
raise WebPushException("subscription_info missing endpoint URL")
|
||||
self.subscription_info = deepcopy(subscription_info)
|
||||
self.auth_key = self.receiver_key = None
|
||||
if "keys" in subscription_info:
|
||||
keys: Dict[str, Union[str, bytes]] = cast(
|
||||
Dict[str, Union[str, bytes]], self.subscription_info["keys"]
|
||||
)
|
||||
for k in ["p256dh", "auth"]:
|
||||
if keys.get(k) is None:
|
||||
raise WebPushException("Missing keys value: {}".format(k))
|
||||
if isinstance(keys[k], six.text_type):
|
||||
keys[k] = bytes(cast(str, keys[k]).encode("utf8"))
|
||||
receiver_raw = base64.urlsafe_b64decode(
|
||||
self._repad(cast(bytes, keys["p256dh"]))
|
||||
)
|
||||
if len(receiver_raw) != 65 and receiver_raw[0] != "\x04":
|
||||
raise WebPushException("Invalid p256dh key specified")
|
||||
self.receiver_key = receiver_raw
|
||||
self.auth_key = base64.urlsafe_b64decode(
|
||||
self._repad(cast(bytes, keys["auth"]))
|
||||
)
|
||||
|
||||
def verb(self, msg: str, *args, **kwargs):
|
||||
if self.verbose:
|
||||
logging.info(msg.format(*args, **kwargs))
|
||||
|
||||
def _repad(self, data: bytes):
|
||||
"""Add base64 padding to the end of a string, if required"""
|
||||
return data + b"===="[: len(data) % 4]
|
||||
|
||||
def encode(
|
||||
self, data: bytes, content_encoding: str = "aes128gcm"
|
||||
) -> CaseInsensitiveDict:
|
||||
"""Encrypt the data.
|
||||
|
||||
:param data: A serialized block of byte data (String, JSON, bit array,
|
||||
etc.) Make sure that whatever you send, your client knows how
|
||||
to understand it.
|
||||
:type data: str
|
||||
:param content_encoding: The content_encoding type to use to encrypt
|
||||
the data. Defaults to RFC8188 "aes128gcm". The previous draft-01 is
|
||||
"aesgcm", however this format is now deprecated.
|
||||
:type content_encoding: enum("aesgcm", "aes128gcm")
|
||||
|
||||
"""
|
||||
reply = CaseInsensitiveDict()
|
||||
# Salt is a random 16 byte array.
|
||||
if not data:
|
||||
self.verb("No data found...")
|
||||
raise NoData()
|
||||
if not self.auth_key or not self.receiver_key:
|
||||
raise WebPushException("No keys specified in subscription info")
|
||||
self.verb("Encoding data...")
|
||||
salt = None
|
||||
if content_encoding not in self.valid_encodings:
|
||||
raise WebPushException(
|
||||
"Invalid content encoding specified. "
|
||||
"Select from " + json.dumps(self.valid_encodings)
|
||||
)
|
||||
if content_encoding == "aesgcm":
|
||||
self.verb("Generating salt for aesgcm...")
|
||||
salt = os.urandom(16)
|
||||
logging.debug("Salt: {}".format(salt))
|
||||
# The server key is an ephemeral ECDH key used only for this
|
||||
# transaction
|
||||
server_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
crypto_key = server_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
|
||||
if isinstance(data, six.text_type):
|
||||
data = bytes(data.encode("utf8"))
|
||||
if content_encoding == "aes128gcm":
|
||||
self.verb("Encrypting to aes128gcm...")
|
||||
encrypted = http_ece.encrypt(
|
||||
data,
|
||||
salt=salt,
|
||||
private_key=server_key,
|
||||
dh=self.receiver_key,
|
||||
auth_secret=self.auth_key,
|
||||
version=content_encoding,
|
||||
)
|
||||
reply["body"] = encrypted
|
||||
else:
|
||||
self.verb("Encrypting to aesgcm...")
|
||||
crypto_key = base64.urlsafe_b64encode(crypto_key).strip(b"=")
|
||||
encrypted = http_ece.encrypt(
|
||||
data,
|
||||
salt=salt,
|
||||
private_key=server_key,
|
||||
keyid=crypto_key.decode(),
|
||||
dh=self.receiver_key,
|
||||
auth_secret=self.auth_key,
|
||||
version=content_encoding,
|
||||
)
|
||||
reply["crypto_key"] = crypto_key
|
||||
reply["body"] = encrypted
|
||||
if salt:
|
||||
reply["salt"] = base64.urlsafe_b64encode(salt).strip(b"=")
|
||||
return reply
|
||||
|
||||
def as_curl(self, endpoint: str, encoded_data: bytes, headers: Dict[str, str]):
|
||||
"""Return the send as a curl command.
|
||||
|
||||
Useful for debugging. This will write out the encoded data to a local
|
||||
file named `encrypted.data`
|
||||
|
||||
:param endpoint: Push service endpoint URL
|
||||
:type endpoint: basestring
|
||||
:param encoded_data: byte array of encoded data
|
||||
:type encoded_data: bytearray
|
||||
:param headers: Additional headers for the send
|
||||
:type headers: dict
|
||||
:returns string
|
||||
|
||||
"""
|
||||
header_list = [
|
||||
'-H "{}: {}" \\ \n'.format(key.lower(), val) for key, val in headers.items()
|
||||
]
|
||||
data = ""
|
||||
if encoded_data:
|
||||
with open("encrypted.data", "wb") as f:
|
||||
f.write(encoded_data)
|
||||
data = "--data-binary @encrypted.data"
|
||||
if "content-length" not in headers:
|
||||
self.verb("Generating content-length header...")
|
||||
header_list.append(
|
||||
'-H "content-length: {}" \\ \n'.format(len(encoded_data))
|
||||
)
|
||||
return """curl -vX POST {url} \\\n{headers}{data}""".format(
|
||||
url=endpoint, headers="".join(header_list), data=data
|
||||
)
|
||||
|
||||
def _prepare_send_data(
|
||||
self,
|
||||
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.
|
||||
|
||||
:param data: A serialized block of data (see encode() ).
|
||||
:type data: str
|
||||
:param headers: A dictionary containing any additional HTTP headers.
|
||||
:type headers: dict
|
||||
:param ttl: The Time To Live in seconds for this message if the
|
||||
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:
|
||||
headers = dict()
|
||||
encoded = CaseInsensitiveDict()
|
||||
headers = CaseInsensitiveDict(headers)
|
||||
if data:
|
||||
encoded = self.encode(data, content_encoding)
|
||||
if "crypto_key" in encoded:
|
||||
# Append the p256dh to the end of any existing crypto-key
|
||||
crypto_key = headers.get("crypto-key", "")
|
||||
if crypto_key:
|
||||
# due to some confusion by a push service provider, we
|
||||
# should use ';' instead of ',' to append the headers.
|
||||
# see
|
||||
# https://github.com/webpush-wg/webpush-encryption/issues/6
|
||||
crypto_key += ";"
|
||||
crypto_key += "dh=" + encoded["crypto_key"].decode("utf8")
|
||||
headers.update({"crypto-key": crypto_key})
|
||||
if "salt" in encoded:
|
||||
headers.update({"encryption": "salt=" + encoded["salt"].decode("utf8")})
|
||||
headers.update(
|
||||
{
|
||||
"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"]
|
||||
|
||||
if "ttl" not in headers or ttl:
|
||||
self.verb("Generating TTL of 0...")
|
||||
headers["ttl"] = str(ttl or 0)
|
||||
# Additionally useful headers:
|
||||
# Authorization / Crypto-Key (VAPID headers)
|
||||
|
||||
self.verb(
|
||||
"\nSending request to" "\n\thost: {}\n\theaders: {}\n\tdata: {}",
|
||||
endpoint,
|
||||
headers,
|
||||
encoded_data,
|
||||
)
|
||||
|
||||
return {"endpoint": endpoint, "data": encoded_data, "headers": headers}
|
||||
|
||||
def send(self, *args, **kwargs) -> Union[Response, str]:
|
||||
"""Encode and send the data to the Push Service"""
|
||||
timeout = kwargs.pop("timeout", 10000)
|
||||
curl = kwargs.pop("curl", False)
|
||||
|
||||
params = self._prepare_send_data(*args, **kwargs)
|
||||
endpoint = params.pop("endpoint")
|
||||
|
||||
if curl:
|
||||
encoded_data = params["data"]
|
||||
headers = params["headers"]
|
||||
return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers)
|
||||
|
||||
resp = self.requests_method.post(
|
||||
endpoint,
|
||||
timeout=timeout,
|
||||
**params,
|
||||
)
|
||||
self.verb(
|
||||
"\nResponse:\n\tcode: {}\n\tbody: {}\n",
|
||||
resp.status_code,
|
||||
resp.text or "Empty",
|
||||
)
|
||||
return resp
|
||||
|
||||
async def send_async(self, *args, **kwargs) -> Union[aiohttp.ClientResponse, str]:
|
||||
timeout = kwargs.pop("timeout", 10000)
|
||||
curl = kwargs.pop("curl", False)
|
||||
|
||||
params = self._prepare_send_data(*args, **kwargs)
|
||||
endpoint = params.pop("endpoint")
|
||||
|
||||
if curl:
|
||||
encoded_data = params["data"]
|
||||
headers = params["headers"]
|
||||
return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers)
|
||||
if self.aiohttp_session:
|
||||
resp = await self.aiohttp_session.post(endpoint, timeout=timeout, **params)
|
||||
resp_text = await resp.text()
|
||||
else:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
resp = await session.post(endpoint, timeout=timeout, **params)
|
||||
resp_text = await resp.text()
|
||||
self.verb(
|
||||
"\nResponse:\n\tcode: {}\n\tbody: {}\n",
|
||||
resp.status,
|
||||
resp_text or "Empty",
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def webpush(
|
||||
subscription_info: Dict[
|
||||
str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
|
||||
],
|
||||
data: Union[None, str] = None,
|
||||
vapid_private_key: Union[None, Vapid, str] = None,
|
||||
vapid_claims: Union[None, Dict[str, Union[str, int]]] = None,
|
||||
content_encoding: str = "aes128gcm",
|
||||
curl: bool = False,
|
||||
timeout: Union[None, float] = None,
|
||||
ttl: int = 0,
|
||||
verbose: bool = False,
|
||||
headers: Union[None, Dict[str, Union[str, int, float]]] = None,
|
||||
requests_session: Union[None, requests.Session] = None,
|
||||
) -> Union[str, requests.Response]:
|
||||
"""
|
||||
One call solution to endcode and send `data` to the endpoint
|
||||
contained in `subscription_info` using optional VAPID auth headers.
|
||||
|
||||
in example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pywebpush import python
|
||||
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": "https://push.example.com/v1/abcd",
|
||||
"keys": {"p256dh": "0123abcd...",
|
||||
"auth": "001122..."}
|
||||
},
|
||||
data="Mary had a little lamb, with a nice mint jelly",
|
||||
vapid_private_key="path/to/key.pem",
|
||||
vapid_claims={"sub": "YourNameHere@example.com"}
|
||||
)
|
||||
|
||||
No additional method call is required. Any non-success will throw a
|
||||
`WebPushException`.
|
||||
|
||||
:param subscription_info: Provided by the client call
|
||||
:type subscription_info: dict
|
||||
:param data: Serialized data to send
|
||||
:type data: str
|
||||
:param vapid_private_key: Vapid instance or path to vapid private key PEM \
|
||||
or encoded str
|
||||
:type vapid_private_key: Union[Vapid, str]
|
||||
:param vapid_claims: Dictionary of claims ('sub' required)
|
||||
:type vapid_claims: dict
|
||||
:param content_encoding: Optional content type string
|
||||
:type content_encoding: str
|
||||
:param curl: Return as "curl" string instead of sending
|
||||
:type curl: bool
|
||||
:param timeout: POST requests timeout
|
||||
:type timeout: float
|
||||
:param ttl: Time To Live
|
||||
:type ttl: int
|
||||
:param verbose: Provide verbose feedback
|
||||
:type verbose: bool
|
||||
:return requests.Response or string
|
||||
:param headers: Dictionary of extra HTTP headers to include
|
||||
:type headers: dict
|
||||
|
||||
"""
|
||||
if headers is None:
|
||||
headers = dict()
|
||||
else:
|
||||
# Ensure we don't leak VAPID headers by mutating the passed in dict.
|
||||
headers = headers.copy()
|
||||
|
||||
vapid_headers = None
|
||||
if vapid_claims:
|
||||
if verbose:
|
||||
logging.info("Generating VAPID headers...")
|
||||
if not vapid_claims.get("aud"):
|
||||
url = urlparse(cast(str, subscription_info.get("endpoint")))
|
||||
aud = "{}://{}".format(url.scheme, url.netloc)
|
||||
vapid_claims["aud"] = aud
|
||||
# Remember, passed structures are mutable in python.
|
||||
# It's possible that a previously set `exp` field is no longer valid.
|
||||
if not vapid_claims.get("exp") or int(vapid_claims.get("exp") or 0) < int(
|
||||
time.time()
|
||||
):
|
||||
# encryption lives for 12 hours
|
||||
vapid_claims["exp"] = int(time.time()) + (12 * 60 * 60)
|
||||
if verbose:
|
||||
logging.info("Setting VAPID expry to {}...".format(vapid_claims["exp"]))
|
||||
if not vapid_private_key:
|
||||
raise WebPushException("VAPID dict missing 'private_key'")
|
||||
if isinstance(vapid_private_key, Vapid01):
|
||||
if verbose:
|
||||
logging.info("Looks like we already have a valid VAPID key")
|
||||
vv = vapid_private_key
|
||||
elif os.path.isfile(vapid_private_key):
|
||||
# Presume that key from file is handled correctly by
|
||||
# py_vapid.
|
||||
if verbose:
|
||||
logging.info("Reading VAPID key from file {}".format(vapid_private_key))
|
||||
vv = Vapid.from_file(private_key_file=vapid_private_key) # pragma no cover
|
||||
else:
|
||||
if verbose:
|
||||
logging.info("Reading VAPID key from arguments")
|
||||
vv = Vapid.from_string(private_key=vapid_private_key)
|
||||
if verbose:
|
||||
logging.info("\t claims: {}".format(vapid_claims))
|
||||
vapid_headers = vv.sign(vapid_claims)
|
||||
if verbose:
|
||||
logging.info("\t headers: {}".format(vapid_headers))
|
||||
headers.update(vapid_headers)
|
||||
|
||||
response = WebPusher(
|
||||
subscription_info, requests_session=requests_session, verbose=verbose
|
||||
).send(
|
||||
data,
|
||||
headers,
|
||||
ttl=ttl,
|
||||
content_encoding=content_encoding,
|
||||
curl=curl,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not curl and cast(Response, response).status_code > 202:
|
||||
response = cast(Response, response)
|
||||
raise WebPushException(
|
||||
"Push failed: {} {}\nResponse body:{}".format(
|
||||
response.status_code, response.reason, response.text
|
||||
),
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
async def webpush_async(
|
||||
subscription_info: Dict[
|
||||
str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
|
||||
],
|
||||
data: Union[None, str] = None,
|
||||
vapid_private_key: Union[None, Vapid, str] = None,
|
||||
vapid_claims: Union[None, Dict[str, Union[str, int]]] = None,
|
||||
content_encoding: str = "aes128gcm",
|
||||
curl: bool = False,
|
||||
timeout: Union[None, float] = None,
|
||||
ttl: int = 0,
|
||||
verbose: bool = False,
|
||||
headers: Union[None, Dict[str, Union[str, int, float]]] = None,
|
||||
aiohttp_session: Union[None, aiohttp.ClientSession] = None,
|
||||
) -> Union[str, aiohttp.ClientResponse]:
|
||||
"""
|
||||
Async version of webpush function. One call solution to encode and send
|
||||
`data` to the endpoint contained in `subscription_info` using optional
|
||||
VAPID auth headers.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pywebpush import webpush_async
|
||||
import asyncio
|
||||
|
||||
async def send_notification():
|
||||
response = await webpush_async(
|
||||
subscription_info={
|
||||
"endpoint": "https://push.example.com/v1/abcd",
|
||||
"keys": {"p256dh": "0123abcd...",
|
||||
"auth": "001122..."}
|
||||
},
|
||||
data="Mary had a little lamb, with a nice mint jelly",
|
||||
vapid_private_key="path/to/key.pem",
|
||||
vapid_claims={"sub": "YourNameHere@example.com"}
|
||||
)
|
||||
|
||||
asyncio.run(send_notification())
|
||||
|
||||
No additional method call is required. Any non-success will throw a
|
||||
`WebPushException`.
|
||||
|
||||
:param subscription_info: Provided by the client call
|
||||
:type subscription_info: dict
|
||||
:param data: Serialized data to send
|
||||
:type data: str
|
||||
:param vapid_private_key: Vapid instance or path to vapid private key PEM \
|
||||
or encoded str
|
||||
:type vapid_private_key: Union[Vapid, str]
|
||||
:param vapid_claims: Dictionary of claims ('sub' required)
|
||||
:type vapid_claims: dict
|
||||
:param content_encoding: Optional content type string
|
||||
:type content_encoding: str
|
||||
:param curl: Return as "curl" string instead of sending
|
||||
:type curl: bool
|
||||
:param timeout: POST requests timeout
|
||||
:type timeout: float
|
||||
:param ttl: Time To Live
|
||||
:type ttl: int
|
||||
:param verbose: Provide verbose feedback
|
||||
:type verbose: bool
|
||||
:param headers: Dictionary of extra HTTP headers to include
|
||||
:type headers: dict
|
||||
:param aiohttp_session: Optional aiohttp ClientSession for connection reuse
|
||||
:type aiohttp_session: aiohttp.ClientSession
|
||||
:return aiohttp.ClientResponse or string
|
||||
|
||||
"""
|
||||
if headers is None:
|
||||
headers = dict()
|
||||
else:
|
||||
# Ensure we don't leak VAPID headers by mutating the passed in dict.
|
||||
headers = headers.copy()
|
||||
|
||||
vapid_headers = None
|
||||
if vapid_claims:
|
||||
if verbose:
|
||||
logging.info("Generating VAPID headers...")
|
||||
if not vapid_claims.get("aud"):
|
||||
url = urlparse(cast(str, subscription_info.get("endpoint")))
|
||||
aud = "{}://{}".format(url.scheme, url.netloc)
|
||||
vapid_claims["aud"] = aud
|
||||
# Remember, passed structures are mutable in python.
|
||||
# It's possible that a previously set `exp` field is no longer valid.
|
||||
if not vapid_claims.get("exp") or int(vapid_claims.get("exp") or 0) < int(
|
||||
time.time()
|
||||
):
|
||||
# encryption lives for 12 hours
|
||||
vapid_claims["exp"] = int(time.time()) + (12 * 60 * 60)
|
||||
if verbose:
|
||||
logging.info(
|
||||
"Setting VAPID expiry to {}...".format(vapid_claims["exp"])
|
||||
)
|
||||
if not vapid_private_key:
|
||||
raise WebPushException("VAPID dict missing 'private_key'")
|
||||
if isinstance(vapid_private_key, Vapid01):
|
||||
if verbose:
|
||||
logging.info("Looks like we already have a valid VAPID key")
|
||||
vv = vapid_private_key
|
||||
elif os.path.isfile(vapid_private_key):
|
||||
# Presume that key from file is handled correctly by
|
||||
# py_vapid.
|
||||
if verbose:
|
||||
logging.info("Reading VAPID key from file {}".format(vapid_private_key))
|
||||
vv = Vapid.from_file(private_key_file=vapid_private_key) # pragma no cover
|
||||
else:
|
||||
if verbose:
|
||||
logging.info("Reading VAPID key from arguments")
|
||||
vv = Vapid.from_string(private_key=vapid_private_key)
|
||||
if verbose:
|
||||
logging.info("\t claims: {}".format(vapid_claims))
|
||||
vapid_headers = vv.sign(vapid_claims)
|
||||
if verbose:
|
||||
logging.info("\t headers: {}".format(vapid_headers))
|
||||
headers.update(vapid_headers)
|
||||
|
||||
response = await WebPusher(
|
||||
subscription_info, aiohttp_session=aiohttp_session, verbose=verbose
|
||||
).send_async(
|
||||
data,
|
||||
headers,
|
||||
ttl=ttl,
|
||||
content_encoding=content_encoding,
|
||||
curl=curl,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not curl and cast(aiohttp.ClientResponse, response).status > 202:
|
||||
response = cast(aiohttp.ClientResponse, response)
|
||||
response_text = await response.text()
|
||||
raise WebPushException(
|
||||
"Push failed: {} {}\nResponse body:{}".format(
|
||||
response.status, response.reason, response_text
|
||||
),
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,93 @@
|
||||
import argparse
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
|
||||
from requests import JSONDecodeError
|
||||
|
||||
from pywebpush import webpush, WebPushException
|
||||
|
||||
|
||||
def get_config():
|
||||
parser = argparse.ArgumentParser(description="WebPush tool")
|
||||
parser.add_argument("--data", "-d", help="Data file")
|
||||
parser.add_argument("--info", "-i", help="Subscription Info JSON file")
|
||||
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(
|
||||
"--curl",
|
||||
help="Don't send, display as curl command",
|
||||
default=False,
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument("--encoding", default="aes128gcm")
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
help="Provide verbose feedback",
|
||||
default=False,
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.info:
|
||||
raise WebPushException("Subscription Info argument missing.")
|
||||
if not os.path.exists(args.info):
|
||||
raise WebPushException("Subscription Info file missing.")
|
||||
try:
|
||||
with open(args.info) as r:
|
||||
try:
|
||||
args.sub_info = json.loads(r.read())
|
||||
except JSONDecodeError as e:
|
||||
raise WebPushException(
|
||||
"Could not read the subscription info file: {}", e
|
||||
)
|
||||
if args.data:
|
||||
with open(args.data) as r:
|
||||
args.data = r.read()
|
||||
if args.head:
|
||||
with open(args.head) as r:
|
||||
try:
|
||||
args.head = json.loads(r.read())
|
||||
except JSONDecodeError as e:
|
||||
raise WebPushException("Could not read the header arguments: {}", e)
|
||||
if args.claims:
|
||||
if not args.key:
|
||||
raise WebPushException("No private --key specified for claims")
|
||||
with open(args.claims) as r:
|
||||
try:
|
||||
args.claims = json.loads(r.read())
|
||||
except JSONDecodeError as e:
|
||||
raise WebPushException(
|
||||
"Could not read the VAPID claims file {}".format(e)
|
||||
)
|
||||
except Exception as ex:
|
||||
logging.error("Couldn't read input {}.".format(ex))
|
||||
raise ex
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
"""Send data"""
|
||||
|
||||
try:
|
||||
args = get_config()
|
||||
result = webpush(
|
||||
args.sub_info,
|
||||
data=args.data,
|
||||
vapid_private_key=args.key,
|
||||
vapid_claims=args.claims,
|
||||
curl=args.curl,
|
||||
content_encoding=args.encoding,
|
||||
verbose=args.verbose,
|
||||
headers=args.head,
|
||||
)
|
||||
print(result)
|
||||
except Exception as ex:
|
||||
logging.error("{}".format(ex))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,51 @@
|
||||
from pywebpush import webpush
|
||||
import json
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
|
||||
def send_push_notification(subscription, payload):
|
||||
|
||||
try:
|
||||
|
||||
# subscriptionData = json.loads(subscription)
|
||||
|
||||
# logger.error(subscriptionData)
|
||||
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": subscription["endpoint"],
|
||||
"keys": subscription["keys"],
|
||||
},
|
||||
data=json.dumps(payload),
|
||||
vapid_claims={
|
||||
"aud": "https://eshopper.africa",
|
||||
"exp": int((datetime.datetime.now().timestamp())) + 86400,
|
||||
"sub": "mailto:events@eshopper.africa",
|
||||
},
|
||||
vapid_private_key="UCUKEHn7Jd33QZx5lJFKBY4plOxGsJ6xJSOzE14jQlo",
|
||||
)
|
||||
|
||||
# subscription_info = { 'endpoint': subscription['endpoint'], 'keys': subscription['keys'] },
|
||||
|
||||
# data = json.loads(payload),
|
||||
|
||||
# headers = {}
|
||||
|
||||
# ttl = 0
|
||||
|
||||
# gcm_key = ''
|
||||
|
||||
# content_encoding="aes128gcm"
|
||||
|
||||
# reg_id=""
|
||||
|
||||
# WebPusher = webpush(subscription_info)
|
||||
|
||||
# WebPusher(subscription_info).send(data, headers, ttl, gcm_key, reg_id, content_encoding, timeout=None)
|
||||
|
||||
except Exception as inst:
|
||||
print(f" webpush Notification Error : {inst}")
|
||||
|
||||
|
||||
send_push_notification({"endpoint": "https://example.com", "keys": {}}, "laaaa")
|
||||
@@ -0,0 +1,617 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
import time
|
||||
from typing import cast, Union, Dict
|
||||
from unittest.mock import patch, Mock, AsyncMock
|
||||
|
||||
import http_ece
|
||||
import py_vapid
|
||||
import requests
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
from pywebpush import (
|
||||
WebPusher,
|
||||
NoData,
|
||||
WebPushException,
|
||||
CaseInsensitiveDict,
|
||||
webpush,
|
||||
webpush_async,
|
||||
)
|
||||
|
||||
|
||||
class WebpushTestUtils(unittest.TestCase):
|
||||
# This is a exported DER formatted string of an ECDH public key
|
||||
# This was lifted from the py_vapid tests.
|
||||
vapid_key = (
|
||||
"MHcCAQEEIPeN1iAipHbt8+/KZ2NIF8NeN24jqAmnMLFZEMocY8RboAoGCCqGSM49"
|
||||
"AwEHoUQDQgAEEJwJZq/GN8jJbo1GGpyU70hmP2hbWAUpQFKDByKB81yldJ9GTklB"
|
||||
"M5xqEwuPM7VuQcyiLDhvovthPIXx+gsQRQ=="
|
||||
)
|
||||
|
||||
def _gen_subscription_info(self, recv_key=None, endpoint="https://example.com/"):
|
||||
if not recv_key:
|
||||
recv_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
return {
|
||||
"endpoint": endpoint,
|
||||
"keys": {
|
||||
"auth": base64.urlsafe_b64encode(os.urandom(16)).strip(b"="),
|
||||
"p256dh": self._get_pubkey_str(recv_key),
|
||||
},
|
||||
}
|
||||
|
||||
def _get_pubkey_str(self, priv_key):
|
||||
return base64.urlsafe_b64encode(
|
||||
priv_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
).strip(b"=")
|
||||
|
||||
def test_init(self):
|
||||
# use static values so we know what to look for in the reply
|
||||
subscription_info = {
|
||||
"endpoint": "https://example.com/",
|
||||
"keys": {
|
||||
"p256dh": (
|
||||
"BOrnIslXrUow2VAzKCUAE4sIbK00daEZCswOcf8m3T"
|
||||
"F8V82B-OpOg5JbmYLg44kRcvQC1E2gMJshsUYA-_zMPR8"
|
||||
),
|
||||
"auth": "k8JV6sjdbhAi1n3_LDBLvA",
|
||||
},
|
||||
}
|
||||
rk_decode = (
|
||||
b'\x04\xea\xe7"\xc9W\xadJ0\xd9P3(%\x00\x13\x8b'
|
||||
b"\x08l\xad4u\xa1\x19\n\xcc\x0eq\xff&\xdd1"
|
||||
b"|W\xcd\x81\xf8\xeaN\x83\x92[\x99\x82\xe0\xe3"
|
||||
b"\x89\x11r\xf4\x02\xd4M\xa00\x9b!\xb1F\x00"
|
||||
b"\xfb\xfc\xcc=\x1f"
|
||||
)
|
||||
self.assertRaises(
|
||||
WebPushException, WebPusher, {"keys": {"p256dh": "AAA=", "auth": "AAA="}}
|
||||
)
|
||||
self.assertRaises(
|
||||
WebPushException,
|
||||
WebPusher,
|
||||
{"endpoint": "https://example.com", "keys": {"p256dh": "AAA="}},
|
||||
)
|
||||
self.assertRaises(
|
||||
WebPushException,
|
||||
WebPusher,
|
||||
{"endpoint": "https://example.com", "keys": {"auth": "AAA="}},
|
||||
)
|
||||
self.assertRaises(
|
||||
WebPushException,
|
||||
WebPusher,
|
||||
{
|
||||
"endpoint": "https://example.com",
|
||||
"keys": {"p256dh": "AAA=", "auth": "AAA="},
|
||||
},
|
||||
)
|
||||
|
||||
push = WebPusher(subscription_info)
|
||||
assert push.subscription_info != subscription_info
|
||||
assert push.subscription_info["keys"] != subscription_info["keys"]
|
||||
assert push.subscription_info["endpoint"] == subscription_info["endpoint"]
|
||||
assert push.receiver_key == rk_decode
|
||||
assert push.auth_key == b'\x93\xc2U\xea\xc8\xddn\x10"\xd6}\xff,0K\xbc'
|
||||
|
||||
def test_encode(self):
|
||||
for content_encoding in ["aesgcm", "aes128gcm"]:
|
||||
recv_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
subscription_info = self._gen_subscription_info(recv_key)
|
||||
data = "Mary had a little lamb, with some nice mint jelly"
|
||||
push = WebPusher(subscription_info)
|
||||
encoded = push.encode(data.encode(), content_encoding=content_encoding)
|
||||
"""
|
||||
crypto_key = base64.urlsafe_b64encode(
|
||||
self._get_pubkey_str(recv_key)
|
||||
).strip(b'=')
|
||||
"""
|
||||
# Convert these b64 strings into their raw, binary form.
|
||||
raw_salt = None
|
||||
if "salt" in encoded:
|
||||
raw_salt = base64.urlsafe_b64decode(push._repad(encoded["salt"]))
|
||||
raw_dh = None
|
||||
if content_encoding != "aes128gcm":
|
||||
raw_dh = base64.urlsafe_b64decode(push._repad(encoded["crypto_key"]))
|
||||
raw_auth = base64.urlsafe_b64decode(
|
||||
push._repad(subscription_info["keys"]["auth"])
|
||||
)
|
||||
decoded = http_ece.decrypt(
|
||||
encoded["body"],
|
||||
salt=raw_salt,
|
||||
dh=raw_dh,
|
||||
private_key=recv_key,
|
||||
auth_secret=raw_auth,
|
||||
version=content_encoding,
|
||||
)
|
||||
assert decoded.decode("utf8") == data
|
||||
|
||||
def test_bad_content_encoding(self):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb, with some nice mint jelly"
|
||||
push = WebPusher(subscription_info)
|
||||
self.assertRaises(
|
||||
WebPushException, push.encode, data, content_encoding="aesgcm128"
|
||||
)
|
||||
|
||||
@patch("requests.post")
|
||||
def test_send(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
data = "Mary had a little lamb"
|
||||
WebPusher(subscription_info).send(data, headers)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "pre-existing" in ckey
|
||||
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||
|
||||
@patch("requests.post")
|
||||
def test_send_vapid(self, mock_post):
|
||||
mock_post.return_value.status_code = 200
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_private_key=self.vapid_key,
|
||||
vapid_claims={"sub": "mailto:ops@example.com"},
|
||||
content_encoding="aesgcm",
|
||||
headers={"Test-Header": "test-value"},
|
||||
)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
|
||||
def repad(str):
|
||||
return str + "===="[: len(str) % 4]
|
||||
|
||||
auth = json.loads(
|
||||
base64.urlsafe_b64decode(
|
||||
repad(pheaders["authorization"].split(".")[1])
|
||||
).decode("utf8")
|
||||
)
|
||||
assert subscription_info.get("endpoint", "").startswith(auth["aud"])
|
||||
assert "vapid" in pheaders.get("authorization")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "dh=" in ckey
|
||||
assert pheaders.get("content-encoding") == "aesgcm"
|
||||
assert pheaders.get("test-header") == "test-value"
|
||||
|
||||
@patch.object(WebPusher, "send")
|
||||
@patch.object(py_vapid.Vapid, "sign")
|
||||
def test_webpush_vapid_instance(self, vapid_sign, pusher_send):
|
||||
pusher_send.return_value.status_code = 200
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
||||
claims: Dict[str, Union[str, int]] = dict(
|
||||
sub="mailto:ops@example.com", aud="https://example.com"
|
||||
)
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_private_key=vapid_key,
|
||||
vapid_claims=claims,
|
||||
)
|
||||
vapid_sign.assert_called_once_with(claims)
|
||||
pusher_send.assert_called_once()
|
||||
|
||||
@patch.object(WebPusher, "send")
|
||||
@patch.object(py_vapid.Vapid, "sign")
|
||||
def test_webpush_vapid_exp(self, vapid_sign, pusher_send):
|
||||
pusher_send.return_value.status_code = 200
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
||||
claims = dict(
|
||||
sub="mailto:ops@example.com",
|
||||
aud="https://example.com",
|
||||
exp=int(time.time() - 48600),
|
||||
)
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_private_key=vapid_key,
|
||||
vapid_claims=claims,
|
||||
)
|
||||
vapid_sign.assert_called_once_with(claims)
|
||||
pusher_send.assert_called_once()
|
||||
assert int(claims["exp"]) > int(time.time())
|
||||
|
||||
@patch("requests.post")
|
||||
def test_send_bad_vapid_no_key(self, mock_post):
|
||||
mock_post.return_value.status_code = 200
|
||||
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
self.assertRaises(
|
||||
WebPushException,
|
||||
webpush,
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_claims={
|
||||
"aud": "https://example.com",
|
||||
"sub": "mailto:ops@example.com",
|
||||
},
|
||||
)
|
||||
|
||||
@patch("requests.post")
|
||||
def test_send_bad_vapid_bad_return(self, mock_post):
|
||||
mock_post.return_value.status_code = 410
|
||||
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
self.assertRaises(
|
||||
WebPushException,
|
||||
webpush,
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_claims={
|
||||
"aud": "https://example.com",
|
||||
"sub": "mailto:ops@example.com",
|
||||
},
|
||||
vapid_private_key=self.vapid_key,
|
||||
)
|
||||
|
||||
@patch("requests.post")
|
||||
def test_send_empty(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
WebPusher(subscription_info).send("", headers)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert "encryption" not in pheaders
|
||||
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "pre-existing" in ckey
|
||||
|
||||
def test_encode_empty(self):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
pusher = WebPusher(subscription_info)
|
||||
self.assertRaises(NoData, pusher.encode, "", headers)
|
||||
|
||||
def test_encode_no_crypto(self):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
del subscription_info["keys"]
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
data = "Something"
|
||||
pusher = WebPusher(subscription_info)
|
||||
self.assertRaises(WebPushException, pusher.encode, data, headers)
|
||||
|
||||
@patch("requests.post")
|
||||
def test_send_no_headers(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
WebPusher(subscription_info).send(data)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||
|
||||
@patch("pywebpush.open")
|
||||
def test_as_curl(self, opener):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
result = webpush(
|
||||
subscription_info,
|
||||
data="Mary had a little lamb",
|
||||
vapid_claims={
|
||||
"aud": "https://example.com",
|
||||
"sub": "mailto:ops@example.com",
|
||||
},
|
||||
vapid_private_key=self.vapid_key,
|
||||
curl=True,
|
||||
)
|
||||
result = cast(str, result)
|
||||
for s in [
|
||||
"curl -vX POST https://example.com",
|
||||
'-H "content-encoding: aes128gcm"',
|
||||
'-H "authorization: vapid ',
|
||||
'-H "ttl: 0"',
|
||||
'-H "content-length:',
|
||||
]:
|
||||
assert s in result, "missing: {}".format(s)
|
||||
|
||||
def test_ci_dict(self):
|
||||
ci = CaseInsensitiveDict({"Foo": "apple", "bar": "banana"})
|
||||
assert "apple" == ci["foo"]
|
||||
assert "apple" == ci.get("FOO")
|
||||
assert "apple" == ci.get("Foo")
|
||||
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
|
||||
subscription_info = self._gen_subscription_info()
|
||||
WebPusher(subscription_info).send(timeout=5.2)
|
||||
assert mock_post.call_args[1].get("timeout") == 5.2
|
||||
webpush(subscription_info, timeout=10.001)
|
||||
assert mock_post.call_args[1].get("timeout") == 10.001
|
||||
|
||||
@patch("requests.Session")
|
||||
def test_send_using_requests_session(self, mock_session):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
data = "Mary had a little lamb"
|
||||
WebPusher(subscription_info, requests_session=mock_session).send(data, headers)
|
||||
assert subscription_info.get("endpoint") == mock_session.post.call_args[0][0]
|
||||
pheaders = mock_session.post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "pre-existing" in ckey
|
||||
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||
|
||||
|
||||
class WebPusherAsyncTestCase(WebpushTestUtils, unittest.IsolatedAsyncioTestCase):
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_send(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
data = "Mary had a little lamb"
|
||||
await WebPusher(subscription_info).send_async(data, headers)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "pre-existing" in ckey
|
||||
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_send_empty(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
await WebPusher(subscription_info).send_async("", headers)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert "encryption" not in pheaders
|
||||
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "pre-existing" in ckey
|
||||
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_send_no_headers(self, mock_post):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
await WebPusher(subscription_info).send_async(data)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
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
|
||||
subscription_info = self._gen_subscription_info()
|
||||
await WebPusher(subscription_info).send_async(timeout=5.2)
|
||||
assert mock_post.call_args[1].get("timeout") == 5.2
|
||||
|
||||
@patch("aiohttp.ClientSession", new_callable=AsyncMock)
|
||||
async def test_send_using_requests_session(self, mock_session):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"}
|
||||
data = "Mary had a little lamb"
|
||||
await WebPusher(subscription_info, aiohttp_session=mock_session).send_async(
|
||||
data, headers
|
||||
)
|
||||
assert subscription_info.get("endpoint") == mock_session.post.call_args[0][0]
|
||||
pheaders = mock_session.post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
assert pheaders.get("AUTHENTICATION") == headers.get("Authentication")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "pre-existing" in ckey
|
||||
assert pheaders.get("content-encoding") == "aes128gcm"
|
||||
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_webpush_async_vapid(self, mock_post):
|
||||
mock_post.return_value.status = 200
|
||||
mock_post.return_value.text = AsyncMock(return_value="")
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
await webpush_async(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_private_key=self.vapid_key,
|
||||
vapid_claims={"sub": "mailto:ops@example.com"},
|
||||
content_encoding="aesgcm",
|
||||
headers={"Test-Header": "test-value"},
|
||||
)
|
||||
assert subscription_info.get("endpoint") == mock_post.call_args[0][0]
|
||||
pheaders = mock_post.call_args[1].get("headers")
|
||||
assert pheaders.get("ttl") == "0"
|
||||
|
||||
def repad(str):
|
||||
return str + "===="[: len(str) % 4]
|
||||
|
||||
auth = json.loads(
|
||||
base64.urlsafe_b64decode(
|
||||
repad(pheaders["authorization"].split(".")[1])
|
||||
).decode("utf8")
|
||||
)
|
||||
assert subscription_info.get("endpoint", "").startswith(auth["aud"])
|
||||
assert "vapid" in pheaders.get("authorization")
|
||||
ckey = pheaders.get("crypto-key")
|
||||
assert "dh=" in ckey
|
||||
assert pheaders.get("content-encoding") == "aesgcm"
|
||||
assert pheaders.get("test-header") == "test-value"
|
||||
|
||||
@patch.object(WebPusher, "send_async")
|
||||
@patch.object(py_vapid.Vapid, "sign")
|
||||
async def test_webpush_async_vapid_instance(self, vapid_sign, pusher_send):
|
||||
mock_response = Mock()
|
||||
mock_response.status = 200
|
||||
pusher_send.return_value = mock_response
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
||||
claims: Dict[str, Union[str, int]] = dict(
|
||||
sub="mailto:ops@example.com", aud="https://example.com"
|
||||
)
|
||||
await webpush_async(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_private_key=vapid_key,
|
||||
vapid_claims=claims,
|
||||
)
|
||||
vapid_sign.assert_called_once_with(claims)
|
||||
pusher_send.assert_called_once()
|
||||
|
||||
@patch.object(WebPusher, "send_async")
|
||||
@patch.object(py_vapid.Vapid, "sign")
|
||||
async def test_webpush_async_vapid_exp(self, vapid_sign, pusher_send):
|
||||
mock_response = Mock()
|
||||
mock_response.status = 200
|
||||
pusher_send.return_value = mock_response
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
||||
claims = dict(
|
||||
sub="mailto:ops@example.com",
|
||||
aud="https://example.com",
|
||||
exp=int(time.time() - 48600),
|
||||
)
|
||||
await webpush_async(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_private_key=vapid_key,
|
||||
vapid_claims=claims,
|
||||
)
|
||||
vapid_sign.assert_called_once_with(claims)
|
||||
pusher_send.assert_called_once()
|
||||
assert int(claims["exp"]) > int(time.time())
|
||||
|
||||
async def test_webpush_async_bad_vapid_no_key(self):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
with self.assertRaises(WebPushException):
|
||||
await webpush_async(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_claims={
|
||||
"aud": "https://example.com",
|
||||
"sub": "mailto:ops@example.com",
|
||||
},
|
||||
)
|
||||
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_webpush_async_bad_vapid_bad_return(self, mock_post):
|
||||
mock_post.return_value.status = 410
|
||||
mock_post.return_value.reason = "Gone"
|
||||
mock_post.return_value.text = AsyncMock(return_value="Subscription expired")
|
||||
|
||||
subscription_info = self._gen_subscription_info()
|
||||
data = "Mary had a little lamb"
|
||||
with self.assertRaises(WebPushException):
|
||||
await webpush_async(
|
||||
subscription_info=subscription_info,
|
||||
data=data,
|
||||
vapid_claims={
|
||||
"aud": "https://example.com",
|
||||
"sub": "mailto:ops@example.com",
|
||||
},
|
||||
vapid_private_key=self.vapid_key,
|
||||
)
|
||||
|
||||
@patch("aiohttp.ClientSession.post", new_callable=AsyncMock)
|
||||
async def test_webpush_async_timeout(self, mock_post):
|
||||
mock_response = Mock()
|
||||
mock_response.status = 200
|
||||
mock_response.text = AsyncMock(return_value="")
|
||||
mock_post.return_value = mock_response
|
||||
subscription_info = self._gen_subscription_info()
|
||||
await webpush_async(subscription_info, timeout=10.001)
|
||||
assert mock_post.call_args[1].get("timeout") == 10.001
|
||||
|
||||
async def test_webpush_async_as_curl(self):
|
||||
subscription_info = self._gen_subscription_info()
|
||||
result = await webpush_async(
|
||||
subscription_info,
|
||||
data="Mary had a little lamb",
|
||||
vapid_claims={
|
||||
"aud": "https://example.com",
|
||||
"sub": "mailto:ops@example.com",
|
||||
},
|
||||
vapid_private_key=self.vapid_key,
|
||||
curl=True,
|
||||
)
|
||||
result = cast(str, result)
|
||||
for s in [
|
||||
"curl -vX POST https://example.com",
|
||||
'-H "content-encoding: aes128gcm"',
|
||||
'-H "authorization: vapid ',
|
||||
'-H "ttl: 0"',
|
||||
'-H "content-length:',
|
||||
]:
|
||||
assert s in result, "missing: {}".format(s)
|
||||
|
||||
|
||||
class WebpushExceptionTestCase(unittest.TestCase):
|
||||
def test_exception(self):
|
||||
from requests import Response
|
||||
|
||||
exp = WebPushException("foo")
|
||||
assert "{}".format(exp) == "WebPushException: foo"
|
||||
# Really should try to load the response to verify, but this mock
|
||||
# covers what we need.
|
||||
response = Mock(spec=Response)
|
||||
response.text = (
|
||||
'{"code": 401, "errno": 109, "error": '
|
||||
'"Unauthorized", "more_info": "http://'
|
||||
"autopush.readthedocs.io/en/latest/htt"
|
||||
'p.html#error-codes", "message": "Requ'
|
||||
"est did not validate missing authoriz"
|
||||
'ation header"}'
|
||||
)
|
||||
response.json.return_value = json.loads(response.text)
|
||||
response.status_code = 401
|
||||
response.reason = "Unauthorized"
|
||||
exp = WebPushException("foo", response)
|
||||
assert "{}".format(exp) == "WebPushException: foo, Response {}".format(
|
||||
response.text
|
||||
)
|
||||
assert "{}".format(exp.response), "<Response [401]>"
|
||||
assert cast(requests.Response, exp.response).json().get("errno") == 109
|
||||
exp = WebPushException("foo", [1, 2, 3])
|
||||
assert "{}".format(exp) == "WebPushException: foo, Response [1, 2, 3]"
|
||||
Reference in New Issue
Block a user