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'):
# 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('
'):
content = obj_or_activity['content'] = f'
{content}
'
obj_or_activity['content_is_html'] = True
# language, in contentMap
# https://github.com/snarfed/bridgy-fed/issues/681
obj_or_activity.setdefault('contentMap', {'en': content})
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 url.rstrip('/') in [val.rstrip('/'),
link.get('href').rstrip('/')]:
att['name'] = 'Web site'
# required by pixelfed. 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)
return actor
# 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/')
# source protocol in path; primarily for backcompat
@app.get(f'/ap/web/')
# special case Web users without /ap/web/ prefix, for backward compatibility
@app.get(f'/')
@flask_util.headers(CACHE_CONTROL)
def actor(handle_or_id):
"""Serves a user's AS2 actor from the datastore."""
if handle_or_id == PRIMARY_DOMAIN or handle_or_id in PROTOCOL_DOMAINS:
from web import Web
cls = Web
else:
cls = Protocol.for_request(fed='web')
if not cls:
error(f"Couldn't determine protocol", status=404)
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
return redirect(subdomain_wrap(None, f'/{handle_or_id}'), code=301)
if cls.owns_id(handle_or_id) is False:
if cls.owns_handle(handle_or_id) is False:
error(f"{handle_or_id} doesn't look like a {cls.LABEL} id or handle",
status=404)
id = cls.handle_to_id(handle_or_id)
if not id:
error(f"Couldn't resolve {handle_or_id} as a {cls.LABEL} handle",
status=404)
else:
id = handle_or_id
assert id
user = cls.get_or_create(id)
if not user or not user.is_enabled(ActivityPub):
error(f'{cls.LABEL} user {id} not found', status=404)
id = user.id_as(ActivityPub)
# check that we're serving from the right subdomain
if request.host != urlparse(id).netloc:
return redirect(id)
if not user.obj or not user.obj.as1:
user.obj = cls.load(user.profile_id(), gateway=True)
if user.obj:
user.obj.put()
actor = ActivityPub.convert(user.obj, from_user=user) or {
'@context': [as2.CONTEXT],
'type': 'Person',
}
actor = postprocess_as2_actor(actor, user=user)
actor.update({
'id': id,
'inbox': id + '/inbox',
'outbox': id + '/outbox',
'following': id + '/following',
'followers': id + '/followers',
'endpoints': {
'sharedInbox': subdomain_wrap(cls, '/ap/sharedInbox'),
},
# add this if we ever change the Web actor ids to be /web/[id]
# 'alsoKnownAs': [host_url(id)],
})
logger.debug(f'Returning: {json_dumps(actor, indent=2)}')
return actor, {
'Content-Type': as2.CONTENT_TYPE_LD_PROFILE,
'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 without /ap/web/ prefix, for backward compatibility
@app.post('/inbox')
@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)
if type := activity.get('type'):
inner_type = as1.object_type(as1.get_object(activity)) 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)
# are we already processing or done with this activity?
id = activity.get('id')
if id:
if util.domain_or_parent_in(util.domain_from_link(id), web_opt_out_domains()):
logger.info(f'Discarding, {id} is on an opted out domain')
return '', 204
if memcache.get(activity_id_memcache_key(id)):
logger.info(f'Already seen this activity {id}')
return '', 204
# check actor, signature, auth
actor = as1.get_object(activity, 'actor')
actor_id = actor.get('id')
logger.info(f'Got {type} {id} from {actor_id}')
if ActivityPub.is_blocklisted(actor_id):
error(f'Actor {actor_id} is blocklisted')
elif util.domain_or_parent_in(util.domain_from_link(actor_id),
web_opt_out_domains()):
logger.info(f'Discarding, actor {actor_id} is on an opted out domain')
return '', 204
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)
# check that this activity is public. only do this for creates, not likes,
# 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.
# 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_AP_IDS)):
logger.info('Dropping non-public activity')
return 'OK'
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}-{object.get("id", "")}-{util.now().isoformat()}'
try:
obj = Object.get_or_create(id=id, as2=unwrap(activity), authed_as=authed_as,
source_protocol=ActivityPub.LABEL)
except AssertionError as e:
error(f'Invalid activity, probably due to id: {e}', status=400)
# 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')]
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 {user.enabled_protocols}')
return create_task(queue='receive', obj=obj.key.urlsafe(), authed_as=authed_as)
# protocol in subdomain
@app.get(f'/ap//')
# source protocol in path; primarily for backcompat
@app.get(f'/ap/web//')
# special case Web users 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)
protocol = Protocol.for_request(fed='web')
assert protocol
user = protocol.get_by_id(id)
if not user or not user.is_enabled(ActivityPub):
return f'{protocol} user {id} not found', 404
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')
# source protocol in path; primarily for backcompat
@app.get(f'/ap/web//outbox')
# special case Web users 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()
"""
protocol = Protocol.for_request(fed='web')
if not protocol:
error(f"Couldn't determine protocol", status=404)
user = protocol.get_by_id(id)
if not user or not user.is_enabled(ActivityPub):
error(f'User {id} not found', status=404)
if request.method == 'HEAD':
return '', {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
query = Object.query(Object.users == user.key)
objects, new_before, new_after = fetch_objects(query, by=Object.updated,
user=user)
# page
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",
'first': page,
}
# 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}