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

snarfed / bridgy-fed / e1b78dcc-12ed-4deb-bdb4-dbefcfb42f6a

10 Jan 2025 08:38PM UTC coverage: 92.817% (+0.03%) from 92.784%
e1b78dcc-12ed-4deb-bdb4-dbefcfb42f6a

push

circleci

snarfed
add /user-page form for finding user page, for any protocol

for #1043

22 of 23 new or added lines in 1 file covered. (95.65%)

76 existing lines in 3 files now uncovered.

4484 of 4831 relevant lines covered (92.82%)

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✔
UNCOV
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
        """
UNCOV
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:
×
UNCOV
358
                code, _ = util.interpret_http_exception(e)
×
UNCOV
359
                if code:
×
360
                    # we tried and failed fetching the id over the network
UNCOV
361
                    return None
×
UNCOV
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✔
UNCOV
401
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
UNCOV
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
        """
UNCOV
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
        """
UNCOV
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
        """
UNCOV
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
        """
UNCOV
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
        """
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
863
            delay_s = int((util.now().replace(tzinfo=None)
×
864
                           - received_at.replace(tzinfo=None)
865
                           ).total_seconds())
UNCOV
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 = obj
1✔
911
        obj = Object.get_or_create(id, authed_as=actor, **orig.to_dict())
1✔
912
        if orig.new is not None:
1✔
913
            obj.new = orig.new
1✔
914
        if orig.changed is not None:
1✔
915
            obj.changed = orig.changed
1✔
916

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

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

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

932
        obj.source_protocol = from_cls.LABEL
1✔
933
        obj.put()
1✔
934

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

940
        actor = as1.get_object(obj.as1, 'actor')
1✔
941
        actor_id = actor.get('id')
1✔
942

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

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

964
            # fall through to deliver to followee
965
            # TODO: do we convert stop-following to webmention 410 of original
966
            # follow?
967

968
            # fall through to deliver to followers
969

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

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

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

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

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

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

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

1025
            from_cls.handle_follow(obj)
1✔
1026

1027
        # deliver to targets
1028
        resp = from_cls.deliver(obj, from_user=from_user)
1✔
1029

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

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

1046
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1047
        return resp
1✔
1048

1049
    @classmethod
1✔
1050
    def handle_follow(from_cls, obj):
1✔
1051
        """Handles an incoming follow activity.
1052

1053
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1054
        happens in :meth:`deliver`.
1055

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

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

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

1071
        if not from_obj.as1:
1✔
1072
            from_obj.our_as1 = from_as1
1✔
1073
            from_obj.put()
1✔
1074

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

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

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

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

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

1101
            to_obj = to_cls.load(to_id)
1✔
1102
            if to_obj and not to_obj.as1:
1✔
1103
                to_obj.our_as1 = to_as1
1✔
1104
                to_obj.put()
1✔
1105

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

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

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

1123
        ...if the follower protocol handles accepts. Otherwise, does nothing.
1124

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

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

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

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

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

1189
        Checks if we've seen it before.
1190

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

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

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

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

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

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

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

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

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

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

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

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

1295
        return 'OK', 202
1✔
1296

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

1301
        Targets are both objects - original posts, events, etc - and actors.
1302

1303
        Args:
1304
          obj (models.Object)
1305
          from_user (User)
1306
          internal (bool): whether this is a recursive internal call
1307

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

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

1326
        if is_reply:
1✔
1327
            original_ids = in_reply_tos
1✔
1328
        else:
1329
            if inner_obj_id == from_user.key.id():
1✔
1330
                inner_obj_id = from_user.profile_id()
1✔
1331
            original_ids = [inner_obj_id]
1✔
1332

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

1352
            util.add(to_protocols, proto)
1✔
1353

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

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

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

1380
                continue
1✔
1381

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

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

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

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

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

1414
            if target_author_key:
1✔
1415
                logger.debug(f'Recipient is {target_author_key}')
1✔
1416
                obj.add('notify', target_author_key)
1✔
1417

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

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

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

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

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

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

1474
            for user in users:
1✔
1475
                if feed_obj:
1✔
1476
                    feed_obj.add('feed', user.key)
1✔
1477

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

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

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

1493
            if feed_obj:
1✔
1494
                feed_obj.put()
1✔
1495

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

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

1526
        return targets
1✔
1527

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

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

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

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

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

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

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

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

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

1601
        if not fetched:
1✔
1602
            return None
1✔
1603

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

1610
        obj.resolve_ids()
1✔
1611
        obj.normalize_ids()
1✔
1612

1613
        if obj.new is False:
1✔
1614
            obj.changed = obj.activity_changed(orig_as1)
1✔
1615

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

1621
        obj.put()
1✔
1622
        return obj
1✔
1623

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

1628
        Also reports an error.
1629

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

1634
        Args:
1635
          obj (Object)
1636
        """
1637
        if not obj.type:
1✔
UNCOV
1638
            return
×
1639

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

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

1655

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

1660
    Calls :meth:`Protocol.receive` with the form parameters.
1661

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

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

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

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

1693
    assert obj
1✔
1694
    assert obj.source_protocol
1✔
1695
    obj.new = True
1✔
1696

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

1707

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

1712
    Calls :meth:`Protocol.send` with the form parameters.
1713

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

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

1734
    target = Target(uri=url, protocol=protocol)
1✔
1735

1736
    obj = Object.get_by_id(form['obj_id'])
1✔
1737
    assert obj
1✔
1738

1739
    PROTOCOLS[protocol].check_supported(obj)
1✔
1740
    allow_opt_out = (obj.type == 'delete')
1✔
1741

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

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

1765
    if sent is False:
1✔
1766
        logger.info(f'Failed sending!')
1✔
1767

1768
    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