diff --git a/common.py b/common.py index dbfffc9..aa3bfff 100644 --- a/common.py +++ b/common.py @@ -2,6 +2,7 @@ """Misc common utilities. """ from base64 import b64encode +import copy import datetime from hashlib import sha256 import itertools @@ -36,7 +37,8 @@ LINK_HEADER_RE = re.compile(r""" *< *([^ >]+) *> *; *rel=['"]([^'"]+)['"] *""") # # ActivityPub Content-Type details: # https://www.w3.org/TR/activitypub/#retrieving-objects -CONTENT_TYPE_AS2_LD = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' +CONTENT_TYPE_LD = 'application/ld+json' +CONTENT_TYPE_AS2_LD = f'{CONTENT_TYPE_LD};profile="https://www.w3.org/ns/activitystreams"' CONTENT_TYPE_AS2 = 'application/activity+json' CONTENT_TYPE_AS1 = 'application/stream+json' CONTENT_TYPE_HTML = 'text/html; charset=utf-8' @@ -46,9 +48,8 @@ CONTENT_TYPE_MAGIC_ENVELOPE = 'application/magic-envelope+xml' CONNEG_HEADERS_AS2 = { 'Accept': '%s; q=0.9, %s; q=0.8' % (CONTENT_TYPE_AS2, CONTENT_TYPE_AS2_LD), } -CONNEG_HEADERS_AS2_HTML = { - 'Accept': CONNEG_HEADERS_AS2['Accept'] + ', %s; q=0.7' % CONTENT_TYPE_HTML, -} +CONNEG_HEADERS_AS2_HTML = copy.deepcopy(CONNEG_HEADERS_AS2) +CONNEG_HEADERS_AS2_HTML['Accept'] += ', {CONTENT_TYPE_HTML}; q=0.7' SUPPORTED_VERBS = ( 'checkin', @@ -189,7 +190,7 @@ def get_as2(url, user=None): raise err resp = signed_get(url, user=user, headers=CONNEG_HEADERS_AS2_HTML) - if content_type(resp) in (CONTENT_TYPE_AS2, CONTENT_TYPE_AS2_LD): + if content_type(resp) in (CONTENT_TYPE_AS2, CONTENT_TYPE_LD): return resp parsed = util.parse_html(resp) @@ -200,7 +201,7 @@ def get_as2(url, user=None): resp = signed_get(urllib.parse.urljoin(resp.url, as2['href']), headers=CONNEG_HEADERS_AS2) - if content_type(resp) in (CONTENT_TYPE_AS2, CONTENT_TYPE_AS2_LD): + if content_type(resp) in (CONTENT_TYPE_AS2, CONTENT_TYPE_LD): return resp _error(resp) @@ -310,7 +311,7 @@ def send_webmentions(activity_wrapped, proxy=None, **activity_props): return True -def postprocess_as2(activity, user=None, target=None): +def postprocess_as2(activity, user=None, target=None, create=True): """Prepare an AS2 object to be served or sent via ActivityPub. Args: @@ -319,6 +320,8 @@ def postprocess_as2(activity, user=None, target=None): publicKey fields if needed. target: dict, AS2 object, optional. The target of activity's inReplyTo or Like/Announce/etc object, if any. + create: boolean, whether to wrap `Note` and `Article` objects in a + `Create` activity """ assert user type = activity.get('type') @@ -420,7 +423,7 @@ def postprocess_as2(activity, user=None, target=None): to.append(as2.PUBLIC_AUDIENCE) # wrap articles and notes in a Create activity - if type in ('Article', 'Note'): + if create and type in ('Article', 'Note'): activity = { '@context': as2.CONTEXT, 'type': 'Create', diff --git a/redirect.py b/redirect.py index 9c19ef7..9e5536c 100644 --- a/redirect.py +++ b/redirect.py @@ -1,12 +1,14 @@ -"""Simple endpoint that redirects to the embedded fully qualified URL. +"""Simple conneg endpoint that serves AS2 or redirects to to the original post. -May also instead fetch and convert to AS2, depending on conneg. - -Used to wrap ActivityPub ids with the fed.brid.gy domain so that Mastodon -accepts them. Background: +Serves /r/https://foo.com/bar URL paths, where https://foo.com/bar is an +original post. Needed for Mastodon interop, they require that AS2 object ids and +urls are on the same domain that serves them. Background: https://github.com/snarfed/bridgy-fed/issues/16#issuecomment-424799599 https://github.com/tootsuite/mastodon/pull/6219#issuecomment-429142747 + +The conneg makes these /r/ URLs searchable in Mastodon: +https://github.com/snarfed/bridgy-fed/issues/352 """ import datetime import logging @@ -16,19 +18,31 @@ import urllib.parse from flask import redirect, request from granary import as2, microformats2 import mf2util +from negotiator import ContentNegotiator, AcceptParameters, ContentType, Language from oauth_dropins.webutil import flask_util, util from oauth_dropins.webutil.flask_util import error from oauth_dropins.webutil.util import json_dumps from werkzeug.exceptions import abort from app import app, cache -import common +from common import ( + CONTENT_TYPE_AS2, + CONTENT_TYPE_AS2_LD, + CONTENT_TYPE_HTML, + postprocess_as2, +) from models import User logger = logging.getLogger(__name__) CACHE_TIME = datetime.timedelta(seconds=15) +_negotiator = ContentNegotiator(acceptable=[ + AcceptParameters(ContentType(CONTENT_TYPE_HTML)), + AcceptParameters(ContentType(CONTENT_TYPE_AS2)), + AcceptParameters(ContentType(CONTENT_TYPE_AS2_LD)), +]) + @app.get(r'/r/') @flask_util.cached(cache, CACHE_TIME) @@ -60,11 +74,17 @@ def redir(to): logger.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, user) + # check conneg, serve AS2 if requested + accept = request.headers.get('Accept') + if accept: + negotiated = _negotiator.negotiate(accept) + if negotiated: + type = str(negotiated.content_type) + if type in (CONTENT_TYPE_AS2, CONTENT_TYPE_AS2_LD): + return convert_to_as2(to, user), { + 'Content-Type': type, + 'Access-Control-Allow-Origin': '*', + } # redirect logger.info(f'redirecting to {to}') @@ -74,22 +94,18 @@ def redir(to): def convert_to_as2(url, domain): """Fetch a URL as HTML, convert it to AS2, and return it. - Currently mainly for Pixelfed. - https://github.com/snarfed/bridgy-fed/issues/39 - Args: url: str domain: :class:`User` + + Returns: + dict: AS2 object """ mf2 = util.fetch_mf2(url) entry = mf2util.find_first_entry(mf2, ['h-entry']) logger.info(f"Parsed mf2 for {mf2['url']}: {json_dumps(entry, indent=2)}") - obj = common.postprocess_as2(as2.from_as1(microformats2.json_to_object(entry)), - domain) + obj = postprocess_as2(as2.from_as1(microformats2.json_to_object(entry)), + domain, create=False) logger.info(f'Returning: {json_dumps(obj, indent=2)}') - - return obj, { - 'Content-Type': common.CONTENT_TYPE_AS2, - 'Access-Control-Allow-Origin': '*', - } + return obj diff --git a/requirements.txt b/requirements.txt index 505279b..386dd99 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ git+https://github.com/snarfed/django-salmon.git#egg=django_salmon git+https://github.com/snarfed/httpsig.git@HTTPSignatureAuth-sign-header#egg=httpsig git+https://github.com/snarfed/oauth-dropins.git#egg=oauth_dropins git+https://github.com/snarfed/granary.git#egg=granary +git+https://github.com/snarfed/negotiator.git@py3#egg=negotiator git+https://github.com/dvska/gdata-python3.git#egg=gdata beautifulsoup4==4.11.1