bridgy-fed/activitypub.py

257 wiersze
9.3 KiB
Python
Czysty Zwykły widok Historia

"""Handles requests for ActivityPub endpoints: actors, inbox, etc.
"""
from base64 import b64encode
import datetime
from hashlib import sha256
import logging
from google.cloud import ndb
from granary import as2, microformats2
import mf2util
from oauth_dropins.webutil import util
from oauth_dropins.webutil.handlers import cache_response
from oauth_dropins.webutil.util import json_dumps, json_loads
import webapp2
import common
from models import Follower, MagicKey
from httpsig.requests_auth import HTTPSignatureAuth
CACHE_TIME = datetime.timedelta(seconds=15)
SUPPORTED_TYPES = (
2018-10-25 04:44:21 +00:00
'Accept',
'Announce',
'Article',
'Audio',
'Create',
'Delete',
'Follow',
'Image',
'Like',
'Note',
'Undo',
'Video',
)
def send(activity, inbox_url, user_domain):
"""Sends an ActivityPub request to an inbox.
Args:
activity: dict, AS2 activity
inbox_url: string
user_domain: string, domain of the bridgy fed user sending the request
Returns:
requests.Response
"""
logging.info('Sending AP request from %s: %s', user_domain,
json_dumps(activity, indent=2))
# prepare HTTP Signature (required by Mastodon)
# https://w3c.github.io/activitypub/#authorization
# https://tools.ietf.org/html/draft-cavage-http-signatures-07
# https://github.com/tootsuite/mastodon/issues/4906#issuecomment-328844846
acct = 'acct:%s@%s' % (user_domain, user_domain)
key = MagicKey.get_or_create(user_domain)
auth = HTTPSignatureAuth(secret=key.private_pem(), key_id=acct,
algorithm='rsa-sha256', sign_header='signature',
headers=('Date', 'Digest', 'Host'))
# deliver to inbox
body = json_dumps(activity).encode()
headers = {
'Content-Type': common.CONTENT_TYPE_AS2,
# required for HTTP Signature
# https://tools.ietf.org/html/draft-cavage-http-signatures-07#section-2.1.3
'Date': datetime.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT'),
# required by Mastodon
# https://github.com/tootsuite/mastodon/pull/14556#issuecomment-674077648
'Digest': 'SHA-256=' + b64encode(sha256(body).digest()).decode(),
'Host': util.domain_from_link(inbox_url),
}
return common.requests_post(inbox_url, data=body, auth=auth,
headers=headers)
2021-07-09 05:50:33 +00:00
class ActorHandler():
"""Serves /[DOMAIN], fetches its mf2, converts to AS Actor, and serves it."""
@cache_response(CACHE_TIME)
def get(self, domain):
tld = domain.split('.')[-1]
if tld in common.TLD_BLOCKLIST:
self.error('', status=404)
mf2 = util.fetch_mf2('http://%s/' % domain, gateway=True,
headers=common.HEADERS)
# logging.info('Parsed mf2 for %s: %s', resp.url, json_dumps(mf2, indent=2))
hcard = mf2util.representative_hcard(mf2, mf2['url'])
logging.info('Representative h-card: %s', json_dumps(hcard, indent=2))
if not hcard:
self.error("""\
Couldn't find a representative h-card (http://microformats.org/wiki/representative-hcard-parsing) on %s""" % mf2['url'])
key = MagicKey.get_or_create(domain)
obj = self.postprocess_as2(as2.from_as1(microformats2.json_to_object(hcard)),
key=key)
obj.update({
2021-07-09 05:50:33 +00:00
'inbox': f'{request.host_url}{domain}/inbox',
'outbox': f'{request.host_url}{domain}/outbox',
'following': f'{request.host_url}{domain}/following',
'followers': f'{request.host_url}{domain}/followers',
})
logging.info('Returning: %s', json_dumps(obj, indent=2))
self.response.headers.update({
'Content-Type': common.CONTENT_TYPE_AS2,
'Access-Control-Allow-Origin': '*',
})
self.response.write(json_dumps(obj, indent=2))
2021-07-09 05:50:33 +00:00
class InboxHandler():
"""Accepts POSTs to /[DOMAIN]/inbox and converts to outbound webmentions."""
def post(self, domain):
logging.info('Got: %s', self.request.body)
# parse and validate AS2 activity
try:
activity = json_loads(self.request.body)
assert activity
except (TypeError, ValueError, AssertionError):
self.error("Couldn't parse body as JSON", exc_info=True)
obj = activity.get('object') or {}
if isinstance(obj, str):
obj = {'id': obj}
type = activity.get('type')
2018-10-25 04:44:21 +00:00
if type == 'Accept': # eg in response to a Follow
return # noop
if type == 'Create':
type = obj.get('type')
2018-10-25 04:44:21 +00:00
elif type not in SUPPORTED_TYPES:
self.error('Sorry, %s activities are not supported yet.' % type,
status=501)
# TODO: verify signature if there is one
if type == 'Undo' and obj.get('type') == 'Follow':
# skip actor fetch below; we don't need it to undo a follow
return self.undo_follow(self.redirect_unwrap(activity))
elif type == 'Delete':
id = obj.get('id')
# !!! temporarily disabled actually deleting Followers below because
# mastodon.social sends Deletes for every Bridgy Fed account, all at
# basically the same time, and we have many Follower objects, so we
# have to do this table scan for each one, so the requests take a
# long time and end up spawning extra App Engine instances that we
# get billed for. and the Delete requests are almost never for
# followers we have. TODO: revisit this and do it right.
# if isinstance(id, str):
# # assume this is an actor
# # https://github.com/snarfed/bridgy-fed/issues/63
# for key in Follower.query().iter(keys_only=True):
# if key.id().split(' ')[-1] == id:
# key.delete()
return
# fetch actor if necessary so we have name, profile photo, etc
for elem in obj, activity:
actor = elem.get('actor')
if actor and isinstance(actor, str):
elem['actor'] = common.get_as2(actor).json()
activity_unwrapped = self.redirect_unwrap(activity)
if type == 'Follow':
return self.accept_follow(activity, activity_unwrapped)
# send webmentions to each target
as1 = as2.to_as1(activity)
self.send_webmentions(as1, proxy=True, protocol='activitypub',
source_as2=json_dumps(activity_unwrapped))
def accept_follow(self, follow, follow_unwrapped):
"""Replies to an AP Follow request with an Accept request.
Args:
follow: dict, AP Follow activity
follow_unwrapped: dict, same, except with redirect URLs unwrapped
"""
logging.info('Replying to Follow with Accept')
followee = follow.get('object')
followee_unwrapped = follow_unwrapped.get('object')
follower = follow.get('actor')
if not followee or not followee_unwrapped or not follower:
self.error('Follow activity requires object and actor. Got: %s' % follow)
inbox = follower.get('inbox')
follower_id = follower.get('id')
if not inbox or not follower_id:
self.error('Follow actor requires id and inbox. Got: %s', follower)
# store Follower
user_domain = util.domain_from_link(followee_unwrapped)
Follower.get_or_create(user_domain, follower_id, last_follow=json_dumps(follow))
# send AP Accept
accept = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': util.tag_uri(self.request.host, 'accept/%s/%s' % (
(user_domain, follow.get('id')))),
'type': 'Accept',
'actor': followee,
'object': {
'type': 'Follow',
'actor': follower_id,
'object': followee,
}
}
resp = send(accept, inbox, user_domain)
self.response.status_int = resp.status_code
self.response.write(resp.text)
# send webmention
self.send_webmentions(as2.to_as1(follow), proxy=True, protocol='activitypub',
source_as2=json_dumps(follow_unwrapped))
@ndb.transactional()
def undo_follow(self, undo_unwrapped):
"""Replies to an AP Follow request with an Accept request.
Args:
undo_unwrapped: dict, AP Undo activity with redirect URLs unwrapped
"""
logging.info('Undoing Follow')
follow = undo_unwrapped.get('object', {})
follower = follow.get('actor')
followee = follow.get('object')
if not follower or not followee:
self.error('Undo of Follow requires object with actor and object. Got: %s' % follow)
# deactivate Follower
user_domain = util.domain_from_link(followee)
follower_obj = Follower.get_by_id(Follower._id(user_domain, follower))
if follower_obj:
logging.info('Marking %s as inactive' % follower_obj.key)
follower_obj.status = 'inactive'
follower_obj.put()
else:
logging.warning('No Follower found for %s %s', user_domain, follower)
# TODO send webmention with 410 of u-follow
ROUTES = [
(r'/%s/?' % common.DOMAIN_RE, ActorHandler),
(r'/%s/inbox' % common.DOMAIN_RE, InboxHandler),
]