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

snarfed / bridgy-fed / c23ae712-02b9-4e75-89a6-6a7c81c3e76e

pending completion
c23ae712-02b9-4e75-89a6-6a7c81c3e76e

push

circleci

Ryan Barrett
activitypub.py: minor noop tweaks, add log message

2 of 2 new or added lines in 1 file covered. (100.0%)

1552 of 1659 relevant lines covered (93.55%)

0.94 hits per line

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

95.67
/activitypub.py
1
"""Handles requests for ActivityPub endpoints: actors, inbox, etc.
2
"""
3
from base64 import b64encode
1✔
4
from hashlib import sha256
1✔
5
import itertools
1✔
6
import logging
1✔
7

8
from flask import abort, request
1✔
9
from granary import as1, as2
1✔
10
from httpsig import HeaderVerifier
1✔
11
from httpsig.requests_auth import HTTPSignatureAuth
1✔
12
from httpsig.utils import parse_signature_header
1✔
13
from oauth_dropins.webutil import flask_util, util
1✔
14
from oauth_dropins.webutil.util import fragmentless, json_dumps, json_loads
1✔
15
import requests
1✔
16
from werkzeug.exceptions import BadGateway
1✔
17

18
from app import app, cache
1✔
19
import common
1✔
20
from common import (
1✔
21
    CACHE_TIME,
22
    CONTENT_TYPE_HTML,
23
    error,
24
    host_url,
25
    redirect_unwrap,
26
    redirect_wrap,
27
    TLD_BLOCKLIST,
28
)
29
from models import Follower, Object, Target, User
1✔
30
from protocol import Protocol
1✔
31

32
logger = logging.getLogger(__name__)
1✔
33

34
CONNEG_HEADERS_AS2_HTML = {
1✔
35
    'Accept': f'{as2.CONNEG_HEADERS["Accept"]}, {CONTENT_TYPE_HTML}; q=0.7'
36
}
37

38
HTTP_SIG_HEADERS = ('Date', 'Host', 'Digest', '(request-target)')
1✔
39

40
_DEFAULT_SIGNATURE_USER = None
1✔
41

42
def default_signature_user():
1✔
43
    global _DEFAULT_SIGNATURE_USER
44
    if _DEFAULT_SIGNATURE_USER is None:
1✔
45
        _DEFAULT_SIGNATURE_USER = User.get_or_create('snarfed.org')
1✔
46
    return _DEFAULT_SIGNATURE_USER
1✔
47

48

49
class ActivityPub(Protocol):
1✔
50
    """ActivityPub protocol class."""
51
    LABEL = 'activitypub'
1✔
52

53
    @classmethod
1✔
54
    def send(cls, url, activity, *, user=None, log_data=True):
1✔
55
        """Sends an outgoing activity.
56

57
        To be implemented by subclasses.
58

59
        Args:
60
            url: str, destination URL to send to
61
            activity: dict, AS1 activity to send
62
            user: :class:`User` this is on behalf of
63
            log_data: boolean, whether to log full data object
64

65
        Raises:
66
            :class:`werkzeug.HTTPException` if the request fails
67
        """
68
        return signed_post(url, user=user, log_data=True, data=activity)
1✔
69

70
    @classmethod
1✔
71
    def fetch(cls, id, obj, *, user=None):
1✔
72
        """Tries to fetch an AS2 object and populate it into an :class:`Object`.
73

74
        Uses HTTP content negotiation via the Content-Type header. If the url is
75
        HTML and it has a rel-alternate link with an AS2 content type, fetches and
76
        returns that URL.
77

78
        Includes an HTTP Signature with the request.
79
        https://w3c.github.io/activitypub/#authorization
80
        https://tools.ietf.org/html/draft-cavage-http-signatures-07
81
        https://github.com/mastodon/mastodon/pull/11269
82

83
        Mastodon requires this signature if AUTHORIZED_FETCH aka secure mode is on:
84
        https://docs.joinmastodon.org/admin/config/#authorized_fetch
85

86
        Signs the request with the given user. If not provided, defaults to
87
        using @snarfed.org@snarfed.org's key.
88

89
        Args:
90
          id: str, object's URL id
91
          obj: :class:`Object` to populate the fetched object into
92
          user: optional :class:`User` we're fetching on behalf of
93

94
        Raises:
95
          :class:`requests.HTTPError`, :class:`werkzeug.exceptions.HTTPException`
96

97
          If we raise a werkzeug HTTPException, it will have an additional
98
          requests_response attribute with the last requests.Response we received.
99
        """
100
        def _error(resp, extra_msg=None):
1✔
101
            msg = f"Couldn't fetch {id} as ActivityStreams 2"
1✔
102
            if extra_msg:
1✔
103
                msg += ': ' + extra_msg
1✔
104
            logger.warning(msg)
1✔
105
            err = BadGateway(msg)
1✔
106
            err.requests_response = resp
1✔
107
            raise err
1✔
108

109
        def _get(url, headers):
1✔
110
            """Returns None if we fetched and populated, resp otherwise."""
111
            resp = signed_get(url, user=user, headers=headers, gateway=True)
1✔
112
            if not resp.content:
1✔
113
                _error(resp, 'empty response')
1✔
114
            elif common.content_type(resp) == as2.CONTENT_TYPE:
1✔
115
                try:
1✔
116
                    obj.as2 = resp.json()
1✔
117
                    return
1✔
118
                except requests.JSONDecodeError:
1✔
119
                    _error(resp, "Couldn't decode as JSON")
1✔
120

121
            return resp
1✔
122

123
        resp = _get(id, CONNEG_HEADERS_AS2_HTML)
1✔
124
        if resp is None:
1✔
125
            return
1✔
126

127
        # look in HTML to find AS2 link
128
        if common.content_type(resp) != 'text/html':
1✔
129
            _error(resp, 'no AS2 available')
1✔
130
        parsed = util.parse_html(resp)
1✔
131
        link = parsed.find('link', rel=('alternate', 'self'), type=(
1✔
132
            as2.CONTENT_TYPE, as2.CONTENT_TYPE_LD))
133
        if not (link and link['href']):
1✔
134
            _error(resp, 'no AS2 available')
1✔
135

136
        resp = _get(link['href'], as2.CONNEG_HEADERS)
1✔
137
        if resp is not None:
1✔
138
            _error(resp)
×
139

140
    @classmethod
1✔
141
    def verify_signature(cls, activity, *, user=None):
1✔
142
        """Verifies the current request's HTTP Signature.
143

144
        Args:
145
          activity: dict, AS2 activity
146
          user: optional :class:`User`
147

148
        Logs details of the result. Raises :class:`werkzeug.HTTPError` if the
149
        signature is missing or invalid, otherwise does nothing and returns None.
150
        """
151
        sig = request.headers.get('Signature')
1✔
152
        if not sig:
1✔
153
            error('No HTTP Signature', status=401)
1✔
154

155
        logging.info('Verifying HTTP Signature')
1✔
156
        logger.info(f'Headers: {json_dumps(dict(request.headers), indent=2)}')
1✔
157

158
        # parse_signature_header lower-cases all keys
159
        keyId = parse_signature_header(sig).get('keyid')
1✔
160
        if not keyId:
1✔
161
            error('HTTP Signature missing keyId', status=401)
1✔
162

163
        digest = request.headers.get('Digest') or ''
1✔
164
        if not digest:
1✔
165
            error('Missing Digest header, required for HTTP Signature', status=401)
×
166

167
        expected = b64encode(sha256(request.data).digest()).decode()
1✔
168
        if digest.removeprefix('SHA-256=') != expected:
1✔
169
            error('Invalid Digest header, required for HTTP Signature', status=401)
1✔
170

171
        try:
1✔
172
            key_actor = cls.get_object(keyId, user=user).as2
1✔
173
        except BadGateway:
1✔
174
            if (activity.get('type') == 'Delete' and
1✔
175
                fragmentless(keyId) == fragmentless(activity.get('object'))):
176
                logging.info("Object/actor being deleted is also keyId; ignoring")
1✔
177
                abort(202, 'OK')
1✔
178
            raise
×
179

180
        key = key_actor.get("publicKey", {}).get('publicKeyPem')
1✔
181
        logger.info(f'Verifying signature for {request.path} with key {key}')
1✔
182
        try:
1✔
183
            verified = HeaderVerifier(request.headers, key,
1✔
184
                                      required_headers=['Digest'],
185
                                      method=request.method,
186
                                      path=request.path,
187
                                      sign_header='signature').verify()
188
        except BaseException as e:
×
189
            error(f'HTTP Signature verification failed: {e}', status=401)
×
190

191
        if verified:
1✔
192
            logger.info('HTTP Signature verified!')
1✔
193
        else:
194
            error('HTTP Signature verification failed', status=401)
1✔
195

196
    @classmethod
1✔
197
    def accept_follow(cls, obj, user):
1✔
198
        """Replies to an AP Follow request with an Accept request.
199

200
        TODO: move to Protocol
201

202
        Args:
203
          obj: :class:`Object`
204
          user: :class:`User`
205
        """
206
        logger.info('Replying to Follow with Accept')
1✔
207

208
        followee = obj.as2.get('object')
1✔
209
        followee_id = followee.get('id') if isinstance(followee, dict) else followee
1✔
210
        follower = obj.as2.get('actor')
1✔
211
        if not followee or not followee_id or not follower:
1✔
212
            error(f'Follow activity requires object and actor. Got: {follow}')
×
213

214
        inbox = follower.get('inbox')
1✔
215
        follower_id = follower.get('id')
1✔
216
        if not inbox or not follower_id:
1✔
217
            error(f'Follow actor requires id and inbox. Got: {follower}')
×
218

219
        # rendered mf2 HTML proxy pages (in render.py) fall back to redirecting to
220
        # the follow's AS2 id field, but Mastodon's ids are URLs that don't load in
221
        # browsers, eg https://jawns.club/ac33c547-ca6b-4351-80d5-d11a6879a7b0
222
        # so, set a synthetic URL based on the follower's profile.
223
        # https://github.com/snarfed/bridgy-fed/issues/336
224
        follower_url = util.get_url(follower) or follower_id
1✔
225
        followee_url = util.get_url(followee) or followee_id
1✔
226
        obj.as2.setdefault('url', f'{follower_url}#followed-{followee_url}')
1✔
227

228
        # store Follower
229
        follower_obj = Follower.get_or_create(
1✔
230
            dest=user.key.id(), src=follower_id, last_follow=obj.as2)
231
        follower_obj.status = 'active'
1✔
232
        follower_obj.put()
1✔
233

234
        # send AP Accept
235
        followee_actor_url = common.host_url(user.key.id())
1✔
236
        accept = {
1✔
237
            '@context': 'https://www.w3.org/ns/activitystreams',
238
            'id': util.tag_uri(common.PRIMARY_DOMAIN,
239
                               f'accept/{user.key.id()}/{obj.key.id()}'),
240
            'type': 'Accept',
241
            'actor': followee_actor_url,
242
            'object': {
243
                'type': 'Follow',
244
                'actor': follower_id,
245
                'object': followee_actor_url,
246
            }
247
        }
248

249
        return cls.send(inbox, accept, user=user)
1✔
250

251

252
def signed_get(url, *, user=None, **kwargs):
1✔
253
    return signed_request(util.requests_get, url, user=user, **kwargs)
1✔
254

255

256
def signed_post(url, *, user=None, **kwargs):
1✔
257
    assert user
1✔
258
    return signed_request(util.requests_post, url, user=user, **kwargs)
1✔
259

260

261
def signed_request(fn, url, *, user=None, data=None, log_data=True,
1✔
262
                   headers=None, **kwargs):
263
    """Wraps requests.* and adds HTTP Signature.
264

265
    Args:
266
      fn: :func:`util.requests_get` or  :func:`util.requests_get`
267
      url: str
268
      user: optional :class:`User` to sign request with
269
      data: optional AS2 object
270
      log_data: boolean, whether to log full data object
271
      kwargs: passed through to requests
272

273
    Returns: :class:`requests.Response`
274
    """
275
    if headers is None:
1✔
276
        headers = {}
1✔
277

278
    # prepare HTTP Signature and headers
279
    if not user:
1✔
280
        user = default_signature_user()
1✔
281

282
    if data:
1✔
283
        if log_data:
1✔
284
            logger.info(f'Sending AS2 object: {json_dumps(data, indent=2)}')
1✔
285
        data = json_dumps(data).encode()
1✔
286

287
    headers = {
1✔
288
        **headers,
289
        # required for HTTP Signature
290
        # https://tools.ietf.org/html/draft-cavage-http-signatures-07#section-2.1.3
291
        'Date': util.now().strftime('%a, %d %b %Y %H:%M:%S GMT'),
292
        # required by Mastodon
293
        # https://github.com/tootsuite/mastodon/pull/14556#issuecomment-674077648
294
        'Host': util.domain_from_link(url, minimize=False),
295
        'Content-Type': as2.CONTENT_TYPE,
296
        # required for HTTP Signature and Mastodon
297
        'Digest': f'SHA-256={b64encode(sha256(data or b"").digest()).decode()}',
298
    }
299

300
    domain = user.key.id()
1✔
301
    logger.info(f"Signing with {domain}'s key")
1✔
302
    key_id = host_url(domain)
1✔
303
    # (request-target) is a special HTTP Signatures header that some fediverse
304
    # implementations require, eg Peertube.
305
    # https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#section-2.3
306
    # https://github.com/snarfed/bridgy-fed/issues/40
307
    auth = HTTPSignatureAuth(secret=user.private_pem(), key_id=key_id,
1✔
308
                             algorithm='rsa-sha256', sign_header='signature',
309
                             headers=HTTP_SIG_HEADERS)
310

311
    # make HTTP request
312
    kwargs.setdefault('gateway', True)
1✔
313
    resp = fn(url, data=data, auth=auth, headers=headers, allow_redirects=False,
1✔
314
              **kwargs)
315
    logger.info(f'Got {resp.status_code} headers: {resp.headers}')
1✔
316

317
    # handle GET redirects manually so that we generate a new HTTP signature
318
    if resp.is_redirect and fn == util.requests_get:
1✔
319
      return signed_request(fn, resp.headers['Location'], data=data, user=user,
1✔
320
                            headers=headers, log_data=log_data, **kwargs)
321

322
    type = common.content_type(resp)
1✔
323
    if (type and type != 'text/html' and
1✔
324
        (type.startswith('text/') or type.endswith('+json') or type.endswith('/json'))):
325
        logger.info(resp.text)
1✔
326

327
    return resp
1✔
328

329

330
def postprocess_as2(activity, user=None, target=None, create=True):
1✔
331
    """Prepare an AS2 object to be served or sent via ActivityPub.
332

333
    Args:
334
      activity: dict, AS2 object or activity
335
      user: :class:`User`, required. populated into actor.id and
336
        publicKey fields if needed.
337
      target: dict, AS2 object, optional. The target of activity's inReplyTo or
338
        Like/Announce/etc object, if any.
339
      create: boolean, whether to wrap `Note` and `Article` objects in a
340
        `Create` activity
341
    """
342
    assert user
1✔
343
    type = activity.get('type')
1✔
344

345
    # actor objects
346
    if type == 'Person':
1✔
347
        postprocess_as2_actor(activity, user)
1✔
348
        if not activity.get('publicKey'):
1✔
349
            # underspecified, inferred from this issue and Mastodon's implementation:
350
            # https://github.com/w3c/activitypub/issues/203#issuecomment-297553229
351
            # https://github.com/tootsuite/mastodon/blob/bc2c263504e584e154384ecc2d804aeb1afb1ba3/app/services/activitypub/process_account_service.rb#L77
352
            actor_url = host_url(activity.get('preferredUsername'))
1✔
353
            activity.update({
1✔
354
                'publicKey': {
355
                    'id': actor_url,
356
                    'owner': actor_url,
357
                    'publicKeyPem': user.public_pem().decode(),
358
                },
359
                '@context': (util.get_list(activity, '@context') +
360
                             ['https://w3id.org/security/v1']),
361
            })
362
        return activity
1✔
363

364
    for actor in (util.get_list(activity, 'attributedTo') +
1✔
365
                  util.get_list(activity, 'actor')):
366
        postprocess_as2_actor(actor, user)
1✔
367

368
    # inReplyTo: singly valued, prefer id over url
369
    target_id = target.get('id') if target else None
1✔
370
    in_reply_to = activity.get('inReplyTo')
1✔
371
    if in_reply_to:
1✔
372
        if target_id:
1✔
373
            activity['inReplyTo'] = target_id
1✔
374
        elif isinstance(in_reply_to, list):
1✔
375
            if len(in_reply_to) > 1:
1✔
376
                logger.warning(
1✔
377
                    "AS2 doesn't support multiple inReplyTo URLs! "
378
                    f'Only using the first: {in_reply_to[0]}')
379
            activity['inReplyTo'] = in_reply_to[0]
1✔
380

381
        # Mastodon evidently requires a Mention tag for replies to generate a
382
        # notification to the original post's author. not required for likes,
383
        # reposts, etc. details:
384
        # https://github.com/snarfed/bridgy-fed/issues/34
385
        if target:
1✔
386
            for to in (util.get_list(target, 'attributedTo') +
1✔
387
                       util.get_list(target, 'actor')):
388
                if isinstance(to, dict):
1✔
389
                    to = util.get_first(to, 'url') or to.get('id')
1✔
390
                if to:
1✔
391
                    activity.setdefault('tag', []).append({
1✔
392
                        'type': 'Mention',
393
                        'href': to,
394
                    })
395

396
    # activity objects (for Like, Announce, etc): prefer id over url
397
    obj = activity.get('object')
1✔
398
    if obj:
1✔
399
        if isinstance(obj, dict) and not obj.get('id'):
1✔
400
            obj['id'] = target_id or util.get_first(obj, 'url')
×
401
        elif target_id and obj != target_id:
1✔
402
            activity['object'] = target_id
1✔
403

404
    # id is required for most things. default to url if it's not set.
405
    if not activity.get('id'):
1✔
406
        activity['id'] = util.get_first(activity, 'url')
1✔
407

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

413
    activity['id'] = redirect_wrap(activity.get('id'))
1✔
414
    activity['url'] = [redirect_wrap(u) for u in util.get_list(activity, 'url')]
1✔
415
    if len(activity['url']) == 1:
1✔
416
        activity['url'] = activity['url'][0]
1✔
417

418
    # copy image(s) into attachment(s). may be Mastodon-specific.
419
    # https://github.com/snarfed/bridgy-fed/issues/33#issuecomment-440965618
420
    obj_or_activity = obj if isinstance(obj, dict) else activity
1✔
421
    img = util.get_list(obj_or_activity, 'image')
1✔
422
    if img:
1✔
423
        obj_or_activity.setdefault('attachment', []).extend(img)
1✔
424

425
    # cc target's author(s) and recipients
426
    # https://www.w3.org/TR/activitystreams-vocabulary/#audienceTargeting
427
    # https://w3c.github.io/activitypub/#delivery
428
    if target and (type in as2.TYPE_TO_VERB or type in ('Article', 'Note')):
1✔
429
        recips = itertools.chain(*(util.get_list(target, field) for field in
1✔
430
                                 ('actor', 'attributedTo', 'to', 'cc')))
431
        activity['cc'] = util.dedupe_urls(util.get_url(recip) or recip.get('id')
1✔
432
                                          for recip in recips)
433

434
    # to public, since Mastodon interprets to public as public, cc public as unlisted:
435
    # https://socialhub.activitypub.rocks/t/visibility-to-cc-mapping/284
436
    # https://wordsmith.social/falkreon/securing-activitypub
437
    to = activity.setdefault('to', [])
1✔
438
    if as2.PUBLIC_AUDIENCE not in to:
1✔
439
        to.append(as2.PUBLIC_AUDIENCE)
1✔
440

441
    # wrap articles and notes in a Create activity
442
    if create and type in ('Article', 'Note'):
1✔
443
        activity = {
1✔
444
            '@context': as2.CONTEXT,
445
            'type': 'Create',
446
            'id': f'{activity["id"]}#bridgy-fed-create',
447
            'actor': postprocess_as2_actor({}, user),
448
            'object': activity,
449
        }
450

451
    return util.trim_nulls(activity)
1✔
452

453

454
def postprocess_as2_actor(actor, user=None):
1✔
455
    """Prepare an AS2 actor object to be served or sent via ActivityPub.
456

457
    Modifies actor in place.
458

459
    Args:
460
      actor: dict, AS2 actor object
461
      user: :class:`User`
462

463
    Returns:
464
      actor dict
465
    """
466
    url = user.homepage if user else None
1✔
467
    urls = util.get_list(actor, 'url')
1✔
468
    if not urls and url:
1✔
469
      urls = [url]
1✔
470

471
    domain = util.domain_from_link(urls[0], minimize=False)
1✔
472
    urls[0] = redirect_wrap(urls[0])
1✔
473

474
    actor.setdefault('id', host_url(domain))
1✔
475
    actor.update({
1✔
476
        'url': urls if len(urls) > 1 else urls[0],
477
        # This has to be the domain for Mastodon interop/Webfinger discovery!
478
        # See related comment in actor() below.
479
        'preferredUsername': domain,
480
    })
481

482
    # Override the label for their home page to be "Web site"
483
    for att in util.get_list(actor, 'attachment'):
1✔
484
      if att.get('type') == 'PropertyValue':
1✔
485
        val = att.get('value', '')
1✔
486
        link = util.parse_html(val).find('a')
1✔
487
        if url and (val == url or link.get('href') == url):
1✔
488
          att['name'] = 'Web site'
1✔
489

490
    # required by pixelfed. https://github.com/snarfed/bridgy-fed/issues/39
491
    actor.setdefault('summary', '')
1✔
492
    return actor
1✔
493

494

495
@app.get(f'/<regex("{common.DOMAIN_RE}"):domain>')
1✔
496
@flask_util.cached(cache, CACHE_TIME)
1✔
497
def actor(domain):
1✔
498
    """Serves a user's AS2 actor from the datastore."""
499
    tld = domain.split('.')[-1]
1✔
500
    if tld in TLD_BLOCKLIST:
1✔
501
        error('', status=404)
1✔
502

503
    user = User.get_by_id(domain)
1✔
504
    if not user:
1✔
505
        return f'User {domain} not found', 404
1✔
506
    elif not user.actor_as2:
1✔
507
        return f'User {domain} not fully set up', 404
×
508

509
    # TODO: unify with common.actor()
510
    actor = {
1✔
511
        **postprocess_as2(user.actor_as2, user=user),
512
        'id': host_url(domain),
513
        # This has to be the domain for Mastodon etc interop! It seems like it
514
        # should be the custom username from the acct: u-url in their h-card,
515
        # but that breaks Mastodon's Webfinger discovery. Background:
516
        # https://github.com/snarfed/bridgy-fed/issues/302#issuecomment-1324305460
517
        # https://github.com/snarfed/bridgy-fed/issues/77
518
        'preferredUsername': domain,
519
        'inbox': host_url(f'{domain}/inbox'),
520
        'outbox': host_url(f'{domain}/outbox'),
521
        'following': host_url(f'{domain}/following'),
522
        'followers': host_url(f'{domain}/followers'),
523
        'endpoints': {
524
            'sharedInbox': host_url('inbox'),
525
        },
526
    }
527

528
    logger.info(f'Returning: {json_dumps(actor, indent=2)}')
1✔
529
    return actor, {
1✔
530
        'Content-Type': as2.CONTENT_TYPE,
531
        'Access-Control-Allow-Origin': '*',
532
    }
533

534

535
@app.post('/inbox')
1✔
536
@app.post(f'/<regex("{common.DOMAIN_RE}"):domain>/inbox')
1✔
537
def inbox(domain=None):
1✔
538
    """Handles ActivityPub inbox delivery."""
539
    # parse and validate AS2 activity
540
    try:
1✔
541
        activity = request.json
1✔
542
        assert activity and isinstance(activity, dict)
1✔
543
    except (TypeError, ValueError, AssertionError):
×
544
        body = request.get_data(as_text=True)
×
545
        error(f"Couldn't parse body as non-empty JSON mapping: {body}", exc_info=True)
×
546

547
    actor_id = as1.get_object(activity, 'actor').get('id')
1✔
548
    logger.info(f'Got {activity.get("type")} activity from {actor_id}: {json_dumps(activity, indent=2)}')
1✔
549

550
    # load user
551
    # TODO: store in g instead of passing around
552
    user = None
1✔
553
    if domain:
1✔
554
        user = User.get_by_id(domain)
1✔
555
        if not user:
1✔
556
            error(f'User {domain} not found', status=404)
1✔
557

558
    ActivityPub.verify_signature(activity, user=user)
1✔
559

560
    # check that this activity is public. only do this for creates, not likes,
561
    # follows, or other activity types, since Mastodon doesn't currently mark
562
    # those as explicitly public. Use as2's is_public instead of as1's because
563
    # as1's interprets unlisted as true.
564
    if activity.get('type') == 'Create' and not as2.is_public(activity):
1✔
565
        logger.info('Dropping non-public activity')
1✔
566
        return 'OK'
1✔
567

568
    return ActivityPub.receive(activity.get('id'), user=user,
1✔
569
                               as2=redirect_unwrap(activity))
570

571

572
@app.get(f'/<regex("{common.DOMAIN_RE}"):domain>/<any(followers,following):collection>')
1✔
573
@flask_util.cached(cache, CACHE_TIME)
1✔
574
def follower_collection(domain, collection):
1✔
575
    """ActivityPub Followers and Following collections.
576

577
    https://www.w3.org/TR/activitypub/#followers
578
    https://www.w3.org/TR/activitypub/#collections
579
    https://www.w3.org/TR/activitystreams-core/#paging
580
    """
581
    if not User.get_by_id(domain):
1✔
582
        return f'User {domain} not found', 404
1✔
583

584
    # page
585
    followers, new_before, new_after = Follower.fetch_page(domain, collection)
1✔
586
    items = []
1✔
587
    for f in followers:
1✔
588
        f_as2 = f.to_as2()
1✔
589
        if f_as2:
1✔
590
            items.append(f_as2)
1✔
591

592
    page = {
1✔
593
        'type': 'CollectionPage',
594
        'partOf': request.base_url,
595
        'items': items,
596
    }
597
    if new_before:
1✔
598
        page['next'] = f'{request.base_url}?before={new_before}'
1✔
599
    if new_after:
1✔
600
        page['prev'] = f'{request.base_url}?after={new_after}'
1✔
601

602
    if 'before' in request.args or 'after' in request.args:
1✔
603
        page.update({
1✔
604
            '@context': 'https://www.w3.org/ns/activitystreams',
605
            'id': request.url,
606
        })
607
        logger.info(f'Returning {json_dumps(page, indent=2)}')
1✔
608
        return page, {'Content-Type': as2.CONTENT_TYPE}
1✔
609

610
    # collection
611
    domain_prop = Follower.dest if collection == 'followers' else Follower.src
1✔
612
    count = Follower.query(
1✔
613
        Follower.status == 'active',
614
        domain_prop == domain,
615
    ).count()
616

617
    collection = {
1✔
618
        '@context': 'https://www.w3.org/ns/activitystreams',
619
        'id': request.base_url,
620
        'type': 'Collection',
621
        'summary': f"{domain}'s {collection}",
622
        'totalItems': count,
623
        'first': page,
624
    }
625
    logger.info(f'Returning {json_dumps(collection, indent=2)}')
1✔
626
    return collection, {'Content-Type': as2.CONTENT_TYPE}
1✔
627

628

629
@app.get(f'/<regex("{common.DOMAIN_RE}"):domain>/outbox')
1✔
630
def outbox(domain):
1✔
631
    url = common.host_url(f"{domain}/outbox")
1✔
632
    return {
1✔
633
            '@context': 'https://www.w3.org/ns/activitystreams',
634
            'id': url,
635
            'summary': f"{domain}'s outbox",
636
            'type': 'OrderedCollection',
637
            'totalItems': 0,
638
            'first': {
639
                'type': 'CollectionPage',
640
                'partOf': url,
641
                'items': [],
642
            },
643
        }, {'Content-Type': as2.CONTENT_TYPE}
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