Porównaj commity

...

27 Commity

Autor SHA1 Wiadomość Data
Ryan Barrett c87e69d354
bug fix for ids.web_ap_base_domain
...and test it properly instead of mocking out the constant
2024-04-22 15:15:27 -07:00
Ryan Barrett 34692abc60
handle protocol bot users in webfinger, ids.translate_handle, Web.owns_handle
for #880
2024-04-22 14:01:09 -07:00
Ryan Barrett b9551c4de7
adjust Web.owns_id to say it owns protocol bot user domains but not their pages
eg bsky.brid.gy True, https://bsky.brid.gy/ True, https://bsky.brid.gy/foo False.

also move our internal synthetic UI-initiated follow ids from https://fed.brid.gy/web/... to under the user's own domain. hopefully this won't break anything 🤞
2024-04-22 11:58:01 -07:00
Ryan Barrett ed78090d2c
expand User.ap_subdomain to allow protocol subdomains like bsky
part of setting up per-protocol bot users for #880
2024-04-22 11:12:03 -07:00
Ryan Barrett e1f9021696
AP instance actor: move AS2 JSON files, start adding per-protocol bot users 2024-04-21 16:35:17 -07:00
Ryan Barrett 18b1a33d22
testutil.setUp() noop tweak for adding Fake classes to common.*_DOMAINS 2024-04-21 16:22:33 -07:00
Ryan Barrett dcadbccb3a
start to make per-protocol bot users
bsky.brid.gy instance actor, rel-me links
2024-04-21 14:08:26 -07:00
Ryan Barrett 10023d17fd
Protocol.enable_protocol: create copy user if necessary 2024-04-21 12:18:12 -07:00
Ryan Barrett 6b597c90c3
User.get_or_create: abstract propagate and create_for across protocols 2024-04-21 11:40:13 -07:00
Ryan Barrett f357ea1698
ActivityPub: accept non-public DMs to protocol bot users
for #880
2024-04-21 08:36:03 -07:00
Ryan Barrett 1686a2ba91
opt in/out prompt: accept yes/no DMs to bot users to enable/disable protocols
for #880
2024-04-21 08:08:12 -07:00
Ryan Barrett 0c37d94191
ids.translate_* noop refactoring: from_proto => from_, to_proto => to 2024-04-20 21:03:06 -07:00
Ryan Barrett 7c34689c9f
index.yaml: remove obsolete datastore indices 2024-04-20 21:02:14 -07:00
Ryan Barrett 70da21a7f3
Protocol.receive: send accepts for bot user follows 2024-04-20 21:02:14 -07:00
Ryan Barrett 1981c8eba8
User.get_or_create: propagate obj into existing user 2024-04-19 12:53:44 -07:00
Ryan Barrett 20e061f476
Protocol.receive: extract out maybe_accept_follow method
for #880
2024-04-19 12:53:44 -07:00
Ryan Barrett 3c55d7c145
protocol: extract out enable/disable_protocol methods
for #880
2024-04-19 12:53:44 -07:00
dependabot[bot] 64a196a8c8 build(deps): bump grpcio-status from 1.62.1 to 1.62.2
Bumps [grpcio-status](https://grpc.io) from 1.62.1 to 1.62.2.

---
updated-dependencies:
- dependency-name: grpcio-status
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-19 06:05:10 -07:00
dependabot[bot] 7dbab83a17 build(deps): bump grpcio from 1.62.1 to 1.62.2
Bumps [grpcio](https://github.com/grpc/grpc) from 1.62.1 to 1.62.2.
- [Release notes](https://github.com/grpc/grpc/releases)
- [Changelog](https://github.com/grpc/grpc/blob/master/doc/grpc_release_schedule.md)
- [Commits](https://github.com/grpc/grpc/compare/v1.62.1...v1.62.2)

---
updated-dependencies:
- dependency-name: grpcio
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-19 05:33:19 -07:00
Ryan Barrett 2886ae180d
remove common.ENABLED_PROTOCOLS, use Protocol.DEFAULT_ENABLED_PROTOCOLS instead
also use is_enabled_to in user page template
2024-04-18 16:39:15 -07:00
Ryan Barrett 8bcae4c09d
Protocol.receive: following protocol user enables that protocol
for #880
2024-04-18 16:09:09 -07:00
Ryan Barrett d36885728f
Protocol.receive: blocking protocol user disables that protocol
for #880
2024-04-18 16:03:51 -07:00
Ryan Barrett 917732ad4b
demote models import in ids.py to top-level to avoid circular import 2024-04-18 07:09:52 -07:00
Ryan Barrett 5556f2756b
add cron jobs for ATProto polling posts and notifications
fixes #942
2024-04-17 20:20:50 -07:00
Ryan Barrett 8077a7f4ca
remove activitypub from ATProto.enabled_protocols, for now 2024-04-17 19:17:04 -07:00
Ryan Barrett 39a641e000
remove USER_ALLOWLIST in favor of User.enabled_protocols 2024-04-17 17:02:17 -07:00
Ryan Barrett 259b7d72dd
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
2024-04-17 16:43:10 -07:00
28 zmienionych plików z 772 dodań i 361 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

13
cron.yaml 100644
Wyświetl plik

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

Wyświetl plik

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

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

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

Wyświetl plik

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

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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) %}
&middot;
<nobr title="{{ proto.__name__ }} (bridged)">

Wyświetl plik

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

Wyświetl plik

@ -60,6 +60,7 @@ NOTE_AS = {
}
@patch('ids.COPIES_PROTOCOLS', ['atproto'])
class ATProtoTest(TestCase):
def setUp(self):

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

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

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,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',

Wyświetl plik

@ -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("""\

Wyświetl plik

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

Wyświetl plik

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

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

Wyświetl plik

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