bridgy-fed/activitypub.py

1291 wiersze
48 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, urlparse
from unittest.mock import MagicMock
from flask import abort, g, redirect, request
from google.cloud import ndb
from google.cloud.ndb.query import FilterNode, OR, Query
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.flask_util import MovedPermanently
from oauth_dropins.webutil.util import add, fragmentless, json_dumps, json_loads
import requests
from requests import TooManyRedirects
from requests.models import DEFAULT_REDIRECT_LIMIT
from werkzeug.exceptions import BadGateway
from flask_app import app
import common
from common import (
CACHE_CONTROL,
2025-01-29 05:13:35 +00:00
CACHE_CONTROL_VARY_ACCEPT,
CONTENT_TYPE_HTML,
create_task,
DOMAINS,
2023-09-23 20:53:17 +00:00
DOMAIN_RE,
error,
host_url,
LOCAL_DOMAINS,
PRIMARY_DOMAIN,
PROTOCOL_DOMAINS,
redirect_wrap,
report_error,
subdomain_wrap,
unwrap,
)
from ids import BOT_ACTOR_AP_IDS
import memcache
from models import fetch_objects, Follower, Object, PROTOCOLS, User
from protocol import activity_id_memcache_key, DELETE_TASK_DELAY, Protocol
import webfinger
logger = logging.getLogger(__name__)
CONNEG_HEADERS_AS2_HTML = {
'Accept': f'{as2.CONNEG_HEADERS["Accept"]}, {CONTENT_TYPE_HTML}; q=0.5'
}
HTTP_SIG_HEADERS = ('Date', 'Host', 'Digest', '(request-target)')
SECURITY_CONTEXT = 'https://w3id.org/security/v1'
# https://www.w3.org/ns/activitystreams#did-core
# https://docs.joinmastodon.org/spec/activitypub/#properties-used-1
AKA_CONTEXT = {'alsoKnownAs': {'@id': 'as:alsoKnownAs', '@type': '@id'}}
# https://seb.jambor.dev/posts/understanding-activitypub-part-4-threads/#the-instance-actor
_INSTANCE_ACTOR = None
OLD_ACCOUNT_EXEMPT_DOMAINS = (
'channel.org',
'newsmast.community',
'pixelfed.social',
)
# we can't yet authorize activities from these domains:
# * a.gup.pe groups sign with the group's actor but use the external author as
# actor and attributedTo, and don't include an LD Sig
# https://github.com/snarfed/bridgy-fed/issues/566#issuecomment-2130714037
NO_AUTH_DOMAINS = (
'a.gup.pe',
)
FEDI_URL_RE = re.compile(r'https://[^/]+/(@|users/)([^/@]+)(@[^/@]+)?(/(?:statuses/)?[0-9]+)?')
def instance_actor():
global _INSTANCE_ACTOR
if _INSTANCE_ACTOR is None:
import web
_INSTANCE_ACTOR = web.Web.get_or_create(PRIMARY_DOMAIN)
return _INSTANCE_ACTOR
class ActivityPub(User, Protocol):
"""ActivityPub protocol class.
Key id is AP/AS2 actor id URL. (*Not* fediverse/WebFinger @-@ handle!)
"""
ABBREV = 'ap'
''
PHRASE = 'the fediverse'
''
LOGO_HTML = '<img src="/static/fediverse_logo.svg">'
''
CONTENT_TYPE = as2.CONTENT_TYPE_LD_PROFILE
''
REQUIRES_AVATAR = True
''
REQUIRES_NAME = False
''
DEFAULT_ENABLED_PROTOCOLS = ('web',)
''
2024-07-04 23:58:06 +00:00
SUPPORTED_AS1_TYPES = (
tuple(as1.ACTOR_TYPES)
+ tuple(as1.POST_TYPES)
+ tuple(as1.CRUD_VERBS)
2024-07-04 23:58:06 +00:00
+ tuple(as1.VERBS_WITH_OBJECT)
+ ('audio', 'bookmark', 'image', 'video')
)
''
2024-07-04 23:58:06 +00:00
SUPPORTED_AS2_TYPES = tuple(
as2.OBJECT_TYPE_TO_TYPE.get(t) or as2.VERB_TO_TYPE.get(t)
for t in SUPPORTED_AS1_TYPES)
''
SUPPORTS_DMS = True
''
@property
def REQUIRES_OLD_ACCOUNT(self):
''
return util.domain_from_link(self.key.id()) not in OLD_ACCOUNT_EXEMPT_DOMAINS
def _pre_put_hook(self):
2024-09-12 19:37:09 +00:00
r"""Validate id, require URL, don't allow Bridgy Fed domains.
TODO: normalize scheme and domain to lower case. Add that to
:class:`oauth_dropins.webutil.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):
2023-09-27 21:58:33 +00:00
"""Returns this user's web URL aka web_url, eg ``https://foo.com/``."""
if self.obj and self.obj.as1:
url = as1.get_url(self.obj.as1)
if url:
return url
return self.key.id()
@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._convert(self.obj, from_user=self))
if addr:
return addr
return as2.address(self.key.id())
@ndb.ComputedProperty
def status(self):
if self.obj and self.obj.as2 and as2.is_server_actor(self.obj.as2):
return None
status = super().status
if status:
return status
@classmethod
def owns_id(cls, id):
2023-10-06 15:22:50 +00:00
"""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
I used to include a heuristic here that no actor is the root path on its
host, which was nice because it let us assume that home pages are Web
users without making any network requests...but then I inevitably ran
into AP actors that _are_ the root path, eg microblog.pub sites like
https://bw3.dev/ .
https://docs.microblog.pub/user_guide.html#activitypub
"""
if util.is_web(id) and not cls.is_blocklisted(id):
return None
return False
@classmethod
def owns_handle(cls, handle, allow_internal=False):
2023-10-06 15:22:50 +00:00
"""Returns True if handle is a WebFinger ``@-@`` handle, 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('@')
if len(parts) != 2:
return False
user, domain = parts
return user and domain and not cls.is_blocklisted(
domain, allow_internal=allow_internal)
@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."""
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
actor = cls.load(inner_id, raise_=False)
if actor and actor.as1:
target = cls.target_for(actor, shared=shared)
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
actor = cls._convert(obj)
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, from_user=None, orig_obj_id=None):
"""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 not from_user:
logger.info('Skipping sending, no from_user!')
return False
elif to_cls.is_blocklisted(url):
2023-10-18 19:14:18 +00:00
logger.info(f'Skipping sending to blocklisted {url}')
return False
orig_obj = None
if orig_obj_id:
orig_obj = to_cls.convert(Object.get_by_id(orig_obj_id),
from_user=from_user)
activity = to_cls.convert(obj, from_user=from_user, orig_obj=orig_obj)
return signed_post(url, data=activity, from_user=from_user).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.Protocol.fetch` for more details.
Args:
obj (models.Object): with the id to fetch. Fills data into the as2
property.
kwargs: ignored
Returns:
bool: True if the object was fetched and populated successfully,
False otherwise
Raises:
requests.HTTPError:
werkzeug.exceptions.HTTPException: will have an additional
``requests_response`` attribute with the last
:class:`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.debug('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.debug('no AS2 available')
return False
obj.as2 = _get(link['href'], as2.CONNEG_HEADERS)
if obj.as2:
return True
return False
@classmethod
def _convert(cls, obj, orig_obj=None, from_user=None):
"""Convert a :class:`models.Object` to AS2.
Args:
obj (models.Object)
orig_obj (dict): AS2 object, optional. The target of activity's
``inReplyTo`` or ``Like``/``Announce``/etc object, if any. Passed
through to :func:`postprocess_as2`.
from_user (models.User): user (actor) this activity/object is from
Returns:
dict: AS2 JSON
"""
if not obj or not obj.as1:
return {}
# TODO: uncomment
# from_proto = PROTOCOLS.get(obj.source_protocol)
# if from_proto and not from_user.is_enabled(cls):
# error(f'{cls.LABEL} <=> {from_proto.LABEL} not enabled')
if obj.as2:
return {
# add back @context since we strip it when we store Objects
'@context': [as2.CONTEXT, SECURITY_CONTEXT],
**obj.as2,
}
translated = cls.translate_ids(obj.as1)
# compact actors to just string id for compatibility, since many other
# AP implementations choke on objects.
# https://github.com/snarfed/bridgy-fed/issues/658
#
# TODO: expand this to general purpose compact() function and use
# elsewhere, eg in models.resolve_id
for o in translated, as1.get_object(translated):
for field in 'actor', 'attributedTo', 'author':
actors = as1.get_objects(o, field)
ids = [a['id'] for a in actors if a.get('id')]
o[field] = ids[0] if len(ids) == 1 else ids
converted = as2.from_as1(translated)
2023-11-03 22:11:21 +00:00
if obj.source_protocol in ('ap', 'activitypub'):
return converted
2023-11-03 22:11:21 +00:00
# special cases where obj or obj['object'] or obj['object']['object']
# are an actor
if from_user:
if as1.object_type(obj.as1) in as1.ACTOR_TYPES:
return postprocess_as2_actor(converted, user=from_user)
inner_obj = as1.get_object(obj.as1)
if as1.object_type(inner_obj) in as1.ACTOR_TYPES:
converted['object'] = postprocess_as2_actor(converted['object'],
user=from_user)
# eg Accept of a Follow
if from_user.is_web_url(as1.get_object(inner_obj).get('id')):
converted['object']['object'] = from_user.id_as(ActivityPub)
# convert!
return postprocess_as2(converted, orig_obj=orig_obj)
@classmethod
def verify_signature(cls, activity):
"""Verifies the current request's HTTP Signature.
Raises :class:`werkzeug.exceptions.HTTPError` if the signature is
missing or invalid, otherwise does nothing and returns the id of the
actor whose key signed the request.
2023-10-06 15:22:50 +00:00
Logs details of the result.
https://swicg.github.io/activitypub-http-signature/
Args:
activity (dict): AS2 activity
Returns:
str: signing AP actor id
"""
headers = dict(request.headers) # copy so we can modify below
sig = headers.get('Signature')
if not sig:
if appengine_info.DEBUG:
logger.info('No HTTP Signature, allowing due to DEBUG=true')
return
error('No HTTP Signature', status=401)
2024-12-03 06:20:12 +00:00
logger.debug('Verifying HTTP Signature')
logger.debug(f'Headers: {json_dumps(headers, indent=2)}')
# parse_signature_header lower-cases all keys
sig_fields = parse_signature_header(sig)
key_id = fragmentless(sig_fields.get('keyid'))
if not key_id:
2024-12-03 06:20:12 +00:00
error('sig 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:
2024-12-03 06:20:12 +00:00
error('Missing Digest', status=401)
expected = b64encode(sha256(request.data).digest()).decode()
if digest.removeprefix('SHA-256=').removeprefix('sha-256=') != expected:
2024-12-03 06:20:12 +00:00
error('Invalid Digest', status=401)
try:
key_actor = cls._load_key(key_id)
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 key_id == fragmentless(obj_id)):
2024-12-03 06:20:12 +00:00
logger.debug('Object/actor being deleted is also keyId')
key_actor = Object.get_or_create(
id=key_id, authed_as=key_id, source_protocol='activitypub',
deleted=True)
else:
raise
if key_actor and key_actor.deleted:
abort(202, f'Ignoring, signer {key_id} is already deleted')
elif not key_actor or not key_actor.as1:
error(f"Couldn't load {key_id} to verify signature", status=401)
# don't ActivityPub.convert since we don't want to postprocess_as2
key = as2.from_as1(key_actor.as1).get('publicKey', {}).get('publicKeyPem')
if not key:
error(f'No public key for {key_id}', status=401)
# can't use request.full_path because it includes a trailing ? even if
# it wasn't in the request. https://github.com/pallets/flask/issues/2867
path_query = request.url.removeprefix(request.host_url.rstrip('/'))
2024-12-03 06:20:12 +00:00
logger.debug(f'Verifying signature for {path_query} with key {sig_fields["keyid"]}')
try:
verified = HeaderVerifier(headers, key,
required_headers=['Digest'],
method=request.method,
path=path_query,
sign_header='signature',
).verify()
except BaseException as e:
2024-12-03 06:20:12 +00:00
error(f'sig verification failed: {e}', status=401)
if verified:
2024-12-03 06:20:12 +00:00
logger.debug('sig ok')
else:
2024-12-03 06:20:12 +00:00
error('sig failed', status=401)
return key_actor.key.id()
@classmethod
def _load_key(cls, key_id, follow_owner=True):
"""Loads the ActivityPub actor for a given ``keyId``.
https://swicg.github.io/activitypub-http-signature/#how-to-obtain-a-signature-s-public-key
Args:
key_id (str): ``keyId`` from an HTTP Signature
follow_owner (bool): whether to follow ``owner``/``controller`` fields
Returns:
Object or None:
Raises:
requests.HTTPError:
"""
assert '#' not in key_id
actor = cls.load(key_id)
if not actor:
return None
if follow_owner and actor.as1:
actor_as2 = as2.from_as1(actor.as1)
key = actor_as2.get('publicKey', {})
owner = key.get('controller') or key.get('owner')
if not owner and actor.type not in as1.ACTOR_TYPES:
owner = actor_as2.get('controller') or actor_as2.get('owner')
if owner:
owner = fragmentless(owner)
if owner != key_id:
2024-12-03 06:20:12 +00:00
logger.debug(f'keyId {key_id} has controller/owner {owner}, fetching that')
return cls._load_key(owner, follow_owner=False)
return actor
def signed_get(url, from_user=None, **kwargs):
Revert "cache outbound HTTP request responses, locally to each inbound request" This reverts commit 30debfc8faf730190bd51a3aef49df6c6bfbd50a. seemed promising, but broke in production. Saw a lot of `IncompleteRead`s on both GETs and POSTs. Rolled back for now. ``` ('Connection broken: IncompleteRead(9172 bytes read, -4586 more expected)', IncompleteRead(9172 bytes read, -4586 more expected)) ... File "oauth_dropins/webutil/util.py", line 1673, in call resp = getattr((session or requests), fn)(url, *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 102, in get return self.request('GET', url, params=params, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 158, in request return super().request(method, url, *args, headers=headers, **kwargs) # type: ignore ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests/sessions.py", line 589, in request resp = self.send(prep, **send_kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 205, in send response = self._send_and_cache(request, actions, cached_response, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 233, in _send_and_cache self.cache.save_response(response, actions.cache_key, actions.expires) File "requests_cache/backends/base.py", line 89, in save_response cached_response = CachedResponse.from_response(response, expires=expires) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/models/response.py", line 102, in from_response obj.raw = CachedHTTPResponse.from_response(response) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/models/raw_response.py", line 69, in from_response _ = response.content # This property reads, decodes, and stores response content ^^^^^^^^^^^^^^^^ File "requests/models.py", line 899, in content self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b"" ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests/models.py", line 818, in generate raise ChunkedEncodingError(e) ```
2024-03-08 21:24:28 +00:00
return signed_request(util.requests_get, url, from_user=from_user, **kwargs)
def signed_post(url, from_user, **kwargs):
assert from_user
Revert "cache outbound HTTP request responses, locally to each inbound request" This reverts commit 30debfc8faf730190bd51a3aef49df6c6bfbd50a. seemed promising, but broke in production. Saw a lot of `IncompleteRead`s on both GETs and POSTs. Rolled back for now. ``` ('Connection broken: IncompleteRead(9172 bytes read, -4586 more expected)', IncompleteRead(9172 bytes read, -4586 more expected)) ... File "oauth_dropins/webutil/util.py", line 1673, in call resp = getattr((session or requests), fn)(url, *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 102, in get return self.request('GET', url, params=params, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 158, in request return super().request(method, url, *args, headers=headers, **kwargs) # type: ignore ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests/sessions.py", line 589, in request resp = self.send(prep, **send_kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 205, in send response = self._send_and_cache(request, actions, cached_response, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 233, in _send_and_cache self.cache.save_response(response, actions.cache_key, actions.expires) File "requests_cache/backends/base.py", line 89, in save_response cached_response = CachedResponse.from_response(response, expires=expires) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/models/response.py", line 102, in from_response obj.raw = CachedHTTPResponse.from_response(response) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/models/raw_response.py", line 69, in from_response _ = response.content # This property reads, decodes, and stores response content ^^^^^^^^^^^^^^^^ File "requests/models.py", line 899, in content self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b"" ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests/models.py", line 818, in generate raise ChunkedEncodingError(e) ```
2024-03-08 21:24:28 +00:00
return signed_request(util.requests_post, url, from_user=from_user, **kwargs)
def signed_request(fn, url, data=None, headers=None, from_user=None,
_redirect_count=None, **kwargs):
"""Wraps ``requests.*`` and adds HTTP Signature.
https://swicg.github.io/activitypub-http-signature/
Args:
Revert "cache outbound HTTP request responses, locally to each inbound request" This reverts commit 30debfc8faf730190bd51a3aef49df6c6bfbd50a. seemed promising, but broke in production. Saw a lot of `IncompleteRead`s on both GETs and POSTs. Rolled back for now. ``` ('Connection broken: IncompleteRead(9172 bytes read, -4586 more expected)', IncompleteRead(9172 bytes read, -4586 more expected)) ... File "oauth_dropins/webutil/util.py", line 1673, in call resp = getattr((session or requests), fn)(url, *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 102, in get return self.request('GET', url, params=params, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 158, in request return super().request(method, url, *args, headers=headers, **kwargs) # type: ignore ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests/sessions.py", line 589, in request resp = self.send(prep, **send_kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 205, in send response = self._send_and_cache(request, actions, cached_response, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 233, in _send_and_cache self.cache.save_response(response, actions.cache_key, actions.expires) File "requests_cache/backends/base.py", line 89, in save_response cached_response = CachedResponse.from_response(response, expires=expires) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/models/response.py", line 102, in from_response obj.raw = CachedHTTPResponse.from_response(response) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/models/raw_response.py", line 69, in from_response _ = response.content # This property reads, decodes, and stores response content ^^^^^^^^^^^^^^^^ File "requests/models.py", line 899, in content self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b"" ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests/models.py", line 818, in generate raise ChunkedEncodingError(e) ```
2024-03-08 21:24:28 +00:00
fn (callable): :func:`util.requests_get` or :func:`util.requests_post`
url (str):
data (dict): optional AS2 object
2023-11-26 04:23:19 +00:00
from_user (models.User): user to sign request as; optional. If not
provided, uses the default user ``@fed.brid.gy@fed.brid.gy``.
_redirect_count: internal, used to count redirects followed so far
kwargs: passed through to requests
Returns:
requests.Response:
"""
if headers is None:
headers = {}
# prepare HTTP Signature and headers
if not from_user or isinstance(from_user, ActivityPub):
# ActivityPub users are remote, so we don't have their keys
from_user = instance_actor()
if data:
logger.debug(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_LD_PROFILE,
# required for HTTP Signature and Mastodon
'Digest': f'SHA-256={b64encode(sha256(data or b"").digest()).decode()}',
}
2024-12-03 06:20:12 +00:00
logger.debug(f"Signing with {from_user.key.id()} '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'{from_user.id_as(ActivityPub)}#key'
auth = HTTPSignatureAuth(secret=from_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)
if fn == util.requests_get:
assert not isinstance(resp, MagicMock), \
f'unit test missing a mock HTTP response for {url}'
# handle GET redirects manually so that we generate a new HTTP signature
Revert "cache outbound HTTP request responses, locally to each inbound request" This reverts commit 30debfc8faf730190bd51a3aef49df6c6bfbd50a. seemed promising, but broke in production. Saw a lot of `IncompleteRead`s on both GETs and POSTs. Rolled back for now. ``` ('Connection broken: IncompleteRead(9172 bytes read, -4586 more expected)', IncompleteRead(9172 bytes read, -4586 more expected)) ... File "oauth_dropins/webutil/util.py", line 1673, in call resp = getattr((session or requests), fn)(url, *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 102, in get return self.request('GET', url, params=params, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 158, in request return super().request(method, url, *args, headers=headers, **kwargs) # type: ignore ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests/sessions.py", line 589, in request resp = self.send(prep, **send_kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 205, in send response = self._send_and_cache(request, actions, cached_response, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/session.py", line 233, in _send_and_cache self.cache.save_response(response, actions.cache_key, actions.expires) File "requests_cache/backends/base.py", line 89, in save_response cached_response = CachedResponse.from_response(response, expires=expires) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/models/response.py", line 102, in from_response obj.raw = CachedHTTPResponse.from_response(response) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests_cache/models/raw_response.py", line 69, in from_response _ = response.content # This property reads, decodes, and stores response content ^^^^^^^^^^^^^^^^ File "requests/models.py", line 899, in content self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b"" ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "requests/models.py", line 818, in generate raise ChunkedEncodingError(e) ```
2024-03-08 21:24:28 +00:00
if resp.is_redirect and fn == util.requests_get:
new_url = urljoin(url, resp.headers['Location'])
if _redirect_count is None:
_redirect_count = 0
elif _redirect_count > DEFAULT_REDIRECT_LIMIT:
raise TooManyRedirects(response=resp)
return signed_request(fn, new_url, data=data, from_user=from_user,
headers=headers, _redirect_count=_redirect_count + 1,
**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.debug(resp.text)
return resp
def postprocess_as2(activity, orig_obj=None, wrap=True):
"""Prepare an AS2 object to be served or sent via ActivityPub.
TODO: get rid of orig_obj! https://github.com/snarfed/bridgy-fed/issues/1257
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 (bool): whether to wrap ``id``, ``url``, ``object``, ``actor``, and
``attributedTo``
"""
if not activity or isinstance(activity, str):
return redirect_wrap(activity) if wrap else activity
elif activity.keys() == {'id'}:
return redirect_wrap(activity['id']) if wrap else activity['id']
type = activity.get('type')
# 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 = util.get_list(activity, 'inReplyTo')
if in_reply_to:
if orig_id: # TODO: and orig_id in in_reply_to ...or get rid of orig_obj
activity['inReplyTo'] = orig_id
elif len(in_reply_to) > 1:
# AS2 inReplyTo can be multiply valued, it's not marked Functional:
# https://www.w3.org/TR/activitystreaams-vocabulary/#dfn-inreplyto
# ...but most fediverse projects don't support that:
# https://funfedi.dev/support_tables/generated/in_reply_to/
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:
add(activity.setdefault('tag', []), {
'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 and type != 'Undo':
# 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')
# 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:
# some fediverse servers (eg Misskey) require activity id and actor id
# to be on the same domain
2024-10-28 15:01:36 +00:00
# https://github.com/snarfed/bridgy-fed/issues/1093#issuecomment-2299247639
redirect_domain = util.domain_from_link(as1.get_id(activity, 'actor'))
if redirect_domain not in DOMAINS:
redirect_domain = None
activity['id'] = redirect_wrap(activity.get('id'), domain=redirect_domain)
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'))
obj_or_activity = obj if obj.keys() > set(['id']) else activity
# move Link attachments to links in text since fediverse instances generate
# their own link previews.
# https://github.com/snarfed/bridgy-fed/issues/958
atts = util.pop_list(obj_or_activity, 'attachment')
obj_or_activity['attachment'] = [a for a in atts if a.get('type') != 'Link']
link_atts = [a for a in atts if a.get('type') == 'Link']
for link in link_atts:
for url in util.get_list(link, 'href'):
if obj_or_activity.setdefault('content', ''):
obj_or_activity['content'] += '<br><br>'
obj_or_activity['content'] += util.pretty_link(url, text=link.get('name'))
# copy image(s) into attachment(s). may be Mastodon-specific.
# https://github.com/snarfed/bridgy-fed/issues/33#issuecomment-440965618
imgs = util.get_list(obj_or_activity, 'image')
if imgs:
atts = obj_or_activity['attachment']
for img in imgs:
if isinstance(img, str):
img = {'url': img}
add(atts, img)
# cc target's author(s), recipients, mentions
# https://www.w3.org/TR/activitystreams-vocabulary/#audienceTargeting
# https://w3c.github.io/activitypub/#delivery
# https://docs.joinmastodon.org/spec/activitypub/#Mention
cc = obj_or_activity.setdefault('cc', [])
tags = util.get_list(activity, 'tag') + util.get_list(obj, 'tag')
for tag in tags:
href = tag.get('href')
if (tag.get('type') == 'Mention'
and href
and href not in util.get_list(obj_or_activity, 'to')
and not ActivityPub.is_blocklisted(href)):
add(cc, href)
if orig_obj and type in as2.TYPE_TO_VERB:
for field in 'actor', 'attributedTo', 'to', 'cc':
for recip in as1.get_objects(orig_obj, field):
add(cc, util.get_url(recip) or recip.get('id'))
# for some activities, Pleroma (and Akkoma?) seem to crash if the activity's
# to and cc aren't exactly the same as the object's. (I think?)
# https://indieweb.social/@diego@lounge.collabfc.com/112977955332152430
# https://git.pleroma.social/pleroma/pleroma/-/issues/3206#note_108296
# https://github.com/snarfed/bridgy-fed/issues/12#issuecomment-2302776658
if type in ('Create', 'Update'):
activity['to'] = util.get_list(obj, 'to')
activity['cc'] = util.get_list(obj, 'cc')
# WARNING: activity here is AS2, but we're using as1.is_dm. right now the
# logic is effectively the same for our purposes, but watch out here if that
# ever changes.
if not as1.is_dm(activity):
# 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
add(activity.setdefault('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 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
content_map = obj_or_activity.setdefault('contentMap', {'en': content})
# wrap in <p>. 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('<p>'):
obj_or_activity['content'] = f'<p>{content}</p>'
for lang, val in content_map.items():
if val == content:
content_map[lang] = obj_or_activity['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:
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]
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'):
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 link and url.rstrip('/') in [val.rstrip('/'),
link.get('href').rstrip('/')]:
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
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
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)
2023-09-23 20:53:17 +00:00
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",
2023-09-23 20:53:17 +00:00
status=404)
id = proto.handle_to_id(handle_or_id)
2023-09-23 20:53:17 +00:00
if not id:
error(f"Couldn't resolve {handle_or_id} as a {proto.LABEL} handle",
2023-09-23 20:53:17 +00:00
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/<handle_or_id>')
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
# backward compatibility
@app.get(f'/<regex("{DOMAIN_RE}"):handle_or_id>')
2025-01-29 05:13:35 +00:00
@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()])
2023-03-19 04:45:27 +00:00
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,
2021-07-10 15:07:40 +00:00
'Access-Control-Allow-Origin': '*',
}
2021-07-10 15:07:40 +00:00
# note that this shared inbox path overlaps with the /ap/<handle_or_id> 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/<id>/inbox')
# source protocol in path; primarily for backcompat
@app.post(f'/ap/<protocol>/<id>/inbox')
# special case Web users on fed subdomain without /ap/web/ prefix
@app.post(f'/<regex("{DOMAIN_RE}"):id>/inbox')
def inbox(protocol=None, id=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
2024-07-04 23:58:06 +00:00
# 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)
2024-07-04 23:58:06 +00:00
# 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')
2021-07-10 15:07:40 +00:00
if ActivityPub.is_blocklisted(actor_id):
error(f'Actor {actor_id} is blocklisted')
2024-12-03 06:20:12 +00:00
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:
2024-12-03 06:20:12 +00:00
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)
2021-07-10 15:07:40 +00:00
# protocol in subdomain
@app.get(f'/ap/<id>/<any(followers,following):collection>')
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
# backward compatibility
@app.route(f'/<regex("{DOMAIN_RE}"):id>/<any(followers,following):collection>',
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,
}
2023-01-25 21:12:24 +00:00
# protocol in subdomain
@app.get(f'/ap/<id>/outbox')
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
# backward compatibility
@app.route(f'/<regex("{DOMAIN_RE}"):id>/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}
2024-08-14 04:22:00 +00:00
# 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)
2024-08-14 04:22:00 +00:00
# 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,
2024-08-14 04:22:00 +00:00
# '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}