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 = """\
-
-