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

snarfed / bridgy-fed / 476d39c4-9986-449b-8a42-69b083594144

23 May 2025 02:18AM UTC coverage: 92.374% (+0.009%) from 92.365%
476d39c4-9986-449b-8a42-69b083594144

push

circleci

snarfed
memcache.add_notifications: handle User.send_notifs

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

8 existing lines in 1 file now uncovered.

5051 of 5468 relevant lines covered (92.37%)

0.92 hits per line

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

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

56
OBJECT_REFRESH_AGE = timedelta(days=30)
1✔
57
DELETE_TASK_DELAY = timedelta(minutes=2)
1✔
58
CREATE_MAX_AGE = timedelta(weeks=2)
1✔
59

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

65
DONT_STORE_AS1_TYPES = as1.CRUD_VERBS | set((
1✔
66
    'accept',
67
    'reject',
68
    'stop-following',
69
    'undo',
70
))
71
STORE_AS1_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES | as1.VERBS_WITH_OBJECT
1✔
72
                   - DONT_STORE_AS1_TYPES)
73

74
logger = logging.getLogger(__name__)
1✔
75

76

77
def error(*args, status=299, **kwargs):
1✔
78
    """Default HTTP status code to 299 to prevent retrying task."""
79
    return common.error(*args, status=status, **kwargs)
1✔
80

81

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

85

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

115
    def __init__(self):
1✔
116
        assert False
×
117

118
    @classmethod
1✔
119
    @property
1✔
120
    def LABEL(cls):
1✔
121
        """str: human-readable lower case name of this protocol, eg ``'activitypub``"""
122
        return cls.__name__.lower()
1✔
123

124
    @staticmethod
1✔
125
    def for_request(fed=None):
1✔
126
        """Returns the protocol for the current request.
127

128
        ...based on the request's hostname.
129

130
        Args:
131
          fed (str or protocol.Protocol): protocol to return if the current
132
            request is on ``fed.brid.gy``
133

134
        Returns:
135
          Protocol: protocol, or None if the provided domain or request hostname
136
          domain is not a subdomain of ``brid.gy`` or isn't a known protocol
137
        """
138
        return Protocol.for_bridgy_subdomain(request.host, fed=fed)
1✔
139

140
    @staticmethod
1✔
141
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
142
        """Returns the protocol for a brid.gy subdomain.
143

144
        Args:
145
          domain_or_url (str)
146
          fed (str or protocol.Protocol): protocol to return if the current
147
            request is on ``fed.brid.gy``
148

149
        Returns:
150
          class: :class:`Protocol` subclass, or None if the provided domain or request
151
          hostname domain is not a subdomain of ``brid.gy`` or isn't a known
152
          protocol
153
        """
154
        domain = (util.domain_from_link(domain_or_url, minimize=False)
1✔
155
                  if util.is_web(domain_or_url)
156
                  else domain_or_url)
157

158
        if domain == common.PRIMARY_DOMAIN or domain in common.LOCAL_DOMAINS:
1✔
159
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
1✔
160
        elif domain and domain.endswith(common.SUPERDOMAIN):
1✔
161
            label = domain.removesuffix(common.SUPERDOMAIN)
1✔
162
            return PROTOCOLS.get(label)
1✔
163

164
    @classmethod
1✔
165
    def owns_id(cls, id):
1✔
166
        """Returns whether this protocol owns the id, or None if it's unclear.
167

168
        To be implemented by subclasses.
169

170
        IDs are string identities that uniquely identify users, and are intended
171
        primarily to be machine readable and usable. Compare to handles, which
172
        are human-chosen, human-meaningful, and often but not always unique.
173

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

178
        This should be a quick guess without expensive side effects, eg no
179
        external HTTP fetches to fetch the id itself or otherwise perform
180
        discovery.
181

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

184
        Args:
185
          id (str)
186

187
        Returns:
188
          bool or None:
189
        """
190
        return False
1✔
191

192
    @classmethod
1✔
193
    def owns_handle(cls, handle, allow_internal=False):
1✔
194
        """Returns whether this protocol owns the handle, or None if it's unclear.
195

196
        To be implemented by subclasses.
197

198
        Handles are string identities that are human-chosen, human-meaningful,
199
        and often but not always unique. Compare to IDs, which uniquely identify
200
        users, and are intended primarily to be machine readable and usable.
201

202
        Some protocols' handles are more or less deterministic based on the id
203
        format, eg ActivityPub (technically WebFinger) handles are
204
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
205
        ActivityPub, AT Protocol, or others.
206

207
        This should be a quick guess without expensive side effects, eg no
208
        external HTTP fetches to fetch the id itself or otherwise perform
209
        discovery.
210

211
        Args:
212
          handle (str)
213
          allow_internal (bool): whether to return False for internal domains
214
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
215

216
        Returns:
217
          bool or None
218
        """
219
        return False
1✔
220

221
    @classmethod
1✔
222
    def handle_to_id(cls, handle):
1✔
223
        """Converts a handle to an id.
224

225
        To be implemented by subclasses.
226

227
        May incur network requests, eg DNS queries or HTTP requests. Avoids
228
        blocked or opted out users.
229

230
        Args:
231
          handle (str)
232

233
        Returns:
234
          str: corresponding id, or None if the handle can't be found
235
        """
236
        raise NotImplementedError()
×
237

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

242
        To be implemented by subclasses. Canonicalizes the id if necessary.
243

244
        If called via `Protocol.key_for`, infers the appropriate protocol with
245
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
246
        as is.
247

248
        Args:
249
          id (str):
250
          allow_opt_out (bool): whether to allow users who are currently opted out
251

252
        Returns:
253
          google.cloud.ndb.Key: matching key, or None if the given id is not a
254
          valid :class:`User` id for this protocol.
255
        """
256
        if cls == Protocol:
1✔
257
            proto = Protocol.for_id(id)
1✔
258
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
259

260
        # load user so that we follow use_instead
261
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
262
        if existing:
1✔
263
            if existing.status and not allow_opt_out:
1✔
264
                return None
1✔
265
            return existing.key
1✔
266

267
        return cls(id=id).key
1✔
268

269
    @staticmethod
1✔
270
    def _for_id_memcache_key(id, remote=None):
1✔
271
        """If id is a URL, uses its domain, otherwise returns None.
272

273
        Args:
274
          id (str)
275

276
        Returns:
277
          (str domain, bool remote) or None
278
        """
279
        if remote and util.is_web(id):
1✔
280
            return util.domain_from_link(id)
1✔
281

282
    @cached(LRUCache(20000), lock=Lock())
1✔
283
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote: remote,
1✔
284
                      version=3)
285
    @staticmethod
1✔
286
    def for_id(id, remote=True):
1✔
287
        """Returns the protocol for a given id.
288

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

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

302
        # remove our synthetic id fragment, if any
303
        #
304
        # will this eventually cause false positives for other services that
305
        # include our full ids inside their own ids, non-URL-encoded? guess
306
        # we'll figure that out if/when it happens.
307
        id = id.partition('#bridgy-fed-')[0]
1✔
308
        if not id:
1✔
309
            return None
1✔
310

311
        if util.is_web(id):
1✔
312
            # step 1: check for our per-protocol subdomains
313
            try:
1✔
314
                is_homepage = urlparse(id).path.strip('/') == ''
1✔
315
            except ValueError as e:
1✔
316
                logger.info(f'urlparse ValueError: {e}')
1✔
317
                return None
1✔
318

319
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
320
            if by_subdomain and not is_homepage and id not in BOT_ACTOR_AP_IDS:
1✔
321
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
322
                return by_subdomain
1✔
323

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

337
        if len(candidates) == 1:
1✔
338
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
339
            return candidates[0]
1✔
340

341
        # step 3: look for existing Objects in the datastore
342
        #
343
        # note that we don't currently see if this is a copy id because I have FUD
344
        # over which Protocol for_id should return in that case...and also because a
345
        # protocol may already say definitively above that it owns the id, eg ATProto
346
        # with DIDs and at:// URIs.
347
        obj = Protocol.load(id, remote=False)
1✔
348
        if obj and obj.source_protocol:
1✔
349
            logger.debug(f'  {obj.key.id()} owned by source_protocol {obj.source_protocol}')
1✔
350
            return PROTOCOLS[obj.source_protocol]
1✔
351

352
        # step 4: fetch over the network, if necessary
353
        if not remote:
1✔
354
            return None
1✔
355

356
        for protocol in candidates:
1✔
357
            logger.debug(f'Trying {protocol.LABEL}')
1✔
358
            try:
1✔
359
                obj = protocol.load(id, local=False, remote=True)
1✔
360

361
                if protocol.ABBREV == 'web':
1✔
362
                    # for web, if we fetch and get HTML without microformats,
363
                    # load returns False but the object will be stored in the
364
                    # datastore with source_protocol web, and in cache. load it
365
                    # again manually to check for that.
366
                    obj = Object.get_by_id(id)
1✔
367
                    if obj and obj.source_protocol != 'web':
1✔
368
                        obj = None
×
369

370
                if obj:
1✔
371
                    logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
372
                    return protocol
1✔
373
            except BadGateway:
1✔
374
                # we tried and failed fetching the id over the network.
375
                # this depends on ActivityPub.fetch raising this!
376
                return None
1✔
377
            except HTTPException as e:
×
378
                # internal error we generated ourselves; try next protocol
379
                pass
×
380
            except Exception as e:
×
381
                code, _ = util.interpret_http_exception(e)
×
382
                if code:
×
383
                    # we tried and failed fetching the id over the network
384
                    return None
×
385
                raise
×
386

387
        logger.info(f'No matching protocol found for {id} !')
1✔
388
        return None
1✔
389

390
    @cached(LRUCache(20000), lock=Lock())
1✔
391
    @staticmethod
1✔
392
    def for_handle(handle):
1✔
393
        """Returns the protocol for a given handle.
394

395
        May incur expensive side effects like resolving the handle itself over
396
        the network or other discovery.
397

398
        Args:
399
          handle (str)
400

401
        Returns:
402
          (Protocol subclass, str) tuple: matching protocol and optional id (if
403
          resolved), or ``(None, None)`` if no known protocol owns this handle
404
        """
405
        # TODO: normalize, eg convert domains to lower case
406
        logger.debug(f'Determining protocol for handle {handle}')
1✔
407
        if not handle:
1✔
408
            return (None, None)
1✔
409

410
        # step 1: check if any Protocols say conclusively that they own it.
411
        # sort to be deterministic.
412
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
413
                           key=lambda p: p.LABEL)
414
        candidates = []
1✔
415
        for proto in protocols:
1✔
416
            owns = proto.owns_handle(handle)
1✔
417
            if owns:
1✔
418
                logger.debug(f'  {proto.LABEL} owns handle {handle}')
1✔
419
                return (proto, None)
1✔
420
            elif owns is not False:
1✔
421
                candidates.append(proto)
1✔
422

423
        if len(candidates) == 1:
1✔
424
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
425
            return (candidates[0], None)
×
426

427
        # step 2: look for matching User in the datastore
428
        for proto in candidates:
1✔
429
            user = proto.query(proto.handle == handle).get()
1✔
430
            if user:
1✔
431
                if user.status:
1✔
432
                    return (None, None)
1✔
433
                logger.debug(f'  user {user.key} handle {handle}')
1✔
434
                return (proto, user.key.id())
1✔
435

436
        # step 3: resolve handle to id
437
        for proto in candidates:
1✔
438
            id = proto.handle_to_id(handle)
1✔
439
            if id:
1✔
440
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
441
                return (proto, id)
1✔
442

443
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
444
        return (None, None)
1✔
445

446
    @classmethod
1✔
447
    def bridged_web_url_for(cls, user, fallback=False):
1✔
448
        """Returns the web URL for a user's bridged profile in this protocol.
449

450
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
451
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
452

453
        Args:
454
          user (models.User)
455
          fallback (bool): if True, and bridged users have no canonical user
456
            profile URL in this protocol, return the native protocol's profile URL
457

458
        Returns:
459
          str, or None if there isn't a canonical URL
460
        """
461
        if fallback:
1✔
462
            return user.web_url()
1✔
463

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

468
        Args:
469
          obj (models.Object)
470
          allow_opt_out (bool): whether to return a user key if they're opted out
471

472
        Returns:
473
          google.cloud.ndb.key.Key or None:
474
        """
475
        owner = as1.get_owner(obj.as1)
1✔
476
        if owner:
1✔
477
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
478

479
    @classmethod
1✔
480
    def bot_user_id(cls):
1✔
481
        """Returns the Web user id for the bot user for this protocol.
482

483
        For example, ``'bsky.brid.gy'`` for ATProto.
484

485
        Returns:
486
          str:
487
        """
488
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
489

490
    @classmethod
1✔
491
    def create_for(cls, user):
1✔
492
        """Creates or re-activate a copy user in this protocol.
493

494
        Should add the copy user to :attr:`copies`.
495

496
        If the copy user already exists and active, should do nothing.
497

498
        Args:
499
          user (models.User): original source user. Shouldn't already have a
500
            copy user for this protocol in :attr:`copies`.
501

502
        Raises:
503
          ValueError: if we can't create a copy of the given user in this protocol
504
        """
505
        raise NotImplementedError()
×
506

507
    @classmethod
1✔
508
    def send(to_cls, obj, url, from_user=None, orig_obj_id=None):
1✔
509
        """Sends an outgoing activity.
510

511
        To be implemented by subclasses.
512

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

517
        Args:
518
          obj (models.Object): with activity to send
519
          url (str): destination URL to send to
520
          from_user (models.User): user (actor) this activity is from
521
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
522
            that this object refers to, eg replies to or reposts or likes
523

524
        Returns:
525
          bool: True if the activity is sent successfully, False if it is
526
          ignored or otherwise unsent due to protocol logic, eg no webmention
527
          endpoint, protocol doesn't support the activity type. (Failures are
528
          raised as exceptions.)
529

530
        Raises:
531
          werkzeug.HTTPException if the request fails
532
        """
533
        raise NotImplementedError()
×
534

535
    @classmethod
1✔
536
    def fetch(cls, obj, **kwargs):
1✔
537
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
538

539
        Errors are raised as exceptions. If this method returns False, the fetch
540
        didn't fail but didn't succeed either, eg the id isn't valid for this
541
        protocol, or the fetch didn't return valid data for this protocol.
542

543
        To be implemented by subclasses.
544

545
        Args:
546
          obj (models.Object): with the id to fetch. Data is filled into one of
547
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
548
          kwargs: subclass-specific
549

550
        Returns:
551
          bool: True if the object was fetched and populated successfully,
552
          False otherwise
553

554
        Raises:
555
          requests.RequestException or werkzeug.HTTPException: if the fetch fails
556
        """
557
        raise NotImplementedError()
×
558

559
    @classmethod
1✔
560
    def convert(cls, obj, from_user=None, **kwargs):
1✔
561
        """Converts an :class:`Object` to this protocol's data format.
562

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

566
        Just passes through to :meth:`_convert`, then does minor
567
        protocol-independent postprocessing.
568

569
        Args:
570
          obj (models.Object):
571
          from_user (models.User): user (actor) this activity/object is from
572
          kwargs: protocol-specific, passed through to :meth:`_convert`
573

574
        Returns:
575
          converted object in the protocol's native format, often a dict
576
        """
577
        if not obj or not obj.as1:
1✔
578
            return {}
1✔
579

580
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
581
        is_activity = obj.as1.get('verb') in ('post', 'update')
1✔
582
        base_obj = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
583
        orig_our_as1 = obj.our_as1
1✔
584

585
        # mark bridged actors as bots and add "bridged by Bridgy Fed" to their bios
586
        if (from_user and base_obj
1✔
587
            and base_obj.get('objectType') in as1.ACTOR_TYPES
588
            and PROTOCOLS.get(obj.source_protocol) != cls
589
            and Protocol.for_bridgy_subdomain(id) not in DOMAINS
590
            # Web users are special cased, they don't get the label if they've
591
            # explicitly enabled Bridgy Fed with redirects or webmentions
592
            and not (from_user.LABEL == 'web'
593
                     and (from_user.last_webmention_in or from_user.has_redirects))):
594

595
            obj.our_as1 = copy.deepcopy(obj.as1)
1✔
596
            actor = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
597
            actor['objectType'] = 'person'
1✔
598
            cls.add_source_links(actor=actor, obj=obj, from_user=from_user)
1✔
599

600
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
601
        obj.our_as1 = orig_our_as1
1✔
602
        return converted
1✔
603

604
    @classmethod
1✔
605
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
606
        """Converts an :class:`Object` to this protocol's data format.
607

608
        To be implemented by subclasses. Implementations should generally call
609
        :meth:`Protocol.translate_ids` (as their own class) before converting to
610
        their format.
611

612
        Args:
613
          obj (models.Object):
614
          from_user (models.User): user (actor) this activity/object is from
615
          kwargs: protocol-specific
616

617
        Returns:
618
          converted object in the protocol's native format, often a dict. May
619
            return the ``{}`` empty dict if the object can't be converted.
620
        """
621
        raise NotImplementedError()
×
622

623
    @classmethod
1✔
624
    def add_source_links(cls, actor, obj, from_user):
1✔
625
        """Adds "bridged from ... by Bridgy Fed" HTML to ``actor['summary']``.
626

627
        Default implementation; subclasses may override.
628

629
        Args:
630
          actor (dict): AS1 actor
631
          obj (models.Object):
632
          from_user (models.User): user (actor) this activity/object is from
633
        """
634
        assert from_user
1✔
635
        summary = actor.setdefault('summary', '')
1✔
636
        if 'Bridgy Fed]' in html_to_text(summary, ignore_links=True):
1✔
637
            return
1✔
638

639
        id = actor.get('id')
1✔
640
        proto_phrase = (PROTOCOLS[obj.source_protocol].PHRASE
1✔
641
                        if obj.source_protocol else '')
642
        if proto_phrase:
1✔
643
            proto_phrase = f' on {proto_phrase}'
1✔
644

645
        if from_user.key and id in (from_user.key.id(), from_user.profile_id()):
1✔
646
            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✔
647

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

653
        if summary:
1✔
654
            summary += '<br><br>'
1✔
655
        actor['summary'] = summary + source_links
1✔
656

657
    @classmethod
1✔
658
    def set_username(to_cls, user, username):
1✔
659
        """Sets a custom username for a user's bridged account in this protocol.
660

661
        Args:
662
          user (models.User)
663
          username (str)
664

665
        Raises:
666
          ValueError: if the username is invalid
667
          RuntimeError: if the username could not be set
668
        """
669
        raise NotImplementedError()
1✔
670

671
    @classmethod
1✔
672
    def migrate_out(cls, user, to_user_id):
1✔
673
        """Migrates a bridged account out to be a native account.
674

675
        Args:
676
          user (models.User)
677
          to_user_id (str)
678

679
        Raises:
680
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
681
            ``user`` is on this protocol or not bridged to this protocol
682
        """
683
        raise NotImplementedError()
×
684

685
    @classmethod
1✔
686
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
687
        """Migrates a native account in to be a bridged account.
688

689
        Args:
690
          user (models.User): native user on another protocol to attach the
691
            newly imported bridged account to
692
          from_user_id (str)
693
          kwargs: additional protocol-specific parameters
694

695
        Raises:
696
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
697
            ``user`` is on this protocol or already bridged to this protocol
698
        """
699
        raise NotImplementedError()
×
700

701
    @classmethod
1✔
702
    def target_for(cls, obj, shared=False):
1✔
703
        """Returns an :class:`Object`'s delivery target (endpoint).
704

705
        To be implemented by subclasses.
706

707
        Examples:
708

709
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
710
          webmention target.
711
        * If obj is an ``activitypub`` actor, returns its inbox.
712
        * If obj is an ``activitypub`` object, returns it's author's or actor's
713
          inbox.
714

715
        Args:
716
          obj (models.Object):
717
          shared (bool): optional. If True, returns a common/shared
718
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
719
            multiple recipients for efficiency
720

721
        Returns:
722
          str: target endpoint, or None if not available.
723
        """
724
        raise NotImplementedError()
×
725

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

730
        Default implementation here, subclasses may override.
731

732
        Args:
733
          url (str):
734
          allow_internal (bool): whether to return False for internal domains
735
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
736
        """
737
        blocklist = DOMAIN_BLOCKLIST
1✔
738
        if not allow_internal:
1✔
739
            blocklist += DOMAINS
1✔
740
        return util.domain_or_parent_in(util.domain_from_link(url), blocklist)
1✔
741

742
    @classmethod
1✔
743
    def translate_ids(to_cls, obj):
1✔
744
        """Translates all ids in an AS1 object to a specific protocol.
745

746
        Infers source protocol for each id value separately.
747

748
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
749
        ``at://did:plc:abc/coll/123`` will be converted to
750
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
751

752
        Wraps these AS1 fields:
753

754
        * ``id``
755
        * ``actor``
756
        * ``author``
757
        * ``bcc``
758
        * ``bto``
759
        * ``cc``
760
        * ``featured[].items``, ``featured[].orderedItems``
761
        * ``object``
762
        * ``object.actor``
763
        * ``object.author``
764
        * ``object.id``
765
        * ``object.inReplyTo``
766
        * ``object.object``
767
        * ``attachments[].id``
768
        * ``tags[objectType=mention].url``
769
        * ``to``
770

771
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
772
        same logic is duplicated there!
773

774
        TODO: unify with :meth:`Object.resolve_ids`,
775
        :meth:`models.Object.normalize_ids`.
776

777
        Args:
778
          to_proto (Protocol subclass)
779
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
780

781
        Returns:
782
          dict: wrapped AS1 version of ``obj``
783
        """
784
        assert to_cls != Protocol
1✔
785
        if not obj:
1✔
786
            return obj
1✔
787

788
        outer_obj = copy.deepcopy(obj)
1✔
789
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
790

791
        def translate(elem, field, fn, uri=False):
1✔
792
            elem[field] = as1.get_objects(elem, field)
1✔
793
            for obj in elem[field]:
1✔
794
                if id := obj.get('id'):
1✔
795
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
796
                        continue
1✔
797
                    from_cls = Protocol.for_id(id)
1✔
798
                    # TODO: what if from_cls is None? relax translate_object_id,
799
                    # make it a noop if we don't know enough about from/to?
800
                    if from_cls and from_cls != to_cls:
1✔
801
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
802
                    if obj['id'] and uri:
1✔
803
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
804

805
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
806
                           for o in elem[field]]
807

808
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
809
                elem[field] = elem[field][0]
1✔
810

811
        type = as1.object_type(outer_obj)
1✔
812
        translate(outer_obj, 'id',
1✔
813
                  translate_user_id if type in as1.ACTOR_TYPES
814
                  else translate_object_id)
815

816
        for o in inner_objs:
1✔
817
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
818
                        or as1.get_owner(outer_obj) == o.get('id')
819
                        or type in ('follow', 'stop-following'))
820
            translate(o, 'id', translate_user_id if is_actor else translate_object_id)
1✔
821
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
822
            translate(o, 'object', translate_user_id if obj_is_actor
1✔
823
                      else translate_object_id)
824

825
        for o in [outer_obj] + inner_objs:
1✔
826
            translate(o, 'inReplyTo', translate_object_id)
1✔
827
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
828
                translate(o, field, translate_user_id)
1✔
829
            for tag in as1.get_objects(o, 'tags'):
1✔
830
                if tag.get('objectType') == 'mention':
1✔
831
                    translate(tag, 'url', translate_user_id, uri=True)
1✔
832
            for att in as1.get_objects(o, 'attachments'):
1✔
833
                translate(att, 'id', translate_object_id)
1✔
834
                url = att.get('url')
1✔
835
                if url and not att.get('id'):
1✔
836
                    if from_cls := Protocol.for_id(url):
1✔
837
                        att['id'] = translate_object_id(from_=from_cls, to=to_cls,
1✔
838
                                                        id=url)
839
            if feat := as1.get_object(o, 'featured'):
1✔
840
                translate(feat, 'orderedItems', translate_object_id)
1✔
841
                translate(feat, 'items', translate_object_id)
1✔
842

843
        outer_obj = util.trim_nulls(outer_obj)
1✔
844

845
        if objs := util.get_list(outer_obj ,'object'):
1✔
846
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
847
            if len(outer_obj['object']) == 1:
1✔
848
                outer_obj['object'] = outer_obj['object'][0]
1✔
849

850
        return outer_obj
1✔
851

852
    @classmethod
1✔
853
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
854
        """Handles an incoming activity.
855

856
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
857
        unset, returns HTTP 299.
858

859
        Args:
860
          obj (models.Object)
861
          authed_as (str): authenticated actor id who sent this activity
862
          internal (bool): whether to allow activity ids on internal domains,
863
            from opted out/blocked users, etc.
864
          received_at (datetime): when we first saw (received) this activity.
865
            Right now only used for monitoring.
866

867
        Returns:
868
          (str, int) tuple: (response body, HTTP status code) Flask response
869

870
        Raises:
871
          werkzeug.HTTPException: if the request is invalid
872
        """
873
        # check some invariants
874
        assert from_cls != Protocol
1✔
875
        assert isinstance(obj, Object), obj
1✔
876

877
        if not obj.as1:
1✔
878
            error('No object data provided')
×
879

880
        id = None
1✔
881
        if obj.key and obj.key.id():
1✔
882
            id = obj.key.id()
1✔
883

884
        if not id:
1✔
885
            id = obj.as1.get('id')
1✔
886
            obj.key = ndb.Key(Object, id)
1✔
887

888
        if not id:
1✔
889
            error('No id provided')
×
890
        elif from_cls.owns_id(id) is False:
1✔
891
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
892
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
893
            error(f'Activity {id} is blocklisted')
1✔
894
        # check that this activity is public. only do this for some activities,
895
        # not eg likes or follows, since Mastodon doesn't currently mark those
896
        # as explicitly public.
897
        elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
898
                  and not as1.is_public(obj.as1, unlisted=False)
899
                  and not as1.is_dm(obj.as1)):
900
              logger.info('Dropping non-public activity')
1✔
901
              return ('OK', 200)
1✔
902

903
        # lease this object, atomically
904
        memcache_key = activity_id_memcache_key(id)
1✔
905
        leased = memcache.memcache.add(memcache_key, 'leased', noreply=False,
1✔
906
                                       expire=5 * 60)  # 5 min
907
        # short circuit if we've already seen this activity id.
908
        # (don't do this for bare objects since we need to check further down
909
        # whether they've been updated since we saw them last.)
910
        if (obj.as1.get('objectType') == 'activity'
1✔
911
            and 'force' not in request.values
912
            and (not leased
913
                 or (obj.new is False and obj.changed is False))):
914
            error(f'Already seen this activity {id}', status=204)
1✔
915

916
        pruned = {k: v for k, v in obj.as1.items()
1✔
917
                  if k not in ('contentMap', 'replies', 'signature')}
918
        delay = ''
1✔
919
        if (received_at and request.headers.get('X-AppEngine-TaskRetryCount') == '0'
1✔
920
                and obj.type != 'delete'):  # we delay deletes for 2m
921
            delay_s = int((util.now().replace(tzinfo=None)
×
922
                           - received_at.replace(tzinfo=None)
923
                           ).total_seconds())
924
            delay = f'({delay_s} s behind)'
×
925
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
926

927
        # does this protocol support this activity/object type?
928
        from_cls.check_supported(obj)
1✔
929

930
        # check authorization
931
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
932
        actor = as1.get_owner(obj.as1)
1✔
933
        if not actor:
1✔
934
            error('Activity missing actor or author')
1✔
935
        elif from_cls.owns_id(actor) is False:
1✔
936
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
937

938
        assert authed_as
1✔
939
        assert isinstance(authed_as, str)
1✔
940
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
941
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
942
        if actor != authed_as:
1✔
943
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
944
                         user=f'{id} authed_as {authed_as} owner {actor}')
945
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
946

947
        # update copy ids to originals
948
        obj.normalize_ids()
1✔
949
        obj.resolve_ids()
1✔
950

951
        if (obj.type == 'follow'
1✔
952
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
953
            # follows of bot user; refresh user profile first
954
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
955
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
956
            from_user.reload_profile()
1✔
957
        else:
958
            # load actor user
959
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
960

961
        if not internal and (not from_user or from_user.manual_opt_out):
1✔
962
            error(f'Actor {actor} is manually opted out', status=204)
1✔
963

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

968
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
969
        inner_obj_id = inner_obj_as1.get('id')
1✔
970
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
971
            if not inner_obj_id:
1✔
972
                error(f'{obj.type} object has no id!')
1✔
973

974
        # check age. we support backdated posts, but if they're over 2w old, we
975
        # don't deliver them
976
        if obj.type == 'post':
1✔
977
            if published := inner_obj_as1.get('published'):
1✔
978
                try:
×
979
                    published_dt = util.parse_iso8601(published)
×
980
                    if not published_dt.tzinfo:
×
981
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
982
                    age = util.now() - published_dt
×
983
                    if age > CREATE_MAX_AGE:
×
984
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
985
                              status=204)
986
                except ValueError:  # from parse_iso8601
×
987
                    logger.debug(f"Couldn't parse published {published}")
×
988

989
        # write Object to datastore
990
        obj.source_protocol = from_cls.LABEL
1✔
991
        if obj.type in STORE_AS1_TYPES:
1✔
992
            obj.put()
1✔
993

994
        # store inner object
995
        # TODO: unify with big obj.type conditional below. would have to merge
996
        # this with the DM handling block lower down.
997
        crud_obj = None
1✔
998
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
999
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
1000
                                            source_protocol=from_cls.LABEL,
1001
                                            authed_as=actor, users=[from_user.key])
1002

1003
        actor = as1.get_object(obj.as1, 'actor')
1✔
1004
        actor_id = actor.get('id')
1✔
1005

1006
        # handle activity!
1007
        if obj.type == 'stop-following':
1✔
1008
            # TODO: unify with handle_follow?
1009
            # TODO: handle multiple followees
1010
            if not actor_id or not inner_obj_id:
1✔
1011
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1012

1013
            # deactivate Follower
1014
            from_ = from_cls.key_for(actor_id)
1✔
1015
            to_cls = Protocol.for_id(inner_obj_id)
1✔
1016
            to = to_cls.key_for(inner_obj_id)
1✔
1017
            follower = Follower.query(Follower.to == to,
1✔
1018
                                      Follower.from_ == from_,
1019
                                      Follower.status == 'active').get()
1020
            if follower:
1✔
1021
                logger.info(f'Marking {follower} inactive')
1✔
1022
                follower.status = 'inactive'
1✔
1023
                follower.put()
1✔
1024
            else:
1025
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1026

1027
            # fall through to deliver to followee
1028
            # TODO: do we convert stop-following to webmention 410 of original
1029
            # follow?
1030

1031
            # fall through to deliver to followers
1032

1033
        elif obj.type in ('delete', 'undo'):
1✔
1034
            delete_obj_id = (from_user.profile_id()
1✔
1035
                            if inner_obj_id == from_user.key.id()
1036
                            else inner_obj_id)
1037

1038
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1039
            if not delete_obj:
1✔
1040
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1041
                return 'OK', 204
1✔
1042

1043
            # TODO: just delete altogether!
1044
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1045
            delete_obj.deleted = True
1✔
1046
            delete_obj.put()
1✔
1047

1048
            # if this is an actor, handle deleting it later so that
1049
            # in case it's from_user, user.enabled_protocols is still populated
1050
            #
1051
            # fall through to deliver to followers and delete copy if necessary.
1052
            # should happen via protocol-specific copy target and send of
1053
            # delete activity.
1054
            # https://github.com/snarfed/bridgy-fed/issues/63
1055

1056
        elif obj.type == 'block':
1✔
1057
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1058
                # blocking protocol bot user disables that protocol
1059
                from_user.delete(proto)
1✔
1060
                from_user.disable_protocol(proto)
1✔
1061
                return 'OK', 200
1✔
1062

1063
        elif obj.type == 'post':
1✔
1064
            # handle DMs to bot users
1065
            if as1.is_dm(obj.as1):
1✔
1066
                return dms.receive(from_user=from_user, obj=obj)
1✔
1067

1068
        # fetch actor if necessary
1069
        if (actor and actor.keys() == set(['id'])
1✔
1070
                and obj.type not in ('delete', 'undo')):
1071
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1072
            actor_obj = from_cls.load(ids.profile_id(id=actor['id'], proto=from_cls),
1✔
1073
                                      raise_=False)
1074
            if actor_obj and actor_obj.as1:
1✔
1075
                obj.our_as1 = {
1✔
1076
                    **obj.as1, 'actor': {
1077
                        **actor_obj.as1,
1078
                        # override profile id with actor id
1079
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1080
                        'id': actor['id'],
1081
                    }
1082
                }
1083

1084
        # fetch object if necessary
1085
        if (obj.type in ('post', 'update', 'share')
1✔
1086
                and inner_obj_as1.keys() == set(['id'])
1087
                and from_cls.owns_id(inner_obj_id)):
1088
            logger.debug('Fetching inner object')
1✔
1089
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1090
                                      remote=(obj.type in ('post', 'update')))
1091
            if obj.type in ('post', 'update'):
1✔
1092
                crud_obj = inner_obj
1✔
1093
            if inner_obj and inner_obj.as1:
1✔
1094
                obj.our_as1 = {
1✔
1095
                    **obj.as1,
1096
                    'object': {
1097
                        **inner_obj_as1,
1098
                        **inner_obj.as1,
1099
                    }
1100
                }
1101
            elif obj.type in ('post', 'update'):
1✔
1102
                error("Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1103

1104
        if obj.type == 'follow':
1✔
1105
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1106
                # follow of one of our protocol bot users; enable that protocol.
1107
                # fall through so that we send an accept.
1108
                try:
1✔
1109
                    from_user.enable_protocol(proto)
1✔
1110
                except ErrorButDoNotRetryTask:
1✔
1111
                    from web import Web
1✔
1112
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1113
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1114
                                               followee=bot, follow=obj)
1115
                    raise
1✔
1116
                proto.bot_follow(from_user)
1✔
1117

1118
            from_cls.handle_follow(obj)
1✔
1119

1120
        # deliver to targets
1121
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1122

1123
        # if this is a user, deactivate its followers/followings
1124
        # https://github.com/snarfed/bridgy-fed/issues/1304
1125
        if obj.type == 'delete':
1✔
1126
            if user_key := from_cls.key_for(id=inner_obj_id):
1✔
1127
                if user := user_key.get():
1✔
1128
                    for proto in user.enabled_protocols:
1✔
1129
                        user.disable_protocol(PROTOCOLS[proto])
1✔
1130

1131
                    logger.info(f'Deactivating Followers from or to {user_key.id()}')
1✔
1132
                    followers = Follower.query(
1✔
1133
                        OR(Follower.to == user_key, Follower.from_ == user_key)
1134
                        ).fetch()
1135
                    for f in followers:
1✔
1136
                        f.status = 'inactive'
1✔
1137
                    ndb.put_multi(followers)
1✔
1138

1139
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1140
        return resp
1✔
1141

1142
    @classmethod
1✔
1143
    def handle_follow(from_cls, obj):
1✔
1144
        """Handles an incoming follow activity.
1145

1146
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1147
        happens in :meth:`deliver`.
1148

1149
        Args:
1150
          obj (models.Object): follow activity
1151
        """
1152
        logger.debug('Got follow. Loading users, storing Follow(s), sending accept(s)')
1✔
1153

1154
        # Prepare follower (from) users' data
1155
        # TODO: remove all of this and just use from_user
1156
        from_as1 = as1.get_object(obj.as1, 'actor')
1✔
1157
        from_id = from_as1.get('id')
1✔
1158
        if not from_id:
1✔
1159
            error(f'Follow activity requires actor. Got: {obj.as1}')
×
1160

1161
        from_obj = from_cls.load(from_id, raise_=False)
1✔
1162
        if not from_obj:
1✔
1163
            error(f"Couldn't load {from_id}", status=502)
×
1164

1165
        if not from_obj.as1:
1✔
1166
            from_obj.our_as1 = from_as1
1✔
1167
            from_obj.put()
1✔
1168

1169
        from_key = from_cls.key_for(from_id)
1✔
1170
        if not from_key:
1✔
1171
            error(f'Invalid {from_cls.LABEL} user key: {from_id}')
×
1172
        obj.users = [from_key]
1✔
1173
        from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
1✔
1174

1175
        # Prepare followee (to) users' data
1176
        to_as1s = as1.get_objects(obj.as1)
1✔
1177
        if not to_as1s:
1✔
1178
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1179

1180
        # Store Followers
1181
        for to_as1 in to_as1s:
1✔
1182
            to_id = to_as1.get('id')
1✔
1183
            if not to_id:
1✔
1184
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1185

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

1188
            to_cls = Protocol.for_id(to_id)
1✔
1189
            if not to_cls:
1✔
1190
                error(f"Couldn't determine protocol for {to_id}")
×
1191
            elif from_cls == to_cls:
1✔
1192
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1193
                continue
1✔
1194

1195
            to_obj = to_cls.load(to_id)
1✔
1196
            if to_obj and not to_obj.as1:
1✔
1197
                to_obj.our_as1 = to_as1
1✔
1198
                to_obj.put()
1✔
1199

1200
            to_key = to_cls.key_for(to_id)
1✔
1201
            if not to_key:
1✔
1202
                logger.info(f'Skipping invalid {from_cls.LABEL} user key: {from_id}')
×
1203
                continue
×
1204

1205
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj,
1✔
1206
                                           allow_opt_out=True)
1207
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1208
                                                  follow=obj.key, status='active')
1209
            obj.add('notify', to_key)
1✔
1210
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1211
                                       followee=to_user, follow=obj)
1212

1213
    @classmethod
1✔
1214
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1215
        """Sends an accept or reject activity for a follow.
1216

1217
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1218
        nothing.
1219

1220
        Args:
1221
          verb (str): ``accept`` or  ``reject``
1222
          follower (models.User)
1223
          followee (models.User)
1224
          follow (models.Object)
1225
        """
1226
        assert verb in ('accept', 'reject')
1✔
1227
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1228
            return
1✔
1229

1230
        target = follower.target_for(follower.obj)
1✔
1231
        if not target:
1✔
1232
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1233

1234
        # send. note that this is one response for the whole follow, even if it
1235
        # has multiple followees!
1236
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1237
        accept = {
1✔
1238
            'id': id,
1239
            'objectType': 'activity',
1240
            'verb': verb,
1241
            'actor': followee.key.id(),
1242
            'object': follow.as1,
1243
        }
1244
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1245
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1246

1247
    @classmethod
1✔
1248
    def bot_follow(bot_cls, user):
1✔
1249
        """Follow a user from a protocol bot user.
1250

1251
        ...so that the protocol starts sending us their activities, if it needs
1252
        a follow for that (eg ActivityPub).
1253

1254
        Args:
1255
          user (User)
1256
        """
1257
        from web import Web
1✔
1258
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1259
        now = util.now().isoformat()
1✔
1260
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1261

1262
        if not user.obj:
1✔
1263
            logger.info("  can't follow, user has no profile obj")
1✔
1264
            return
1✔
1265

1266
        target = user.target_for(user.obj)
1✔
1267
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1268
        follow_back_as1 = {
1✔
1269
            'objectType': 'activity',
1270
            'verb': 'follow',
1271
            'id': follow_back_id,
1272
            'actor': bot.key.id(),
1273
            'object': user.key.id(),
1274
        }
1275
        common.create_task(queue='send', id=follow_back_id,
1✔
1276
                           our_as1=follow_back_as1, url=target,
1277
                           source_protocol='web', protocol=user.LABEL,
1278
                           user=bot.key.urlsafe())
1279

1280
    @classmethod
1✔
1281
    def handle_bare_object(cls, obj, authed_as=None):
1✔
1282
        """If obj is a bare object, wraps it in a create or update activity.
1283

1284
        Checks if we've seen it before.
1285

1286
        Args:
1287
          obj (models.Object)
1288
          authed_as (str): authenticated actor id who sent this activity
1289

1290
        Returns:
1291
          models.Object: ``obj`` if it's an activity, otherwise a new object
1292
        """
1293
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1294
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1295
            return obj
1✔
1296

1297
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1298
        now = util.now().isoformat()
1✔
1299

1300
        # occasionally we override the object, eg if this is a profile object
1301
        # coming in via a user with use_instead set
1302
        obj_as1 = obj.as1
1✔
1303
        if obj_id := obj.key.id():
1✔
1304
            if obj_as1_id := obj_as1.get('id'):
1✔
1305
                if obj_id != obj_as1_id:
1✔
1306
                    logger.info(f'Overriding AS1 object id {obj_as1_id} with Object id {obj_id}')
1✔
1307
                    obj_as1['id'] = obj_id
1✔
1308

1309
        # this is a raw post; wrap it in a create or update activity
1310
        if obj.changed or is_actor:
1✔
1311
            if obj.changed:
1✔
1312
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1313
            else:
1314
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1315
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1316
            update_as1 = {
1✔
1317
                'objectType': 'activity',
1318
                'verb': 'update',
1319
                'id': id,
1320
                'actor': obj_actor,
1321
                'object': {
1322
                    # Mastodon requires the updated field for Updates, so
1323
                    # add a default value.
1324
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1325
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1326
                    # https://github.com/mastodon/documentation/pull/1150
1327
                    'updated': now,
1328
                    **obj_as1,
1329
                },
1330
            }
1331
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1332
            return Object(id=id, our_as1=update_as1,
1✔
1333
                          source_protocol=obj.source_protocol)
1334

1335
        if (obj.new
1✔
1336
                # HACK: force query param here is specific to webmention
1337
                or 'force' in request.form):
1338
            create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1339
            create_as1 = {
1✔
1340
                'objectType': 'activity',
1341
                'verb': 'post',
1342
                'id': create_id,
1343
                'actor': obj_actor,
1344
                'object': obj_as1,
1345
                'published': now,
1346
            }
1347
            logger.info(f'Wrapping in post')
1✔
1348
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1349
            return Object(id=create_id, our_as1=create_as1,
1✔
1350
                          source_protocol=obj.source_protocol)
1351

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

1354
    @classmethod
1✔
1355
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1356
        """Delivers an activity to its external recipients.
1357

1358
        Args:
1359
          obj (models.Object): activity to deliver
1360
          from_user (models.User): user (actor) this activity is from
1361
          crud_obj (models.Object): if this is a create, update, or delete/undo
1362
            activity, the inner object that's being written, otherwise None.
1363
            (This object's ``notify`` and ``feed`` properties may be updated.)
1364
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1365
            targets on this protocol
1366

1367
        Returns:
1368
          (str, int) tuple: Flask response
1369
        """
1370
        if to_proto:
1✔
1371
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1372

1373
        # find delivery targets. maps Target to Object or None
1374
        #
1375
        # ...then write the relevant object, since targets() has a side effect of
1376
        # setting the notify and feed properties (and dirty attribute)
1377
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1378
        if not targets:
1✔
1379
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1380

1381
        # store object that targets() updated
1382
        if crud_obj and crud_obj.dirty:
1✔
1383
            crud_obj.put()
1✔
1384
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1385
            obj.put()
1✔
1386

1387
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1388
                      else obj.to_request())
1389

1390
        # sort targets so order is deterministic for tests, debugging, etc
1391
        sorted_targets = sorted(targets.items(), key=lambda t: t[0].uri)
1✔
1392

1393
        # enqueue send task for each targets
1394
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1395
        user = from_user.key.urlsafe()
1✔
1396
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1397
            if to_proto and target.protocol != to_proto.LABEL:
1✔
1398
                continue
×
1399
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1400
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1401
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1402

1403
        return 'OK', 202
1✔
1404

1405
    @classmethod
1✔
1406
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1407
        """Collects the targets to send a :class:`models.Object` to.
1408

1409
        Targets are both objects - original posts, events, etc - and actors.
1410

1411
        Args:
1412
          obj (models.Object)
1413
          from_user (User)
1414
          crud_obj (models.Object): if this is a create, update, or delete/undo
1415
            activity, the inner object that's being written, otherwise None.
1416
            (This object's ``notify`` and ``feed`` properties may be updated.)
1417
          internal (bool): whether this is a recursive internal call
1418

1419
        Returns:
1420
          dict: maps :class:`models.Target` to original (in response to)
1421
          :class:`models.Object`, if any, otherwise None
1422
        """
1423
        logger.debug('Finding recipients and their targets')
1✔
1424

1425
        # we should only have crud_obj iff this is a create or update
1426
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1427
        write_obj = crud_obj or obj
1✔
1428
        write_obj.dirty = False
1✔
1429

1430
        target_uris = sorted(set(as1.targets(obj.as1)))
1✔
1431
        logger.info(f'Raw targets: {target_uris}')
1✔
1432
        orig_obj = None
1✔
1433
        targets = {}  # maps Target to Object or None
1✔
1434
        owner = as1.get_owner(obj.as1)
1✔
1435
        allow_opt_out = (obj.type == 'delete')
1✔
1436
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1437
        inner_obj_id = inner_obj_as1.get('id')
1✔
1438
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1439
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1440
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1441
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1442
        is_self_reply = False
1✔
1443

1444
        original_ids = []
1✔
1445
        if is_reply:
1✔
1446
            original_ids = in_reply_tos
1✔
1447
        elif inner_obj_id:
1✔
1448
            if inner_obj_id == from_user.key.id():
1✔
1449
                inner_obj_id = from_user.profile_id()
1✔
1450
            original_ids = [inner_obj_id]
1✔
1451

1452
        # which protocols should we allow delivering to?
1453
        to_protocols = []
1✔
1454
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1455
                      + from_user.enabled_protocols):
1456
            proto = PROTOCOLS[label]
1✔
1457
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1458
                                     or is_reply):
1459
                for id in original_ids:
1✔
1460
                    if Protocol.for_id(id) == proto:
1✔
1461
                        logger.info(f'Allowing {label} for original post {id}')
1✔
1462
                        break
1✔
1463
                    elif orig := from_user.load(id, remote=False):
1✔
1464
                        if orig.get_copy(proto):
1✔
1465
                            logger.info(f'Allowing {label}, original post {id} was bridged there')
1✔
1466
                            break
1✔
1467
                else:
1468
                    logger.info(f"Skipping {label}, original objects {original_ids} weren't bridged there")
1✔
1469
                    continue
1✔
1470

1471
            util.add(to_protocols, proto)
1✔
1472

1473
        # process direct targets
1474
        for id in sorted(target_uris):
1✔
1475
            target_proto = Protocol.for_id(id)
1✔
1476
            if not target_proto:
1✔
1477
                logger.info(f"Can't determine protocol for {id}")
1✔
1478
                continue
1✔
1479
            elif target_proto.is_blocklisted(id):
1✔
1480
                logger.debug(f'{id} is blocklisted')
1✔
1481
                continue
1✔
1482

1483
            orig_obj = target_proto.load(id, raise_=False)
1✔
1484
            if not orig_obj or not orig_obj.as1:
1✔
1485
                logger.info(f"Couldn't load {id}")
1✔
1486
                continue
1✔
1487

1488
            target_author_key = (target_proto(id=id).key if id in mentioned_urls
1✔
1489
                                 else target_proto.actor_key(orig_obj))
1490
            if not from_user.is_enabled(target_proto):
1✔
1491
                # if author isn't bridged and target user is, DM a prompt and
1492
                # add a notif for the target user
1493
                if (id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
1494
                        and target_author_key):
1495
                    if target_author := target_author_key.get():
1✔
1496
                        if target_author.is_enabled(from_cls):
1✔
1497
                            memcache.add_notification(target_author, write_obj)
1✔
1498
                            verb, noun = (
1✔
1499
                                ('replied to', 'replies') if id in in_reply_tos
1500
                                else ('quoted', 'quotes') if id in quoted_posts
1501
                                else ('mentioned', 'mentions'))
1502
                            dms.maybe_send(
1✔
1503
                                from_proto=target_proto, to_user=from_user,
1504
                                type='replied_to_bridged_user', text=f"""\
1505
Hi! You <a href="{inner_obj_as1.get('url') or inner_obj_id}">recently {verb}</a> {target_author.user_link()}, who's bridged here from {target_proto.PHRASE}. If you want them to see your {noun}, 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.""")
1506

1507
                continue
1✔
1508

1509
            # deliver self-replies to followers
1510
            # https://github.com/snarfed/bridgy-fed/issues/639
1511
            if id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1512
                is_self_reply = True
1✔
1513
                logger.info(f'self reply!')
1✔
1514

1515
            # also add copies' targets
1516
            for copy in orig_obj.copies:
1✔
1517
                proto = PROTOCOLS[copy.protocol]
1✔
1518
                if proto in to_protocols:
1✔
1519
                    # copies generally won't have their own Objects
1520
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1521
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {id}')
1✔
1522
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1523

1524
            if target_proto == from_cls:
1✔
1525
                logger.debug(f'Skipping same-protocol target {id}')
1✔
1526
                continue
1✔
1527

1528
            target = target_proto.target_for(orig_obj)
1✔
1529
            if not target:
1✔
1530
                # TODO: surface errors like this somehow?
UNCOV
1531
                logger.error(f"Can't find delivery target for {id}")
×
UNCOV
1532
                continue
×
1533

1534
            logger.debug(f'Target for {id} is {target}')
1✔
1535
            # only use orig_obj for inReplyTos, like/repost objects, etc
1536
            # https://github.com/snarfed/bridgy-fed/issues/1237
1537
            targets[Target(protocol=target_proto.LABEL, uri=target)] = (
1✔
1538
                orig_obj if id in in_reply_tos or id in as1.get_ids(obj.as1, 'object')
1539
                else None)
1540

1541
            if target_author_key:
1✔
1542
                logger.debug(f'Recipient is {target_author_key}')
1✔
1543
                if write_obj.add('notify', target_author_key):
1✔
1544
                    write_obj.dirty = True
1✔
1545

1546
        if obj.type == 'undo':
1✔
1547
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1548
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1549
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1550
            else:
1551
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1552
            if inner_obj:
1✔
1553
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1554
                                                internal=True))
1555

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

1558
        # deliver to followers, if appropriate
1559
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1560
        if not user_key:
1✔
1561
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1562
            return targets
1✔
1563

1564
        followers = []
1✔
1565
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1566
                and (not is_reply or is_self_reply)):
1567
            logger.info(f'Delivering to followers of {user_key}')
1✔
1568
            followers = [
1✔
1569
                f for f in Follower.query(Follower.to == user_key,
1570
                                          Follower.status == 'active')
1571
                # skip protocol bot users
1572
                if not Protocol.for_bridgy_subdomain(f.from_.id())
1573
                # skip protocols this user hasn't enabled, or where the base
1574
                # object of this activity hasn't been bridged
1575
                and PROTOCOLS_BY_KIND[f.from_.kind()] in to_protocols]
1576
            user_keys = [f.from_ for f in followers]
1✔
1577
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1578
            User.load_multi(users)
1✔
1579

1580
            if (not followers and
1✔
1581
                (util.domain_or_parent_in(
1582
                    util.domain_from_link(from_user.key.id()), LIMITED_DOMAINS)
1583
                 or util.domain_or_parent_in(
1584
                     util.domain_from_link(obj.key.id()), LIMITED_DOMAINS))):
1585
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1586
                return {}
1✔
1587

1588
            # add to followers' feeds, if any
1589
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1590
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1591
                    write_obj.feed = [u.key for u in users]
1✔
1592
                    if write_obj.feed:
1✔
1593
                        write_obj.dirty = True
1✔
1594

1595
            # collect targets for followers
1596
            for user in users:
1✔
1597
                # TODO: should we pass remote=False through here to Protocol.load?
1598
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1599
                if not target:
1✔
1600
                    # TODO: surface errors like this somehow?
1601
                    logger.error(f'Follower {user.key} has no delivery target')
1✔
1602
                    continue
1✔
1603

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

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

1612
        # deliver to enabled HAS_COPIES protocols proactively
1613
        # TODO: abstract for other protocols
1614
        from atproto import ATProto
1✔
1615
        if (ATProto in to_protocols
1✔
1616
                and obj.type in ('post', 'update', 'delete', 'share')):
1617
            logger.info(f'user has ATProto enabled, adding {ATProto.PDS_URL}')
1✔
1618
            targets.setdefault(
1✔
1619
                Target(protocol=ATProto.LABEL, uri=ATProto.PDS_URL), None)
1620

1621
        # de-dupe targets, discard same-domain
1622
        # maps string target URL to (Target, Object) tuple
1623
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1624
        # maps Target to Object or None
1625
        targets = {}
1✔
1626
        source_domains = [
1✔
1627
            util.domain_from_link(url) for url in
1628
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1629
            if util.is_web(url)
1630
        ]
1631
        for url in sorted(util.dedupe_urls(
1✔
1632
                candidates.keys(),
1633
                # preserve our PDS URL without trailing slash in path
1634
                # https://atproto.com/specs/did#did-documents
1635
                trailing_slash=False)):
1636
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
UNCOV
1637
                logger.info(f'Skipping same-domain target {url}')
×
UNCOV
1638
                continue
×
1639
            target, obj = candidates[url]
1✔
1640
            targets[target] = obj
1✔
1641

1642
        return targets
1✔
1643

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

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

1651
        Args:
1652
          id (str)
1653
          remote (bool): whether to fetch the object over the network. If True,
1654
            fetches even if we already have the object stored, and updates our
1655
            stored copy. If False and we don't have the object stored, returns
1656
            None. Default (None) means to fetch over the network only if we
1657
            don't already have it stored.
1658
          local (bool): whether to load from the datastore before
1659
            fetching over the network. If False, still stores back to the
1660
            datastore after a successful remote fetch.
1661
          raise_ (bool): if False, catches any :class:`request.RequestException`
1662
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1663
            ``None`` instead
1664
          kwargs: passed through to :meth:`fetch()`
1665

1666
        Returns:
1667
          models.Object: loaded object, or None if it isn't fetchable, eg a
1668
          non-URL string for Web, or ``remote`` is False and it isn't in the
1669
          datastore
1670

1671
        Raises:
1672
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1673
            is True
1674
        """
1675
        assert id
1✔
1676
        assert local or remote is not False
1✔
1677
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1678

1679
        obj = orig_as1 = None
1✔
1680
        if local:
1✔
1681
            obj = Object.get_by_id(id)
1✔
1682
            if not obj:
1✔
1683
                # logger.debug(f' {id} not in datastore')
1684
                pass
1✔
1685
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1686
                # logger.debug(f'  {id} got from datastore')
1687
                obj.new = False
1✔
1688

1689
        if remote is False:
1✔
1690
            return obj
1✔
1691
        elif remote is None and obj:
1✔
1692
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1693
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1694
                pass
1✔
1695
            else:
1696
                return obj
1✔
1697

1698
        if obj:
1✔
1699
            orig_as1 = obj.as1
1✔
1700
            obj.our_as1 = None
1✔
1701
            obj.new = False
1✔
1702
        else:
1703
            obj = Object(id=id)
1✔
1704
            if local:
1✔
1705
                # logger.debug(f'  {id} not in datastore')
1706
                obj.new = True
1✔
1707
                obj.changed = False
1✔
1708

1709
        try:
1✔
1710
            fetched = cls.fetch(obj, **kwargs)
1✔
1711
        except (RequestException, HTTPException) as e:
1✔
1712
            if raise_:
1✔
1713
                raise
1✔
1714
            util.interpret_http_exception(e)
1✔
1715
            return None
1✔
1716

1717
        if not fetched:
1✔
1718
            return None
1✔
1719

1720
        # https://stackoverflow.com/a/3042250/186123
1721
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1722
        if size > models.MAX_ENTITY_SIZE:
1✔
1723
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1724
            return None
1✔
1725

1726
        obj.resolve_ids()
1✔
1727
        obj.normalize_ids()
1✔
1728

1729
        if obj.new is False:
1✔
1730
            obj.changed = obj.activity_changed(orig_as1)
1✔
1731

1732
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1733
            if obj.source_protocol:
1✔
UNCOV
1734
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1735
            obj.source_protocol = cls.LABEL
1✔
1736

1737
        obj.put()
1✔
1738
        return obj
1✔
1739

1740
    @classmethod
1✔
1741
    def check_supported(cls, obj):
1✔
1742
        """If this protocol doesn't support this object, raises HTTP 204.
1743

1744
        Also reports an error.
1745

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

1750
        Args:
1751
          obj (Object)
1752

1753
        Raises:
1754
          werkzeug.HTTPException: if this protocol doesn't support this object
1755
        """
1756
        if not obj.type:
1✔
UNCOV
1757
            return
×
1758

1759
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1760
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1761
            or (obj.type in as1.CRUD_VERBS
1762
                and inner_type
1763
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1764
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1765

1766
        # don't allow posts with blank content and no image/video/audio
1767
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
1768
                    else obj.as1)
1769
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
1770
                and not util.get_url(crud_obj, key='image')
1771
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
1772
                # TODO: handle articles with displayName but not content
1773
                and not source.html_to_text(crud_obj.get('content')).strip()):
1774
            error('Blank content and no image or video or audio', status=204)
1✔
1775

1776
        # DMs are only allowed to/from protocol bot accounts
1777
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1778
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1779
            if (not cls.SUPPORTS_DMS
1✔
1780
                    or (recip not in protocol_user_ids
1781
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1782
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1783

1784

1785
@cloud_tasks_only(log=None)
1✔
1786
def receive_task():
1✔
1787
    """Task handler for a newly received :class:`models.Object`.
1788

1789
    Calls :meth:`Protocol.receive` with the form parameters.
1790

1791
    Parameters:
1792
      authed_as (str): passed to :meth:`Protocol.receive`
1793
      obj_id (str): key id of :class:`models.Object` to handle
1794
      received_at (str, ISO 8601 timestamp): when we first saw (received)
1795
        this activity
1796
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1797
        :class:`models.Object` to handle
1798

1799
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
1800
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
1801
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
1802
    ``changed``, HTTP request details, etc. See stash for attempt at this for
1803
    :class:`web.Web`.
1804
    """
1805
    common.log_request()
1✔
1806
    form = request.form.to_dict()
1✔
1807

1808
    authed_as = form.pop('authed_as', None)
1✔
1809
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1810
                or authed_as in common.PROTOCOL_DOMAINS)
1811

1812
    obj = Object.from_request()
1✔
1813
    assert obj
1✔
1814
    assert obj.source_protocol
1✔
1815
    obj.new = True
1✔
1816

1817
    if received_at := form.pop('received_at', None):
1✔
1818
        received_at = datetime.fromisoformat(received_at)
1✔
1819

1820
    try:
1✔
1821
        return PROTOCOLS[obj.source_protocol].receive(
1✔
1822
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
1823
    except RequestException as e:
1✔
1824
        util.interpret_http_exception(e)
1✔
1825
        error(e, status=304)
1✔
1826
    except ValueError as e:
1✔
UNCOV
1827
        logger.warning(e, exc_info=True)
×
UNCOV
1828
        error(e, status=304)
×
1829

1830

1831
@cloud_tasks_only(log=None)
1✔
1832
def send_task():
1✔
1833
    """Task handler for sending an activity to a single specific destination.
1834

1835
    Calls :meth:`Protocol.send` with the form parameters.
1836

1837
    Parameters:
1838
      protocol (str): :class:`Protocol` to send to
1839
      url (str): destination URL to send to
1840
      obj_id (str): key id of :class:`models.Object` to send
1841
      orig_obj_id (str): optional, :class:`models.Object` key id of the
1842
        "original object" that this object refers to, eg replies to or reposts
1843
        or likes
1844
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1845
        this activity is from
1846
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1847
        :class:`models.Object` to handle
1848
    """
1849
    common.log_request()
1✔
1850

1851
    # prepare
1852
    form = request.form.to_dict()
1✔
1853
    url = form.get('url')
1✔
1854
    protocol = form.get('protocol')
1✔
1855
    if not url or not protocol:
1✔
1856
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1857
        return '', 204
1✔
1858

1859
    target = Target(uri=url, protocol=protocol)
1✔
1860
    obj = Object.from_request()
1✔
1861
    assert obj and obj.key and obj.key.id()
1✔
1862

1863
    PROTOCOLS[protocol].check_supported(obj)
1✔
1864
    allow_opt_out = (obj.type == 'delete')
1✔
1865

1866
    user = None
1✔
1867
    if user_key := form.get('user'):
1✔
1868
        key = ndb.Key(urlsafe=user_key)
1✔
1869
        # use get_by_id so that we follow use_instead
1870
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
1871
            key.id(), allow_opt_out=allow_opt_out)
1872

1873
    # send
1874
    delay = ''
1✔
1875
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
1876
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
1877
        delay = f'({delay_s} s behind)'
1✔
1878
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
1879
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
1880
    sent = None
1✔
1881
    try:
1✔
1882
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
1883
                                        orig_obj_id=form.get('orig_obj_id'))
1884
    except BaseException as e:
1✔
1885
        code, body = util.interpret_http_exception(e)
1✔
1886
        if not code and not body:
1✔
1887
            raise
1✔
1888

1889
    if sent is False:
1✔
1890
        logger.info(f'Failed sending!')
1✔
1891

1892
    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