2017-08-13 07:12:16 +00:00
|
|
|
"""Handles requests for ActivityPub endpoints: actors, inbox, etc.
|
|
|
|
"""
|
2018-10-19 13:50:00 +00:00
|
|
|
import datetime
|
2017-08-13 07:12:16 +00:00
|
|
|
import json
|
|
|
|
import logging
|
2018-10-19 13:50:00 +00:00
|
|
|
import string
|
2017-08-13 07:12:16 +00:00
|
|
|
|
|
|
|
import appengine_config
|
|
|
|
|
2017-09-28 14:25:21 +00:00
|
|
|
from granary import as2, microformats2
|
2017-08-13 07:12:16 +00:00
|
|
|
import mf2py
|
|
|
|
import mf2util
|
2017-08-13 21:49:35 +00:00
|
|
|
from oauth_dropins.webutil import util
|
2017-08-13 07:12:16 +00:00
|
|
|
import webapp2
|
|
|
|
|
2017-08-15 06:07:24 +00:00
|
|
|
import common
|
2018-10-22 00:37:33 +00:00
|
|
|
from models import Follower, MagicKey, Response
|
2018-10-19 13:50:00 +00:00
|
|
|
from httpsig.requests_auth import HTTPSignatureAuth
|
2017-08-15 06:07:24 +00:00
|
|
|
|
2017-10-17 05:21:13 +00:00
|
|
|
SUPPORTED_TYPES = (
|
2018-10-25 04:44:21 +00:00
|
|
|
'Accept',
|
2017-10-17 05:21:13 +00:00
|
|
|
'Announce',
|
|
|
|
'Article',
|
|
|
|
'Audio',
|
2018-10-15 15:09:36 +00:00
|
|
|
'Create',
|
2018-10-19 13:50:00 +00:00
|
|
|
'Follow',
|
2017-10-17 05:21:13 +00:00
|
|
|
'Image',
|
|
|
|
'Like',
|
|
|
|
'Note',
|
|
|
|
'Video',
|
|
|
|
)
|
2017-08-15 06:07:24 +00:00
|
|
|
|
2017-10-20 14:13:04 +00:00
|
|
|
|
2018-10-21 22:28:42 +00:00
|
|
|
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
|
|
|
|
"""
|
2018-11-13 15:26:50 +00:00
|
|
|
logging.info('Sending AP request from %s: %s', user_domain,
|
|
|
|
json.dumps(activity, indent=2))
|
2018-10-21 22:28:42 +00:00
|
|
|
|
|
|
|
# prepare HTTP Signature (required by Mastodon)
|
|
|
|
# https://w3c.github.io/activitypub/#authorization-lds
|
|
|
|
# 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')
|
|
|
|
|
|
|
|
# deliver to inbox
|
|
|
|
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'),
|
|
|
|
}
|
|
|
|
return common.requests_post(inbox_url, json=activity, auth=auth, headers=headers)
|
|
|
|
|
|
|
|
|
2017-08-13 07:12:16 +00:00
|
|
|
class ActorHandler(webapp2.RequestHandler):
|
|
|
|
"""Serves /[DOMAIN], fetches its mf2, converts to AS Actor, and serves it."""
|
|
|
|
|
|
|
|
def get(self, domain):
|
2017-08-19 19:29:10 +00:00
|
|
|
url = 'http://%s/' % domain
|
2017-08-15 06:07:24 +00:00
|
|
|
resp = common.requests_get(url)
|
2018-10-12 02:12:18 +00:00
|
|
|
mf2 = mf2py.parse(resp.text, url=resp.url, img_with_alt=True)
|
2017-10-24 14:23:51 +00:00
|
|
|
# logging.info('Parsed mf2 for %s: %s', resp.url, json.dumps(mf2, indent=2))
|
2017-08-13 07:12:16 +00:00
|
|
|
|
|
|
|
hcard = mf2util.representative_hcard(mf2, resp.url)
|
|
|
|
logging.info('Representative h-card: %s', json.dumps(hcard, indent=2))
|
2017-08-19 20:31:06 +00:00
|
|
|
if not hcard:
|
2017-10-15 23:57:33 +00:00
|
|
|
common.error(self, """\
|
2017-08-19 20:31:06 +00:00
|
|
|
Couldn't find a <a href="http://microformats.org/wiki/representative-hcard-parsing">\
|
|
|
|
representative h-card</a> on %s""" % resp.url)
|
2017-08-13 07:12:16 +00:00
|
|
|
|
2017-10-10 00:29:50 +00:00
|
|
|
key = MagicKey.get_or_create(domain)
|
2017-10-01 14:01:35 +00:00
|
|
|
obj = common.postprocess_as2(as2.from_as1(microformats2.json_to_object(hcard)),
|
|
|
|
key=key)
|
2017-08-13 07:12:16 +00:00
|
|
|
obj.update({
|
2017-09-30 14:56:40 +00:00
|
|
|
'inbox': '%s/%s/inbox' % (appengine_config.HOST_URL, domain),
|
2019-01-04 15:04:45 +00:00
|
|
|
'outbox': '%s/%s/outbox' % (appengine_config.HOST_URL, domain),
|
2017-08-13 07:12:16 +00:00
|
|
|
})
|
|
|
|
logging.info('Returning: %s', json.dumps(obj, indent=2))
|
|
|
|
|
|
|
|
self.response.headers.update({
|
2017-10-20 14:13:04 +00:00
|
|
|
'Content-Type': common.CONTENT_TYPE_AS2,
|
2017-08-13 07:12:16 +00:00
|
|
|
'Access-Control-Allow-Origin': '*',
|
|
|
|
})
|
|
|
|
self.response.write(json.dumps(obj, indent=2))
|
|
|
|
|
|
|
|
|
2017-08-13 21:49:35 +00:00
|
|
|
class InboxHandler(webapp2.RequestHandler):
|
|
|
|
"""Accepts POSTs to /[DOMAIN]/inbox and converts to outbound webmentions."""
|
|
|
|
|
|
|
|
def post(self, domain):
|
|
|
|
logging.info('Got: %s', self.request.body)
|
2017-10-13 06:14:46 +00:00
|
|
|
|
|
|
|
# parse and validate AS2 activity
|
2017-08-13 21:49:35 +00:00
|
|
|
try:
|
2017-10-11 05:42:19 +00:00
|
|
|
activity = json.loads(self.request.body)
|
|
|
|
assert activity
|
2017-10-17 14:46:42 +00:00
|
|
|
except (TypeError, ValueError, AssertionError):
|
|
|
|
common.error(self, "Couldn't parse body as JSON", exc_info=True)
|
2017-10-17 05:21:13 +00:00
|
|
|
|
2018-10-15 15:09:36 +00:00
|
|
|
obj = activity.get('object') or {}
|
|
|
|
if isinstance(obj, basestring):
|
|
|
|
obj = {'id': obj}
|
|
|
|
|
2017-10-17 05:21:13 +00:00
|
|
|
type = activity.get('type')
|
2018-10-25 04:44:21 +00:00
|
|
|
if type == 'Accept': # eg in response to a Follow
|
|
|
|
return # noop
|
2018-10-15 15:09:36 +00:00
|
|
|
if type == 'Create':
|
|
|
|
type = obj.get('type')
|
2018-10-25 04:44:21 +00:00
|
|
|
elif type not in SUPPORTED_TYPES:
|
2017-10-17 05:21:13 +00:00
|
|
|
common.error(self, 'Sorry, %s activities are not supported yet.' % type,
|
|
|
|
status=501)
|
2017-08-13 21:49:35 +00:00
|
|
|
|
2017-10-01 14:01:35 +00:00
|
|
|
# TODO: verify signature if there is one
|
|
|
|
|
2017-10-13 06:14:46 +00:00
|
|
|
# fetch actor if necessary so we have name, profile photo, etc
|
2018-11-15 15:02:36 +00:00
|
|
|
for elem in obj, activity:
|
|
|
|
actor = elem.get('actor')
|
|
|
|
if actor and isinstance(actor, basestring):
|
|
|
|
elem['actor'] = common.get_as2(actor).json()
|
2017-10-13 06:14:46 +00:00
|
|
|
|
2018-10-23 14:52:30 +00:00
|
|
|
activity_unwrapped = common.redirect_unwrap(activity)
|
|
|
|
if type == 'Follow':
|
|
|
|
self.accept_follow(activity, activity_unwrapped)
|
|
|
|
return
|
|
|
|
|
2017-10-13 06:14:46 +00:00
|
|
|
# send webmentions to each target
|
2018-10-23 14:52:30 +00:00
|
|
|
as1 = as2.to_as1(activity_unwrapped)
|
2018-10-17 14:49:04 +00:00
|
|
|
common.send_webmentions(self, as1, proxy=True, protocol='activitypub',
|
2018-10-23 14:52:30 +00:00
|
|
|
source_as2=json.dumps(activity_unwrapped))
|
2017-08-13 21:49:35 +00:00
|
|
|
|
2018-10-23 14:52:30 +00:00
|
|
|
def accept_follow(self, follow, follow_unwrapped):
|
2018-10-21 22:28:42 +00:00
|
|
|
"""Replies to an AP Follow request with an Accept request.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
follow: dict, AP Follow activity
|
2018-10-23 14:52:30 +00:00
|
|
|
follow_unwrapped: dict, same, except with redirect URLs unwrapped
|
2018-10-21 22:28:42 +00:00
|
|
|
"""
|
|
|
|
logging.info('Replying to Follow with Accept')
|
|
|
|
|
2018-10-23 14:52:30 +00:00
|
|
|
followee = follow.get('object')
|
|
|
|
followee_unwrapped = follow_unwrapped.get('object')
|
|
|
|
follower = follow.get('actor')
|
|
|
|
if not followee or not followee_unwrapped or not follower:
|
2018-10-21 22:28:42 +00:00
|
|
|
common.error(self, 'Follow activity requires object and actor. Got: %s' % follow)
|
2018-10-19 13:50:00 +00:00
|
|
|
|
2018-10-23 14:52:30 +00:00
|
|
|
inbox = follower.get('inbox')
|
|
|
|
follower_id = follower.get('id')
|
|
|
|
if not inbox or not follower_id:
|
|
|
|
common.error(self, 'Follow actor requires id and inbox. Got: %s', follower)
|
2018-10-21 22:28:42 +00:00
|
|
|
|
2018-10-22 00:37:33 +00:00
|
|
|
# store Follower
|
2018-10-23 14:52:30 +00:00
|
|
|
user_domain = util.domain_from_link(followee_unwrapped)
|
|
|
|
Follower.get_or_create(user_domain, follower_id, last_follow=json.dumps(follow))
|
2018-10-22 00:37:33 +00:00
|
|
|
|
|
|
|
# send AP Accept
|
2018-10-19 13:50:00 +00:00
|
|
|
accept = {
|
|
|
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
2018-10-21 22:28:42 +00:00
|
|
|
'id': util.tag_uri(appengine_config.HOST, 'accept/%s/%s' % (
|
|
|
|
(user_domain, follow.get('id')))),
|
2018-10-19 13:50:00 +00:00
|
|
|
'type': 'Accept',
|
2018-10-23 14:52:30 +00:00
|
|
|
'actor': followee,
|
2018-10-19 13:50:00 +00:00
|
|
|
'object': {
|
2018-10-23 14:52:30 +00:00
|
|
|
'type': 'Follow',
|
|
|
|
'actor': follower_id,
|
|
|
|
'object': followee,
|
2018-10-19 13:50:00 +00:00
|
|
|
}
|
|
|
|
}
|
2018-10-21 22:28:42 +00:00
|
|
|
resp = send(accept, inbox, user_domain)
|
|
|
|
self.response.status_int = resp.status_code
|
2018-10-19 13:50:00 +00:00
|
|
|
self.response.write(resp.text)
|
|
|
|
|
2018-10-23 14:11:44 +00:00
|
|
|
# send webmention
|
2018-10-23 14:52:30 +00:00
|
|
|
common.send_webmentions(
|
|
|
|
self, as2.to_as1(follow), proxy=True, protocol='activitypub',
|
|
|
|
source_as2=json.dumps(follow_unwrapped))
|
2018-10-23 14:11:44 +00:00
|
|
|
|
2017-08-13 21:49:35 +00:00
|
|
|
|
|
|
|
app = webapp2.WSGIApplication([
|
2017-09-03 19:54:10 +00:00
|
|
|
(r'/%s/?' % common.DOMAIN_RE, ActorHandler),
|
|
|
|
(r'/%s/inbox' % common.DOMAIN_RE, InboxHandler),
|
2017-08-13 21:49:35 +00:00
|
|
|
], debug=appengine_config.DEBUG)
|