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

snarfed / bridgy-fed / 7c5b28e3-04e9-4574-90ee-b11c173012ff

11 Oct 2024 03:21AM UTC coverage: 92.755% (-0.002%) from 92.757%
7c5b28e3-04e9-4574-90ee-b11c173012ff

push

circleci

snarfed
tests: remove Fake special case in Follower._pre_put_hook

for #1180

4225 of 4555 relevant lines covered (92.76%)

0.93 hits per line

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

95.09
/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
from requests import RequestException
1✔
23
import werkzeug.exceptions
1✔
24
from werkzeug.exceptions import BadGateway, HTTPException
1✔
25

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

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

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

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

64

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

69

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

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

78

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

82

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

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

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

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

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

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

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

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

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

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

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

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

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

179
        To be implemented by subclasses.
180

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

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

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

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

195
        Args:
196
          id (str)
197

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

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

207
        To be implemented by subclasses.
208

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

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

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

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

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

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

236
        To be implemented by subclasses.
237

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

241
        Args:
242
          handle (str)
243

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

369
        Args:
370
          handle (str)
371

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

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

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

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

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

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

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

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

424
        Args:
425
          user (models.User)
426
          fallback (bool): if True, and bridged users have no canonical user
427
            profile URL in this protocol, return the native protocol's profile URL
428

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

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

439
        Args:
440
          obj (models.Object)
441
          allow_opt_out (bool): whether to return a user key if they're opted out
442

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

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

454
        For example, ``'bsky.brid.gy'`` for ATProto.
455

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

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

465
        Should add the copy user to :attr:`copies`.
466

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

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

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

480
        To be implemented by subclasses.
481

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

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

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

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

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

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

512
        To be implemented by subclasses.
513

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

596
        Default implementation; subclasses may override.
597

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

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

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

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

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

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

630
        To be implemented by subclasses.
631

632
        Examples:
633

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

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

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

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

655
        Default implementation here, subclasses may override.
656

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

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

671
        Infers source protocol for each id value separately.
672

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

677
        Wraps these AS1 fields:
678

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

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

697
        TODO: unify with :meth:`Object.resolve_ids`,
698
        :meth:`models.Object.normalize_ids`.
699

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

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

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

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

728
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
729
                           for o in elem[field]]
730

731
            if len(elem[field]) == 1:
1✔
732
                elem[field] = elem[field][0]
1✔
733

734
        type = as1.object_type(outer_obj)
1✔
735
        translate(outer_obj, 'id',
1✔
736
                  translate_user_id if type in as1.ACTOR_TYPES
737
                  else translate_object_id)
738

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

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

760
        outer_obj = util.trim_nulls(outer_obj)
1✔
761

762
        if objs := outer_obj.get('object', []):
1✔
763
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
764
            if len(outer_obj['object']) == 1:
1✔
765
                outer_obj['object'] = outer_obj['object'][0]
1✔
766

767
        return outer_obj
1✔
768

769
    @classmethod
1✔
770
    def receive(from_cls, obj, authed_as=None, internal=False):
1✔
771
        """Handles an incoming activity.
772

773
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
774
        unset, returns HTTP 299.
775

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

782
        Returns:
783
          (str, int) tuple: (response body, HTTP status code) Flask response
784

785
        Raises:
786
          werkzeug.HTTPException: if the request is invalid
787
        """
788
        # check some invariants
789
        assert from_cls != Protocol
1✔
790
        assert isinstance(obj, Object), obj
1✔
791

792
        if not obj.as1:
1✔
793
            error('No object data provided')
×
794

795
        id = None
1✔
796
        if obj.key and obj.key.id():
1✔
797
            id = obj.key.id()
1✔
798

799
        if not id:
1✔
800
            id = obj.as1.get('id')
1✔
801
            obj.key = ndb.Key(Object, id)
1✔
802

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

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

835
        pruned = {k: v for k, v in obj.as1.items()
1✔
836
                  if k not in ('contentMap', 'replies', 'signature')}
837
        delay = ''
1✔
838
        if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
839
            delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
840
            delay = f'({delay_s} s behind)'
1✔
841
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
842

843
        # does this protocol support this activity/object type?
844
        from_cls.check_supported(obj)
1✔
845

846
        # load actor user, check authorization
847
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
848
        actor = as1.get_owner(obj.as1)
1✔
849
        if not actor:
1✔
850
            error('Activity missing actor or author')
1✔
851
        elif from_cls.owns_id(actor) is False:
1✔
852
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
853

854
        assert authed_as
1✔
855
        assert isinstance(authed_as, str)
1✔
856
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
857
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
858
        if actor != authed_as:
1✔
859
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
860
                         user=f'{id} authed_as {authed_as} owner {actor}')
861
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
862

863
        # update copy ids to originals
864
        obj.normalize_ids()
1✔
865
        obj.resolve_ids()
1✔
866

867
        if (obj.type == 'follow'
1✔
868
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
869
            # follows of bot user; refresh user profile first
870
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
871
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
872
            from_user.reload_profile()
1✔
873
        else:
874
            # load actor user
875
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
876

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

880
        # write Object to datastore
881
        orig = obj
1✔
882
        obj = Object.get_or_create(id, authed_as=actor, **orig.to_dict())
1✔
883
        if orig.new is not None:
1✔
884
            obj.new = orig.new
1✔
885
        if orig.changed is not None:
1✔
886
            obj.changed = orig.changed
1✔
887

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

892
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
893
        if obj.type in as1.CRUD_VERBS:
1✔
894
            if inner_owner := as1.get_owner(inner_obj_as1):
1✔
895
                if inner_owner_key := from_cls.key_for(inner_owner):
1✔
896
                    obj.add('users', inner_owner_key)
1✔
897

898
        obj.source_protocol = from_cls.LABEL
1✔
899
        obj.put()
1✔
900

901
        # store inner object
902
        inner_obj_id = inner_obj_as1.get('id')
1✔
903
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
904
            Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
905
                                 source_protocol=from_cls.LABEL, authed_as=actor)
906

907
        actor = as1.get_object(obj.as1, 'actor')
1✔
908
        actor_id = actor.get('id')
1✔
909

910
        # handle activity!
911
        if obj.type == 'stop-following':
1✔
912
            # TODO: unify with handle_follow?
913
            # TODO: handle multiple followees
914
            if not actor_id or not inner_obj_id:
1✔
915
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
916

917
            # deactivate Follower
918
            from_ = from_cls.key_for(actor_id)
1✔
919
            to_cls = Protocol.for_id(inner_obj_id)
1✔
920
            to = to_cls.key_for(inner_obj_id)
1✔
921
            follower = Follower.query(Follower.to == to,
1✔
922
                                      Follower.from_ == from_,
923
                                      Follower.status == 'active').get()
924
            if follower:
1✔
925
                logger.info(f'Marking {follower} inactive')
1✔
926
                follower.status = 'inactive'
1✔
927
                follower.put()
1✔
928
            else:
929
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
930

931
            # fall through to deliver to followee
932
            # TODO: do we convert stop-following to webmention 410 of original
933
            # follow?
934

935
        elif obj.type in ('update', 'like', 'share'):  # require object
1✔
936
            if not inner_obj_id:
1✔
937
                error("Couldn't find id of object to update")
1✔
938

939
            # fall through to deliver to followers
940

941
        elif obj.type in ('delete', 'undo'):
1✔
942
            if not inner_obj_id:
1✔
943
                error("Couldn't find id of object to delete")
×
944

945
            logger.info(f'Marking Object {inner_obj_id} deleted')
1✔
946
            Object.get_or_create(inner_obj_id, deleted=True, authed_as=authed_as)
1✔
947

948
            # if this is an actor, handle deleting it later so that
949
            # in case it's from_user, user.enabled_protocols is still populated
950
            #
951
            # fall through to deliver to followers and delete copy if necessary.
952
            # should happen via protocol-specific copy target and send of
953
            # delete activity.
954
            # https://github.com/snarfed/bridgy-fed/issues/63
955

956
        elif obj.type == 'block':
1✔
957
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
958
                # blocking protocol bot user disables that protocol
959
                from_user.delete(proto)
1✔
960
                from_user.disable_protocol(proto)
1✔
961
                return 'OK', 200
1✔
962

963
        elif obj.type == 'post':
1✔
964
            # handle DMs to bot users
965
            if as1.is_dm(obj.as1):
1✔
966
                return dms.receive(from_user=from_user, obj=obj)
1✔
967

968
        # fetch actor if necessary
969
        if (actor and actor.keys() == set(['id'])
1✔
970
                and obj.type not in ('delete', 'undo')):
971
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
972
            actor_obj = from_cls.load(actor['id'], raise_=False)
1✔
973
            if actor_obj and actor_obj.as1:
1✔
974
                obj.our_as1 = {**obj.as1, 'actor': actor_obj.as1}
1✔
975

976
        # fetch object if necessary so we can render it in feeds
977
        if (obj.type == 'share'
1✔
978
                and inner_obj_as1.keys() == set(['id'])
979
                and from_cls.owns_id(inner_obj_id)):
980
            logger.debug('Fetching object so we can render it in feeds')
1✔
981
            inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
982
            if inner_obj and inner_obj.as1:
1✔
983
                obj.our_as1 = {
1✔
984
                    **obj.as1,
985
                    'object': {
986
                        **inner_obj_as1,
987
                        **inner_obj.as1,
988
                    }
989
                }
990

991
        if obj.type == 'follow':
1✔
992
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
993
                # follow of one of our protocol bot users; enable that protocol.
994
                # foll through so that we send an accept.
995
                from_user.enable_protocol(proto)
1✔
996
                proto.bot_follow(from_user)
1✔
997

998
            from_cls.handle_follow(obj)
1✔
999

1000
        # deliver to targets
1001
        resp = from_cls.deliver(obj, from_user=from_user)
1✔
1002

1003
        # if this is a user, deactivate its followers/followings
1004
        # https://github.com/snarfed/bridgy-fed/issues/1304
1005
        if obj.type == 'delete':
1✔
1006
            if user_key := from_cls.key_for(id=inner_obj_id):
1✔
1007
                if user := user_key.get():
1✔
1008
                    for proto in user.enabled_protocols:
1✔
1009
                        user.disable_protocol(PROTOCOLS[proto])
1✔
1010

1011
                    logger.info(f'Deactivating Followers from or to = {inner_obj_id}')
1✔
1012
                    followers = Follower.query(
1✔
1013
                        OR(Follower.to == user_key, Follower.from_ == user_key)
1014
                        ).fetch()
1015
                    for f in followers:
1✔
1016
                        f.status = 'inactive'
1✔
1017
                    ndb.put_multi(followers)
1✔
1018

1019
        common.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1020
        return resp
1✔
1021

1022
    @classmethod
1✔
1023
    def handle_follow(from_cls, obj):
1✔
1024
        """Handles an incoming follow activity.
1025

1026
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1027
        happens in :meth:`deliver`.
1028

1029
        Args:
1030
          obj (models.Object): follow activity
1031
        """
1032
        logger.debug('Got follow. Loading users, storing Follow(s), sending accept(s)')
1✔
1033

1034
        # Prepare follower (from) users' data
1035
        from_as1 = as1.get_object(obj.as1, 'actor')
1✔
1036
        from_id = from_as1.get('id')
1✔
1037
        if not from_id:
1✔
1038
            error(f'Follow activity requires actor. Got: {obj.as1}')
×
1039

1040
        from_obj = from_cls.load(from_id, raise_=False)
1✔
1041
        if not from_obj:
1✔
1042
            error(f"Couldn't load {from_id}", status=502)
×
1043

1044
        if not from_obj.as1:
1✔
1045
            from_obj.our_as1 = from_as1
1✔
1046
            from_obj.put()
1✔
1047

1048
        from_key = from_cls.key_for(from_id)
1✔
1049
        if not from_key:
1✔
1050
            error(f'Invalid {from_cls} user key: {from_id}')
×
1051
        obj.users = [from_key]
1✔
1052
        from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
1✔
1053

1054
        # Prepare followee (to) users' data
1055
        to_as1s = as1.get_objects(obj.as1)
1✔
1056
        if not to_as1s:
1✔
1057
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1058

1059
        # Store Followers
1060
        for to_as1 in to_as1s:
1✔
1061
            to_id = to_as1.get('id')
1✔
1062
            if not to_id:
1✔
1063
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1064

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

1067
            to_cls = Protocol.for_id(to_id)
1✔
1068
            if not to_cls:
1✔
1069
                error(f"Couldn't determine protocol for {to_id}")
×
1070
            elif from_cls == to_cls:
1✔
1071
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1072
                continue
1✔
1073

1074
            to_obj = to_cls.load(to_id)
1✔
1075
            if to_obj and not to_obj.as1:
1✔
1076
                to_obj.our_as1 = to_as1
1✔
1077
                to_obj.put()
1✔
1078

1079
            to_key = to_cls.key_for(to_id)
1✔
1080
            if not to_key:
1✔
1081
                logger.info(f'Skipping invalid {from_cls} user key: {from_id}')
×
1082
                continue
×
1083

1084
            # If followee user is already direct, follower may not know they're
1085
            # interacting with a bridge. if followee user is indirect though,
1086
            # follower should know, so they're direct.
1087
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj, direct=False,
1✔
1088
                                           allow_opt_out=True)
1089
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1090
                                                  follow=obj.key, status='active')
1091
            obj.add('notify', to_key)
1✔
1092
            from_cls.maybe_accept_follow(follower=from_user, followee=to_user,
1✔
1093
                                         follow=obj)
1094

1095
    @classmethod
1✔
1096
    def maybe_accept_follow(_, follower, followee, follow):
1✔
1097
        """Sends an accept activity for a follow.
1098

1099
        ...if the follower protocol handles accepts. Otherwise, does nothing.
1100

1101
        Args:
1102
          follower: :class:`models.User`
1103
          followee: :class:`models.User`
1104
          follow: :class:`models.Object`
1105
        """
1106
        if 'accept' not in follower.SUPPORTED_AS1_TYPES:
1✔
1107
            return
1✔
1108

1109
        target = follower.target_for(follower.obj)
1✔
1110
        if not target:
1✔
1111
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1112

1113
        # send accept. note that this is one accept for the whole
1114
        # follow, even if it has multiple followees!
1115
        id = f'{followee.key.id()}/followers#accept-{follow.key.id()}'
1✔
1116
        undelivered = [Target(protocol=follower.LABEL, uri=target)]
1✔
1117
        accept = {
1✔
1118
            'id': id,
1119
            'objectType': 'activity',
1120
            'verb': 'accept',
1121
            'actor': followee.key.id(),
1122
            'object': follow.as1,
1123
        }
1124
        obj = Object.get_or_create(id, authed_as=followee.key.id(),
1✔
1125
                                      undelivered=undelivered, our_as1=accept)
1126

1127
        common.create_task(queue='send', obj=obj.key.urlsafe(),
1✔
1128
                           url=target, protocol=follower.LABEL,
1129
                           user=followee.key.urlsafe())
1130

1131
    @classmethod
1✔
1132
    def bot_follow(bot_cls, user):
1✔
1133
        """Follow a user from a protocol bot user.
1134

1135
        ...so that the protocol starts sending us their activities, if it needs
1136
        a follow for that (eg ActivityPub).
1137

1138
        Args:
1139
          user (User)
1140
        """
1141
        from web import Web
1✔
1142
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1143
        now = util.now().isoformat()
1✔
1144
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1145

1146
        if not user.obj:
1✔
1147
            logger.info("  can't follow, user has no profile obj")
1✔
1148
            return
1✔
1149

1150
        target = user.target_for(user.obj)
1✔
1151
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1152
        follow_back = Object(id=follow_back_id, source_protocol='web',
1✔
1153
                             undelivered=[Target(protocol=user.LABEL, uri=target)],
1154
                             our_as1={
1155
            'objectType': 'activity',
1156
            'verb': 'follow',
1157
            'id': follow_back_id,
1158
            'actor': bot.key.id(),
1159
            'object': user.key.id(),
1160
        }).put()
1161

1162
        common.create_task(queue='send', obj=follow_back.urlsafe(),
1✔
1163
                           url=target, protocol=user.LABEL,
1164
                           user=bot.key.urlsafe())
1165

1166
    @classmethod
1✔
1167
    def handle_bare_object(cls, obj, authed_as=None):
1✔
1168
        """If obj is a bare object, wraps it in a create or update activity.
1169

1170
        Checks if we've seen it before.
1171

1172
        Args:
1173
          obj (models.Object)
1174
          authed_as (str): authenticated actor id who sent this activity
1175

1176
        Returns:
1177
          models.Object: ``obj`` if it's an activity, otherwise a new object
1178
        """
1179
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1180
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1181
            return obj
1✔
1182

1183
        obj_actor = as1.get_owner(obj.as1)
1✔
1184
        now = util.now().isoformat()
1✔
1185

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

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

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

1237
    @classmethod
1✔
1238
    def deliver(from_cls, obj, from_user, to_proto=None):
1✔
1239
        """Delivers an activity to its external recipients.
1240

1241
        Args:
1242
          obj (models.Object): activity to deliver
1243
          from_user (models.User): user (actor) this activity is from
1244
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1245
            targets on this protocol
1246

1247
        Returns:
1248
          (str, int) tuple: Flask response
1249
        """
1250
        if to_proto:
1✔
1251
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1252

1253
        # find delivery targets. maps Target to Object or None
1254
        targets = from_cls.targets(obj, from_user=from_user)
1✔
1255

1256
        if not targets:
1✔
1257
            obj.status = 'ignored'
1✔
1258
            obj.put()
1✔
1259
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1260

1261
        # sort targets so order is deterministic for tests, debugging, etc
1262
        sorted_targets = sorted(targets.items(), key=lambda t: t[0].uri)
1✔
1263
        obj.populate(
1✔
1264
            status='in progress',
1265
            delivered=[],
1266
            failed=[],
1267
            undelivered=[t for t, _ in sorted_targets],
1268
        )
1269
        obj.put()
1✔
1270
        logger.info(f'Delivering to: {obj.undelivered}')
1✔
1271

1272
        # enqueue send task for each targets
1273
        user = from_user.key.urlsafe()
1✔
1274
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1275
            if to_proto and target.protocol != to_proto.LABEL:
1✔
1276
                continue
×
1277
            orig_obj = orig_obj.key.urlsafe() if orig_obj else ''
1✔
1278
            common.create_task(queue='send', obj=obj.key.urlsafe(),
1✔
1279
                               url=target.uri, protocol=target.protocol,
1280
                               orig_obj=orig_obj, user=user)
1281

1282
        return 'OK', 202
1✔
1283

1284
    @classmethod
1✔
1285
    def targets(from_cls, obj, from_user, internal=False):
1✔
1286
        """Collects the targets to send a :class:`models.Object` to.
1287

1288
        Targets are both objects - original posts, events, etc - and actors.
1289

1290
        Args:
1291
          obj (models.Object)
1292
          from_user (User)
1293
          internal (bool): whether this is a recursive internal call
1294

1295
        Returns:
1296
          dict: maps :class:`models.Target` to original (in response to)
1297
          :class:`models.Object`, if any, otherwise None
1298
        """
1299
        logger.info('Finding recipients and their targets')
1✔
1300

1301
        target_uris = sorted(set(as1.targets(obj.as1)))
1✔
1302
        logger.info(f'Raw targets: {target_uris}')
1✔
1303
        orig_obj = None
1✔
1304
        targets = {}  # maps Target to Object or None
1✔
1305
        owner = as1.get_owner(obj.as1)
1✔
1306
        allow_opt_out = (obj.type == 'delete')
1✔
1307
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1308
        inner_obj_id = inner_obj_as1.get('id')
1✔
1309
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1310
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1311
        is_self_reply = False
1✔
1312

1313
        if is_reply:
1✔
1314
            original_ids = in_reply_tos
1✔
1315
        else:
1316
            if inner_obj_id == from_user.key.id():
1✔
1317
                inner_obj_id = from_user.profile_id()
1✔
1318
            original_ids = [inner_obj_id]
1✔
1319

1320
        # which protocols should we allow delivering to?
1321
        to_protocols = []
1✔
1322
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1323
                      + from_user.enabled_protocols):
1324
            proto = PROTOCOLS[label]
1✔
1325
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1326
                                     or is_reply):
1327
                for id in original_ids:
1✔
1328
                    if Protocol.for_id(id) == proto:
1✔
1329
                        logger.info(f'Allowing {label} for original post {id}')
1✔
1330
                        break
1✔
1331
                    elif orig := from_user.load(id, remote=False):
1✔
1332
                        if orig.get_copy(proto):
1✔
1333
                            logger.info(f'Allowing {label}, original post {id} was bridged there')
1✔
1334
                            break
1✔
1335
                else:
1336
                    logger.info(f"Skipping {label}, original posts {original_ids} weren't bridged there")
1✔
1337
                    continue
1✔
1338

1339
            add(to_protocols, proto)
1✔
1340

1341
        # process direct targets
1342
        for id in sorted(target_uris):
1✔
1343
            target_proto = Protocol.for_id(id)
1✔
1344
            if not target_proto:
1✔
1345
                logger.info(f"Can't determine protocol for {id}")
1✔
1346
                continue
1✔
1347
            elif target_proto.is_blocklisted(id):
1✔
1348
                logger.info(f'{id} is blocklisted')
1✔
1349
                continue
1✔
1350

1351
            orig_obj = target_proto.load(id, raise_=False)
1✔
1352
            if not orig_obj or not orig_obj.as1:
1✔
1353
                logger.info(f"Couldn't load {id}")
1✔
1354
                continue
1✔
1355

1356
            target_author_key = target_proto.actor_key(orig_obj)
1✔
1357
            if not from_user.is_enabled(target_proto):
1✔
1358
                # if author isn't bridged and inReplyTo author is, DM a prompt
1359
                if id in in_reply_tos:
1✔
1360
                    if target_author := target_author_key.get():
1✔
1361
                        if target_author.is_enabled(from_cls):
1✔
1362
                            dms.maybe_send(
1✔
1363
                                from_proto=target_proto, to_user=from_user,
1364
                                type='replied_to_bridged_user', text=f"""\
1365
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.""")
1366

1367
                continue
1✔
1368

1369
            # deliver self-replies to followers
1370
            # https://github.com/snarfed/bridgy-fed/issues/639
1371
            if id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1372
                is_self_reply = True
1✔
1373
                logger.info(f'self reply!')
1✔
1374

1375
            # also add copies' targets
1376
            for copy in orig_obj.copies:
1✔
1377
                proto = PROTOCOLS[copy.protocol]
1✔
1378
                if proto in to_protocols:
1✔
1379
                    # copies generally won't have their own Objects
1380
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1381
                        logger.info(f'Adding target {target} for copy {copy.uri} of original {id}')
1✔
1382
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1383

1384
            if target_proto == from_cls:
1✔
1385
                logger.info(f'Skipping same-protocol target {id}')
1✔
1386
                continue
1✔
1387

1388
            target = target_proto.target_for(orig_obj)
1✔
1389
            if not target:
1✔
1390
                # TODO: surface errors like this somehow?
1391
                logger.error(f"Can't find delivery target for {id}")
×
1392
                continue
×
1393

1394
            logger.info(f'Target for {id} is {target}')
1✔
1395
            # only use orig_obj for inReplyTos and repost objects
1396
            # https://github.com/snarfed/bridgy-fed/issues/1237
1397
            targets[Target(protocol=target_proto.LABEL, uri=target)] = (
1✔
1398
                orig_obj if id in in_reply_tos or id in as1.get_ids(obj.as1, 'object')
1399
                else None)
1400

1401
            if target_author_key:
1✔
1402
                logger.info(f'Recipient is {target_author_key}')
1✔
1403
                obj.add('notify', target_author_key)
1✔
1404

1405
        if obj.type == 'undo':
1✔
1406
            logger.info('Object is an undo; adding targets for inner object')
1✔
1407
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1408
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1409
            else:
1410
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1411
            if inner_obj:
1✔
1412
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1413
                                                internal=True))
1414

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

1417
        # deliver to followers, if appropriate
1418
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1419
        if not user_key:
1✔
1420
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1421
            return targets
1✔
1422

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

1439
            if (not followers and
1✔
1440
                (util.domain_or_parent_in(
1441
                    util.domain_from_link(from_user.key.id()), LIMITED_DOMAINS)
1442
                 or util.domain_or_parent_in(
1443
                     util.domain_from_link(obj.key.id()), LIMITED_DOMAINS))):
1444
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1445
                return {}
1✔
1446

1447
            # which object should we add to followers' feeds, if any
1448
            feed_obj = None
1✔
1449
            if not internal:
1✔
1450
                if obj.type == 'share':
1✔
1451
                    feed_obj = obj
1✔
1452
                elif obj.type not in ('delete', 'undo', 'stop-following'):
1✔
1453
                    inner = as1.get_object(obj.as1)
1✔
1454
                    # don't add profile updates to feeds
1455
                    if not (obj.type == 'update'
1✔
1456
                            and inner.get('objectType') in as1.ACTOR_TYPES):
1457
                        inner_id = inner.get('id')
1✔
1458
                        if inner_id:
1✔
1459
                            feed_obj = from_cls.load(inner_id, raise_=False)
1✔
1460

1461
            for user in users:
1✔
1462
                if feed_obj:
1✔
1463
                    feed_obj.add('feed', user.key)
1✔
1464

1465
                # TODO: should we pass remote=False through here to Protocol.load?
1466
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1467
                if not target:
1✔
1468
                    # TODO: surface errors like this somehow?
1469
                    logger.error(f'Follower {user.key} has no delivery target')
1✔
1470
                    continue
1✔
1471

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

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

1480
            if feed_obj:
1✔
1481
                feed_obj.put()
1✔
1482

1483
        # deliver to enabled HAS_COPIES protocols proactively
1484
        # TODO: abstract for other protocols
1485
        from atproto import ATProto
1✔
1486
        if (ATProto in to_protocols
1✔
1487
                and obj.type in ('post', 'update', 'delete', 'share')):
1488
            logger.info(f'user has ATProto enabled, adding {ATProto.PDS_URL}')
1✔
1489
            targets.setdefault(
1✔
1490
                Target(protocol=ATProto.LABEL, uri=ATProto.PDS_URL), None)
1491

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

1513
        return targets
1✔
1514

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

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

1522
        Args:
1523
          id (str)
1524
          remote (bool): whether to fetch the object over the network. If True,
1525
            fetches even if we already have the object stored, and updates our
1526
            stored copy. If False and we don't have the object stored, returns
1527
            None. Default (None) means to fetch over the network only if we
1528
            don't already have it stored.
1529
          local (bool): whether to load from the datastore before
1530
            fetching over the network. If False, still stores back to the
1531
            datastore after a successful remote fetch.
1532
          raise_ (bool): if False, catches any :class:`request.RequestException`
1533
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1534
            ``None`` instead
1535
          kwargs: passed through to :meth:`fetch()`
1536

1537
        Returns:
1538
          models.Object: loaded object, or None if it isn't fetchable, eg a
1539
          non-URL string for Web, or ``remote`` is False and it isn't in the
1540
          datastore
1541

1542
        Raises:
1543
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1544
            is True
1545
        """
1546
        assert id
1✔
1547
        assert local or remote is not False
1✔
1548
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1549

1550
        obj = orig_as1 = None
1✔
1551
        if local and not obj:
1✔
1552
            obj = Object.get_by_id(id)
1✔
1553
            if not obj:
1✔
1554
                # logger.debug(f' not in datastore')
1555
                pass
1✔
1556
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1557
                # logger.debug('  got from datastore')
1558
                obj.new = False
1✔
1559

1560
        if remote is False:
1✔
1561
            return obj
1✔
1562
        elif remote is None and obj:
1✔
1563
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1564
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1565
                pass
1✔
1566
            else:
1567
                return obj
1✔
1568

1569
        if obj:
1✔
1570
            orig_as1 = obj.as1
1✔
1571
            obj.clear()
1✔
1572
            obj.new = False
1✔
1573
        else:
1574
            obj = Object(id=id)
1✔
1575
            if local:
1✔
1576
                # logger.debug('  not in datastore')
1577
                obj.new = True
1✔
1578
                obj.changed = False
1✔
1579

1580
        try:
1✔
1581
            fetched = cls.fetch(obj, **kwargs)
1✔
1582
        except (RequestException, HTTPException) as e:
1✔
1583
            if raise_:
1✔
1584
                raise
1✔
1585
            util.interpret_http_exception(e)
1✔
1586
            return None
1✔
1587

1588
        if not fetched:
1✔
1589
            return None
1✔
1590

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

1597
        obj.resolve_ids()
1✔
1598
        obj.normalize_ids()
1✔
1599

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

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

1608
        obj.put()
1✔
1609
        return obj
1✔
1610

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

1615
        Also reports an error.
1616

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

1621
        Args:
1622
          obj (Object)
1623
        """
1624
        if not obj.type:
1✔
1625
            return
×
1626

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

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

1642

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

1647
    Calls :meth:`Protocol.receive` with the form parameters.
1648

1649
    Parameters:
1650
      authed_as (str): passed to :meth:`Protocol.receive`
1651
      obj (url-safe google.cloud.ndb.key.Key): :class:`models.Object` to handle
1652
      *: If ``obj`` is unset, all other parameters are properties for a new
1653
        :class:`models.Object` to handle
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:\n' + '\n'.join(f'{k} = {v[:100]}' for k, v in form.items()))
1✔
1663

1664
    authed_as = form.pop('authed_as', None)
1✔
1665
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1666
                or authed_as in common.PROTOCOL_DOMAINS)
1667

1668
    if obj_key := form.get('obj'):
1✔
1669
        obj = ndb.Key(urlsafe=obj_key).get()
1✔
1670
    else:
1671
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1672
            if val := form.get(json_prop):
1✔
1673
                form[json_prop] = json_loads(val)
1✔
1674
        obj = Object(**form)
1✔
1675

1676
    assert obj
1✔
1677
    assert obj.source_protocol
1✔
1678
    obj.new = True
1✔
1679

1680
    try:
1✔
1681
        return PROTOCOLS[obj.source_protocol].receive(obj=obj, authed_as=authed_as,
1✔
1682
                                                      internal=internal)
1683
    except RequestException as e:
1✔
1684
        util.interpret_http_exception(e)
1✔
1685
        error(e, status=304)
1✔
1686
    except ValueError as e:
1✔
1687
        logger.warning(e, exc_info=True)
1✔
1688
        error(e, status=304)
1✔
1689

1690

1691
@cloud_tasks_only
1✔
1692
def send_task():
1✔
1693
    """Task handler for sending an activity to a single specific destination.
1694

1695
    Calls :meth:`Protocol.send` with the form parameters.
1696

1697
    Parameters:
1698
      protocol (str): :class:`Protocol` to send to
1699
      url (str): destination URL to send to
1700
      obj (url-safe google.cloud.ndb.key.Key): :class:`models.Object` to send
1701
      orig_obj (url-safe google.cloud.ndb.key.Key): optional "original object"
1702
        :class:`models.Object` that this object refers to, eg replies to or
1703
        reposts or likes
1704
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1705
        this activity is from
1706
    """
1707
    form = request.form.to_dict()
1✔
1708
    logger.info(f'Params: {list(form.items())}')
1✔
1709

1710
    # prepare
1711
    url = form.get('url')
1✔
1712
    protocol = form.get('protocol')
1✔
1713
    if not url or not protocol:
1✔
1714
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1715
        return '', 204
1✔
1716

1717
    target = Target(uri=url, protocol=protocol)
1✔
1718

1719
    obj = ndb.Key(urlsafe=form['obj']).get()
1✔
1720
    PROTOCOLS[protocol].check_supported(obj)
1✔
1721
    allow_opt_out = (obj.type == 'delete')
1✔
1722

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

1728
    user = None
1✔
1729
    if user_key := form.get('user'):
1✔
1730
        key = ndb.Key(urlsafe=user_key)
1✔
1731
        # use get_by_id so that we follow use_instead
1732
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(key.id(), allow_opt_out=allow_opt_out)
1✔
1733

1734
    orig_obj = (ndb.Key(urlsafe=form['orig_obj']).get()
1✔
1735
                if form.get('orig_obj') else None)
1736

1737
    # send
1738
    delay = ''
1✔
1739
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
1740
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
1741
        delay = f'({delay_s} s behind)'
1✔
1742
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
1743
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
1744
    sent = None
1✔
1745
    try:
1✔
1746
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user, orig_obj=orig_obj)
1✔
1747
    except BaseException as e:
1✔
1748
        code, body = util.interpret_http_exception(e)
1✔
1749
        if not code and not body:
1✔
1750
            raise
1✔
1751

1752
    if sent is False:
1✔
1753
        logger.info(f'Failed sending!')
1✔
1754

1755
    # write results to Object
1756
    #
1757
    # retry aggressively because this has high contention during inbox delivery.
1758
    # (ndb does exponential backoff.)
1759
    # https://console.cloud.google.com/errors/detail/CJm_4sDv9O-iKg;time=P7D?project=bridgy-federated
1760
    @ndb.transactional(retries=10)
1✔
1761
    def update_object(obj_key):
1✔
1762
        obj = obj_key.get()
1✔
1763
        if target in obj.undelivered:
1✔
1764
            obj.remove('undelivered', target)
1✔
1765

1766
        if sent is None:
1✔
1767
            obj.add('failed', target)
1✔
1768
        else:
1769
            if target in obj.failed:
1✔
1770
                obj.remove('failed', target)
×
1771
            if sent:
1✔
1772
                obj.add('delivered', target)
1✔
1773

1774
        if not obj.undelivered:
1✔
1775
            obj.status = ('complete' if obj.delivered
1✔
1776
                          else 'failed' if obj.failed
1777
                          else 'ignored')
1778
        obj.put()
1✔
1779

1780
    update_object(obj.key)
1✔
1781

1782
    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