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

snarfed / bridgy-fed / e088701b-a0f8-4060-a6ab-0d3ecf05b3d0

30 May 2025 08:00PM UTC coverage: 92.096% (-0.4%) from 92.475%
e088701b-a0f8-4060-a6ab-0d3ecf05b3d0

push

circleci

snarfed
start on Nostr!

for #446

74 of 103 new or added lines in 6 files covered. (71.84%)

60 existing lines in 2 files now uncovered.

5115 of 5554 relevant lines covered (92.1%)

0.92 hits per line

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

94.26
/protocol.py
1
"""Base protocol class and common code."""
2
import copy
1✔
3
from datetime import datetime, timedelta, timezone
1✔
4
import logging
1✔
5
import os
1✔
6
import re
1✔
7
from threading import Lock
1✔
8
from urllib.parse import urljoin, urlparse
1✔
9

10
from cachetools import cached, LRUCache
1✔
11
from flask import request
1✔
12
from google.cloud import ndb
1✔
13
from google.cloud.ndb import OR
1✔
14
from google.cloud.ndb.model import _entity_to_protobuf
1✔
15
from granary import as1, as2, source
1✔
16
from granary.source import html_to_text
1✔
17
from oauth_dropins.webutil.appengine_info import DEBUG
1✔
18
from oauth_dropins.webutil.flask_util import cloud_tasks_only
1✔
19
from oauth_dropins.webutil import models
1✔
20
from oauth_dropins.webutil import util
1✔
21
from oauth_dropins.webutil.util import json_dumps, json_loads
1✔
22
from requests import RequestException
1✔
23
import werkzeug.exceptions
1✔
24
from werkzeug.exceptions import BadGateway, HTTPException
1✔
25

26
import common
1✔
27
from common import (
1✔
28
    DOMAIN_BLOCKLIST,
29
    DOMAIN_RE,
30
    DOMAINS,
31
    ErrorButDoNotRetryTask,
32
    PRIMARY_DOMAIN,
33
    PROTOCOL_DOMAINS,
34
    report_error,
35
    subdomain_wrap,
36
)
37
import dms
1✔
38
import ids
1✔
39
from ids import (
1✔
40
    BOT_ACTOR_AP_IDS,
41
    normalize_user_id,
42
    translate_object_id,
43
    translate_user_id,
44
)
45
import memcache
1✔
46
from models import (
1✔
47
    DM,
48
    Follower,
49
    Object,
50
    PROTOCOLS,
51
    PROTOCOLS_BY_KIND,
52
    Target,
53
    User,
54
)
55
import notifications
1✔
56

57
OBJECT_REFRESH_AGE = timedelta(days=30)
1✔
58
DELETE_TASK_DELAY = timedelta(minutes=2)
1✔
59
CREATE_MAX_AGE = timedelta(weeks=2)
1✔
60

61
# require a follow for users on these domains before we deliver anything from
62
# them other than their profile
63
LIMITED_DOMAINS = (os.getenv('LIMITED_DOMAINS', '').split()
1✔
64
                   or util.load_file_lines('limited_domains'))
65

66
DONT_STORE_AS1_TYPES = as1.CRUD_VERBS | set((
1✔
67
    'accept',
68
    'reject',
69
    'stop-following',
70
    'undo',
71
))
72
STORE_AS1_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES | as1.VERBS_WITH_OBJECT
1✔
73
                   - DONT_STORE_AS1_TYPES)
74

75
# when we see a post from a user id that's a key in this dict, we automatically
76
# repost it as each of the accounts in the value sequence
77
PROTOCOL_BOT_ACCOUNTS = tuple(('web', domain) for domain in PROTOCOL_DOMAINS)
1✔
78
AUTO_REPOST_ACCOUNTS = {
1✔
79
    'https://blog.anew.social/.ghost/activitypub/users/index': PROTOCOL_BOT_ACCOUNTS,
80
    'https://mastodon.social/users/anewsocial': PROTOCOL_BOT_ACCOUNTS,
81
}
82

83
logger = logging.getLogger(__name__)
1✔
84

85

86
def error(*args, status=299, **kwargs):
1✔
87
    """Default HTTP status code to 299 to prevent retrying task."""
88
    return common.error(*args, status=status, **kwargs)
1✔
89

90

91
def activity_id_memcache_key(id):
1✔
92
    return memcache.key(f'receive-{id}')
1✔
93

94

95
class Protocol:
1✔
96
    """Base protocol class. Not to be instantiated; classmethods only."""
97
    ABBREV = None
1✔
98
    """str: lower case abbreviation, used in URL paths"""
1✔
99
    PHRASE = None
1✔
100
    """str: human-readable name or phrase. Used in phrases like ``Follow this person on {PHRASE}``"""
1✔
101
    OTHER_LABELS = ()
1✔
102
    """sequence of str: label aliases"""
1✔
103
    LOGO_HTML = ''
1✔
104
    """str: logo emoji or ``<img>`` tag"""
1✔
105
    CONTENT_TYPE = None
1✔
106
    """str: MIME type of this protocol's native data format, appropriate for the ``Content-Type`` HTTP header."""
1✔
107
    HAS_COPIES = False
1✔
108
    """bool: whether this protocol is push and needs us to proactively create "copy" users and objects, as opposed to pulling converted objects on demand"""
1✔
109
    REQUIRES_AVATAR = False
1✔
110
    """bool: whether accounts on this protocol are required to have a profile picture. If they don't, their ``User.status`` will be ``blocked``."""
1✔
111
    REQUIRES_NAME = False
1✔
112
    """bool: whether accounts on this protocol are required to have a profile name that's different than their handle or id. If they don't, their ``User.status`` will be ``blocked``."""
1✔
113
    REQUIRES_OLD_ACCOUNT = False
1✔
114
    """bool: whether accounts on this protocol are required to be at least :const:`common.OLD_ACCOUNT_AGE` old. If their profile includes creation date and it's not old enough, their ``User.status`` will be ``blocked``."""
1✔
115
    DEFAULT_ENABLED_PROTOCOLS = ()
1✔
116
    """sequence of str: labels of other protocols that are automatically enabled for this protocol to bridge into"""
1✔
117
    DEFAULT_SERVE_USER_PAGES = False
1✔
118
    """bool: whether to serve user pages for all of this protocol's users on the fed.brid.gy. If ``False``, user pages will only be served for users who have explictly opted in."""
1✔
119
    SUPPORTED_AS1_TYPES = ()
1✔
120
    """sequence of str: AS1 objectTypes and verbs that this protocol supports receiving and sending"""
1✔
121
    SUPPORTS_DMS = False
1✔
122
    """bool: whether this protocol can receive DMs (chat messages)"""
1✔
123

124
    def __init__(self):
1✔
125
        assert False
×
126

127
    @classmethod
1✔
128
    @property
1✔
129
    def LABEL(cls):
1✔
130
        """str: human-readable lower case name of this protocol, eg ``'activitypub``"""
131
        return cls.__name__.lower()
1✔
132

133
    @staticmethod
1✔
134
    def for_request(fed=None):
1✔
135
        """Returns the protocol for the current request.
136

137
        ...based on the request's hostname.
138

139
        Args:
140
          fed (str or protocol.Protocol): protocol to return if the current
141
            request is on ``fed.brid.gy``
142

143
        Returns:
144
          Protocol: protocol, or None if the provided domain or request hostname
145
          domain is not a subdomain of ``brid.gy`` or isn't a known protocol
146
        """
147
        return Protocol.for_bridgy_subdomain(request.host, fed=fed)
1✔
148

149
    @staticmethod
1✔
150
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
151
        """Returns the protocol for a brid.gy subdomain.
152

153
        Args:
154
          domain_or_url (str)
155
          fed (str or protocol.Protocol): protocol to return if the current
156
            request is on ``fed.brid.gy``
157

158
        Returns:
159
          class: :class:`Protocol` subclass, or None if the provided domain or request
160
          hostname domain is not a subdomain of ``brid.gy`` or isn't a known
161
          protocol
162
        """
163
        domain = (util.domain_from_link(domain_or_url, minimize=False)
1✔
164
                  if util.is_web(domain_or_url)
165
                  else domain_or_url)
166

167
        if domain == common.PRIMARY_DOMAIN or domain in common.LOCAL_DOMAINS:
1✔
168
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
1✔
169
        elif domain and domain.endswith(common.SUPERDOMAIN):
1✔
170
            label = domain.removesuffix(common.SUPERDOMAIN)
1✔
171
            return PROTOCOLS.get(label)
1✔
172

173
    @classmethod
1✔
174
    def owns_id(cls, id):
1✔
175
        """Returns whether this protocol owns the id, or None if it's unclear.
176

177
        To be implemented by subclasses.
178

179
        IDs are string identities that uniquely identify users, and are intended
180
        primarily to be machine readable and usable. Compare to handles, which
181
        are human-chosen, human-meaningful, and often but not always unique.
182

183
        Some protocols' ids are more or less deterministic based on the id
184
        format, eg AT Protocol owns ``at://`` URIs. Others, like http(s) URLs,
185
        could be owned by eg Web or ActivityPub.
186

187
        This should be a quick guess without expensive side effects, eg no
188
        external HTTP fetches to fetch the id itself or otherwise perform
189
        discovery.
190

191
        Returns False if the id's domain is in :const:`common.DOMAIN_BLOCKLIST`.
192

193
        Args:
194
          id (str)
195

196
        Returns:
197
          bool or None:
198
        """
199
        return False
1✔
200

201
    @classmethod
1✔
202
    def owns_handle(cls, handle, allow_internal=False):
1✔
203
        """Returns whether this protocol owns the handle, or None if it's unclear.
204

205
        To be implemented by subclasses.
206

207
        Handles are string identities that are human-chosen, human-meaningful,
208
        and often but not always unique. Compare to IDs, which uniquely identify
209
        users, and are intended primarily to be machine readable and usable.
210

211
        Some protocols' handles are more or less deterministic based on the id
212
        format, eg ActivityPub (technically WebFinger) handles are
213
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
214
        ActivityPub, AT Protocol, or others.
215

216
        This should be a quick guess without expensive side effects, eg no
217
        external HTTP fetches to fetch the id itself or otherwise perform
218
        discovery.
219

220
        Args:
221
          handle (str)
222
          allow_internal (bool): whether to return False for internal domains
223
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
224

225
        Returns:
226
          bool or None
227
        """
228
        return False
1✔
229

230
    @classmethod
1✔
231
    def handle_to_id(cls, handle):
1✔
232
        """Converts a handle to an id.
233

234
        To be implemented by subclasses.
235

236
        May incur network requests, eg DNS queries or HTTP requests. Avoids
237
        blocked or opted out users.
238

239
        Args:
240
          handle (str)
241

242
        Returns:
243
          str: corresponding id, or None if the handle can't be found
244
        """
245
        raise NotImplementedError()
×
246

247
    @classmethod
1✔
248
    def key_for(cls, id, allow_opt_out=False):
1✔
249
        """Returns the :class:`google.cloud.ndb.Key` for a given id's :class:`models.User`.
250

251
        To be implemented by subclasses. Canonicalizes the id if necessary.
252

253
        If called via `Protocol.key_for`, infers the appropriate protocol with
254
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
255
        as is.
256

257
        Args:
258
          id (str):
259
          allow_opt_out (bool): whether to allow users who are currently opted out
260

261
        Returns:
262
          google.cloud.ndb.Key: matching key, or None if the given id is not a
263
          valid :class:`User` id for this protocol.
264
        """
265
        if cls == Protocol:
1✔
266
            proto = Protocol.for_id(id)
1✔
267
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
268

269
        # load user so that we follow use_instead
270
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
271
        if existing:
1✔
272
            if existing.status and not allow_opt_out:
1✔
273
                return None
1✔
274
            return existing.key
1✔
275

276
        return cls(id=id).key
1✔
277

278
    @staticmethod
1✔
279
    def _for_id_memcache_key(id, remote=None):
1✔
280
        """If id is a URL, uses its domain, otherwise returns None.
281

282
        Args:
283
          id (str)
284

285
        Returns:
286
          (str domain, bool remote) or None
287
        """
288
        if remote and util.is_web(id):
1✔
289
            return util.domain_from_link(id)
1✔
290

291
    @cached(LRUCache(20000), lock=Lock())
1✔
292
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote: remote,
1✔
293
                      version=3)
294
    @staticmethod
1✔
295
    def for_id(id, remote=True):
1✔
296
        """Returns the protocol for a given id.
297

298
        Args:
299
          id (str)
300
          remote (bool): whether to perform expensive side effects like fetching
301
            the id itself over the network, or other discovery.
302

303
        Returns:
304
          Protocol subclass: matching protocol, or None if no single known
305
          protocol definitively owns this id
306
        """
307
        logger.debug(f'Determining protocol for id {id}')
1✔
308
        if not id:
1✔
309
            return None
1✔
310

311
        # remove our synthetic id fragment, if any
312
        #
313
        # will this eventually cause false positives for other services that
314
        # include our full ids inside their own ids, non-URL-encoded? guess
315
        # we'll figure that out if/when it happens.
316
        id = id.partition('#bridgy-fed-')[0]
1✔
317
        if not id:
1✔
318
            return None
1✔
319

320
        if util.is_web(id):
1✔
321
            # step 1: check for our per-protocol subdomains
322
            try:
1✔
323
                is_homepage = urlparse(id).path.strip('/') == ''
1✔
324
            except ValueError as e:
1✔
325
                logger.info(f'urlparse ValueError: {e}')
1✔
326
                return None
1✔
327

328
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
329
            if by_subdomain and not is_homepage and id not in BOT_ACTOR_AP_IDS:
1✔
330
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
331
                return by_subdomain
1✔
332

333
        # step 2: check if any Protocols say conclusively that they own it
334
        # sort to be deterministic
335
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
336
                           key=lambda p: p.LABEL)
337
        candidates = []
1✔
338
        for protocol in protocols:
1✔
339
            owns = protocol.owns_id(id)
1✔
340
            if owns:
1✔
341
                logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
342
                return protocol
1✔
343
            elif owns is not False:
1✔
344
                candidates.append(protocol)
1✔
345

346
        if len(candidates) == 1:
1✔
347
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
348
            return candidates[0]
1✔
349

350
        # step 3: look for existing Objects in the datastore
351
        #
352
        # note that we don't currently see if this is a copy id because I have FUD
353
        # over which Protocol for_id should return in that case...and also because a
354
        # protocol may already say definitively above that it owns the id, eg ATProto
355
        # with DIDs and at:// URIs.
356
        obj = Protocol.load(id, remote=False)
1✔
357
        if obj and obj.source_protocol:
1✔
358
            logger.debug(f'  {obj.key.id()} owned by source_protocol {obj.source_protocol}')
1✔
359
            return PROTOCOLS[obj.source_protocol]
1✔
360

361
        # step 4: fetch over the network, if necessary
362
        if not remote:
1✔
363
            return None
1✔
364

365
        for protocol in candidates:
1✔
366
            logger.debug(f'Trying {protocol.LABEL}')
1✔
367
            try:
1✔
368
                obj = protocol.load(id, local=False, remote=True)
1✔
369

370
                if protocol.ABBREV == 'web':
1✔
371
                    # for web, if we fetch and get HTML without microformats,
372
                    # load returns False but the object will be stored in the
373
                    # datastore with source_protocol web, and in cache. load it
374
                    # again manually to check for that.
375
                    obj = Object.get_by_id(id)
1✔
376
                    if obj and obj.source_protocol != 'web':
1✔
377
                        obj = None
×
378

379
                if obj:
1✔
380
                    logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
381
                    return protocol
1✔
382
            except BadGateway:
1✔
383
                # we tried and failed fetching the id over the network.
384
                # this depends on ActivityPub.fetch raising this!
385
                return None
1✔
386
            except HTTPException as e:
×
387
                # internal error we generated ourselves; try next protocol
388
                pass
×
389
            except Exception as e:
×
390
                code, _ = util.interpret_http_exception(e)
×
391
                if code:
×
392
                    # we tried and failed fetching the id over the network
393
                    return None
×
394
                raise
×
395

396
        logger.info(f'No matching protocol found for {id} !')
1✔
397
        return None
1✔
398

399
    @cached(LRUCache(20000), lock=Lock())
1✔
400
    @staticmethod
1✔
401
    def for_handle(handle):
1✔
402
        """Returns the protocol for a given handle.
403

404
        May incur expensive side effects like resolving the handle itself over
405
        the network or other discovery.
406

407
        Args:
408
          handle (str)
409

410
        Returns:
411
          (Protocol subclass, str) tuple: matching protocol and optional id (if
412
          resolved), or ``(None, None)`` if no known protocol owns this handle
413
        """
414
        # TODO: normalize, eg convert domains to lower case
415
        logger.debug(f'Determining protocol for handle {handle}')
1✔
416
        if not handle:
1✔
417
            return (None, None)
1✔
418

419
        # step 1: check if any Protocols say conclusively that they own it.
420
        # sort to be deterministic.
421
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
422
                           key=lambda p: p.LABEL)
423
        candidates = []
1✔
424
        for proto in protocols:
1✔
425
            owns = proto.owns_handle(handle)
1✔
426
            if owns:
1✔
427
                logger.debug(f'  {proto.LABEL} owns handle {handle}')
1✔
428
                return (proto, None)
1✔
429
            elif owns is not False:
1✔
430
                candidates.append(proto)
1✔
431

432
        if len(candidates) == 1:
1✔
433
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
434
            return (candidates[0], None)
×
435

436
        # step 2: look for matching User in the datastore
437
        for proto in candidates:
1✔
438
            user = proto.query(proto.handle == handle).get()
1✔
439
            if user:
1✔
440
                if user.status:
1✔
441
                    return (None, None)
1✔
442
                logger.debug(f'  user {user.key} handle {handle}')
1✔
443
                return (proto, user.key.id())
1✔
444

445
        # step 3: resolve handle to id
446
        for proto in candidates:
1✔
447
            id = proto.handle_to_id(handle)
1✔
448
            if id:
1✔
449
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
450
                return (proto, id)
1✔
451

452
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
453
        return (None, None)
1✔
454

455
    @classmethod
1✔
456
    def is_user_at_domain(cls, handle, allow_internal=False):
1✔
457
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
458

459
        Example: ``@user@instance.com``
460

461
        Args:
462
          handle (str)
463
          allow_internal (bool): whether the domain can be a Bridgy Fed domain
464
        """
465
        parts = handle.split('@')
1✔
466
        if len(parts) != 2:
1✔
467
            return False
1✔
468

469
        user, domain = parts
1✔
470
        return bool(user and domain
1✔
471
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
472

473
    @classmethod
1✔
474
    def bridged_web_url_for(cls, user, fallback=False):
1✔
475
        """Returns the web URL for a user's bridged profile in this protocol.
476

477
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
478
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
479

480
        Args:
481
          user (models.User)
482
          fallback (bool): if True, and bridged users have no canonical user
483
            profile URL in this protocol, return the native protocol's profile URL
484

485
        Returns:
486
          str, or None if there isn't a canonical URL
487
        """
488
        if fallback:
1✔
489
            return user.web_url()
1✔
490

491
    @classmethod
1✔
492
    def actor_key(cls, obj, allow_opt_out=False):
1✔
493
        """Returns the :class:`User`: key for a given object's author or actor.
494

495
        Args:
496
          obj (models.Object)
497
          allow_opt_out (bool): whether to return a user key if they're opted out
498

499
        Returns:
500
          google.cloud.ndb.key.Key or None:
501
        """
502
        owner = as1.get_owner(obj.as1)
1✔
503
        if owner:
1✔
504
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
505

506
    @classmethod
1✔
507
    def bot_user_id(cls):
1✔
508
        """Returns the Web user id for the bot user for this protocol.
509

510
        For example, ``'bsky.brid.gy'`` for ATProto.
511

512
        Returns:
513
          str:
514
        """
515
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
516

517
    @classmethod
1✔
518
    def create_for(cls, user):
1✔
519
        """Creates or re-activate a copy user in this protocol.
520

521
        Should add the copy user to :attr:`copies`.
522

523
        If the copy user already exists and active, should do nothing.
524

525
        Args:
526
          user (models.User): original source user. Shouldn't already have a
527
            copy user for this protocol in :attr:`copies`.
528

529
        Raises:
530
          ValueError: if we can't create a copy of the given user in this protocol
531
        """
UNCOV
532
        raise NotImplementedError()
×
533

534
    @classmethod
1✔
535
    def send(to_cls, obj, url, from_user=None, orig_obj_id=None):
1✔
536
        """Sends an outgoing activity.
537

538
        To be implemented by subclasses.
539

540
        NOTE: if this protocol's ``HAS_COPIES`` is True, and this method creates
541
        a copy and sends it, it *must* add that copy to the *object*'s (not
542
        activity's) :attr:`copies`!
543

544
        Args:
545
          obj (models.Object): with activity to send
546
          url (str): destination URL to send to
547
          from_user (models.User): user (actor) this activity is from
548
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
549
            that this object refers to, eg replies to or reposts or likes
550

551
        Returns:
552
          bool: True if the activity is sent successfully, False if it is
553
          ignored or otherwise unsent due to protocol logic, eg no webmention
554
          endpoint, protocol doesn't support the activity type. (Failures are
555
          raised as exceptions.)
556

557
        Raises:
558
          werkzeug.HTTPException if the request fails
559
        """
UNCOV
560
        raise NotImplementedError()
×
561

562
    @classmethod
1✔
563
    def fetch(cls, obj, **kwargs):
1✔
564
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
565

566
        Errors are raised as exceptions. If this method returns False, the fetch
567
        didn't fail but didn't succeed either, eg the id isn't valid for this
568
        protocol, or the fetch didn't return valid data for this protocol.
569

570
        To be implemented by subclasses.
571

572
        Args:
573
          obj (models.Object): with the id to fetch. Data is filled into one of
574
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
575
          kwargs: subclass-specific
576

577
        Returns:
578
          bool: True if the object was fetched and populated successfully,
579
          False otherwise
580

581
        Raises:
582
          requests.RequestException or werkzeug.HTTPException: if the fetch fails
583
        """
UNCOV
584
        raise NotImplementedError()
×
585

586
    @classmethod
1✔
587
    def convert(cls, obj, from_user=None, **kwargs):
1✔
588
        """Converts an :class:`Object` to this protocol's data format.
589

590
        For example, an HTML string for :class:`Web`, or a dict with AS2 JSON
591
        and ``application/activity+json`` for :class:`ActivityPub`.
592

593
        Just passes through to :meth:`_convert`, then does minor
594
        protocol-independent postprocessing.
595

596
        Args:
597
          obj (models.Object):
598
          from_user (models.User): user (actor) this activity/object is from
599
          kwargs: protocol-specific, passed through to :meth:`_convert`
600

601
        Returns:
602
          converted object in the protocol's native format, often a dict
603
        """
604
        if not obj or not obj.as1:
1✔
605
            return {}
1✔
606

607
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
608
        is_activity = obj.as1.get('verb') in ('post', 'update')
1✔
609
        base_obj = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
610
        orig_our_as1 = obj.our_as1
1✔
611

612
        # mark bridged actors as bots and add "bridged by Bridgy Fed" to their bios
613
        if (from_user and base_obj
1✔
614
            and base_obj.get('objectType') in as1.ACTOR_TYPES
615
            and PROTOCOLS.get(obj.source_protocol) != cls
616
            and Protocol.for_bridgy_subdomain(id) not in DOMAINS
617
            # Web users are special cased, they don't get the label if they've
618
            # explicitly enabled Bridgy Fed with redirects or webmentions
619
            and not (from_user.LABEL == 'web'
620
                     and (from_user.last_webmention_in or from_user.has_redirects))):
621

622
            obj.our_as1 = copy.deepcopy(obj.as1)
1✔
623
            actor = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
624
            actor['objectType'] = 'person'
1✔
625
            cls.add_source_links(actor=actor, obj=obj, from_user=from_user)
1✔
626

627
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
628
        obj.our_as1 = orig_our_as1
1✔
629
        return converted
1✔
630

631
    @classmethod
1✔
632
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
633
        """Converts an :class:`Object` to this protocol's data format.
634

635
        To be implemented by subclasses. Implementations should generally call
636
        :meth:`Protocol.translate_ids` (as their own class) before converting to
637
        their format.
638

639
        Args:
640
          obj (models.Object):
641
          from_user (models.User): user (actor) this activity/object is from
642
          kwargs: protocol-specific
643

644
        Returns:
645
          converted object in the protocol's native format, often a dict. May
646
            return the ``{}`` empty dict if the object can't be converted.
647
        """
UNCOV
648
        raise NotImplementedError()
×
649

650
    @classmethod
1✔
651
    def add_source_links(cls, actor, obj, from_user):
1✔
652
        """Adds "bridged from ... by Bridgy Fed" HTML to ``actor['summary']``.
653

654
        Default implementation; subclasses may override.
655

656
        Args:
657
          actor (dict): AS1 actor
658
          obj (models.Object):
659
          from_user (models.User): user (actor) this activity/object is from
660
        """
661
        assert from_user
1✔
662
        summary = actor.setdefault('summary', '')
1✔
663
        if 'Bridgy Fed]' in html_to_text(summary, ignore_links=True):
1✔
664
            return
1✔
665

666
        id = actor.get('id')
1✔
667
        proto_phrase = (PROTOCOLS[obj.source_protocol].PHRASE
1✔
668
                        if obj.source_protocol else '')
669
        if proto_phrase:
1✔
670
            proto_phrase = f' on {proto_phrase}'
1✔
671

672
        if from_user.key and id in (from_user.key.id(), from_user.profile_id()):
1✔
673
            source_links = f'[<a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a> from <a href="{from_user.web_url()}">{from_user.handle}</a>{proto_phrase} by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>]'
1✔
674

675
        else:
676
            url = as1.get_url(actor) or id
1✔
677
            source = util.pretty_link(url) if url else '?'
1✔
678
            source_links = f'[bridged from {source}{proto_phrase} by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>]'
1✔
679

680
        if summary:
1✔
681
            summary += '<br><br>'
1✔
682
        actor['summary'] = summary + source_links
1✔
683

684
    @classmethod
1✔
685
    def set_username(to_cls, user, username):
1✔
686
        """Sets a custom username for a user's bridged account in this protocol.
687

688
        Args:
689
          user (models.User)
690
          username (str)
691

692
        Raises:
693
          ValueError: if the username is invalid
694
          RuntimeError: if the username could not be set
695
        """
696
        raise NotImplementedError()
1✔
697

698
    @classmethod
1✔
699
    def migrate_out(cls, user, to_user_id):
1✔
700
        """Migrates a bridged account out to be a native account.
701

702
        Args:
703
          user (models.User)
704
          to_user_id (str)
705

706
        Raises:
707
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
708
            ``user`` is on this protocol or not bridged to this protocol
709
        """
UNCOV
710
        raise NotImplementedError()
×
711

712
    @classmethod
1✔
713
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
714
        """Migrates a native account in to be a bridged account.
715

716
        Args:
717
          user (models.User): native user on another protocol to attach the
718
            newly imported bridged account to
719
          from_user_id (str)
720
          kwargs: additional protocol-specific parameters
721

722
        Raises:
723
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
724
            ``user`` is on this protocol or already bridged to this protocol
725
        """
UNCOV
726
        raise NotImplementedError()
×
727

728
    @classmethod
1✔
729
    def target_for(cls, obj, shared=False):
1✔
730
        """Returns an :class:`Object`'s delivery target (endpoint).
731

732
        To be implemented by subclasses.
733

734
        Examples:
735

736
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
737
          webmention target.
738
        * If obj is an ``activitypub`` actor, returns its inbox.
739
        * If obj is an ``activitypub`` object, returns it's author's or actor's
740
          inbox.
741

742
        Args:
743
          obj (models.Object):
744
          shared (bool): optional. If True, returns a common/shared
745
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
746
            multiple recipients for efficiency
747

748
        Returns:
749
          str: target endpoint, or None if not available.
750
        """
UNCOV
751
        raise NotImplementedError()
×
752

753
    @classmethod
1✔
754
    def is_blocklisted(cls, url, allow_internal=False):
1✔
755
        """Returns True if we block the given URL and shouldn't deliver to it.
756

757
        Default implementation here, subclasses may override.
758

759
        Args:
760
          url (str):
761
          allow_internal (bool): whether to return False for internal domains
762
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
763
        """
764
        blocklist = DOMAIN_BLOCKLIST
1✔
765
        if not allow_internal:
1✔
766
            blocklist += DOMAINS
1✔
767
        return util.domain_or_parent_in(util.domain_from_link(url), blocklist)
1✔
768

769
    @classmethod
1✔
770
    def translate_ids(to_cls, obj):
1✔
771
        """Translates all ids in an AS1 object to a specific protocol.
772

773
        Infers source protocol for each id value separately.
774

775
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
776
        ``at://did:plc:abc/coll/123`` will be converted to
777
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
778

779
        Wraps these AS1 fields:
780

781
        * ``id``
782
        * ``actor``
783
        * ``author``
784
        * ``bcc``
785
        * ``bto``
786
        * ``cc``
787
        * ``featured[].items``, ``featured[].orderedItems``
788
        * ``object``
789
        * ``object.actor``
790
        * ``object.author``
791
        * ``object.id``
792
        * ``object.inReplyTo``
793
        * ``object.object``
794
        * ``attachments[].id``
795
        * ``tags[objectType=mention].url``
796
        * ``to``
797

798
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
799
        same logic is duplicated there!
800

801
        TODO: unify with :meth:`Object.resolve_ids`,
802
        :meth:`models.Object.normalize_ids`.
803

804
        Args:
805
          to_proto (Protocol subclass)
806
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
807

808
        Returns:
809
          dict: wrapped AS1 version of ``obj``
810
        """
811
        assert to_cls != Protocol
1✔
812
        if not obj:
1✔
813
            return obj
1✔
814

815
        outer_obj = copy.deepcopy(obj)
1✔
816
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
817

818
        def translate(elem, field, fn, uri=False):
1✔
819
            elem[field] = as1.get_objects(elem, field)
1✔
820
            for obj in elem[field]:
1✔
821
                if id := obj.get('id'):
1✔
822
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
823
                        continue
1✔
824
                    from_cls = Protocol.for_id(id)
1✔
825
                    # TODO: what if from_cls is None? relax translate_object_id,
826
                    # make it a noop if we don't know enough about from/to?
827
                    if from_cls and from_cls != to_cls:
1✔
828
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
829
                    if obj['id'] and uri:
1✔
830
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
831

832
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
833
                           for o in elem[field]]
834

835
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
836
                elem[field] = elem[field][0]
1✔
837

838
        type = as1.object_type(outer_obj)
1✔
839
        translate(outer_obj, 'id',
1✔
840
                  translate_user_id if type in as1.ACTOR_TYPES
841
                  else translate_object_id)
842

843
        for o in inner_objs:
1✔
844
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
845
                        or as1.get_owner(outer_obj) == o.get('id')
846
                        or type in ('follow', 'stop-following'))
847
            translate(o, 'id', translate_user_id if is_actor else translate_object_id)
1✔
848
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
849
            translate(o, 'object', translate_user_id if obj_is_actor
1✔
850
                      else translate_object_id)
851

852
        for o in [outer_obj] + inner_objs:
1✔
853
            translate(o, 'inReplyTo', translate_object_id)
1✔
854
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
855
                translate(o, field, translate_user_id)
1✔
856
            for tag in as1.get_objects(o, 'tags'):
1✔
857
                if tag.get('objectType') == 'mention':
1✔
858
                    translate(tag, 'url', translate_user_id, uri=True)
1✔
859
            for att in as1.get_objects(o, 'attachments'):
1✔
860
                translate(att, 'id', translate_object_id)
1✔
861
                url = att.get('url')
1✔
862
                if url and not att.get('id'):
1✔
863
                    if from_cls := Protocol.for_id(url):
1✔
864
                        att['id'] = translate_object_id(from_=from_cls, to=to_cls,
1✔
865
                                                        id=url)
866
            if feat := as1.get_object(o, 'featured'):
1✔
867
                translate(feat, 'orderedItems', translate_object_id)
1✔
868
                translate(feat, 'items', translate_object_id)
1✔
869

870
        outer_obj = util.trim_nulls(outer_obj)
1✔
871

872
        if objs := util.get_list(outer_obj ,'object'):
1✔
873
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
874
            if len(outer_obj['object']) == 1:
1✔
875
                outer_obj['object'] = outer_obj['object'][0]
1✔
876

877
        return outer_obj
1✔
878

879
    @classmethod
1✔
880
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
881
        """Handles an incoming activity.
882

883
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
884
        unset, returns HTTP 299.
885

886
        Args:
887
          obj (models.Object)
888
          authed_as (str): authenticated actor id who sent this activity
889
          internal (bool): whether to allow activity ids on internal domains,
890
            from opted out/blocked users, etc.
891
          received_at (datetime): when we first saw (received) this activity.
892
            Right now only used for monitoring.
893

894
        Returns:
895
          (str, int) tuple: (response body, HTTP status code) Flask response
896

897
        Raises:
898
          werkzeug.HTTPException: if the request is invalid
899
        """
900
        # check some invariants
901
        assert from_cls != Protocol
1✔
902
        assert isinstance(obj, Object), obj
1✔
903

904
        if not obj.as1:
1✔
UNCOV
905
            error('No object data provided')
×
906

907
        id = None
1✔
908
        if obj.key and obj.key.id():
1✔
909
            id = obj.key.id()
1✔
910

911
        if not id:
1✔
912
            id = obj.as1.get('id')
1✔
913
            obj.key = ndb.Key(Object, id)
1✔
914

915
        if not id:
1✔
UNCOV
916
            error('No id provided')
×
917
        elif from_cls.owns_id(id) is False:
1✔
918
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
919
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
920
            error(f'Activity {id} is blocklisted')
1✔
921
        # check that this activity is public. only do this for some activities,
922
        # not eg likes or follows, since Mastodon doesn't currently mark those
923
        # as explicitly public.
924
        elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
925
                  and not as1.is_public(obj.as1, unlisted=False)
926
                  and not as1.is_dm(obj.as1)):
927
              logger.info('Dropping non-public activity')
1✔
928
              return ('OK', 200)
1✔
929

930
        # lease this object, atomically
931
        memcache_key = activity_id_memcache_key(id)
1✔
932
        leased = memcache.memcache.add(memcache_key, 'leased', noreply=False,
1✔
933
                                       expire=5 * 60)  # 5 min
934
        # short circuit if we've already seen this activity id.
935
        # (don't do this for bare objects since we need to check further down
936
        # whether they've been updated since we saw them last.)
937
        if (obj.as1.get('objectType') == 'activity'
1✔
938
            and 'force' not in request.values
939
            and (not leased
940
                 or (obj.new is False and obj.changed is False))):
941
            error(f'Already seen this activity {id}', status=204)
1✔
942

943
        pruned = {k: v for k, v in obj.as1.items()
1✔
944
                  if k not in ('contentMap', 'replies', 'signature')}
945
        delay = ''
1✔
946
        if (received_at and request.headers.get('X-AppEngine-TaskRetryCount') == '0'
1✔
947
                and obj.type != 'delete'):  # we delay deletes for 2m
UNCOV
948
            delay_s = int((util.now().replace(tzinfo=None)
×
949
                           - received_at.replace(tzinfo=None)
950
                           ).total_seconds())
UNCOV
951
            delay = f'({delay_s} s behind)'
×
952
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
953

954
        # does this protocol support this activity/object type?
955
        from_cls.check_supported(obj)
1✔
956

957
        # check authorization
958
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
959
        actor = as1.get_owner(obj.as1)
1✔
960
        if not actor:
1✔
961
            error('Activity missing actor or author')
1✔
962
        elif from_cls.owns_id(actor) is False:
1✔
963
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
964

965
        assert authed_as
1✔
966
        assert isinstance(authed_as, str)
1✔
967
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
968
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
969
        if actor != authed_as:
1✔
970
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
971
                         user=f'{id} authed_as {authed_as} owner {actor}')
972
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
973

974
        # update copy ids to originals
975
        obj.normalize_ids()
1✔
976
        obj.resolve_ids()
1✔
977

978
        if (obj.type == 'follow'
1✔
979
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
980
            # follows of bot user; refresh user profile first
981
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
982
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
983
            from_user.reload_profile()
1✔
984
        else:
985
            # load actor user
986
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
987

988
        if not internal and (not from_user or from_user.manual_opt_out):
1✔
989
            error(f'Actor {actor} is manually opted out', status=204)
1✔
990

991
        # if this is an object, ie not an activity, wrap it in a create or update
992
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as)
1✔
993
        obj.add('users', from_user.key)
1✔
994

995
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
996
        inner_obj_id = inner_obj_as1.get('id')
1✔
997
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
998
            if not inner_obj_id:
1✔
999
                error(f'{obj.type} object has no id!')
1✔
1000

1001
        # check age. we support backdated posts, but if they're over 2w old, we
1002
        # don't deliver them
1003
        if obj.type == 'post':
1✔
1004
            if published := inner_obj_as1.get('published'):
1✔
UNCOV
1005
                try:
×
UNCOV
1006
                    published_dt = util.parse_iso8601(published)
×
UNCOV
1007
                    if not published_dt.tzinfo:
×
UNCOV
1008
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
UNCOV
1009
                    age = util.now() - published_dt
×
UNCOV
1010
                    if age > CREATE_MAX_AGE:
×
UNCOV
1011
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
1012
                              status=204)
UNCOV
1013
                except ValueError:  # from parse_iso8601
×
UNCOV
1014
                    logger.debug(f"Couldn't parse published {published}")
×
1015

1016
        # write Object to datastore
1017
        obj.source_protocol = from_cls.LABEL
1✔
1018
        if obj.type in STORE_AS1_TYPES:
1✔
1019
            obj.put()
1✔
1020

1021
        # store inner object
1022
        # TODO: unify with big obj.type conditional below. would have to merge
1023
        # this with the DM handling block lower down.
1024
        crud_obj = None
1✔
1025
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1026
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
1027
                                            source_protocol=from_cls.LABEL,
1028
                                            authed_as=actor, users=[from_user.key])
1029

1030
        actor = as1.get_object(obj.as1, 'actor')
1✔
1031
        actor_id = actor.get('id')
1✔
1032

1033
        # handle activity!
1034
        if obj.type == 'stop-following':
1✔
1035
            # TODO: unify with handle_follow?
1036
            # TODO: handle multiple followees
1037
            if not actor_id or not inner_obj_id:
1✔
UNCOV
1038
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1039

1040
            # deactivate Follower
1041
            from_ = from_cls.key_for(actor_id)
1✔
1042
            to_cls = Protocol.for_id(inner_obj_id)
1✔
1043
            to = to_cls.key_for(inner_obj_id)
1✔
1044
            follower = Follower.query(Follower.to == to,
1✔
1045
                                      Follower.from_ == from_,
1046
                                      Follower.status == 'active').get()
1047
            if follower:
1✔
1048
                logger.info(f'Marking {follower} inactive')
1✔
1049
                follower.status = 'inactive'
1✔
1050
                follower.put()
1✔
1051
            else:
1052
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1053

1054
            # fall through to deliver to followee
1055
            # TODO: do we convert stop-following to webmention 410 of original
1056
            # follow?
1057

1058
            # fall through to deliver to followers
1059

1060
        elif obj.type in ('delete', 'undo'):
1✔
1061
            delete_obj_id = (from_user.profile_id()
1✔
1062
                            if inner_obj_id == from_user.key.id()
1063
                            else inner_obj_id)
1064

1065
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1066
            if not delete_obj:
1✔
1067
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1068
                return 'OK', 204
1✔
1069

1070
            # TODO: just delete altogether!
1071
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1072
            delete_obj.deleted = True
1✔
1073
            delete_obj.put()
1✔
1074

1075
            # if this is an actor, handle deleting it later so that
1076
            # in case it's from_user, user.enabled_protocols is still populated
1077
            #
1078
            # fall through to deliver to followers and delete copy if necessary.
1079
            # should happen via protocol-specific copy target and send of
1080
            # delete activity.
1081
            # https://github.com/snarfed/bridgy-fed/issues/63
1082

1083
        elif obj.type == 'block':
1✔
1084
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1085
                # blocking protocol bot user disables that protocol
1086
                from_user.delete(proto)
1✔
1087
                from_user.disable_protocol(proto)
1✔
1088
                return 'OK', 200
1✔
1089

1090
        elif obj.type == 'post':
1✔
1091
            # handle DMs to bot users
1092
            if as1.is_dm(obj.as1):
1✔
1093
                return dms.receive(from_user=from_user, obj=obj)
1✔
1094

1095
            # auto-repost from configured accounts
1096
            if reposters := AUTO_REPOST_ACCOUNTS.get(actor_id):
1✔
1097
                logger.info(f'auto reposting {inner_obj_id} from {reposters}')
1✔
1098
                for proto, id in reposters:
1✔
1099
                    profile_id = ids.profile_id(id=id, proto=PROTOCOLS[proto])
1✔
1100
                    repost_as1 = {
1✔
1101
                        # double profile id because we subdomain-unwrap it for
1102
                        # protocol bot accounts
1103
                        'id': f'{profile_id}{profile_id}#auto-repost-{inner_obj_id}',
1104
                        'objectType': 'activity',
1105
                        'verb': 'share',
1106
                        'actor': id,
1107
                        'object': inner_obj_id,
1108
                    }
1109
                    # enqueue receive task
1110
                    common.create_task(queue='receive', authed_as=id,
1✔
1111
                                       our_as1=repost_as1, source_protocol=proto)
1112

1113
        # fetch actor if necessary
1114
        if (actor and actor.keys() == set(['id'])
1✔
1115
                and obj.type not in ('delete', 'undo')):
1116
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1117
            actor_obj = from_cls.load(ids.profile_id(id=actor['id'], proto=from_cls),
1✔
1118
                                      raise_=False)
1119
            if actor_obj and actor_obj.as1:
1✔
1120
                obj.our_as1 = {
1✔
1121
                    **obj.as1, 'actor': {
1122
                        **actor_obj.as1,
1123
                        # override profile id with actor id
1124
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1125
                        'id': actor['id'],
1126
                    }
1127
                }
1128

1129
        # fetch object if necessary
1130
        if (obj.type in ('post', 'update', 'share')
1✔
1131
                and inner_obj_as1.keys() == set(['id'])
1132
                and from_cls.owns_id(inner_obj_id)):
1133
            logger.debug('Fetching inner object')
1✔
1134
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1135
                                      remote=(obj.type in ('post', 'update')))
1136
            if obj.type in ('post', 'update'):
1✔
1137
                crud_obj = inner_obj
1✔
1138
            if inner_obj and inner_obj.as1:
1✔
1139
                obj.our_as1 = {
1✔
1140
                    **obj.as1,
1141
                    'object': {
1142
                        **inner_obj_as1,
1143
                        **inner_obj.as1,
1144
                    }
1145
                }
1146
            elif obj.type in ('post', 'update'):
1✔
1147
                error("Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1148

1149
        if obj.type == 'follow':
1✔
1150
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1151
                # follow of one of our protocol bot users; enable that protocol.
1152
                # fall through so that we send an accept.
1153
                try:
1✔
1154
                    from_user.enable_protocol(proto)
1✔
1155
                except ErrorButDoNotRetryTask:
1✔
1156
                    from web import Web
1✔
1157
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1158
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1159
                                               followee=bot, follow=obj)
1160
                    raise
1✔
1161
                proto.bot_follow(from_user)
1✔
1162

1163
            from_cls.handle_follow(obj)
1✔
1164

1165
        # deliver to targets
1166
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1167

1168
        # if this is a user, deactivate its followers/followings
1169
        # https://github.com/snarfed/bridgy-fed/issues/1304
1170
        if obj.type == 'delete':
1✔
1171
            if user_key := from_cls.key_for(id=inner_obj_id):
1✔
1172
                if user := user_key.get():
1✔
1173
                    for proto in user.enabled_protocols:
1✔
1174
                        user.disable_protocol(PROTOCOLS[proto])
1✔
1175

1176
                    logger.info(f'Deactivating Followers from or to {user_key.id()}')
1✔
1177
                    followers = Follower.query(
1✔
1178
                        OR(Follower.to == user_key, Follower.from_ == user_key)
1179
                        ).fetch()
1180
                    for f in followers:
1✔
1181
                        f.status = 'inactive'
1✔
1182
                    ndb.put_multi(followers)
1✔
1183

1184
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1185
        return resp
1✔
1186

1187
    @classmethod
1✔
1188
    def handle_follow(from_cls, obj):
1✔
1189
        """Handles an incoming follow activity.
1190

1191
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1192
        happens in :meth:`deliver`.
1193

1194
        Args:
1195
          obj (models.Object): follow activity
1196
        """
1197
        logger.debug('Got follow. Loading users, storing Follow(s), sending accept(s)')
1✔
1198

1199
        # Prepare follower (from) users' data
1200
        # TODO: remove all of this and just use from_user
1201
        from_as1 = as1.get_object(obj.as1, 'actor')
1✔
1202
        from_id = from_as1.get('id')
1✔
1203
        if not from_id:
1✔
UNCOV
1204
            error(f'Follow activity requires actor. Got: {obj.as1}')
×
1205

1206
        from_obj = from_cls.load(from_id, raise_=False)
1✔
1207
        if not from_obj:
1✔
UNCOV
1208
            error(f"Couldn't load {from_id}", status=502)
×
1209

1210
        if not from_obj.as1:
1✔
1211
            from_obj.our_as1 = from_as1
1✔
1212
            from_obj.put()
1✔
1213

1214
        from_key = from_cls.key_for(from_id)
1✔
1215
        if not from_key:
1✔
UNCOV
1216
            error(f'Invalid {from_cls.LABEL} user key: {from_id}')
×
1217
        obj.users = [from_key]
1✔
1218
        from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
1✔
1219

1220
        # Prepare followee (to) users' data
1221
        to_as1s = as1.get_objects(obj.as1)
1✔
1222
        if not to_as1s:
1✔
UNCOV
1223
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1224

1225
        # Store Followers
1226
        for to_as1 in to_as1s:
1✔
1227
            to_id = to_as1.get('id')
1✔
1228
            if not to_id:
1✔
1229
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1230

1231
            logger.info(f'Follow {from_id} => {to_id}')
1✔
1232

1233
            to_cls = Protocol.for_id(to_id)
1✔
1234
            if not to_cls:
1✔
UNCOV
1235
                error(f"Couldn't determine protocol for {to_id}")
×
1236
            elif from_cls == to_cls:
1✔
1237
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1238
                continue
1✔
1239

1240
            to_obj = to_cls.load(to_id)
1✔
1241
            if to_obj and not to_obj.as1:
1✔
1242
                to_obj.our_as1 = to_as1
1✔
1243
                to_obj.put()
1✔
1244

1245
            to_key = to_cls.key_for(to_id)
1✔
1246
            if not to_key:
1✔
UNCOV
1247
                logger.info(f'Skipping invalid {from_cls.LABEL} user key: {from_id}')
×
UNCOV
1248
                continue
×
1249

1250
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj,
1✔
1251
                                           allow_opt_out=True)
1252
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1253
                                                  follow=obj.key, status='active')
1254
            obj.add('notify', to_key)
1✔
1255
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1256
                                       followee=to_user, follow=obj)
1257

1258
    @classmethod
1✔
1259
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1260
        """Sends an accept or reject activity for a follow.
1261

1262
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1263
        nothing.
1264

1265
        Args:
1266
          verb (str): ``accept`` or  ``reject``
1267
          follower (models.User)
1268
          followee (models.User)
1269
          follow (models.Object)
1270
        """
1271
        assert verb in ('accept', 'reject')
1✔
1272
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1273
            return
1✔
1274

1275
        target = follower.target_for(follower.obj)
1✔
1276
        if not target:
1✔
UNCOV
1277
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1278

1279
        # send. note that this is one response for the whole follow, even if it
1280
        # has multiple followees!
1281
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1282
        accept = {
1✔
1283
            'id': id,
1284
            'objectType': 'activity',
1285
            'verb': verb,
1286
            'actor': followee.key.id(),
1287
            'object': follow.as1,
1288
        }
1289
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1290
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1291

1292
    @classmethod
1✔
1293
    def bot_follow(bot_cls, user):
1✔
1294
        """Follow a user from a protocol bot user.
1295

1296
        ...so that the protocol starts sending us their activities, if it needs
1297
        a follow for that (eg ActivityPub).
1298

1299
        Args:
1300
          user (User)
1301
        """
1302
        from web import Web
1✔
1303
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1304
        now = util.now().isoformat()
1✔
1305
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1306

1307
        if not user.obj:
1✔
1308
            logger.info("  can't follow, user has no profile obj")
1✔
1309
            return
1✔
1310

1311
        target = user.target_for(user.obj)
1✔
1312
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1313
        follow_back_as1 = {
1✔
1314
            'objectType': 'activity',
1315
            'verb': 'follow',
1316
            'id': follow_back_id,
1317
            'actor': bot.key.id(),
1318
            'object': user.key.id(),
1319
        }
1320
        common.create_task(queue='send', id=follow_back_id,
1✔
1321
                           our_as1=follow_back_as1, url=target,
1322
                           source_protocol='web', protocol=user.LABEL,
1323
                           user=bot.key.urlsafe())
1324

1325
    @classmethod
1✔
1326
    def handle_bare_object(cls, obj, authed_as=None):
1✔
1327
        """If obj is a bare object, wraps it in a create or update activity.
1328

1329
        Checks if we've seen it before.
1330

1331
        Args:
1332
          obj (models.Object)
1333
          authed_as (str): authenticated actor id who sent this activity
1334

1335
        Returns:
1336
          models.Object: ``obj`` if it's an activity, otherwise a new object
1337
        """
1338
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1339
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1340
            return obj
1✔
1341

1342
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1343
        now = util.now().isoformat()
1✔
1344

1345
        # occasionally we override the object, eg if this is a profile object
1346
        # coming in via a user with use_instead set
1347
        obj_as1 = obj.as1
1✔
1348
        if obj_id := obj.key.id():
1✔
1349
            if obj_as1_id := obj_as1.get('id'):
1✔
1350
                if obj_id != obj_as1_id:
1✔
1351
                    logger.info(f'Overriding AS1 object id {obj_as1_id} with Object id {obj_id}')
1✔
1352
                    obj_as1['id'] = obj_id
1✔
1353

1354
        # this is a raw post; wrap it in a create or update activity
1355
        if obj.changed or is_actor:
1✔
1356
            if obj.changed:
1✔
1357
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1358
            else:
1359
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1360
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1361
            update_as1 = {
1✔
1362
                'objectType': 'activity',
1363
                'verb': 'update',
1364
                'id': id,
1365
                'actor': obj_actor,
1366
                'object': {
1367
                    # Mastodon requires the updated field for Updates, so
1368
                    # add a default value.
1369
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1370
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1371
                    # https://github.com/mastodon/documentation/pull/1150
1372
                    'updated': now,
1373
                    **obj_as1,
1374
                },
1375
            }
1376
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1377
            return Object(id=id, our_as1=update_as1,
1✔
1378
                          source_protocol=obj.source_protocol)
1379

1380
        if (obj.new
1✔
1381
                # HACK: force query param here is specific to webmention
1382
                or 'force' in request.form):
1383
            create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1384
            create_as1 = {
1✔
1385
                'objectType': 'activity',
1386
                'verb': 'post',
1387
                'id': create_id,
1388
                'actor': obj_actor,
1389
                'object': obj_as1,
1390
                'published': now,
1391
            }
1392
            logger.info(f'Wrapping in post')
1✔
1393
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1394
            return Object(id=create_id, our_as1=create_as1,
1✔
1395
                          source_protocol=obj.source_protocol)
1396

1397
        error(f'{obj.key.id()} is unchanged, nothing to do', status=204)
1✔
1398

1399
    @classmethod
1✔
1400
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1401
        """Delivers an activity to its external recipients.
1402

1403
        Args:
1404
          obj (models.Object): activity to deliver
1405
          from_user (models.User): user (actor) this activity is from
1406
          crud_obj (models.Object): if this is a create, update, or delete/undo
1407
            activity, the inner object that's being written, otherwise None.
1408
            (This object's ``notify`` and ``feed`` properties may be updated.)
1409
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1410
            targets on this protocol
1411

1412
        Returns:
1413
          (str, int) tuple: Flask response
1414
        """
1415
        if to_proto:
1✔
1416
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1417

1418
        # find delivery targets. maps Target to Object or None
1419
        #
1420
        # ...then write the relevant object, since targets() has a side effect of
1421
        # setting the notify and feed properties (and dirty attribute)
1422
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1423
        if not targets:
1✔
1424
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1425

1426
        # store object that targets() updated
1427
        if crud_obj and crud_obj.dirty:
1✔
1428
            crud_obj.put()
1✔
1429
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1430
            obj.put()
1✔
1431

1432
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1433
                      else obj.to_request())
1434

1435
        # sort targets so order is deterministic for tests, debugging, etc
1436
        sorted_targets = sorted(targets.items(), key=lambda t: t[0].uri)
1✔
1437

1438
        # enqueue send task for each targets
1439
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1440
        user = from_user.key.urlsafe()
1✔
1441
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1442
            if to_proto and target.protocol != to_proto.LABEL:
1✔
UNCOV
1443
                continue
×
1444
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1445
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1446
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1447

1448
        return 'OK', 202
1✔
1449

1450
    @classmethod
1✔
1451
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1452
        """Collects the targets to send a :class:`models.Object` to.
1453

1454
        Targets are both objects - original posts, events, etc - and actors.
1455

1456
        Args:
1457
          obj (models.Object)
1458
          from_user (User)
1459
          crud_obj (models.Object): if this is a create, update, or delete/undo
1460
            activity, the inner object that's being written, otherwise None.
1461
            (This object's ``notify`` and ``feed`` properties may be updated.)
1462
          internal (bool): whether this is a recursive internal call
1463

1464
        Returns:
1465
          dict: maps :class:`models.Target` to original (in response to)
1466
          :class:`models.Object`, if any, otherwise None
1467
        """
1468
        logger.debug('Finding recipients and their targets')
1✔
1469

1470
        # we should only have crud_obj iff this is a create or update
1471
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1472
        write_obj = crud_obj or obj
1✔
1473
        write_obj.dirty = False
1✔
1474

1475
        target_uris = sorted(set(as1.targets(obj.as1)))
1✔
1476
        logger.info(f'Raw targets: {target_uris}')
1✔
1477
        orig_obj = None
1✔
1478
        targets = {}  # maps Target to Object or None
1✔
1479
        owner = as1.get_owner(obj.as1)
1✔
1480
        allow_opt_out = (obj.type == 'delete')
1✔
1481
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1482
        inner_obj_id = inner_obj_as1.get('id')
1✔
1483
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1484
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1485
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1486
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1487
        is_self_reply = False
1✔
1488

1489
        original_ids = []
1✔
1490
        if is_reply:
1✔
1491
            original_ids = in_reply_tos
1✔
1492
        elif inner_obj_id:
1✔
1493
            if inner_obj_id == from_user.key.id():
1✔
1494
                inner_obj_id = from_user.profile_id()
1✔
1495
            original_ids = [inner_obj_id]
1✔
1496

1497
        # which protocols should we allow delivering to?
1498
        to_protocols = []
1✔
1499
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1500
                      + from_user.enabled_protocols):
1501
            proto = PROTOCOLS[label]
1✔
1502
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1503
                                     or is_reply):
1504
                for id in original_ids:
1✔
1505
                    if Protocol.for_id(id) == proto:
1✔
1506
                        logger.info(f'Allowing {label} for original post {id}')
1✔
1507
                        break
1✔
1508
                    elif orig := from_user.load(id, remote=False):
1✔
1509
                        if orig.get_copy(proto):
1✔
1510
                            logger.info(f'Allowing {label}, original post {id} was bridged there')
1✔
1511
                            break
1✔
1512
                else:
1513
                    logger.info(f"Skipping {label}, original objects {original_ids} weren't bridged there")
1✔
1514
                    continue
1✔
1515

1516
            util.add(to_protocols, proto)
1✔
1517

1518
        # process direct targets
1519
        for id in sorted(target_uris):
1✔
1520
            target_proto = Protocol.for_id(id)
1✔
1521
            if not target_proto:
1✔
1522
                logger.info(f"Can't determine protocol for {id}")
1✔
1523
                continue
1✔
1524
            elif target_proto.is_blocklisted(id):
1✔
1525
                logger.debug(f'{id} is blocklisted')
1✔
1526
                continue
1✔
1527

1528
            orig_obj = target_proto.load(id, raise_=False)
1✔
1529
            if not orig_obj or not orig_obj.as1:
1✔
1530
                logger.info(f"Couldn't load {id}")
1✔
1531
                continue
1✔
1532

1533
            target_author_key = (target_proto(id=id).key if id in mentioned_urls
1✔
1534
                                 else target_proto.actor_key(orig_obj))
1535
            if not from_user.is_enabled(target_proto):
1✔
1536
                # if author isn't bridged and target user is, DM a prompt and
1537
                # add a notif for the target user
1538
                if (id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
1539
                        and target_author_key):
1540
                    if target_author := target_author_key.get():
1✔
1541
                        if target_author.is_enabled(from_cls):
1✔
1542
                            notifications.add_notification(target_author, write_obj)
1✔
1543
                            verb, noun = (
1✔
1544
                                ('replied to', 'replies') if id in in_reply_tos
1545
                                else ('quoted', 'quotes') if id in quoted_posts
1546
                                else ('mentioned', 'mentions'))
1547
                            dms.maybe_send(
1✔
1548
                                from_proto=target_proto, to_user=from_user,
1549
                                type='replied_to_bridged_user', text=f"""\
1550
Hi! You <a href="{inner_obj_as1.get('url') or inner_obj_id}">recently {verb}</a> {target_author.user_link()}, who's bridged here from {target_proto.PHRASE}. If you want them to see your {noun}, you can bridge your account into {target_proto.PHRASE} by following this account. <a href="https://fed.brid.gy/docs">See the docs</a> for more information.""")
1551

1552
                continue
1✔
1553

1554
            # deliver self-replies to followers
1555
            # https://github.com/snarfed/bridgy-fed/issues/639
1556
            if id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1557
                is_self_reply = True
1✔
1558
                logger.info(f'self reply!')
1✔
1559

1560
            # also add copies' targets
1561
            for copy in orig_obj.copies:
1✔
1562
                proto = PROTOCOLS[copy.protocol]
1✔
1563
                if proto in to_protocols:
1✔
1564
                    # copies generally won't have their own Objects
1565
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1566
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {id}')
1✔
1567
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1568

1569
            if target_proto == from_cls:
1✔
1570
                logger.debug(f'Skipping same-protocol target {id}')
1✔
1571
                continue
1✔
1572

1573
            target = target_proto.target_for(orig_obj)
1✔
1574
            if not target:
1✔
1575
                # TODO: surface errors like this somehow?
1576
                logger.error(f"Can't find delivery target for {id}")
1✔
1577
                continue
1✔
1578

1579
            logger.debug(f'Target for {id} is {target}')
1✔
1580
            # only use orig_obj for inReplyTos, like/repost objects, etc
1581
            # https://github.com/snarfed/bridgy-fed/issues/1237
1582
            targets[Target(protocol=target_proto.LABEL, uri=target)] = (
1✔
1583
                orig_obj if id in in_reply_tos or id in as1.get_ids(obj.as1, 'object')
1584
                else None)
1585

1586
            if target_author_key:
1✔
1587
                logger.debug(f'Recipient is {target_author_key}')
1✔
1588
                if write_obj.add('notify', target_author_key):
1✔
1589
                    write_obj.dirty = True
1✔
1590

1591
        if obj.type == 'undo':
1✔
1592
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1593
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1594
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1595
            else:
1596
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1597
            if inner_obj:
1✔
1598
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1599
                                                internal=True))
1600

1601
        logger.info(f'Direct targets: {[t.uri for t in targets.keys()]}')
1✔
1602

1603
        # deliver to followers, if appropriate
1604
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1605
        if not user_key:
1✔
1606
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1607
            return targets
1✔
1608

1609
        followers = []
1✔
1610
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1611
                and (not is_reply or is_self_reply)):
1612
            logger.info(f'Delivering to followers of {user_key}')
1✔
1613
            followers = [
1✔
1614
                f for f in Follower.query(Follower.to == user_key,
1615
                                          Follower.status == 'active')
1616
                # skip protocol bot users
1617
                if not Protocol.for_bridgy_subdomain(f.from_.id())
1618
                # skip protocols this user hasn't enabled, or where the base
1619
                # object of this activity hasn't been bridged
1620
                and PROTOCOLS_BY_KIND[f.from_.kind()] in to_protocols]
1621
            user_keys = [f.from_ for f in followers]
1✔
1622
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1623
            User.load_multi(users)
1✔
1624

1625
            if (not followers and
1✔
1626
                (util.domain_or_parent_in(
1627
                    util.domain_from_link(from_user.key.id()), LIMITED_DOMAINS)
1628
                 or util.domain_or_parent_in(
1629
                     util.domain_from_link(obj.key.id()), LIMITED_DOMAINS))):
1630
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1631
                return {}
1✔
1632

1633
            # add to followers' feeds, if any
1634
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1635
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1636
                    write_obj.feed = [u.key for u in users]
1✔
1637
                    if write_obj.feed:
1✔
1638
                        write_obj.dirty = True
1✔
1639

1640
            # collect targets for followers
1641
            for user in users:
1✔
1642
                # TODO: should we pass remote=False through here to Protocol.load?
1643
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1644
                if not target:
1✔
1645
                    # TODO: surface errors like this somehow?
1646
                    logger.error(f'Follower {user.key} has no delivery target')
1✔
1647
                    continue
1✔
1648

1649
                # normalize URL (lower case hostname, etc)
1650
                # ...but preserve our PDS URL without trailing slash in path
1651
                # https://atproto.com/specs/did#did-documents
1652
                target = util.dedupe_urls([target], trailing_slash=False)[0]
1✔
1653

1654
                targets[Target(protocol=user.LABEL, uri=target)] = \
1✔
1655
                    Object.get_by_id(inner_obj_id) if obj.type == 'share' else None
1656

1657
        # deliver to enabled HAS_COPIES protocols proactively
1658
        # TODO: abstract for other protocols
1659
        from atproto import ATProto
1✔
1660
        if (ATProto in to_protocols
1✔
1661
                and obj.type in ('post', 'update', 'delete', 'share')):
1662
            logger.info(f'user has ATProto enabled, adding {ATProto.PDS_URL}')
1✔
1663
            targets.setdefault(
1✔
1664
                Target(protocol=ATProto.LABEL, uri=ATProto.PDS_URL), None)
1665

1666
        # de-dupe targets, discard same-domain
1667
        # maps string target URL to (Target, Object) tuple
1668
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1669
        # maps Target to Object or None
1670
        targets = {}
1✔
1671
        source_domains = [
1✔
1672
            util.domain_from_link(url) for url in
1673
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1674
            if util.is_web(url)
1675
        ]
1676
        for url in sorted(util.dedupe_urls(
1✔
1677
                candidates.keys(),
1678
                # preserve our PDS URL without trailing slash in path
1679
                # https://atproto.com/specs/did#did-documents
1680
                trailing_slash=False)):
1681
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
UNCOV
1682
                logger.info(f'Skipping same-domain target {url}')
×
UNCOV
1683
                continue
×
1684
            target, obj = candidates[url]
1✔
1685
            targets[target] = obj
1✔
1686

1687
        return targets
1✔
1688

1689
    @classmethod
1✔
1690
    def load(cls, id, remote=None, local=True, raise_=True, **kwargs):
1✔
1691
        """Loads and returns an Object from datastore or HTTP fetch.
1692

1693
        Sets the :attr:`new` and :attr:`changed` attributes if we know either
1694
        one for the loaded object, ie local is True and remote is True or None.
1695

1696
        Args:
1697
          id (str)
1698
          remote (bool): whether to fetch the object over the network. If True,
1699
            fetches even if we already have the object stored, and updates our
1700
            stored copy. If False and we don't have the object stored, returns
1701
            None. Default (None) means to fetch over the network only if we
1702
            don't already have it stored.
1703
          local (bool): whether to load from the datastore before
1704
            fetching over the network. If False, still stores back to the
1705
            datastore after a successful remote fetch.
1706
          raise_ (bool): if False, catches any :class:`request.RequestException`
1707
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1708
            ``None`` instead
1709
          kwargs: passed through to :meth:`fetch()`
1710

1711
        Returns:
1712
          models.Object: loaded object, or None if it isn't fetchable, eg a
1713
          non-URL string for Web, or ``remote`` is False and it isn't in the
1714
          datastore
1715

1716
        Raises:
1717
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1718
            is True
1719
        """
1720
        assert id
1✔
1721
        assert local or remote is not False
1✔
1722
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1723

1724
        obj = orig_as1 = None
1✔
1725
        if local:
1✔
1726
            obj = Object.get_by_id(id)
1✔
1727
            if not obj:
1✔
1728
                # logger.debug(f' {id} not in datastore')
1729
                pass
1✔
1730
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1731
                # logger.debug(f'  {id} got from datastore')
1732
                obj.new = False
1✔
1733

1734
        if remote is False:
1✔
1735
            return obj
1✔
1736
        elif remote is None and obj:
1✔
1737
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1738
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1739
                pass
1✔
1740
            else:
1741
                return obj
1✔
1742

1743
        if obj:
1✔
1744
            orig_as1 = obj.as1
1✔
1745
            obj.our_as1 = None
1✔
1746
            obj.new = False
1✔
1747
        else:
1748
            obj = Object(id=id)
1✔
1749
            if local:
1✔
1750
                # logger.debug(f'  {id} not in datastore')
1751
                obj.new = True
1✔
1752
                obj.changed = False
1✔
1753

1754
        try:
1✔
1755
            fetched = cls.fetch(obj, **kwargs)
1✔
1756
        except (RequestException, HTTPException) as e:
1✔
1757
            if raise_:
1✔
1758
                raise
1✔
1759
            util.interpret_http_exception(e)
1✔
1760
            return None
1✔
1761

1762
        if not fetched:
1✔
1763
            return None
1✔
1764

1765
        # https://stackoverflow.com/a/3042250/186123
1766
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1767
        if size > models.MAX_ENTITY_SIZE:
1✔
1768
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1769
            return None
1✔
1770

1771
        obj.resolve_ids()
1✔
1772
        obj.normalize_ids()
1✔
1773

1774
        if obj.new is False:
1✔
1775
            obj.changed = obj.activity_changed(orig_as1)
1✔
1776

1777
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1778
            if obj.source_protocol:
1✔
UNCOV
1779
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1780
            obj.source_protocol = cls.LABEL
1✔
1781

1782
        obj.put()
1✔
1783
        return obj
1✔
1784

1785
    @classmethod
1✔
1786
    def check_supported(cls, obj):
1✔
1787
        """If this protocol doesn't support this object, raises HTTP 204.
1788

1789
        Also reports an error.
1790

1791
        (This logic is duplicated in some protocols, eg ActivityPub, so that
1792
        they can short circuit out early. It generally uses their native formats
1793
        instead of AS1, before an :class:`models.Object` is created.)
1794

1795
        Args:
1796
          obj (Object)
1797

1798
        Raises:
1799
          werkzeug.HTTPException: if this protocol doesn't support this object
1800
        """
1801
        if not obj.type:
1✔
UNCOV
1802
            return
×
1803

1804
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1805
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1806
            or (obj.type in as1.CRUD_VERBS
1807
                and inner_type
1808
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1809
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1810

1811
        # don't allow posts with blank content and no image/video/audio
1812
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
1813
                    else obj.as1)
1814
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
1815
                and not util.get_url(crud_obj, key='image')
1816
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
1817
                # TODO: handle articles with displayName but not content
1818
                and not source.html_to_text(crud_obj.get('content')).strip()):
1819
            error('Blank content and no image or video or audio', status=204)
1✔
1820

1821
        # DMs are only allowed to/from protocol bot accounts
1822
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1823
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1824
            if (not cls.SUPPORTS_DMS
1✔
1825
                    or (recip not in protocol_user_ids
1826
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1827
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1828

1829

1830
@cloud_tasks_only(log=None)
1✔
1831
def receive_task():
1✔
1832
    """Task handler for a newly received :class:`models.Object`.
1833

1834
    Calls :meth:`Protocol.receive` with the form parameters.
1835

1836
    Parameters:
1837
      authed_as (str): passed to :meth:`Protocol.receive`
1838
      obj_id (str): key id of :class:`models.Object` to handle
1839
      received_at (str, ISO 8601 timestamp): when we first saw (received)
1840
        this activity
1841
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1842
        :class:`models.Object` to handle
1843

1844
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
1845
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
1846
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
1847
    ``changed``, HTTP request details, etc. See stash for attempt at this for
1848
    :class:`web.Web`.
1849
    """
1850
    common.log_request()
1✔
1851
    form = request.form.to_dict()
1✔
1852

1853
    authed_as = form.pop('authed_as', None)
1✔
1854
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1855
                or authed_as in common.PROTOCOL_DOMAINS)
1856

1857
    obj = Object.from_request()
1✔
1858
    assert obj
1✔
1859
    assert obj.source_protocol
1✔
1860
    obj.new = True
1✔
1861

1862
    if received_at := form.pop('received_at', None):
1✔
1863
        received_at = datetime.fromisoformat(received_at)
1✔
1864

1865
    try:
1✔
1866
        return PROTOCOLS[obj.source_protocol].receive(
1✔
1867
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
1868
    except RequestException as e:
1✔
1869
        util.interpret_http_exception(e)
1✔
1870
        error(e, status=304)
1✔
1871
    except ValueError as e:
1✔
UNCOV
1872
        logger.warning(e, exc_info=True)
×
UNCOV
1873
        error(e, status=304)
×
1874

1875

1876
@cloud_tasks_only(log=None)
1✔
1877
def send_task():
1✔
1878
    """Task handler for sending an activity to a single specific destination.
1879

1880
    Calls :meth:`Protocol.send` with the form parameters.
1881

1882
    Parameters:
1883
      protocol (str): :class:`Protocol` to send to
1884
      url (str): destination URL to send to
1885
      obj_id (str): key id of :class:`models.Object` to send
1886
      orig_obj_id (str): optional, :class:`models.Object` key id of the
1887
        "original object" that this object refers to, eg replies to or reposts
1888
        or likes
1889
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1890
        this activity is from
1891
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1892
        :class:`models.Object` to handle
1893
    """
1894
    common.log_request()
1✔
1895

1896
    # prepare
1897
    form = request.form.to_dict()
1✔
1898
    url = form.get('url')
1✔
1899
    protocol = form.get('protocol')
1✔
1900
    if not url or not protocol:
1✔
1901
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1902
        return '', 204
1✔
1903

1904
    target = Target(uri=url, protocol=protocol)
1✔
1905
    obj = Object.from_request()
1✔
1906
    assert obj and obj.key and obj.key.id()
1✔
1907

1908
    PROTOCOLS[protocol].check_supported(obj)
1✔
1909
    allow_opt_out = (obj.type == 'delete')
1✔
1910

1911
    user = None
1✔
1912
    if user_key := form.get('user'):
1✔
1913
        key = ndb.Key(urlsafe=user_key)
1✔
1914
        # use get_by_id so that we follow use_instead
1915
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
1916
            key.id(), allow_opt_out=allow_opt_out)
1917

1918
    # send
1919
    delay = ''
1✔
1920
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
1921
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
1922
        delay = f'({delay_s} s behind)'
1✔
1923
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
1924
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
1925
    sent = None
1✔
1926
    try:
1✔
1927
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
1928
                                        orig_obj_id=form.get('orig_obj_id'))
1929
    except BaseException as e:
1✔
1930
        code, body = util.interpret_http_exception(e)
1✔
1931
        if not code and not body:
1✔
1932
            raise
1✔
1933

1934
    if sent is False:
1✔
1935
        logger.info(f'Failed sending!')
1✔
1936

1937
    return '', 200 if sent else 204 if sent is False else 304
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc