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

snarfed / bridgy-fed / bda6442c-9991-475e-baa0-326fdec91086

04 Apr 2026 11:50PM UTC coverage: 94.194% (+0.001%) from 94.193%
bda6442c-9991-475e-baa0-326fdec91086

push

circleci

snarfed
circle: add heavy service

for #2424

7203 of 7647 relevant lines covered (94.19%)

0.94 hits per line

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

95.97
/protocol.py
1
"""Base protocol class and common code."""
2
from bs4 import BeautifulSoup
1✔
3
import copy
1✔
4
from datetime import datetime, timedelta, timezone
1✔
5
import logging
1✔
6
import os
1✔
7
import re
1✔
8
from threading import Lock
1✔
9
from urllib.parse import urljoin, urlparse
1✔
10

11
from cachetools import cached, LRUCache
1✔
12
from flask import request
1✔
13
from google.cloud import ndb
1✔
14
from google.cloud.ndb import OR
1✔
15
from google.cloud.ndb.model import _entity_to_protobuf
1✔
16
from granary import as1, as2, source
1✔
17
from granary.source import HTML_ENTITY_RE, html_to_text
1✔
18
from oauth_dropins.webutil.appengine_info import DEBUG
1✔
19
from oauth_dropins.webutil.flask_util import cloud_tasks_only
1✔
20
from oauth_dropins.webutil.models import MAX_ENTITY_SIZE
1✔
21
from oauth_dropins.webutil import util
1✔
22
from oauth_dropins.webutil.util import json_dumps, json_loads
1✔
23
from pymemcache.exceptions import (
1✔
24
    MemcacheServerError,
25
    MemcacheUnexpectedCloseError,
26
    MemcacheUnknownError,
27
)
28
from requests import RequestException
1✔
29
from websockets.exceptions import InvalidStatus
1✔
30
import werkzeug.exceptions
1✔
31
from werkzeug.exceptions import BadGateway, BadRequest, HTTPException
1✔
32

33
import common
1✔
34
from common import (
1✔
35
    ErrorButDoNotRetryTask,
36
    report_error,
37
)
38
from domains import (
1✔
39
    DOMAINS,
40
    LOCAL_DOMAINS,
41
    PRIMARY_DOMAIN,
42
    PROTOCOL_DOMAINS,
43
    SUPERDOMAIN,
44
)
45
import dms
1✔
46
from domains import DOMAIN_BLOCKLIST
1✔
47
import ids
1✔
48
import memcache
1✔
49
from models import (
1✔
50
    Follower,
51
    get_original_user_key,
52
    load_user,
53
    Object,
54
    PROTOCOLS,
55
    PROTOCOLS_BY_KIND,
56
    Target,
57
    User,
58
)
59
import notifications
1✔
60

61
OBJECT_REFRESH_AGE = timedelta(days=30)
1✔
62
DELETE_TASK_DELAY = timedelta(minutes=1)
1✔
63
CREATE_MAX_AGE = timedelta(weeks=2)
1✔
64
CREATE_MAX_AGE_EXEMPT_DOMAINS = (
1✔
65
    'alt.store',
66
)
67
# WARNING: keep this below the receive queue's min_backoff_seconds in queue.yaml!
68
MEMCACHE_LEASE_EXPIRATION = timedelta(seconds=25)
1✔
69
MEMCACHE_DOWN_TASK_DELAY = timedelta(minutes=5)
1✔
70
# WARNING: keep this in sync with queue.yaml's receive and webmention task_retry_limit!
71
TASK_RETRIES_RECEIVE = 4
1✔
72
# https://docs.cloud.google.com/tasks/docs/creating-appengine-handlers#reading-headers
73
TASK_RETRIES_HEADER = 'X-AppEngine-TaskRetryCount'
1✔
74

75
# require a follow for users on these domains before we deliver anything from
76
# them other than their profile
77
LIMITED_DOMAINS = (os.getenv('LIMITED_DOMAINS', '').split()
1✔
78
                   or util.load_file_lines('limited_domains'))
79

80
# domains to allow non-public activities from
81
NON_PUBLIC_DOMAINS = (
1✔
82
    # bridged from twitter (X). bird.makeup, kilogram.makeup, etc federate
83
    # tweets as followers-only, but they're public on twitter itself
84
    '.makeup',
85
)
86

87
DONT_STORE_AS1_TYPES = as1.CRUD_VERBS | set((
1✔
88
    'accept',
89
    'reject',
90
    'stop-following',
91
    'undo',
92
))
93
STORE_AS1_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES | as1.VERBS_WITH_OBJECT
1✔
94
                   - DONT_STORE_AS1_TYPES)
95

96
logger = logging.getLogger(__name__)
1✔
97

98

99
def error(*args, status=299, **kwargs):
1✔
100
    """Default HTTP status code to 299 to prevent retrying task."""
101
    return common.error(*args, status=status, **kwargs)
1✔
102

103

104
def activity_id_memcache_key(id):
1✔
105
    return memcache.key(f'receive-{id}')
1✔
106

107

108
class Protocol:
1✔
109
    """Base protocol class. Not to be instantiated; classmethods only."""
110
    ABBREV = None
1✔
111
    'str: lower case abbreviation, used in URL paths'
1✔
112
    PHRASE = None
1✔
113
    'str: human-readable name or phrase. Used in phrases like ``Follow this person on {PHRASE}``'
1✔
114
    OTHER_LABELS = ()
1✔
115
    'sequence of str: label aliases'
1✔
116
    LOGO_EMOJI = ''
1✔
117
    'str: logo emoji, if any'
1✔
118
    LOGO_HTML = ''
1✔
119
    'str: logo ``<img>`` tag, if any'
1✔
120
    CONTENT_TYPE = None
1✔
121
    "str: MIME type of this protocol's native data format, appropriate for the ``Content-Type`` HTTP header."
1✔
122
    HAS_COPIES = False
1✔
123
    '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✔
124
    DEFAULT_TARGET = None
1✔
125
    'str: optional, the default target URI to send this protocol\'s activities to. May be used as the "shared" target. Often only set if ``HAS_COPIES`` is true.'
1✔
126
    REQUIRES_AVATAR = False
1✔
127
    "bool: whether accounts on this protocol are required to have a profile picture. If they don't, their ``User.status`` will be ``blocked``."
1✔
128
    REQUIRES_NAME = False
1✔
129
    "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✔
130
    REQUIRES_OLD_ACCOUNT = False
1✔
131
    "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✔
132
    DEFAULT_ENABLED_PROTOCOLS = ()
1✔
133
    'sequence of str: labels of other protocols that are automatically enabled for this protocol to bridge into'
1✔
134
    DEFAULT_SERVE_USER_PAGES = False
1✔
135
    "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✔
136
    SUPPORTED_AS1_TYPES = ()
1✔
137
    'sequence of str: AS1 objectTypes and verbs that this protocol supports receiving and sending'
1✔
138
    SUPPORTS_DMS = False
1✔
139
    'bool: whether this protocol can receive DMs (chat messages)'
1✔
140
    USES_OBJECT_FEED = False
1✔
141
    'bool: whether to store followers on this protocol in :attr:`Object.feed`.'
1✔
142
    HTML_PROFILES = False
1✔
143
    'bool: whether this protocol supports HTML in profile descriptions. If False, profile descriptions should be plain text.'
1✔
144
    SEND_REPLIES_TO_ORIG_POSTS_MENTIONS = False
1✔
145
    "bool: whether replies to this protocol should include the original post's mentions as delivery targets"
1✔
146
    BOTS_FOLLOW_BACK = False
1✔
147
    'bool: when a user on this protocol follows a bot user to enable bridging, does the bot follow them back?'
1✔
148
    HANDLES_PER_PAY_LEVEL_DOMAIN = None
1✔
149
    'int: how many users to allow with handles on the same pay-level domain. None for no limit.'
1✔
150
    RECEIVE_FILTERS = ()
1✔
151
    'tuple of callable: filter functions from filters.py to apply to incoming activities. Applied in order, so put the cheapest filters first.'
1✔
152
    RATE_LIMIT_TYPE = memcache.RateLimitType.LINEAR
1✔
153
    'Whether receive and send task rate limiting increases linearly or exponential.'
1✔
154

155
    @classmethod
1✔
156
    @property
1✔
157
    def LABEL(cls):
1✔
158
        """str: human-readable lower case name of this protocol, eg ``'activitypub``"""
159
        return cls.__name__.lower()
1✔
160

161
    @staticmethod
1✔
162
    def for_request(fed=None):
1✔
163
        """Returns the protocol for the current request.
164

165
        ...based on the request's hostname.
166

167
        Args:
168
          fed (str or protocol.Protocol): protocol to return if the current
169
            request is on ``fed.brid.gy``
170

171
        Returns:
172
          Protocol: protocol, or None if the provided domain or request hostname
173
          domain is not a subdomain of ``brid.gy`` or isn't a known protocol
174
        """
175
        return Protocol.for_bridgy_subdomain(request.host, fed=fed)
1✔
176

177
    @staticmethod
1✔
178
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
179
        """Returns the protocol for a brid.gy subdomain.
180

181
        Args:
182
          domain_or_url (str)
183
          fed (str or protocol.Protocol): protocol to return if the current
184
            request is on ``fed.brid.gy``
185

186
        Returns:
187
          class: :class:`Protocol` subclass, or None if the provided domain or request
188
          hostname domain is not a subdomain of ``brid.gy`` or isn't a known
189
          protocol
190
        """
191
        domain = (util.domain_from_link(domain_or_url, minimize=False)
1✔
192
                  if util.is_web(domain_or_url)
193
                  else domain_or_url)
194

195
        if domain == PRIMARY_DOMAIN or domain in LOCAL_DOMAINS:
1✔
196
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
1✔
197
        elif domain and domain.endswith(SUPERDOMAIN):
1✔
198
            label = domain.removesuffix(SUPERDOMAIN)
1✔
199
            return PROTOCOLS.get(label)
1✔
200

201
    @classmethod
1✔
202
    def owns_id(cls, id):
1✔
203
        """Returns whether this protocol owns the id, or None if it's unclear.
204

205
        To be implemented by subclasses.
206

207
        IDs are string identities that uniquely identify users or objects, and
208
        are intended primarily to be machine readable and usable. Compare to
209
        handles, which are human-chosen, human-meaningful, and often but not
210
        always unique.
211

212
        Some protocols' ids are more or less deterministic based on the id
213
        format, eg AT Protocol owns ``at://`` URIs and DIDs. Others, like
214
        http(s) URLs, could be owned by eg Web or ActivityPub.
215

216
        This should be a quick guess without expensive side effects, eg no
217
        external HTTP fetches to fetch the id itself or otherwise perform
218
        discovery.
219

220
        Returns False if the id's domain is in :const:`domains.DOMAIN_BLOCKLIST`.
221

222
        Args:
223
          id (str): user id or object id
224

225
        Returns:
226
          bool or None:
227
        """
228
        return False
1✔
229

230
    @classmethod
1✔
231
    def owns_handle(cls, handle, allow_internal=False):
1✔
232
        """Returns whether this protocol owns the handle, or None if it's unclear.
233

234
        To be implemented by subclasses.
235

236
        Handles are string identities that are human-chosen, human-meaningful,
237
        and often but not always unique. Compare to IDs, which uniquely identify
238
        users, and are intended primarily to be machine readable and usable.
239

240
        Some protocols' handles are more or less deterministic based on the id
241
        format, eg ActivityPub (technically WebFinger) handles are
242
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
243
        ActivityPub, AT Protocol, or others.
244

245
        This should be a quick guess without expensive side effects, eg no
246
        external HTTP fetches to fetch the id itself or otherwise perform
247
        discovery.
248

249
        Args:
250
          handle (str)
251
          allow_internal (bool): whether to return False for internal domains
252
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
253

254
        Returns:
255
          bool or None
256
        """
257
        return False
1✔
258

259
    @classmethod
1✔
260
    def handle_to_id(cls, handle):
1✔
261
        """Converts a handle to an id.
262

263
        To be implemented by subclasses.
264

265
        May incur network requests, eg DNS queries or HTTP requests. Avoids
266
        blocked or opted out users.
267

268
        Args:
269
          handle (str)
270

271
        Returns:
272
          str: corresponding id, or None if the handle can't be found
273
        """
274
        raise NotImplementedError()
×
275

276
    @classmethod
1✔
277
    def authed_user_for_request(cls):
1✔
278
        """Returns the authenticated user id for the current request.
279

280

281
        Checks authentication on the current request, eg HTTP Signature for
282
        ActivityPub. To be implemented by subclasses.
283

284
        Returns:
285
          str: authenticated user id, or None if there is no authentication
286

287
        Raises:
288
          RuntimeError: if the request's authentication (eg signature) is
289
          invalid or otherwise can't be verified
290
        """
291
        return None
1✔
292

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

297
        If called via `Protocol.key_for`, infers the appropriate protocol with
298
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
299
        as is.
300

301
        Args:
302
          id (str):
303
          allow_opt_out (bool): whether to allow users who are currently opted out
304

305
        Returns:
306
          google.cloud.ndb.Key: matching key, or None if the given id is not a
307
          valid :class:`User` id for this protocol.
308
        """
309
        if cls == Protocol:
1✔
310
            proto = Protocol.for_id(id)
1✔
311
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
312

313
        # load user so that we follow use_instead
314
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
315
        if existing:
1✔
316
            if existing.status and not allow_opt_out:
1✔
317
                return None
1✔
318
            return existing.key
1✔
319

320
        return cls(id=id).key
1✔
321

322
    @staticmethod
1✔
323
    def _for_id_memcache_key(id, remote=None):
1✔
324
        """If id is a URL, uses its domain, otherwise returns None.
325

326
        Args:
327
          id (str)
328

329
        Returns:
330
          (str domain, bool remote) or None
331
        """
332
        domain = util.domain_from_link(id)
1✔
333
        if domain in PROTOCOL_DOMAINS:
1✔
334
            return id
1✔
335
        elif remote and util.is_web(id):
1✔
336
            return domain
1✔
337

338
    @cached(LRUCache(20000), lock=Lock())
1✔
339
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote=True: remote,
1✔
340
                      version=3)
341
    @staticmethod
1✔
342
    def for_id(id, remote=True):
1✔
343
        """Returns the protocol for a given id.
344

345
        Args:
346
          id (str)
347
          remote (bool): whether to perform expensive side effects like fetching
348
            the id itself over the network, or other discovery.
349

350
        Returns:
351
          Protocol subclass: matching protocol, or None if no single known
352
          protocol definitively owns this id
353
        """
354
        logger.debug(f'Determining protocol for id {id}')
1✔
355
        if not id:
1✔
356
            return None
1✔
357

358
        # remove our synthetic id fragment, if any
359
        #
360
        # will this eventually cause false positives for other services that
361
        # include our full ids inside their own ids, non-URL-encoded? guess
362
        # we'll figure that out if/when it happens.
363
        id = id.partition('#bridgy-fed-')[0]
1✔
364
        if not id:
1✔
365
            return None
1✔
366

367
        if util.is_web(id):
1✔
368
            # step 1: check for our per-protocol subdomains
369
            try:
1✔
370
                parsed = urlparse(id)
1✔
371
            except ValueError as e:
1✔
372
                logger.info(f'urlparse ValueError: {e}')
1✔
373
                return None
1✔
374

375
            is_homepage = parsed.path.strip('/') == ''
1✔
376
            is_internal = parsed.path.startswith(ids.INTERNAL_PATH_PREFIX)
1✔
377
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
378
            if by_subdomain and not (is_homepage or is_internal
1✔
379
                                     or id in ids.BOT_ACTOR_AP_IDS):
380
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
381
                return by_subdomain
1✔
382

383
        # step 2: check if any Protocols say conclusively that they own it
384
        # sort to be deterministic
385
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
386
                           key=lambda p: p.LABEL)
387
        candidates = []
1✔
388
        for protocol in protocols:
1✔
389
            owns = protocol.owns_id(id)
1✔
390
            if owns:
1✔
391
                logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
392
                return protocol
1✔
393
            elif owns is not False:
1✔
394
                candidates.append(protocol)
1✔
395

396
        if len(candidates) == 1:
1✔
397
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
398
            return candidates[0]
1✔
399

400
        # step 3: look for existing Objects in the datastore
401
        #
402
        # note that we don't currently see if this is a copy id because I have FUD
403
        # over which Protocol for_id should return in that case...and also because a
404
        # protocol may already say definitively above that it owns the id, eg ATProto
405
        # with DIDs and at:// URIs.
406
        obj = Protocol.load(id, remote=False)
1✔
407
        if obj and obj.source_protocol:
1✔
408
            logger.debug(f'  {obj.key.id()} owned by source_protocol {obj.source_protocol}')
1✔
409
            return PROTOCOLS[obj.source_protocol]
1✔
410

411
        # step 4: fetch over the network, if necessary
412
        if not remote:
1✔
413
            return None
1✔
414

415
        for protocol in candidates:
1✔
416
            logger.debug(f'Trying {protocol.LABEL}')
1✔
417
            try:
1✔
418
                obj = protocol.load(id, local=False, remote=True)
1✔
419

420
                if protocol.ABBREV == 'web':
1✔
421
                    # for web, if we fetch and get HTML without microformats,
422
                    # load returns False but the object will be stored in the
423
                    # datastore with source_protocol web, and in cache. load it
424
                    # again manually to check for that.
425
                    obj = Object.get_by_id(id)
1✔
426
                    if obj and obj.source_protocol != 'web':
1✔
427
                        obj = None
×
428

429
                if obj:
1✔
430
                    logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
431
                    return protocol
1✔
432
            except BadGateway:
1✔
433
                # we tried and failed fetching the id over the network.
434
                # this depends on ActivityPub.fetch raising this!
435
                return None
1✔
436
            except HTTPException as e:
×
437
                # internal error we generated ourselves; try next protocol
438
                pass
×
439
            except Exception as e:
×
440
                code, _ = util.interpret_http_exception(e)
×
441
                if code:
×
442
                    # we tried and failed fetching the id over the network
443
                    return None
×
444
                raise
×
445

446
        logger.info(f'No matching protocol found for {id} !')
1✔
447
        return None
1✔
448

449
    @cached(LRUCache(20000), lock=Lock())
1✔
450
    @staticmethod
1✔
451
    def for_handle(handle):
1✔
452
        """Returns the protocol for a given handle.
453

454
        May incur expensive side effects like resolving the handle itself over
455
        the network or other discovery.
456

457
        Args:
458
          handle (str)
459

460
        Returns:
461
          (Protocol subclass, str) tuple: matching protocol and optional id (if
462
          resolved), or ``(None, None)`` if no known protocol owns this handle
463
        """
464
        # TODO: normalize, eg convert domains to lower case
465
        logger.debug(f'Determining protocol for handle {handle}')
1✔
466
        if not handle:
1✔
467
            return (None, None)
1✔
468

469
        # step 1: check if any Protocols say conclusively that they own it.
470
        # sort to be deterministic.
471
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
472
                           key=lambda p: p.LABEL)
473
        candidates = []
1✔
474
        for proto in protocols:
1✔
475
            owns = proto.owns_handle(handle)
1✔
476
            if owns:
1✔
477
                logger.debug(f'  {proto.LABEL} owns handle {handle}')
1✔
478
                return (proto, None)
1✔
479
            elif owns is not False:
1✔
480
                candidates.append(proto)
1✔
481

482
        if len(candidates) == 1:
1✔
483
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
484
            return (candidates[0], None)
×
485

486
        # step 2: look for matching User in the datastore
487
        for proto in candidates:
1✔
488
            user = proto.query(proto.handle == handle).get()
1✔
489
            if user:
1✔
490
                if user.status:
1✔
491
                    return (None, None)
1✔
492
                logger.debug(f'  user {user.key} handle {handle}')
1✔
493
                return (proto, user.key.id())
1✔
494

495
        # step 3: resolve handle to id
496
        for proto in candidates:
1✔
497
            id = proto.handle_to_id(handle)
1✔
498
            if id:
1✔
499
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
500
                return (proto, id)
1✔
501

502
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
503
        return (None, None)
1✔
504

505
    @classmethod
1✔
506
    def is_user_at_domain(cls, handle, allow_internal=False):
1✔
507
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
508

509
        Example: ``@user@instance.com``
510

511
        Args:
512
          handle (str)
513
          allow_internal (bool): whether the domain can be a Bridgy Fed domain
514
        """
515
        parts = handle.split('@')
1✔
516
        if len(parts) != 2:
1✔
517
            return False
1✔
518

519
        user, domain = parts
1✔
520
        return bool(user and domain
1✔
521
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
522

523
    @classmethod
1✔
524
    def bridged_web_url_for(cls, user, fallback=False):
1✔
525
        """Returns the web URL for a user's bridged profile in this protocol.
526

527
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
528
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
529

530
        Args:
531
          user (models.User)
532
          fallback (bool): if True, and bridged users have no canonical user
533
            profile URL in this protocol, return the native protocol's profile URL
534

535
        Returns:
536
          str, or None if there isn't a canonical URL
537
        """
538
        if fallback:
1✔
539
            return user.web_url()
1✔
540

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

545
        Args:
546
          obj (models.Object)
547
          allow_opt_out (bool): whether to return a user key if they're opted out
548

549
        Returns:
550
          google.cloud.ndb.key.Key or None:
551
        """
552
        owner = as1.get_owner(obj.as1)
1✔
553
        if owner:
1✔
554
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
555

556
    @classmethod
1✔
557
    def bot_user_id(cls):
1✔
558
        """Returns the Web user id for the bot user for this protocol.
559

560
        For example, ``'bsky.brid.gy'`` for ATProto.
561

562
        Returns:
563
          str:
564
        """
565
        return f'{cls.ABBREV}{SUPERDOMAIN}'
1✔
566

567
    @classmethod
1✔
568
    def create_for(cls, user):
1✔
569
        """Creates or re-activate a copy user in this protocol.
570

571
        Should add the copy user to :attr:`copies`.
572

573
        If the copy user already exists and active, should do nothing.
574

575
        Args:
576
          user (models.User): original source user. Shouldn't already have a
577
            copy user for this protocol in :attr:`copies`.
578

579
        Raises:
580
          ValueError: if we can't create a copy of the given user in this protocol
581
        """
582
        raise NotImplementedError()
×
583

584
    @classmethod
1✔
585
    def send(to_cls, obj, target, from_user=None, orig_obj_id=None):
1✔
586
        """Sends an outgoing activity.
587

588
        To be implemented by subclasses. Should call
589
        ``to_cls.translate_ids(obj.as1)`` before converting it to this Protocol's
590
        format.
591

592
        NOTE: if this protocol's ``HAS_COPIES`` is True, and this method creates a
593
        copy and sends it, it *must* add that copy to the *object*'s (not activity's)
594
        :attr:`copies`, and store it back in the datastore, *in a transaction*!
595

596
        Args:
597
          obj (models.Object): with activity to send
598
          target (str): destination URL to send to
599
          from_user (models.User): user (actor) this activity is from
600
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
601
            that this object refers to, eg replies to or reposts or likes
602

603
        Returns:
604
          bool: True if the activity is sent successfully, False if it is
605
          ignored or otherwise unsent due to protocol logic, eg no webmention
606
          endpoint, protocol doesn't support the activity type. (Failures are
607
          raised as exceptions.)
608

609
        Raises:
610
          werkzeug.HTTPException if the request fails
611
        """
612
        raise NotImplementedError()
×
613

614
    @classmethod
1✔
615
    def fetch(cls, obj, **kwargs):
1✔
616
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
617

618
        Errors are raised as exceptions. If this method returns False, the fetch
619
        didn't fail but didn't succeed either, eg the id isn't valid for this
620
        protocol, or the fetch didn't return valid data for this protocol.
621

622
        To be implemented by subclasses.
623

624
        Args:
625
          obj (models.Object): with the id to fetch. Data is filled into one of
626
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
627
          kwargs: subclass-specific
628

629
        Returns:
630
          bool: True if the object was fetched and populated successfully,
631
          False otherwise
632

633
        Raises:
634
          requests.RequestException, werkzeug.HTTPException,
635
          websockets.WebSocketException, etc: if the fetch fails
636
        """
637
        raise NotImplementedError()
×
638

639
    @classmethod
1✔
640
    def convert(cls, obj, from_user=None, **kwargs):
1✔
641
        """Converts an :class:`Object` to this protocol's data format.
642

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

646
        Just passes through to :meth:`_convert`, then does minor
647
        protocol-independent postprocessing.
648

649
        Args:
650
          obj (models.Object):
651
          from_user (models.User): user (actor) this activity/object is from
652
          kwargs: protocol-specific, passed through to :meth:`_convert`
653

654
        Returns:
655
          converted object in the protocol's native format, often a dict
656
        """
657
        if not obj or not obj.as1:
1✔
658
            return {}
1✔
659

660
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
661
        is_crud = obj.as1.get('verb') in as1.CRUD_VERBS
1✔
662
        base_obj = as1.get_object(obj.as1) if is_crud else obj.as1
1✔
663
        orig_our_as1 = obj.our_as1
1✔
664

665
        # mark bridged actors as bots and add "bridged by Bridgy Fed" to their bios
666
        if (from_user and base_obj
1✔
667
            and base_obj.get('objectType') in as1.ACTOR_TYPES
668
            and PROTOCOLS.get(obj.source_protocol) != cls
669
            and Protocol.for_bridgy_subdomain(id) not in DOMAINS
670
            # Web users are special cased, they don't get the label if they've
671
            # explicitly enabled Bridgy Fed with redirects or webmentions
672
            and not (from_user.LABEL == 'web'
673
                     and (from_user.last_webmention_in or from_user.has_redirects))):
674
            cls.add_source_links(obj=obj, from_user=from_user)
1✔
675

676
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
677
        obj.our_as1 = orig_our_as1
1✔
678
        return converted
1✔
679

680
    @classmethod
1✔
681
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
682
        """Converts an :class:`Object` to this protocol's data format.
683

684
        To be implemented by subclasses. Implementations should generally call
685
        :meth:`Protocol.translate_ids` (as their own class) before converting to
686
        their format.
687

688
        Args:
689
          obj (models.Object):
690
          from_user (models.User): user (actor) this activity/object is from
691
          kwargs: protocol-specific
692

693
        Returns:
694
          converted object in the protocol's native format, often a dict. May
695
            return the ``{}`` empty dict if the object can't be converted.
696
        """
697
        raise NotImplementedError()
×
698

699
    @classmethod
1✔
700
    def add_source_links(cls, obj, from_user):
1✔
701
        """Adds "bridged from ... by Bridgy Fed" to the user's actor's ``summary``.
702

703
        Uses HTML for protocols that support it, plain text otherwise.
704

705
        Args:
706
          cls (Protocol subclass): protocol that the user is bridging into
707
          obj (models.Object): user's actor/profile object
708
          from_user (models.User): user (actor) this activity/object is from
709
        """
710
        assert obj and obj.as1
1✔
711
        assert from_user
1✔
712

713
        obj.our_as1 = copy.deepcopy(obj.as1)
1✔
714
        actor = (as1.get_object(obj.as1) if obj.type in as1.CRUD_VERBS
1✔
715
                 else obj.as1)
716
        actor.setdefault('objectType', 'person')
1✔
717

718
        orig_summary = actor.setdefault('summary', '')
1✔
719
        summary_text = html_to_text(orig_summary, ignore_links=True)
1✔
720

721
        # Check if we've already added source links
722
        if '🌉 bridged' in summary_text:
1✔
723
            return
1✔
724

725
        actor_id = actor.get('id')
1✔
726

727
        url = (as1.get_url(actor)
1✔
728
               or (from_user.web_url() if from_user.profile_id() == actor_id
729
                   else actor_id))
730

731
        from web import Web
1✔
732
        bot_user = Web.get_by_id(from_user.bot_user_id())
1✔
733

734
        if cls.HTML_PROFILES:
1✔
735
            if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS:
1✔
736
                mention = bot_user.html_link(proto=cls, name=False, handle='short')
1✔
737
                suffix = f', follow {mention} to interact'
1✔
738
            else:
739
                suffix = f' by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>'
1✔
740

741
            separator = '<br><br>'
1✔
742

743
            is_user = from_user.key and actor_id in (from_user.key.id(),
1✔
744
                                                     from_user.profile_id())
745
            if is_user:
1✔
746
                bridged = f'🌉 <a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a>'
1✔
747
                from_ = f'<a href="{from_user.web_url()}">{from_user.handle}</a>'
1✔
748
            else:
749
                bridged = '🌉 bridged'
1✔
750
                from_ = util.pretty_link(url) if url else '?'
1✔
751

752
        else:  # plain text
753
            # TODO: unify with above. which is right?
754
            id = obj.key.id() if obj.key else obj.our_as1.get('id')
1✔
755
            is_user = from_user.key and id in (from_user.key.id(),
1✔
756
                                               from_user.profile_id())
757
            from_ = (from_user.web_url() if is_user else url) or '?'
1✔
758

759
            bridged = '🌉 bridged'
1✔
760
            suffix = (
1✔
761
                f': https://{PRIMARY_DOMAIN}{from_user.user_page_path()}'
762
                # link web users to their user pages
763
                if from_user.LABEL == 'web'
764
                else f', follow @{bot_user.handle_as(cls)} to interact'
765
                if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS
766
                else f' by https://{PRIMARY_DOMAIN}/')
767
            separator = '\n\n'
1✔
768
            orig_summary = summary_text
1✔
769

770
        logo = f'{from_user.LOGO_EMOJI} ' if from_user.LOGO_EMOJI else ''
1✔
771
        source_links = f'{separator if orig_summary else ""}{bridged} from {logo}{from_}{suffix}'
1✔
772
        actor['summary'] = orig_summary + source_links
1✔
773

774
    @classmethod
1✔
775
    def set_username(to_cls, user, username):
1✔
776
        """Sets a custom username for a user's bridged account in this protocol.
777

778
        Args:
779
          user (models.User)
780
          username (str)
781

782
        Raises:
783
          ValueError: if the username is invalid
784
          RuntimeError: if the username could not be set
785
        """
786
        raise NotImplementedError()
1✔
787

788
    @classmethod
1✔
789
    def migrate_out(cls, user, to_user_id):
1✔
790
        """Migrates a bridged account out to be a native account.
791

792
        Args:
793
          user (models.User)
794
          to_user_id (str)
795

796
        Raises:
797
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
798
            ``user`` is on this protocol or not bridged to this protocol
799
        """
800
        raise NotImplementedError()
×
801

802
    @classmethod
1✔
803
    def check_can_migrate_out(cls, user, to_user_id):
1✔
804
        """Raises an exception if a user can't yet migrate to a native account.
805

806
        For example, if ``to_user_id`` isn't on this protocol, or if ``user`` is on
807
        this protocol, or isn't bridged to this protocol.
808

809
        If the user is ready to migrate, returns ``None``.
810

811
        Subclasses may override this to add more criteria, but they should call this
812
        implementation first.
813

814
        Args:
815
          user (models.User)
816
          to_user_id (str)
817

818
        Raises:
819
          ValueError: if ``user`` isn't ready to migrate to this protocol yet
820
        """
821
        def _error(msg):
1✔
822
            logger.warning(msg)
1✔
823
            raise ValueError(msg)
1✔
824

825
        if cls.owns_id(to_user_id) is False:
1✔
826
            _error(f"{to_user_id} doesn't look like an {cls.LABEL} id")
1✔
827
        elif isinstance(user, cls):
1✔
828
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
829
        elif not user.is_enabled(cls):
1✔
830
            _error(f"{user.handle_or_id()} isn't currently bridged to {cls.PHRASE}")
1✔
831

832
    @classmethod
1✔
833
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
834
        """Migrates a native account in to be a bridged account.
835

836
        The protocol independent parts are done here; protocol-specific parts are
837
        done in :meth:`_migrate_in`, which this wraps.
838

839
        Reloads the user's profile before calling :meth:`_migrate_in`.
840

841
        Args:
842
          user (models.User): native user on another protocol to attach the
843
            newly imported bridged account to
844
          from_user_id (str)
845
          kwargs: additional protocol-specific parameters
846

847
        Raises:
848
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
849
            ``user`` is on this protocol or already bridged to this protocol
850
        """
851
        def _error(msg):
1✔
852
            logger.warning(msg)
1✔
853
            raise ValueError(msg)
1✔
854

855
        logger.info(f"Migrating in {from_user_id} for {user.key.id()}")
1✔
856

857
        # check req'ts
858
        if cls.owns_id(from_user_id) is False:
1✔
859
            _error(f"{from_user_id} doesn't look like an {cls.LABEL} id")
1✔
860
        elif isinstance(user, cls):
1✔
861
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
862
        elif cls.HAS_COPIES and cls.LABEL in user.enabled_protocols:
1✔
863
            _error(f"{user.handle_or_id()} is already bridged to {cls.PHRASE}")
1✔
864

865
        # reload profile
866
        try:
1✔
867
            user.reload_profile()
1✔
868
        except (RequestException, HTTPException) as e:
×
869
            _, msg = util.interpret_http_exception(e)
×
870

871
        # migrate!
872
        cls._migrate_in(user, from_user_id, **kwargs)
1✔
873
        user.add('enabled_protocols', cls.LABEL)
1✔
874
        user.put()
1✔
875

876
        # attach profile object
877
        if user.obj:
1✔
878
            if cls.HAS_COPIES:
1✔
879
                profile_id = ids.profile_id(id=from_user_id, proto=cls)
1✔
880
                user.obj.remove_copies_on(cls)
1✔
881
                user.obj.add('copies', Target(uri=profile_id, protocol=cls.LABEL))
1✔
882
                user.obj.put()
1✔
883

884
            common.create_task(queue='receive', obj_id=user.obj_key.id(),
1✔
885
                               authed_as=user.key.id())
886

887
    @classmethod
1✔
888
    def _migrate_in(cls, user, from_user_id, **kwargs):
1✔
889
        """Protocol-specific parts of migrating in external account.
890

891
        Called by :meth:`migrate_in`, which does most of the work, including calling
892
        :meth:`reload_profile` before this.
893

894
        Args:
895
          user (models.User): native user on another protocol to attach the
896
            newly imported account to. Unused.
897
          from_user_id (str): DID of the account to be migrated in
898
          kwargs: protocol dependent
899
        """
900
        raise NotImplementedError()
×
901

902
    @classmethod
1✔
903
    def target_for(cls, obj, shared=False):
1✔
904
        """Returns an :class:`Object`'s delivery target (endpoint).
905

906
        To be implemented by subclasses.
907

908
        Examples:
909

910
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
911
          webmention target.
912
        * If obj is an ``activitypub`` actor, returns its inbox.
913
        * If obj is an ``activitypub`` object, returns it's author's or actor's
914
          inbox.
915

916
        Args:
917
          obj (models.Object):
918
          shared (bool): optional. If True, returns a common/shared
919
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
920
            multiple recipients for efficiency
921

922
        Returns:
923
          str: target endpoint, or None if not available.
924
        """
925
        raise NotImplementedError()
×
926

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

931
        Default implementation here, subclasses may override.
932

933
        Args:
934
          url (str):
935
          allow_internal (bool): whether to return False for internal domains
936
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
937
        """
938
        blocklist = DOMAIN_BLOCKLIST
1✔
939
        if not DEBUG:
1✔
940
            blocklist += tuple(util.RESERVED_TLDS | util.LOCAL_TLDS)
1✔
941
        if not allow_internal:
1✔
942
            blocklist += DOMAINS
1✔
943
        return util.domain_or_parent_in(url, blocklist)
1✔
944

945
    @classmethod
1✔
946
    def translate_ids(to_cls, obj):
1✔
947
        """Translates all ids in an AS1 object to a specific protocol.
948

949
        Infers source protocol for each id value separately.
950

951
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
952
        ``at://did:plc:abc/coll/123`` will be converted to
953
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
954

955
        Wraps these AS1 fields:
956

957
        * ``id``
958
        * ``actor``
959
        * ``author``
960
        * ``bcc``
961
        * ``bto``
962
        * ``cc``
963
        * ``featured[].items``, ``featured[].orderedItems``
964
        * ``object``
965
        * ``object.actor``
966
        * ``object.author``
967
        * ``object.id``
968
        * ``object.inReplyTo``
969
        * ``object.object``
970
        * ``attachments[].id``
971
        * ``tags[objectType=mention].url``
972
        * ``to``
973

974
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
975
        same logic is duplicated there!
976

977
        TODO: unify with :meth:`Object.resolve_ids`,
978
        :meth:`models.Object.normalize_ids`.
979

980
        Args:
981
          to_proto (Protocol subclass)
982
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
983

984
        Returns:
985
          dict: translated AS1 version of ``obj``
986
        """
987
        from ui import UIProtocol
1✔
988

989
        assert to_cls != Protocol
1✔
990
        if not obj:
1✔
991
            return obj
1✔
992

993
        outer_obj = to_cls.translate_mention_handles(copy.deepcopy(obj))
1✔
994
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
995

996
        def translate(elem, field, fn, uri=False):
1✔
997
            owner_id = as1.get_owner(elem)
1✔
998
            owner_proto = Protocol.for_id(owner_id)
1✔
999

1000
            elem[field] = as1.get_objects(elem, field)
1✔
1001
            for obj in elem[field]:
1✔
1002
                if id := obj.get('id'):
1✔
1003
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
1004
                        continue
1✔
1005

1006
                    from_cls = Protocol.for_id(id)
1✔
1007
                    if field == 'id' and from_cls == UIProtocol and owner_proto:
1✔
1008
                        logger.info(f'owner of {id} {owner_id} is {owner_proto.LABEL}, translating id from that protocol')
1✔
1009
                        from_cls = owner_proto
1✔
1010

1011
                    # TODO: what if from_cls is None? relax translate_object_id,
1012
                    # make it a noop if we don't know enough about from/to?
1013
                    if from_cls and from_cls != to_cls:
1✔
1014
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
1015
                    if uri:
1✔
1016
                        obj['id'] = to_cls(id=obj['id']).id_uri() if obj['id'] else id
1✔
1017

1018
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
1019
                           for o in elem[field]]
1020

1021
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
1022
                elem[field] = elem[field][0]
1✔
1023

1024
        type = as1.object_type(outer_obj)
1✔
1025
        translate(outer_obj, 'id',
1✔
1026
                  ids.translate_user_id if type in as1.ACTOR_TYPES
1027
                  else ids.translate_object_id)
1028

1029
        for o in inner_objs:
1✔
1030
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
1031
                        or as1.get_owner(outer_obj) == o.get('id')
1032
                        or type in ('follow', 'stop-following', 'block'))
1033
            translate(o, 'id', (ids.translate_user_id if is_actor
1✔
1034
                                else ids.translate_object_id))
1035
            # TODO: need to handle both user and object ids here
1036
            # https://github.com/snarfed/bridgy-fed/issues/2281
1037
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
1038
            translate(o, 'object', (ids.translate_user_id if obj_is_actor
1✔
1039
                                    else ids.translate_object_id))
1040

1041
        for o in [outer_obj] + inner_objs:
1✔
1042
            translate(o, 'inReplyTo', ids.translate_object_id)
1✔
1043
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
1044
                translate(o, field, ids.translate_user_id)
1✔
1045
            for tag in as1.get_objects(o, 'tags'):
1✔
1046
                if tag.get('objectType') == 'mention':
1✔
1047
                    translate(tag, 'url', ids.translate_user_id, uri=True)
1✔
1048
            for att in as1.get_objects(o, 'attachments'):
1✔
1049
                translate(att, 'id', ids.translate_object_id)
1✔
1050
                url = att.get('url')
1✔
1051
                if url and not att.get('id'):
1✔
1052
                    if from_cls := Protocol.for_id(url):
1✔
1053
                        att['id'] = ids.translate_object_id(from_=from_cls, to=to_cls,
1✔
1054
                                                            id=url)
1055
            if feat := as1.get_object(o, 'featured'):
1✔
1056
                translate(feat, 'orderedItems', ids.translate_object_id)
1✔
1057
                translate(feat, 'items', ids.translate_object_id)
1✔
1058

1059
        outer_obj = util.trim_nulls(outer_obj)
1✔
1060

1061
        if objs := util.get_list(outer_obj ,'object'):
1✔
1062
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
1063
            if len(outer_obj['object']) == 1:
1✔
1064
                outer_obj['object'] = outer_obj['object'][0]
1✔
1065

1066
        return outer_obj
1✔
1067

1068
    @classmethod
1✔
1069
    def translate_mention_handles(cls, obj):
1✔
1070
        """Translates @-mentions in ``obj.content`` to this protocol's handles.
1071

1072
        Specifically, for each ``mention`` tag in the object's tags that has
1073
        ``startIndex`` and ``length``, replaces it in ``obj.content`` with that
1074
        user's translated handle in this protocol and updates the tag's location.
1075

1076
        Called by :meth:`Protocol.translate_ids`.
1077

1078
        If ``obj.content`` is HTML, does nothing.
1079

1080
        Args:
1081
          obj (dict): AS1 object
1082

1083
        Returns:
1084
          dict: modified AS1 object
1085
        """
1086
        if not obj:
1✔
1087
            return None
×
1088

1089
        obj = copy.deepcopy(obj)
1✔
1090
        obj['object'] = [cls.translate_mention_handles(o)
1✔
1091
                                for o in as1.get_objects(obj)]
1092
        if len(obj['object']) == 1:
1✔
1093
            obj['object'] = obj['object'][0]
1✔
1094

1095
        content = obj.get('content')
1✔
1096
        tags = obj.get('tags')
1✔
1097
        if (not content or not tags
1✔
1098
                or obj.get('content_is_html')
1099
                or bool(BeautifulSoup(content, 'html.parser').find())
1100
                or HTML_ENTITY_RE.search(content)):
1101
            return util.trim_nulls(obj)
1✔
1102

1103
        indexed = [tag for tag in tags if tag.get('startIndex') and tag.get('length')]
1✔
1104

1105
        offset = 0
1✔
1106
        last_orig_end = 0
1✔
1107
        for tag in sorted(indexed, key=lambda t: t['startIndex']):
1✔
1108
            orig_start = tag['startIndex']
1✔
1109
            if orig_start < last_orig_end:
1✔
1110
                logger.warning(f'tags overlap! removing indices from {tag.get("url")}')
1✔
1111
                del tag['startIndex']
1✔
1112
                del tag['length']
1✔
1113
                continue
1✔
1114

1115
            orig_end = orig_start + tag['length']
1✔
1116
            last_orig_end = orig_end
1✔
1117
            tag['startIndex'] += offset
1✔
1118
            if tag.get('objectType') == 'mention' and (id := tag['url']):
1✔
1119
                if proto := Protocol.for_id(id):
1✔
1120
                    id = ids.normalize_user_id(id=id, proto=proto)
1✔
1121
                    if key := get_original_user_key(id):
1✔
1122
                        user = key.get()
×
1123
                    else:
1124
                        user = proto.get_or_create(id, allow_opt_out=True)
1✔
1125
                    if user:
1✔
1126
                        start = tag['startIndex']
1✔
1127
                        end = start + tag['length']
1✔
1128
                        if handle := user.handle_as(cls):
1✔
1129
                            content = content[:start] + handle + content[end:]
1✔
1130
                            offset += len(handle) - tag['length']
1✔
1131
                            tag.update({
1✔
1132
                                'displayName': handle,
1133
                                'length': len(handle),
1134
                            })
1135

1136
        obj['tags'] = tags
1✔
1137
        as2.set_content(obj, content)  # sets content *and* contentMap; obj is still AS1 here
1✔
1138
        return util.trim_nulls(obj)
1✔
1139

1140
    @classmethod
1✔
1141
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
1142
        """Handles an incoming activity.
1143

1144
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
1145
        unset, returns HTTP 299.
1146

1147
        Args:
1148
          obj (models.Object)
1149
          authed_as (str): authenticated actor id who sent this activity
1150
          internal (bool): whether to allow activity ids on internal domains,
1151
            from opted out/blocked users, etc.
1152
          received_at (datetime): when we first saw (received) this activity.
1153
            Right now only used for monitoring.
1154

1155
        Returns:
1156
          (str, int) tuple: (response body, HTTP status code) Flask response
1157

1158
        Raises:
1159
          werkzeug.HTTPException: if the request is invalid
1160
        """
1161
        # check some invariants
1162
        assert from_cls != Protocol
1✔
1163
        assert isinstance(obj, Object), obj
1✔
1164

1165
        if not obj.as1:
1✔
1166
            error('No object data provided')
1✔
1167

1168
        orig_obj = obj
1✔
1169
        id = None
1✔
1170
        if obj.key and obj.key.id():
1✔
1171
            id = obj.key.id()
1✔
1172

1173
        if not id:
1✔
1174
            id = obj.as1.get('id')
1✔
1175
            obj.key = ndb.Key(Object, id)
1✔
1176

1177
        if not id:
1✔
1178
            error('No id provided')
×
1179
        elif from_cls.owns_id(id) is False:
1✔
1180
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
1181
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
1182
            error(f'Activity {id} is blocklisted')
1✔
1183

1184
        # does this protocol support this activity/object type?
1185
        from_cls.check_supported(obj, 'receive')
1✔
1186

1187
        # lease this object, atomically
1188
        memcache_key = activity_id_memcache_key(id)
1✔
1189
        leased = memcache.memcache.add(
1✔
1190
            memcache_key, 'leased', noreply=False,
1191
            expire=int(MEMCACHE_LEASE_EXPIRATION.total_seconds()))
1192

1193
        # short circuit if we've already seen this activity id
1194
        if ('force' not in request.values
1✔
1195
            and (not leased
1196
                 or (obj.new is False and obj.changed is False))):
1197
            error(f'Already seen this activity {id}', status=204)
1✔
1198

1199
        pruned = {k: v for k, v in obj.as1.items()
1✔
1200
                  if k not in ('contentMap', 'replies', 'signature')}
1201
        delay = ''
1✔
1202
        retry = request.headers.get('X-AppEngine-TaskRetryCount')
1✔
1203
        if (received_at and retry in (None, '0')
1✔
1204
                and obj.type not in ('delete', 'undo')):  # we delay deletes/undos
1205
            delay_s = int((util.now().replace(tzinfo=None)
1✔
1206
                           - received_at.replace(tzinfo=None)
1207
                           ).total_seconds())
1208
            delay = f'({delay_s} s behind)'
1✔
1209
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
1210

1211
        # check authorization
1212
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1213
        actor = as1.get_owner(obj.as1)
1✔
1214
        if not actor:
1✔
1215
            error('Activity missing actor or author')
1✔
1216

1217
        if not (from_user_cls := obj.owner_protocol()):
1✔
1218
            error(f"couldn't determine owner protocol for {obj.key.id()} source_protocol {obj.source_protocol}", status=204)
×
1219
        elif from_user_cls.owns_id(actor) is False:
1✔
1220
            error(f"{from_user_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
1221

1222
        assert authed_as
1✔
1223
        assert isinstance(authed_as, str)
1✔
1224
        authed_as = ids.normalize_user_id(id=authed_as, proto=from_user_cls)
1✔
1225
        actor = ids.normalize_user_id(id=actor, proto=from_user_cls)
1✔
1226
        # TODO: remove internal here once we've fixed #2237
1227
        if actor != authed_as and not internal:
1✔
1228
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
1229
                         user=f'{id} authed_as {authed_as} owner {actor}')
1230
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
1231

1232
        # update copy ids to originals
1233
        obj.normalize_ids()
1✔
1234
        obj.resolve_ids()
1✔
1235

1236
        if (obj.type == 'follow'
1✔
1237
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
1238
            # follows of bot user; refresh user profile first
1239
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
1240
            from_user = from_user_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1241
            from_user.reload_profile()
1✔
1242
        else:
1243
            # load actor user
1244
            from_user = from_user_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1245

1246
        if not internal and (not from_user or from_user.manual_opt_out):
1✔
1247
            error(f"Couldn't load actor {actor}", status=204)
×
1248

1249
        # apply protocol-specific filters
1250
        for filter in from_cls.RECEIVE_FILTERS:
1✔
1251
            if filter(obj, from_user):
1✔
1252
                error(f'Activity {id} blocked by filter {filter.__name__}')
1✔
1253

1254
        # check if this is a profile object coming in via a user with use_instead
1255
        # set. if so, override the object's id to be the final user id (from_user's),
1256
        # after following use_instead.
1257
        if obj.type in as1.ACTOR_TYPES and from_user.key.id() != actor:
1✔
1258
            as1_id = obj.as1.get('id')
1✔
1259
            if ids.normalize_user_id(id=as1_id, proto=from_user) == actor:
1✔
1260
                logger.info(f'Overriding AS1 object id {as1_id} with Object id {from_user.profile_id()}')
1✔
1261
                obj.our_as1 = {**obj.as1, 'id': from_user.profile_id()}
1✔
1262

1263
        # if this is an object, ie not an activity, wrap it in a create or update
1264
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as,
1✔
1265
                                          from_user=from_user)
1266
        obj.add('users', from_user.key)
1✔
1267

1268
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1269
        inner_obj_id = inner_obj_as1.get('id')
1✔
1270
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1271
            if not inner_obj_id:
1✔
1272
                error(f'{obj.type} object has no id!')
1✔
1273

1274
        # check age. we support backdated posts, but if they're over 2w old, we
1275
        # don't deliver them
1276
        if obj.type == 'post':
1✔
1277
            if published := inner_obj_as1.get('published'):
1✔
1278
                try:
1✔
1279
                    published_dt = util.parse_iso8601(published)
1✔
1280
                    if not published_dt.tzinfo:
1✔
1281
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
1282
                    age = util.now() - published_dt
1✔
1283
                    if (age > CREATE_MAX_AGE
1✔
1284
                            and 'force' not in request.values
1285
                            and not util.domain_or_parent_in(
1286
                                from_user.key.id(), CREATE_MAX_AGE_EXEMPT_DOMAINS)):
1287
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
1288
                              status=204)
1289
                except ValueError:  # from parse_iso8601
×
1290
                    logger.debug(f"Couldn't parse published {published}")
×
1291

1292
        # write Object to datastore
1293
        if obj.type in STORE_AS1_TYPES:
1✔
1294
            obj.put()
1✔
1295

1296
        # store inner object
1297
        # TODO: unify with big obj.type conditional below. would have to merge
1298
        # this with the DM handling block lower down.
1299
        crud_obj = None
1✔
1300
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1301
            # normalize_ids may have converted the inner object id to a user id
1302
            # (eg Web profile URL to domain), so normalize back to the profile
1303
            # object id to find the right existing Object in the datastore
1304
            crud_obj_id = (ids.normalize_object_id(id=inner_obj_id, proto=from_cls)
1✔
1305
                           or inner_obj_id)
1306
            crud_obj = Object.get_or_create(crud_obj_id, our_as1=inner_obj_as1,
1✔
1307
                                            source_protocol=obj.source_protocol,
1308
                                            authed_as=actor, users=[from_user.key],
1309
                                            deleted=False)
1310

1311
        actor = as1.get_object(obj.as1, 'actor')
1✔
1312
        actor_id = actor.get('id')
1✔
1313

1314
        # handle activity!
1315
        if obj.type == 'stop-following':
1✔
1316
            # TODO: unify with handle_follow?
1317
            # TODO: handle multiple followees
1318
            if not actor_id or not inner_obj_id:
1✔
1319
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1320

1321
            # deactivate Follower
1322
            from_ = from_user_cls.key_for(actor_id)
1✔
1323
            if not (to_cls := Protocol.for_id(inner_obj_id)):
1✔
1324
                error(f"Can't determine protocol for {inner_obj_id} , giving up")
1✔
1325
            to = to_cls.key_for(inner_obj_id)
1✔
1326
            follower = Follower.query(Follower.to == to,
1✔
1327
                                      Follower.from_ == from_,
1328
                                      Follower.status == 'active').get()
1329
            if follower:
1✔
1330
                logger.info(f'Marking {follower} inactive')
1✔
1331
                follower.status = 'inactive'
1✔
1332
                follower.put()
1✔
1333
            else:
1334
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1335

1336
            # fall through to deliver to followee
1337
            # TODO: do we convert stop-following to webmention 410 of original
1338
            # follow?
1339

1340
            # fall through to deliver to followers
1341

1342
        elif obj.type in ('delete', 'undo'):
1✔
1343
            delete_obj_id = (from_user.profile_id()
1✔
1344
                            if inner_obj_id == from_user.key.id()
1345
                            else inner_obj_id)
1346

1347
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1348
            if not delete_obj:
1✔
1349
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1350
                return 'OK', 204
1✔
1351

1352
            # TODO: just delete altogether!
1353
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1354
            delete_obj.deleted = True
1✔
1355
            delete_obj.put()
1✔
1356

1357
            # if this is an actor, handle deleting it later so that
1358
            # in case it's from_user, user.enabled_protocols is still populated
1359
            #
1360
            # fall through to deliver to followers and delete copy if necessary.
1361
            # should happen via protocol-specific copy target and send of
1362
            # delete activity.
1363
            # https://github.com/snarfed/bridgy-fed/issues/63
1364

1365
        elif obj.type == 'block':
1✔
1366
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1367
                # blocking protocol bot user disables that protocol
1368
                from_user.delete(proto)
1✔
1369
                from_user.disable_protocol(proto)
1✔
1370
                return 'OK', 200
1✔
1371

1372
        elif obj.type == 'post':
1✔
1373
            # handle DMs to bot users
1374
            if as1.is_dm(obj.as1):
1✔
1375
                return dms.receive(from_user=from_user, obj=obj)
1✔
1376

1377
        # fetch actor if necessary
1378
        is_user = (inner_obj_id in (from_user.key.id(), from_user.profile_id())
1✔
1379
                   or from_user.is_profile(orig_obj))
1380
        if (actor and actor.keys() == set(['id'])
1✔
1381
                and not is_user and obj.type not in ('delete', 'undo')):
1382
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1383
            actor_obj = from_user_cls.load(
1✔
1384
                ids.profile_id(id=actor['id'], proto=from_cls), raise_=False)
1385
            if actor_obj and actor_obj.as1:
1✔
1386
                obj.our_as1 = {
1✔
1387
                    **obj.as1, 'actor': {
1388
                        **actor_obj.as1,
1389
                        # override profile id with actor id
1390
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1391
                        'id': actor['id'],
1392
                    }
1393
                }
1394

1395
        # fetch object if necessary
1396
        if (obj.type in ('post', 'update', 'share')
1✔
1397
                and inner_obj_as1.keys() == set(['id'])
1398
                and from_cls.owns_id(inner_obj_id) is not False):
1399
            logger.debug('Fetching inner object')
1✔
1400
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1401
                                      remote=(obj.type in ('post', 'update')))
1402
            if obj.type in ('post', 'update'):
1✔
1403
                crud_obj = inner_obj
1✔
1404
            if inner_obj and inner_obj.as1:
1✔
1405
                obj.our_as1 = {
1✔
1406
                    **obj.as1,
1407
                    'object': {
1408
                        **inner_obj_as1,
1409
                        **inner_obj.as1,
1410
                    }
1411
                }
1412
            elif obj.type in ('post', 'update'):
1✔
1413
                error(f"Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1414

1415
        if obj.type == 'follow':
1✔
1416
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1417
                # follow of one of our protocol bot users; enable that protocol.
1418
                # fall through so that we send an accept.
1419
                try:
1✔
1420
                    from_user.enable_protocol(proto)
1✔
1421
                except ErrorButDoNotRetryTask:
1✔
1422
                    from web import Web
1✔
1423
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1424
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1425
                                               followee=bot, follow=obj)
1426
                    raise
1✔
1427
                proto.bot_maybe_follow_back(from_user)
1✔
1428
                from_cls.handle_follow(obj, from_user=from_user)
1✔
1429
                return 'OK', 202
1✔
1430

1431
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1432

1433
        # on update of the user's own actor/profile, set user.obj and store user back
1434
        # to datastore so that we recalculate computed properties like status etc
1435
        if is_user:
1✔
1436
            if obj.type == 'update' and crud_obj:
1✔
1437
                logger.info(f"update of the user's profile, re-storing user with obj_key {crud_obj.key.id()}")
1✔
1438
                from_user.obj = crud_obj
1✔
1439
                from_user.put()
1✔
1440

1441
        # deliver to targets
1442
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1443

1444
        # on user deleting themselves, deactivate their followers/followings.
1445
        # https://github.com/snarfed/bridgy-fed/issues/1304
1446
        #
1447
        # do this *after* delivering because delivery finds targets based on
1448
        # stored Followers
1449
        if is_user and obj.type == 'delete':
1✔
1450
            for proto in from_user.enabled_protocols:
1✔
1451
                from_user.disable_protocol(PROTOCOLS[proto])
1✔
1452

1453
            logger.info(f'Deactivating Followers from or to {from_user.key.id()}')
1✔
1454
            followers = Follower.query(
1✔
1455
                OR(Follower.to == from_user.key, Follower.from_ == from_user.key)
1456
            ).fetch()
1457
            for f in followers:
1✔
1458
                f.status = 'inactive'
1✔
1459
            ndb.put_multi(followers)
1✔
1460

1461
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1462
        return resp
1✔
1463

1464
    @classmethod
1✔
1465
    def handle_follow(from_cls, obj, from_user):
1✔
1466
        """Handles an incoming follow activity.
1467

1468
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1469
        happens in :meth:`deliver`.
1470

1471
        Args:
1472
          obj (models.Object): follow activity
1473
        """
1474
        logger.debug('Got follow. storing Follow(s), sending accept(s)')
1✔
1475
        from_id = from_user.key.id()
1✔
1476

1477
        # Prepare followee (to) users' data
1478
        to_as1s = as1.get_objects(obj.as1)
1✔
1479
        if not to_as1s:
1✔
1480
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1481

1482
        # Store Followers
1483
        for to_as1 in to_as1s:
1✔
1484
            to_id = to_as1.get('id')
1✔
1485
            if not to_id:
1✔
1486
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1487

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

1490
            to_cls = Protocol.for_id(to_id)
1✔
1491
            if not to_cls:
1✔
1492
                error(f"Couldn't determine protocol for {to_id}")
×
1493
            elif from_cls == to_cls:
1✔
1494
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1495
                continue
1✔
1496

1497
            to_key = to_cls.key_for(to_id)
1✔
1498
            if not to_key:
1✔
1499
                logger.info(f'Skipping invalid {to_cls.LABEL} user key: {to_id}')
×
1500
                continue
×
1501

1502
            to_user = to_cls.get_or_create(id=to_key.id())
1✔
1503
            if not to_user or not to_user.is_enabled(from_cls):
1✔
1504
                error(f'{to_id} not found')
1✔
1505

1506
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1507
                                                  follow=obj.key, status='active')
1508
            obj.add('notify', to_key)
1✔
1509
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1510
                                       followee=to_user, follow=obj)
1511

1512
    @classmethod
1✔
1513
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1514
        """Sends an accept or reject activity for a follow.
1515

1516
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1517
        nothing.
1518

1519
        Args:
1520
          verb (str): ``accept`` or  ``reject``
1521
          follower (models.User)
1522
          followee (models.User)
1523
          follow (models.Object)
1524
        """
1525
        assert verb in ('accept', 'reject')
1✔
1526
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1527
            return
1✔
1528

1529
        if not follower.obj or not (target := follower.target_for(follower.obj)):
1✔
1530
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
1✔
1531

1532
        # send. note that this is one response for the whole follow, even if it
1533
        # has multiple followees!
1534
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1535
        accept = {
1✔
1536
            'id': id,
1537
            'objectType': 'activity',
1538
            'verb': verb,
1539
            'actor': followee.key.id(),
1540
            'object': follow.as1,
1541
        }
1542
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1543
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1544

1545
    @classmethod
1✔
1546
    def bot_maybe_follow_back(bot_cls, user):
1✔
1547
        """Follow a user from a protocol bot user, if their protocol needs that.
1548

1549
        ...so that the protocol starts sending us their activities, if it needs
1550
        a follow for that (eg ActivityPub).
1551

1552
        Args:
1553
          user (User)
1554
        """
1555
        if not user.BOTS_FOLLOW_BACK:
1✔
1556
            return
1✔
1557

1558
        from web import Web
1✔
1559
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1560
        now = util.now().isoformat()
1✔
1561
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1562

1563
        if not user.obj:
1✔
1564
            logger.info("  can't follow, user has no profile obj")
1✔
1565
            return
1✔
1566

1567
        target = user.target_for(user.obj)
1✔
1568
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1569
        follow_back_as1 = {
1✔
1570
            'objectType': 'activity',
1571
            'verb': 'follow',
1572
            'id': follow_back_id,
1573
            'actor': bot.key.id(),
1574
            'object': user.key.id(),
1575
        }
1576
        common.create_task(queue='send', id=follow_back_id,
1✔
1577
                           our_as1=follow_back_as1, url=target,
1578
                           source_protocol='web', protocol=user.LABEL,
1579
                           user=bot.key.urlsafe())
1580

1581
    @classmethod
1✔
1582
    def handle_bare_object(cls, obj, *, authed_as, from_user):
1✔
1583
        """If obj is a bare object, wraps it in a create or update activity.
1584

1585
        Checks if we've seen it before.
1586

1587
        Args:
1588
          obj (models.Object)
1589
          authed_as (str): authenticated actor id who sent this activity
1590
          from_user (models.User): user (actor) this activity/object is from
1591

1592
        Returns:
1593
          models.Object: ``obj`` if it's an activity, otherwise a new object
1594
        """
1595
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1596
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1597
            return obj
1✔
1598

1599
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1600
        now = util.now().isoformat()
1✔
1601

1602
        # this is a raw post; wrap it in a create or update activity
1603
        if obj.changed or is_actor:
1✔
1604
            if obj.changed:
1✔
1605
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1606
            else:
1607
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1608
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1609
            update_as1 = {
1✔
1610
                'objectType': 'activity',
1611
                'verb': 'update',
1612
                'id': id,
1613
                'actor': obj_actor,
1614
                'object': {
1615
                    # Mastodon requires the updated field for Updates, so
1616
                    # add a default value.
1617
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1618
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1619
                    # https://github.com/mastodon/documentation/pull/1150
1620
                    'updated': now,
1621
                    **obj.as1,
1622
                },
1623
            }
1624
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1625
            return Object(id=id, our_as1=update_as1,
1✔
1626
                          source_protocol=obj.source_protocol)
1627

1628
        if obj.new or 'force' in request.values:
1✔
1629
            create_id = f'{obj.key.id()}#bridgy-fed-create-{now}'
1✔
1630
            create_as1 = {
1✔
1631
                'objectType': 'activity',
1632
                'verb': 'post',
1633
                'id': create_id,
1634
                'actor': obj_actor,
1635
                'object': obj.as1,
1636
                'published': now,
1637
            }
1638
            logger.info(f'Wrapping in post')
1✔
1639
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1640
            return Object(id=create_id, our_as1=create_as1,
1✔
1641
                          source_protocol=obj.source_protocol)
1642

1643
        error(f'{obj.key.id()} is unchanged, nothing to do', status=204)
×
1644

1645
    @classmethod
1✔
1646
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1647
        """Delivers an activity to its external recipients.
1648

1649
        Args:
1650
          obj (models.Object): activity to deliver
1651
          from_user (models.User): user (actor) this activity is from
1652
          crud_obj (models.Object): if this is a create, update, or delete/undo
1653
            activity, the inner object that's being written, otherwise None.
1654
            (This object's ``notify`` and ``feed`` properties may be updated.)
1655
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1656
            targets on this protocol
1657

1658
        Returns:
1659
          (str, int) tuple: Flask response
1660
        """
1661
        if to_proto:
1✔
1662
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1663

1664
        # find delivery targets. maps Target to Object or None
1665
        #
1666
        # ...then write the relevant object, since targets() has a side effect of
1667
        # setting the notify and feed properties (and dirty attribute)
1668
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1669
        if to_proto:
1✔
1670
            targets = {t: obj for t, obj in targets.items()
1✔
1671
                       if t.protocol == to_proto.LABEL}
1672
        if not targets:
1✔
1673
            # don't raise via error() because we call deliver in code paths where
1674
            # we want to continue after
1675
            msg = r'No targets, nothing to do ¯\_(ツ)_/¯'
1✔
1676
            logger.info(msg)
1✔
1677
            return msg, 204
1✔
1678

1679
        # store object that targets() updated
1680
        if crud_obj and crud_obj.dirty:
1✔
1681
            crud_obj.put()
1✔
1682
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1683
            obj.put()
1✔
1684

1685
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1686
                      else obj.to_request())
1687

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

1691
        # enqueue send task for each targets
1692
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1693
        user = from_user.key.urlsafe()
1✔
1694
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1695
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1696
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1697
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1698

1699
        return 'OK', 202
1✔
1700

1701
    @classmethod
1✔
1702
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1703
        """Collects the targets to send a :class:`models.Object` to.
1704

1705
        Targets are both objects - original posts, events, etc - and actors.
1706

1707
        Args:
1708
          obj (models.Object)
1709
          from_user (User)
1710
          crud_obj (models.Object): if this is a create, update, or delete/undo
1711
            activity, the inner object that's being written, otherwise None.
1712
            (This object's ``notify`` and ``feed`` properties may be updated.)
1713
          internal (bool): whether this is a recursive internal call
1714

1715
        Returns:
1716
          dict: maps :class:`models.Target` to original (in response to)
1717
          :class:`models.Object`
1718
        """
1719
        logger.debug('Finding recipients and their targets')
1✔
1720

1721
        # we should only have crud_obj iff this is a create or update
1722
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1723
        write_obj = crud_obj or obj
1✔
1724
        write_obj.dirty = False
1✔
1725

1726
        target_uris = as1.targets(obj.as1)
1✔
1727
        orig_obj = None
1✔
1728
        targets = {}  # maps Target (with *normalized* uri) to Object or None
1✔
1729
        owner = as1.get_owner(obj.as1)
1✔
1730
        allow_opt_out = (obj.type == 'delete')
1✔
1731
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1732
        inner_obj_id = inner_obj_as1.get('id')
1✔
1733
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1734
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1735
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1736
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1737
        is_self_reply = False
1✔
1738

1739
        original_ids = []
1✔
1740
        if is_reply:
1✔
1741
            original_ids = in_reply_tos
1✔
1742
        elif inner_obj_id:
1✔
1743
            if inner_obj_id == from_user.key.id():
1✔
1744
                inner_obj_id = from_user.profile_id()
1✔
1745
            original_ids = [inner_obj_id]
1✔
1746

1747
        # maps id to Object
1748
        original_objs = {}
1✔
1749
        for id in original_ids:
1✔
1750
            if proto := Protocol.for_id(id):
1✔
1751
                original_objs[id] = proto.load(id, raise_=False)
1✔
1752

1753
        # for AP, add in-reply-tos' mentions
1754
        # https://github.com/snarfed/bridgy-fed/issues/1608
1755
        # https://github.com/snarfed/bridgy-fed/issues/1218
1756
        orig_post_mentions = {}  # maps mentioned id to original post Object
1✔
1757
        for id in in_reply_tos:
1✔
1758
            if ((in_reply_to_obj := original_objs.get(id))
1✔
1759
                    and (proto := PROTOCOLS.get(in_reply_to_obj.source_protocol))
1760
                    and proto.SEND_REPLIES_TO_ORIG_POSTS_MENTIONS
1761
                    and (mentions := as1.mentions(in_reply_to_obj.as1))):
1762
                logger.info(f"Adding in-reply-to {id} 's mentions to targets: {mentions}")
1✔
1763
                target_uris.extend(mentions)
1✔
1764
                for mention in mentions:
1✔
1765
                    orig_post_mentions[mention] = in_reply_to_obj
1✔
1766

1767
        target_uris = sorted(set(target_uris))
1✔
1768
        logger.info(f'Raw targets: {target_uris}')
1✔
1769

1770
        # which protocols should we allow delivering to?
1771
        to_protocols = []  # elements are Protocol subclasses
1✔
1772
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1773
                      + from_user.enabled_protocols):
1774
            if not (proto := PROTOCOLS.get(label)):
1✔
1775
                report_error(f'unknown enabled protocol {label} for {from_user.key.id()}')
1✔
1776
                continue
1✔
1777

1778
            if (obj.type == 'post' and (orig := original_objs.get(inner_obj_id))
1✔
1779
                    and orig.get_copy(proto)):
1780
                logger.info(f'Already created {id} on {label}, cowardly refusing to create there again')
1✔
1781
                continue
1✔
1782

1783
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1784
                                     or is_reply):
1785
                origs_could_bridge = None
1✔
1786

1787
                for id in original_ids:
1✔
1788
                    if not (orig := original_objs.get(id)):
1✔
1789
                        continue
1✔
1790
                    elif orig.get_copy(proto):
1✔
1791
                        logger.info(f'Allowing {label}, original {id} was bridged there')
1✔
1792
                        break
1✔
1793
                    elif from_user.is_profile(orig):
1✔
1794
                        logger.info(f"Allowing {label}, this is the user's profile")
1✔
1795
                        break
1✔
1796

1797
                    if (origs_could_bridge is not False
1✔
1798
                            and (orig_author_id := as1.get_owner(orig.as1))
1799
                            and (orig_proto := orig.owner_protocol())
1800
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1801
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1802

1803
                else:
1804
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1805
                    last_retry = False
1✔
1806
                    if retries := request.headers.get(TASK_RETRIES_HEADER):
1✔
1807
                        if (last_retry := int(retries) >= TASK_RETRIES_RECEIVE):
1✔
1808
                            logger.info(f'last retry! skipping {proto.LABEL} and continuing')
1✔
1809

1810
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
1✔
1811
                            and origs_could_bridge and not last_retry):
1812
                        # retry later; original obj may still be bridging
1813
                        # TODO: limit to brief window, eg no older than 2h? 1d?
1814
                        error(msg, status=304)
1✔
1815

1816
                    logger.info(msg)
1✔
1817
                    continue
1✔
1818

1819
            util.add(to_protocols, proto)
1✔
1820

1821
        logger.info(f'allowed protocols {[p.LABEL for p in to_protocols]}')
1✔
1822

1823
        # process direct targets
1824
        for target_id in target_uris:
1✔
1825
            target_proto = Protocol.for_id(target_id)
1✔
1826
            if not target_proto:
1✔
1827
                logger.info(f"Can't determine protocol for {target_id}")
1✔
1828
                continue
1✔
1829
            elif target_proto.is_blocklisted(target_id):
1✔
1830
                logger.debug(f'{target_id} is blocklisted')
1✔
1831
                continue
1✔
1832

1833
            target_obj_id = target_id
1✔
1834
            if target_id in mentioned_urls or obj.type in as1.VERBS_WITH_ACTOR_OBJECT:
1✔
1835
                # not ideal. this can sometimes be a non-user, eg blocking a
1836
                # blocklist. ok right now since profile_id() returns its input id
1837
                # unchanged if it doesn't look like a user id, but that's brittle.
1838
                target_obj_id = ids.profile_id(id=target_id, proto=target_proto)
1✔
1839

1840
            orig_obj = target_proto.load(target_obj_id, raise_=False)
1✔
1841
            if not orig_obj or not orig_obj.as1:
1✔
1842
                logger.info(f"Couldn't load {target_obj_id}")
1✔
1843
                continue
1✔
1844

1845
            target_author_key = (target_proto(id=target_id).key
1✔
1846
                                 if target_id in mentioned_urls
1847
                                 else target_proto.actor_key(orig_obj))
1848
            if not from_user.is_enabled(target_proto):
1✔
1849
                # if author isn't bridged and target user is, DM a prompt and
1850
                # add a notif for the target user
1851
                if (target_id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
1852
                        and target_author_key):
1853
                    if target_author := target_author_key.get():
1✔
1854
                        if target_author.is_enabled(from_cls):
1✔
1855
                            notifications.add_notification(target_author, write_obj)
1✔
1856
                            verb, noun = (
1✔
1857
                                ('replied to', 'replies') if target_id in in_reply_tos
1858
                                else ('quoted', 'quotes') if target_id in quoted_posts
1859
                                else ('mentioned', 'mentions'))
1860
                            dms.maybe_send(from_=target_proto, to_user=from_user,
1✔
1861
                                           type='replied_to_bridged_user', text=f"""\
1862
Hi! You <a href="{inner_obj_as1.get('url') or inner_obj_id}">recently {verb}</a> {target_author.html_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.""")
1863

1864
                continue
1✔
1865

1866
            # deliver self-replies to followers
1867
            # https://github.com/snarfed/bridgy-fed/issues/639
1868
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1869
                is_self_reply = True
1✔
1870
                logger.info(f'self reply!')
1✔
1871

1872
            # also add copies' targets
1873
            for copy in orig_obj.copies:
1✔
1874
                proto = PROTOCOLS[copy.protocol]
1✔
1875
                if proto in to_protocols:
1✔
1876
                    # copies generally won't have their own Objects
1877
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1878
                        target = util.normalize_url(target, trailing_slash=False)
1✔
1879
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {target_id}')
1✔
1880
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1881

1882
            if target_proto == from_cls:
1✔
1883
                logger.debug(f'Skipping same-protocol target {target_id}')
1✔
1884
                continue
1✔
1885

1886
            target = target_proto.target_for(orig_obj)
1✔
1887
            if not target:
1✔
1888
                # TODO: surface errors like this somehow?
1889
                logger.error(f"Can't find delivery target for {target_id}")
×
1890
                continue
×
1891

1892
            target = util.normalize_url(target, trailing_slash=False)
1✔
1893
            logger.debug(f'Target for {target_id} is {target}')
1✔
1894
            # only use orig_obj for inReplyTos, like/repost objects, reply's original
1895
            # post's mentions, etc
1896
            # https://github.com/snarfed/bridgy-fed/issues/1237
1897
            target_obj = None
1✔
1898
            if target_id in in_reply_tos + as1.get_ids(obj.as1, 'object'):
1✔
1899
                target_obj = orig_obj
1✔
1900
            elif target_id in orig_post_mentions:
1✔
1901
                target_obj = orig_post_mentions[target_id]
1✔
1902
            targets[Target(protocol=target_proto.LABEL, uri=target)] = target_obj
1✔
1903

1904
            if target_author_key:
1✔
1905
                logger.debug(f'Recipient is {target_author_key}')
1✔
1906
                if write_obj.add('notify', target_author_key):
1✔
1907
                    write_obj.dirty = True
1✔
1908

1909
        if obj.type == 'undo':
1✔
1910
            logger.info('Object is an undo; adding targets for inner object')
1✔
1911
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1912
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1913
            else:
1914
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1915
            if inner_obj:
1✔
1916
                for target, target_obj in from_cls.targets(
1✔
1917
                        inner_obj, from_user=from_user, internal=True).items():
1918
                    targets[target] = target_obj
1✔
1919
                    util.add(to_protocols, PROTOCOLS[target.protocol])
1✔
1920

1921
        if not to_protocols:
1✔
1922
            return {}
1✔
1923

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

1926
        # deliver to followers, if appropriate
1927
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1928
        if not user_key:
1✔
1929
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1930
            return targets
1✔
1931

1932
        followers = []
1✔
1933
        is_undo_block = obj.type == 'undo' and inner_obj_as1.get('verb') == 'block'
1✔
1934
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1935
                and (not is_reply or is_self_reply) and not is_undo_block):
1936
            logger.info(f'Delivering to followers of {user_key} on {[p.LABEL for p in to_protocols]}')
1✔
1937
            followers = []
1✔
1938
            for f in Follower.query(Follower.to == user_key,
1✔
1939
                                    Follower.status == 'active'):
1940
                proto = PROTOCOLS_BY_KIND[f.from_.kind()]
1✔
1941
                # skip protocol bot users
1942
                if (not Protocol.for_bridgy_subdomain(f.from_.id())
1✔
1943
                        # skip protocols this user hasn't enabled, or where the base
1944
                        # object of this activity hasn't been bridged
1945
                        and proto in to_protocols
1946
                        # we deliver to HAS_COPIES protocols separately, below. we
1947
                        # assume they have follower-independent targets.
1948
                        and not (proto.HAS_COPIES and proto.DEFAULT_TARGET)):
1949
                    followers.append(f)
1✔
1950

1951
            logger.info(f'  loaded {len(followers)} followers')
1✔
1952

1953
            user_keys = [f.from_ for f in followers]
1✔
1954
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1955
            logger.info(f'  loaded {len(users)} users')
1✔
1956

1957
            User.load_multi(users)
1✔
1958
            logger.info(f'  loaded user objects')
1✔
1959

1960
            if (not followers and
1✔
1961
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
1962
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
1963
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1964
                return {}
1✔
1965

1966
            # add to followers' feeds, if any
1967
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1968
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1969
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
1✔
1970
                    if write_obj.feed:
1✔
1971
                        write_obj.dirty = True
1✔
1972

1973
            # collect targets for followers
1974
            target_obj = (original_objs.get(inner_obj_id)
1✔
1975
                          if obj.type == 'share' else None)
1976
            for user in users:
1✔
1977
                if user.is_blocking(from_user):
1✔
1978
                    logger.debug(f'  {user.key.id()} blocks {from_user.key.id()}')
1✔
1979
                    continue
1✔
1980

1981
                # TODO: should we pass remote=False through here to Protocol.load?
1982
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1983
                if not target:
1✔
1984
                    continue
1✔
1985

1986
                target = util.normalize_url(target, trailing_slash=False)
1✔
1987
                targets[Target(protocol=user.LABEL, uri=target)] = target_obj
1✔
1988

1989
            logger.info(f'  collected {len(targets)} targets')
1✔
1990

1991
        # deliver to enabled HAS_COPIES protocols proactively
1992
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
1993
            for proto in to_protocols:
1✔
1994
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
1995
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
1996
                    targets.setdefault(
1✔
1997
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1998

1999
        # maps string target URL to (Target, Object) tuple
2000
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
2001
        # maps Target to Object or None
2002
        targets = {}
1✔
2003
        source_domains = [
1✔
2004
            util.domain_from_link(url) for url in
2005
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
2006
            if util.is_web(url)
2007
        ]
2008
        for url in sorted(util.dedupe_urls(
1✔
2009
                candidates.keys(),
2010
                # preserve our PDS URL without trailing slash in path
2011
                # https://atproto.com/specs/did#did-documents
2012
                trailing_slash=False)):
2013
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
2014
                logger.info(f'Skipping same-domain target {url}')
×
2015
                continue
×
2016
            elif from_user.is_blocking(url):
1✔
2017
                logger.debug(f'{from_user.key.id()} blocks {url}')
1✔
2018
                continue
1✔
2019

2020
            target, obj = candidates[url]
1✔
2021
            targets[target] = obj
1✔
2022

2023
        return targets
1✔
2024

2025
    @classmethod
1✔
2026
    def load(cls, id, remote=None, local=True, raise_=True, raw=False, csv=False,
1✔
2027
             **kwargs):
2028
        """Loads and returns an Object from datastore or HTTP fetch.
2029

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

2033
        Args:
2034
          id (str)
2035
          remote (bool): whether to fetch the object over the network. If True,
2036
            fetches even if we already have the object stored, and updates our
2037
            stored copy. If False and we don't have the object stored, returns
2038
            None. Default (None) means to fetch over the network only if we
2039
            don't already have it stored.
2040
          local (bool): whether to load from the datastore before
2041
            fetching over the network. If False, still stores back to the
2042
            datastore after a successful remote fetch.
2043
          raise_ (bool): if False, catches any :class:`request.RequestException`
2044
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
2045
            ``None`` instead
2046
          raw (bool): whether to load this as a "raw" id, as is, without
2047
            normalizing to an on-protocol object id. Exact meaning varies by subclass.
2048
          csv (bool): whether to specifically load a CSV object
2049
            TODO: merge this into raw, using returned Content-Type?
2050
          kwargs: passed through to :meth:`fetch()`
2051

2052
        Returns:
2053
          models.Object: loaded object, or None if it isn't fetchable, eg a
2054
          non-URL string for Web, or ``remote`` is False and it isn't in the
2055
          datastore
2056

2057
        Raises:
2058
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
2059
            is True
2060
        """
2061
        assert id
1✔
2062
        assert local or remote is not False
1✔
2063
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
2064

2065
        if not raw:
1✔
2066
            id = ids.normalize_object_id(id=id, proto=cls)
1✔
2067

2068
        obj = orig_as1 = None
1✔
2069
        if local:
1✔
2070
            if obj := Object.get_by_id(id):
1✔
2071
                if csv and not obj.is_csv:
1✔
2072
                    return None
1✔
2073
                elif obj.as1 or obj.csv or obj.raw or obj.deleted:
1✔
2074
                    # logger.debug(f'  {id} got from datastore')
2075
                    obj.new = False
1✔
2076

2077
        if remote is False:
1✔
2078
            return obj
1✔
2079
        elif remote is None and obj:
1✔
2080
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
2081
                # logger.debug(f'  last updated {obj.updated}, refreshing')
2082
                pass
1✔
2083
            else:
2084
                return obj
1✔
2085

2086
        if obj:
1✔
2087
            orig_as1 = obj.as1
1✔
2088
            obj.our_as1 = None
1✔
2089
            obj.new = False
1✔
2090
        else:
2091
            if cls == Protocol:
1✔
2092
                return None
1✔
2093
            obj = Object(id=id)
1✔
2094
            if local:
1✔
2095
                # logger.debug(f'  {id} not in datastore')
2096
                obj.new = True
1✔
2097
                obj.changed = False
1✔
2098

2099
        try:
1✔
2100
            fetched = cls.fetch(obj, csv=csv, **kwargs)
1✔
2101
        except (RequestException, HTTPException, InvalidStatus) as e:
1✔
2102
            if raise_:
1✔
2103
                raise
1✔
2104
            util.interpret_http_exception(e)
1✔
2105
            return None
1✔
2106

2107
        if not fetched:
1✔
2108
            return None
1✔
2109
        elif csv and not obj.is_csv:
1✔
2110
            return None
×
2111

2112
        # https://stackoverflow.com/a/3042250/186123
2113
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
2114
        if size > MAX_ENTITY_SIZE:
1✔
2115
            logger.warning(f'Object is too big! {size} bytes is over {MAX_ENTITY_SIZE}')
1✔
2116
            return None
1✔
2117

2118
        obj.resolve_ids()
1✔
2119
        obj.normalize_ids()
1✔
2120

2121
        if obj.new is False:
1✔
2122
            obj.changed = obj.activity_changed(orig_as1)
1✔
2123

2124
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
2125
            if obj.source_protocol:
1✔
2126
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
2127
            obj.source_protocol = cls.LABEL
1✔
2128

2129
        obj.put()
1✔
2130
        return obj
1✔
2131

2132
    @classmethod
1✔
2133
    def check_supported(cls, obj, direction):
1✔
2134
        """If this protocol doesn't support this activity, raises HTTP 204.
2135

2136
        Also reports an error.
2137

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

2142
        Args:
2143
          obj (Object)
2144
          direction (str): ``'receive'`` or  ``'send'``
2145

2146
        Raises:
2147
          werkzeug.HTTPException: if this protocol doesn't support this object
2148
        """
2149
        assert direction in ('receive', 'send')
1✔
2150
        if not obj.type:
1✔
2151
            return
×
2152

2153
        inner = as1.get_object(obj.as1)
1✔
2154
        inner_type = as1.object_type(inner) or ''
1✔
2155
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
2156
            or (obj.type in as1.CRUD_VERBS
2157
                and inner_type
2158
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
2159
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
2160

2161
        # don't allow posts with blank content and no image/video/audio
2162
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
2163
                    else obj.as1)
2164
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
2165
                and not util.get_url(crud_obj, key='image')
2166
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
2167
                # TODO: handle articles with displayName but not content
2168
                and not source.html_to_text(crud_obj.get('content')).strip()):
2169
            error('Blank content and no image or video or audio', status=204)
1✔
2170

2171
        # receiving DMs is only allowed to protocol bot accounts
2172
        if direction == 'receive':
1✔
2173
            if recip := as1.recipient_if_dm(obj.as1):
1✔
2174
                owner = as1.get_owner(obj.as1)
1✔
2175
                if (not cls.SUPPORTS_DMS or (recip not in common.bot_user_ids()
1✔
2176
                                             and owner not in common.bot_user_ids())):
2177
                    # reply and say DMs aren't supported
2178
                    from_proto = obj.owner_protocol()
1✔
2179
                    to_proto = Protocol.for_id(recip)
1✔
2180
                    if owner and from_proto and to_proto:
1✔
2181
                        if ((from_user := from_proto.get_or_create(id=owner))
1✔
2182
                                and (to_user := to_proto.get_or_create(id=recip))):
2183
                            in_reply_to = (inner.get('id') if obj.type == 'post'
1✔
2184
                                           else obj.as1.get('id'))
2185
                            text = f"Hi! Sorry, this account is bridged from {to_user.PHRASE}, so it doesn't support DMs. Try getting in touch another way!"
1✔
2186
                            type = f'dms_not_supported-{to_user.key.id()}'
1✔
2187
                            dms.maybe_send(from_=to_user, to_user=from_user,
1✔
2188
                                           text=text, type=type,
2189
                                           in_reply_to=in_reply_to)
2190

2191
                    error("Bridgy Fed doesn't support DMs", status=204)
1✔
2192

2193
            # check that this activity is public. only do this for some activities,
2194
            # not eg likes or follows, since Mastodon doesn't currently mark those
2195
            # as explicitly public.
2196
            elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
2197
                  and not util.domain_or_parent_in(crud_obj.get('id'), NON_PUBLIC_DOMAINS)
2198
                  and not as1.is_public(obj.as1, unlisted=False)):
2199
                error('Bridgy Fed only supports public activities', status=204)
1✔
2200

2201
    @classmethod
1✔
2202
    def block(cls, from_user, arg):
1✔
2203
        """Blocks a user or list.
2204

2205
        Args:
2206
          from_user (models.User): user doing the blocking
2207
          arg (str): handle or id of user/list to block
2208

2209
        Returns:
2210
          models.User or models.Object: user or list that was blocked
2211

2212
        Raises:
2213
          ValueError: if arg doesn't look like a user or list on this protocol
2214
        """
2215
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2216

2217
        def fail(msg):
1✔
2218
            logger.warning(msg)
1✔
2219
            raise ValueError(msg)
1✔
2220

2221
        blockee = None
1✔
2222
        try:
1✔
2223
            # first, try interpreting as a user handle or id
2224
            blockee = load_user(arg, proto=cls, create=True, allow_opt_out=True)
1✔
2225
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2226
            logger.info(err)
1✔
2227

2228
        if type(from_user) == type(blockee):
1✔
2229
            fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking them there.')
1✔
2230

2231
        # may not be a user, see if it's a list
2232
        if not blockee:
1✔
2233
            if not cls or cls == Protocol:
1✔
2234
                cls = Protocol.for_id(arg)
1✔
2235

2236
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2237
                if blockee.source_protocol == from_user.LABEL:
1✔
2238
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2239
            else:
2240
                if blocklist := from_user.add_domain_blocklist(arg):
1✔
2241
                    return blocklist
1✔
2242
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2243

2244
        logger.info(f'  blocking {blockee.key.id()}')
1✔
2245
        id = f'{from_user.key.id()}#bridgy-fed-block-{util.now().isoformat()}'
1✔
2246
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2247
            'objectType': 'activity',
2248
            'verb': 'block',
2249
            'id': id,
2250
            'actor': from_user.key.id(),
2251
            'object': blockee.key.id(),
2252
        })
2253
        obj.put()
1✔
2254
        from_user.deliver(obj, from_user=from_user)
1✔
2255

2256
        return blockee
1✔
2257

2258
    @classmethod
1✔
2259
    def unblock(cls, from_user, arg):
1✔
2260
        """Unblocks a user or list.
2261

2262
        Args:
2263
          from_user (models.User): user doing the unblocking
2264
          arg (str): handle or id of user/list to unblock
2265

2266
        Returns:
2267
          models.User or models.Object: user or list that was unblocked
2268

2269
        Raises:
2270
          ValueError: if arg doesn't look like a user or list on this protocol
2271
        """
2272
        logger.info(f'user {from_user.key.id()} trying to unblock {arg}')
1✔
2273
        def fail(msg):
1✔
2274
            logger.warning(msg)
1✔
2275
            raise ValueError(msg)
1✔
2276

2277
        blockee = None
1✔
2278
        try:
1✔
2279
            # first, try interpreting as a user handle or id
2280
            blockee = load_user(arg, cls, create=True, allow_opt_out=True)
1✔
2281
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2282
            logger.info(err)
1✔
2283

2284
        if type(from_user) == type(blockee):
1✔
2285
            fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try unblocking them there.')
1✔
2286

2287
        # may not be a user, see if it's a list
2288
        if not blockee:
1✔
2289
            if not cls or cls == Protocol:
1✔
2290
                cls = Protocol.for_id(arg)
1✔
2291

2292
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2293
                if blockee.source_protocol == from_user.LABEL:
1✔
2294
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2295
            else:
2296
                if blocklist := from_user.remove_domain_blocklist(arg):
1✔
2297
                    return blocklist
1✔
2298
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2299

2300
        logger.info(f'  unblocking {blockee.key.id()}')
1✔
2301
        id = f'{from_user.key.id()}#bridgy-fed-unblock-{util.now().isoformat()}'
1✔
2302
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2303
            'objectType': 'activity',
2304
            'verb': 'undo',
2305
            'id': id,
2306
            'actor': from_user.key.id(),
2307
            'object': {
2308
                'objectType': 'activity',
2309
                'verb': 'block',
2310
                'actor': from_user.key.id(),
2311
                'object': blockee.key.id(),
2312
            },
2313
        })
2314
        obj.put()
1✔
2315
        from_user.deliver(obj, from_user=from_user)
1✔
2316

2317
        return blockee
1✔
2318

2319

2320
@cloud_tasks_only(log=None)
1✔
2321
def receive_task():
1✔
2322
    """Task handler for a newly received :class:`models.Object`.
2323

2324
    Calls :meth:`Protocol.receive` with the form parameters.
2325

2326
    Parameters:
2327
      authed_as (str): passed to :meth:`Protocol.receive`
2328
      obj_id (str): key id of :class:`models.Object` to handle
2329
      received_at (str, ISO 8601 timestamp): when we first saw (received)
2330
        this activity
2331
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2332
        :class:`models.Object` to handle
2333

2334
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
2335
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
2336
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
2337
    ``changed``, HTTP request details, etc. See stash for attempt at this for
2338
    :class:`web.Web`.
2339
    """
2340
    common.log_request()
1✔
2341
    form = request.form.to_dict()
1✔
2342

2343
    authed_as = form.pop('authed_as', None)
1✔
2344
    internal = authed_as == PRIMARY_DOMAIN or authed_as in PROTOCOL_DOMAINS
1✔
2345

2346
    obj = Object.from_request()
1✔
2347
    assert obj
1✔
2348
    assert obj.source_protocol
1✔
2349
    obj.new = True
1✔
2350

2351
    if received_at := form.pop('received_at', None):
1✔
2352
        received_at = datetime.fromisoformat(received_at)
1✔
2353

2354
    try:
1✔
2355
        return PROTOCOLS[obj.source_protocol].receive(
1✔
2356
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
2357
    except RequestException as e:
1✔
2358
        util.interpret_http_exception(e)
1✔
2359
        error(e, status=304)
1✔
2360
    except (RuntimeError, ValueError) as e:
1✔
2361
        logger.warning(e, exc_info=True)
×
2362
        error(e, status=304)
×
2363

2364

2365
@cloud_tasks_only(log=None)
1✔
2366
def send_task():
1✔
2367
    """Task handler for sending an activity to a single specific destination.
2368

2369
    Calls :meth:`Protocol.send` with the form parameters.
2370

2371
    Parameters:
2372
      protocol (str): :class:`Protocol` to send to
2373
      url (str): destination URL to send to
2374
      obj_id (str): key id of :class:`models.Object` to send
2375
      orig_obj_id (str): optional, :class:`models.Object` key id of the
2376
        "original object" that this object refers to, eg replies to or reposts
2377
        or likes
2378
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
2379
        this activity is from
2380
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2381
        :class:`models.Object` to handle
2382
    """
2383
    common.log_request()
1✔
2384

2385
    # prepare
2386
    form = request.form.to_dict()
1✔
2387
    url = form.get('url')
1✔
2388
    protocol = form.get('protocol')
1✔
2389
    if not url or not protocol:
1✔
2390
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
2391
        return '', 204
1✔
2392

2393
    target = Target(uri=url, protocol=protocol)
1✔
2394
    obj = Object.from_request()
1✔
2395
    assert obj and obj.key and obj.key.id()
1✔
2396

2397
    PROTOCOLS[protocol].check_supported(obj, 'send')
1✔
2398
    allow_opt_out = (obj.type == 'delete')
1✔
2399

2400
    user = None
1✔
2401
    if user_key := form.get('user'):
1✔
2402
        key = ndb.Key(urlsafe=user_key)
1✔
2403
        # use get_by_id so that we follow use_instead
2404
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2405
            key.id(), allow_opt_out=allow_opt_out)
2406

2407
    # send
2408
    delay = ''
1✔
2409
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
2410
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
2411
        delay = f'({delay_s} s behind)'
1✔
2412
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
2413
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
2414
    sent = None
1✔
2415
    try:
1✔
2416
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
2417
                                        orig_obj_id=form.get('orig_obj_id'))
2418
    except (MemcacheServerError, MemcacheUnexpectedCloseError,
1✔
2419
            MemcacheUnknownError) as e:
2420
        # our memorystore instance is probably undergoing maintenance. re-enqueue
2421
        # task with a delay.
2422
        # https://docs.cloud.google.com/memorystore/docs/memcached/about-maintenance
2423
        report_error(f'memcache error on send task, re-enqueuing in {MEMCACHE_DOWN_TASK_DELAY}: {e}')
1✔
2424
        common.create_task(queue='send', delay=MEMCACHE_DOWN_TASK_DELAY, **form)
1✔
2425
        sent = False
1✔
2426
    except BaseException as e:
1✔
2427
        code, body = util.interpret_http_exception(e)
1✔
2428
        if not code and not body:
1✔
2429
            raise
1✔
2430

2431
    if sent is False:
1✔
2432
        logger.info(f'Failed sending!')
1✔
2433

2434
    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