Skip to content

Commit 9dd9e76

Browse files
committed
Merge pull request #2 from jrconlin/feature/tests
feat: Added tests, restructured code
2 parents 5c13474 + 68ab34a commit 9dd9e76

9 files changed

+325
-131
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.2 (2016-04-27)
2+
feat: Added tests, restructured code
3+
4+
15
## 0.1 (2016-04-25)
26

37
Initial release

README.md

+35-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This is a work in progress.
44

5-
## App Installation
5+
## Installation
66

77
You'll need to run `python virtualenv`.
88
Then
@@ -11,7 +11,39 @@ bin/pip install -r requirements.txt
1111
bin/python setup.py develop
1212
```
1313

14-
### App Usage
14+
## Usage
1515

16-
`webpush/publish.py` contains an example in the stand-alone function.
16+
In the browser, the promise handler for
17+
[registration.pushManager.subscribe()](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)
18+
returns a
19+
[PushSubscription](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription)
20+
object. This object has a .toJSON() method that will return a JSON
21+
object that contains all the info we need to encrypt and push data.
1722

23+
As illustration, a subscription info object may look like:
24+
```
25+
{"endpoint": "https://updates.push.services.mozilla.com/push/v1/gAA...", "keys": {"auth": "k8J...", "p256dh": "BOr..."}}
26+
```
27+
28+
How you send the PushSubscription data to your backend, store it
29+
referenced to the user who requested it, and recall it when there's
30+
new a new push subscription update is left as an excerise for the
31+
reader.
32+
33+
The data can be any serial content (string, bit array, serialized
34+
JSON, etc), but be sure that your receiving application is able to
35+
parse and understand it. (e.g. `data = "Mary had a little lamb."`)
36+
37+
`headers` is a `dict`ionary of additional HTTP header values (e.g.
38+
[VAPID](https://github.com/mozilla-services/vapid/tree/master/python)
39+
self identification headers). It is optional and may be omitted.
40+
41+
to send:
42+
```
43+
WebPusher(subscription_info).send(data, headers)
44+
```
45+
You can also simply encode the data to send later by calling
46+
47+
```
48+
encoded = WebPush(subscription_info).encode(data)
49+
```

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
http-ece==0.5.0
22
python-jose==0.5.6
3+
requests==2.9.1

setup.cfg

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[nosetests]
2+
verbose=True
3+
verbosity=1
4+
cover-tests=True
5+
cover-erase=True
6+
with-coverage=True
7+
detailed-errors=True
8+
cover-package=webpush

setup.py

+21-11
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,30 @@
33

44
from setuptools import setup
55

6+
__version__ = "0.2"
7+
8+
9+
def read_from(file):
10+
reply = []
11+
with io.open(os.path.join(here, file), encoding='utf8') as f:
12+
for l in f:
13+
l = l.strip()
14+
if not l:
15+
break
16+
if l[0] != '#' or l[:2] != '//':
17+
reply.append(l)
18+
return reply
19+
20+
621
here = os.path.abspath(os.path.dirname(__file__))
722
with io.open(os.path.join(here, 'README.md'), encoding='utf8') as f:
823
README = f.read()
924
with io.open(os.path.join(here, 'CHANGELOG.md'), encoding='utf8') as f:
1025
CHANGES = f.read()
1126

12-
extra_options = {
13-
"packages": ["http-ece==0.5.0"]
14-
}
15-
16-
17-
setup(name="Webpusher",
18-
version="0.1",
19-
description='Webpush publication library',
27+
setup(name="pywebpush",
28+
version="0.2",
29+
description='WebPush publication library',
2030
long_description=README + '\n\n' + CHANGES,
2131
classifiers=["Topic :: Internet :: WWW/HTTP",
2232
"Programming Language :: Python :: Implementation :: PyPy",
@@ -27,11 +37,11 @@
2737
keywords='push webpush publication',
2838
author="jr conlin",
2939
author_email="src+webpusher@jrconlin.com",
30-
url='http:///',
40+
url='https://github.com/jrconlin/pywebpush',
3141
license="MPL2",
3242
test_suite="nose.collector",
3343
include_package_data=True,
3444
zip_safe=False,
35-
tests_require=['nose', 'coverage', 'mock>=1.0.1', 'moto>=0.4.1'],
36-
**extra_options
45+
install_requires=read_from('requirements.txt'),
46+
tests_require=read_from('test-requirements.txt')
3747
)

test-requirements.txt

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
nose
2+
coverage
3+
mock>=1.0.1

webpush/__init__.py

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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 base64
6+
import os
7+
8+
import http_ece
9+
import pyelliptic
10+
import requests
11+
12+
13+
class WebPushException(Exception):
14+
pass
15+
16+
17+
class WebPusher:
18+
"""WebPusher encrypts a data block using HTTP Encrypted Content Encoding
19+
for WebPush.
20+
21+
See https://tools.ietf.org/html/draft-ietf-webpush-protocol-04
22+
for the current specification, and
23+
https://developer.mozilla.org/en-US/docs/Web/API/Push_API for an
24+
overview of Web Push.
25+
26+
Example of use:
27+
28+
The javascript promise handler for PushManager.subscribe()
29+
receives a subscription_info object. subscription_info.getJSON()
30+
will return a JSON representation.
31+
(e.g.
32+
.. code-block:: javascript
33+
subscription_info.getJSON() ==
34+
{"endpoint": "https://push...",
35+
"keys":{"auth": "...", "p256dh": "..."}
36+
}
37+
)
38+
39+
This subscription_info block can be stored.
40+
41+
To send a subscription update:
42+
43+
.. code-block:: python
44+
# Optional
45+
# headers = py_vapid.sign({"aud": "http://your.site.com",
46+
"sub": "mailto:your_admin@your.site.com"})
47+
data = "Mary had a little lamb, with a nice mint jelly"
48+
WebPusher(subscription_info).send(data, headers)
49+
50+
"""
51+
subscription_info = {}
52+
53+
def __init__(self, subscription_info):
54+
"""Initialize using the info provided by the client PushSubscription
55+
object (See
56+
https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)
57+
58+
:param subscription_info: a dict containing the subscription_info from
59+
the client.
60+
61+
"""
62+
if 'endpoint' not in subscription_info:
63+
raise WebPushException("subscription_info missing endpoint URL")
64+
if 'keys' not in subscription_info:
65+
raise WebPushException("subscription_info missing keys dictionary")
66+
self.subscription_info = subscription_info
67+
keys = self.subscription_info['keys']
68+
for k in ['p256dh', 'auth']:
69+
if keys.get(k) is None:
70+
raise WebPushException("Missing keys value: %s", k)
71+
receiver_raw = base64.urlsafe_b64decode(self._repad(keys['p256dh']))
72+
if len(receiver_raw) != 65 and receiver_raw[0] != "\x04":
73+
raise WebPushException("Invalid p256dh key specified")
74+
self.receiver_key = receiver_raw
75+
self.auth_key = base64.urlsafe_b64decode(self._repad(keys['auth']))
76+
77+
def _repad(self, str):
78+
"""Add base64 padding to the end of a string, if required"""
79+
return str + "===="[:len(str) % 4]
80+
81+
def encode(self, data):
82+
"""Encrypt the data.
83+
84+
:param data: A serialized block of data (String, JSON, bit array,
85+
etc.) Make sure that whatever you send, your client knows how
86+
to understand it.
87+
88+
"""
89+
# Salt is a random 16 byte array.
90+
salt = os.urandom(16)
91+
# The server key is an ephemeral ECDH key used only for this
92+
# transaction
93+
server_key = pyelliptic.ECC(curve="prime256v1")
94+
# the ID is the base64 of the raw key, minus the leading "\x04"
95+
# ID tag.
96+
server_key_id = base64.urlsafe_b64encode(server_key.get_pubkey()[1:])
97+
98+
# http_ece requires that these both be set BEFORE encrypt or
99+
# decrypt is called.
100+
http_ece.keys[server_key_id] = server_key
101+
http_ece.labels[server_key_id] = "P-256"
102+
103+
encrypted = http_ece.encrypt(
104+
data,
105+
salt=salt,
106+
keyid=server_key_id,
107+
dh=self.receiver_key,
108+
authSecret=self.auth_key)
109+
110+
return {
111+
'crypto_key': base64.urlsafe_b64encode(
112+
server_key.get_pubkey()).strip('='),
113+
'salt': base64.urlsafe_b64encode(salt).strip("="),
114+
'body': encrypted,
115+
}
116+
117+
def send(self, data, headers={}, ttl=0):
118+
"""Encode and send the data to the Push Service.
119+
120+
:param data: A serialized block of data (see encode() ).
121+
:param headers: A dictionary containing any additional HTTP headers.
122+
:param ttl: The Time To Live in seconds for this message if the
123+
recipient is not online. (Defaults to "0", which discards the
124+
message immediately if the recipient is unavailable.)
125+
126+
"""
127+
# Encode the data.
128+
encoded = self.encode(data)
129+
# Append the p256dh to the end of any existing crypto-key
130+
crypto_key = headers.get("crypto-key", "")
131+
if crypto_key:
132+
crypto_key += ','
133+
crypto_key += "keyid=p256dh;dh=" + encoded["crypto_key"]
134+
headers.update({
135+
'crypto-key': crypto_key,
136+
'content-encoding': 'aesgcm',
137+
'encryption': "keyid=p256dh;salt=" + encoded['salt'],
138+
})
139+
if 'ttl' not in headers or ttl:
140+
headers['ttl'] = ttl
141+
# Additionally useful headers:
142+
# Authorization / Crypto-Key (VAPID headers)
143+
return requests.post(self.subscription_info['endpoint'],
144+
data=encoded.get('body'),
145+
headers=headers)

webpush/publish.py

-117
This file was deleted.

0 commit comments

Comments
 (0)