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

snarfed / bridgy-fed / 6f815cb4-4d7d-487e-a07a-218399f88a89

14 Jan 2025 12:19AM UTC coverage: 92.752%. Remained the same
6f815cb4-4d7d-487e-a07a-218399f88a89

push

circleci

snarfed
updates for ndb context caching everything: Object.new/changed logic, tests

for #1149, 18aa302da

14 of 14 new or added lines in 2 files covered. (100.0%)

39 existing lines in 3 files now uncovered.

4479 of 4829 relevant lines covered (92.75%)

0.93 hits per line

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

95.01
/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
import memcache
1✔
45
from models import (
1✔
46
    DM,
47
    Follower,
48
    Object,
49
    PROTOCOLS,
50
    PROTOCOLS_BY_KIND,
51
    Target,
52
    User,
53
)
54

55
OBJECT_REFRESH_AGE = timedelta(days=30)
1✔
56
DELETE_TASK_DELAY = timedelta(minutes=2)
1✔
57

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

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

65

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

70

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

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

79

80
def activity_id_memcache_key(id):
1✔
81
    return memcache.key(f'receive-{id}')
1✔
82

83

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

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

118
    """
119
    ABBREV = None
1✔
120
    PHRASE = None
1✔
121
    OTHER_LABELS = ()
1✔
122
    LOGO_HTML = ''
1✔
123
    CONTENT_TYPE = None
1✔
124
    HAS_COPIES = False
1✔
125
    REQUIRES_AVATAR = False
1✔
126
    REQUIRES_NAME = False
1✔
127
    REQUIRES_OLD_ACCOUNT = False
1✔
128
    DEFAULT_ENABLED_PROTOCOLS = ()
1✔
129
    DEFAULT_SERVE_USER_PAGES = False
1✔
130
    SUPPORTED_AS1_TYPES = ()
1✔
131
    SUPPORTS_DMS = False
1✔
132

133
    def __init__(self):
1✔
134
        assert False
×
135

136
    @classmethod
1✔
137
    @property
1✔
138
    def LABEL(cls):
1✔
139
        return cls.__name__.lower()
1✔
140

141
    @staticmethod
1✔
142
    def for_request(fed=None):
1✔
143
        """Returns the protocol for the current request.
144

145
        ...based on the request's hostname.
146

147
        Args:
148
          fed (str or protocol.Protocol): protocol to return if the current
149
            request is on ``fed.brid.gy``
150

151
        Returns:
152
          Protocol: protocol, or None if the provided domain or request hostname
153
          domain is not a subdomain of ``brid.gy`` or isn't a known protocol
154
        """
155
        return Protocol.for_bridgy_subdomain(request.host, fed=fed)
1✔
156

157
    @staticmethod
1✔
158
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
159
        """Returns the protocol for a brid.gy subdomain.
160

161
        Args:
162
          domain_or_url (str)
163
          fed (str or protocol.Protocol): protocol to return if the current
164
            request is on ``fed.brid.gy``
165

166
        Returns:
167
          class: :class:`Protocol` subclass, or None if the provided domain or request
168
          hostname domain is not a subdomain of ``brid.gy`` or isn't a known
169
          protocol
170
        """
171
        domain = (util.domain_from_link(domain_or_url, minimize=False)
1✔
172
                  if util.is_web(domain_or_url)
173
                  else domain_or_url)
174

175
        if domain == common.PRIMARY_DOMAIN or domain in common.LOCAL_DOMAINS:
1✔
176
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
1✔
177
        elif domain and domain.endswith(common.SUPERDOMAIN):
1✔
178
            label = domain.removesuffix(common.SUPERDOMAIN)
1✔
179
            return PROTOCOLS.get(label)
1✔
180

181
    @classmethod
1✔
182
    def owns_id(cls, id):
1✔
183
        """Returns whether this protocol owns the id, or None if it's unclear.
184

185
        To be implemented by subclasses.
186

187
        IDs are string identities that uniquely identify users, and are intended
188
        primarily to be machine readable and usable. Compare to handles, which
189
        are human-chosen, human-meaningful, and often but not always unique.
190

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

195
        This should be a quick guess without expensive side effects, eg no
196
        external HTTP fetches to fetch the id itself or otherwise perform
197
        discovery.
198

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

201
        Args:
202
          id (str)
203

204
        Returns:
205
          bool or None:
206
        """
207
        return False
1✔
208

209
    @classmethod
1✔
210
    def owns_handle(cls, handle, allow_internal=False):
1✔
211
        """Returns whether this protocol owns the handle, or None if it's unclear.
212

213
        To be implemented by subclasses.
214

215
        Handles are string identities that are human-chosen, human-meaningful,
216
        and often but not always unique. Compare to IDs, which uniquely identify
217
        users, and are intended primarily to be machine readable and usable.
218

219
        Some protocols' handles are more or less deterministic based on the id
220
        format, eg ActivityPub (technically WebFinger) handles are
221
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
222
        ActivityPub, AT Protocol, or others.
223

224
        This should be a quick guess without expensive side effects, eg no
225
        external HTTP fetches to fetch the id itself or otherwise perform
226
        discovery.
227

228
        Args:
229
          handle (str)
230
          allow_internal (bool): whether to return False for internal domains
231
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
232

233
        Returns:
234
          bool or None
235
        """
236
        return False
1✔
237

238
    @classmethod
1✔
239
    def handle_to_id(cls, handle):
1✔
240
        """Converts a handle to an id.
241

242
        To be implemented by subclasses.
243

244
        May incur network requests, eg DNS queries or HTTP requests. Avoids
245
        blocked or opted out users.
246

247
        Args:
248
          handle (str)
249

250
        Returns:
251
          str: corresponding id, or None if the handle can't be found
252
        """
253
        raise NotImplementedError()
×
254

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

259
        To be implemented by subclasses. Canonicalizes the id if necessary.
260

261
        If called via `Protocol.key_for`, infers the appropriate protocol with
262
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
263
        as is.
264

265
        Args:
266
          id (str):
267
          allow_opt_out (bool): whether to allow users who are currently opted out
268

269
        Returns:
270
          google.cloud.ndb.Key: matching key, or None if the given id is not a
271
          valid :class:`User` id for this protocol.
272
        """
273
        if cls == Protocol:
1✔
274
            proto = Protocol.for_id(id)
1✔
275
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
276

277
        # load user so that we follow use_instead
278
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
279
        if existing:
1✔
280
            if existing.status and not allow_opt_out:
1✔
281
                return None
1✔
282
            return existing.key
1✔
283

284
        return cls(id=id).key
1✔
285

286
    @cached(LRUCache(20000), lock=Lock())
1✔
287
    @staticmethod
1✔
288
    def for_id(id, remote=True):
1✔
289
        """Returns the protocol for a given id.
290

291
        Args:
292
          id (str)
293
          remote (bool): whether to perform expensive side effects like fetching
294
            the id itself over the network, or other discovery.
295

296
        Returns:
297
          Protocol subclass: matching protocol, or None if no single known
298
          protocol definitively owns this id
299
        """
300
        logger.debug(f'Determining protocol for id {id}')
1✔
301
        if not id:
1✔
302
            return None
1✔
303

304
        if util.is_web(id):
1✔
305
            # step 1: check for our per-protocol subdomains
306
            try:
1✔
307
                is_homepage = urlparse(id).path.strip('/') == ''
1✔
308
            except ValueError as e:
1✔
309
                logger.info(f'urlparse ValueError: {e}')
1✔
310
                return None
1✔
311

312
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
313
            if by_subdomain and not is_homepage and id not in BOT_ACTOR_AP_IDS:
1✔
314
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
315
                return by_subdomain
1✔
316

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

330
        if len(candidates) == 1:
1✔
331
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
332
            return candidates[0]
1✔
333

334
        # step 3: look for existing Objects in the datastore
335
        obj = Protocol.load(id, remote=False)
1✔
336
        if obj and obj.source_protocol:
1✔
337
            logger.debug(f'  {obj.key.id()} owned by source_protocol {obj.source_protocol}')
1✔
338
            return PROTOCOLS[obj.source_protocol]
1✔
339

340
        # step 4: fetch over the network, if necessary
341
        if not remote:
1✔
342
            return None
1✔
343

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

364
        logger.info(f'No matching protocol found for {id} !')
1✔
365
        return None
1✔
366

367
    @cached(LRUCache(20000), lock=Lock())
1✔
368
    @staticmethod
1✔
369
    def for_handle(handle):
1✔
370
        """Returns the protocol for a given handle.
371

372
        May incur expensive side effects like resolving the handle itself over
373
        the network or other discovery.
374

375
        Args:
376
          handle (str)
377

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

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

400
        if len(candidates) == 1:
1✔
401
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
402
            return (candidates[0], None)
×
403

404
        # step 2: look for matching User in the datastore
405
        for proto in candidates:
1✔
406
            user = proto.query(proto.handle == handle).get()
1✔
407
            if user:
1✔
408
                if user.status:
1✔
409
                    return (None, None)
1✔
410
                logger.debug(f'  user {user.key} handle {handle}')
1✔
411
                return (proto, user.key.id())
1✔
412

413
        # step 3: resolve handle to id
414
        for proto in candidates:
1✔
415
            id = proto.handle_to_id(handle)
1✔
416
            if id:
1✔
417
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
418
                return (proto, id)
1✔
419

420
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
421
        return (None, None)
1✔
422

423
    @classmethod
1✔
424
    def bridged_web_url_for(cls, user, fallback=False):
1✔
425
        """Returns the web URL for a user's bridged profile in this protocol.
426

427
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
428
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
429

430
        Args:
431
          user (models.User)
432
          fallback (bool): if True, and bridged users have no canonical user
433
            profile URL in this protocol, return the native protocol's profile URL
434

435
        Returns:
436
          str, or None if there isn't a canonical URL
437
        """
438
        if fallback:
1✔
439
            return user.web_url()
1✔
440

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

445
        Args:
446
          obj (models.Object)
447
          allow_opt_out (bool): whether to return a user key if they're opted out
448

449
        Returns:
450
          google.cloud.ndb.key.Key or None:
451
        """
452
        owner = as1.get_owner(obj.as1)
1✔
453
        if owner:
1✔
454
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
455

456
    @classmethod
1✔
457
    def bot_user_id(cls):
1✔
458
        """Returns the Web user id for the bot user for this protocol.
459

460
        For example, ``'bsky.brid.gy'`` for ATProto.
461

462
        Returns:
463
          str:
464
        """
465
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
466

467
    @classmethod
1✔
468
    def create_for(cls, user):
1✔
469
        """Creates or re-activate a copy user in this protocol.
470

471
        Should add the copy user to :attr:`copies`.
472

473
        If the copy user already exists and active, should do nothing.
474

475
        Args:
476
          user (models.User): original source user. Shouldn't already have a
477
            copy user for this protocol in :attr:`copies`.
478

479
        Raises:
480
          ValueError: if we can't create a copy of the given user in this protocol
481
        """
482
        raise NotImplementedError()
×
483

484
    @classmethod
1✔
485
    def send(to_cls, obj, url, from_user=None, orig_obj_id=None):
1✔
486
        """Sends an outgoing activity.
487

488
        To be implemented by subclasses.
489

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

494
        Args:
495
          obj (models.Object): with activity to send
496
          url (str): destination URL to send to
497
          from_user (models.User): user (actor) this activity is from
498
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
499
            that this object refers to, eg replies to or reposts or likes
500

501
        Returns:
502
          bool: True if the activity is sent successfully, False if it is
503
          ignored or otherwise unsent due to protocol logic, eg no webmention
504
          endpoint, protocol doesn't support the activity type. (Failures are
505
          raised as exceptions.)
506

507
        Raises:
508
          werkzeug.HTTPException if the request fails
509
        """
510
        raise NotImplementedError()
×
511

512
    @classmethod
1✔
513
    def fetch(cls, obj, **kwargs):
1✔
514
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
515

516
        Errors are raised as exceptions. If this method returns False, the fetch
517
        didn't fail but didn't succeed either, eg the id isn't valid for this
518
        protocol, or the fetch didn't return valid data for this protocol.
519

520
        To be implemented by subclasses.
521

522
        Args:
523
          obj (models.Object): with the id to fetch. Data is filled into one of
524
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
525
          kwargs: subclass-specific
526

527
        Returns:
528
          bool: True if the object was fetched and populated successfully,
529
          False otherwise
530

531
        Raises:
532
          requests.RequestException or werkzeug.HTTPException: if the fetch fails
533
        """
534
        raise NotImplementedError()
×
535

536
    @classmethod
1✔
537
    def convert(cls, obj, from_user=None, **kwargs):
1✔
538
        """Converts an :class:`Object` to this protocol's data format.
539

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

543
        Just passes through to :meth:`_convert`, then does minor
544
        protocol-independent postprocessing.
545

546
        Args:
547
          obj (models.Object):
548
          from_user (models.User): user (actor) this activity/object is from
549
          kwargs: protocol-specific, passed through to :meth:`_convert`
550

551
        Returns:
552
          converted object in the protocol's native format, often a dict
553
        """
554
        if not obj or not obj.as1:
1✔
555
            return {}
1✔
556

557
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
558
        is_activity = obj.as1.get('verb') in ('post', 'update')
1✔
559
        base_obj = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
560
        orig_our_as1 = obj.our_as1
1✔
561

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

572
            obj.our_as1 = copy.deepcopy(obj.as1)
1✔
573
            actor = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
574
            actor['objectType'] = 'person'
1✔
575
            cls.add_source_links(actor=actor, obj=obj, from_user=from_user)
1✔
576

577
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
578
        obj.our_as1 = orig_our_as1
1✔
579
        return converted
1✔
580

581
    @classmethod
1✔
582
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
583
        """Converts an :class:`Object` to this protocol's data format.
584

585
        To be implemented by subclasses. Implementations should generally call
586
        :meth:`Protocol.translate_ids` (as their own class) before converting to
587
        their format.
588

589
        Args:
590
          obj (models.Object):
591
          from_user (models.User): user (actor) this activity/object is from
592
          kwargs: protocol-specific
593

594
        Returns:
595
          converted object in the protocol's native format, often a dict. May
596
            return the ``{}`` empty dict if the object can't be converted.
597
        """
598
        raise NotImplementedError()
×
599

600
    @classmethod
1✔
601
    def add_source_links(cls, actor, obj, from_user):
1✔
602
        """Adds "bridged from ... by Bridgy Fed" HTML to ``actor['summary']``.
603

604
        Default implementation; subclasses may override.
605

606
        Args:
607
          actor (dict): AS1 actor
608
          obj (models.Object):
609
          from_user (models.User): user (actor) this activity/object is from
610
        """
611
        assert from_user
1✔
612
        summary = actor.setdefault('summary', '')
1✔
613
        if 'Bridgy Fed]' in html_to_text(summary, ignore_links=True):
1✔
614
            return
1✔
615

616
        id = actor.get('id')
1✔
617
        proto_phrase = (PROTOCOLS[obj.source_protocol].PHRASE
1✔
618
                        if obj.source_protocol else '')
619
        if proto_phrase:
1✔
620
            proto_phrase = f' on {proto_phrase}'
1✔
621

622
        if from_user.key and id in (from_user.key.id(), from_user.profile_id()):
1✔
623
            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✔
624

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

630
        if summary:
1✔
631
            summary += '<br><br>'
1✔
632
        actor['summary'] = summary + source_links
1✔
633

634
    @classmethod
1✔
635
    def set_username(to_cls, user, username):
1✔
636
        """Sets a custom username for a user's bridged account in this protocol.
637

638
        Args:
639
          user (models.User)
640
          username (str)
641

642
        Raises:
643
          ValueError: if the username is invalid
644
          RuntimeError: if the username could not be set
645
        """
646
        raise NotImplementedError()
1✔
647

648
    @classmethod
1✔
649
    def target_for(cls, obj, shared=False):
1✔
650
        """Returns an :class:`Object`'s delivery target (endpoint).
651

652
        To be implemented by subclasses.
653

654
        Examples:
655

656
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
657
          webmention target.
658
        * If obj is an ``activitypub`` actor, returns its inbox.
659
        * If obj is an ``activitypub`` object, returns it's author's or actor's
660
          inbox.
661

662
        Args:
663
          obj (models.Object):
664
          shared (bool): optional. If True, returns a common/shared
665
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
666
            multiple recipients for efficiency
667

668
        Returns:
669
          str: target endpoint, or None if not available.
670
        """
671
        raise NotImplementedError()
×
672

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

677
        Default implementation here, subclasses may override.
678

679
        Args:
680
          url (str):
681
          allow_internal (bool): whether to return False for internal domains
682
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
683
        """
684
        blocklist = DOMAIN_BLOCKLIST
1✔
685
        if not allow_internal:
1✔
686
            blocklist += DOMAINS
1✔
687
        return util.domain_or_parent_in(util.domain_from_link(url), blocklist)
1✔
688

689
    @classmethod
1✔
690
    def translate_ids(to_cls, obj):
1✔
691
        """Translates all ids in an AS1 object to a specific protocol.
692

693
        Infers source protocol for each id value separately.
694

695
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
696
        ``at://did:plc:abc/coll/123`` will be converted to
697
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
698

699
        Wraps these AS1 fields:
700

701
        * ``id``
702
        * ``actor``
703
        * ``author``
704
        * ``bcc``
705
        * ``bto``
706
        * ``cc``
707
        * ``object``
708
        * ``object.actor``
709
        * ``object.author``
710
        * ``object.id``
711
        * ``object.inReplyTo``
712
        * ``object.object``
713
        * ``attachments[].id``
714
        * ``tags[objectType=mention].url``
715
        * ``to``
716

717
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
718
        same logic is duplicated there!
719

720
        TODO: unify with :meth:`Object.resolve_ids`,
721
        :meth:`models.Object.normalize_ids`.
722

723
        Args:
724
          to_proto (Protocol subclass)
725
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
726

727
        Returns:
728
          dict: wrapped AS1 version of ``obj``
729
        """
730
        assert to_cls != Protocol
1✔
731
        if not obj:
1✔
732
            return obj
1✔
733

734
        outer_obj = copy.deepcopy(obj)
1✔
735
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
736

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

751
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
752
                           for o in elem[field]]
753

754
            if len(elem[field]) == 1:
1✔
755
                elem[field] = elem[field][0]
1✔
756

757
        type = as1.object_type(outer_obj)
1✔
758
        translate(outer_obj, 'id',
1✔
759
                  translate_user_id if type in as1.ACTOR_TYPES
760
                  else translate_object_id)
761

762
        for o in inner_objs:
1✔
763
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
764
                        or as1.get_owner(outer_obj) == o.get('id')
765
                        or type in ('follow', 'stop-following'))
766
            translate(o, 'id', translate_user_id if is_actor else translate_object_id)
1✔
767
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
768
            translate(o, 'object', translate_user_id if obj_is_actor
1✔
769
                      else translate_object_id)
770

771
        for o in [outer_obj] + inner_objs:
1✔
772
            translate(o, 'inReplyTo', translate_object_id)
1✔
773
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
774
                translate(o, field, translate_user_id)
1✔
775
            for tag in as1.get_objects(o, 'tags'):
1✔
776
                if tag.get('objectType') == 'mention':
1✔
777
                    translate(tag, 'url', translate_user_id, uri=True)
1✔
778
            for att in as1.get_objects(o, 'attachments'):
1✔
779
                translate(att, 'id', translate_object_id)
1✔
780
                url = att.get('url')
1✔
781
                if url and not att.get('id'):
1✔
782
                    if from_cls := Protocol.for_id(url):
1✔
783
                        att['id'] = translate_object_id(from_=from_cls, to=to_cls,
1✔
784
                                                        id=url)
785

786
        outer_obj = util.trim_nulls(outer_obj)
1✔
787

788
        if objs := util.get_list(outer_obj ,'object'):
1✔
789
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
790
            if len(outer_obj['object']) == 1:
1✔
791
                outer_obj['object'] = outer_obj['object'][0]
1✔
792

793
        return outer_obj
1✔
794

795
    @classmethod
1✔
796
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
797
        """Handles an incoming activity.
798

799
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
800
        unset, returns HTTP 299.
801

802
        Args:
803
          obj (models.Object)
804
          authed_as (str): authenticated actor id who sent this activity
805
          internal (bool): whether to allow activity ids on internal domains,
806
            from opted out/blocked users, etc.
807
          received_at (datetime): when we first saw (received) this activity.
808
            Right now only used for monitoring.
809

810
        Returns:
811
          (str, int) tuple: (response body, HTTP status code) Flask response
812

813
        Raises:
814
          werkzeug.HTTPException: if the request is invalid
815
        """
816
        # check some invariants
817
        assert from_cls != Protocol
1✔
818
        assert isinstance(obj, Object), obj
1✔
819

820
        if not obj.as1:
1✔
821
            error('No object data provided')
×
822

823
        id = None
1✔
824
        if obj.key and obj.key.id():
1✔
825
            id = obj.key.id()
1✔
826

827
        if not id:
1✔
828
            id = obj.as1.get('id')
1✔
829
            obj.key = ndb.Key(Object, id)
1✔
830

831
        if not id:
1✔
832
            error('No id provided')
×
833
        elif from_cls.owns_id(id) is False:
1✔
834
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
835
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
836
            error(f'Activity {id} is blocklisted')
1✔
837
        # check that this activity is public. only do this for some activities,
838
        # not eg likes or follows, since Mastodon doesn't currently mark those
839
        # as explicitly public.
840
        elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
841
                  and not as1.is_public(obj.as1, unlisted=False)
842
                  and not as1.is_dm(obj.as1)):
843
              logger.info('Dropping non-public activity')
1✔
844
              return ('OK', 200)
1✔
845

846
        # lease this object, atomically
847
        memcache_key = activity_id_memcache_key(id)
1✔
848
        leased = memcache.memcache.add(memcache_key, 'leased', noreply=False,
1✔
849
                                     expire=5 * 60)  # 5 min
850
        # short circuit if we've already seen this activity id.
851
        # (don't do this for bare objects since we need to check further down
852
        # whether they've been updated since we saw them last.)
853
        if (obj.as1.get('objectType') == 'activity'
1✔
854
            and 'force' not in request.values
855
            and (not leased
856
                 or (obj.new is False and obj.changed is False))):
857
            error(f'Already seen this activity {id}', status=204)
1✔
858

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

869
        # does this protocol support this activity/object type?
870
        from_cls.check_supported(obj)
1✔
871

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

880
        assert authed_as
1✔
881
        assert isinstance(authed_as, str)
1✔
882
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
883
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
884
        if actor != authed_as:
1✔
885
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
886
                         user=f'{id} authed_as {authed_as} owner {actor}')
887
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
888

889
        # update copy ids to originals
890
        obj.normalize_ids()
1✔
891
        obj.resolve_ids()
1✔
892

893
        if (obj.type == 'follow'
1✔
894
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
895
            # follows of bot user; refresh user profile first
896
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
897
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
898
            from_user.reload_profile()
1✔
899
        else:
900
            # load actor user
901
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
902

903
        if not internal and (not from_user
1✔
904
                             or from_user.manual_opt_out
905
                             # we want to override opt-out but not manual or blocked
906
                             or (from_user.status and from_user.status != 'opt-out')):
907
            error(f'Actor {actor} is opted out or blocked', status=204)
1✔
908

909
        # write Object to datastore
910
        orig_props = obj.to_dict()
1✔
911
        obj = Object.get_or_create(id, new=obj.new, changed=obj.changed,
1✔
912
                                   authed_as=actor, **orig_props)
913

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

918
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
919
        inner_obj_id = inner_obj_as1.get('id')
1✔
920
        if obj.type in as1.CRUD_VERBS | set(('like', 'share')):
1✔
921
            if not inner_obj_id:
1✔
922
                error(f'{obj.type} object has no id!')
1✔
923

924
        if obj.type in as1.CRUD_VERBS:
1✔
925
            if inner_owner := as1.get_owner(inner_obj_as1):
1✔
926
                if inner_owner_key := from_cls.key_for(inner_owner):
1✔
927
                    obj.add('users', inner_owner_key)
1✔
928

929
        obj.source_protocol = from_cls.LABEL
1✔
930
        obj.put()
1✔
931

932
        # store inner object
933
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
934
            Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
935
                                 source_protocol=from_cls.LABEL, authed_as=actor)
936

937
        actor = as1.get_object(obj.as1, 'actor')
1✔
938
        actor_id = actor.get('id')
1✔
939

940
        # handle activity!
941
        if obj.type == 'stop-following':
1✔
942
            # TODO: unify with handle_follow?
943
            # TODO: handle multiple followees
944
            if not actor_id or not inner_obj_id:
1✔
UNCOV
945
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
946

947
            # deactivate Follower
948
            from_ = from_cls.key_for(actor_id)
1✔
949
            to_cls = Protocol.for_id(inner_obj_id)
1✔
950
            to = to_cls.key_for(inner_obj_id)
1✔
951
            follower = Follower.query(Follower.to == to,
1✔
952
                                      Follower.from_ == from_,
953
                                      Follower.status == 'active').get()
954
            if follower:
1✔
955
                logger.info(f'Marking {follower} inactive')
1✔
956
                follower.status = 'inactive'
1✔
957
                follower.put()
1✔
958
            else:
959
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
960

961
            # fall through to deliver to followee
962
            # TODO: do we convert stop-following to webmention 410 of original
963
            # follow?
964

965
            # fall through to deliver to followers
966

967
        elif obj.type in ('delete', 'undo'):
1✔
968
            assert inner_obj_id
1✔
969
            logger.info(f'Marking Object {inner_obj_id} deleted')
1✔
970
            Object.get_or_create(inner_obj_id, deleted=True, authed_as=authed_as)
1✔
971

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

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

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

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

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

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

1022
            from_cls.handle_follow(obj)
1✔
1023

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1108
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj,
1✔
1109
                                           allow_opt_out=True)
1110
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1111
                                                  follow=obj.key, status='active')
1112
            obj.add('notify', to_key)
1✔
1113
            from_cls.maybe_accept_follow(follower=from_user, followee=to_user,
1✔
1114
                                         follow=obj)
1115

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

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

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

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

1134
        # send accept. note that this is one accept for the whole
1135
        # follow, even if it has multiple followees!
1136
        id = f'{followee.key.id()}/followers#accept-{follow.key.id()}'
1✔
1137
        accept = {
1✔
1138
            'id': id,
1139
            'objectType': 'activity',
1140
            'verb': 'accept',
1141
            'actor': followee.key.id(),
1142
            'object': follow.as1,
1143
        }
1144
        Object.get_or_create(id, authed_as=followee.key.id(), our_as1=accept)
1✔
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
               our_as1={
1172
                   'objectType': 'activity',
1173
                   'verb': 'follow',
1174
                   'id': follow_back_id,
1175
                   'actor': bot.key.id(),
1176
                   'object': user.key.id(),
1177
               }).put()
1178

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

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

1186
        Checks if we've seen it before.
1187

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

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

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

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

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

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

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

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

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

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

1272
        # TODO: this would be clearer if it was at the end of receive(), which
1273
        # that *should* be equivalent, but oddly tests fail if it's moved there
1274
        obj.put()
1✔
1275
        if not targets:
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
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1281

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

1292
        return 'OK', 202
1✔
1293

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

1298
        Targets are both objects - original posts, events, etc - and actors.
1299

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

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

1311
        target_uris = sorted(set(as1.targets(obj.as1)))
1✔
1312
        logger.info(f'Raw targets: {target_uris}')
1✔
1313
        orig_obj = None
1✔
1314
        targets = {}  # maps Target to Object or None
1✔
1315
        owner = as1.get_owner(obj.as1)
1✔
1316
        allow_opt_out = (obj.type == 'delete')
1✔
1317
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1318
        inner_obj_id = inner_obj_as1.get('id')
1✔
1319
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1320
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1321
        is_self_reply = False
1✔
1322

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

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

1349
            util.add(to_protocols, proto)
1✔
1350

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

1361
            orig_obj = target_proto.load(id, raise_=False)
1✔
1362
            if not orig_obj or not orig_obj.as1:
1✔
1363
                logger.info(f"Couldn't load {id}")
1✔
1364
                continue
1✔
1365

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

1377
                continue
1✔
1378

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

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

1394
            if target_proto == from_cls:
1✔
1395
                logger.debug(f'Skipping same-protocol target {id}')
1✔
1396
                continue
1✔
1397

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

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

1411
            if target_author_key:
1✔
1412
                logger.debug(f'Recipient is {target_author_key}')
1✔
1413
                obj.add('notify', target_author_key)
1✔
1414

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

1425
        logger.info(f'Direct targets: {[t.uri for t in targets.keys()]}')
1✔
1426

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

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

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

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

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

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

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

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

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

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

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

1523
        return targets
1✔
1524

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

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

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

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

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

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

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

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

1590
        try:
1✔
1591
            fetched = cls.fetch(obj, **kwargs)
1✔
1592
        except (RequestException, HTTPException) as e:
1✔
1593
            if raise_:
1✔
1594
                raise
1✔
1595
            util.interpret_http_exception(e)
1✔
1596
            return None
1✔
1597

1598
        if not fetched:
1✔
1599
            return None
1✔
1600

1601
        # https://stackoverflow.com/a/3042250/186123
1602
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1603
        if size > models.MAX_ENTITY_SIZE:
1✔
1604
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1605
            return None
1✔
1606

1607
        obj.resolve_ids()
1✔
1608
        obj.normalize_ids()
1✔
1609

1610
        if obj.new is False:
1✔
1611
            obj.changed = obj.activity_changed(orig_as1)
1✔
1612

1613
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1614
            if obj.source_protocol:
1✔
UNCOV
1615
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1616
            obj.source_protocol = cls.LABEL
1✔
1617

1618
        obj.put()
1✔
1619
        return obj
1✔
1620

1621
    @classmethod
1✔
1622
    def check_supported(cls, obj):
1✔
1623
        """If this protocol doesn't support this object, return 204.
1624

1625
        Also reports an error.
1626

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

1631
        Args:
1632
          obj (Object)
1633
        """
1634
        if not obj.type:
1✔
UNCOV
1635
            return
×
1636

1637
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1638
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1639
            or (obj.type in as1.CRUD_VERBS
1640
                and inner_type
1641
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1642
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1643

1644
        # DMs are only allowed to/from protocol bot accounts
1645
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1646
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1647
            if (not cls.SUPPORTS_DMS
1✔
1648
                    or (recip not in protocol_user_ids
1649
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1650
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1651

1652

1653
@cloud_tasks_only(log=None)
1✔
1654
def receive_task():
1✔
1655
    """Task handler for a newly received :class:`models.Object`.
1656

1657
    Calls :meth:`Protocol.receive` with the form parameters.
1658

1659
    Parameters:
1660
      authed_as (str): passed to :meth:`Protocol.receive`
1661
      obj_id (str): key id of :class:`models.Object` to handle
1662
      received_at (str, ISO 8601 timestamp): when we first saw (received)
1663
        this activity
1664
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1665
        :class:`models.Object` to handle
1666

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

1676
    authed_as = form.pop('authed_as', None)
1✔
1677
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1678
                or authed_as in common.PROTOCOL_DOMAINS)
1679
    if received_at := form.pop('received_at', None):
1✔
1680
        received_at = datetime.fromisoformat(received_at)
1✔
1681

1682
    if obj_id := form.get('obj_id'):
1✔
1683
        obj = Object.get_by_id(obj_id)
1✔
1684
    else:
1685
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1686
            if val := form.get(json_prop):
1✔
1687
                form[json_prop] = json_loads(val)
1✔
1688
        obj = Object(**form)
1✔
1689

1690
    assert obj
1✔
1691
    assert obj.source_protocol
1✔
1692
    obj.new = True
1✔
1693

1694
    try:
1✔
1695
        return PROTOCOLS[obj.source_protocol].receive(
1✔
1696
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
1697
    except RequestException as e:
1✔
1698
        util.interpret_http_exception(e)
1✔
1699
        error(e, status=304)
1✔
1700
    except ValueError as e:
1✔
UNCOV
1701
        logger.warning(e, exc_info=True)
×
UNCOV
1702
        error(e, status=304)
×
1703

1704

1705
@cloud_tasks_only(log=None)
1✔
1706
def send_task():
1✔
1707
    """Task handler for sending an activity to a single specific destination.
1708

1709
    Calls :meth:`Protocol.send` with the form parameters.
1710

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

1724
    # prepare
1725
    url = form.get('url')
1✔
1726
    protocol = form.get('protocol')
1✔
1727
    if not url or not protocol:
1✔
1728
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1729
        return '', 204
1✔
1730

1731
    target = Target(uri=url, protocol=protocol)
1✔
1732

1733
    obj = Object.get_by_id(form['obj_id'])
1✔
1734
    assert obj
1✔
1735

1736
    PROTOCOLS[protocol].check_supported(obj)
1✔
1737
    allow_opt_out = (obj.type == 'delete')
1✔
1738

1739
    user = None
1✔
1740
    if user_key := form.get('user'):
1✔
1741
        key = ndb.Key(urlsafe=user_key)
1✔
1742
        # use get_by_id so that we follow use_instead
1743
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
1744
            key.id(), allow_opt_out=allow_opt_out)
1745

1746
    # send
1747
    delay = ''
1✔
1748
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
1749
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
1750
        delay = f'({delay_s} s behind)'
1✔
1751
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
1752
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
1753
    sent = None
1✔
1754
    try:
1✔
1755
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
1756
                                        orig_obj_id=form.get('orig_obj_id'))
1757
    except BaseException as e:
1✔
1758
        code, body = util.interpret_http_exception(e)
1✔
1759
        if not code and not body:
1✔
1760
            raise
1✔
1761

1762
    if sent is False:
1✔
1763
        logger.info(f'Failed sending!')
1✔
1764

1765
    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