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

snarfed / bridgy-fed / 50c2b87f-d8bf-462f-b0eb-5bb3d36b6975

20 Aug 2025 08:04PM UTC coverage: 92.64%. Remained the same
50c2b87f-d8bf-462f-b0eb-5bb3d36b6975

push

circleci

snarfed
Protocol.receive: make force override too old check

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

5 existing lines in 1 file now uncovered.

5626 of 6073 relevant lines covered (92.64%)

0.93 hits per line

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

94.74
/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
# WARNING: keep this below the receive queue's min_backoff_seconds in queue.yaml!
61
MEMCACHE_LEASE_EXPIRATION = timedelta(seconds=25)
1✔
62

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

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

77
logger = logging.getLogger(__name__)
1✔
78

79

80
def error(*args, status=299, **kwargs):
1✔
81
    """Default HTTP status code to 299 to prevent retrying task."""
82
    return common.error(*args, status=status, **kwargs)
1✔
83

84

85
def activity_id_memcache_key(id):
1✔
86
    return memcache.key(f'receive-{id}')
1✔
87

88

89
class Protocol:
1✔
90
    """Base protocol class. Not to be instantiated; classmethods only."""
91
    ABBREV = None
1✔
92
    """str: lower case abbreviation, used in URL paths"""
1✔
93
    PHRASE = None
1✔
94
    """str: human-readable name or phrase. Used in phrases like ``Follow this person on {PHRASE}``"""
1✔
95
    OTHER_LABELS = ()
1✔
96
    """sequence of str: label aliases"""
1✔
97
    LOGO_HTML = ''
1✔
98
    """str: logo emoji or ``<img>`` tag"""
1✔
99
    CONTENT_TYPE = None
1✔
100
    """str: MIME type of this protocol's native data format, appropriate for the ``Content-Type`` HTTP header."""
1✔
101
    HAS_COPIES = False
1✔
102
    """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✔
103
    DEFAULT_TARGET = None
1✔
104
    """str: optional, the default target URI to send this protocol's activities to. May be used as the "shared" target. Often only set if ``HAS_COPIES`` is true."""
1✔
105
    REQUIRES_AVATAR = False
1✔
106
    """bool: whether accounts on this protocol are required to have a profile picture. If they don't, their ``User.status`` will be ``blocked``."""
1✔
107
    REQUIRES_NAME = False
1✔
108
    """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✔
109
    REQUIRES_OLD_ACCOUNT = False
1✔
110
    """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✔
111
    DEFAULT_ENABLED_PROTOCOLS = ()
1✔
112
    """sequence of str: labels of other protocols that are automatically enabled for this protocol to bridge into"""
1✔
113
    DEFAULT_SERVE_USER_PAGES = False
1✔
114
    """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✔
115
    SUPPORTED_AS1_TYPES = ()
1✔
116
    """sequence of str: AS1 objectTypes and verbs that this protocol supports receiving and sending"""
1✔
117
    SUPPORTS_DMS = False
1✔
118
    """bool: whether this protocol can receive DMs (chat messages)"""
1✔
119
    USES_OBJECT_FEED = False
1✔
120
    """bool: whether to store followers on this protocol in :attr:`Object.feed`."""
1✔
121
    HTML_PROFILES = True
1✔
122
    """bool: whether this protocol supports HTML in profile descriptions. If False, profile descriptions should be plain text."""
1✔
123
    SEND_REPLIES_TO_ORIG_POSTS_MENTIONS = False
1✔
124
    """bool: whether replies to this protocol should include the original post's mentions as delivery targets"""
1✔
125

126
    def __init__(self):
1✔
127
        assert False
×
128

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

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

139
        ...based on the request's hostname.
140

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

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

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

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

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

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

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

179
        To be implemented by subclasses.
180

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

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

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

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

195
        Args:
196
          id (str)
197

198
        Returns:
199
          bool or None:
200
        """
201
        return False
1✔
202

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

207
        To be implemented by subclasses.
208

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

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

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

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

227
        Returns:
228
          bool or None
229
        """
230
        return False
1✔
231

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

236
        To be implemented by subclasses.
237

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

241
        Args:
242
          handle (str)
243

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

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

253
        To be implemented by subclasses. Canonicalizes the id if necessary.
254

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

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

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

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

278
        return cls(id=id).key
1✔
279

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

284
        Args:
285
          id (str)
286

287
        Returns:
288
          (str domain, bool remote) or None
289
        """
290
        domain = util.domain_from_link(id)
1✔
291
        if domain in PROTOCOL_DOMAINS:
1✔
292
            return id
1✔
293
        elif remote and util.is_web(id):
1✔
294
            return domain
1✔
295

296
    @cached(LRUCache(20000), lock=Lock())
1✔
297
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote=True: remote,
1✔
298
                      version=3)
299
    @staticmethod
1✔
300
    def for_id(id, remote=True):
1✔
301
        """Returns the protocol for a given id.
302

303
        Args:
304
          id (str)
305
          remote (bool): whether to perform expensive side effects like fetching
306
            the id itself over the network, or other discovery.
307

308
        Returns:
309
          Protocol subclass: matching protocol, or None if no single known
310
          protocol definitively owns this id
311
        """
312
        logger.debug(f'Determining protocol for id {id}')
1✔
313
        if not id:
1✔
314
            return None
1✔
315

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

325
        if util.is_web(id):
1✔
326
            # step 1: check for our per-protocol subdomains
327
            try:
1✔
328
                parsed = urlparse(id)
1✔
329
            except ValueError as e:
1✔
330
                logger.info(f'urlparse ValueError: {e}')
1✔
331
                return None
1✔
332

333
            is_homepage = parsed.path.strip('/') == ''
1✔
334
            is_internal = parsed.path.startswith(ids.INTERNAL_PATH_PREFIX)
1✔
335
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
336
            if by_subdomain and not (is_homepage or is_internal
1✔
337
                                     or id in BOT_ACTOR_AP_IDS):
338
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
339
                return by_subdomain
1✔
340

341
        # step 2: check if any Protocols say conclusively that they own it
342
        # sort to be deterministic
343
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
344
                           key=lambda p: p.LABEL)
345
        candidates = []
1✔
346
        for protocol in protocols:
1✔
347
            owns = protocol.owns_id(id)
1✔
348
            if owns:
1✔
349
                logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
350
                return protocol
1✔
351
            elif owns is not False:
1✔
352
                candidates.append(protocol)
1✔
353

354
        if len(candidates) == 1:
1✔
355
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
356
            return candidates[0]
1✔
357

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

369
        # step 4: fetch over the network, if necessary
370
        if not remote:
1✔
371
            return None
1✔
372

373
        for protocol in candidates:
1✔
374
            logger.debug(f'Trying {protocol.LABEL}')
1✔
375
            try:
1✔
376
                obj = protocol.load(id, local=False, remote=True)
1✔
377

378
                if protocol.ABBREV == 'web':
1✔
379
                    # for web, if we fetch and get HTML without microformats,
380
                    # load returns False but the object will be stored in the
381
                    # datastore with source_protocol web, and in cache. load it
382
                    # again manually to check for that.
383
                    obj = Object.get_by_id(id)
1✔
384
                    if obj and obj.source_protocol != 'web':
1✔
385
                        obj = None
×
386

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

404
        logger.info(f'No matching protocol found for {id} !')
1✔
405
        return None
1✔
406

407
    @cached(LRUCache(20000), lock=Lock())
1✔
408
    @staticmethod
1✔
409
    def for_handle(handle):
1✔
410
        """Returns the protocol for a given handle.
411

412
        May incur expensive side effects like resolving the handle itself over
413
        the network or other discovery.
414

415
        Args:
416
          handle (str)
417

418
        Returns:
419
          (Protocol subclass, str) tuple: matching protocol and optional id (if
420
          resolved), or ``(None, None)`` if no known protocol owns this handle
421
        """
422
        # TODO: normalize, eg convert domains to lower case
423
        logger.debug(f'Determining protocol for handle {handle}')
1✔
424
        if not handle:
1✔
425
            return (None, None)
1✔
426

427
        # step 1: check if any Protocols say conclusively that they own it.
428
        # sort to be deterministic.
429
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
430
                           key=lambda p: p.LABEL)
431
        candidates = []
1✔
432
        for proto in protocols:
1✔
433
            owns = proto.owns_handle(handle)
1✔
434
            if owns:
1✔
435
                logger.debug(f'  {proto.LABEL} owns handle {handle}')
1✔
436
                return (proto, None)
1✔
437
            elif owns is not False:
1✔
438
                candidates.append(proto)
1✔
439

440
        if len(candidates) == 1:
1✔
441
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
442
            return (candidates[0], None)
×
443

444
        # step 2: look for matching User in the datastore
445
        for proto in candidates:
1✔
446
            user = proto.query(proto.handle == handle).get()
1✔
447
            if user:
1✔
448
                if user.status:
1✔
449
                    return (None, None)
1✔
450
                logger.debug(f'  user {user.key} handle {handle}')
1✔
451
                return (proto, user.key.id())
1✔
452

453
        # step 3: resolve handle to id
454
        for proto in candidates:
1✔
455
            id = proto.handle_to_id(handle)
1✔
456
            if id:
1✔
457
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
458
                return (proto, id)
1✔
459

460
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
461
        return (None, None)
1✔
462

463
    @classmethod
1✔
464
    def is_user_at_domain(cls, handle, allow_internal=False):
1✔
465
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
466

467
        Example: ``@user@instance.com``
468

469
        Args:
470
          handle (str)
471
          allow_internal (bool): whether the domain can be a Bridgy Fed domain
472
        """
473
        parts = handle.split('@')
1✔
474
        if len(parts) != 2:
1✔
475
            return False
1✔
476

477
        user, domain = parts
1✔
478
        return bool(user and domain
1✔
479
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
480

481
    @classmethod
1✔
482
    def bridged_web_url_for(cls, user, fallback=False):
1✔
483
        """Returns the web URL for a user's bridged profile in this protocol.
484

485
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
486
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
487

488
        Args:
489
          user (models.User)
490
          fallback (bool): if True, and bridged users have no canonical user
491
            profile URL in this protocol, return the native protocol's profile URL
492

493
        Returns:
494
          str, or None if there isn't a canonical URL
495
        """
496
        if fallback:
1✔
497
            return user.web_url()
1✔
498

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

503
        Args:
504
          obj (models.Object)
505
          allow_opt_out (bool): whether to return a user key if they're opted out
506

507
        Returns:
508
          google.cloud.ndb.key.Key or None:
509
        """
510
        owner = as1.get_owner(obj.as1)
1✔
511
        if owner:
1✔
512
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
513

514
    @classmethod
1✔
515
    def bot_user_id(cls):
1✔
516
        """Returns the Web user id for the bot user for this protocol.
517

518
        For example, ``'bsky.brid.gy'`` for ATProto.
519

520
        Returns:
521
          str:
522
        """
523
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
524

525
    @classmethod
1✔
526
    def create_for(cls, user):
1✔
527
        """Creates or re-activate a copy user in this protocol.
528

529
        Should add the copy user to :attr:`copies`.
530

531
        If the copy user already exists and active, should do nothing.
532

533
        Args:
534
          user (models.User): original source user. Shouldn't already have a
535
            copy user for this protocol in :attr:`copies`.
536

537
        Raises:
538
          ValueError: if we can't create a copy of the given user in this protocol
539
        """
540
        raise NotImplementedError()
×
541

542
    @classmethod
1✔
543
    def send(to_cls, obj, target, from_user=None, orig_obj_id=None):
1✔
544
        """Sends an outgoing activity.
545

546
        To be implemented by subclasses. Should call
547
        ``to_cls.translate_ids(obj.as1)`` before converting it to this Protocol's
548
        format.
549

550
        NOTE: if this protocol's ``HAS_COPIES`` is True, and this method creates
551
        a copy and sends it, it *must* add that copy to the *object*'s (not
552
        activity's) :attr:`copies`, and store it back in the datastore!
553

554
        Args:
555
          obj (models.Object): with activity to send
556
          target (str): destination URL to send to
557
          from_user (models.User): user (actor) this activity is from
558
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
559
            that this object refers to, eg replies to or reposts or likes
560

561
        Returns:
562
          bool: True if the activity is sent successfully, False if it is
563
          ignored or otherwise unsent due to protocol logic, eg no webmention
564
          endpoint, protocol doesn't support the activity type. (Failures are
565
          raised as exceptions.)
566

567
        Raises:
568
          werkzeug.HTTPException if the request fails
569
        """
570
        raise NotImplementedError()
×
571

572
    @classmethod
1✔
573
    def fetch(cls, obj, **kwargs):
1✔
574
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
575

576
        Errors are raised as exceptions. If this method returns False, the fetch
577
        didn't fail but didn't succeed either, eg the id isn't valid for this
578
        protocol, or the fetch didn't return valid data for this protocol.
579

580
        To be implemented by subclasses.
581

582
        Args:
583
          obj (models.Object): with the id to fetch. Data is filled into one of
584
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
585
          kwargs: subclass-specific
586

587
        Returns:
588
          bool: True if the object was fetched and populated successfully,
589
          False otherwise
590

591
        Raises:
592
          requests.RequestException, werkzeug.HTTPException,
593
          websockets.WebSocketException, etc: if the fetch fails
594
        """
595
        raise NotImplementedError()
×
596

597
    @classmethod
1✔
598
    def convert(cls, obj, from_user=None, **kwargs):
1✔
599
        """Converts an :class:`Object` to this protocol's data format.
600

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

604
        Just passes through to :meth:`_convert`, then does minor
605
        protocol-independent postprocessing.
606

607
        Args:
608
          obj (models.Object):
609
          from_user (models.User): user (actor) this activity/object is from
610
          kwargs: protocol-specific, passed through to :meth:`_convert`
611

612
        Returns:
613
          converted object in the protocol's native format, often a dict
614
        """
615
        if not obj or not obj.as1:
1✔
616
            return {}
1✔
617

618
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
619
        is_activity = obj.as1.get('verb') in as1.CRUD_VERBS
1✔
620
        base_obj = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
621
        orig_our_as1 = obj.our_as1
1✔
622

623
        # mark bridged actors as bots and add "bridged by Bridgy Fed" to their bios
624
        if (from_user and base_obj
1✔
625
            and base_obj.get('objectType') in as1.ACTOR_TYPES
626
            and PROTOCOLS.get(obj.source_protocol) != cls
627
            and Protocol.for_bridgy_subdomain(id) not in DOMAINS
628
            # Web users are special cased, they don't get the label if they've
629
            # explicitly enabled Bridgy Fed with redirects or webmentions
630
            and not (from_user.LABEL == 'web'
631
                     and (from_user.last_webmention_in or from_user.has_redirects))):
632

633
            cls.add_source_links(obj=obj, from_user=from_user)
1✔
634

635
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
636
        obj.our_as1 = orig_our_as1
1✔
637
        return converted
1✔
638

639
    @classmethod
1✔
640
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
641
        """Converts an :class:`Object` to this protocol's data format.
642

643
        To be implemented by subclasses. Implementations should generally call
644
        :meth:`Protocol.translate_ids` (as their own class) before converting to
645
        their format.
646

647
        Args:
648
          obj (models.Object):
649
          from_user (models.User): user (actor) this activity/object is from
650
          kwargs: protocol-specific
651

652
        Returns:
653
          converted object in the protocol's native format, often a dict. May
654
            return the ``{}`` empty dict if the object can't be converted.
655
        """
656
        raise NotImplementedError()
×
657

658
    @classmethod
1✔
659
    def add_source_links(cls, obj, from_user):
1✔
660
        """Adds "bridged from ... by Bridgy Fed" to the user's actor's ``summary``.
661

662
        Uses HTML for protocols that support it, plain text otherwise.
663

664
        Args:
665
          obj (models.Object): user's actor/profile object
666
          from_user (models.User): user (actor) this activity/object is from
667
        """
668
        assert obj and obj.as1
1✔
669
        assert from_user
1✔
670

671
        obj.our_as1 = copy.deepcopy(obj.as1)
1✔
672
        actor = (as1.get_object(obj.as1) if obj.as1.get('verb') in as1.CRUD_VERBS
1✔
673
                 else obj.as1)
674
        actor['objectType'] = 'person'
1✔
675

676
        orig_summary = actor.setdefault('summary', '')
1✔
677
        summary_text = html_to_text(orig_summary, ignore_links=True)
1✔
678

679
        # Check if we've already added source links
680
        if '🌉 bridged' in summary_text:
1✔
681
            return
1✔
682

683
        actor_id = actor.get('id')
1✔
684
        proto_phrase = (f' on {PROTOCOLS[obj.source_protocol].PHRASE}'
1✔
685
                        if obj.source_protocol else '')
686
        url = as1.get_url(actor) or obj.key.id() if obj.key else actor_id
1✔
687

688
        if cls.HTML_PROFILES:
1✔
689
            by = f' by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>'
1✔
690
            separator = '<br><br>'
1✔
691

692
            is_user = from_user.key and actor_id in (from_user.key.id(),
1✔
693
                                                     from_user.profile_id())
694
            if is_user:
1✔
695
                bridged = f'🌉 <a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a>'
1✔
696
                from_ = f'<a href="{from_user.web_url()}">{from_user.handle}</a>'
1✔
697
            else:
698
                bridged = '🌉 bridged'
1✔
699
                from_ = util.pretty_link(url) if url else '?'
1✔
700

701
        else:  # plain text
702
            # TODO: unify with above. which is right?
703
            id = obj.key.id() if obj.key else obj.our_as1.get('id')
1✔
704
            is_user = from_user.key and id in (from_user.key.id(),
1✔
705
                                               from_user.profile_id())
706
            from_ = (from_user.web_url() if is_user else url) or '?'
1✔
707

708
            bridged = '🌉 bridged'
1✔
709
            by = (f': https://{PRIMARY_DOMAIN}{from_user.user_page_path()}'
1✔
710
                  # link web users to their user pages
711
                  if from_user.LABEL == 'web'
712
                  else f' by https://{PRIMARY_DOMAIN}/')
713
            separator = '\n\n'
1✔
714
            orig_summary = summary_text
1✔
715

716
        source_links = f'{separator if orig_summary else ""}{bridged} from {from_}{proto_phrase}{by}'
1✔
717
        actor['summary'] = orig_summary + source_links
1✔
718

719
    @classmethod
1✔
720
    def set_username(to_cls, user, username):
1✔
721
        """Sets a custom username for a user's bridged account in this protocol.
722

723
        Args:
724
          user (models.User)
725
          username (str)
726

727
        Raises:
728
          ValueError: if the username is invalid
729
          RuntimeError: if the username could not be set
730
        """
731
        raise NotImplementedError()
1✔
732

733
    @classmethod
1✔
734
    def migrate_out(cls, user, to_user_id):
1✔
735
        """Migrates a bridged account out to be a native account.
736

737
        Args:
738
          user (models.User)
739
          to_user_id (str)
740

741
        Raises:
742
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
743
            ``user`` is on this protocol or not bridged to this protocol
744
        """
745
        raise NotImplementedError()
×
746

747
    @classmethod
1✔
748
    def check_can_migrate_out(cls, user, to_user_id):
1✔
749
        """Raises an exception if a user can't yet migrate to a native account.
750

751
        For example, if ``to_user_id`` isn't on this protocol, or if ``user`` is on
752
        this protocol, or isn't bridged to this protocol.
753

754
        If the user is ready to migrate, returns ``None``.
755

756
        Subclasses may override this to add more criteria, but they should call this
757
        implementation first.
758

759
        Args:
760
          user (models.User)
761
          to_user_id (str)
762

763
        Raises:
764
          ValueError: if ``user`` isn't ready to migrate to this protocol yet
765
        """
766
        def _error(msg):
1✔
767
            logger.warning(msg)
1✔
768
            raise ValueError(msg)
1✔
769

770
        if cls.owns_id(to_user_id) is False:
1✔
771
            _error(f"{to_user_id} doesn't look like an {cls.LABEL} id")
1✔
772
        elif isinstance(user, cls):
1✔
773
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
774
        elif not user.is_enabled(cls):
1✔
775
            _error(f"{user.handle_or_id()} isn't currently bridged to {cls.PHRASE}")
1✔
776

777
    @classmethod
1✔
778
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
779
        """Migrates a native account in to be a bridged account.
780

781
        Args:
782
          user (models.User): native user on another protocol to attach the
783
            newly imported bridged account to
784
          from_user_id (str)
785
          kwargs: additional protocol-specific parameters
786

787
        Raises:
788
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
789
            ``user`` is on this protocol or already bridged to this protocol
790
        """
791
        raise NotImplementedError()
×
792

793
    @classmethod
1✔
794
    def target_for(cls, obj, shared=False):
1✔
795
        """Returns an :class:`Object`'s delivery target (endpoint).
796

797
        To be implemented by subclasses.
798

799
        Examples:
800

801
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
802
          webmention target.
803
        * If obj is an ``activitypub`` actor, returns its inbox.
804
        * If obj is an ``activitypub`` object, returns it's author's or actor's
805
          inbox.
806

807
        Args:
808
          obj (models.Object):
809
          shared (bool): optional. If True, returns a common/shared
810
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
811
            multiple recipients for efficiency
812

813
        Returns:
814
          str: target endpoint, or None if not available.
815
        """
816
        raise NotImplementedError()
×
817

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

822
        Default implementation here, subclasses may override.
823

824
        Args:
825
          url (str):
826
          allow_internal (bool): whether to return False for internal domains
827
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
828
        """
829
        blocklist = DOMAIN_BLOCKLIST
1✔
830
        if not DEBUG:
1✔
831
            blocklist += tuple(util.RESERVED_TLDS | util.LOCAL_TLDS)
×
832
        if not allow_internal:
1✔
833
            blocklist += DOMAINS
1✔
834
        return util.domain_or_parent_in(url, blocklist)
1✔
835

836
    @classmethod
1✔
837
    def translate_ids(to_cls, obj):
1✔
838
        """Translates all ids in an AS1 object to a specific protocol.
839

840
        Infers source protocol for each id value separately.
841

842
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
843
        ``at://did:plc:abc/coll/123`` will be converted to
844
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
845

846
        Wraps these AS1 fields:
847

848
        * ``id``
849
        * ``actor``
850
        * ``author``
851
        * ``bcc``
852
        * ``bto``
853
        * ``cc``
854
        * ``featured[].items``, ``featured[].orderedItems``
855
        * ``object``
856
        * ``object.actor``
857
        * ``object.author``
858
        * ``object.id``
859
        * ``object.inReplyTo``
860
        * ``object.object``
861
        * ``attachments[].id``
862
        * ``tags[objectType=mention].url``
863
        * ``to``
864

865
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
866
        same logic is duplicated there!
867

868
        TODO: unify with :meth:`Object.resolve_ids`,
869
        :meth:`models.Object.normalize_ids`.
870

871
        Args:
872
          to_proto (Protocol subclass)
873
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
874

875
        Returns:
876
          dict: wrapped AS1 version of ``obj``
877
        """
878
        assert to_cls != Protocol
1✔
879
        if not obj:
1✔
880
            return obj
1✔
881

882
        outer_obj = copy.deepcopy(obj)
1✔
883
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
884

885
        def translate(elem, field, fn, uri=False):
1✔
886
            elem[field] = as1.get_objects(elem, field)
1✔
887
            for obj in elem[field]:
1✔
888
                if id := obj.get('id'):
1✔
889
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
890
                        continue
1✔
891
                    from_cls = Protocol.for_id(id)
1✔
892
                    # TODO: what if from_cls is None? relax translate_object_id,
893
                    # make it a noop if we don't know enough about from/to?
894
                    if from_cls and from_cls != to_cls:
1✔
895
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
896
                    if obj['id'] and uri:
1✔
897
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
898

899
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
900
                           for o in elem[field]]
901

902
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
903
                elem[field] = elem[field][0]
1✔
904

905
        type = as1.object_type(outer_obj)
1✔
906
        translate(outer_obj, 'id',
1✔
907
                  translate_user_id if type in as1.ACTOR_TYPES
908
                  else translate_object_id)
909

910
        for o in inner_objs:
1✔
911
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
912
                        or as1.get_owner(outer_obj) == o.get('id')
913
                        or type in ('follow', 'stop-following'))
914
            translate(o, 'id', translate_user_id if is_actor else translate_object_id)
1✔
915
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
916
            translate(o, 'object', translate_user_id if obj_is_actor
1✔
917
                      else translate_object_id)
918

919
        for o in [outer_obj] + inner_objs:
1✔
920
            translate(o, 'inReplyTo', translate_object_id)
1✔
921
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
922
                translate(o, field, translate_user_id)
1✔
923
            for tag in as1.get_objects(o, 'tags'):
1✔
924
                if tag.get('objectType') == 'mention':
1✔
925
                    translate(tag, 'url', translate_user_id, uri=True)
1✔
926
            for att in as1.get_objects(o, 'attachments'):
1✔
927
                translate(att, 'id', translate_object_id)
1✔
928
                url = att.get('url')
1✔
929
                if url and not att.get('id'):
1✔
930
                    if from_cls := Protocol.for_id(url):
1✔
931
                        att['id'] = translate_object_id(from_=from_cls, to=to_cls,
1✔
932
                                                        id=url)
933
            if feat := as1.get_object(o, 'featured'):
1✔
934
                translate(feat, 'orderedItems', translate_object_id)
1✔
935
                translate(feat, 'items', translate_object_id)
1✔
936

937
        outer_obj = util.trim_nulls(outer_obj)
1✔
938

939
        if objs := util.get_list(outer_obj ,'object'):
1✔
940
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
941
            if len(outer_obj['object']) == 1:
1✔
942
                outer_obj['object'] = outer_obj['object'][0]
1✔
943

944
        return outer_obj
1✔
945

946
    @classmethod
1✔
947
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
948
        """Handles an incoming activity.
949

950
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
951
        unset, returns HTTP 299.
952

953
        Args:
954
          obj (models.Object)
955
          authed_as (str): authenticated actor id who sent this activity
956
          internal (bool): whether to allow activity ids on internal domains,
957
            from opted out/blocked users, etc.
958
          received_at (datetime): when we first saw (received) this activity.
959
            Right now only used for monitoring.
960

961
        Returns:
962
          (str, int) tuple: (response body, HTTP status code) Flask response
963

964
        Raises:
965
          werkzeug.HTTPException: if the request is invalid
966
        """
967
        # check some invariants
968
        assert from_cls != Protocol
1✔
969
        assert isinstance(obj, Object), obj
1✔
970

971
        if not obj.as1:
1✔
972
            error('No object data provided')
×
973

974
        id = None
1✔
975
        if obj.key and obj.key.id():
1✔
976
            id = obj.key.id()
1✔
977

978
        if not id:
1✔
979
            id = obj.as1.get('id')
1✔
980
            obj.key = ndb.Key(Object, id)
1✔
981

982
        if not id:
1✔
983
            error('No id provided')
×
984
        elif from_cls.owns_id(id) is False:
1✔
985
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
986
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
987
            error(f'Activity {id} is blocklisted')
1✔
988
        # check that this activity is public. only do this for some activities,
989
        # not eg likes or follows, since Mastodon doesn't currently mark those
990
        # as explicitly public.
991
        elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
992
                  and not as1.is_public(obj.as1, unlisted=False)
993
                  and not as1.is_dm(obj.as1)):
994
              logger.info('Dropping non-public activity')
1✔
995
              return ('OK', 200)
1✔
996

997
        # lease this object, atomically
998
        memcache_key = activity_id_memcache_key(id)
1✔
999
        leased = memcache.memcache.add(
1✔
1000
            memcache_key, 'leased', noreply=False,
1001
            expire=int(MEMCACHE_LEASE_EXPIRATION.total_seconds()))
1002

1003
        # short circuit if we've already seen this activity id.
1004
        # (don't do this for bare objects since we need to check further down
1005
        # whether they've been updated since we saw them last.)
1006
        if (obj.as1.get('objectType') == 'activity'
1✔
1007
            and 'force' not in request.values
1008
            and (not leased
1009
                 or (obj.new is False and obj.changed is False))):
1010
            error(f'Already seen this activity {id}', status=204)
1✔
1011

1012
        pruned = {k: v for k, v in obj.as1.items()
1✔
1013
                  if k not in ('contentMap', 'replies', 'signature')}
1014
        delay = ''
1✔
1015
        if (received_at and request.headers.get('X-AppEngine-TaskRetryCount') == '0'
1✔
1016
                and obj.type != 'delete'):  # we delay deletes for 2m
1017
            delay_s = int((util.now().replace(tzinfo=None)
×
1018
                           - received_at.replace(tzinfo=None)
1019
                           ).total_seconds())
1020
            delay = f'({delay_s} s behind)'
×
1021
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
1022

1023
        # does this protocol support this activity/object type?
1024
        from_cls.check_supported(obj)
1✔
1025

1026
        # check authorization
1027
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1028
        actor = as1.get_owner(obj.as1)
1✔
1029
        if not actor:
1✔
1030
            error('Activity missing actor or author')
1✔
1031
        elif from_cls.owns_id(actor) is False:
1✔
1032
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
1033

1034
        assert authed_as
1✔
1035
        assert isinstance(authed_as, str)
1✔
1036
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
1037
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
1038
        if actor != authed_as:
1✔
1039
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
1040
                         user=f'{id} authed_as {authed_as} owner {actor}')
1041
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
1042

1043
        # update copy ids to originals
1044
        obj.normalize_ids()
1✔
1045
        obj.resolve_ids()
1✔
1046

1047
        if (obj.type == 'follow'
1✔
1048
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
1049
            # follows of bot user; refresh user profile first
1050
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
1051
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1052
            from_user.reload_profile()
1✔
1053
        else:
1054
            # load actor user
1055
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
1056

1057
        if not internal and (not from_user or from_user.manual_opt_out):
1✔
1058
            error(f"Couldn't load actor {actor}", status=204)
1✔
1059

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

1064
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1065
        inner_obj_id = inner_obj_as1.get('id')
1✔
1066
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1067
            if not inner_obj_id:
1✔
1068
                error(f'{obj.type} object has no id!')
1✔
1069

1070
        # check age. we support backdated posts, but if they're over 2w old, we
1071
        # don't deliver them
1072
        if obj.type == 'post':
1✔
1073
            if published := inner_obj_as1.get('published'):
1✔
1074
                try:
1✔
1075
                    published_dt = util.parse_iso8601(published)
1✔
1076
                    if not published_dt.tzinfo:
1✔
1077
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
1078
                    age = util.now() - published_dt
1✔
1079
                    if age > CREATE_MAX_AGE and 'force' not in request.values:
1✔
1080
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
1081
                              status=204)
1082
                except ValueError:  # from parse_iso8601
×
1083
                    logger.debug(f"Couldn't parse published {published}")
×
1084

1085
        # write Object to datastore
1086
        obj.source_protocol = from_cls.LABEL
1✔
1087
        if obj.type in STORE_AS1_TYPES:
1✔
1088
            obj.put()
1✔
1089

1090
        # store inner object
1091
        # TODO: unify with big obj.type conditional below. would have to merge
1092
        # this with the DM handling block lower down.
1093
        crud_obj = None
1✔
1094
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1095
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
1096
                                            source_protocol=from_cls.LABEL,
1097
                                            authed_as=actor, users=[from_user.key],
1098
                                            deleted=False)
1099

1100
        actor = as1.get_object(obj.as1, 'actor')
1✔
1101
        actor_id = actor.get('id')
1✔
1102

1103
        # handle activity!
1104
        if obj.type == 'stop-following':
1✔
1105
            # TODO: unify with handle_follow?
1106
            # TODO: handle multiple followees
1107
            if not actor_id or not inner_obj_id:
1✔
1108
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1109

1110
            # deactivate Follower
1111
            from_ = from_cls.key_for(actor_id)
1✔
1112
            to_cls = Protocol.for_id(inner_obj_id)
1✔
1113
            to = to_cls.key_for(inner_obj_id)
1✔
1114
            follower = Follower.query(Follower.to == to,
1✔
1115
                                      Follower.from_ == from_,
1116
                                      Follower.status == 'active').get()
1117
            if follower:
1✔
1118
                logger.info(f'Marking {follower} inactive')
1✔
1119
                follower.status = 'inactive'
1✔
1120
                follower.put()
1✔
1121
            else:
1122
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1123

1124
            # fall through to deliver to followee
1125
            # TODO: do we convert stop-following to webmention 410 of original
1126
            # follow?
1127

1128
            # fall through to deliver to followers
1129

1130
        elif obj.type in ('delete', 'undo'):
1✔
1131
            delete_obj_id = (from_user.profile_id()
1✔
1132
                            if inner_obj_id == from_user.key.id()
1133
                            else inner_obj_id)
1134

1135
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1136
            if not delete_obj:
1✔
1137
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1138
                return 'OK', 204
1✔
1139

1140
            # TODO: just delete altogether!
1141
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1142
            delete_obj.deleted = True
1✔
1143
            delete_obj.put()
1✔
1144

1145
            # if this is an actor, handle deleting it later so that
1146
            # in case it's from_user, user.enabled_protocols is still populated
1147
            #
1148
            # fall through to deliver to followers and delete copy if necessary.
1149
            # should happen via protocol-specific copy target and send of
1150
            # delete activity.
1151
            # https://github.com/snarfed/bridgy-fed/issues/63
1152

1153
        elif obj.type == 'block':
1✔
1154
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1155
                # blocking protocol bot user disables that protocol
1156
                from_user.delete(proto)
1✔
1157
                from_user.disable_protocol(proto)
1✔
1158
                return 'OK', 200
1✔
1159

1160
        elif obj.type == 'post':
1✔
1161
            # handle DMs to bot users
1162
            if as1.is_dm(obj.as1):
1✔
1163
                return dms.receive(from_user=from_user, obj=obj)
1✔
1164

1165
        # fetch actor if necessary
1166
        if (actor and actor.keys() == set(['id'])
1✔
1167
                and obj.type not in ('delete', 'undo')):
1168
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1169
            actor_obj = from_cls.load(ids.profile_id(id=actor['id'], proto=from_cls),
1✔
1170
                                      raise_=False)
1171
            if actor_obj and actor_obj.as1:
1✔
1172
                obj.our_as1 = {
1✔
1173
                    **obj.as1, 'actor': {
1174
                        **actor_obj.as1,
1175
                        # override profile id with actor id
1176
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1177
                        'id': actor['id'],
1178
                    }
1179
                }
1180

1181
        # fetch object if necessary
1182
        if (obj.type in ('post', 'update', 'share')
1✔
1183
                and inner_obj_as1.keys() == set(['id'])
1184
                and from_cls.owns_id(inner_obj_id)):
1185
            logger.debug('Fetching inner object')
1✔
1186
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1187
                                      remote=(obj.type in ('post', 'update')))
1188
            if obj.type in ('post', 'update'):
1✔
1189
                crud_obj = inner_obj
1✔
1190
            if inner_obj and inner_obj.as1:
1✔
1191
                obj.our_as1 = {
1✔
1192
                    **obj.as1,
1193
                    'object': {
1194
                        **inner_obj_as1,
1195
                        **inner_obj.as1,
1196
                    }
1197
                }
1198
            elif obj.type in ('post', 'update'):
1✔
1199
                error("Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1200

1201
        if obj.type == 'follow':
1✔
1202
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1203
                # follow of one of our protocol bot users; enable that protocol.
1204
                # fall through so that we send an accept.
1205
                try:
1✔
1206
                    from_user.enable_protocol(proto)
1✔
1207
                except ErrorButDoNotRetryTask:
1✔
1208
                    from web import Web
1✔
1209
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1210
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1211
                                               followee=bot, follow=obj)
1212
                    raise
1✔
1213
                proto.bot_follow(from_user)
1✔
1214

1215
            from_cls.handle_follow(obj)
1✔
1216

1217
        # deliver to targets
1218
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1219

1220
        # if this is a user, deactivate its followers/followings
1221
        # https://github.com/snarfed/bridgy-fed/issues/1304
1222
        if obj.type == 'delete':
1✔
1223
            if user_key := from_cls.key_for(id=inner_obj_id):
1✔
1224
                if user := user_key.get():
1✔
1225
                    for proto in user.enabled_protocols:
1✔
1226
                        user.disable_protocol(PROTOCOLS[proto])
1✔
1227

1228
                    logger.info(f'Deactivating Followers from or to {user_key.id()}')
1✔
1229
                    followers = Follower.query(
1✔
1230
                        OR(Follower.to == user_key, Follower.from_ == user_key)
1231
                        ).fetch()
1232
                    for f in followers:
1✔
1233
                        f.status = 'inactive'
1✔
1234
                    ndb.put_multi(followers)
1✔
1235

1236
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1237
        return resp
1✔
1238

1239
    @classmethod
1✔
1240
    def handle_follow(from_cls, obj):
1✔
1241
        """Handles an incoming follow activity.
1242

1243
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1244
        happens in :meth:`deliver`.
1245

1246
        Args:
1247
          obj (models.Object): follow activity
1248
        """
1249
        logger.debug('Got follow. Loading users, storing Follow(s), sending accept(s)')
1✔
1250

1251
        # Prepare follower (from) users' data
1252
        # TODO: remove all of this and just use from_user
1253
        from_as1 = as1.get_object(obj.as1, 'actor')
1✔
1254
        from_id = from_as1.get('id')
1✔
1255
        if not from_id:
1✔
1256
            error(f'Follow activity requires actor. Got: {obj.as1}')
×
1257

1258
        from_obj = from_cls.load(from_id, raise_=False)
1✔
1259
        if not from_obj:
1✔
1260
            error(f"Couldn't load {from_id}", status=502)
×
1261

1262
        if not from_obj.as1:
1✔
1263
            from_obj.our_as1 = from_as1
1✔
1264
            from_obj.put()
1✔
1265

1266
        from_key = from_cls.key_for(from_id)
1✔
1267
        if not from_key:
1✔
1268
            error(f'Invalid {from_cls.LABEL} user key: {from_id}')
×
1269
        obj.users = [from_key]
1✔
1270
        from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
1✔
1271

1272
        # Prepare followee (to) users' data
1273
        to_as1s = as1.get_objects(obj.as1)
1✔
1274
        if not to_as1s:
1✔
1275
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1276

1277
        # Store Followers
1278
        for to_as1 in to_as1s:
1✔
1279
            to_id = to_as1.get('id')
1✔
1280
            if not to_id:
1✔
1281
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1282

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

1285
            to_cls = Protocol.for_id(to_id)
1✔
1286
            if not to_cls:
1✔
1287
                error(f"Couldn't determine protocol for {to_id}")
×
1288
            elif from_cls == to_cls:
1✔
1289
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1290
                continue
1✔
1291

1292
            to_obj = to_cls.load(to_id)
1✔
1293
            if to_obj and not to_obj.as1:
1✔
1294
                to_obj.our_as1 = to_as1
1✔
1295
                to_obj.put()
1✔
1296

1297
            to_key = to_cls.key_for(to_id)
1✔
1298
            if not to_key:
1✔
1299
                logger.info(f'Skipping invalid {from_cls.LABEL} user key: {from_id}')
×
1300
                continue
×
1301

1302
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj,
1✔
1303
                                           allow_opt_out=True)
1304
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1305
                                                  follow=obj.key, status='active')
1306
            obj.add('notify', to_key)
1✔
1307
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1308
                                       followee=to_user, follow=obj)
1309

1310
    @classmethod
1✔
1311
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1312
        """Sends an accept or reject activity for a follow.
1313

1314
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1315
        nothing.
1316

1317
        Args:
1318
          verb (str): ``accept`` or  ``reject``
1319
          follower (models.User)
1320
          followee (models.User)
1321
          follow (models.Object)
1322
        """
1323
        assert verb in ('accept', 'reject')
1✔
1324
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1325
            return
1✔
1326

1327
        target = follower.target_for(follower.obj)
1✔
1328
        if not target:
1✔
1329
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1330

1331
        # send. note that this is one response for the whole follow, even if it
1332
        # has multiple followees!
1333
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1334
        accept = {
1✔
1335
            'id': id,
1336
            'objectType': 'activity',
1337
            'verb': verb,
1338
            'actor': followee.key.id(),
1339
            'object': follow.as1,
1340
        }
1341
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1342
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1343

1344
    @classmethod
1✔
1345
    def bot_follow(bot_cls, user):
1✔
1346
        """Follow a user from a protocol bot user.
1347

1348
        ...so that the protocol starts sending us their activities, if it needs
1349
        a follow for that (eg ActivityPub).
1350

1351
        Args:
1352
          user (User)
1353
        """
1354
        from web import Web
1✔
1355
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1356
        now = util.now().isoformat()
1✔
1357
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1358

1359
        if not user.obj:
1✔
1360
            logger.info("  can't follow, user has no profile obj")
1✔
1361
            return
1✔
1362

1363
        target = user.target_for(user.obj)
1✔
1364
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1365
        follow_back_as1 = {
1✔
1366
            'objectType': 'activity',
1367
            'verb': 'follow',
1368
            'id': follow_back_id,
1369
            'actor': bot.key.id(),
1370
            'object': user.key.id(),
1371
        }
1372
        common.create_task(queue='send', id=follow_back_id,
1✔
1373
                           our_as1=follow_back_as1, url=target,
1374
                           source_protocol='web', protocol=user.LABEL,
1375
                           user=bot.key.urlsafe())
1376

1377
    @classmethod
1✔
1378
    def handle_bare_object(cls, obj, authed_as=None):
1✔
1379
        """If obj is a bare object, wraps it in a create or update activity.
1380

1381
        Checks if we've seen it before.
1382

1383
        Args:
1384
          obj (models.Object)
1385
          authed_as (str): authenticated actor id who sent this activity
1386

1387
        Returns:
1388
          models.Object: ``obj`` if it's an activity, otherwise a new object
1389
        """
1390
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1391
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1392
            return obj
1✔
1393

1394
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1395
        now = util.now().isoformat()
1✔
1396

1397
        # occasionally we override the object, eg if this is a profile object
1398
        # coming in via a user with use_instead set
1399
        obj_as1 = obj.as1
1✔
1400
        if obj_id := obj.key.id():
1✔
1401
            if obj_as1_id := obj_as1.get('id'):
1✔
1402
                if obj_id != obj_as1_id:
1✔
1403
                    logger.info(f'Overriding AS1 object id {obj_as1_id} with Object id {obj_id}')
1✔
1404
                    obj_as1['id'] = obj_id
1✔
1405

1406
        # this is a raw post; wrap it in a create or update activity
1407
        if obj.changed or is_actor:
1✔
1408
            if obj.changed:
1✔
1409
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1410
            else:
1411
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1412
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1413
            update_as1 = {
1✔
1414
                'objectType': 'activity',
1415
                'verb': 'update',
1416
                'id': id,
1417
                'actor': obj_actor,
1418
                'object': {
1419
                    # Mastodon requires the updated field for Updates, so
1420
                    # add a default value.
1421
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1422
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1423
                    # https://github.com/mastodon/documentation/pull/1150
1424
                    'updated': now,
1425
                    **obj_as1,
1426
                },
1427
            }
1428
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1429
            return Object(id=id, our_as1=update_as1,
1✔
1430
                          source_protocol=obj.source_protocol)
1431

1432
        if (obj.new
1✔
1433
                # HACK: force query param here is specific to webmention
1434
                or 'force' in request.form):
1435
            create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1436
            create_as1 = {
1✔
1437
                'objectType': 'activity',
1438
                'verb': 'post',
1439
                'id': create_id,
1440
                'actor': obj_actor,
1441
                'object': obj_as1,
1442
                'published': now,
1443
            }
1444
            logger.info(f'Wrapping in post')
1✔
1445
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1446
            return Object(id=create_id, our_as1=create_as1,
1✔
1447
                          source_protocol=obj.source_protocol)
1448

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

1451
    @classmethod
1✔
1452
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1453
        """Delivers an activity to its external recipients.
1454

1455
        Args:
1456
          obj (models.Object): activity to deliver
1457
          from_user (models.User): user (actor) this activity is from
1458
          crud_obj (models.Object): if this is a create, update, or delete/undo
1459
            activity, the inner object that's being written, otherwise None.
1460
            (This object's ``notify`` and ``feed`` properties may be updated.)
1461
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1462
            targets on this protocol
1463

1464
        Returns:
1465
          (str, int) tuple: Flask response
1466
        """
1467
        if to_proto:
1✔
1468
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1469

1470
        # find delivery targets. maps Target to Object or None
1471
        #
1472
        # ...then write the relevant object, since targets() has a side effect of
1473
        # setting the notify and feed properties (and dirty attribute)
1474
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1475
        if to_proto:
1✔
1476
            targets = {t: obj for t, obj in targets.items()
1✔
1477
                       if t.protocol == to_proto.LABEL}
1478
        if not targets:
1✔
1479
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1480

1481
        # store object that targets() updated
1482
        if crud_obj and crud_obj.dirty:
1✔
1483
            crud_obj.put()
1✔
1484
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1485
            obj.put()
1✔
1486

1487
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1488
                      else obj.to_request())
1489

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

1493
        # enqueue send task for each targets
1494
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1495
        user = from_user.key.urlsafe()
1✔
1496
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1497
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1498
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1499
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1500

1501
        return 'OK', 202
1✔
1502

1503
    @classmethod
1✔
1504
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1505
        """Collects the targets to send a :class:`models.Object` to.
1506

1507
        Targets are both objects - original posts, events, etc - and actors.
1508

1509
        Args:
1510
          obj (models.Object)
1511
          from_user (User)
1512
          crud_obj (models.Object): if this is a create, update, or delete/undo
1513
            activity, the inner object that's being written, otherwise None.
1514
            (This object's ``notify`` and ``feed`` properties may be updated.)
1515
          internal (bool): whether this is a recursive internal call
1516

1517
        Returns:
1518
          dict: maps :class:`models.Target` to original (in response to)
1519
          :class:`models.Object`, if any, otherwise None
1520
        """
1521
        logger.debug('Finding recipients and their targets')
1✔
1522

1523
        # we should only have crud_obj iff this is a create or update
1524
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1525
        write_obj = crud_obj or obj
1✔
1526
        write_obj.dirty = False
1✔
1527

1528
        target_uris = as1.targets(obj.as1)
1✔
1529
        orig_obj = None
1✔
1530
        targets = {}  # maps Target to Object or None
1✔
1531
        owner = as1.get_owner(obj.as1)
1✔
1532
        allow_opt_out = (obj.type == 'delete')
1✔
1533
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1534
        inner_obj_id = inner_obj_as1.get('id')
1✔
1535
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1536
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1537
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1538
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1539
        is_self_reply = False
1✔
1540

1541
        original_ids = []
1✔
1542
        if is_reply:
1✔
1543
            original_ids = in_reply_tos
1✔
1544
        elif inner_obj_id:
1✔
1545
            if inner_obj_id == from_user.key.id():
1✔
1546
                inner_obj_id = from_user.profile_id()
1✔
1547
            original_ids = [inner_obj_id]
1✔
1548

1549
        original_objs = {}
1✔
1550
        for id in original_ids:
1✔
1551
            if proto := Protocol.for_id(id):
1✔
1552
                original_objs[id] = proto.load(id, raise_=False)
1✔
1553

1554
        # for AP, add in-reply-tos' mentions
1555
        # https://github.com/snarfed/bridgy-fed/issues/1608
1556
        # https://github.com/snarfed/bridgy-fed/issues/1218
1557
        orig_post_mentions = {}  # maps mentioned id to original post Object
1✔
1558
        for id in in_reply_tos:
1✔
1559
            if ((in_reply_to_obj := original_objs.get(id))
1✔
1560
                    and (proto := PROTOCOLS.get(in_reply_to_obj.source_protocol))
1561
                    and proto.SEND_REPLIES_TO_ORIG_POSTS_MENTIONS
1562
                    and (mentions := as1.mentions(in_reply_to_obj.as1))):
1563
                logger.info(f"Adding in-reply-to {id} 's mentions to targets: {mentions}")
1✔
1564
                target_uris.extend(mentions)
1✔
1565
                for mention in mentions:
1✔
1566
                    orig_post_mentions[mention] = in_reply_to_obj
1✔
1567

1568
        target_uris = sorted(set(target_uris))
1✔
1569
        logger.info(f'Raw targets: {target_uris}')
1✔
1570

1571
        # which protocols should we allow delivering to?
1572
        to_protocols = []
1✔
1573
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1574
                      + from_user.enabled_protocols):
1575
            if not (proto := PROTOCOLS.get(label)):
1✔
1576
                report_error(f'unknown enabled protocol {label} for {from_user.key.id()}')
1✔
1577
                continue
1✔
1578

1579
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1580
                                     or is_reply):
1581
                origs_could_bridge = None
1✔
1582

1583
                for id in original_ids:
1✔
1584
                    if not (orig := original_objs.get(id)):
1✔
1585
                        continue
1✔
1586
                    elif isinstance(orig, proto):
1✔
1587
                        logger.info(f'Allowing {label} for original post {id}')
×
1588
                        break
×
1589
                    elif orig.get_copy(proto):
1✔
1590
                        logger.info(f'Allowing {label}, original post {id} was bridged there')
1✔
1591
                        break
1✔
1592

1593
                    if (origs_could_bridge is not False
1✔
1594
                            and (orig_author_id := as1.get_owner(orig.as1))
1595
                            and (orig_proto := PROTOCOLS.get(orig.source_protocol))
1596
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1597
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1598

1599
                else:
1600
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1601
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
1✔
1602
                            and origs_could_bridge):
1603
                        # retry later; original obj may still be bridging
1604
                        # TODO: limit to brief window, eg no older than 2h? 1d?
1605
                        error(msg, status=304)
1✔
1606

1607
                    logger.info(msg)
1✔
1608
                    continue
1✔
1609

1610

1611
            util.add(to_protocols, proto)
1✔
1612

1613
        # process direct targets
1614
        for target_id in target_uris:
1✔
1615
            target_proto = Protocol.for_id(target_id)
1✔
1616
            if not target_proto:
1✔
1617
                logger.info(f"Can't determine protocol for {target_id}")
1✔
1618
                continue
1✔
1619
            elif target_proto.is_blocklisted(target_id):
1✔
1620
                logger.debug(f'{target_id} is blocklisted')
1✔
1621
                continue
1✔
1622

1623
            orig_obj = target_proto.load(target_id, raise_=False)
1✔
1624
            if not orig_obj or not orig_obj.as1:
1✔
1625
                logger.info(f"Couldn't load {target_id}")
1✔
1626
                continue
1✔
1627

1628
            target_author_key = (target_proto(id=target_id).key
1✔
1629
                                 if target_id in mentioned_urls
1630
                                 else target_proto.actor_key(orig_obj))
1631
            if not from_user.is_enabled(target_proto):
1✔
1632
                # if author isn't bridged and target user is, DM a prompt and
1633
                # add a notif for the target user
1634
                if (target_id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
1635
                        and target_author_key):
1636
                    if target_author := target_author_key.get():
1✔
1637
                        if target_author.is_enabled(from_cls):
1✔
1638
                            notifications.add_notification(target_author, write_obj)
1✔
1639
                            verb, noun = (
1✔
1640
                                ('replied to', 'replies') if target_id in in_reply_tos
1641
                                else ('quoted', 'quotes') if target_id in quoted_posts
1642
                                else ('mentioned', 'mentions'))
1643
                            dms.maybe_send(
1✔
1644
                                from_proto=target_proto, to_user=from_user,
1645
                                type='replied_to_bridged_user', text=f"""\
1646
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.""")
1647

1648
                continue
1✔
1649

1650
            # deliver self-replies to followers
1651
            # https://github.com/snarfed/bridgy-fed/issues/639
1652
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1653
                is_self_reply = True
1✔
1654
                logger.info(f'self reply!')
1✔
1655

1656
            # also add copies' targets
1657
            for copy in orig_obj.copies:
1✔
1658
                proto = PROTOCOLS[copy.protocol]
1✔
1659
                if proto in to_protocols:
1✔
1660
                    # copies generally won't have their own Objects
1661
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1662
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {target_id}')
1✔
1663
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1664

1665
            if target_proto == from_cls:
1✔
1666
                logger.debug(f'Skipping same-protocol target {target_id}')
1✔
1667
                continue
1✔
1668

1669
            target = target_proto.target_for(orig_obj)
1✔
1670
            if not target:
1✔
1671
                # TODO: surface errors like this somehow?
1672
                logger.error(f"Can't find delivery target for {target_id}")
×
UNCOV
1673
                continue
×
1674

1675
            logger.debug(f'Target for {target_id} is {target}')
1✔
1676
            # only use orig_obj for inReplyTos, like/repost objects, reply's original
1677
            # post's mentions, etc
1678
            # https://github.com/snarfed/bridgy-fed/issues/1237
1679
            target_obj = None
1✔
1680
            if target_id in in_reply_tos + as1.get_ids(obj.as1, 'object'):
1✔
1681
                target_obj = orig_obj
1✔
1682
            elif target_id in orig_post_mentions:
1✔
1683
                target_obj = orig_post_mentions[target_id]
1✔
1684
            targets[Target(protocol=target_proto.LABEL, uri=target)] = target_obj
1✔
1685

1686
            if target_author_key:
1✔
1687
                logger.debug(f'Recipient is {target_author_key}')
1✔
1688
                if write_obj.add('notify', target_author_key):
1✔
1689
                    write_obj.dirty = True
1✔
1690

1691
        if obj.type == 'undo':
1✔
1692
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1693
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1694
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1695
            else:
1696
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1697
            if inner_obj:
1✔
1698
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1699
                                                internal=True))
1700

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

1703
        # deliver to followers, if appropriate
1704
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1705
        if not user_key:
1✔
1706
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1707
            return targets
1✔
1708

1709
        followers = []
1✔
1710
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1711
                and (not is_reply or is_self_reply)):
1712
            logger.info(f'Delivering to followers of {user_key}')
1✔
1713
            followers = []
1✔
1714
            for f in Follower.query(Follower.to == user_key,
1✔
1715
                                    Follower.status == 'active'):
1716
                proto = PROTOCOLS_BY_KIND[f.from_.kind()]
1✔
1717
                # skip protocol bot users
1718
                if (not Protocol.for_bridgy_subdomain(f.from_.id())
1✔
1719
                        # skip protocols this user hasn't enabled, or where the base
1720
                        # object of this activity hasn't been bridged
1721
                        and proto in to_protocols
1722
                        # we deliver to HAS_COPIES protocols separately, below. we
1723
                        # assume they have follower-independent targets.
1724
                        and not (proto.HAS_COPIES and proto.DEFAULT_TARGET)):
1725
                    followers.append(f)
1✔
1726

1727
            user_keys = [f.from_ for f in followers]
1✔
1728
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1729
            User.load_multi(users)
1✔
1730

1731
            if (not followers and
1✔
1732
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
1733
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
1734
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1735
                return {}
1✔
1736

1737
            # add to followers' feeds, if any
1738
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1739
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1740
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
1✔
1741
                    if write_obj.feed:
1✔
1742
                        write_obj.dirty = True
1✔
1743

1744
            # collect targets for followers
1745
            for user in users:
1✔
1746
                # TODO: should we pass remote=False through here to Protocol.load?
1747
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1748
                if not target:
1✔
1749
                    # logger.error(f'Follower {user.key} has no delivery target')
1750
                    continue
1✔
1751

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

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

1760
        # deliver to enabled HAS_COPIES protocols proactively
1761
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
1762
            for proto in to_protocols:
1✔
1763
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
1764
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
1765
                    targets.setdefault(
1✔
1766
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1767

1768
        # de-dupe targets, discard same-domain
1769
        # maps string target URL to (Target, Object) tuple
1770
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1771
        # maps Target to Object or None
1772
        targets = {}
1✔
1773
        source_domains = [
1✔
1774
            util.domain_from_link(url) for url in
1775
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1776
            if util.is_web(url)
1777
        ]
1778
        for url in sorted(util.dedupe_urls(
1✔
1779
                candidates.keys(),
1780
                # preserve our PDS URL without trailing slash in path
1781
                # https://atproto.com/specs/did#did-documents
1782
                trailing_slash=False)):
1783
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
1784
                logger.info(f'Skipping same-domain target {url}')
×
UNCOV
1785
                continue
×
1786
            target, obj = candidates[url]
1✔
1787
            targets[target] = obj
1✔
1788

1789
        return targets
1✔
1790

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

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

1798
        Args:
1799
          id (str)
1800
          remote (bool): whether to fetch the object over the network. If True,
1801
            fetches even if we already have the object stored, and updates our
1802
            stored copy. If False and we don't have the object stored, returns
1803
            None. Default (None) means to fetch over the network only if we
1804
            don't already have it stored.
1805
          local (bool): whether to load from the datastore before
1806
            fetching over the network. If False, still stores back to the
1807
            datastore after a successful remote fetch.
1808
          raise_ (bool): if False, catches any :class:`request.RequestException`
1809
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1810
            ``None`` instead
1811
          kwargs: passed through to :meth:`fetch()`
1812

1813
        Returns:
1814
          models.Object: loaded object, or None if it isn't fetchable, eg a
1815
          non-URL string for Web, or ``remote`` is False and it isn't in the
1816
          datastore
1817

1818
        Raises:
1819
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1820
            is True
1821
        """
1822
        assert id
1✔
1823
        assert local or remote is not False
1✔
1824
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1825

1826
        obj = orig_as1 = None
1✔
1827
        if local:
1✔
1828
            obj = Object.get_by_id(id)
1✔
1829
            if not obj:
1✔
1830
                # logger.debug(f' {id} not in datastore')
1831
                pass
1✔
1832
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1833
                # logger.debug(f'  {id} got from datastore')
1834
                obj.new = False
1✔
1835

1836
        if remote is False:
1✔
1837
            return obj
1✔
1838
        elif remote is None and obj:
1✔
1839
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1840
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1841
                pass
1✔
1842
            else:
1843
                return obj
1✔
1844

1845
        if obj:
1✔
1846
            orig_as1 = obj.as1
1✔
1847
            obj.our_as1 = None
1✔
1848
            obj.new = False
1✔
1849
        else:
1850
            obj = Object(id=id)
1✔
1851
            if local:
1✔
1852
                # logger.debug(f'  {id} not in datastore')
1853
                obj.new = True
1✔
1854
                obj.changed = False
1✔
1855

1856
        try:
1✔
1857
            fetched = cls.fetch(obj, **kwargs)
1✔
1858
        except (RequestException, HTTPException) as e:
1✔
1859
            if raise_:
1✔
1860
                raise
1✔
1861
            util.interpret_http_exception(e)
1✔
1862
            return None
1✔
1863

1864
        if not fetched:
1✔
1865
            return None
1✔
1866

1867
        # https://stackoverflow.com/a/3042250/186123
1868
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1869
        if size > models.MAX_ENTITY_SIZE:
1✔
1870
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1871
            return None
1✔
1872

1873
        obj.resolve_ids()
1✔
1874
        obj.normalize_ids()
1✔
1875

1876
        if obj.new is False:
1✔
1877
            obj.changed = obj.activity_changed(orig_as1)
1✔
1878

1879
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1880
            if obj.source_protocol:
1✔
UNCOV
1881
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1882
            obj.source_protocol = cls.LABEL
1✔
1883

1884
        obj.put()
1✔
1885
        return obj
1✔
1886

1887
    @classmethod
1✔
1888
    def check_supported(cls, obj):
1✔
1889
        """If this protocol doesn't support this object, raises HTTP 204.
1890

1891
        Also reports an error.
1892

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

1897
        Args:
1898
          obj (Object)
1899

1900
        Raises:
1901
          werkzeug.HTTPException: if this protocol doesn't support this object
1902
        """
1903
        if not obj.type:
1✔
UNCOV
1904
            return
×
1905

1906
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1907
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1908
            or (obj.type in as1.CRUD_VERBS
1909
                and inner_type
1910
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1911
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1912

1913
        # don't allow posts with blank content and no image/video/audio
1914
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
1915
                    else obj.as1)
1916
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
1917
                and not util.get_url(crud_obj, key='image')
1918
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
1919
                # TODO: handle articles with displayName but not content
1920
                and not source.html_to_text(crud_obj.get('content')).strip()):
1921
            error('Blank content and no image or video or audio', status=204)
1✔
1922

1923
        # DMs are only allowed to/from protocol bot accounts
1924
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1925
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1926
            if (not cls.SUPPORTS_DMS
1✔
1927
                    or (recip not in protocol_user_ids
1928
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1929
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1930

1931

1932
@cloud_tasks_only(log=None)
1✔
1933
def receive_task():
1✔
1934
    """Task handler for a newly received :class:`models.Object`.
1935

1936
    Calls :meth:`Protocol.receive` with the form parameters.
1937

1938
    Parameters:
1939
      authed_as (str): passed to :meth:`Protocol.receive`
1940
      obj_id (str): key id of :class:`models.Object` to handle
1941
      received_at (str, ISO 8601 timestamp): when we first saw (received)
1942
        this activity
1943
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1944
        :class:`models.Object` to handle
1945

1946
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
1947
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
1948
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
1949
    ``changed``, HTTP request details, etc. See stash for attempt at this for
1950
    :class:`web.Web`.
1951
    """
1952
    common.log_request()
1✔
1953
    form = request.form.to_dict()
1✔
1954

1955
    authed_as = form.pop('authed_as', None)
1✔
1956
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1957
                or authed_as in common.PROTOCOL_DOMAINS)
1958

1959
    obj = Object.from_request()
1✔
1960
    assert obj
1✔
1961
    assert obj.source_protocol
1✔
1962
    obj.new = True
1✔
1963

1964
    if received_at := form.pop('received_at', None):
1✔
1965
        received_at = datetime.fromisoformat(received_at)
1✔
1966

1967
    try:
1✔
1968
        return PROTOCOLS[obj.source_protocol].receive(
1✔
1969
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
1970
    except RequestException as e:
1✔
1971
        util.interpret_http_exception(e)
1✔
1972
        error(e, status=304)
1✔
1973
    except ValueError as e:
1✔
1974
        logger.warning(e, exc_info=True)
×
UNCOV
1975
        error(e, status=304)
×
1976

1977

1978
@cloud_tasks_only(log=None)
1✔
1979
def send_task():
1✔
1980
    """Task handler for sending an activity to a single specific destination.
1981

1982
    Calls :meth:`Protocol.send` with the form parameters.
1983

1984
    Parameters:
1985
      protocol (str): :class:`Protocol` to send to
1986
      url (str): destination URL to send to
1987
      obj_id (str): key id of :class:`models.Object` to send
1988
      orig_obj_id (str): optional, :class:`models.Object` key id of the
1989
        "original object" that this object refers to, eg replies to or reposts
1990
        or likes
1991
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1992
        this activity is from
1993
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1994
        :class:`models.Object` to handle
1995
    """
1996
    common.log_request()
1✔
1997

1998
    # prepare
1999
    form = request.form.to_dict()
1✔
2000
    url = form.get('url')
1✔
2001
    protocol = form.get('protocol')
1✔
2002
    if not url or not protocol:
1✔
2003
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
2004
        return '', 204
1✔
2005

2006
    target = Target(uri=url, protocol=protocol)
1✔
2007
    obj = Object.from_request()
1✔
2008
    assert obj and obj.key and obj.key.id()
1✔
2009

2010
    PROTOCOLS[protocol].check_supported(obj)
1✔
2011
    allow_opt_out = (obj.type == 'delete')
1✔
2012

2013
    user = None
1✔
2014
    if user_key := form.get('user'):
1✔
2015
        key = ndb.Key(urlsafe=user_key)
1✔
2016
        # use get_by_id so that we follow use_instead
2017
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2018
            key.id(), allow_opt_out=allow_opt_out)
2019

2020
    # send
2021
    delay = ''
1✔
2022
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
2023
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
2024
        delay = f'({delay_s} s behind)'
1✔
2025
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
2026
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
2027
    sent = None
1✔
2028
    try:
1✔
2029
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
2030
                                        orig_obj_id=form.get('orig_obj_id'))
2031
    except BaseException as e:
1✔
2032
        code, body = util.interpret_http_exception(e)
1✔
2033
        if not code and not body:
1✔
2034
            raise
1✔
2035

2036
    if sent is False:
1✔
2037
        logger.info(f'Failed sending!')
1✔
2038

2039
    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