bridgy-fed/activitypub.py

635 wiersze
23 KiB
Python
Czysty Zwykły widok Historia

"""Handles requests for ActivityPub endpoints: actors, inbox, etc.
"""
from base64 import b64encode
from hashlib import sha256
import itertools
import logging
from flask import request
from granary import as1, as2
from httpsig import HeaderVerifier
from httpsig.requests_auth import HTTPSignatureAuth
from httpsig.utils import parse_signature_header
2021-08-28 14:18:46 +00:00
from oauth_dropins.webutil import flask_util, util
from oauth_dropins.webutil.util import json_dumps, json_loads
import requests
from werkzeug.exceptions import BadGateway
2021-07-10 15:07:40 +00:00
from app import app, cache
import common
from common import (
CACHE_TIME,
CONTENT_TYPE_HTML,
error,
host_url,
redirect_unwrap,
redirect_wrap,
TLD_BLOCKLIST,
)
from models import Follower, Object, Target, User
from protocol import Protocol
logger = logging.getLogger(__name__)
CONNEG_HEADERS_AS2_HTML = {
'Accept': f'{as2.CONNEG_HEADERS["Accept"]}, {CONTENT_TYPE_HTML}; q=0.7'
}
HTTP_SIG_HEADERS = ('Date', 'Host', 'Digest', '(request-target)')
_DEFAULT_SIGNATURE_USER = None
def default_signature_user():
global _DEFAULT_SIGNATURE_USER
if _DEFAULT_SIGNATURE_USER is None:
_DEFAULT_SIGNATURE_USER = User.get_or_create('snarfed.org')
return _DEFAULT_SIGNATURE_USER
class ActivityPub(Protocol):
"""ActivityPub protocol class."""
LABEL = 'activitypub'
@classmethod
def send(cls, url, activity, *, user=None, log_data=True):
"""Sends an outgoing activity.
To be implemented by subclasses.
Args:
url: str, destination URL to send to
activity: dict, AS1 activity to send
user: :class:`User` this is on behalf of
log_data: boolean, whether to log full data object
Raises:
:class:`werkzeug.HTTPException` if the request fails
"""
return signed_post(url, user=user, log_data=True, data=activity)
@classmethod
def fetch(cls, id, obj, *, user=None):
"""Tries to fetch an AS2 object and populate it into an :class:`Object`.
Uses HTTP content negotiation via the Content-Type header. If the url is
HTML and it has a rel-alternate link with an AS2 content type, fetches and
returns that URL.
Includes an HTTP Signature with the request.
https://w3c.github.io/activitypub/#authorization
https://tools.ietf.org/html/draft-cavage-http-signatures-07
https://github.com/mastodon/mastodon/pull/11269
Mastodon requires this signature if AUTHORIZED_FETCH aka secure mode is on:
https://docs.joinmastodon.org/admin/config/#authorized_fetch
Signs the request with the given user. If not provided, defaults to
using @snarfed.org@snarfed.org's key.
Args:
id: str, object's URL id
obj: :class:`Object` to populate the fetched object into
user: optional :class:`User` we're fetching on behalf of
Raises:
:class:`requests.HTTPError`, :class:`werkzeug.exceptions.HTTPException`
If we raise a werkzeug HTTPException, it will have an additional
requests_response attribute with the last requests.Response we received.
"""
def _error(resp, extra_msg=None):
msg = f"Couldn't fetch {id} as ActivityStreams 2"
if extra_msg:
msg += ': ' + extra_msg
logger.warning(msg)
err = BadGateway(msg)
err.requests_response = resp
raise err
def _get(url, headers):
"""Returns None if we fetched and populated, resp otherwise."""
resp = signed_get(url, user=user, headers=headers, gateway=True)
if not resp.content:
_error(resp, 'empty response')
elif common.content_type(resp) == as2.CONTENT_TYPE:
try:
obj.as2 = resp.json()
return
except requests.JSONDecodeError:
_error(resp, "Couldn't decode as JSON")
return resp
resp = _get(id, CONNEG_HEADERS_AS2_HTML)
if resp is None:
return
# look in HTML to find AS2 link
if common.content_type(resp) != 'text/html':
_error(resp, 'no AS2 available')
parsed = util.parse_html(resp)
link = parsed.find('link', rel=('alternate', 'self'), type=(
as2.CONTENT_TYPE, as2.CONTENT_TYPE_LD))
if not (link and link['href']):
_error(resp, 'no AS2 available')
resp = _get(link['href'], as2.CONNEG_HEADERS)
if resp is not None:
_error(resp)
@classmethod
def verify_signature(cls, user):
"""Verifies the current request's HTTP Signature.
Args:
user: :class:`User`
Logs details of the result. Raises :class:`werkzeug.HTTPSignature` if the
signature is missing or invalid, otherwise does nothing and returns None.
"""
sig = request.headers.get('Signature')
if not sig:
error('No HTTP Signature', status=401)
logger.info(f'Headers: {json_dumps(dict(request.headers), indent=2)}')
# parse_signature_header lower-cases all keys
keyId = parse_signature_header(sig).get('keyid')
if not keyId:
error('HTTP Signature missing keyId', status=401)
digest = request.headers.get('Digest') or ''
if not digest:
error('Missing Digest header, required for HTTP Signature', status=401)
expected = b64encode(sha256(request.data).digest()).decode()
if digest.removeprefix('SHA-256=') != expected:
error('Invalid Digest header, required for HTTP Signature', status=401)
key_actor = cls.get_object(keyId, user=user).as2
key = key_actor.get("publicKey", {}).get('publicKeyPem')
logger.info(f'Verifying signature for {request.path} with key {key}')
try:
verified = HeaderVerifier(request.headers, key,
required_headers=['Digest'],
method=request.method,
path=request.path,
sign_header='signature').verify()
except BaseException as e:
error(f'HTTP Signature verification failed: {e}', status=401)
if verified:
logger.info('HTTP Signature verified!')
else:
error('HTTP Signature verification failed', status=401)
@classmethod
def accept_follow(cls, obj, user):
"""Replies to an AP Follow request with an Accept request.
TODO: move to Protocol
Args:
obj: :class:`Object`
user: :class:`User`
"""
logger.info('Replying to Follow with Accept')
followee = obj.as2.get('object')
followee_id = followee.get('id') if isinstance(followee, dict) else followee
follower = obj.as2.get('actor')
if not followee or not followee_id or not follower:
error(f'Follow activity requires object and actor. Got: {follow}')
inbox = follower.get('inbox')
follower_id = follower.get('id')
if not inbox or not follower_id:
error(f'Follow actor requires id and inbox. Got: {follower}')
# rendered mf2 HTML proxy pages (in render.py) fall back to redirecting to
# the follow's AS2 id field, but Mastodon's 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 = util.get_url(follower) or follower_id
followee_url = util.get_url(followee) or followee_id
obj.as2.setdefault('url', f'{follower_url}#followed-{followee_url}')
# store Follower
follower_obj = Follower.get_or_create(
dest=user.key.id(), src=follower_id, last_follow=obj.as2)
follower_obj.status = 'active'
follower_obj.put()
# send AP Accept
followee_actor_url = common.host_url(user.key.id())
accept = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': util.tag_uri(common.PRIMARY_DOMAIN,
f'accept/{user.key.id()}/{obj.key.id()}'),
'type': 'Accept',
'actor': followee_actor_url,
'object': {
'type': 'Follow',
'actor': follower_id,
'object': followee_actor_url,
}
}
return cls.send(inbox, accept, user=user)
def signed_get(url, *, user=None, **kwargs):
return signed_request(util.requests_get, url, user=user, **kwargs)
def signed_post(url, *, user=None, **kwargs):
assert user
return signed_request(util.requests_post, url, user=user, **kwargs)
def signed_request(fn, url, *, user=None, data=None, log_data=True,
headers=None, **kwargs):
"""Wraps requests.* and adds HTTP Signature.
Args:
fn: :func:`util.requests_get` or :func:`util.requests_get`
url: str
user: optional :class:`User` to sign request with
data: optional AS2 object
log_data: boolean, whether to log full data object
kwargs: passed through to requests
Returns: :class:`requests.Response`
"""
if headers is None:
headers = {}
# prepare HTTP Signature and headers
if not user:
user = default_signature_user()
if data:
if log_data:
logger.info(f'Sending AS2 object: {json_dumps(data, indent=2)}')
data = json_dumps(data).encode()
headers = {
**headers,
# required for HTTP Signature
# https://tools.ietf.org/html/draft-cavage-http-signatures-07#section-2.1.3
'Date': util.now().strftime('%a, %d %b %Y %H:%M:%S GMT'),
# required by Mastodon
# https://github.com/tootsuite/mastodon/pull/14556#issuecomment-674077648
'Host': util.domain_from_link(url, minimize=False),
'Content-Type': as2.CONTENT_TYPE,
# required for HTTP Signature and Mastodon
'Digest': f'SHA-256={b64encode(sha256(data or b"").digest()).decode()}',
}
domain = user.key.id()
logger.info(f"Signing with {domain}'s key")
key_id = host_url(domain)
# (request-target) is a special HTTP Signatures header that some fediverse
# implementations require, eg Peertube.
# https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#section-2.3
# https://github.com/snarfed/bridgy-fed/issues/40
auth = HTTPSignatureAuth(secret=user.private_pem(), key_id=key_id,
algorithm='rsa-sha256', sign_header='signature',
headers=HTTP_SIG_HEADERS)
# make HTTP request
kwargs.setdefault('gateway', True)
resp = fn(url, data=data, auth=auth, headers=headers, allow_redirects=False,
**kwargs)
logger.info(f'Got {resp.status_code} headers: {resp.headers}')
# handle GET redirects manually so that we generate a new HTTP signature
if resp.is_redirect and fn == util.requests_get:
return signed_request(fn, resp.headers['Location'], data=data, user=user,
headers=headers, log_data=log_data, **kwargs)
type = common.content_type(resp)
if (type and type != 'text/html' and
(type.startswith('text/') or type.endswith('+json') or type.endswith('/json'))):
logger.info(resp.text)
return resp
def postprocess_as2(activity, user=None, target=None, create=True):
"""Prepare an AS2 object to be served or sent via ActivityPub.
Args:
activity: dict, AS2 object or activity
user: :class:`User`, required. populated into actor.id and
publicKey fields if needed.
target: dict, AS2 object, optional. The target of activity's inReplyTo or
Like/Announce/etc object, if any.
create: boolean, whether to wrap `Note` and `Article` objects in a
`Create` activity
"""
assert user
type = activity.get('type')
# actor objects
if type == 'Person':
postprocess_as2_actor(activity, user)
if not activity.get('publicKey'):
# 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_url = host_url(activity.get('preferredUsername'))
activity.update({
'publicKey': {
'id': actor_url,
'owner': actor_url,
'publicKeyPem': user.public_pem().decode(),
},
'@context': (util.get_list(activity, '@context') +
['https://w3id.org/security/v1']),
})
return activity
for actor in (util.get_list(activity, 'attributedTo') +
util.get_list(activity, 'actor')):
postprocess_as2_actor(actor, user)
# inReplyTo: singly valued, prefer id over url
target_id = target.get('id') if target else None
in_reply_to = activity.get('inReplyTo')
if in_reply_to:
if target_id:
activity['inReplyTo'] = target_id
elif isinstance(in_reply_to, list):
if len(in_reply_to) > 1:
logger.warning(
"AS2 doesn't support multiple inReplyTo URLs! "
f'Only using the first: {in_reply_to[0]}')
activity['inReplyTo'] = in_reply_to[0]
# Mastodon evidently requires a Mention tag for replies to generate a
# notification to the original post's author. not required for likes,
# reposts, etc. details:
# https://github.com/snarfed/bridgy-fed/issues/34
if target:
for to in (util.get_list(target, 'attributedTo') +
util.get_list(target, 'actor')):
if isinstance(to, dict):
to = util.get_first(to, 'url') or to.get('id')
if to:
activity.setdefault('tag', []).append({
'type': 'Mention',
'href': to,
})
# activity objects (for Like, Announce, etc): prefer id over url
obj = activity.get('object')
if obj:
if isinstance(obj, dict) and not obj.get('id'):
obj['id'] = target_id or util.get_first(obj, 'url')
elif target_id and obj != target_id:
activity['object'] = target_id
# id is required for most things. default to url if it's not set.
if not activity.get('id'):
activity['id'] = util.get_first(activity, 'url')
# TODO: find a better way to check this, sometimes or always?
# removed for now since it fires on posts without u-id or u-url, eg
# https://chrisbeckstrom.com/2018/12/27/32551/
# assert activity.get('id') or (isinstance(obj, dict) and obj.get('id'))
activity['id'] = redirect_wrap(activity.get('id'))
activity['url'] = [redirect_wrap(u) for u in util.get_list(activity, 'url')]
if len(activity['url']) == 1:
activity['url'] = activity['url'][0]
# copy image(s) into attachment(s). may be Mastodon-specific.
# https://github.com/snarfed/bridgy-fed/issues/33#issuecomment-440965618
obj_or_activity = obj if isinstance(obj, dict) else activity
img = util.get_list(obj_or_activity, 'image')
if img:
obj_or_activity.setdefault('attachment', []).extend(img)
# cc target's author(s) and recipients
# https://www.w3.org/TR/activitystreams-vocabulary/#audienceTargeting
# https://w3c.github.io/activitypub/#delivery
if target and (type in as2.TYPE_TO_VERB or type in ('Article', 'Note')):
recips = itertools.chain(*(util.get_list(target, field) for field in
('actor', 'attributedTo', 'to', 'cc')))
activity['cc'] = util.dedupe_urls(util.get_url(recip) or recip.get('id')
for recip in recips)
# to public, since Mastodon interprets to public as public, cc public as unlisted:
# https://socialhub.activitypub.rocks/t/visibility-to-cc-mapping/284
# https://wordsmith.social/falkreon/securing-activitypub
to = activity.setdefault('to', [])
if as2.PUBLIC_AUDIENCE not in to:
to.append(as2.PUBLIC_AUDIENCE)
# wrap articles and notes in a Create activity
if create and type in ('Article', 'Note'):
activity = {
'@context': as2.CONTEXT,
'type': 'Create',
'id': f'{activity["id"]}#bridgy-fed-create',
'actor': postprocess_as2_actor({}, user),
'object': activity,
}
return util.trim_nulls(activity)
def postprocess_as2_actor(actor, user=None):
"""Prepare an AS2 actor object to be served or sent via ActivityPub.
Modifies actor in place.
Args:
actor: dict, AS2 actor object
user: :class:`User`
Returns:
actor dict
"""
url = user.homepage if user else None
urls = util.get_list(actor, 'url')
if not urls and url:
urls = [url]
domain = util.domain_from_link(urls[0], minimize=False)
urls[0] = redirect_wrap(urls[0])
actor.setdefault('id', host_url(domain))
actor.update({
'url': urls if len(urls) > 1 else urls[0],
# This has to be the domain for Mastodon interop/Webfinger discovery!
# See related comment in actor() below.
'preferredUsername': domain,
})
# 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 (val == url or link.get('href') == url):
att['name'] = 'Web site'
# required by pixelfed. https://github.com/snarfed/bridgy-fed/issues/39
actor.setdefault('summary', '')
return actor
@app.get(f'/<regex("{common.DOMAIN_RE}"):domain>')
@flask_util.cached(cache, CACHE_TIME)
2021-07-10 15:07:40 +00:00
def actor(domain):
"""Serves a user's AS2 actor from the datastore."""
tld = domain.split('.')[-1]
if tld in TLD_BLOCKLIST:
error('', status=404)
user = User.get_by_id(domain)
if not user:
return f'User {domain} not found', 404
elif not user.actor_as2:
return f'User {domain} not fully set up', 404
# TODO: unify with common.actor()
actor = {
**postprocess_as2(user.actor_as2, user=user),
'id': host_url(domain),
# This has to be the 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://github.com/snarfed/bridgy-fed/issues/302#issuecomment-1324305460
# https://github.com/snarfed/bridgy-fed/issues/77
'preferredUsername': domain,
'inbox': host_url(f'{domain}/inbox'),
'outbox': host_url(f'{domain}/outbox'),
'following': host_url(f'{domain}/following'),
'followers': host_url(f'{domain}/followers'),
'endpoints': {
'sharedInbox': host_url('inbox'),
},
}
logger.info(f'Returning: {json_dumps(actor, indent=2)}')
return actor, {
'Content-Type': as2.CONTENT_TYPE,
2021-07-10 15:07:40 +00:00
'Access-Control-Allow-Origin': '*',
}
2021-07-10 15:07:40 +00:00
@app.post('/inbox')
@app.post(f'/<regex("{common.DOMAIN_RE}"):domain>/inbox')
def inbox(domain=None):
"""Handles ActivityPub inbox delivery."""
2021-07-10 15:07:40 +00:00
body = request.get_data(as_text=True)
# parse and validate AS2 activity
try:
activity = request.json
assert activity and isinstance(activity, dict)
2021-07-10 15:07:40 +00:00
except (TypeError, ValueError, AssertionError):
error(f"Couldn't parse body as non-empty JSON mapping: {body}", exc_info=True)
2021-07-10 15:07:40 +00:00
actor_id = as1.get_object(activity, 'actor').get('id')
logger.info(f'Got {activity.get("type")} activity from {actor_id}: {json_dumps(activity, indent=2)}')
2021-07-10 15:07:40 +00:00
# load user
# TODO: store in g instead of passing around
user = None
if domain:
user = User.get_by_id(domain)
if not user:
error(f'User {domain} not found', status=404)
ActivityPub.verify_signature(user)
# 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.
if activity.get('type') == 'Create' and not as2.is_public(activity):
logger.info('Dropping non-public activity')
return 'OK'
return ActivityPub.receive(activity.get('id'), user=user,
as2=redirect_unwrap(activity))
2021-07-10 15:07:40 +00:00
@app.get(f'/<regex("{common.DOMAIN_RE}"):domain>/<any(followers,following):collection>')
@flask_util.cached(cache, CACHE_TIME)
def follower_collection(domain, 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
"""
if not User.get_by_id(domain):
return f'User {domain} not found', 404
# page
followers, new_before, new_after = Follower.fetch_page(domain, collection)
items = []
for f in followers:
f_as2 = f.to_as2()
if f_as2:
items.append(f_as2)
page = {
'type': 'CollectionPage',
'partOf': request.base_url,
'items': items,
}
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.info(f'Returning {json_dumps(page, indent=2)}')
return page, {'Content-Type': as2.CONTENT_TYPE}
# collection
domain_prop = Follower.dest if collection == 'followers' else Follower.src
count = Follower.query(
Follower.status == 'active',
domain_prop == domain,
).count()
collection = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': request.base_url,
'type': 'Collection',
'summary': f"{domain}'s {collection}",
'totalItems': count,
'first': page,
}
logger.info(f'Returning {json_dumps(collection, indent=2)}')
return collection, {'Content-Type': as2.CONTENT_TYPE}
2023-01-25 21:12:24 +00:00
@app.get(f'/<regex("{common.DOMAIN_RE}"):domain>/outbox')
def outbox(domain):
url = common.host_url(f"{domain}/outbox")
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': url,
'summary': f"{domain}'s outbox",
'type': 'OrderedCollection',
'totalItems': 0,
'first': {
'type': 'CollectionPage',
'partOf': url,
'items': [],
},
}, {'Content-Type': as2.CONTENT_TYPE}