tag with a fully qualified URL and the hashtag name
# (with leading #) as its text, Mastodon will rewrite its href to the local
# instance's search for that hashtag. If content doesn't have a link for a
# given hashtag, Mastodon won't add one, but that hashtag will still be
# indexed in search.
#
# https://docs.joinmastodon.org/spec/activitypub/#properties-used
# https://github.com/snarfed/bridgy-fed/issues/45
for tag in tags:
name = tag.get('name')
if name and tag.get('type', 'Tag') == 'Tag':
tag['type'] = 'Hashtag'
url_path = f'/hashtag/{quote_plus(name.removeprefix("#"))}'
tag.setdefault('href', urljoin(activity['id'], url_path))
if not name.startswith('#'):
tag['name'] = f'#{name}'
as2.link_tags(obj_or_activity)
activity['object'] = [
postprocess_as2(o, orig_obj=orig_obj,
wrap=wrap and type in ('Create', 'Update', 'Delete'))
for o in as1.get_objects(activity)]
if len(activity['object']) == 1:
activity['object'] = activity['object'][0]
if content := obj_or_activity.get('content'):
# language, in contentMap
# https://github.com/snarfed/bridgy-fed/issues/681
obj_or_activity.setdefault('contentMap', {'en': content})
# wrap in . some fediverse servers (eg Mastodon) have a white-space:
# pre-wrap style that applies to p inside content. this preserves
# meaningful whitespace in plain text content.
# https://github.com/snarfed/bridgy-fed/issues/990
if not content.startswith('
'):
as2.set_content(obj_or_activity, f'
{content}
')
activity.pop('content_is_html', None)
return util.trim_nulls(activity)
def postprocess_as2_actor(actor, user):
"""Prepare an AS2 actor object to be served or sent via ActivityPub.
Modifies actor in place.
Args:
actor (dict): AS2 actor object
user (models.User): current user
Returns:
actor dict
"""
if not actor:
return actor
assert isinstance(actor, dict)
assert user
url = user.web_url()
urls = [u for u in util.get_list(actor, 'url') if u and not u.startswith('acct:')]
if not urls and url:
urls = [url]
if urls:
urls[0] = redirect_wrap(urls[0])
id = actor.get('id')
user_id = user.key.id()
if not id or user.is_web_url(id) or unwrap(id) in (
user_id, user.profile_id(), f'www.{user_id}'):
id = actor['id'] = user.id_as(ActivityPub)
actor['url'] = urls[0] if len(urls) == 1 else urls
# required by ActivityPub
# https://www.w3.org/TR/activitypub/#actor-objects
actor.setdefault('inbox', id + '/inbox')
actor.setdefault('outbox', id + '/outbox')
# For web, this has to be domain for Mastodon etc interop! It seems like it
# should be the custom username from the acct: u-url in their h-card, but
# that breaks Mastodon's Webfinger discovery.
# Background:
# https://docs.joinmastodon.org/spec/activitypub/#properties-used-1
# https://docs.joinmastodon.org/spec/webfinger/#mastodons-requirements-for-webfinger
# https://github.com/snarfed/bridgy-fed/issues/302#issuecomment-1324305460
# https://github.com/snarfed/bridgy-fed/issues/77
if user.LABEL == 'web':
actor['preferredUsername'] = user.key.id()
else:
handle = user.handle_as(ActivityPub)
if handle:
actor['preferredUsername'] = handle.strip('@').split('@')[0]
# Override the label for their home page to be "Web site"
for att in util.get_list(actor, 'attachment'):
if att.get('type') == 'PropertyValue':
val = att.get('value', '')
link = util.parse_html(val).find('a')
if url and link and url.rstrip('/') in [val.rstrip('/'),
link.get('href').rstrip('/')]:
att['name'] = 'Web site'
# required by pixelfed
#
# https://github.com/snarfed/bridgy-fed/issues/1893
actor.setdefault('manuallyApprovesFollowers', False)
# https://github.com/snarfed/bridgy-fed/issues/39
actor.setdefault('summary', '')
if not actor.get('publicKey') and not isinstance(user, ActivityPub):
# underspecified, inferred from this issue and Mastodon's implementation:
# https://github.com/w3c/activitypub/issues/203#issuecomment-297553229
# https://github.com/tootsuite/mastodon/blob/bc2c263504e584e154384ecc2d804aeb1afb1ba3/app/services/activitypub/process_account_service.rb#L77
actor['publicKey'] = {
'id': f'{id}#key',
'owner': id,
'publicKeyPem': user.public_pem().decode(),
}
actor['@context'] = util.get_list(actor, '@context')
add(actor['@context'], SECURITY_CONTEXT)
# featured collection, pinned posts
if featured := actor.get('featured'):
featured.setdefault('id', id + '/featured')
return actor
def _load_user(handle_or_id, create=False):
if handle_or_id == PRIMARY_DOMAIN or handle_or_id in PROTOCOL_DOMAINS:
from web import Web
proto = Web
else:
proto = Protocol.for_request(fed='web')
if not proto:
error(f"Couldn't determine protocol", status=404)
if proto.owns_id(handle_or_id) is False:
if proto.owns_handle(handle_or_id) is False:
error(f"{handle_or_id} doesn't look like a {proto.LABEL} id or handle",
status=404)
id = proto.handle_to_id(handle_or_id)
if not id:
error(f"Couldn't resolve {handle_or_id} as a {proto.LABEL} handle",
status=404)
else:
id = handle_or_id
assert id
try:
user = proto.get_or_create(id) if create else proto.get_by_id(id)
except ValueError as e:
logging.warning(e)
user = None
if not user or not user.is_enabled(ActivityPub):
error(f'{proto.LABEL} user {id} not found', status=404)
return user
# source protocol in subdomain.
# WARNING: the user page handler in pages.py overrides this for fediverse
# addresses with leading @ character. be careful when changing this route!
@app.get(f'/ap/')
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
# backward compatibility
@app.get(f'/')
@flask_util.headers(CACHE_CONTROL_VARY_ACCEPT)
def actor(handle_or_id):
"""Serves a user's AS2 actor from the datastore."""
user = _load_user(handle_or_id, create=True)
proto = user
as2_type = common.as2_request_type()
if not as2_type:
return redirect(user.web_url(), code=302)
if proto.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
raise MovedPermanently(location=subdomain_wrap(None, f'/{handle_or_id}'))
id = user.id_as(ActivityPub)
# check that we're serving from the right subdomain
if request.host != urlparse(id).netloc:
raise MovedPermanently(location=id)
actor = ActivityPub.convert(user.obj, from_user=user) or {
'@context': as2.CONTEXT,
'type': 'Person',
}
actor = postprocess_as2_actor(actor, user=user)
actor['@context'] = util.get_list(actor, '@context')
add(actor['@context'], AKA_CONTEXT)
actor.setdefault('alsoKnownAs', [user.id_uri()])
actor.update({
'id': id,
'inbox': id + '/inbox',
'outbox': id + '/outbox',
'following': id + '/following',
'followers': id + '/followers',
'endpoints': {
'sharedInbox': urljoin(id, '/ap/sharedInbox'),
},
})
logger.debug(f'Returning: {json_dumps(actor, indent=2)}')
return actor, {
'Content-Type': as2_type,
'Access-Control-Allow-Origin': '*',
}
# note that this shared inbox path overlaps with the /ap/ actor
# route above, but doesn't collide because this is POST and that one is GET.
@app.post('/ap/sharedInbox')
# source protocol in subdomain
@app.post(f'/ap//inbox')
# source protocol in path; primarily for backcompat
@app.post(f'/ap///inbox')
# special case Web users on fed subdomain without /ap/web/ prefix
@app.post(f'//inbox')
def inbox(protocol=None, id=None):
"""Handles ActivityPub inbox delivery."""
# parse and validate AS2 activity
try:
activity = request.json
assert activity and isinstance(activity, dict)
except (TypeError, ValueError, AssertionError):
body = request.get_data(as_text=True)
error(f"Couldn't parse body as non-empty JSON mapping: {body}", exc_info=True)
# do we support this object type?
# (this logic is duplicated in Protocol.check_supported)
obj = as1.get_object(activity)
if type := activity.get('type'):
inner_type = as1.object_type(obj) or ''
if (type not in ActivityPub.SUPPORTED_AS2_TYPES or
(type in as2.CRUD_VERBS
and inner_type
and inner_type not in ActivityPub.SUPPORTED_AS2_TYPES)):
error(f"Bridgy Fed for ActivityPub doesn't support {type} {inner_type} yet: {json_dumps(activity, indent=2)}", status=204)
# check actor, authz actor's domain against activity and object ids
# https://github.com/snarfed/bridgy-fed/security/advisories/GHSA-37r7-jqmr-3472
actor = (as1.get_object(activity, 'actor')
or as1.get_object(activity, 'attributedTo'))
actor_id = actor.get('id')
if ActivityPub.is_blocklisted(actor_id):
error(f'Actor {actor_id} is blocklisted')
actor_domain = util.domain_from_link(actor_id)
# temporary, see emails w/Michael et al, and
# https://github.com/snarfed/bridgy-fed/issues/1686
if actor_domain == 'newsmast.community' and type == 'Undo':
return ':(', 204
id = activity.get('id')
obj_id = obj.get('id')
if id and actor_domain != util.domain_from_link(id):
report_error(f'Auth: actor and activity on different domains: {json_dumps(activity, indent=2)}',
user=f'actor {actor_id} activity {id}')
return f'actor {actor_id} and activity {id} on different domains', 403
elif (type in as2.CRUD_VERBS and obj_id
and actor_domain != util.domain_from_link(obj_id)):
report_error(f'Auth: actor and object on different domains {json_dumps(activity, indent=2)}',
user=f'actor {actor_id} object {obj_id}')
return f'actor {actor_id} and object {obj_id} on different domains', 403
# are we already processing or done with this activity?
if id:
domain = util.domain_from_link(id)
if memcache.memcache.get(activity_id_memcache_key(id)):
logger.info(f'Already seen {id}')
return '', 204
# check signature, auth
authed_as = ActivityPub.verify_signature(activity)
authed_domain = util.domain_from_link(authed_as)
if util.domain_or_parent_in(authed_domain, NO_AUTH_DOMAINS):
error(f"Ignoring, sorry, we don't know how to authorize {authed_domain} activities yet. https://github.com/snarfed/bridgy-fed/issues/566", status=204)
# if we need the LD Sig to authorize this activity, bail out, we don't do
# those yet
if authed_as != actor_id and activity.get('signature'):
error(f"Ignoring LD Signature, sorry, we can't verify those yet. https://github.com/snarfed/bridgy-fed/issues/566", status=202)
logger.info(f'Got {type} {id} from {actor_id}')
if type == 'Follow':
# rendered mf2 HTML proxy pages (in render.py) fall back to redirecting
# to the follow's AS2 id field, but Mastodon's Accept ids are URLs that
# don't load in browsers, eg:
# https://jawns.club/ac33c547-ca6b-4351-80d5-d11a6879a7b0
#
# so, set a synthetic URL based on the follower's profile.
# https://github.com/snarfed/bridgy-fed/issues/336
follower_url = unwrap(util.get_url(activity, 'actor'))
followee_url = unwrap(util.get_url(activity, 'object'))
activity.setdefault('url', f'{follower_url}#followed-{followee_url}')
if not id:
id = f'{actor_id}#{type}-{obj_id or ""}-{util.now().isoformat()}'
# automatically bridge server aka instance actors
# https://codeberg.org/fediverse/fep/src/branch/main/fep/d556/fep-d556.md
if as2.is_server_actor(actor):
all_protocols = [
label for label, proto in PROTOCOLS.items()
if label and proto and label not in ('ui', 'activitypub', 'ap')]
user = ActivityPub.get_or_create(actor_id, propagate=True,
enabled_protocols=all_protocols)
if user and not user.existing:
logger.info(f'Automatically enabled AP server actor {actor_id} for ')
delay = DELETE_TASK_DELAY if type in ('Delete', 'Undo') else None
return create_task(queue='receive', id=id, as2=activity,
source_protocol=ActivityPub.LABEL, authed_as=authed_as,
received_at=util.now().isoformat(), delay=delay)
# protocol in subdomain
@app.get(f'/ap//')
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
# backward compatibility
@app.route(f'//',
methods=['GET', 'HEAD'])
@flask_util.headers(CACHE_CONTROL)
def follower_collection(id, collection):
"""ActivityPub Followers and Following collections.
* https://www.w3.org/TR/activitypub/#followers
* https://www.w3.org/TR/activitypub/#collections
* https://www.w3.org/TR/activitystreams-core/#paging
TODO: unify page generation with outbox()
"""
if (request.path.startswith('/ap/')
and request.host in (PRIMARY_DOMAIN,) + LOCAL_DOMAINS):
# UI request. unfortunate that the URL paths overlap like this!
import pages
return pages.followers_or_following('ap', id, collection)
user = _load_user(id)
if request.method == 'HEAD':
return '', {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
# page
followers, new_before, new_after = Follower.fetch_page(collection, user=user)
page = {
'type': 'CollectionPage',
'partOf': request.base_url,
'items': util.trim_nulls([ActivityPub.convert(f.user.obj, from_user=f.user)
for f in followers]),
}
if new_before:
page['next'] = f'{request.base_url}?before={new_before}'
if new_after:
page['prev'] = f'{request.base_url}?after={new_after}'
if 'before' in request.args or 'after' in request.args:
page.update({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': request.url,
})
logger.debug(f'Returning {json_dumps(page, indent=2)}')
return page, {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
ret = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': request.base_url,
'type': 'Collection',
'summary': f"{id}'s {collection}",
'first': page,
}
# count total if it's small, <= 1k. we should eventually precompute this
# so that we can always return it cheaply.
prop = Follower.to if collection == 'followers' else Follower.from_
count = Follower.query(prop == user.key, Follower.status == 'active')\
.count(limit=1001)
if count != 1001:
ret['totalItems'] = count
logger.debug(f'Returning {json_dumps(collection, indent=2)}')
return ret, {
'Content-Type': as2.CONTENT_TYPE_LD_PROFILE,
}
# protocol in subdomain
@app.get(f'/ap//outbox')
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
# backward compatibility
@app.route(f'//outbox', methods=['GET', 'HEAD'])
@flask_util.headers(CACHE_CONTROL)
def outbox(id):
"""Serves a user's AP outbox.
TODO: unify page generation with follower_collection()
"""
user = _load_user(id)
if request.method == 'HEAD':
return '', {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
# TODO: bring this back once we filter it by author status, etc
# query = Object.query(Object.users == user.key)
# objects, new_before, new_after = fetch_objects(query, by=Object.updated,
# user=user)
# page = {
# 'type': 'CollectionPage',
# 'partOf': request.base_url,
# 'items': util.trim_nulls([ActivityPub.convert(obj, from_user=user)
# for obj in objects]),
# }
# if new_before:
# page['next'] = f'{request.base_url}?before={new_before}'
# if new_after:
# page['prev'] = f'{request.base_url}?after={new_after}'
# if 'before' in request.args or 'after' in request.args:
# page.update({
# '@context': 'https://www.w3.org/ns/activitystreams',
# 'id': request.url,
# })
# logger.debug(f'Returning {json_dumps(page, indent=2)}')
# return page, {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
ret = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': request.url,
'type': 'OrderedCollection',
'summary': f"{id}'s outbox",
'totalItems': 0,
# 'first': page,
'first': {
'type': 'CollectionPage',
'partOf': request.base_url,
'items': [],
},
}
# # count total if it's small, <= 1k. we should eventually precompute this
# # so that we can always return it cheaply.
# count = query.count(limit=1001)
# if count != 1001:
# ret['totalItems'] = count
return ret, {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
# protocol in subdomain
@app.get(f'/ap//featured')
def featured(id):
"""Serves a user's AP featured collection for pinned posts.
https://docs.joinmastodon.org/spec/activitypub/#featured
We inline the featured collection in users' actors, but Mastodon (and
Pleroma/Akkoma?) require it to be fetchable separately too. :(
Also, it's critical that the collection items here are expanded objects!
Originally they were compacted string ids, but that triggered a massive flood of
requests from Pleroma and Akkoma:
https://github.com/snarfed/bridgy-fed/issues/1374#issuecomment-2891993190
"""
user = _load_user(id)
items = []
if user.obj and user.obj.as1:
for obj in as1.get_objects(user.obj.as1.get('featured', {}), 'items'):
if set(obj.keys()) == {'id'}:
if obj := user.load(obj['id']):
if obj.as1:
items.append(ActivityPub.convert(obj))
elif obj:
items.append(ActivityPub.convert(Object(our_as1=obj)))
return {
'@context': as2.CONTEXT,
'type': 'OrderedCollection',
'id': request.base_url,
'totalItems': len(items),
'orderedItems': items,
}, {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
#
# OAuth
#
class MastodonStart(FlashErrors, oauth_dropins.mastodon.Start):
def app_name(self):
return 'Bridgy Fed'
def app_url(self):
return 'https://fed.brid.gy/'
class MastodonCallback(FlashErrors, oauth_dropins.mastodon.Callback):
pass
class PixelfedStart(FlashErrors, oauth_dropins.pixelfed.Start):
def app_name(self):
return 'Bridgy Fed'
def app_url(self):
return 'https://fed.brid.gy/'
class PixelfedCallback(FlashErrors, oauth_dropins.pixelfed.Callback):
pass
class ThreadsStart(FlashErrors, oauth_dropins.threads.Start):
pass
class ThreadsCallback(FlashErrors, oauth_dropins.threads.Callback):
pass
app.add_url_rule('/oauth/mastodon/start', view_func=MastodonStart.as_view(
'/oauth/mastodon/start', '/oauth/mastodon/finish'),
methods=['POST'])
app.add_url_rule('/oauth/mastodon/finish', view_func=MastodonCallback.as_view(
'/oauth/mastodon/finish', '/settings'))
app.add_url_rule('/oauth/pixelfed/start', view_func=PixelfedStart.as_view(
'/oauth/pixelfed/start', '/oauth/pixelfed/finish'),
methods=['POST'])
app.add_url_rule('/oauth/pixelfed/finish', view_func=PixelfedCallback.as_view(
'/oauth/pixelfed/finish', '/settings'))
app.add_url_rule('/oauth/threads/start', view_func=ThreadsStart.as_view(
'/oauth/threads/start', '/oauth/threads/finish'),
methods=['POST'])
app.add_url_rule('/oauth/threads/finish', view_func=ThreadsCallback.as_view(
'/oauth/threads/finish', '/settings'))