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

snarfed / bridgy-fed / d51ce335-94be-472f-9ce6-7b21a1b7f3f2

30 Aug 2024 08:29PM UTC coverage: 92.692% (+0.05%) from 92.638%
d51ce335-94be-472f-9ce6-7b21a1b7f3f2

push

circleci

snarfed
atproto_firehose.subscribe: handle commits on unsupported collections without record

fixes https://console.cloud.google.com/errors/detail/CJT6x7XMiNLobg;time=PT1H;locations=global?project=bridgy-federated

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

52 existing lines in 3 files now uncovered.

4122 of 4447 relevant lines covered (92.69%)

0.93 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
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
958
            actor_obj = from_cls.load(actor['id'])
1✔
959
            if actor_obj and actor_obj.as1:
1✔
960
                obj.our_as1 = {**obj.as1, 'actor': actor_obj.as1}
1✔
961

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

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

984
            from_cls.handle_follow(obj)
1✔
985

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1157
        Checks if we've seen it before.
1158

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

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

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

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

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

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

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

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

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

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

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

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

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

1269
        return 'OK', 202
1✔
1270

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

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

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

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

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

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

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

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

1325
            add(to_protocols, proto)
1✔
1326

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

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

1342
            target_author_key = target_proto.actor_key(orig_obj)
1✔
1343
            if (target_proto not in to_protocols
1✔
1344
                  and obj.source_protocol != target_proto.LABEL):
1345
                # if author isn't bridged and inReplyTo author is, DM a prompt
1346
                if id in in_reply_tos:
1✔
1347
                    if target_author := target_author_key.get():
1✔
1348
                        if target_author.is_enabled(from_cls):
1✔
1349
                            dms.maybe_send(
1✔
1350
                                from_proto=target_proto, to_user=from_user,
1351
                                type='replied_to_bridged_user', text=f"""\
1352
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.""")
1353

1354
                continue
1✔
1355

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1503
        return targets
1✔
1504

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1596
        Also reports an error.
1597

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

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

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

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

1623

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

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

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

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

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

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

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

1658

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1741
    update_object(obj.key)
1✔
1742

1743
    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