AP: fix followers and outbox for protocol bot users

eg https://bsky.brid.gy/bsky.brid.gy/followers , wasn't working before
pull/1263/head
Ryan Barrett 2024-08-17 12:01:58 -07:00
rodzic ea6e195835
commit 34cb4fbe59
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 6BE31FDF4776E9D4
3 zmienionych plików z 122 dodań i 48 usunięć

Wyświetl plik

@ -7,7 +7,7 @@ import re
from urllib.parse import quote_plus, urljoin, urlparse
from unittest.mock import MagicMock
from flask import abort, g, redirect, request
from flask import abort, g, request
from google.cloud import ndb
from google.cloud.ndb.query import FilterNode, OR, Query
from granary import as1, as2
@ -15,6 +15,7 @@ from httpsig import HeaderVerifier
from httpsig.requests_auth import HTTPSignatureAuth
from httpsig.utils import parse_signature_header
from oauth_dropins.webutil import appengine_info, flask_util, util
from oauth_dropins.webutil.flask_util import MovedPermanently
from oauth_dropins.webutil.util import fragmentless, json_dumps, json_loads
import requests
from requests import TooManyRedirects
@ -928,6 +929,36 @@ def postprocess_as2_actor(actor, user):
return actor
def _load_user(handle_or_id, create=False):
if handle_or_id == PRIMARY_DOMAIN or handle_or_id in PROTOCOL_DOMAINS:
from web import Web
proto = Web
else:
proto = Protocol.for_request(fed='web')
if not proto:
error(f"Couldn't determine protocol", status=404)
if proto.owns_id(handle_or_id) is False:
if proto.owns_handle(handle_or_id) is False:
error(f"{handle_or_id} doesn't look like a {proto.LABEL} id or handle",
status=404)
id = proto.handle_to_id(handle_or_id)
if not id:
error(f"Couldn't resolve {handle_or_id} as a {proto.LABEL} handle",
status=404)
else:
id = handle_or_id
assert id
user = proto.get_or_create(id) if create else proto.get_by_id(id)
if not user or not user.is_enabled(ActivityPub):
error(f'{proto.LABEL} user {id} not found', status=404)
return user
# source protocol in subdomain.
# WARNING: the user page handler in pages.py overrides this for fediverse
# addresses with leading @ character. be careful when changing this route!
@ -939,42 +970,21 @@ def postprocess_as2_actor(actor, user):
@flask_util.headers(CACHE_CONTROL)
def actor(handle_or_id):
"""Serves a user's AS2 actor from the datastore."""
if handle_or_id == PRIMARY_DOMAIN or handle_or_id in PROTOCOL_DOMAINS:
from web import Web
cls = Web
else:
cls = Protocol.for_request(fed='web')
user = _load_user(handle_or_id, create=True)
proto = user
if not cls:
error(f"Couldn't determine protocol", status=404)
elif cls.LABEL == 'web' and request.path.startswith('/ap/'):
if proto.LABEL == 'web' and request.path.startswith('/ap/'):
# we started out with web users' AP ids as fed.brid.gy/[domain], so we
# need to preserve those for backward compatibility
return redirect(subdomain_wrap(None, f'/{handle_or_id}'), code=301)
if cls.owns_id(handle_or_id) is False:
if cls.owns_handle(handle_or_id) is False:
error(f"{handle_or_id} doesn't look like a {cls.LABEL} id or handle",
status=404)
id = cls.handle_to_id(handle_or_id)
if not id:
error(f"Couldn't resolve {handle_or_id} as a {cls.LABEL} handle",
status=404)
else:
id = handle_or_id
assert id
user = cls.get_or_create(id)
if not user or not user.is_enabled(ActivityPub):
error(f'{cls.LABEL} user {id} not found', status=404)
raise MovedPermanently(location=subdomain_wrap(None, f'/{handle_or_id}'))
id = user.id_as(ActivityPub)
# check that we're serving from the right subdomain
if request.host != urlparse(id).netloc:
return redirect(id)
raise MovedPermanently(location=id)
if not user.obj or not user.obj.as1:
user.obj = cls.load(user.profile_id(), gateway=True)
user.obj = proto.load(user.profile_id(), gateway=True)
if user.obj:
user.obj.put()
@ -990,7 +1000,7 @@ def actor(handle_or_id):
'following': id + '/following',
'followers': id + '/followers',
'endpoints': {
'sharedInbox': subdomain_wrap(cls, '/ap/sharedInbox'),
'sharedInbox': subdomain_wrap(proto, '/ap/sharedInbox'),
},
# add this if we ever change the Web actor ids to be /web/[id]
# 'alsoKnownAs': [host_url(id)],
@ -1139,11 +1149,7 @@ def follower_collection(id, collection):
import pages
return pages.followers_or_following('ap', id, collection)
protocol = Protocol.for_request(fed='web')
assert protocol
user = protocol.get_by_id(id)
if not user or not user.is_enabled(ActivityPub):
return f'{protocol} user {id} not found', 404
user = _load_user(id)
if request.method == 'HEAD':
return '', {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
@ -1203,13 +1209,7 @@ def outbox(id):
TODO: unify page generation with follower_collection()
"""
protocol = Protocol.for_request(fed='web')
if not protocol:
error(f"Couldn't determine protocol", status=404)
user = protocol.get_by_id(id)
if not user or not user.is_enabled(ActivityPub):
error(f'User {id} not found', status=404)
user = _load_user(id)
if request.method == 'HEAD':
return '', {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}

Wyświetl plik

@ -476,13 +476,13 @@ class ActivityPubTest(TestCase):
self.user.ap_subdomain = 'web'
self.user.put()
resp = self.client.get('/user.com', base_url='https://fed.brid.gy/')
self.assertEqual(302, resp.status_code)
self.assertEqual(301, resp.status_code)
self.assertEqual('https://web.brid.gy/user.com', resp.headers['Location'])
self.user.ap_subdomain = 'fed'
self.user.put()
got = self.client.get('/user.com', base_url='https://web.brid.gy/')
self.assertEqual(302, got.status_code)
self.assertEqual(301, got.status_code)
self.assertEqual('https://fed.brid.gy/user.com', got.headers['Location'])
def test_actor_opted_out(self, *_):
@ -493,8 +493,6 @@ class ActivityPubTest(TestCase):
got = self.client.get('/user.com')
self.assertEqual(404, got.status_code)
# skip _pre_put_hook since it doesn't allow internal domains
@patch.object(Web, '_pre_put_hook', new=lambda self: None)
def test_actor_protocol_bot_user(self, *_):
"""Web users are special cased to drop the /web/ prefix."""
actor_as2 = json_loads(util.read('bsky.brid.gy.as2.json'))
@ -1844,6 +1842,26 @@ class ActivityPubTest(TestCase):
},
}, resp.json)
def test_followers_collection_protocol_bot_user(self, *_):
self.user = self.make_user('bsky.brid.gy', cls=Web, ap_subdomain='bsky')
self.store_followers()
resp = self.client.get('/bsky.brid.gy/followers',
base_url='https://bsky.brid.gy')
self.assertEqual(200, resp.status_code)
self.assert_equals({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'https://bsky.brid.gy/bsky.brid.gy/followers',
'type': 'Collection',
'summary': "bsky.brid.gy's followers",
'totalItems': 2,
'first': {
'type': 'CollectionPage',
'partOf': 'https://bsky.brid.gy/bsky.brid.gy/followers',
'items': [ACTOR, ACTOR],
},
}, resp.json)
@patch('models.PAGE_SIZE', 1)
def test_followers_collection_page(self, *_):
self.store_followers()
@ -1864,6 +1882,15 @@ class ActivityPubTest(TestCase):
'items': [ACTOR],
}, resp.json)
def test_followers_collection_page_protocol_bot_user(self, *_):
self.user = self.make_user('bsky.brid.gy', cls=Web, ap_subdomain='bsky')
self.store_followers()
before = (datetime.utcnow() + timedelta(seconds=1)).isoformat()
resp = self.client.get(f'/bsky.brid.gy/followers?before={before}',
base_url='https://bsky.brid.gy')
self.assertEqual(200, resp.status_code)
def test_following_collection_unknown_user(self, *_):
resp = self.client.get('/nope.com/following')
self.assertEqual(404, resp.status_code)
@ -1920,6 +1947,26 @@ class ActivityPubTest(TestCase):
},
}, resp.json)
def test_following_collection_protocol_bot_user(self, *_):
self.user = self.make_user('bsky.brid.gy', cls=Web, ap_subdomain='bsky')
self.store_following()
resp = self.client.get('/bsky.brid.gy/following',
base_url='https://bsky.brid.gy')
self.assertEqual(200, resp.status_code)
self.assert_equals({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'https://bsky.brid.gy/bsky.brid.gy/following',
'type': 'Collection',
'summary': "bsky.brid.gy's following",
'totalItems': 2,
'first': {
'type': 'CollectionPage',
'partOf': 'https://bsky.brid.gy/bsky.brid.gy/following',
'items': [ACTOR, ACTOR],
},
}, resp.json)
@patch('models.PAGE_SIZE', 1)
def test_following_collection_page(self, *_):
self.store_following()
@ -1940,6 +1987,15 @@ class ActivityPubTest(TestCase):
'items': [ACTOR],
}, resp.json)
def test_following_collection_page_protocol_bot_user(self, *_):
self.user = self.make_user('bsky.brid.gy', cls=Web, ap_subdomain='bsky')
self.store_following()
before = (datetime.utcnow() + timedelta(seconds=1)).isoformat()
resp = self.client.get(f'/bsky.brid.gy/following?before={before}',
base_url='https://bsky.brid.gy')
self.assertEqual(200, resp.status_code)
def test_following_collection_head(self, *_):
resp = self.client.head(f'/user.com/following')
self.assertEqual(200, resp.status_code)
@ -1974,6 +2030,24 @@ class ActivityPubTest(TestCase):
},
}, resp.json)
def test_outbox_protocol_bot_user_empty(self, *_):
self.make_user('bsky.brid.gy', cls=Web, ap_subdomain='bsky')
resp = self.client.get(f'/bsky.brid.gy/outbox',
base_url='https://bsky.brid.gy')
self.assertEqual(200, resp.status_code)
self.assertEqual({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'https://bsky.brid.gy/bsky.brid.gy/outbox',
'summary': "bsky.brid.gy's outbox",
'type': 'OrderedCollection',
'totalItems': 0,
'first': {
'type': 'CollectionPage',
'partOf': 'https://bsky.brid.gy/bsky.brid.gy/outbox',
'items': [],
},
}, resp.json)
def store_outbox_objects(self, user):
for i, obj in enumerate([REPLY, MENTION, LIKE, DELETE]):
self.store_object(id=obj['id'], users=[user.key], as2=obj)

6
web.py
Wyświetl plik

@ -68,7 +68,7 @@ def is_valid_domain(domain, allow_internal=True):
Valid means TLD is ok, not blacklisted, etc.
"""
if not domain or not re.match(DOMAIN_RE, domain):
# logger.debug(f"{domain} doesn't look like a domain")
logger.debug(f"{domain} doesn't look like a domain")
return False
if Web.is_blocklisted(domain, allow_internal=allow_internal):
@ -404,7 +404,7 @@ class Web(User, Protocol):
# homepage, check domain too
(urlparse(url).path.strip('/') == ''
and util.domain_from_link(url) in targets)):
# logger.info(f'Skipping sending to {url} , not a target in the object')
logger.debug(f'Skipping sending to {url} , not a target in the object')
return False
if to_cls.is_blocklisted(url):
@ -739,7 +739,7 @@ def poll_feed_task():
if url := elem.get('url'):
elem['id'] = elem['url']
# logger.info(f'Converted to AS1: {json_dumps(activity, indent=2)}')
logger.debug(f'Converted to AS1: {json_dumps(activity, indent=2)}')
id = Object(our_as1=activity).as1.get('id')
if not id: