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

snarfed / bridgy-fed / b32b718a-0530-4815-9ddd-0bd0aed30c24

11 Oct 2024 10:24PM UTC coverage: 92.757% (+0.02%) from 92.741%
b32b718a-0530-4815-9ddd-0bd0aed30c24

push

circleci

snarfed
noop: tweak a few log messages

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

1 existing line in 1 file now uncovered.

4226 of 4556 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_id=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_id (str): :class:`models.Object` key id of the "original object"
491
            that this object 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
        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_id=id, url=target,
1✔
1128
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1129

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

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

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

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

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

1161
        common.create_task(queue='send', obj_id=follow_back_id, url=target,
1✔
1162
                           protocol=user.LABEL, user=bot.key.urlsafe())
1163

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

1168
        Checks if we've seen it before.
1169

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

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

1181
        obj_actor = as1.get_owner(obj.as1)
1✔
1182
        now = util.now().isoformat()
1✔
1183

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

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

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

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

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

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

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

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

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

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

1280
        return 'OK', 202
1✔
1281

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

1286
        Targets are both objects - original posts, events, etc - and actors.
1287

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

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

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

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

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

1337
            add(to_protocols, proto)
1✔
1338

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

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

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

1365
                continue
1✔
1366

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1478
            if feed_obj:
1✔
1479
                feed_obj.put()
1✔
1480

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

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

1511
        return targets
1✔
1512

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

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

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

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

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

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

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

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

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

1586
        if not fetched:
1✔
1587
            return None
1✔
1588

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

1595
        obj.resolve_ids()
1✔
1596
        obj.normalize_ids()
1✔
1597

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

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

1606
        obj.put()
1✔
1607
        return obj
1✔
1608

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

1613
        Also reports an error.
1614

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

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

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

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

1640

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

1645
    Calls :meth:`Protocol.receive` with the form parameters.
1646

1647
    Parameters:
1648
      authed_as (str): passed to :meth:`Protocol.receive`
1649
      obj_id (str): key id of :class:`models.Object` to handle
1650
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1651
        :class:`models.Object` to handle
1652

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

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

1666
    if obj_id := form.get('obj_id'):
1✔
1667
        obj = Object.get_by_id(obj_id)
1✔
1668
    else:
1669
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1670
            if val := form.get(json_prop):
1✔
1671
                form[json_prop] = json_loads(val)
1✔
1672
        obj = Object(**form)
1✔
1673

1674
    assert obj
1✔
1675
    assert obj.source_protocol
1✔
1676
    obj.new = True
1✔
1677

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

1688

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

1693
    Calls :meth:`Protocol.send` with the form parameters.
1694

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

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

1715
    target = Target(uri=url, protocol=protocol)
1✔
1716

1717
    obj = Object.get_by_id(form['obj_id'])
1✔
1718
    assert obj
1✔
1719

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(
1✔
1733
            key.id(), allow_opt_out=allow_opt_out)
1734

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

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

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

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

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

1779
    update_object(obj.key)
1✔
1780

1781
    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