bridgy-fed/activitypub.py

910 wiersze
34 KiB
Python
Czysty Zwykły widok Historia

"""ActivityPub protocol implementation."""
from base64 import b64encode
from hashlib import sha256
import itertools
import logging
2023-09-23 20:53:17 +00:00
import re
from urllib.parse import quote_plus, urljoin
from flask import abort, g, request
from google.cloud import ndb
from google.cloud.ndb.query import OR
from granary import as1, as2
from httpsig import HeaderVerifier
from httpsig.requests_auth import HTTPSignatureAuth
from httpsig.utils import parse_signature_header
from oauth_dropins.webutil import appengine_info, flask_util, util
from oauth_dropins.webutil.util import fragmentless, json_dumps, json_loads
import requests
from werkzeug.exceptions import BadGateway
from flask_app import app, cache
import common
from common import (
add,
CACHE_TIME,
CONTENT_TYPE_HTML,
2023-09-23 20:53:17 +00:00
DOMAIN_RE,
error,
host_url,
redirect_unwrap,
redirect_wrap,
)
noop, lint fixes from flake8 remaining: $ flake8 --extend-ignore=E501 *.py tests/*.py "pyflakes" failed during execution due to "'FlakesChecker' object has no attribute 'NAMEDEXPR'" Run flake8 with greater verbosity to see more details activitypub.py:15:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused activitypub.py:36:1: F401 'web' imported but unused activitypub.py:48:1: E302 expected 2 blank lines, found 1 activitypub.py:51:9: F811 redefinition of unused 'web' from line 36 app.py:6:1: F401 'flask_app.app' imported but unused app.py:9:1: F401 'activitypub' imported but unused app.py:9:1: F401 'convert' imported but unused app.py:9:1: F401 'follow' imported but unused app.py:9:1: F401 'pages' imported but unused app.py:9:1: F401 'redirect' imported but unused app.py:9:1: F401 'superfeedr' imported but unused app.py:9:1: F401 'ui' imported but unused app.py:9:1: F401 'webfinger' imported but unused app.py:9:1: F401 'web' imported but unused app.py:9:1: F401 'xrpc_actor' imported but unused app.py:9:1: F401 'xrpc_feed' imported but unused app.py:9:1: F401 'xrpc_graph' imported but unused app.py:9:19: E401 multiple imports on one line models.py:19:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused models.py:364:31: E114 indentation is not a multiple of four (comment) models.py:364:31: E116 unexpected indentation (comment) protocol.py:17:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused redirect.py:26:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused web.py:18:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused webfinger.py:13:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused webfinger.py:110:13: E122 continuation line missing indentation or outdented webfinger.py:111:13: E122 continuation line missing indentation or outdented webfinger.py:131:13: E122 continuation line missing indentation or outdented webfinger.py:132:13: E122 continuation line missing indentation or outdented webfinger.py:133:13: E122 continuation line missing indentation or outdented webfinger.py:134:13: E122 continuation line missing indentation or outdented tests/__init__.py:2:1: F401 'oauth_dropins.webutil.tests' imported but unused tests/test_follow.py:11:1: F401 'oauth_dropins.webutil.util.json_dumps' imported but unused tests/test_follow.py:14:1: F401 '.testutil.Fake' imported but unused tests/test_models.py:156:15: E122 continuation line missing indentation or outdented tests/test_models.py:157:15: E122 continuation line missing indentation or outdented tests/test_models.py:158:11: E122 continuation line missing indentation or outdented tests/test_web.py:12:1: F401 'oauth_dropins.webutil.util.json_dumps' imported but unused tests/test_web.py:17:1: F401 '.testutil' imported but unused tests/test_web.py:1513:13: E128 continuation line under-indented for visual indent tests/test_web.py:1514:9: E124 closing bracket does not match visual indentation tests/testutil.py:106:1: E402 module level import not at top of file tests/testutil.py:107:1: E402 module level import not at top of file tests/testutil.py:108:1: E402 module level import not at top of file tests/testutil.py:109:1: E402 module level import not at top of file tests/testutil.py:110:1: E402 module level import not at top of file tests/testutil.py:301:24: E203 whitespace before ':' tests/testutil.py:301:25: E701 multiple statements on one line (colon) tests/testutil.py:301:25: E231 missing whitespace after ':'
2023-06-20 18:22:54 +00:00
from models import Follower, Object, PROTOCOLS, User
from protocol import Protocol
import webfinger
# TODO: remove this. we only need it to make sure Web is registered in PROTOCOLS
# before the URL route registrations below.
import web
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:
import web
_DEFAULT_SIGNATURE_USER = web.Web.get_or_create('snarfed.org')
return _DEFAULT_SIGNATURE_USER
class ActivityPub(User, Protocol):
"""ActivityPub protocol class.
Key id is AP/AS2 actor id URL. (*Not* fediverse/WebFinger @-@ handle!)
"""
ABBREV = 'ap'
def _pre_put_hook(self):
"""Validate id, require URL, don't allow Bridgy Fed domains.
TODO: normalize scheme and domain to lower case. Add that to
:class:`util.UrlCanonicalizer`?
"""
super()._pre_put_hook()
id = self.key.id()
assert id
assert util.is_web(id), f'{id} is not a URL'
domain = util.domain_from_link(id)
assert domain, 'missing domain'
assert not self.is_blocklisted(domain), f'{id} is a blocked domain'
def web_url(self):
"""Returns this user's web URL aka web_url, eg 'https://foo.com/'."""
if self.obj and self.obj.as1:
url = util.get_url(self.obj.as1)
if url:
return url
return self.ap_actor()
@ndb.ComputedProperty
def handle(self):
2023-09-25 17:27:08 +00:00
"""Returns this user's ActivityPub address, eg ``@user@foo.com``."""
if self.obj and self.obj.as1:
addr = as2.address(self.as2())
if addr:
return addr
return as2.address(self.key.id())
def ap_address(self):
return self.handle
2023-09-25 17:27:08 +00:00
def ap_actor(self, rest=None):
"""Returns this user's actor id URL, eg ``https://foo.com/@user``."""
url = self.key.id()
if rest:
url += f'/{rest.lstrip("/")}'
return url
@classmethod
def owns_id(cls, id):
"""Returns None if id is an http(s) URL, False otherwise.
All AP ids are http(s) URLs, but not all http(s) URLs are AP ids.
https://www.w3.org/TR/activitypub/#obj-id
"""
if util.is_web(id) and not cls.is_blocklisted(id):
return None
return False
@classmethod
def owns_handle(cls, handle):
"""Returns True if handle is a WebFinger @-@, False otherwise.
Example: ``@user@instance.com``. The leading ``@`` is optional.
https://datatracker.ietf.org/doc/html/rfc7033#section-3.1
https://datatracker.ietf.org/doc/html/rfc7033#section-4.5
"""
parts = handle.lstrip('@').split('@')
return len(parts) == 2 and parts[0] and parts[1]
@classmethod
def handle_to_id(cls, handle):
"""Looks in the datastore first, then queries WebFinger."""
assert cls.owns_handle(handle)
if not handle.startswith('@'):
handle = '@' + handle
user = ActivityPub.query(OR(ActivityPub.handle == handle,
ActivityPub.readable_id == handle),
).get()
if user:
return user.key.id()
return webfinger.fetch_actor_url(handle)
2023-06-16 20:16:17 +00:00
@classmethod
def target_for(cls, obj, shared=False):
"""Returns `obj`'s or its author's/actor's inbox, if available."""
# TODO: we have entities in prod that fail this, eg
# https://indieweb.social/users/bismark has source_protocol webmention
# assert obj.source_protocol in (cls.LABEL, cls.ABBREV, 'ui', None), str(obj)
if not obj.as1:
return None
2023-06-16 20:16:17 +00:00
if obj.type not in as1.ACTOR_TYPES:
for field in 'actor', 'author', 'attributedTo':
inner_obj = as1.get_object(obj.as1, field)
inner_id = inner_obj.get('id') or as1.get_url(inner_obj)
if (not inner_id
or inner_id == obj.as1.get('id')
or (obj.key and inner_id == obj.key.id())):
continue
# TODO: need a "soft" kwarg for load to suppress errors?
actor = cls.load(inner_id)
if actor and actor.as1:
target = cls.target_for(actor)
if target:
logger.info(f'Target for {obj.key} via {inner_id} is {target}')
return target
logger.info(f'{obj.key} type {obj.type} is not an actor and has no author or actor with inbox')
2023-06-16 20:16:17 +00:00
2023-06-16 21:09:28 +00:00
actor = obj.as_as2()
2023-06-16 20:16:17 +00:00
if shared:
shared_inbox = actor.get('endpoints', {}).get('sharedInbox')
if shared_inbox:
return shared_inbox
return actor.get('publicInbox') or actor.get('inbox')
2023-06-16 20:16:17 +00:00
@classmethod
def send(to_cls, obj, url, log_data=True):
"""Delivers an activity to an inbox URL.
If `obj.recipient_obj` is set, it's interpreted as the receiving actor
who we're delivering to and its id is populated into `cc`.
"""
if to_cls.is_blocklisted(url):
logger.info(f'Skipping sending to {url}')
return False
# this is set in web.webmention_task()
orig_obj = getattr(obj, 'orig_obj', None)
orig_as2 = orig_obj.as_as2() if orig_obj else None
activity = obj.as2 or postprocess_as2(as2.from_as1(obj.as1),
orig_obj=orig_as2)
if g.user:
activity['actor'] = g.user.ap_actor()
elif not activity.get('actor'):
logger.warning('Outgoing AP activity has no actor!')
return signed_post(url, log_data=True, data=activity).ok
@classmethod
def fetch(cls, obj, **kwargs):
"""Tries to fetch an AS2 object.
Assumes obj.id is a URL. Any fragment at the end is stripped before
loading. This is currently underspecified and somewhat inconsistent
across AP implementations:
https://socialhub.activitypub.rocks/t/problems-posting-to-mastodon-inbox/801/11
https://socialhub.activitypub.rocks/t/problems-posting-to-mastodon-inbox/801/23
https://socialhub.activitypub.rocks/t/s2s-create-activity/1647/5
https://github.com/mastodon/mastodon/issues/13879 (open!)
https://github.com/w3c/activitypub/issues/224
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 current user's key. If not provided, defaults to
using @snarfed.org@snarfed.org's key.
See :meth:`Protocol.fetch` for more details.
Args:
obj: :class:`Object` with the id to fetch. Fills data into the as2
property.
kwargs: ignored
Returns:
True if the object was fetched and populated successfully,
False otherwise
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.
"""
url = obj.key.id()
if not util.is_web(url):
logger.info(f'{url} is not a URL')
return False
resp = None
def _error(extra_msg=None):
msg = f"Couldn't fetch {url} as ActivityStreams 2"
if extra_msg:
msg += ': ' + extra_msg
logger.warning(msg)
# protocol.for_id depends on us raising this when an AP network
# fetch fails. if we change that, update for_id too!
err = BadGateway(msg)
err.requests_response = resp
raise err
def _get(url, headers):
"""Returns None if we fetched and populated, resp otherwise."""
nonlocal resp
try:
resp = signed_get(url, headers=headers, gateway=True)
except BadGateway as e:
# ugh, this is ugly, should be something structured
if '406 Client Error' in str(e):
return
raise
if not resp.content:
_error('empty response')
elif common.content_type(resp) in as2.CONTENT_TYPES:
try:
return resp.json()
except requests.JSONDecodeError:
_error("Couldn't decode as JSON")
obj.as2 = _get(url, CONNEG_HEADERS_AS2_HTML)
if obj.as2:
return True
elif not resp:
return False
# look in HTML to find AS2 link
if common.content_type(resp) != 'text/html':
logger.info('no AS2 available')
return False
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']):
logger.info('no AS2 available')
return False
obj.as2 = _get(link['href'], as2.CONNEG_HEADERS)
if obj.as2:
return True
return False
@classmethod
def serve(cls, obj):
"""Serves an :class:`Object` as AS2."""
return (postprocess_as2(as2.from_as1(obj.as1)),
{'Content-Type': as2.CONTENT_TYPE})
@classmethod
def verify_signature(cls, activity):
"""Verifies the current request's HTTP Signature.
Args:
activity: dict, AS2 activity
Logs details of the result. Raises :class:`werkzeug.HTTPError` if the
signature is missing or invalid, otherwise does nothing and returns None.
"""
headers = dict(request.headers) # copy so we can modify below
sig = headers.get('Signature')
if not sig:
if appengine_info.DEBUG:
logging.info('No HTTP Signature, allowing due to DEBUG=true')
return
error('No HTTP Signature', status=401)
logger.info('Verifying HTTP Signature')
logger.info(f'Headers: {json_dumps(headers, indent=2)}')
# parse_signature_header lower-cases all keys
sig_fields = parse_signature_header(sig)
keyId = fragmentless(sig_fields.get('keyid'))
if not keyId:
error('HTTP Signature missing keyId', status=401)
# TODO: right now, assume hs2019 is rsa-sha256. the real answer is...
# ...complicated and unclear. 🤷
# https://github.com/snarfed/bridgy-fed/issues/430#issuecomment-1510462267
# https://arewehs2019yet.vpzom.click/
# https://socialhub.activitypub.rocks/t/state-of-http-signatures/754/23
# https://socialhub.activitypub.rocks/t/http-signatures-libraray/2087/2
# https://github.com/mastodon/mastodon/pull/14556
if sig_fields.get('algorithm') == 'hs2019':
headers['Signature'] = headers['Signature'].replace(
'algorithm="hs2019"', 'algorithm=rsa-sha256')
digest = 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)
try:
key_actor = cls.load(keyId)
except BadGateway:
obj_id = as1.get_object(activity).get('id')
noop, lint fixes from flake8 remaining: $ flake8 --extend-ignore=E501 *.py tests/*.py "pyflakes" failed during execution due to "'FlakesChecker' object has no attribute 'NAMEDEXPR'" Run flake8 with greater verbosity to see more details activitypub.py:15:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused activitypub.py:36:1: F401 'web' imported but unused activitypub.py:48:1: E302 expected 2 blank lines, found 1 activitypub.py:51:9: F811 redefinition of unused 'web' from line 36 app.py:6:1: F401 'flask_app.app' imported but unused app.py:9:1: F401 'activitypub' imported but unused app.py:9:1: F401 'convert' imported but unused app.py:9:1: F401 'follow' imported but unused app.py:9:1: F401 'pages' imported but unused app.py:9:1: F401 'redirect' imported but unused app.py:9:1: F401 'superfeedr' imported but unused app.py:9:1: F401 'ui' imported but unused app.py:9:1: F401 'webfinger' imported but unused app.py:9:1: F401 'web' imported but unused app.py:9:1: F401 'xrpc_actor' imported but unused app.py:9:1: F401 'xrpc_feed' imported but unused app.py:9:1: F401 'xrpc_graph' imported but unused app.py:9:19: E401 multiple imports on one line models.py:19:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused models.py:364:31: E114 indentation is not a multiple of four (comment) models.py:364:31: E116 unexpected indentation (comment) protocol.py:17:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused redirect.py:26:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused web.py:18:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused webfinger.py:13:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused webfinger.py:110:13: E122 continuation line missing indentation or outdented webfinger.py:111:13: E122 continuation line missing indentation or outdented webfinger.py:131:13: E122 continuation line missing indentation or outdented webfinger.py:132:13: E122 continuation line missing indentation or outdented webfinger.py:133:13: E122 continuation line missing indentation or outdented webfinger.py:134:13: E122 continuation line missing indentation or outdented tests/__init__.py:2:1: F401 'oauth_dropins.webutil.tests' imported but unused tests/test_follow.py:11:1: F401 'oauth_dropins.webutil.util.json_dumps' imported but unused tests/test_follow.py:14:1: F401 '.testutil.Fake' imported but unused tests/test_models.py:156:15: E122 continuation line missing indentation or outdented tests/test_models.py:157:15: E122 continuation line missing indentation or outdented tests/test_models.py:158:11: E122 continuation line missing indentation or outdented tests/test_web.py:12:1: F401 'oauth_dropins.webutil.util.json_dumps' imported but unused tests/test_web.py:17:1: F401 '.testutil' imported but unused tests/test_web.py:1513:13: E128 continuation line under-indented for visual indent tests/test_web.py:1514:9: E124 closing bracket does not match visual indentation tests/testutil.py:106:1: E402 module level import not at top of file tests/testutil.py:107:1: E402 module level import not at top of file tests/testutil.py:108:1: E402 module level import not at top of file tests/testutil.py:109:1: E402 module level import not at top of file tests/testutil.py:110:1: E402 module level import not at top of file tests/testutil.py:301:24: E203 whitespace before ':' tests/testutil.py:301:25: E701 multiple statements on one line (colon) tests/testutil.py:301:25: E231 missing whitespace after ':'
2023-06-20 18:22:54 +00:00
if (activity.get('type') == 'Delete' and obj_id
and keyId == fragmentless(obj_id)):
logger.info('Object/actor being deleted is also keyId')
key_actor = Object(id=keyId, source_protocol='activitypub', deleted=True)
key_actor.put()
else:
raise
if key_actor and key_actor.deleted:
abort(202, f'Ignoring, signer {keyId} is already deleted')
elif not key_actor or not key_actor.as1:
error(f"Couldn't load {keyId} to verify signature", status=401)
key = key_actor.as_as2().get('publicKey', {}).get('publicKeyPem')
if not key:
error(f'No public key for {keyId}', status=401)
logger.info(f'Verifying signature for {request.path} with key {key}')
try:
verified = HeaderVerifier(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)
def signed_get(url, **kwargs):
return signed_request(util.requests_get, url, **kwargs)
def signed_post(url, **kwargs):
assert g.user
return signed_request(util.requests_post, url, **kwargs)
def signed_request(fn, url, data=None, log_data=True, headers=None, **kwargs):
"""Wraps requests.* and adds HTTP Signature.
If the current session has a user (ie in g.user), signs with that user's
key. Otherwise, uses the default user snarfed.org.
Args:
fn: :func:`util.requests_get` or :func:`util.requests_get`
url: str
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
user = g.user or 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()}',
}
logger.info(f"Signing with {user.key}'s key")
# (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://www.w3.org/wiki/SocialCG/ActivityPub/Authentication_Authorization#Signing_requests_using_HTTP_Signatures
# https://docs.joinmastodon.org/spec/security/#http
key_id = f'{user.ap_actor()}#key'
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:
new_url = urljoin(url, resp.headers['Location'])
return signed_request(fn, new_url, data=data, headers=headers,
log_data=log_data, **kwargs)
type = common.content_type(resp)
if (type and type != 'text/html' and
noop, lint fixes from flake8 remaining: $ flake8 --extend-ignore=E501 *.py tests/*.py "pyflakes" failed during execution due to "'FlakesChecker' object has no attribute 'NAMEDEXPR'" Run flake8 with greater verbosity to see more details activitypub.py:15:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused activitypub.py:36:1: F401 'web' imported but unused activitypub.py:48:1: E302 expected 2 blank lines, found 1 activitypub.py:51:9: F811 redefinition of unused 'web' from line 36 app.py:6:1: F401 'flask_app.app' imported but unused app.py:9:1: F401 'activitypub' imported but unused app.py:9:1: F401 'convert' imported but unused app.py:9:1: F401 'follow' imported but unused app.py:9:1: F401 'pages' imported but unused app.py:9:1: F401 'redirect' imported but unused app.py:9:1: F401 'superfeedr' imported but unused app.py:9:1: F401 'ui' imported but unused app.py:9:1: F401 'webfinger' imported but unused app.py:9:1: F401 'web' imported but unused app.py:9:1: F401 'xrpc_actor' imported but unused app.py:9:1: F401 'xrpc_feed' imported but unused app.py:9:1: F401 'xrpc_graph' imported but unused app.py:9:19: E401 multiple imports on one line models.py:19:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused models.py:364:31: E114 indentation is not a multiple of four (comment) models.py:364:31: E116 unexpected indentation (comment) protocol.py:17:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused redirect.py:26:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused web.py:18:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused webfinger.py:13:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused webfinger.py:110:13: E122 continuation line missing indentation or outdented webfinger.py:111:13: E122 continuation line missing indentation or outdented webfinger.py:131:13: E122 continuation line missing indentation or outdented webfinger.py:132:13: E122 continuation line missing indentation or outdented webfinger.py:133:13: E122 continuation line missing indentation or outdented webfinger.py:134:13: E122 continuation line missing indentation or outdented tests/__init__.py:2:1: F401 'oauth_dropins.webutil.tests' imported but unused tests/test_follow.py:11:1: F401 'oauth_dropins.webutil.util.json_dumps' imported but unused tests/test_follow.py:14:1: F401 '.testutil.Fake' imported but unused tests/test_models.py:156:15: E122 continuation line missing indentation or outdented tests/test_models.py:157:15: E122 continuation line missing indentation or outdented tests/test_models.py:158:11: E122 continuation line missing indentation or outdented tests/test_web.py:12:1: F401 'oauth_dropins.webutil.util.json_dumps' imported but unused tests/test_web.py:17:1: F401 '.testutil' imported but unused tests/test_web.py:1513:13: E128 continuation line under-indented for visual indent tests/test_web.py:1514:9: E124 closing bracket does not match visual indentation tests/testutil.py:106:1: E402 module level import not at top of file tests/testutil.py:107:1: E402 module level import not at top of file tests/testutil.py:108:1: E402 module level import not at top of file tests/testutil.py:109:1: E402 module level import not at top of file tests/testutil.py:110:1: E402 module level import not at top of file tests/testutil.py:301:24: E203 whitespace before ':' tests/testutil.py:301:25: E701 multiple statements on one line (colon) tests/testutil.py:301:25: E231 missing whitespace after ':'
2023-06-20 18:22:54 +00:00
(type.startswith('text/') or type.endswith('+json')
or type.endswith('/json'))):
logger.info(resp.text)
return resp
def postprocess_as2(activity, orig_obj=None, wrap=True):
"""Prepare an AS2 object to be served or sent via ActivityPub.
g.user is required. Populates it into the actor.id and publicKey fields.
Args:
activity: dict, AS2 object or activity
orig_obj: dict, AS2 object, optional. The target of activity's inReplyTo or
Like/Announce/etc object, if any.
wrap: boolean, whether to wrap id, url, object, actor, and attributedTo
"""
if not activity or isinstance(activity, str):
return activity
assert g.user
type = activity.get('type')
# actor objects
if type == 'Person':
postprocess_as2_actor(activity)
if g.user and 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': f'{actor_url}#key',
'owner': actor_url,
'publicKeyPem': g.user.public_pem().decode(),
},
'@context': (util.get_list(activity, '@context') +
['https://w3id.org/security/v1']),
})
return activity
if wrap:
for field in 'actor', 'attributedTo':
activity[field] = [postprocess_as2_actor(actor, wrap=wrap)
for actor in util.get_list(activity, field)]
if len(activity[field]) == 1:
activity[field] = activity[field][0]
# inReplyTo: singly valued, prefer id over url
# TODO: ignore orig_obj, do for all inReplyTo
orig_id = orig_obj.get('id') if orig_obj else None
in_reply_to = activity.get('inReplyTo')
if in_reply_to:
if orig_id:
activity['inReplyTo'] = orig_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 orig_obj:
for to in (util.get_list(orig_obj, 'attributedTo') +
util.get_list(orig_obj, 'author') +
util.get_list(orig_obj, '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 = as1.get_object(activity)
id = obj.get('id')
if orig_id and type in as2.TYPES_WITH_OBJECT:
# inline most objects as bare string ids, not composite objects, for interop
activity['object'] = orig_id
elif not id:
obj['id'] = util.get_first(obj, 'url') or orig_id
elif g.user and g.user.is_web_url(id):
obj['id'] = g.user.ap_actor()
# for Accepts
2023-06-16 21:27:46 +00:00
if g.user and g.user.is_web_url(as1.get_object(obj).get('id')):
obj['object'] = g.user.ap_actor()
# 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')
if wrap:
# Deletes' object is our own id
if type == 'Delete':
activity['object'] = redirect_wrap(activity['object'])
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]
2023-04-17 22:36:29 +00:00
# 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'))
# 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 obj.keys() > set(['id']) else activity
imgs = util.get_list(obj_or_activity, 'image')
atts = obj_or_activity.setdefault('attachment', [])
if imgs:
atts.extend(img for img in imgs if img not in atts)
# cc target's author(s) and recipients
# https://www.w3.org/TR/activitystreams-vocabulary/#audienceTargeting
# https://w3c.github.io/activitypub/#delivery
if orig_obj and type in as2.TYPE_TO_VERB:
recips = itertools.chain(*(util.get_list(orig_obj, field) for field in
('actor', 'attributedTo', 'to', 'cc')))
obj_or_activity['cc'] = sorted(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', [])
add(to, as2.PUBLIC_AUDIENCE)
# hashtags. Mastodon requires:
# * type: Hashtag
# * name starts with #
2023-03-14 22:36:18 +00:00
# * href is set to a valid, fully qualified URL
#
2023-03-14 22:36:18 +00:00
# If content has an <a> 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 util.get_list(activity, 'tag') + util.get_list(obj, 'tag'):
name = tag.get('name')
if name and tag.get('type', 'Tag') == 'Tag':
tag['type'] = 'Hashtag'
tag.setdefault('href', common.host_url(
f'hashtag/{quote_plus(name.removeprefix("#"))}'))
if not name.startswith('#'):
tag['name'] = f'#{name}'
activity['object'] = postprocess_as2(activity.get('object'), orig_obj=orig_obj,
wrap=type in ('Create', 'Update', 'Delete'))
return util.trim_nulls(activity)
def postprocess_as2_actor(actor, wrap=True):
"""Prepare an AS2 actor object to be served or sent via ActivityPub.
Modifies actor in place.
Args:
actor: dict, AS2 actor object
wrap: boolean, whether to wrap url
Returns:
actor dict
"""
if not actor:
return actor
elif isinstance(actor, str):
if g.user and g.user.is_web_url(actor):
return g.user.ap_actor()
return redirect_wrap(actor)
url = g.user.web_url() if g.user else None
urls = util.get_list(actor, 'url')
if not urls and url:
noop, lint fixes from flake8 remaining: $ flake8 --extend-ignore=E501 *.py tests/*.py "pyflakes" failed during execution due to "'FlakesChecker' object has no attribute 'NAMEDEXPR'" Run flake8 with greater verbosity to see more details activitypub.py:15:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused activitypub.py:36:1: F401 'web' imported but unused activitypub.py:48:1: E302 expected 2 blank lines, found 1 activitypub.py:51:9: F811 redefinition of unused 'web' from line 36 app.py:6:1: F401 'flask_app.app' imported but unused app.py:9:1: F401 'activitypub' imported but unused app.py:9:1: F401 'convert' imported but unused app.py:9:1: F401 'follow' imported but unused app.py:9:1: F401 'pages' imported but unused app.py:9:1: F401 'redirect' imported but unused app.py:9:1: F401 'superfeedr' imported but unused app.py:9:1: F401 'ui' imported but unused app.py:9:1: F401 'webfinger' imported but unused app.py:9:1: F401 'web' imported but unused app.py:9:1: F401 'xrpc_actor' imported but unused app.py:9:1: F401 'xrpc_feed' imported but unused app.py:9:1: F401 'xrpc_graph' imported but unused app.py:9:19: E401 multiple imports on one line models.py:19:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused models.py:364:31: E114 indentation is not a multiple of four (comment) models.py:364:31: E116 unexpected indentation (comment) protocol.py:17:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused redirect.py:26:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused web.py:18:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused webfinger.py:13:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused webfinger.py:110:13: E122 continuation line missing indentation or outdented webfinger.py:111:13: E122 continuation line missing indentation or outdented webfinger.py:131:13: E122 continuation line missing indentation or outdented webfinger.py:132:13: E122 continuation line missing indentation or outdented webfinger.py:133:13: E122 continuation line missing indentation or outdented webfinger.py:134:13: E122 continuation line missing indentation or outdented tests/__init__.py:2:1: F401 'oauth_dropins.webutil.tests' imported but unused tests/test_follow.py:11:1: F401 'oauth_dropins.webutil.util.json_dumps' imported but unused tests/test_follow.py:14:1: F401 '.testutil.Fake' imported but unused tests/test_models.py:156:15: E122 continuation line missing indentation or outdented tests/test_models.py:157:15: E122 continuation line missing indentation or outdented tests/test_models.py:158:11: E122 continuation line missing indentation or outdented tests/test_web.py:12:1: F401 'oauth_dropins.webutil.util.json_dumps' imported but unused tests/test_web.py:17:1: F401 '.testutil' imported but unused tests/test_web.py:1513:13: E128 continuation line under-indented for visual indent tests/test_web.py:1514:9: E124 closing bracket does not match visual indentation tests/testutil.py:106:1: E402 module level import not at top of file tests/testutil.py:107:1: E402 module level import not at top of file tests/testutil.py:108:1: E402 module level import not at top of file tests/testutil.py:109:1: E402 module level import not at top of file tests/testutil.py:110:1: E402 module level import not at top of file tests/testutil.py:301:24: E203 whitespace before ':' tests/testutil.py:301:25: E701 multiple statements on one line (colon) tests/testutil.py:301:25: E231 missing whitespace after ':'
2023-06-20 18:22:54 +00:00
urls = [url]
domain = util.domain_from_link(urls[0], minimize=False)
if wrap:
urls[0] = redirect_wrap(urls[0])
id = actor.get('id')
if g.user and (not id or g.user.is_web_url(id)):
actor['id'] = g.user.ap_actor()
actor.update({
'url': urls if len(urls) > 1 else urls[0],
# required by ActivityPub
# https://www.w3.org/TR/activitypub/#actor-objects
'inbox': g.user.ap_actor('inbox'),
'outbox': g.user.ap_actor('outbox'),
})
# TODO: genericize (see line 752 in actor())
if g.user.LABEL != 'atproto':
# This has to be the domain for Mastodon interop/Webfinger discovery!
# See related comment in actor() below.
actor['preferredUsername'] = domain
# Override the label for their home page to be "Web site"
for att in util.get_list(actor, 'attachment'):
noop, lint fixes from flake8 remaining: $ flake8 --extend-ignore=E501 *.py tests/*.py "pyflakes" failed during execution due to "'FlakesChecker' object has no attribute 'NAMEDEXPR'" Run flake8 with greater verbosity to see more details activitypub.py:15:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused activitypub.py:36:1: F401 'web' imported but unused activitypub.py:48:1: E302 expected 2 blank lines, found 1 activitypub.py:51:9: F811 redefinition of unused 'web' from line 36 app.py:6:1: F401 'flask_app.app' imported but unused app.py:9:1: F401 'activitypub' imported but unused app.py:9:1: F401 'convert' imported but unused app.py:9:1: F401 'follow' imported but unused app.py:9:1: F401 'pages' imported but unused app.py:9:1: F401 'redirect' imported but unused app.py:9:1: F401 'superfeedr' imported but unused app.py:9:1: F401 'ui' imported but unused app.py:9:1: F401 'webfinger' imported but unused app.py:9:1: F401 'web' imported but unused app.py:9:1: F401 'xrpc_actor' imported but unused app.py:9:1: F401 'xrpc_feed' imported but unused app.py:9:1: F401 'xrpc_graph' imported but unused app.py:9:19: E401 multiple imports on one line models.py:19:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused models.py:364:31: E114 indentation is not a multiple of four (comment) models.py:364:31: E116 unexpected indentation (comment) protocol.py:17:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused redirect.py:26:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused web.py:18:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused webfinger.py:13:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused webfinger.py:110:13: E122 continuation line missing indentation or outdented webfinger.py:111:13: E122 continuation line missing indentation or outdented webfinger.py:131:13: E122 continuation line missing indentation or outdented webfinger.py:132:13: E122 continuation line missing indentation or outdented webfinger.py:133:13: E122 continuation line missing indentation or outdented webfinger.py:134:13: E122 continuation line missing indentation or outdented tests/__init__.py:2:1: F401 'oauth_dropins.webutil.tests' imported but unused tests/test_follow.py:11:1: F401 'oauth_dropins.webutil.util.json_dumps' imported but unused tests/test_follow.py:14:1: F401 '.testutil.Fake' imported but unused tests/test_models.py:156:15: E122 continuation line missing indentation or outdented tests/test_models.py:157:15: E122 continuation line missing indentation or outdented tests/test_models.py:158:11: E122 continuation line missing indentation or outdented tests/test_web.py:12:1: F401 'oauth_dropins.webutil.util.json_dumps' imported but unused tests/test_web.py:17:1: F401 '.testutil' imported but unused tests/test_web.py:1513:13: E128 continuation line under-indented for visual indent tests/test_web.py:1514:9: E124 closing bracket does not match visual indentation tests/testutil.py:106:1: E402 module level import not at top of file tests/testutil.py:107:1: E402 module level import not at top of file tests/testutil.py:108:1: E402 module level import not at top of file tests/testutil.py:109:1: E402 module level import not at top of file tests/testutil.py:110:1: E402 module level import not at top of file tests/testutil.py:301:24: E203 whitespace before ':' tests/testutil.py:301:25: E701 multiple statements on one line (colon) tests/testutil.py:301:25: E231 missing whitespace after ':'
2023-06-20 18:22:54 +00:00
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
2023-09-23 20:53:17 +00:00
@app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<handle_or_id>')
# special case Web users without /ap/web/ prefix, for backward compatibility
2023-09-23 20:53:17 +00:00
@app.get(f'/<regex("{DOMAIN_RE}"):handle_or_id>', defaults={'protocol': 'web'})
@flask_util.cached(cache, CACHE_TIME)
2023-09-23 20:53:17 +00:00
def actor(protocol, handle_or_id):
"""Serves a user's AS2 actor from the datastore."""
cls = PROTOCOLS[protocol]
2023-09-23 20:53:17 +00:00
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
g.user = cls.get_or_create(id)
if not g.user.obj or not g.user.obj.as1:
2023-09-23 20:53:17 +00:00
g.user.obj = cls.load(g.user.profile_id(), gateway=True)
actor = g.user.as2() or {
'@context': [as2.CONTEXT],
'type': 'Person',
}
actor = postprocess_as2(actor)
2023-03-19 04:45:27 +00:00
actor.update({
'id': g.user.ap_actor(),
'inbox': g.user.ap_actor('inbox'),
'outbox': g.user.ap_actor('outbox'),
'following': g.user.ap_actor('following'),
'followers': g.user.ap_actor('followers'),
'endpoints': {
'sharedInbox': host_url('/ap/sharedInbox'),
},
2023-09-23 20:53:17 +00:00
# add this if we ever change the Web actor ids to be /web/[id]
# 'alsoKnownAs': [host_url(id)],
2023-03-19 04:45:27 +00:00
})
# TODO: genericize (see line 690 in postprocess_as2)
if cls.LABEL != 'atproto':
# This has to be the id (domain for Web) 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
actor['preferredUsername'] = id
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('/ap/sharedInbox')
2023-09-23 20:53:17 +00:00
@app.post(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{DOMAIN_RE}"):domain>/inbox')
# special case Web users without /ap/web/ prefix, for backward compatibility
@app.post('/inbox')
2023-09-23 20:53:17 +00:00
@app.post(f'/<regex("{DOMAIN_RE}"):domain>/inbox', defaults={'protocol': 'web'})
def inbox(protocol=None, domain=None):
"""Handles ActivityPub inbox delivery."""
2021-07-10 15:07:40 +00:00
# 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):
body = request.get_data(as_text=True)
error(f"Couldn't parse body as non-empty JSON mapping: {body}", exc_info=True)
2021-07-10 15:07:40 +00:00
type = activity.get('type')
actor = as1.get_object(activity, 'actor')
actor_id = actor.get('id')
logger.info(f'Got {type} from {actor_id}: {json_dumps(activity, indent=2)}')
2021-07-10 15:07:40 +00:00
# load receiving user
obj_id = as1.get_object(redirect_unwrap(activity)).get('id')
receiving_proto = receiving_user_id = None
if protocol:
receiving_proto = PROTOCOLS[protocol]
elif type == 'Follow':
receiving_proto = Protocol.for_id(obj_id)
if receiving_proto:
if domain:
assert receiving_proto is web.Web, 'https://github.com/snarfed/bridgy-fed/issues/611'
receiving_user_id = domain
else:
receiving_key = receiving_proto.key_for(obj_id)
if receiving_key:
receiving_user_id = receiving_key.id()
if receiving_user_id:
g.user = receiving_proto.get_or_create(receiving_user_id, direct=False)
logger.info(f'Setting g.user to {g.user.key}')
if not g.user.direct and actor_id:
# this is a deliberate interaction with an indirect receiving user;
# create a local AP User for the sending user
actor_obj = ActivityPub.load(actor_id)
ActivityPub.get_or_create(actor_id, direct=True, obj=actor_obj)
ActivityPub.verify_signature(activity)
# 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 type == 'Create' and not as2.is_public(activity):
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 = redirect_unwrap(util.get_url(activity, 'actor'))
followee_url = redirect_unwrap(util.get_url(activity, 'object'))
activity.setdefault('url', f'{follower_url}#followed-{followee_url}')
obj = Object(id=activity.get('id'), as2=redirect_unwrap(activity))
return ActivityPub.receive(obj)
2021-07-10 15:07:40 +00:00
2023-09-23 20:53:17 +00:00
@app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{DOMAIN_RE}"):domain>/<any(followers,following):collection>')
# special case Web users without /ap/web/ prefix, for backward compatibility
2023-09-23 20:53:17 +00:00
@app.get(f'/<regex("{DOMAIN_RE}"):domain>/<any(followers,following):collection>',
defaults={'protocol': 'web'})
@flask_util.cached(cache, CACHE_TIME)
def follower_collection(protocol, 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
"""
g.user = PROTOCOLS[protocol].get_by_id(domain)
if not g.user:
return f'{protocol} user {domain} not found', 404
# page
followers, new_before, new_after = Follower.fetch_page(collection)
page = {
'type': 'CollectionPage',
'partOf': request.base_url,
'items': util.trim_nulls([f.user.as2() 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.info(f'Returning {json_dumps(page, indent=2)}')
return page, {'Content-Type': as2.CONTENT_TYPE}
# collection
prop = Follower.to if collection == 'followers' else Follower.from_
count = Follower.query(
Follower.status == 'active',
prop == g.user.key,
).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
2023-09-23 20:53:17 +00:00
@app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{DOMAIN_RE}"):domain>/outbox')
# special case Web users without /ap/web/ prefix, for backward compatibility
2023-09-23 20:53:17 +00:00
@app.get(f'/<regex("{DOMAIN_RE}"):domain>/outbox', defaults={'protocol': 'web'})
def outbox(protocol, domain):
2023-01-25 21:12:24 +00:00
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': request.url,
2023-01-25 21:12:24 +00:00
'summary': f"{domain}'s outbox",
'type': 'OrderedCollection',
'totalItems': 0,
'first': {
'type': 'CollectionPage',
'partOf': request.base_url,
2023-01-25 21:12:24 +00:00
'items': [],
},
}, {'Content-Type': as2.CONTENT_TYPE}