diff --git a/activitypub.py b/activitypub.py index 1217b09..4d1c129 100644 --- a/activitypub.py +++ b/activitypub.py @@ -250,16 +250,14 @@ def follower_collection(domain, collection): if not User.get_by_id(domain): return f'User {domain} not found', 404 - # this query is duplicated in pages.followers_or_following() - logger.info(f"Counting {domain}'s {collection}") + followers, before, after = common.fetch_followers(domain, collection) + domain_prop = Follower.dest if collection == 'followers' else Follower.src query = Follower.query( Follower.status == 'active', domain_prop == domain, ) count = query.count() - followers, before, after = common.fetch_page(query, Follower) - ret = { '@context': 'https://www.w3.org/ns/activitystreams', 'summary': f"{domain}'s {collection}", diff --git a/common.py b/common.py index 9750b32..0d2e002 100644 --- a/common.py +++ b/common.py @@ -22,7 +22,7 @@ from oauth_dropins.webutil.util import json_dumps, json_loads import requests from werkzeug.exceptions import BadGateway -from models import Activity, User +from models import Activity, Follower, User logger = logging.getLogger(__name__) @@ -589,6 +589,32 @@ def actor(domain, user=None): return actor +def fetch_followers(domain, collection): + """Fetches a page of Follower entities. + + Wraps :func:`common.fetch_page`. Paging uses the `before` and `after` query + parameters, if available in the request. + + Args: + domain: str, user to fetch entities for + collection, str, 'followers' or 'following' + + Returns: + (results, new_before, new_after) tuple with: + results: list of Follower entities + new_before, new_after: str query param values for `before` and `after` + to fetch the previous and next pages, respectively + """ + assert collection in ('followers', 'following'), collection + + domain_prop = Follower.dest if collection == 'followers' else Follower.src + query = Follower.query( + Follower.status == 'active', + domain_prop == domain, + ).order(-Follower.updated) + return fetch_page(query, Follower) + + def fetch_page(query, model_class): """Fetches a page of results from a datastore query. diff --git a/pages.py b/pages.py index f6bdb16..b0ecdb0 100644 --- a/pages.py +++ b/pages.py @@ -103,16 +103,10 @@ def user(domain): @app.get(f'/user//') def followers_or_following(domain, collection): - if not (user := User.get_by_id(domain)): + if not (user := User.get_by_id(domain)): # user var is used in template return render_template('user_not_found.html', domain=domain), 404 - # this query is duplicated in activitypub.followers_collection() - domain_prop = Follower.dest if collection == 'followers' else Follower.src - query = Follower.query( - Follower.status == 'active', - domain_prop == domain, - ).order(-Follower.updated) - followers, before, after = common.fetch_page(query, Follower) + followers, before, after = common.fetch_followers(domain, collection) for f in followers: f.url = f.src if collection == 'followers' else f.dest diff --git a/tests/test_xrpc_graph.py b/tests/test_xrpc_graph.py index c37dfd8..ead6137 100644 --- a/tests/test_xrpc_graph.py +++ b/tests/test_xrpc_graph.py @@ -9,7 +9,7 @@ import requests from .test_activitypub import ACTOR, FOLLOW, FOLLOW_WITH_ACTOR, FOLLOW_WITH_OBJECT from . import testutil -from models import Follower +from models import Follower, User ACTOR_DECLARATION = { '$type': 'app.bsky.system.declRef', @@ -49,7 +49,14 @@ class XrpcGraphTest(testutil.TestCase): query_string={'user': 'not a domain'}) self.assertEqual(400, resp.status_code) + def test_getFollowers_no_user(self, mock_get): + resp = self.client.get('/xrpc/app.bsky.graph.getFollowers', + query_string={'user': 'no.com'}) + self.assertEqual(400, resp.status_code) + def test_getFollowers_empty(self, mock_get): + User.get_or_create('foo.com') + resp = self.client.get('/xrpc/app.bsky.graph.getFollowers', query_string={'user': 'foo.com'}) self.assertEqual(200, resp.status_code) @@ -60,6 +67,8 @@ class XrpcGraphTest(testutil.TestCase): }, resp.json) def test_getFollowers(self, mock_get): + User.get_or_create('foo.com') + other_follow = copy.deepcopy(FOLLOW) other_follow['actor'] = { 'url': 'http://other', @@ -89,6 +98,8 @@ class XrpcGraphTest(testutil.TestCase): self.assertEqual(400, resp.status_code) def test_getFollows_empty(self, mock_get): + User.get_or_create('foo.com') + resp = self.client.get('/xrpc/app.bsky.graph.getFollows', query_string={'user': 'foo.com'}) self.assertEqual(200, resp.status_code) @@ -99,6 +110,8 @@ class XrpcGraphTest(testutil.TestCase): }, resp.json) def test_getFollows(self, mock_get): + User.get_or_create('foo.com') + other_follow = copy.deepcopy(FOLLOW) other_follow['object'] = { 'url': 'http://other', diff --git a/xrpc_graph.py b/xrpc_graph.py index fb63813..0518e14 100644 --- a/xrpc_graph.py +++ b/xrpc_graph.py @@ -6,7 +6,8 @@ from granary import bluesky from oauth_dropins.webutil import util from app import xrpc_server -from models import Follower +import common +from models import Follower, User logger = logging.getLogger(__name__) @@ -24,12 +25,17 @@ def get_followers(query_prop, output_field, user=None, limit=50, before=None): # TODO: what is user? if not user or not re.match(util.DOMAIN_RE, user): raise ValueError(f'{user} is not a domain') + elif not User.get_by_id(user): + raise ValueError(f'Unknown user {user}') - followers = [] - for follower in Follower.query(query_prop == user).fetch(limit): + collection = 'followers' if output_field == 'followers' else 'following' + followers, before, after = common.fetch_followers(user, collection) + + actors = [] + for follower in followers: actor = follower.to_as1() if actor: - followers.append({ + actors.append({ **bluesky.actor_to_ref(actor), '$type': 'app.bsky.graph.getFollowers#follower', 'indexedAt': util.now().isoformat(), @@ -37,7 +43,7 @@ def get_followers(query_prop, output_field, user=None, limit=50, before=None): return { 'subject': bluesky.actor_to_ref({'url': f'https://{user}/'}), - output_field: followers, + output_field: actors, 'cursor': '', }