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

snarfed / bridgy-fed / aac02a76-5d7e-42f7-9f54-ed9e15521404

07 Aug 2026 10:40PM UTC coverage: 93.074% (+0.007%) from 93.067%
aac02a76-5d7e-42f7-9f54-ed9e15521404

push

circleci

snarfed
ATProto.set_username: allow setting to default *.brid.gy handle

for #2611

[deploy]

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

37 existing lines in 3 files now uncovered.

8762 of 9414 relevant lines covered (93.07%)

0.93 hits per line

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

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

10
from cachetools import cached, LRUCache
1✔
11
from flask import request
1✔
12
from google.cloud import ndb
1✔
13
from google.cloud.ndb import OR
1✔
14
from google.cloud.ndb.model import _entity_to_protobuf
1✔
15
from granary import as1, as2, source
1✔
16
from granary.source import html_to_text
1✔
17
from pymemcache.exceptions import (
1✔
18
    MemcacheServerError,
19
    MemcacheUnexpectedCloseError,
20
    MemcacheUnknownError,
21
)
22
from requests import RequestException
1✔
23
from websockets.exceptions import InvalidStatus
1✔
24
from webutil.appengine_info import DEBUG, LOCAL_SERVER
1✔
25
from webutil.flask_util import cloud_tasks_only
1✔
26
from webutil.models import MAX_ENTITY_SIZE
1✔
27
from webutil import util
1✔
28
from webutil.util import json_dumps, json_loads
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
    LOCAL_SUPERDOMAIN,
41
    PRIMARY_DOMAIN,
42
    PROTOCOL_DOMAINS,
43
    SUPERDOMAIN,
44
)
45
import dms
1✔
46
from domains import DOMAIN_BLOCKLIST
1✔
47
import ids
1✔
48
import memcache
1✔
49
from models import (
1✔
50
    Follower,
51
    get_original_user_key,
52
    load_user,
53
    Object,
54
    PROTOCOLS,
55
    PROTOCOLS_BY_KIND,
56
    Target,
57
    User,
58
)
59
import notifications
1✔
60

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

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

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

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

96
DONT_NOTIFY_TYPES = (
1✔
97
    'block',
98
)
99

100
logger = logging.getLogger(__name__)
1✔
101

102

103
def error(*args, status=299, **kwargs):
1✔
104
    """Default HTTP status code to 299 to prevent retrying task."""
105
    return common.error(*args, status=status, **kwargs)
1✔
106

107

108
def activity_id_memcache_key(id):
1✔
109
    return memcache.key(f'receive-{id}')
1✔
110

111

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

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

165
    @staticmethod
1✔
166
    def for_request(fed=None):
1✔
167
        """Returns the protocol for the current request.
168

169
        ...based on the request's hostname.
170

171
        Args:
172
          fed (str or protocol.Protocol): protocol to return if the current
173
            request is on ``fed.brid.gy``
174

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

181
    @staticmethod
1✔
182
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
183
        """Returns the protocol for a brid.gy subdomain.
184

185
        Args:
186
          domain_or_url (str)
187
          fed (str or protocol.Protocol): protocol to return if the current
188
            request is on ``fed.brid.gy``
189

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

198
        if domain == PRIMARY_DOMAIN or domain in LOCAL_DOMAINS:
1✔
199
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
1✔
200
        elif domain.endswith(SUPERDOMAIN):
1✔
201
            label = domain.removesuffix(SUPERDOMAIN)
1✔
202
            return PROTOCOLS.get(label)
1✔
203
        elif (DEBUG or LOCAL_SERVER) and domain.endswith(LOCAL_SUPERDOMAIN):
1✔
204
            label = domain.removesuffix(LOCAL_SUPERDOMAIN)
1✔
205
            return PROTOCOLS.get(label)
1✔
206

207
    @classmethod
1✔
208
    def owns_id(cls, id):
1✔
209
        """Returns whether this protocol owns the id, or None if it's unclear.
210

211
        To be implemented by subclasses.
212

213
        IDs are string identities that uniquely identify users or objects, and
214
        are intended primarily to be machine readable and usable. Compare to
215
        handles, which are human-chosen, human-meaningful, and often but not
216
        always unique.
217

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

222
        This should be a quick guess without expensive side effects, eg no
223
        external HTTP fetches to fetch the id itself or otherwise perform
224
        discovery.
225

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

228
        Callers that know the id is ours, and want to know whether it's a user
229
        id or an object id, should use :meth:`id_type` instead.
230

231
        Args:
232
          id (str): user id or object id
233

234
        Returns:
235
          bool or None:
236
        """
237
        return False
1✔
238

239
    @classmethod
1✔
240
    def id_type(cls, id):
1✔
241
        """Returns whether ``id`` identifies a user or an object.
242

243
        To be implemented by subclasses.
244

245
        Assumes that ``id`` is this protocol's. Returns None if we can't tell from
246
        the id alone, eg :class:`activitypub.ActivityPub` actor ids and object ids
247
        are both http(s) URLs, and :class:`nostr.Nostr` uses hex ids for both pubkeys
248
        and events. Callers should fall back to eg looking the id up as a user.
249

250
        Accepts any recognized format, not just the canonical format, eg
251
        :class:`atproto.ATProto` accepts ``https://bsky.app/profile/...`` URLs as
252
        well as DIDs.
253

254
        Args:
255
          id (str)
256

257
        Returns:
258
          ids.IdType or None:
259

260
        """
261
        return None
1✔
262

263
    @classmethod
1✔
264
    def owns_handle(cls, handle, allow_internal=False):
1✔
265
        """Returns whether this protocol owns the handle, or None if it's unclear.
266

267
        To be implemented by subclasses.
268

269
        Handles are string identities that are human-chosen, human-meaningful,
270
        and often but not always unique. Compare to IDs, which uniquely identify
271
        users, and are intended primarily to be machine readable and usable.
272

273
        Some protocols' handles are more or less deterministic based on the id
274
        format, eg ActivityPub (technically WebFinger) handles are
275
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
276
        ActivityPub, AT Protocol, or others.
277

278
        This should be a quick guess without expensive side effects, eg no
279
        external HTTP fetches to fetch the id itself or otherwise perform
280
        discovery.
281

282
        Args:
283
          handle (str)
284
          allow_internal (bool): whether to return False for internal domains
285
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
286

287
        Returns:
288
          bool or None
289
        """
290
        return False
1✔
291

292
    @classmethod
1✔
293
    def handle_to_id(cls, handle):
1✔
294
        """Converts a handle to an id.
295

296
        To be implemented by subclasses.
297

298
        May incur network requests, eg DNS queries or HTTP requests. Avoids
299
        blocked or opted out users.
300

301
        Args:
302
          handle (str)
303

304
        Returns:
305
          str: corresponding id, or None if the handle can't be found
306
        """
307
        raise NotImplementedError()
×
308

309
    @classmethod
1✔
310
    def authed_user_for_request(cls):
1✔
311
        """Returns the authenticated user id for the current request.
312

313

314
        Checks authentication on the current request, eg HTTP Signature for
315
        ActivityPub. To be implemented by subclasses.
316

317
        Returns:
318
          str: authenticated user id, or None if there is no authentication
319

320
        Raises:
321
          RuntimeError: if the request's authentication (eg signature) is
322
          invalid or otherwise can't be verified
323
        """
324
        return None
1✔
325

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

330
        If called via `Protocol.key_for`, infers the appropriate protocol with
331
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
332
        as is.
333

334
        Args:
335
          id (str):
336
          allow_opt_out (bool): whether to allow users who are currently opted out
337

338
        Returns:
339
          google.cloud.ndb.Key: matching key, or None if the given id is not a
340
          valid :class:`User` id for this protocol.
341
        """
342
        if cls == Protocol:
1✔
343
            proto = Protocol.for_id(id)
1✔
344
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
345

346
        # load user so that we follow use_instead
347
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
348
        if existing:
1✔
349
            if existing.status and not allow_opt_out:
1✔
350
                return None
1✔
351
            return existing.key
1✔
352

353
        return cls(id=id).key
1✔
354

355
    @staticmethod
1✔
356
    def _for_id_memcache_key(id, remote=None):
1✔
357
        """If id is a URL, uses its domain, otherwise returns None.
358

359
        Args:
360
          id (str)
361

362
        Returns:
363
          (str domain, bool remote) or None
364
        """
365
        domain = util.domain_from_link(id)
1✔
366
        if domain in PROTOCOL_DOMAINS:
1✔
367
            return id
1✔
368
        elif remote and util.is_web(id):
1✔
369
            return domain
1✔
370

371
    @cached(LRUCache(20000), lock=Lock())
1✔
372
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote=True: remote,
1✔
373
                      version=3)
374
    @staticmethod
1✔
375
    def for_id(id, remote=True):
1✔
376
        """Returns the protocol for a given id.
377

378
        Args:
379
          id (str)
380
          remote (bool): whether to perform expensive side effects like fetching
381
            the id itself over the network, or other discovery.
382

383
        Returns:
384
          Protocol subclass: matching protocol, or None if no single known
385
          protocol definitively owns this id
386
        """
387
        logger.debug(f'Determining protocol for id {id}')
1✔
388
        if not id:
1✔
389
            return None
1✔
390

391
        # remove our synthetic id fragment, if any
392
        #
393
        # will this eventually cause false positives for other services that
394
        # include our full ids inside their own ids, non-URL-encoded? guess
395
        # we'll figure that out if/when it happens.
396
        id = id.partition('#bridgy-fed-')[0]
1✔
397
        if not id:
1✔
398
            return None
1✔
399

400
        if util.is_web(id):
1✔
401
            # step 1: check for our per-protocol subdomains
402
            try:
1✔
403
                parsed = urlparse(id)
1✔
404
            except ValueError as e:
1✔
405
                logger.info(f'urlparse ValueError: {e}')
1✔
406
                return None
1✔
407

408
            is_internal = parsed.path.startswith(ids.INTERNAL_PATH_PREFIX)
1✔
409
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
410
            if by_subdomain and not (util.is_homepage(id) or is_internal
1✔
411
                                     or id in ids.BOT_ACTOR_AP_IDS):
412
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
413
                return by_subdomain
1✔
414

415
        # step 2: check if any Protocols say conclusively that they own it
416
        # sort to be deterministic
417
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
418
                           key=lambda p: p.LABEL)
419
        candidates = []
1✔
420
        for protocol in protocols:
1✔
421
            owns = protocol.owns_id(id)
1✔
422
            if owns:
1✔
423
                logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
424
                return protocol
1✔
425
            elif owns is not False:
1✔
426
                candidates.append(protocol)
1✔
427

428
        if len(candidates) == 1:
1✔
429
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
430
            return candidates[0]
1✔
431

432
        # step 3: look for existing Objects in the datastore
433
        #
434
        # note that we don't currently see if this is a copy id because I have FUD
435
        # over which Protocol for_id should return in that case...and also because a
436
        # protocol may already say definitively above that it owns the id, eg ATProto
437
        # with DIDs and at:// URIs.
438
        obj = Protocol.load(id, remote=False)
1✔
439
        if obj and obj.source_protocol:
1✔
440
            logger.debug(f'  {obj.key.id()} owned by source_protocol {obj.source_protocol}')
1✔
441
            return PROTOCOLS[obj.source_protocol]
1✔
442

443
        # step 4: fetch over the network, if necessary
444
        if not remote:
1✔
445
            return None
1✔
446

447
        for protocol in candidates:
1✔
448
            logger.debug(f'Trying {protocol.LABEL}')
1✔
449
            try:
1✔
450
                obj = protocol.load(id, local=False, remote=True)
1✔
451

452
                if protocol.ABBREV == 'web':
1✔
453
                    # for web, if we fetch and get HTML without microformats,
454
                    # load returns False but the object will be stored in the
455
                    # datastore with source_protocol web, and in cache. load it
456
                    # again manually to check for that.
457
                    obj = Object.get_by_id(id)
1✔
458
                    if obj and obj.source_protocol != 'web':
1✔
459
                        obj = None
×
460

461
                if obj:
1✔
462
                    logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
463
                    return protocol
1✔
464
            except BadGateway:
1✔
465
                # we tried and failed fetching the id over the network.
466
                # this depends on ActivityPub.fetch raising this!
467
                return None
1✔
468
            except HTTPException as e:
×
469
                # internal error we generated ourselves; try next protocol
470
                pass
×
471
            except Exception as e:
×
472
                code, _ = util.interpret_http_exception(e)
×
473
                if code:
×
474
                    # we tried and failed fetching the id over the network
475
                    return None
×
476
                raise
×
477

478
        logger.info(f'No matching protocol found for {id} !')
1✔
479
        return None
1✔
480

481
    @cached(LRUCache(20000), lock=Lock())
1✔
482
    @staticmethod
1✔
483
    def for_handle(handle):
1✔
484
        """Returns the protocol for a given handle.
485

486
        May incur expensive side effects like resolving the handle itself over
487
        the network or other discovery.
488

489
        Args:
490
          handle (str)
491

492
        Returns:
493
          (Protocol subclass, str) tuple: matching protocol and optional id (if
494
          resolved), or ``(None, None)`` if no known protocol owns this handle
495
        """
496
        # TODO: normalize, eg convert domains to lower case
497
        logger.debug(f'Determining protocol for handle {handle}')
1✔
498
        if not handle:
1✔
499
            return (None, None)
1✔
500

501
        # step 1: check if any Protocols say conclusively that they own it.
502
        # sort to be deterministic.
503
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
504
                           key=lambda p: p.LABEL)
505
        candidates = []
1✔
506
        for proto in protocols:
1✔
507
            owns = proto.owns_handle(handle)
1✔
508
            if owns:
1✔
509
                logger.debug(f'  {proto.LABEL} owns handle {handle}')
1✔
510
                return (proto, None)
1✔
511
            elif owns is not False:
1✔
512
                candidates.append(proto)
1✔
513

514
        if len(candidates) == 1:
1✔
515
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
1✔
516
            return (candidates[0], None)
1✔
517

518
        # step 2: look for matching User in the datastore
519
        for proto in candidates:
1✔
520
            user = proto.query(proto.handle == handle).get()
1✔
521
            if user:
1✔
522
                if user.status:
1✔
523
                    return (None, None)
1✔
524
                logger.debug(f'  user {user.key} handle {handle}')
1✔
525
                return (proto, user.key.id())
1✔
526

527
        # step 3: resolve handle to id
528
        for proto in candidates:
1✔
529
            id = proto.handle_to_id(handle)
1✔
530
            if id:
1✔
531
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
532
                return (proto, id)
1✔
533

534
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
535
        return (None, None)
1✔
536

537
    @classmethod
1✔
538
    def is_user_at_domain(cls, handle, allow_internal=False):
1✔
539
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
540

541
        Example: ``@user@instance.com``
542

543
        Args:
544
          handle (str)
545
          allow_internal (bool): whether the domain can be a Bridgy Fed domain
546
        """
547
        parts = handle.split('@')
1✔
548
        if len(parts) != 2:
1✔
549
            return False
1✔
550

551
        user, domain = parts
1✔
552
        return bool(user and domain
1✔
553
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
554

555
    @classmethod
1✔
556
    def bridged_web_url_for(cls, user, fallback=False):
1✔
557
        """Returns the web URL for a user's bridged profile in this protocol.
558

559
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
560
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
561

562
        Args:
563
          user (models.User)
564
          fallback (bool): if True, and bridged users have no canonical user
565
            profile URL in this protocol, return the native protocol's profile URL
566

567
        Returns:
568
          str, or None if there isn't a canonical URL
569
        """
570
        if fallback:
1✔
571
            return user.web_url()
1✔
572

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

577
        Args:
578
          obj (models.Object)
579
          allow_opt_out (bool): whether to return a user key if they're opted out
580

581
        Returns:
582
          google.cloud.ndb.key.Key or None:
583
        """
584
        owner = as1.get_owner(obj.as1)
1✔
585
        if owner:
1✔
586
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
587

588
    @classmethod
1✔
589
    def bot_user_id(cls):
1✔
590
        """Returns the Web user id for the bot user for this protocol.
591

592
        For example, ``'bsky.brid.gy'`` for ATProto.
593

594
        Returns:
595
          str:
596
        """
597
        return f'{cls.ABBREV}{SUPERDOMAIN}'
1✔
598

599
    @classmethod
1✔
600
    def create_for(cls, user):
1✔
601
        """Creates or re-activate a copy user in this protocol.
602

603
        Should add the copy user to :attr:`copies`.
604

605
        If the copy user already exists and active, should do nothing.
606

607
        Args:
608
          user (models.User): original source user. Shouldn't already have a
609
            copy user for this protocol in :attr:`copies`.
610

611
        Raises:
612
          ValueError: if we can't create a copy of the given user in this protocol
613
        """
614
        raise NotImplementedError()
×
615

616
    @classmethod
1✔
617
    def send(to_cls, obj, target, from_user=None, orig_obj_id=None):
1✔
618
        """Sends an outgoing activity.
619

620
        To be implemented by subclasses. Should call
621
        ``to_cls.translate_ids(obj.as1)`` before converting it to this Protocol's
622
        format.
623

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

628
        Args:
629
          obj (models.Object): with activity to send
630
          target (str): destination URL to send to
631
          from_user (models.User): user (actor) this activity is from
632
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
633
            that this object refers to, eg replies to or reposts or likes
634

635
        Returns:
636
          bool: True if the activity is sent successfully, False if it is
637
          ignored or otherwise unsent due to protocol logic, eg no webmention
638
          endpoint, protocol doesn't support the activity type. (Failures are
639
          raised as exceptions.)
640

641
        Raises:
642
          werkzeug.HTTPException if the request fails
643
        """
644
        raise NotImplementedError()
×
645

646
    @classmethod
1✔
647
    def fetch(cls, obj, **kwargs):
1✔
648
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
649

650
        Errors are raised as exceptions. If this method returns False, the fetch
651
        didn't fail but didn't succeed either, eg the id isn't valid for this
652
        protocol, or the fetch didn't return valid data for this protocol.
653

654
        To be implemented by subclasses.
655

656
        Args:
657
          obj (models.Object): with the id to fetch. Data is filled into one of
658
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
659
          kwargs: subclass-specific
660

661
        Returns:
662
          bool: True if the object was fetched and populated successfully,
663
          False otherwise
664

665
        Raises:
666
          requests.RequestException, werkzeug.HTTPException,
667
          websockets.WebSocketException, etc: if the fetch fails
668
        """
669
        raise NotImplementedError()
×
670

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

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

678
        Just passes through to :meth:`_convert`, then does minor
679
        protocol-independent postprocessing.
680

681
        Args:
682
          obj (models.Object):
683
          from_user (models.User): user (actor) this activity/object is from
684
          kwargs: protocol-specific, passed through to :meth:`_convert`
685

686
        Returns:
687
          converted object in the protocol's native format, often a dict, or None
688
        """
689
        if not obj or not obj.as1:
1✔
690
            return None
1✔
691

692
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
693
        is_crud = obj.as1.get('verb') in as1.CRUD_VERBS
1✔
694
        base_obj = as1.get_object(obj.as1) if is_crud else obj.as1
1✔
695
        orig_our_as1 = obj.our_as1
1✔
696

697
        # post-processing for user profiles
698
        if (from_user and from_user.is_profile(obj)
1✔
699
                and PROTOCOLS.get(obj.source_protocol) != cls
700
                and Protocol.for_bridgy_subdomain(id) not in DOMAINS):
701
            # TODO: more systematic way to get this that covers all protocols,
702
            # eg Nostr NIP-05
703
            web_opted_in = (from_user.LABEL == 'web' and
1✔
704
                            (from_user.last_webmention_in
705
                             or from_user.has_redirects
706
                             or from_user.handle_as('atproto') == from_user.key.id()))
707
            if not web_opted_in:
1✔
708
                # mark bridged actors as bots and add "bridged by Bridgy Fed" to
709
                # their bios. (web users are special cased, they don't get the label
710
                # if they've explicitly enabled Bridgy Fed with redirects or
711
                # webmentions.)
712
                cls.add_source_links(obj=obj, from_user=from_user)
1✔
713

714
                # web is currently opt out, so add [Unofficial] to their display name
715
                # to be explicit that they may not have enabled this themselves
716
                if from_user.LABEL == 'web':
1✔
717
                    if obj.our_as1 is orig_our_as1:
1✔
718
                        obj.our_as1 = copy.deepcopy(obj.as1)
×
719
                    actor = as1.get_object(obj.our_as1) if is_crud else obj.our_as1
1✔
720
                    if ((name := actor.get('displayName'))
1✔
721
                            and not name.endswith(' [Unofficial]')):
722
                        actor['displayName'] = f'{name} [Unofficial]'
1✔
723

724
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
725
        obj.our_as1 = orig_our_as1
1✔
726
        return converted
1✔
727

728
    @classmethod
1✔
729
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
730
        """Converts an :class:`Object` to this protocol's data format.
731

732
        To be implemented by subclasses. Implementations should generally call
733
        :meth:`Protocol.translate_ids` (as their own class) before converting to
734
        their format.
735

736
        Args:
737
          obj (models.Object):
738
          from_user (models.User): user (actor) this activity/object is from
739
          kwargs: protocol-specific
740

741
        Returns:
742
          converted object in the protocol's native format, often a dict. May
743
            return the ``{}`` empty dict if the object can't be converted.
744
        """
745
        raise NotImplementedError()
×
746

747
    @classmethod
1✔
748
    def add_source_links(cls, obj, from_user):
1✔
749
        """Adds "bridged from ... by Bridgy Fed" to the user's actor's ``summary``.
750

751
        Uses HTML for protocols that support it, plain text otherwise.
752

753
        Args:
754
          cls (Protocol subclass): protocol that the user is bridging into
755
          obj (models.Object): user's actor/profile object
756
          from_user (models.User): user (actor) this activity/object is from
757
        """
758
        assert obj and obj.as1
1✔
759
        assert from_user
1✔
760

761
        obj.our_as1 = copy.deepcopy(obj.as1)
1✔
762
        actor = (as1.get_object(obj.as1) if obj.type in as1.CRUD_VERBS
1✔
763
                 else obj.as1)
764
        actor.setdefault('objectType', 'person')
1✔
765

766
        orig_summary = actor.setdefault('summary', '')
1✔
767
        summary_text = html_to_text(orig_summary, ignore_links=True)
1✔
768

769
        # Check if we've already added source links
770
        if '🌉 bridged' in summary_text:
1✔
771
            return
1✔
772

773
        actor_id = actor.get('id')
1✔
774

775
        url = (as1.get_url(actor)
1✔
776
               or (from_user.web_url() if from_user.profile_id() == actor_id
777
                   else actor_id))
778

779
        from web import Web
1✔
780
        bot_user = Web.get_by_id(from_user.bot_user_id())
1✔
781

782
        if cls.HTML_PROFILES:
1✔
783
            if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS:
1✔
784
                mention = bot_user.html_link(proto=cls, name=False, handle='short')
1✔
785
                suffix = f', follow {mention} to interact'
1✔
786
            else:
787
                suffix = f' by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>'
1✔
788

789
            separator = '<br><br>'
1✔
790

791
            is_user = from_user.key and actor_id in (from_user.key.id(),
1✔
792
                                                     from_user.profile_id())
793
            if is_user:
1✔
794
                bridged = f'🌉 <a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a>'
1✔
795
                from_ = f'<a href="{from_user.web_url()}">{from_user.handle}</a>'
1✔
796
            else:
797
                bridged = '🌉 bridged'
×
798
                from_ = util.pretty_link(url) if url else '?'
×
799

800
        else:  # plain text
801
            # TODO: unify with above. which is right?
802
            id = obj.key.id() if obj.key else obj.our_as1.get('id')
1✔
803
            is_user = from_user.key and id in (from_user.key.id(),
1✔
804
                                               from_user.profile_id())
805
            from_ = (from_user.web_url() if is_user else url) or '?'
1✔
806

807
            bridged = '🌉 bridged'
1✔
808
            suffix = (
1✔
809
                f': https://{PRIMARY_DOMAIN}{from_user.user_page_path()}'
810
                # link web users to their user pages
811
                if from_user.LABEL == 'web'
812
                else f', follow @{bot_user.handle_as(cls)} to interact'
813
                if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS
814
                else f' by https://{PRIMARY_DOMAIN}/')
815
            separator = '\n\n'
1✔
816
            orig_summary = summary_text
1✔
817

818
        logo = f'{from_user.LOGO_EMOJI} ' if from_user.LOGO_EMOJI else ''
1✔
819
        source_links = f'{separator if orig_summary else ""}{bridged} from {logo}{from_}{suffix}'
1✔
820
        actor['summary'] = orig_summary + source_links
1✔
821

822
    @classmethod
1✔
823
    def set_username(to_cls, user, username):
1✔
824
        """Sets a custom username for a user's bridged account in this protocol.
825

826
        Args:
827
          user (models.User)
828
          username (str)
829

830
        Raises:
831
          ValueError: if the username is invalid
832
          RuntimeError: if the username could not be set
833
        """
834
        raise NotImplementedError()
1✔
835

836
    @classmethod
1✔
837
    def migrate_out(cls, user, to_user_id):
1✔
838
        """Migrates a bridged account out to be a native account.
839

840
        Args:
841
          user (models.User)
842
          to_user_id (str)
843

844
        Raises:
845
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
846
            ``user`` is on this protocol or not bridged to this protocol
847
        """
848
        raise NotImplementedError()
×
849

850
    @classmethod
1✔
851
    def check_can_migrate_out(cls, user, to_user_id):
1✔
852
        """Raises an exception if a user can't yet migrate to a native account.
853

854
        For example, if ``to_user_id`` isn't on this protocol, or if ``user`` is on
855
        this protocol, or isn't bridged to this protocol.
856

857
        If the user is ready to migrate, returns ``None``.
858

859
        Subclasses may override this to add more criteria, but they should call this
860
        implementation first.
861

862
        Args:
863
          user (models.User)
864
          to_user_id (str)
865

866
        Raises:
867
          ValueError: if ``user`` isn't ready to migrate to this protocol yet
868
        """
869
        def _error(msg):
1✔
870
            logger.warning(msg)
1✔
871
            raise ValueError(msg)
1✔
872

873
        if cls.owns_id(to_user_id) is False:
1✔
874
            _error(f"{to_user_id} doesn't look like an {cls.LABEL} id")
1✔
875
        elif isinstance(user, cls):
1✔
876
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
877
        elif not user.is_enabled(cls):
1✔
878
            _error(f"{user.handle_or_id()} isn't currently bridged to {cls.PHRASE}")
1✔
879

880
    @classmethod
1✔
881
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
882
        """Migrates a native account in to be a bridged account.
883

884
        The protocol independent parts are done here; protocol-specific parts are
885
        done in :meth:`_migrate_in`, which this wraps.
886

887
        Reloads the user's profile before calling :meth:`_migrate_in`.
888

889
        Args:
890
          user (models.User): native user on another protocol to attach the
891
            newly imported bridged account to
892
          from_user_id (str)
893
          kwargs: additional protocol-specific parameters
894

895
        Raises:
896
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
897
            ``user`` is on this protocol or already bridged to this protocol
898
        """
899
        def _error(msg):
1✔
900
            logger.warning(msg)
1✔
901
            raise ValueError(msg)
1✔
902

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

905
        # check req'ts
906
        if cls.owns_id(from_user_id) is False:
1✔
907
            _error(f"{from_user_id} doesn't look like an {cls.LABEL} id")
1✔
908
        elif isinstance(user, cls):
1✔
909
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
910
        elif cls.HAS_COPIES and cls.LABEL in user.enabled_protocols:
1✔
911
            _error(f"{user.handle_or_id()} is already bridged to {cls.PHRASE}")
1✔
912

913
        # reload profile
914
        try:
1✔
915
            user.reload_profile()
1✔
916
        except (RequestException, HTTPException) as e:
×
917
            _, msg = util.interpret_http_exception(e)
×
918

919
        # migrate!
920
        cls._migrate_in(user, from_user_id, **kwargs)
1✔
921
        user.add('enabled_protocols', cls.LABEL)
1✔
922
        user.put()
1✔
923

924
        # attach profile object
925
        if user.obj:
1✔
926
            if cls.HAS_COPIES:
1✔
927
                profile_id = ids.profile_id(id=from_user_id, proto=cls)
1✔
928
                user.obj.remove_copies_on(cls)
1✔
929
                user.obj.add('copies', Target(uri=profile_id, protocol=cls.LABEL))
1✔
930
                user.obj.put()
1✔
931

932
            common.create_task(queue='receive', obj_id=user.obj_key.id(),
1✔
933
                               authed_as=user.key.id())
934

935
    @classmethod
1✔
936
    def _migrate_in(cls, user, from_user_id, **kwargs):
1✔
937
        """Protocol-specific parts of migrating in external account.
938

939
        Called by :meth:`migrate_in`, which does most of the work, including calling
940
        :meth:`reload_profile` before this.
941

942
        Args:
943
          user (models.User): native user on another protocol to attach the
944
            newly imported account to. Unused.
945
          from_user_id (str): DID of the account to be migrated in
946
          kwargs: protocol dependent
947
        """
948
        raise NotImplementedError()
×
949

950
    @classmethod
1✔
951
    def target_for(cls, obj, shared=False):
1✔
952
        """Returns an :class:`Object`'s delivery target (endpoint).
953

954
        To be implemented by subclasses.
955

956
        Examples:
957

958
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
959
          webmention target.
960
        * If obj is an ``activitypub`` actor, returns its inbox.
961
        * If obj is an ``activitypub`` object, returns it's author's or actor's
962
          inbox.
963

964
        Args:
965
          obj (models.Object):
966
          shared (bool): optional. If True, returns a common/shared
967
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
968
            multiple recipients for efficiency
969

970
        Returns:
971
          str: target endpoint, or None if not available.
972
        """
973
        raise NotImplementedError()
×
974

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

979
        Default implementation here, subclasses may override.
980

981
        Args:
982
          url (str):
983
          allow_internal (bool): whether to return False for internal domains
984
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
985
        """
986
        blocklist = DOMAIN_BLOCKLIST
1✔
987
        if not DEBUG:
1✔
988
            blocklist += tuple(util.RESERVED_TLDS | util.LOCAL_TLDS)
1✔
989
        if not allow_internal:
1✔
990
            blocklist += DOMAINS
1✔
991
        return util.domain_or_parent_in(url, blocklist)
1✔
992

993
    @classmethod
1✔
994
    def translate_ids(to_cls, obj):
1✔
995
        """Translates all ids in an AS1 object to a specific protocol.
996

997
        Infers source protocol for each id value separately.
998

999
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
1000
        ``at://did:plc:abc/coll/123`` will be converted to
1001
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
1002

1003
        Wraps these AS1 fields:
1004

1005
        * ``id``
1006
        * ``actor``
1007
        * ``author``
1008
        * ``bcc``
1009
        * ``bto``
1010
        * ``cc``
1011
        * ``featured[].items``, ``featured[].orderedItems``
1012
        * ``object``
1013
        * ``object.actor``
1014
        * ``object.author``
1015
        * ``object.id``
1016
        * ``object.inReplyTo``
1017
        * ``object.object``
1018
        * ``attachments[].id``
1019
        * ``tags[objectType=mention].url``
1020
        * ``to``
1021

1022
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
1023
        same logic is duplicated there!
1024

1025
        TODO: unify with :meth:`Object.resolve_ids`,
1026
        :meth:`models.Object.normalize_ids`.
1027

1028
        Args:
1029
          to_proto (Protocol subclass)
1030
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
1031

1032
        Returns:
1033
          dict: translated AS1 version of ``obj``
1034
        """
1035
        from ui import UIProtocol
1✔
1036

1037
        assert to_cls != Protocol
1✔
1038
        if not obj:
1✔
1039
            return obj
1✔
1040

1041
        outer_obj = to_cls.translate_mention_handles(copy.deepcopy(obj))
1✔
1042
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
1043

1044
        def translate(elem, field, fn, uri=False):
1✔
1045
            owner_id = as1.get_owner(elem)
1✔
1046
            owner_proto = Protocol.for_id(owner_id)
1✔
1047

1048
            elem[field] = as1.get_objects(elem, field)
1✔
1049
            for obj in elem[field]:
1✔
1050
                if id := obj.get('id'):
1✔
1051
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
1052
                        continue
1✔
1053

1054
                    from_cls = Protocol.for_id(id)
1✔
1055
                    if field == 'id' and from_cls == UIProtocol and owner_proto:
1✔
1056
                        logger.info(f'owner of {id} {owner_id} is {owner_proto.LABEL}, translating id from that protocol')
1✔
1057
                        from_cls = owner_proto
1✔
1058

1059
                    # TODO: what if from_cls is None? relax translate_object_id,
1060
                    # make it a noop if we don't know enough about from/to?
1061
                    if from_cls and from_cls != to_cls:
1✔
1062
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
1063
                    if uri:
1✔
1064
                        obj['id'] = to_cls(id=obj['id']).id_uri() if obj['id'] else id
1✔
1065

1066
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
1067
                           for o in elem[field]]
1068

1069
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
1070
                elem[field] = elem[field][0]
1✔
1071

1072
        type = as1.object_type(outer_obj)
1✔
1073
        translate(outer_obj, 'id',
1✔
1074
                  ids.translate_user_id if type in as1.ACTOR_TYPES
1075
                  else ids.translate_object_id)
1076

1077
        for o in inner_objs:
1✔
1078
            if (as1.object_type(o) in as1.ACTOR_TYPES
1✔
1079
                    or as1.get_owner(outer_obj) == o.get('id')
1080
                    or type in ('follow', 'stop-following')):
1081
                fn = ids.translate_user_id
1✔
1082
            elif type == 'block':
1✔
1083
                # a block's object may be a user or an object, eg a blocklist
1084
                fn = ids.translate_id
1✔
1085
            else:
1086
                fn = ids.translate_object_id
1✔
1087
            translate(o, 'id', fn)
1✔
1088

1089
            verb = o.get('verb')
1✔
1090
            if verb == 'block':
1✔
1091
                fn = ids.translate_id
×
1092
            elif verb in as1.VERBS_WITH_ACTOR_OBJECT:
1✔
1093
                fn = ids.translate_user_id
1✔
1094
            else:
1095
                fn = ids.translate_object_id
1✔
1096
            translate(o, 'object', fn)
1✔
1097

1098
        for o in [outer_obj] + inner_objs:
1✔
1099
            translate(o, 'inReplyTo', ids.translate_object_id)
1✔
1100
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
1101
                translate(o, field, ids.translate_user_id)
1✔
1102
            for tag in as1.get_objects(o, 'tags'):
1✔
1103
                if tag.get('objectType') == 'mention':
1✔
1104
                    translate(tag, 'url', ids.translate_user_id, uri=True)
1✔
1105
            for att in as1.get_objects(o, 'attachments'):
1✔
1106
                translate(att, 'id', ids.translate_object_id)
1✔
1107
                url = att.get('url')
1✔
1108
                if url and not att.get('id'):
1✔
1109
                    if from_cls := Protocol.for_id(url):
1✔
1110
                        att['id'] = ids.translate_object_id(from_=from_cls, to=to_cls,
1✔
1111
                                                            id=url)
1112
            if feat := as1.get_object(o, 'featured'):
1✔
1113
                translate(feat, 'orderedItems', ids.translate_object_id)
1✔
1114
                translate(feat, 'items', ids.translate_object_id)
1✔
1115

1116
        outer_obj = util.trim_nulls(outer_obj)
1✔
1117

1118
        if objs := util.get_list(outer_obj ,'object'):
1✔
1119
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
1120
            if len(outer_obj['object']) == 1:
1✔
1121
                outer_obj['object'] = outer_obj['object'][0]
1✔
1122

1123
        return outer_obj
1✔
1124

1125
    @classmethod
1✔
1126
    def translate_mention_handles(cls, obj):
1✔
1127
        """Translates @-mentions in ``obj.content`` to this protocol's handles.
1128

1129
        Specifically, for each ``mention`` tag in the object's tags that has
1130
        ``startIndex`` and ``length``, replaces it in ``obj.content`` with that
1131
        user's translated handle in this protocol and updates the tag's location.
1132

1133
        Called by :meth:`Protocol.translate_ids`.
1134

1135
        If ``obj.content`` is HTML, does nothing.
1136

1137
        Args:
1138
          obj (dict): AS1 object
1139

1140
        Returns:
1141
          dict: modified AS1 object
1142
        """
1143
        if not obj:
1✔
1144
            return None
×
1145

1146
        obj = copy.deepcopy(obj)
1✔
1147
        obj['object'] = [cls.translate_mention_handles(o)
1✔
1148
                                for o in as1.get_objects(obj)]
1149
        if len(obj['object']) == 1:
1✔
1150
            obj['object'] = obj['object'][0]
1✔
1151

1152
        content = obj.get('content')
1✔
1153
        tags = obj.get('tags')
1✔
1154
        if not content or not tags or as1.is_content_html(obj):
1✔
1155
            return util.trim_nulls(obj)
1✔
1156

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

1159
        offset = 0
1✔
1160
        last_orig_end = 0
1✔
1161
        for tag in sorted(indexed, key=lambda t: t['startIndex']):
1✔
1162
            orig_start = tag['startIndex']
1✔
1163
            if orig_start < last_orig_end:
1✔
1164
                logger.warning(f'tags overlap! removing indices from {tag.get("url")}')
1✔
1165
                del tag['startIndex']
1✔
1166
                del tag['length']
1✔
1167
                continue
1✔
1168

1169
            orig_end = orig_start + tag['length']
1✔
1170
            last_orig_end = orig_end
1✔
1171
            tag['startIndex'] += offset
1✔
1172
            if tag.get('objectType') == 'mention' and (id := tag['url']):
1✔
1173
                if proto := Protocol.for_id(id):
1✔
1174
                    id = ids.normalize_user_id(id=id, proto=proto)
1✔
1175
                    if key := get_original_user_key(id):
1✔
1176
                        user = key.get()
×
1177
                    else:
1178
                        user = proto.get_or_create(id, allow_opt_out=True)
1✔
1179
                    if user:
1✔
1180
                        start = tag['startIndex']
1✔
1181
                        end = start + tag['length']
1✔
1182
                        if handle := user.handle_as(cls):
1✔
1183
                            content = content[:start] + handle + content[end:]
1✔
1184
                            offset += len(handle) - tag['length']
1✔
1185
                            tag.update({
1✔
1186
                                'displayName': handle,
1187
                                'length': len(handle),
1188
                            })
1189

1190
        obj['tags'] = tags
1✔
1191
        as2.set_content(obj, content)  # sets content *and* contentMap; obj is still AS1 here
1✔
1192
        return util.trim_nulls(obj)
1✔
1193

1194
    @classmethod
1✔
1195
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
1196
        """Handles an incoming activity.
1197

1198
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
1199
        unset, returns HTTP 299.
1200

1201
        Args:
1202
          obj (models.Object)
1203
          authed_as (str): authenticated actor id who sent this activity
1204
          internal (bool): whether to allow activity ids on internal domains,
1205
            from opted out/blocked users, etc.
1206
          received_at (datetime): when we first saw (received) this activity.
1207
            Right now only used for monitoring.
1208

1209
        Returns:
1210
          (str, int) tuple: (response body, HTTP status code) Flask response
1211

1212
        Raises:
1213
          werkzeug.HTTPException: if the request is invalid
1214
        """
1215
        # check some invariants
1216
        assert from_cls != Protocol
1✔
1217
        assert isinstance(obj, Object), obj
1✔
1218

1219
        if not obj.as1:
1✔
1220
            error('No object data provided')
1✔
1221

1222
        orig_obj = obj
1✔
1223
        id = None
1✔
1224
        if obj.key and obj.key.id():
1✔
1225
            id = obj.key.id()
1✔
1226

1227
        if not id:
1✔
1228
            id = obj.as1.get('id')
1✔
1229
            obj.key = ndb.Key(Object, id)
1✔
1230

1231
        if not id:
1✔
1232
            error('No id provided')
×
1233
        elif from_cls.owns_id(id) is False:
1✔
1234
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
1235
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
1236
            error(f'{id} is blocklisted')
1✔
1237

1238
        # does this protocol support this activity/object type?
1239
        from_cls.check_supported(obj, 'receive')
1✔
1240

1241
        # lease this object, atomically
1242
        memcache_key = activity_id_memcache_key(id)
1✔
1243
        leased = memcache.memcache.add(
1✔
1244
            memcache_key, 'leased', noreply=False,
1245
            expire=int(MEMCACHE_LEASE_EXPIRATION.total_seconds()))
1246

1247
        # short circuit if we've already seen this activity id
1248
        #
1249
        # * 'leased' (25s): if add failed and the value is 'leased', another task is
1250
        #   processing this same id right now, so skip.
1251
        #
1252
        # * 'done' (1w): set at the end of receive below. if it's 'done' here, or
1253
        #   we just have the obj in the datastore, skip unless content has changed
1254
        #   (changed is True). changed is often None here, eg a duplicate inbox
1255
        #   delivery, so we deliberately check `is not True`, not `is False`.
1256
        if 'force' not in request.values:
1✔
1257
            prior = None
1✔
1258
            if not leased and (prior := memcache.memcache.get(memcache_key)):
1✔
1259
                prior = prior.decode()
1✔
1260
                if prior == 'leased':
1✔
1261
                    error('Already in progress', status=204)
1✔
1262

1263
            if (prior == 'done' or obj.new is False) and obj.changed is not True:
1✔
1264
                error('Already seen', status=204)
1✔
1265

1266
        pruned = {k: v for k, v in obj.as1.items()
1✔
1267
                  if k not in ('contentMap', 'replies', 'signature')}
1268
        delay = ''
1✔
1269
        retry = request.headers.get('X-AppEngine-TaskRetryCount')
1✔
1270
        if (received_at and retry in (None, '0')
1✔
1271
                and obj.type not in ('delete', 'undo')):  # we delay deletes/undos
1272
            delay_s = int((util.now().replace(tzinfo=None)
1✔
1273
                           - received_at.replace(tzinfo=None)
1274
                           ).total_seconds())
1275
            delay = f'({delay_s} s behind)'
1✔
1276
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
1277

1278
        # check authorization
1279
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1280
        actor = as1.get_owner(obj.as1)
1✔
1281
        if not actor:
1✔
1282
            error('Activity missing actor or author')
1✔
1283

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

1289
        assert authed_as
1✔
1290
        assert isinstance(authed_as, str)
1✔
1291
        authed_as = ids.normalize_user_id(id=authed_as, proto=from_user_cls)
1✔
1292
        actor = ids.normalize_user_id(id=actor, proto=from_user_cls)
1✔
1293
        if actor != authed_as and not internal:
1✔
1294
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
1295
                         user=f'{id} authed_as {authed_as} owner {actor}')
1296
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
1297

1298
        # update copy ids to originals
1299
        obj.normalize_ids()
1✔
1300
        obj.resolve_ids()
1✔
1301

1302
        if (obj.type == 'follow'
1✔
1303
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
1304
            # follows of bot user; refresh user profile first
1305
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
1306
            from_user = from_user_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1307
            from_user.reload_profile()
1✔
1308
        else:
1309
            # load actor user
1310
            from_user = from_user_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1311

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

1315
        # apply protocol-specific filters
1316
        if 'force' not in request.values:
1✔
1317
            for filter in from_cls.RECEIVE_FILTERS:
1✔
1318
                if filter(obj, from_user):
1✔
1319
                    error(f'Activity {id} blocked by filter {filter.__name__}')
1✔
1320

1321
        # check if this is a profile object coming in via a user with use_instead
1322
        # set. if so, override the object's id to be the final user id (from_user's),
1323
        # after following use_instead.
1324
        if obj.type in as1.ACTOR_TYPES and from_user.key.id() != actor:
1✔
1325
            as1_id = obj.as1.get('id')
1✔
1326
            if ids.normalize_user_id(id=as1_id, proto=from_user) == actor:
1✔
1327
                logger.info(f'Overriding AS1 object id {as1_id} with Object id {from_user.profile_id()}')
1✔
1328
                obj.our_as1 = {**obj.as1, 'id': from_user.profile_id()}
1✔
1329

1330
        # if this is an object, ie not an activity, wrap it in a create or update
1331
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as,
1✔
1332
                                          from_user=from_user)
1333
        obj.add('users', from_user.key)
1✔
1334

1335
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1336
        inner_obj_id = inner_obj_as1.get('id')
1✔
1337
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1338
            if not inner_obj_id:
1✔
1339
                error(f'{obj.type} object has no id!')
1✔
1340

1341
        # check age. we support backdated posts, but if they're over 2w old, we
1342
        # don't deliver them
1343
        if obj.type == 'post':
1✔
1344
            if published := inner_obj_as1.get('published'):
1✔
1345
                try:
1✔
1346
                    published_dt = util.parse_iso8601(published)
1✔
1347
                    if not published_dt.tzinfo:
1✔
1348
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
1349
                    age = util.now() - published_dt
1✔
1350
                    if (age > CREATE_MAX_AGE
1✔
1351
                            and 'force' not in request.values
1352
                            and not util.domain_or_parent_in(
1353
                                from_user.key.id(), CREATE_MAX_AGE_EXEMPT_DOMAINS)):
1354
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
1355
                              status=204)
1356
                except ValueError:  # from parse_iso8601
×
1357
                    logger.debug(f"Couldn't parse published {published}")
×
1358

1359
        # write Object to datastore
1360
        if obj.type in STORE_AS1_TYPES:
1✔
1361
            obj.put()
1✔
1362

1363
        # store inner object
1364
        # TODO: unify with big obj.type conditional below. would have to merge
1365
        # this with the DM handling block lower down.
1366
        crud_obj = None
1✔
1367
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1368
            # normalize_ids may have converted the inner object id to a user id
1369
            # (eg Web profile URL to domain), so normalize back to the profile
1370
            # object id to find the right existing Object in the datastore
1371
            crud_obj_id = (ids.normalize_object_id(id=inner_obj_id, proto=from_cls)
1✔
1372
                           or inner_obj_id)
1373
            crud_obj = Object.get_or_create(crud_obj_id, our_as1=inner_obj_as1,
1✔
1374
                                            source_protocol=obj.source_protocol,
1375
                                            authed_as=actor, users=[from_user.key],
1376
                                            deleted=False)
1377

1378
        actor = as1.get_object(obj.as1, 'actor')
1✔
1379
        actor_id = actor.get('id')
1✔
1380

1381
        # handle activity!
1382
        if obj.type == 'stop-following':
1✔
1383
            # TODO: unify with handle_follow?
1384
            # TODO: handle multiple followees
1385
            if not actor_id or not inner_obj_id:
1✔
1386
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1387

1388
            # deactivate Follower
1389
            from_ = from_user_cls.key_for(actor_id)
1✔
1390
            if not (to_cls := Protocol.for_id(inner_obj_id)):
1✔
1391
                error(f"Can't determine protocol for {inner_obj_id} , giving up")
1✔
1392
            to = to_cls.key_for(inner_obj_id)
1✔
1393
            follower = Follower.query(Follower.to == to,
1✔
1394
                                      Follower.from_ == from_,
1395
                                      Follower.status == 'active').get()
1396
            if follower:
1✔
1397
                follow_id = obj.as1.get('followId')
1✔
1398
                if (follow_id and follower.follow
1✔
1399
                        and follower.follow.id() != follow_id):
1400
                    logger.info(f"Ignoring stop-following: its follow id {follow_id} doesn't match current {follower.follow.id()}")
1✔
1401
                    return 'OK', 204
1✔
1402
                logger.info(f'Marking {follower} inactive')
1✔
1403
                follower.status = 'inactive'
1✔
1404
                follower.put()
1✔
1405
            else:
1406
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1407

1408
            # fall through to deliver to followee
1409
            # TODO: do we convert stop-following to webmention 410 of original
1410
            # follow?
1411

1412
            # fall through to deliver to followers
1413

1414
        elif obj.type in ('delete', 'undo'):
1✔
1415
            delete_obj_id = (from_user.profile_id()
1✔
1416
                            if inner_obj_id == from_user.key.id()
1417
                            else inner_obj_id)
1418

1419
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1420
            if not delete_obj:
1✔
1421
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1422
                return 'OK', 204
1✔
1423

1424
            # TODO: just delete altogether!
1425
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1426
            delete_obj.deleted = True
1✔
1427
            delete_obj.put()
1✔
1428

1429
            # if this is an actor, handle deleting it later so that
1430
            # in case it's from_user, user.enabled_protocols is still populated
1431
            #
1432
            # fall through to deliver to followers and delete copy if necessary.
1433
            # should happen via protocol-specific copy target and send of
1434
            # delete activity.
1435
            # https://github.com/snarfed/bridgy-fed/issues/63
1436

1437
        elif obj.type == 'block':
1✔
1438
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1439
                # blocking protocol bot user disables that protocol
1440
                from_user.delete(proto)
1✔
1441
                from_user.disable_protocol(proto)
1✔
1442
                return 'OK', 200
1✔
1443

1444
        elif obj.type == 'move':
1✔
1445
            from_cls.handle_move(obj, from_user=from_user)
1✔
1446
            # fall through to deliver the Move activity to remaining followers
1447

1448
        elif obj.type == 'post':
1✔
1449
            # handle DMs to bot users
1450
            if as1.is_dm(obj.as1):
1✔
1451
                return dms.receive(from_user=from_user, obj=obj)
1✔
1452

1453
        # fetch actor if necessary
1454
        is_user = from_user.is_profile(orig_obj)
1✔
1455
        if (actor and actor.keys() == set(['id'])
1✔
1456
                and not is_user and obj.type not in ('delete', 'undo')):
1457
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1458
            actor_obj = from_user_cls.load(
1✔
1459
                ids.profile_id(id=actor['id'], proto=from_cls), raise_=False)
1460
            if actor_obj and actor_obj.as1:
1✔
1461
                obj.our_as1 = {
1✔
1462
                    **obj.as1, 'actor': {
1463
                        **actor_obj.as1,
1464
                        # override profile id with actor id
1465
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1466
                        'id': actor['id'],
1467
                    }
1468
                }
1469

1470
        # fetch object if necessary
1471
        if (obj.type in ('post', 'update', 'share')
1✔
1472
                and inner_obj_as1.keys() == set(['id'])
1473
                and from_cls.owns_id(inner_obj_id) is not False):
1474
            logger.debug('Fetching inner object')
1✔
1475
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1476
                                      remote=(obj.type in ('post', 'update')))
1477
            if obj.type in ('post', 'update'):
1✔
1478
                crud_obj = inner_obj
1✔
1479
            if inner_obj and inner_obj.as1:
1✔
1480
                obj.our_as1 = {
1✔
1481
                    **obj.as1,
1482
                    'object': {
1483
                        **inner_obj_as1,
1484
                        **inner_obj.as1,
1485
                    }
1486
                }
1487

1488
        # creates and updates need their inner object, whether we fetched it above
1489
        # or already had it
1490
        if obj.type in ('post', 'update') and not (crud_obj and crud_obj.as1):
1✔
1491
            error(f"Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1492

1493
        if obj.type == 'follow':
1✔
1494
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1495
                # follow of one of our protocol bot users; enable that protocol.
1496
                # fall through so that we send an accept.
1497
                try:
1✔
1498
                    from_user.enable_protocol(proto)
1✔
1499
                except ErrorButDoNotRetryTask:
1✔
1500
                    from web import Web
1✔
1501
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1502
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1503
                                               followee=bot, follow=obj)
1504
                    raise
1✔
1505
                proto.bot_maybe_follow_back(from_user)
1✔
1506
                from_cls.handle_follow(obj, from_user=from_user)
1✔
1507
                return 'OK', 202
1✔
1508

1509
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1510

1511
        # on update of the user's own actor/profile, set user.obj and store user back
1512
        # to datastore so that we recalculate computed properties like status etc
1513
        if is_user:
1✔
1514
            if obj.type == 'update' and crud_obj:
1✔
1515
                logger.info(f"update of the user's profile, re-storing user with obj_key {crud_obj.key.id()}")
1✔
1516
                from_user.obj = crud_obj
1✔
1517
                from_user.put()
1✔
1518

1519
        # deliver to targets
1520
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1521

1522
        # on user deleting themselves, deactivate their followers/followings.
1523
        # https://github.com/snarfed/bridgy-fed/issues/1304
1524
        #
1525
        # do this *after* delivering because delivery finds targets based on
1526
        # stored Followers
1527
        if is_user and obj.type == 'delete':
1✔
1528
            for proto in from_user.enabled_protocols:
1✔
1529
                from_user.disable_protocol(PROTOCOLS[proto])
1✔
1530

1531
            logger.info(f'Deactivating Followers from or to {from_user.key.id()}')
1✔
1532
            followers = Follower.query(
1✔
1533
                OR(Follower.to == from_user.key, Follower.from_ == from_user.key)
1534
            ).fetch()
1535
            for f in followers:
1✔
1536
                f.status = 'inactive'
1✔
1537
            ndb.put_multi(followers)
1✔
1538

1539
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1540
        return resp
1✔
1541

1542
    @classmethod
1✔
1543
    def handle_follow(from_cls, obj, from_user):
1✔
1544
        """Handles an incoming follow activity.
1545

1546
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1547
        happens in :meth:`deliver`.
1548

1549
        Args:
1550
          obj (models.Object): follow activity
1551
        """
1552
        logger.debug('Got follow. storing Follow(s), sending accept(s)')
1✔
1553
        from_id = from_user.key.id()
1✔
1554

1555
        # Prepare followee (to) users' data
1556
        to_as1s = as1.get_objects(obj.as1)
1✔
1557
        if not to_as1s:
1✔
1558
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1559

1560
        # Store Followers
1561
        for to_as1 in to_as1s:
1✔
1562
            to_id = to_as1.get('id')
1✔
1563
            if not to_id:
1✔
1564
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1565

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

1568
            to_cls = Protocol.for_id(to_id)
1✔
1569
            if not to_cls:
1✔
1570
                error(f"Couldn't determine protocol for {to_id}")
×
1571
            elif from_cls == to_cls:
1✔
1572
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1573
                continue
1✔
1574

1575
            to_key = to_cls.key_for(to_id)
1✔
1576
            if not to_key:
1✔
1577
                logger.info(f'Skipping invalid {to_cls.LABEL} user key: {to_id}')
×
1578
                continue
×
1579

1580
            to_user = to_cls.get_or_create(id=to_key.id())
1✔
1581
            if not to_user or not to_user.is_enabled(from_cls):
1✔
1582
                error(f'{to_id} not found')
1✔
1583

1584
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1585
                                                  follow=obj.key, status='active')
1586
            if (from_cls.USES_OBJECT_FEED
1✔
1587
                    and from_cls.LABEL not in to_user.has_object_feed_followers_on):
1588
                to_user.has_object_feed_followers_on.append(from_cls.LABEL)
1✔
1589
                to_user.put()
1✔
1590

1591
            obj.add('notify', to_key)
1✔
1592
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1593
                                       followee=to_user, follow=obj)
1594

1595
    @classmethod
1✔
1596
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1597
        """Sends an accept or reject activity for a follow.
1598

1599
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1600
        nothing.
1601

1602
        Args:
1603
          verb (str): ``accept`` or  ``reject``
1604
          follower (models.User)
1605
          followee (models.User)
1606
          follow (models.Object)
1607
        """
1608
        assert verb in ('accept', 'reject')
1✔
1609
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1610
            return
1✔
1611

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

1615
        # send. note that this is one response for the whole follow, even if it
1616
        # has multiple followees!
1617
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1618
        accept = {
1✔
1619
            'id': id,
1620
            'objectType': 'activity',
1621
            'verb': verb,
1622
            'actor': followee.key.id(),
1623
            'object': follow.as1,
1624
        }
1625
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1626
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1627

1628
    @classmethod
1✔
1629
    def bot_maybe_follow_back(bot_cls, user):
1✔
1630
        """Follow a user from a protocol bot user, if their protocol needs that.
1631

1632
        ...so that the protocol starts sending us their activities, if it needs
1633
        a follow for that (eg ActivityPub).
1634

1635
        Args:
1636
          user (User)
1637
        """
1638
        if not user.BOTS_FOLLOW_BACK:
1✔
1639
            return
1✔
1640

1641
        from web import Web
1✔
1642
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1643
        now = util.now().isoformat()
1✔
1644
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1645

1646
        if not user.obj:
1✔
1647
            logger.info("  can't follow, user has no profile obj")
1✔
1648
            return
1✔
1649

1650
        target = user.target_for(user.obj)
1✔
1651
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1652
        follow_back_as1 = {
1✔
1653
            'objectType': 'activity',
1654
            'verb': 'follow',
1655
            'id': follow_back_id,
1656
            'actor': bot.key.id(),
1657
            'object': user.key.id(),
1658
        }
1659
        common.create_task(queue='send', id=follow_back_id,
1✔
1660
                           our_as1=follow_back_as1, url=target,
1661
                           source_protocol='web', protocol=user.LABEL,
1662
                           user=bot.key.urlsafe())
1663

1664
    @classmethod
1✔
1665
    def handle_move(from_cls, obj, from_user):
1✔
1666
        """Handles an incoming move (account migration) activity.
1667

1668
        Updates all of the account's :class:`Follower`s to point to the new id.
1669

1670
        Args:
1671
          obj (models.Object): follow activity
1672
          from_user (models.User): user (actor) this activity/object is from
1673
        """
1674
        if not (target_id := as1.get_id(obj.as1, 'target')):
1✔
1675
            error(f'Move activity requires target. Got: {obj.as1}')
1✔
1676

1677
        logger.info(f'Got move activity from {from_user.key.id()} to {target_id}')
1✔
1678

1679
        # check that object is the actor (the account being moved)
1680
        actor_id = as1.get_id(obj.as1, 'actor')
1✔
1681
        object_id = as1.get_id(obj.as1, 'object')
1✔
1682
        if actor_id != object_id:
1✔
1683
            error(f"Move activity object {object_id} isn't actor {actor_id}")
1✔
1684

1685
        # get the target protocol and key
1686
        to_cls = Protocol.for_id(target_id)
1✔
1687
        if not to_cls:
1✔
1688
            error(f"Couldn't determine protocol for target {target_id}")
×
1689

1690
        to_user = to_cls.get_or_create(
1✔
1691
            target_id, manual_opt_out=False, allow_opt_out=True,
1692
            enabled_protocols=from_user.enabled_protocols)
1693
        if not to_user:
1✔
1694
            error(f"Couldn't create {to_cls.LABEL} user {target_id}", status=299)
×
1695

1696
        if from_user.enabled_protocols:
1✔
1697
            # from user has bridged copy accounts; transfer them to the new user
1698
            for label in from_user.enabled_protocols:
1✔
1699
                proto = PROTOCOLS[label]
1✔
1700
                if copy_id := from_user.get_copy(proto):
1✔
1701
                    logger.info(f'Transferring {label} copy id {copy_id} to {target_id}')
1✔
1702
                    from_user.remove_copies_on(proto)
1✔
1703
                    to_user.add('copies', Target(uri=copy_id, protocol=label))
1✔
1704

1705
            to_user.put()
1✔
1706
            from_user.enabled_protocols = []
1✔
1707
            from_user.put()
1✔
1708

1709
        for proto in set(p for p in PROTOCOLS.values() if p):
1✔
1710
            if to_user.is_enabled(proto) and not isinstance(to_user, proto):
1✔
1711
                # follow the new account from the protocol bots that need to
1712
                proto.bot_maybe_follow_back(to_user)
1✔
1713

1714
                # update its bridged handle, but only if it's still the default
1715
                # handle we generated for the old account, ie not custom
1716
                old_default = ids.translate_handle(
1✔
1717
                    from_=from_user.__class__, to=proto, handle=from_user.handle)
1718
                new_default = ids.translate_handle(
1✔
1719
                    from_=to_user.__class__, to=proto, handle=to_user.handle)
1720
                if (old_default and new_default
1✔
1721
                        and to_user.handle_as(proto) == old_default):
1722
                    try:
1✔
1723
                        proto.set_username(to_user, new_default)
1✔
1724
                    except NotImplementedError:
1✔
1725
                        pass
1✔
1726
                    except Exception as e:
1✔
1727
                        util.interpret_http_exception(e)
1✔
1728
                        logger.warning(f"Couldn't update {to_user.key.id()}'s bridged {proto.LABEL} handle to {new_default}", exc_info=True)
1✔
1729

1730
        # query for all active followers of the source account
1731
        followers = Follower.query(
1✔
1732
            Follower.to == from_user.key,
1733
            Follower.status == 'active'
1734
        ).fetch()
1735

1736
        # update each follower to point to the new account
1737
        # but skip if it would create a same-protocol follower
1738
        logger.info(f'Updating {len(followers)} followers from {actor_id} to {target_id}')
1✔
1739
        updated_followers = []
1✔
1740
        for follower in followers:
1✔
1741
            # check if this would create a same-protocol follower
1742
            if follower.from_.kind() != to_user.key.kind():
1✔
1743
                follower.to = to_user.key
1✔
1744
                updated_followers.append(follower)
1✔
1745
            else:
1746
                logger.info(f'Skipping same-protocol follower {follower.from_.id()} => {to_user.key.id()}')
1✔
1747

1748
        if updated_followers:
1✔
1749
            ndb.put_multi(updated_followers)
1✔
1750

1751
    @classmethod
1✔
1752
    def handle_bare_object(cls, obj, *, authed_as, from_user):
1✔
1753
        """If obj is a bare object, wraps it in a create or update activity.
1754

1755
        Checks if we've seen it before.
1756

1757
        Args:
1758
          obj (models.Object)
1759
          authed_as (str): authenticated actor id who sent this activity
1760
          from_user (models.User): user (actor) this activity/object is from
1761

1762
        Returns:
1763
          models.Object: ``obj`` if it's an activity, otherwise a new object
1764
        """
1765
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1766
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1767
            return obj
1✔
1768

1769
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1770
        now = util.now().isoformat()
1✔
1771

1772
        # this is a raw post; wrap it in a create or update activity
1773
        if obj.changed or is_actor:
1✔
1774
            if obj.changed:
1✔
1775
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1776
            else:
1777
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1778
            id = obj.key.id()
1✔
1779
            if '#bridgy-fed-' not in id:
1✔
1780
                id = f'{id}#bridgy-fed-update-{now}'
1✔
1781
            update_as1 = {
1✔
1782
                'objectType': 'activity',
1783
                'verb': 'update',
1784
                'id': id,
1785
                'actor': obj_actor,
1786
                'object': {
1787
                    # Mastodon requires the updated field for Updates, so
1788
                    # add a default value.
1789
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1790
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1791
                    # https://github.com/mastodon/documentation/pull/1150
1792
                    'updated': now,
1793
                    **obj.as1,
1794
                },
1795
            }
1796
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1797
            return Object(id=id, our_as1=update_as1,
1✔
1798
                          source_protocol=obj.source_protocol)
1799

1800
        if obj.new or 'force' in request.values:
1✔
1801
            create_id = f'{obj.key.id()}#bridgy-fed-create-{now}'
1✔
1802
            create_as1 = {
1✔
1803
                'objectType': 'activity',
1804
                'verb': 'post',
1805
                'id': create_id,
1806
                'actor': obj_actor,
1807
                'object': obj.as1,
1808
                'published': now,
1809
            }
1810
            logger.info(f'Wrapping in post')
1✔
1811
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1812
            return Object(id=create_id, our_as1=create_as1,
1✔
1813
                          source_protocol=obj.source_protocol)
1814

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

1817
    @classmethod
1✔
1818
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1819
        """Delivers an activity to its external recipients.
1820

1821
        Args:
1822
          obj (models.Object): activity to deliver
1823
          from_user (models.User): user (actor) this activity is from
1824
          crud_obj (models.Object): if this is a create, update, or delete/undo
1825
            activity, the inner object that's being written, otherwise None.
1826
            (This object's ``notify`` and ``feed`` properties may be updated.)
1827
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1828
            targets on this protocol
1829

1830
        Returns:
1831
          (str, int) tuple: Flask response
1832
        """
1833
        if to_proto:
1✔
1834
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1835

1836
        # find delivery targets. maps Target to Object or None
1837
        #
1838
        # ...then write the relevant object, since targets() has a side effect of
1839
        # setting the notify and feed properties (and dirty attribute)
1840
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1841
        if to_proto:
1✔
1842
            targets = {t: obj for t, obj in targets.items()
1✔
1843
                       if t.protocol == to_proto.LABEL}
1844
        if not targets:
1✔
1845
            # don't raise via error() because we call deliver in code paths where
1846
            # we want to continue after
1847
            msg = r'No targets, nothing to do ¯\_(ツ)_/¯'
1✔
1848
            logger.info(msg)
1✔
1849
            return msg, 204
1✔
1850

1851
        # store object that targets() updated
1852
        if crud_obj and crud_obj.dirty:
1✔
1853
            crud_obj.put()
1✔
1854
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1855
            obj.put()
1✔
1856

1857
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1858
                      else obj.to_request())
1859

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

1863
        # enqueue send task for each targets
1864
        logger.info(f'Delivering to {" ".join(t.uri for t, _ in sorted_targets)}')
1✔
1865
        user = from_user.key.urlsafe()
1✔
1866
        # maps protocol label to whether we've sent to one of its targets yet
1867
        first_per_protocol = {}
1✔
1868
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1869
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1870
            first = target.protocol not in first_per_protocol
1✔
1871
            first_per_protocol[target.protocol] = True
1✔
1872
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1873
                               orig_obj_id=orig_obj_id, user=user, first=first,
1874
                               **obj_params)
1875

1876
        return 'OK', 202
1✔
1877

1878
    @classmethod
1✔
1879
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1880
        """Collects the targets to send a :class:`models.Object` to.
1881

1882
        Targets are both objects - original posts, events, etc - and actors.
1883

1884
        Args:
1885
          obj (models.Object)
1886
          from_user (User)
1887
          crud_obj (models.Object): if this is a create, update, or delete/undo
1888
            activity, the inner object that's being written, otherwise None.
1889
            (This object's ``notify`` and ``feed`` properties may be updated.)
1890
          internal (bool): whether this is a recursive internal call
1891

1892
        Returns:
1893
          dict: maps :class:`models.Target` to original (in response to)
1894
          :class:`models.Object`
1895
        """
1896
        logger.debug('Finding recipients and their targets')
1✔
1897

1898
        # we should only have crud_obj iff this is a create or update
1899
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1900
        write_obj = crud_obj or obj
1✔
1901
        write_obj.dirty = False
1✔
1902

1903
        target_uris = as1.targets(obj.as1)
1✔
1904
        orig_obj = None
1✔
1905
        targets = {}  # maps Target (with *normalized* uri) to Object or None
1✔
1906
        owner = as1.get_owner(obj.as1)
1✔
1907
        allow_opt_out = (obj.type == 'delete')
1✔
1908
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1909
        inner_obj_id = inner_obj_as1.get('id')
1✔
1910
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1911
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1912
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1913
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1914
        is_self_reply = False
1✔
1915

1916
        original_ids = []
1✔
1917
        if is_reply:
1✔
1918
            original_ids = in_reply_tos
1✔
1919
        elif inner_obj_id:
1✔
1920
            if inner_obj_id == from_user.key.id():
1✔
1921
                inner_obj_id = from_user.profile_id()
1✔
1922
            original_ids = [inner_obj_id]
1✔
1923

1924
        # maps id to Object
1925
        original_objs = {}
1✔
1926
        for id in original_ids:
1✔
1927
            if proto := Protocol.for_id(id):
1✔
1928
                original_objs[id] = proto.load(id, raise_=False)
1✔
1929

1930
        # for AP, add in-reply-tos' mentions
1931
        # https://github.com/snarfed/bridgy-fed/issues/1608
1932
        # https://github.com/snarfed/bridgy-fed/issues/1218
1933
        orig_post_mentions = {}  # maps mentioned id to original post Object
1✔
1934
        for id in in_reply_tos:
1✔
1935
            if ((in_reply_to_obj := original_objs.get(id))
1✔
1936
                    and (proto := PROTOCOLS.get(in_reply_to_obj.source_protocol))
1937
                    and proto.SEND_REPLIES_TO_ORIG_POSTS_MENTIONS
1938
                    and (mentions := as1.mentions(in_reply_to_obj.as1))):
1939
                logger.info(f"Adding in-reply-to {id} 's mentions to targets: {mentions}")
1✔
1940
                target_uris.extend(mentions)
1✔
1941
                for mention in mentions:
1✔
1942
                    orig_post_mentions[mention] = in_reply_to_obj
1✔
1943

1944
        target_uris = sorted(set(target_uris))
1✔
1945
        logger.info(f'Raw targets: {target_uris}')
1✔
1946

1947
        # which protocols should we allow delivering to?
1948
        to_protocols = []  # elements are Protocol subclasses
1✔
1949
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1950
                      + from_user.enabled_protocols):
1951
            if not (proto := PROTOCOLS.get(label)):
1✔
1952
                report_error(f'unknown enabled protocol {label} for {from_user.key.id()}')
1✔
1953
                continue
1✔
1954

1955
            if obj.type == 'post' and crud_obj.key and crud_obj.get_copy(proto):
1✔
1956
                logger.info(f'Already created {crud_obj.key.id()} on {label}, cowardly refusing to create there again')
1✔
1957
                continue
1✔
1958

1959
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1960
                                     or is_reply):
1961
                origs_could_bridge = None
1✔
1962

1963
                for id in original_ids:
1✔
1964
                    if not (orig := original_objs.get(id)):
1✔
1965
                        continue
1✔
1966
                    elif orig.get_copy(proto):
1✔
1967
                        logger.info(f'Allowing {label}, original {id} was bridged there')
1✔
1968
                        break
1✔
1969
                    elif from_user.is_profile(orig):
1✔
1970
                        logger.info(f"Allowing {label}, this is the user's profile")
1✔
1971
                        break
1✔
1972

1973
                    if (origs_could_bridge is not False
1✔
1974
                            and (orig_author_id := as1.get_owner(orig.as1))
1975
                            and (orig_proto := orig.owner_protocol())
1976
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1977
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1978

1979
                else:
1980
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1981
                    last_retry = False
1✔
1982
                    if retries := request.headers.get(TASK_RETRIES_HEADER):
1✔
1983
                        if (last_retry := int(retries) >= TASK_RETRIES_RECEIVE):
1✔
1984
                            logger.info(f'last retry! skipping {proto.LABEL} and continuing')
1✔
1985

1986
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
1✔
1987
                            and origs_could_bridge and not last_retry):
1988
                        # retry later; original obj may still be bridging
1989
                        # TODO: limit to brief window, eg no older than 2h? 1d?
1990
                        error(msg, status=304)
1✔
1991

1992
                    logger.info(msg)
1✔
1993
                    continue
1✔
1994

1995
            util.add(to_protocols, proto)
1✔
1996

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

1999
        # process direct targets
2000
        for target_id in target_uris:
1✔
2001
            target_proto = Protocol.for_id(target_id)
1✔
2002
            if not target_proto:
1✔
2003
                logger.info(f"Can't determine protocol for {target_id}")
1✔
2004
                continue
1✔
2005
            elif target_proto.is_blocklisted(target_id):
1✔
2006
                logger.debug(f'{target_id} is blocklisted')
1✔
2007
                continue
1✔
2008

2009
            target_is_actor = (target_id in mentioned_urls
1✔
2010
                               or obj.type in as1.VERBS_WITH_ACTOR_OBJECT)
2011

2012
            target_obj_id = (ids.profile_id(id=target_id, proto=target_proto)
1✔
2013
                             if target_is_actor
2014
                             # not ideal. this can sometimes be a non-user, eg
2015
                             # blocking a blocklist. ok right now since profile_id()
2016
                             # returns its input id unchanged if it doesn't look like
2017
                             # a user id, but that's brittle.
2018
                             else target_id)
2019
            orig_obj = target_proto.load(target_obj_id, raise_=False)
1✔
2020
            if not orig_obj or not orig_obj.as1:
1✔
2021
                logger.info(f"Couldn't load {target_obj_id}")
1✔
2022
                continue
1✔
2023

2024
            target_author_key = (target_proto(id=target_id).key if target_is_actor
1✔
2025
                                 else target_proto.actor_key(orig_obj))
2026

2027
            if not from_user.is_enabled(target_proto):
1✔
2028
                # if author isn't bridged and target user is, DM a prompt and
2029
                # add a notif for the target user
2030
                if (target_id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
2031
                        and target_author_key):
2032
                    if target_author := target_author_key.get():
1✔
2033
                        if target_author.is_enabled(from_cls):
1✔
2034
                            notifications.add_notification(target_author, write_obj)
1✔
2035
                            verb, noun = (
1✔
2036
                                ('replied to', 'replies') if target_id in in_reply_tos
2037
                                else ('quoted', 'quotes') if target_id in quoted_posts
2038
                                else ('mentioned', 'mentions'))
2039
                            dms.maybe_send(from_=target_proto, to_user=from_user,
1✔
2040
                                           type='replied_to_bridged_user', text=f"""\
2041
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}. To make sure they 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.""")
2042

2043
                continue
1✔
2044

2045
            # deliver self-replies to followers
2046
            # https://github.com/snarfed/bridgy-fed/issues/639
2047
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
2048
                is_self_reply = True
1✔
2049
                logger.info(f'self reply!')
1✔
2050

2051
            # also add copies' targets
2052
            for copy in orig_obj.copies:
1✔
2053
                proto = PROTOCOLS[copy.protocol]
1✔
2054
                if proto in to_protocols:
1✔
2055
                    # copies generally won't have their own Objects
2056
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
2057
                        target = util.normalize_url(target, trailing_slash=False)
1✔
2058
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {target_id}')
1✔
2059
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
2060

2061
            if target_proto == from_cls:
1✔
2062
                logger.debug(f'Skipping same-protocol target {target_id}')
1✔
2063
                continue
1✔
2064

2065
            target = target_proto.target_for(orig_obj)
1✔
2066
            if not target:
1✔
2067
                # TODO: surface errors like this somehow?
UNCOV
2068
                logger.error(f"Can't find delivery target for {target_id}")
×
UNCOV
2069
                continue
×
2070

2071
            target = util.normalize_url(target, trailing_slash=False)
1✔
2072
            logger.debug(f'Target for {target_id} is {target} {target_author_key}')
1✔
2073

2074
            # only use orig_obj for inReplyTos, like/repost objects, reply's original
2075
            # post's mentions, etc
2076
            # https://github.com/snarfed/bridgy-fed/issues/1237
2077
            target_obj = None
1✔
2078
            if target_id in in_reply_tos + as1.get_ids(obj.as1, 'object'):
1✔
2079
                target_obj = orig_obj
1✔
2080
            elif target_id in orig_post_mentions:
1✔
2081
                target_obj = orig_post_mentions[target_id]
1✔
2082
            targets[Target(protocol=target_proto.LABEL, uri=target)] = target_obj
1✔
2083

2084
            if target_author_key:
1✔
2085
                logger.debug(f'Recipient is {target_author_key}')
1✔
2086
                if obj.type not in DONT_NOTIFY_TYPES:
1✔
2087
                    if write_obj.add('notify', target_author_key):
1✔
2088
                        write_obj.dirty = True
1✔
2089

2090
        if obj.type == 'undo':
1✔
2091
            logger.info('Object is an undo; adding targets for inner object')
1✔
2092
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
2093
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
2094
            else:
2095
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
2096
            if inner_obj:
1✔
2097
                for target, target_obj in from_cls.targets(
1✔
2098
                        inner_obj, from_user=from_user, internal=True).items():
2099
                    targets[target] = target_obj
1✔
2100
                    util.add(to_protocols, PROTOCOLS[target.protocol])
1✔
2101

2102
        if not to_protocols:
1✔
2103
            return {}
1✔
2104

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

2107
        # deliver to followers, if appropriate
2108
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
2109
        if not user_key:
1✔
2110
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
2111
            return targets
1✔
2112

2113
        # we deliver to HAS_COPIES protocols separately, below. we assume they have
2114
        # follower-independent targets.
2115
        to_followers_protos = [
1✔
2116
            p for p in to_protocols
2117
            if not (p.HAS_COPIES and p.DEFAULT_TARGET)
2118
            and not (p.USES_OBJECT_FEED and p.LABEL not in from_user.has_object_feed_followers_on)]
2119
        followers = []
1✔
2120
        is_undo_block = obj.type == 'undo' and inner_obj_as1.get('verb') == 'block'
1✔
2121
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
2122
                and (not is_reply or is_self_reply) and not is_undo_block
2123
                and to_followers_protos):
2124
            logger.info(f'Delivering to followers of {user_key.id()} on {[p.LABEL for p in to_followers_protos]}')
1✔
2125
            # query each protocol individually
2126
            for proto in to_followers_protos:
1✔
2127
                kind = proto._get_kind()
1✔
2128
                for f in Follower.query(
1✔
2129
                        Follower.to == user_key,
2130
                        Follower.status == 'active',
2131
                        Follower.from_ >= ndb.Key(kind, '\x00'),
2132
                        Follower.from_ < ndb.Key(kind + '\x00', '\x00')):
2133
                    # skip protocol bot users
2134
                    if not Protocol.for_bridgy_subdomain(f.from_.id()):
1✔
2135
                        followers.append(f)
1✔
2136

2137
            logger.debug(f'  loaded {len(followers)} followers')
1✔
2138

2139
            user_keys = [f.from_ for f in followers]
1✔
2140
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
2141
            logger.debug(f'  loaded {len(users)} users')
1✔
2142

2143
            User.load_multi(users)
1✔
2144
            logger.debug(f'  loaded user objects')
1✔
2145

2146
            if (not followers and
1✔
2147
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
2148
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
2149
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
2150
                return {}
1✔
2151

2152
            # add to followers' feeds, if any
2153
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
2154
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
2155
                    write_obj.feed = [
1✔
2156
                        u.key for u in users
2157
                        if u.USES_OBJECT_FEED or u.key.id() in common.BETA_USER_IDS
2158
                    ]
2159
                    if write_obj.feed:
1✔
2160
                        write_obj.dirty = True
1✔
2161

2162
            # collect targets for followers
2163
            target_obj = (original_objs.get(inner_obj_id)
1✔
2164
                          if obj.type == 'share' else None)
2165
            for user in users:
1✔
2166
                if user.is_blocking(from_user):
1✔
2167
                    logger.debug(f'  {user.key.id()} blocks {from_user.key.id()}')
1✔
2168
                    continue
1✔
2169

2170
                # TODO: should we pass remote=False through here to Protocol.load?
2171
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
2172
                if not target:
1✔
2173
                    continue
1✔
2174

2175
                target = util.normalize_url(target, trailing_slash=False)
1✔
2176
                targets[Target(protocol=user.LABEL, uri=target)] = target_obj
1✔
2177

2178
            logger.debug(f'  collected {len(targets)} targets')
1✔
2179

2180
        # deliver to enabled HAS_COPIES protocols proactively
2181
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
2182
            for proto in to_protocols:
1✔
2183
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
2184
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
2185
                    targets.setdefault(
1✔
2186
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
2187

2188
        # maps string target URL to (Target, Object) tuple
2189
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
2190
        # maps Target to Object or None
2191
        targets = {}
1✔
2192
        source_domains = [
1✔
2193
            util.domain_from_link(url) for url in
2194
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
2195
            if util.is_web(url)
2196
        ]
2197
        for url in sorted(util.dedupe_urls(
1✔
2198
                candidates.keys(),
2199
                # preserve our PDS URL without trailing slash in path
2200
                # https://atproto.com/specs/did#did-documents
2201
                trailing_slash=False)):
2202
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
UNCOV
2203
                logger.info(f'Skipping same-domain target {url}')
×
UNCOV
2204
                continue
×
2205
            elif from_user.is_blocking(url):
1✔
2206
                logger.debug(f'{from_user.key.id()} blocks {url}')
1✔
2207
                continue
1✔
2208

2209
            target, obj = candidates[url]
1✔
2210
            targets[target] = obj
1✔
2211

2212
        return targets
1✔
2213

2214
    @classmethod
1✔
2215
    def load(cls, id, remote=None, local=True, raise_=True, raw=False, csv=False,
1✔
2216
             **kwargs):
2217
        """Loads and returns an Object from datastore or HTTP fetch.
2218

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

2222
        Args:
2223
          id (str)
2224
          remote (bool): whether to fetch the object over the network. If True,
2225
            fetches even if we already have the object stored, and updates our
2226
            stored copy. If False and we don't have the object stored, returns
2227
            None. Default (None) means to fetch over the network only if we
2228
            don't already have it stored.
2229
          local (bool): whether to load from the datastore before
2230
            fetching over the network. If False, still stores back to the
2231
            datastore after a successful remote fetch.
2232
          raise_ (bool): if False, catches any :class:`request.RequestException`
2233
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
2234
            ``None`` instead
2235
          raw (bool): whether to load this as a "raw" id, as is, without
2236
            normalizing to an on-protocol object id. Exact meaning varies by subclass.
2237
          csv (bool): whether to specifically load a CSV object
2238
            TODO: merge this into raw, using returned Content-Type?
2239
          kwargs: passed through to :meth:`fetch()`
2240

2241
        Returns:
2242
          models.Object: loaded object, or None if it isn't fetchable, eg a
2243
          non-URL string for Web, or ``remote`` is False and it isn't in the
2244
          datastore
2245

2246
        Raises:
2247
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
2248
            is True
2249
        """
2250
        assert id
1✔
2251
        assert local or remote is not False
1✔
2252
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
2253

2254
        if not raw:
1✔
2255
            id = ids.normalize_object_id(id=id, proto=cls)
1✔
2256

2257
        obj = orig_as1 = None
1✔
2258
        if local:
1✔
2259
            if obj := Object.get_by_id(id):
1✔
2260
                if csv and not obj.is_csv:
1✔
2261
                    return None
1✔
2262
                elif obj.as1 or obj.csv or obj.raw or obj.deleted:
1✔
2263
                    # logger.debug(f'  {id} got from datastore')
2264
                    obj.new = False
1✔
2265

2266
        if remote is False:
1✔
2267
            return obj
1✔
2268
        elif remote is None and obj:
1✔
2269
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
2270
                # logger.debug(f'  last updated {obj.updated}, refreshing')
2271
                pass
1✔
2272
            else:
2273
                return obj
1✔
2274

2275
        if obj:
1✔
2276
            orig_as1 = obj.as1
1✔
2277
            obj.our_as1 = None
1✔
2278
            obj.new = False
1✔
2279
        else:
2280
            if cls == Protocol:
1✔
2281
                return None
1✔
2282
            obj = Object(id=id)
1✔
2283
            if local:
1✔
2284
                # logger.debug(f'  {id} not in datastore')
2285
                obj.new = True
1✔
2286
                obj.changed = False
1✔
2287

2288
        try:
1✔
2289
            fetched = cls.fetch(obj, csv=csv, **kwargs)
1✔
2290
        except (RequestException, HTTPException, InvalidStatus) as e:
1✔
2291
            if raise_:
1✔
2292
                raise
1✔
2293
            util.interpret_http_exception(e)
1✔
2294
            return None
1✔
2295

2296
        if not fetched:
1✔
2297
            return None
1✔
2298
        elif csv and not obj.is_csv:
1✔
2299
            return None
×
2300

2301
        # https://stackoverflow.com/a/3042250/186123
2302
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
2303
        if size > MAX_ENTITY_SIZE:
1✔
2304
            logger.warning(f'Object is too big! {size} bytes is over {MAX_ENTITY_SIZE}')
1✔
2305
            return None
1✔
2306

2307
        obj.resolve_ids()
1✔
2308
        obj.normalize_ids()
1✔
2309

2310
        if obj.new is False:
1✔
2311
            obj.changed = obj.activity_changed(orig_as1)
1✔
2312

2313
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
2314
            if obj.source_protocol:
1✔
UNCOV
2315
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
2316
            obj.source_protocol = cls.LABEL
1✔
2317

2318
        obj.put()
1✔
2319
        return obj
1✔
2320

2321
    @classmethod
1✔
2322
    def check_supported(cls, obj, direction):
1✔
2323
        """If this protocol doesn't support this activity, raises HTTP 204.
2324

2325
        Also reports an error.
2326

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

2331
        Args:
2332
          obj (Object)
2333
          direction (str): ``'receive'`` or  ``'send'``
2334

2335
        Raises:
2336
          werkzeug.HTTPException: if this protocol doesn't support this object
2337
        """
2338
        assert direction in ('receive', 'send')
1✔
2339
        if not obj.type:
1✔
UNCOV
2340
            return
×
2341

2342
        inner = as1.get_object(obj.as1)
1✔
2343
        inner_type = as1.object_type(inner) or ''
1✔
2344
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
2345
            or (obj.type in as1.CRUD_VERBS
2346
                and inner_type
2347
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
2348
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
2349

2350
        # don't allow posts with blank content and no image/video/audio
2351
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
2352
                    else obj.as1)
2353
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
2354
                and not util.get_url(crud_obj, key='image')
2355
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
2356
                # TODO: handle articles with displayName but not content
2357
                and not source.html_to_text(crud_obj.get('content')).strip()):
2358
            error('Blank content and no image or video or audio', status=204)
1✔
2359

2360
        # receiving DMs is only allowed to protocol bot accounts
2361
        if direction == 'receive':
1✔
2362
            if recip := as1.recipient_if_dm(obj.as1):
1✔
2363
                owner = as1.get_owner(obj.as1)
1✔
2364
                if (not cls.SUPPORTS_DMS or (recip not in common.bot_user_ids()
1✔
2365
                                             and owner not in common.bot_user_ids())):
2366
                    # reply and say DMs aren't supported
2367
                    from_proto = obj.owner_protocol()
1✔
2368
                    to_proto = Protocol.for_id(recip)
1✔
2369
                    if owner and from_proto and to_proto:
1✔
2370
                        if ((from_user := from_proto.get_or_create(id=owner))
1✔
2371
                                and (to_user := to_proto.get_or_create(id=recip))):
2372
                            in_reply_to = (inner.get('id') if obj.type == 'post'
1✔
2373
                                           else obj.as1.get('id'))
2374
                            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✔
2375
                            type = f'dms_not_supported-{to_user.key.id()}'
1✔
2376
                            dms.maybe_send(from_=to_user, to_user=from_user,
1✔
2377
                                           text=text, type=type,
2378
                                           in_reply_to=in_reply_to)
2379

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

2382
            # check that this activity is public. only do this for some activities,
2383
            # not eg likes or follows, since Mastodon doesn't currently mark those
2384
            # as explicitly public.
2385
            elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
2386
                  and not util.domain_or_parent_in(crud_obj.get('id'), NON_PUBLIC_DOMAINS)
2387
                  and not as1.is_public(obj.as1, unlisted=False)):
2388
                error('Bridgy Fed only supports public activities', status=204)
1✔
2389

2390
    @classmethod
1✔
2391
    def block(cls, from_user, arg):
1✔
2392
        """Blocks a user or list.
2393

2394
        Args:
2395
          from_user (models.User): user doing the blocking
2396
          arg (str): handle or id of user/list to block
2397

2398
        Returns:
2399
          models.User or models.Object: user or list that was blocked
2400

2401
        Raises:
2402
          ValueError: if arg doesn't look like a user or list on this protocol
2403
        """
2404
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2405

2406
        def fail(msg):
1✔
2407
            logger.warning(msg)
1✔
2408
            raise ValueError(msg)
1✔
2409

2410
        blockee = None
1✔
2411
        try:
1✔
2412
            # first, try interpreting as a user handle or id
2413
            blockee = load_user(arg, proto=cls, create=True, allow_opt_out=True)
1✔
2414
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2415
            logger.info(err)
1✔
2416

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

2420
        # may not be a user, see if it's a list
2421
        if not blockee:
1✔
2422
            if not cls or cls == Protocol:
1✔
2423
                cls = Protocol.for_id(arg)
1✔
2424

2425
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2426
                if blockee.source_protocol == from_user.LABEL:
1✔
2427
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2428
            else:
2429
                if blocklist := from_user.add_domain_blocklist(arg):
1✔
2430
                    return blocklist
1✔
2431
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2432

2433
        logger.info(f'  blocking {blockee.key.id()}')
1✔
2434
        id = f'{from_user.profile_id()}#bridgy-fed-block-{util.now().isoformat()}'
1✔
2435
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2436
            'objectType': 'activity',
2437
            'verb': 'block',
2438
            'id': id,
2439
            'actor': from_user.key.id(),
2440
            'object': blockee.key.id(),
2441
        })
2442
        obj.put()
1✔
2443
        from_user.deliver(obj, from_user=from_user)
1✔
2444

2445
        return blockee
1✔
2446

2447
    @classmethod
1✔
2448
    def unblock(cls, from_user, arg):
1✔
2449
        """Unblocks a user or list.
2450

2451
        Args:
2452
          from_user (models.User): user doing the unblocking
2453
          arg (str): handle or id of user/list to unblock
2454

2455
        Returns:
2456
          models.User or models.Object: user or list that was unblocked
2457

2458
        Raises:
2459
          ValueError: if arg doesn't look like a user or list on this protocol
2460
        """
2461
        logger.info(f'user {from_user.key.id()} trying to unblock {arg}')
1✔
2462
        def fail(msg):
1✔
2463
            logger.warning(msg)
1✔
2464
            raise ValueError(msg)
1✔
2465

2466
        blockee = None
1✔
2467
        try:
1✔
2468
            # first, try interpreting as a user handle or id
2469
            blockee = load_user(arg, cls, create=True, allow_opt_out=True)
1✔
2470
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2471
            logger.info(err)
1✔
2472

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

2476
        # may not be a user, see if it's a list
2477
        if not blockee:
1✔
2478
            if not cls or cls == Protocol:
1✔
2479
                cls = Protocol.for_id(arg)
1✔
2480

2481
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2482
                if blockee.source_protocol == from_user.LABEL:
1✔
2483
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2484
            else:
2485
                if blocklist := from_user.remove_domain_blocklist(arg):
1✔
2486
                    return blocklist
1✔
2487
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2488

2489
        logger.info(f'  unblocking {blockee.key.id()}')
1✔
2490
        id = f'{from_user.profile_id()}#bridgy-fed-unblock-{util.now().isoformat()}'
1✔
2491
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2492
            'objectType': 'activity',
2493
            'verb': 'undo',
2494
            'id': id,
2495
            'actor': from_user.key.id(),
2496
            'object': {
2497
                'objectType': 'activity',
2498
                'verb': 'block',
2499
                'actor': from_user.key.id(),
2500
                'object': blockee.key.id(),
2501
            },
2502
        })
2503
        obj.put()
1✔
2504
        from_user.deliver(obj, from_user=from_user)
1✔
2505

2506
        return blockee
1✔
2507

2508

2509
@cloud_tasks_only(log=None)
1✔
2510
def receive_task():
1✔
2511
    """Task handler for a newly received :class:`models.Object`.
2512

2513
    Calls :meth:`Protocol.receive` with the form parameters.
2514

2515
    Parameters:
2516
      authed_as (str): passed to :meth:`Protocol.receive`
2517
      obj_id (str): key id of :class:`models.Object` to handle
2518
      received_at (str, ISO 8601 timestamp): when we first saw (received)
2519
        this activity
2520
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2521
        :class:`models.Object` to handle
2522

2523
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
2524
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
2525
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
2526
    ``changed``, HTTP request details, etc. See stash for attempt at this for
2527
    :class:`web.Web`.
2528
    """
2529
    common.log_request()
1✔
2530
    form = request.form.to_dict()
1✔
2531

2532
    authed_as = form.pop('authed_as', None)
1✔
2533
    internal = authed_as == PRIMARY_DOMAIN or authed_as in PROTOCOL_DOMAINS
1✔
2534

2535
    obj = Object.from_request()
1✔
2536
    assert obj
1✔
2537
    assert obj.source_protocol
1✔
2538
    obj.new = True
1✔
2539

2540
    if received_at := form.pop('received_at', None):
1✔
2541
        received_at = datetime.fromisoformat(received_at)
1✔
2542

2543
    try:
1✔
2544
        return PROTOCOLS[obj.source_protocol].receive(
1✔
2545
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
2546
    except RequestException as e:
1✔
2547
        util.interpret_http_exception(e)
1✔
2548
        error(e, status=304)
1✔
2549
    except (RuntimeError, ValueError) as e:
1✔
UNCOV
2550
        logger.warning(e, exc_info=True)
×
UNCOV
2551
        error(e, status=304)
×
2552

2553

2554
@cloud_tasks_only(log=None)
1✔
2555
def send_task():
1✔
2556
    """Task handler for sending an activity to a single specific destination.
2557

2558
    Calls :meth:`Protocol.send` with the form parameters.
2559

2560
    Parameters:
2561
      protocol (str): :class:`Protocol` to send to
2562
      url (str): destination URL to send to
2563
      obj_id (str): key id of :class:`models.Object` to send
2564
      orig_obj_id (str): optional, :class:`models.Object` key id of the
2565
        "original object" that this object refers to, eg replies to or reposts
2566
        or likes
2567
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
2568
        this activity is from
2569
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2570
        :class:`models.Object` to handle
2571
      first: ``true`` if this is the first task of this group (eg sends for
2572
        a given receive) for this protocol, ``false`` otherwise
2573
    """
2574
    if request.values.get('first', '').lower() == 'true':
1✔
2575
        common.log_request()
1✔
2576

2577
    # prepare
2578
    form = request.form.to_dict()
1✔
2579
    url = form.get('url')
1✔
2580
    protocol = form.get('protocol')
1✔
2581
    if not url or not protocol:
1✔
2582
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
2583
        return '', 204
1✔
2584

2585
    target = Target(uri=url, protocol=protocol)
1✔
2586
    obj = Object.from_request()
1✔
2587
    assert obj and obj.key and obj.key.id()
1✔
2588

2589
    PROTOCOLS[protocol].check_supported(obj, 'send')
1✔
2590
    allow_opt_out = (obj.type == 'delete')
1✔
2591

2592
    user = None
1✔
2593
    if user_key := form.get('user'):
1✔
2594
        key = ndb.Key(urlsafe=user_key)
1✔
2595
        # use get_by_id so that we follow use_instead
2596
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2597
            key.id(), allow_opt_out=allow_opt_out)
2598

2599
    # send
2600
    delay = ''
1✔
2601
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
2602
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
2603
        delay = f'({delay_s} s behind)'
1✔
2604
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
2605
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
2606
    sent = None
1✔
2607
    try:
1✔
2608
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
2609
                                        orig_obj_id=form.get('orig_obj_id'))
2610
    except (MemcacheServerError, MemcacheUnexpectedCloseError,
1✔
2611
            MemcacheUnknownError) as e:
2612
        # our memorystore instance is probably undergoing maintenance. re-enqueue
2613
        # task with a delay.
2614
        # https://docs.cloud.google.com/memorystore/docs/memcached/about-maintenance
2615
        report_error(f'memcache error on send task, re-enqueuing in {MEMCACHE_DOWN_TASK_DELAY}: {e}')
1✔
2616
        common.create_task(queue='send', delay=MEMCACHE_DOWN_TASK_DELAY, **form)
1✔
2617
        sent = False
1✔
2618
    except BaseException as e:
1✔
2619
        code, body = util.interpret_http_exception(e)
1✔
2620
        if not code and not body:
1✔
2621
            raise
1✔
2622

2623
    if sent is False:
1✔
2624
        logger.info(f'Failed sending!')
1✔
2625

2626
    return '', 200 if sent else 204 if sent is False else 304
1✔
2627

2628

2629
@cloud_tasks_only(log=None)
1✔
2630
def user_enabled_task():
1✔
2631
    r"""Task handler for when a user enables a protocol.
2632

2633
    DMs any dormant :class:`models.Follower`\s pointing at the user to let them
2634
    know the user is now bridged, so they can follow them for real, and flips
2635
    those ``Follower``\s from ``dormant`` to ``inactive``.
2636

2637
    Parameters:
2638
      user (url-safe google.cloud.ndb.key.Key): the :class:`models.User` who
2639
        enabled bridging
2640
      protocol (str): ``LABEL`` of the protocol they enabled
2641
    """
2642
    common.log_request()
1✔
2643

2644
    proto = PROTOCOLS[request.form['protocol']]
1✔
2645
    user = ndb.Key(urlsafe=request.form['user']).get()
1✔
2646
    assert user
1✔
2647
    logger.info(f'{user.key.id()} is {user.status or "ok"}')
1✔
2648
    if user.status:
1✔
UNCOV
2649
        raise ErrorButDoNotRetryTask()
×
2650

2651
    followers = Follower.query(Follower.to == user.key,
1✔
2652
                               Follower.status == 'dormant').fetch()
2653
    from_users = ndb.get_multi(
1✔
2654
        f.from_ for f in followers if f.from_.kind() == proto._get_kind())
2655

2656
    for follower, from_user in zip(followers, from_users):
1✔
2657
        if from_user and not from_user.status:
1✔
2658
            logger.info('Updating and DMing Follower from {from_user.key.id()}')
1✔
2659
            follower.status = 'inactive'
1✔
2660
            follower.put()
1✔
2661

2662
            relationship = {
1✔
2663
                'bounce': ', who you originally followed before you Bounced,',
2664
                'requested': ', who you asked to bridge,',
2665
            }.get(follower.reason, '')
2666
            dms.maybe_send(from_=proto, to_user=from_user, text=f'<p>Hi! {user.html_link(proto=proto, proto_fallback=True)}{relationship} has bridged their account into {proto.PHRASE}. You can follow them now if you want.')
1✔
2667

2668
    return '', 200
1✔
2669

2670

2671
@cloud_tasks_only(log=None)
1✔
2672
def migrate_out_task():
1✔
2673
    """Task handler for finishing a migration out.
2674

2675
    Currently, for migrating out to ATProto, uploads the user's blobs to the new PDS.
2676
    Otherwise, does nothing.
2677

2678
    Parameters:
2679
      user (str, url-safe ndb.Key of a User): the bridged :class:`models.User`
2680
        migrating out
2681
      protocol (str): destination protocol
2682
      auth (optional url-safe ndb.Key of an oauth-dropins auth entity): the user's
2683
        new account. For ATProto, an :class:`oauth_dropins.bluesky.BlueskyAuth`.
2684
    """
2685
    from atproto import ATProto
1✔
2686

2687
    common.log_request()
1✔
2688

2689
    user = ndb.Key(urlsafe=request.form['user']).get()
1✔
2690
    if not user:
1✔
UNCOV
2691
        raise ErrorButDoNotRetryTask()
×
2692

2693
    if request.form['protocol'] == ATProto.LABEL:
1✔
2694
        auth = ndb.Key(urlsafe=request.form['auth']).get()
1✔
2695
        assert auth
1✔
2696
        ATProto.migrate_out_blobs(user, auth)
1✔
2697

2698
    return '', 200
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc