kopia lustrzana https://github.com/snarfed/bridgy-fed
add opt out via #nobridge or #nobot text in user profile bio
fixes #666 (unfortunate issue number 😆)pull/684/head
rodzic
fbf1e645ef
commit
d0da119b07
27
models.py
27
models.py
|
@ -48,6 +48,9 @@ OBJECT_EXPIRE_TYPES = (
|
||||||
)
|
)
|
||||||
OBJECT_EXPIRE_AGE = timedelta(days=90)
|
OBJECT_EXPIRE_AGE = timedelta(days=90)
|
||||||
|
|
||||||
|
OPT_OUT_TAGS = frozenset(('#nobot', '#nobridge'))
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -314,6 +317,29 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
||||||
"""DEPRECATED: replaced by handle. Kept for backward compatibility."""
|
"""DEPRECATED: replaced by handle. Kept for backward compatibility."""
|
||||||
return None
|
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):
|
def handle_as(self, to_proto):
|
||||||
"""Returns this user's handle in a different protocol.
|
"""Returns this user's handle in a different protocol.
|
||||||
|
|
||||||
|
@ -991,6 +1017,7 @@ class Follower(ndb.Model):
|
||||||
|
|
||||||
for f, u in zip(followers, users):
|
for f, u in zip(followers, users):
|
||||||
f.user = u
|
f.user = u
|
||||||
|
followers = [f for f in followers if f.user.status != 'opt-out']
|
||||||
|
|
||||||
return followers, before, after
|
return followers, before, after
|
||||||
|
|
||||||
|
|
2
pages.py
2
pages.py
|
@ -71,7 +71,7 @@ def load_user(protocol, id):
|
||||||
elif g.user and id != g.user.key.id(): # use_instead redirect
|
elif g.user and id != g.user.key.id(): # use_instead redirect
|
||||||
error('', status=302, location=g.user.user_page_path())
|
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
|
# TODO: switch back to USER_NOT_FOUND_HTML
|
||||||
# not easy via exception/abort because this uses Werkzeug's built in
|
# not easy via exception/abort because this uses Werkzeug's built in
|
||||||
# NotFound exception subclass, and we'd need to make it implement
|
# NotFound exception subclass, and we'd need to make it implement
|
||||||
|
|
21
protocol.py
21
protocol.py
|
@ -214,11 +214,17 @@ class Protocol:
|
||||||
valid :class:`User` id for this protocol.
|
valid :class:`User` id for this protocol.
|
||||||
"""
|
"""
|
||||||
if cls == 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
|
# load user so that we follow use_instead
|
||||||
existing = cls.get_by_id(id)
|
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
|
@staticmethod
|
||||||
def for_id(id):
|
def for_id(id):
|
||||||
|
@ -330,10 +336,12 @@ class Protocol:
|
||||||
|
|
||||||
# step 2: look for matching User in the datastore
|
# step 2: look for matching User in the datastore
|
||||||
for proto in candidates:
|
for proto in candidates:
|
||||||
user = proto.query(proto.handle == handle).get(keys_only=True)
|
user = proto.query(proto.handle == handle).get()
|
||||||
if user:
|
if user:
|
||||||
logger.info(f' user {user} owns handle {handle}')
|
if user.status == 'opt-out':
|
||||||
return (proto, user.id())
|
return (None, None)
|
||||||
|
logger.info(f' user {user.key} owns handle {handle}')
|
||||||
|
return (proto, user.key.id())
|
||||||
|
|
||||||
# step 3: resolve handle to id
|
# step 3: resolve handle to id
|
||||||
for proto in candidates:
|
for proto in candidates:
|
||||||
|
@ -361,7 +369,7 @@ class Protocol:
|
||||||
owner = as1.get_owner(obj.as1)
|
owner = as1.get_owner(obj.as1)
|
||||||
if owner:
|
if owner:
|
||||||
return cls.key_for(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
|
return g.user.key
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -534,6 +542,7 @@ class Protocol:
|
||||||
error(f'Sorry, {obj.type} activities are not supported yet.', status=501)
|
error(f'Sorry, {obj.type} activities are not supported yet.', status=501)
|
||||||
|
|
||||||
# add owner(s)
|
# add owner(s)
|
||||||
|
# (actor_key returns None if the user is opted out)
|
||||||
actor_key = from_cls.actor_key(obj, default_g_user=False)
|
actor_key = from_cls.actor_key(obj, default_g_user=False)
|
||||||
if actor_key:
|
if actor_key:
|
||||||
obj.add('users', actor_key)
|
obj.add('users', actor_key)
|
||||||
|
|
|
@ -36,6 +36,7 @@ Bridgy Fed takes some technical know-how to set up, and there are simpler (but l
|
||||||
<li><a href="#update-profile">How do I update my profile?</a></li>
|
<li><a href="#update-profile">How do I update my profile?</a></li>
|
||||||
<li><a href="#user-page">Where's my user page and dashboard?</a></li>
|
<li><a href="#user-page">Where's my user page and dashboard?</a></li>
|
||||||
<li><a href="#mastodon-link-verification">How do I verify my profile links (ie get green checks) in Mastodon?</a></li>
|
<li><a href="#mastodon-link-verification">How do I verify my profile links (ie get green checks) in Mastodon?</a></li>
|
||||||
|
<li><a href="#opt-out">I hate this! How do I opt out?</a></li>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
<p><em>Usage</em></p>
|
<p><em>Usage</em></p>
|
||||||
|
@ -218,6 +219,11 @@ Your site's fediverse profile comes from the <a href="https://microformats.org/w
|
||||||
<p>When you're logged into a Mastodon instance, searching for your Bridgy Fed user triggers that instance to check and verify its profile link(s) in the background. This only works when you're logged in with a native Mastodon account. Also, each instance does this independently; verified links are not synched across instances.</p>
|
<p>When you're logged into a Mastodon instance, searching for your Bridgy Fed user triggers that instance to check and verify its profile link(s) in the background. This only works when you're logged in with a native Mastodon account. Also, each instance does this independently; verified links are not synched across instances.</p>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li id="opt-out" class="question">I hate this! How do I opt out?</li>
|
||||||
|
<li class="answer">
|
||||||
|
<p>Sorry to hear it! Just put the text <code>#nobridge</code> or <code>#nobot</code> in your profile bio, refresh your profile on your Bridgy Fed user page, and it will stop bridging your account.</p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
<h3 id="usage">Usage</h3>
|
<h3 id="usage">Usage</h3>
|
||||||
|
|
|
@ -211,13 +211,32 @@ class UserTest(TestCase):
|
||||||
user = g.user.key.get()
|
user = g.user.key.get()
|
||||||
self.assertFalse(hasattr(user, '_obj'))
|
self.assertFalse(hasattr(user, '_obj'))
|
||||||
self.assertFalse(hasattr(alice, '_obj'))
|
self.assertFalse(hasattr(alice, '_obj'))
|
||||||
self.assertFalse(hasattr(bob, '_obj'))
|
self.assertIsNone(bob._obj)
|
||||||
|
|
||||||
User.load_multi([user, alice, bob])
|
User.load_multi([user, alice, bob])
|
||||||
self.assertIsNotNone(user._obj)
|
self.assertIsNotNone(user._obj)
|
||||||
self.assertIsNone(alice._obj)
|
self.assertIsNone(alice._obj)
|
||||||
self.assertIsNone(bob._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):
|
class ObjectTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
|
@ -101,6 +101,12 @@ class PagesTest(TestCase):
|
||||||
got = self.client.get('/web/user.com')
|
got = self.client.get('/web/user.com')
|
||||||
self.assert_equals(404, got.status_code)
|
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):
|
def test_user_web_redirect(self):
|
||||||
got = self.client.get('/user/user.com')
|
got = self.client.get('/user/user.com')
|
||||||
self.assert_equals(301, got.status_code)
|
self.assert_equals(301, got.status_code)
|
||||||
|
|
|
@ -155,6 +155,11 @@ class ProtocolTest(TestCase):
|
||||||
self.assertEqual('user.com', user.handle)
|
self.assertEqual('user.com', user.handle)
|
||||||
self.assertEqual((Web, 'user.com'), Protocol.for_handle('user.com'))
|
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(
|
@patch('dns.resolver.resolve', return_value = dns_answer(
|
||||||
'_atproto.han.dull.', '"did=did:plc:123abc"'))
|
'_atproto.han.dull.', '"did=did:plc:123abc"'))
|
||||||
def test_for_handle_atproto_resolve(self, _):
|
def test_for_handle_atproto_resolve(self, _):
|
||||||
|
@ -282,7 +287,7 @@ class ProtocolTest(TestCase):
|
||||||
Fake.load('nope', local=False, remote=False)
|
Fake.load('nope', local=False, remote=False)
|
||||||
|
|
||||||
def test_actor_key(self):
|
def test_actor_key(self):
|
||||||
user = Fake(id='fake:a')
|
user = self.make_user(id='fake:a', cls=Fake)
|
||||||
a_key = user.key
|
a_key = user.key
|
||||||
|
|
||||||
for expected, obj in [
|
for expected, obj in [
|
||||||
|
@ -300,15 +305,22 @@ class ProtocolTest(TestCase):
|
||||||
self.assertEqual(a_key, Fake.actor_key(Object()))
|
self.assertEqual(a_key, Fake.actor_key(Object()))
|
||||||
self.assertIsNone(Fake.actor_key(Object(), default_g_user=False))
|
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):
|
def test_key_for(self):
|
||||||
self.assertEqual(self.user.key, Protocol.key_for(self.user.key.id()))
|
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'))
|
self.assertEqual(self.user.key, Protocol.key_for('fake:other'))
|
||||||
|
|
||||||
# no stored user
|
# no stored user
|
||||||
self.assertEqual(ndb.Key('Fake', 'fake:foo'), Protocol.key_for('fake:foo'))
|
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):
|
def test_targets_checks_blocklisted_per_protocol(self):
|
||||||
"""_targets should call the target protocol's is_blocklisted()."""
|
"""_targets should call the target protocol's is_blocklisted()."""
|
||||||
# non-ATProto account, ATProto target (PDS) is http://localhost,
|
# non-ATProto account, ATProto target (PDS) is http://localhost,
|
||||||
|
@ -909,7 +921,7 @@ class ProtocolReceiveTest(TestCase):
|
||||||
'object': 'fake:post',
|
'object': 'fake:post',
|
||||||
}
|
}
|
||||||
with self.assertRaises(NoContent):
|
with self.assertRaises(NoContent):
|
||||||
self.assertEqual('OK', Fake.receive_as1(delete_as1))
|
Fake.receive_as1(delete_as1)
|
||||||
|
|
||||||
self.assert_object('fake:post',
|
self.assert_object('fake:post',
|
||||||
deleted=True,
|
deleted=True,
|
||||||
|
@ -1364,3 +1376,17 @@ class ProtocolReceiveTest(TestCase):
|
||||||
self.client.post('/queue/receive', data={'obj': obj.key.urlsafe()})
|
self.client.post('/queue/receive', data={'obj': obj.key.urlsafe()})
|
||||||
obj = Object.get_by_id('fake:post#bridgy-fed-create')
|
obj = Object.get_by_id('fake:post#bridgy-fed-create')
|
||||||
self.assertEqual('ignored', obj.status)
|
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)
|
||||||
|
|
Ładowanie…
Reference in New Issue