aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLorenz Diener <[email protected]>2018-06-05 14:10:53 +0200
committerLorenz Diener <[email protected]>2018-06-05 14:10:53 +0200
commit392dd3d61d7f8080faa56e0b7ac9bd09b21ef218 (patch)
tree0442378491710573ec21493e3d9d7c6de6317640
parent37cd1a489befe9e6914a77958133952066105296 (diff)
downloadmastodon.py-392dd3d61d7f8080faa56e0b7ac9bd09b21ef218.tar.gz
Add webpush support
-rw-r--r--DEVELOPMENT.md2
-rw-r--r--docs/index.rst43
-rw-r--r--mastodon/Mastodon.py170
-rw-r--r--setup.py10
4 files changed, 215 insertions, 10 deletions
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 837cbaa..cf00e6a 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -6,5 +6,5 @@ Here's some general stuff to keep in mind, and some work that needs to be done
6* Current TODOs: 6* Current TODOs:
7 * Testing - test 2.3 stuff and verify it works: TODO: media updating 7 * Testing - test 2.3 stuff and verify it works: TODO: media updating
8 * 2.4 support: 8 * 2.4 support:
9 * Push API 9 * Document and add tests for webpush
10 \ No newline at end of file 10 \ No newline at end of file
diff --git a/docs/index.rst b/docs/index.rst
index dc9423f..ef96273 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -529,6 +529,23 @@ Report dicts
529 # will set this field to True. 529 # will set this field to True.
530 } 530 }
531 531
532Push subscription dicts
533~~~~~~~~~~~~~~~~~~~~~~~
534.. _push subscription dict:
535
536.. code-block:: python
537
538 mastodon.push_subscription()
539 # Returns the following dictionary
540 {
541 'id': # Numerical id of the push subscription
542 'endpoint': # Endpoint URL for the subscription
543 'server_key': # Server pubkey used for signature verification
544 'alerts': # Subscribed events - dict that may contain keys 'follow',
545 # 'favourite', 'reblog' and 'mention', with value True
546 # if webpushes have been requested for those events.
547 }
548
532App registration and user authentication 549App registration and user authentication
533---------------------------------------- 550----------------------------------------
534Before you can use the mastodon API, you have to register your 551Before you can use the mastodon API, you have to register your
@@ -805,6 +822,7 @@ StreamListener
805.. automethod:: StreamListener.on_update 822.. automethod:: StreamListener.on_update
806.. automethod:: StreamListener.on_notification 823.. automethod:: StreamListener.on_notification
807.. automethod:: StreamListener.on_delete 824.. automethod:: StreamListener.on_delete
825.. automethod:: StreamListener.on_abort
808.. automethod:: StreamListener.handle_heartbeat 826.. automethod:: StreamListener.handle_heartbeat
809 827
810CallbackStreamListener 828CallbackStreamListener
@@ -812,12 +830,31 @@ CallbackStreamListener
812 830
813.. autoclass:: CallbackStreamListener 831.. autoclass:: CallbackStreamListener
814 832
815.. _Mastodon: https://github.com/tootsuite/mastodon 833Push subscriptions
816.. _Mastodon flagship instance: http://mastodon.social/ 834------------------
817.. _Mastodon api docs: https://github.com/tootsuite/documentation/ 835These functions allow you to manage webpush subscriptions and to decrypt received
836pushes. Note that the intended setup is not mastodon pushing directly to a users client -
837the push endpoint should usually be a relay server that then takes care of delivering the
838(encrypted) push to the end user via some mechanism, where it can then be decrypted and
839displayed.
840
841Mastodon allows an application to have one webpush subscription per user at a time.
842
843.. automethod:: Mastodon.push_subscription
844.. automethod:: Mastodon.push_subscription_set
845.. automethod:: Mastodon.push_subscription_update
846
847.. push_subscription_generate_keys():
848
849.. automethod:: Mastodon.push_subscription_generate_keys
850.. automethod:: Mastodon.push_subscription_decrypt_push
818 851
819Acknowledgements 852Acknowledgements
820---------------- 853----------------
821Mastodon.py contains work by a large amount of contributors, many of which have 854Mastodon.py contains work by a large amount of contributors, many of which have
822put significant work into making it a better library. You can find some information 855put significant work into making it a better library. You can find some information
823about who helped with which particular feature or fix in the changelog. 856about who helped with which particular feature or fix in the changelog.
857
858.. _Mastodon: https://github.com/tootsuite/mastodon
859.. _Mastodon flagship instance: http://mastodon.social/
860.. _Mastodon api docs: https://github.com/tootsuite/documentation/ \ No newline at end of file
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py
index 17f059f..ed2a1f7 100644
--- a/mastodon/Mastodon.py
+++ b/mastodon/Mastodon.py
@@ -20,6 +20,11 @@ import threading
20import sys 20import sys
21import six 21import six
22from decorator import decorate 22from decorator import decorate
23from cryptography.hazmat.backends import default_backend
24from cryptography.hazmat.primitives.asymmetric import ec
25import http_ece
26import base64
27import json
23 28
24try: 29try:
25 from urllib.parse import urlparse 30 from urllib.parse import urlparse
@@ -126,15 +131,16 @@ class Mastodon:
126 __DICT_VERSION_ACCOUNT), __DICT_VERSION_STATUS), __DICT_VERSION_HASHTAG) 131 __DICT_VERSION_ACCOUNT), __DICT_VERSION_STATUS), __DICT_VERSION_HASHTAG)
127 __DICT_VERSION_ACTIVITY = "2.1.2" 132 __DICT_VERSION_ACTIVITY = "2.1.2"
128 __DICT_VERSION_REPORT = "1.1.0" 133 __DICT_VERSION_REPORT = "1.1.0"
134 __DICT_VERSION_PUSH = "2.4.0"
129 135
130 ### 136 ###
131 # Registering apps 137 # Registering apps
132 ### 138 ###
133 @staticmethod 139 @staticmethod
134 def create_app(client_name, scopes=['read', 'write', 'follow'], redirect_uris=None, website=None, to_file=None, 140 def create_app(client_name, scopes=['read', 'write', 'follow', 'push'], redirect_uris=None, website=None, to_file=None,
135 api_base_url=__DEFAULT_BASE_URL, request_timeout=__DEFAULT_TIMEOUT): 141 api_base_url=__DEFAULT_BASE_URL, request_timeout=__DEFAULT_TIMEOUT):
136 """ 142 """
137 Create a new app with given `client_name` and `scopes` (read, write, follow) 143 Create a new app with given `client_name` and `scopes` (read, write, follow, push)
138 144
139 Specify `redirect_uris` if you want users to be redirected to a certain page after authenticating. 145 Specify `redirect_uris` if you want users to be redirected to a certain page after authenticating.
140 Specify `to_file` to persist your apps info to a file so you can use them in the constructor. 146 Specify `to_file` to persist your apps info to a file so you can use them in the constructor.
@@ -303,7 +309,7 @@ class Mastodon:
303 return Mastodon.__SUPPORTED_MASTODON_VERSION 309 return Mastodon.__SUPPORTED_MASTODON_VERSION
304 310
305 def auth_request_url(self, client_id=None, redirect_uris="urn:ietf:wg:oauth:2.0:oob", 311 def auth_request_url(self, client_id=None, redirect_uris="urn:ietf:wg:oauth:2.0:oob",
306 scopes=['read', 'write', 'follow']): 312 scopes=['read', 'write', 'follow', 'push']):
307 """Returns the url that a client needs to request the grant from the server. 313 """Returns the url that a client needs to request the grant from the server.
308 """ 314 """
309 if client_id is None: 315 if client_id is None:
@@ -323,7 +329,7 @@ class Mastodon:
323 329
324 def log_in(self, username=None, password=None, 330 def log_in(self, username=None, password=None,
325 code=None, redirect_uri="urn:ietf:wg:oauth:2.0:oob", refresh_token=None, 331 code=None, redirect_uri="urn:ietf:wg:oauth:2.0:oob", refresh_token=None,
326 scopes=['read', 'write', 'follow'], to_file=None): 332 scopes=['read', 'write', 'follow', 'push'], to_file=None):
327 """ 333 """
328 Get the access token for a user. 334 Get the access token for a user.
329 335
@@ -950,6 +956,19 @@ class Mastodon:
950 return self.__api_request('GET', '/api/v1/custom_emojis') 956 return self.__api_request('GET', '/api/v1/custom_emojis')
951 957
952 ### 958 ###
959 # Reading data: Webpush subscriptions
960 ###
961 @api_version("2.4.0", "2.4.0", __DICT_VERSION_PUSH)
962 def push_subscription(self):
963 """
964 Fetch the current push subscription the logged-in user has for this app.
965
966 Returns a `push subscription dict`_.
967
968 """
969 return self.__api_request('GET', '/api/v1/push/subscription')
970
971 ###
953 # Writing data: Statuses 972 # Writing data: Statuses
954 ### 973 ###
955 @api_version("1.0.0", "2.3.0", __DICT_VERSION_STATUS) 974 @api_version("1.0.0", "2.3.0", __DICT_VERSION_STATUS)
@@ -1455,6 +1474,8 @@ class Mastodon:
1455 """ 1474 """
1456 Update the metadata of the media file with the given `id`. `description` and 1475 Update the metadata of the media file with the given `id`. `description` and
1457 `focus` are as in `media_post()`_ . 1476 `focus` are as in `media_post()`_ .
1477
1478 Returns the updated `media dict`_.
1458 """ 1479 """
1459 id = self.__unpack_id(id) 1480 id = self.__unpack_id(id)
1460 1481
@@ -1484,6 +1505,136 @@ class Mastodon:
1484 self.__api_request('DELETE', '/api/v1/domain_blocks', params) 1505 self.__api_request('DELETE', '/api/v1/domain_blocks', params)
1485 1506
1486 ### 1507 ###
1508 # Writing data: Push subscriptions
1509 ###
1510 @api_version("2.4.0", "2.4.0", __DICT_VERSION_PUSH)
1511 def push_subscription_set(self, endpoint, encrypt_params, follow_events=False,
1512 favourite_events=False, reblog_events=False,
1513 mention_events=False):
1514 """
1515 Sets up or modifies the push subscription the logged-in user has for this app.
1516
1517 `endpoint` is the endpoint URL mastodon should call for pushes. Note that mastodon
1518 requires https for this URL. `encrypt_params` is a dict with key parameters that allow
1519 the server to encrypt data for you: A public key `pubkey` and a shared secret `auth`.
1520 You can generate this as well as the corresponding private key using the
1521 `push_subscription_generate_keys()`_ .
1522
1523 The rest of the parameters controls what kind of events you wish to subscribe to.
1524
1525 Returns a `push subscription dict`_.
1526 """
1527 endpoint = Mastodon.__protocolize(endpoint)
1528
1529 push_pubkey_b64 = base64.b64encode(encrypt_params['pubkey'])
1530 push_auth_b64 = base64.b64encode(encrypt_params['auth'])
1531
1532 params = {
1533 'subscription[endpoint]': endpoint,
1534 'subscription[keys][p256dh]': push_pubkey_b64,
1535 'subscription[keys][auth]': push_auth_b64
1536 }
1537
1538 if follow_events == True:
1539 params['data[alerts][follow]'] = True
1540
1541 if favourite_events == True:
1542 params['data[alerts][favourite]'] = True
1543
1544 if reblog_events == True:
1545 params['data[alerts][reblog]'] = True
1546
1547 if mention_events == True:
1548 params['data[alerts][mention]'] = True
1549
1550 return self.__api_request('POST', '/api/v1/push/subscription', params)
1551
1552 @api_version("2.4.0", "2.4.0", __DICT_VERSION_PUSH)
1553 def push_subscription_update(self, endpoint, encrypt_params, follow_events=False,
1554 favourite_events=False, reblog_events=False,
1555 mention_events=False):
1556 """
1557 Modifies what kind of events the app wishes to subscribe to.
1558
1559 Returns the updated `push subscription dict`_.
1560 """
1561 params = {}
1562
1563 if follow_events == True:
1564 params['data[alerts][follow]'] = True
1565
1566 if favourite_events == True:
1567 params['data[alerts][favourite]'] = True
1568
1569 if reblog_events == True:
1570 params['data[alerts][reblog]'] = True
1571
1572 if mention_events == True:
1573 params['data[alerts][mention]'] = True
1574
1575 return self.__api_request('PUT', '/api/v1/push/subscription', params)
1576
1577 @api_version("2.4.0", "2.4.0", "2.4.0")
1578 def push_subscription_delete(self):
1579 """
1580 Remove the current push subscription the logged-in user has for this app.
1581 """
1582 self.__api_request('DELETE', '/api/v1/push/subscription')
1583
1584
1585 ###
1586 # Push subscription crypto utilities
1587 ###
1588 def push_subscription_generate_keys(self):
1589 """
1590 Generates a private key, public key and shared secret for use in webpush subscriptionss.
1591
1592 Returns two dicts: One with the private key and shared secret and another with the
1593 public key and shared secret.
1594 """
1595 push_key_pair = ec.generate_private_key(ec.SECP256R1(), default_backend())
1596 push_key_priv = push_key_pair.private_numbers().private_value
1597 push_key_pub = push_key_pair.public_key().public_numbers().encode_point()
1598 push_shared_secret = os.urandom(16)
1599
1600 priv_dict = {
1601 'privkey': push_key_priv,
1602 'auth': push_shared_secret
1603 }
1604
1605 pub_dict = {
1606 'pubkey': push_key_pub,
1607 'auth': push_shared_secret
1608 }
1609
1610 return priv_dict, pub_dict
1611
1612 def push_subscription_decrypt_push(self, data, decrypt_params, encryption_header, crypto_key_header):
1613 """
1614 Decrypts `data` received in a webpush request. Requires the private key dict
1615 from `push_subscription_generate_keys()`_ (`decrypt_params`) as well as the
1616 Encryption and server Crypto-Key headers from the received webpush
1617
1618 Returns the decoded webpush.
1619 """
1620 salt = self.__decode_webpush_b64(encryption_header.split("salt=")[1].strip())
1621 dhparams = self.__decode_webpush_b64(crypto_key_header.split("dh=")[1].split(";")[0].strip())
1622 p256ecdsa = self.__decode_webpush_b64(crypto_key_header.split("p256ecdsa=")[1].strip())
1623 dec_key = ec.derive_private_key(decrypt_params['privkey'], ec.SECP256R1(), default_backend())
1624 decrypted = http_ece.decrypt(
1625 data,
1626 salt = salt,
1627 key = p256ecdsa,
1628 private_key = dec_key,
1629 dh = dhparams,
1630 auth_secret=decrypt_params['auth'],
1631 keylabel = "P-256",
1632 version = "aesgcm"
1633 )
1634
1635 return json.loads(decrypted.decode('utf-8'), object_hook = Mastodon.__json_hooks)
1636
1637 ###
1487 # Pagination 1638 # Pagination
1488 ### 1639 ###
1489 def fetch_next(self, previous_page): 1640 def fetch_next(self, previous_page):
@@ -1983,7 +2134,16 @@ class Mastodon:
1983 return id["id"] 2134 return id["id"]
1984 else: 2135 else:
1985 return id 2136 return id
1986 2137
2138 def __decode_webpush_b64(self, data):
2139 """
2140 Re-pads and decodes urlsafe base64.
2141 """
2142 missing_padding = len(data) % 4
2143 if missing_padding != 0:
2144 data += '=' * (4 - missing_padding)
2145 return base64.urlsafe_b64decode(data)
2146
1987 def __get_token_expired(self): 2147 def __get_token_expired(self):
1988 """Internal helper for oauth code""" 2148 """Internal helper for oauth code"""
1989 return self._token_expired < datetime.datetime.now() 2149 return self._token_expired < datetime.datetime.now()
diff --git a/setup.py b/setup.py
index 69feee7..48c2f56 100644
--- a/setup.py
+++ b/setup.py
@@ -10,7 +10,15 @@ setup(name='Mastodon.py',
10 description='Python wrapper for the Mastodon API', 10 description='Python wrapper for the Mastodon API',
11 packages=['mastodon'], 11 packages=['mastodon'],
12 setup_requires=['pytest-runner'], 12 setup_requires=['pytest-runner'],
13 install_requires=['requests', 'python-dateutil', 'six', 'pytz', 'decorator>=4.0.0'], 13 install_requires=[
14 'requests',
15 'python-dateutil',
16 'six',
17 'pytz',
18 'decorator>=4.0.0',
19 'http_ece>=1.0.5',
20 'cryptography>=1.6.0'
21 ],
14 tests_require=test_deps, 22 tests_require=test_deps,
15 extras_require=extras, 23 extras_require=extras,
16 url='https://github.com/halcy/Mastodon.py', 24 url='https://github.com/halcy/Mastodon.py',
Powered by cgit v1.2.3 (git 2.41.0)