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/in
pull/965/head
Ryan Barrett 2024-04-17 16:43:10 -07:00
rodzic f02ba80304
commit 259b7d72dd
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 6BE31FDF4776E9D4
11 zmienionych plików z 132 dodań i 61 usunięć

Wyświetl plik

@ -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)

Wyświetl plik

@ -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:

Wyświetl plik

@ -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
Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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.

Wyświetl plik

@ -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)

Wyświetl plik

@ -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'))

Wyświetl plik

@ -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'}

Wyświetl plik

@ -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
Wyświetl plik

@ -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