diff --git a/activitypub.py b/activitypub.py index 99cb319..5796b26 100644 --- a/activitypub.py +++ b/activitypub.py @@ -133,6 +133,12 @@ class ActivityPub(Protocol): _error() + @classmethod + def serve(cls, obj): + """Serves an :class:`Object` as AS2.""" + return (postprocess_as2(as2.from_as1(obj.as1)), + {'Content-Type': as2.CONTENT_TYPE}) + @classmethod def verify_signature(cls, activity): """Verifies the current request's HTTP Signature. diff --git a/models.py b/models.py index 1470fc2..652b090 100644 --- a/models.py +++ b/models.py @@ -29,7 +29,8 @@ import common WWW_DOMAINS = frozenset(( 'www.jvt.me', )) -# TODO: eventually load from Protocol subclasses' IDs instead? +# TODO: eventually load from protocol.protocols instead, if/when we can get +# around the circular import PROTOCOLS = ('activitypub', 'bluesky', 'ostatus', 'webmention', 'ui') # 2048 bits makes tests slow, so use 1024 for them KEY_BITS = 1024 if DEBUG else 2048 diff --git a/protocol.py b/protocol.py index 0169fb2..6e5f846 100644 --- a/protocol.py +++ b/protocol.py @@ -109,6 +109,25 @@ class Protocol(metaclass=ProtocolMeta): """ raise NotImplementedError() + @classmethod + def serve(cls, obj): + """Returns this protocol's Flask response for a given :class:`Object`. + + For example, an HTML string and `'text/html'` for :class:`Webmention`, + or a dict with AS2 JSON and `'application/activity+json'` for + :class:`ActivityPub. + + To be implemented by subclasses. + + Args: + obj: :class:`Object` + + Returns: + (response body, dict with HTTP headers) tuple appropriate to be + returned from a Flask handler + """ + raise NotImplementedError() + @classmethod def receive(cls, id, **props): """Handles an incoming activity. diff --git a/redirect.py b/redirect.py index 2a8c71e..ef510f2 100644 --- a/redirect.py +++ b/redirect.py @@ -21,7 +21,7 @@ from oauth_dropins.webutil import flask_util, util from oauth_dropins.webutil.flask_util import error from oauth_dropins.webutil.util import json_dumps, json_loads -import activitypub +from activitypub import ActivityPub from flask_app import app, cache from common import CACHE_TIME, CONTENT_TYPE_HTML from models import Object, User @@ -92,7 +92,7 @@ def redir(to): obj = Webmention.load(to, check_backlink=False) if not obj or obj.deleted: return f'Object not found: {to}', 404 - ret = activitypub.postprocess_as2(as2.from_as1(obj.as1)) + ret, _ = ActivityPub.serve(obj) logger.info(f'Returning: {json_dumps(ret, indent=2)}') return ret, { 'Content-Type': accept_type, diff --git a/render.py b/render.py index 5e23909..4abec59 100644 --- a/render.py +++ b/render.py @@ -13,6 +13,7 @@ import activitypub from flask_app import app, cache import common from models import Object +from webmention import Webmention logger = logging.getLogger(__name__) @@ -42,23 +43,4 @@ def render(): if obj.deleted or type == 'delete': return '', 410 - # fill in author/actor if available - obj_as1 = obj.as1 - for field in 'author', 'actor': - val = as1.get_object(obj.as1, field) - if val.keys() == set(['id']) and val['id']: - # TODO: abstract on obj.source_protocol - loaded = activitypub.ActivityPub.load(val['id']) - if loaded and loaded.as1: - obj_as1 = {**obj_as1, field: loaded.as1} - - # 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([obj_as1]) - utf8 = '' - url = util.get_url(obj_as1) - if url: - refresh = f'' - html = html.replace(utf8, utf8 + '\n' + refresh) - - return html + return Webmention.serve(obj) diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py index 2faba1d..0d442dd 100644 --- a/tests/test_activitypub.py +++ b/tests/test_activitypub.py @@ -5,6 +5,7 @@ import copy from datetime import datetime, timedelta from hashlib import sha256 import logging +from unittest import skip from unittest.mock import ANY, call, patch import urllib.parse @@ -1413,6 +1414,12 @@ class ActivityPubUtilsTest(testutil.TestCase): mock_get.assert_has_calls([self.as2_req('http://the/id')]) + @skip + def test_serve(self): + obj = Object(id='http://orig', as2=LIKE) + self.assertEqual((LIKE_WRAPPED, {'Content-Type': 'application/activity+json'}), + ActivityPub.serve(obj)) + def test_postprocess_as2_idempotent(self): g.user = self.make_user('foo.com') diff --git a/tests/test_render.py b/tests/test_render.py index 753192e..809c3c0 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -74,8 +74,11 @@ class RenderTest(testutil.TestCase): def test_render_with_author(self): with self.request_context: - Object(id='abc', as2=as2.from_as1({**COMMENT, 'author': 'def'})).put() - Object(id='def', as2=as2.from_as1(ACTOR)).put() + Object(id='abc', as2=as2.from_as1({**COMMENT, 'author': 'def'}), + source_protocol='activitypub').put() + Object(id='def', as2=as2.from_as1(ACTOR), + source_protocol='activitypub').put() + resp = self.client.get('/render?id=abc') self.assertEqual(200, resp.status_code) self.assert_multiline_equals( diff --git a/tests/test_webmention.py b/tests/test_webmention.py index 0a71b7f..4edccfc 100644 --- a/tests/test_webmention.py +++ b/tests/test_webmention.py @@ -1452,3 +1452,20 @@ class WebmentionUtilTest(testutil.TestCase): self.assert_req(mock_get, 'https://user.com/post') mock_post.assert_not_called() + + def test_serve(self, _, __): + obj = Object(id='http://orig', mf2=ACTOR_MF2) + html, headers = Webmention.serve(obj) + self.assert_multiline_equals("""\ + + + + + + + Ms. ☕ Baz + + + +""", html, ignore_blanks=True) + self.assertEqual({'Content-Type': 'text/html; charset=utf-8'}, headers) diff --git a/tests/testutil.py b/tests/testutil.py index 8cbe667..4e7294f 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -72,6 +72,12 @@ class FakeProtocol(protocol.Protocol): raise requests.HTTPError(response=util.Struct(status_code='410')) + @classmethod + def serve(cls, obj): + logger.info(f'FakeProtocol.load {obj.key.id()}') + return (f'FakeProtocol object {obj.key.id()}', + {'Accept': 'fake/protocol'}) + class TestCase(unittest.TestCase, testutil.Asserts): maxDiff = None diff --git a/webmention.py b/webmention.py index 5d2d27e..213a4d0 100644 --- a/webmention.py +++ b/webmention.py @@ -23,7 +23,7 @@ from flask_app import app import common from models import Follower, Object, Target, User import models -from protocol import Protocol +from protocol import Protocol, protocols logger = logging.getLogger(__name__) @@ -125,6 +125,35 @@ class Webmention(Protocol): obj.mf2 = entry return obj + @classmethod + def serve(cls, obj): + """Serves an :class:`Object` as HTML.""" + obj_as1 = obj.as1 + + from_proto = protocols.get(obj.source_protocol) + if from_proto: + # fill in author/actor if available + for field in 'author', 'actor': + val = as1.get_object(obj.as1, field) + if val.keys() == set(['id']) and val['id']: + loaded = from_proto.load(val['id']) + if loaded and loaded.as1: + obj_as1 = {**obj_as1, field: loaded.as1} + else: + logger.debug(f'Not hydrating actor or author due to source_protocol {obj.source_protocol}') + + html = microformats2.activities_to_html([obj_as1]) + + # add HTML meta redirect to source page. should trigger for end users in + # browsers but not for webmention receivers (hopefully). + url = util.get_url(obj_as1) + if url: + utf8 = '' + refresh = f'' + html = html.replace(utf8, utf8 + '\n' + refresh) + + return html, {'Content-Type': common.CONTENT_TYPE_HTML} + @app.post('/webmention') def webmention_external():