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

snarfed / bridgy-fed / 878ced7f-3b32-4aaf-9280-63d2666620db

17 Oct 2025 03:56AM UTC coverage: 92.855% (-0.002%) from 92.857%
878ced7f-3b32-4aaf-9280-63d2666620db

push

circleci

snarfed
fix integration test test_nostr_follow_activitypub_bot_user_invalid_nip05

5939 of 6396 relevant lines covered (92.85%)

0.93 hits per line

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

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

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

31
from flask_app import app
1✔
32
import common
1✔
33
from common import (
1✔
34
    CACHE_CONTROL,
35
    CACHE_CONTROL_VARY_ACCEPT,
36
    CONTENT_TYPE_HTML,
37
    create_task,
38
    DOMAINS,
39
    DOMAIN_RE,
40
    error,
41
    FlashErrors,
42
    host_url,
43
    LOCAL_DOMAINS,
44
    PRIMARY_DOMAIN,
45
    PROTOCOL_DOMAINS,
46
    redirect_wrap,
47
    report_error,
48
    subdomain_wrap,
49
    unwrap,
50
)
51
import ids
1✔
52
import memcache
1✔
53
from models import fetch_objects, Follower, Object, PROTOCOLS, User
1✔
54
from protocol import activity_id_memcache_key, DELETE_TASK_DELAY, Protocol
1✔
55
from ui import UIProtocol
1✔
56
import webfinger
1✔
57

58
logger = logging.getLogger(__name__)
1✔
59

60
CONNEG_HEADERS_AS2_HTML = {
1✔
61
    'Accept': f'{as2.CONNEG_HEADERS["Accept"]}, {CONTENT_TYPE_HTML}; q=0.5'
62
}
63

64
HTTP_SIG_HEADERS = ('Date', 'Host', 'Digest', '(request-target)')
1✔
65

66
SECURITY_CONTEXT = 'https://w3id.org/security/v1'
1✔
67

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

72
# https://seb.jambor.dev/posts/understanding-activitypub-part-4-threads/#the-instance-actor
73
_INSTANCE_ACTOR = None
1✔
74

75
OLD_ACCOUNT_EXEMPT_DOMAINS = (
1✔
76
    'channel.org',
77
    'mo-me.social',
78
    'newsmast.community',
79
    'pixelfed.social',
80
)
81

82
# domains that we can't yet authorize activities from. currently none.
83
#
84
# historical:
85
# * a.gup.pe groups signed with the group's actor but use the external author as
86
#   actor and attributedTo, and don't include an LD Sig. a.gup.pe is now shut down.
87
#   https://github.com/snarfed/bridgy-fed/issues/566#issuecomment-2130714037
88
NO_AUTH_DOMAINS = ()
1✔
89

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

92

93
def instance_actor():
1✔
94
    global _INSTANCE_ACTOR
95

96
    if _INSTANCE_ACTOR is None:
1✔
97
        import web
×
98
        _INSTANCE_ACTOR = web.Web.get_or_create(PRIMARY_DOMAIN, verify=False)
×
99
        assert _INSTANCE_ACTOR
×
100

101
    return _INSTANCE_ACTOR
1✔
102

103

104
class ActivityPub(User, Protocol):
1✔
105
    """ActivityPub protocol class.
106

107
    Key id is AP/AS2 actor id URL. (*Not* fediverse/WebFinger @-@ handle!)
108
    """
109
    ABBREV = 'ap'
1✔
110
    ''
1✔
111
    PHRASE = 'the fediverse'
1✔
112
    ''
1✔
113
    LOGO_EMOJI = '⁂'
1✔
114
    ''
1✔
115
    LOGO_HTML = '<img src="/static/fediverse_logo.svg">'
1✔
116
    ''
1✔
117
    CONTENT_TYPE = as2.CONTENT_TYPE_LD_PROFILE
1✔
118
    ''
1✔
119
    REQUIRES_NAME = False
1✔
120
    ''
1✔
121
    DEFAULT_ENABLED_PROTOCOLS = ('web',)
1✔
122
    ''
1✔
123
    SUPPORTED_AS1_TYPES = (
1✔
124
        tuple(as1.ACTOR_TYPES)
125
        + tuple(as1.POST_TYPES)
126
        + tuple(as1.CRUD_VERBS)
127
        + tuple(as1.VERBS_WITH_OBJECT)
128
        + ('add', 'audio', 'bookmark', 'image', 'move', 'remove', 'video')
129
    )
130
    ''
1✔
131
    SUPPORTED_AS2_TYPES = tuple(
1✔
132
        as2.OBJECT_TYPE_TO_TYPE.get(t) or as2.VERB_TO_TYPE.get(t)
133
        for t in SUPPORTED_AS1_TYPES)
134
    ''
1✔
135
    SUPPORTS_DMS = True
1✔
136
    ''
1✔
137
    SEND_REPLIES_TO_ORIG_POSTS_MENTIONS = True
1✔
138
    'https://github.com/snarfed/bridgy-fed/issues/1608 , https://github.com/snarfed/bridgy-fed/issues/1218'
1✔
139
    HTML_PROFILES = True
1✔
140
    ''
1✔
141
    BOTS_FOLLOW_BACK = True
1✔
142
    ''
1✔
143

144
    webfinger_addr = ndb.StringProperty()
1✔
145
    """Populated by :meth:`reload_profile`."""
1✔
146

147
    @property
1✔
148
    def REQUIRES_AVATAR(self):
1✔
149
        ''
150
        return not util.domain_or_parent_in(self.key.id(), ids.ATPROTO_HANDLE_DOMAINS)
1✔
151

152
    @property
1✔
153
    def REQUIRES_OLD_ACCOUNT(self):
1✔
154
        ''
155
        return not util.domain_or_parent_in(
1✔
156
            self.key.id(), OLD_ACCOUNT_EXEMPT_DOMAINS + ids.ATPROTO_HANDLE_DOMAINS)
157

158
    def _pre_put_hook(self):
1✔
159
        r"""Validate id, require URL, don't allow Bridgy Fed domains.
160

161
        TODO: normalize scheme and domain to lower case. Add that to
162
        :class:`oauth_dropins.webutil.util.UrlCanonicalizer`\?
163
        """
164
        super()._pre_put_hook()
1✔
165
        id = self.key.id()
1✔
166
        assert id
1✔
167
        assert util.is_web(id), f'{id} is not a URL'
1✔
168
        domain = util.domain_from_link(id)
1✔
169
        assert domain, 'missing domain'
1✔
170
        assert not self.is_blocklisted(domain), f'{id} is a blocked domain'
1✔
171

172
    def web_url(self):
1✔
173
        """Returns this user's web URL aka web_url, eg ``https://foo.com/``."""
174
        if self.obj and self.obj.as1:
1✔
175
            url = as1.get_url(self.obj.as1)
1✔
176
            if url:
1✔
177
                return url
1✔
178

179
        return self.key.id()
1✔
180

181
    @ndb.ComputedProperty
1✔
182
    def handle(self):
1✔
183
        """Returns this user's ActivityPub address, eg ``@user@foo.com``."""
184
        if self.webfinger_addr:
1✔
185
            assert self.webfinger_addr.startswith('@')
1✔
186
            return self.webfinger_addr
1✔
187

188
        if self.obj and self.obj.as1:
1✔
189
            addr = as2.address(self._convert(self.obj, from_user=self))
1✔
190
            if addr:
1✔
191
                return addr
1✔
192

193
        return as2.address(self.key.id())
1✔
194

195
    @ndb.ComputedProperty
1✔
196
    def status(self):
1✔
197
        if self.obj and self.obj.as2 and as2.is_server_actor(self.obj.as2):
1✔
198
            return None
1✔
199

200
        return super().status
1✔
201

202
    def reload_profile(self, **kwargs):
1✔
203
        """Reloads this user's AP actor, then resolves their webfinger subject.
204

205
        1. load AP actor
206
        2. fetch Webfinger with preferredUsername
207
        3. re-fetch Webfinger with subject from first Webfinger
208

209
        https://www.w3.org/community/reports/socialcg/CG-FINAL-apwf-20240608/#reverse-discovery
210
        https://correct.webfinger-canary.fietkau.software/#developers
211
        """
212
        super().reload_profile(**kwargs)
1✔
213

214
        self.webfinger_addr = None
1✔
215
        if self.handle:
1✔
216
            if profile := webfinger.fetch(self.handle):
1✔
217
                if subject := profile.get('subject'):
1✔
218
                    addr = subject.removeprefix('acct:')
1✔
219
                    if profile := webfinger.fetch(addr):
1✔
220
                        if subject == profile.get('subject'):
1✔
221
                            logger.info(f'resolved webfinger subject to {subject}')
1✔
222
                            if not addr.startswith('@'):
1✔
223
                                addr = '@' + addr
1✔
224
                            self.webfinger_addr = addr
1✔
225
                            self.put()
1✔
226

227
    @classmethod
1✔
228
    def owns_id(cls, id):
1✔
229
        """Returns None if ``id`` is an http(s) URL, False otherwise.
230

231
        All AP ids are http(s) URLs, but not all http(s) URLs are AP ids.
232

233
        https://www.w3.org/TR/activitypub/#obj-id
234

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

241
        https://docs.microblog.pub/user_guide.html#activitypub
242
        """
243
        if util.is_web(id) and util.is_url(id) and not cls.is_blocklisted(id):
1✔
244
            return None
1✔
245

246
        return False
1✔
247

248
    @classmethod
1✔
249
    def owns_handle(cls, handle, allow_internal=False):
1✔
250
        """Returns True if handle is a WebFinger ``@-@`` handle, False otherwise.
251

252
        Example: ``@user@instance.com``. The leading ``@`` is optional.
253

254
        https://datatracker.ietf.org/doc/html/rfc7033#section-3.1
255
        https://datatracker.ietf.org/doc/html/rfc7033#section-4.5
256
        """
257
        if (handle and handle[0] == '@'
1✔
258
                and cls.is_user_at_domain(handle[1:], allow_internal=allow_internal)):
259
            return True
1✔
260

261
        return False
1✔
262

263
    @classmethod
1✔
264
    def handle_to_id(cls, handle):
1✔
265
        """Looks in the datastore first, then queries WebFinger."""
266
        assert cls.owns_handle(handle)
1✔
267

268
        if not handle.startswith('@'):
1✔
269
            handle = '@' + handle
×
270

271
        user = ActivityPub.query(ActivityPub.handle == handle).get()
1✔
272
        if user:
1✔
273
            return user.key.id()
1✔
274

275
        return webfinger.fetch_actor_url(handle)
1✔
276

277
    def user_page_path(self, rest=None, **kwargs):
1✔
278
        """Always prefer handle, since id is a full URL."""
279
        kwargs['prefer_id'] = False
1✔
280
        return super().user_page_path(rest=rest, **kwargs)
1✔
281

282
    @classmethod
1✔
283
    def target_for(cls, obj, shared=False):
1✔
284
        """Returns ``obj``'s or its author's/actor's inbox, if available."""
285
        if not obj.as1:
1✔
286
            return None
1✔
287

288
        if obj.type not in as1.ACTOR_TYPES:
1✔
289
            for field in 'actor', 'author', 'attributedTo':
1✔
290
                inner_obj = as1.get_object(obj.as1, field)
1✔
291
                inner_id = inner_obj.get('id') or as1.get_url(inner_obj)
1✔
292
                if (not inner_id
1✔
293
                        or inner_id == obj.as1.get('id')
294
                        or (obj.key and inner_id == obj.key.id())):
295
                    continue
1✔
296

297
                actor = cls.load(inner_id, raise_=False)
1✔
298
                if actor and actor.as1:
1✔
299
                    target = cls.target_for(actor, shared=shared)
1✔
300
                    if target:
1✔
301
                        logger.info(f'Target for {obj.key} via {inner_id} is {target}')
1✔
302
                        return target
1✔
303

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

306
        if not (actor := cls._convert(obj)):
1✔
307
            return None
×
308

309
        if shared:
1✔
310
            shared_inbox = (actor.get('endpoints') or {}).get('sharedInbox')
1✔
311
            if shared_inbox:
1✔
312
                return shared_inbox
1✔
313

314
        return actor.get('publicInbox') or actor.get('inbox')
1✔
315

316
    @classmethod
1✔
317
    def bridged_web_url_for(cls, user, fallback=False):
1✔
318
        """Returns the user's bridged AP id.
319

320
        There's no single canonical web URL for a user bridged into ActivityPub. So,
321
        we want some URL that's reasonable, and when it's used in a link in a
322
        fediverse post, eg in an @-mention, we ideally want it to open that local
323
        instance's view of the remote bridged user. In general, that means it needs
324
        to serve the AP actor when requested with AP conneg.
325

326
        Our translated AP actor ids, served by ``/actor`` below, satisfy this. They
327
        serve the actor via conneg, and otherwise redirect to the user's profile in
328
        their native protocol.
329
        """
330
        if not isinstance(user, ActivityPub):
1✔
331
            if ap_id := user.id_as(ActivityPub):
1✔
332
                return ap_id
1✔
333

334
        return super().bridged_web_url_for(user, fallback=fallback)
1✔
335

336
    @classmethod
1✔
337
    def send(to_cls, obj, inbox_url, from_user=None, orig_obj_id=None):
1✔
338
        """Delivers an activity to an inbox URL.
339

340
        If ``obj.recipient_obj`` is set, it's interpreted as the receiving actor
341
        who we're delivering to and its id is populated into ``cc``.
342
        """
343
        if not from_user:
1✔
344
            logger.info('Skipping sending, no from_user!')
1✔
345
            return False
1✔
346
        elif to_cls.is_blocklisted(inbox_url):
1✔
347
            logger.info(f'Skipping sending to blocklisted {inbox_url}')
1✔
348
            return False
1✔
349

350
        orig_obj = None
1✔
351
        if orig_obj_id:
1✔
352
            orig_obj = to_cls.convert(Object.get_by_id(orig_obj_id),
1✔
353
                                      from_user=from_user)
354
        activity = to_cls.convert(obj, from_user=from_user, orig_obj=orig_obj)
1✔
355

356
        return signed_post(inbox_url, data=activity, from_user=from_user).ok
1✔
357

358
    @classmethod
1✔
359
    def fetch(cls, obj, **kwargs):
1✔
360
        """Tries to fetch an AS2 object.
361

362
        Assumes ``obj.id`` is a URL. Any fragment at the end is stripped before
363
        loading. This is currently underspecified and somewhat inconsistent
364
        across AP implementations:
365

366
        * https://socialhub.activitypub.rocks/t/problems-posting-to-mastodon-inbox/801/11
367
        * https://socialhub.activitypub.rocks/t/problems-posting-to-mastodon-inbox/801/23
368
        * https://socialhub.activitypub.rocks/t/s2s-create-activity/1647/5
369
        * https://github.com/mastodon/mastodon/issues/13879 (open!)
370
        * https://github.com/w3c/activitypub/issues/224
371

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

376
        Includes an HTTP Signature with the request.
377

378
        * https://w3c.github.io/activitypub/#authorization
379
        * https://tools.ietf.org/html/draft-cavage-http-signatures-07
380
        * https://github.com/mastodon/mastodon/pull/11269
381

382
        Mastodon requires this signature if ``AUTHORIZED_FETCH`` aka secure mode
383
        is on: https://docs.joinmastodon.org/admin/config/#authorized_fetch
384

385
        Signs the request with the current user's key. If not provided, defaults to
386
        using @snarfed.org@snarfed.org's key.
387

388
        See :meth:`protocol.Protocol.fetch` for more details.
389

390
        Args:
391
          obj (models.Object): with the id to fetch. Fills data into the as2
392
            property.
393
          kwargs: ignored
394

395
        Returns:
396
          bool: True if the object was fetched and populated successfully,
397
          False otherwise
398

399
        Raises:
400
          requests.HTTPError:
401
          werkzeug.exceptions.HTTPException: will have an additional
402
            ``requests_response`` attribute with the last
403
            :class:`requests.Response` we received.
404
        """
405
        url = obj.key.id()
1✔
406
        if not util.is_web(url):
1✔
407
            logger.info(f'{url} is not a URL')
1✔
408
            return False
1✔
409

410
        resp, obj.as2 = cls._get(url, headers=CONNEG_HEADERS_AS2_HTML)
1✔
411
        if obj.as2:
1✔
412
            return True
1✔
413
        elif not resp:
1✔
414
            return False
1✔
415

416
        # look in HTML to find AS2 link
417
        if common.content_type(resp) != 'text/html':
1✔
418
            logger.debug('no AS2 available')
1✔
419
            return False
1✔
420

421
        parsed = util.parse_html(resp)
1✔
422
        link = parsed.find('link', rel=('alternate', 'self'), type=(
1✔
423
            as2.CONTENT_TYPE, as2.CONTENT_TYPE_LD))
424
        if not (link and link['href']):
1✔
425
            logger.debug('no AS2 available')
1✔
426
            return False
1✔
427

428
        _, obj.as2 = cls._get(link['href'])
1✔
429
        if not obj.as2:
1✔
430
            return False
×
431

432
        return True
1✔
433

434
    @classmethod
1✔
435
    def _get(cls, url, headers=as2.CONNEG_HEADERS):
1✔
436
        """Fetches a URL as AS2.
437

438
        Args:
439
          url (str)
440
          headers (dict)
441

442
        Returns:
443
          (requests.Response, dict JSON response body or None) tuple:
444
        """
445
        def _error(extra_msg=None):
1✔
446
            msg = f"Couldn't fetch {url} as ActivityStreams 2"
1✔
447
            if extra_msg:
1✔
448
                msg += ': ' + extra_msg
1✔
449
            logger.warning(msg)
1✔
450
            # protocol.for_id depends on us raising this when an AP network
451
            # fetch fails. if we change that, update for_id too!
452
            err = BadGateway(msg)
1✔
453
            err.requests_response = resp
1✔
454
            raise err
1✔
455

456
        resp = None
1✔
457
        try:
1✔
458
            resp = signed_get(url, headers=headers, gateway=True)
1✔
459
        except BadGateway as e:
1✔
460
            # ugh, this is ugly, should be something structured
461
            if '406 Client Error' in str(e):
1✔
462
                return resp, None
1✔
463
            raise
1✔
464

465
        if not resp.content:
1✔
466
            _error('empty response')
1✔
467
        elif common.content_type(resp) in as2.CONTENT_TYPES:
1✔
468
            try:
1✔
469
                obj = resp.json()
1✔
470
            except requests.JSONDecodeError:
1✔
471
                _error("Couldn't decode as JSON")
1✔
472
            if not isinstance(obj, dict):
1✔
473
                logger.warning(f'Got non-object: {obj}')
1✔
474
                return resp, None
1✔
475

476
            cls._hydrate(obj)
1✔
477
            return resp, obj
1✔
478

479
        return resp, None
1✔
480

481
    @classmethod
1✔
482
    def _hydrate(cls, obj):
1✔
483
        """Hydrates compacted values in ``obj``, in place.
484

485
        Very minimal and incomplete! Right now only handles the ``featured``
486
        collection in actors.
487

488
        Args:
489
          obj (dict)
490
        """
491
        if util.get_first(obj, 'type') in as2.ACTOR_TYPES:
1✔
492
            if feat := as1.get_object(obj, 'featured'):
1✔
493
                if set(feat.keys()) == {'id'}:
1✔
494
                    # fetch collection
495
                    _, obj['featured'] = cls._get(feat['id'])
1✔
496

497
    @classmethod
1✔
498
    def _convert(cls, obj, orig_obj=None, from_user=None):
1✔
499
        """Convert a :class:`models.Object` to AS2.
500

501
        Args:
502
          obj (models.Object)
503
          orig_obj (dict): AS2 object, optional. The target of activity's
504
            ``inReplyTo`` or ``Like``/``Announce``/etc object, if any. Passed
505
            through to :func:`postprocess_as2`.
506
          from_user (models.User): user (actor) this activity/object is from
507

508
        Returns:
509
          dict: AS2 JSON
510
        """
511
        if not obj or not obj.as1:
1✔
512
            return {}
×
513

514
        # TODO: uncomment
515
        # from_proto = PROTOCOLS.get(obj.source_protocol)
516
        # if from_proto and not from_user.is_enabled(cls):
517
        #     error(f'{cls.LABEL} <=> {from_proto.LABEL} not enabled')
518

519
        if obj.as2:
1✔
520
            return {
1✔
521
                # add back @context since we strip it when we store Objects
522
                '@context': as2.CONTEXT + [SECURITY_CONTEXT],
523
                **obj.as2,
524
            }
525

526
        translated = cls.translate_ids(obj.as1)
1✔
527

528
        # compact actors to just string id for compatibility, since many other
529
        # AP implementations choke on objects.
530
        # https://github.com/snarfed/bridgy-fed/issues/658
531
        #
532
        # TODO: expand this to general purpose compact() function and use
533
        # elsewhere, eg in models.resolve_id
534
        for o in translated, as1.get_object(translated):
1✔
535
            for field in 'actor', 'attributedTo', 'author':
1✔
536
                actors = as1.get_objects(o, field)
1✔
537
                ids = [a['id'] for a in actors if a.get('id')]
1✔
538
                o[field] = ids[0] if len(ids) == 1 else ids
1✔
539

540
        converted = as2.from_as1(translated)
1✔
541

542
        if obj.source_protocol in ('ap', 'activitypub'):
1✔
543
            return converted
1✔
544

545
        # special cases where obj or obj['object'] or obj['object']['object']
546
        # are an actor
547
        if from_user:
1✔
548
            if as1.object_type(obj.as1) in as1.ACTOR_TYPES:
1✔
549
                return postprocess_as2_actor(converted, user=from_user)
1✔
550

551
            inner_obj = as1.get_object(obj.as1)
1✔
552
            if as1.object_type(inner_obj) in as1.ACTOR_TYPES:
1✔
553
                converted['object'] = postprocess_as2_actor(converted['object'],
1✔
554
                                                            user=from_user)
555

556
            # eg Accept of a Follow
557
            if from_user.is_web_url(as1.get_object(inner_obj).get('id')):
1✔
558
                converted['object']['object'] = from_user.id_as(ActivityPub)
1✔
559

560
        # convert!
561
        converted = postprocess_as2(converted, orig_obj=orig_obj)
1✔
562

563
        # FEP-fffd proxy link
564
        # https://codeberg.org/fediverse/fep/src/branch/main/fep/fffd/fep-fffd.md
565
        # https://github.com/snarfed/bridgy-fed/issues/543
566
        if (obj.source_protocol not in (None, 'activitypub')
1✔
567
                and obj.type not in as1.CRUD_VERBS and obj.key and obj.key.id()
568
                and not cls.is_blocklisted(obj.key.id())):
569
            canonical = {
1✔
570
                'type': 'Link',
571
                'rel': 'canonical',
572
                'href': obj.key.id(),
573
            }
574
            converted['url'] = util.get_list(converted, 'url')
1✔
575
            util.add(converted['url'], canonical)
1✔
576

577
        return converted
1✔
578

579
    @classmethod
1✔
580
    def _migrate_in(cls, user, from_user_id, **kwargs):
1✔
581
        """Migrates an external fediverse account in to be a bridged account.
582

583
        Just sets ``alsoKnownAs`` on this user's profile object. After calling this,
584
        the user must trigger the migration themselves, interactively, on the
585
        ``from_user_id`` account's instance!
586

587
        Args:
588
          user (models.User): native user on another protocol to attach the
589
            newly imported bridged account to
590
          from_user_id (str)
591
          kwargs: additional protocol-specific parameters
592
        """
593
        if not user.obj or not user.obj.as1:
1✔
594
            raise ValueError(f'No profile object for {user.key.id()}')
×
595

596
        logger.info(f"Adding {from_user_id} to {user.key.id()} 's alsoKnownAs")
1✔
597
        user.obj.our_as1 = user.obj.as1
1✔
598
        util.add(user.obj.our_as1.setdefault('alsoKnownAs', []), from_user_id)
1✔
599
        user.obj.put()
1✔
600

601
    @classmethod
1✔
602
    def migrate_out(cls, user, to_user_id):
1✔
603
        """Migrates a bridged account out to be a native account.
604

605
        * https://www.manton.org/2022/12/02/moving-from-mastodon.html
606
        * https://docs.joinmastodon.org/user/moving/#migration
607
        * https://docs.joinmastodon.org/spec/activitypub/#Move
608
        * https://www.w3.org/TR/activitystreams-vocabulary/#dfn-move
609

610
        Args:
611
          user (models.User)
612
          to_user_id (str)
613

614
        Raises:
615
          ValueError: eg if ``ActivityPub`` doesn't own ``to_user_id``
616
        """
617
        user_ap_id = user.id_as(cls)
1✔
618
        logger.info(f"Migrating {user.key.id()} 's bridged AP actor {user_ap_id} to {to_user_id}")
1✔
619

620
        cls.check_can_migrate_out(user, to_user_id)
1✔
621

622
        # send a Move activity to all followers' inboxes
623
        id = f'{user_ap_id}#move-{to_user_id}'
1✔
624
        move = Object(id=id, as2={
1✔
625
            'type': 'Move',
626
            'id': id,
627
            'actor': user_ap_id,
628
            'object': user_ap_id,
629
            'target': to_user_id,
630
            'to': [as2.PUBLIC_AUDIENCE],
631
        })
632
        move.put()
1✔
633
        logger.info(f'Delivering to AP followers: {move.as2}')
1✔
634
        ret = user.deliver(move, from_user=user, to_proto=cls)
1✔
635

636
        # set the bridged actor's alsoKnownAs, movedTo
637
        user.obj.our_as1 = copy.deepcopy(user.obj.as1)
1✔
638
        user.obj.our_as1['movedTo'] = to_user_id
1✔
639
        util.add(user.obj.our_as1.setdefault('alsoKnownAs', []), to_user_id)
1✔
640
        user.obj.put()
1✔
641

642
        return ret
1✔
643

644
    @classmethod
1✔
645
    def check_can_migrate_out(cls, user, to_user_id):
1✔
646
        """Raises an exception if a user can't yet migrate to a native AP account.
647

648
        For example, if ``to_user_id`` isn't an ActivityPub actor id, or if it
649
        doesn't have ``user``'s bridged AP id in its ``alsoKnownAs``.
650

651
        Args:
652
          user (models.User)
653
          to_user_id (str)
654

655
        Raises:
656
          ValueError: if ``user`` can't migrate to ActivityPub or ``to_user_id`` yet
657
        """
658
        logger.info(f'Checking that {user.key} can migrate to {to_user_id}')
1✔
659
        super().check_can_migrate_out(user, to_user_id)
1✔
660

661
        # check that the destination actor has an alias to the bridged actor
662
        if not (to_actor := cls.load(to_user_id, remote=True)):
1✔
663
            raise ValueError("Couldn't fetch {to_user_id}")
×
664

665
        aka = util.get_list(to_actor.as2, 'alsoKnownAs')
1✔
666
        user_ap_id = user.id_as(cls)
1✔
667
        if user_ap_id not in aka:
1✔
668
            msg = f"{to_user_id} 's alsoKnownAs doesn't contain {user_ap_id}: {aka}"
1✔
669
            logger.warning(msg)
1✔
670
            raise ValueError(msg)
1✔
671

672
    @classmethod
1✔
673
    def verify_signature(cls, activity):
1✔
674
        """Verifies the current request's HTTP Signature.
675

676
        Raises :class:`werkzeug.exceptions.HTTPError` if the signature is
677
        missing or invalid, otherwise does nothing and returns the id of the
678
        actor whose key signed the request.
679

680
        Logs details of the result.
681

682
        https://swicg.github.io/activitypub-http-signature/
683

684
        Args:
685
          activity (dict): AS2 activity
686

687
        Returns:
688
          str: signing AP actor id
689
        """
690
        headers = dict(request.headers)  # copy so we can modify below
1✔
691
        sig = headers.get('Signature')
1✔
692
        if not sig:
1✔
693
            if appengine_info.DEBUG:
1✔
694
                logger.info('No HTTP Signature, allowing due to DEBUG=true')
1✔
695
                return
1✔
696
            error('No HTTP Signature', status=401)
1✔
697

698
        logger.debug('Verifying HTTP Signature')
1✔
699
        logger.debug(f'Headers: {json_dumps(headers, indent=2)}')
1✔
700

701
        # parse_signature_header lower-cases all keys
702
        sig_fields = parse_signature_header(sig)
1✔
703
        key_id = fragmentless(sig_fields.get('keyid'))
1✔
704
        if not key_id:
1✔
705
            error('sig missing keyId', status=401)
1✔
706

707
        # TODO: right now, assume hs2019 is rsa-sha256. the real answer is...
708
        # ...complicated and unclear. 🤷
709
        # https://github.com/snarfed/bridgy-fed/issues/430#issuecomment-1510462267
710
        # https://arewehs2019yet.vpzom.click/
711
        # https://socialhub.activitypub.rocks/t/state-of-http-signatures/754/23
712
        # https://socialhub.activitypub.rocks/t/http-signatures-libraray/2087/2
713
        # https://github.com/mastodon/mastodon/pull/14556
714
        if sig_fields.get('algorithm') == 'hs2019':
1✔
715
            headers['Signature'] = headers['Signature'].replace(
×
716
                'algorithm="hs2019"', 'algorithm=rsa-sha256')
717

718
        digest = headers.get('Digest') or ''
1✔
719
        if not digest:
1✔
720
            error('Missing Digest', status=401)
×
721

722
        expected = b64encode(sha256(request.data).digest()).decode()
1✔
723
        if digest.removeprefix('SHA-256=').removeprefix('sha-256=') != expected:
1✔
724
            error('Invalid Digest', status=401)
1✔
725

726
        try:
1✔
727
            key_actor = cls._load_key(key_id)
1✔
728
        except BadGateway:
1✔
729
            obj_id = as1.get_object(activity).get('id')
1✔
730
            if (activity.get('type') == 'Delete' and obj_id
1✔
731
                    and key_id == fragmentless(obj_id)):
732
                logger.debug('Object/actor being deleted is also keyId')
1✔
733
                key_actor = Object.get_or_create(
1✔
734
                    id=key_id, authed_as=key_id, source_protocol='activitypub',
735
                    deleted=True)
736
            else:
737
                raise
×
738

739
        if key_actor and key_actor.deleted:
1✔
740
            abort(202, f'Ignoring, signer {key_id} is already deleted')
1✔
741
        elif not key_actor or not key_actor.as1:
1✔
742
            error(f"Couldn't load {key_id} to verify signature", status=401)
×
743

744
        # don't ActivityPub.convert since we don't want to postprocess_as2
745
        key = as2.from_as1(key_actor.as1).get('publicKey', {}).get('publicKeyPem')
1✔
746
        if not key:
1✔
747
            error(f'No public key for {key_id}', status=401)
1✔
748

749
        # can't use request.full_path because it includes a trailing ? even if
750
        # it wasn't in the request. https://github.com/pallets/flask/issues/2867
751
        path_query = request.url.removeprefix(request.host_url.rstrip('/'))
1✔
752
        logger.debug(f'Verifying signature for {path_query} with key {sig_fields["keyid"]}')
1✔
753
        try:
1✔
754
            verified = HeaderVerifier(headers, key,
1✔
755
                                      required_headers=['Digest'],
756
                                      method=request.method,
757
                                      path=path_query,
758
                                      sign_header='signature',
759
                                      ).verify()
760
        except BaseException as e:
×
761
            error(f'sig verification failed: {e}', status=401)
×
762

763
        if verified:
1✔
764
            logger.debug('sig ok')
1✔
765
        else:
766
            error('sig failed', status=401)
1✔
767

768
        return key_actor.key.id()
1✔
769

770
    @classmethod
1✔
771
    def _load_key(cls, key_id, follow_owner=True):
1✔
772
        """Loads the ActivityPub actor for a given ``keyId``.
773

774
        https://swicg.github.io/activitypub-http-signature/#how-to-obtain-a-signature-s-public-key
775
        Args:
776
          key_id (str): ``keyId`` from an HTTP Signature
777
          follow_owner (bool): whether to follow ``owner``/``controller`` fields
778

779
        Returns:
780
          Object or None:
781

782
        Raises:
783
          requests.HTTPError:
784
        """
785
        assert '#' not in key_id
1✔
786
        # TODO: we don't currently handle when this raises InvalidURL, see error
787
        # below, but I can't reproduce it, when I mock request.get to return
788
        # InvalidURL in test_inbox_verify_sig_fetch_key_fails, we return 400
789
        # instead of crashing :/
790
        # https://console.cloud.google.com/errors/detail/COLzgISI47vpMg?project=bridgy-federated
791
        actor = cls.load(key_id)
1✔
792
        if not actor:
1✔
793
            return None
×
794

795
        if follow_owner and actor.as1:
1✔
796
            actor_as2 = as2.from_as1(actor.as1)
1✔
797
            key = actor_as2.get('publicKey', {})
1✔
798
            owner = key.get('controller') or key.get('owner')
1✔
799
            if not owner and actor.type not in as1.ACTOR_TYPES:
1✔
800
                owner = actor_as2.get('controller') or actor_as2.get('owner')
×
801

802
            if owner:
1✔
803
                owner = fragmentless(owner)
1✔
804
                if owner != key_id:
1✔
805
                    logger.debug(f'keyId {key_id} has controller/owner {owner}, fetching that')
1✔
806
                    return cls._load_key(owner, follow_owner=False)
1✔
807

808
        return actor
1✔
809

810

811
def signed_get(url, from_user=None, **kwargs):
1✔
812
    return signed_request(util.requests_get, url, from_user=from_user, **kwargs)
1✔
813

814

815
def signed_post(url, from_user, **kwargs):
1✔
816
    assert from_user
1✔
817
    return signed_request(util.requests_post, url, from_user=from_user, **kwargs)
1✔
818

819

820
def signed_request(fn, url, data=None, headers=None, from_user=None,
1✔
821
                   _redirect_count=None, **kwargs):
822
    """Wraps ``requests.*`` and adds HTTP Signature.
823

824
    https://swicg.github.io/activitypub-http-signature/
825

826
    Args:
827
      fn (callable): :func:`util.requests_get` or  :func:`util.requests_post`
828
      url (str):
829
      data (dict): optional AS2 object
830
      from_user (models.User): user to sign request as; optional. If not
831
        provided, uses the default user ``@fed.brid.gy@fed.brid.gy``.
832
      _redirect_count: internal, used to count redirects followed so far
833
      kwargs: passed through to requests
834

835
    Returns:
836
      requests.Response:
837
    """
838
    if headers is None:
1✔
839
        headers = {}
1✔
840

841
    # prepare HTTP Signature and headers
842
    if not from_user or isinstance(from_user, ActivityPub):
1✔
843
        # ActivityPub users are remote, so we don't have their keys
844
        from_user = instance_actor()
1✔
845

846
    if data:
1✔
847
        logger.debug(f'Sending AS2 object: {json_dumps(data, indent=2)}')
1✔
848
        data = json_dumps(data).encode()
1✔
849

850
    headers = {
1✔
851
        **headers,
852
        # required for HTTP Signature
853
        # https://tools.ietf.org/html/draft-cavage-http-signatures-07#section-2.1.3
854
        'Date': util.now().strftime('%a, %d %b %Y %H:%M:%S GMT'),
855
        # required by Mastodon
856
        # https://github.com/tootsuite/mastodon/pull/14556#issuecomment-674077648
857
        'Host': util.domain_from_link(url, minimize=False),
858
        'Content-Type': as2.CONTENT_TYPE_LD_PROFILE,
859
        # required for HTTP Signature and Mastodon
860
        'Digest': f'SHA-256={b64encode(sha256(data or b"").digest()).decode()}',
861
    }
862

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

865
    # (request-target) is a special HTTP Signatures header that some fediverse
866
    # implementations require, eg Peertube.
867
    # https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#section-2.3
868
    # https://www.w3.org/wiki/SocialCG/ActivityPub/Authentication_Authorization#Signing_requests_using_HTTP_Signatures
869
    # https://docs.joinmastodon.org/spec/security/#http
870
    key_id = f'{from_user.id_as(ActivityPub)}#key'
1✔
871
    auth = HTTPSignatureAuth(secret=from_user.private_pem(), key_id=key_id,
1✔
872
                             algorithm='rsa-sha256', sign_header='signature',
873
                             headers=HTTP_SIG_HEADERS)
874

875
    # make HTTP request
876
    kwargs.setdefault('gateway', True)
1✔
877
    resp = fn(url, data=data, auth=auth, headers=headers, allow_redirects=False,
1✔
878
              **kwargs)
879

880
    if fn == util.requests_get:
1✔
881
        assert not isinstance(resp, MagicMock), \
1✔
882
            f'unit test missing a mock HTTP response for {url}'
883

884
    # handle GET redirects manually so that we generate a new HTTP signature
885
    if resp.is_redirect and fn == util.requests_get:
1✔
886
        new_url = urljoin(url, resp.headers['Location'])
1✔
887
        if _redirect_count is None:
1✔
888
            _redirect_count = 0
1✔
889
        elif _redirect_count > DEFAULT_REDIRECT_LIMIT:
1✔
890
            raise TooManyRedirects(response=resp)
1✔
891

892
        return signed_request(fn, new_url, data=data, from_user=from_user,
1✔
893
                              headers=headers, _redirect_count=_redirect_count + 1,
894
                              **kwargs)
895

896
    type = common.content_type(resp)
1✔
897
    if (type and type != 'text/html' and
1✔
898
        (type.startswith('text/') or type.endswith('+json')
899
         or type.endswith('/json'))):
900
        logger.debug(resp.text)
1✔
901

902
    return resp
1✔
903

904

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

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

910
    Args:
911
      activity (dict): AS2 object or activity
912
      orig_obj (dict): AS2 object, optional. The target of activity's
913
        ``inReplyTo`` or ``Like``/``Announce``/etc object, if any.
914
      wrap (bool): whether to wrap ``id``, ``url``, ``object``, ``actor``, and
915
       ``attributedTo``
916
    """
917
    if not activity or isinstance(activity, str):
1✔
918
        return redirect_wrap(activity) if wrap else activity
×
919
    elif activity.keys() == {'id'}:
1✔
920
        return redirect_wrap(activity['id']) if wrap else activity['id']
1✔
921

922
    type = activity.get('type')
1✔
923

924
    # inReplyTo: singly valued, prefer id over url
925
    # TODO: ignore orig_obj, do for all inReplyTo
926
    orig_id = orig_obj.get('id') if orig_obj else None
1✔
927
    in_reply_to = util.get_list(activity, 'inReplyTo')
1✔
928
    if in_reply_to:
1✔
929
        if orig_id:  # TODO: and orig_id in in_reply_to ...or get rid of orig_obj
1✔
930
            activity['inReplyTo'] = orig_id
1✔
931
        elif len(in_reply_to) > 1:
1✔
932
            # AS2 inReplyTo can be multiply valued, it's not marked Functional:
933
            # https://www.w3.org/TR/activitystreaams-vocabulary/#dfn-inreplyto
934
            # ...but most fediverse projects don't support that:
935
            # https://funfedi.dev/support_tables/generated/in_reply_to/
936
            logger.warning(
1✔
937
                "AS2 doesn't support multiple inReplyTo URLs! "
938
                f'Only using the first: {in_reply_to[0]}')
939
            activity['inReplyTo'] = in_reply_to[0]
1✔
940

941
        # Mastodon evidently requires a Mention tag for replies to generate a
942
        # notification to the original post's author. also include the original
943
        # post's own Mention tags to notify other people involved in the thread.
944
        # not required for likes, reposts, etc.
945
        # https://github.com/snarfed/bridgy-fed/issues/34
946
        # https://github.com/snarfed/bridgy-fed/issues/1608
947
        if orig_obj and ActivityPub.owns_id(orig_id) is not False:
1✔
948
            orig_mentions = [t.get('href') for t in as1.get_objects(orig_obj, 'tag')
1✔
949
                             if t.get('type') == 'Mention']
950
            for to in (util.get_list(orig_obj, 'attributedTo') +
1✔
951
                       util.get_list(orig_obj, 'author') +
952
                       util.get_list(orig_obj, 'actor') +
953
                       orig_mentions):
954
                if isinstance(to, dict):
1✔
955
                    to = util.get_first(to, 'url') or to.get('id')
1✔
956
                if to:
1✔
957
                    add(activity.setdefault('tag', []), {
1✔
958
                        'type': 'Mention',
959
                        'href': to,
960
                    })
961

962
    # activity objects (for Like, Announce, etc): prefer id over url
963
    obj = as1.get_object(activity)
1✔
964
    id = obj.get('id')
1✔
965
    if orig_id and type in as2.TYPES_WITH_OBJECT and type != 'Undo':
1✔
966
        # inline most objects as bare string ids, not composite objects, for interop
967
        activity['object'] = orig_id
1✔
968
    elif not id:
1✔
969
        obj['id'] = util.get_first(obj, 'url')
1✔
970

971
    # id is required for most things. default to url if it's not set.
972
    if not activity.get('id'):
1✔
973
        activity['id'] = util.get_first(activity, 'url')
1✔
974

975
    if wrap:
1✔
976
        # some fediverse servers (eg Misskey) require activity id and actor id
977
        # to be on the same domain
978
        # https://github.com/snarfed/bridgy-fed/issues/1093#issuecomment-2299247639
979
        redirect_domain = util.domain_from_link(as1.get_id(activity, 'actor'))
1✔
980
        if redirect_domain not in DOMAINS:
1✔
981
            redirect_domain = None
1✔
982
        activity['id'] = redirect_wrap(activity.get('id'), domain=redirect_domain)
1✔
983
        activity['url'] = [redirect_wrap(u) for u in util.get_list(activity, 'url')]
1✔
984
        if len(activity['url']) == 1:
1✔
985
            activity['url'] = activity['url'][0]
1✔
986

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

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

994
    # move Link attachments to links in text since fediverse instances generate
995
    # their own link previews.
996
    # https://github.com/snarfed/bridgy-fed/issues/958
997
    atts = util.pop_list(obj_or_activity, 'attachment')
1✔
998
    obj_or_activity['attachment'] = [a for a in atts if a.get('type') != 'Link']
1✔
999
    link_atts = [a for a in atts if a.get('type') == 'Link']
1✔
1000

1001
    content = obj_or_activity.get('content', '')
1✔
1002
    for link in link_atts:
1✔
1003
        for url in util.get_list(link, 'href'):
1✔
1004
            if content:
1✔
1005
                content += '<br><br>'
1✔
1006
            content += util.pretty_link(url, text=link.get('name'))
1✔
1007

1008
    if content:
1✔
1009
        as2.set_content(obj_or_activity, content)
1✔
1010

1011
    # copy image(s) into attachment(s). may be Mastodon-specific.
1012
    # https://github.com/snarfed/bridgy-fed/issues/33#issuecomment-440965618
1013
    imgs = util.get_list(obj_or_activity, 'image')
1✔
1014
    if imgs:
1✔
1015
        atts = obj_or_activity['attachment']
1✔
1016
        for img in imgs:
1✔
1017
            if isinstance(img, str):
1✔
1018
                img = {'url': img}
1✔
1019
            add(atts, img)
1✔
1020

1021
    # determine whether this is a DM *before* we modify the cc field, below
1022
    #
1023
    # WARNING: activity and obj here are AS2, but we're using as1.is_dm. right now
1024
    # the logic is effectively the same for our purposes, but watch out here if that
1025
    # ever changes.
1026
    if not as1.is_dm(activity):
1✔
1027
        # to public, since Mastodon interprets to public as public, cc public as
1028
        # unlisted:
1029
        # https://socialhub.activitypub.rocks/t/visibility-to-cc-mapping/284
1030
        # https://wordsmith.social/falkreon/securing-activitypub
1031
        add(activity.setdefault('to', []), as2.PUBLIC_AUDIENCE)
1✔
1032
        if obj and type in as2.CRUD_VERBS:
1✔
1033
            add(obj.setdefault('to', []), as2.PUBLIC_AUDIENCE)
1✔
1034

1035
    # cc target's author(s), recipients, mentions
1036
    # https://www.w3.org/TR/activitystreams-vocabulary/#audienceTargeting
1037
    # https://w3c.github.io/activitypub/#delivery
1038
    # https://docs.joinmastodon.org/spec/activitypub/#Mention
1039
    cc = obj_or_activity.setdefault('cc', [])
1✔
1040

1041
    tags = util.get_list(activity, 'tag') + util.get_list(obj, 'tag')
1✔
1042
    for tag in tags:
1✔
1043
        href = tag.get('href')
1✔
1044
        if (tag.get('type') == 'Mention'
1✔
1045
                and href
1046
                and href not in util.get_list(obj_or_activity, 'to')
1047
                and not ActivityPub.is_blocklisted(href)):
1048
            add(cc, href)
1✔
1049

1050
    if orig_obj and type in as2.TYPE_TO_VERB:
1✔
1051
        for field in 'actor', 'attributedTo', 'to', 'cc':
1✔
1052
            for recip in as1.get_objects(orig_obj, field):
1✔
1053
                add(cc, util.get_url(recip) or recip.get('id'))
1✔
1054

1055
    # for some activities, Pleroma (and Akkoma?) seem to crash if the activity's
1056
    # to and cc aren't exactly the same as the object's. (I think?)
1057
    # https://indieweb.social/@diego@lounge.collabfc.com/112977955332152430
1058
    # https://git.pleroma.social/pleroma/pleroma/-/issues/3206#note_108296
1059
    # https://github.com/snarfed/bridgy-fed/issues/12#issuecomment-2302776658
1060
    if type in ('Create', 'Update'):
1✔
1061
        activity['to'] = util.get_list(obj, 'to')
1✔
1062
        activity['cc'] = util.get_list(obj, 'cc')
1✔
1063

1064
    # hashtags. Mastodon requires:
1065
    # * type: Hashtag
1066
    # * name starts with #
1067
    # * href is set to a valid, fully qualified URL
1068
    #
1069
    # If content has an <a> tag with a fully qualified URL and the hashtag name
1070
    # (with leading #) as its text, Mastodon will rewrite its href to the local
1071
    # instance's search for that hashtag. If content doesn't have a link for a
1072
    # given hashtag, Mastodon won't add one, but that hashtag will still be
1073
    # indexed in search.
1074
    #
1075
    # https://docs.joinmastodon.org/spec/activitypub/#properties-used
1076
    # https://github.com/snarfed/bridgy-fed/issues/45
1077
    for tag in tags:
1✔
1078
        name = tag.get('name')
1✔
1079
        if name and tag.get('type', 'Tag') == 'Tag':
1✔
1080
            tag['type'] = 'Hashtag'
1✔
1081
            url_path = f'/hashtag/{quote_plus(name.removeprefix("#"))}'
1✔
1082
            tag.setdefault('href', urljoin(activity['id'], url_path))
1✔
1083
            if not name.startswith('#'):
1✔
1084
                tag['name'] = f'#{name}'
1✔
1085

1086
    as2.link_tags(obj_or_activity)
1✔
1087

1088
    activity['object'] = [
1✔
1089
        postprocess_as2(o, orig_obj=orig_obj,
1090
                        wrap=wrap and type in ('Create', 'Update', 'Delete'))
1091
        for o in as1.get_objects(activity)]
1092
    if len(activity['object']) == 1:
1✔
1093
        activity['object'] = activity['object'][0]
1✔
1094

1095
    if content := obj_or_activity.get('content'):
1✔
1096
        # language, in contentMap
1097
        # https://github.com/snarfed/bridgy-fed/issues/681
1098
        obj_or_activity.setdefault('contentMap', {'en': content})
1✔
1099

1100
        # wrap in <p>. some fediverse servers (eg Mastodon) have a white-space:
1101
        # pre-wrap style that applies to p inside content. this preserves
1102
        # meaningful whitespace in plain text content.
1103
        # https://github.com/snarfed/bridgy-fed/issues/990
1104
        if not content.startswith('<p>'):
1✔
1105
            as2.set_content(obj_or_activity, f'<p>{content}</p>')
1✔
1106

1107
    activity.pop('content_is_html', None)
1✔
1108
    return util.trim_nulls(activity)
1✔
1109

1110

1111
def postprocess_as2_actor(actor, user):
1✔
1112
    """Prepare an AS2 actor object to be served or sent via ActivityPub.
1113

1114
    Modifies actor in place.
1115

1116
    Args:
1117
      actor (dict): AS2 actor object
1118
      user (models.User): current user
1119

1120
    Returns:
1121
      actor dict
1122
    """
1123
    if not actor:
1✔
1124
        return actor
×
1125

1126
    assert isinstance(actor, dict)
1✔
1127
    assert user
1✔
1128

1129
    # remove acct: urls, set default url
1130
    urls = [u for u in as2.get_urls(actor) if not u.startswith('acct:')]
1✔
1131
    url = user.web_url()
1✔
1132
    if not urls and url:
1✔
1133
        urls = [url]
1✔
1134
    if urls:
1✔
1135
        urls[0] = redirect_wrap(urls[0])
1✔
1136
    actor['url'] = urls[0] if len(urls) == 1 else urls
1✔
1137

1138
    id = actor.get('id')
1✔
1139
    user_id = user.key.id()
1✔
1140
    if not id or user.is_web_url(id) or unwrap(id) in (
1✔
1141
            user_id, user.profile_id(), f'www.{user_id}'):
1142
        id = actor['id'] = user.id_as(ActivityPub)
1✔
1143

1144
    # required by ActivityPub
1145
    # https://www.w3.org/TR/activitypub/#actor-objects
1146
    actor.setdefault('inbox', id + '/inbox')
1✔
1147
    actor.setdefault('outbox', id + '/outbox')
1✔
1148

1149
    # For web, this has to be domain for Mastodon etc interop! It seems like it
1150
    # should be the custom username from the acct: u-url in their h-card, but
1151
    # that breaks Mastodon's Webfinger discovery.
1152
    # Background:
1153
    # https://docs.joinmastodon.org/spec/activitypub/#properties-used-1
1154
    # https://docs.joinmastodon.org/spec/webfinger/#mastodons-requirements-for-webfinger
1155
    # https://github.com/snarfed/bridgy-fed/issues/302#issuecomment-1324305460
1156
    # https://github.com/snarfed/bridgy-fed/issues/77
1157
    if user.LABEL == 'web':
1✔
1158
        actor['preferredUsername'] = user.key.id()
1✔
1159
    else:
1160
        handle = user.handle_as(ActivityPub)
1✔
1161
        if handle:
1✔
1162
            actor['preferredUsername'] = handle.strip('@').split('@')[0]
1✔
1163

1164
    # Override the label for their home page to be "Web site"
1165
    for att in util.get_list(actor, 'attachment'):
1✔
1166
        if att.get('type') == 'PropertyValue':
1✔
1167
            val = att.get('value', '')
1✔
1168
            link = util.parse_html(val).find('a')
1✔
1169
            if url and link and url.rstrip('/') in [val.rstrip('/'),
1✔
1170
                                                    link.get('href').rstrip('/')]:
1171
                att['name'] = 'Web site'
1✔
1172

1173
    # required by pixelfed
1174
    #
1175
    # https://github.com/snarfed/bridgy-fed/issues/1893
1176
    actor.setdefault('manuallyApprovesFollowers', False)
1✔
1177
    # https://github.com/snarfed/bridgy-fed/issues/39
1178
    actor.setdefault('summary', '')
1✔
1179

1180
    if not actor.get('publicKey') and not isinstance(user, ActivityPub):
1✔
1181
        # underspecified, inferred from this issue and Mastodon's implementation:
1182
        # https://github.com/w3c/activitypub/issues/203#issuecomment-297553229
1183
        # https://github.com/tootsuite/mastodon/blob/bc2c263504e584e154384ecc2d804aeb1afb1ba3/app/services/activitypub/process_account_service.rb#L77
1184
        actor['publicKey'] = {
1✔
1185
            'id': f'{id}#key',
1186
            'owner': id,
1187
            'publicKeyPem': user.public_pem().decode(),
1188
        }
1189
        actor['@context'] = util.get_list(actor, '@context')
1✔
1190
        add(actor['@context'], SECURITY_CONTEXT)
1✔
1191

1192
    # TODO: bring back once we figure out how to get Mastodon to support this and
1193
    # Pleroma and Akkoma not to DDoS us
1194
    # https://github.com/snarfed/bridgy-fed/issues/1374#issuecomment-2891993190
1195
    #
1196
    # featured collection, pinned posts
1197
    if featured := actor.get('featured'):
1✔
1198
        featured.setdefault('id', id + '/featured')
×
1199

1200
    return actor
1✔
1201

1202

1203
def _load_user(handle_or_id, create=False):
1✔
1204
    if handle_or_id == PRIMARY_DOMAIN or handle_or_id in PROTOCOL_DOMAINS:
1✔
1205
        from web import Web
1✔
1206
        proto = Web
1✔
1207
    else:
1208
        proto = Protocol.for_request(fed='web')
1✔
1209

1210
    if not proto:
1✔
1211
        error(f"Couldn't determine protocol", status=404)
1✔
1212

1213
    if proto.owns_id(handle_or_id) is False:
1✔
1214
        if proto.owns_handle(handle_or_id) is False:
1✔
1215
            error(f"{handle_or_id} doesn't look like a {proto.LABEL} id or handle",
1✔
1216
                  status=404)
1217
        id = proto.handle_to_id(handle_or_id)
×
1218
        if not id:
×
1219
            error(f"Couldn't resolve {handle_or_id} as a {proto.LABEL} handle",
×
1220
                  status=404)
1221
    else:
1222
        id = handle_or_id
1✔
1223

1224
    assert id
1✔
1225
    try:
1✔
1226
        user = proto.get_or_create(id) if create else proto.get_by_id(id)
1✔
1227
    except ValueError as e:
1✔
1228
        logging.warning(e)
1✔
1229
        user = None
1✔
1230

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

1234
    return user
1✔
1235

1236

1237
# source protocol in subdomain.
1238
# WARNING: the user page handler in pages.py overrides this for fediverse
1239
# addresses with leading @ character. be careful when changing this route!
1240
@app.get(f'/ap/<handle_or_id>')
1✔
1241
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
1242
# backward compatibility
1243
@app.get(f'/<regex("{DOMAIN_RE}"):handle_or_id>')
1✔
1244
@flask_util.headers(CACHE_CONTROL_VARY_ACCEPT)
1✔
1245
def actor(handle_or_id):
1✔
1246
    """Serves a user's AS2 actor from the datastore."""
1247
    user = _load_user(handle_or_id, create=True)
1✔
1248
    proto = user
1✔
1249

1250
    as2_type = as2_request_type()
1✔
1251
    if not as2_type:
1✔
1252
        return redirect(user.web_url(), code=302)
1✔
1253

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

1259
    id = user.id_as(ActivityPub)
1✔
1260
    # check that we're serving from the right subdomain
1261
    if request.host != urlparse(id).netloc:
1✔
1262
        raise MovedPermanently(location=id)
1✔
1263

1264
    actor = ActivityPub.convert(user.obj, from_user=user) or {
1✔
1265
        '@context': as2.CONTEXT,
1266
        'type': 'Person',
1267
    }
1268
    actor = postprocess_as2_actor(actor, user=user)
1✔
1269

1270
    actor['@context'] = util.get_list(actor, '@context')
1✔
1271
    add(actor['@context'], AKA_CONTEXT)
1✔
1272
    add(actor.setdefault('alsoKnownAs', []), user.id_uri())
1✔
1273

1274
    actor.update({
1✔
1275
        'id': id,
1276
        'inbox': id + '/inbox',
1277
        'outbox': id + '/outbox',
1278
        'following': id + '/following',
1279
        'followers': id + '/followers',
1280
        'endpoints': {
1281
            'sharedInbox': urljoin(id, '/ap/sharedInbox'),
1282
        },
1283
    })
1284

1285
    logger.debug(f'Returning: {json_dumps(actor, indent=2)}')
1✔
1286
    return actor, {
1✔
1287
        'Content-Type': as2_type,
1288
        'Access-Control-Allow-Origin': '*',
1289
    }
1290

1291

1292
# note that this shared inbox path overlaps with the /ap/<handle_or_id> actor
1293
# route above, but doesn't collide because this is POST and that one is GET.
1294
@app.post('/ap/sharedInbox')
1✔
1295
# source protocol in subdomain
1296
@app.post(f'/ap/<id>/inbox')
1✔
1297
# source protocol in path; primarily for backcompat
1298
@app.post(f'/ap/<protocol>/<id>/inbox')
1✔
1299
# special case Web users on fed subdomain without /ap/web/ prefix
1300
@app.post(f'/<regex("{DOMAIN_RE}"):id>/inbox')
1✔
1301
def inbox(protocol=None, id=None):
1✔
1302
    """Handles ActivityPub inbox delivery."""
1303
    # parse and validate AS2 activity
1304
    try:
1✔
1305
        activity = request.json
1✔
1306
        assert activity and isinstance(activity, dict)
1✔
1307
    except (TypeError, ValueError, AssertionError):
×
1308
        body = request.get_data(as_text=True)
×
1309
        error(f"Couldn't parse body as non-empty JSON mapping: {body}", exc_info=True)
×
1310

1311
    # do we support this object type?
1312
    # (this logic is duplicated in Protocol.check_supported)
1313
    obj = as1.get_object(activity)
1✔
1314
    inner_type = obj.get('type')
1✔
1315
    if type := activity.get('type'):
1✔
1316
        if (type not in ActivityPub.SUPPORTED_AS2_TYPES or
1✔
1317
            (type in ('Create', 'Update')
1318
             and inner_type
1319
             and inner_type not in ActivityPub.SUPPORTED_AS2_TYPES)):
1320
            error(f"Bridgy Fed for ActivityPub doesn't support {type} {inner_type} yet: {json_dumps(activity, indent=2)}", status=204)
1✔
1321

1322
    # check actor, authz actor's domain against activity and object ids
1323
    # https://github.com/snarfed/bridgy-fed/security/advisories/GHSA-37r7-jqmr-3472
1324
    actor = (as1.get_object(activity, 'actor')
1✔
1325
             or as1.get_object(activity, 'attributedTo'))
1326
    actor_id = actor.get('id')
1✔
1327
    if ActivityPub.owns_id(actor_id) is False:
1✔
1328
        error(f'Bad ActivityPub actor id {actor_id}', status=400)
1✔
1329

1330
    actor_domain = util.domain_from_link(actor_id)
1✔
1331
    # temporary, see emails w/Michael et al, and
1332
    # https://github.com/snarfed/bridgy-fed/issues/1686
1333
    if actor_domain == 'newsmast.community' and type == 'Undo':
1✔
1334
        return ':(', 204
×
1335

1336
    id = activity.get('id')
1✔
1337
    if id and ActivityPub.owns_id(id) is False:
1✔
1338
        error(f'Bad ActivityPub activity id {id}', status=400)
1✔
1339

1340
    obj_id = obj.get('id')
1✔
1341
    if id and actor_domain != util.domain_from_link(id):
1✔
1342
        report_error(f'Auth: actor and activity on different domains: {json_dumps(activity, indent=2)}',
1✔
1343
                     user=f'actor {actor_id} activity {id}')
1344
        return f'actor {actor_id} and activity {id} on different domains', 403
1✔
1345
    elif (type in as2.CRUD_VERBS and obj_id
1✔
1346
          and actor_domain != util.domain_from_link(obj_id)):
1347
        report_error(f'Auth: actor and object on different domains {json_dumps(activity, indent=2)}',
1✔
1348
                     user=f'actor {actor_id} object {obj_id}')
1349
        return f'actor {actor_id} and object {obj_id} on different domains', 403
1✔
1350

1351
    # are we already processing or done with this activity?
1352
    if id:
1✔
1353
        domain = util.domain_from_link(id)
1✔
1354
        if memcache.memcache.get(activity_id_memcache_key(id)):
1✔
1355
            logger.info(f'Already seen {id}')
1✔
1356
            return '', 204
1✔
1357

1358
    # check signature, auth
1359
    authed_as = ActivityPub.verify_signature(activity)
1✔
1360

1361
    if util.domain_or_parent_in(authed_as, NO_AUTH_DOMAINS):
1✔
1362
        error(f"Ignoring, sorry, we don't know how to authorize {util.domain_from_link(authed_as)} activities yet. https://github.com/snarfed/bridgy-fed/issues/566", status=204)
1✔
1363

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

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

1371
    if type == 'Follow':
1✔
1372
        # rendered mf2 HTML proxy pages (in render.py) fall back to redirecting
1373
        # to the follow's AS2 id field, but Mastodon's Accept ids are URLs that
1374
        # don't load in browsers, eg:
1375
        # https://jawns.club/ac33c547-ca6b-4351-80d5-d11a6879a7b0
1376
        #
1377
        # so, set a synthetic URL based on the follower's profile.
1378
        # https://github.com/snarfed/bridgy-fed/issues/336
1379
        follower_url = unwrap(util.get_url(activity, 'actor'))
1✔
1380
        followee_url = unwrap(util.get_url(activity, 'object'))
1✔
1381
        activity.setdefault('url', f'{follower_url}#followed-{followee_url}')
1✔
1382

1383
    elif type in ('Add', 'Remove'):
1✔
1384
        # a user changed their pinned posts
1385
        # https://github.com/snarfed/bridgy-fed/issues/2102
1386
        if target := as1.get_id(activity, 'target'):
1✔
1387
            user = ActivityPub.get_by_id(actor_id)
1✔
1388
            if user and user.obj and target == as1.get_id(user.obj.as1, 'featured'):
1✔
1389
                logger.info('Modified pinned posts, reloading profile')
1✔
1390
                user.reload_profile()
1✔
1391
                create_task(queue='receive', obj_id=user.obj_key.id(),
1✔
1392
                            authed_as=authed_as)
1393
                return 'OK', 202
1✔
1394
        return 'Ignored', 204
1✔
1395

1396
    if not id:
1✔
1397
        id = f'{actor_id}#{type}-{obj_id or ""}-{util.now().isoformat()}'
1✔
1398
        logger.info(f'Generated synthetic activity id {id}')
1✔
1399

1400
    # automatically bridge server aka instance actors
1401
    # https://codeberg.org/fediverse/fep/src/branch/main/fep/d556/fep-d556.md
1402
    if as2.is_server_actor(actor):
1✔
1403
        all_protocols = [proto.LABEL for proto in set(PROTOCOLS.values())
1✔
1404
                         if proto not in (ActivityPub, UIProtocol, None)]
1405
        user = ActivityPub.get_or_create(actor_id, propagate=False,
1✔
1406
                                         enabled_protocols=all_protocols)
1407
        if user and not user.existing:
1✔
1408
            logger.info(f'Automatically enabled AP server actor {actor_id}')
1✔
1409

1410
    delay = None
1✔
1411
    if type == 'Delete' or (type == 'Undo' and inner_type != 'Follow'):
1✔
1412
        delay = DELETE_TASK_DELAY
1✔
1413

1414
    return create_task(queue='receive', id=id, as2=activity,
1✔
1415
                       source_protocol=ActivityPub.LABEL, authed_as=authed_as,
1416
                       received_at=util.now().isoformat(), delay=delay)
1417

1418

1419
# protocol in subdomain
1420
@app.get(f'/ap/<id>/<any(followers,following):collection>')
1✔
1421
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
1422
# backward compatibility
1423
@app.route(f'/<regex("{DOMAIN_RE}"):id>/<any(followers,following):collection>',
1✔
1424
           methods=['GET', 'HEAD'])
1425
@flask_util.headers(CACHE_CONTROL)
1✔
1426
def follower_collection(id, collection):
1✔
1427
    """ActivityPub Followers and Following collections.
1428

1429
    * https://www.w3.org/TR/activitypub/#followers
1430
    * https://www.w3.org/TR/activitypub/#collections
1431
    * https://www.w3.org/TR/activitystreams-core/#paging
1432

1433
    TODO: unify page generation with outbox()
1434
    """
1435
    if (request.path.startswith('/ap/')
1✔
1436
            and request.host in (PRIMARY_DOMAIN,) + LOCAL_DOMAINS):
1437
        # UI request. unfortunate that the URL paths overlap like this!
1438
        import pages
1✔
1439
        return pages.followers_or_following('ap', id, collection)
1✔
1440

1441
    user = _load_user(id)
1✔
1442

1443
    if request.method == 'HEAD':
1✔
1444
        return '', {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
1✔
1445

1446
    # page
1447
    followers, new_before, new_after = Follower.fetch_page(collection, user=user)
1✔
1448
    page = {
1✔
1449
        'type': 'CollectionPage',
1450
        'partOf': request.base_url,
1451
        'items': util.trim_nulls(
1452
            [ActivityPub.convert(f.user.obj)
1453
             for f in followers if f.user.is_enabled(ActivityPub)]),
1454
    }
1455
    if new_before:
1✔
1456
        page['next'] = f'{request.base_url}?before={new_before}'
1✔
1457
    if new_after:
1✔
1458
        page['prev'] = f'{request.base_url}?after={new_after}'
1✔
1459

1460
    if 'before' in request.args or 'after' in request.args:
1✔
1461
        page.update({
1✔
1462
            '@context': 'https://www.w3.org/ns/activitystreams',
1463
            'id': request.url,
1464
        })
1465
        logger.debug(f'Returning {json_dumps(page, indent=2)}')
1✔
1466
        return page, {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
1✔
1467

1468
    ret = {
1✔
1469
        '@context': 'https://www.w3.org/ns/activitystreams',
1470
        'id': request.base_url,
1471
        'type': 'Collection',
1472
        'summary': f"{id}'s {collection}",
1473
        'first': page,
1474
    }
1475

1476
    # count total if it's small, <= 1k. we should eventually precompute this
1477
    # so that we can always return it cheaply.
1478
    prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1479
    count = Follower.query(prop == user.key, Follower.status == 'active')\
1✔
1480
                    .count(limit=1001)
1481
    if count != 1001:
1✔
1482
        ret['totalItems'] = count
1✔
1483

1484
    logger.debug(f'Returning {json_dumps(collection, indent=2)}')
1✔
1485
    return ret, {
1✔
1486
        'Content-Type': as2.CONTENT_TYPE_LD_PROFILE,
1487
    }
1488

1489

1490
# protocol in subdomain
1491
@app.get(f'/ap/<id>/outbox')
1✔
1492
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
1493
# backward compatibility
1494
@app.route(f'/<regex("{DOMAIN_RE}"):id>/outbox', methods=['GET', 'HEAD'])
1✔
1495
@flask_util.headers(CACHE_CONTROL)
1✔
1496
def outbox(id):
1✔
1497
    """Serves a user's AP outbox.
1498

1499
    TODO: unify page generation with follower_collection()
1500
    """
1501
    user = _load_user(id)
1✔
1502

1503
    if request.method == 'HEAD':
1✔
1504
        return '', {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
1✔
1505

1506
    # TODO: bring this back once we filter it by author status, etc
1507
    # query = Object.query(Object.users == user.key)
1508
    # objects, new_before, new_after = fetch_objects(query, by=Object.updated,
1509
    #                                                user=user)
1510

1511
    # page = {
1512
    #     'type': 'CollectionPage',
1513
    #     'partOf': request.base_url,
1514
    #     'items': util.trim_nulls([ActivityPub.convert(obj, from_user=user)
1515
    #                               for obj in objects]),
1516
    # }
1517
    # if new_before:
1518
    #     page['next'] = f'{request.base_url}?before={new_before}'
1519
    # if new_after:
1520
    #     page['prev'] = f'{request.base_url}?after={new_after}'
1521

1522
    # if 'before' in request.args or 'after' in request.args:
1523
    #     page.update({
1524
    #         '@context': 'https://www.w3.org/ns/activitystreams',
1525
    #         'id': request.url,
1526
    #     })
1527
    #     logger.debug(f'Returning {json_dumps(page, indent=2)}')
1528
    #     return page, {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
1529

1530
    ret = {
1✔
1531
        '@context': 'https://www.w3.org/ns/activitystreams',
1532
        'id': request.url,
1533
        'type': 'OrderedCollection',
1534
        'summary': f"{id}'s outbox",
1535
        'totalItems': 0,
1536
        # 'first': page,
1537
        'first': {
1538
            'type': 'CollectionPage',
1539
            'partOf': request.base_url,
1540
            'items': [],
1541
        },
1542
    }
1543

1544
    # # count total if it's small, <= 1k. we should eventually precompute this
1545
    # # so that we can always return it cheaply.
1546
    # count = query.count(limit=1001)
1547
    # if count != 1001:
1548
    #     ret['totalItems'] = count
1549

1550
    return ret, {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
1✔
1551

1552

1553
# protocol in subdomain
1554
@app.get('/ap/<id>/featured')
1✔
1555
def featured(id):
1✔
1556
    """Serves a user's AP featured collection for pinned posts.
1557

1558
    https://docs.joinmastodon.org/spec/activitypub/#featured
1559

1560
    We inline the featured collection in users' actors, but Mastodon (and
1561
    Pleroma/Akkoma?) require it to be fetchable separately too. :(
1562

1563
    Also, it's critical that the collection items here are expanded objects!
1564
    Originally they were compacted string ids, but that triggered a massive flood of
1565
    requests from Pleroma and Akkoma:
1566
    https://github.com/snarfed/bridgy-fed/issues/1374#issuecomment-2891993190
1567
    """
1568
    # TODO: bring back once we figure out how to get Mastodon to support this and
1569
    # Pleroma and Akkoma not to DDoS us
1570
    # https://github.com/snarfed/bridgy-fed/issues/1374#issuecomment-2891993190
1571
    return '', 404
1✔
1572

1573
    user = _load_user(id)
1574

1575
    items = []
1576
    if user.obj and user.obj.as1:
1577
        for obj in as1.get_objects(user.obj.as1.get('featured', {}), 'items'):
1578
            if set(obj.keys()) == {'id'}:
1579
                if obj := user.load(obj['id']):
1580
                    if obj.as1:
1581
                        items.append(ActivityPub.convert(obj))
1582
            elif obj:
1583
                items.append(ActivityPub.convert(Object(our_as1=obj)))
1584

1585
    return {
1586
        '@context': as2.CONTEXT,
1587
        'type': 'OrderedCollection',
1588
        'id': request.base_url,
1589
        'totalItems': len(items),
1590
        'orderedItems': items,
1591
    }, {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
1592

1593

1594
@app.get('/.well-known/nodeinfo')
1✔
1595
@flask_util.canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
1596
@flask_util.headers(CACHE_CONTROL)
1✔
1597
def nodeinfo_jrd():
1✔
1598
    """
1599
    https://nodeinfo.diaspora.software/protocol.html
1600
    """
1601
    return {
1✔
1602
        'links': [{
1603
            'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.1',
1604
            'href': common.host_url('nodeinfo.json'),
1605
        }, {
1606
            "rel": "https://www.w3.org/ns/activitystreams#Application",
1607
            "href": instance_actor().id_as(ActivityPub),
1608
        }],
1609
    }, {
1610
        'Content-Type': 'application/jrd+json',
1611
    }
1612

1613

1614
@app.get('/nodeinfo.json')
1✔
1615
@flask_util.canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
1616
@memcache.memoize(expire=datetime.timedelta(hours=1))
1✔
1617
@flask_util.headers(CACHE_CONTROL)
1✔
1618
def nodeinfo():
1✔
1619
    """
1620
    https://nodeinfo.diaspora.software/schema.html
1621
    """
1622
    from atproto import ATProto
1✔
1623
    from nostr import Nostr
1✔
1624
    from web import Web
1✔
1625

1626
    atp = ATProto.query(ATProto.enabled_protocols != None).count()
1✔
1627
    ap = ActivityPub.query(ActivityPub.enabled_protocols != None).count()
1✔
1628
    nostr = Nostr.query(Nostr.enabled_protocols != None).count()
1✔
1629
    web = Web.query(Web.status == None).count()
1✔
1630
    total = atp + ap + nostr + web
1✔
1631

1632
    logger.info(f'Users: ap: {ap}')
1✔
1633
    logger.info(f'Users: atproto: {atp}')
1✔
1634
    logger.info(f'Users: web: {web}')
1✔
1635
    logger.info(f'Users: total: {total}')
1✔
1636

1637
    return {
1✔
1638
        'version': '2.1',
1639
        'software': {
1640
            'name': 'bridgy-fed',
1641
            'version': os.getenv('GAE_VERSION'),
1642
            'repository': 'https://github.com/snarfed/bridgy-fed',
1643
            'homepage': 'https://fed.brid.gy/',
1644
        },
1645
        'protocols': [
1646
            'activitypub',
1647
            'atprotocol',
1648
            'webmention',
1649
        ],
1650
        'services': {
1651
            'outbound': [],
1652
            'inbound': [],
1653
        },
1654
        'usage': {
1655
            'users': {
1656
                'total': total,
1657
                # 'activeMonth':
1658
                # 'activeHalfyear':
1659
            },
1660
            # these are too heavy
1661
            # 'localPosts': Object.query(Object.source_protocol.IN(('web', 'webmention')),
1662
            #                            Object.type.IN(['note', 'article']),
1663
            #                            ).count(),
1664
            # 'localComments': Object.query(Object.source_protocol.IN(('web', 'webmention')),
1665
            #                               Object.type == 'comment',
1666
            #                               ).count(),
1667
        },
1668
        'openRegistrations': True,
1669
        'metadata': {
1670
            'users': {
1671
                'activitypub': ap,
1672
                'atprotocol': atp,
1673
                'webmention': web,
1674
            },
1675
        },
1676
    }, {
1677
        # https://nodeinfo.diaspora.software/protocol.html
1678
        'Content-Type': 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"',
1679
    }
1680

1681

1682
@app.get('/api/v1/instance')
1✔
1683
@flask_util.canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
1684
@flask_util.headers(CACHE_CONTROL)
1✔
1685
def instance_info():
1✔
1686
    """
1687
    https://docs.joinmastodon.org/methods/instance/#v1
1688
    """
1689
    return {
1✔
1690
        'uri': 'fed.brid.gy',
1691
        'title': 'Bridgy Fed',
1692
        'version': os.getenv('GAE_VERSION'),
1693
        'short_description': 'Bridging the new social internet',
1694
        'description': 'Bridging the new social internet',
1695
        'email': 'feedback@brid.gy',
1696
        'thumbnail': 'https://fed.brid.gy/static/bridgy_logo_with_alpha.png',
1697
        'registrations': True,
1698
        'approval_required': False,
1699
        'invites_enabled': False,
1700
        'contact_account': {
1701
            'username': 'snarfed.org',
1702
            'acct': 'snarfed.org',
1703
            'display_name': 'Ryan',
1704
            'url': 'https://snarfed.org/',
1705
        },
1706
    }
1707

1708

1709
def as2_request_type():
1✔
1710
    """If this request has conneg (ie the ``Accept`` header) for AS2, returns its type.
1711

1712
    Specifically, returns either
1713
    ``application/ld+json; profile="https://www.w3.org/ns/activitystreams"`` or
1714
    ``application/activity+json``.
1715

1716
    If the current request's conneg isn't asking for AS2, returns None.
1717

1718
    https://www.w3.org/TR/activitypub/#retrieving-objects
1719
    https://snarfed.org/2023-03-24_49619-2
1720
    """
1721
    if accept := request.headers.get('Accept'):
1✔
1722
        try:
1✔
1723
            negotiated = common._negotiator.negotiate(accept)
1✔
1724
        except ValueError:
1✔
1725
            # work around https://github.com/CottageLabs/negotiator/issues/6
1726
            negotiated = None
1✔
1727
        if negotiated:
1✔
1728
            accept_type = str(negotiated.content_type)
1✔
1729
            if accept_type == as2.CONTENT_TYPE:
1✔
1730
                return as2.CONTENT_TYPE
1✔
1731
            elif accept_type in (as2.CONTENT_TYPE_LD, as2.CONTENT_TYPE_LD_PROFILE):
1✔
1732
                return as2.CONTENT_TYPE_LD_PROFILE
1✔
1733
            logger.debug(f'Conneg resolved {accept_type} for Accept: {accept}')
1✔
1734

1735

1736
#
1737
# OAuth
1738
#
1739
class MastodonStart(FlashErrors, oauth_dropins.mastodon.Start):
1✔
1740
  def app_name(self):
1✔
1741
      return 'Bridgy Fed'
×
1742

1743
  def app_url(self):
1✔
1744
      return 'https://fed.brid.gy/'
×
1745

1746
class MastodonCallback(FlashErrors, oauth_dropins.mastodon.Callback):
1✔
1747
    pass
1✔
1748

1749
class PixelfedStart(FlashErrors, oauth_dropins.pixelfed.Start):
1✔
1750
  def app_name(self):
1✔
1751
      return 'Bridgy Fed'
×
1752

1753
  def app_url(self):
1✔
1754
      return 'https://fed.brid.gy/'
×
1755

1756
class PixelfedCallback(FlashErrors, oauth_dropins.pixelfed.Callback):
1✔
1757
    pass
1✔
1758

1759
class ThreadsStart(FlashErrors, oauth_dropins.threads.Start):
1✔
1760
    pass
1✔
1761

1762
class ThreadsCallback(FlashErrors, oauth_dropins.threads.Callback):
1✔
1763
    pass
1✔
1764

1765

1766
app.add_url_rule('/oauth/mastodon/start', view_func=MastodonStart.as_view(
1✔
1767
                     '/oauth/mastodon/start', '/oauth/mastodon/finish'),
1768
                 methods=['POST'])
1769
app.add_url_rule('/oauth/mastodon/finish', view_func=MastodonCallback.as_view(
1✔
1770
                     '/oauth/mastodon/finish', '/settings'))
1771

1772
app.add_url_rule('/oauth/pixelfed/start', view_func=PixelfedStart.as_view(
1✔
1773
                     '/oauth/pixelfed/start', '/oauth/pixelfed/finish'),
1774
                 methods=['POST'])
1775
app.add_url_rule('/oauth/pixelfed/finish', view_func=PixelfedCallback.as_view(
1✔
1776
                     '/oauth/pixelfed/finish', '/settings'))
1777

1778
app.add_url_rule('/oauth/threads/start', view_func=ThreadsStart.as_view(
1✔
1779
                     '/oauth/threads/start', '/oauth/threads/finish'),
1780
                 methods=['POST'])
1781
app.add_url_rule('/oauth/threads/finish', view_func=ThreadsCallback.as_view(
1✔
1782
                     '/oauth/threads/finish', '/settings'))
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