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)
|
||||
|
||||
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
|
||||
|
||||
|
|
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
|
||||
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
|
||||
|
|
21
protocol.py
21
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)
|
||||
|
|
|
@ -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="#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="#opt-out">I hate this! How do I opt out?</a></li>
|
||||
|
||||
<br>
|
||||
<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>
|
||||
</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>
|
||||
<h3 id="usage">Usage</h3>
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
Ładowanie…
Reference in New Issue