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

snarfed / bridgy-fed / 9c03143a-2784-4d7c-abfe-52be9b57b870

14 Sep 2024 05:17AM UTC coverage: 92.8% (+0.01%) from 92.79%
9c03143a-2784-4d7c-abfe-52be9b57b870

push

circleci

snarfed
Protocol.receive: move deleting actors to after deliver

so that User.enabled_protocols is still populated during deliver

for #1304

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

34 existing lines in 2 files now uncovered.

4163 of 4486 relevant lines covered (92.8%)

0.93 hits per line

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

95.06
/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, fallback=False):
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
          fallback (bool): if True, and bridged users have no canonical user
426
            profile URL in this protocol, return the native protocol's profile URL
427

428
        Returns:
429
          str, or None if there isn't a canonical URL
430
        """
431
        if fallback:
1✔
432
            return user.web_url()
1✔
433

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

438
        Args:
439
          obj (models.Object)
440

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

448
    @classmethod
1✔
449
    def bot_user_id(cls):
1✔
450
        """Returns the Web user id for the bot user for this protocol.
451

452
        For example, ``'bsky.brid.gy'`` for ATProto.
453

454
        Returns:
455
          str:
456
        """
457
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
458

459
    @classmethod
1✔
460
    def create_for(cls, user):
1✔
461
        """Creates a copy user in this protocol.
462

463
        Should add the copy user to :attr:`copies`.
464

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

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

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

478
        To be implemented by subclasses.
479

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

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

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

497
        Raises:
498
          werkzeug.HTTPException if the request fails
499
        """
500
        raise NotImplementedError()
×
501

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

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

510
        To be implemented by subclasses.
511

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

517
        Returns:
518
          bool: True if the object was fetched and populated successfully,
519
          False otherwise
520

521
        Raises:
522
          requests.RequestException or werkzeug.HTTPException: if the fetch fails
523
        """
524
        raise NotImplementedError()
×
525

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

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

533
        Just passes through to :meth:`_convert`, then does minor
534
        protocol-independent postprocessing.
535

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

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

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

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

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

567
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
568
        obj.our_as1 = orig_our_as1
1✔
569
        return converted
1✔
570

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

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

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

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

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

594
        Default implementation; subclasses may override.
595

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

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

612
        if from_user.key and id in (from_user.key.id(), from_user.profile_id()):
1✔
613
            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✔
614

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

620
        if summary:
1✔
621
            summary += '<br><br>'
1✔
622
        actor['summary'] = summary + source_links
1✔
623

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

628
        To be implemented by subclasses.
629

630
        Examples:
631

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

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

644
        Returns:
645
          str: target endpoint, or None if not available.
646
        """
647
        raise NotImplementedError()
×
648

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

653
        Default implementation here, subclasses may override.
654

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

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

669
        Infers source protocol for each id value separately.
670

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

675
        Wraps these AS1 fields:
676

677
        * ``id``
678
        * ``actor``
679
        * ``author``
680
        * ``bcc``
681
        * ``bto``
682
        * ``cc``
683
        * ``object``
684
        * ``object.actor``
685
        * ``object.author``
686
        * ``object.id``
687
        * ``object.inReplyTo``
688
        * ``attachments[].id``
689
        * ``tags[objectType=mention].url``
690
        * ``to``
691

692
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
693
        same logic is duplicated there!
694

695
        TODO: unify with :meth:`Object.resolve_ids`,
696
        :meth:`models.Object.normalize_ids`.
697

698
        Args:
699
          to_proto (Protocol subclass)
700
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
701

702
        Returns:
703
          dict: wrapped AS1 version of ``obj``
704
        """
705
        assert to_cls != Protocol
1✔
706
        if not obj:
1✔
707
            return obj
1✔
708

709
        outer_obj = copy.deepcopy(obj)
1✔
710
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
711

712
        def translate(elem, field, fn):
1✔
713
            elem[field] = as1.get_objects(elem, field)
1✔
714
            for obj in elem[field]:
1✔
715
                if id := obj.get('id'):
1✔
716
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
717
                        continue
1✔
718
                    from_cls = Protocol.for_id(id)
1✔
719
                    # TODO: what if from_cls is None? relax translate_object_id,
720
                    # make it a noop if we don't know enough about from/to?
721
                    if from_cls and from_cls != to_cls:
1✔
722
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
723

724
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
725
                           for o in elem[field]]
726

727
            if len(elem[field]) == 1:
1✔
728
                elem[field] = elem[field][0]
1✔
729

730
        type = as1.object_type(outer_obj)
1✔
731
        translate(outer_obj, 'id',
1✔
732
                  translate_user_id if type in as1.ACTOR_TYPES
733
                  else translate_object_id)
734

735
        for o in inner_objs:
1✔
736
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
737
                        or as1.get_owner(outer_obj) == o.get('id')
738
                        or type in ('follow', 'stop-following'))
739
            translate(o, 'id', translate_user_id if is_actor else translate_object_id)
1✔
740

741
        for o in [outer_obj] + inner_objs:
1✔
742
            translate(o, 'inReplyTo', translate_object_id)
1✔
743
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
744
                translate(o, field, translate_user_id)
1✔
745
            for tag in as1.get_objects(o, 'tags'):
1✔
746
                if tag.get('objectType') == 'mention':
1✔
747
                    translate(tag, 'url', translate_user_id)
1✔
748
            for att in as1.get_objects(o, 'attachments'):
1✔
749
                translate(att, 'id', translate_object_id)
1✔
750
                url = att.get('url')
1✔
751
                if url and not att.get('id'):
1✔
752
                    if from_cls := Protocol.for_id(url):
1✔
753
                        att['id'] = translate_object_id(from_=from_cls, to=to_cls,
1✔
754
                                                        id=url)
755

756
        outer_obj = util.trim_nulls(outer_obj)
1✔
757

758
        if objs := outer_obj.get('object', []):
1✔
759
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
760
            if len(outer_obj['object']) == 1:
1✔
761
                outer_obj['object'] = outer_obj['object'][0]
1✔
762

763
        return outer_obj
1✔
764

765
    @classmethod
1✔
766
    def receive(from_cls, obj, authed_as=None, internal=False):
1✔
767
        """Handles an incoming activity.
768

769
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
770
        unset, returns HTTP 299.
771

772
        Args:
773
          obj (models.Object)
774
          authed_as (str): authenticated actor id who sent this activity
775
          internal (bool): whether to allow activity ids on internal domains,
776
            from opted out/blocked users, etc.
777

778
        Returns:
779
          (str, int) tuple: (response body, HTTP status code) Flask response
780

781
        Raises:
782
          werkzeug.HTTPException: if the request is invalid
783
        """
784
        # check some invariants
785
        assert from_cls != Protocol
1✔
786
        assert isinstance(obj, Object), obj
1✔
787

788
        if not obj.as1:
1✔
789
            error('No object data provided')
×
790

791
        id = None
1✔
792
        if obj.key and obj.key.id():
1✔
793
            id = obj.key.id()
1✔
794

795
        if not id:
1✔
796
            id = obj.as1.get('id')
1✔
797
            obj.key = ndb.Key(Object, id)
1✔
798

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

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

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

833
        # does this protocol support this activity/object type?
834
        from_cls.check_supported(obj)
1✔
835

836
        # load actor user, check authorization
837
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
838
        actor = as1.get_owner(obj.as1)
1✔
839
        if not actor:
1✔
840
            error('Activity missing actor or author')
1✔
841
        elif from_cls.owns_id(actor) is False:
1✔
842
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
843

844
        assert authed_as
1✔
845
        assert isinstance(authed_as, str)
1✔
846
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
847
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
848
        if actor != authed_as:
1✔
849
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
850
                         user=f'{id} authed_as {authed_as} owner {actor}')
851
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
852

853
        # update copy ids to originals
854
        obj.normalize_ids()
1✔
855
        obj.resolve_ids()
1✔
856

857
        if (obj.type == 'follow'
1✔
858
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
859
            # follows of bot user; refresh user profile first
860
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
861
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
862
            from_user.obj = from_cls.load(from_user.profile_id(), remote=True)
1✔
863
        else:
864
            # load actor user
865
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
866

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

870
        # write Object to datastore
871
        orig = obj
1✔
872
        obj = Object.get_or_create(id, authed_as=actor, **orig.to_dict())
1✔
873
        if orig.new is not None:
1✔
874
            obj.new = orig.new
1✔
875
        if orig.changed is not None:
1✔
876
            obj.changed = orig.changed
1✔
877

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

882
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
883
        if obj.type in as1.CRUD_VERBS:
1✔
884
            if inner_owner := as1.get_owner(inner_obj_as1):
1✔
885
                if inner_owner_key := from_cls.key_for(inner_owner):
1✔
886
                    obj.add('users', inner_owner_key)
1✔
887

888
        obj.source_protocol = from_cls.LABEL
1✔
889
        obj.put()
1✔
890

891
        # store inner object
892
        inner_obj_id = inner_obj_as1.get('id')
1✔
893
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
894
            Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
895
                                 source_protocol=from_cls.LABEL, authed_as=actor)
896

897
        actor = as1.get_object(obj.as1, 'actor')
1✔
898
        actor_id = actor.get('id')
1✔
899

900
        # handle activity!
901
        if obj.type == 'stop-following':
1✔
902
            # TODO: unify with handle_follow?
903
            # TODO: handle multiple followees
904
            if not actor_id or not inner_obj_id:
1✔
905
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
906

907
            # deactivate Follower
908
            from_ = from_cls.key_for(actor_id)
1✔
909
            to_cls = Protocol.for_id(inner_obj_id)
1✔
910
            to = to_cls.key_for(inner_obj_id)
1✔
911
            follower = Follower.query(Follower.to == to,
1✔
912
                                      Follower.from_ == from_,
913
                                      Follower.status == 'active').get()
914
            if follower:
1✔
915
                logger.info(f'Marking {follower} inactive')
1✔
916
                follower.status = 'inactive'
1✔
917
                follower.put()
1✔
918
            else:
919
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
920

921
            # fall through to deliver to followee
922
            # TODO: do we convert stop-following to webmention 410 of original
923
            # follow?
924

925
        elif obj.type in ('update', 'like', 'share'):  # require object
1✔
926
            if not inner_obj_id:
1✔
927
                error("Couldn't find id of object to update")
1✔
928

929
            # fall through to deliver to followers
930

931
        elif obj.type in ('delete', 'undo'):
1✔
932
            if not inner_obj_id:
1✔
933
                error("Couldn't find id of object to delete")
×
934

935
            logger.info(f'Marking Object {inner_obj_id} deleted')
1✔
936
            Object.get_or_create(inner_obj_id, deleted=True, authed_as=authed_as)
1✔
937

938
            # if this is an actor, handle deleting it later so that
939
            # in case it's from_user, user.enabled_protocols is still populated
940
            #
941
            # fall through to deliver to followers and delete copy if necessary.
942
            # should happen via protocol-specific copy target and send of
943
            # delete activity.
944
            # https://github.com/snarfed/bridgy-fed/issues/63
945

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

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

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

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

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

988
            from_cls.handle_follow(obj)
1✔
989

990
        # deliver to targets
991
        resp = from_cls.deliver(obj, from_user=from_user)
1✔
992

993
        # if this is a user, deactivate its followers/followings
994
        # https://github.com/snarfed/bridgy-fed/issues/1304
995
        if obj.type == 'delete':
1✔
996
            if user_key := from_cls.key_for(id=inner_obj_id):
1✔
997
                if user := user_key.get():
1✔
998
                    for proto in user.enabled_protocols:
1✔
999
                        user.disable_protocol(PROTOCOLS[proto])
1✔
1000

1001
                    logger.info(f'Deactivating Followers from or to = {inner_obj_id}')
1✔
1002
                    followers = Follower.query(
1✔
1003
                        OR(Follower.to == user_key, Follower.from_ == user_key)
1004
                        ).fetch()
1005
                    for f in followers:
1✔
1006
                        f.status = 'inactive'
1✔
1007
                    ndb.put_multi(followers)
1✔
1008

1009
        common.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1010
        return resp
1✔
1011

1012
    @classmethod
1✔
1013
    def handle_follow(from_cls, obj):
1✔
1014
        """Handles an incoming follow activity.
1015

1016
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1017
        happens in :meth:`deliver`.
1018

1019
        Args:
1020
          obj (models.Object): follow activity
1021
        """
1022
        logger.debug('Got follow. Loading users, storing Follow(s), sending accept(s)')
1✔
1023

1024
        # Prepare follower (from) users' data
1025
        from_as1 = as1.get_object(obj.as1, 'actor')
1✔
1026
        from_id = from_as1.get('id')
1✔
1027
        if not from_id:
1✔
UNCOV
1028
            error(f'Follow activity requires actor. Got: {obj.as1}')
×
1029

1030
        from_obj = from_cls.load(from_id)
1✔
1031
        if not from_obj:
1✔
UNCOV
1032
            error(f"Couldn't load {from_id}", status=502)
×
1033

1034
        if not from_obj.as1:
1✔
1035
            from_obj.our_as1 = from_as1
1✔
1036
            from_obj.put()
1✔
1037

1038
        from_key = from_cls.key_for(from_id)
1✔
1039
        if not from_key:
1✔
UNCOV
1040
            error(f'Invalid {from_cls} user key: {from_id}')
×
1041
        obj.users = [from_key]
1✔
1042
        from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
1✔
1043

1044
        # Prepare followee (to) users' data
1045
        to_as1s = as1.get_objects(obj.as1)
1✔
1046
        if not to_as1s:
1✔
1047
            error(f'Follow activity requires object(s). Got: {obj.as1}')
1✔
1048

1049
        # Store Followers
1050
        for to_as1 in to_as1s:
1✔
1051
            to_id = to_as1.get('id')
1✔
1052
            if not to_id:
1✔
UNCOV
1053
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1054

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

1057
            to_cls = Protocol.for_id(to_id)
1✔
1058
            if not to_cls:
1✔
1059
                error(f"Couldn't determine protocol for {to_id}")
1✔
1060
            elif from_cls == to_cls and from_cls.LABEL != 'fake':
1✔
1061
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1062
                continue
1✔
1063

1064
            to_obj = to_cls.load(to_id)
1✔
1065
            if to_obj and not to_obj.as1:
1✔
1066
                to_obj.our_as1 = to_as1
1✔
1067
                to_obj.put()
1✔
1068

1069
            to_key = to_cls.key_for(to_id)
1✔
1070
            if not to_key:
1✔
UNCOV
1071
                logger.info(f'Skipping invalid {from_cls} user key: {from_id}')
×
UNCOV
1072
                continue
×
1073

1074
            # If followee user is already direct, follower may not know they're
1075
            # interacting with a bridge. if followee user is indirect though,
1076
            # follower should know, so they're direct.
1077
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj, direct=False)
1✔
1078
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1079
                                                  follow=obj.key, status='active')
1080
            obj.add('notify', to_key)
1✔
1081
            from_cls.maybe_accept_follow(follower=from_user, followee=to_user,
1✔
1082
                                         follow=obj)
1083

1084
    @classmethod
1✔
1085
    def maybe_accept_follow(_, follower, followee, follow):
1✔
1086
        """Sends an accept activity for a follow.
1087

1088
        ...if the follower protocol handles accepts. Otherwise, does nothing.
1089

1090
        Args:
1091
          follower: :class:`models.User`
1092
          followee: :class:`models.User`
1093
          follow: :class:`models.Object`
1094
        """
1095
        if 'accept' not in follower.SUPPORTED_AS1_TYPES:
1✔
1096
            return
1✔
1097

1098
        target = follower.target_for(follower.obj)
1✔
1099
        if not target:
1✔
UNCOV
1100
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1101

1102
        # send accept. note that this is one accept for the whole
1103
        # follow, even if it has multiple followees!
1104
        id = f'{followee.key.id()}/followers#accept-{follow.key.id()}'
1✔
1105
        undelivered = [Target(protocol=follower.LABEL, uri=target)]
1✔
1106
        accept = {
1✔
1107
            'id': id,
1108
            'objectType': 'activity',
1109
            'verb': 'accept',
1110
            'actor': followee.key.id(),
1111
            'object': follow.as1,
1112
        }
1113
        obj = Object.get_or_create(id, authed_as=followee.key.id(),
1✔
1114
                                      undelivered=undelivered, our_as1=accept)
1115

1116
        common.create_task(queue='send', obj=obj.key.urlsafe(),
1✔
1117
                           url=target, protocol=follower.LABEL,
1118
                           user=followee.key.urlsafe())
1119

1120
    @classmethod
1✔
1121
    def bot_follow(bot_cls, user):
1✔
1122
        """Follow a user from a protocol bot user.
1123

1124
        ...so that the protocol starts sending us their activities, if it needs
1125
        a follow for that (eg ActivityPub).
1126

1127
        Args:
1128
          user (User)
1129
        """
1130
        from web import Web
1✔
1131
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1132
        now = util.now().isoformat()
1✔
1133
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1134

1135
        if not user.obj:
1✔
1136
            logger.info("  can't follow, user has no profile obj")
1✔
1137
            return
1✔
1138

1139
        target = user.target_for(user.obj)
1✔
1140
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1141
        follow_back = Object(id=follow_back_id, source_protocol='web',
1✔
1142
                             undelivered=[Target(protocol=user.LABEL, uri=target)],
1143
                             our_as1={
1144
            'objectType': 'activity',
1145
            'verb': 'follow',
1146
            'id': follow_back_id,
1147
            'actor': bot.key.id(),
1148
            'object': user.key.id(),
1149
        }).put()
1150

1151
        common.create_task(queue='send', obj=follow_back.urlsafe(),
1✔
1152
                           url=target, protocol=user.LABEL,
1153
                           user=bot.key.urlsafe())
1154

1155
    @classmethod
1✔
1156
    def delete_user_copy(copy_cls, user):
1✔
1157
        """Deletes a user's copy actor in a given protocol.
1158

1159
        Args:
1160
          user (User)
1161
        """
1162
        now = util.now().isoformat()
1✔
1163
        delete_id = f'{ids.profile_id(id=user.key.id(), proto=user)}#delete-copy-{copy_cls.LABEL}-{now}'
1✔
1164
        delete = Object(id=delete_id, source_protocol=user.LABEL, our_as1={
1✔
1165
            'id': delete_id,
1166
            'objectType': 'activity',
1167
            'verb': 'delete',
1168
            'actor': user.key.id(),
1169
            'object': user.key.id(),
1170
        })
1171
        delete.put()
1✔
1172
        user.deliver(delete, from_user=user, to_proto=copy_cls)
1✔
1173

1174
    @classmethod
1✔
1175
    def handle_bare_object(cls, obj, authed_as=None):
1✔
1176
        """If obj is a bare object, wraps it in a create or update activity.
1177

1178
        Checks if we've seen it before.
1179

1180
        Args:
1181
          obj (models.Object)
1182
          authed_as (str): authenticated actor id who sent this activity
1183

1184
        Returns:
1185
          models.Object: ``obj`` if it's an activity, otherwise a new object
1186
        """
1187
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1188
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1189
            return obj
1✔
1190

1191
        obj_actor = as1.get_owner(obj.as1)
1✔
1192
        now = util.now().isoformat()
1✔
1193

1194
        # this is a raw post; wrap it in a create or update activity
1195
        if obj.changed or is_actor:
1✔
1196
            if obj.changed:
1✔
1197
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1198
            else:
1199
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1200
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1201
            update_as1 = {
1✔
1202
                'objectType': 'activity',
1203
                'verb': 'update',
1204
                'id': id,
1205
                'actor': obj_actor,
1206
                'object': {
1207
                    # Mastodon requires the updated field for Updates, so
1208
                    # add a default value.
1209
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1210
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1211
                    # https://github.com/mastodon/documentation/pull/1150
1212
                    'updated': now,
1213
                    **obj.as1,
1214
                },
1215
            }
1216
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1217
            return Object(id=id, our_as1=update_as1,
1✔
1218
                          source_protocol=obj.source_protocol)
1219

1220
        create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1221
        create = cls.load(create_id, remote=False)
1✔
1222
        if (obj.new or not create or create.status != 'complete'
1✔
1223
                # HACK: force query param here is specific to webmention
1224
                or 'force' in request.form):
1225
            if create:
1✔
1226
                logger.info(f'Existing create {create.key.id()} status {create.status}')
1✔
1227
            else:
1228
                logger.info(f'No existing create activity')
1✔
1229
            create_as1 = {
1✔
1230
                'objectType': 'activity',
1231
                'verb': 'post',
1232
                'id': create_id,
1233
                'actor': obj_actor,
1234
                'object': obj.as1,
1235
                'published': now,
1236
            }
1237
            logger.info(f'Wrapping in post')
1✔
1238
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1239
            return Object.get_or_create(create_id, our_as1=create_as1,
1✔
1240
                                        source_protocol=obj.source_protocol,
1241
                                        authed_as=authed_as)
1242

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

1245
    @classmethod
1✔
1246
    def deliver(from_cls, obj, from_user, to_proto=None):
1✔
1247
        """Delivers an activity to its external recipients.
1248

1249
        Args:
1250
          obj (models.Object): activity to deliver
1251
          from_user (models.User): user (actor) this activity is from
1252
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1253
            targets on this protocol
1254

1255
        Returns:
1256
          (str, int) tuple: Flask response
1257
        """
1258
        if to_proto:
1✔
1259
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1260

1261
        # find delivery targets. maps Target to Object or None
1262
        targets = from_cls.targets(obj, from_user=from_user)
1✔
1263

1264
        if not targets:
1✔
1265
            obj.status = 'ignored'
1✔
1266
            obj.put()
1✔
1267
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1268

1269
        # sort targets so order is deterministic for tests, debugging, etc
1270
        sorted_targets = sorted(targets.items(), key=lambda t: t[0].uri)
1✔
1271
        obj.populate(
1✔
1272
            status='in progress',
1273
            delivered=[],
1274
            failed=[],
1275
            undelivered=[t for t, _ in sorted_targets],
1276
        )
1277
        obj.put()
1✔
1278
        logger.info(f'Delivering to: {obj.undelivered}')
1✔
1279

1280
        # enqueue send task for each targets
1281
        user = from_user.key.urlsafe()
1✔
1282
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1283
            if to_proto and target.protocol != to_proto.LABEL:
1✔
UNCOV
1284
                continue
×
1285
            orig_obj = orig_obj.key.urlsafe() if orig_obj else ''
1✔
1286
            common.create_task(queue='send', obj=obj.key.urlsafe(),
1✔
1287
                               url=target.uri, protocol=target.protocol,
1288
                               orig_obj=orig_obj, user=user)
1289

1290
        return 'OK', 202
1✔
1291

1292
    @classmethod
1✔
1293
    def targets(from_cls, obj, from_user, internal=False):
1✔
1294
        """Collects the targets to send a :class:`models.Object` to.
1295

1296
        Targets are both objects - original posts, events, etc - and actors.
1297

1298
        Args:
1299
          obj (models.Object)
1300
          from_user (User)
1301
          internal (bool): whether this is a recursive internal call
1302

1303
        Returns:
1304
          dict: maps :class:`models.Target` to original (in response to)
1305
          :class:`models.Object`, if any, otherwise None
1306
        """
1307
        logger.info('Finding recipients and their targets')
1✔
1308

1309
        target_uris = sorted(set(as1.targets(obj.as1)))
1✔
1310
        logger.info(f'Raw targets: {target_uris}')
1✔
1311
        orig_obj = None
1✔
1312
        targets = {}  # maps Target to Object or None
1✔
1313
        owner = as1.get_owner(obj.as1)
1✔
1314

1315
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1316
        inner_obj_id = inner_obj_as1.get('id')
1✔
1317
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1318
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1319
        is_self_reply = False
1✔
1320

1321
        if is_reply:
1✔
1322
            original_ids = in_reply_tos
1✔
1323
        else:
1324
            if inner_obj_id == from_user.key.id():
1✔
1325
                inner_obj_id = from_user.profile_id()
1✔
1326
            original_ids = [inner_obj_id]
1✔
1327

1328
        # which protocols should we allow delivering to?
1329
        to_protocols = []
1✔
1330
        if DEBUG and from_user.LABEL != 'eefake':  # for unit tests
1✔
1331
            to_protocols += [PROTOCOLS['fake'], PROTOCOLS['other']]
1✔
1332
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1333
                      + from_user.enabled_protocols):
1334
            proto = PROTOCOLS[label]
1✔
1335
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share')
1✔
1336
                                     or is_reply):
1337
                for id in original_ids:
1✔
1338
                    if orig := from_user.load(id, remote=False):
1✔
1339
                        if orig.get_copy(proto):
1✔
1340
                            logger.info(f'Allowing {proto.LABEL}, original post {id} was bridged there')
1✔
1341
                            break
1✔
1342
                else:
1343
                    logger.info(f"Skipping {proto.LABEL}, original posts {original_ids} weren't bridged there")
1✔
1344
                    continue
1✔
1345

1346
            add(to_protocols, proto)
1✔
1347

1348
        # process direct targets
1349
        for id in sorted(target_uris):
1✔
1350
            target_proto = Protocol.for_id(id)
1✔
1351
            if not target_proto:
1✔
1352
                logger.info(f"Can't determine protocol for {id}")
1✔
1353
                continue
1✔
1354
            elif target_proto.is_blocklisted(id):
1✔
1355
                logger.info(f'{id} is blocklisted')
1✔
1356
                continue
1✔
1357

1358
            orig_obj = target_proto.load(id)
1✔
1359
            if not orig_obj or not orig_obj.as1:
1✔
1360
                logger.info(f"Couldn't load {id}")
1✔
1361
                continue
1✔
1362

1363
            target_author_key = target_proto.actor_key(orig_obj)
1✔
1364
            if (target_proto not in to_protocols
1✔
1365
                  and obj.source_protocol != target_proto.LABEL):
1366
                # if author isn't bridged and inReplyTo author is, DM a prompt
1367
                if id in in_reply_tos:
1✔
1368
                    if target_author := target_author_key.get():
1✔
1369
                        if target_author.is_enabled(from_cls):
1✔
1370
                            dms.maybe_send(
1✔
1371
                                from_proto=target_proto, to_user=from_user,
1372
                                type='replied_to_bridged_user', text=f"""\
1373
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.""")
1374

1375
                continue
1✔
1376

1377
            # deliver self-replies to followers
1378
            # https://github.com/snarfed/bridgy-fed/issues/639
1379
            if id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1380
                is_self_reply = True
1✔
1381
                logger.info(f'self reply!')
1✔
1382

1383
            # also add copies' targets
1384
            for copy in orig_obj.copies:
1✔
1385
                proto = PROTOCOLS[copy.protocol]
1✔
1386
                if proto in to_protocols:
1✔
1387
                    # copies generally won't have their own Objects
1388
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1389
                        logger.info(f'Adding target {target} for copy {copy.uri} of original {id}')
1✔
1390
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1391

1392
            if target_proto == from_cls and from_cls.LABEL != 'fake':
1✔
1393
                logger.info(f'Skipping same-protocol target {id}')
1✔
1394
                continue
1✔
1395

1396
            target = target_proto.target_for(orig_obj)
1✔
1397
            if not target:
1✔
1398
                # TODO: surface errors like this somehow?
UNCOV
1399
                logger.error(f"Can't find delivery target for {id}")
×
UNCOV
1400
                continue
×
1401

1402
            logger.info(f'Target for {id} is {target}')
1✔
1403
            # only use orig_obj for inReplyTos and repost objects
1404
            # https://github.com/snarfed/bridgy-fed/issues/1237
1405
            targets[Target(protocol=target_proto.LABEL, uri=target)] = (
1✔
1406
                orig_obj if id in in_reply_tos or id in as1.get_ids(obj.as1, 'object')
1407
                else None)
1408

1409
            if target_author_key:
1✔
1410
                logger.info(f'Recipient is {target_author_key}')
1✔
1411
                obj.add('notify', target_author_key)
1✔
1412

1413
        if obj.type == 'undo':
1✔
1414
            logger.info('Object is an undo; adding targets for inner object')
1✔
1415
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1416
                inner_obj = from_cls.load(inner_obj_id)
1✔
1417
            else:
1418
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1419
            if inner_obj:
1✔
1420
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1421
                                                internal=True))
1422

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

1425
        # deliver to followers, if appropriate
1426
        user_key = from_cls.actor_key(obj)
1✔
1427
        if not user_key:
1✔
1428
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1429
            return targets
1✔
1430

1431
        followers = []
1✔
1432
        if (obj.type in ('post', 'update', 'delete', 'share')
1✔
1433
                and (not is_reply or is_self_reply)):
1434
            logger.info(f'Delivering to followers of {user_key}')
1✔
1435
            followers = [
1✔
1436
                f for f in Follower.query(Follower.to == user_key,
1437
                                          Follower.status == 'active')
1438
                # skip protocol bot users
1439
                if not Protocol.for_bridgy_subdomain(f.from_.id())
1440
                # skip protocols this user hasn't enabled, or where the base
1441
                # object of this activity hasn't been bridged
1442
                and PROTOCOLS_BY_KIND[f.from_.kind()] in to_protocols]
1443
            user_keys = [f.from_ for f in followers]
1✔
1444
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1445
            User.load_multi(users)
1✔
1446

1447
            if (not followers and
1✔
1448
                (util.domain_or_parent_in(
1449
                    util.domain_from_link(from_user.key.id()), LIMITED_DOMAINS)
1450
                 or util.domain_or_parent_in(
1451
                     util.domain_from_link(obj.key.id()), LIMITED_DOMAINS))):
1452
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1453
                return {}
1✔
1454

1455
            # which object should we add to followers' feeds, if any
1456
            feed_obj = None
1✔
1457
            if not internal:
1✔
1458
                if obj.type == 'share':
1✔
1459
                    feed_obj = obj
1✔
1460
                elif obj.type not in ('delete', 'undo', 'stop-following'):
1✔
1461
                    inner = as1.get_object(obj.as1)
1✔
1462
                    # don't add profile updates to feeds
1463
                    if not (obj.type == 'update'
1✔
1464
                            and inner.get('objectType') in as1.ACTOR_TYPES):
1465
                        inner_id = inner.get('id')
1✔
1466
                        if inner_id:
1✔
1467
                            feed_obj = from_cls.load(inner_id)
1✔
1468

1469
            for user in users:
1✔
1470
                if feed_obj:
1✔
1471
                    feed_obj.add('feed', user.key)
1✔
1472

1473
                # TODO: should we pass remote=False through here to Protocol.load?
1474
                target = (user.target_for(user.obj, shared=True)
1✔
1475
                          if user.obj else None)
1476
                if not target:
1✔
1477
                    # TODO: surface errors like this somehow?
1478
                    logger.error(f'Follower {user.key} has no delivery target')
1✔
1479
                    continue
1✔
1480

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

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

1491
            if feed_obj:
1✔
1492
                feed_obj.put()
1✔
1493

1494
        # deliver to enabled HAS_COPIES protocols proactively
1495
        # TODO: abstract for other protocols
1496
        from atproto import ATProto
1✔
1497
        if (ATProto in to_protocols
1✔
1498
                and obj.type in ('post', 'update', 'delete', 'share')):
1499
            logger.info(f'user has ATProto enabled, adding {ATProto.PDS_URL}')
1✔
1500
            targets.setdefault(
1✔
1501
                Target(protocol=ATProto.LABEL, uri=ATProto.PDS_URL), None)
1502

1503
        # de-dupe targets, discard same-domain
1504
        # maps string target URL to (Target, Object) tuple
1505
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1506
        # maps Target to Object or None
1507
        targets = {}
1✔
1508
        source_domains = [
1✔
1509
            util.domain_from_link(url) for url in
1510
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1511
            if util.is_web(url)
1512
        ]
1513
        for url in sorted(util.dedupe_urls(
1✔
1514
                candidates.keys(),
1515
                # preserve our PDS URL without trailing slash in path
1516
                # https://atproto.com/specs/did#did-documents
1517
                trailing_slash=False)):
1518
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
UNCOV
1519
                logger.info(f'Skipping same-domain target {url}')
×
UNCOV
1520
                continue
×
1521
            target, obj = candidates[url]
1✔
1522
            targets[target] = obj
1✔
1523

1524
        return targets
1✔
1525

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

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

1533
        Note that :meth:`Object._post_put_hook` updates the cache.
1534

1535
        Args:
1536
          id (str)
1537
          remote (bool): whether to fetch the object over the network. If True,
1538
            fetches even if we already have the object stored, and updates our
1539
            stored copy. If False and we don't have the object stored, returns
1540
            None. Default (None) means to fetch over the network only if we
1541
            don't already have it stored.
1542
          local (bool): whether to load from the datastore before
1543
            fetching over the network. If False, still stores back to the
1544
            datastore after a successful remote fetch.
1545
          kwargs: passed through to :meth:`fetch()`
1546

1547
        Returns:
1548
          models.Object: loaded object, or None if it isn't fetchable, eg a
1549
          non-URL string for Web, or ``remote`` is False and it isn't in the
1550
          cache or datastore
1551

1552
        Raises:
1553
          requests.HTTPError: anything that :meth:`fetch` raises
1554
        """
1555
        assert id
1✔
1556
        assert local or remote is not False
1✔
1557
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1558

1559
        obj = orig_as1 = None
1✔
1560
        if local and not obj:
1✔
1561
            obj = Object.get_by_id(id)
1✔
1562
            if not obj:
1✔
1563
                # logger.debug(f' not in datastore')
1564
                pass
1✔
1565
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1566
                # logger.debug('  got from datastore')
1567
                obj.new = False
1✔
1568

1569
        if remote is False:
1✔
1570
            return obj
1✔
1571
        elif remote is None and obj:
1✔
1572
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1573
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1574
                pass
1✔
1575
            else:
1576
                return obj
1✔
1577

1578
        if obj:
1✔
1579
            orig_as1 = obj.as1
1✔
1580
            obj.clear()
1✔
1581
            obj.new = False
1✔
1582
        else:
1583
            obj = Object(id=id)
1✔
1584
            if local:
1✔
1585
                # logger.debug('  not in datastore')
1586
                obj.new = True
1✔
1587
                obj.changed = False
1✔
1588

1589
        fetched = cls.fetch(obj, **kwargs)
1✔
1590
        if not fetched:
1✔
1591
            return None
1✔
1592

1593
        # https://stackoverflow.com/a/3042250/186123
1594
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1595
        if size > models.MAX_ENTITY_SIZE:
1✔
1596
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1597
            return None
1✔
1598

1599
        obj.resolve_ids()
1✔
1600
        obj.normalize_ids()
1✔
1601

1602
        if obj.new is False:
1✔
1603
            obj.changed = obj.activity_changed(orig_as1)
1✔
1604

1605
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1606
            if obj.source_protocol:
1✔
UNCOV
1607
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1608
            obj.source_protocol = cls.LABEL
1✔
1609

1610
        obj.put()
1✔
1611
        return obj
1✔
1612

1613
    @classmethod
1✔
1614
    def check_supported(cls, obj):
1✔
1615
        """If this protocol doesn't support this object, return 204.
1616

1617
        Also reports an error.
1618

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

1623
        Args:
1624
          obj (Object)
1625
        """
1626
        if not obj.type:
1✔
UNCOV
1627
            return
×
1628

1629
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1630
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1631
            or (obj.type in as1.CRUD_VERBS
1632
                and inner_type
1633
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1634
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1635

1636
        # DMs are only allowed to/from protocol bot accounts
1637
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1638
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1639
            if (not cls.SUPPORTS_DMS
1✔
1640
                    or (recip not in protocol_user_ids
1641
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1642
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1643

1644

1645
@cloud_tasks_only
1✔
1646
def receive_task():
1✔
1647
    """Task handler for a newly received :class:`models.Object`.
1648

1649
    Calls :meth:`Protocol.receive` with the form parameters.
1650

1651
    Parameters:
1652
      obj (url-safe google.cloud.ndb.key.Key): :class:`models.Object` to handle
1653
      authed_as (str): passed to :meth:`Protocol.receive`
1654

1655
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
1656
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
1657
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
1658
    ``changed``, HTTP request details, etc. See stash for attempt at this for
1659
    :class:`web.Web`.
1660
    """
1661
    form = request.form.to_dict()
1✔
1662
    logger.info(f'Params: {list(form.items())}')
1✔
1663

1664
    obj = ndb.Key(urlsafe=form['obj']).get()
1✔
1665
    assert obj
1✔
1666
    obj.new = True
1✔
1667

1668
    authed_as = form.get('authed_as')
1✔
1669

1670
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1671
                or authed_as in common.PROTOCOL_DOMAINS)
1672
    try:
1✔
1673
        return PROTOCOLS[obj.source_protocol].receive(obj=obj, authed_as=authed_as,
1✔
1674
                                                      internal=internal)
1675
    except ValueError as e:
1✔
1676
        logger.warning(e, exc_info=True)
1✔
1677
        error(e, status=304)
1✔
1678

1679

1680
@cloud_tasks_only
1✔
1681
def send_task():
1✔
1682
    """Task handler for sending an activity to a single specific destination.
1683

1684
    Calls :meth:`Protocol.send` with the form parameters.
1685

1686
    Parameters:
1687
      protocol (str): :class:`Protocol` to send to
1688
      url (str): destination URL to send to
1689
      obj (url-safe google.cloud.ndb.key.Key): :class:`models.Object` to send
1690
      orig_obj (url-safe google.cloud.ndb.key.Key): optional "original object"
1691
        :class:`models.Object` that this object refers to, eg replies to or
1692
        reposts or likes
1693
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1694
        this activity is from
1695
    """
1696
    form = request.form.to_dict()
1✔
1697
    logger.info(f'Params: {list(form.items())}')
1✔
1698

1699
    # prepare
1700
    url = form.get('url')
1✔
1701
    protocol = form.get('protocol')
1✔
1702
    if not url or not protocol:
1✔
1703
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1704
        return '', 204
1✔
1705

1706
    target = Target(uri=url, protocol=protocol)
1✔
1707

1708
    obj = ndb.Key(urlsafe=form['obj']).get()
1✔
1709

1710
    PROTOCOLS[protocol].check_supported(obj)
1✔
1711

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

1717
    user = None
1✔
1718
    if user_key := form.get('user'):
1✔
1719
        key = ndb.Key(urlsafe=user_key)
1✔
1720
        # use get_by_id so that we follow use_instead
1721
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(key.id())
1✔
1722

1723
    orig_obj = (ndb.Key(urlsafe=form['orig_obj']).get()
1✔
1724
                if form.get('orig_obj') else None)
1725

1726
    # send
1727
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url}')
1✔
1728
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
1729
    sent = None
1✔
1730
    try:
1✔
1731
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user, orig_obj=orig_obj)
1✔
1732
    except BaseException as e:
1✔
1733
        code, body = util.interpret_http_exception(e)
1✔
1734
        if not code and not body:
1✔
UNCOV
1735
            raise
×
1736

1737
    if sent is False:
1✔
1738
        logger.info(f'Failed sending!')
1✔
1739

1740
    # write results to Object
1741
    #
1742
    # retry aggressively because this has high contention during inbox delivery.
1743
    # (ndb does exponential backoff.)
1744
    # https://console.cloud.google.com/errors/detail/CJm_4sDv9O-iKg;time=P7D?project=bridgy-federated
1745
    @ndb.transactional(retries=10)
1✔
1746
    def update_object(obj_key):
1✔
1747
        obj = obj_key.get()
1✔
1748
        if target in obj.undelivered:
1✔
1749
            obj.remove('undelivered', target)
1✔
1750

1751
        if sent is None:
1✔
1752
            obj.add('failed', target)
1✔
1753
        else:
1754
            if target in obj.failed:
1✔
UNCOV
1755
                obj.remove('failed', target)
×
1756
            if sent:
1✔
1757
                obj.add('delivered', target)
1✔
1758

1759
        if not obj.undelivered:
1✔
1760
            obj.status = ('complete' if obj.delivered
1✔
1761
                          else 'failed' if obj.failed
1762
                          else 'ignored')
1763
        obj.put()
1✔
1764

1765
    update_object(obj.key)
1✔
1766

1767
    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