• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

snarfed / bridgy-fed / a62808aa-5996-4422-8905-839df0c498c8

29 Jan 2025 07:57PM UTC coverage: 93.218%. Remained the same
a62808aa-5996-4422-8905-839df0c498c8

push

circleci

snarfed
cache /convert/ and /r/ endpoints in memcache

...since GAE's edge caching based on Cache-Control doesn't seem to be very effective :/

for #1149

7 of 7 new or added lines in 2 files covered. (100.0%)

58 existing lines in 5 files now uncovered.

4536 of 4866 relevant lines covered (93.22%)

0.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.12
/activitypub.py
1
"""ActivityPub protocol implementation."""
2
from base64 import b64encode
1✔
3
from hashlib import sha256
1✔
4
import itertools
1✔
5
import logging
1✔
6
import re
1✔
7
from urllib.parse import quote_plus, urljoin, urlparse
1✔
8
from unittest.mock import MagicMock
1✔
9

10
from flask import abort, g, redirect, request
1✔
11
from google.cloud import ndb
1✔
12
from google.cloud.ndb.query import FilterNode, OR, Query
1✔
13
from granary import as1, as2
1✔
14
from httpsig import HeaderVerifier
1✔
15
from httpsig.requests_auth import HTTPSignatureAuth
1✔
16
from httpsig.utils import parse_signature_header
1✔
17
from oauth_dropins.webutil import appengine_info, flask_util, util
1✔
18
from oauth_dropins.webutil.flask_util import MovedPermanently
1✔
19
from oauth_dropins.webutil.util import add, fragmentless, json_dumps, json_loads
1✔
20
import requests
1✔
21
from requests import TooManyRedirects
1✔
22
from requests.models import DEFAULT_REDIRECT_LIMIT
1✔
23
from werkzeug.exceptions import BadGateway
1✔
24

25
from flask_app import app
1✔
26
import common
1✔
27
from common import (
1✔
28
    CACHE_CONTROL,
29
    CACHE_CONTROL_VARY_ACCEPT,
30
    CONTENT_TYPE_HTML,
31
    create_task,
32
    DOMAINS,
33
    DOMAIN_RE,
34
    error,
35
    host_url,
36
    LOCAL_DOMAINS,
37
    PRIMARY_DOMAIN,
38
    PROTOCOL_DOMAINS,
39
    redirect_wrap,
40
    report_error,
41
    subdomain_wrap,
42
    unwrap,
43
)
44
from ids import BOT_ACTOR_AP_IDS
1✔
45
import memcache
1✔
46
from models import fetch_objects, Follower, Object, PROTOCOLS, User
1✔
47
from protocol import activity_id_memcache_key, DELETE_TASK_DELAY, Protocol
1✔
48
import webfinger
1✔
49

50
logger = logging.getLogger(__name__)
1✔
51

52
CONNEG_HEADERS_AS2_HTML = {
1✔
53
    'Accept': f'{as2.CONNEG_HEADERS["Accept"]}, {CONTENT_TYPE_HTML}; q=0.5'
54
}
55

56
HTTP_SIG_HEADERS = ('Date', 'Host', 'Digest', '(request-target)')
1✔
57

58
SECURITY_CONTEXT = 'https://w3id.org/security/v1'
1✔
59

60
# https://www.w3.org/ns/activitystreams#did-core
61
# https://docs.joinmastodon.org/spec/activitypub/#properties-used-1
62
AKA_CONTEXT = {'alsoKnownAs': {'@id': 'as:alsoKnownAs', '@type': '@id'}}
1✔
63

64
# https://seb.jambor.dev/posts/understanding-activitypub-part-4-threads/#the-instance-actor
65
_INSTANCE_ACTOR = None
1✔
66

67
OLD_ACCOUNT_EXEMPT_DOMAINS = (
1✔
68
    'pixelfed.social',
69
)
70

71
# we can't yet authorize activities from these domains:
72
# * a.gup.pe groups sign with the group's actor but use the external author as
73
#   actor and attributedTo, and don't include an LD Sig
74
#   https://github.com/snarfed/bridgy-fed/issues/566#issuecomment-2130714037
75
NO_AUTH_DOMAINS = (
1✔
76
    'a.gup.pe',
77
)
78

79
FEDI_URL_RE = re.compile(r'https://[^/]+/(@|users/)([^/@]+)(@[^/@]+)?(/(?:statuses/)?[0-9]+)?')
1✔
80

81

82
def instance_actor():
1✔
83
    global _INSTANCE_ACTOR
84
    if _INSTANCE_ACTOR is None:
1✔
85
        import web
×
UNCOV
86
        _INSTANCE_ACTOR = web.Web.get_or_create(PRIMARY_DOMAIN)
×
87
    return _INSTANCE_ACTOR
1✔
88

89

90
class ActivityPub(User, Protocol):
1✔
91
    """ActivityPub protocol class.
92

93
    Key id is AP/AS2 actor id URL. (*Not* fediverse/WebFinger @-@ handle!)
94
    """
95
    ABBREV = 'ap'
1✔
96
    PHRASE = 'the fediverse'
1✔
97
    LOGO_HTML = '<img src="/static/fediverse_logo.svg">'
1✔
98
    CONTENT_TYPE = as2.CONTENT_TYPE_LD_PROFILE
1✔
99
    REQUIRES_AVATAR = True
1✔
100
    REQUIRES_NAME = False
1✔
101
    DEFAULT_ENABLED_PROTOCOLS = ('web',)
1✔
102
    SUPPORTED_AS1_TYPES = (
1✔
103
        tuple(as1.ACTOR_TYPES)
104
        + tuple(as1.POST_TYPES)
105
        + tuple(as1.CRUD_VERBS)
106
        + tuple(as1.VERBS_WITH_OBJECT)
107
        + ('audio', 'bookmark', 'image', 'video')
108
    )
109
    SUPPORTED_AS2_TYPES = tuple(
1✔
110
        as2.OBJECT_TYPE_TO_TYPE.get(t) or as2.VERB_TO_TYPE.get(t)
111
        for t in SUPPORTED_AS1_TYPES)
112
    SUPPORTS_DMS = True
1✔
113

114
    @property
1✔
115
    def REQUIRES_OLD_ACCOUNT(self):
1✔
116
        return util.domain_from_link(self.key.id()) not in OLD_ACCOUNT_EXEMPT_DOMAINS
1✔
117

118
    def _pre_put_hook(self):
1✔
119
        r"""Validate id, require URL, don't allow Bridgy Fed domains.
120

121
        TODO: normalize scheme and domain to lower case. Add that to
122
        :class:`oauth_dropins.webutil.util.UrlCanonicalizer`\?
123
        """
124
        super()._pre_put_hook()
1✔
125
        id = self.key.id()
1✔
126
        assert id
1✔
127
        assert util.is_web(id), f'{id} is not a URL'
1✔
128
        domain = util.domain_from_link(id)
1✔
129
        assert domain, 'missing domain'
1✔
130
        assert not self.is_blocklisted(domain), f'{id} is a blocked domain'
1✔
131

132
    def web_url(self):
1✔
133
        """Returns this user's web URL aka web_url, eg ``https://foo.com/``."""
134
        if self.obj and self.obj.as1:
1✔
135
            url = as1.get_url(self.obj.as1)
1✔
136
            if url:
1✔
137
                return url
1✔
138

139
        return self.key.id()
1✔
140

141
    @ndb.ComputedProperty
1✔
142
    def handle(self):
1✔
143
        """Returns this user's ActivityPub address, eg ``@user@foo.com``."""
144
        if self.obj and self.obj.as1:
1✔
145
            addr = as2.address(self._convert(self.obj, from_user=self))
1✔
146
            if addr:
1✔
147
                return addr
1✔
148

149
        return as2.address(self.key.id())
1✔
150

151
    @ndb.ComputedProperty
1✔
152
    def status(self):
1✔
153
        if self.obj and self.obj.as2 and as2.is_server_actor(self.obj.as2):
1✔
154
            return None
1✔
155

156
        status = super().status
1✔
157
        if status:
1✔
158
            return status
1✔
159

160
    @classmethod
1✔
161
    def owns_id(cls, id):
1✔
162
        """Returns None if ``id`` is an http(s) URL, False otherwise.
163

164
        All AP ids are http(s) URLs, but not all http(s) URLs are AP ids.
165

166
        https://www.w3.org/TR/activitypub/#obj-id
167

168
        I used to include a heuristic here that no actor is the root path on its
169
        host, which was nice because it let us assume that home pages are Web
170
        users without making any network requests...but then I inevitably ran
171
        into AP actors that _are_ the root path, eg microblog.pub sites like
172
        https://bw3.dev/ .
173

174
        https://docs.microblog.pub/user_guide.html#activitypub
175
        """
176
        if util.is_web(id) and not cls.is_blocklisted(id):
1✔
177
            return None
1✔
178

179
        return False
1✔
180

181
    @classmethod
1✔
182
    def owns_handle(cls, handle, allow_internal=False):
1✔
183
        """Returns True if handle is a WebFinger ``@-@`` handle, False otherwise.
184

185
        Example: ``@user@instance.com``. The leading ``@`` is optional.
186

187
        https://datatracker.ietf.org/doc/html/rfc7033#section-3.1
188
        https://datatracker.ietf.org/doc/html/rfc7033#section-4.5
189
        """
190
        parts = handle.lstrip('@').split('@')
1✔
191
        if len(parts) != 2:
1✔
192
            return False
1✔
193

194
        user, domain = parts
1✔
195
        return user and domain and not cls.is_blocklisted(
1✔
196
            domain, allow_internal=allow_internal)
197

198
    @classmethod
1✔
199
    def handle_to_id(cls, handle):
1✔
200
        """Looks in the datastore first, then queries WebFinger."""
201
        assert cls.owns_handle(handle)
1✔
202

203
        if not handle.startswith('@'):
1✔
UNCOV
204
            handle = '@' + handle
×
205

206
        user = ActivityPub.query(OR(ActivityPub.handle == handle,
1✔
207
                                    ActivityPub.readable_id == handle),
208
                                 ).get()
209
        if user:
1✔
210
            return user.key.id()
1✔
211

212
        return webfinger.fetch_actor_url(handle)
1✔
213

214
    @classmethod
1✔
215
    def target_for(cls, obj, shared=False):
1✔
216
        """Returns ``obj``'s or its author's/actor's inbox, if available."""
217
        if not obj.as1:
1✔
218
            return None
1✔
219

220
        if obj.type not in as1.ACTOR_TYPES:
1✔
221
            for field in 'actor', 'author', 'attributedTo':
1✔
222
                inner_obj = as1.get_object(obj.as1, field)
1✔
223
                inner_id = inner_obj.get('id') or as1.get_url(inner_obj)
1✔
224
                if (not inner_id
1✔
225
                        or inner_id == obj.as1.get('id')
226
                        or (obj.key and inner_id == obj.key.id())):
227
                    continue
1✔
228

229
                actor = cls.load(inner_id, raise_=False)
1✔
230
                if actor and actor.as1:
1✔
231
                    target = cls.target_for(actor, shared=shared)
1✔
232
                    if target:
1✔
233
                        logger.info(f'Target for {obj.key} via {inner_id} is {target}')
1✔
234
                        return target
1✔
235

236
            logger.info(f'{obj.key} type {obj.type} is not an actor and has no author or actor with inbox')
1✔
237

238
        actor = cls._convert(obj)
1✔
239

240
        if shared:
1✔
241
            shared_inbox = actor.get('endpoints', {}).get('sharedInbox')
1✔
242
            if shared_inbox:
1✔
243
                return shared_inbox
1✔
244

245
        return actor.get('publicInbox') or actor.get('inbox')
1✔
246

247
    @classmethod
1✔
248
    def send(to_cls, obj, url, from_user=None, orig_obj_id=None):
1✔
249
        """Delivers an activity to an inbox URL.
250

251
        If ``obj.recipient_obj`` is set, it's interpreted as the receiving actor
252
        who we're delivering to and its id is populated into ``cc``.
253
        """
254
        if not from_user:
1✔
255
            logger.info('Skipping sending, no from_user!')
1✔
256
            return False
1✔
257
        elif to_cls.is_blocklisted(url):
1✔
258
            logger.info(f'Skipping sending to blocklisted {url}')
1✔
259
            return False
1✔
260

261
        orig_obj = None
1✔
262
        if orig_obj_id:
1✔
263
            orig_obj = to_cls.convert(Object.get_by_id(orig_obj_id),
1✔
264
                                      from_user=from_user)
265
        activity = to_cls.convert(obj, from_user=from_user, orig_obj=orig_obj)
1✔
266

267
        return signed_post(url, data=activity, from_user=from_user).ok
1✔
268

269
    @classmethod
1✔
270
    def fetch(cls, obj, **kwargs):
1✔
271
        """Tries to fetch an AS2 object.
272

273
        Assumes ``obj.id`` is a URL. Any fragment at the end is stripped before
274
        loading. This is currently underspecified and somewhat inconsistent
275
        across AP implementations:
276

277
        * https://socialhub.activitypub.rocks/t/problems-posting-to-mastodon-inbox/801/11
278
        * https://socialhub.activitypub.rocks/t/problems-posting-to-mastodon-inbox/801/23
279
        * https://socialhub.activitypub.rocks/t/s2s-create-activity/1647/5
280
        * https://github.com/mastodon/mastodon/issues/13879 (open!)
281
        * https://github.com/w3c/activitypub/issues/224
282

283
        Uses HTTP content negotiation via the ``Content-Type`` header. If the
284
        url is HTML and it has a ``rel-alternate`` link with an AS2 content
285
        type, fetches and returns that URL.
286

287
        Includes an HTTP Signature with the request.
288

289
        * https://w3c.github.io/activitypub/#authorization
290
        * https://tools.ietf.org/html/draft-cavage-http-signatures-07
291
        * https://github.com/mastodon/mastodon/pull/11269
292

293
        Mastodon requires this signature if ``AUTHORIZED_FETCH`` aka secure mode
294
        is on: https://docs.joinmastodon.org/admin/config/#authorized_fetch
295

296
        Signs the request with the current user's key. If not provided, defaults to
297
        using @snarfed.org@snarfed.org's key.
298

299
        See :meth:`protocol.Protocol.fetch` for more details.
300

301
        Args:
302
          obj (models.Object): with the id to fetch. Fills data into the as2
303
            property.
304
          kwargs: ignored
305

306
        Returns:
307
          bool: True if the object was fetched and populated successfully,
308
          False otherwise
309

310
        Raises:
311
          requests.HTTPError:
312
          werkzeug.exceptions.HTTPException: will have an additional
313
            ``requests_response`` attribute with the last
314
            :class:`requests.Response` we received.
315
        """
316
        url = obj.key.id()
1✔
317
        if not util.is_web(url):
1✔
318
            logger.info(f'{url} is not a URL')
1✔
319
            return False
1✔
320

321
        resp = None
1✔
322

323
        def _error(extra_msg=None):
1✔
324
            msg = f"Couldn't fetch {url} as ActivityStreams 2"
1✔
325
            if extra_msg:
1✔
326
                msg += ': ' + extra_msg
1✔
327
            logger.warning(msg)
1✔
328
            # protocol.for_id depends on us raising this when an AP network
329
            # fetch fails. if we change that, update for_id too!
330
            err = BadGateway(msg)
1✔
331
            err.requests_response = resp
1✔
332
            raise err
1✔
333

334
        def _get(url, headers):
1✔
335
            """Returns None if we fetched and populated, resp otherwise."""
336
            nonlocal resp
337

338
            try:
1✔
339
                resp = signed_get(url, headers=headers, gateway=True)
1✔
340
            except BadGateway as e:
1✔
341
                # ugh, this is ugly, should be something structured
342
                if '406 Client Error' in str(e):
1✔
343
                    return
1✔
344
                raise
1✔
345

346
            if not resp.content:
1✔
347
                _error('empty response')
1✔
348
            elif common.content_type(resp) in as2.CONTENT_TYPES:
1✔
349
                try:
1✔
350
                    return resp.json()
1✔
351
                except requests.JSONDecodeError:
1✔
352
                    _error("Couldn't decode as JSON")
1✔
353

354
        obj.as2 = _get(url, CONNEG_HEADERS_AS2_HTML)
1✔
355

356
        if obj.as2:
1✔
357
            return True
1✔
358
        elif not resp:
1✔
359
            return False
1✔
360

361
        # look in HTML to find AS2 link
362
        if common.content_type(resp) != 'text/html':
1✔
363
            logger.info('no AS2 available')
1✔
364
            return False
1✔
365

366
        parsed = util.parse_html(resp)
1✔
367
        link = parsed.find('link', rel=('alternate', 'self'), type=(
1✔
368
            as2.CONTENT_TYPE, as2.CONTENT_TYPE_LD))
369
        if not (link and link['href']):
1✔
370
            logger.info('no AS2 available')
1✔
371
            return False
1✔
372

373
        obj.as2 = _get(link['href'], as2.CONNEG_HEADERS)
1✔
374
        if obj.as2:
1✔
375
            return True
1✔
376

UNCOV
377
        return False
×
378

379
    @classmethod
1✔
380
    def _convert(cls, obj, orig_obj=None, from_user=None):
1✔
381
        """Convert a :class:`models.Object` to AS2.
382

383
        Args:
384
          obj (models.Object)
385
          orig_obj (dict): AS2 object, optional. The target of activity's
386
            ``inReplyTo`` or ``Like``/``Announce``/etc object, if any. Passed
387
            through to :func:`postprocess_as2`.
388
          from_user (models.User): user (actor) this activity/object is from
389

390
        Returns:
391
          dict: AS2 JSON
392
        """
393
        if not obj or not obj.as1:
1✔
UNCOV
394
            return {}
×
395

396
        # TODO: uncomment
397
        # from_proto = PROTOCOLS.get(obj.source_protocol)
398
        # if from_proto and not from_user.is_enabled(cls):
399
        #     error(f'{cls.LABEL} <=> {from_proto.LABEL} not enabled')
400

401
        if obj.as2:
1✔
402
            return {
1✔
403
                # add back @context since we strip it when we store Objects
404
                '@context': [as2.CONTEXT, SECURITY_CONTEXT],
405
                **obj.as2,
406
            }
407

408
        translated = cls.translate_ids(obj.as1)
1✔
409

410
        # compact actors to just string id for compatibility, since many other
411
        # AP implementations choke on objects.
412
        # https://github.com/snarfed/bridgy-fed/issues/658
413
        #
414
        # TODO: expand this to general purpose compact() function and use
415
        # elsewhere, eg in models.resolve_id
416
        for o in translated, as1.get_object(translated):
1✔
417
            for field in 'actor', 'attributedTo', 'author':
1✔
418
                actors = as1.get_objects(o, field)
1✔
419
                ids = [a['id'] for a in actors if a.get('id')]
1✔
420
                o[field] = ids[0] if len(ids) == 1 else ids
1✔
421

422
        converted = as2.from_as1(translated)
1✔
423

424
        if obj.source_protocol in ('ap', 'activitypub'):
1✔
425
            return converted
1✔
426

427
        # special cases where obj or obj['object'] or obj['object']['object']
428
        # are an actor
429
        if from_user:
1✔
430
            if as1.object_type(obj.as1) in as1.ACTOR_TYPES:
1✔
431
                return postprocess_as2_actor(converted, user=from_user)
1✔
432

433
            inner_obj = as1.get_object(obj.as1)
1✔
434
            if as1.object_type(inner_obj) in as1.ACTOR_TYPES:
1✔
435
                converted['object'] = postprocess_as2_actor(converted['object'],
1✔
436
                                                            user=from_user)
437

438
            # eg Accept of a Follow
439
            if from_user.is_web_url(as1.get_object(inner_obj).get('id')):
1✔
440
                converted['object']['object'] = from_user.id_as(ActivityPub)
1✔
441

442
        # convert!
443
        return postprocess_as2(converted, orig_obj=orig_obj)
1✔
444

445
    @classmethod
1✔
446
    def verify_signature(cls, activity):
1✔
447
        """Verifies the current request's HTTP Signature.
448

449
        Raises :class:`werkzeug.exceptions.HTTPError` if the signature is
450
        missing or invalid, otherwise does nothing and returns the id of the
451
        actor whose key signed the request.
452

453
        Logs details of the result.
454

455
        https://swicg.github.io/activitypub-http-signature/
456

457
        Args:
458
          activity (dict): AS2 activity
459

460
        Returns:
461
          str: signing AP actor id
462
        """
463
        headers = dict(request.headers)  # copy so we can modify below
1✔
464
        sig = headers.get('Signature')
1✔
465
        if not sig:
1✔
466
            if appengine_info.DEBUG:
1✔
467
                logger.info('No HTTP Signature, allowing due to DEBUG=true')
1✔
468
                return
1✔
469
            error('No HTTP Signature', status=401)
1✔
470

471
        logger.debug('Verifying HTTP Signature')
1✔
472
        logger.debug(f'Headers: {json_dumps(headers, indent=2)}')
1✔
473

474
        # parse_signature_header lower-cases all keys
475
        sig_fields = parse_signature_header(sig)
1✔
476
        key_id = fragmentless(sig_fields.get('keyid'))
1✔
477
        if not key_id:
1✔
478
            error('sig missing keyId', status=401)
1✔
479

480
        # TODO: right now, assume hs2019 is rsa-sha256. the real answer is...
481
        # ...complicated and unclear. 🤷
482
        # https://github.com/snarfed/bridgy-fed/issues/430#issuecomment-1510462267
483
        # https://arewehs2019yet.vpzom.click/
484
        # https://socialhub.activitypub.rocks/t/state-of-http-signatures/754/23
485
        # https://socialhub.activitypub.rocks/t/http-signatures-libraray/2087/2
486
        # https://github.com/mastodon/mastodon/pull/14556
487
        if sig_fields.get('algorithm') == 'hs2019':
1✔
UNCOV
488
            headers['Signature'] = headers['Signature'].replace(
×
489
                'algorithm="hs2019"', 'algorithm=rsa-sha256')
490

491
        digest = headers.get('Digest') or ''
1✔
492
        if not digest:
1✔
UNCOV
493
            error('Missing Digest', status=401)
×
494

495
        expected = b64encode(sha256(request.data).digest()).decode()
1✔
496
        if digest.removeprefix('SHA-256=').removeprefix('sha-256=') != expected:
1✔
497
            error('Invalid Digest', status=401)
1✔
498

499
        try:
1✔
500
            key_actor = cls._load_key(key_id)
1✔
501
        except BadGateway:
1✔
502
            obj_id = as1.get_object(activity).get('id')
1✔
503
            if (activity.get('type') == 'Delete' and obj_id
1✔
504
                    and key_id == fragmentless(obj_id)):
505
                logger.debug('Object/actor being deleted is also keyId')
1✔
506
                key_actor = Object.get_or_create(
1✔
507
                    id=key_id, authed_as=key_id, source_protocol='activitypub',
508
                    deleted=True)
509
            else:
UNCOV
510
                raise
×
511

512
        if key_actor and key_actor.deleted:
1✔
513
            abort(202, f'Ignoring, signer {key_id} is already deleted')
1✔
514
        elif not key_actor or not key_actor.as1:
1✔
UNCOV
515
            error(f"Couldn't load {key_id} to verify signature", status=401)
×
516

517
        # don't ActivityPub.convert since we don't want to postprocess_as2
518
        key = as2.from_as1(key_actor.as1).get('publicKey', {}).get('publicKeyPem')
1✔
519
        if not key:
1✔
520
            error(f'No public key for {key_id}', status=401)
1✔
521

522
        # can't use request.full_path because it includes a trailing ? even if
523
        # it wasn't in the request. https://github.com/pallets/flask/issues/2867
524
        path_query = request.url.removeprefix(request.host_url.rstrip('/'))
1✔
525
        logger.debug(f'Verifying signature for {path_query} with key {sig_fields["keyid"]}')
1✔
526
        try:
1✔
527
            verified = HeaderVerifier(headers, key,
1✔
528
                                      required_headers=['Digest'],
529
                                      method=request.method,
530
                                      path=path_query,
531
                                      sign_header='signature',
532
                                      ).verify()
533
        except BaseException as e:
×
UNCOV
534
            error(f'sig verification failed: {e}', status=401)
×
535

536
        if verified:
1✔
537
            logger.debug('sig ok')
1✔
538
        else:
539
            error('sig failed', status=401)
1✔
540

541
        return key_actor.key.id()
1✔
542

543
    @classmethod
1✔
544
    def _load_key(cls, key_id, follow_owner=True):
1✔
545
        """Loads the ActivityPub actor for a given ``keyId``.
546

547
        https://swicg.github.io/activitypub-http-signature/#how-to-obtain-a-signature-s-public-key
548
        Args:
549
          key_id (str): ``keyId`` from an HTTP Signature
550
          follow_owner (bool): whether to follow ``owner``/``controller`` fields
551

552
        Returns:
553
          Object or None:
554

555
        Raises:
556
          requests.HTTPError:
557
        """
558
        assert '#' not in key_id
1✔
559
        actor = cls.load(key_id)
1✔
560
        if not actor:
1✔
UNCOV
561
            return None
×
562

563
        if follow_owner and actor.as1:
1✔
564
            actor_as2 = as2.from_as1(actor.as1)
1✔
565
            key = actor_as2.get('publicKey', {})
1✔
566
            owner = key.get('controller') or key.get('owner')
1✔
567
            if not owner and actor.type not in as1.ACTOR_TYPES:
1✔
UNCOV
568
                owner = actor_as2.get('controller') or actor_as2.get('owner')
×
569

570
            if owner:
1✔
571
                owner = fragmentless(owner)
1✔
572
                if owner != key_id:
1✔
573
                    logger.debug(f'keyId {key_id} has controller/owner {owner}, fetching that')
1✔
574
                    return cls._load_key(owner, follow_owner=False)
1✔
575

576
        return actor
1✔
577

578

579
def signed_get(url, from_user=None, **kwargs):
1✔
580
    return signed_request(util.requests_get, url, from_user=from_user, **kwargs)
1✔
581

582

583
def signed_post(url, from_user, **kwargs):
1✔
584
    assert from_user
1✔
585
    return signed_request(util.requests_post, url, from_user=from_user, **kwargs)
1✔
586

587

588
def signed_request(fn, url, data=None, headers=None, from_user=None,
1✔
589
                   _redirect_count=None, **kwargs):
590
    """Wraps ``requests.*`` and adds HTTP Signature.
591

592
    https://swicg.github.io/activitypub-http-signature/
593

594
    Args:
595
      fn (callable): :func:`util.requests_get` or  :func:`util.requests_post`
596
      url (str):
597
      data (dict): optional AS2 object
598
      from_user (models.User): user to sign request as; optional. If not
599
        provided, uses the default user ``@fed.brid.gy@fed.brid.gy``.
600
      _redirect_count: internal, used to count redirects followed so far
601
      kwargs: passed through to requests
602

603
    Returns:
604
      requests.Response:
605
    """
606
    if headers is None:
1✔
607
        headers = {}
1✔
608

609
    # prepare HTTP Signature and headers
610
    if not from_user or isinstance(from_user, ActivityPub):
1✔
611
        # ActivityPub users are remote, so we don't have their keys
612
        from_user = instance_actor()
1✔
613

614
    if data:
1✔
615
        logger.debug(f'Sending AS2 object: {json_dumps(data, indent=2)}')
1✔
616
        data = json_dumps(data).encode()
1✔
617

618
    headers = {
1✔
619
        **headers,
620
        # required for HTTP Signature
621
        # https://tools.ietf.org/html/draft-cavage-http-signatures-07#section-2.1.3
622
        'Date': util.now().strftime('%a, %d %b %Y %H:%M:%S GMT'),
623
        # required by Mastodon
624
        # https://github.com/tootsuite/mastodon/pull/14556#issuecomment-674077648
625
        'Host': util.domain_from_link(url, minimize=False),
626
        'Content-Type': as2.CONTENT_TYPE_LD_PROFILE,
627
        # required for HTTP Signature and Mastodon
628
        'Digest': f'SHA-256={b64encode(sha256(data or b"").digest()).decode()}',
629
    }
630

631
    logger.debug(f"Signing with {from_user.key.id()} 's key")
1✔
632

633
    # (request-target) is a special HTTP Signatures header that some fediverse
634
    # implementations require, eg Peertube.
635
    # https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#section-2.3
636
    # https://www.w3.org/wiki/SocialCG/ActivityPub/Authentication_Authorization#Signing_requests_using_HTTP_Signatures
637
    # https://docs.joinmastodon.org/spec/security/#http
638
    key_id = f'{from_user.id_as(ActivityPub)}#key'
1✔
639
    auth = HTTPSignatureAuth(secret=from_user.private_pem(), key_id=key_id,
1✔
640
                             algorithm='rsa-sha256', sign_header='signature',
641
                             headers=HTTP_SIG_HEADERS)
642

643
    # make HTTP request
644
    kwargs.setdefault('gateway', True)
1✔
645
    resp = fn(url, data=data, auth=auth, headers=headers, allow_redirects=False,
1✔
646
              **kwargs)
647

648
    if fn == util.requests_get:
1✔
649
        assert not isinstance(resp, MagicMock), \
1✔
650
            f'unit test missing a mock HTTP response for {url}'
651

652
    # handle GET redirects manually so that we generate a new HTTP signature
653
    if resp.is_redirect and fn == util.requests_get:
1✔
654
        new_url = urljoin(url, resp.headers['Location'])
1✔
655
        if _redirect_count is None:
1✔
656
            _redirect_count = 0
1✔
657
        elif _redirect_count > DEFAULT_REDIRECT_LIMIT:
1✔
658
            raise TooManyRedirects(response=resp)
1✔
659

660
        return signed_request(fn, new_url, data=data, from_user=from_user,
1✔
661
                              headers=headers, _redirect_count=_redirect_count + 1,
662
                              **kwargs)
663

664
    type = common.content_type(resp)
1✔
665
    if (type and type != 'text/html' and
1✔
666
        (type.startswith('text/') or type.endswith('+json')
667
         or type.endswith('/json'))):
668
        logger.debug(resp.text)
1✔
669

670
    return resp
1✔
671

672

673
def postprocess_as2(activity, orig_obj=None, wrap=True):
1✔
674
    """Prepare an AS2 object to be served or sent via ActivityPub.
675

676
    TODO: get rid of orig_obj! https://github.com/snarfed/bridgy-fed/issues/1257
677

678
    Args:
679
      activity (dict): AS2 object or activity
680
      orig_obj (dict): AS2 object, optional. The target of activity's
681
        ``inReplyTo`` or ``Like``/``Announce``/etc object, if any.
682
      wrap (bool): whether to wrap ``id``, ``url``, ``object``, ``actor``, and
683
       ``attributedTo``
684
    """
685
    if not activity or isinstance(activity, str):
1✔
UNCOV
686
        return redirect_wrap(activity) if wrap else activity
×
687
    elif activity.keys() == {'id'}:
1✔
688
        return redirect_wrap(activity['id']) if wrap else activity['id']
1✔
689

690
    type = activity.get('type')
1✔
691

692
    # inReplyTo: singly valued, prefer id over url
693
    # TODO: ignore orig_obj, do for all inReplyTo
694
    orig_id = orig_obj.get('id') if orig_obj else None
1✔
695
    in_reply_to = util.get_list(activity, 'inReplyTo')
1✔
696
    if in_reply_to:
1✔
697
        if orig_id:  # TODO: and orig_id in in_reply_to ...or get rid of orig_obj
1✔
698
            activity['inReplyTo'] = orig_id
1✔
699
        elif len(in_reply_to) > 1:
1✔
700
            # AS2 inReplyTo can be multiply valued, it's not marked Functional:
701
            # https://www.w3.org/TR/activitystreaams-vocabulary/#dfn-inreplyto
702
            # ...but most fediverse projects don't support that:
703
            # https://funfedi.dev/support_tables/generated/in_reply_to/
704
            logger.warning(
1✔
705
                "AS2 doesn't support multiple inReplyTo URLs! "
706
                f'Only using the first: {in_reply_to[0]}')
707
            activity['inReplyTo'] = in_reply_to[0]
1✔
708

709
        # Mastodon evidently requires a Mention tag for replies to generate a
710
        # notification to the original post's author. not required for likes,
711
        # reposts, etc. details:
712
        # https://github.com/snarfed/bridgy-fed/issues/34
713
        if orig_obj:
1✔
714
            for to in (util.get_list(orig_obj, 'attributedTo') +
1✔
715
                       util.get_list(orig_obj, 'author') +
716
                       util.get_list(orig_obj, 'actor')):
717
                if isinstance(to, dict):
1✔
718
                    to = util.get_first(to, 'url') or to.get('id')
1✔
719
                if to:
1✔
720
                    add(activity.setdefault('tag', []), {
1✔
721
                        'type': 'Mention',
722
                        'href': to,
723
                    })
724

725
    # activity objects (for Like, Announce, etc): prefer id over url
726
    obj = as1.get_object(activity)
1✔
727
    id = obj.get('id')
1✔
728
    if orig_id and type in as2.TYPES_WITH_OBJECT and type != 'Undo':
1✔
729
        # inline most objects as bare string ids, not composite objects, for interop
730
        activity['object'] = orig_id
1✔
731
    elif not id:
1✔
732
        obj['id'] = util.get_first(obj, 'url')
1✔
733

734
    # id is required for most things. default to url if it's not set.
735
    if not activity.get('id'):
1✔
736
        activity['id'] = util.get_first(activity, 'url')
1✔
737

738
    if wrap:
1✔
739
        # some fediverse servers (eg Misskey) require activity id and actor id
740
        # to be on the same domain
741
        # https://github.com/snarfed/bridgy-fed/issues/1093#issuecomment-2299247639
742
        redirect_domain = util.domain_from_link(as1.get_id(activity, 'actor'))
1✔
743
        if redirect_domain not in DOMAINS:
1✔
744
            redirect_domain = None
1✔
745
        activity['id'] = redirect_wrap(activity.get('id'), domain=redirect_domain)
1✔
746
        activity['url'] = [redirect_wrap(u) for u in util.get_list(activity, 'url')]
1✔
747
        if len(activity['url']) == 1:
1✔
748
            activity['url'] = activity['url'][0]
1✔
749

750
    # TODO: find a better way to check this, sometimes or always?
751
    # removed for now since it fires on posts without u-id or u-url, eg
752
    # https://chrisbeckstrom.com/2018/12/27/32551/
753
    # assert activity.get('id') or (isinstance(obj, dict) and obj.get('id'))
754

755
    obj_or_activity = obj if obj.keys() > set(['id']) else activity
1✔
756

757
    # move Link attachments to links in text since fediverse instances generate
758
    # their own link previews.
759
    # https://github.com/snarfed/bridgy-fed/issues/958
760
    atts = util.pop_list(obj_or_activity, 'attachment')
1✔
761
    obj_or_activity['attachment'] = [a for a in atts if a.get('type') != 'Link']
1✔
762
    link_atts = [a for a in atts if a.get('type') == 'Link']
1✔
763

764
    for link in link_atts:
1✔
765
        for url in util.get_list(link, 'href'):
1✔
766
            if obj_or_activity.setdefault('content', ''):
1✔
767
                obj_or_activity['content'] += '<br><br>'
1✔
768
            obj_or_activity['content'] += util.pretty_link(url, text=link.get('name'))
1✔
769

770
    # copy image(s) into attachment(s). may be Mastodon-specific.
771
    # https://github.com/snarfed/bridgy-fed/issues/33#issuecomment-440965618
772
    imgs = util.get_list(obj_or_activity, 'image')
1✔
773
    if imgs:
1✔
774
        atts = obj_or_activity['attachment']
1✔
775
        for img in imgs:
1✔
776
            if isinstance(img, str):
1✔
777
                img = {'url': img}
1✔
778
            add(atts, img)
1✔
779

780
    # cc target's author(s), recipients, mentions
781
    # https://www.w3.org/TR/activitystreams-vocabulary/#audienceTargeting
782
    # https://w3c.github.io/activitypub/#delivery
783
    # https://docs.joinmastodon.org/spec/activitypub/#Mention
784
    cc = obj_or_activity.setdefault('cc', [])
1✔
785

786
    tags = util.get_list(activity, 'tag') + util.get_list(obj, 'tag')
1✔
787
    for tag in tags:
1✔
788
        href = tag.get('href')
1✔
789
        if (tag.get('type') == 'Mention'
1✔
790
                and href
791
                and href not in util.get_list(obj_or_activity, 'to')
792
                and not ActivityPub.is_blocklisted(href)):
793
            add(cc, href)
1✔
794

795
    if orig_obj and type in as2.TYPE_TO_VERB:
1✔
796
        for field in 'actor', 'attributedTo', 'to', 'cc':
1✔
797
            for recip in as1.get_objects(orig_obj, field):
1✔
798
                add(cc, util.get_url(recip) or recip.get('id'))
1✔
799

800
    # for some activities, Pleroma (and Akkoma?) seem to crash if the activity's
801
    # to and cc aren't exactly the same as the object's. (I think?)
802
    # https://indieweb.social/@diego@lounge.collabfc.com/112977955332152430
803
    # https://git.pleroma.social/pleroma/pleroma/-/issues/3206#note_108296
804
    # https://github.com/snarfed/bridgy-fed/issues/12#issuecomment-2302776658
805
    if type in ('Create', 'Update'):
1✔
806
        activity['to'] = util.get_list(obj, 'to')
1✔
807
        activity['cc'] = util.get_list(obj, 'cc')
1✔
808

809
    # WARNING: activity here is AS2, but we're using as1.is_dm. right now the
810
    # logic is effectively the same for our purposes, but watch out here if that
811
    # ever changes.
812
    if not as1.is_dm(activity):
1✔
813
        # to public, since Mastodon interprets to public as public, cc public as
814
        # unlisted:
815
        # https://socialhub.activitypub.rocks/t/visibility-to-cc-mapping/284
816
        # https://wordsmith.social/falkreon/securing-activitypub
817
        add(activity.setdefault('to', []), as2.PUBLIC_AUDIENCE)
1✔
818

819
    # hashtags. Mastodon requires:
820
    # * type: Hashtag
821
    # * name starts with #
822
    # * href is set to a valid, fully qualified URL
823
    #
824
    # If content has an <a> tag with a fully qualified URL and the hashtag name
825
    # (with leading #) as its text, Mastodon will rewrite its href to the local
826
    # instance's search for that hashtag. If content doesn't have a link for a
827
    # given hashtag, Mastodon won't add one, but that hashtag will still be
828
    # indexed in search.
829
    #
830
    # https://docs.joinmastodon.org/spec/activitypub/#properties-used
831
    # https://github.com/snarfed/bridgy-fed/issues/45
832
    for tag in tags:
1✔
833
        name = tag.get('name')
1✔
834
        if name and tag.get('type', 'Tag') == 'Tag':
1✔
835
            tag['type'] = 'Hashtag'
1✔
836
            url_path = f'/hashtag/{quote_plus(name.removeprefix("#"))}'
1✔
837
            tag.setdefault('href', urljoin(activity['id'], url_path))
1✔
838
            if not name.startswith('#'):
1✔
839
                tag['name'] = f'#{name}'
1✔
840

841
    as2.link_tags(obj_or_activity)
1✔
842

843
    activity['object'] = [
1✔
844
        postprocess_as2(o, orig_obj=orig_obj,
845
                        wrap=wrap and type in ('Create', 'Update', 'Delete'))
846
        for o in as1.get_objects(activity)]
847
    if len(activity['object']) == 1:
1✔
848
        activity['object'] = activity['object'][0]
1✔
849

850
    if content := obj_or_activity.get('content'):
1✔
851
        # wrap in <p>. some fediverse servers (eg Mastodon) have a white-space:
852
        # pre-wrap style that applies to p inside content. this preserves
853
        # meaningful whitespace in plain text content.
854
        # https://github.com/snarfed/bridgy-fed/issues/990
855
        if not content.startswith('<p>'):
1✔
856
            content = obj_or_activity['content'] = f'<p>{content}</p>'
1✔
857

858
        # language, in contentMap
859
        # https://github.com/snarfed/bridgy-fed/issues/681
860
        obj_or_activity.setdefault('contentMap', {'en': content})
1✔
861

862
    activity.pop('content_is_html', None)
1✔
863
    return util.trim_nulls(activity)
1✔
864

865

866
def postprocess_as2_actor(actor, user):
1✔
867
    """Prepare an AS2 actor object to be served or sent via ActivityPub.
868

869
    Modifies actor in place.
870

871
    Args:
872
      actor (dict): AS2 actor object
873
      user (models.User): current user
874

875
    Returns:
876
      actor dict
877
    """
878
    if not actor:
1✔
UNCOV
879
        return actor
×
880

881
    assert isinstance(actor, dict)
1✔
882
    assert user
1✔
883

884
    url = user.web_url()
1✔
885
    urls = [u for u in util.get_list(actor, 'url') if u and not u.startswith('acct:')]
1✔
886
    if not urls and url:
1✔
887
        urls = [url]
1✔
888
    if urls:
1✔
889
        urls[0] = redirect_wrap(urls[0])
1✔
890

891
    id = actor.get('id')
1✔
892
    user_id = user.key.id()
1✔
893
    if not id or user.is_web_url(id) or unwrap(id) in (
1✔
894
            user_id, user.profile_id(), f'www.{user_id}'):
895
        id = actor['id'] = user.id_as(ActivityPub)
1✔
896

897
    actor['url'] = urls[0] if len(urls) == 1 else urls
1✔
898
    # required by ActivityPub
899
    # https://www.w3.org/TR/activitypub/#actor-objects
900
    actor.setdefault('inbox', id + '/inbox')
1✔
901
    actor.setdefault('outbox', id + '/outbox')
1✔
902

903
    # For web, this has to be domain for Mastodon etc interop! It seems like it
904
    # should be the custom username from the acct: u-url in their h-card, but
905
    # that breaks Mastodon's Webfinger discovery.
906
    # Background:
907
    # https://docs.joinmastodon.org/spec/activitypub/#properties-used-1
908
    # https://docs.joinmastodon.org/spec/webfinger/#mastodons-requirements-for-webfinger
909
    # https://github.com/snarfed/bridgy-fed/issues/302#issuecomment-1324305460
910
    # https://github.com/snarfed/bridgy-fed/issues/77
911
    if user.LABEL == 'web':
1✔
912
        actor['preferredUsername'] = user.key.id()
1✔
913
    else:
914
        handle = user.handle_as(ActivityPub)
1✔
915
        if handle:
1✔
916
            actor['preferredUsername'] = handle.strip('@').split('@')[0]
1✔
917

918
    # Override the label for their home page to be "Web site"
919
    for att in util.get_list(actor, 'attachment'):
1✔
920
        if att.get('type') == 'PropertyValue':
1✔
921
            val = att.get('value', '')
1✔
922
            link = util.parse_html(val).find('a')
1✔
923
            if url and link and url.rstrip('/') in [val.rstrip('/'),
1✔
924
                                                    link.get('href').rstrip('/')]:
925
                att['name'] = 'Web site'
1✔
926

927
    # required by pixelfed. https://github.com/snarfed/bridgy-fed/issues/39
928
    actor.setdefault('summary', '')
1✔
929

930
    if not actor.get('publicKey') and not isinstance(user, ActivityPub):
1✔
931
        # underspecified, inferred from this issue and Mastodon's implementation:
932
        # https://github.com/w3c/activitypub/issues/203#issuecomment-297553229
933
        # https://github.com/tootsuite/mastodon/blob/bc2c263504e584e154384ecc2d804aeb1afb1ba3/app/services/activitypub/process_account_service.rb#L77
934
        actor['publicKey'] = {
1✔
935
            'id': f'{id}#key',
936
            'owner': id,
937
            'publicKeyPem': user.public_pem().decode(),
938
        }
939
        actor['@context'] = util.get_list(actor, '@context')
1✔
940
        add(actor['@context'], SECURITY_CONTEXT)
1✔
941

942
    return actor
1✔
943

944

945
def _load_user(handle_or_id, create=False):
1✔
946
    if handle_or_id == PRIMARY_DOMAIN or handle_or_id in PROTOCOL_DOMAINS:
1✔
947
        from web import Web
1✔
948
        proto = Web
1✔
949
    else:
950
        proto = Protocol.for_request(fed='web')
1✔
951

952
    if not proto:
1✔
953
        error(f"Couldn't determine protocol", status=404)
1✔
954

955
    if proto.owns_id(handle_or_id) is False:
1✔
956
        if proto.owns_handle(handle_or_id) is False:
1✔
957
            error(f"{handle_or_id} doesn't look like a {proto.LABEL} id or handle",
1✔
958
                  status=404)
959
        id = proto.handle_to_id(handle_or_id)
×
960
        if not id:
×
UNCOV
961
            error(f"Couldn't resolve {handle_or_id} as a {proto.LABEL} handle",
×
962
                  status=404)
963
    else:
964
        id = handle_or_id
1✔
965

966
    assert id
1✔
967
    try:
1✔
968
        user = proto.get_or_create(id) if create else proto.get_by_id(id)
1✔
969
    except ValueError as e:
1✔
970
        logging.warning(e)
1✔
971
        user = None
1✔
972

973
    if not user or not user.is_enabled(ActivityPub):
1✔
974
        error(f'{proto.LABEL} user {id} not found', status=404)
1✔
975

976
    return user
1✔
977

978

979
# source protocol in subdomain.
980
# WARNING: the user page handler in pages.py overrides this for fediverse
981
# addresses with leading @ character. be careful when changing this route!
982
@app.get(f'/ap/<handle_or_id>')
1✔
983
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
984
# backward compatibility
985
@app.get(f'/<regex("{DOMAIN_RE}"):handle_or_id>')
1✔
986
@flask_util.headers(CACHE_CONTROL_VARY_ACCEPT)
1✔
987
def actor(handle_or_id):
1✔
988
    """Serves a user's AS2 actor from the datastore."""
989
    user = _load_user(handle_or_id, create=True)
1✔
990
    proto = user
1✔
991

992
    as2_type = common.as2_request_type()
1✔
993
    if not as2_type:
1✔
994
        return redirect(user.web_url(), code=302)
1✔
995

996
    if proto.LABEL == 'web' and request.path.startswith('/ap/'):
1✔
997
        # we started out with web users' AP ids as fed.brid.gy/[domain], so we
998
        # need to preserve those for backward compatibility
999
        raise MovedPermanently(location=subdomain_wrap(None, f'/{handle_or_id}'))
1✔
1000

1001
    id = user.id_as(ActivityPub)
1✔
1002
    # check that we're serving from the right subdomain
1003
    if request.host != urlparse(id).netloc:
1✔
1004
        raise MovedPermanently(location=id)
1✔
1005

1006
    actor = ActivityPub.convert(user.obj, from_user=user) or {
1✔
1007
        '@context': [as2.CONTEXT],
1008
        'type': 'Person',
1009
    }
1010
    actor = postprocess_as2_actor(actor, user=user)
1✔
1011

1012
    actor['@context'] = util.get_list(actor, '@context')
1✔
1013
    add(actor['@context'], AKA_CONTEXT)
1✔
1014
    actor.setdefault('alsoKnownAs', [user.id_uri()])
1✔
1015

1016
    actor.update({
1✔
1017
        'id': id,
1018
        'inbox': id + '/inbox',
1019
        'outbox': id + '/outbox',
1020
        'following': id + '/following',
1021
        'followers': id + '/followers',
1022
        'endpoints': {
1023
            'sharedInbox': urljoin(id, '/ap/sharedInbox'),
1024
        },
1025
    })
1026

1027
    logger.debug(f'Returning: {json_dumps(actor, indent=2)}')
1✔
1028
    return actor, {
1✔
1029
        'Content-Type': as2_type,
1030
        'Access-Control-Allow-Origin': '*',
1031
    }
1032

1033

1034
# note that this shared inbox path overlaps with the /ap/<handle_or_id> actor
1035
# route above, but doesn't collide because this is POST and that one is GET.
1036
@app.post('/ap/sharedInbox')
1✔
1037
# source protocol in subdomain
1038
@app.post(f'/ap/<id>/inbox')
1✔
1039
# source protocol in path; primarily for backcompat
1040
@app.post(f'/ap/<protocol>/<id>/inbox')
1✔
1041
# special case Web users on fed subdomain without /ap/web/ prefix
1042
@app.post(f'/<regex("{DOMAIN_RE}"):id>/inbox')
1✔
1043
def inbox(protocol=None, id=None):
1✔
1044
    """Handles ActivityPub inbox delivery."""
1045
    # parse and validate AS2 activity
1046
    try:
1✔
1047
        activity = request.json
1✔
1048
        assert activity and isinstance(activity, dict)
1✔
1049
    except (TypeError, ValueError, AssertionError):
×
1050
        body = request.get_data(as_text=True)
×
UNCOV
1051
        error(f"Couldn't parse body as non-empty JSON mapping: {body}", exc_info=True)
×
1052

1053
    # do we support this object type?
1054
    # (this logic is duplicated in Protocol.check_supported)
1055
    obj = as1.get_object(activity)
1✔
1056
    if type := activity.get('type'):
1✔
1057
        inner_type = as1.object_type(obj) or ''
1✔
1058
        if (type not in ActivityPub.SUPPORTED_AS2_TYPES or
1✔
1059
            (type in as2.CRUD_VERBS
1060
             and inner_type
1061
             and inner_type not in ActivityPub.SUPPORTED_AS2_TYPES)):
1062
            error(f"Bridgy Fed for ActivityPub doesn't support {type} {inner_type} yet: {json_dumps(activity, indent=2)}", status=204)
1✔
1063

1064
    # check actor, authz actor's domain against activity and object ids
1065
    # https://github.com/snarfed/bridgy-fed/security/advisories/GHSA-37r7-jqmr-3472
1066
    actor = (as1.get_object(activity, 'actor')
1✔
1067
             or as1.get_object(activity, 'attributedTo'))
1068
    actor_id = actor.get('id')
1✔
1069

1070
    if ActivityPub.is_blocklisted(actor_id):
1✔
1071
        error(f'Actor {actor_id} is blocklisted')
1✔
1072

1073
    actor_domain = util.domain_from_link(actor_id)
1✔
1074
    # temporary, see emails w/Michael et al, and
1075
    # https://github.com/snarfed/bridgy-fed/issues/1686
1076
    if actor_domain == 'newsmast.community' and type == 'Undo':
1✔
UNCOV
1077
        return ':(', 204
×
1078

1079
    id = activity.get('id')
1✔
1080
    obj_id = obj.get('id')
1✔
1081
    if id and actor_domain != util.domain_from_link(id):
1✔
1082
        report_error(f'Auth: actor and activity on different domains: {json_dumps(activity, indent=2)}',
1✔
1083
                     user=f'actor {actor_id} activity {id}')
1084
        return f'actor {actor_id} and activity {id} on different domains', 403
1✔
1085
    elif (type in as2.CRUD_VERBS and obj_id
1✔
1086
          and actor_domain != util.domain_from_link(obj_id)):
1087
        report_error(f'Auth: actor and object on different domains {json_dumps(activity, indent=2)}',
1✔
1088
                     user=f'actor {actor_id} object {obj_id}')
1089
        return f'actor {actor_id} and object {obj_id} on different domains', 403
1✔
1090

1091
    # are we already processing or done with this activity?
1092
    if id:
1✔
1093
        domain = util.domain_from_link(id)
1✔
1094
        if memcache.memcache.get(activity_id_memcache_key(id)):
1✔
1095
            logger.info(f'Already seen {id}')
1✔
1096
            return '', 204
1✔
1097

1098
    # check signature, auth
1099
    authed_as = ActivityPub.verify_signature(activity)
1✔
1100

1101
    authed_domain = util.domain_from_link(authed_as)
1✔
1102
    if util.domain_or_parent_in(authed_domain, NO_AUTH_DOMAINS):
1✔
1103
        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)
1✔
1104

1105
    # if we need the LD Sig to authorize this activity, bail out, we don't do
1106
    # those yet
1107
    if authed_as != actor_id and activity.get('signature'):
1✔
1108
        error(f"Ignoring LD Signature, sorry, we can't verify those yet. https://github.com/snarfed/bridgy-fed/issues/566", status=202)
1✔
1109

1110
    logger.info(f'Got {type} {id} from {actor_id}')
1✔
1111

1112
    if type == 'Follow':
1✔
1113
        # rendered mf2 HTML proxy pages (in render.py) fall back to redirecting
1114
        # to the follow's AS2 id field, but Mastodon's Accept ids are URLs that
1115
        # don't load in browsers, eg:
1116
        # https://jawns.club/ac33c547-ca6b-4351-80d5-d11a6879a7b0
1117
        #
1118
        # so, set a synthetic URL based on the follower's profile.
1119
        # https://github.com/snarfed/bridgy-fed/issues/336
1120
        follower_url = unwrap(util.get_url(activity, 'actor'))
1✔
1121
        followee_url = unwrap(util.get_url(activity, 'object'))
1✔
1122
        activity.setdefault('url', f'{follower_url}#followed-{followee_url}')
1✔
1123

1124
    if not id:
1✔
1125
        id = f'{actor_id}#{type}-{obj_id or ""}-{util.now().isoformat()}'
1✔
1126

1127
    # automatically bridge server aka instance actors
1128
    # https://codeberg.org/fediverse/fep/src/branch/main/fep/d556/fep-d556.md
1129
    if as2.is_server_actor(actor):
1✔
1130
        all_protocols = [
1✔
1131
            label for label, proto in PROTOCOLS.items()
1132
            if label and proto and label not in ('ui', 'activitypub', 'ap')]
1133
        user = ActivityPub.get_or_create(actor_id, propagate=True,
1✔
1134
                                         enabled_protocols=all_protocols)
1135
        if user and not user.existing:
1✔
1136
            logger.info(f'Automatically enabled AP server actor {actor_id} for ')
1✔
1137

1138
    delay = DELETE_TASK_DELAY if type in ('Delete', 'Undo') else None
1✔
1139
    return create_task(queue='receive', id=id, as2=activity,
1✔
1140
                       source_protocol=ActivityPub.LABEL, authed_as=authed_as,
1141
                       received_at=util.now().isoformat(), delay=delay)
1142

1143

1144
# protocol in subdomain
1145
@app.get(f'/ap/<id>/<any(followers,following):collection>')
1✔
1146
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
1147
# backward compatibility
1148
@app.route(f'/<regex("{DOMAIN_RE}"):id>/<any(followers,following):collection>',
1✔
1149
           methods=['GET', 'HEAD'])
1150
@flask_util.headers(CACHE_CONTROL)
1✔
1151
def follower_collection(id, collection):
1✔
1152
    """ActivityPub Followers and Following collections.
1153

1154
    * https://www.w3.org/TR/activitypub/#followers
1155
    * https://www.w3.org/TR/activitypub/#collections
1156
    * https://www.w3.org/TR/activitystreams-core/#paging
1157

1158
    TODO: unify page generation with outbox()
1159
    """
1160
    if (request.path.startswith('/ap/')
1✔
1161
            and request.host in (PRIMARY_DOMAIN,) + LOCAL_DOMAINS):
1162
        # UI request. unfortunate that the URL paths overlap like this!
1163
        import pages
1✔
1164
        return pages.followers_or_following('ap', id, collection)
1✔
1165

1166
    user = _load_user(id)
1✔
1167

1168
    if request.method == 'HEAD':
1✔
1169
        return '', {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
1✔
1170

1171
    # page
1172
    followers, new_before, new_after = Follower.fetch_page(collection, user=user)
1✔
1173
    page = {
1✔
1174
        'type': 'CollectionPage',
1175
        'partOf': request.base_url,
1176
        'items': util.trim_nulls([ActivityPub.convert(f.user.obj, from_user=f.user)
1177
                                  for f in followers]),
1178
    }
1179
    if new_before:
1✔
1180
        page['next'] = f'{request.base_url}?before={new_before}'
1✔
1181
    if new_after:
1✔
1182
        page['prev'] = f'{request.base_url}?after={new_after}'
1✔
1183

1184
    if 'before' in request.args or 'after' in request.args:
1✔
1185
        page.update({
1✔
1186
            '@context': 'https://www.w3.org/ns/activitystreams',
1187
            'id': request.url,
1188
        })
1189
        logger.debug(f'Returning {json_dumps(page, indent=2)}')
1✔
1190
        return page, {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
1✔
1191

1192
    ret = {
1✔
1193
        '@context': 'https://www.w3.org/ns/activitystreams',
1194
        'id': request.base_url,
1195
        'type': 'Collection',
1196
        'summary': f"{id}'s {collection}",
1197
        'first': page,
1198
    }
1199

1200
    # count total if it's small, <= 1k. we should eventually precompute this
1201
    # so that we can always return it cheaply.
1202
    prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1203
    count = Follower.query(prop == user.key, Follower.status == 'active')\
1✔
1204
                    .count(limit=1001)
1205
    if count != 1001:
1✔
1206
        ret['totalItems'] = count
1✔
1207

1208
    logger.debug(f'Returning {json_dumps(collection, indent=2)}')
1✔
1209
    return ret, {
1✔
1210
        'Content-Type': as2.CONTENT_TYPE_LD_PROFILE,
1211
    }
1212

1213

1214
# protocol in subdomain
1215
@app.get(f'/ap/<id>/outbox')
1✔
1216
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
1217
# backward compatibility
1218
@app.route(f'/<regex("{DOMAIN_RE}"):id>/outbox', methods=['GET', 'HEAD'])
1✔
1219
@flask_util.headers(CACHE_CONTROL)
1✔
1220
def outbox(id):
1✔
1221
    """Serves a user's AP outbox.
1222

1223
    TODO: unify page generation with follower_collection()
1224
    """
1225
    user = _load_user(id)
1✔
1226

1227
    if request.method == 'HEAD':
1✔
1228
        return '', {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
1✔
1229

1230
    # TODO: bring this back once we filter it by author status, etc
1231
    # query = Object.query(Object.users == user.key)
1232
    # objects, new_before, new_after = fetch_objects(query, by=Object.updated,
1233
    #                                                user=user)
1234

1235
    # page = {
1236
    #     'type': 'CollectionPage',
1237
    #     'partOf': request.base_url,
1238
    #     'items': util.trim_nulls([ActivityPub.convert(obj, from_user=user)
1239
    #                               for obj in objects]),
1240
    # }
1241
    # if new_before:
1242
    #     page['next'] = f'{request.base_url}?before={new_before}'
1243
    # if new_after:
1244
    #     page['prev'] = f'{request.base_url}?after={new_after}'
1245

1246
    # if 'before' in request.args or 'after' in request.args:
1247
    #     page.update({
1248
    #         '@context': 'https://www.w3.org/ns/activitystreams',
1249
    #         'id': request.url,
1250
    #     })
1251
    #     logger.debug(f'Returning {json_dumps(page, indent=2)}')
1252
    #     return page, {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
1253

1254
    ret = {
1✔
1255
        '@context': 'https://www.w3.org/ns/activitystreams',
1256
        'id': request.url,
1257
        'type': 'OrderedCollection',
1258
        'summary': f"{id}'s outbox",
1259
        'totalItems': 0,
1260
        # 'first': page,
1261
        'first': {
1262
            'type': 'CollectionPage',
1263
            'partOf': request.base_url,
1264
            'items': [],
1265
        },
1266
    }
1267

1268
    # # count total if it's small, <= 1k. we should eventually precompute this
1269
    # # so that we can always return it cheaply.
1270
    # count = query.count(limit=1001)
1271
    # if count != 1001:
1272
    #     ret['totalItems'] = count
1273

1274
    return ret, {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc