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

snarfed / bridgy-fed / 65cb0bd4-066b-4385-bebb-bcb13c12be5a

20 May 2025 06:32PM UTC coverage: 92.431% (+1.5%) from 90.953%
65cb0bd4-066b-4385-bebb-bcb13c12be5a

push

circleci

snarfed
login/settings: fix /pages tests for #1939

4958 of 5364 relevant lines covered (92.43%)

0.92 hits per line

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

93.88
/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
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1440
        is_self_reply = False
1✔
1441

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

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

1469
            util.add(to_protocols, proto)
1✔
1470

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

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

1486
            target_author_key = target_proto.actor_key(orig_obj)
1✔
1487
            if not from_user.is_enabled(target_proto):
1✔
1488
                # if author isn't bridged and inReplyTo author is, DM a prompt
1489
                if id in in_reply_tos and target_author_key:
1✔
1490
                    if target_author := target_author_key.get():
1✔
1491
                        if target_author.is_enabled(from_cls):
1✔
1492
                            dms.maybe_send(
1✔
1493
                                from_proto=target_proto, to_user=from_user,
1494
                                type='replied_to_bridged_user', text=f"""\
1495
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.""")
1496

1497
                continue
1✔
1498

1499
            # deliver self-replies to followers
1500
            # https://github.com/snarfed/bridgy-fed/issues/639
1501
            if id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1502
                is_self_reply = True
1✔
1503
                logger.info(f'self reply!')
1✔
1504

1505
            # also add copies' targets
1506
            for copy in orig_obj.copies:
1✔
1507
                proto = PROTOCOLS[copy.protocol]
1✔
1508
                if proto in to_protocols:
1✔
1509
                    # copies generally won't have their own Objects
1510
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1511
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {id}')
1✔
1512
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1513

1514
            if target_proto == from_cls:
1✔
1515
                logger.debug(f'Skipping same-protocol target {id}')
1✔
1516
                continue
1✔
1517

1518
            target = target_proto.target_for(orig_obj)
1✔
1519
            if not target:
1✔
1520
                # TODO: surface errors like this somehow?
1521
                logger.error(f"Can't find delivery target for {id}")
×
1522
                continue
×
1523

1524
            logger.debug(f'Target for {id} is {target}')
1✔
1525
            # only use orig_obj for inReplyTos, like/repost objects, etc
1526
            # https://github.com/snarfed/bridgy-fed/issues/1237
1527
            targets[Target(protocol=target_proto.LABEL, uri=target)] = (
1✔
1528
                orig_obj if id in in_reply_tos or id in as1.get_ids(obj.as1, 'object')
1529
                else None)
1530

1531
            if target_author_key:
1✔
1532
                logger.debug(f'Recipient is {target_author_key}')
1✔
1533
                if write_obj.add('notify', target_author_key):
1✔
1534
                    write_obj.dirty = True
1✔
1535

1536
        if obj.type == 'undo':
1✔
1537
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1538
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1539
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1540
            else:
1541
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1542
            if inner_obj:
1✔
1543
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1544
                                                internal=True))
1545

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

1548
        # deliver to followers, if appropriate
1549
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1550
        if not user_key:
1✔
1551
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1552
            return targets
1✔
1553

1554
        followers = []
1✔
1555
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1556
                and (not is_reply or is_self_reply)):
1557
            logger.info(f'Delivering to followers of {user_key}')
1✔
1558
            followers = [
1✔
1559
                f for f in Follower.query(Follower.to == user_key,
1560
                                          Follower.status == 'active')
1561
                # skip protocol bot users
1562
                if not Protocol.for_bridgy_subdomain(f.from_.id())
1563
                # skip protocols this user hasn't enabled, or where the base
1564
                # object of this activity hasn't been bridged
1565
                and PROTOCOLS_BY_KIND[f.from_.kind()] in to_protocols]
1566
            user_keys = [f.from_ for f in followers]
1✔
1567
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1568
            User.load_multi(users)
1✔
1569

1570
            if (not followers and
1✔
1571
                (util.domain_or_parent_in(
1572
                    util.domain_from_link(from_user.key.id()), LIMITED_DOMAINS)
1573
                 or util.domain_or_parent_in(
1574
                     util.domain_from_link(obj.key.id()), LIMITED_DOMAINS))):
1575
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1576
                return {}
1✔
1577

1578
            # add to followers' feeds, if any
1579
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1580
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1581
                    write_obj.feed = [u.key for u in users]
1✔
1582
                    if write_obj.feed:
1✔
1583
                        write_obj.dirty = True
1✔
1584

1585
            # collect targets for followers
1586
            for user in users:
1✔
1587
                # TODO: should we pass remote=False through here to Protocol.load?
1588
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1589
                if not target:
1✔
1590
                    # TODO: surface errors like this somehow?
1591
                    logger.error(f'Follower {user.key} has no delivery target')
1✔
1592
                    continue
1✔
1593

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

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

1602
        # deliver to enabled HAS_COPIES protocols proactively
1603
        # TODO: abstract for other protocols
1604
        from atproto import ATProto
1✔
1605
        if (ATProto in to_protocols
1✔
1606
                and obj.type in ('post', 'update', 'delete', 'share')):
1607
            logger.info(f'user has ATProto enabled, adding {ATProto.PDS_URL}')
1✔
1608
            targets.setdefault(
1✔
1609
                Target(protocol=ATProto.LABEL, uri=ATProto.PDS_URL), None)
1610

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

1632
        return targets
1✔
1633

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

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

1641
        Args:
1642
          id (str)
1643
          remote (bool): whether to fetch the object over the network. If True,
1644
            fetches even if we already have the object stored, and updates our
1645
            stored copy. If False and we don't have the object stored, returns
1646
            None. Default (None) means to fetch over the network only if we
1647
            don't already have it stored.
1648
          local (bool): whether to load from the datastore before
1649
            fetching over the network. If False, still stores back to the
1650
            datastore after a successful remote fetch.
1651
          raise_ (bool): if False, catches any :class:`request.RequestException`
1652
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1653
            ``None`` instead
1654
          kwargs: passed through to :meth:`fetch()`
1655

1656
        Returns:
1657
          models.Object: loaded object, or None if it isn't fetchable, eg a
1658
          non-URL string for Web, or ``remote`` is False and it isn't in the
1659
          datastore
1660

1661
        Raises:
1662
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1663
            is True
1664
        """
1665
        assert id
1✔
1666
        assert local or remote is not False
1✔
1667
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1668

1669
        obj = orig_as1 = None
1✔
1670
        if local:
1✔
1671
            obj = Object.get_by_id(id)
1✔
1672
            if not obj:
1✔
1673
                # logger.debug(f' {id} not in datastore')
1674
                pass
1✔
1675
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1676
                # logger.debug(f'  {id} got from datastore')
1677
                obj.new = False
1✔
1678

1679
        if remote is False:
1✔
1680
            return obj
1✔
1681
        elif remote is None and obj:
1✔
1682
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1683
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1684
                pass
1✔
1685
            else:
1686
                return obj
1✔
1687

1688
        if obj:
1✔
1689
            orig_as1 = obj.as1
1✔
1690
            obj.our_as1 = None
1✔
1691
            obj.new = False
1✔
1692
        else:
1693
            obj = Object(id=id)
1✔
1694
            if local:
1✔
1695
                # logger.debug(f'  {id} not in datastore')
1696
                obj.new = True
1✔
1697
                obj.changed = False
1✔
1698

1699
        try:
1✔
1700
            fetched = cls.fetch(obj, **kwargs)
1✔
1701
        except (RequestException, HTTPException) as e:
1✔
1702
            if raise_:
1✔
1703
                raise
1✔
1704
            util.interpret_http_exception(e)
1✔
1705
            return None
1✔
1706

1707
        if not fetched:
1✔
1708
            return None
1✔
1709

1710
        # https://stackoverflow.com/a/3042250/186123
1711
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1712
        if size > models.MAX_ENTITY_SIZE:
1✔
1713
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1714
            return None
1✔
1715

1716
        obj.resolve_ids()
1✔
1717
        obj.normalize_ids()
1✔
1718

1719
        if obj.new is False:
1✔
1720
            obj.changed = obj.activity_changed(orig_as1)
1✔
1721

1722
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1723
            if obj.source_protocol:
1✔
1724
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1725
            obj.source_protocol = cls.LABEL
1✔
1726

1727
        obj.put()
1✔
1728
        return obj
1✔
1729

1730
    @classmethod
1✔
1731
    def check_supported(cls, obj):
1✔
1732
        """If this protocol doesn't support this object, raises HTTP 204.
1733

1734
        Also reports an error.
1735

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

1740
        Args:
1741
          obj (Object)
1742

1743
        Raises:
1744
          werkzeug.HTTPException: if this protocol doesn't support this object
1745
        """
1746
        if not obj.type:
1✔
1747
            return
×
1748

1749
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1750
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1751
            or (obj.type in as1.CRUD_VERBS
1752
                and inner_type
1753
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1754
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1755

1756
        # don't allow posts with blank content and no image/video/audio
1757
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
1758
                    else obj.as1)
1759
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
1760
                and not util.get_url(crud_obj, key='image')
1761
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
1762
                # TODO: handle articles with displayName but not content
1763
                and not source.html_to_text(crud_obj.get('content')).strip()):
1764
            error('Blank content and no image or video or audio', status=204)
1✔
1765

1766
        # DMs are only allowed to/from protocol bot accounts
1767
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1768
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1769
            if (not cls.SUPPORTS_DMS
1✔
1770
                    or (recip not in protocol_user_ids
1771
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1772
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1773

1774

1775
@cloud_tasks_only(log=None)
1✔
1776
def receive_task():
1✔
1777
    """Task handler for a newly received :class:`models.Object`.
1778

1779
    Calls :meth:`Protocol.receive` with the form parameters.
1780

1781
    Parameters:
1782
      authed_as (str): passed to :meth:`Protocol.receive`
1783
      obj_id (str): key id of :class:`models.Object` to handle
1784
      received_at (str, ISO 8601 timestamp): when we first saw (received)
1785
        this activity
1786
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1787
        :class:`models.Object` to handle
1788

1789
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
1790
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
1791
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
1792
    ``changed``, HTTP request details, etc. See stash for attempt at this for
1793
    :class:`web.Web`.
1794
    """
1795
    common.log_request()
1✔
1796
    form = request.form.to_dict()
1✔
1797

1798
    authed_as = form.pop('authed_as', None)
1✔
1799
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1800
                or authed_as in common.PROTOCOL_DOMAINS)
1801

1802
    obj = Object.from_request()
1✔
1803
    assert obj
1✔
1804
    assert obj.source_protocol
1✔
1805
    obj.new = True
1✔
1806

1807
    if received_at := form.pop('received_at', None):
1✔
1808
        received_at = datetime.fromisoformat(received_at)
1✔
1809

1810
    try:
1✔
1811
        return PROTOCOLS[obj.source_protocol].receive(
1✔
1812
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
1813
    except RequestException as e:
1✔
1814
        util.interpret_http_exception(e)
1✔
1815
        error(e, status=304)
1✔
1816
    except ValueError as e:
1✔
1817
        logger.warning(e, exc_info=True)
×
1818
        error(e, status=304)
×
1819

1820

1821
@cloud_tasks_only(log=None)
1✔
1822
def send_task():
1✔
1823
    """Task handler for sending an activity to a single specific destination.
1824

1825
    Calls :meth:`Protocol.send` with the form parameters.
1826

1827
    Parameters:
1828
      protocol (str): :class:`Protocol` to send to
1829
      url (str): destination URL to send to
1830
      obj_id (str): key id of :class:`models.Object` to send
1831
      orig_obj_id (str): optional, :class:`models.Object` key id of the
1832
        "original object" that this object refers to, eg replies to or reposts
1833
        or likes
1834
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1835
        this activity is from
1836
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1837
        :class:`models.Object` to handle
1838
    """
1839
    common.log_request()
1✔
1840

1841
    # prepare
1842
    form = request.form.to_dict()
1✔
1843
    url = form.get('url')
1✔
1844
    protocol = form.get('protocol')
1✔
1845
    if not url or not protocol:
1✔
1846
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1847
        return '', 204
1✔
1848

1849
    target = Target(uri=url, protocol=protocol)
1✔
1850
    obj = Object.from_request()
1✔
1851
    assert obj and obj.key and obj.key.id()
1✔
1852

1853
    PROTOCOLS[protocol].check_supported(obj)
1✔
1854
    allow_opt_out = (obj.type == 'delete')
1✔
1855

1856
    user = None
1✔
1857
    if user_key := form.get('user'):
1✔
1858
        key = ndb.Key(urlsafe=user_key)
1✔
1859
        # use get_by_id so that we follow use_instead
1860
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
1861
            key.id(), allow_opt_out=allow_opt_out)
1862

1863
    # send
1864
    delay = ''
1✔
1865
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
1866
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
1867
        delay = f'({delay_s} s behind)'
1✔
1868
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
1869
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
1870
    sent = None
1✔
1871
    try:
1✔
1872
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
1873
                                        orig_obj_id=form.get('orig_obj_id'))
1874
    except BaseException as e:
1✔
1875
        code, body = util.interpret_http_exception(e)
1✔
1876
        if not code and not body:
1✔
1877
            raise
1✔
1878

1879
    if sent is False:
1✔
1880
        logger.info(f'Failed sending!')
1✔
1881

1882
    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