kopia lustrzana https://github.com/snarfed/bridgy-fed
initial design of new Object model, implement it in render.py
rodzic
55ee0e468a
commit
cc453035c8
100
models.py
100
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)}
|
||||
</a>"""
|
||||
|
||||
@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.
|
||||
|
|
53
render.py
53
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 = '<meta charset="utf-8">'
|
||||
url = util.get_url(as1) or as1.get('id') or source
|
||||
refresh = f'<meta http-equiv="refresh" content="0;url={url}">'
|
||||
return html.replace(utf8, utf8 + '\n' + refresh)
|
||||
url = util.get_url(as1)
|
||||
if url:
|
||||
refresh = f'<meta http-equiv="refresh" content="0;url={url}">'
|
||||
html = html.replace(utf8, utf8 + '\n' + refresh)
|
||||
|
||||
return html
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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 = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<entry xmlns="http://www.w3.org/2005/Atom"
|
||||
xmlns:thr="http://purl.org/syndication/thread/1.0">
|
||||
|
||||
<uri>http://this/reply</uri>
|
||||
<thr:in-reply-to href="http://orig/post" />
|
||||
<content>A ☕ reply</content>
|
||||
</entry>
|
||||
"""
|
||||
self.html = """\
|
||||
EXPECTED_HTML = """\
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="0;url=http://this/reply"></head>
|
||||
<meta http-equiv="refresh" content="0;url=https://fake.com/123456"></head>
|
||||
<body class="">
|
||||
<article class="h-entry">
|
||||
<span class="p-uid">http://this/reply</span>
|
||||
<a class="u-url" href="http://this/reply">http://this/reply</a>
|
||||
<span class="p-uid">tag:fake.com:123456</span>
|
||||
<time class="dt-published" datetime="2012-12-05T00:58:26+00:00">2012-12-05T00:58:26+00:00</time>
|
||||
<a class="u-url" href="https://fake.com/123456">https://fake.com/123456</a>
|
||||
<div class="e-content p-name">
|
||||
A ☕ reply
|
||||
</div>
|
||||
<a class="u-in-reply-to" href="http://orig/post"></a>
|
||||
<a class="u-in-reply-to" href="https://fake.com/123"></a>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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<meta http-equiv="refresh" content="0;url=https://fake.com/123456">', ''
|
||||
).replace('<a class="u-url" href="https://fake.com/123456">https://fake.com/123456</a>', '')
|
||||
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'])
|
||||
|
|
Ładowanie…
Reference in New Issue