kopia lustrzana https://github.com/snarfed/bridgy-fed
Porównaj commity
27 Commity
f02ba80304
...
c87e69d354
Autor | SHA1 | Data |
---|---|---|
Ryan Barrett | c87e69d354 | |
Ryan Barrett | 34692abc60 | |
Ryan Barrett | b9551c4de7 | |
Ryan Barrett | ed78090d2c | |
Ryan Barrett | e1f9021696 | |
Ryan Barrett | 18b1a33d22 | |
Ryan Barrett | dcadbccb3a | |
Ryan Barrett | 10023d17fd | |
Ryan Barrett | 6b597c90c3 | |
Ryan Barrett | f357ea1698 | |
Ryan Barrett | 1686a2ba91 | |
Ryan Barrett | 0c37d94191 | |
Ryan Barrett | 7c34689c9f | |
Ryan Barrett | 70da21a7f3 | |
Ryan Barrett | 1981c8eba8 | |
Ryan Barrett | 20e061f476 | |
Ryan Barrett | 3c55d7c145 | |
dependabot[bot] | 64a196a8c8 | |
dependabot[bot] | 7dbab83a17 | |
Ryan Barrett | 2886ae180d | |
Ryan Barrett | 8bcae4c09d | |
Ryan Barrett | d36885728f | |
Ryan Barrett | 917732ad4b | |
Ryan Barrett | 5556f2756b | |
Ryan Barrett | 8077a7f4ca | |
Ryan Barrett | 39a641e000 | |
Ryan Barrett | 259b7d72dd |
|
@ -30,6 +30,7 @@ from common import (
|
|||
host_url,
|
||||
LOCAL_DOMAINS,
|
||||
PRIMARY_DOMAIN,
|
||||
PROTOCOL_DOMAINS,
|
||||
redirect_wrap,
|
||||
subdomain_wrap,
|
||||
unwrap,
|
||||
|
@ -56,6 +57,10 @@ WEB_OPT_OUT_DOMAINS = None
|
|||
|
||||
FEDI_URL_RE = re.compile(r'https://[^/]+/(@|users/)([^/@]+)(@[^/@]+)?(/(?:statuses/)?[0-9]+)?')
|
||||
|
||||
# can't use translate_user_id because Web.owns_id checks valid_domain, which
|
||||
# doesn't allow our protocol subdomains
|
||||
BOT_ACTOR_IDS = tuple(f'https://{domain}/{domain}' for domain in PROTOCOL_DOMAINS)
|
||||
|
||||
|
||||
def instance_actor():
|
||||
global _INSTANCE_ACTOR
|
||||
|
@ -75,6 +80,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 +369,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 +838,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 +855,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)
|
||||
|
@ -924,7 +931,13 @@ def inbox(protocol=None, id=None):
|
|||
# follows, or other activity types, since Mastodon doesn't currently mark
|
||||
# those as explicitly public. Use as2's is_public instead of as1's because
|
||||
# as1's interprets unlisted as true.
|
||||
if type == 'Create' and not as2.is_public(activity, unlisted=False):
|
||||
# TODO: move this to Protocol
|
||||
object = as1.get_object(activity)
|
||||
to_cc = set(as1.get_ids(object, 'to') + as1.get_ids(activity, 'cc') +
|
||||
as1.get_ids(object, 'to') + as1.get_ids(object, 'cc'))
|
||||
if (type == 'Create' and not as2.is_public(activity, unlisted=False)
|
||||
# DM to one of our protocol bot users
|
||||
and not (len(to_cc) == 1 and to_cc.pop() in BOT_ACTOR_IDS)):
|
||||
logger.info('Dropping non-public activity')
|
||||
return 'OK'
|
||||
|
||||
|
|
13
atproto.py
13
atproto.py
|
@ -34,7 +34,6 @@ from common import (
|
|||
DOMAINS,
|
||||
error,
|
||||
USER_AGENT,
|
||||
USER_ALLOWLIST,
|
||||
)
|
||||
import flask_app
|
||||
from models import Object, PROTOCOLS, Target, User
|
||||
|
@ -97,6 +96,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 = ()
|
||||
|
||||
def _pre_put_hook(self):
|
||||
"""Validate id, require did:plc or non-blocklisted did:web."""
|
||||
|
@ -237,7 +237,7 @@ class ATProto(User, Protocol):
|
|||
|
||||
return None
|
||||
|
||||
def is_blocklisted(url):
|
||||
def is_blocklisted(url, allow_internal=False):
|
||||
# don't block common.DOMAINS since we want ourselves, ie our own PDS, to
|
||||
# be a valid domain to send to
|
||||
return util.domain_or_parent_in(util.domain_from_link(url), DOMAIN_BLOCKLIST)
|
||||
|
@ -506,9 +506,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:
|
||||
|
@ -580,8 +579,7 @@ def poll_notifications():
|
|||
headers={'User-Agent': USER_AGENT})
|
||||
|
||||
for user in users:
|
||||
# TODO: remove for launch
|
||||
if not DEBUG and user.key.id() not in USER_ALLOWLIST:
|
||||
if not user.is_enabled_to(ATProto, user=user):
|
||||
logger.info(f'Skipping {user.key.id()}')
|
||||
continue
|
||||
|
||||
|
@ -640,8 +638,7 @@ def poll_posts():
|
|||
headers={'User-Agent': USER_AGENT})
|
||||
|
||||
for user in users:
|
||||
# TODO: remove for launch
|
||||
if not DEBUG and user.key.id() not in USER_ALLOWLIST:
|
||||
if not user.is_enabled_to(ATProto, user=user):
|
||||
logger.info(f'Skipping {user.key.id()}')
|
||||
continue
|
||||
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1"
|
||||
],
|
||||
"type": "Application",
|
||||
"id": "https://bsky.brid.gy/bsky.brid.gy",
|
||||
"url": "https://bsky.brid.gy/",
|
||||
"preferredUsername": "bsky.brid.gy",
|
||||
"summary": "<a href='https://fed.brid.gy/'>Bridgy Fed</a> bot user for <a href='https://bsky.social/'>Bluesky</a>. To bridge your fediverse account to Bluesky, follow this account or reply <em>yes</em> when it promps you with a DM. <a href='https://fed.brid.gy/docs'>More info here.</a>",
|
||||
"name": "Bridgy Fed",
|
||||
"attachment": [{
|
||||
"name": "Web site",
|
||||
"type": "PropertyValue",
|
||||
"value": "<a rel=\"me\" href=\"https://fed.brid.gy\"><span class=\"invisible\">https://</span>fed.brid.gy</a>"
|
||||
}],
|
||||
"image": [
|
||||
{
|
||||
"type": "Image",
|
||||
"url": "https://fed.brid.gy/static/bridgy_logo.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Bridgy Fed for Bluesky",
|
||||
"type": "Image",
|
||||
"url": "https://fed.brid.gy/static/bridgy_logo_square.jpg"
|
||||
}
|
||||
],
|
||||
"icon": {
|
||||
"name": "Bridgy Fed for Bluesky",
|
||||
"type": "Image",
|
||||
"url": "https://fed.brid.gy/oauth_dropins_static/bluesky.svg"
|
||||
}
|
||||
}
|
49
common.py
49
common.py
|
@ -31,13 +31,6 @@ TLD_BLOCKLIST = ('7z', 'asp', 'aspx', 'gif', 'html', 'ico', 'jpg', 'jpeg', 'js',
|
|||
|
||||
CONTENT_TYPE_HTML = 'text/html; charset=utf-8'
|
||||
|
||||
# Protocol pairs that we currently support bridging between. Values must be
|
||||
# Protocol LABELs. Each pair must be lexicographically sorted!
|
||||
ENABLED_BRIDGES = frozenset((
|
||||
('activitypub', 'web'),
|
||||
('atproto', 'web'),
|
||||
))
|
||||
|
||||
PRIMARY_DOMAIN = 'fed.brid.gy'
|
||||
# protocol-specific subdomains are under this "super"domain
|
||||
SUPERDOMAIN = '.brid.gy'
|
||||
|
@ -45,13 +38,12 @@ SUPERDOMAIN = '.brid.gy'
|
|||
# use it to canonicalize most UI routes from these to fed.brid.gy.
|
||||
PROTOCOL_DOMAINS = (
|
||||
'ap.brid.gy',
|
||||
'atp.brid.gy',
|
||||
'atproto.brid.gy',
|
||||
'bluesky.brid.gy',
|
||||
'bsky.brid.gy',
|
||||
'fa.brid.gy',
|
||||
'nostr.brid.gy',
|
||||
'web.brid.gy',
|
||||
'eefake.brid.gy',
|
||||
'fa.brid.gy',
|
||||
'other.brid.gy',
|
||||
)
|
||||
OTHER_DOMAINS = (
|
||||
'bridgy-federated.appspot.com',
|
||||
|
@ -83,15 +75,6 @@ util.set_user_agent(USER_AGENT)
|
|||
TASKS_LOCATION = 'us-central1'
|
||||
RUN_TASKS_INLINE = False # overridden by unit tests
|
||||
|
||||
USER_ALLOWLIST = (
|
||||
'snarfed.org',
|
||||
'did:plc:fdme4gb7mu7zrie7peay7tst',
|
||||
'snarfed.bsky.social',
|
||||
'did:plc:3ljmtyyjqcjee2kpewgsifvb',
|
||||
'https://indieweb.social/users/snarfed',
|
||||
'@snarfed@indieweb.social',
|
||||
)
|
||||
|
||||
|
||||
def base64_to_long(x):
|
||||
"""Converts from URL safe base64 encoding to long integer.
|
||||
|
@ -264,29 +247,13 @@ 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.
|
||||
def remove(seq, val):
|
||||
"""Removes ``val`` to ``seq`` if seq contains it.
|
||||
|
||||
Args:
|
||||
proto_a (Protocol subclass)
|
||||
proto_b (Protocol subclass)
|
||||
handle_or_id (str): optional user handle or id
|
||||
|
||||
Returns:
|
||||
bool:
|
||||
Useful for treating repeated ndb properties like sets instead of lists.
|
||||
"""
|
||||
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
|
||||
if val in seq:
|
||||
seq.remove(val)
|
||||
|
||||
|
||||
def create_task(queue, delay=None, **params):
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
# timezone defaults to UTC
|
||||
# docs: https://cloud.google.com/appengine/docs/standard/python3/scheduling-jobs-with-cron-yaml
|
||||
|
||||
cron:
|
||||
- description: ATProto poll posts
|
||||
url: /queue/atproto-poll-posts
|
||||
schedule: every 15 minutes
|
||||
target: hub
|
||||
|
||||
- description: ATProto poll notifications
|
||||
url: /queue/atproto-poll-notifs
|
||||
schedule: every 15 minutes
|
||||
target: hub
|
|
@ -122,7 +122,7 @@ class FollowCallback(indieauth.Callback):
|
|||
|
||||
followee_id = followee.as1.get('id')
|
||||
timestamp = util.now().replace(microsecond=0, tzinfo=None).isoformat()
|
||||
follow_id = common.host_url(user.user_page_path(f'following#{timestamp}-{addr}'))
|
||||
follow_id = f'{user.web_url()}#follow-{timestamp}-{addr}'
|
||||
follow_as1 = {
|
||||
'objectType': 'activity',
|
||||
'verb': 'follow',
|
||||
|
@ -204,7 +204,7 @@ class UnfollowCallback(indieauth.Callback):
|
|||
|
||||
# TODO(#529): generalize
|
||||
timestamp = util.now().replace(microsecond=0, tzinfo=None).isoformat()
|
||||
unfollow_id = common.host_url(user.user_page_path(f'following#undo-{timestamp}-{followee_id}'))
|
||||
unfollow_id = f'{user.web_url()}#unfollow-{timestamp}-{followee_id}'
|
||||
unfollow_as1 = {
|
||||
'objectType': 'activity',
|
||||
'verb': 'stop-following',
|
||||
|
|
4
hub.py
4
hub.py
|
@ -60,11 +60,11 @@ lexrpc.flask_server.init_flask(arroba.server.server, app)
|
|||
|
||||
app.add_url_rule('/queue/atproto-poll-notifs',
|
||||
view_func=atproto.poll_notifications,
|
||||
methods=['POST'])
|
||||
methods=['GET', 'POST'])
|
||||
|
||||
app.add_url_rule('/queue/atproto-poll-posts',
|
||||
view_func=atproto.poll_posts,
|
||||
methods=['POST'])
|
||||
methods=['GET', 'POST'])
|
||||
|
||||
@app.post('/queue/atproto-commit')
|
||||
@flask_util.cloud_tasks_only
|
||||
|
|
136
ids.py
136
ids.py
|
@ -11,17 +11,25 @@ from google.cloud.ndb.query import FilterNode, Query
|
|||
from granary.bluesky import BSKY_APP_URL_RE, web_url_to_at_uri
|
||||
from oauth_dropins.webutil import util
|
||||
|
||||
from common import subdomain_wrap, LOCAL_DOMAINS, PRIMARY_DOMAIN, SUPERDOMAIN
|
||||
from common import (
|
||||
LOCAL_DOMAINS,
|
||||
PRIMARY_DOMAIN,
|
||||
PROTOCOL_DOMAINS,
|
||||
subdomain_wrap,
|
||||
SUPERDOMAIN,
|
||||
)
|
||||
import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Protocols to check User.copies and Object.copies before translating
|
||||
COPIES_PROTOCOLS = ('atproto', 'fake', 'other', 'nostr')
|
||||
COPIES_PROTOCOLS = ('atproto',)
|
||||
|
||||
# Web user domains whose AP actor ids are on fed.brid.gy, not web.brid.gy, for
|
||||
# historical compatibility. Loaded on first call to web_ap_subdomain().
|
||||
_FED_SUBDOMAIN_SITES = None
|
||||
#
|
||||
# Maps string domain to string subdomain (bsky, fed, or web).
|
||||
_NON_WEB_SUBDOMAIN_SITES = None
|
||||
|
||||
|
||||
def web_ap_base_domain(user_domain):
|
||||
|
@ -40,43 +48,45 @@ def web_ap_base_domain(user_domain):
|
|||
if request.host in LOCAL_DOMAINS:
|
||||
return request.host_url
|
||||
|
||||
global _FED_SUBDOMAIN_SITES
|
||||
if _FED_SUBDOMAIN_SITES is None:
|
||||
_FED_SUBDOMAIN_SITES = {
|
||||
key.id() for key in Query('MagicKey',
|
||||
filters=FilterNode('ap_subdomain', '=', 'fed')
|
||||
).fetch(keys_only=True)
|
||||
global _NON_WEB_SUBDOMAIN_SITES
|
||||
if _NON_WEB_SUBDOMAIN_SITES is None:
|
||||
_NON_WEB_SUBDOMAIN_SITES = {
|
||||
user.key.id(): user.ap_subdomain
|
||||
for user in Query('MagicKey',
|
||||
filters=FilterNode('ap_subdomain', '!=', 'web'),
|
||||
projection=['ap_subdomain'],
|
||||
).fetch()
|
||||
}
|
||||
logger.info(f'Loaded {len(_FED_SUBDOMAIN_SITES)} fed subdomain Web users')
|
||||
logger.info(f'Loaded {len(_NON_WEB_SUBDOMAIN_SITES)} non-web.brid.gy Web users')
|
||||
|
||||
subdomain = 'fed' if user_domain in _FED_SUBDOMAIN_SITES else 'web'
|
||||
subdomain = _NON_WEB_SUBDOMAIN_SITES.get(user_domain, 'web')
|
||||
return f'https://{subdomain}{SUPERDOMAIN}/'
|
||||
|
||||
|
||||
def translate_user_id(*, id, from_proto, to_proto):
|
||||
def translate_user_id(*, id, from_, to):
|
||||
"""Translate a user id from one protocol to another.
|
||||
|
||||
TODO: unify with :func:`translate_object_id`.
|
||||
|
||||
Args:
|
||||
id (str)
|
||||
from_proto (protocol.Protocol)
|
||||
to_proto (protocol.Protocol)
|
||||
from_ (protocol.Protocol)
|
||||
to (protocol.Protocol)
|
||||
|
||||
Returns:
|
||||
str: the corresponding id in ``to_proto``
|
||||
str: the corresponding id in ``to``
|
||||
"""
|
||||
assert id and from_proto and to_proto, (id, from_proto, to_proto)
|
||||
assert from_proto.owns_id(id) is not False or from_proto.LABEL == 'ui', \
|
||||
(id, from_proto.LABEL, to_proto.LABEL)
|
||||
assert id and from_ and to, (id, from_, to)
|
||||
assert from_.owns_id(id) is not False or from_.LABEL == 'ui', \
|
||||
(id, from_.LABEL, to.LABEL)
|
||||
|
||||
parsed = urlparse(id)
|
||||
if from_proto.LABEL == 'web' and parsed.path.strip('/') == '':
|
||||
if from_.LABEL == 'web' and parsed.path.strip('/') == '':
|
||||
# home page; replace with domain
|
||||
id = parsed.netloc
|
||||
|
||||
# bsky.app profile URL to DID
|
||||
if to_proto.LABEL == 'atproto':
|
||||
if to.LABEL == 'atproto':
|
||||
if match := BSKY_APP_URL_RE.match(id):
|
||||
repo = match.group('id')
|
||||
if repo.startswith('did:'):
|
||||
|
@ -89,25 +99,25 @@ def translate_user_id(*, id, from_proto, to_proto):
|
|||
logger.warning(e)
|
||||
return None
|
||||
|
||||
if from_proto == to_proto:
|
||||
if from_ == to:
|
||||
return id
|
||||
|
||||
# follow use_instead
|
||||
user = from_proto.get_by_id(id)
|
||||
user = from_.get_by_id(id)
|
||||
if user:
|
||||
id = user.key.id()
|
||||
|
||||
if from_proto.LABEL in COPIES_PROTOCOLS or to_proto.LABEL in COPIES_PROTOCOLS:
|
||||
if from_.LABEL in COPIES_PROTOCOLS or to.LABEL in COPIES_PROTOCOLS:
|
||||
if user:
|
||||
if copy := user.get_copy(to_proto):
|
||||
if copy := user.get_copy(to):
|
||||
return copy
|
||||
if orig := models.get_original(id):
|
||||
if isinstance(orig, to_proto):
|
||||
if isinstance(orig, to):
|
||||
return orig.key.id()
|
||||
|
||||
match from_proto.LABEL, to_proto.LABEL:
|
||||
match from_.LABEL, to.LABEL:
|
||||
case _, 'atproto' | 'nostr':
|
||||
logger.warning(f"Can't translate user id {id} to {to_proto.LABEL} , haven't copied it there yet!")
|
||||
logger.warning(f"Can't translate user id {id} to {to.LABEL} , haven't copied it there yet!")
|
||||
return None
|
||||
|
||||
case 'web', 'activitypub':
|
||||
|
@ -117,45 +127,47 @@ def translate_user_id(*, id, from_proto, to_proto):
|
|||
return id
|
||||
|
||||
case _, 'activitypub' | 'web':
|
||||
return subdomain_wrap(from_proto, f'/{to_proto.ABBREV}/{id}')
|
||||
return subdomain_wrap(from_, f'/{to.ABBREV}/{id}')
|
||||
|
||||
# only for unit tests
|
||||
case _, 'fake' | 'other':
|
||||
return f'{to_proto.LABEL}:u:{id}'
|
||||
case _, 'fake' | 'other' | 'eefake':
|
||||
return f'{to.LABEL}:u:{id}'
|
||||
case 'fake' | 'other', _:
|
||||
return id
|
||||
|
||||
assert False, (id, from_proto.LABEL, to_proto.LABEL)
|
||||
assert False, (id, from_.LABEL, to.LABEL)
|
||||
|
||||
|
||||
def translate_handle(*, handle, from_proto, to_proto, enhanced):
|
||||
def translate_handle(*, handle, from_, to, enhanced):
|
||||
"""Translates a user handle from one protocol to another.
|
||||
|
||||
Args:
|
||||
handle (str)
|
||||
from_proto (protocol.Protocol)
|
||||
to_proto (protocol.Protocol)
|
||||
from_ (protocol.Protocol)
|
||||
to (protocol.Protocol)
|
||||
enhanced (bool): whether to convert to an "enhanced" handle based on the
|
||||
user's domain
|
||||
|
||||
Returns:
|
||||
str: the corresponding handle in ``to_proto``
|
||||
str: the corresponding handle in ``to``
|
||||
"""
|
||||
assert handle and from_proto and to_proto, (handle, from_proto, to_proto)
|
||||
assert from_proto.owns_handle(handle) is not False or from_proto.LABEL == 'ui'
|
||||
assert handle and from_ and to, (handle, from_, to)
|
||||
assert from_.owns_handle(handle) is not False or from_.LABEL == 'ui'
|
||||
|
||||
if from_proto == to_proto:
|
||||
if from_ == to:
|
||||
return handle
|
||||
|
||||
match from_proto.LABEL, to_proto.LABEL:
|
||||
match from_.LABEL, to.LABEL:
|
||||
case _, 'activitypub':
|
||||
domain = handle if enhanced else f'{from_proto.ABBREV}{SUPERDOMAIN}'
|
||||
domain = f'{from_.ABBREV}{SUPERDOMAIN}'
|
||||
if enhanced or handle == PRIMARY_DOMAIN or handle in PROTOCOL_DOMAINS:
|
||||
domain = handle
|
||||
return f'@{handle}@{domain}'
|
||||
|
||||
case _, 'atproto' | 'nostr':
|
||||
handle = handle.lstrip('@').replace('@', '.')
|
||||
return (handle if enhanced
|
||||
else f'{handle}.{from_proto.ABBREV}{SUPERDOMAIN}')
|
||||
else f'{handle}.{from_.ABBREV}{SUPERDOMAIN}')
|
||||
|
||||
case 'activitypub', 'web':
|
||||
user, instance = handle.lstrip('@').split('@')
|
||||
|
@ -167,32 +179,30 @@ 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.LABEL}:handle:{handle}'
|
||||
|
||||
assert False, (handle, from_proto.LABEL, to_proto.LABEL)
|
||||
assert False, (handle, from_.LABEL, to.LABEL)
|
||||
|
||||
|
||||
def translate_object_id(*, id, from_proto, to_proto):
|
||||
def translate_object_id(*, id, from_, to):
|
||||
"""Translates a user handle from one protocol to another.
|
||||
|
||||
TODO: unify with :func:`translate_user_id`.
|
||||
|
||||
Args:
|
||||
id (str)
|
||||
from_proto (protocol.Protocol)
|
||||
to_proto (protocol.Protocol)
|
||||
from_ (protocol.Protocol)
|
||||
to (protocol.Protocol)
|
||||
|
||||
Returns:
|
||||
str: the corresponding id in ``to_proto``
|
||||
str: the corresponding id in ``to``
|
||||
"""
|
||||
assert id and from_proto and to_proto, (id, from_proto, to_proto)
|
||||
assert from_proto.owns_id(id) is not False or from_proto.LABEL == 'ui'
|
||||
assert id and from_ and to, (id, from_, to)
|
||||
assert from_.owns_id(id) is not False or from_.LABEL == 'ui'
|
||||
|
||||
# bsky.app profile URL to DID
|
||||
if to_proto.LABEL == 'atproto':
|
||||
if to.LABEL == 'atproto':
|
||||
if match := BSKY_APP_URL_RE.match(id):
|
||||
repo = match.group('id')
|
||||
handle = None
|
||||
|
@ -207,29 +217,29 @@ def translate_object_id(*, id, from_proto, to_proto):
|
|||
|
||||
return web_url_to_at_uri(id, handle=handle, did=repo)
|
||||
|
||||
if from_proto == to_proto:
|
||||
if from_ == to:
|
||||
return id
|
||||
|
||||
if from_proto.LABEL in COPIES_PROTOCOLS or to_proto.LABEL in COPIES_PROTOCOLS:
|
||||
if obj := from_proto.load(id, remote=False):
|
||||
if copy := obj.get_copy(to_proto):
|
||||
if from_.LABEL in COPIES_PROTOCOLS or to.LABEL in COPIES_PROTOCOLS:
|
||||
if obj := from_.load(id, remote=False):
|
||||
if copy := obj.get_copy(to):
|
||||
return copy
|
||||
if orig := models.get_original(id):
|
||||
return orig.key.id()
|
||||
|
||||
match from_proto.LABEL, to_proto.LABEL:
|
||||
match from_.LABEL, to.LABEL:
|
||||
case _, 'atproto' | 'nostr':
|
||||
logger.warning(f"Can't translate object id {id} to {to_proto.LABEL} , haven't copied it there yet!")
|
||||
logger.warning(f"Can't translate object id {id} to {to.LABEL} , haven't copied it there yet!")
|
||||
return id
|
||||
|
||||
case 'web', 'activitypub':
|
||||
return urljoin(web_ap_base_domain(util.domain_from_link(id)), f'/r/{id}')
|
||||
|
||||
case _, 'activitypub' | 'web':
|
||||
return subdomain_wrap(from_proto, f'/convert/{to_proto.ABBREV}/{id}')
|
||||
return subdomain_wrap(from_, f'/convert/{to.ABBREV}/{id}')
|
||||
|
||||
# only for unit tests
|
||||
case _, 'fake' | 'other':
|
||||
return f'{to_proto.LABEL}:o:{from_proto.ABBREV}:{id}'
|
||||
case _, 'fake' | 'other' | 'eefake':
|
||||
return f'{to.LABEL}:o:{from_.ABBREV}:{id}'
|
||||
|
||||
assert False, (id, from_proto.LABEL, to_proto.LABEL)
|
||||
assert False, (id, from_.LABEL, to.LABEL)
|
||||
|
|
66
index.yaml
66
index.yaml
|
@ -6,48 +6,6 @@ indexes:
|
|||
# index.yaml file manually, remove the "# AUTOGENERATED" marker line above.
|
||||
# If you want to manage some indexes manually, move them above the marker line.
|
||||
|
||||
- kind: Object
|
||||
properties:
|
||||
- name: domains
|
||||
- name: labels
|
||||
- name: updated
|
||||
direction: asc
|
||||
|
||||
- kind: Object
|
||||
properties:
|
||||
- name: users
|
||||
- name: labels
|
||||
- name: updated
|
||||
direction: asc
|
||||
|
||||
- kind: Object
|
||||
properties:
|
||||
- name: domains
|
||||
- name: labels
|
||||
- name: updated
|
||||
direction: desc
|
||||
|
||||
- kind: Object
|
||||
properties:
|
||||
- name: users
|
||||
- name: labels
|
||||
- name: updated
|
||||
direction: desc
|
||||
|
||||
- kind: Object
|
||||
properties:
|
||||
- name: domains
|
||||
- name: labels
|
||||
- name: created
|
||||
direction: desc
|
||||
|
||||
- kind: Object
|
||||
properties:
|
||||
- name: users
|
||||
- name: labels
|
||||
- name: created
|
||||
direction: desc
|
||||
|
||||
- kind: Object
|
||||
properties:
|
||||
- name: users
|
||||
|
@ -59,18 +17,6 @@ indexes:
|
|||
- name: updated
|
||||
direction: desc
|
||||
|
||||
# these two are currently unused! as of 2024-01-24
|
||||
- kind: Object
|
||||
properties:
|
||||
- name: users
|
||||
- name: created
|
||||
|
||||
- kind: Object
|
||||
properties:
|
||||
- name: users
|
||||
- name: created
|
||||
direction: desc
|
||||
|
||||
- kind: Object
|
||||
properties:
|
||||
- name: notify
|
||||
|
@ -93,6 +39,12 @@ indexes:
|
|||
- name: created
|
||||
direction: desc
|
||||
|
||||
- kind: Follower
|
||||
properties:
|
||||
- name: from
|
||||
- name: status
|
||||
- name: updated
|
||||
|
||||
- kind: Follower
|
||||
properties:
|
||||
- name: from
|
||||
|
@ -100,6 +52,12 @@ indexes:
|
|||
- name: updated
|
||||
direction: desc
|
||||
|
||||
- kind: Follower
|
||||
properties:
|
||||
- name: to
|
||||
- name: status
|
||||
- name: updated
|
||||
|
||||
- kind: Follower
|
||||
properties:
|
||||
- name: to
|
||||
|
|
101
models.py
101
models.py
|
@ -28,8 +28,8 @@ from oauth_dropins.webutil.models import (
|
|||
from oauth_dropins.webutil.util import json_dumps, json_loads
|
||||
|
||||
import common
|
||||
from common import add, base64_to_long, DOMAIN_RE, long_to_base64, unwrap
|
||||
from ids import translate_handle, translate_object_id, translate_user_id
|
||||
from common import add, base64_to_long, DOMAIN_RE, long_to_base64, remove, unwrap
|
||||
import ids
|
||||
|
||||
# maps string label to Protocol subclass. populated by ProtocolUserMeta.
|
||||
# seed with old and upcoming protocols that don't have their own classes (yet).
|
||||
|
@ -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)
|
||||
|
||||
|
@ -222,13 +229,16 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
if user.status == 'opt-out':
|
||||
return None
|
||||
user.existing = True
|
||||
# override direct from False => True if set
|
||||
# TODO: propagate more props into user?
|
||||
direct = kwargs.get('direct')
|
||||
if direct and not user.direct:
|
||||
logger.info(f'Setting {user.key} direct={direct}')
|
||||
user.direct = direct
|
||||
user.put()
|
||||
|
||||
# TODO: propagate more fields?
|
||||
for field in ['direct', 'obj', 'obj_key']:
|
||||
old_val = getattr(user, field, None)
|
||||
new_val = kwargs.get(field)
|
||||
if ((old_val is None and new_val is not None)
|
||||
or (field == 'direct' and not old_val and new_val)):
|
||||
setattr(user, field, new_val)
|
||||
user.put()
|
||||
|
||||
if not propagate:
|
||||
return user
|
||||
else:
|
||||
|
@ -239,12 +249,14 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
if not user.obj_key:
|
||||
user.obj = cls.load(user.profile_id())
|
||||
|
||||
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):
|
||||
ATProto.create_for(user)
|
||||
else:
|
||||
logger.info(f'{cls.LABEL} <=> atproto not enabled, skipping')
|
||||
if propagate:
|
||||
for label in ids.COPIES_PROTOCOLS:
|
||||
proto = PROTOCOLS[label]
|
||||
if proto != cls and not user.get_copy(proto):
|
||||
if cls.is_enabled_to(proto, user=id):
|
||||
proto.create_for(user)
|
||||
else:
|
||||
logger.info(f'{cls.LABEL} <=> atproto not enabled, skipping')
|
||||
|
||||
# generate keys for all protocols _except_ our own
|
||||
#
|
||||
|
@ -341,6 +353,36 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
|
||||
return None
|
||||
|
||||
@ndb.transactional()
|
||||
def enable_protocol(self, to_proto):
|
||||
"""Adds ``to_proto` to :attr:`enabled_protocols`.
|
||||
|
||||
Args:
|
||||
to_proto (:class:`protocol.Protocol` subclass)
|
||||
"""
|
||||
user = self.key.get()
|
||||
add(user.enabled_protocols, to_proto.LABEL)
|
||||
if not user.get_copy(to_proto):
|
||||
to_proto.create_for(user)
|
||||
user.put()
|
||||
|
||||
add(self.enabled_protocols, to_proto.LABEL)
|
||||
|
||||
@ndb.transactional()
|
||||
def disable_protocol(self, to_proto):
|
||||
"""Removes ``to_proto` from :attr:`enabled_protocols`.
|
||||
|
||||
Args:
|
||||
to_proto (:class:`protocol.Protocol` subclass)
|
||||
"""
|
||||
user = self.key.get()
|
||||
remove(user.enabled_protocols, to_proto.LABEL)
|
||||
# TODO: delete copy user
|
||||
# https://github.com/snarfed/bridgy-fed/issues/783
|
||||
user.put()
|
||||
|
||||
remove(self.enabled_protocols, to_proto.LABEL)
|
||||
|
||||
def handle_as(self, to_proto):
|
||||
"""Returns this user's handle in a different protocol.
|
||||
|
||||
|
@ -359,8 +401,8 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
if not handle:
|
||||
return None
|
||||
|
||||
return translate_handle(handle=handle, from_proto=self.__class__,
|
||||
to_proto=to_proto, enhanced=False)
|
||||
return ids.translate_handle(handle=handle, from_=self.__class__,
|
||||
to=to_proto, enhanced=False)
|
||||
|
||||
def id_as(self, to_proto):
|
||||
"""Returns this user's id in a different protocol.
|
||||
|
@ -374,8 +416,8 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
if isinstance(to_proto, str):
|
||||
to_proto = PROTOCOLS[to_proto]
|
||||
|
||||
return translate_user_id(id=self.key.id(), from_proto=self.__class__,
|
||||
to_proto=to_proto)
|
||||
return ids.translate_user_id(id=self.key.id(), from_=self.__class__,
|
||||
to=to_proto)
|
||||
|
||||
def handle_or_id(self):
|
||||
"""Returns handle if we know it, otherwise id."""
|
||||
|
@ -485,7 +527,8 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
Returns:
|
||||
str:
|
||||
"""
|
||||
if isinstance(self, proto):
|
||||
# don't use isinstance because the testutil Fake protocol has subclasses
|
||||
if self.LABEL == proto.LABEL:
|
||||
return self.key.id()
|
||||
|
||||
for copy in self.copies:
|
||||
|
@ -535,9 +578,6 @@ class Object(StringIdModel):
|
|||
'feed', 'notification', 'user')
|
||||
|
||||
# Keys for user(s) who created or otherwise own this activity.
|
||||
#
|
||||
# DEPRECATED: this used to include all users related the activity, including
|
||||
# followers, but we've now moved those to the notify and feed properties.
|
||||
users = ndb.KeyProperty(repeated=True)
|
||||
# User keys who should see this activity in their user page, eg in reply to,
|
||||
# reaction to, share of, etc.
|
||||
|
@ -551,8 +591,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)
|
||||
|
@ -1044,7 +1084,7 @@ class Object(StringIdModel):
|
|||
if not proto:
|
||||
return val
|
||||
|
||||
translated = translate_fn(id=orig, from_proto=proto, to_proto=proto)
|
||||
translated = translate_fn(id=orig, from_=proto, to=proto)
|
||||
if translated and translated != orig:
|
||||
logger.info(f'Normalized {proto.LABEL} id {orig} to {translated}')
|
||||
replaced = True
|
||||
|
@ -1060,20 +1100,21 @@ class Object(StringIdModel):
|
|||
for obj in [outer_obj] + inner_objs:
|
||||
for tag in as1.get_objects(obj, 'tags'):
|
||||
if tag.get('objectType') == 'mention':
|
||||
tag['url'] = replace(tag.get('url'), translate_user_id)
|
||||
tag['url'] = replace(tag.get('url'), ids.translate_user_id)
|
||||
for field in ['actor', 'author', 'inReplyTo']:
|
||||
fn = translate_object_id if field == 'inReplyTo' else translate_user_id
|
||||
fn = (ids.translate_object_id if field == 'inReplyTo'
|
||||
else ids.translate_user_id)
|
||||
obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
|
||||
if len(obj[field]) == 1:
|
||||
obj[field] = obj[field][0]
|
||||
|
||||
outer_obj['object'] = []
|
||||
for inner_obj in inner_objs:
|
||||
translate_fn = (translate_user_id
|
||||
translate_fn = (ids.translate_user_id
|
||||
if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
|
||||
or as1.object_type(outer_obj) in
|
||||
('follow', 'stop-following'))
|
||||
else translate_object_id)
|
||||
else ids.translate_object_id)
|
||||
|
||||
got = replace(inner_obj, translate_fn)
|
||||
if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
|
||||
|
|
192
protocol.py
192
protocol.py
|
@ -12,14 +12,23 @@ 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
|
||||
|
||||
import common
|
||||
from common import add, DOMAIN_BLOCKLIST, DOMAIN_RE, DOMAINS, error, subdomain_wrap
|
||||
from common import (
|
||||
add,
|
||||
DOMAIN_BLOCKLIST,
|
||||
DOMAIN_RE,
|
||||
DOMAINS,
|
||||
error,
|
||||
PROTOCOL_DOMAINS,
|
||||
subdomain_wrap,
|
||||
)
|
||||
from flask_app import app
|
||||
from ids import translate_object_id, translate_user_id
|
||||
from models import Follower, get_originals, Object, PROTOCOLS, Target, User
|
||||
|
@ -28,6 +37,7 @@ SUPPORTED_TYPES = (
|
|||
'accept',
|
||||
'article',
|
||||
'audio',
|
||||
'block',
|
||||
'comment',
|
||||
'delete',
|
||||
'follow',
|
||||
|
@ -70,6 +80,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 +89,7 @@ class Protocol:
|
|||
LOGO_HTML = ''
|
||||
CONTENT_TYPE = None
|
||||
HAS_FOLLOW_ACCEPTS = False
|
||||
DEFAULT_ENABLED_PROTOCOLS = ()
|
||||
|
||||
def __init__(self):
|
||||
assert False
|
||||
|
@ -126,6 +139,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 to_label in from_cls.DEFAULT_ENABLED_PROTOCOLS:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def owns_id(cls, id):
|
||||
"""Returns whether this protocol owns the id, or None if it's unclear.
|
||||
|
@ -382,6 +441,18 @@ class Protocol:
|
|||
if owner:
|
||||
return cls.key_for(owner)
|
||||
|
||||
@classmethod
|
||||
def create_for(cls, user):
|
||||
"""Creates a copy user in this protocol.
|
||||
|
||||
Should add the copy user to :attr:`copies`.
|
||||
|
||||
Args:
|
||||
user (models.User): original source user. Shouldn't already have a
|
||||
copy user for this protocol in :attr:`copies`.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def send(to_cls, obj, url, from_user=None, orig_obj=None):
|
||||
"""Sends an outgoing activity.
|
||||
|
@ -476,18 +547,22 @@ class Protocol:
|
|||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def is_blocklisted(cls, url):
|
||||
def is_blocklisted(cls, url, allow_internal=False):
|
||||
"""Returns True if we block the given URL and shouldn't deliver to it.
|
||||
|
||||
Default implementation here, subclasses may override.
|
||||
|
||||
Args:
|
||||
url (str):
|
||||
allow_internal (bool): whether to return False for internal domains
|
||||
like ``fed.brid.gy``, ``bsky.brid.gy``, etc
|
||||
|
||||
Returns: bool:
|
||||
"""
|
||||
return util.domain_or_parent_in(util.domain_from_link(url),
|
||||
DOMAIN_BLOCKLIST + DOMAINS)
|
||||
blocklist = DOMAIN_BLOCKLIST
|
||||
if not allow_internal:
|
||||
blocklist += DOMAINS
|
||||
return util.domain_or_parent_in(util.domain_from_link(url), blocklist)
|
||||
|
||||
@classmethod
|
||||
def translate_ids(to_cls, obj):
|
||||
|
@ -539,7 +614,7 @@ class Protocol:
|
|||
# TODO: what if from_cls is None? relax translate_object_id,
|
||||
# make it a noop if we don't know enough about from/to?
|
||||
if from_cls and from_cls != to_cls:
|
||||
elem[field]['id'] = fn(id=id, from_proto=from_cls, to_proto=to_cls)
|
||||
elem[field]['id'] = fn(id=id, from_=from_cls, to=to_cls)
|
||||
if elem[field].keys() == {'id'}:
|
||||
elem[field] = elem[field]['id']
|
||||
|
||||
|
@ -603,7 +678,7 @@ class Protocol:
|
|||
|
||||
if not id:
|
||||
error('No id provided')
|
||||
elif from_cls.is_blocklisted(id) and not internal:
|
||||
elif from_cls.is_blocklisted(id, allow_internal=internal):
|
||||
error(f'Activity {id} is blocklisted')
|
||||
|
||||
# short circuit if we've already seen this activity id.
|
||||
|
@ -736,6 +811,29 @@ class Protocol:
|
|||
|
||||
# fall through to deliver to followers
|
||||
|
||||
elif obj.type == 'block':
|
||||
proto = Protocol.for_bridgy_subdomain(inner_obj_id)
|
||||
if not proto:
|
||||
logger.info("Ignoring block, target isn't one of our protocol domains")
|
||||
return 'OK', 200
|
||||
|
||||
from_user.disable_protocol(proto)
|
||||
return 'OK', 200
|
||||
|
||||
elif obj.type == 'post':
|
||||
to_cc = (as1.get_ids(inner_obj_as1, 'to')
|
||||
+ as1.get_ids(inner_obj_as1, 'cc'))
|
||||
content = inner_obj_as1.get('content', '').strip().lower()
|
||||
logger.info(f'got DM to {to_cc}: {content}')
|
||||
if len(to_cc) == 1:
|
||||
proto = Protocol.for_bridgy_subdomain(to_cc[0])
|
||||
if proto:
|
||||
if content in ('yes', 'ok'):
|
||||
from_user.enable_protocol(proto)
|
||||
elif content == 'no':
|
||||
from_user.disable_protocol(proto)
|
||||
return 'OK', 200
|
||||
|
||||
# fetch actor if necessary
|
||||
if actor and actor.keys() == set(['id']):
|
||||
logger.info('Fetching actor so we have name, profile photo, etc')
|
||||
|
@ -759,6 +857,12 @@ class Protocol:
|
|||
}
|
||||
|
||||
if obj.type == 'follow':
|
||||
proto = Protocol.for_bridgy_subdomain(inner_obj_id)
|
||||
if proto:
|
||||
# follow of one of our protocol users; enable that protocol.
|
||||
# foll through so that we send an accept.
|
||||
from_user.enable_protocol(proto)
|
||||
|
||||
from_cls.handle_follow(obj)
|
||||
|
||||
# deliver to targets
|
||||
|
@ -790,14 +894,11 @@ class Protocol:
|
|||
from_obj.our_as1 = from_as1
|
||||
from_obj.put()
|
||||
|
||||
from_target = from_cls.target_for(from_obj)
|
||||
if not from_target:
|
||||
error(f"Couldn't find delivery target for follower {from_obj}")
|
||||
|
||||
from_key = from_cls.key_for(from_id)
|
||||
if not from_key:
|
||||
error(f'Invalid {from_cls} user key: {from_id}')
|
||||
obj.users = [from_key]
|
||||
from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
|
||||
|
||||
# Prepare followee (to) users' data
|
||||
to_as1s = as1.get_objects(obj.as1)
|
||||
|
@ -807,9 +908,8 @@ class Protocol:
|
|||
# Store Followers
|
||||
for to_as1 in to_as1s:
|
||||
to_id = to_as1.get('id')
|
||||
if not to_id or not from_id:
|
||||
if not to_id:
|
||||
error(f'Follow activity requires object(s). Got: {obj.as1}')
|
||||
from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
|
||||
|
||||
logger.info(f'Follow {from_id} => {to_id}')
|
||||
|
||||
|
@ -825,43 +925,57 @@ class Protocol:
|
|||
to_obj.our_as1 = to_as1
|
||||
to_obj.put()
|
||||
|
||||
# If followee user is already direct, follower may not know they're
|
||||
# interacting with a bridge. if followee user is indirect though,
|
||||
# follower should know, so they're direct.
|
||||
to_key = to_cls.key_for(to_id)
|
||||
if not to_key:
|
||||
logger.info(f'Skipping invalid {from_cls} user key: {from_id}')
|
||||
continue
|
||||
|
||||
# If followee user is already direct, follower may not know they're
|
||||
# interacting with a bridge. if followee user is indirect though,
|
||||
# follower should know, so they're direct.
|
||||
to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj, direct=False)
|
||||
|
||||
# HACK: we rewrite direct here for each followee, so the last one
|
||||
# wins. Could we do something better?
|
||||
from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj,
|
||||
direct=not to_user.direct)
|
||||
follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
|
||||
follow=obj.key, status='active')
|
||||
obj.add('notify', to_key)
|
||||
from_cls.maybe_accept_follow(follower=from_user, followee=to_user,
|
||||
follow=obj)
|
||||
|
||||
if not to_user.HAS_FOLLOW_ACCEPTS:
|
||||
# send accept. note that this is one accept for the whole
|
||||
# follow, even if it has multiple followees!
|
||||
id = to_user.id_as('activitypub') + f'/followers#accept-{obj.key.id()}'
|
||||
accept = Object.get_or_create(id, our_as1={
|
||||
'id': id,
|
||||
'objectType': 'activity',
|
||||
'verb': 'accept',
|
||||
'actor': to_id,
|
||||
'object': obj.as1,
|
||||
})
|
||||
@classmethod
|
||||
def maybe_accept_follow(_, follower, followee, follow):
|
||||
"""Sends an accept activity for a follow.
|
||||
|
||||
sent = from_cls.send(accept, from_target, from_user=to_user)
|
||||
if sent:
|
||||
accept.populate(
|
||||
delivered=[Target(protocol=from_cls.LABEL, uri=from_target)],
|
||||
status='complete',
|
||||
)
|
||||
accept.put()
|
||||
...if the follower protocol handles accepts. Otherwise, does nothing.
|
||||
|
||||
Args:
|
||||
follower: :class:`models.User`
|
||||
followee: :class:`models.User`
|
||||
follow: :class:`models.Object`
|
||||
"""
|
||||
if followee.HAS_FOLLOW_ACCEPTS:
|
||||
return
|
||||
|
||||
# send accept. note that this is one accept for the whole
|
||||
# follow, even if it has multiple followees!
|
||||
id = f'{followee.key.id()}/followers#accept-{follow.key.id()}'
|
||||
accept = Object.get_or_create(id, our_as1={
|
||||
'id': id,
|
||||
'objectType': 'activity',
|
||||
'verb': 'accept',
|
||||
'actor': followee.key.id(),
|
||||
'object': follow.as1,
|
||||
})
|
||||
|
||||
from_target = follower.target_for(follower.obj)
|
||||
if not from_target:
|
||||
error(f"Couldn't find delivery target for follower {follower}")
|
||||
|
||||
sent = follower.send(accept, from_target, from_user=followee)
|
||||
if sent:
|
||||
accept.populate(
|
||||
delivered=[Target(protocol=follower.LABEL, uri=from_target)],
|
||||
status='complete',
|
||||
)
|
||||
accept.put()
|
||||
|
||||
@classmethod
|
||||
def handle_bare_object(cls, obj):
|
||||
|
|
|
@ -48,8 +48,8 @@ google-cloud-ndb==2.3.1
|
|||
google-cloud-tasks==2.16.3
|
||||
googleapis-common-protos==1.63.0
|
||||
grpc-google-iam-v1==0.13.0
|
||||
grpcio==1.62.1
|
||||
grpcio-status==1.62.1
|
||||
grpcio==1.62.2
|
||||
grpcio-status==1.62.2
|
||||
gunicorn==22.0.0
|
||||
h11==0.14.0
|
||||
html2text==2024.2.26
|
||||
|
|
|
@ -9,7 +9,9 @@
|
|||
<!-- For Mastodon profile link verification
|
||||
https://fed.brid.gy/docs#mastodon-link-verification
|
||||
-->
|
||||
<a rel="me" href="https://bsky.brid.gy/"></a>
|
||||
<a rel="me" href="https://fed.brid.gy/"></a>
|
||||
<a rel="me" href="https://web.brid.gy/"></a>
|
||||
|
||||
<div id="front-form" class="row front-dark">
|
||||
<div id="topology" style="position: absolute; top: 0; left: 0"></div>
|
||||
|
|
|
@ -59,9 +59,8 @@
|
|||
|
||||
{% set copies = user.copies|map(attribute='protocol')|list %}
|
||||
{% for proto in set(PROTOCOLS.values()) %}
|
||||
{% if proto and not isinstance(user, proto)
|
||||
and proto.LABEL not in ('ui', 'web')
|
||||
and (proto.LABEL not in ids.COPIES_PROTOCOLS or proto.LABEL in copies) %}
|
||||
{% if proto and not isinstance(user, proto) and proto.LABEL not in ('ui', 'web')
|
||||
and user.is_enabled_to(proto, user=user) %}
|
||||
{% set url = proto.bridged_web_url_for(user) %}
|
||||
·
|
||||
<nobr title="{{ proto.__name__ }} (bridged)">
|
||||
|
|
|
@ -20,7 +20,7 @@ from werkzeug.exceptions import BadGateway, BadRequest
|
|||
|
||||
# import first so that Fake is defined before URL routes are registered
|
||||
from . import testutil
|
||||
from .testutil import Fake, TestCase
|
||||
from .testutil import ExplicitEnableFake, Fake, TestCase
|
||||
|
||||
import activitypub
|
||||
from activitypub import (
|
||||
|
@ -230,7 +230,7 @@ ACCEPT_FOLLOW['object'] = 'http://localhost/user.com'
|
|||
ACCEPT = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Accept',
|
||||
'id': 'http://localhost/user.com/followers#accept-https://mas.to/6d1a',
|
||||
'id': 'http://localhost/r/user.com/followers#accept-https://mas.to/6d1a',
|
||||
'actor': 'http://localhost/user.com',
|
||||
'object': {
|
||||
'type': 'Follow',
|
||||
|
@ -441,9 +441,8 @@ class ActivityPubTest(TestCase):
|
|||
self.store_object(id='did:plc:user', raw={'foo': 'baz'})
|
||||
self.make_user('did:plc:user', cls=ATProto)
|
||||
got = self.client.get('/ap/did:plc:user', base_url='https://bsky.brid.gy/')
|
||||
self.assertEqual(400, got.status_code)
|
||||
self.assertEqual(404, got.status_code)
|
||||
|
||||
@patch('common.ENABLED_BRIDGES', new=[('activitypub', 'atproto')])
|
||||
def test_actor_atproto_no_handle(self, *_):
|
||||
self.store_object(id='did:plc:user', raw={'foo': 'bar'})
|
||||
self.store_object(id='at://did:plc:user/app.bsky.actor.profile/self', bsky={
|
||||
|
@ -451,7 +450,7 @@ class ActivityPubTest(TestCase):
|
|||
'displayName': 'Alice',
|
||||
})
|
||||
|
||||
self.make_user('did:plc:user', cls=ATProto)
|
||||
self.make_user('did:plc:user', cls=ATProto, enabled_protocols=['activitypub'])
|
||||
|
||||
got = self.client.get('/ap/did:plc:user', base_url='https://bsky.brid.gy/')
|
||||
self.assertEqual(200, got.status_code)
|
||||
|
@ -491,13 +490,33 @@ class ActivityPubTest(TestCase):
|
|||
got = self.client.get('/user.com')
|
||||
self.assertEqual(404, got.status_code)
|
||||
|
||||
# skip _pre_put_hook since it doesn't allow internal domains
|
||||
@patch.object(Web, '_pre_put_hook', new=lambda self: None)
|
||||
def test_actor_protocol_bot_user(self, *_):
|
||||
"""Web users are special cased to drop the /web/ prefix."""
|
||||
actor_as2 = json_loads(util.read('bsky.brid.gy.as2.json'))
|
||||
self.make_user('bsky.brid.gy', cls=Web, obj_as2=actor_as2,
|
||||
obj_id='https://bsky.brid.gy/')
|
||||
|
||||
got = self.client.get('/bsky.brid.gy')
|
||||
self.assertEqual(200, got.status_code)
|
||||
self.assertEqual(as2.CONTENT_TYPE_LD_PROFILE, got.headers['Content-Type'])
|
||||
self.assert_equals({
|
||||
**actor_as2,
|
||||
'id': 'http://localhost/bsky.brid.gy',
|
||||
}, got.json, ignore=['inbox', 'outbox', 'endpoints', 'followers',
|
||||
'following', 'publicKey', 'publicKeyPem'])
|
||||
|
||||
# skip _pre_put_hook since it doesn't allow internal domains
|
||||
@patch.object(Web, '_pre_put_hook', new=lambda self: None)
|
||||
def test_instance_actor_fetch(self, *_):
|
||||
def reset_instance_actor():
|
||||
activitypub._INSTANCE_ACTOR = testutil.global_user
|
||||
self.addCleanup(reset_instance_actor)
|
||||
|
||||
actor_as2 = json_loads(util.read('static/instance-actor.as2.json'))
|
||||
self.make_user(common.PRIMARY_DOMAIN, cls=Web, obj_as2=actor_as2)
|
||||
actor_as2 = json_loads(util.read('fed.brid.gy.as2.json'))
|
||||
self.make_user(common.PRIMARY_DOMAIN, cls=Web, obj_as2=actor_as2,
|
||||
obj_id='https://fed.brid.gy/')
|
||||
|
||||
activitypub._INSTANCE_ACTOR = None
|
||||
got = self.client.get(f'/{common.PRIMARY_DOMAIN}')
|
||||
|
@ -830,6 +849,9 @@ class ActivityPubTest(TestCase):
|
|||
def test_inbox_unlisted(self, *mocks):
|
||||
self._test_inbox_with_to_ignored(['@unlisted'], *mocks)
|
||||
|
||||
def test_inbox_dm(self, *mocks):
|
||||
self._test_inbox_with_to_ignored(['http://localhost/web/user.com'], *mocks)
|
||||
|
||||
def _test_inbox_with_to_ignored(self, to, mock_head, mock_get, mock_post):
|
||||
Follower.get_or_create(to=self.make_user(ACTOR['id'], cls=ActivityPub),
|
||||
from_=self.user)
|
||||
|
@ -845,6 +867,26 @@ class ActivityPubTest(TestCase):
|
|||
self.assertIsNone(Object.get_by_id(not_public['id']))
|
||||
self.assertIsNone(Object.get_by_id(not_public['object']['id']))
|
||||
|
||||
def test_inbox_dm_yes_to_bot_user_enables_protocol(self, *mocks):
|
||||
user = self.make_user(ACTOR['id'], cls=ActivityPub)
|
||||
self.assertFalse(ActivityPub.is_enabled_to(ExplicitEnableFake, user))
|
||||
|
||||
got = self.post('/ap/sharedInbox', json={
|
||||
'type': 'Create',
|
||||
'id': 'https://mas.to/dm#create',
|
||||
'to': ['https://eefake.brid.gy/eefake.brid.gy'],
|
||||
'object': {
|
||||
'type': 'Note',
|
||||
'id': 'https://mas.to/dm',
|
||||
'attributedTo': ACTOR['id'],
|
||||
'to': ['https://eefake.brid.gy/eefake.brid.gy'],
|
||||
'content': 'yes',
|
||||
},
|
||||
})
|
||||
self.assertEqual(200, got.status_code, got.get_data(as_text=True))
|
||||
user = user.key.get()
|
||||
self.assertTrue(ActivityPub.is_enabled_to(ExplicitEnableFake, user))
|
||||
|
||||
def test_inbox_actor_blocklisted(self, mock_head, mock_get, mock_post):
|
||||
got = self.post('/ap/sharedInbox', json={
|
||||
'type': 'Delete',
|
||||
|
@ -1036,7 +1078,7 @@ class ActivityPubTest(TestCase):
|
|||
ignore=['created', 'updated'])
|
||||
|
||||
self.assert_user(ActivityPub, 'https://mas.to/users/swentel',
|
||||
obj_as2=ACTOR, direct=True)
|
||||
obj_as2=ACTOR, direct=False)
|
||||
self.assert_user(Web, 'user.com', direct=False,
|
||||
has_hcard=True, has_redirects=True)
|
||||
|
||||
|
@ -1097,7 +1139,7 @@ class ActivityPubTest(TestCase):
|
|||
self.assert_equals(('http://mas.to/inbox',), args)
|
||||
self.assert_equals({
|
||||
'type': 'Accept',
|
||||
'id': 'https://web.brid.gy/user.com/followers#accept-https://mas.to/6d1a',
|
||||
'id': 'https://web.brid.gy/r/user.com/followers#accept-https://mas.to/6d1a',
|
||||
'actor': 'https://web.brid.gy/user.com',
|
||||
'object': {
|
||||
'type': 'Follow',
|
||||
|
@ -1184,8 +1226,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)
|
||||
|
@ -1200,9 +1242,9 @@ class ActivityPubTest(TestCase):
|
|||
got = self.post('/user.com/inbox', json={
|
||||
'@context': ['https://www.w3.org/ns/activitystreams'],
|
||||
'id': 'https://xoxo.zone/users/aaronpk#follows/40',
|
||||
'type': 'Block',
|
||||
'type': 'Arrive',
|
||||
'actor': 'https://xoxo.zone/users/aaronpk',
|
||||
'object': 'http://snarfed.org/',
|
||||
'object': 'http://a/place',
|
||||
})
|
||||
self.assertEqual(501, got.status_code)
|
||||
|
||||
|
@ -1803,6 +1845,7 @@ class ActivityPubUtilsTest(TestCase):
|
|||
|
||||
self.assertFalse(ActivityPub.owns_id('https://twitter.com/foo'))
|
||||
self.assertFalse(ActivityPub.owns_id('https://fed.brid.gy/foo'))
|
||||
self.assertFalse(ActivityPub.owns_id('https://ap.brid.gy/foo'))
|
||||
|
||||
def test_owns_handle(self):
|
||||
for handle in ('@user@instance', 'user@instance.com', 'user.com@instance.com',
|
||||
|
|
|
@ -60,6 +60,7 @@ NOTE_AS = {
|
|||
}
|
||||
|
||||
|
||||
@patch('ids.COPIES_PROTOCOLS', ['atproto'])
|
||||
class ATProtoTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -43,17 +43,17 @@ FOLLOWEE = {
|
|||
FOLLOW_ADDRESS = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Follow',
|
||||
'id': f'http://localhost/r/alice.com/following#2022-01-02T03:04:05-@foo@ba.r',
|
||||
'id': f'http://localhost/r/https://alice.com/#follow-2022-01-02T03:04:05-@foo@ba.r',
|
||||
'actor': 'http://localhost/alice.com',
|
||||
'object': FOLLOWEE['id'],
|
||||
'to': [as2.PUBLIC_AUDIENCE],
|
||||
}
|
||||
FOLLOW_URL = copy.deepcopy(FOLLOW_ADDRESS)
|
||||
FOLLOW_URL['id'] = f'http://localhost/r/alice.com/following#2022-01-02T03:04:05-https://ba.r/actor'
|
||||
FOLLOW_URL['id'] = f'http://localhost/r/https://alice.com/#follow-2022-01-02T03:04:05-https://ba.r/actor'
|
||||
UNDO_FOLLOW = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Undo',
|
||||
'id': f'http://localhost/r/alice.com/following#undo-2022-01-02T03:04:05-https://ba.r/id',
|
||||
'id': f'http://localhost/r/https://alice.com/#unfollow-2022-01-02T03:04:05-https://ba.r/id',
|
||||
'actor': 'http://localhost/alice.com',
|
||||
'object': copy.deepcopy(FOLLOW_ADDRESS),
|
||||
}
|
||||
|
@ -213,7 +213,7 @@ class FollowTest(TestCase):
|
|||
|
||||
follow_with_profile_link = {
|
||||
**FOLLOW_URL,
|
||||
'id': f'http://localhost/r/alice.com/following#2022-01-02T03:04:05-https://ba.r/id',
|
||||
'id': f'http://localhost/r/https://alice.com/#follow-2022-01-02T03:04:05-https://ba.r/id',
|
||||
'object': 'https://ba.r/id',
|
||||
}
|
||||
self.check('https://ba.r/id', resp, follow_with_profile_link, mock_get,
|
||||
|
@ -355,7 +355,7 @@ class FollowTest(TestCase):
|
|||
sig_template.startswith('keyId="http://localhost/alice.com#key"'),
|
||||
sig_template)
|
||||
|
||||
follow_id = f'https://fed.brid.gy/web/alice.com/following#2022-01-02T03:04:05-{input}'
|
||||
follow_id = f'https://alice.com/#follow-2022-01-02T03:04:05-{input}'
|
||||
|
||||
followers = Follower.query().fetch()
|
||||
followee = ActivityPub(id='https://ba.r/id').key
|
||||
|
@ -410,7 +410,7 @@ class FollowTest(TestCase):
|
|||
self.assertEqual(302, resp.status_code)
|
||||
self.assertEqual('/web/www.alice.com/following', resp.headers['Location'])
|
||||
|
||||
id = 'www.alice.com/following#2022-01-02T03:04:05-https://ba.r/actor'
|
||||
id = 'https://www.alice.com/#follow-2022-01-02T03:04:05-https://ba.r/actor'
|
||||
expected_follow_as1 = as2.to_as1({
|
||||
**FOLLOW_URL,
|
||||
'id': id,
|
||||
|
@ -418,16 +418,14 @@ class FollowTest(TestCase):
|
|||
})
|
||||
del expected_follow_as1['to']
|
||||
followee = ActivityPub(id='https://ba.r/id').key
|
||||
follow_obj = self.assert_object(
|
||||
f'https://fed.brid.gy/web/{id}',
|
||||
users=[user.key],
|
||||
notify=[followee],
|
||||
status='complete',
|
||||
labels=['user', 'activity'],
|
||||
source_protocol='ui',
|
||||
our_as1=expected_follow_as1,
|
||||
delivered=['http://ba.r/inbox'],
|
||||
delivered_protocol='activitypub')
|
||||
follow_obj = self.assert_object(id, users=[user.key],
|
||||
notify=[followee],
|
||||
status='complete',
|
||||
labels=['user', 'activity'],
|
||||
source_protocol='ui',
|
||||
our_as1=expected_follow_as1,
|
||||
delivered=['http://ba.r/inbox'],
|
||||
delivered_protocol='activitypub')
|
||||
|
||||
followers = Follower.query().fetch()
|
||||
self.assert_entities_equal(
|
||||
|
@ -607,7 +605,7 @@ class UnfollowTest(TestCase):
|
|||
self.assertEqual('inactive', follower.status)
|
||||
|
||||
self.assert_object(
|
||||
'https://fed.brid.gy/web/alice.com/following#undo-2022-01-02T03:04:05-https://ba.r/id',
|
||||
'https://alice.com/#unfollow-2022-01-02T03:04:05-https://ba.r/id',
|
||||
users=[self.user.key],
|
||||
notify=[ActivityPub(id='https://ba.r/id').key],
|
||||
status='complete',
|
||||
|
@ -646,7 +644,7 @@ class UnfollowTest(TestCase):
|
|||
self.assertEqual(302, resp.status_code)
|
||||
self.assertEqual('/web/www.alice.com/following', resp.headers['Location'])
|
||||
|
||||
id = 'http://localhost/r/www.alice.com/following#undo-2022-01-02T03:04:05-https://ba.r/id'
|
||||
id = 'http://localhost/r/https://www.alice.com/#unfollow-2022-01-02T03:04:05-https://ba.r/id'
|
||||
expected_undo = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Undo',
|
||||
|
@ -670,7 +668,7 @@ class UnfollowTest(TestCase):
|
|||
self.assertEqual('inactive', follower.status)
|
||||
|
||||
self.assert_object(
|
||||
'https://fed.brid.gy/web/www.alice.com/following#undo-2022-01-02T03:04:05-https://ba.r/id',
|
||||
'https://www.alice.com/#unfollow-2022-01-02T03:04:05-https://ba.r/id',
|
||||
users=[user.key],
|
||||
notify=[ActivityPub(id='https://ba.r/id').key],
|
||||
status='complete',
|
||||
|
|
|
@ -61,7 +61,7 @@ class IdsTest(TestCase):
|
|||
]:
|
||||
with self.subTest(from_=from_.LABEL, to=to.LABEL):
|
||||
self.assertEqual(expected, translate_user_id(
|
||||
id=id, from_proto=from_, to_proto=to))
|
||||
id=id, from_=from_, to=to))
|
||||
|
||||
def test_translate_user_id_no_copy_did_stored(self):
|
||||
for proto, id in [
|
||||
|
@ -70,8 +70,7 @@ class IdsTest(TestCase):
|
|||
(Fake, 'fake:user'),
|
||||
]:
|
||||
with self.subTest(proto=proto.LABEL):
|
||||
self.assertIsNone(translate_user_id(
|
||||
id=id, from_proto=proto, to_proto=ATProto))
|
||||
self.assertIsNone(translate_user_id(id=id, from_=proto, to=ATProto))
|
||||
|
||||
def test_translate_user_id_use_instead(self):
|
||||
did = Target(uri='did:plc:123', protocol='atproto')
|
||||
|
@ -85,18 +84,22 @@ class IdsTest(TestCase):
|
|||
]:
|
||||
with self.subTest(proto=proto.LABEL):
|
||||
self.assertEqual(expected, translate_user_id(
|
||||
id='www.user.com', from_proto=Web, to_proto=proto))
|
||||
id='www.user.com', from_=Web, to=proto))
|
||||
self.assertEqual(expected, translate_user_id(
|
||||
id='https://www.user.com/', from_proto=Web, to_proto=proto))
|
||||
id='https://www.user.com/', from_=Web, to=proto))
|
||||
|
||||
@patch('ids._FED_SUBDOMAIN_SITES', new={'on-fed.com'})
|
||||
def test_translate_user_id_web_ap_subdomain_fed(self):
|
||||
self.make_user('on-fed.com', cls=Web, ap_subdomain='fed')
|
||||
self.make_user('on-bsky.com', cls=Web, ap_subdomain='bsky')
|
||||
|
||||
for base_url in ['https://web.brid.gy/', 'https://fed.brid.gy/']:
|
||||
with app.test_request_context('/', base_url=base_url):
|
||||
self.assertEqual('https://web.brid.gy/on-web.com', translate_user_id(
|
||||
id='on-web.com', from_proto=Web, to_proto=ActivityPub))
|
||||
id='on-web.com', from_=Web, to=ActivityPub))
|
||||
self.assertEqual('https://fed.brid.gy/on-fed.com', translate_user_id(
|
||||
id='on-fed.com', from_proto=Web, to_proto=ActivityPub))
|
||||
id='on-fed.com', from_=Web, to=ActivityPub))
|
||||
self.assertEqual('https://bsky.brid.gy/on-bsky.com', translate_user_id(
|
||||
id='on-bsky.com', from_=Web, to=ActivityPub))
|
||||
|
||||
def test_translate_handle(self):
|
||||
for from_, handle, to, expected in [
|
||||
|
@ -106,6 +109,10 @@ class IdsTest(TestCase):
|
|||
(Web, 'user.com', Fake, 'fake:handle:user.com'),
|
||||
(Web, 'user.com', Web, 'user.com'),
|
||||
|
||||
# instance actor, protocol bot user
|
||||
(Web, 'fed.brid.gy', ActivityPub, '@fed.brid.gy@fed.brid.gy'),
|
||||
(Web, 'bsky.brid.gy', ActivityPub, '@bsky.brid.gy@bsky.brid.gy'),
|
||||
|
||||
(ActivityPub, '@user@instance', ActivityPub, '@user@instance'),
|
||||
(ActivityPub, '@user@instance', ATProto, 'user.instance.ap.brid.gy'),
|
||||
(ActivityPub, '@user@instance', Fake, 'fake:handle:@user@instance'),
|
||||
|
@ -123,7 +130,7 @@ class IdsTest(TestCase):
|
|||
]:
|
||||
with self.subTest(from_=from_.LABEL, to=to.LABEL):
|
||||
self.assertEqual(expected, translate_handle(
|
||||
handle=handle, from_proto=from_, to_proto=to, enhanced=False))
|
||||
handle=handle, from_=from_, to=to, enhanced=False))
|
||||
|
||||
def test_translate_handle_enhanced(self):
|
||||
for from_, handle, to, expected in [
|
||||
|
@ -133,10 +140,14 @@ class IdsTest(TestCase):
|
|||
(ActivityPub, '@user@user', Web, 'https://user'),
|
||||
(ActivityPub, '@user@instance', Fake, 'fake:handle:@user@instance'),
|
||||
(ATProto, 'user.com', ActivityPub, '@user.com@user.com'),
|
||||
|
||||
# instance actor, protocol bot user
|
||||
(Web, 'fed.brid.gy', ActivityPub, '@fed.brid.gy@fed.brid.gy'),
|
||||
(Web, 'bsky.brid.gy', ActivityPub, '@bsky.brid.gy@bsky.brid.gy'),
|
||||
]:
|
||||
with self.subTest(from_=from_.LABEL, to=to.LABEL):
|
||||
self.assertEqual(expected, translate_handle(
|
||||
handle=handle, from_proto=from_, to_proto=to, enhanced=True))
|
||||
handle=handle, from_=from_, to=to, enhanced=True))
|
||||
|
||||
def test_translate_object_id(self):
|
||||
self.store_object(id='http://po.st',
|
||||
|
@ -184,16 +195,17 @@ class IdsTest(TestCase):
|
|||
]:
|
||||
with self.subTest(from_=from_.LABEL, to=to.LABEL):
|
||||
self.assertEqual(expected, translate_object_id(
|
||||
id=id, from_proto=from_, to_proto=to))
|
||||
id=id, from_=from_, to=to))
|
||||
|
||||
@patch('ids._FED_SUBDOMAIN_SITES', new={'on-fed.com'})
|
||||
def test_translate_object_id_web_ap_subdomain_fed(self):
|
||||
self.make_user('on-fed.com', cls=Web, ap_subdomain='fed')
|
||||
|
||||
for base_url in ['https://web.brid.gy/', 'https://fed.brid.gy/']:
|
||||
with app.test_request_context('/', base_url=base_url):
|
||||
got = translate_object_id(id='http://on-fed.com/post', from_proto=Web,
|
||||
to_proto=ActivityPub)
|
||||
got = translate_object_id(id='http://on-fed.com/post', from_=Web,
|
||||
to=ActivityPub)
|
||||
self.assertEqual('https://fed.brid.gy/r/http://on-fed.com/post', got)
|
||||
|
||||
got = translate_object_id(id='http://on-web.com/post', from_proto=Web,
|
||||
to_proto=ActivityPub)
|
||||
got = translate_object_id(id='http://on-web.com/post', from_=Web,
|
||||
to=ActivityPub)
|
||||
self.assertEqual('https://web.brid.gy/r/http://on-web.com/post', got)
|
||||
|
|
|
@ -32,11 +32,11 @@ PROFILE_GETRECORD = {
|
|||
}
|
||||
|
||||
|
||||
@patch('ids.COPIES_PROTOCOLS', ['atproto'])
|
||||
class IntegrationTests(TestCase):
|
||||
|
||||
@patch('requests.post')
|
||||
@patch('requests.get')
|
||||
@patch('common.ENABLED_BRIDGES', new=[('activitypub', 'atproto')])
|
||||
def test_atproto_notify_reply_to_activitypub(self, mock_get, mock_post):
|
||||
"""ATProto poll notifications, deliver reply to ActivityPub.
|
||||
|
||||
|
@ -55,6 +55,7 @@ class IntegrationTests(TestCase):
|
|||
id='http://inst/bob',
|
||||
cls=ActivityPub,
|
||||
copies=[Target(uri='did:plc:bob', protocol='atproto')],
|
||||
enabled_protocols=['atproto'],
|
||||
obj_as2={
|
||||
'id': 'http://inst/bob',
|
||||
'inbox': 'http://inst/bob/inbox',
|
||||
|
@ -145,7 +146,8 @@ class IntegrationTests(TestCase):
|
|||
storage = DatastoreStorage()
|
||||
Repo.create(storage, 'did:plc:bob', signing_key=ATPROTO_KEY)
|
||||
bob = self.make_user(id='bob.com', cls=Web,
|
||||
copies=[Target(uri='did:plc:bob', protocol='atproto')])
|
||||
copies=[Target(uri='did:plc:bob', protocol='atproto')],
|
||||
enabled_protocols=['atproto'])
|
||||
|
||||
mock_get.side_effect = [
|
||||
# ATProto listNotifications
|
||||
|
@ -223,13 +225,14 @@ class IntegrationTests(TestCase):
|
|||
ATProto user alice.com (did:plc:alice)
|
||||
Follow is HTML with mf2 u-follow-of of https://bsky.app/profile/alice.com
|
||||
"""
|
||||
bob = self.make_user(id='bob.com', cls=Web, obj_mf2={
|
||||
'type': ['h-card'],
|
||||
'properties': {
|
||||
'url': ['https://bob.com/'],
|
||||
'name': ['Bob'],
|
||||
},
|
||||
})
|
||||
bob = self.make_user(id='bob.com', cls=Web, enabled_protocols=['atproto'],
|
||||
obj_mf2={
|
||||
'type': ['h-card'],
|
||||
'properties': {
|
||||
'url': ['https://bob.com/'],
|
||||
'name': ['Bob'],
|
||||
},
|
||||
})
|
||||
|
||||
# send webmention
|
||||
resp = self.post('/webmention', data={
|
||||
|
|
|
@ -66,10 +66,15 @@ class UserTest(TestCase):
|
|||
user.direct = True
|
||||
self.assert_entities_equal(same, user, ignore=['updated'])
|
||||
|
||||
@patch('ids.COPIES_PROTOCOLS', ['fake', 'other'])
|
||||
def test_get_or_create_propagate_fake_other(self):
|
||||
user = Fake.get_or_create('fake:user', propagate=True)
|
||||
self.assertEqual(['fake:user'], OtherFake.created_for)
|
||||
|
||||
@patch.object(tasks_client, 'create_task', return_value=Task(name='my task'))
|
||||
@patch('requests.post',
|
||||
return_value=requests_response('OK')) # create DID on PLC
|
||||
def test_get_or_create_propagate(self, mock_post, mock_create_task):
|
||||
def test_get_or_create_propagate_atproto(self, mock_post, mock_create_task):
|
||||
common.RUN_TASKS_INLINE = False
|
||||
|
||||
Fake.fetchable = {
|
||||
|
@ -107,6 +112,7 @@ class UserTest(TestCase):
|
|||
|
||||
mock_create_task.assert_called()
|
||||
|
||||
@patch('ids.COPIES_PROTOCOLS', ['eefake', 'atproto'])
|
||||
@patch.object(tasks_client, 'create_task')
|
||||
@patch('requests.post')
|
||||
@patch('requests.get')
|
||||
|
@ -123,7 +129,6 @@ class UserTest(TestCase):
|
|||
self.assertEqual([], user.copies)
|
||||
self.assertEqual(0, AtpRepo.query().count())
|
||||
|
||||
|
||||
def test_get_or_create_use_instead(self):
|
||||
user = Fake.get_or_create('a.b')
|
||||
user.use_instead = self.user.key
|
||||
|
@ -278,9 +283,13 @@ class UserTest(TestCase):
|
|||
user.copies.append(Target(uri='fake:foo', protocol='fake'))
|
||||
self.assertIsNone(user.get_copy(OtherFake))
|
||||
|
||||
self.assertIsNone(user.get_copy(OtherFake))
|
||||
user.copies = [Target(uri='other:foo', protocol='other')]
|
||||
self.assertEqual('other:foo', user.get_copy(OtherFake))
|
||||
|
||||
self.assertIsNone(OtherFake().get_copy(Fake))
|
||||
|
||||
|
||||
def test_count_followers(self):
|
||||
self.assertEqual((0, 0), self.user.count_followers())
|
||||
|
||||
|
|
|
@ -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,41 @@ 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(Fake.is_enabled_to(OtherFake))
|
||||
self.assertTrue(Fake.is_enabled_to(ExplicitEnableFake))
|
||||
|
||||
self.assertFalse(ActivityPub.is_enabled_to(ATProto))
|
||||
self.assertFalse(ATProto.is_enabled_to(ActivityPub))
|
||||
self.assertFalse(ATProto.is_enabled_to(Web))
|
||||
self.assertFalse(Web.is_enabled_to(ATProto))
|
||||
self.assertFalse(ExplicitEnableFake.is_enabled_to(Fake))
|
||||
self.assertFalse(ExplicitEnableFake.is_enabled_to(Web))
|
||||
|
||||
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'}
|
||||
|
||||
|
@ -1340,7 +1375,7 @@ class ProtocolReceiveTest(TestCase):
|
|||
delivered=['fake:user:target'],
|
||||
)
|
||||
|
||||
accept_id = 'https://fa.brid.gy/ap/fake:user/followers#accept-fake:follow'
|
||||
accept_id = 'fake:user/followers#accept-fake:follow'
|
||||
accept_as1 = {
|
||||
'id': accept_id,
|
||||
'objectType': 'activity',
|
||||
|
@ -1562,9 +1597,8 @@ class ProtocolReceiveTest(TestCase):
|
|||
self.assertEqual(('OK', 202), OtherFake.receive_as1(follow_as1))
|
||||
|
||||
self.assertEqual(1, len(OtherFake.sent))
|
||||
self.assertEqual(
|
||||
'https://fa.brid.gy/ap/fake:alice/followers#accept-other:follow',
|
||||
OtherFake.sent[0][0])
|
||||
self.assertEqual('fake:alice/followers#accept-other:follow',
|
||||
OtherFake.sent[0][0])
|
||||
|
||||
self.assertEqual(1, len(Fake.sent))
|
||||
self.assertEqual('other:follow', Fake.sent[0][0])
|
||||
|
@ -1743,6 +1777,104 @@ class ProtocolReceiveTest(TestCase):
|
|||
}],
|
||||
}, obj.key.get().our_as1)
|
||||
|
||||
def test_follow_and_block_protocol_user_sets_enabled_protocols(self):
|
||||
follow = {
|
||||
'objectType': 'activity',
|
||||
'verb': 'follow',
|
||||
'id': 'eefake:follow',
|
||||
'actor': 'eefake:user',
|
||||
'object': 'fa.brid.gy',
|
||||
}
|
||||
block = {
|
||||
'objectType': 'activity',
|
||||
'verb': 'block',
|
||||
'id': 'eefake:block',
|
||||
'actor': 'eefake:user',
|
||||
'object': 'fa.brid.gy',
|
||||
}
|
||||
|
||||
user = self.make_user('eefake:user', cls=ExplicitEnableFake)
|
||||
self.assertFalse(ExplicitEnableFake.is_enabled_to(Fake, user))
|
||||
|
||||
# fake protocol isn't enabled yet, block should be a noop
|
||||
self.assertEqual(('OK', 200), ExplicitEnableFake.receive_as1(block))
|
||||
user = user.key.get()
|
||||
self.assertEqual([], user.enabled_protocols)
|
||||
self.assertEqual([], Fake.created_for)
|
||||
|
||||
# follow should add to enabled_protocols
|
||||
with self.assertRaises(NoContent):
|
||||
ExplicitEnableFake.receive_as1(follow)
|
||||
user = user.key.get()
|
||||
self.assertEqual(['fake'], user.enabled_protocols)
|
||||
self.assertEqual(['eefake:user'], Fake.created_for)
|
||||
self.assertTrue(ExplicitEnableFake.is_enabled_to(Fake, user))
|
||||
self.assertEqual([
|
||||
('https://fa.brid.gy//followers#accept-eefake:follow',
|
||||
'eefake:user:target'),
|
||||
], ExplicitEnableFake.sent)
|
||||
|
||||
# another follow should be a noop
|
||||
follow['id'] += '2'
|
||||
Fake.created_for = []
|
||||
with self.assertRaises(NoContent):
|
||||
ExplicitEnableFake.receive_as1(follow)
|
||||
user = user.key.get()
|
||||
self.assertEqual(['fake'], user.enabled_protocols)
|
||||
self.assertEqual([], Fake.created_for)
|
||||
|
||||
# block should remove from enabled_protocols
|
||||
block['id'] += '2'
|
||||
self.assertEqual(('OK', 200), ExplicitEnableFake.receive_as1(block))
|
||||
user = user.key.get()
|
||||
self.assertEqual([], user.enabled_protocols)
|
||||
self.assertEqual([], Fake.created_for)
|
||||
self.assertFalse(ExplicitEnableFake.is_enabled_to(Fake, user))
|
||||
|
||||
def test_dm_no_yes_sets_enabled_protocols(self):
|
||||
dm = {
|
||||
'objectType': 'note',
|
||||
'id': 'eefake:dm',
|
||||
'actor': 'eefake:user',
|
||||
'to': ['fa.brid.gy'],
|
||||
'content': 'no',
|
||||
}
|
||||
|
||||
user = self.make_user('eefake:user', cls=ExplicitEnableFake)
|
||||
self.assertFalse(ExplicitEnableFake.is_enabled_to(Fake, user))
|
||||
|
||||
# fake protocol isn't enabled yet, no DM should be a noop
|
||||
self.assertEqual(('OK', 200), ExplicitEnableFake.receive_as1(dm))
|
||||
user = user.key.get()
|
||||
self.assertEqual([], user.enabled_protocols)
|
||||
self.assertEqual([], Fake.created_for)
|
||||
|
||||
# yes DM should add to enabled_protocols
|
||||
dm['id'] += '2'
|
||||
dm['content'] = 'yes'
|
||||
self.assertEqual(('OK', 200), ExplicitEnableFake.receive_as1(dm))
|
||||
user = user.key.get()
|
||||
self.assertEqual(['fake'], user.enabled_protocols)
|
||||
self.assertEqual(['eefake:user'], Fake.created_for)
|
||||
self.assertTrue(ExplicitEnableFake.is_enabled_to(Fake, user))
|
||||
|
||||
# another yes DM should be a noop
|
||||
dm['id'] += '3'
|
||||
Fake.created_for = []
|
||||
self.assertEqual(('OK', 200), ExplicitEnableFake.receive_as1(dm))
|
||||
user = user.key.get()
|
||||
self.assertEqual(['fake'], user.enabled_protocols)
|
||||
self.assertEqual([], Fake.created_for)
|
||||
|
||||
# block should remove from enabled_protocols
|
||||
dm['id'] += '4'
|
||||
dm['content'] = ' \n NO '
|
||||
self.assertEqual(('OK', 200), ExplicitEnableFake.receive_as1(dm))
|
||||
user = user.key.get()
|
||||
self.assertEqual([], user.enabled_protocols)
|
||||
self.assertEqual([], Fake.created_for)
|
||||
self.assertFalse(ExplicitEnableFake.is_enabled_to(Fake, user))
|
||||
|
||||
def test_receive_task_handler(self):
|
||||
note = {
|
||||
'id': 'fake:post',
|
||||
|
|
|
@ -2373,6 +2373,13 @@ http://this/404s
|
|||
self.user.ap_subdomain = 'fed'
|
||||
self.assertEqual('@user.com@fed.brid.gy', self.user.handle_as(ActivityPub))
|
||||
|
||||
def test_handle_as_bot_users(self, *_):
|
||||
fed = Web(id='fed.brid.gy', ap_subdomain='fed')
|
||||
self.assertEqual('@fed.brid.gy@fed.brid.gy', fed.handle_as(ActivityPub))
|
||||
|
||||
bsky = Web(id='bsky.brid.gy', ap_subdomain='bsky')
|
||||
self.assertEqual('@bsky.brid.gy@bsky.brid.gy', bsky.handle_as(ActivityPub))
|
||||
|
||||
def test_id_as(self, *_):
|
||||
self.assertEqual('http://localhost/user.com', self.user.id_as(ActivityPub))
|
||||
|
||||
|
@ -2552,7 +2559,9 @@ class WebUtilTest(TestCase):
|
|||
self.assertEqual(False, Web.owns_handle('@foo@bar.com'))
|
||||
self.assertEqual(False, Web.owns_handle('foo@bar.com'))
|
||||
self.assertEqual(False, Web.owns_handle('localhost'))
|
||||
self.assertEqual(False, Web.owns_handle('bsky.brid.gy'))
|
||||
|
||||
self.assertEqual(True, Web.owns_handle('fed.brid.gy'))
|
||||
self.assertEqual(True, Web.owns_handle('bsky.brid.gy'))
|
||||
|
||||
def test_handle_to_id(self, *_):
|
||||
self.assertEqual('foo.com', Web.handle_to_id('foo.com'))
|
||||
|
@ -2755,8 +2764,7 @@ class WebUtilTest(TestCase):
|
|||
def test_fetch_instance_actor(self, _, __):
|
||||
obj = Object(id=f'https://{common.PRIMARY_DOMAIN}/')
|
||||
self.assertTrue(Web.fetch(obj))
|
||||
self.assertEqual(obj.as2,
|
||||
json_loads(util.read('static/instance-actor.as2.json')))
|
||||
self.assertEqual(obj.as2, json_loads(util.read('fed.brid.gy.as2.json')))
|
||||
|
||||
def test_fetch_resolves_relative_urls(self, mock_get, __):
|
||||
mock_get.return_value = requests_response("""\
|
||||
|
|
|
@ -4,6 +4,7 @@ from unittest.mock import patch
|
|||
import urllib.parse
|
||||
|
||||
from granary.as2 import CONTENT_TYPE_LD_PROFILE
|
||||
from oauth_dropins.webutil import util
|
||||
from oauth_dropins.webutil.testutil import requests_response
|
||||
|
||||
# import first so that Fake is defined before URL routes are registered
|
||||
|
@ -341,11 +342,29 @@ class WebfingerTest(TestCase):
|
|||
user = Web.get_by_id('user.com')
|
||||
assert not user.direct
|
||||
|
||||
def test_fed_brid_gy(self):
|
||||
# skip _pre_put_hook since it doesn't allow internal domains
|
||||
@patch.object(Web, '_pre_put_hook', new=lambda self: None)
|
||||
def test_protocol_bot_user(self):
|
||||
self.make_user('bsky.brid.gy', cls=Web, obj_id='https://bsky.brid.gy/',
|
||||
ap_subdomain='bsky')
|
||||
|
||||
for id in ('acct:bsky.brid.gy@bsky.brid.gy',
|
||||
'https://bsky.brid.gy/bsky.brid.gy'):
|
||||
got = self.client.get(f'/.well-known/webfinger?resource={id}')
|
||||
self.assertEqual(200, got.status_code, got.get_data(as_text=True))
|
||||
self.assertEqual('acct:bsky.brid.gy@bsky.brid.gy', got.json['subject'])
|
||||
self.assertEqual(['https://bsky.brid.gy/'], got.json['aliases'])
|
||||
self.assertIn({
|
||||
'href': 'http://localhost/bsky.brid.gy',
|
||||
'rel': 'self',
|
||||
'type': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
}, got.json['links'])
|
||||
|
||||
def test_internal_domain_error(self):
|
||||
got = self.client.get('/.well-known/webfinger?resource=http://localhost/')
|
||||
self.assertEqual(400, got.status_code, got.get_data(as_text=True))
|
||||
|
||||
got = self.client.get('/.well-known/webfinger?resource=acct%3A%40localhost')
|
||||
got = self.client.get('/.well-known/webfinger?resource=acct:@localhost')
|
||||
self.assertEqual(400, got.status_code, got.get_data(as_text=True))
|
||||
|
||||
@patch('requests.get', return_value=requests_response(
|
||||
|
|
|
@ -34,7 +34,8 @@ import requests
|
|||
|
||||
# other modules are imported _after_ Fake etc classes is defined so that it's in
|
||||
# PROTOCOLS when URL routes are registered.
|
||||
from common import long_to_base64, TASKS_LOCATION
|
||||
from common import add, long_to_base64, TASKS_LOCATION
|
||||
import ids
|
||||
import models
|
||||
from models import KEY_BITS, Object, PROTOCOLS, Target, User
|
||||
import protocol
|
||||
|
@ -76,8 +77,9 @@ class Fake(User, protocol.Protocol):
|
|||
# in-order list of (Object, str URL)
|
||||
sent = []
|
||||
|
||||
# in-order list of ids
|
||||
# in-order lists of ids
|
||||
fetched = []
|
||||
created_for = []
|
||||
|
||||
@ndb.ComputedProperty
|
||||
def handle(self):
|
||||
|
@ -86,6 +88,14 @@ class Fake(User, protocol.Protocol):
|
|||
def web_url(self):
|
||||
return self.key.id()
|
||||
|
||||
@classmethod
|
||||
def create_for(cls, user):
|
||||
assert not user.get_copy(cls)
|
||||
id = user.key.id()
|
||||
cls.created_for.append(id)
|
||||
add(user.copies, Target(uri=ids.translate_user_id(id=id, from_=user, to=cls),
|
||||
protocol=cls.LABEL))
|
||||
|
||||
@classmethod
|
||||
def owns_id(cls, id):
|
||||
if id.startswith('nope') or id == f'{cls.LABEL}:nope':
|
||||
|
@ -106,7 +116,7 @@ class Fake(User, protocol.Protocol):
|
|||
return handle.replace(f'{cls.LABEL}:handle:', f'{cls.LABEL}:')
|
||||
|
||||
@classmethod
|
||||
def is_blocklisted(cls, url):
|
||||
def is_blocklisted(cls, url, allow_internal=False):
|
||||
return url.startswith(f'{cls.LABEL}:blocklisted')
|
||||
|
||||
@classmethod
|
||||
|
@ -157,6 +167,7 @@ class OtherFake(Fake):
|
|||
fetchable = {}
|
||||
sent = []
|
||||
fetched = []
|
||||
created_for = []
|
||||
|
||||
@classmethod
|
||||
def target_for(cls, obj, shared=False):
|
||||
|
@ -164,6 +175,16 @@ class OtherFake(Fake):
|
|||
return f'{obj.key.id()}:target'
|
||||
|
||||
|
||||
class ExplicitEnableFake(Fake):
|
||||
LABEL = ABBREV = 'eefake'
|
||||
CONTENT_TYPE = 'un/known'
|
||||
|
||||
fetchable = {}
|
||||
sent = []
|
||||
fetched = []
|
||||
created_for = []
|
||||
|
||||
|
||||
# import other modules that register Flask handlers *after* Fake is defined
|
||||
models.reset_protocol_properties()
|
||||
|
||||
|
@ -172,6 +193,7 @@ import activitypub
|
|||
from activitypub import ActivityPub, CONNEG_HEADERS_AS2_HTML
|
||||
from atproto import ATProto
|
||||
import common
|
||||
from common import PRIMARY_DOMAIN, PROTOCOL_DOMAINS, OTHER_DOMAINS, LOCAL_DOMAINS
|
||||
from web import Web
|
||||
from flask_app import app, cache
|
||||
|
||||
|
@ -202,13 +224,24 @@ class TestCase(unittest.TestCase, testutil.Asserts):
|
|||
did.resolve_plc.cache.clear()
|
||||
did.resolve_web.cache.clear()
|
||||
|
||||
for cls in Fake, OtherFake:
|
||||
for cls in ExplicitEnableFake, Fake, OtherFake:
|
||||
cls.fetchable = {}
|
||||
cls.sent = []
|
||||
cls.fetched = []
|
||||
cls.created_for = []
|
||||
|
||||
common.OTHER_DOMAINS += ('fake.brid.gy',)
|
||||
common.DOMAINS += ('fake.brid.gy',)
|
||||
ids._NON_WEB_SUBDOMAIN_SITES = None
|
||||
ids.COPIES_PROTOCOLS = ('atproto', 'fake', 'other')
|
||||
common.PROTOCOL_DOMAINS = (
|
||||
'ap.brid.gy',
|
||||
'bsky.brid.gy',
|
||||
'web.brid.gy',
|
||||
'eefake.brid.gy',
|
||||
'fa.brid.gy',
|
||||
'other.brid.gy',
|
||||
)
|
||||
common.DOMAINS = ((PRIMARY_DOMAIN,) + common.PROTOCOL_DOMAINS
|
||||
+ OTHER_DOMAINS + LOCAL_DOMAINS)
|
||||
|
||||
# make random test data deterministic
|
||||
arroba.util._clockid = 17
|
||||
|
|
58
web.py
58
web.py
|
@ -24,7 +24,7 @@ from requests.auth import HTTPBasicAuth
|
|||
from werkzeug.exceptions import BadGateway, BadRequest, HTTPException, NotFound
|
||||
|
||||
import common
|
||||
from common import add, DOMAIN_RE, SUPERDOMAIN
|
||||
from common import add, DOMAIN_RE, PRIMARY_DOMAIN, PROTOCOL_DOMAINS, SUPERDOMAIN
|
||||
from flask_app import app, cache
|
||||
from ids import translate_handle, translate_object_id, translate_user_id
|
||||
from models import Follower, Object, PROTOCOLS, Target, User
|
||||
|
@ -67,16 +67,21 @@ MAX_FEED_POLL_PERIOD = timedelta(weeks=1)
|
|||
MAX_FEED_PROPERTY_SIZE = 500 * 1000 # Object.atom/rss
|
||||
|
||||
|
||||
def is_valid_domain(domain):
|
||||
def is_valid_domain(domain, allow_internal=True):
|
||||
"""Returns True if this is a valid domain we can use, False otherwise.
|
||||
|
||||
Args:
|
||||
domain (str):
|
||||
allow_internal (bool): whether to return True for internal domains
|
||||
like ``fed.brid.gy``, ``bsky.brid.gy``, etc
|
||||
|
||||
Valid means TLD is ok, not blacklisted, etc.
|
||||
"""
|
||||
if not domain or not re.match(DOMAIN_RE, domain):
|
||||
# logger.debug(f"{domain} doesn't look like a domain")
|
||||
return False
|
||||
|
||||
if Web.is_blocklisted(domain) and domain != common.PRIMARY_DOMAIN:
|
||||
if Web.is_blocklisted(domain, allow_internal=allow_internal):
|
||||
logger.debug(f'{domain} is blocklisted')
|
||||
return False
|
||||
|
||||
|
@ -98,6 +103,7 @@ class Web(User, Protocol):
|
|||
OTHER_LABELS = ('webmention',)
|
||||
LOGO_HTML = '🌐' # used to be 🕸️
|
||||
CONTENT_TYPE = common.CONTENT_TYPE_HTML
|
||||
DEFAULT_ENABLED_PROTOCOLS = ('activitypub',)
|
||||
|
||||
has_redirects = ndb.BooleanProperty()
|
||||
redirects_error = ndb.TextProperty()
|
||||
|
@ -111,9 +117,14 @@ class Web(User, Protocol):
|
|||
# Originally, BF served Web users' AP actor ids on fed.brid.gy, eg
|
||||
# https://fed.brid.gy/snarfed.org . When we started adding new protocols, we
|
||||
# switched to per-protocol subdomains, eg https://web.brid.gy/snarfed.org .
|
||||
# However, we need to preserve the old users' actor ids as is. So, this
|
||||
# property tracks which subdomain a given Web user's AP actor uses.
|
||||
ap_subdomain = ndb.StringProperty(choices=['fed', 'web'], default='web')
|
||||
# However, we need to preserve the old users' actor ids as is.
|
||||
#
|
||||
# Also, our per-protocol bot accounts in ActivityPub are on their own
|
||||
# subdomains, eg @bsky.brid.gy@bsky.brid.gy.
|
||||
#
|
||||
# So, this property tracks which subdomain a given Web user's AP actor uses.
|
||||
ap_subdomain = ndb.StringProperty(choices=['bsky', 'fed', 'web'],
|
||||
default='web')
|
||||
|
||||
# OLD. some stored entities still have these; do not reuse.
|
||||
# superfeedr_subscribed = ndb.DateTimeProperty(tzinfo=timezone.utc)
|
||||
|
@ -127,7 +138,7 @@ class Web(User, Protocol):
|
|||
"""Validate domain id, don't allow upper case or invalid characters."""
|
||||
super()._pre_put_hook()
|
||||
id = self.key.id()
|
||||
assert is_valid_domain(id), id
|
||||
assert is_valid_domain(id, allow_internal=False), id
|
||||
assert id.lower() == id, f'upper case is not allowed in Web key id: {id}'
|
||||
|
||||
@classmethod
|
||||
|
@ -171,8 +182,8 @@ class Web(User, Protocol):
|
|||
if isinstance(to_proto, str):
|
||||
to_proto = PROTOCOLS[to_proto]
|
||||
|
||||
converted = translate_user_id(id=self.key.id(), from_proto=self,
|
||||
to_proto=to_proto)
|
||||
converted = translate_user_id(id=self.key.id(), from_=self,
|
||||
to=to_proto)
|
||||
|
||||
if to_proto.LABEL == 'activitypub':
|
||||
other = 'web' if self.ap_subdomain == 'fed' else 'fed'
|
||||
|
@ -316,7 +327,7 @@ class Web(User, Protocol):
|
|||
if parsed.path in ('', '/'):
|
||||
id = parsed.netloc
|
||||
|
||||
if is_valid_domain(id):
|
||||
if is_valid_domain(id, allow_internal=True):
|
||||
return super().key_for(id)
|
||||
|
||||
# logger.info(f'{id} is not a domain or usable home page URL')
|
||||
|
@ -336,14 +347,21 @@ class Web(User, Protocol):
|
|||
return True if user and user.has_redirects else None
|
||||
elif is_valid_domain(id):
|
||||
return None
|
||||
elif util.is_web(id) and is_valid_domain(util.domain_from_link(id)):
|
||||
|
||||
# we allowed internal domains for protocol bot actors above, but we
|
||||
# don't want to allow non-homepage URLs on those domains, eg
|
||||
# https://bsky.brid.gy/foo, so don't allow internal here
|
||||
domain = util.domain_from_link(id)
|
||||
if util.is_web(id) and is_valid_domain(domain, allow_internal=False):
|
||||
return None
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def owns_handle(cls, handle):
|
||||
if not is_valid_domain(handle):
|
||||
if handle == PRIMARY_DOMAIN or handle in PROTOCOL_DOMAINS:
|
||||
return True
|
||||
elif not is_valid_domain(handle, allow_internal=False):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
|
@ -396,7 +414,7 @@ class Web(User, Protocol):
|
|||
return False
|
||||
|
||||
source_id = translate_object_id(
|
||||
id=obj.key.id(), from_proto=PROTOCOLS[obj.source_protocol], to_proto=Web)
|
||||
id=obj.key.id(), from_=PROTOCOLS[obj.source_protocol], to=Web)
|
||||
source_url = quote(source_id, safe=':/%+')
|
||||
logger.info(f'Sending webmention from {source_url} to {url}')
|
||||
|
||||
|
@ -439,10 +457,11 @@ class Web(User, Protocol):
|
|||
return False
|
||||
|
||||
is_homepage = urlparse(url).path.strip('/') == ''
|
||||
|
||||
if is_homepage and util.domain_from_link(url) == common.PRIMARY_DOMAIN:
|
||||
obj.as2 = json_loads(util.read('static/instance-actor.as2.json'))
|
||||
return True
|
||||
if is_homepage:
|
||||
domain = util.domain_from_link(url)
|
||||
if domain == PRIMARY_DOMAIN or domain in PROTOCOL_DOMAINS:
|
||||
obj.as2 = json_loads(util.read(f'{domain}.as2.json'))
|
||||
return True
|
||||
|
||||
require_backlink = (common.host_url().rstrip('/')
|
||||
if check_backlink and not is_homepage
|
||||
|
@ -537,8 +556,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
|
||||
|
@ -578,7 +596,7 @@ def check_web_site():
|
|||
|
||||
# this normalizes and lower cases domain
|
||||
domain = util.domain_from_link(url, minimize=False)
|
||||
if not domain or not is_valid_domain(domain):
|
||||
if not domain or not is_valid_domain(domain, allow_internal=False):
|
||||
flash(f'{url} is not a valid or supported web site')
|
||||
return render_template('enter_web_site.html'), 400
|
||||
|
||||
|
|
|
@ -14,9 +14,10 @@ from oauth_dropins.webutil.util import json_dumps, json_loads
|
|||
|
||||
import activitypub
|
||||
import common
|
||||
from common import LOCAL_DOMAINS, SUPERDOMAIN
|
||||
from common import LOCAL_DOMAINS, PRIMARY_DOMAIN, PROTOCOL_DOMAINS, SUPERDOMAIN
|
||||
from flask_app import app, cache
|
||||
from protocol import Protocol
|
||||
from web import Web
|
||||
|
||||
SUBSCRIBE_LINK_REL = 'http://ostatus.org/schema/1.0/subscribe'
|
||||
|
||||
|
@ -58,7 +59,9 @@ class Webfinger(flask_util.XrdOrJrd):
|
|||
except ValueError:
|
||||
id = urlparse(resource).netloc or resource
|
||||
|
||||
if not cls:
|
||||
if id == PRIMARY_DOMAIN or id in PROTOCOL_DOMAINS:
|
||||
cls = Web
|
||||
elif not cls:
|
||||
cls = Protocol.for_request(fed='web')
|
||||
|
||||
if not cls:
|
||||
|
|
Ładowanie…
Reference in New Issue