From d0da119b076d47d68e85e3f2bca5c756c0f78e6b Mon Sep 17 00:00:00 2001 From: Ryan Barrett Date: Fri, 13 Oct 2023 12:36:31 -0700 Subject: [PATCH] add opt out via #nobridge or #nobot text in user profile bio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes #666 (unfortunate issue number 😆) --- models.py | 27 +++++++++++++++++++++++++++ pages.py | 2 +- protocol.py | 21 +++++++++++++++------ templates/docs.html | 6 ++++++ tests/test_models.py | 21 ++++++++++++++++++++- tests/test_pages.py | 6 ++++++ tests/test_protocol.py | 32 +++++++++++++++++++++++++++++--- 7 files changed, 104 insertions(+), 11 deletions(-) diff --git a/models.py b/models.py index f854cb5..18b32d5 100644 --- a/models.py +++ b/models.py @@ -48,6 +48,9 @@ OBJECT_EXPIRE_TYPES = ( ) OBJECT_EXPIRE_AGE = timedelta(days=90) +OPT_OUT_TAGS = frozenset(('#nobot', '#nobridge')) + + logger = logging.getLogger(__name__) @@ -314,6 +317,29 @@ class User(StringIdModel, metaclass=ProtocolUserMeta): """DEPRECATED: replaced by handle. Kept for backward compatibility.""" return None + @ndb.ComputedProperty + def status(self): + """Whether this user has explicitly opted out of Bridgy Fed. + + Optional. Current possible values: + * ``opt-out`` + + Currently just looks for ``#nobridge`` or ``#nobot`` in the profile + description/bio. + + https://github.com/snarfed/bridgy-fed/issues/666 + """ + if not self.obj or not self.obj.as1: + return None + + for field in 'summary', 'displayName': + val = self.obj.as1.get(field) + for tag in OPT_OUT_TAGS: + if val and tag in val: + return 'opt-out' + + return None + def handle_as(self, to_proto): """Returns this user's handle in a different protocol. @@ -991,6 +1017,7 @@ class Follower(ndb.Model): for f, u in zip(followers, users): f.user = u + followers = [f for f in followers if f.user.status != 'opt-out'] return followers, before, after diff --git a/pages.py b/pages.py index 9e2bb9e..b8c27cb 100644 --- a/pages.py +++ b/pages.py @@ -71,7 +71,7 @@ def load_user(protocol, id): elif g.user and id != g.user.key.id(): # use_instead redirect error('', status=302, location=g.user.user_page_path()) - if not g.user or not g.user.direct: + if not g.user or not g.user.direct or g.user.status == 'opt-out': # TODO: switch back to USER_NOT_FOUND_HTML # not easy via exception/abort because this uses Werkzeug's built in # NotFound exception subclass, and we'd need to make it implement diff --git a/protocol.py b/protocol.py index e1584ba..92ec3d0 100644 --- a/protocol.py +++ b/protocol.py @@ -214,11 +214,17 @@ class Protocol: valid :class:`User` id for this protocol. """ if cls == Protocol: - return Protocol.for_id(id).key_for(id) + proto = Protocol.for_id(id) + return proto.key_for(id) if proto else None # load user so that we follow use_instead existing = cls.get_by_id(id) - return existing.key if existing else cls(id=id).key + if existing: + if existing.status == 'opt-out': + return None + return existing.key + + return cls(id=id).key @staticmethod def for_id(id): @@ -330,10 +336,12 @@ class Protocol: # step 2: look for matching User in the datastore for proto in candidates: - user = proto.query(proto.handle == handle).get(keys_only=True) + user = proto.query(proto.handle == handle).get() if user: - logger.info(f' user {user} owns handle {handle}') - return (proto, user.id()) + if user.status == 'opt-out': + return (None, None) + logger.info(f' user {user.key} owns handle {handle}') + return (proto, user.key.id()) # step 3: resolve handle to id for proto in candidates: @@ -361,7 +369,7 @@ class Protocol: owner = as1.get_owner(obj.as1) if owner: return cls.key_for(owner) - elif default_g_user and g.user: + elif default_g_user and g.user and g.user.status != 'opt-out': return g.user.key @classmethod @@ -534,6 +542,7 @@ class Protocol: error(f'Sorry, {obj.type} activities are not supported yet.', status=501) # add owner(s) + # (actor_key returns None if the user is opted out) actor_key = from_cls.actor_key(obj, default_g_user=False) if actor_key: obj.add('users', actor_key) diff --git a/templates/docs.html b/templates/docs.html index 3c2a1c0..9a5a00b 100644 --- a/templates/docs.html +++ b/templates/docs.html @@ -36,6 +36,7 @@ Bridgy Fed takes some technical know-how to set up, and there are simpler (but l
  • How do I update my profile?
  • Where's my user page and dashboard?
  • How do I verify my profile links (ie get green checks) in Mastodon?
  • +
  • I hate this! How do I opt out?

  • Usage

    @@ -218,6 +219,11 @@ Your site's fediverse profile comes from the I hate this! How do I opt out? +
  • +

    Sorry to hear it! Just put the text #nobridge or #nobot in your profile bio, refresh your profile on your Bridgy Fed user page, and it will stop bridging your account.

    +
  • +

    Usage

    diff --git a/tests/test_models.py b/tests/test_models.py index e5350de..bd43f6f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -211,13 +211,32 @@ class UserTest(TestCase): user = g.user.key.get() self.assertFalse(hasattr(user, '_obj')) self.assertFalse(hasattr(alice, '_obj')) - self.assertFalse(hasattr(bob, '_obj')) + self.assertIsNone(bob._obj) User.load_multi([user, alice, bob]) self.assertIsNotNone(user._obj) self.assertIsNone(alice._obj) self.assertIsNone(bob._obj) + def test_status(self): + self.assertIsNone(g.user.status) + + user = self.make_user('fake:user', cls=Fake, obj_as1={ + 'summary': 'I like this', + }) + self.assertIsNone(user.status) + + user.obj.our_as1.update({ + 'summary': 'well #nobot yeah', + }) + self.assertEqual('opt-out', user.status) + + user.obj.our_as1.update({ + 'summary': '🤷', + 'displayName': 'well #nobridge yeah', + }) + self.assertEqual('opt-out', user.status) + class ObjectTest(TestCase): def setUp(self): diff --git a/tests/test_pages.py b/tests/test_pages.py index 410b97b..13db7c7 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -101,6 +101,12 @@ class PagesTest(TestCase): got = self.client.get('/web/user.com') self.assert_equals(404, got.status_code) + def test_user_opted_out(self): + self.user.obj.our_as1 = {'summary': '#nobridge'} + self.user.obj.put() + got = self.client.get('/web/user.com') + self.assert_equals(404, got.status_code) + def test_user_web_redirect(self): got = self.client.get('/user/user.com') self.assert_equals(301, got.status_code) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 998c760..5871460 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -155,6 +155,11 @@ class ProtocolTest(TestCase): self.assertEqual('user.com', user.handle) self.assertEqual((Web, 'user.com'), Protocol.for_handle('user.com')) + def test_for_handle_opted_out_user(self): + user = self.make_user(id='user.com', cls=Web, obj_as1={'summary': '#nobot'}) + self.assertEqual('user.com', user.handle) + self.assertEqual((None, None), Protocol.for_handle('user.com')) + @patch('dns.resolver.resolve', return_value = dns_answer( '_atproto.han.dull.', '"did=did:plc:123abc"')) def test_for_handle_atproto_resolve(self, _): @@ -282,7 +287,7 @@ class ProtocolTest(TestCase): Fake.load('nope', local=False, remote=False) def test_actor_key(self): - user = Fake(id='fake:a') + user = self.make_user(id='fake:a', cls=Fake) a_key = user.key for expected, obj in [ @@ -300,15 +305,22 @@ class ProtocolTest(TestCase): self.assertEqual(a_key, Fake.actor_key(Object())) self.assertIsNone(Fake.actor_key(Object(), default_g_user=False)) + g.user.obj.our_as1 = {'summary': '#nobot'} + self.assertIsNone(Fake.actor_key(Object(), default_g_user=True)) + def test_key_for(self): self.assertEqual(self.user.key, Protocol.key_for(self.user.key.id())) - Fake(id='fake:other', use_instead=self.user.key).put() + user = Fake(id='fake:other', use_instead=self.user.key).put() self.assertEqual(self.user.key, Protocol.key_for('fake:other')) # no stored user self.assertEqual(ndb.Key('Fake', 'fake:foo'), Protocol.key_for('fake:foo')) + self.user.obj.our_as1 = {'summary': '#nobridge'} + self.user.obj.put() + self.assertIsNone(Protocol.key_for(self.user.key.id())) + def test_targets_checks_blocklisted_per_protocol(self): """_targets should call the target protocol's is_blocklisted().""" # non-ATProto account, ATProto target (PDS) is http://localhost, @@ -909,7 +921,7 @@ class ProtocolReceiveTest(TestCase): 'object': 'fake:post', } with self.assertRaises(NoContent): - self.assertEqual('OK', Fake.receive_as1(delete_as1)) + Fake.receive_as1(delete_as1) self.assert_object('fake:post', deleted=True, @@ -1364,3 +1376,17 @@ class ProtocolReceiveTest(TestCase): self.client.post('/queue/receive', data={'obj': obj.key.urlsafe()}) obj = Object.get_by_id('fake:post#bridgy-fed-create') self.assertEqual('ignored', obj.status) + + def test_g_user_opted_out(self): + self.make_followers() + g.user.obj.our_as1 = {'summary': '#nobot'} + g.user.obj.put() + + with self.assertRaises(NoContent): + Fake.receive_as1({ + 'id': 'fake:post', + 'objectType': 'note', + 'author': 'fake:user', + }) + + self.assertEqual([], Fake.sent)