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

snarfed / bridgy-fed / 64fb6922-c165-4fed-b535-7dfa34ca844a

31 Oct 2024 08:07PM UTC coverage: 92.741% (-0.009%) from 92.75%
64fb6922-c165-4fed-b535-7dfa34ca844a

push

circleci

snarfed
AP: update tests for discoverable, indexable flags in AS2 actors

for #1419, snarfed/granary@2e8a1f8d6

https://docs.joinmastodon.org/spec/activitypub/#discoverable
https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md#specifying-search-indexing-consent-at-the-actor-level

4280 of 4615 relevant lines covered (92.74%)

0.93 hits per line

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

94.87
/protocol.py
1
"""Base protocol class and common code."""
2
import copy
1✔
3
from datetime import datetime, 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
    DOMAIN_BLOCKLIST,
29
    DOMAIN_RE,
30
    DOMAINS,
31
    PRIMARY_DOMAIN,
32
    PROTOCOL_DOMAINS,
33
    report_error,
34
    subdomain_wrap,
35
)
36
import dms
1✔
37
import ids
1✔
38
from ids import (
1✔
39
    BOT_ACTOR_AP_IDS,
40
    normalize_user_id,
41
    translate_object_id,
42
    translate_user_id,
43
)
44
from models import (
1✔
45
    DM,
46
    Follower,
47
    Object,
48
    PROTOCOLS,
49
    PROTOCOLS_BY_KIND,
50
    Target,
51
    User,
52
)
53

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

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

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

63

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

68

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

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

77

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

81

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

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

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

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

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

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

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

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

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

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

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

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

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

178
        To be implemented by subclasses.
179

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

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

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

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

194
        Args:
195
          id (str)
196

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

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

206
        To be implemented by subclasses.
207

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

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

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

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

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

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

235
        To be implemented by subclasses.
236

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

240
        Args:
241
          handle (str)
242

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

368
        Args:
369
          handle (str)
370

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

460
    @classmethod
1✔
461
    def create_for(cls, user):
1✔
462
        """Creates or re-activate a copy user in this protocol.
463

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

466
        If the copy user already exists and active, should do nothing.
467

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

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

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

481
        To be implemented by subclasses.
482

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

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

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

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

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

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

513
        To be implemented by subclasses.
514

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

597
        Default implementation; subclasses may override.
598

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

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

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

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

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

627
    @classmethod
1✔
628
    def set_username(to_cls, user, username):
1✔
629
        """Sets a custom username for a user's bridged account in this protocol.
630

631
        Args:
632
          user (models.User)
633
          username (str)
634

635
        Raises:
636
          ValueError: if the username is invalid
637
          RuntimeError: if the username could not be set
638
        """
639
        raise NotImplementedError()
1✔
640

641
    @classmethod
1✔
642
    def target_for(cls, obj, shared=False):
1✔
643
        """Returns an :class:`Object`'s delivery target (endpoint).
644

645
        To be implemented by subclasses.
646

647
        Examples:
648

649
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
650
          webmention target.
651
        * If obj is an ``activitypub`` actor, returns its inbox.
652
        * If obj is an ``activitypub`` object, returns it's author's or actor's
653
          inbox.
654

655
        Args:
656
          obj (models.Object):
657
          shared (bool): optional. If True, returns a common/shared
658
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
659
            multiple recipients for efficiency
660

661
        Returns:
662
          str: target endpoint, or None if not available.
663
        """
664
        raise NotImplementedError()
×
665

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

670
        Default implementation here, subclasses may override.
671

672
        Args:
673
          url (str):
674
          allow_internal (bool): whether to return False for internal domains
675
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
676
        """
677
        blocklist = DOMAIN_BLOCKLIST
1✔
678
        if not allow_internal:
1✔
679
            blocklist += DOMAINS
1✔
680
        return util.domain_or_parent_in(util.domain_from_link(url), blocklist)
1✔
681

682
    @classmethod
1✔
683
    def translate_ids(to_cls, obj):
1✔
684
        """Translates all ids in an AS1 object to a specific protocol.
685

686
        Infers source protocol for each id value separately.
687

688
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
689
        ``at://did:plc:abc/coll/123`` will be converted to
690
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
691

692
        Wraps these AS1 fields:
693

694
        * ``id``
695
        * ``actor``
696
        * ``author``
697
        * ``bcc``
698
        * ``bto``
699
        * ``cc``
700
        * ``object``
701
        * ``object.actor``
702
        * ``object.author``
703
        * ``object.id``
704
        * ``object.inReplyTo``
705
        * ``attachments[].id``
706
        * ``tags[objectType=mention].url``
707
        * ``to``
708

709
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
710
        same logic is duplicated there!
711

712
        TODO: unify with :meth:`Object.resolve_ids`,
713
        :meth:`models.Object.normalize_ids`.
714

715
        Args:
716
          to_proto (Protocol subclass)
717
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
718

719
        Returns:
720
          dict: wrapped AS1 version of ``obj``
721
        """
722
        assert to_cls != Protocol
1✔
723
        if not obj:
1✔
724
            return obj
1✔
725

726
        outer_obj = copy.deepcopy(obj)
1✔
727
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
728

729
        def translate(elem, field, fn, uri=False):
1✔
730
            elem[field] = as1.get_objects(elem, field)
1✔
731
            for obj in elem[field]:
1✔
732
                if id := obj.get('id'):
1✔
733
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
734
                        continue
1✔
735
                    from_cls = Protocol.for_id(id)
1✔
736
                    # TODO: what if from_cls is None? relax translate_object_id,
737
                    # make it a noop if we don't know enough about from/to?
738
                    if from_cls and from_cls != to_cls:
1✔
739
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
740
                    if obj['id'] and uri:
1✔
741
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
742

743
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
744
                           for o in elem[field]]
745

746
            if len(elem[field]) == 1:
1✔
747
                elem[field] = elem[field][0]
1✔
748

749
        type = as1.object_type(outer_obj)
1✔
750
        translate(outer_obj, 'id',
1✔
751
                  translate_user_id if type in as1.ACTOR_TYPES
752
                  else translate_object_id)
753

754
        for o in inner_objs:
1✔
755
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
756
                        or as1.get_owner(outer_obj) == o.get('id')
757
                        or type in ('follow', 'stop-following'))
758
            translate(o, 'id', translate_user_id if is_actor else translate_object_id)
1✔
759

760
        for o in [outer_obj] + inner_objs:
1✔
761
            translate(o, 'inReplyTo', translate_object_id)
1✔
762
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
763
                translate(o, field, translate_user_id)
1✔
764
            for tag in as1.get_objects(o, 'tags'):
1✔
765
                if tag.get('objectType') == 'mention':
1✔
766
                    translate(tag, 'url', translate_user_id, uri=True)
1✔
767
            for att in as1.get_objects(o, 'attachments'):
1✔
768
                translate(att, 'id', translate_object_id)
1✔
769
                url = att.get('url')
1✔
770
                if url and not att.get('id'):
1✔
771
                    if from_cls := Protocol.for_id(url):
1✔
772
                        att['id'] = translate_object_id(from_=from_cls, to=to_cls,
1✔
773
                                                        id=url)
774

775
        outer_obj = util.trim_nulls(outer_obj)
1✔
776

777
        if objs := outer_obj.get('object', []):
1✔
778
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
779
            if len(outer_obj['object']) == 1:
1✔
780
                outer_obj['object'] = outer_obj['object'][0]
1✔
781

782
        return outer_obj
1✔
783

784
    @classmethod
1✔
785
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
786
        """Handles an incoming activity.
787

788
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
789
        unset, returns HTTP 299.
790

791
        Args:
792
          obj (models.Object)
793
          authed_as (str): authenticated actor id who sent this activity
794
          internal (bool): whether to allow activity ids on internal domains,
795
            from opted out/blocked users, etc.
796
          received_at (datetime): when we first saw (received) this activity.
797
            Right now only used for monitoring.
798

799
        Returns:
800
          (str, int) tuple: (response body, HTTP status code) Flask response
801

802
        Raises:
803
          werkzeug.HTTPException: if the request is invalid
804
        """
805
        # check some invariants
806
        assert from_cls != Protocol
1✔
807
        assert isinstance(obj, Object), obj
1✔
808

809
        if not obj.as1:
1✔
810
            error('No object data provided')
×
811

812
        id = None
1✔
813
        if obj.key and obj.key.id():
1✔
814
            id = obj.key.id()
1✔
815

816
        if not id:
1✔
817
            id = obj.as1.get('id')
1✔
818
            obj.key = ndb.Key(Object, id)
1✔
819

820
        if not id:
1✔
821
            error('No id provided')
×
822
        elif from_cls.owns_id(id) is False:
1✔
823
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
824
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
825
            error(f'Activity {id} is blocklisted')
1✔
826
        # check that this activity is public. only do this for some activities,
827
        # not eg likes or follows, since Mastodon doesn't currently mark those
828
        # as explicitly public.
829
        elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
830
                  and not as1.is_public(obj.as1, unlisted=False)
831
                  and not as1.is_dm(obj.as1)):
832
              logger.info('Dropping non-public activity')
1✔
833
              return ('OK', 200)
1✔
834

835
        # lease this object, atomically
836
        memcache_key = activity_id_memcache_key(id)
1✔
837
        leased = common.memcache.add(memcache_key, 'leased', noreply=False,
1✔
838
                                     expire=5 * 60)  # 5 min
839
        # short circuit if we've already seen this activity id.
840
        # (don't do this for bare objects since we need to check further down
841
        # whether they've been updated since we saw them last.)
842
        if (obj.as1.get('objectType') == 'activity'
1✔
843
            and 'force' not in request.values
844
            and (not leased
845
                 or (obj.new is False and obj.changed is False)
846
                 # TODO: how does this make sense? won't these two lines
847
                 # always be true?!
848
                 or (obj.new is None and obj.changed is None
849
                     and from_cls.load(id, remote=False)))):
850
            error(f'Already seen this activity {id}', status=204)
1✔
851

852
        pruned = {k: v for k, v in obj.as1.items()
1✔
853
                  if k not in ('contentMap', 'replies', 'signature')}
854
        delay = ''
1✔
855
        if received_at and request.headers.get('X-AppEngine-TaskRetryCount') == '0':
1✔
856
            delay_s = int((util.now().replace(tzinfo=None)
×
857
                           - received_at.replace(tzinfo=None)
858
                           ).total_seconds())
859
            delay = f'({delay_s} s behind)'
×
860
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
861

862
        # does this protocol support this activity/object type?
863
        from_cls.check_supported(obj)
1✔
864

865
        # load actor user, check authorization
866
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
867
        actor = as1.get_owner(obj.as1)
1✔
868
        if not actor:
1✔
869
            error('Activity missing actor or author')
1✔
870
        elif from_cls.owns_id(actor) is False:
1✔
871
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
872

873
        assert authed_as
1✔
874
        assert isinstance(authed_as, str)
1✔
875
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
876
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
877
        if actor != authed_as:
1✔
878
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
879
                         user=f'{id} authed_as {authed_as} owner {actor}')
880
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
881

882
        # update copy ids to originals
883
        obj.normalize_ids()
1✔
884
        obj.resolve_ids()
1✔
885

886
        if (obj.type == 'follow'
1✔
887
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
888
            # follows of bot user; refresh user profile first
889
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
890
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
891
            from_user.reload_profile()
1✔
892
        else:
893
            # load actor user
894
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
895

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

899
        # write Object to datastore
900
        orig = obj
1✔
901
        obj = Object.get_or_create(id, authed_as=actor, **orig.to_dict())
1✔
902
        if orig.new is not None:
1✔
903
            obj.new = orig.new
1✔
904
        if orig.changed is not None:
1✔
905
            obj.changed = orig.changed
1✔
906

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

911
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
912
        if obj.type in as1.CRUD_VERBS:
1✔
913
            if inner_owner := as1.get_owner(inner_obj_as1):
1✔
914
                if inner_owner_key := from_cls.key_for(inner_owner):
1✔
915
                    obj.add('users', inner_owner_key)
1✔
916

917
        obj.source_protocol = from_cls.LABEL
1✔
918
        obj.put()
1✔
919

920
        # store inner object
921
        inner_obj_id = inner_obj_as1.get('id')
1✔
922
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
923
            Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
924
                                 source_protocol=from_cls.LABEL, authed_as=actor)
925

926
        actor = as1.get_object(obj.as1, 'actor')
1✔
927
        actor_id = actor.get('id')
1✔
928

929
        # handle activity!
930
        if obj.type == 'stop-following':
1✔
931
            # TODO: unify with handle_follow?
932
            # TODO: handle multiple followees
933
            if not actor_id or not inner_obj_id:
1✔
934
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
935

936
            # deactivate Follower
937
            from_ = from_cls.key_for(actor_id)
1✔
938
            to_cls = Protocol.for_id(inner_obj_id)
1✔
939
            to = to_cls.key_for(inner_obj_id)
1✔
940
            follower = Follower.query(Follower.to == to,
1✔
941
                                      Follower.from_ == from_,
942
                                      Follower.status == 'active').get()
943
            if follower:
1✔
944
                logger.info(f'Marking {follower} inactive')
1✔
945
                follower.status = 'inactive'
1✔
946
                follower.put()
1✔
947
            else:
948
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
949

950
            # fall through to deliver to followee
951
            # TODO: do we convert stop-following to webmention 410 of original
952
            # follow?
953

954
        elif obj.type in ('update', 'like', 'share'):  # require object
1✔
955
            if not inner_obj_id:
1✔
956
                error("Couldn't find id of object to update")
1✔
957

958
            # fall through to deliver to followers
959

960
        elif obj.type in ('delete', 'undo'):
1✔
961
            if not inner_obj_id:
1✔
962
                error("Couldn't find id of object to delete")
×
963

964
            logger.info(f'Marking Object {inner_obj_id} deleted')
1✔
965
            Object.get_or_create(inner_obj_id, deleted=True, authed_as=authed_as)
1✔
966

967
            # if this is an actor, handle deleting it later so that
968
            # in case it's from_user, user.enabled_protocols is still populated
969
            #
970
            # fall through to deliver to followers and delete copy if necessary.
971
            # should happen via protocol-specific copy target and send of
972
            # delete activity.
973
            # https://github.com/snarfed/bridgy-fed/issues/63
974

975
        elif obj.type == 'block':
1✔
976
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
977
                # blocking protocol bot user disables that protocol
978
                from_user.delete(proto)
1✔
979
                from_user.disable_protocol(proto)
1✔
980
                return 'OK', 200
1✔
981

982
        elif obj.type == 'post':
1✔
983
            # handle DMs to bot users
984
            if as1.is_dm(obj.as1):
1✔
985
                return dms.receive(from_user=from_user, obj=obj)
1✔
986

987
        # fetch actor if necessary
988
        if (actor and actor.keys() == set(['id'])
1✔
989
                and obj.type not in ('delete', 'undo')):
990
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
991
            actor_obj = from_cls.load(actor['id'], raise_=False)
1✔
992
            if actor_obj and actor_obj.as1:
1✔
993
                obj.our_as1 = {**obj.as1, 'actor': actor_obj.as1}
1✔
994

995
        # fetch object if necessary so we can render it in feeds
996
        if (obj.type == 'share'
1✔
997
                and inner_obj_as1.keys() == set(['id'])
998
                and from_cls.owns_id(inner_obj_id)):
999
            logger.debug('Fetching object so we can render it in feeds')
1✔
1000
            inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1001
            if inner_obj and inner_obj.as1:
1✔
1002
                obj.our_as1 = {
1✔
1003
                    **obj.as1,
1004
                    'object': {
1005
                        **inner_obj_as1,
1006
                        **inner_obj.as1,
1007
                    }
1008
                }
1009

1010
        if obj.type == 'follow':
1✔
1011
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1012
                # follow of one of our protocol bot users; enable that protocol.
1013
                # foll through so that we send an accept.
1014
                from_user.enable_protocol(proto)
1✔
1015
                proto.bot_follow(from_user)
1✔
1016

1017
            from_cls.handle_follow(obj)
1✔
1018

1019
        # deliver to targets
1020
        resp = from_cls.deliver(obj, from_user=from_user)
1✔
1021

1022
        # if this is a user, deactivate its followers/followings
1023
        # https://github.com/snarfed/bridgy-fed/issues/1304
1024
        if obj.type == 'delete':
1✔
1025
            if user_key := from_cls.key_for(id=inner_obj_id):
1✔
1026
                if user := user_key.get():
1✔
1027
                    for proto in user.enabled_protocols:
1✔
1028
                        user.disable_protocol(PROTOCOLS[proto])
1✔
1029

1030
                    logger.info(f'Deactivating Followers from or to = {inner_obj_id}')
1✔
1031
                    followers = Follower.query(
1✔
1032
                        OR(Follower.to == user_key, Follower.from_ == user_key)
1033
                        ).fetch()
1034
                    for f in followers:
1✔
1035
                        f.status = 'inactive'
1✔
1036
                    ndb.put_multi(followers)
1✔
1037

1038
        common.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1039
        return resp
1✔
1040

1041
    @classmethod
1✔
1042
    def handle_follow(from_cls, obj):
1✔
1043
        """Handles an incoming follow activity.
1044

1045
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1046
        happens in :meth:`deliver`.
1047

1048
        Args:
1049
          obj (models.Object): follow activity
1050
        """
1051
        logger.debug('Got follow. Loading users, storing Follow(s), sending accept(s)')
1✔
1052

1053
        # Prepare follower (from) users' data
1054
        from_as1 = as1.get_object(obj.as1, 'actor')
1✔
1055
        from_id = from_as1.get('id')
1✔
1056
        if not from_id:
1✔
1057
            error(f'Follow activity requires actor. Got: {obj.as1}')
×
1058

1059
        from_obj = from_cls.load(from_id, raise_=False)
1✔
1060
        if not from_obj:
1✔
1061
            error(f"Couldn't load {from_id}", status=502)
×
1062

1063
        if not from_obj.as1:
1✔
1064
            from_obj.our_as1 = from_as1
1✔
1065
            from_obj.put()
1✔
1066

1067
        from_key = from_cls.key_for(from_id)
1✔
1068
        if not from_key:
1✔
1069
            error(f'Invalid {from_cls} user key: {from_id}')
×
1070
        obj.users = [from_key]
1✔
1071
        from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
1✔
1072

1073
        # Prepare followee (to) users' data
1074
        to_as1s = as1.get_objects(obj.as1)
1✔
1075
        if not to_as1s:
1✔
1076
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1077

1078
        # Store Followers
1079
        for to_as1 in to_as1s:
1✔
1080
            to_id = to_as1.get('id')
1✔
1081
            if not to_id:
1✔
1082
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1083

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

1086
            to_cls = Protocol.for_id(to_id)
1✔
1087
            if not to_cls:
1✔
1088
                error(f"Couldn't determine protocol for {to_id}")
×
1089
            elif from_cls == to_cls:
1✔
1090
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1091
                continue
1✔
1092

1093
            to_obj = to_cls.load(to_id)
1✔
1094
            if to_obj and not to_obj.as1:
1✔
1095
                to_obj.our_as1 = to_as1
1✔
1096
                to_obj.put()
1✔
1097

1098
            to_key = to_cls.key_for(to_id)
1✔
1099
            if not to_key:
1✔
1100
                logger.info(f'Skipping invalid {from_cls} user key: {from_id}')
×
1101
                continue
×
1102

1103
            # If followee user is already direct, follower may not know they're
1104
            # interacting with a bridge. if followee user is indirect though,
1105
            # follower should know, so they're direct.
1106
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj, direct=False,
1✔
1107
                                           allow_opt_out=True)
1108
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1109
                                                  follow=obj.key, status='active')
1110
            obj.add('notify', to_key)
1✔
1111
            from_cls.maybe_accept_follow(follower=from_user, followee=to_user,
1✔
1112
                                         follow=obj)
1113

1114
    @classmethod
1✔
1115
    def maybe_accept_follow(_, follower, followee, follow):
1✔
1116
        """Sends an accept activity for a follow.
1117

1118
        ...if the follower protocol handles accepts. Otherwise, does nothing.
1119

1120
        Args:
1121
          follower: :class:`models.User`
1122
          followee: :class:`models.User`
1123
          follow: :class:`models.Object`
1124
        """
1125
        if 'accept' not in follower.SUPPORTED_AS1_TYPES:
1✔
1126
            return
1✔
1127

1128
        target = follower.target_for(follower.obj)
1✔
1129
        if not target:
1✔
1130
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1131

1132
        # send accept. note that this is one accept for the whole
1133
        # follow, even if it has multiple followees!
1134
        id = f'{followee.key.id()}/followers#accept-{follow.key.id()}'
1✔
1135
        undelivered = [Target(protocol=follower.LABEL, uri=target)]
1✔
1136
        accept = {
1✔
1137
            'id': id,
1138
            'objectType': 'activity',
1139
            'verb': 'accept',
1140
            'actor': followee.key.id(),
1141
            'object': follow.as1,
1142
        }
1143
        Object.get_or_create(id, authed_as=followee.key.id(),
1✔
1144
                             undelivered=undelivered, our_as1=accept)
1145

1146
        common.create_task(queue='send', obj_id=id, url=target,
1✔
1147
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1148

1149
    @classmethod
1✔
1150
    def bot_follow(bot_cls, user):
1✔
1151
        """Follow a user from a protocol bot user.
1152

1153
        ...so that the protocol starts sending us their activities, if it needs
1154
        a follow for that (eg ActivityPub).
1155

1156
        Args:
1157
          user (User)
1158
        """
1159
        from web import Web
1✔
1160
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1161
        now = util.now().isoformat()
1✔
1162
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1163

1164
        if not user.obj:
1✔
1165
            logger.info("  can't follow, user has no profile obj")
1✔
1166
            return
1✔
1167

1168
        target = user.target_for(user.obj)
1✔
1169
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1170
        Object(id=follow_back_id, source_protocol='web',
1✔
1171
               undelivered=[Target(protocol=user.LABEL, uri=target)],
1172
               our_as1={
1173
                   'objectType': 'activity',
1174
                   'verb': 'follow',
1175
                   'id': follow_back_id,
1176
                   'actor': bot.key.id(),
1177
                   'object': user.key.id(),
1178
               }).put()
1179

1180
        common.create_task(queue='send', obj_id=follow_back_id, url=target,
1✔
1181
                           protocol=user.LABEL, user=bot.key.urlsafe())
1182

1183
    @classmethod
1✔
1184
    def handle_bare_object(cls, obj, authed_as=None):
1✔
1185
        """If obj is a bare object, wraps it in a create or update activity.
1186

1187
        Checks if we've seen it before.
1188

1189
        Args:
1190
          obj (models.Object)
1191
          authed_as (str): authenticated actor id who sent this activity
1192

1193
        Returns:
1194
          models.Object: ``obj`` if it's an activity, otherwise a new object
1195
        """
1196
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1197
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1198
            return obj
1✔
1199

1200
        obj_actor = as1.get_owner(obj.as1)
1✔
1201
        now = util.now().isoformat()
1✔
1202

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

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

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

1254
    @classmethod
1✔
1255
    def deliver(from_cls, obj, from_user, to_proto=None):
1✔
1256
        """Delivers an activity to its external recipients.
1257

1258
        Args:
1259
          obj (models.Object): activity to deliver
1260
          from_user (models.User): user (actor) this activity is from
1261
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1262
            targets on this protocol
1263

1264
        Returns:
1265
          (str, int) tuple: Flask response
1266
        """
1267
        if to_proto:
1✔
1268
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1269

1270
        # find delivery targets. maps Target to Object or None
1271
        targets = from_cls.targets(obj, from_user=from_user)
1✔
1272

1273
        if not targets:
1✔
1274
            obj.status = 'ignored'
1✔
1275
            obj.put()
1✔
1276
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1277

1278
        # sort targets so order is deterministic for tests, debugging, etc
1279
        sorted_targets = sorted(targets.items(), key=lambda t: t[0].uri)
1✔
1280
        obj.populate(
1✔
1281
            status='in progress',
1282
            delivered=[],
1283
            failed=[],
1284
            undelivered=[t for t, _ in sorted_targets],
1285
        )
1286
        obj.put()
1✔
1287
        logger.info(f'Delivering to: {obj.undelivered}')
1✔
1288

1289
        # enqueue send task for each targets
1290
        user = from_user.key.urlsafe()
1✔
1291
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1292
            if to_proto and target.protocol != to_proto.LABEL:
1✔
1293
                continue
×
1294
            orig_obj_id = orig_obj.key.id() if orig_obj else ''
1✔
1295
            common.create_task(queue='send', obj_id=obj.key.id(),
1✔
1296
                               url=target.uri, protocol=target.protocol,
1297
                               orig_obj_id=orig_obj_id, user=user)
1298

1299
        return 'OK', 202
1✔
1300

1301
    @classmethod
1✔
1302
    def targets(from_cls, obj, from_user, internal=False):
1✔
1303
        """Collects the targets to send a :class:`models.Object` to.
1304

1305
        Targets are both objects - original posts, events, etc - and actors.
1306

1307
        Args:
1308
          obj (models.Object)
1309
          from_user (User)
1310
          internal (bool): whether this is a recursive internal call
1311

1312
        Returns:
1313
          dict: maps :class:`models.Target` to original (in response to)
1314
          :class:`models.Object`, if any, otherwise None
1315
        """
1316
        logger.info('Finding recipients and their targets')
1✔
1317

1318
        target_uris = sorted(set(as1.targets(obj.as1)))
1✔
1319
        logger.info(f'Raw targets: {target_uris}')
1✔
1320
        orig_obj = None
1✔
1321
        targets = {}  # maps Target to Object or None
1✔
1322
        owner = as1.get_owner(obj.as1)
1✔
1323
        allow_opt_out = (obj.type == 'delete')
1✔
1324
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1325
        inner_obj_id = inner_obj_as1.get('id')
1✔
1326
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1327
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1328
        is_self_reply = False
1✔
1329

1330
        if is_reply:
1✔
1331
            original_ids = in_reply_tos
1✔
1332
        else:
1333
            if inner_obj_id == from_user.key.id():
1✔
1334
                inner_obj_id = from_user.profile_id()
1✔
1335
            original_ids = [inner_obj_id]
1✔
1336

1337
        # which protocols should we allow delivering to?
1338
        to_protocols = []
1✔
1339
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1340
                      + from_user.enabled_protocols):
1341
            proto = PROTOCOLS[label]
1✔
1342
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1343
                                     or is_reply):
1344
                for id in original_ids:
1✔
1345
                    if Protocol.for_id(id) == proto:
1✔
1346
                        logger.info(f'Allowing {label} for original post {id}')
1✔
1347
                        break
1✔
1348
                    elif orig := from_user.load(id, remote=False):
1✔
1349
                        if orig.get_copy(proto):
1✔
1350
                            logger.info(f'Allowing {label}, original post {id} was bridged there')
1✔
1351
                            break
1✔
1352
                else:
1353
                    logger.info(f"Skipping {label}, original posts {original_ids} weren't bridged there")
1✔
1354
                    continue
1✔
1355

1356
            util.add(to_protocols, proto)
1✔
1357

1358
        # process direct targets
1359
        for id in sorted(target_uris):
1✔
1360
            target_proto = Protocol.for_id(id)
1✔
1361
            if not target_proto:
1✔
1362
                logger.info(f"Can't determine protocol for {id}")
1✔
1363
                continue
1✔
1364
            elif target_proto.is_blocklisted(id):
1✔
1365
                logger.info(f'{id} is blocklisted')
1✔
1366
                continue
1✔
1367

1368
            orig_obj = target_proto.load(id, raise_=False)
1✔
1369
            if not orig_obj or not orig_obj.as1:
1✔
1370
                logger.info(f"Couldn't load {id}")
1✔
1371
                continue
1✔
1372

1373
            target_author_key = target_proto.actor_key(orig_obj)
1✔
1374
            if not from_user.is_enabled(target_proto):
1✔
1375
                # if author isn't bridged and inReplyTo author is, DM a prompt
1376
                if id in in_reply_tos:
1✔
1377
                    if target_author := target_author_key.get():
1✔
1378
                        if target_author.is_enabled(from_cls):
1✔
1379
                            dms.maybe_send(
1✔
1380
                                from_proto=target_proto, to_user=from_user,
1381
                                type='replied_to_bridged_user', text=f"""\
1382
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.""")
1383

1384
                continue
1✔
1385

1386
            # deliver self-replies to followers
1387
            # https://github.com/snarfed/bridgy-fed/issues/639
1388
            if id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1389
                is_self_reply = True
1✔
1390
                logger.info(f'self reply!')
1✔
1391

1392
            # also add copies' targets
1393
            for copy in orig_obj.copies:
1✔
1394
                proto = PROTOCOLS[copy.protocol]
1✔
1395
                if proto in to_protocols:
1✔
1396
                    # copies generally won't have their own Objects
1397
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1398
                        logger.info(f'Adding target {target} for copy {copy.uri} of original {id}')
1✔
1399
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1400

1401
            if target_proto == from_cls:
1✔
1402
                logger.info(f'Skipping same-protocol target {id}')
1✔
1403
                continue
1✔
1404

1405
            target = target_proto.target_for(orig_obj)
1✔
1406
            if not target:
1✔
1407
                # TODO: surface errors like this somehow?
1408
                logger.error(f"Can't find delivery target for {id}")
×
1409
                continue
×
1410

1411
            logger.info(f'Target for {id} is {target}')
1✔
1412
            # only use orig_obj for inReplyTos and repost objects
1413
            # https://github.com/snarfed/bridgy-fed/issues/1237
1414
            targets[Target(protocol=target_proto.LABEL, uri=target)] = (
1✔
1415
                orig_obj if id in in_reply_tos or id in as1.get_ids(obj.as1, 'object')
1416
                else None)
1417

1418
            if target_author_key:
1✔
1419
                logger.info(f'Recipient is {target_author_key}')
1✔
1420
                obj.add('notify', target_author_key)
1✔
1421

1422
        if obj.type == 'undo':
1✔
1423
            logger.info('Object is an undo; adding targets for inner object')
1✔
1424
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1425
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1426
            else:
1427
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1428
            if inner_obj:
1✔
1429
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1430
                                                internal=True))
1431

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

1434
        # deliver to followers, if appropriate
1435
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1436
        if not user_key:
1✔
1437
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1438
            return targets
1✔
1439

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

1456
            if (not followers and
1✔
1457
                (util.domain_or_parent_in(
1458
                    util.domain_from_link(from_user.key.id()), LIMITED_DOMAINS)
1459
                 or util.domain_or_parent_in(
1460
                     util.domain_from_link(obj.key.id()), LIMITED_DOMAINS))):
1461
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1462
                return {}
1✔
1463

1464
            # which object should we add to followers' feeds, if any
1465
            feed_obj = None
1✔
1466
            if not internal:
1✔
1467
                if obj.type == 'share':
1✔
1468
                    feed_obj = obj
1✔
1469
                elif obj.type not in ('delete', 'undo', 'stop-following'):
1✔
1470
                    inner = as1.get_object(obj.as1)
1✔
1471
                    # don't add profile updates to feeds
1472
                    if not (obj.type == 'update'
1✔
1473
                            and inner.get('objectType') in as1.ACTOR_TYPES):
1474
                        inner_id = inner.get('id')
1✔
1475
                        if inner_id:
1✔
1476
                            feed_obj = from_cls.load(inner_id, raise_=False)
1✔
1477

1478
            for user in users:
1✔
1479
                if feed_obj:
1✔
1480
                    feed_obj.add('feed', user.key)
1✔
1481

1482
                # TODO: should we pass remote=False through here to Protocol.load?
1483
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1484
                if not target:
1✔
1485
                    # TODO: surface errors like this somehow?
1486
                    logger.error(f'Follower {user.key} has no delivery target')
1✔
1487
                    continue
1✔
1488

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

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

1497
            if feed_obj:
1✔
1498
                feed_obj.put()
1✔
1499

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

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

1530
        return targets
1✔
1531

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

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

1539
        Args:
1540
          id (str)
1541
          remote (bool): whether to fetch the object over the network. If True,
1542
            fetches even if we already have the object stored, and updates our
1543
            stored copy. If False and we don't have the object stored, returns
1544
            None. Default (None) means to fetch over the network only if we
1545
            don't already have it stored.
1546
          local (bool): whether to load from the datastore before
1547
            fetching over the network. If False, still stores back to the
1548
            datastore after a successful remote fetch.
1549
          raise_ (bool): if False, catches any :class:`request.RequestException`
1550
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1551
            ``None`` instead
1552
          kwargs: passed through to :meth:`fetch()`
1553

1554
        Returns:
1555
          models.Object: loaded object, or None if it isn't fetchable, eg a
1556
          non-URL string for Web, or ``remote`` is False and it isn't in the
1557
          datastore
1558

1559
        Raises:
1560
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1561
            is True
1562
        """
1563
        assert id
1✔
1564
        assert local or remote is not False
1✔
1565
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1566

1567
        obj = orig_as1 = None
1✔
1568
        if local and not obj:
1✔
1569
            obj = Object.get_by_id(id)
1✔
1570
            if not obj:
1✔
1571
                # logger.debug(f' not in datastore')
1572
                pass
1✔
1573
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1574
                # logger.debug('  got from datastore')
1575
                obj.new = False
1✔
1576

1577
        if remote is False:
1✔
1578
            return obj
1✔
1579
        elif remote is None and obj:
1✔
1580
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1581
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1582
                pass
1✔
1583
            else:
1584
                return obj
1✔
1585

1586
        if obj:
1✔
1587
            orig_as1 = obj.as1
1✔
1588
            obj.clear()
1✔
1589
            obj.new = False
1✔
1590
        else:
1591
            obj = Object(id=id)
1✔
1592
            if local:
1✔
1593
                # logger.debug('  not in datastore')
1594
                obj.new = True
1✔
1595
                obj.changed = False
1✔
1596

1597
        try:
1✔
1598
            fetched = cls.fetch(obj, **kwargs)
1✔
1599
        except (RequestException, HTTPException) as e:
1✔
1600
            if raise_:
1✔
1601
                raise
1✔
1602
            util.interpret_http_exception(e)
1✔
1603
            return None
1✔
1604

1605
        if not fetched:
1✔
1606
            return None
1✔
1607

1608
        # https://stackoverflow.com/a/3042250/186123
1609
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1610
        if size > models.MAX_ENTITY_SIZE:
1✔
1611
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1612
            return None
1✔
1613

1614
        obj.resolve_ids()
1✔
1615
        obj.normalize_ids()
1✔
1616

1617
        if obj.new is False:
1✔
1618
            obj.changed = obj.activity_changed(orig_as1)
1✔
1619

1620
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1621
            if obj.source_protocol:
1✔
1622
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1623
            obj.source_protocol = cls.LABEL
1✔
1624

1625
        obj.put()
1✔
1626
        return obj
1✔
1627

1628
    @classmethod
1✔
1629
    def check_supported(cls, obj):
1✔
1630
        """If this protocol doesn't support this object, return 204.
1631

1632
        Also reports an error.
1633

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

1638
        Args:
1639
          obj (Object)
1640
        """
1641
        if not obj.type:
1✔
1642
            return
×
1643

1644
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1645
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1646
            or (obj.type in as1.CRUD_VERBS
1647
                and inner_type
1648
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1649
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1650

1651
        # DMs are only allowed to/from protocol bot accounts
1652
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1653
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1654
            if (not cls.SUPPORTS_DMS
1✔
1655
                    or (recip not in protocol_user_ids
1656
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1657
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1658

1659

1660
@cloud_tasks_only
1✔
1661
def receive_task():
1✔
1662
    """Task handler for a newly received :class:`models.Object`.
1663

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

1666
    Parameters:
1667
      authed_as (str): passed to :meth:`Protocol.receive`
1668
      obj_id (str): key id of :class:`models.Object` to handle
1669
      received_at (str, ISO 8601 timestamp): when we first saw (received)
1670
        this activity
1671
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1672
        :class:`models.Object` to handle
1673

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

1683
    authed_as = form.pop('authed_as', None)
1✔
1684
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1685
                or authed_as in common.PROTOCOL_DOMAINS)
1686
    if received_at := form.pop('received_at', None):
1✔
1687
        received_at = datetime.fromisoformat(received_at)
1✔
1688

1689
    if obj_id := form.get('obj_id'):
1✔
1690
        obj = Object.get_by_id(obj_id)
1✔
1691
    else:
1692
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1693
            if val := form.get(json_prop):
1✔
1694
                form[json_prop] = json_loads(val)
1✔
1695
        obj = Object(**form)
1✔
1696

1697
    assert obj
1✔
1698
    assert obj.source_protocol
1✔
1699
    obj.new = True
1✔
1700

1701
    try:
1✔
1702
        return PROTOCOLS[obj.source_protocol].receive(
1✔
1703
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
1704
    except RequestException as e:
1✔
1705
        util.interpret_http_exception(e)
1✔
1706
        error(e, status=304)
1✔
1707
    except ValueError as e:
1✔
1708
        logger.warning(e, exc_info=True)
1✔
1709
        error(e, status=304)
1✔
1710

1711

1712
@cloud_tasks_only
1✔
1713
def send_task():
1✔
1714
    """Task handler for sending an activity to a single specific destination.
1715

1716
    Calls :meth:`Protocol.send` with the form parameters.
1717

1718
    Parameters:
1719
      protocol (str): :class:`Protocol` to send to
1720
      url (str): destination URL to send to
1721
      obj_id (str): key id of :class:`models.Object` to send
1722
      orig_obj_id (str): optional, :class:`models.Object` key id of the
1723
        "original object" that this object refers to, eg replies to or reposts
1724
        or likes
1725
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1726
        this activity is from
1727
    """
1728
    form = request.form.to_dict()
1✔
1729
    logger.info(f'Params: {list(form.items())}')
1✔
1730

1731
    # prepare
1732
    url = form.get('url')
1✔
1733
    protocol = form.get('protocol')
1✔
1734
    if not url or not protocol:
1✔
1735
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1736
        return '', 204
1✔
1737

1738
    target = Target(uri=url, protocol=protocol)
1✔
1739

1740
    obj = Object.get_by_id(form['obj_id'])
1✔
1741
    assert obj
1✔
1742

1743
    PROTOCOLS[protocol].check_supported(obj)
1✔
1744
    allow_opt_out = (obj.type == 'delete')
1✔
1745

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

1751
    user = None
1✔
1752
    if user_key := form.get('user'):
1✔
1753
        key = ndb.Key(urlsafe=user_key)
1✔
1754
        # use get_by_id so that we follow use_instead
1755
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
1756
            key.id(), allow_opt_out=allow_opt_out)
1757

1758
    # send
1759
    delay = ''
1✔
1760
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
1761
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
1762
        delay = f'({delay_s} s behind)'
1✔
1763
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
1764
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
1765
    sent = None
1✔
1766
    try:
1✔
1767
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
1768
                                        orig_obj_id=form.get('orig_obj_id'))
1769
    except BaseException as e:
1✔
1770
        code, body = util.interpret_http_exception(e)
1✔
1771
        if not code and not body:
1✔
1772
            raise
1✔
1773

1774
    if sent is False:
1✔
1775
        logger.info(f'Failed sending!')
1✔
1776

1777
    # write results to Object
1778
    #
1779
    # retry aggressively because this has high contention during inbox delivery.
1780
    # (ndb does exponential backoff.)
1781
    # https://console.cloud.google.com/errors/detail/CJm_4sDv9O-iKg;time=P7D?project=bridgy-federated
1782
    @ndb.transactional(retries=10)
1✔
1783
    def update_object(obj_key):
1✔
1784
        obj = obj_key.get()
1✔
1785
        if target in obj.undelivered:
1✔
1786
            obj.remove('undelivered', target)
1✔
1787

1788
        if sent is None:
1✔
1789
            obj.add('failed', target)
1✔
1790
        else:
1791
            if target in obj.failed:
1✔
1792
                obj.remove('failed', target)
×
1793
            if sent:
1✔
1794
                obj.add('delivered', target)
1✔
1795

1796
        if not obj.undelivered:
1✔
1797
            obj.status = ('complete' if obj.delivered
1✔
1798
                          else 'failed' if obj.failed
1799
                          else 'ignored')
1800
        obj.put()
1✔
1801

1802
    update_object(obj.key)
1✔
1803

1804
    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