def actor(domain): """Serves /[DOMAIN], fetches its mf2, converts to AS Actor, and serves it.""" tld = domain.split('.')[-1] if tld in common.TLD_BLOCKLIST: error('', status=404) mf2 = util.fetch_mf2(f'http://{domain}/', gateway=True, headers=common.HEADERS) hcard = mf2util.representative_hcard(mf2, mf2['url']) logging.info(f'Representative h-card: {json_dumps(hcard, indent=2)}') if not hcard: error( f"Couldn't find a representative h-card (http://microformats.org/wiki/representative-hcard-parsing) on {mf2['url']}" ) key = MagicKey.get_or_create(domain) obj = common.postprocess_as2(as2.from_as1( microformats2.json_to_object(hcard)), key=key) obj.update({ 'preferredUsername': domain, '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(f'Returning: {json_dumps(obj, indent=2)}') return (obj, { 'Content-Type': common.CONTENT_TYPE_AS2, 'Access-Control-Allow-Origin': '*', })
def render(): """Fetches a stored Response and renders it as HTML.""" source = flask_util.get_required_param('source') target = flask_util.get_required_param('target') id = f'{source} {target}' resp = Response.get_by_id(id) if not resp: error(f'No stored response for {id}', status=404) if resp.source_mf2: as1 = microformats2.json_to_object(json_loads(resp.source_mf2)) elif resp.source_as2: as1 = as2.to_as1(json_loads(resp.source_as2)) elif resp.source_atom: as1 = atom.atom_to_activity(resp.source_atom) else: error(f'Stored response for {id} has no data', status=404) # add HTML meta redirect to source page. should trigger for end users in # browsers but not for webmention receivers (hopefully). html = microformats2.activities_to_html([as1]) utf8 = '<meta charset="utf-8">' refresh = f'<meta http-equiv="refresh" content="0;url={source}">' return html.replace(utf8, utf8 + '\n' + refresh)
def redir(to): """301 redirect to the embedded fully qualified URL. e.g. redirects /r/https://foo.com/bar?baz to https://foo.com/bar?baz """ if request.args: to += '?' + urllib.parse.urlencode(request.args) # some browsers collapse repeated /s in the path down to a single slash. # if that happened to this URL, expand it back to two /s. to = re.sub(r'^(https?:/)([^/])', r'\1/\2', to) if not to.startswith('http://') and not to.startswith('https://'): error(f'Expected fully qualified URL; got {to}') # check that we've seen this domain before so we're not an open redirect domains = set( (util.domain_from_link(to), urllib.parse.urlparse(to).hostname)) for domain in domains: if domain and MagicKey.get_by_id(domain): logging.info(f'Found MagicKey for domain {domain}') break else: logging.info(f'No user found for any of {domains}; returning 404') abort(404) # poor man's conneg, only handle single Accept values, not multiple with # priorities. if request.headers.get('Accept') in (common.CONTENT_TYPE_AS2, common.CONTENT_TYPE_AS2_LD): return convert_to_as2(to) # redirect logging.info(f'redirecting to {to}') return redirect(to, code=301)
def render(activities, actor=None): # Pass images and videos through caching proxy to cache them for a in activities: microformats2.prefix_image_urls(a, IMAGE_PROXY_URL_BASE) microformats2.prefix_video_urls(a, VIDEO_PROXY_URL_BASE) # Generate output format = request.args.get('format') or 'atom' if format == 'atom': title = 'instagram-atom feed for %s' % source.Source.actor_name(actor) return atom.activities_to_atom( activities, actor, title=title, host_url=request.host_url, request_url=request.url, xml_base='https://www.instagram.com/', ), { 'Content-Type': 'application/atom+xml' } elif format == 'html': return microformats2.activities_to_html(activities) else: flask_util.error(f'format must be either atom or html; got {format}')
def new(cls, auth_entity=None, actor=None, **kwargs): """Creates and returns an entity based on an AS1 actor. Args: auth_entity: unused actor: dict AS1 actor """ assert not auth_entity assert actor if not kwargs.get('features'): kwargs['features'] = ['listen'] try: id = cls.key_id_from_actor(actor) except KeyError as e: flask_util.error(f'Missing AS1 actor field: {e}') src = cls(id=id, name=actor.get('displayName'), picture=actor.get('image', {}).get('url'), **kwargs) src.domain_urls, src.domains = src.urls_and_domains( None, None, actor=actor, resolve_source_domain=False) return src
def error(msg, status=400): """Return plain text errors for display in the browser extension.""" flask_util.error(msg, status=status, response=make_response( msg, status, {'Content-Type': 'text/plain; charset=utf-8'}))
def error(self, error, html=None, status=400, data=None, report=False, **kwargs): error = html or util.linkify(error) logging.info(f'publish: {error}') if report: self.report_error(error, status=status) flask_util.error(error, status=status)
def accept_follow(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: 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: 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(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) # send webmention common.send_webmentions(as2.to_as1(follow), proxy=True, protocol='activitypub', source_as2=json_dumps(follow_unwrapped)) return resp.text, resp.status_code
def slap(acct): """Accepts POSTs to /[ACCT]/salmon and converts to outbound webmentions.""" # TODO: unify with activitypub body = request.get_data(as_text=True) logging.info(f'Got: {body}') try: parsed = utils.parse_magic_envelope(body) except ParseError as e: error('Could not parse POST body as XML', exc_info=True) data = parsed['data'] logging.info(f'Decoded: {data}') # check that we support this activity type try: activity = atom.atom_to_activity(data) except ParseError as e: error('Could not parse envelope data as XML', exc_info=True) verb = activity.get('verb') if verb and verb not in SUPPORTED_VERBS: error(f'Sorry, {verb} activities are not supported yet.', status=501) # verify author and signature author = util.get_url(activity.get('actor')) if ':' not in author: author = f'acct:{author}' elif not author.startswith('acct:'): error(f'Author URI {author} has unsupported scheme; expected acct:') logging.info(f'Fetching Salmon key for {author}') if not magicsigs.verify(data, parsed['sig'], author_uri=author): error('Could not verify magic signature.') logging.info('Verified magic signature.') # Verify that the timestamp is recent. Required by spec. # I get that this helps prevent spam, but in practice it's a bit silly, # and other major implementations don't (e.g. Mastodon), so forget it. # # updated = utils.parse_updated_from_atom(data) # if not utils.verify_timestamp(updated): # error('Timestamp is more than 1h old.') # send webmentions to each target activity = atom.atom_to_activity(data) common.send_webmentions(activity, protocol='ostatus', source_atom=data) return ''
def dispatch_request(self): logging.info(f'Params: {list(request.form.items())}') # fetch source page source = flask_util.get_required_param('source') source_resp = common.requests_get(source) self.source_url = source_resp.url or source self.source_domain = urllib.parse.urlparse( self.source_url).netloc.split(':')[0] self.source_mf2 = util.parse_mf2(source_resp) # logging.debug(f'Parsed mf2 for {source_resp.url} : {json_dumps(self.source_mf2 indent=2)}') # check for backlink to bridgy fed (for webmention spec and to confirm # source's intent to federate to mastodon) if (request.host_url not in source_resp.text and urllib.parse.quote( request.host_url, safe='') not in source_resp.text): error("Couldn't find link to {request.host_url}") # convert source page to ActivityStreams entry = mf2util.find_first_entry(self.source_mf2, ['h-entry']) if not entry: error(f'No microformats2 found on {self.source_url}') logging.info(f'First entry: {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'] = [self.source_url] self.source_obj = microformats2.json_to_object(entry, fetch_mf2=True) logging.info( f'Converted to AS1: {json_dumps(self.source_obj, indent=2)}') for method in self.try_activitypub, self.try_salmon: ret = method() if ret: return ret return ''
def error(self, error, html=None, status=400, data=None, log_exception=False, report=False, extra_json=None): """Handle an error. May be overridden by subclasses. Args: error: string human-readable error message html: string HTML human-readable error message status: int HTTP response status code data: mf2 data dict parsed from source page log_exception: boolean, whether to include a stack trace in the log msg report: boolean, whether to report to StackDriver Error Reporting extra_json: dict to be merged into the JSON response body """ if self.entity and self.entity.status == 'new': self.entity.status = 'failed' self.entity.put() resp = {'error': error} if data: resp['parsed'] = data if extra_json: assert 'error' not in extra_json assert 'parsed' not in extra_json resp.update(extra_json) if report and status != 404: self.report_error(error, status=status) flask_util.error(str(resp), status=status, response=jsonify(resp), exc_info=log_exception)
def source_url(self, target_url): # determine which activity to use try: activity = self.activities[0] if self.entity.urls_to_activity: urls_to_activity = json_loads(self.entity.urls_to_activity) if urls_to_activity: activity = self.activities[urls_to_activity[target_url]] except (KeyError, IndexError): error( f"""Hit https://github.com/snarfed/bridgy/issues/237 KeyError! target url {target_url} not in urls_to_activity: {self.entity.urls_to_activity} activities: {self.activities}""", status=ERROR_HTTP_RETURN_CODE) # generate source URL id = activity['id'] parsed = util.parse_tag_uri(id) post_id = parsed[1] if parsed else id parts = [ self.entity.type, g.source.SHORT_NAME, g.source.key.string_id(), post_id ] if self.entity.type != 'post': # parse and add response id. (we know Response key ids are always tag URIs) _, response_id = util.parse_tag_uri(self.entity.key.string_id()) reaction_id = response_id if self.entity.type in ('like', 'react', 'repost', 'rsvp'): response_id = response_id.split('_')[ -1] # extract responder user id parts.append(response_id) if self.entity.type == 'react': parts.append(reaction_id) return util.host_url('/'.join(parts))
def undo_follow(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: 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(f'Marking {follower_obj.key} as inactive') follower_obj.status = 'inactive' follower_obj.put() else: logging.warning(f'No Follower found for {user_domain} {follower}')
def add_wm(url=None): """Proxies HTTP requests and adds Link header to our webmention endpoint.""" url = urllib.parse.unquote(url) if not url.startswith('http://') and not url.startswith('https://'): error('URL must start with http:// or https://') try: got = common.requests_get(url) except requests.exceptions.Timeout as e: error(str(e), status=504, exc_info=True) except requests.exceptions.RequestException as e: error(str(e), status=502, exc_info=True) resp = flask.make_response(got.content, got.status_code, dict(got.headers)) resp.headers.add( 'Link', LINK_HEADER % (request.args.get('endpoint') or request.host_url + 'webmention')) return resp
def template_vars(self, domain=None, url=None): logging.debug(f'Headers: {list(request.headers.items())}') if domain.split('.')[-1] in NON_TLDS: error(f"{domain} doesn't look like a domain", status=404) # find representative h-card. try url, then url's home page, then domain urls = [f'http://{domain}/'] if url: urls = [url, urllib.parse.urljoin(url, '/')] + urls for candidate in urls: resp = common.requests_get(candidate) parsed = util.parse_html(resp) mf2 = util.parse_mf2(parsed, url=resp.url) # logging.debug(f'Parsed mf2 for {resp.url}: {json_dumps(mf2, indent=2)}') hcard = mf2util.representative_hcard(mf2, resp.url) if hcard: logging.info( f'Representative h-card: {json_dumps(hcard, indent=2)}') break else: error( f"didn't find a representative h-card (http://microformats.org/wiki/representative-hcard-parsing) on {resp.url}" ) logging.info(f'Generating WebFinger data for {domain}') key = models.MagicKey.get_or_create(domain) props = hcard.get('properties', {}) urls = util.dedupe_urls(props.get('url', []) + [resp.url]) canonical_url = urls[0] acct = f'{domain}@{domain}' for url in urls: if url.startswith('acct:'): urluser, urldomain = util.parse_acct_uri(url) if urldomain == domain: acct = f'{urluser}@{domain}' logging.info(f'Found custom username: acct:{acct}') break # discover atom feed, if any atom = parsed.find('link', rel='alternate', type=common.CONTENT_TYPE_ATOM) if atom and atom['href']: atom = urllib.parse.urljoin(resp.url, atom['href']) else: atom = 'https://granary.io/url?' + urllib.parse.urlencode( { 'input': 'html', 'output': 'atom', 'url': resp.url, 'hub': resp.url, }) # discover PuSH, if any for link in resp.headers.get('Link', '').split(','): match = common.LINK_HEADER_RE.match(link) if match and match.group(2) == 'hub': hub = match.group(1) else: hub = 'https://bridgy-fed.superfeedr.com/' # generate webfinger content data = util.trim_nulls({ 'subject': 'acct:' + acct, 'aliases': urls, 'magic_keys': [{ 'value': key.href() }], 'links': sum(([{ 'rel': 'http://webfinger.net/rel/profile-page', 'type': 'text/html', 'href': url, }] for url in urls if url.startswith("http")), []) + [{ 'rel': 'http://webfinger.net/rel/avatar', 'href': get_text(url), } for url in props.get('photo', [])] + [ { 'rel': 'canonical_uri', 'type': 'text/html', 'href': canonical_url, }, # ActivityPub { 'rel': 'self', 'type': common.CONTENT_TYPE_AS2, # WARNING: in python 2 sometimes request.host_url lost port, # http://localhost:8080 would become just http://localhost. no # clue how or why. pay attention here if that happens again. 'href': f'{request.host_url}{domain}', }, { 'rel': 'inbox', 'type': common.CONTENT_TYPE_AS2, 'href': f'{request.host_url}{domain}/inbox', }, # OStatus { 'rel': 'http://schemas.google.com/g/2010#updates-from', 'type': common.CONTENT_TYPE_ATOM, 'href': atom, }, { 'rel': 'hub', 'href': hub, }, { 'rel': 'magic-public-key', 'href': key.href(), }, { 'rel': 'salmon', 'href': f'{request.host_url}{domain}/salmon', } ] }) logging.info(f'Returning WebFinger data: {json_dumps(data, indent=2)}') return data
def dispatch_request(self, site, key_id, **kwargs): """Handle HTTP request.""" source_cls = models.sources.get(site) if not source_cls: error( f"Source type '{site}' not found. Known sources: {[s for s in models.sources.keys() if s]}" ) self.source = source_cls.get_by_id(key_id) if not self.source: error(f'Source {site} {key_id} not found') elif (self.source.status == 'disabled' or 'listen' not in self.source.features): error( f'Source {self.source.bridgy_path()} is disabled for backfeed') format = request.values.get('format', 'html') if format not in ('html', 'json'): error(f'Invalid format {format}, expected html or json') for id in kwargs.values(): if not self.VALID_ID.match(id): error(f'Invalid id {id}', 404) try: obj = self.get_item(**kwargs) except models.DisableSource: error( "Bridgy's access to your account has expired. Please visit https://brid.gy/ to refresh it!", 401) except ValueError as e: error(f'{self.source.GR_CLASS.NAME} error: {e}') if not obj: error(f'Not found: {site}:{key_id} {kwargs}', 404) if self.source.is_blocked(obj): error('That user is currently blocked', 410) # use https for profile pictures so we don't cause SSL mixed mode errors # when serving over https. author = obj.get('author', {}) image = author.get('image', {}) url = image.get('url') if url: image['url'] = util.update_scheme(url, request) mf2_json = microformats2.object_to_json(obj, synthesize_content=False) # try to include the author's silo profile url author = first_props(mf2_json.get('properties', {})).get('author', {}) author_uid = first_props(author.get('properties', {})).get('uid', '') if author_uid: parsed = util.parse_tag_uri(author_uid) if parsed: urls = author.get('properties', {}).setdefault('url', []) try: silo_url = self.source.gr_source.user_url(parsed[1]) if silo_url not in microformats2.get_string_urls(urls): urls.append(silo_url) except NotImplementedError: # from gr_source.user_url() pass # write the response! if format == 'html': url = obj.get('url', '') return TEMPLATE.substitute({ 'refresh': (f'<meta http-equiv="refresh" content="0;url={url}">' if url else ''), 'url': url, 'body': microformats2.json_to_html(mf2_json), 'title': obj.get('title') or obj.get('content') or 'Bridgy Response', }) elif format == 'json': return mf2_json
def _try_salmon(self, target): """ Args: target: string """ # fetch target HTML page, extract Atom rel-alternate link if not self.target_resp: self.target_resp = common.requests_get(target) parsed = util.parse_html(self.target_resp) atom_url = parsed.find('link', rel='alternate', type=common.CONTENT_TYPE_ATOM) if not atom_url or not atom_url.get('href'): error(f'Target post {target} has no Atom link') # fetch Atom target post, extract and inject id into source object base_url = '' base = parsed.find('base') if base and base.get('href'): base_url = base['href'] atom_link = parsed.find('link', rel='alternate', type=common.CONTENT_TYPE_ATOM) atom_url = urllib.parse.urljoin( target, urllib.parse.urljoin(base_url, atom_link['href'])) feed = common.requests_get(atom_url).text parsed = feedparser.parse(feed) entry = parsed.entries[0] logging.info(f'Parsed: {json_dumps(entry, indent=2)}') target_id = entry.id in_reply_to = self.source_obj.get('inReplyTo') source_obj_obj = self.source_obj.get('object') if in_reply_to: for elem in in_reply_to: if elem.get('url') == target: elem['id'] = target_id elif isinstance(source_obj_obj, dict): source_obj_obj['id'] = target_id # Mastodon (and maybe others?) require a rel-mentioned link to the # original post's author to make it show up as a reply: # app/services/process_interaction_service.rb # ...so add them as a tag, which atom renders as a rel-mention link. authors = entry.get('authors', None) if authors: url = entry.authors[0].get('href') if url: self.source_obj.setdefault('tags', []).append({'url': url}) # extract and discover salmon endpoint logging.info(f'Discovering Salmon endpoint in {atom_url}') endpoint = django_salmon.discover_salmon_endpoint(feed) if not endpoint: # try webfinger parsed = urllib.parse.urlparse(target) # TODO: test missing email author = entry.get('author_detail', {}) email = author.get('email') or '@'.join( (author.get('name', ''), parsed.netloc)) try: # TODO: always https? profile = common.requests_get( '%s://%s/.well-known/webfinger?resource=acct:%s' % (parsed.scheme, parsed.netloc, email), parse_json=True) endpoint = django_salmon.get_salmon_replies_link(profile) except requests.HTTPError as e: pass if not endpoint: error('No salmon endpoint found!') logging.info(f'Discovered Salmon endpoint {endpoint}') # construct reply Atom object activity = self.source_obj if self.source_obj.get('verb') not in source.VERBS_WITH_OBJECT: activity = {'object': self.source_obj} entry = atom.activity_to_atom(activity, xml_base=self.source_url) logging.info(f'Converted {self.source_url} to Atom:\n{entry}') # sign reply and wrap in magic envelope domain = urllib.parse.urlparse(self.source_url).netloc key = MagicKey.get_or_create(domain) logging.info(f'Using key for {domain}: {key}') magic_envelope = magicsigs.magic_envelope(entry, common.CONTENT_TYPE_ATOM, key).decode() logging.info(f'Sending Salmon slap to {endpoint}') common.requests_post( endpoint, data=common.XML_UTF8 + magic_envelope, headers={'Content-Type': common.CONTENT_TYPE_MAGIC_ENVELOPE}) return 'Sent!'
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 = set() 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.add( 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 sorted(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, BadGateway) as e: self.target_resp = getattr(e, 'requests_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.requests_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: error( 'Target object has no actor or attributedTo with URL or id.' ) elif not isinstance(actor, str): error( f'Target actor or attributedTo has unexpected url or id object: {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. # 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 send_webmentions(activity_wrapped, proxy=None, **response_props): """Sends webmentions for an incoming Salmon slap or ActivityPub inbox delivery. Args: activity_wrapped: dict, AS1 activity response_props: passed through to the newly created Responses """ activity = redirect_unwrap(activity_wrapped) verb = activity.get('verb') if verb and verb not in SUPPORTED_VERBS: error(f'{verb} activities are not supported yet.') # extract source and targets source = activity.get('url') or activity.get('id') obj = activity.get('object') obj_url = util.get_url(obj) targets = util.get_list(activity, 'inReplyTo') if isinstance(obj, dict): if not source or verb in ('create', 'post', 'update'): source = obj_url or obj.get('id') targets.extend(util.get_list(obj, 'inReplyTo')) tags = util.get_list(activity_wrapped, 'tags') obj_wrapped = activity_wrapped.get('object') if isinstance(obj_wrapped, dict): tags.extend(util.get_list(obj_wrapped, 'tags')) for tag in tags: if tag.get('objectType') == 'mention': url = tag.get('url') if url and url.startswith(request.host_url): targets.append(redirect_unwrap(url)) if verb in ('follow', 'like', 'share'): targets.append(obj_url) targets = util.dedupe_urls(util.get_url(t) for t in targets) if not source: error("Couldn't find original post URL") if not targets: error( "Couldn't find any target URLs in inReplyTo, object, or mention tags" ) # send webmentions and store Responses errors = [] # stores (code, body) tuples for target in targets: if util.domain_from_link(target) == util.domain_from_link(source): logging.info( f'Skipping same-domain webmention from {source} to {target}') continue response = Response(source=source, target=target, direction='in', **response_props) response.put() wm_source = (response.proxy_url() if verb in ('follow', 'like', 'share') or proxy else source) logging.info(f'Sending webmention from {wm_source} to {target}') try: endpoint = webmention.discover(target, headers=HEADERS).endpoint if endpoint: webmention.send(endpoint, wm_source, target, headers=HEADERS) response.status = 'complete' logging.info('Success!') else: response.status = 'ignored' logging.info('Ignoring.') except BaseException as e: errors.append(util.interpret_http_exception(e)) response.put() if errors: msg = 'Errors: ' + ', '.join(f'{code} {body}' for code, body in errors) error(msg, status=int(errors[0][0] or 502))
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 ''