def test_get_as2_via_html(self, mock_get): resp = common.get_as2('http://orig') self.assertEqual(AS2, resp) mock_get.assert_has_calls(( self.req('http://orig', headers=common.CONNEG_HEADERS_AS2_HTML), self.req('http://as2', headers=common.CONNEG_HEADERS_AS2), ))
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): common.error(self, "Couldn't parse body as JSON", exc_info=True) type = activity.get('type') if type not in SUPPORTED_TYPES: common.error(self, 'Sorry, %s activities are not supported yet.' % type, status=501) # TODO: verify signature if there is one # fetch actor if necessary so we have name, profile photo, etc if activity.get('type') in ('Like', 'Announce'): actor = activity.get('actor') if actor: activity['actor'] = common.get_as2(actor).json() # send webmentions to each target as1 = as2.to_as1(activity) common.send_webmentions(self, as1, protocol='activitypub', source_as2=json.dumps(activity))
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') if type == 'Accept': # eg in response to a Follow return # noop if type == 'Create': type = obj.get('type') 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') 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 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): common.error(self, "Couldn't parse body as JSON", exc_info=True) obj = activity.get('object') or {} if isinstance(obj, basestring): obj = {'id': obj} type = activity.get('type') if type == 'Accept': # eg in response to a Follow return # noop if type == 'Create': type = obj.get('type') elif type not in SUPPORTED_TYPES: common.error(self, 'Sorry, %s activities are not supported yet.' % type, status=501) # TODO: verify signature if there is one # 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, basestring): elem['actor'] = common.get_as2(actor).json() activity_unwrapped = common.redirect_unwrap(activity) if type == 'Follow': self.accept_follow(activity, activity_unwrapped) return # send webmentions to each target as1 = as2.to_as1(activity) common.send_webmentions(self, as1, proxy=True, protocol='activitypub', source_as2=json.dumps(activity_unwrapped))
def _activitypub_targets(self): """ Returns: list of (Response, string inbox URL) """ # if there's in-reply-to, like-of, or repost-of, they're the targets. # otherwise, it's all followers' inboxes. targets = self._targets() if not targets: # interpret this as a Create or Update, deliver it to followers inboxes = [] for follower in Follower.query().filter( Follower.key > Key('Follower', self.source_domain + ' '), Follower.key < Key( 'Follower', self.source_domain + chr(ord(' ') + 1))): if follower.status != 'inactive' and follower.last_follow: actor = json_loads(follower.last_follow).get('actor') if actor and isinstance(actor, dict): inboxes.append( actor.get('endpoints', {}).get('sharedInbox') or actor.get('publicInbox') or actor.get('inbox')) return [(Response.get_or_create(source=self.source_url, target=inbox, direction='out', protocol='activitypub', source_mf2=json_dumps( self.source_mf2)), inbox) for inbox in inboxes if inbox] resps_and_inbox_urls = [] for target in targets: # fetch target page as AS2 object try: self.target_resp = common.get_as2(target) except (requests.HTTPError, exc.HTTPBadGateway) as e: self.target_resp = getattr(e, 'response', None) if self.target_resp and self.target_resp.status_code // 100 == 2: content_type = common.content_type(self.target_resp) or '' if content_type.startswith('text/html'): # TODO: pass e.response to try_salmon()'s target_resp continue # give up raise target_url = self.target_resp.url or target resp = Response.get_or_create(source=self.source_url, target=target_url, direction='out', protocol='activitypub', source_mf2=json_dumps( self.source_mf2)) # find target's inbox target_obj = self.target_resp.json() resp.target_as2 = json_dumps(target_obj) inbox_url = target_obj.get('inbox') if not inbox_url: # TODO: test actor/attributedTo and not, with/without inbox actor = (util.get_first(target_obj, 'actor') or util.get_first(target_obj, 'attributedTo')) if isinstance(actor, dict): inbox_url = actor.get('inbox') actor = actor.get('url') or actor.get('id') if not inbox_url and not actor: self.error( 'Target object has no actor or attributedTo with URL or id.' ) elif not isinstance(actor, str): self.error( 'Target actor or attributedTo has unexpected url or id object: %r' % actor) if not inbox_url: # fetch actor as AS object actor = common.get_as2(actor).json() inbox_url = actor.get('inbox') if not inbox_url: # TODO: probably need a way to save errors like this so that we can # return them if ostatus fails too. # self.error('Target actor has no inbox') continue inbox_url = urllib.parse.urljoin(target_url, inbox_url) resps_and_inbox_urls.append((resp, inbox_url)) return resps_and_inbox_urls
def test_get_as2_not_acceptable(self, mock_get): with self.assertRaises(exc.HTTPBadGateway): resp = common.get_as2('http://orig')
def test_get_as2_only_html(self, mock_get): with self.assertRaises(exc.HTTPBadGateway): resp = common.get_as2('http://orig')
def try_activitypub(self): source = util.get_required_param(self, 'source') # fetch source page, convert to ActivityStreams source_resp = common.requests_get(source) source_url = source_resp.url or source source_mf2 = mf2py.parse(source_resp.text, url=source_url) # logging.debug('Parsed mf2 for %s: %s', source_resp.url, json.dumps(source_mf2, indent=2)) entry = mf2util.find_first_entry(source_mf2, ['h-entry']) logging.info('First entry: %s', json.dumps(entry, indent=2)) # make sure it has url, since we use that for AS2 id, which is required # for ActivityPub. props = entry.setdefault('properties', {}) if not props.get('url'): props['url'] = [source_url] source_obj = microformats2.json_to_object(entry, fetch_mf2=True) logging.info('Converted to AS: %s', json.dumps(source_obj, indent=2)) # fetch target page as AS object. target is first in-reply-to, like-of, # or repost-of, *not* target query param.) target = util.get_url(util.get_first(source_obj, 'inReplyTo') or util.get_first(source_obj, 'object')) if not target: common.error(self, 'No u-in-reply-to, u-like-of, or u-repost-of ' 'found in %s' % source_url) try: target_resp = common.get_as2(target) except (requests.HTTPError, exc.HTTPBadGateway) as e: if (e.response.status_code // 100 == 2 and common.content_type(e.response).startswith('text/html')): self.resp = Response.get_or_create( source=source_url, target=e.response.url or target, direction='out', source_mf2=json.dumps(source_mf2)) return self.send_salmon(source_obj, target_resp=e.response) raise target_url = target_resp.url or target self.resp = Response.get_or_create( source=source_url, target=target_url, direction='out', protocol='activitypub', source_mf2=json.dumps(source_mf2)) # find actor's inbox target_obj = target_resp.json() inbox_url = target_obj.get('inbox') if not inbox_url: # TODO: test actor/attributedTo and not, with/without inbox actor = target_obj.get('actor') or target_obj.get('attributedTo') if isinstance(actor, dict): inbox_url = actor.get('inbox') actor = actor.get('url') if not inbox_url and not actor: common.error(self, 'Target object has no actor or attributedTo URL') if not inbox_url: # fetch actor as AS object actor = common.get_as2(actor).json() inbox_url = actor.get('inbox') if not inbox_url: # TODO: probably need a way to save errors like this so that we can # return them if ostatus fails too. # common.error(self, 'Target actor has no inbox') return self.send_salmon(source_obj, target_resp=target_resp) # convert to AS2 source_domain = urlparse.urlparse(source_url).netloc key = MagicKey.get_or_create(source_domain) source_activity = common.postprocess_as2( as2.from_as1(source_obj), target=target_obj, key=key) if self.resp.status == 'complete': source_activity['type'] = 'Update' # 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' % (source_domain, source_domain) auth = HTTPSignatureAuth(secret=key.private_pem(), key_id=acct, algorithm='rsa-sha256') # deliver source object to target actor's 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'), } inbox_url = urlparse.urljoin(target_url, inbox_url) resp = common.requests_post(inbox_url, json=source_activity, auth=auth, headers=headers) self.response.status_int = resp.status_code if resp.status_code == 202: self.response.write('202 response! If this is Mastodon 1.x, their ' 'signature verification probably failed. :(\n') self.response.write(resp.text)
def test_get_ssl_error(self, mock_get): with self.assertRaises(BadGateway): resp = common.get_as2('http://orig')
def inbox(domain): """Accepts POSTs to /[DOMAIN]/inbox and converts to outbound webmentions.""" body = request.get_data(as_text=True) logging.info(f'Got: {body}') # parse and validate AS2 activity try: activity = request.json assert activity except (TypeError, ValueError, AssertionError): 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') if type == 'Accept': # eg in response to a Follow return '' # noop if type == 'Create': type = obj.get('type') elif type not in SUPPORTED_TYPES: 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 undo_follow(redirect_unwrap(activity)) return '' 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 = redirect_unwrap(activity) if type == 'Follow': return accept_follow(activity, activity_unwrapped) # send webmentions to each target as1 = as2.to_as1(activity) common.send_webmentions(as1, proxy=True, protocol='activitypub', source_as2=json_dumps(activity_unwrapped)) return ''