diff --git a/models.py b/models.py index 24de051..5f6d7c1 100644 --- a/models.py +++ b/models.py @@ -227,78 +227,50 @@ class User(StringIdModel): return self -class Activity(StringIdModel): - """A reply, like, repost, or other interaction that we've relayed. +class Object(StringIdModel): + """An activity or other object, eg actor. - Key name is 'SOURCE_URL TARGET_URL', e.g. 'http://a/reply http://orig/post'. + Key name is the id. We synthesize ids if necessary. """ - STATUSES = ('new', 'complete', 'error', 'ignored') - PROTOCOLS = ('activitypub', 'ostatus') - DIRECTIONS = ('out', 'in') + STATUSES = ('new', 'in progress', 'complete', 'ignored') + PROTOCOLS = ('activitypub', 'bluesky', 'webmention') + LABELS = ('feed', 'notification') # domains of the Bridgy Fed users this activity is to or from - domain = ndb.StringProperty(repeated=True) + domains = ndb.StringProperty(repeated=True) status = ndb.StringProperty(choices=STATUSES, default='new') - protocol = ndb.StringProperty(choices=PROTOCOLS) - direction = ndb.StringProperty(choices=DIRECTIONS) + source_protocol = ndb.StringProperty(choices=PROTOCOLS) + labels = ndb.StringProperty(repeated=True, choices=LABELS) - # usually only one of these at most will be populated. - source_mf2 = ndb.TextProperty() # JSON - source_as2 = ndb.TextProperty() # JSON - source_atom = ndb.TextProperty() - target_as2 = ndb.TextProperty() # JSON + # these are all JSON. They're TextProperty, and not JsonProperty, so that + # their plain text is visible in the App Engine admin console. (JsonProperty + # uses a blob.) + as1 = ndb.TextProperty(required=True) # converted from source data + as2 = ndb.TextProperty() # only one of the rest will be populated... + bsky = ndb.TextProperty() # Bluesky / AT Protocol + mf2 = ndb.TextProperty() # HTML microformats2 + + type = ndb.StringProperty() # AS1 objectType, or verb if it's an activity + deleted = ndb.BooleanProperty(default=False) + object_ids = ndb.StringProperty(repeated=True) # id(s) of inner objects + + # ActivityPub inbox delivery + ap_delivered = ndb.StringProperty(repeated=True) + ap_undelivered = ndb.StringProperty(repeated=True) + ap_failed = ndb.StringProperty(repeated=True) created = ndb.DateTimeProperty(auto_now_add=True) updated = ndb.DateTimeProperty(auto_now=True) - @classmethod - def _get_kind(cls): - return 'Response' - - def __init__(self, source=None, target=None, **kwargs): - if source and target: - assert 'id' not in kwargs - kwargs['id'] = self._id(source, target) - logger.info(f"Activity id (source target): {kwargs['id']}") - super(Activity, self).__init__(**kwargs) - - @classmethod - def get_or_create(cls, source=None, target=None, **kwargs): - logger.info(f'Activity source target: {source} {target}') - return cls.get_or_insert(cls._id(source, target), **kwargs) - - def source(self): - return self.key.id().split()[0] - - def target(self): - return self.key.id().split()[1] - def proxy_url(self): """Returns the Bridgy Fed proxy URL to render this post as HTML.""" - if self.source_mf2 or self.source_as2 or self.source_atom: - source, target = self.key.id().split(' ') - return common.host_url('render?' + urllib.parse.urlencode({ - 'source': source, - 'target': target, - })) - - def to_as1(self): - """Returns this activity as an ActivityStreams 1 dict, if available.""" - if self.source_mf2: - mf2 = json_loads(self.source_mf2) - items = mf2.get('items') - if items: - mf2 = items[0] - return microformats2.json_to_object(mf2) - if self.source_as2: - return as2.to_as1(json_loads(self.source_as2)) - if self.source_atom: - return atom.atom_to_activity(self.source_atom) + return common.host_url('render?' + + urllib.parse.urlencode({'id': self.key.id()})) def actor_link(self, as1=None): """Returns a pretty actor link with their name and profile picture.""" - if self.direction == 'out' and self.domain: - return User.get_by_id(self.domain[0]).user_page_link() + if self.direction == 'out' and self.domains: + return User.get_by_id(self.domains[0]).user_page_link() if not as1: as1 = self.to_as1() @@ -319,20 +291,6 @@ class Activity(StringIdModel): {util.ellipsize(name, chars=40)} """ - @classmethod - def _id(cls, source, target): - assert source - assert target - return f'{cls._encode(source)} {cls._encode(target)}' - - @classmethod - def _encode(cls, val): - return val.replace('#', '__') - - @classmethod - def _decode(cls, val): - return val.replace('__', '#') - class Follower(StringIdModel): """A follower of a Bridgy Fed user. diff --git a/render.py b/render.py index 7897c47..662567a 100644 --- a/render.py +++ b/render.py @@ -1,8 +1,10 @@ # coding=utf-8 -"""Renders mf2 proxy pages based on stored Activity entities.""" +"""Renders mf2 proxy pages based on stored Object entities.""" import datetime +import logging +from urllib.parse import urlencode -from flask import request +from flask import redirect, request from granary import as2, atom, microformats2 from oauth_dropins.webutil import flask_util from oauth_dropins.webutil.flask_util import error @@ -11,34 +13,41 @@ from oauth_dropins.webutil.util import json_loads from app import app, cache import common -from models import Activity +from models import Object + +logger = logging.getLogger(__name__) @app.get('/render') @flask_util.cached(cache, common.CACHE_TIME) def render(): - """Fetches a stored Activity and renders it as HTML.""" - source = flask_util.get_required_param('source') - target = flask_util.get_required_param('target') + """Fetches a stored Object and renders it as HTML.""" + id = flask_util.get_required_param('id') + obj = Object.get_by_id(id) + if not obj: + error(f'No stored object for {id}', status=404) + elif not obj.as1: + error(f'Stored object for {id} has no AS1', status=404) - id = f'{source} {target}' - activity = Activity.get_by_id(id) - if not activity: - error(f'No stored activity for {id}', status=404) - - if activity.source_mf2: - as1 = microformats2.json_to_object(json_loads(activity.source_mf2)) - elif activity.source_as2: - as1 = as2.to_as1(json_loads(activity.source_as2)) - elif activity.source_atom: - as1 = atom.atom_to_activity(activity.source_atom) - else: - error(f'Stored activity for {id} has no data', status=404) + as1 = json_loads(obj.as1) + if (as1.get('objectType') == 'activity' and + as1.get('verb') in ('post', 'update', 'delete')): + # redirect to inner object + obj_id = as1.get('object') + if isinstance(obj_id, dict): + obj_id = obj_id.get('id') + if not obj_id: + error(f'Stored {type} activity has no object id!', status=404) + logger.info(f'{type} activity, redirecting to object id {obj_id}') + return redirect('/render?' + urlencode({'id': obj_id}), code=301) # 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 = '' - url = util.get_url(as1) or as1.get('id') or source - refresh = f'' - return html.replace(utf8, utf8 + '\n' + refresh) + url = util.get_url(as1) + if url: + refresh = f'' + html = html.replace(utf8, utf8 + '\n' + refresh) + + return html diff --git a/tests/test_models.py b/tests/test_models.py index e669bf5..28f4a8d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,7 +7,7 @@ from oauth_dropins.webutil.testutil import requests_response from oauth_dropins.webutil.util import json_dumps, json_loads from app import app -from models import Activity, Follower, User +from models import Follower, Object, User from . import testutil from .test_activitypub import ACTOR @@ -167,30 +167,12 @@ http://this/404s self.assertEqual(root_user.key, User.get_or_create('www.y.z').key) -class ActivityTest(testutil.TestCase): - - def test_constructor(self): - activity = Activity('abc', 'xyz') - self.assertEqual('abc xyz', activity.key.id()) - - activity = Activity('abc#1', 'xyz#Z') - self.assertEqual('abc__1 xyz__Z', activity.key.id()) - - def test_get_or_create(self): - activity = Activity.get_or_create('abc', 'xyz') - self.assertEqual('abc xyz', activity.key.id()) - - activity = Activity.get_or_create('abc#1', 'xyz#Z') - self.assertEqual('abc__1 xyz__Z', activity.key.id()) +class ObjectTest(testutil.TestCase): def test_proxy_url(self): with app.test_request_context('/'): - activity = Activity.get_or_create('abc', 'xyz') - self.assertIsNone(activity.proxy_url()) - - activity.source_as2 = 'as2' - self.assertEqual('http://localhost/render?source=abc&target=xyz', - activity.proxy_url()) + obj = Object.get_or_insert('abc') + self.assertEqual('http://localhost/render?id=abc', obj.proxy_url()) class FollowerTest(testutil.TestCase): diff --git a/tests/test_render.py b/tests/test_render.py index e298256..0a2d974 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,93 +1,81 @@ # coding=utf-8 """Unit tests for render.py.""" +import copy + +from granary.tests.test_as1 import COMMENT, DELETE_OF_ID, UPDATE from oauth_dropins.webutil.util import json_dumps import common -from models import Activity +from models import Object import render from . import testutil - -class RenderTest(testutil.TestCase): - - def setUp(self): - super().setUp() - self.as2 = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'type': 'Note', - 'id': 'http://this/reply', - 'url': 'http://this/reply', - 'content': 'A ☕ reply', - 'inReplyTo': 'http://orig/post', - } - self.mf2 = { - 'type': ['h-entry'], - 'properties': { - 'uid': ['http://this/reply'], - 'url': ['http://this/reply'], - 'content': [{'value': 'A ☕ reply'}], - 'in-reply-to': ['http://orig/post'], - }, - } - self.atom = """\ - - - -http://this/reply - -A ☕ reply - -""" - self.html = """\ +EXPECTED_HTML = """\ - +
- http://this/reply - http://this/reply + tag:fake.com:123456 + + https://fake.com/123456
A ☕ reply
- +
""" +class RenderTest(testutil.TestCase): + + def setUp(self): + super().setUp() + def test_render_errors(self): - for source, target in ('', ''), ('abc', ''), ('', 'xyz'): - resp = self.client.get(f'/render?source={source}&target={target}') - self.assertEqual(400, resp.status_code, resp.get_data(as_text=True)) + resp = self.client.get(f'/render?id=') + self.assertEqual(400, resp.status_code) - # no Activity - resp = self.client.get('/render?source=abc&target=xyz') + resp = self.client.get(f'/render') + self.assertEqual(400, resp.status_code) + + # no Object + resp = self.client.get('/render?id=abc') self.assertEqual(404, resp.status_code) - # no source data - Activity(id='abc xyz').put() - resp = self.client.get('/render?source=abc&target=xyz') - self.assertEqual(404, resp.status_code) - - def test_render_as2(self): - Activity(id='abc xyz', source_as2=json_dumps(self.as2)).put() - resp = self.client.get('/render?source=abc&target=xyz') + def test_render(self): + Object(id='abc', as1=json_dumps(COMMENT)).put() + resp = self.client.get('/render?id=abc') self.assertEqual(200, resp.status_code) - self.assert_multiline_equals(self.html, resp.get_data(as_text=True), + self.assert_multiline_equals(EXPECTED_HTML, resp.get_data(as_text=True), ignore_blanks=True) + + def test_render_no_url(self): + comment = copy.deepcopy(COMMENT) + del comment['url'] + Object(id='abc', as1=json_dumps(comment)).put() + + resp = self.client.get('/render?id=abc') + self.assertEqual(200, resp.status_code) + expected = EXPECTED_HTML.replace( + '\n', '' + ).replace('https://fake.com/123456', '') + self.assert_multiline_equals(expected, resp.get_data(as_text=True), ignore_blanks=True) - def test_render_mf2(self): - Activity(id='abc xyz', source_mf2=json_dumps(self.mf2)).put() - resp = self.client.get('/render?source=abc&target=xyz') - self.assertEqual(200, resp.status_code) - self.assert_multiline_equals(self.html, resp.get_data(as_text=True), - ignore_blanks=True) + def test_render_update_redirect(self): + # UPDATE's object field is a full object + Object(id='abc', as1=json_dumps(UPDATE)).put() + resp = self.client.get('/render?id=abc') + self.assertEqual(301, resp.status_code) + self.assertEqual(f'/render?id=tag%3Afake.com%3A123456', + resp.headers['Location']) - def test_render_atom(self): - Activity(id='abc xyz', source_atom=self.atom).put() - resp = self.client.get('/render?source=abc&target=xyz') - self.assertEqual(200, resp.status_code) - self.assert_multiline_equals(self.html, resp.get_data(as_text=True), - ignore_blanks=True) + def test_render_delete_redirect(self): + # DELETE_OF_ID's object field is a bare string id + Object(id='abc', as1=json_dumps(DELETE_OF_ID)).put() + resp = self.client.get('/render?id=abc') + self.assertEqual(301, resp.status_code) + self.assertEqual(f'/render?id=tag%3Afake.com%3A123456', + resp.headers['Location'])