move address, actor_id from User to activitypub.py, /web-site to web.py

for #512
circle-datastore-transactions
Ryan Barrett 2023-05-30 17:24:49 -07:00
rodzic cec093ea60
commit 9e906f18e4
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 6BE31FDF4776E9D4
12 zmienionych plików z 180 dodań i 161 usunięć

Wyświetl plik

@ -27,7 +27,7 @@ from common import (
TLD_BLOCKLIST,
)
from models import Follower, Object, PROTOCOLS, Target, User
from protocol import Protocol
import protocol
import web
logger = logging.getLogger(__name__)
@ -47,7 +47,7 @@ def default_signature_user():
return _DEFAULT_SIGNATURE_USER
class ActivityPub(User, Protocol):
class ActivityPub(User, protocol.Protocol):
"""ActivityPub protocol class."""
LABEL = 'activitypub'
@ -58,7 +58,7 @@ class ActivityPub(User, Protocol):
target = getattr(obj, 'target_as2', None)
activity = obj.as2 or postprocess_as2(as2.from_as1(obj.as1), target=target)
activity['actor'] = g.user.actor_id()
activity['actor'] = actor_id(g.user)
return signed_post(url, log_data=True, data=activity)
# TODO: return bool or otherwise unify return value with others
@ -214,6 +214,45 @@ class ActivityPub(User, Protocol):
error('HTTP Signature verification failed', status=401)
def address(user):
"""Returns a user's ActivityPub address, eg '@me@foo.com'.
Args:
user: :class:`User`
Returns:
str
"""
if user.direct:
return f'@{user.username()}@{user.key.id()}'
else:
return f'@{user.key.id()}@{request.host}'
def actor_id(user, rest=None):
"""Returns a user's AS2 actor id.
Example: 'https://fed.brid.gy/ap/bluesky/foo.com'
Args:
user: :class:`User`
rest: str, optional, added as path suffix
Returns:
str
"""
if user.direct or rest:
# special case Web users to skip /ap/web/ prefix, for backward compatibility
url = common.host_url(user.key.id() if user.LABEL == 'web'
else f'/ap{user.user_page_path()}')
if rest:
url += f'/{rest}'
return url
# TODO(#512): drop once we fetch site if web user doesn't already exist
else:
return redirect_wrap(user.homepage)
def signed_get(url, **kwargs):
return signed_request(util.requests_get, url, **kwargs)
@ -267,7 +306,7 @@ def signed_request(fn, url, data=None, log_data=True, headers=None, **kwargs):
# implementations require, eg Peertube.
# https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#section-2.3
# https://github.com/snarfed/bridgy-fed/issues/40
auth = HTTPSignatureAuth(secret=user.private_pem(), key_id=user.actor_id(),
auth = HTTPSignatureAuth(secret=user.private_pem(), key_id=actor_id(user),
algorithm='rsa-sha256', sign_header='signature',
headers=HTTP_SIG_HEADERS)
@ -371,13 +410,13 @@ def postprocess_as2(activity, target=None, wrap=True):
elif not id:
obj['id'] = util.get_first(obj, 'url') or target_id
elif g.user and g.user.is_homepage(id):
obj['id'] = g.user.actor_id()
obj['id'] = actor_id(g.user)
elif g.external_user:
obj['id'] = redirect_wrap(g.external_user)
# for Accepts
if g.user and g.user.is_homepage(obj.get('object')):
obj['object'] = g.user.actor_id()
obj['object'] = actor_id(g.user)
elif g.external_user and g.external_user == obj.get('object'):
obj['object'] = redirect_wrap(g.external_user)
@ -467,7 +506,7 @@ def postprocess_as2_actor(actor, wrap=True):
return actor
elif isinstance(actor, str):
if g.user and g.user.is_homepage(actor):
return g.user.actor_id()
return actor_id(g.user)
return redirect_wrap(actor)
url = g.user.homepage if g.user else None
@ -481,7 +520,7 @@ def postprocess_as2_actor(actor, wrap=True):
id = actor.get('id')
if g.user and (not id or g.user.is_homepage(id)):
actor['id'] = g.user.actor_id()
actor['id'] = actor_id(g.user)
elif g.external_user and (not id or id == g.external_user):
actor['id'] = redirect_wrap(g.external_user)
@ -523,7 +562,7 @@ def actor(protocol, domain):
# TODO: unify with common.actor()
actor = postprocess_as2(g.user.actor_as2 or {})
actor.update({
'id': g.user.actor_id(),
'id': actor_id(g.user),
# This has to be the domain for Mastodon etc interop! It seems like it
# should be the custom username from the acct: u-url in their h-card,
# but that breaks Mastodon's Webfinger discovery. Background:
@ -532,10 +571,10 @@ def actor(protocol, domain):
# https://github.com/snarfed/bridgy-fed/issues/302#issuecomment-1324305460
# https://github.com/snarfed/bridgy-fed/issues/77
'preferredUsername': domain,
'inbox': g.user.actor_id('inbox'),
'outbox': g.user.actor_id('outbox'),
'following': g.user.actor_id('following'),
'followers': g.user.actor_id('followers'),
'inbox': actor_id(g.user, 'inbox'),
'outbox': actor_id(g.user, 'outbox'),
'following': actor_id(g.user, 'following'),
'followers': actor_id(g.user, 'followers'),
'endpoints': {
'sharedInbox': host_url('/ap/sharedInbox'),
},

Wyświetl plik

@ -16,7 +16,7 @@ from oauth_dropins.webutil import util
from oauth_dropins.webutil.testutil import NOW
from oauth_dropins.webutil.util import json_dumps, json_loads
from activitypub import ActivityPub
from activitypub import ActivityPub, actor_id, address
from flask_app import app
import common
from models import Follower, Object, PROTOCOLS
@ -98,7 +98,7 @@ def remote_follow():
if link.get('rel') == SUBSCRIBE_LINK_REL:
template = link.get('template')
if template and '{uri}' in template:
return redirect(template.replace('{uri}', g.user.address()))
return redirect(template.replace('{uri}', address(g.user)))
flash(f"Couldn't find remote follow link for {addr}")
return redirect(g.user.user_page_path())
@ -179,7 +179,7 @@ class FollowCallback(indieauth.Callback):
'type': 'Follow',
'id': follow_id,
'object': followee,
'actor': g.user.actor_id(),
'actor': actor_id(g.user),
'to': [as2.PUBLIC_AUDIENCE],
}
obj = Object(id=follow_id, domains=[domain], labels=['user'],
@ -257,7 +257,7 @@ class UnfollowCallback(indieauth.Callback):
'@context': 'https://www.w3.org/ns/activitystreams',
'type': 'Undo',
'id': unfollow_id,
'actor': g.user.actor_id(),
'actor': actor_id(g.user),
'object': follower.last_follow,
}

Wyświetl plik

@ -199,31 +199,6 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
logger.info(f'Defaulting username to domain {domain}')
return domain
# TODO(#512): move to activitypub.py?
def address(self):
"""Returns this user's ActivityPub address, eg '@me@foo.com'."""
if self.direct:
return f'@{self.username()}@{self.key.id()}'
else:
return f'@{self.key.id()}@{request.host}'
# TODO(#512): move to activitypub.py?
def actor_id(self, rest=None):
"""Returns this user's AS2 actor id.
Example: 'https://fed.brid.gy/ap/bluesky/foo.com'
"""
if self.direct or rest:
# special case Web users to skip /ap/web/ prefix, for backward compatibility
url = common.host_url(self.key.id() if self.LABEL == 'web'
else f'/ap{self.user_page_path()}')
if rest:
url += f'/{rest}'
return url
# TODO(#512): drop once we fetch site if web user doesn't already exist
else:
return redirect_wrap(self.homepage)
def is_homepage(self, url):
"""Returns True if the given URL points to this user's home page."""
if not url:

Wyświetl plik

@ -14,9 +14,10 @@ from oauth_dropins.webutil import flask_util, logs, util
from oauth_dropins.webutil.flask_util import error, flash, redirect
from oauth_dropins.webutil.util import json_dumps, json_loads
from flask_app import app, cache
import activitypub
import common
from common import DOMAIN_RE
from flask_app import app, cache
from models import fetch_page, Follower, Object, PAGE_SIZE, PROTOCOLS, User
from web import Web
@ -45,34 +46,6 @@ def docs():
return render_template('docs.html')
@app.get('/web-site')
@flask_util.cached(cache, datetime.timedelta(days=1))
def enter_web_site():
return render_template('enter_web_site.html')
# TODO(#512): move to webmention.py?
@app.post('/web-site')
def check_web_site():
url = request.values['url']
domain = util.domain_from_link(url, minimize=False)
if not domain:
flash(f'No domain found in {url}')
return render_template('enter_web_site.html')
g.user = Web.get_or_create(domain, direct=True)
try:
g.user = g.user.verify()
except BaseException as e:
code, body = util.interpret_http_exception(e)
if code:
flash(f"Couldn't connect to {url}: {e}")
return render_template('enter_web_site.html')
raise
g.user.put()
return redirect(g.user.user_page_path())
@app.get(f'/user/<regex("{DOMAIN_RE}"):domain>')
@app.get(f'/user/<regex("{DOMAIN_RE}"):domain>/feed')
@app.get(f'/user/<regex("{DOMAIN_RE}"):domain>/<any(followers,following):collection>')
@ -112,6 +85,7 @@ def user(protocol, domain):
util=util,
address=request.args.get('address'),
g=g,
activitypub=activitypub,
**locals(),
)
@ -137,6 +111,7 @@ def followers_or_following(protocol, domain, collection):
util=util,
address=request.args.get('address'),
g=g,
activitypub=activitypub,
**locals()
)
@ -168,7 +143,8 @@ def feed(protocol, domain):
# syntax. maybe a fediverse kwarg down through the call chain?
if format == 'html':
entries = [microformats2.object_to_html(a) for a in activities]
return render_template('feed.html', util=util, g=g, **locals())
return render_template('feed.html', util=util, g=g,
activitypub=activitypub, **locals())
elif format == 'atom':
body = atom.activities_to_atom(activities, actor=actor, title=title,
request_url=request.url)

Wyświetl plik

@ -278,7 +278,10 @@ class Protocol:
follower_obj.put()
# send AP Accept
followee_actor_url = g.user.actor_id()
# TODO: switch back to activitypub.actor_id() once this is moved into
# activitypub.py
followee_actor_url = common.host_url(g.user.key.id() if g.user.LABEL == 'web'
else f'/ap{g.user.user_page_path()}')
accept = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': common.host_url(f'/user/{g.user.key.id()}/followers#accept-{obj.key.id()}'),

Wyświetl plik

@ -5,7 +5,7 @@
<span title="Fediverse address">
<nobr>
<img class="logo" src="/static/fediverse_logo.svg">
{{ g.user.address() }}
{{ activitypub.address(g.user) }}
</nobr>
</span>
&middot;
@ -19,5 +19,6 @@
class="btn btn-default glyphicon glyphicon-refresh"></button>
</form>
</nobr>
</div>
</div>

Wyświetl plik

@ -24,7 +24,7 @@ from werkzeug.exceptions import BadGateway
from .testutil import Fake, TestCase
import activitypub
from activitypub import ActivityPub
from activitypub import ActivityPub, actor_id, address
import common
import models
from models import Follower, Object
@ -1510,3 +1510,29 @@ class ActivityPubUtilsTest(TestCase):
activitypub.postprocess_as2(obj),
activitypub.postprocess_as2(activitypub.postprocess_as2(obj)),
ignore=['to'])
def test_address(self):
self.assertEqual('@user.com@user.com', address(g.user))
g.user.actor_as2 = {'type': 'Person'}
self.assertEqual('@user.com@user.com', address(g.user))
g.user.actor_as2 = {'url': 'http://foo'}
self.assertEqual('@user.com@user.com', address(g.user))
g.user.actor_as2 = {'url': ['http://foo', 'acct:bar@foo', 'acct:baz@user.com']}
self.assertEqual('@baz@user.com', address(g.user))
g.user.direct = False
self.assertEqual('@user.com@localhost', address(g.user))
def test_actor_id(self):
self.assertEqual('http://localhost/ap/fake/foo',
actor_id(self.make_user('foo', cls=Fake)))
self.assertEqual('http://localhost/user.com', actor_id(g.user))
g.user.direct = False
self.assertEqual('http://localhost/r/https://user.com/', actor_id(g.user))
self.assertEqual('http://localhost/user.com/inbox', actor_id(g.user, 'inbox'))

Wyświetl plik

@ -86,32 +86,6 @@ class UserTest(TestCase):
g.user.actor_as2 = ACTOR
self.assertEqual('<a class="h-card u-author" href="/web/y.z"><img src="https://user.com/me.jpg" class="profile"> Mrs. ☕ Foo</a>', g.user.user_page_link())
def test_address(self):
self.assertEqual('@y.z@y.z', g.user.address())
g.user.actor_as2 = {'type': 'Person'}
self.assertEqual('@y.z@y.z', g.user.address())
g.user.actor_as2 = {'url': 'http://foo'}
self.assertEqual('@y.z@y.z', g.user.address())
g.user.actor_as2 = {'url': ['http://foo', 'acct:bar@foo', 'acct:baz@y.z']}
self.assertEqual('@baz@y.z', g.user.address())
g.user.direct = False
self.assertEqual('@y.z@localhost', g.user.address())
def test_actor_id(self):
self.assertEqual('http://localhost/ap/fake/foo',
self.make_user('foo', cls=Fake).actor_id())
self.assertEqual('http://localhost/y.z', g.user.actor_id())
g.user.direct = False
self.assertEqual('http://localhost/r/https://y.z/', g.user.actor_id())
self.assertEqual('http://localhost/y.z/inbox', g.user.actor_id('inbox'))
class ObjectTest(TestCase):
def setUp(self):

Wyświetl plik

@ -1,7 +1,4 @@
"""Unit tests for pages.py."""
from unittest.mock import patch
from flask import get_flashed_messages
from granary import as2, atom, microformats2, rss
from granary.tests.test_bluesky import REPLY_BSKY
from granary.tests.test_as1 import (
@ -115,43 +112,6 @@ class PagesTest(TestCase):
got = self.client.get('/web/user.com?before=2024-01-01+01:01:01&after=2023-01-01+01:01:01')
self.assert_equals(400, got.status_code)
@patch('requests.get')
def test_check_web_site(self, mock_get):
redir = 'http://localhost/.well-known/webfinger?resource=acct:user.com@user.com'
mock_get.side_effect = (
requests_response('', status=302, redirected_url=redir),
requests_response(ACTOR_HTML, url='https://user.com/',
content_type=common.CONTENT_TYPE_HTML),
)
got = self.client.post('/web-site', data={'url': 'https://user.com/'})
self.assert_equals(302, got.status_code)
self.assert_equals('/web/user.com', got.headers['Location'])
user = Web.get_by_id('user.com')
self.assertTrue(user.has_hcard)
self.assertEqual('Person', user.actor_as2['type'])
self.assertEqual('http://localhost/user.com', user.actor_as2['id'])
def test_check_web_site_bad_url(self):
got = self.client.post('/web-site', data={'url': '!!!'})
self.assert_equals(200, got.status_code)
self.assertEqual(['No domain found in !!!'], get_flashed_messages())
self.assertEqual(1, Web.query().count())
@patch('requests.get')
def test_check_web_site_fetch_fails(self, mock_get):
redir = 'http://localhost/.well-known/webfinger?resource=acct:orig@orig'
mock_get.side_effect = (
requests_response('', status=302, redirected_url=redir),
requests_response('', status=503),
)
got = self.client.post('/web-site', data={'url': 'https://orig/'})
self.assert_equals(200, got.status_code, got.headers)
self.assertTrue(get_flashed_messages()[0].startswith(
"Couldn't connect to https://orig/: "))
def test_followers(self):
self.make_user('bar.com')
Follower.get_or_create('bar.com', 'https://no/stored/follow')

Wyświetl plik

@ -1,11 +1,11 @@
# coding=utf-8
"""Unit tests for webmention.py."""
import copy
from unittest import mock
from unittest.mock import patch
from urllib.parse import urlencode
import feedparser
from flask import g
from flask import g, get_flashed_messages
from granary import as1, as2, atom, microformats2
from httpsig.sign import HeaderSigner
from oauth_dropins.webutil import appengine_config, util
@ -158,8 +158,8 @@ DELETE_AS2 = {
'to': [as2.PUBLIC_AUDIENCE],
}
@mock.patch('requests.post')
@mock.patch('requests.get')
@patch('requests.post')
@patch('requests.get')
class WebTest(testutil.TestCase):
def setUp(self):
super().setUp()
@ -413,7 +413,7 @@ class WebTest(testutil.TestCase):
self.assertEqual(400, got.status_code)
self.assertEqual(0, Object.query().count())
@mock.patch('oauth_dropins.webutil.appengine_config.tasks_client.create_task')
@patch('oauth_dropins.webutil.appengine_config.tasks_client.create_task')
def test_make_task(self, mock_create_task, mock_get, mock_post):
mock_get.side_effect = [self.note, self.actor]
@ -1512,9 +1512,44 @@ http://this/404s
'https://user', '://user.com'):
self.assertFalse(self.user.is_homepage(url), url)
def test_check_web_site(self, mock_get, _):
redir = 'http://localhost/.well-known/webfinger?resource=acct:user.com@user.com'
mock_get.side_effect = (
requests_response('', status=302, redirected_url=redir),
requests_response(ACTOR_HTML, url='https://user.com/',
content_type=CONTENT_TYPE_HTML),
)
@mock.patch('requests.post')
@mock.patch('requests.get')
got = self.client.post('/web-site', data={'url': 'https://user.com/'})
self.assert_equals(302, got.status_code)
self.assert_equals('/web/user.com', got.headers['Location'])
user = Web.get_by_id('user.com')
self.assertTrue(user.has_hcard)
self.assertEqual('Person', user.actor_as2['type'])
self.assertEqual('http://localhost/user.com', user.actor_as2['id'])
def test_check_web_site_bad_url(self, _, __):
got = self.client.post('/web-site', data={'url': '!!!'})
self.assert_equals(200, got.status_code)
self.assertEqual(['No domain found in !!!'], get_flashed_messages())
self.assertEqual(1, Web.query().count())
def test_check_web_site_fetch_fails(self, mock_get, _):
redir = 'http://localhost/.well-known/webfinger?resource=acct:orig@orig'
mock_get.side_effect = (
requests_response('', status=302, redirected_url=redir),
requests_response('', status=503),
)
got = self.client.post('/web-site', data={'url': 'https://orig/'})
self.assert_equals(200, got.status_code, got.headers)
self.assertTrue(get_flashed_messages()[0].startswith(
"Couldn't connect to https://orig/: "))
@patch('requests.post')
@patch('requests.get')
class WebProtocolTest(testutil.TestCase):
def setUp(self):

47
web.py
Wyświetl plik

@ -1,10 +1,11 @@
"""Handles inbound webmentions."""
import datetime
import difflib
import logging
from urllib.parse import urlencode, urljoin, urlparse
import feedparser
from flask import g, redirect, request
from flask import g, redirect, render_template, request
from flask.views import View
from google.cloud.ndb import Key
from granary import as1, as2, microformats2
@ -19,8 +20,8 @@ from requests import HTTPError, RequestException, URLRequired
from werkzeug.exceptions import BadGateway, BadRequest, HTTPException, NotFound
import activitypub
from flask_app import app
import common
from flask_app import app, cache
from models import Follower, Object, PROTOCOLS, Target, User
from protocol import Protocol
@ -231,6 +232,34 @@ class Web(User, Protocol):
PROTOCOLS['webmention'] = Web
@app.get('/web-site')
@flask_util.cached(cache, datetime.timedelta(days=1))
def enter_web_site():
return render_template('enter_web_site.html')
@app.post('/web-site')
def check_web_site():
url = request.values['url']
domain = util.domain_from_link(url, minimize=False)
if not domain:
flash(f'No domain found in {url}')
return render_template('enter_web_site.html')
g.user = Web.get_or_create(domain, direct=True)
try:
g.user = g.user.verify()
except BaseException as e:
code, body = util.interpret_http_exception(e)
if code:
flash(f"Couldn't connect to {url}: {e}")
return render_template('enter_web_site.html')
raise
g.user.put()
return redirect(g.user.user_page_path())
@app.post('/webmention')
def webmention_external():
"""Handles inbound webmention, enqueue task to process.
@ -315,7 +344,7 @@ def webmention_task():
'id': id,
'objectType': 'activity',
'verb': 'delete',
'actor': g.user.actor_id(),
'actor': activitypub.actor_id(g.user),
'object': source,
})
@ -324,8 +353,8 @@ def webmention_task():
props = obj.mf2['properties']
author_urls = microformats2.get_string_urls(props.get('author', []))
if author_urls and not g.user.is_homepage(author_urls[0]):
logger.info(f'Overriding author {author_urls[0]} with {g.user.actor_id()}')
props['author'] = [g.user.actor_id()]
logger.info(f'Overriding author {author_urls[0]} with {activitypub.actor_id(g.user)}')
props['author'] = [activitypub.actor_id(g.user)]
logger.info(f'Converted to AS1: {obj.type}: {json_dumps(obj.as1, indent=2)}')
@ -334,7 +363,7 @@ def webmention_task():
obj.put()
actor_as1 = {
**obj.as1,
'id': g.user.actor_id(),
'id': activitypub.actor_id(g.user),
'updated': util.now().isoformat(),
}
id = common.host_url(f'{obj.key.id()}#update-{util.now().isoformat()}')
@ -342,7 +371,7 @@ def webmention_task():
'objectType': 'activity',
'verb': 'update',
'id': id,
'actor': g.user.actor_id(),
'actor': activitypub.actor_id(g.user),
'object': actor_as1,
})
@ -374,7 +403,7 @@ def webmention_task():
'objectType': 'activity',
'verb': 'update',
'id': id,
'actor': g.user.actor_id(),
'actor': activitypub.actor_id(g.user),
'object': {
# Mastodon requires the updated field for Updates, so
# add a default value.
@ -397,7 +426,7 @@ def webmention_task():
'objectType': 'activity',
'verb': 'post',
'id': id,
'actor': g.user.actor_id(),
'actor': activitypub.actor_id(g.user),
'object': obj.as1,
}
obj = Object(id=id, mf2=obj.mf2, our_as1=create_as1,

Wyświetl plik

@ -14,8 +14,9 @@ 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
from flask_app import app, cache
from activitypub import actor_id, address
import common
from flask_app import app, cache
from models import User
from web import Web
@ -55,7 +56,7 @@ class Actor(flask_util.XrdOrJrd):
actor = g.user.to_as1() or {}
homepage = g.user.homepage
handle = g.user.address()
handle = address(g.user)
logger.info(f'Generating WebFinger data for {domain}')
logger.info(f'AS1 actor: {actor}')
@ -94,13 +95,13 @@ class Actor(flask_util.XrdOrJrd):
# WARNING: in python 2 sometimes request.host_url lost port,
# http://localhost:8080 would become just http://localhost. no
# clue how or why. pay attention here if that happens again.
'href': g.user.actor_id(),
'href': actor_id(g.user),
}, {
# AP reads this and sharedInbox from the AS2 actor, not
# webfinger, so strictly speaking, it's probably not needed here.
'rel': 'inbox',
'type': as2.CONTENT_TYPE,
'href': g.user.actor_id('inbox'),
'href': actor_id(g.user, 'inbox'),
}, {
# https://www.w3.org/TR/activitypub/#sharedInbox
'rel': 'sharedInbox',