Add webpush support
This commit is contained in:
pare
37cd1a489b
commit
392dd3d61d
S'han modificat 4 arxius amb 215 adicions i 10 eliminacions
|
@ -6,5 +6,5 @@ Here's some general stuff to keep in mind, and some work that needs to be done
|
||||||
* Current TODOs:
|
* Current TODOs:
|
||||||
* Testing - test 2.3 stuff and verify it works: TODO: media updating
|
* Testing - test 2.3 stuff and verify it works: TODO: media updating
|
||||||
* 2.4 support:
|
* 2.4 support:
|
||||||
* Push API
|
* Document and add tests for webpush
|
||||||
|
|
|
@ -529,6 +529,23 @@ Report dicts
|
||||||
# will set this field to True.
|
# will set this field to True.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Push subscription dicts
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
.. _push subscription dict:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
mastodon.push_subscription()
|
||||||
|
# Returns the following dictionary
|
||||||
|
{
|
||||||
|
'id': # Numerical id of the push subscription
|
||||||
|
'endpoint': # Endpoint URL for the subscription
|
||||||
|
'server_key': # Server pubkey used for signature verification
|
||||||
|
'alerts': # Subscribed events - dict that may contain keys 'follow',
|
||||||
|
# 'favourite', 'reblog' and 'mention', with value True
|
||||||
|
# if webpushes have been requested for those events.
|
||||||
|
}
|
||||||
|
|
||||||
App registration and user authentication
|
App registration and user authentication
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
Before you can use the mastodon API, you have to register your
|
Before you can use the mastodon API, you have to register your
|
||||||
|
@ -805,6 +822,7 @@ StreamListener
|
||||||
.. automethod:: StreamListener.on_update
|
.. automethod:: StreamListener.on_update
|
||||||
.. automethod:: StreamListener.on_notification
|
.. automethod:: StreamListener.on_notification
|
||||||
.. automethod:: StreamListener.on_delete
|
.. automethod:: StreamListener.on_delete
|
||||||
|
.. automethod:: StreamListener.on_abort
|
||||||
.. automethod:: StreamListener.handle_heartbeat
|
.. automethod:: StreamListener.handle_heartbeat
|
||||||
|
|
||||||
CallbackStreamListener
|
CallbackStreamListener
|
||||||
|
@ -812,12 +830,31 @@ CallbackStreamListener
|
||||||
|
|
||||||
.. autoclass:: CallbackStreamListener
|
.. autoclass:: CallbackStreamListener
|
||||||
|
|
||||||
.. _Mastodon: https://github.com/tootsuite/mastodon
|
Push subscriptions
|
||||||
.. _Mastodon flagship instance: http://mastodon.social/
|
------------------
|
||||||
.. _Mastodon api docs: https://github.com/tootsuite/documentation/
|
These functions allow you to manage webpush subscriptions and to decrypt received
|
||||||
|
pushes. Note that the intended setup is not mastodon pushing directly to a users client -
|
||||||
|
the push endpoint should usually be a relay server that then takes care of delivering the
|
||||||
|
(encrypted) push to the end user via some mechanism, where it can then be decrypted and
|
||||||
|
displayed.
|
||||||
|
|
||||||
|
Mastodon allows an application to have one webpush subscription per user at a time.
|
||||||
|
|
||||||
|
.. automethod:: Mastodon.push_subscription
|
||||||
|
.. automethod:: Mastodon.push_subscription_set
|
||||||
|
.. automethod:: Mastodon.push_subscription_update
|
||||||
|
|
||||||
|
.. push_subscription_generate_keys():
|
||||||
|
|
||||||
|
.. automethod:: Mastodon.push_subscription_generate_keys
|
||||||
|
.. automethod:: Mastodon.push_subscription_decrypt_push
|
||||||
|
|
||||||
Acknowledgements
|
Acknowledgements
|
||||||
----------------
|
----------------
|
||||||
Mastodon.py contains work by a large amount of contributors, many of which have
|
Mastodon.py contains work by a large amount of contributors, many of which have
|
||||||
put significant work into making it a better library. You can find some information
|
put significant work into making it a better library. You can find some information
|
||||||
about who helped with which particular feature or fix in the changelog.
|
about who helped with which particular feature or fix in the changelog.
|
||||||
|
|
||||||
|
.. _Mastodon: https://github.com/tootsuite/mastodon
|
||||||
|
.. _Mastodon flagship instance: http://mastodon.social/
|
||||||
|
.. _Mastodon api docs: https://github.com/tootsuite/documentation/
|
|
@ -20,6 +20,11 @@ import threading
|
||||||
import sys
|
import sys
|
||||||
import six
|
import six
|
||||||
from decorator import decorate
|
from decorator import decorate
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
import http_ece
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
@ -126,15 +131,16 @@ class Mastodon:
|
||||||
__DICT_VERSION_ACCOUNT), __DICT_VERSION_STATUS), __DICT_VERSION_HASHTAG)
|
__DICT_VERSION_ACCOUNT), __DICT_VERSION_STATUS), __DICT_VERSION_HASHTAG)
|
||||||
__DICT_VERSION_ACTIVITY = "2.1.2"
|
__DICT_VERSION_ACTIVITY = "2.1.2"
|
||||||
__DICT_VERSION_REPORT = "1.1.0"
|
__DICT_VERSION_REPORT = "1.1.0"
|
||||||
|
__DICT_VERSION_PUSH = "2.4.0"
|
||||||
|
|
||||||
###
|
###
|
||||||
# Registering apps
|
# Registering apps
|
||||||
###
|
###
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_app(client_name, scopes=['read', 'write', 'follow'], redirect_uris=None, website=None, to_file=None,
|
def create_app(client_name, scopes=['read', 'write', 'follow', 'push'], redirect_uris=None, website=None, to_file=None,
|
||||||
api_base_url=__DEFAULT_BASE_URL, request_timeout=__DEFAULT_TIMEOUT):
|
api_base_url=__DEFAULT_BASE_URL, request_timeout=__DEFAULT_TIMEOUT):
|
||||||
"""
|
"""
|
||||||
Create a new app with given `client_name` and `scopes` (read, write, follow)
|
Create a new app with given `client_name` and `scopes` (read, write, follow, push)
|
||||||
|
|
||||||
Specify `redirect_uris` if you want users to be redirected to a certain page after authenticating.
|
Specify `redirect_uris` if you want users to be redirected to a certain page after authenticating.
|
||||||
Specify `to_file` to persist your apps info to a file so you can use them in the constructor.
|
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:
|
||||||
return Mastodon.__SUPPORTED_MASTODON_VERSION
|
return Mastodon.__SUPPORTED_MASTODON_VERSION
|
||||||
|
|
||||||
def auth_request_url(self, client_id=None, redirect_uris="urn:ietf:wg:oauth:2.0:oob",
|
def auth_request_url(self, client_id=None, redirect_uris="urn:ietf:wg:oauth:2.0:oob",
|
||||||
scopes=['read', 'write', 'follow']):
|
scopes=['read', 'write', 'follow', 'push']):
|
||||||
"""Returns the url that a client needs to request the grant from the server.
|
"""Returns the url that a client needs to request the grant from the server.
|
||||||
"""
|
"""
|
||||||
if client_id is None:
|
if client_id is None:
|
||||||
|
@ -323,7 +329,7 @@ class Mastodon:
|
||||||
|
|
||||||
def log_in(self, username=None, password=None,
|
def log_in(self, username=None, password=None,
|
||||||
code=None, redirect_uri="urn:ietf:wg:oauth:2.0:oob", refresh_token=None,
|
code=None, redirect_uri="urn:ietf:wg:oauth:2.0:oob", refresh_token=None,
|
||||||
scopes=['read', 'write', 'follow'], to_file=None):
|
scopes=['read', 'write', 'follow', 'push'], to_file=None):
|
||||||
"""
|
"""
|
||||||
Get the access token for a user.
|
Get the access token for a user.
|
||||||
|
|
||||||
|
@ -949,6 +955,19 @@ class Mastodon:
|
||||||
"""
|
"""
|
||||||
return self.__api_request('GET', '/api/v1/custom_emojis')
|
return self.__api_request('GET', '/api/v1/custom_emojis')
|
||||||
|
|
||||||
|
###
|
||||||
|
# Reading data: Webpush subscriptions
|
||||||
|
###
|
||||||
|
@api_version("2.4.0", "2.4.0", __DICT_VERSION_PUSH)
|
||||||
|
def push_subscription(self):
|
||||||
|
"""
|
||||||
|
Fetch the current push subscription the logged-in user has for this app.
|
||||||
|
|
||||||
|
Returns a `push subscription dict`_.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return self.__api_request('GET', '/api/v1/push/subscription')
|
||||||
|
|
||||||
###
|
###
|
||||||
# Writing data: Statuses
|
# Writing data: Statuses
|
||||||
###
|
###
|
||||||
|
@ -1455,6 +1474,8 @@ class Mastodon:
|
||||||
"""
|
"""
|
||||||
Update the metadata of the media file with the given `id`. `description` and
|
Update the metadata of the media file with the given `id`. `description` and
|
||||||
`focus` are as in `media_post()`_ .
|
`focus` are as in `media_post()`_ .
|
||||||
|
|
||||||
|
Returns the updated `media dict`_.
|
||||||
"""
|
"""
|
||||||
id = self.__unpack_id(id)
|
id = self.__unpack_id(id)
|
||||||
|
|
||||||
|
@ -1483,6 +1504,136 @@ class Mastodon:
|
||||||
params = self.__generate_params(locals())
|
params = self.__generate_params(locals())
|
||||||
self.__api_request('DELETE', '/api/v1/domain_blocks', params)
|
self.__api_request('DELETE', '/api/v1/domain_blocks', params)
|
||||||
|
|
||||||
|
###
|
||||||
|
# Writing data: Push subscriptions
|
||||||
|
###
|
||||||
|
@api_version("2.4.0", "2.4.0", __DICT_VERSION_PUSH)
|
||||||
|
def push_subscription_set(self, endpoint, encrypt_params, follow_events=False,
|
||||||
|
favourite_events=False, reblog_events=False,
|
||||||
|
mention_events=False):
|
||||||
|
"""
|
||||||
|
Sets up or modifies the push subscription the logged-in user has for this app.
|
||||||
|
|
||||||
|
`endpoint` is the endpoint URL mastodon should call for pushes. Note that mastodon
|
||||||
|
requires https for this URL. `encrypt_params` is a dict with key parameters that allow
|
||||||
|
the server to encrypt data for you: A public key `pubkey` and a shared secret `auth`.
|
||||||
|
You can generate this as well as the corresponding private key using the
|
||||||
|
`push_subscription_generate_keys()`_ .
|
||||||
|
|
||||||
|
The rest of the parameters controls what kind of events you wish to subscribe to.
|
||||||
|
|
||||||
|
Returns a `push subscription dict`_.
|
||||||
|
"""
|
||||||
|
endpoint = Mastodon.__protocolize(endpoint)
|
||||||
|
|
||||||
|
push_pubkey_b64 = base64.b64encode(encrypt_params['pubkey'])
|
||||||
|
push_auth_b64 = base64.b64encode(encrypt_params['auth'])
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'subscription[endpoint]': endpoint,
|
||||||
|
'subscription[keys][p256dh]': push_pubkey_b64,
|
||||||
|
'subscription[keys][auth]': push_auth_b64
|
||||||
|
}
|
||||||
|
|
||||||
|
if follow_events == True:
|
||||||
|
params['data[alerts][follow]'] = True
|
||||||
|
|
||||||
|
if favourite_events == True:
|
||||||
|
params['data[alerts][favourite]'] = True
|
||||||
|
|
||||||
|
if reblog_events == True:
|
||||||
|
params['data[alerts][reblog]'] = True
|
||||||
|
|
||||||
|
if mention_events == True:
|
||||||
|
params['data[alerts][mention]'] = True
|
||||||
|
|
||||||
|
return self.__api_request('POST', '/api/v1/push/subscription', params)
|
||||||
|
|
||||||
|
@api_version("2.4.0", "2.4.0", __DICT_VERSION_PUSH)
|
||||||
|
def push_subscription_update(self, endpoint, encrypt_params, follow_events=False,
|
||||||
|
favourite_events=False, reblog_events=False,
|
||||||
|
mention_events=False):
|
||||||
|
"""
|
||||||
|
Modifies what kind of events the app wishes to subscribe to.
|
||||||
|
|
||||||
|
Returns the updated `push subscription dict`_.
|
||||||
|
"""
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if follow_events == True:
|
||||||
|
params['data[alerts][follow]'] = True
|
||||||
|
|
||||||
|
if favourite_events == True:
|
||||||
|
params['data[alerts][favourite]'] = True
|
||||||
|
|
||||||
|
if reblog_events == True:
|
||||||
|
params['data[alerts][reblog]'] = True
|
||||||
|
|
||||||
|
if mention_events == True:
|
||||||
|
params['data[alerts][mention]'] = True
|
||||||
|
|
||||||
|
return self.__api_request('PUT', '/api/v1/push/subscription', params)
|
||||||
|
|
||||||
|
@api_version("2.4.0", "2.4.0", "2.4.0")
|
||||||
|
def push_subscription_delete(self):
|
||||||
|
"""
|
||||||
|
Remove the current push subscription the logged-in user has for this app.
|
||||||
|
"""
|
||||||
|
self.__api_request('DELETE', '/api/v1/push/subscription')
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# Push subscription crypto utilities
|
||||||
|
###
|
||||||
|
def push_subscription_generate_keys(self):
|
||||||
|
"""
|
||||||
|
Generates a private key, public key and shared secret for use in webpush subscriptionss.
|
||||||
|
|
||||||
|
Returns two dicts: One with the private key and shared secret and another with the
|
||||||
|
public key and shared secret.
|
||||||
|
"""
|
||||||
|
push_key_pair = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||||
|
push_key_priv = push_key_pair.private_numbers().private_value
|
||||||
|
push_key_pub = push_key_pair.public_key().public_numbers().encode_point()
|
||||||
|
push_shared_secret = os.urandom(16)
|
||||||
|
|
||||||
|
priv_dict = {
|
||||||
|
'privkey': push_key_priv,
|
||||||
|
'auth': push_shared_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
pub_dict = {
|
||||||
|
'pubkey': push_key_pub,
|
||||||
|
'auth': push_shared_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
return priv_dict, pub_dict
|
||||||
|
|
||||||
|
def push_subscription_decrypt_push(self, data, decrypt_params, encryption_header, crypto_key_header):
|
||||||
|
"""
|
||||||
|
Decrypts `data` received in a webpush request. Requires the private key dict
|
||||||
|
from `push_subscription_generate_keys()`_ (`decrypt_params`) as well as the
|
||||||
|
Encryption and server Crypto-Key headers from the received webpush
|
||||||
|
|
||||||
|
Returns the decoded webpush.
|
||||||
|
"""
|
||||||
|
salt = self.__decode_webpush_b64(encryption_header.split("salt=")[1].strip())
|
||||||
|
dhparams = self.__decode_webpush_b64(crypto_key_header.split("dh=")[1].split(";")[0].strip())
|
||||||
|
p256ecdsa = self.__decode_webpush_b64(crypto_key_header.split("p256ecdsa=")[1].strip())
|
||||||
|
dec_key = ec.derive_private_key(decrypt_params['privkey'], ec.SECP256R1(), default_backend())
|
||||||
|
decrypted = http_ece.decrypt(
|
||||||
|
data,
|
||||||
|
salt = salt,
|
||||||
|
key = p256ecdsa,
|
||||||
|
private_key = dec_key,
|
||||||
|
dh = dhparams,
|
||||||
|
auth_secret=decrypt_params['auth'],
|
||||||
|
keylabel = "P-256",
|
||||||
|
version = "aesgcm"
|
||||||
|
)
|
||||||
|
|
||||||
|
return json.loads(decrypted.decode('utf-8'), object_hook = Mastodon.__json_hooks)
|
||||||
|
|
||||||
###
|
###
|
||||||
# Pagination
|
# Pagination
|
||||||
###
|
###
|
||||||
|
@ -1983,7 +2134,16 @@ class Mastodon:
|
||||||
return id["id"]
|
return id["id"]
|
||||||
else:
|
else:
|
||||||
return id
|
return id
|
||||||
|
|
||||||
|
def __decode_webpush_b64(self, data):
|
||||||
|
"""
|
||||||
|
Re-pads and decodes urlsafe base64.
|
||||||
|
"""
|
||||||
|
missing_padding = len(data) % 4
|
||||||
|
if missing_padding != 0:
|
||||||
|
data += '=' * (4 - missing_padding)
|
||||||
|
return base64.urlsafe_b64decode(data)
|
||||||
|
|
||||||
def __get_token_expired(self):
|
def __get_token_expired(self):
|
||||||
"""Internal helper for oauth code"""
|
"""Internal helper for oauth code"""
|
||||||
return self._token_expired < datetime.datetime.now()
|
return self._token_expired < datetime.datetime.now()
|
||||||
|
|
10
setup.py
10
setup.py
|
@ -10,7 +10,15 @@ setup(name='Mastodon.py',
|
||||||
description='Python wrapper for the Mastodon API',
|
description='Python wrapper for the Mastodon API',
|
||||||
packages=['mastodon'],
|
packages=['mastodon'],
|
||||||
setup_requires=['pytest-runner'],
|
setup_requires=['pytest-runner'],
|
||||||
install_requires=['requests', 'python-dateutil', 'six', 'pytz', 'decorator>=4.0.0'],
|
install_requires=[
|
||||||
|
'requests',
|
||||||
|
'python-dateutil',
|
||||||
|
'six',
|
||||||
|
'pytz',
|
||||||
|
'decorator>=4.0.0',
|
||||||
|
'http_ece>=1.0.5',
|
||||||
|
'cryptography>=1.6.0'
|
||||||
|
],
|
||||||
tests_require=test_deps,
|
tests_require=test_deps,
|
||||||
extras_require=extras,
|
extras_require=extras,
|
||||||
url='https://github.com/halcy/Mastodon.py',
|
url='https://github.com/halcy/Mastodon.py',
|
||||||
|
|
Loading…
Referencia en una nova incidència