diff options
-rw-r--r-- | DEVELOPMENT.md | 2 | ||||
-rw-r--r-- | docs/index.rst | 43 | ||||
-rw-r--r-- | mastodon/Mastodon.py | 170 | ||||
-rw-r--r-- | setup.py | 10 |
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 | ||
532 | Push 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 | |||
532 | App registration and user authentication | 549 | App registration and user authentication |
533 | ---------------------------------------- | 550 | ---------------------------------------- |
534 | Before you can use the mastodon API, you have to register your | 551 | Before 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 | ||
810 | CallbackStreamListener | 828 | CallbackStreamListener |
@@ -812,12 +830,31 @@ CallbackStreamListener | |||
812 | 830 | ||
813 | .. autoclass:: CallbackStreamListener | 831 | .. autoclass:: CallbackStreamListener |
814 | 832 | ||
815 | .. _Mastodon: https://github.com/tootsuite/mastodon | 833 | Push subscriptions |
816 | .. _Mastodon flagship instance: http://mastodon.social/ | 834 | ------------------ |
817 | .. _Mastodon api docs: https://github.com/tootsuite/documentation/ | 835 | These functions allow you to manage webpush subscriptions and to decrypt received |
836 | pushes. Note that the intended setup is not mastodon pushing directly to a users client - | ||
837 | the 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 | ||
839 | displayed. | ||
840 | |||
841 | Mastodon 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 | ||
819 | Acknowledgements | 852 | Acknowledgements |
820 | ---------------- | 853 | ---------------- |
821 | Mastodon.py contains work by a large amount of contributors, many of which have | 854 | Mastodon.py contains work by a large amount of contributors, many of which have |
822 | put significant work into making it a better library. You can find some information | 855 | put significant work into making it a better library. You can find some information |
823 | about who helped with which particular feature or fix in the changelog. | 856 | about 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 | |||
20 | import sys | 20 | import sys |
21 | import six | 21 | import six |
22 | from decorator import decorate | 22 | from decorator import decorate |
23 | from cryptography.hazmat.backends import default_backend | ||
24 | from cryptography.hazmat.primitives.asymmetric import ec | ||
25 | import http_ece | ||
26 | import base64 | ||
27 | import json | ||
23 | 28 | ||
24 | try: | 29 | try: |
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() |
@@ -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', |