diff options
author | Lorenz Diener <[email protected]> | 2018-06-05 14:10:53 +0200 |
---|---|---|
committer | Lorenz Diener <[email protected]> | 2018-06-05 14:10:53 +0200 |
commit | 392dd3d61d7f8080faa56e0b7ac9bd09b21ef218 (patch) | |
tree | 0442378491710573ec21493e3d9d7c6de6317640 /mastodon | |
parent | 37cd1a489befe9e6914a77958133952066105296 (diff) | |
download | mastodon.py-392dd3d61d7f8080faa56e0b7ac9bd09b21ef218.tar.gz |
Add webpush support
Diffstat (limited to 'mastodon')
-rw-r--r-- | mastodon/Mastodon.py | 170 |
1 files changed, 165 insertions, 5 deletions
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() |