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

snarfed / bridgy-fed / 4c1ad163-32f0-404b-944a-68bd86aa19ad

18 Jan 2026 05:17PM UTC coverage: 93.26% (+0.2%) from 93.028%
4c1ad163-32f0-404b-944a-68bd86aa19ad

push

circleci

snarfed
circle: use dummy encrypted_property_key file for tests

6655 of 7136 relevant lines covered (93.26%)

0.93 hits per line

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

95.88
/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
import werkzeug.exceptions
1✔
30
from werkzeug.exceptions import BadGateway, BadRequest, HTTPException
1✔
31

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

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

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

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

83
DONT_STORE_AS1_TYPES = as1.CRUD_VERBS | set((
1✔
84
    'accept',
85
    'reject',
86
    'stop-following',
87
    'undo',
88
))
89
STORE_AS1_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES | as1.VERBS_WITH_OBJECT
1✔
90
                   - DONT_STORE_AS1_TYPES)
91

92
logger = logging.getLogger(__name__)
1✔
93

94

95
def error(*args, status=299, **kwargs):
1✔
96
    """Default HTTP status code to 299 to prevent retrying task."""
97
    return common.error(*args, status=status, **kwargs)
1✔
98

99

100
def activity_id_memcache_key(id):
1✔
101
    return memcache.key(f'receive-{id}')
1✔
102

103

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

147
    @classmethod
1✔
148
    @property
1✔
149
    def LABEL(cls):
1✔
150
        """str: human-readable lower case name of this protocol, eg ``'activitypub``"""
151
        return cls.__name__.lower()
1✔
152

153
    @staticmethod
1✔
154
    def for_request(fed=None):
1✔
155
        """Returns the protocol for the current request.
156

157
        ...based on the request's hostname.
158

159
        Args:
160
          fed (str or protocol.Protocol): protocol to return if the current
161
            request is on ``fed.brid.gy``
162

163
        Returns:
164
          Protocol: protocol, or None if the provided domain or request hostname
165
          domain is not a subdomain of ``brid.gy`` or isn't a known protocol
166
        """
167
        return Protocol.for_bridgy_subdomain(request.host, fed=fed)
1✔
168

169
    @staticmethod
1✔
170
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
171
        """Returns the protocol for a brid.gy subdomain.
172

173
        Args:
174
          domain_or_url (str)
175
          fed (str or protocol.Protocol): protocol to return if the current
176
            request is on ``fed.brid.gy``
177

178
        Returns:
179
          class: :class:`Protocol` subclass, or None if the provided domain or request
180
          hostname domain is not a subdomain of ``brid.gy`` or isn't a known
181
          protocol
182
        """
183
        domain = (util.domain_from_link(domain_or_url, minimize=False)
1✔
184
                  if util.is_web(domain_or_url)
185
                  else domain_or_url)
186

187
        if domain == PRIMARY_DOMAIN or domain in LOCAL_DOMAINS:
1✔
188
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
1✔
189
        elif domain and domain.endswith(SUPERDOMAIN):
1✔
190
            label = domain.removesuffix(SUPERDOMAIN)
1✔
191
            return PROTOCOLS.get(label)
1✔
192

193
    @classmethod
1✔
194
    def owns_id(cls, id):
1✔
195
        """Returns whether this protocol owns the id, or None if it's unclear.
196

197
        To be implemented by subclasses.
198

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

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

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

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

214
        Args:
215
          id (str): user id or object id
216

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

222
    @classmethod
1✔
223
    def owns_handle(cls, handle, allow_internal=False):
1✔
224
        """Returns whether this protocol owns the handle, or None if it's unclear.
225

226
        To be implemented by subclasses.
227

228
        Handles are string identities that are human-chosen, human-meaningful,
229
        and often but not always unique. Compare to IDs, which uniquely identify
230
        users, and are intended primarily to be machine readable and usable.
231

232
        Some protocols' handles are more or less deterministic based on the id
233
        format, eg ActivityPub (technically WebFinger) handles are
234
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
235
        ActivityPub, AT Protocol, or others.
236

237
        This should be a quick guess without expensive side effects, eg no
238
        external HTTP fetches to fetch the id itself or otherwise perform
239
        discovery.
240

241
        Args:
242
          handle (str)
243
          allow_internal (bool): whether to return False for internal domains
244
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
245

246
        Returns:
247
          bool or None
248
        """
249
        return False
1✔
250

251
    @classmethod
1✔
252
    def handle_to_id(cls, handle):
1✔
253
        """Converts a handle to an id.
254

255
        To be implemented by subclasses.
256

257
        May incur network requests, eg DNS queries or HTTP requests. Avoids
258
        blocked or opted out users.
259

260
        Args:
261
          handle (str)
262

263
        Returns:
264
          str: corresponding id, or None if the handle can't be found
265
        """
266
        raise NotImplementedError()
×
267

268
    @classmethod
1✔
269
    def authed_user_for_request(cls):
1✔
270
        """Returns the authenticated user id for the current request.
271

272

273
        Checks authentication on the current request, eg HTTP Signature for
274
        ActivityPub. To be implemented by subclasses.
275

276
        Returns:
277
          str: authenticated user id, or None if there is no authentication
278

279
        Raises:
280
          RuntimeError: if the request's authentication (eg signature) is
281
          invalid or otherwise can't be verified
282
        """
283
        return None
1✔
284

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

289
        If called via `Protocol.key_for`, infers the appropriate protocol with
290
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
291
        as is.
292

293
        Args:
294
          id (str):
295
          allow_opt_out (bool): whether to allow users who are currently opted out
296

297
        Returns:
298
          google.cloud.ndb.Key: matching key, or None if the given id is not a
299
          valid :class:`User` id for this protocol.
300
        """
301
        if cls == Protocol:
1✔
302
            proto = Protocol.for_id(id)
1✔
303
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
304

305
        # load user so that we follow use_instead
306
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
307
        if existing:
1✔
308
            if existing.status and not allow_opt_out:
1✔
309
                return None
1✔
310
            return existing.key
1✔
311

312
        return cls(id=id).key
1✔
313

314
    @staticmethod
1✔
315
    def _for_id_memcache_key(id, remote=None):
1✔
316
        """If id is a URL, uses its domain, otherwise returns None.
317

318
        Args:
319
          id (str)
320

321
        Returns:
322
          (str domain, bool remote) or None
323
        """
324
        domain = util.domain_from_link(id)
1✔
325
        if domain in PROTOCOL_DOMAINS:
1✔
326
            return id
1✔
327
        elif remote and util.is_web(id):
1✔
328
            return domain
1✔
329

330
    @cached(LRUCache(20000), lock=Lock())
1✔
331
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote=True: remote,
1✔
332
                      version=3)
333
    @staticmethod
1✔
334
    def for_id(id, remote=True):
1✔
335
        """Returns the protocol for a given id.
336

337
        Args:
338
          id (str)
339
          remote (bool): whether to perform expensive side effects like fetching
340
            the id itself over the network, or other discovery.
341

342
        Returns:
343
          Protocol subclass: matching protocol, or None if no single known
344
          protocol definitively owns this id
345
        """
346
        logger.debug(f'Determining protocol for id {id}')
1✔
347
        if not id:
1✔
348
            return None
1✔
349

350
        # remove our synthetic id fragment, if any
351
        #
352
        # will this eventually cause false positives for other services that
353
        # include our full ids inside their own ids, non-URL-encoded? guess
354
        # we'll figure that out if/when it happens.
355
        id = id.partition('#bridgy-fed-')[0]
1✔
356
        if not id:
1✔
357
            return None
1✔
358

359
        if util.is_web(id):
1✔
360
            # step 1: check for our per-protocol subdomains
361
            try:
1✔
362
                parsed = urlparse(id)
1✔
363
            except ValueError as e:
1✔
364
                logger.info(f'urlparse ValueError: {e}')
1✔
365
                return None
1✔
366

367
            is_homepage = parsed.path.strip('/') == ''
1✔
368
            is_internal = parsed.path.startswith(ids.INTERNAL_PATH_PREFIX)
1✔
369
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
370
            if by_subdomain and not (is_homepage or is_internal
1✔
371
                                     or id in ids.BOT_ACTOR_AP_IDS):
372
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
373
                return by_subdomain
1✔
374

375
        # step 2: check if any Protocols say conclusively that they own it
376
        # sort to be deterministic
377
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
378
                           key=lambda p: p.LABEL)
379
        candidates = []
1✔
380
        for protocol in protocols:
1✔
381
            owns = protocol.owns_id(id)
1✔
382
            if owns:
1✔
383
                logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
384
                return protocol
1✔
385
            elif owns is not False:
1✔
386
                candidates.append(protocol)
1✔
387

388
        if len(candidates) == 1:
1✔
389
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
390
            return candidates[0]
1✔
391

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

403
        # step 4: fetch over the network, if necessary
404
        if not remote:
1✔
405
            return None
1✔
406

407
        for protocol in candidates:
1✔
408
            logger.debug(f'Trying {protocol.LABEL}')
1✔
409
            try:
1✔
410
                obj = protocol.load(id, local=False, remote=True)
1✔
411

412
                if protocol.ABBREV == 'web':
1✔
413
                    # for web, if we fetch and get HTML without microformats,
414
                    # load returns False but the object will be stored in the
415
                    # datastore with source_protocol web, and in cache. load it
416
                    # again manually to check for that.
417
                    obj = Object.get_by_id(id)
1✔
418
                    if obj and obj.source_protocol != 'web':
1✔
419
                        obj = None
×
420

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

438
        logger.info(f'No matching protocol found for {id} !')
1✔
439
        return None
1✔
440

441
    @cached(LRUCache(20000), lock=Lock())
1✔
442
    @staticmethod
1✔
443
    def for_handle(handle):
1✔
444
        """Returns the protocol for a given handle.
445

446
        May incur expensive side effects like resolving the handle itself over
447
        the network or other discovery.
448

449
        Args:
450
          handle (str)
451

452
        Returns:
453
          (Protocol subclass, str) tuple: matching protocol and optional id (if
454
          resolved), or ``(None, None)`` if no known protocol owns this handle
455
        """
456
        # TODO: normalize, eg convert domains to lower case
457
        logger.debug(f'Determining protocol for handle {handle}')
1✔
458
        if not handle:
1✔
459
            return (None, None)
1✔
460

461
        # step 1: check if any Protocols say conclusively that they own it.
462
        # sort to be deterministic.
463
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
464
                           key=lambda p: p.LABEL)
465
        candidates = []
1✔
466
        for proto in protocols:
1✔
467
            owns = proto.owns_handle(handle)
1✔
468
            if owns:
1✔
469
                logger.debug(f'  {proto.LABEL} owns handle {handle}')
1✔
470
                return (proto, None)
1✔
471
            elif owns is not False:
1✔
472
                candidates.append(proto)
1✔
473

474
        if len(candidates) == 1:
1✔
475
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
476
            return (candidates[0], None)
×
477

478
        # step 2: look for matching User in the datastore
479
        for proto in candidates:
1✔
480
            user = proto.query(proto.handle == handle).get()
1✔
481
            if user:
1✔
482
                if user.status:
1✔
483
                    return (None, None)
1✔
484
                logger.debug(f'  user {user.key} handle {handle}')
1✔
485
                return (proto, user.key.id())
1✔
486

487
        # step 3: resolve handle to id
488
        for proto in candidates:
1✔
489
            id = proto.handle_to_id(handle)
1✔
490
            if id:
1✔
491
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
492
                return (proto, id)
1✔
493

494
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
495
        return (None, None)
1✔
496

497
    @classmethod
1✔
498
    def is_user_at_domain(cls, handle, allow_internal=False):
1✔
499
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
500

501
        Example: ``@user@instance.com``
502

503
        Args:
504
          handle (str)
505
          allow_internal (bool): whether the domain can be a Bridgy Fed domain
506
        """
507
        parts = handle.split('@')
1✔
508
        if len(parts) != 2:
1✔
509
            return False
1✔
510

511
        user, domain = parts
1✔
512
        return bool(user and domain
1✔
513
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
514

515
    @classmethod
1✔
516
    def bridged_web_url_for(cls, user, fallback=False):
1✔
517
        """Returns the web URL for a user's bridged profile in this protocol.
518

519
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
520
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
521

522
        Args:
523
          user (models.User)
524
          fallback (bool): if True, and bridged users have no canonical user
525
            profile URL in this protocol, return the native protocol's profile URL
526

527
        Returns:
528
          str, or None if there isn't a canonical URL
529
        """
530
        if fallback:
1✔
531
            return user.web_url()
1✔
532

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

537
        Args:
538
          obj (models.Object)
539
          allow_opt_out (bool): whether to return a user key if they're opted out
540

541
        Returns:
542
          google.cloud.ndb.key.Key or None:
543
        """
544
        owner = as1.get_owner(obj.as1)
1✔
545
        if owner:
1✔
546
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
547

548
    @classmethod
1✔
549
    def bot_user_id(cls):
1✔
550
        """Returns the Web user id for the bot user for this protocol.
551

552
        For example, ``'bsky.brid.gy'`` for ATProto.
553

554
        Returns:
555
          str:
556
        """
557
        return f'{cls.ABBREV}{SUPERDOMAIN}'
1✔
558

559
    @classmethod
1✔
560
    def create_for(cls, user):
1✔
561
        """Creates or re-activate a copy user in this protocol.
562

563
        Should add the copy user to :attr:`copies`.
564

565
        If the copy user already exists and active, should do nothing.
566

567
        Args:
568
          user (models.User): original source user. Shouldn't already have a
569
            copy user for this protocol in :attr:`copies`.
570

571
        Raises:
572
          ValueError: if we can't create a copy of the given user in this protocol
573
        """
574
        raise NotImplementedError()
×
575

576
    @classmethod
1✔
577
    def send(to_cls, obj, target, from_user=None, orig_obj_id=None):
1✔
578
        """Sends an outgoing activity.
579

580
        To be implemented by subclasses. Should call
581
        ``to_cls.translate_ids(obj.as1)`` before converting it to this Protocol's
582
        format.
583

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

588
        Args:
589
          obj (models.Object): with activity to send
590
          target (str): destination URL to send to
591
          from_user (models.User): user (actor) this activity is from
592
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
593
            that this object refers to, eg replies to or reposts or likes
594

595
        Returns:
596
          bool: True if the activity is sent successfully, False if it is
597
          ignored or otherwise unsent due to protocol logic, eg no webmention
598
          endpoint, protocol doesn't support the activity type. (Failures are
599
          raised as exceptions.)
600

601
        Raises:
602
          werkzeug.HTTPException if the request fails
603
        """
604
        raise NotImplementedError()
×
605

606
    @classmethod
1✔
607
    def fetch(cls, obj, **kwargs):
1✔
608
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
609

610
        Errors are raised as exceptions. If this method returns False, the fetch
611
        didn't fail but didn't succeed either, eg the id isn't valid for this
612
        protocol, or the fetch didn't return valid data for this protocol.
613

614
        To be implemented by subclasses.
615

616
        Args:
617
          obj (models.Object): with the id to fetch. Data is filled into one of
618
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
619
          kwargs: subclass-specific
620

621
        Returns:
622
          bool: True if the object was fetched and populated successfully,
623
          False otherwise
624

625
        Raises:
626
          requests.RequestException, werkzeug.HTTPException,
627
          websockets.WebSocketException, etc: if the fetch fails
628
        """
629
        raise NotImplementedError()
×
630

631
    @classmethod
1✔
632
    def convert(cls, obj, from_user=None, **kwargs):
1✔
633
        """Converts an :class:`Object` to this protocol's data format.
634

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

638
        Just passes through to :meth:`_convert`, then does minor
639
        protocol-independent postprocessing.
640

641
        Args:
642
          obj (models.Object):
643
          from_user (models.User): user (actor) this activity/object is from
644
          kwargs: protocol-specific, passed through to :meth:`_convert`
645

646
        Returns:
647
          converted object in the protocol's native format, often a dict
648
        """
649
        if not obj or not obj.as1:
1✔
650
            return {}
1✔
651

652
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
653
        is_crud = obj.as1.get('verb') in as1.CRUD_VERBS
1✔
654
        base_obj = as1.get_object(obj.as1) if is_crud else obj.as1
1✔
655
        orig_our_as1 = obj.our_as1
1✔
656

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

668
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
669
        obj.our_as1 = orig_our_as1
1✔
670
        return converted
1✔
671

672
    @classmethod
1✔
673
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
674
        """Converts an :class:`Object` to this protocol's data format.
675

676
        To be implemented by subclasses. Implementations should generally call
677
        :meth:`Protocol.translate_ids` (as their own class) before converting to
678
        their format.
679

680
        Args:
681
          obj (models.Object):
682
          from_user (models.User): user (actor) this activity/object is from
683
          kwargs: protocol-specific
684

685
        Returns:
686
          converted object in the protocol's native format, often a dict. May
687
            return the ``{}`` empty dict if the object can't be converted.
688
        """
689
        raise NotImplementedError()
×
690

691
    @classmethod
1✔
692
    def add_source_links(cls, obj, from_user):
1✔
693
        """Adds "bridged from ... by Bridgy Fed" to the user's actor's ``summary``.
694

695
        Uses HTML for protocols that support it, plain text otherwise.
696

697
        Args:
698
          cls (Protocol subclass): protocol that the user is bridging into
699
          obj (models.Object): user's actor/profile object
700
          from_user (models.User): user (actor) this activity/object is from
701
        """
702
        assert obj and obj.as1
1✔
703
        assert from_user
1✔
704

705
        obj.our_as1 = copy.deepcopy(obj.as1)
1✔
706
        actor = (as1.get_object(obj.as1) if obj.type in as1.CRUD_VERBS
1✔
707
                 else obj.as1)
708
        actor['objectType'] = 'person'
1✔
709

710
        orig_summary = actor.setdefault('summary', '')
1✔
711
        summary_text = html_to_text(orig_summary, ignore_links=True)
1✔
712

713
        # Check if we've already added source links
714
        if '🌉 bridged' in summary_text:
1✔
715
            return
1✔
716

717
        actor_id = actor.get('id')
1✔
718

719
        url = (as1.get_url(actor)
1✔
720
               or (from_user.web_url() if from_user.profile_id() == actor_id
721
                   else actor_id))
722

723
        from web import Web
1✔
724
        bot_user = Web.get_by_id(from_user.bot_user_id())
1✔
725

726
        if cls.HTML_PROFILES:
1✔
727
            if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS:
1✔
728
                mention = bot_user.html_link(proto=cls, name=False, handle='short')
1✔
729
                suffix = f', follow {mention} to interact'
1✔
730
            else:
731
                suffix = f' by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>'
1✔
732

733
            separator = '<br><br>'
1✔
734

735
            is_user = from_user.key and actor_id in (from_user.key.id(),
1✔
736
                                                     from_user.profile_id())
737
            if is_user:
1✔
738
                bridged = f'🌉 <a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a>'
1✔
739
                from_ = f'<a href="{from_user.web_url()}">{from_user.handle}</a>'
1✔
740
            else:
741
                bridged = '🌉 bridged'
1✔
742
                from_ = util.pretty_link(url) if url else '?'
1✔
743

744
        else:  # plain text
745
            # TODO: unify with above. which is right?
746
            id = obj.key.id() if obj.key else obj.our_as1.get('id')
1✔
747
            is_user = from_user.key and id in (from_user.key.id(),
1✔
748
                                               from_user.profile_id())
749
            from_ = (from_user.web_url() if is_user else url) or '?'
1✔
750

751
            bridged = '🌉 bridged'
1✔
752
            suffix = (
1✔
753
                f': https://{PRIMARY_DOMAIN}{from_user.user_page_path()}'
754
                # link web users to their user pages
755
                if from_user.LABEL == 'web'
756
                else f', follow @{bot_user.handle_as(cls)} to interact'
757
                if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS
758
                else f' by https://{PRIMARY_DOMAIN}/')
759
            separator = '\n\n'
1✔
760
            orig_summary = summary_text
1✔
761

762
        logo = f'{from_user.LOGO_EMOJI} ' if from_user.LOGO_EMOJI else ''
1✔
763
        source_links = f'{separator if orig_summary else ""}{bridged} from {logo}{from_}{suffix}'
1✔
764
        actor['summary'] = orig_summary + source_links
1✔
765

766
    @classmethod
1✔
767
    def set_username(to_cls, user, username):
1✔
768
        """Sets a custom username for a user's bridged account in this protocol.
769

770
        Args:
771
          user (models.User)
772
          username (str)
773

774
        Raises:
775
          ValueError: if the username is invalid
776
          RuntimeError: if the username could not be set
777
        """
778
        raise NotImplementedError()
1✔
779

780
    @classmethod
1✔
781
    def migrate_out(cls, user, to_user_id):
1✔
782
        """Migrates a bridged account out to be a native account.
783

784
        Args:
785
          user (models.User)
786
          to_user_id (str)
787

788
        Raises:
789
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
790
            ``user`` is on this protocol or not bridged to this protocol
791
        """
792
        raise NotImplementedError()
×
793

794
    @classmethod
1✔
795
    def check_can_migrate_out(cls, user, to_user_id):
1✔
796
        """Raises an exception if a user can't yet migrate to a native account.
797

798
        For example, if ``to_user_id`` isn't on this protocol, or if ``user`` is on
799
        this protocol, or isn't bridged to this protocol.
800

801
        If the user is ready to migrate, returns ``None``.
802

803
        Subclasses may override this to add more criteria, but they should call this
804
        implementation first.
805

806
        Args:
807
          user (models.User)
808
          to_user_id (str)
809

810
        Raises:
811
          ValueError: if ``user`` isn't ready to migrate to this protocol yet
812
        """
813
        def _error(msg):
1✔
814
            logger.warning(msg)
1✔
815
            raise ValueError(msg)
1✔
816

817
        if cls.owns_id(to_user_id) is False:
1✔
818
            _error(f"{to_user_id} doesn't look like an {cls.LABEL} id")
1✔
819
        elif isinstance(user, cls):
1✔
820
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
821
        elif not user.is_enabled(cls):
1✔
822
            _error(f"{user.handle_or_id()} isn't currently bridged to {cls.PHRASE}")
1✔
823

824
    @classmethod
1✔
825
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
826
        """Migrates a native account in to be a bridged account.
827

828
        The protocol independent parts are done here; protocol-specific parts are
829
        done in :meth:`_migrate_in`, which this wraps.
830

831
        Reloads the user's profile before calling :meth:`_migrate_in`.
832

833
        Args:
834
          user (models.User): native user on another protocol to attach the
835
            newly imported bridged account to
836
          from_user_id (str)
837
          kwargs: additional protocol-specific parameters
838

839
        Raises:
840
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
841
            ``user`` is on this protocol or already bridged to this protocol
842
        """
843
        def _error(msg):
1✔
844
            logger.warning(msg)
1✔
845
            raise ValueError(msg)
1✔
846

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

849
        # check req'ts
850
        if cls.owns_id(from_user_id) is False:
1✔
851
            _error(f"{from_user_id} doesn't look like an {cls.LABEL} id")
1✔
852
        elif isinstance(user, cls):
1✔
853
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
854
        elif cls.HAS_COPIES and cls.LABEL in user.enabled_protocols:
1✔
855
            _error(f"{user.handle_or_id()} is already bridged to {cls.PHRASE}")
1✔
856

857
        # reload profile
858
        try:
1✔
859
            user.reload_profile()
1✔
860
        except (RequestException, HTTPException) as e:
×
861
            _, msg = util.interpret_http_exception(e)
×
862

863
        # migrate!
864
        cls._migrate_in(user, from_user_id, **kwargs)
1✔
865
        user.add('enabled_protocols', cls.LABEL)
1✔
866
        user.put()
1✔
867

868
        # attach profile object
869
        if user.obj:
1✔
870
            if cls.HAS_COPIES:
1✔
871
                profile_id = ids.profile_id(id=from_user_id, proto=cls)
1✔
872
                user.obj.remove_copies_on(cls)
1✔
873
                user.obj.add('copies', Target(uri=profile_id, protocol=cls.LABEL))
1✔
874
                user.obj.put()
1✔
875

876
            common.create_task(queue='receive', obj_id=user.obj_key.id(),
1✔
877
                               authed_as=user.key.id())
878

879
    @classmethod
1✔
880
    def _migrate_in(cls, user, from_user_id, **kwargs):
1✔
881
        """Protocol-specific parts of migrating in external account.
882

883
        Called by :meth:`migrate_in`, which does most of the work, including calling
884
        :meth:`reload_profile` before this.
885

886
        Args:
887
          user (models.User): native user on another protocol to attach the
888
            newly imported account to. Unused.
889
          from_user_id (str): DID of the account to be migrated in
890
          kwargs: protocol dependent
891
        """
892
        raise NotImplementedError()
×
893

894
    @classmethod
1✔
895
    def target_for(cls, obj, shared=False):
1✔
896
        """Returns an :class:`Object`'s delivery target (endpoint).
897

898
        To be implemented by subclasses.
899

900
        Examples:
901

902
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
903
          webmention target.
904
        * If obj is an ``activitypub`` actor, returns its inbox.
905
        * If obj is an ``activitypub`` object, returns it's author's or actor's
906
          inbox.
907

908
        Args:
909
          obj (models.Object):
910
          shared (bool): optional. If True, returns a common/shared
911
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
912
            multiple recipients for efficiency
913

914
        Returns:
915
          str: target endpoint, or None if not available.
916
        """
917
        raise NotImplementedError()
×
918

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

923
        Default implementation here, subclasses may override.
924

925
        Args:
926
          url (str):
927
          allow_internal (bool): whether to return False for internal domains
928
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
929
        """
930
        blocklist = DOMAIN_BLOCKLIST
1✔
931
        if not DEBUG:
1✔
932
            blocklist += tuple(util.RESERVED_TLDS | util.LOCAL_TLDS)
1✔
933
        if not allow_internal:
1✔
934
            blocklist += DOMAINS
1✔
935
        return util.domain_or_parent_in(url, blocklist)
1✔
936

937
    @classmethod
1✔
938
    def translate_ids(to_cls, obj):
1✔
939
        """Translates all ids in an AS1 object to a specific protocol.
940

941
        Infers source protocol for each id value separately.
942

943
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
944
        ``at://did:plc:abc/coll/123`` will be converted to
945
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
946

947
        Wraps these AS1 fields:
948

949
        * ``id``
950
        * ``actor``
951
        * ``author``
952
        * ``bcc``
953
        * ``bto``
954
        * ``cc``
955
        * ``featured[].items``, ``featured[].orderedItems``
956
        * ``object``
957
        * ``object.actor``
958
        * ``object.author``
959
        * ``object.id``
960
        * ``object.inReplyTo``
961
        * ``object.object``
962
        * ``attachments[].id``
963
        * ``tags[objectType=mention].url``
964
        * ``to``
965

966
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
967
        same logic is duplicated there!
968

969
        TODO: unify with :meth:`Object.resolve_ids`,
970
        :meth:`models.Object.normalize_ids`.
971

972
        Args:
973
          to_proto (Protocol subclass)
974
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
975

976
        Returns:
977
          dict: translated AS1 version of ``obj``
978
        """
979
        assert to_cls != Protocol
1✔
980
        if not obj:
1✔
981
            return obj
1✔
982

983
        outer_obj = to_cls.translate_mention_handles(copy.deepcopy(obj))
1✔
984
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
985

986
        def translate(elem, field, fn, uri=False):
1✔
987
            elem[field] = as1.get_objects(elem, field)
1✔
988
            for obj in elem[field]:
1✔
989
                if id := obj.get('id'):
1✔
990
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
991
                        continue
1✔
992
                    from_cls = Protocol.for_id(id)
1✔
993
                    # TODO: what if from_cls is None? relax translate_object_id,
994
                    # make it a noop if we don't know enough about from/to?
995
                    if from_cls and from_cls != to_cls:
1✔
996
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
997
                    if obj['id'] and uri:
1✔
998
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
999

1000
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
1001
                           for o in elem[field]]
1002

1003
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
1004
                elem[field] = elem[field][0]
1✔
1005

1006
        type = as1.object_type(outer_obj)
1✔
1007
        translate(outer_obj, 'id',
1✔
1008
                  ids.translate_user_id if type in as1.ACTOR_TYPES
1009
                  else ids.translate_object_id)
1010

1011
        for o in inner_objs:
1✔
1012
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
1013
                        or as1.get_owner(outer_obj) == o.get('id')
1014
                        or type in ('follow', 'stop-following'))
1015
            translate(o, 'id', (ids.translate_user_id if is_actor
1✔
1016
                                else ids.translate_object_id))
1017
            # TODO: need to handle both user and object ids here
1018
            # https://github.com/snarfed/bridgy-fed/issues/2281
1019
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
1020
            translate(o, 'object', (ids.translate_user_id if obj_is_actor
1✔
1021
                                    else ids.translate_object_id))
1022

1023
        for o in [outer_obj] + inner_objs:
1✔
1024
            translate(o, 'inReplyTo', ids.translate_object_id)
1✔
1025
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
1026
                translate(o, field, ids.translate_user_id)
1✔
1027
            for tag in as1.get_objects(o, 'tags'):
1✔
1028
                if tag.get('objectType') == 'mention':
1✔
1029
                    translate(tag, 'url', ids.translate_user_id, uri=True)
1✔
1030
            for att in as1.get_objects(o, 'attachments'):
1✔
1031
                translate(att, 'id', ids.translate_object_id)
1✔
1032
                url = att.get('url')
1✔
1033
                if url and not att.get('id'):
1✔
1034
                    if from_cls := Protocol.for_id(url):
1✔
1035
                        att['id'] = ids.translate_object_id(from_=from_cls, to=to_cls,
1✔
1036
                                                            id=url)
1037
            if feat := as1.get_object(o, 'featured'):
1✔
1038
                translate(feat, 'orderedItems', ids.translate_object_id)
1✔
1039
                translate(feat, 'items', ids.translate_object_id)
1✔
1040

1041
        outer_obj = util.trim_nulls(outer_obj)
1✔
1042

1043
        if objs := util.get_list(outer_obj ,'object'):
1✔
1044
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
1045
            if len(outer_obj['object']) == 1:
1✔
1046
                outer_obj['object'] = outer_obj['object'][0]
1✔
1047

1048
        return outer_obj
1✔
1049

1050
    @classmethod
1✔
1051
    def translate_mention_handles(cls, obj):
1✔
1052
        """Translates @-mentions in ``obj.content`` to this protocol's handles.
1053

1054
        Specifically, for each ``mention`` tag in the object's tags that has
1055
        ``startIndex`` and ``length``, replaces it in ``obj.content`` with that
1056
        user's translated handle in this protocol and updates the tag's location.
1057

1058
        Called by :meth:`Protocol.translate_ids`.
1059

1060
        If ``obj.content`` is HTML, does nothing.
1061

1062
        Args:
1063
          obj (dict): AS2 object
1064

1065
        Returns:
1066
          dict: modified AS2 object
1067
        """
1068
        if not obj:
1✔
1069
            return None
×
1070

1071
        obj = copy.deepcopy(obj)
1✔
1072
        obj['object'] = [cls.translate_mention_handles(o)
1✔
1073
                                for o in as1.get_objects(obj)]
1074
        if len(obj['object']) == 1:
1✔
1075
            obj['object'] = obj['object'][0]
1✔
1076

1077
        content = obj.get('content')
1✔
1078
        tags = obj.get('tags')
1✔
1079
        if (not content or not tags
1✔
1080
                or obj.get('content_is_html')
1081
                or bool(BeautifulSoup(content, 'html.parser').find())
1082
                or HTML_ENTITY_RE.search(content)):
1083
            return util.trim_nulls(obj)
1✔
1084

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

1087
        offset = 0
1✔
1088
        for tag in sorted(indexed, key=lambda t: t['startIndex']):
1✔
1089
            tag['startIndex'] += offset
1✔
1090
            if tag.get('objectType') == 'mention' and (id := tag['url']):
1✔
1091
                if proto := Protocol.for_id(id):
1✔
1092
                    id = ids.normalize_user_id(id=id, proto=proto)
1✔
1093
                    if key := get_original_user_key(id):
1✔
1094
                        user = key.get()
×
1095
                    else:
1096
                        user = proto.get_or_create(id, allow_opt_out=True)
1✔
1097
                    if user:
1✔
1098
                        start = tag['startIndex']
1✔
1099
                        end = start + tag['length']
1✔
1100
                        if handle := user.handle_as(cls):
1✔
1101
                            content = content[:start] + handle + content[end:]
1✔
1102
                            offset += len(handle) - tag['length']
1✔
1103
                            tag.update({
1✔
1104
                                'displayName': handle,
1105
                                'length': len(handle),
1106
                            })
1107

1108
        obj['tags'] = tags
1✔
1109
        as2.set_content(obj, content)  # sets content *and* contentMap
1✔
1110
        return util.trim_nulls(obj)
1✔
1111

1112
    @classmethod
1✔
1113
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
1114
        """Handles an incoming activity.
1115

1116
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
1117
        unset, returns HTTP 299.
1118

1119
        Args:
1120
          obj (models.Object)
1121
          authed_as (str): authenticated actor id who sent this activity
1122
          internal (bool): whether to allow activity ids on internal domains,
1123
            from opted out/blocked users, etc.
1124
          received_at (datetime): when we first saw (received) this activity.
1125
            Right now only used for monitoring.
1126

1127
        Returns:
1128
          (str, int) tuple: (response body, HTTP status code) Flask response
1129

1130
        Raises:
1131
          werkzeug.HTTPException: if the request is invalid
1132
        """
1133
        # check some invariants
1134
        assert from_cls != Protocol
1✔
1135
        assert isinstance(obj, Object), obj
1✔
1136

1137
        if not obj.as1:
1✔
1138
            error('No object data provided')
1✔
1139

1140
        orig_obj = obj
1✔
1141
        id = None
1✔
1142
        if obj.key and obj.key.id():
1✔
1143
            id = obj.key.id()
1✔
1144

1145
        if not id:
1✔
1146
            id = obj.as1.get('id')
1✔
1147
            obj.key = ndb.Key(Object, id)
1✔
1148

1149
        if not id:
1✔
1150
            error('No id provided')
×
1151
        elif from_cls.owns_id(id) is False:
1✔
1152
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
1153
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
1154
            error(f'Activity {id} is blocklisted')
1✔
1155

1156
        # does this protocol support this activity/object type?
1157
        from_cls.check_supported(obj, 'receive')
1✔
1158

1159
        # lease this object, atomically
1160
        memcache_key = activity_id_memcache_key(id)
1✔
1161
        leased = memcache.memcache.add(
1✔
1162
            memcache_key, 'leased', noreply=False,
1163
            expire=int(MEMCACHE_LEASE_EXPIRATION.total_seconds()))
1164

1165
        # short circuit if we've already seen this activity id
1166
        if ('force' not in request.values
1✔
1167
            and (not leased
1168
                 or (obj.new is False and obj.changed is False))):
1169
            error(f'Already seen this activity {id}', status=204)
1✔
1170

1171
        pruned = {k: v for k, v in obj.as1.items()
1✔
1172
                  if k not in ('contentMap', 'replies', 'signature')}
1173
        delay = ''
1✔
1174
        retry = request.headers.get('X-AppEngine-TaskRetryCount')
1✔
1175
        if (received_at and retry in (None, '0')
1✔
1176
                and obj.type not in ('delete', 'undo')):  # we delay deletes/undos
1177
            delay_s = int((util.now().replace(tzinfo=None)
1✔
1178
                           - received_at.replace(tzinfo=None)
1179
                           ).total_seconds())
1180
            delay = f'({delay_s} s behind)'
1✔
1181
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
1182

1183
        # check authorization
1184
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1185
        actor = as1.get_owner(obj.as1)
1✔
1186
        if not actor:
1✔
1187
            error('Activity missing actor or author')
1✔
1188
        elif from_cls.owns_id(actor) is False:
1✔
1189
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
1190

1191
        assert authed_as
1✔
1192
        assert isinstance(authed_as, str)
1✔
1193
        authed_as = ids.normalize_user_id(id=authed_as, proto=from_cls)
1✔
1194
        actor = ids.normalize_user_id(id=actor, proto=from_cls)
1✔
1195
        # TODO: remove internal here once we've fixed #2237
1196
        if actor != authed_as and not internal:
1✔
1197
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
1198
                         user=f'{id} authed_as {authed_as} owner {actor}')
1199
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
1200

1201
        # update copy ids to originals
1202
        obj.normalize_ids()
1✔
1203
        obj.resolve_ids()
1✔
1204

1205
        if (obj.type == 'follow'
1✔
1206
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
1207
            # follows of bot user; refresh user profile first
1208
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
1209
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1210
            from_user.reload_profile()
1✔
1211
        else:
1212
            # load actor user
1213
            #
1214
            # TODO: we should maybe eventually allow non-None status users here if
1215
            # this is a profile update, so that we store the user again below and
1216
            # re-calculate its status. right now, if a bridged user updates their
1217
            # profile and invalidates themselves, eg by removing their profile
1218
            # picture, and then updates again to make themselves valid again, we'll
1219
            # ignore the second update. they'll have to un-bridge and re-bridge
1220
            # themselves to get back working again.
1221
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
1222

1223
        if not internal and (not from_user or from_user.manual_opt_out):
1✔
1224
            error(f"Couldn't load actor {actor}", status=204)
1✔
1225

1226
        # check if this is a profile object coming in via a user with use_instead
1227
        # set. if so, override the object's id to be the final user id (from_user's),
1228
        # after following use_instead.
1229
        if obj.type in as1.ACTOR_TYPES and from_user.key.id() != actor:
1✔
1230
            as1_id = obj.as1.get('id')
1✔
1231
            if ids.normalize_user_id(id=as1_id, proto=from_user) == actor:
1✔
1232
                logger.info(f'Overriding AS1 object id {as1_id} with Object id {from_user.profile_id()}')
1✔
1233
                obj.our_as1 = {**obj.as1, 'id': from_user.profile_id()}
1✔
1234

1235
        # if this is an object, ie not an activity, wrap it in a create or update
1236
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as,
1✔
1237
                                          from_user=from_user)
1238
        obj.add('users', from_user.key)
1✔
1239

1240
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1241
        inner_obj_id = inner_obj_as1.get('id')
1✔
1242
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1243
            if not inner_obj_id:
1✔
1244
                error(f'{obj.type} object has no id!')
1✔
1245

1246
        # check age. we support backdated posts, but if they're over 2w old, we
1247
        # don't deliver them
1248
        if obj.type == 'post':
1✔
1249
            if published := inner_obj_as1.get('published'):
1✔
1250
                try:
1✔
1251
                    published_dt = util.parse_iso8601(published)
1✔
1252
                    if not published_dt.tzinfo:
1✔
1253
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
1254
                    age = util.now() - published_dt
1✔
1255
                    if age > CREATE_MAX_AGE and 'force' not in request.values:
1✔
1256
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
1257
                              status=204)
1258
                except ValueError:  # from parse_iso8601
×
1259
                    logger.debug(f"Couldn't parse published {published}")
×
1260

1261
        # write Object to datastore
1262
        obj.source_protocol = from_cls.LABEL
1✔
1263
        if obj.type in STORE_AS1_TYPES:
1✔
1264
            obj.put()
1✔
1265

1266
        # store inner object
1267
        # TODO: unify with big obj.type conditional below. would have to merge
1268
        # this with the DM handling block lower down.
1269
        crud_obj = None
1✔
1270
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1271
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
1272
                                            source_protocol=from_cls.LABEL,
1273
                                            authed_as=actor, users=[from_user.key],
1274
                                            deleted=False)
1275

1276
        actor = as1.get_object(obj.as1, 'actor')
1✔
1277
        actor_id = actor.get('id')
1✔
1278

1279
        # handle activity!
1280
        if obj.type == 'stop-following':
1✔
1281
            # TODO: unify with handle_follow?
1282
            # TODO: handle multiple followees
1283
            if not actor_id or not inner_obj_id:
1✔
1284
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1285

1286
            # deactivate Follower
1287
            from_ = from_cls.key_for(actor_id)
1✔
1288
            if not (to_cls := Protocol.for_id(inner_obj_id)):
1✔
1289
                error(f"Can't determine protocol for {inner_obj_id} , giving up")
1✔
1290
            to = to_cls.key_for(inner_obj_id)
1✔
1291
            follower = Follower.query(Follower.to == to,
1✔
1292
                                      Follower.from_ == from_,
1293
                                      Follower.status == 'active').get()
1294
            if follower:
1✔
1295
                logger.info(f'Marking {follower} inactive')
1✔
1296
                follower.status = 'inactive'
1✔
1297
                follower.put()
1✔
1298
            else:
1299
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1300

1301
            # fall through to deliver to followee
1302
            # TODO: do we convert stop-following to webmention 410 of original
1303
            # follow?
1304

1305
            # fall through to deliver to followers
1306

1307
        elif obj.type in ('delete', 'undo'):
1✔
1308
            delete_obj_id = (from_user.profile_id()
1✔
1309
                            if inner_obj_id == from_user.key.id()
1310
                            else inner_obj_id)
1311

1312
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1313
            if not delete_obj:
1✔
1314
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1315
                return 'OK', 204
1✔
1316

1317
            # TODO: just delete altogether!
1318
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1319
            delete_obj.deleted = True
1✔
1320
            delete_obj.put()
1✔
1321

1322
            # if this is an actor, handle deleting it later so that
1323
            # in case it's from_user, user.enabled_protocols is still populated
1324
            #
1325
            # fall through to deliver to followers and delete copy if necessary.
1326
            # should happen via protocol-specific copy target and send of
1327
            # delete activity.
1328
            # https://github.com/snarfed/bridgy-fed/issues/63
1329

1330
        elif obj.type == 'block':
1✔
1331
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1332
                # blocking protocol bot user disables that protocol
1333
                from_user.delete(proto)
1✔
1334
                from_user.disable_protocol(proto)
1✔
1335
                return 'OK', 200
1✔
1336

1337
        elif obj.type == 'post':
1✔
1338
            # handle DMs to bot users
1339
            if as1.is_dm(obj.as1):
1✔
1340
                return dms.receive(from_user=from_user, obj=obj)
1✔
1341

1342
        # fetch actor if necessary
1343
        is_user = (inner_obj_id in (from_user.key.id(), from_user.profile_id())
1✔
1344
                   or from_user.is_profile(orig_obj))
1345
        if (actor and actor.keys() == set(['id'])
1✔
1346
                and not is_user and obj.type not in ('delete', 'undo')):
1347
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1348
            actor_obj = from_cls.load(ids.profile_id(id=actor['id'], proto=from_cls),
1✔
1349
                                      raise_=False)
1350
            if actor_obj and actor_obj.as1:
1✔
1351
                obj.our_as1 = {
1✔
1352
                    **obj.as1, 'actor': {
1353
                        **actor_obj.as1,
1354
                        # override profile id with actor id
1355
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1356
                        'id': actor['id'],
1357
                    }
1358
                }
1359

1360
        # fetch object if necessary
1361
        if (obj.type in ('post', 'update', 'share')
1✔
1362
                and inner_obj_as1.keys() == set(['id'])
1363
                and from_cls.owns_id(inner_obj_id) is not False):
1364
            logger.debug('Fetching inner object')
1✔
1365
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1366
                                      remote=(obj.type in ('post', 'update')))
1367
            if obj.type in ('post', 'update'):
1✔
1368
                crud_obj = inner_obj
1✔
1369
            if inner_obj and inner_obj.as1:
1✔
1370
                obj.our_as1 = {
1✔
1371
                    **obj.as1,
1372
                    'object': {
1373
                        **inner_obj_as1,
1374
                        **inner_obj.as1,
1375
                    }
1376
                }
1377
            elif obj.type in ('post', 'update'):
1✔
1378
                error(f"Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1379

1380
        if obj.type == 'follow':
1✔
1381
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1382
                # follow of one of our protocol bot users; enable that protocol.
1383
                # fall through so that we send an accept.
1384
                try:
1✔
1385
                    from_user.enable_protocol(proto)
1✔
1386
                except ErrorButDoNotRetryTask:
1✔
1387
                    from web import Web
1✔
1388
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1389
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1390
                                               followee=bot, follow=obj)
1391
                    raise
1✔
1392
                proto.bot_maybe_follow_back(from_user)
1✔
1393
                from_cls.handle_follow(obj, from_user=from_user)
1✔
1394
                return 'OK', 202
1✔
1395

1396
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1397

1398
        # on update of the user's own actor/profile, set user.obj and store user back
1399
        # to datastore so that we recalculate computed properties like status etc
1400
        if is_user:
1✔
1401
            if obj.type == 'update' and crud_obj:
1✔
1402
                logger.info(f"update of the user's profile, re-storing user with obj_key {crud_obj.key.id()}")
1✔
1403
                from_user.obj = crud_obj
1✔
1404
                from_user.put()
1✔
1405

1406
        # deliver to targets
1407
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1408

1409
        # on user deleting themselves, deactivate their followers/followings.
1410
        # https://github.com/snarfed/bridgy-fed/issues/1304
1411
        #
1412
        # do this *after* delivering because delivery finds targets based on
1413
        # stored Followers
1414
        if is_user and obj.type == 'delete':
1✔
1415
            for proto in from_user.enabled_protocols:
1✔
1416
                from_user.disable_protocol(PROTOCOLS[proto])
1✔
1417

1418
            logger.info(f'Deactivating Followers from or to {from_user.key.id()}')
1✔
1419
            followers = Follower.query(
1✔
1420
                OR(Follower.to == from_user.key, Follower.from_ == from_user.key)
1421
            ).fetch()
1422
            for f in followers:
1✔
1423
                f.status = 'inactive'
1✔
1424
            ndb.put_multi(followers)
1✔
1425

1426
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1427
        return resp
1✔
1428

1429
    @classmethod
1✔
1430
    def handle_follow(from_cls, obj, from_user):
1✔
1431
        """Handles an incoming follow activity.
1432

1433
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1434
        happens in :meth:`deliver`.
1435

1436
        Args:
1437
          obj (models.Object): follow activity
1438
        """
1439
        logger.debug('Got follow. storing Follow(s), sending accept(s)')
1✔
1440
        from_id = from_user.key.id()
1✔
1441

1442
        # Prepare followee (to) users' data
1443
        to_as1s = as1.get_objects(obj.as1)
1✔
1444
        if not to_as1s:
1✔
1445
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1446

1447
        # Store Followers
1448
        for to_as1 in to_as1s:
1✔
1449
            to_id = to_as1.get('id')
1✔
1450
            if not to_id:
1✔
1451
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1452

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

1455
            to_cls = Protocol.for_id(to_id)
1✔
1456
            if not to_cls:
1✔
1457
                error(f"Couldn't determine protocol for {to_id}")
×
1458
            elif from_cls == to_cls:
1✔
1459
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1460
                continue
1✔
1461

1462
            to_key = to_cls.key_for(to_id)
1✔
1463
            if not to_key:
1✔
1464
                logger.info(f'Skipping invalid {to_cls.LABEL} user key: {to_id}')
×
1465
                continue
×
1466

1467
            to_user = to_cls.get_or_create(id=to_key.id())
1✔
1468
            if not to_user or not to_user.is_enabled(from_user):
1✔
1469
                error(f'{to_id} not found')
1✔
1470

1471
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1472
                                                  follow=obj.key, status='active')
1473
            obj.add('notify', to_key)
1✔
1474
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1475
                                       followee=to_user, follow=obj)
1476

1477
    @classmethod
1✔
1478
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1479
        """Sends an accept or reject activity for a follow.
1480

1481
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1482
        nothing.
1483

1484
        Args:
1485
          verb (str): ``accept`` or  ``reject``
1486
          follower (models.User)
1487
          followee (models.User)
1488
          follow (models.Object)
1489
        """
1490
        assert verb in ('accept', 'reject')
1✔
1491
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1492
            return
1✔
1493

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

1497
        # send. note that this is one response for the whole follow, even if it
1498
        # has multiple followees!
1499
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1500
        accept = {
1✔
1501
            'id': id,
1502
            'objectType': 'activity',
1503
            'verb': verb,
1504
            'actor': followee.key.id(),
1505
            'object': follow.as1,
1506
        }
1507
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1508
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1509

1510
    @classmethod
1✔
1511
    def bot_maybe_follow_back(bot_cls, user):
1✔
1512
        """Follow a user from a protocol bot user, if their protocol needs that.
1513

1514
        ...so that the protocol starts sending us their activities, if it needs
1515
        a follow for that (eg ActivityPub).
1516

1517
        Args:
1518
          user (User)
1519
        """
1520
        if not user.BOTS_FOLLOW_BACK:
1✔
1521
            return
1✔
1522

1523
        from web import Web
1✔
1524
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1525
        now = util.now().isoformat()
1✔
1526
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1527

1528
        if not user.obj:
1✔
1529
            logger.info("  can't follow, user has no profile obj")
1✔
1530
            return
1✔
1531

1532
        target = user.target_for(user.obj)
1✔
1533
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1534
        follow_back_as1 = {
1✔
1535
            'objectType': 'activity',
1536
            'verb': 'follow',
1537
            'id': follow_back_id,
1538
            'actor': bot.key.id(),
1539
            'object': user.key.id(),
1540
        }
1541
        common.create_task(queue='send', id=follow_back_id,
1✔
1542
                           our_as1=follow_back_as1, url=target,
1543
                           source_protocol='web', protocol=user.LABEL,
1544
                           user=bot.key.urlsafe())
1545

1546
    @classmethod
1✔
1547
    def handle_bare_object(cls, obj, *, authed_as, from_user):
1✔
1548
        """If obj is a bare object, wraps it in a create or update activity.
1549

1550
        Checks if we've seen it before.
1551

1552
        Args:
1553
          obj (models.Object)
1554
          authed_as (str): authenticated actor id who sent this activity
1555
          from_user (models.User): user (actor) this activity/object is from
1556

1557
        Returns:
1558
          models.Object: ``obj`` if it's an activity, otherwise a new object
1559
        """
1560
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1561
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1562
            return obj
1✔
1563

1564
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1565
        now = util.now().isoformat()
1✔
1566

1567
        # this is a raw post; wrap it in a create or update activity
1568
        if obj.changed or is_actor:
1✔
1569
            if obj.changed:
1✔
1570
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1571
            else:
1572
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1573
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1574
            update_as1 = {
1✔
1575
                'objectType': 'activity',
1576
                'verb': 'update',
1577
                'id': id,
1578
                'actor': obj_actor,
1579
                'object': {
1580
                    # Mastodon requires the updated field for Updates, so
1581
                    # add a default value.
1582
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1583
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1584
                    # https://github.com/mastodon/documentation/pull/1150
1585
                    'updated': now,
1586
                    **obj.as1,
1587
                },
1588
            }
1589
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1590
            return Object(id=id, our_as1=update_as1,
1✔
1591
                          source_protocol=obj.source_protocol)
1592

1593
        if obj.new or 'force' in request.values:
1✔
1594
            create_id = f'{obj.key.id()}#bridgy-fed-create-{now}'
1✔
1595
            create_as1 = {
1✔
1596
                'objectType': 'activity',
1597
                'verb': 'post',
1598
                'id': create_id,
1599
                'actor': obj_actor,
1600
                'object': obj.as1,
1601
                'published': now,
1602
            }
1603
            logger.info(f'Wrapping in post')
1✔
1604
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1605
            return Object(id=create_id, our_as1=create_as1,
1✔
1606
                          source_protocol=obj.source_protocol)
1607

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

1610
    @classmethod
1✔
1611
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1612
        """Delivers an activity to its external recipients.
1613

1614
        Args:
1615
          obj (models.Object): activity to deliver
1616
          from_user (models.User): user (actor) this activity is from
1617
          crud_obj (models.Object): if this is a create, update, or delete/undo
1618
            activity, the inner object that's being written, otherwise None.
1619
            (This object's ``notify`` and ``feed`` properties may be updated.)
1620
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1621
            targets on this protocol
1622

1623
        Returns:
1624
          (str, int) tuple: Flask response
1625
        """
1626
        if to_proto:
1✔
1627
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1628

1629
        # find delivery targets. maps Target to Object or None
1630
        #
1631
        # ...then write the relevant object, since targets() has a side effect of
1632
        # setting the notify and feed properties (and dirty attribute)
1633
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1634
        if to_proto:
1✔
1635
            targets = {t: obj for t, obj in targets.items()
1✔
1636
                       if t.protocol == to_proto.LABEL}
1637
        if not targets:
1✔
1638
            # don't raise via error() because we call deliver in code paths where
1639
            # we want to continue after
1640
            msg = r'No targets, nothing to do ¯\_(ツ)_/¯'
1✔
1641
            logger.info(msg)
1✔
1642
            return msg, 204
1✔
1643

1644
        # store object that targets() updated
1645
        if crud_obj and crud_obj.dirty:
1✔
1646
            crud_obj.put()
1✔
1647
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1648
            obj.put()
1✔
1649

1650
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1651
                      else obj.to_request())
1652

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

1656
        # enqueue send task for each targets
1657
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1658
        user = from_user.key.urlsafe()
1✔
1659
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1660
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1661
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1662
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1663

1664
        return 'OK', 202
1✔
1665

1666
    @classmethod
1✔
1667
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1668
        """Collects the targets to send a :class:`models.Object` to.
1669

1670
        Targets are both objects - original posts, events, etc - and actors.
1671

1672
        Args:
1673
          obj (models.Object)
1674
          from_user (User)
1675
          crud_obj (models.Object): if this is a create, update, or delete/undo
1676
            activity, the inner object that's being written, otherwise None.
1677
            (This object's ``notify`` and ``feed`` properties may be updated.)
1678
          internal (bool): whether this is a recursive internal call
1679

1680
        Returns:
1681
          dict: maps :class:`models.Target` to original (in response to)
1682
          :class:`models.Object`
1683
        """
1684
        logger.debug('Finding recipients and their targets')
1✔
1685

1686
        # we should only have crud_obj iff this is a create or update
1687
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1688
        write_obj = crud_obj or obj
1✔
1689
        write_obj.dirty = False
1✔
1690

1691
        target_uris = as1.targets(obj.as1)
1✔
1692
        orig_obj = None
1✔
1693
        targets = {}  # maps Target (with *normalized* uri) to Object or None
1✔
1694
        owner = as1.get_owner(obj.as1)
1✔
1695
        allow_opt_out = (obj.type == 'delete')
1✔
1696
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1697
        inner_obj_id = inner_obj_as1.get('id')
1✔
1698
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1699
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1700
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1701
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1702
        is_self_reply = False
1✔
1703

1704
        original_ids = []
1✔
1705
        if is_reply:
1✔
1706
            original_ids = in_reply_tos
1✔
1707
        elif inner_obj_id:
1✔
1708
            if inner_obj_id == from_user.key.id():
1✔
1709
                inner_obj_id = from_user.profile_id()
1✔
1710
            original_ids = [inner_obj_id]
1✔
1711

1712
        # maps id to Object
1713
        original_objs = {}
1✔
1714
        for id in original_ids:
1✔
1715
            if proto := Protocol.for_id(id):
1✔
1716
                original_objs[id] = proto.load(id, raise_=False)
1✔
1717

1718
        # for AP, add in-reply-tos' mentions
1719
        # https://github.com/snarfed/bridgy-fed/issues/1608
1720
        # https://github.com/snarfed/bridgy-fed/issues/1218
1721
        orig_post_mentions = {}  # maps mentioned id to original post Object
1✔
1722
        for id in in_reply_tos:
1✔
1723
            if ((in_reply_to_obj := original_objs.get(id))
1✔
1724
                    and (proto := PROTOCOLS.get(in_reply_to_obj.source_protocol))
1725
                    and proto.SEND_REPLIES_TO_ORIG_POSTS_MENTIONS
1726
                    and (mentions := as1.mentions(in_reply_to_obj.as1))):
1727
                logger.info(f"Adding in-reply-to {id} 's mentions to targets: {mentions}")
1✔
1728
                target_uris.extend(mentions)
1✔
1729
                for mention in mentions:
1✔
1730
                    orig_post_mentions[mention] = in_reply_to_obj
1✔
1731

1732
        target_uris = sorted(set(target_uris))
1✔
1733
        logger.info(f'Raw targets: {target_uris}')
1✔
1734

1735
        # which protocols should we allow delivering to?
1736
        to_protocols = []  # elements are Protocol subclasses
1✔
1737
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1738
                      + from_user.enabled_protocols):
1739
            if not (proto := PROTOCOLS.get(label)):
1✔
1740
                report_error(f'unknown enabled protocol {label} for {from_user.key.id()}')
1✔
1741
                continue
1✔
1742

1743
            if (obj.type == 'post' and (orig := original_objs.get(inner_obj_id))
1✔
1744
                    and orig.get_copy(proto)):
1745
                logger.info(f'Already created {id} on {label}, cowardly refusing to create there again')
1✔
1746
                continue
1✔
1747

1748
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1749
                                     or is_reply):
1750
                origs_could_bridge = None
1✔
1751

1752
                for id in original_ids:
1✔
1753
                    if not (orig := original_objs.get(id)):
1✔
1754
                        continue
1✔
1755
                    elif isinstance(orig, proto):
1✔
1756
                        logger.info(f'Allowing {label} for original {id}')
×
1757
                        break
×
1758
                    elif orig.get_copy(proto):
1✔
1759
                        logger.info(f'Allowing {label}, original {id} was bridged there')
1✔
1760
                        break
1✔
1761
                    elif from_user.is_profile(orig):
1✔
1762
                        logger.info(f"Allowing {label}, this is the user's profile")
1✔
1763
                        break
1✔
1764

1765
                    if (origs_could_bridge is not False
1✔
1766
                            and (orig_author_id := as1.get_owner(orig.as1))
1767
                            and (orig_proto := PROTOCOLS.get(orig.source_protocol))
1768
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1769
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1770

1771
                else:
1772
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1773
                    last_retry = False
1✔
1774
                    if retries := request.headers.get(TASK_RETRIES_HEADER):
1✔
1775
                        logger.info(f'last retry! skipping {proto.LABEL} and continuing')
1✔
1776
                        last_retry = int(retries) >= TASK_RETRIES_RECEIVE
1✔
1777

1778
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
1✔
1779
                            and origs_could_bridge and not last_retry):
1780
                        # retry later; original obj may still be bridging
1781
                        # TODO: limit to brief window, eg no older than 2h? 1d?
1782
                        error(msg, status=304)
1✔
1783

1784
                    logger.info(msg)
1✔
1785
                    continue
1✔
1786

1787
            util.add(to_protocols, proto)
1✔
1788

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

1791
        # process direct targets
1792
        for target_id in target_uris:
1✔
1793
            target_proto = Protocol.for_id(target_id)
1✔
1794
            if not target_proto:
1✔
1795
                logger.info(f"Can't determine protocol for {target_id}")
1✔
1796
                continue
1✔
1797
            elif target_proto.is_blocklisted(target_id):
1✔
1798
                logger.debug(f'{target_id} is blocklisted')
1✔
1799
                continue
1✔
1800

1801
            target_obj_id = target_id
1✔
1802
            if target_id in mentioned_urls or obj.type in as1.VERBS_WITH_ACTOR_OBJECT:
1✔
1803
                # not ideal. this can sometimes be a non-user, eg blocking a
1804
                # blocklist. ok right now since profile_id() returns its input id
1805
                # unchanged if it doesn't look like a user id, but that's brittle.
1806
                target_obj_id = ids.profile_id(id=target_id, proto=target_proto)
1✔
1807

1808
            orig_obj = target_proto.load(target_obj_id, raise_=False)
1✔
1809
            if not orig_obj or not orig_obj.as1:
1✔
1810
                logger.info(f"Couldn't load {target_obj_id}")
1✔
1811
                continue
1✔
1812

1813
            target_author_key = (target_proto(id=target_id).key
1✔
1814
                                 if target_id in mentioned_urls
1815
                                 else target_proto.actor_key(orig_obj))
1816
            if not from_user.is_enabled(target_proto):
1✔
1817
                # if author isn't bridged and target user is, DM a prompt and
1818
                # add a notif for the target user
1819
                if (target_id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
1820
                        and target_author_key):
1821
                    if target_author := target_author_key.get():
1✔
1822
                        if target_author.is_enabled(from_cls):
1✔
1823
                            notifications.add_notification(target_author, write_obj)
1✔
1824
                            verb, noun = (
1✔
1825
                                ('replied to', 'replies') if target_id in in_reply_tos
1826
                                else ('quoted', 'quotes') if target_id in quoted_posts
1827
                                else ('mentioned', 'mentions'))
1828
                            dms.maybe_send(from_=target_proto, to_user=from_user,
1✔
1829
                                           type='replied_to_bridged_user', text=f"""\
1830
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.""")
1831

1832
                continue
1✔
1833

1834
            # deliver self-replies to followers
1835
            # https://github.com/snarfed/bridgy-fed/issues/639
1836
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1837
                is_self_reply = True
1✔
1838
                logger.info(f'self reply!')
1✔
1839

1840
            # also add copies' targets
1841
            for copy in orig_obj.copies:
1✔
1842
                proto = PROTOCOLS[copy.protocol]
1✔
1843
                if proto in to_protocols:
1✔
1844
                    # copies generally won't have their own Objects
1845
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1846
                        target = util.normalize_url(target, trailing_slash=False)
1✔
1847
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {target_id}')
1✔
1848
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1849

1850
            if target_proto == from_cls:
1✔
1851
                logger.debug(f'Skipping same-protocol target {target_id}')
1✔
1852
                continue
1✔
1853

1854
            target = target_proto.target_for(orig_obj)
1✔
1855
            if not target:
1✔
1856
                # TODO: surface errors like this somehow?
1857
                logger.error(f"Can't find delivery target for {target_id}")
×
1858
                continue
×
1859

1860
            target = util.normalize_url(target, trailing_slash=False)
1✔
1861
            logger.debug(f'Target for {target_id} is {target}')
1✔
1862
            # only use orig_obj for inReplyTos, like/repost objects, reply's original
1863
            # post's mentions, etc
1864
            # https://github.com/snarfed/bridgy-fed/issues/1237
1865
            target_obj = None
1✔
1866
            if target_id in in_reply_tos + as1.get_ids(obj.as1, 'object'):
1✔
1867
                target_obj = orig_obj
1✔
1868
            elif target_id in orig_post_mentions:
1✔
1869
                target_obj = orig_post_mentions[target_id]
1✔
1870
            targets[Target(protocol=target_proto.LABEL, uri=target)] = target_obj
1✔
1871

1872
            if target_author_key:
1✔
1873
                logger.debug(f'Recipient is {target_author_key}')
1✔
1874
                if write_obj.add('notify', target_author_key):
1✔
1875
                    write_obj.dirty = True
1✔
1876

1877
        if obj.type == 'undo':
1✔
1878
            logger.info('Object is an undo; adding targets for inner object')
1✔
1879
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1880
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1881
            else:
1882
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1883
            if inner_obj:
1✔
1884
                for target, target_obj in from_cls.targets(
1✔
1885
                        inner_obj, from_user=from_user, internal=True).items():
1886
                    targets[target] = target_obj
1✔
1887
                    util.add(to_protocols, PROTOCOLS[target.protocol])
1✔
1888

1889
        if not to_protocols:
1✔
1890
            return {}
1✔
1891

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

1894
        # deliver to followers, if appropriate
1895
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1896
        if not user_key:
1✔
1897
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1898
            return targets
1✔
1899

1900
        followers = []
1✔
1901
        is_undo_block = obj.type == 'undo' and inner_obj_as1.get('verb') == 'block'
1✔
1902
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1903
                and (not is_reply or is_self_reply) and not is_undo_block):
1904
            logger.info(f'Delivering to followers of {user_key} on {[p.LABEL for p in to_protocols]}')
1✔
1905
            followers = []
1✔
1906
            for f in Follower.query(Follower.to == user_key,
1✔
1907
                                    Follower.status == 'active'):
1908
                proto = PROTOCOLS_BY_KIND[f.from_.kind()]
1✔
1909
                # skip protocol bot users
1910
                if (not Protocol.for_bridgy_subdomain(f.from_.id())
1✔
1911
                        # skip protocols this user hasn't enabled, or where the base
1912
                        # object of this activity hasn't been bridged
1913
                        and proto in to_protocols
1914
                        # we deliver to HAS_COPIES protocols separately, below. we
1915
                        # assume they have follower-independent targets.
1916
                        and not (proto.HAS_COPIES and proto.DEFAULT_TARGET)):
1917
                    followers.append(f)
1✔
1918

1919
            logger.info(f'  loaded {len(followers)} followers')
1✔
1920

1921
            user_keys = [f.from_ for f in followers]
1✔
1922
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1923
            logger.info(f'  loaded {len(users)} users')
1✔
1924

1925
            User.load_multi(users)
1✔
1926
            logger.info(f'  loaded user objects')
1✔
1927

1928
            if (not followers and
1✔
1929
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
1930
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
1931
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1932
                return {}
1✔
1933

1934
            # add to followers' feeds, if any
1935
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1936
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1937
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
1✔
1938
                    if write_obj.feed:
1✔
1939
                        write_obj.dirty = True
1✔
1940

1941
            # collect targets for followers
1942
            target_obj = (original_objs.get(inner_obj_id)
1✔
1943
                          if obj.type == 'share' else None)
1944
            for user in users:
1✔
1945
                if user.is_blocking(from_user):
1✔
1946
                    logger.debug(f'  {user.key.id()} blocks {from_user.key.id()}')
1✔
1947
                    continue
1✔
1948

1949
                # TODO: should we pass remote=False through here to Protocol.load?
1950
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1951
                if not target:
1✔
1952
                    continue
1✔
1953

1954
                target = util.normalize_url(target, trailing_slash=False)
1✔
1955
                targets[Target(protocol=user.LABEL, uri=target)] = target_obj
1✔
1956

1957
            logger.info(f'  collected {len(targets)} targets')
1✔
1958

1959
        # deliver to enabled HAS_COPIES protocols proactively
1960
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
1961
            for proto in to_protocols:
1✔
1962
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
1963
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
1964
                    targets.setdefault(
1✔
1965
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1966

1967
        # maps string target URL to (Target, Object) tuple
1968
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1969
        # maps Target to Object or None
1970
        targets = {}
1✔
1971
        source_domains = [
1✔
1972
            util.domain_from_link(url) for url in
1973
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1974
            if util.is_web(url)
1975
        ]
1976
        for url in sorted(util.dedupe_urls(
1✔
1977
                candidates.keys(),
1978
                # preserve our PDS URL without trailing slash in path
1979
                # https://atproto.com/specs/did#did-documents
1980
                trailing_slash=False)):
1981
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
1982
                logger.info(f'Skipping same-domain target {url}')
×
1983
                continue
×
1984
            elif from_user.is_blocking(url):
1✔
1985
                logger.debug(f'{from_user.key.id()} blocks {url}')
1✔
1986
                continue
1✔
1987

1988
            target, obj = candidates[url]
1✔
1989
            targets[target] = obj
1✔
1990

1991
        return targets
1✔
1992

1993
    @classmethod
1✔
1994
    def load(cls, id, remote=None, local=True, raise_=True, raw=False, csv=False,
1✔
1995
             **kwargs):
1996
        """Loads and returns an Object from datastore or HTTP fetch.
1997

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

2001
        Args:
2002
          id (str)
2003
          remote (bool): whether to fetch the object over the network. If True,
2004
            fetches even if we already have the object stored, and updates our
2005
            stored copy. If False and we don't have the object stored, returns
2006
            None. Default (None) means to fetch over the network only if we
2007
            don't already have it stored.
2008
          local (bool): whether to load from the datastore before
2009
            fetching over the network. If False, still stores back to the
2010
            datastore after a successful remote fetch.
2011
          raise_ (bool): if False, catches any :class:`request.RequestException`
2012
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
2013
            ``None`` instead
2014
          raw (bool): whether to load this as a "raw" id, as is, without
2015
            normalizing to an on-protocol object id. Exact meaning varies by subclass.
2016
          csv (bool): whether to specifically load a CSV object
2017
            TODO: merge this into raw, using returned Content-Type?
2018
          kwargs: passed through to :meth:`fetch()`
2019

2020
        Returns:
2021
          models.Object: loaded object, or None if it isn't fetchable, eg a
2022
          non-URL string for Web, or ``remote`` is False and it isn't in the
2023
          datastore
2024

2025
        Raises:
2026
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
2027
            is True
2028
        """
2029
        assert id
1✔
2030
        assert local or remote is not False
1✔
2031
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
2032

2033
        if not raw:
1✔
2034
            id = ids.normalize_object_id(id=id, proto=cls)
1✔
2035

2036
        obj = orig_as1 = None
1✔
2037
        if local:
1✔
2038
            if obj := Object.get_by_id(id):
1✔
2039
                if csv and not obj.is_csv:
1✔
2040
                    return None
1✔
2041
                elif obj.as1 or obj.csv or obj.raw or obj.deleted:
1✔
2042
                    # logger.debug(f'  {id} got from datastore')
2043
                    obj.new = False
1✔
2044

2045
        if remote is False:
1✔
2046
            return obj
1✔
2047
        elif remote is None and obj:
1✔
2048
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
2049
                # logger.debug(f'  last updated {obj.updated}, refreshing')
2050
                pass
1✔
2051
            else:
2052
                return obj
1✔
2053

2054
        if obj:
1✔
2055
            orig_as1 = obj.as1
1✔
2056
            obj.our_as1 = None
1✔
2057
            obj.new = False
1✔
2058
        else:
2059
            if cls == Protocol:
1✔
2060
                return None
1✔
2061
            obj = Object(id=id)
1✔
2062
            if local:
1✔
2063
                # logger.debug(f'  {id} not in datastore')
2064
                obj.new = True
1✔
2065
                obj.changed = False
1✔
2066

2067
        try:
1✔
2068
            fetched = cls.fetch(obj, csv=csv, **kwargs)
1✔
2069
        except (RequestException, HTTPException) as e:
1✔
2070
            if raise_:
1✔
2071
                raise
1✔
2072
            util.interpret_http_exception(e)
1✔
2073
            return None
1✔
2074

2075
        if not fetched:
1✔
2076
            return None
1✔
2077
        elif csv and not obj.is_csv:
1✔
2078
            return None
×
2079

2080
        # https://stackoverflow.com/a/3042250/186123
2081
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
2082
        if size > MAX_ENTITY_SIZE:
1✔
2083
            logger.warning(f'Object is too big! {size} bytes is over {MAX_ENTITY_SIZE}')
1✔
2084
            return None
1✔
2085

2086
        obj.resolve_ids()
1✔
2087
        obj.normalize_ids()
1✔
2088

2089
        if obj.new is False:
1✔
2090
            obj.changed = obj.activity_changed(orig_as1)
1✔
2091

2092
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
2093
            if obj.source_protocol:
1✔
2094
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
2095
            obj.source_protocol = cls.LABEL
1✔
2096

2097
        obj.put()
1✔
2098
        return obj
1✔
2099

2100
    @classmethod
1✔
2101
    def check_supported(cls, obj, direction):
1✔
2102
        """If this protocol doesn't support this activity, raises HTTP 204.
2103

2104
        Also reports an error.
2105

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

2110
        Args:
2111
          obj (Object)
2112
          direction (str): ``'receive'`` or  ``'send'``
2113

2114
        Raises:
2115
          werkzeug.HTTPException: if this protocol doesn't support this object
2116
        """
2117
        assert direction in ('receive', 'send')
1✔
2118
        if not obj.type:
1✔
2119
            return
×
2120

2121
        inner = as1.get_object(obj.as1)
1✔
2122
        inner_type = as1.object_type(inner) or ''
1✔
2123
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
2124
            or (obj.type in as1.CRUD_VERBS
2125
                and inner_type
2126
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
2127
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
2128

2129
        # don't allow posts with blank content and no image/video/audio
2130
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
2131
                    else obj.as1)
2132
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
2133
                and not util.get_url(crud_obj, key='image')
2134
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
2135
                # TODO: handle articles with displayName but not content
2136
                and not source.html_to_text(crud_obj.get('content')).strip()):
2137
            error('Blank content and no image or video or audio', status=204)
1✔
2138

2139
        # receiving DMs is only allowed to protocol bot accounts
2140
        if direction == 'receive':
1✔
2141
            if recip := as1.recipient_if_dm(obj.as1):
1✔
2142
                owner = as1.get_owner(obj.as1)
1✔
2143
                if (not cls.SUPPORTS_DMS or (recip not in common.bot_user_ids()
1✔
2144
                                             and owner not in common.bot_user_ids())):
2145
                    # reply and say DMs aren't supported
2146
                    from_proto = PROTOCOLS.get(obj.source_protocol)
1✔
2147
                    to_proto = Protocol.for_id(recip)
1✔
2148
                    if owner and from_proto and to_proto:
1✔
2149
                        if ((from_user := from_proto.get_or_create(id=owner))
1✔
2150
                                and (to_user := to_proto.get_or_create(id=recip))):
2151
                            in_reply_to = (inner.get('id') if obj.type == 'post'
1✔
2152
                                           else obj.as1.get('id'))
2153
                            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✔
2154
                            type = f'dms_not_supported-{to_user.key.id()}'
1✔
2155
                            dms.maybe_send(from_=to_user, to_user=from_user,
1✔
2156
                                           text=text, type=type,
2157
                                           in_reply_to=in_reply_to)
2158

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

2161
            # check that this activity is public. only do this for some activities,
2162
            # not eg likes or follows, since Mastodon doesn't currently mark those
2163
            # as explicitly public.
2164
            elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
2165
                  and not util.domain_or_parent_in(crud_obj.get('id'), NON_PUBLIC_DOMAINS)
2166
                  and not as1.is_public(obj.as1, unlisted=False)):
2167
                error('Bridgy Fed only supports public activities', status=204)
1✔
2168

2169
    @classmethod
1✔
2170
    def block(cls, from_user, arg):
1✔
2171
        """Blocks a user or list.
2172

2173
        Args:
2174
          from_user (models.User): user doing the blocking
2175
          arg (str): handle or id of user/list to block
2176

2177
        Returns:
2178
          models.User or models.Object: user or list that was blocked
2179

2180
        Raises:
2181
          ValueError: if arg doesn't look like a user or list on this protocol
2182
        """
2183
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2184

2185
        def fail(msg):
1✔
2186
            logger.warning(msg)
1✔
2187
            raise ValueError(msg)
1✔
2188

2189
        blockee = None
1✔
2190
        try:
1✔
2191
            # first, try interpreting as a user handle or id
2192
            blockee = load_user(arg, proto=cls, create=True, allow_opt_out=True)
1✔
2193
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2194
            logger.info(err)
1✔
2195

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

2199
        # may not be a user, see if it's a list
2200
        if not blockee:
1✔
2201
            if not cls or cls == Protocol:
1✔
2202
                cls = Protocol.for_id(arg)
1✔
2203

2204
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2205
                if blockee.source_protocol == from_user.LABEL:
1✔
2206
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2207
            else:
2208
                if blocklist := from_user.add_domain_blocklist(arg):
1✔
2209
                    return blocklist
1✔
2210
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2211

2212
        logger.info(f'  blocking {blockee.key.id()}')
1✔
2213
        id = f'{from_user.key.id()}#bridgy-fed-block-{util.now().isoformat()}'
1✔
2214
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2215
            'objectType': 'activity',
2216
            'verb': 'block',
2217
            'id': id,
2218
            'actor': from_user.key.id(),
2219
            'object': blockee.key.id(),
2220
        })
2221
        obj.put()
1✔
2222
        from_user.deliver(obj, from_user=from_user)
1✔
2223

2224
        return blockee
1✔
2225

2226
    @classmethod
1✔
2227
    def unblock(cls, from_user, arg):
1✔
2228
        """Unblocks a user or list.
2229

2230
        Args:
2231
          from_user (models.User): user doing the unblocking
2232
          arg (str): handle or id of user/list to unblock
2233

2234
        Returns:
2235
          models.User or models.Object: user or list that was unblocked
2236

2237
        Raises:
2238
          ValueError: if arg doesn't look like a user or list on this protocol
2239
        """
2240
        logger.info(f'user {from_user.key.id()} trying to unblock {arg}')
1✔
2241
        def fail(msg):
1✔
2242
            logger.warning(msg)
1✔
2243
            raise ValueError(msg)
1✔
2244

2245
        blockee = None
1✔
2246
        try:
1✔
2247
            # first, try interpreting as a user handle or id
2248
            blockee = load_user(arg, cls, create=True, allow_opt_out=True)
1✔
2249
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2250
            logger.info(err)
1✔
2251

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

2255
        # may not be a user, see if it's a list
2256
        if not blockee:
1✔
2257
            if not cls or cls == Protocol:
1✔
2258
                cls = Protocol.for_id(arg)
1✔
2259

2260
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2261
                if blockee.source_protocol == from_user.LABEL:
1✔
2262
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2263
            else:
2264
                if blocklist := from_user.remove_domain_blocklist(arg):
1✔
2265
                    return blocklist
1✔
2266
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2267

2268
        logger.info(f'  unblocking {blockee.key.id()}')
1✔
2269
        id = f'{from_user.key.id()}#bridgy-fed-unblock-{util.now().isoformat()}'
1✔
2270
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2271
            'objectType': 'activity',
2272
            'verb': 'undo',
2273
            'id': id,
2274
            'actor': from_user.key.id(),
2275
            'object': {
2276
                'objectType': 'activity',
2277
                'verb': 'block',
2278
                'actor': from_user.key.id(),
2279
                'object': blockee.key.id(),
2280
            },
2281
        })
2282
        obj.put()
1✔
2283
        from_user.deliver(obj, from_user=from_user)
1✔
2284

2285
        return blockee
1✔
2286

2287

2288
@cloud_tasks_only(log=None)
1✔
2289
def receive_task():
1✔
2290
    """Task handler for a newly received :class:`models.Object`.
2291

2292
    Calls :meth:`Protocol.receive` with the form parameters.
2293

2294
    Parameters:
2295
      authed_as (str): passed to :meth:`Protocol.receive`
2296
      obj_id (str): key id of :class:`models.Object` to handle
2297
      received_at (str, ISO 8601 timestamp): when we first saw (received)
2298
        this activity
2299
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2300
        :class:`models.Object` to handle
2301

2302
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
2303
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
2304
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
2305
    ``changed``, HTTP request details, etc. See stash for attempt at this for
2306
    :class:`web.Web`.
2307
    """
2308
    common.log_request()
1✔
2309
    form = request.form.to_dict()
1✔
2310

2311
    authed_as = form.pop('authed_as', None)
1✔
2312
    internal = authed_as == PRIMARY_DOMAIN or authed_as in PROTOCOL_DOMAINS
1✔
2313

2314
    obj = Object.from_request()
1✔
2315
    assert obj
1✔
2316
    assert obj.source_protocol
1✔
2317
    obj.new = True
1✔
2318

2319
    if received_at := form.pop('received_at', None):
1✔
2320
        received_at = datetime.fromisoformat(received_at)
1✔
2321

2322
    try:
1✔
2323
        return PROTOCOLS[obj.source_protocol].receive(
1✔
2324
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
2325
    except RequestException as e:
1✔
2326
        util.interpret_http_exception(e)
1✔
2327
        error(e, status=304)
1✔
2328
    except ValueError as e:
1✔
2329
        logger.warning(e, exc_info=True)
×
2330
        error(e, status=304)
×
2331

2332

2333
@cloud_tasks_only(log=None)
1✔
2334
def send_task():
1✔
2335
    """Task handler for sending an activity to a single specific destination.
2336

2337
    Calls :meth:`Protocol.send` with the form parameters.
2338

2339
    Parameters:
2340
      protocol (str): :class:`Protocol` to send to
2341
      url (str): destination URL to send to
2342
      obj_id (str): key id of :class:`models.Object` to send
2343
      orig_obj_id (str): optional, :class:`models.Object` key id of the
2344
        "original object" that this object refers to, eg replies to or reposts
2345
        or likes
2346
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
2347
        this activity is from
2348
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2349
        :class:`models.Object` to handle
2350
    """
2351
    common.log_request()
1✔
2352

2353
    # prepare
2354
    form = request.form.to_dict()
1✔
2355
    url = form.get('url')
1✔
2356
    protocol = form.get('protocol')
1✔
2357
    if not url or not protocol:
1✔
2358
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
2359
        return '', 204
1✔
2360

2361
    target = Target(uri=url, protocol=protocol)
1✔
2362
    obj = Object.from_request()
1✔
2363
    assert obj and obj.key and obj.key.id()
1✔
2364

2365
    PROTOCOLS[protocol].check_supported(obj, 'send')
1✔
2366
    allow_opt_out = (obj.type == 'delete')
1✔
2367

2368
    user = None
1✔
2369
    if user_key := form.get('user'):
1✔
2370
        key = ndb.Key(urlsafe=user_key)
1✔
2371
        # use get_by_id so that we follow use_instead
2372
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2373
            key.id(), allow_opt_out=allow_opt_out)
2374

2375
    # send
2376
    delay = ''
1✔
2377
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
2378
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
2379
        delay = f'({delay_s} s behind)'
1✔
2380
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
2381
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
2382
    sent = None
1✔
2383
    try:
1✔
2384
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
2385
                                        orig_obj_id=form.get('orig_obj_id'))
2386
    except (MemcacheServerError, MemcacheUnexpectedCloseError,
1✔
2387
            MemcacheUnknownError) as e:
2388
        # our memorystore instance is probably undergoing maintenance. re-enqueue
2389
        # task with a delay.
2390
        # https://docs.cloud.google.com/memorystore/docs/memcached/about-maintenance
2391
        report_error(f'memcache error on send task, re-enqueuing in {MEMCACHE_DOWN_TASK_DELAY}: {e}')
1✔
2392
        common.create_task(queue='send', delay=MEMCACHE_DOWN_TASK_DELAY, **form)
1✔
2393
        sent = False
1✔
2394
    except BaseException as e:
1✔
2395
        code, body = util.interpret_http_exception(e)
1✔
2396
        if not code and not body:
1✔
2397
            raise
1✔
2398

2399
    if sent is False:
1✔
2400
        logger.info(f'Failed sending!')
1✔
2401

2402
    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