コード例 #1
0
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': '*',
    })
コード例 #2
0
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)
コード例 #3
0
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)
コード例 #4
0
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}')
コード例 #5
0
    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
コード例 #6
0
 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'}))
コード例 #7
0
ファイル: publish.py プロジェクト: snarfed/bridgy
 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)
コード例 #8
0
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
コード例 #9
0
ファイル: salmon.py プロジェクト: snarfed/bridgy-fed
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 ''
コード例 #10
0
ファイル: webmention.py プロジェクト: snarfed/bridgy-fed
    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 ''
コード例 #11
0
    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)
コード例 #12
0
    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))
コード例 #13
0
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}')
コード例 #14
0
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
コード例 #15
0
    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
コード例 #16
0
ファイル: handlers.py プロジェクト: snarfed/bridgy
    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
コード例 #17
0
ファイル: webmention.py プロジェクト: snarfed/bridgy-fed
    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!'
コード例 #18
0
ファイル: webmention.py プロジェクト: snarfed/bridgy-fed
    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
コード例 #19
0
ファイル: common.py プロジェクト: snarfed/bridgy-fed
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))
コード例 #20
0
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 ''