kopia lustrzana https://github.com/snarfed/bridgy-fed
start on conditional opt in
* add Protocol.DEFAULT_ENABLED_PROTOCOLS * add User.enabled_protocols * move common.is_enabled to Protocol.is_enabled_to, include opt out/inpull/965/head
rodzic
f02ba80304
commit
259b7d72dd
|
@ -75,6 +75,7 @@ class ActivityPub(User, Protocol):
|
|||
LOGO_HTML = '<img src="/static/fediverse_logo.svg">'
|
||||
CONTENT_TYPE = as2.CONTENT_TYPE_LD_PROFILE
|
||||
HAS_FOLLOW_ACCEPTS = True
|
||||
DEFAULT_ENABLED_PROTOCOLS = ('web',)
|
||||
|
||||
def _pre_put_hook(self):
|
||||
"""Validate id, require URL, don't allow Bridgy Fed domains.
|
||||
|
@ -363,7 +364,7 @@ class ActivityPub(User, Protocol):
|
|||
from_proto = PROTOCOLS.get(obj.source_protocol)
|
||||
user_id = from_user.key.id() if from_user and from_user.key else None
|
||||
# TODO: uncomment
|
||||
# if from_proto and not common.is_enabled(cls, from_proto, handle_or_id=user_id):
|
||||
# if from_proto and not from_proto.is_enabled_to(cls, user=from_user):
|
||||
# error(f'{cls.LABEL} <=> {from_proto.LABEL} not enabled')
|
||||
|
||||
if obj.as2:
|
||||
|
@ -832,8 +833,6 @@ def actor(handle_or_id):
|
|||
cls = Protocol.for_request(fed='web')
|
||||
if not cls:
|
||||
error(f"Couldn't determine protocol", status=404)
|
||||
elif not common.is_enabled(cls, ActivityPub, handle_or_id=handle_or_id):
|
||||
error(f'{cls.LABEL} <=> activitypub not enabled')
|
||||
elif cls.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
|
||||
|
@ -851,6 +850,9 @@ def actor(handle_or_id):
|
|||
id = handle_or_id
|
||||
|
||||
assert id
|
||||
if not cls.is_enabled_to(ActivityPub, user=id):
|
||||
error(f'{cls.LABEL} user {id} not found', status=404)
|
||||
|
||||
user = cls.get_or_create(id)
|
||||
if not user:
|
||||
error(f'{cls.LABEL} user {id} not found', status=404)
|
||||
|
|
|
@ -97,6 +97,7 @@ class ATProto(User, Protocol):
|
|||
# need to update serviceEndpoint in all users' DID docs. :/
|
||||
PDS_URL = f'https://atproto{common.SUPERDOMAIN}/'
|
||||
CONTENT_TYPE = 'application/json'
|
||||
DEFAULT_ENABLED_PROTOCOLS = ('web',)
|
||||
|
||||
def _pre_put_hook(self):
|
||||
"""Validate id, require did:plc or non-blocklisted did:web."""
|
||||
|
@ -506,9 +507,8 @@ class ATProto(User, Protocol):
|
|||
dict: JSON object
|
||||
"""
|
||||
from_proto = PROTOCOLS.get(obj.source_protocol)
|
||||
user_id = from_user.key.id() if from_user and from_user.key else None
|
||||
# TODO: uncomment
|
||||
# if from_proto and not common.is_enabled(cls, from_proto, handle_or_id=user_id):
|
||||
# if from_proto and not from_proto.is_enabled_to(cls, user=from_user):
|
||||
# error(f'{cls.LABEL} <=> {from_proto.LABEL} not enabled')
|
||||
|
||||
if obj.bsky:
|
||||
|
|
26
common.py
26
common.py
|
@ -83,6 +83,7 @@ util.set_user_agent(USER_AGENT)
|
|||
TASKS_LOCATION = 'us-central1'
|
||||
RUN_TASKS_INLINE = False # overridden by unit tests
|
||||
|
||||
# TODO: switch to User.enabled_protocols
|
||||
USER_ALLOWLIST = (
|
||||
'snarfed.org',
|
||||
'did:plc:fdme4gb7mu7zrie7peay7tst',
|
||||
|
@ -264,31 +265,6 @@ def add(seq, val):
|
|||
seq.append(val)
|
||||
|
||||
|
||||
def is_enabled(proto_a, proto_b, handle_or_id=None):
|
||||
"""Returns True if bridging the two input protocols is enabled, False otherwise.
|
||||
|
||||
Args:
|
||||
proto_a (Protocol subclass)
|
||||
proto_b (Protocol subclass)
|
||||
handle_or_id (str): optional user handle or id
|
||||
|
||||
Returns:
|
||||
bool:
|
||||
"""
|
||||
if proto_a == proto_b:
|
||||
return True
|
||||
|
||||
labels = tuple(sorted((proto_a.LABEL, proto_b.LABEL)))
|
||||
|
||||
if DEBUG and ('fake' in labels or 'other' in labels):
|
||||
return True
|
||||
|
||||
if handle_or_id in USER_ALLOWLIST:
|
||||
return True
|
||||
|
||||
return labels in ENABLED_BRIDGES
|
||||
|
||||
|
||||
def create_task(queue, delay=None, **params):
|
||||
"""Adds a Cloud Tasks task.
|
||||
|
||||
|
|
10
ids.py
10
ids.py
|
@ -120,7 +120,7 @@ def translate_user_id(*, id, from_proto, to_proto):
|
|||
return subdomain_wrap(from_proto, f'/{to_proto.ABBREV}/{id}')
|
||||
|
||||
# only for unit tests
|
||||
case _, 'fake' | 'other':
|
||||
case _, 'fake' | 'other' | 'eefake':
|
||||
return f'{to_proto.LABEL}:u:{id}'
|
||||
case 'fake' | 'other', _:
|
||||
return id
|
||||
|
@ -167,10 +167,8 @@ def translate_handle(*, handle, from_proto, to_proto, enhanced):
|
|||
return handle
|
||||
|
||||
# only for unit tests
|
||||
case _, 'fake':
|
||||
return f'fake:handle:{handle}'
|
||||
case _, 'other':
|
||||
return f'other:handle:{handle}'
|
||||
case _, 'fake' | 'other' | 'eefake':
|
||||
return f'{to_proto.LABEL}:handle:{handle}'
|
||||
|
||||
assert False, (handle, from_proto.LABEL, to_proto.LABEL)
|
||||
|
||||
|
@ -229,7 +227,7 @@ def translate_object_id(*, id, from_proto, to_proto):
|
|||
return subdomain_wrap(from_proto, f'/convert/{to_proto.ABBREV}/{id}')
|
||||
|
||||
# only for unit tests
|
||||
case _, 'fake' | 'other':
|
||||
case _, 'fake' | 'other' | 'eefake':
|
||||
return f'{to_proto.LABEL}:o:{from_proto.ABBREV}:{id}'
|
||||
|
||||
assert False, (id, from_proto.LABEL, to_proto.LABEL)
|
||||
|
|
13
models.py
13
models.py
|
@ -114,6 +114,8 @@ def reset_protocol_properties():
|
|||
'protocol', choices=list(PROTOCOLS.keys()), required=True)
|
||||
Object.source_protocol = ndb.StringProperty(
|
||||
'source_protocol', choices=list(PROTOCOLS.keys()))
|
||||
User.enabled_protocols = ndb.StringProperty(
|
||||
'enabled_protocols', choices=list(PROTOCOLS.keys()), repeated=True)
|
||||
|
||||
abbrevs = f'({"|".join(PROTOCOLS.keys())}|fed)'
|
||||
common.SUBDOMAIN_BASE_URL_RE = re.compile(
|
||||
|
@ -157,6 +159,11 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
# #nobridge in their profile
|
||||
manual_opt_out = ndb.BooleanProperty()
|
||||
|
||||
# protocols that this user has explicitly opted into. protocols that don't
|
||||
# require explicit opt in are omitted here. choices is populated in
|
||||
# reset_protocol_properties.
|
||||
enabled_protocols = ndb.StringProperty(repeated=True, choices=[])
|
||||
|
||||
created = ndb.DateTimeProperty(auto_now_add=True)
|
||||
updated = ndb.DateTimeProperty(auto_now=True)
|
||||
|
||||
|
@ -241,7 +248,7 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
|
||||
ATProto = PROTOCOLS['atproto']
|
||||
if propagate and cls.LABEL != 'atproto' and not user.get_copy(ATProto):
|
||||
if common.is_enabled(cls, ATProto, handle_or_id=id):
|
||||
if cls.is_enabled_to(ATProto, user=id):
|
||||
ATProto.create_for(user)
|
||||
else:
|
||||
logger.info(f'{cls.LABEL} <=> atproto not enabled, skipping')
|
||||
|
@ -551,8 +558,8 @@ class Object(StringIdModel):
|
|||
domains = ndb.StringProperty(repeated=True)
|
||||
|
||||
status = ndb.StringProperty(choices=STATUSES)
|
||||
# choices is populated in app, after all User subclasses are created,
|
||||
# so that PROTOCOLS is fully populated
|
||||
# choices is populated in reset_protocol_properties, after all User
|
||||
# subclasses are created, so that PROTOCOLS is fully populated.
|
||||
# TODO: nail down whether this is ABBREV or LABEL
|
||||
source_protocol = ndb.StringProperty(choices=[])
|
||||
labels = ndb.StringProperty(repeated=True, choices=LABELS)
|
||||
|
|
52
protocol.py
52
protocol.py
|
@ -12,9 +12,10 @@ from google.cloud import ndb
|
|||
from google.cloud.ndb import OR
|
||||
from google.cloud.ndb.model import _entity_to_protobuf
|
||||
from granary import as1
|
||||
from oauth_dropins.webutil.appengine_info import DEBUG
|
||||
from oauth_dropins.webutil.flask_util import cloud_tasks_only
|
||||
from oauth_dropins.webutil import util
|
||||
from oauth_dropins.webutil import models
|
||||
from oauth_dropins.webutil import util
|
||||
from oauth_dropins.webutil.util import json_dumps, json_loads
|
||||
import werkzeug.exceptions
|
||||
|
||||
|
@ -70,6 +71,8 @@ class Protocol:
|
|||
appropriate for the ``Content-Type`` HTTP header.
|
||||
HAS_FOLLOW_ACCEPTS (bool): whether this protocol supports explicit
|
||||
accept/reject activities in response to follows, eg ActivityPub
|
||||
DEFAULT_ENABLED_PROTOCOLS (list of str): labels of other protocols that
|
||||
are automatically enabled for this protocol to bridge into
|
||||
"""
|
||||
ABBREV = None
|
||||
PHRASE = None
|
||||
|
@ -77,6 +80,7 @@ class Protocol:
|
|||
LOGO_HTML = ''
|
||||
CONTENT_TYPE = None
|
||||
HAS_FOLLOW_ACCEPTS = False
|
||||
DEFAULT_ENABLED_PROTOCOLS = ()
|
||||
|
||||
def __init__(self):
|
||||
assert False
|
||||
|
@ -126,6 +130,52 @@ class Protocol:
|
|||
label = domain.removesuffix(common.SUPERDOMAIN)
|
||||
return PROTOCOLS.get(label)
|
||||
|
||||
@classmethod
|
||||
def is_enabled_to(from_cls, to_cls, user=None):
|
||||
"""Returns True if two protocols, and optionally a user, can be bridged.
|
||||
|
||||
Reasons this might return False:
|
||||
* We haven't turned on bridging these two protocols yet.
|
||||
* The user is opted out.
|
||||
* The user is on a domain that's opted out.
|
||||
* The from protocol requires opt in, and the user hasn't opted in.
|
||||
|
||||
Args:
|
||||
from_cls (Protocol subclass)
|
||||
to_cls (Protocol subclass)
|
||||
user (:class:`models.User` or str): optional, user or id
|
||||
|
||||
Returns:
|
||||
bool:
|
||||
"""
|
||||
if from_cls == to_cls:
|
||||
return True
|
||||
|
||||
from_label = from_cls.LABEL
|
||||
to_label = to_cls.LABEL
|
||||
|
||||
if DEBUG and (from_label in ('fake', 'other')
|
||||
or (to_label in ('fake', 'other') and from_label != 'eefake')):
|
||||
return True
|
||||
|
||||
user_id = None
|
||||
if isinstance(user, User):
|
||||
user_id = user.key.id() if user.key else None
|
||||
elif isinstance(user, str):
|
||||
user_id = user
|
||||
user = from_cls.get_by_id(user_id, allow_opt_out=True)
|
||||
|
||||
if user:
|
||||
if user.status == 'opt-out':
|
||||
return False
|
||||
elif to_label in user.enabled_protocols:
|
||||
return True
|
||||
|
||||
if user_id in common.USER_ALLOWLIST:
|
||||
return True
|
||||
|
||||
return tuple(sorted((from_label, to_label))) in common.ENABLED_BRIDGES
|
||||
|
||||
@classmethod
|
||||
def owns_id(cls, id):
|
||||
"""Returns whether this protocol owns the id, or None if it's unclear.
|
||||
|
|
|
@ -1184,8 +1184,8 @@ class ActivityPubTest(TestCase):
|
|||
]
|
||||
mock_post.return_value = requests_response()
|
||||
|
||||
follower = Follower.get_or_create(to=self.user,
|
||||
from_=ActivityPub.get_or_create(ACTOR['id']),
|
||||
follower_key = ActivityPub.get_or_create(ACTOR['id'])
|
||||
follower = Follower.get_or_create(to=self.user, from_=follower_key,
|
||||
status='inactive')
|
||||
|
||||
undo_follow = copy.deepcopy(UNDO_FOLLOW_WRAPPED)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
from flask import g
|
||||
|
||||
# import first so that Fake is defined before URL routes are registered
|
||||
from .testutil import Fake, OtherFake, TestCase
|
||||
from .testutil import ExplicitEnableFake, Fake, OtherFake, TestCase
|
||||
|
||||
from activitypub import ActivityPub
|
||||
from atproto import ATProto
|
||||
|
@ -101,18 +101,3 @@ class CommonTest(TestCase):
|
|||
|
||||
with app.test_request_context(base_url='https://bsky.brid.gy', path='/foo'):
|
||||
self.assertEqual('https://bsky.brid.gy/asdf', common.host_url('asdf'))
|
||||
|
||||
def test_is_enabled(self):
|
||||
self.assertTrue(common.is_enabled(Web, ActivityPub))
|
||||
self.assertTrue(common.is_enabled(ActivityPub, Web))
|
||||
self.assertTrue(common.is_enabled(ActivityPub, ActivityPub))
|
||||
self.assertTrue(common.is_enabled(ATProto, Web))
|
||||
self.assertTrue(common.is_enabled(Fake, OtherFake))
|
||||
self.assertFalse(common.is_enabled(ATProto, ActivityPub))
|
||||
|
||||
self.assertFalse(common.is_enabled(
|
||||
ATProto, ActivityPub, handle_or_id='unknown'))
|
||||
self.assertTrue(common.is_enabled(
|
||||
ATProto, ActivityPub, handle_or_id='snarfed.org'))
|
||||
self.assertTrue(common.is_enabled(
|
||||
ATProto, ActivityPub, handle_or_id='did:plc:fdme4gb7mu7zrie7peay7tst'))
|
||||
|
|
|
@ -16,7 +16,7 @@ import requests
|
|||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
# import first so that Fake is defined before URL routes are registered
|
||||
from .testutil import Fake, OtherFake, TestCase
|
||||
from .testutil import ExplicitEnableFake, Fake, OtherFake, TestCase
|
||||
|
||||
from activitypub import ActivityPub
|
||||
from app import app
|
||||
|
@ -169,6 +169,50 @@ class ProtocolTest(TestCase):
|
|||
def test_for_handle_atproto_resolve(self, _):
|
||||
self.assertEqual((ATProto, 'did:plc:123abc'), Protocol.for_handle('han.dull'))
|
||||
|
||||
def test_is_enabled_to(self):
|
||||
self.assertTrue(Web.is_enabled_to(ActivityPub))
|
||||
self.assertTrue(ActivityPub.is_enabled_to(Web))
|
||||
self.assertTrue(ActivityPub.is_enabled_to(ActivityPub))
|
||||
self.assertTrue(ATProto.is_enabled_to(Web))
|
||||
self.assertTrue(Web.is_enabled_to(ATProto))
|
||||
self.assertTrue(Fake.is_enabled_to(OtherFake))
|
||||
self.assertTrue(Fake.is_enabled_to(ExplicitEnableFake))
|
||||
|
||||
def test_is_enabled_to_not_default_enabled(self):
|
||||
self.assertFalse(ActivityPub.is_enabled_to(ATProto))
|
||||
self.assertFalse(ATProto.is_enabled_to(ActivityPub))
|
||||
self.assertFalse(ExplicitEnableFake.is_enabled_to(Fake))
|
||||
self.assertFalse(ExplicitEnableFake.is_enabled_to(Web))
|
||||
|
||||
def test_is_enabled_to_user_allowlist(self):
|
||||
self.assertFalse(ATProto.is_enabled_to(ActivityPub, user='unknown'))
|
||||
self.assertTrue(ATProto.is_enabled_to(ActivityPub, user='snarfed.org'))
|
||||
|
||||
user = Fake(id='did:plc:fdme4gb7mu7zrie7peay7tst')
|
||||
self.assertTrue(ATProto.is_enabled_to(ActivityPub, user=user))
|
||||
self.assertTrue(ATProto.is_enabled_to(ActivityPub, user=user.key.id()))
|
||||
|
||||
def test_is_enabled_to_opt_out(self):
|
||||
user = self.make_user('user.com', cls=Web)
|
||||
self.assertTrue(Web.is_enabled_to(ActivityPub, user))
|
||||
|
||||
user.manual_opt_out = True
|
||||
user.put()
|
||||
protocol.objects_cache.clear()
|
||||
self.assertFalse(Web.is_enabled_to(ActivityPub, 'user.com'))
|
||||
|
||||
def test_is_enabled_to_enabled_protocols(self):
|
||||
user = self.make_user(id='eefake:foo', cls=ExplicitEnableFake)
|
||||
self.assertFalse(ExplicitEnableFake.is_enabled_to(Fake, 'eefake:foo'))
|
||||
|
||||
user.enabled_protocols = ['web']
|
||||
user.put()
|
||||
self.assertFalse(ExplicitEnableFake.is_enabled_to(Fake, 'eefake:foo'))
|
||||
|
||||
user.enabled_protocols = ['web', 'fake']
|
||||
user.put()
|
||||
self.assertTrue(ExplicitEnableFake.is_enabled_to(Fake, 'eefake:foo'))
|
||||
|
||||
def test_load(self):
|
||||
Fake.fetchable['foo'] = {'x': 'y'}
|
||||
|
||||
|
|
|
@ -164,6 +164,15 @@ class OtherFake(Fake):
|
|||
return f'{obj.key.id()}:target'
|
||||
|
||||
|
||||
class ExplicitEnableFake(Fake):
|
||||
LABEL = ABBREV = 'eefake'
|
||||
CONTENT_TYPE = 'un/known'
|
||||
|
||||
fetchable = {}
|
||||
sent = []
|
||||
fetched = []
|
||||
|
||||
|
||||
# import other modules that register Flask handlers *after* Fake is defined
|
||||
models.reset_protocol_properties()
|
||||
|
||||
|
|
4
web.py
4
web.py
|
@ -98,6 +98,7 @@ class Web(User, Protocol):
|
|||
OTHER_LABELS = ('webmention',)
|
||||
LOGO_HTML = '🌐' # used to be 🕸️
|
||||
CONTENT_TYPE = common.CONTENT_TYPE_HTML
|
||||
DEFAULT_ENABLED_PROTOCOLS = ('activitypub', 'atproto')
|
||||
|
||||
has_redirects = ndb.BooleanProperty()
|
||||
redirects_error = ndb.TextProperty()
|
||||
|
@ -537,8 +538,7 @@ class Web(User, Protocol):
|
|||
obj_as1 = obj.as1
|
||||
from_proto = PROTOCOLS.get(obj.source_protocol)
|
||||
if from_proto:
|
||||
user_id = from_user.key.id() if from_user and from_user.key else None
|
||||
if not common.is_enabled(cls, from_proto, handle_or_id=user_id):
|
||||
if not from_proto.is_enabled_to(cls, user=from_user):
|
||||
error(f'{cls.LABEL} <=> {from_proto.LABEL} not enabled')
|
||||
|
||||
# fill in author/actor if available
|
||||
|
|
Ładowanie…
Reference in New Issue