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

snarfed / bridgy-fed / 835b1bcb-0c0a-4b9b-9f27-dd0c5712a07a

04 Sep 2024 03:59AM UTC coverage: 89.861%. Remained the same
835b1bcb-0c0a-4b9b-9f27-dd0c5712a07a

push

circleci

snarfed
drop router down to 2 cores

it's hanging out at 25-35% on 4 cores these days, almost always under 50%. will see if I need to bump this back up once I speed up the Bluesky firehose client.

4006 of 4458 relevant lines covered (89.86%)

0.9 hits per line

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

95.01
/protocol.py
1
"""Base protocol class and common code."""
2
import copy
1✔
3
from datetime import timedelta
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
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
import werkzeug.exceptions
1✔
23
from werkzeug.exceptions import BadGateway, HTTPException
1✔
24

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

54
OBJECT_REFRESH_AGE = timedelta(days=30)
1✔
55

56
# require a follow for users on these domains before we deliver anything from
57
# them other than their profile
58
LIMITED_DOMAINS = (os.getenv('LIMITED_DOMAINS', '').split()
1✔
59
                   or util.load_file_lines('limited_domains'))
60

61
logger = logging.getLogger(__name__)
1✔
62

63

64
def error(*args, status=299, **kwargs):
1✔
65
    """Default HTTP status code to 299 to prevent retrying task."""
66
    return common.error(*args, status=status, **kwargs)
1✔
67

68

69
class ErrorButDoNotRetryTask(HTTPException):
1✔
70
    code = 299
1✔
71
    description = 'ErrorButDoNotRetryTask'
1✔
72

73
# https://github.com/pallets/flask/issues/1837#issuecomment-304996942
74
werkzeug.exceptions.default_exceptions.setdefault(299, ErrorButDoNotRetryTask)
1✔
75
werkzeug.exceptions._aborter.mapping.setdefault(299, ErrorButDoNotRetryTask)
1✔
76

77

78
def activity_id_memcache_key(id):
1✔
79
    return common.memcache_key(f'receive-{id}')
1✔
80

81

82
class Protocol:
1✔
83
    """Base protocol class. Not to be instantiated; classmethods only.
84

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

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

129
    @classmethod
1✔
130
    @property
1✔
131
    def LABEL(cls):
1✔
132
        return cls.__name__.lower()
1✔
133

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

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

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

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

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

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

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

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

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

178
        To be implemented by subclasses.
179

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

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

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

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

194
        Args:
195
          id (str)
196

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

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

206
        To be implemented by subclasses.
207

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

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

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

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

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

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

235
        To be implemented by subclasses.
236

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

240
        Args:
241
          handle (str)
242

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

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

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

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

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

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

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

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

279
    @cached(LRUCache(20000), lock=Lock())
1✔
280
    @staticmethod
1✔
281
    def for_id(id, remote=True):
1✔
282
        """Returns the protocol for a given id.
283

284
        Args:
285
          id (str)
286
          remote (bool): whether to perform expensive side effects like fetching
287
            the id itself over the network, or other discovery.
288

289
        Returns:
290
          Protocol subclass: matching protocol, or None if no single known
291
          protocol definitively owns this id
292
        """
293
        logger.debug(f'Determining protocol for id {id}')
1✔
294
        if not id:
1✔
295
            return None
1✔
296

297
        if util.is_web(id):
1✔
298
            # step 1: check for our per-protocol subdomains
299
            try:
1✔
300
                is_homepage = urlparse(id).path.strip('/') == ''
1✔
301
            except ValueError as e:
1✔
302
                logger.info(f'urlparse ValueError: {e}')
1✔
303
                return None
1✔
304

305
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
306
            if by_subdomain and not is_homepage and id not in BOT_ACTOR_AP_IDS:
1✔
307
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
308
                return by_subdomain
1✔
309

310
        # step 2: check if any Protocols say conclusively that they own it
311
        # sort to be deterministic
312
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
313
                           key=lambda p: p.LABEL)
314
        candidates = []
1✔
315
        for protocol in protocols:
1✔
316
            owns = protocol.owns_id(id)
1✔
317
            if owns:
1✔
318
                logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
319
                return protocol
1✔
320
            elif owns is not False:
1✔
321
                candidates.append(protocol)
1✔
322

323
        if len(candidates) == 1:
1✔
324
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
325
            return candidates[0]
1✔
326

327
        # step 3: look for existing Objects in the datastore
328
        obj = Protocol.load(id, remote=False)
1✔
329
        if obj and obj.source_protocol:
1✔
330
            logger.debug(f'  {obj.key.id()} owned by source_protocol {obj.source_protocol}')
1✔
331
            return PROTOCOLS[obj.source_protocol]
1✔
332

333
        # step 4: fetch over the network, if necessary
334
        if not remote:
1✔
335
            return None
1✔
336

337
        for protocol in candidates:
1✔
338
            logger.debug(f'Trying {protocol.LABEL}')
1✔
339
            try:
1✔
340
                if protocol.load(id, local=False, remote=True):
1✔
341
                    logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
342
                    return protocol
1✔
343
            except BadGateway:
1✔
344
                # we tried and failed fetching the id over the network.
345
                # this depends on ActivityPub.fetch raising this!
346
                return None
1✔
347
            except HTTPException as e:
×
348
                # internal error we generated ourselves; try next protocol
349
                pass
×
350
            except Exception as e:
×
351
                code, _ = util.interpret_http_exception(e)
×
352
                if code:
×
353
                    # we tried and failed fetching the id over the network
354
                    return None
×
355
                raise
×
356

357
        logger.info(f'No matching protocol found for {id} !')
1✔
358
        return None
1✔
359

360
    @cached(LRUCache(20000), lock=Lock())
1✔
361
    @staticmethod
1✔
362
    def for_handle(handle):
1✔
363
        """Returns the protocol for a given handle.
364

365
        May incur expensive side effects like resolving the handle itself over
366
        the network or other discovery.
367

368
        Args:
369
          handle (str)
370

371
        Returns:
372
          (Protocol subclass, str) tuple: matching protocol and optional id (if
373
          resolved), or ``(None, None)`` if no known protocol owns this handle
374
        """
375
        # TODO: normalize, eg convert domains to lower case
376
        logger.debug(f'Determining protocol for handle {handle}')
1✔
377
        if not handle:
1✔
378
            return (None, None)
1✔
379

380
        # step 1: check if any Protocols say conclusively that they own it.
381
        # sort to be deterministic.
382
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
383
                           key=lambda p: p.LABEL)
384
        candidates = []
1✔
385
        for proto in protocols:
1✔
386
            owns = proto.owns_handle(handle)
1✔
387
            if owns:
1✔
388
                logger.debug(f'  {proto.LABEL} owns handle {handle}')
1✔
389
                return (proto, None)
1✔
390
            elif owns is not False:
1✔
391
                candidates.append(proto)
1✔
392

393
        if len(candidates) == 1:
1✔
394
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
395
            return (candidates[0], None)
×
396

397
        # step 2: look for matching User in the datastore
398
        for proto in candidates:
1✔
399
            user = proto.query(proto.handle == handle).get()
1✔
400
            if user:
1✔
401
                if user.status:
1✔
402
                    return (None, None)
1✔
403
                logger.debug(f'  user {user.key} handle {handle}')
1✔
404
                return (proto, user.key.id())
1✔
405

406
        # step 3: resolve handle to id
407
        for proto in candidates:
1✔
408
            id = proto.handle_to_id(handle)
1✔
409
            if id:
1✔
410
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
411
                return (proto, id)
1✔
412

413
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
414
        return (None, None)
1✔
415

416
    @classmethod
1✔
417
    def bridged_web_url_for(cls, user):
1✔
418
        """Returns the web URL for a user's bridged profile in this protocol.
419

420
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
421
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
422

423
        Args:
424
          user (models.User)
425

426
        Returns:
427
          str, or None if there isn't a canonical URL
428
        """
429
        return None
1✔
430

431
    @classmethod
1✔
432
    def actor_key(cls, obj):
1✔
433
        """Returns the :class:`User`: key for a given object's author or actor.
434

435
        Args:
436
          obj (models.Object)
437

438
        Returns:
439
          google.cloud.ndb.key.Key or None:
440
        """
441
        owner = as1.get_owner(obj.as1)
1✔
442
        if owner:
1✔
443
            return cls.key_for(owner)
1✔
444

445
    @classmethod
1✔
446
    def bot_user_id(cls):
1✔
447
        """Returns the Web user id for the bot user for this protocol.
448

449
        For example, ``'bsky.brid.gy'`` for ATProto.
450

451
        Returns:
452
          str:
453
        """
454
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
455

456
    @classmethod
1✔
457
    def create_for(cls, user):
1✔
458
        """Creates a copy user in this protocol.
459

460
        Should add the copy user to :attr:`copies`.
461

462
        Args:
463
          user (models.User): original source user. Shouldn't already have a
464
            copy user for this protocol in :attr:`copies`.
465

466
        Raises:
467
          ValueError: if we can't create a copy of the given user in this protocol
468
        """
469
        raise NotImplementedError()
×
470

471
    @classmethod
1✔
472
    def send(to_cls, obj, url, from_user=None, orig_obj=None):
1✔
473
        """Sends an outgoing activity.
474

475
        To be implemented by subclasses.
476

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

481
        Args:
482
          obj (models.Object): with activity to send
483
          url (str): destination URL to send to
484
          from_user (models.User): user (actor) this activity is from
485
          orig_obj (models.Object): the "original object" that this object
486
            refers to, eg replies to or reposts or likes
487

488
        Returns:
489
          bool: True if the activity is sent successfully, False if it is
490
          ignored or otherwise unsent due to protocol logic, eg no webmention
491
          endpoint, protocol doesn't support the activity type. (Failures are
492
          raised as exceptions.)
493

494
        Raises:
495
          werkzeug.HTTPException if the request fails
496
        """
497
        raise NotImplementedError()
×
498

499
    @classmethod
1✔
500
    def fetch(cls, obj, **kwargs):
1✔
501
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
502

503
        Errors are raised as exceptions. If this method returns False, the fetch
504
        didn't fail but didn't succeed either, eg the id isn't valid for this
505
        protocol, or the fetch didn't return valid data for this protocol.
506

507
        To be implemented by subclasses.
508

509
        Args:
510
          obj (models.Object): with the id to fetch. Data is filled into one of
511
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
512
          kwargs: subclass-specific
513

514
        Returns:
515
          bool: True if the object was fetched and populated successfully,
516
          False otherwise
517

518
        Raises:
519
          requests.RequestException or werkzeug.HTTPException: if the fetch fails
520
        """
521
        raise NotImplementedError()
×
522

523
    @classmethod
1✔
524
    def convert(cls, obj, from_user=None, **kwargs):
1✔
525
        """Converts an :class:`Object` to this protocol's data format.
526

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

530
        Just passes through to :meth:`_convert`, then does minor
531
        protocol-independent postprocessing.
532

533
        Args:
534
          obj (models.Object):
535
          from_user (models.User): user (actor) this activity/object is from
536
          kwargs: protocol-specific, passed through to :meth:`_convert`
537

538
        Returns:
539
          converted object in the protocol's native format, often a dict
540
        """
541
        if not obj or not obj.as1:
1✔
542
            return {}
1✔
543

544
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
545
        is_activity = obj.as1.get('verb') in ('post', 'update')
1✔
546
        base_obj = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
547
        orig_our_as1 = obj.our_as1
1✔
548

549
        # mark bridged actors as bots and add "bridged by Bridgy Fed" to their bios
550
        if (from_user and base_obj
1✔
551
            and base_obj.get('objectType') in as1.ACTOR_TYPES
552
            and PROTOCOLS.get(obj.source_protocol) != cls
553
            and Protocol.for_bridgy_subdomain(id) not in DOMAINS
554
            # Web users are special cased, they don't get the label if they've
555
            # explicitly enabled Bridgy Fed with redirects or webmentions
556
            and not (from_user.LABEL == 'web'
557
                     and (from_user.last_webmention_in or from_user.has_redirects))):
558

559
            obj.our_as1 = copy.deepcopy(obj.as1)
1✔
560
            actor = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
561
            actor['objectType'] = 'application'
1✔
562
            cls.add_source_links(actor=actor, obj=obj, from_user=from_user)
1✔
563

564
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
565
        obj.our_as1 = orig_our_as1
1✔
566
        return converted
1✔
567

568
    @classmethod
1✔
569
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
570
        """Converts an :class:`Object` to this protocol's data format.
571

572
        To be implemented by subclasses. Implementations should generally call
573
        :meth:`Protocol.translate_ids` (as their own class) before converting to
574
        their format.
575

576
        Args:
577
          obj (models.Object):
578
          from_user (models.User): user (actor) this activity/object is from
579
          kwargs: protocol-specific
580

581
        Returns:
582
          converted object in the protocol's native format, often a dict. May
583
            return the ``{}`` empty dict if the object can't be converted.
584
        """
585
        raise NotImplementedError()
×
586

587
    @classmethod
1✔
588
    def add_source_links(cls, actor, obj, from_user):
1✔
589
        """Adds "bridged from ... by Bridgy Fed" HTML to ``actor['summary']``.
590

591
        Default implementation; subclasses may override.
592

593
        Args:
594
          actor (dict): AS1 actor
595
          obj (models.Object):
596
          from_user (models.User): user (actor) this activity/object is from
597
        """
598
        assert from_user
1✔
599
        summary = actor.setdefault('summary', '')
1✔
600
        if 'Bridgy Fed]' in html_to_text(summary, ignore_links=True):
1✔
601
            return
1✔
602

603
        id = actor.get('id')
1✔
604
        proto_phrase = (PROTOCOLS[obj.source_protocol].PHRASE
1✔
605
                        if obj.source_protocol else '')
606
        if proto_phrase:
1✔
607
            proto_phrase = f' on {proto_phrase}'
1✔
608

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

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

617
        if summary:
1✔
618
            summary += '<br><br>'
1✔
619
        actor['summary'] = summary + source_links
1✔
620

621
    @classmethod
1✔
622
    def target_for(cls, obj, shared=False):
1✔
623
        """Returns an :class:`Object`'s delivery target (endpoint).
624

625
        To be implemented by subclasses.
626

627
        Examples:
628

629
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
630
          webmention target.
631
        * If obj is an ``activitypub`` actor, returns its inbox.
632
        * If obj is an ``activitypub`` object, returns it's author's or actor's
633
          inbox.
634

635
        Args:
636
          obj (models.Object):
637
          shared (bool): optional. If True, returns a common/shared
638
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
639
            multiple recipients for efficiency
640

641
        Returns:
642
          str: target endpoint, or None if not available.
643
        """
644
        raise NotImplementedError()
×
645

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

650
        Default implementation here, subclasses may override.
651

652
        Args:
653
          url (str):
654
          allow_internal (bool): whether to return False for internal domains
655
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
656
        """
657
        blocklist = DOMAIN_BLOCKLIST
1✔
658
        if not allow_internal:
1✔
659
            blocklist += DOMAINS
1✔
660
        return util.domain_or_parent_in(util.domain_from_link(url), blocklist)
1✔
661

662
    @classmethod
1✔
663
    def translate_ids(to_cls, obj):
1✔
664
        """Translates all ids in an AS1 object to a specific protocol.
665

666
        Infers source protocol for each id value separately.
667

668
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
669
        ``at://did:plc:abc/coll/123`` will be converted to
670
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
671

672
        Wraps these AS1 fields:
673

674
        * ``id``
675
        * ``actor``
676
        * ``author``
677
        * ``object``
678
        * ``object.actor``
679
        * ``object.author``
680
        * ``object.id``
681
        * ``object.inReplyTo``
682
        * ``attachments[].id``
683
        * ``tags[objectType=mention].url``
684

685
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
686
        same logic is duplicated there!
687

688
        TODO: unify with :meth:`Object.resolve_ids`,
689
        :meth:`models.Object.normalize_ids`.
690

691
        Args:
692
          to_proto (Protocol subclass)
693
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
694

695
        Returns:
696
          dict: wrapped AS1 version of ``obj``
697
        """
698
        assert to_cls != Protocol
1✔
699
        if not obj:
1✔
700
            return obj
1✔
701

702
        outer_obj = copy.deepcopy(obj)
1✔
703
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
704

705
        def translate(elem, field, fn):
1✔
706
            elem[field] = as1.get_objects(elem, field)
1✔
707
            for obj in elem[field]:
1✔
708
                if id := obj.get('id'):
1✔
709
                    from_cls = Protocol.for_id(id)
1✔
710
                    # TODO: what if from_cls is None? relax translate_object_id,
711
                    # make it a noop if we don't know enough about from/to?
712
                    if from_cls and from_cls != to_cls:
1✔
713
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
714

715
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
716
                           for o in elem[field]]
717

718
            if len(elem[field]) == 1:
1✔
719
                elem[field] = elem[field][0]
1✔
720

721
        type = as1.object_type(outer_obj)
1✔
722
        translate(outer_obj, 'id',
1✔
723
                  translate_user_id if type in as1.ACTOR_TYPES
724
                  else translate_object_id)
725

726
        for o in inner_objs:
1✔
727
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
728
                        or as1.get_owner(outer_obj) == o.get('id')
729
                        or type in ('follow', 'stop-following'))
730
            translate(o, 'id', translate_user_id if is_actor else translate_object_id)
1✔
731

732
        for o in [outer_obj] + inner_objs:
1✔
733
            translate(o, 'inReplyTo', translate_object_id)
1✔
734
            for field in 'actor', 'author':
1✔
735
                translate(o, field, translate_user_id)
1✔
736
            for tag in as1.get_objects(o, 'tags'):
1✔
737
                if tag.get('objectType') == 'mention':
1✔
738
                    translate(tag, 'url', translate_user_id)
1✔
739
            for att in as1.get_objects(o, 'attachments'):
1✔
740
                translate(att, 'id', translate_object_id)
1✔
741
                url = att.get('url')
1✔
742
                if url and not att.get('id'):
1✔
743
                    if from_cls := Protocol.for_id(url):
1✔
744
                        att['id'] = translate_object_id(from_=from_cls, to=to_cls,
1✔
745
                                                        id=url)
746

747
        outer_obj = util.trim_nulls(outer_obj)
1✔
748

749
        if objs := outer_obj.get('object', []):
1✔
750
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
751
            if len(outer_obj['object']) == 1:
1✔
752
                outer_obj['object'] = outer_obj['object'][0]
1✔
753

754
        return outer_obj
1✔
755

756
    @classmethod
1✔
757
    def receive(from_cls, obj, authed_as=None, internal=False):
1✔
758
        """Handles an incoming activity.
759

760
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
761
        unset, returns HTTP 299.
762

763
        Args:
764
          obj (models.Object)
765
          authed_as (str): authenticated actor id who sent this activity
766
          internal (bool): whether to allow activity ids on internal domains,
767
            from opted out/blocked users, etc.
768

769
        Returns:
770
          (str, int) tuple: (response body, HTTP status code) Flask response
771

772
        Raises:
773
          werkzeug.HTTPException: if the request is invalid
774
        """
775
        # check some invariants
776
        assert from_cls != Protocol
1✔
777
        assert isinstance(obj, Object), obj
1✔
778

779
        if not obj.as1:
1✔
780
            error('No object data provided')
×
781

782
        id = None
1✔
783
        if obj.key and obj.key.id():
1✔
784
            id = obj.key.id()
1✔
785

786
        if not id:
1✔
787
            id = obj.as1.get('id')
1✔
788
            obj.key = ndb.Key(Object, id)
1✔
789

790
        if not id:
1✔
791
            error('No id provided')
×
792
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
793
            error(f'Activity {id} is blocklisted')
1✔
794
        # check that this activity is public. only do this for some activities,
795
        # not eg likes or follows, since Mastodon doesn't currently mark those
796
        # as explicitly public.
797
        elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
798
                  and not as1.is_public(obj.as1, unlisted=False)
799
                  and not as1.is_dm(obj.as1)):
800
              logger.info('Dropping non-public activity')
1✔
801
              return ('OK', 200)
1✔
802

803
        # lease this object, atomically
804
        memcache_key = activity_id_memcache_key(id)
1✔
805
        leased = common.memcache.add(memcache_key, 'leased', noreply=False,
1✔
806
                                     expire=5 * 60)  # 5 min
807
        # short circuit if we've already seen this activity id.
808
        # (don't do this for bare objects since we need to check further down
809
        # whether they've been updated since we saw them last.)
810
        if (obj.as1.get('objectType') == 'activity'
1✔
811
            and 'force' not in request.values
812
            and (not leased
813
                 or (obj.new is False and obj.changed is False)
814
                 # TODO: how does this make sense? won't these two lines
815
                 # always be true?!
816
                 or (obj.new is None and obj.changed is None
817
                     and from_cls.load(id, remote=False)))):
818
            error(f'Already seen this activity {id}', status=204)
1✔
819

820
        pruned = {k: v for k, v in obj.as1.items()
1✔
821
                  if k not in ('contentMap', 'replies', 'signature')}
822
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} AS1: {json_dumps(pruned, indent=2)}')
1✔
823

824
        # does this protocol support this activity/object type?
825
        from_cls.check_supported(obj)
1✔
826

827
        # load actor user, check authorization
828
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
829
        actor = as1.get_owner(obj.as1)
1✔
830
        if not actor:
1✔
831
            error('Activity missing actor or author')
1✔
832
        elif from_cls.owns_id(actor) is False:
1✔
833
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
834

835
        assert authed_as
1✔
836
        assert isinstance(authed_as, str)
1✔
837
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
838
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
839
        if actor != authed_as:
1✔
840
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
841
                         user=f'{id} authed_as {authed_as} owner {actor}')
842
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
843

844
        # update copy ids to originals
845
        obj.normalize_ids()
1✔
846
        obj.resolve_ids()
1✔
847

848
        if (obj.type == 'follow'
1✔
849
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
850
            # follows of bot user; refresh user profile first
851
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
852
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
853
            from_user.obj = from_cls.load(from_user.profile_id(), remote=True)
1✔
854
        else:
855
            # load actor user
856
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
857

858
        if not internal and (not from_user or from_user.manual_opt_out):
1✔
859
            error(f'Actor {actor} is opted out or blocked', status=204)
1✔
860

861
        # write Object to datastore
862
        orig = obj
1✔
863
        obj = Object.get_or_create(id, authed_as=actor, **orig.to_dict())
1✔
864
        if orig.new is not None:
1✔
865
            obj.new = orig.new
1✔
866
        if orig.changed is not None:
1✔
867
            obj.changed = orig.changed
1✔
868

869
        # if this is a post, ie not an activity, wrap it in a create or update
870
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as)
1✔
871
        obj.add('users', from_user.key)
1✔
872

873
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
874
        if obj.type in as1.CRUD_VERBS:
1✔
875
            if inner_owner := as1.get_owner(inner_obj_as1):
1✔
876
                if inner_owner_key := from_cls.key_for(inner_owner):
1✔
877
                    obj.add('users', inner_owner_key)
1✔
878

879
        obj.source_protocol = from_cls.LABEL
1✔
880
        obj.put()
1✔
881

882
        # store inner object
883
        inner_obj_id = inner_obj_as1.get('id')
1✔
884
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
885
            Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
886
                                 source_protocol=from_cls.LABEL, authed_as=actor)
887

888
        actor = as1.get_object(obj.as1, 'actor')
1✔
889
        actor_id = actor.get('id')
1✔
890

891
        # handle activity!
892
        if obj.type == 'stop-following':
1✔
893
            # TODO: unify with handle_follow?
894
            # TODO: handle multiple followees
895
            if not actor_id or not inner_obj_id:
1✔
896
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
897

898
            # deactivate Follower
899
            from_ = from_cls.key_for(actor_id)
1✔
900
            to_cls = Protocol.for_id(inner_obj_id)
1✔
901
            to = to_cls.key_for(inner_obj_id)
1✔
902
            follower = Follower.query(Follower.to == to,
1✔
903
                                      Follower.from_ == from_,
904
                                      Follower.status == 'active').get()
905
            if follower:
1✔
906
                logger.info(f'Marking {follower} inactive')
1✔
907
                follower.status = 'inactive'
1✔
908
                follower.put()
1✔
909
            else:
910
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
911

912
            # fall through to deliver to followee
913
            # TODO: do we convert stop-following to webmention 410 of original
914
            # follow?
915

916
        elif obj.type in ('update', 'like', 'share'):  # require object
1✔
917
            if not inner_obj_id:
1✔
918
                error("Couldn't find id of object to update")
1✔
919

920
            # fall through to deliver to followers
921

922
        elif obj.type in ('delete', 'undo'):
1✔
923
            if not inner_obj_id:
1✔
924
                error("Couldn't find id of object to delete")
×
925

926
            logger.info(f'Marking Object {inner_obj_id} deleted')
1✔
927
            Object.get_or_create(inner_obj_id, deleted=True, authed_as=authed_as)
1✔
928

929
            # if this is an actor, deactivate its followers/followings
930
            # https://github.com/snarfed/bridgy-fed/issues/63
931
            deleted_user = from_cls.key_for(id=inner_obj_id)
1✔
932
            if deleted_user:
1✔
933
                logger.info(f'Deactivating Followers from or to = {inner_obj_id}')
1✔
934
                followers = Follower.query(OR(Follower.to == deleted_user,
1✔
935
                                              Follower.from_ == deleted_user)
936
                                           ).fetch()
937
                for f in followers:
1✔
938
                    f.status = 'inactive'
1✔
939
                ndb.put_multi(followers)
1✔
940

941
            # fall through to deliver to followers
942

943
        elif obj.type == 'block':
1✔
944
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
945
                # blocking protocol bot user disables that protocol
946
                proto.delete_user_copy(from_user)
1✔
947
                from_user.disable_protocol(proto)
1✔
948
                return 'OK', 200
1✔
949

950
        elif obj.type == 'post':
1✔
951
            # handle DMs to bot users
952
            if as1.is_dm(obj.as1):
1✔
953
                return dms.receive(from_user=from_user, obj=obj)
1✔
954

955
        # fetch actor if necessary
956
        if (actor and actor.keys() == set(['id'])
1✔
957
                and obj.type not in ('delete', 'undo')):
958
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
959
            actor_obj = from_cls.load(actor['id'])
1✔
960
            if actor_obj and actor_obj.as1:
1✔
961
                obj.our_as1 = {**obj.as1, 'actor': actor_obj.as1}
1✔
962

963
        # fetch object if necessary so we can render it in feeds
964
        if (obj.type == 'share'
1✔
965
                and inner_obj_as1.keys() == set(['id'])
966
                and from_cls.owns_id(inner_obj_id)):
967
            logger.debug('Fetching object so we can render it in feeds')
1✔
968
            inner_obj = from_cls.load(inner_obj_id)
1✔
969
            if inner_obj and inner_obj.as1:
1✔
970
                obj.our_as1 = {
1✔
971
                    **obj.as1,
972
                    'object': {
973
                        **inner_obj_as1,
974
                        **inner_obj.as1,
975
                    }
976
                }
977

978
        if obj.type == 'follow':
1✔
979
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
980
                # follow of one of our protocol bot users; enable that protocol.
981
                # foll through so that we send an accept.
982
                from_user.enable_protocol(proto)
1✔
983
                proto.bot_follow(from_user)
1✔
984

985
            from_cls.handle_follow(obj)
1✔
986

987
        # deliver to targets
988
        resp = from_cls.deliver(obj, from_user=from_user)
1✔
989
        common.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
990
        return resp
1✔
991

992
    @classmethod
1✔
993
    def handle_follow(from_cls, obj):
1✔
994
        """Handles an incoming follow activity.
995

996
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
997
        happens in :meth:`deliver`.
998

999
        Args:
1000
          obj (models.Object): follow activity
1001
        """
1002
        logger.debug('Got follow. Loading users, storing Follow(s), sending accept(s)')
1✔
1003

1004
        # Prepare follower (from) users' data
1005
        from_as1 = as1.get_object(obj.as1, 'actor')
1✔
1006
        from_id = from_as1.get('id')
1✔
1007
        if not from_id:
1✔
1008
            error(f'Follow activity requires actor. Got: {obj.as1}')
×
1009

1010
        from_obj = from_cls.load(from_id)
1✔
1011
        if not from_obj:
1✔
1012
            error(f"Couldn't load {from_id}", status=502)
×
1013

1014
        if not from_obj.as1:
1✔
1015
            from_obj.our_as1 = from_as1
1✔
1016
            from_obj.put()
1✔
1017

1018
        from_key = from_cls.key_for(from_id)
1✔
1019
        if not from_key:
1✔
1020
            error(f'Invalid {from_cls} user key: {from_id}')
×
1021
        obj.users = [from_key]
1✔
1022
        from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
1✔
1023

1024
        # Prepare followee (to) users' data
1025
        to_as1s = as1.get_objects(obj.as1)
1✔
1026
        if not to_as1s:
1✔
1027
            error(f'Follow activity requires object(s). Got: {obj.as1}')
1✔
1028

1029
        # Store Followers
1030
        for to_as1 in to_as1s:
1✔
1031
            to_id = to_as1.get('id')
1✔
1032
            if not to_id:
1✔
1033
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1034

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

1037
            to_cls = Protocol.for_id(to_id)
1✔
1038
            if not to_cls:
1✔
1039
                error(f"Couldn't determine protocol for {to_id}")
1✔
1040
            elif from_cls == to_cls and from_cls.LABEL != 'fake':
1✔
1041
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1042
                continue
1✔
1043

1044
            to_obj = to_cls.load(to_id)
1✔
1045
            if to_obj and not to_obj.as1:
1✔
1046
                to_obj.our_as1 = to_as1
1✔
1047
                to_obj.put()
1✔
1048

1049
            to_key = to_cls.key_for(to_id)
1✔
1050
            if not to_key:
1✔
1051
                logger.info(f'Skipping invalid {from_cls} user key: {from_id}')
×
1052
                continue
×
1053

1054
            # If followee user is already direct, follower may not know they're
1055
            # interacting with a bridge. if followee user is indirect though,
1056
            # follower should know, so they're direct.
1057
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj, direct=False)
1✔
1058
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1059
                                                  follow=obj.key, status='active')
1060
            obj.add('notify', to_key)
1✔
1061
            from_cls.maybe_accept_follow(follower=from_user, followee=to_user,
1✔
1062
                                         follow=obj)
1063

1064
    @classmethod
1✔
1065
    def maybe_accept_follow(_, follower, followee, follow):
1✔
1066
        """Sends an accept activity for a follow.
1067

1068
        ...if the follower protocol handles accepts. Otherwise, does nothing.
1069

1070
        Args:
1071
          follower: :class:`models.User`
1072
          followee: :class:`models.User`
1073
          follow: :class:`models.Object`
1074
        """
1075
        if 'accept' not in follower.SUPPORTED_AS1_TYPES:
1✔
1076
            return
1✔
1077

1078
        target = follower.target_for(follower.obj)
1✔
1079
        if not target:
1✔
1080
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1081

1082
        # send accept. note that this is one accept for the whole
1083
        # follow, even if it has multiple followees!
1084
        id = f'{followee.key.id()}/followers#accept-{follow.key.id()}'
1✔
1085
        undelivered = [Target(protocol=follower.LABEL, uri=target)]
1✔
1086
        accept = {
1✔
1087
            'id': id,
1088
            'objectType': 'activity',
1089
            'verb': 'accept',
1090
            'actor': followee.key.id(),
1091
            'object': follow.as1,
1092
        }
1093
        obj = Object.get_or_create(id, authed_as=followee.key.id(),
1✔
1094
                                      undelivered=undelivered, our_as1=accept)
1095

1096
        common.create_task(queue='send', obj=obj.key.urlsafe(),
1✔
1097
                           url=target, protocol=follower.LABEL,
1098
                           user=followee.key.urlsafe())
1099

1100
    @classmethod
1✔
1101
    def bot_follow(bot_cls, user):
1✔
1102
        """Follow a user from a protocol bot user.
1103

1104
        ...so that the protocol starts sending us their activities, if it needs
1105
        a follow for that (eg ActivityPub).
1106

1107
        Args:
1108
          user (User)
1109
        """
1110
        from web import Web
1✔
1111
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1112
        now = util.now().isoformat()
1✔
1113
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1114

1115
        if not user.obj:
1✔
1116
            logger.info("  can't follow, user has no profile obj")
1✔
1117
            return
1✔
1118

1119
        target = user.target_for(user.obj)
1✔
1120
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1121
        follow_back = Object(id=follow_back_id, source_protocol='web',
1✔
1122
                             undelivered=[Target(protocol=user.LABEL, uri=target)],
1123
                             our_as1={
1124
            'objectType': 'activity',
1125
            'verb': 'follow',
1126
            'id': follow_back_id,
1127
            'actor': bot.key.id(),
1128
            'object': user.key.id(),
1129
        }).put()
1130

1131
        common.create_task(queue='send', obj=follow_back.urlsafe(),
1✔
1132
                           url=target, protocol=user.LABEL,
1133
                           user=bot.key.urlsafe())
1134

1135
    @classmethod
1✔
1136
    def delete_user_copy(copy_cls, user):
1✔
1137
        """Deletes a user's copy actor in a given protocol.
1138

1139
        Args:
1140
          user (User)
1141
        """
1142
        now = util.now().isoformat()
1✔
1143
        delete_id = f'{ids.profile_id(id=user.key.id(), proto=user)}#delete-copy-{copy_cls.LABEL}-{now}'
1✔
1144
        delete = Object(id=delete_id, source_protocol=user.LABEL, our_as1={
1✔
1145
            'id': delete_id,
1146
            'objectType': 'activity',
1147
            'verb': 'delete',
1148
            'actor': user.key.id(),
1149
            'object': user.key.id(),
1150
        })
1151
        delete.put()
1✔
1152
        user.deliver(delete, from_user=user, to_proto=copy_cls)
1✔
1153

1154
    @classmethod
1✔
1155
    def handle_bare_object(cls, obj, authed_as=None):
1✔
1156
        """If obj is a bare object, wraps it in a create or update activity.
1157

1158
        Checks if we've seen it before.
1159

1160
        Args:
1161
          obj (models.Object)
1162
          authed_as (str): authenticated actor id who sent this activity
1163

1164
        Returns:
1165
          models.Object: ``obj`` if it's an activity, otherwise a new object
1166
        """
1167
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1168
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1169
            return obj
1✔
1170

1171
        obj_actor = as1.get_owner(obj.as1)
1✔
1172
        now = util.now().isoformat()
1✔
1173

1174
        # this is a raw post; wrap it in a create or update activity
1175
        if obj.changed or is_actor:
1✔
1176
            if obj.changed:
1✔
1177
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1178
            else:
1179
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1180
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1181
            update_as1 = {
1✔
1182
                'objectType': 'activity',
1183
                'verb': 'update',
1184
                'id': id,
1185
                'actor': obj_actor,
1186
                'object': {
1187
                    # Mastodon requires the updated field for Updates, so
1188
                    # add a default value.
1189
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1190
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1191
                    # https://github.com/mastodon/documentation/pull/1150
1192
                    'updated': now,
1193
                    **obj.as1,
1194
                },
1195
            }
1196
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1197
            return Object(id=id, our_as1=update_as1,
1✔
1198
                          source_protocol=obj.source_protocol)
1199

1200
        create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1201
        create = cls.load(create_id, remote=False)
1✔
1202
        if (obj.new or not create or create.status != 'complete'
1✔
1203
                # HACK: force query param here is specific to webmention
1204
                or 'force' in request.form):
1205
            if create:
1✔
1206
                logger.info(f'Existing create {create.key.id()} status {create.status}')
1✔
1207
            else:
1208
                logger.info(f'No existing create activity')
1✔
1209
            create_as1 = {
1✔
1210
                'objectType': 'activity',
1211
                'verb': 'post',
1212
                'id': create_id,
1213
                'actor': obj_actor,
1214
                'object': obj.as1,
1215
                'published': now,
1216
            }
1217
            logger.info(f'Wrapping in post')
1✔
1218
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1219
            return Object.get_or_create(create_id, our_as1=create_as1,
1✔
1220
                                        source_protocol=obj.source_protocol,
1221
                                        authed_as=authed_as)
1222

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

1225
    @classmethod
1✔
1226
    def deliver(from_cls, obj, from_user, to_proto=None):
1✔
1227
        """Delivers an activity to its external recipients.
1228

1229
        Args:
1230
          obj (models.Object): activity to deliver
1231
          from_user (models.User): user (actor) this activity is from
1232
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1233
            targets on this protocol
1234

1235
        Returns:
1236
          (str, int) tuple: Flask response
1237
        """
1238
        if to_proto:
1✔
1239
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1240

1241
        # find delivery targets. maps Target to Object or None
1242
        targets = from_cls.targets(obj, from_user=from_user)
1✔
1243

1244
        if not targets:
1✔
1245
            obj.status = 'ignored'
1✔
1246
            obj.put()
1✔
1247
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1248

1249
        # sort targets so order is deterministic for tests, debugging, etc
1250
        sorted_targets = sorted(targets.items(), key=lambda t: t[0].uri)
1✔
1251
        obj.populate(
1✔
1252
            status='in progress',
1253
            delivered=[],
1254
            failed=[],
1255
            undelivered=[t for t, _ in sorted_targets],
1256
        )
1257
        obj.put()
1✔
1258
        logger.info(f'Delivering to: {obj.undelivered}')
1✔
1259

1260
        # enqueue send task for each targets
1261
        user = from_user.key.urlsafe()
1✔
1262
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1263
            if to_proto and target.protocol != to_proto.LABEL:
1✔
1264
                continue
×
1265
            orig_obj = orig_obj.key.urlsafe() if orig_obj else ''
1✔
1266
            common.create_task(queue='send', obj=obj.key.urlsafe(),
1✔
1267
                               url=target.uri, protocol=target.protocol,
1268
                               orig_obj=orig_obj, user=user)
1269

1270
        return 'OK', 202
1✔
1271

1272
    @classmethod
1✔
1273
    def targets(from_cls, obj, from_user, internal=False):
1✔
1274
        """Collects the targets to send a :class:`models.Object` to.
1275

1276
        Targets are both objects - original posts, events, etc - and actors.
1277

1278
        Args:
1279
          obj (models.Object)
1280
          from_user (User)
1281
          internal (bool): whether this is a recursive internal call
1282

1283
        Returns:
1284
          dict: maps :class:`models.Target` to original (in response to)
1285
          :class:`models.Object`, if any, otherwise None
1286
        """
1287
        logger.info('Finding recipients and their targets')
1✔
1288

1289
        target_uris = sorted(set(as1.targets(obj.as1)))
1✔
1290
        logger.info(f'Raw targets: {target_uris}')
1✔
1291
        orig_obj = None
1✔
1292
        targets = {}  # maps Target to Object or None
1✔
1293
        owner = as1.get_owner(obj.as1)
1✔
1294

1295
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1296
        inner_obj_id = inner_obj_as1.get('id')
1✔
1297
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1298
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1299
        is_self_reply = False
1✔
1300

1301
        if is_reply:
1✔
1302
            original_ids = in_reply_tos
1✔
1303
        else:
1304
            if inner_obj_id == from_user.key.id():
1✔
1305
                inner_obj_id = from_user.profile_id()
1✔
1306
            original_ids = [inner_obj_id]
1✔
1307

1308
        # which protocols should we allow delivering to?
1309
        to_protocols = []
1✔
1310
        if DEBUG and from_user.LABEL != 'eefake':  # for unit tests
1✔
1311
            to_protocols += [PROTOCOLS['fake'], PROTOCOLS['other']]
1✔
1312
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1313
                      + from_user.enabled_protocols):
1314
            proto = PROTOCOLS[label]
1✔
1315
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share')
1✔
1316
                                     or is_reply):
1317
                for id in original_ids:
1✔
1318
                    if orig := from_user.load(id, remote=False):
1✔
1319
                        if orig.get_copy(proto):
1✔
1320
                            logger.info(f'Allowing {proto.LABEL}, original post {id} was bridged there')
1✔
1321
                            break
1✔
1322
                else:
1323
                    logger.info(f"Skipping {proto.LABEL}, original posts {original_ids} weren't bridged there")
1✔
1324
                    continue
1✔
1325

1326
            add(to_protocols, proto)
1✔
1327

1328
        # process direct targets
1329
        for id in sorted(target_uris):
1✔
1330
            target_proto = Protocol.for_id(id)
1✔
1331
            if not target_proto:
1✔
1332
                logger.info(f"Can't determine protocol for {id}")
1✔
1333
                continue
1✔
1334
            elif target_proto.is_blocklisted(id):
1✔
1335
                logger.info(f'{id} is blocklisted')
1✔
1336
                continue
1✔
1337

1338
            orig_obj = target_proto.load(id)
1✔
1339
            if not orig_obj or not orig_obj.as1:
1✔
1340
                logger.info(f"Couldn't load {id}")
1✔
1341
                continue
1✔
1342

1343
            target_author_key = target_proto.actor_key(orig_obj)
1✔
1344
            if (target_proto not in to_protocols
1✔
1345
                  and obj.source_protocol != target_proto.LABEL):
1346
                # if author isn't bridged and inReplyTo author is, DM a prompt
1347
                if id in in_reply_tos:
1✔
1348
                    if target_author := target_author_key.get():
1✔
1349
                        if target_author.is_enabled(from_cls):
1✔
1350
                            dms.maybe_send(
1✔
1351
                                from_proto=target_proto, to_user=from_user,
1352
                                type='replied_to_bridged_user', text=f"""\
1353
Hi! You <a href="{inner_obj_as1.get('url') or inner_obj_id}">recently replied</a> to {orig_obj.actor_link(image=False)}, who's bridged here from {target_proto.PHRASE}. If you want them to see your replies, 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.""")
1354

1355
                continue
1✔
1356

1357
            # deliver self-replies to followers
1358
            # https://github.com/snarfed/bridgy-fed/issues/639
1359
            if id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1360
                is_self_reply = True
1✔
1361
                logger.info(f'self reply!')
1✔
1362

1363
            # also add copies' targets
1364
            for copy in orig_obj.copies:
1✔
1365
                proto = PROTOCOLS[copy.protocol]
1✔
1366
                if proto in to_protocols:
1✔
1367
                    # copies generally won't have their own Objects
1368
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1369
                        logger.info(f'Adding target {target} for copy {copy.uri} of original {id}')
1✔
1370
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1371

1372
            if target_proto == from_cls and from_cls.LABEL != 'fake':
1✔
1373
                logger.info(f'Skipping same-protocol target {id}')
1✔
1374
                continue
1✔
1375

1376
            target = target_proto.target_for(orig_obj)
1✔
1377
            if not target:
1✔
1378
                # TODO: surface errors like this somehow?
1379
                logger.error(f"Can't find delivery target for {id}")
×
1380
                continue
×
1381

1382
            logger.info(f'Target for {id} is {target}')
1✔
1383
            # only use orig_obj for inReplyTos and repost objects
1384
            # https://github.com/snarfed/bridgy-fed/issues/1237
1385
            targets[Target(protocol=target_proto.LABEL, uri=target)] = (
1✔
1386
                orig_obj if id in in_reply_tos or id in as1.get_ids(obj.as1, 'object')
1387
                else None)
1388

1389
            if target_author_key:
1✔
1390
                logger.info(f'Recipient is {target_author_key}')
1✔
1391
                obj.add('notify', target_author_key)
1✔
1392

1393
        if obj.type == 'undo':
1✔
1394
            logger.info('Object is an undo; adding targets for inner object')
1✔
1395
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1396
                inner_obj = from_cls.load(inner_obj_id)
1✔
1397
            else:
1398
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1399
            if inner_obj:
1✔
1400
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1401
                                                internal=True))
1402

1403
        logger.info(f'Direct (and copy) targets: {targets.keys()}')
1✔
1404

1405
        # deliver to followers, if appropriate
1406
        user_key = from_cls.actor_key(obj)
1✔
1407
        if not user_key:
1✔
1408
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1409
            return targets
1✔
1410

1411
        followers = []
1✔
1412
        if (obj.type in ('post', 'update', 'delete', 'share')
1✔
1413
                and (not is_reply or is_self_reply)):
1414
            logger.info(f'Delivering to followers of {user_key}')
1✔
1415
            followers = [
1✔
1416
                f for f in Follower.query(Follower.to == user_key,
1417
                                          Follower.status == 'active')
1418
                # skip protocol bot users
1419
                if not Protocol.for_bridgy_subdomain(f.from_.id())
1420
                # skip protocols this user hasn't enabled, or where the base
1421
                # object of this activity hasn't been bridged
1422
                and PROTOCOLS_BY_KIND[f.from_.kind()] in to_protocols]
1423
            user_keys = [f.from_ for f in followers]
1✔
1424
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1425
            User.load_multi(users)
1✔
1426

1427
            if (not followers and
1✔
1428
                (util.domain_or_parent_in(
1429
                    util.domain_from_link(from_user.key.id()), LIMITED_DOMAINS)
1430
                 or util.domain_or_parent_in(
1431
                     util.domain_from_link(obj.key.id()), LIMITED_DOMAINS))):
1432
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1433
                return {}
1✔
1434

1435
            # which object should we add to followers' feeds, if any
1436
            feed_obj = None
1✔
1437
            if not internal:
1✔
1438
                if obj.type == 'share':
1✔
1439
                    feed_obj = obj
1✔
1440
                else:
1441
                    inner = as1.get_object(obj.as1)
1✔
1442
                    # don't add profile updates to feeds
1443
                    if not (obj.type == 'update'
1✔
1444
                            and inner.get('objectType') in as1.ACTOR_TYPES):
1445
                        inner_id = inner.get('id')
1✔
1446
                        if inner_id:
1✔
1447
                            feed_obj = from_cls.load(inner_id)
1✔
1448

1449
            for user in users:
1✔
1450
                if feed_obj:
1✔
1451
                    feed_obj.add('feed', user.key)
1✔
1452

1453
                # TODO: should we pass remote=False through here to Protocol.load?
1454
                target = (user.target_for(user.obj, shared=True)
1✔
1455
                          if user.obj else None)
1456
                if not target:
1✔
1457
                    # TODO: surface errors like this somehow?
1458
                    logger.error(f'Follower {user.key} has no delivery target')
1✔
1459
                    continue
1✔
1460

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

1466
                # HACK: use last target object from above for reposts, which
1467
                # has its resolved id
1468
                targets[Target(protocol=user.LABEL, uri=target)] = \
1✔
1469
                    orig_obj if obj.type == 'share' else None
1470

1471
            if feed_obj:
1✔
1472
                feed_obj.put()
1✔
1473

1474
        # deliver to enabled HAS_COPIES protocols proactively
1475
        # TODO: abstract for other protocols
1476
        from atproto import ATProto
1✔
1477
        if (ATProto in to_protocols
1✔
1478
                and obj.type in ('post', 'update', 'delete', 'share')):
1479
            logger.info(f'user has ATProto enabled, adding {ATProto.PDS_URL}')
1✔
1480
            targets.setdefault(
1✔
1481
                Target(protocol=ATProto.LABEL, uri=ATProto.PDS_URL), None)
1482

1483
        # de-dupe targets, discard same-domain
1484
        # maps string target URL to (Target, Object) tuple
1485
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1486
        # maps Target to Object or None
1487
        targets = {}
1✔
1488
        source_domains = [
1✔
1489
            util.domain_from_link(url) for url in
1490
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1491
            if util.is_web(url)
1492
        ]
1493
        for url in sorted(util.dedupe_urls(
1✔
1494
                candidates.keys(),
1495
                # preserve our PDS URL without trailing slash in path
1496
                # https://atproto.com/specs/did#did-documents
1497
                trailing_slash=False)):
1498
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
1499
                logger.info(f'Skipping same-domain target {url}')
×
1500
                continue
×
1501
            target, obj = candidates[url]
1✔
1502
            targets[target] = obj
1✔
1503

1504
        return targets
1✔
1505

1506
    @classmethod
1✔
1507
    def load(cls, id, remote=None, local=True, **kwargs):
1✔
1508
        """Loads and returns an Object from memory cache, datastore, or HTTP fetch.
1509

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

1513
        Note that :meth:`Object._post_put_hook` updates the cache.
1514

1515
        Args:
1516
          id (str)
1517
          remote (bool): whether to fetch the object over the network. If True,
1518
            fetches even if we already have the object stored, and updates our
1519
            stored copy. If False and we don't have the object stored, returns
1520
            None. Default (None) means to fetch over the network only if we
1521
            don't already have it stored.
1522
          local (bool): whether to load from the datastore before
1523
            fetching over the network. If False, still stores back to the
1524
            datastore after a successful remote fetch.
1525
          kwargs: passed through to :meth:`fetch()`
1526

1527
        Returns:
1528
          models.Object: loaded object, or None if it isn't fetchable, eg a
1529
          non-URL string for Web, or ``remote`` is False and it isn't in the
1530
          cache or datastore
1531

1532
        Raises:
1533
          requests.HTTPError: anything that :meth:`fetch` raises
1534
        """
1535
        assert id
1✔
1536
        assert local or remote is not False
1✔
1537
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1538

1539
        obj = orig_as1 = None
1✔
1540
        if local and not obj:
1✔
1541
            obj = Object.get_by_id(id)
1✔
1542
            if not obj:
1✔
1543
                # logger.debug(f' not in datastore')
1544
                pass
1✔
1545
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1546
                # logger.debug('  got from datastore')
1547
                obj.new = False
1✔
1548

1549
        if remote is False:
1✔
1550
            return obj
1✔
1551
        elif remote is None and obj:
1✔
1552
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1553
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1554
                pass
1✔
1555
            else:
1556
                return obj
1✔
1557

1558
        if obj:
1✔
1559
            orig_as1 = obj.as1
1✔
1560
            obj.clear()
1✔
1561
            obj.new = False
1✔
1562
        else:
1563
            obj = Object(id=id)
1✔
1564
            if local:
1✔
1565
                # logger.debug('  not in datastore')
1566
                obj.new = True
1✔
1567
                obj.changed = False
1✔
1568

1569
        fetched = cls.fetch(obj, **kwargs)
1✔
1570
        if not fetched:
1✔
1571
            return None
1✔
1572

1573
        # https://stackoverflow.com/a/3042250/186123
1574
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1575
        if size > models.MAX_ENTITY_SIZE:
1✔
1576
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1577
            return None
1✔
1578

1579
        obj.resolve_ids()
1✔
1580
        obj.normalize_ids()
1✔
1581

1582
        if obj.new is False:
1✔
1583
            obj.changed = obj.activity_changed(orig_as1)
1✔
1584

1585
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1586
            if obj.source_protocol:
1✔
1587
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1588
            obj.source_protocol = cls.LABEL
1✔
1589

1590
        obj.put()
1✔
1591
        return obj
1✔
1592

1593
    @classmethod
1✔
1594
    def check_supported(cls, obj):
1✔
1595
        """If this protocol doesn't support this object, return 204.
1596

1597
        Also reports an error.
1598

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

1603
        Args:
1604
          obj (Object)
1605
        """
1606
        if not obj.type:
1✔
1607
            return
×
1608

1609
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1610
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1611
            or (obj.type in as1.CRUD_VERBS
1612
                and inner_type
1613
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1614
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1615

1616
        # DMs are only allowed to/from protocol bot accounts
1617
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1618
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1619
            if (not cls.SUPPORTS_DMS
1✔
1620
                    or (recip not in protocol_user_ids
1621
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1622
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1623

1624

1625
@cloud_tasks_only
1✔
1626
def receive_task():
1✔
1627
    """Task handler for a newly received :class:`models.Object`.
1628

1629
    Calls :meth:`Protocol.receive` with the form parameters.
1630

1631
    Parameters:
1632
      obj (url-safe google.cloud.ndb.key.Key): :class:`models.Object` to handle
1633
      authed_as (str): passed to :meth:`Protocol.receive`
1634

1635
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
1636
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
1637
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
1638
    ``changed``, HTTP request details, etc. See stash for attempt at this for
1639
    :class:`web.Web`.
1640
    """
1641
    form = request.form.to_dict()
1✔
1642
    logger.info(f'Params: {list(form.items())}')
1✔
1643

1644
    obj = ndb.Key(urlsafe=form['obj']).get()
1✔
1645
    assert obj
1✔
1646
    obj.new = True
1✔
1647

1648
    authed_as = form.get('authed_as')
1✔
1649

1650
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1651
                or authed_as in common.PROTOCOL_DOMAINS)
1652
    try:
1✔
1653
        return PROTOCOLS[obj.source_protocol].receive(obj=obj, authed_as=authed_as,
1✔
1654
                                                      internal=internal)
1655
    except ValueError as e:
1✔
1656
        logger.warning(e, exc_info=True)
1✔
1657
        error(e, status=304)
1✔
1658

1659

1660
@cloud_tasks_only
1✔
1661
def send_task():
1✔
1662
    """Task handler for sending an activity to a single specific destination.
1663

1664
    Calls :meth:`Protocol.send` with the form parameters.
1665

1666
    Parameters:
1667
      protocol (str): :class:`Protocol` to send to
1668
      url (str): destination URL to send to
1669
      obj (url-safe google.cloud.ndb.key.Key): :class:`models.Object` to send
1670
      orig_obj (url-safe google.cloud.ndb.key.Key): optional "original object"
1671
        :class:`models.Object` that this object refers to, eg replies to or
1672
        reposts or likes
1673
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1674
        this activity is from
1675
    """
1676
    form = request.form.to_dict()
1✔
1677
    logger.info(f'Params: {list(form.items())}')
1✔
1678

1679
    # prepare
1680
    url = form.get('url')
1✔
1681
    protocol = form.get('protocol')
1✔
1682
    if not url or not protocol:
1✔
1683
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1684
        return '', 204
1✔
1685

1686
    target = Target(uri=url, protocol=protocol)
1✔
1687

1688
    obj = ndb.Key(urlsafe=form['obj']).get()
1✔
1689

1690
    PROTOCOLS[protocol].check_supported(obj)
1✔
1691

1692
    if (target not in obj.undelivered and target not in obj.failed
1✔
1693
            and 'force' not in request.values):
1694
        logger.info(f"{url} not in {obj.key.id()} undelivered or failed, giving up")
×
1695
        return r'¯\_(ツ)_/¯', 204
×
1696

1697
    user = None
1✔
1698
    if user_key := form.get('user'):
1✔
1699
        user = ndb.Key(urlsafe=user_key).get()
1✔
1700
    orig_obj = (ndb.Key(urlsafe=form['orig_obj']).get()
1✔
1701
                if form.get('orig_obj') else None)
1702

1703
    # send
1704
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url}')
1✔
1705
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
1706
    sent = None
1✔
1707
    try:
1✔
1708
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user, orig_obj=orig_obj)
1✔
1709
    except BaseException as e:
1✔
1710
        code, body = util.interpret_http_exception(e)
1✔
1711
        if not code and not body:
1✔
1712
            raise
×
1713

1714
    if sent is False:
1✔
1715
        logger.info(f'Failed sending!')
1✔
1716

1717
    # write results to Object
1718
    #
1719
    # retry aggressively because this has high contention during inbox delivery.
1720
    # (ndb does exponential backoff.)
1721
    # https://console.cloud.google.com/errors/detail/CJm_4sDv9O-iKg;time=P7D?project=bridgy-federated
1722
    @ndb.transactional(retries=10)
1✔
1723
    def update_object(obj_key):
1✔
1724
        obj = obj_key.get()
1✔
1725
        if target in obj.undelivered:
1✔
1726
            obj.remove('undelivered', target)
1✔
1727

1728
        if sent is None:
1✔
1729
            obj.add('failed', target)
1✔
1730
        else:
1731
            if target in obj.failed:
1✔
1732
                obj.remove('failed', target)
×
1733
            if sent:
1✔
1734
                obj.add('delivered', target)
1✔
1735

1736
        if not obj.undelivered:
1✔
1737
            obj.status = ('complete' if obj.delivered
1✔
1738
                          else 'failed' if obj.failed
1739
                          else 'ignored')
1740
        obj.put()
1✔
1741

1742
    update_object(obj.key)
1✔
1743

1744
    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