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

snarfed / bridgy-fed / 1736300d-fe94-4286-972a-5a6772f073d6

08 Nov 2024 04:52AM UTC coverage: 92.728% (-0.08%) from 92.809%
1736300d-fe94-4286-972a-5a6772f073d6

push

circleci

snarfed
ATProto.handle_to_id: reuse DatastoreClient.resolve_handle

5 of 7 new or added lines in 1 file covered. (71.43%)

23 existing lines in 1 file now uncovered.

4297 of 4634 relevant lines covered (92.73%)

0.93 hits per line

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

94.62
/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
1✔
897
                             or from_user.manual_opt_out
898
                             # we want to override opt-out but not manual or blocked
899
                             or (from_user.status and from_user.status != 'opt-out')):
900
            error(f'Actor {actor} is opted out or blocked', status=204)
1✔
901

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

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

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

920
        obj.source_protocol = from_cls.LABEL
1✔
921
        obj.put()
1✔
922

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

929
        actor = as1.get_object(obj.as1, 'actor')
1✔
930
        actor_id = actor.get('id')
1✔
931

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

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

953
            # fall through to deliver to followee
954
            # TODO: do we convert stop-following to webmention 410 of original
955
            # follow?
956

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

961
            # fall through to deliver to followers
962

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

967
            logger.info(f'Marking Object {inner_obj_id} deleted')
1✔
968
            Object.get_or_create(inner_obj_id, deleted=True, authed_as=authed_as)
1✔
969

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

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

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

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

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

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

1020
            from_cls.handle_follow(obj)
1✔
1021

1022
        # deliver to targets
1023
        resp = from_cls.deliver(obj, from_user=from_user)
1✔
1024

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

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

1041
        common.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1042
        return resp
1✔
1043

1044
    @classmethod
1✔
1045
    def handle_follow(from_cls, obj):
1✔
1046
        """Handles an incoming follow activity.
1047

1048
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1049
        happens in :meth:`deliver`.
1050

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

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

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

1066
        if not from_obj.as1:
1✔
1067
            from_obj.our_as1 = from_as1
1✔
1068
            from_obj.put()
1✔
1069

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

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

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

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

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

1096
            to_obj = to_cls.load(to_id)
1✔
1097
            if to_obj and not to_obj.as1:
1✔
1098
                to_obj.our_as1 = to_as1
1✔
1099
                to_obj.put()
1✔
1100

1101
            to_key = to_cls.key_for(to_id)
1✔
1102
            if not to_key:
1✔
UNCOV
1103
                logger.info(f'Skipping invalid {from_cls.LABEL} user key: {from_id}')
×
UNCOV
1104
                continue
×
1105

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

1117
    @classmethod
1✔
1118
    def maybe_accept_follow(_, follower, followee, follow):
1✔
1119
        """Sends an accept activity for a follow.
1120

1121
        ...if the follower protocol handles accepts. Otherwise, does nothing.
1122

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

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

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

1149
        common.create_task(queue='send', obj_id=id, url=target,
1✔
1150
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1151

1152
    @classmethod
1✔
1153
    def bot_follow(bot_cls, user):
1✔
1154
        """Follow a user from a protocol bot user.
1155

1156
        ...so that the protocol starts sending us their activities, if it needs
1157
        a follow for that (eg ActivityPub).
1158

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

1167
        if not user.obj:
1✔
1168
            logger.info("  can't follow, user has no profile obj")
1✔
1169
            return
1✔
1170

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

1183
        common.create_task(queue='send', obj_id=follow_back_id, url=target,
1✔
1184
                           protocol=user.LABEL, user=bot.key.urlsafe())
1185

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

1190
        Checks if we've seen it before.
1191

1192
        Args:
1193
          obj (models.Object)
1194
          authed_as (str): authenticated actor id who sent this activity
1195

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

1203
        obj_actor = as1.get_owner(obj.as1)
1✔
1204
        now = util.now().isoformat()
1✔
1205

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

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

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

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

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

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

1273
        # find delivery targets. maps Target to Object or None
1274
        targets = from_cls.targets(obj, from_user=from_user)
1✔
1275

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

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

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

1302
        return 'OK', 202
1✔
1303

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

1308
        Targets are both objects - original posts, events, etc - and actors.
1309

1310
        Args:
1311
          obj (models.Object)
1312
          from_user (User)
1313
          internal (bool): whether this is a recursive internal call
1314

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

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

1333
        if is_reply:
1✔
1334
            original_ids = in_reply_tos
1✔
1335
        else:
1336
            if inner_obj_id == from_user.key.id():
1✔
1337
                inner_obj_id = from_user.profile_id()
1✔
1338
            original_ids = [inner_obj_id]
1✔
1339

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

1359
            util.add(to_protocols, proto)
1✔
1360

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

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

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

1387
                continue
1✔
1388

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

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

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

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

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

1421
            if target_author_key:
1✔
1422
                logger.info(f'Recipient is {target_author_key}')
1✔
1423
                obj.add('notify', target_author_key)
1✔
1424

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

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

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

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

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

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

1481
            for user in users:
1✔
1482
                if feed_obj:
1✔
1483
                    feed_obj.add('feed', user.key)
1✔
1484

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

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

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

1500
            if feed_obj:
1✔
1501
                feed_obj.put()
1✔
1502

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

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

1533
        return targets
1✔
1534

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

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

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

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

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

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

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

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

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

1608
        if not fetched:
1✔
1609
            return None
1✔
1610

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

1617
        obj.resolve_ids()
1✔
1618
        obj.normalize_ids()
1✔
1619

1620
        if obj.new is False:
1✔
1621
            obj.changed = obj.activity_changed(orig_as1)
1✔
1622

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

1628
        obj.put()
1✔
1629
        return obj
1✔
1630

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

1635
        Also reports an error.
1636

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

1641
        Args:
1642
          obj (Object)
1643
        """
1644
        if not obj.type:
1✔
UNCOV
1645
            return
×
1646

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

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

1662

1663
@cloud_tasks_only
1✔
1664
def receive_task():
1✔
1665
    """Task handler for a newly received :class:`models.Object`.
1666

1667
    Calls :meth:`Protocol.receive` with the form parameters.
1668

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

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

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

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

1700
    assert obj
1✔
1701
    assert obj.source_protocol
1✔
1702
    obj.new = True
1✔
1703

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

1714

1715
@cloud_tasks_only
1✔
1716
def send_task():
1✔
1717
    """Task handler for sending an activity to a single specific destination.
1718

1719
    Calls :meth:`Protocol.send` with the form parameters.
1720

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

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

1741
    target = Target(uri=url, protocol=protocol)
1✔
1742

1743
    obj = Object.get_by_id(form['obj_id'])
1✔
1744
    assert obj
1✔
1745

1746
    PROTOCOLS[protocol].check_supported(obj)
1✔
1747
    allow_opt_out = (obj.type == 'delete')
1✔
1748

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

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

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

1777
    if sent is False:
1✔
1778
        logger.info(f'Failed sending!')
1✔
1779

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

1791
        if sent is None:
1✔
1792
            obj.add('failed', target)
1✔
1793
        else:
1794
            if target in obj.failed:
1✔
UNCOV
1795
                obj.remove('failed', target)
×
1796
            if sent:
1✔
1797
                obj.add('delivered', target)
1✔
1798

1799
        if not obj.undelivered:
1✔
1800
            obj.status = ('complete' if obj.delivered
1✔
1801
                          else 'failed' if obj.failed
1802
                          else 'ignored')
1803
        obj.put()
1✔
1804

1805
    update_object(obj.key)
1✔
1806

1807
    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