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

snarfed / bridgy-fed / 458759a0-e6e0-4f07-8b42-1db85a592728

02 Aug 2026 10:49PM UTC coverage: 92.989% (+0.01%) from 92.977%
458759a0-e6e0-4f07-8b42-1db85a592728

push

circleci

snarfed
add owns_user_id, owns_object_id to Protocol and subclasses

for #2281

42 of 44 new or added lines in 7 files covered. (95.45%)

8608 of 9257 relevant lines covered (92.99%)

0.93 hits per line

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

95.74
/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 already know whether they have a user id or an object id
229
        should use :meth:`owns_user_id` or :meth:`owns_object_id` instead, which
230
        can often answer definitively where this can't.
231

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

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

240
    @classmethod
1✔
241
    def owns_user_id(cls, id):
1✔
242
        """Returns whether this protocol owns this user id.
243

244
        To be implemented by subclasses.
245

246
        Assumes that ``id`` is a native user id in our eyes, ie what we'd use
247
        as the user's key id.
248

249
        We currently expect that user ids are disjoint across all supported
250
        protocols. Any ``id`` input string here should return True for
251
        either a single protocol, at most, or none. Notably, all http[s] URL user
252
        ids are currently owned by :class:`activitypub.ActivityPub`.
253
        :class:`web.Web` user ids are domains, not URLs.
254

255
        Note that this method differs from :meth:`Protocol.owns_object_id` in
256
        that it only returns True or False, while :meth:`Protocol.owns_object_id`
257
        can also return None to indicate "maybe."
258

259
        Args:
260
          id (str)
261

262
        Returns:
263
          bool:
264
        """
NEW
265
        return False
×
266

267
    @classmethod
1✔
268
    def owns_object_id(cls, id):
1✔
269
        """Returns whether this protocol owns this object id, or None if unclear.
270

271
        To be implemented by subclasses.
272

273
        Assumes that ``id`` is a native object id in our eyes, ie the
274
        :class:`Object` key id.
275

276
        Unlike user ids, object ids overlap across protocols. Notably, http[s]
277
        URLs may be either :class:`web.Web` or :class:`activitypub.ActivityPub`;
278
        we generally can't distinguish based solely on the id itself.
279

280
        Args:
281
          id (str)
282

283
        Returns:
284
          bool or None:
285
        """
NEW
286
        return False
×
287

288
    @classmethod
1✔
289
    def owns_handle(cls, handle, allow_internal=False):
1✔
290
        """Returns whether this protocol owns the handle, or None if it's unclear.
291

292
        To be implemented by subclasses.
293

294
        Handles are string identities that are human-chosen, human-meaningful,
295
        and often but not always unique. Compare to IDs, which uniquely identify
296
        users, and are intended primarily to be machine readable and usable.
297

298
        Some protocols' handles are more or less deterministic based on the id
299
        format, eg ActivityPub (technically WebFinger) handles are
300
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
301
        ActivityPub, AT Protocol, or others.
302

303
        This should be a quick guess without expensive side effects, eg no
304
        external HTTP fetches to fetch the id itself or otherwise perform
305
        discovery.
306

307
        Args:
308
          handle (str)
309
          allow_internal (bool): whether to return False for internal domains
310
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
311

312
        Returns:
313
          bool or None
314
        """
315
        return False
1✔
316

317
    @classmethod
1✔
318
    def handle_to_id(cls, handle):
1✔
319
        """Converts a handle to an id.
320

321
        To be implemented by subclasses.
322

323
        May incur network requests, eg DNS queries or HTTP requests. Avoids
324
        blocked or opted out users.
325

326
        Args:
327
          handle (str)
328

329
        Returns:
330
          str: corresponding id, or None if the handle can't be found
331
        """
332
        raise NotImplementedError()
×
333

334
    @classmethod
1✔
335
    def authed_user_for_request(cls):
1✔
336
        """Returns the authenticated user id for the current request.
337

338

339
        Checks authentication on the current request, eg HTTP Signature for
340
        ActivityPub. To be implemented by subclasses.
341

342
        Returns:
343
          str: authenticated user id, or None if there is no authentication
344

345
        Raises:
346
          RuntimeError: if the request's authentication (eg signature) is
347
          invalid or otherwise can't be verified
348
        """
349
        return None
1✔
350

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

355
        If called via `Protocol.key_for`, infers the appropriate protocol with
356
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
357
        as is.
358

359
        Args:
360
          id (str):
361
          allow_opt_out (bool): whether to allow users who are currently opted out
362

363
        Returns:
364
          google.cloud.ndb.Key: matching key, or None if the given id is not a
365
          valid :class:`User` id for this protocol.
366
        """
367
        if cls == Protocol:
1✔
368
            proto = Protocol.for_id(id)
1✔
369
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
370

371
        # load user so that we follow use_instead
372
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
373
        if existing:
1✔
374
            if existing.status and not allow_opt_out:
1✔
375
                return None
1✔
376
            return existing.key
1✔
377

378
        return cls(id=id).key
1✔
379

380
    @staticmethod
1✔
381
    def _for_id_memcache_key(id, remote=None):
1✔
382
        """If id is a URL, uses its domain, otherwise returns None.
383

384
        Args:
385
          id (str)
386

387
        Returns:
388
          (str domain, bool remote) or None
389
        """
390
        domain = util.domain_from_link(id)
1✔
391
        if domain in PROTOCOL_DOMAINS:
1✔
392
            return id
1✔
393
        elif remote and util.is_web(id):
1✔
394
            return domain
1✔
395

396
    @cached(LRUCache(20000), lock=Lock())
1✔
397
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote=True: remote,
1✔
398
                      version=3)
399
    @staticmethod
1✔
400
    def for_id(id, remote=True):
1✔
401
        """Returns the protocol for a given id.
402

403
        Args:
404
          id (str)
405
          remote (bool): whether to perform expensive side effects like fetching
406
            the id itself over the network, or other discovery.
407

408
        Returns:
409
          Protocol subclass: matching protocol, or None if no single known
410
          protocol definitively owns this id
411
        """
412
        logger.debug(f'Determining protocol for id {id}')
1✔
413
        if not id:
1✔
414
            return None
1✔
415

416
        # remove our synthetic id fragment, if any
417
        #
418
        # will this eventually cause false positives for other services that
419
        # include our full ids inside their own ids, non-URL-encoded? guess
420
        # we'll figure that out if/when it happens.
421
        id = id.partition('#bridgy-fed-')[0]
1✔
422
        if not id:
1✔
423
            return None
1✔
424

425
        if util.is_web(id):
1✔
426
            # step 1: check for our per-protocol subdomains
427
            try:
1✔
428
                parsed = urlparse(id)
1✔
429
            except ValueError as e:
1✔
430
                logger.info(f'urlparse ValueError: {e}')
1✔
431
                return None
1✔
432

433
            is_internal = parsed.path.startswith(ids.INTERNAL_PATH_PREFIX)
1✔
434
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
435
            if by_subdomain and not (util.is_homepage(id) or is_internal
1✔
436
                                     or id in ids.BOT_ACTOR_AP_IDS):
437
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
438
                return by_subdomain
1✔
439

440
        # step 2: check if any Protocols say conclusively that they own it
441
        # sort to be deterministic
442
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
443
                           key=lambda p: p.LABEL)
444
        candidates = []
1✔
445
        for protocol in protocols:
1✔
446
            owns = protocol.owns_id(id)
1✔
447
            if owns:
1✔
448
                logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
449
                return protocol
1✔
450
            elif owns is not False:
1✔
451
                candidates.append(protocol)
1✔
452

453
        if len(candidates) == 1:
1✔
454
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
455
            return candidates[0]
1✔
456

457
        # step 3: look for existing Objects in the datastore
458
        #
459
        # note that we don't currently see if this is a copy id because I have FUD
460
        # over which Protocol for_id should return in that case...and also because a
461
        # protocol may already say definitively above that it owns the id, eg ATProto
462
        # with DIDs and at:// URIs.
463
        obj = Protocol.load(id, remote=False)
1✔
464
        if obj and obj.source_protocol:
1✔
465
            logger.debug(f'  {obj.key.id()} owned by source_protocol {obj.source_protocol}')
1✔
466
            return PROTOCOLS[obj.source_protocol]
1✔
467

468
        # step 4: fetch over the network, if necessary
469
        if not remote:
1✔
470
            return None
1✔
471

472
        for protocol in candidates:
1✔
473
            logger.debug(f'Trying {protocol.LABEL}')
1✔
474
            try:
1✔
475
                obj = protocol.load(id, local=False, remote=True)
1✔
476

477
                if protocol.ABBREV == 'web':
1✔
478
                    # for web, if we fetch and get HTML without microformats,
479
                    # load returns False but the object will be stored in the
480
                    # datastore with source_protocol web, and in cache. load it
481
                    # again manually to check for that.
482
                    obj = Object.get_by_id(id)
1✔
483
                    if obj and obj.source_protocol != 'web':
1✔
484
                        obj = None
×
485

486
                if obj:
1✔
487
                    logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
488
                    return protocol
1✔
489
            except BadGateway:
1✔
490
                # we tried and failed fetching the id over the network.
491
                # this depends on ActivityPub.fetch raising this!
492
                return None
1✔
493
            except HTTPException as e:
×
494
                # internal error we generated ourselves; try next protocol
495
                pass
×
496
            except Exception as e:
×
497
                code, _ = util.interpret_http_exception(e)
×
498
                if code:
×
499
                    # we tried and failed fetching the id over the network
500
                    return None
×
501
                raise
×
502

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

506
    @cached(LRUCache(20000), lock=Lock())
1✔
507
    @staticmethod
1✔
508
    def for_handle(handle):
1✔
509
        """Returns the protocol for a given handle.
510

511
        May incur expensive side effects like resolving the handle itself over
512
        the network or other discovery.
513

514
        Args:
515
          handle (str)
516

517
        Returns:
518
          (Protocol subclass, str) tuple: matching protocol and optional id (if
519
          resolved), or ``(None, None)`` if no known protocol owns this handle
520
        """
521
        # TODO: normalize, eg convert domains to lower case
522
        logger.debug(f'Determining protocol for handle {handle}')
1✔
523
        if not handle:
1✔
524
            return (None, None)
1✔
525

526
        # step 1: check if any Protocols say conclusively that they own it.
527
        # sort to be deterministic.
528
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
529
                           key=lambda p: p.LABEL)
530
        candidates = []
1✔
531
        for proto in protocols:
1✔
532
            owns = proto.owns_handle(handle)
1✔
533
            if owns:
1✔
534
                logger.debug(f'  {proto.LABEL} owns handle {handle}')
1✔
535
                return (proto, None)
1✔
536
            elif owns is not False:
1✔
537
                candidates.append(proto)
1✔
538

539
        if len(candidates) == 1:
1✔
540
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
1✔
541
            return (candidates[0], None)
1✔
542

543
        # step 2: look for matching User in the datastore
544
        for proto in candidates:
1✔
545
            user = proto.query(proto.handle == handle).get()
1✔
546
            if user:
1✔
547
                if user.status:
1✔
548
                    return (None, None)
1✔
549
                logger.debug(f'  user {user.key} handle {handle}')
1✔
550
                return (proto, user.key.id())
1✔
551

552
        # step 3: resolve handle to id
553
        for proto in candidates:
1✔
554
            id = proto.handle_to_id(handle)
1✔
555
            if id:
1✔
556
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
557
                return (proto, id)
1✔
558

559
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
560
        return (None, None)
1✔
561

562
    @classmethod
1✔
563
    def is_user_at_domain(cls, handle, allow_internal=False):
1✔
564
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
565

566
        Example: ``@user@instance.com``
567

568
        Args:
569
          handle (str)
570
          allow_internal (bool): whether the domain can be a Bridgy Fed domain
571
        """
572
        parts = handle.split('@')
1✔
573
        if len(parts) != 2:
1✔
574
            return False
1✔
575

576
        user, domain = parts
1✔
577
        return bool(user and domain
1✔
578
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
579

580
    @classmethod
1✔
581
    def bridged_web_url_for(cls, user, fallback=False):
1✔
582
        """Returns the web URL for a user's bridged profile in this protocol.
583

584
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
585
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
586

587
        Args:
588
          user (models.User)
589
          fallback (bool): if True, and bridged users have no canonical user
590
            profile URL in this protocol, return the native protocol's profile URL
591

592
        Returns:
593
          str, or None if there isn't a canonical URL
594
        """
595
        if fallback:
1✔
596
            return user.web_url()
1✔
597

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

602
        Args:
603
          obj (models.Object)
604
          allow_opt_out (bool): whether to return a user key if they're opted out
605

606
        Returns:
607
          google.cloud.ndb.key.Key or None:
608
        """
609
        owner = as1.get_owner(obj.as1)
1✔
610
        if owner:
1✔
611
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
612

613
    @classmethod
1✔
614
    def bot_user_id(cls):
1✔
615
        """Returns the Web user id for the bot user for this protocol.
616

617
        For example, ``'bsky.brid.gy'`` for ATProto.
618

619
        Returns:
620
          str:
621
        """
622
        return f'{cls.ABBREV}{SUPERDOMAIN}'
1✔
623

624
    @classmethod
1✔
625
    def create_for(cls, user):
1✔
626
        """Creates or re-activate a copy user in this protocol.
627

628
        Should add the copy user to :attr:`copies`.
629

630
        If the copy user already exists and active, should do nothing.
631

632
        Args:
633
          user (models.User): original source user. Shouldn't already have a
634
            copy user for this protocol in :attr:`copies`.
635

636
        Raises:
637
          ValueError: if we can't create a copy of the given user in this protocol
638
        """
639
        raise NotImplementedError()
×
640

641
    @classmethod
1✔
642
    def send(to_cls, obj, target, from_user=None, orig_obj_id=None):
1✔
643
        """Sends an outgoing activity.
644

645
        To be implemented by subclasses. Should call
646
        ``to_cls.translate_ids(obj.as1)`` before converting it to this Protocol's
647
        format.
648

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

653
        Args:
654
          obj (models.Object): with activity to send
655
          target (str): destination URL to send to
656
          from_user (models.User): user (actor) this activity is from
657
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
658
            that this object refers to, eg replies to or reposts or likes
659

660
        Returns:
661
          bool: True if the activity is sent successfully, False if it is
662
          ignored or otherwise unsent due to protocol logic, eg no webmention
663
          endpoint, protocol doesn't support the activity type. (Failures are
664
          raised as exceptions.)
665

666
        Raises:
667
          werkzeug.HTTPException if the request fails
668
        """
669
        raise NotImplementedError()
×
670

671
    @classmethod
1✔
672
    def fetch(cls, obj, **kwargs):
1✔
673
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
674

675
        Errors are raised as exceptions. If this method returns False, the fetch
676
        didn't fail but didn't succeed either, eg the id isn't valid for this
677
        protocol, or the fetch didn't return valid data for this protocol.
678

679
        To be implemented by subclasses.
680

681
        Args:
682
          obj (models.Object): with the id to fetch. Data is filled into one of
683
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
684
          kwargs: subclass-specific
685

686
        Returns:
687
          bool: True if the object was fetched and populated successfully,
688
          False otherwise
689

690
        Raises:
691
          requests.RequestException, werkzeug.HTTPException,
692
          websockets.WebSocketException, etc: if the fetch fails
693
        """
694
        raise NotImplementedError()
×
695

696
    @classmethod
1✔
697
    def convert(cls, obj, from_user=None, **kwargs):
1✔
698
        """Converts an :class:`Object` to this protocol's data format.
699

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

703
        Just passes through to :meth:`_convert`, then does minor
704
        protocol-independent postprocessing.
705

706
        Args:
707
          obj (models.Object):
708
          from_user (models.User): user (actor) this activity/object is from
709
          kwargs: protocol-specific, passed through to :meth:`_convert`
710

711
        Returns:
712
          converted object in the protocol's native format, often a dict, or None
713
        """
714
        if not obj or not obj.as1:
1✔
715
            return None
1✔
716

717
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
718
        is_crud = obj.as1.get('verb') in as1.CRUD_VERBS
1✔
719
        base_obj = as1.get_object(obj.as1) if is_crud else obj.as1
1✔
720
        orig_our_as1 = obj.our_as1
1✔
721

722
        # post-processing for user profiles
723
        if (from_user and from_user.is_profile(obj)
1✔
724
                and PROTOCOLS.get(obj.source_protocol) != cls
725
                and Protocol.for_bridgy_subdomain(id) not in DOMAINS):
726
            # TODO: more systematic way to get this that covers all protocols,
727
            # eg Nostr NIP-05
728
            web_opted_in = (from_user.LABEL == 'web' and
1✔
729
                            (from_user.last_webmention_in
730
                             or from_user.has_redirects
731
                             or from_user.handle_as('atproto') == from_user.key.id()))
732
            if not web_opted_in:
1✔
733
                # mark bridged actors as bots and add "bridged by Bridgy Fed" to
734
                # their bios. (web users are special cased, they don't get the label
735
                # if they've explicitly enabled Bridgy Fed with redirects or
736
                # webmentions.)
737
                cls.add_source_links(obj=obj, from_user=from_user)
1✔
738

739
                # web is currently opt out, so add [Unofficial] to their display name
740
                # to be explicit that they may not have enabled this themselves
741
                if from_user.LABEL == 'web':
1✔
742
                    if obj.our_as1 is orig_our_as1:
1✔
743
                        obj.our_as1 = copy.deepcopy(obj.as1)
×
744
                    actor = as1.get_object(obj.our_as1) if is_crud else obj.our_as1
1✔
745
                    if ((name := actor.get('displayName'))
1✔
746
                            and not name.endswith(' [Unofficial]')):
747
                        actor['displayName'] = f'{name} [Unofficial]'
1✔
748

749
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
750
        obj.our_as1 = orig_our_as1
1✔
751
        return converted
1✔
752

753
    @classmethod
1✔
754
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
755
        """Converts an :class:`Object` to this protocol's data format.
756

757
        To be implemented by subclasses. Implementations should generally call
758
        :meth:`Protocol.translate_ids` (as their own class) before converting to
759
        their format.
760

761
        Args:
762
          obj (models.Object):
763
          from_user (models.User): user (actor) this activity/object is from
764
          kwargs: protocol-specific
765

766
        Returns:
767
          converted object in the protocol's native format, often a dict. May
768
            return the ``{}`` empty dict if the object can't be converted.
769
        """
770
        raise NotImplementedError()
×
771

772
    @classmethod
1✔
773
    def add_source_links(cls, obj, from_user):
1✔
774
        """Adds "bridged from ... by Bridgy Fed" to the user's actor's ``summary``.
775

776
        Uses HTML for protocols that support it, plain text otherwise.
777

778
        Args:
779
          cls (Protocol subclass): protocol that the user is bridging into
780
          obj (models.Object): user's actor/profile object
781
          from_user (models.User): user (actor) this activity/object is from
782
        """
783
        assert obj and obj.as1
1✔
784
        assert from_user
1✔
785

786
        obj.our_as1 = copy.deepcopy(obj.as1)
1✔
787
        actor = (as1.get_object(obj.as1) if obj.type in as1.CRUD_VERBS
1✔
788
                 else obj.as1)
789
        actor.setdefault('objectType', 'person')
1✔
790

791
        orig_summary = actor.setdefault('summary', '')
1✔
792
        summary_text = html_to_text(orig_summary, ignore_links=True)
1✔
793

794
        # Check if we've already added source links
795
        if '🌉 bridged' in summary_text:
1✔
796
            return
1✔
797

798
        actor_id = actor.get('id')
1✔
799

800
        url = (as1.get_url(actor)
1✔
801
               or (from_user.web_url() if from_user.profile_id() == actor_id
802
                   else actor_id))
803

804
        from web import Web
1✔
805
        bot_user = Web.get_by_id(from_user.bot_user_id())
1✔
806

807
        if cls.HTML_PROFILES:
1✔
808
            if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS:
1✔
809
                mention = bot_user.html_link(proto=cls, name=False, handle='short')
1✔
810
                suffix = f', follow {mention} to interact'
1✔
811
            else:
812
                suffix = f' by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>'
1✔
813

814
            separator = '<br><br>'
1✔
815

816
            is_user = from_user.key and actor_id in (from_user.key.id(),
1✔
817
                                                     from_user.profile_id())
818
            if is_user:
1✔
819
                bridged = f'🌉 <a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a>'
1✔
820
                from_ = f'<a href="{from_user.web_url()}">{from_user.handle}</a>'
1✔
821
            else:
822
                bridged = '🌉 bridged'
×
823
                from_ = util.pretty_link(url) if url else '?'
×
824

825
        else:  # plain text
826
            # TODO: unify with above. which is right?
827
            id = obj.key.id() if obj.key else obj.our_as1.get('id')
1✔
828
            is_user = from_user.key and id in (from_user.key.id(),
1✔
829
                                               from_user.profile_id())
830
            from_ = (from_user.web_url() if is_user else url) or '?'
1✔
831

832
            bridged = '🌉 bridged'
1✔
833
            suffix = (
1✔
834
                f': https://{PRIMARY_DOMAIN}{from_user.user_page_path()}'
835
                # link web users to their user pages
836
                if from_user.LABEL == 'web'
837
                else f', follow @{bot_user.handle_as(cls)} to interact'
838
                if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS
839
                else f' by https://{PRIMARY_DOMAIN}/')
840
            separator = '\n\n'
1✔
841
            orig_summary = summary_text
1✔
842

843
        logo = f'{from_user.LOGO_EMOJI} ' if from_user.LOGO_EMOJI else ''
1✔
844
        source_links = f'{separator if orig_summary else ""}{bridged} from {logo}{from_}{suffix}'
1✔
845
        actor['summary'] = orig_summary + source_links
1✔
846

847
    @classmethod
1✔
848
    def set_username(to_cls, user, username):
1✔
849
        """Sets a custom username for a user's bridged account in this protocol.
850

851
        Args:
852
          user (models.User)
853
          username (str)
854

855
        Raises:
856
          ValueError: if the username is invalid
857
          RuntimeError: if the username could not be set
858
        """
859
        raise NotImplementedError()
1✔
860

861
    @classmethod
1✔
862
    def migrate_out(cls, user, to_user_id):
1✔
863
        """Migrates a bridged account out to be a native account.
864

865
        Args:
866
          user (models.User)
867
          to_user_id (str)
868

869
        Raises:
870
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
871
            ``user`` is on this protocol or not bridged to this protocol
872
        """
873
        raise NotImplementedError()
×
874

875
    @classmethod
1✔
876
    def check_can_migrate_out(cls, user, to_user_id):
1✔
877
        """Raises an exception if a user can't yet migrate to a native account.
878

879
        For example, if ``to_user_id`` isn't on this protocol, or if ``user`` is on
880
        this protocol, or isn't bridged to this protocol.
881

882
        If the user is ready to migrate, returns ``None``.
883

884
        Subclasses may override this to add more criteria, but they should call this
885
        implementation first.
886

887
        Args:
888
          user (models.User)
889
          to_user_id (str)
890

891
        Raises:
892
          ValueError: if ``user`` isn't ready to migrate to this protocol yet
893
        """
894
        def _error(msg):
1✔
895
            logger.warning(msg)
1✔
896
            raise ValueError(msg)
1✔
897

898
        if cls.owns_id(to_user_id) is False:
1✔
899
            _error(f"{to_user_id} doesn't look like an {cls.LABEL} id")
1✔
900
        elif isinstance(user, cls):
1✔
901
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
902
        elif not user.is_enabled(cls):
1✔
903
            _error(f"{user.handle_or_id()} isn't currently bridged to {cls.PHRASE}")
1✔
904

905
    @classmethod
1✔
906
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
907
        """Migrates a native account in to be a bridged account.
908

909
        The protocol independent parts are done here; protocol-specific parts are
910
        done in :meth:`_migrate_in`, which this wraps.
911

912
        Reloads the user's profile before calling :meth:`_migrate_in`.
913

914
        Args:
915
          user (models.User): native user on another protocol to attach the
916
            newly imported bridged account to
917
          from_user_id (str)
918
          kwargs: additional protocol-specific parameters
919

920
        Raises:
921
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
922
            ``user`` is on this protocol or already bridged to this protocol
923
        """
924
        def _error(msg):
1✔
925
            logger.warning(msg)
1✔
926
            raise ValueError(msg)
1✔
927

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

930
        # check req'ts
931
        if cls.owns_id(from_user_id) is False:
1✔
932
            _error(f"{from_user_id} doesn't look like an {cls.LABEL} id")
1✔
933
        elif isinstance(user, cls):
1✔
934
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
935
        elif cls.HAS_COPIES and cls.LABEL in user.enabled_protocols:
1✔
936
            _error(f"{user.handle_or_id()} is already bridged to {cls.PHRASE}")
1✔
937

938
        # reload profile
939
        try:
1✔
940
            user.reload_profile()
1✔
941
        except (RequestException, HTTPException) as e:
×
942
            _, msg = util.interpret_http_exception(e)
×
943

944
        # migrate!
945
        cls._migrate_in(user, from_user_id, **kwargs)
1✔
946
        user.add('enabled_protocols', cls.LABEL)
1✔
947
        user.put()
1✔
948

949
        # attach profile object
950
        if user.obj:
1✔
951
            if cls.HAS_COPIES:
1✔
952
                profile_id = ids.profile_id(id=from_user_id, proto=cls)
1✔
953
                user.obj.remove_copies_on(cls)
1✔
954
                user.obj.add('copies', Target(uri=profile_id, protocol=cls.LABEL))
1✔
955
                user.obj.put()
1✔
956

957
            common.create_task(queue='receive', obj_id=user.obj_key.id(),
1✔
958
                               authed_as=user.key.id())
959

960
    @classmethod
1✔
961
    def _migrate_in(cls, user, from_user_id, **kwargs):
1✔
962
        """Protocol-specific parts of migrating in external account.
963

964
        Called by :meth:`migrate_in`, which does most of the work, including calling
965
        :meth:`reload_profile` before this.
966

967
        Args:
968
          user (models.User): native user on another protocol to attach the
969
            newly imported account to. Unused.
970
          from_user_id (str): DID of the account to be migrated in
971
          kwargs: protocol dependent
972
        """
973
        raise NotImplementedError()
×
974

975
    @classmethod
1✔
976
    def target_for(cls, obj, shared=False):
1✔
977
        """Returns an :class:`Object`'s delivery target (endpoint).
978

979
        To be implemented by subclasses.
980

981
        Examples:
982

983
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
984
          webmention target.
985
        * If obj is an ``activitypub`` actor, returns its inbox.
986
        * If obj is an ``activitypub`` object, returns it's author's or actor's
987
          inbox.
988

989
        Args:
990
          obj (models.Object):
991
          shared (bool): optional. If True, returns a common/shared
992
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
993
            multiple recipients for efficiency
994

995
        Returns:
996
          str: target endpoint, or None if not available.
997
        """
998
        raise NotImplementedError()
×
999

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

1004
        Default implementation here, subclasses may override.
1005

1006
        Args:
1007
          url (str):
1008
          allow_internal (bool): whether to return False for internal domains
1009
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
1010
        """
1011
        blocklist = DOMAIN_BLOCKLIST
1✔
1012
        if not DEBUG:
1✔
1013
            blocklist += tuple(util.RESERVED_TLDS | util.LOCAL_TLDS)
1✔
1014
        if not allow_internal:
1✔
1015
            blocklist += DOMAINS
1✔
1016
        return util.domain_or_parent_in(url, blocklist)
1✔
1017

1018
    @classmethod
1✔
1019
    def translate_ids(to_cls, obj):
1✔
1020
        """Translates all ids in an AS1 object to a specific protocol.
1021

1022
        Infers source protocol for each id value separately.
1023

1024
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
1025
        ``at://did:plc:abc/coll/123`` will be converted to
1026
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
1027

1028
        Wraps these AS1 fields:
1029

1030
        * ``id``
1031
        * ``actor``
1032
        * ``author``
1033
        * ``bcc``
1034
        * ``bto``
1035
        * ``cc``
1036
        * ``featured[].items``, ``featured[].orderedItems``
1037
        * ``object``
1038
        * ``object.actor``
1039
        * ``object.author``
1040
        * ``object.id``
1041
        * ``object.inReplyTo``
1042
        * ``object.object``
1043
        * ``attachments[].id``
1044
        * ``tags[objectType=mention].url``
1045
        * ``to``
1046

1047
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
1048
        same logic is duplicated there!
1049

1050
        TODO: unify with :meth:`Object.resolve_ids`,
1051
        :meth:`models.Object.normalize_ids`.
1052

1053
        Args:
1054
          to_proto (Protocol subclass)
1055
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
1056

1057
        Returns:
1058
          dict: translated AS1 version of ``obj``
1059
        """
1060
        from ui import UIProtocol
1✔
1061

1062
        assert to_cls != Protocol
1✔
1063
        if not obj:
1✔
1064
            return obj
1✔
1065

1066
        outer_obj = to_cls.translate_mention_handles(copy.deepcopy(obj))
1✔
1067
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
1068

1069
        def translate(elem, field, fn, uri=False):
1✔
1070
            owner_id = as1.get_owner(elem)
1✔
1071
            owner_proto = Protocol.for_id(owner_id)
1✔
1072

1073
            elem[field] = as1.get_objects(elem, field)
1✔
1074
            for obj in elem[field]:
1✔
1075
                if id := obj.get('id'):
1✔
1076
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
1077
                        continue
1✔
1078

1079
                    from_cls = Protocol.for_id(id)
1✔
1080
                    if field == 'id' and from_cls == UIProtocol and owner_proto:
1✔
1081
                        logger.info(f'owner of {id} {owner_id} is {owner_proto.LABEL}, translating id from that protocol')
1✔
1082
                        from_cls = owner_proto
1✔
1083

1084
                    # TODO: what if from_cls is None? relax translate_object_id,
1085
                    # make it a noop if we don't know enough about from/to?
1086
                    if from_cls and from_cls != to_cls:
1✔
1087
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
1088
                    if uri:
1✔
1089
                        obj['id'] = to_cls(id=obj['id']).id_uri() if obj['id'] else id
1✔
1090

1091
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
1092
                           for o in elem[field]]
1093

1094
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
1095
                elem[field] = elem[field][0]
1✔
1096

1097
        type = as1.object_type(outer_obj)
1✔
1098
        translate(outer_obj, 'id',
1✔
1099
                  ids.translate_user_id if type in as1.ACTOR_TYPES
1100
                  else ids.translate_object_id)
1101

1102
        for o in inner_objs:
1✔
1103
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
1104
                        or as1.get_owner(outer_obj) == o.get('id')
1105
                        or type in ('follow', 'stop-following', 'block'))
1106
            translate(o, 'id', (ids.translate_user_id if is_actor
1✔
1107
                                else ids.translate_object_id))
1108
            # TODO: need to handle both user and object ids here
1109
            # https://github.com/snarfed/bridgy-fed/issues/2281
1110
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
1111
            translate(o, 'object', (ids.translate_user_id if obj_is_actor
1✔
1112
                                    else ids.translate_object_id))
1113

1114
        for o in [outer_obj] + inner_objs:
1✔
1115
            translate(o, 'inReplyTo', ids.translate_object_id)
1✔
1116
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
1117
                translate(o, field, ids.translate_user_id)
1✔
1118
            for tag in as1.get_objects(o, 'tags'):
1✔
1119
                if tag.get('objectType') == 'mention':
1✔
1120
                    translate(tag, 'url', ids.translate_user_id, uri=True)
1✔
1121
            for att in as1.get_objects(o, 'attachments'):
1✔
1122
                translate(att, 'id', ids.translate_object_id)
1✔
1123
                url = att.get('url')
1✔
1124
                if url and not att.get('id'):
1✔
1125
                    if from_cls := Protocol.for_id(url):
1✔
1126
                        att['id'] = ids.translate_object_id(from_=from_cls, to=to_cls,
1✔
1127
                                                            id=url)
1128
            if feat := as1.get_object(o, 'featured'):
1✔
1129
                translate(feat, 'orderedItems', ids.translate_object_id)
1✔
1130
                translate(feat, 'items', ids.translate_object_id)
1✔
1131

1132
        outer_obj = util.trim_nulls(outer_obj)
1✔
1133

1134
        if objs := util.get_list(outer_obj ,'object'):
1✔
1135
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
1136
            if len(outer_obj['object']) == 1:
1✔
1137
                outer_obj['object'] = outer_obj['object'][0]
1✔
1138

1139
        return outer_obj
1✔
1140

1141
    @classmethod
1✔
1142
    def translate_mention_handles(cls, obj):
1✔
1143
        """Translates @-mentions in ``obj.content`` to this protocol's handles.
1144

1145
        Specifically, for each ``mention`` tag in the object's tags that has
1146
        ``startIndex`` and ``length``, replaces it in ``obj.content`` with that
1147
        user's translated handle in this protocol and updates the tag's location.
1148

1149
        Called by :meth:`Protocol.translate_ids`.
1150

1151
        If ``obj.content`` is HTML, does nothing.
1152

1153
        Args:
1154
          obj (dict): AS1 object
1155

1156
        Returns:
1157
          dict: modified AS1 object
1158
        """
1159
        if not obj:
1✔
1160
            return None
×
1161

1162
        obj = copy.deepcopy(obj)
1✔
1163
        obj['object'] = [cls.translate_mention_handles(o)
1✔
1164
                                for o in as1.get_objects(obj)]
1165
        if len(obj['object']) == 1:
1✔
1166
            obj['object'] = obj['object'][0]
1✔
1167

1168
        content = obj.get('content')
1✔
1169
        tags = obj.get('tags')
1✔
1170
        if not content or not tags or as1.is_content_html(obj):
1✔
1171
            return util.trim_nulls(obj)
1✔
1172

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

1175
        offset = 0
1✔
1176
        last_orig_end = 0
1✔
1177
        for tag in sorted(indexed, key=lambda t: t['startIndex']):
1✔
1178
            orig_start = tag['startIndex']
1✔
1179
            if orig_start < last_orig_end:
1✔
1180
                logger.warning(f'tags overlap! removing indices from {tag.get("url")}')
1✔
1181
                del tag['startIndex']
1✔
1182
                del tag['length']
1✔
1183
                continue
1✔
1184

1185
            orig_end = orig_start + tag['length']
1✔
1186
            last_orig_end = orig_end
1✔
1187
            tag['startIndex'] += offset
1✔
1188
            if tag.get('objectType') == 'mention' and (id := tag['url']):
1✔
1189
                if proto := Protocol.for_id(id):
1✔
1190
                    id = ids.normalize_user_id(id=id, proto=proto)
1✔
1191
                    if key := get_original_user_key(id):
1✔
1192
                        user = key.get()
×
1193
                    else:
1194
                        user = proto.get_or_create(id, allow_opt_out=True)
1✔
1195
                    if user:
1✔
1196
                        start = tag['startIndex']
1✔
1197
                        end = start + tag['length']
1✔
1198
                        if handle := user.handle_as(cls):
1✔
1199
                            content = content[:start] + handle + content[end:]
1✔
1200
                            offset += len(handle) - tag['length']
1✔
1201
                            tag.update({
1✔
1202
                                'displayName': handle,
1203
                                'length': len(handle),
1204
                            })
1205

1206
        obj['tags'] = tags
1✔
1207
        as2.set_content(obj, content)  # sets content *and* contentMap; obj is still AS1 here
1✔
1208
        return util.trim_nulls(obj)
1✔
1209

1210
    @classmethod
1✔
1211
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
1212
        """Handles an incoming activity.
1213

1214
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
1215
        unset, returns HTTP 299.
1216

1217
        Args:
1218
          obj (models.Object)
1219
          authed_as (str): authenticated actor id who sent this activity
1220
          internal (bool): whether to allow activity ids on internal domains,
1221
            from opted out/blocked users, etc.
1222
          received_at (datetime): when we first saw (received) this activity.
1223
            Right now only used for monitoring.
1224

1225
        Returns:
1226
          (str, int) tuple: (response body, HTTP status code) Flask response
1227

1228
        Raises:
1229
          werkzeug.HTTPException: if the request is invalid
1230
        """
1231
        # check some invariants
1232
        assert from_cls != Protocol
1✔
1233
        assert isinstance(obj, Object), obj
1✔
1234

1235
        if not obj.as1:
1✔
1236
            error('No object data provided')
1✔
1237

1238
        orig_obj = obj
1✔
1239
        id = None
1✔
1240
        if obj.key and obj.key.id():
1✔
1241
            id = obj.key.id()
1✔
1242

1243
        if not id:
1✔
1244
            id = obj.as1.get('id')
1✔
1245
            obj.key = ndb.Key(Object, id)
1✔
1246

1247
        if not id:
1✔
1248
            error('No id provided')
×
1249
        elif from_cls.owns_id(id) is False:
1✔
1250
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
1251
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
1252
            error(f'{id} is blocklisted')
1✔
1253

1254
        # does this protocol support this activity/object type?
1255
        from_cls.check_supported(obj, 'receive')
1✔
1256

1257
        # lease this object, atomically
1258
        memcache_key = activity_id_memcache_key(id)
1✔
1259
        leased = memcache.memcache.add(
1✔
1260
            memcache_key, 'leased', noreply=False,
1261
            expire=int(MEMCACHE_LEASE_EXPIRATION.total_seconds()))
1262

1263
        # short circuit if we've already seen this activity id
1264
        #
1265
        # * 'leased' (25s): if add failed and the value is 'leased', another task is
1266
        #   processing this same id right now, so skip.
1267
        #
1268
        # * 'done' (1w): set at the end of receive below. if it's 'done' here, or
1269
        #   we just have the obj in the datastore, skip unless content has changed
1270
        #   (changed is True). changed is often None here, eg a duplicate inbox
1271
        #   delivery, so we deliberately check `is not True`, not `is False`.
1272
        if 'force' not in request.values:
1✔
1273
            prior = None
1✔
1274
            if not leased and (prior := memcache.memcache.get(memcache_key)):
1✔
1275
                prior = prior.decode()
1✔
1276
                if prior == 'leased':
1✔
1277
                    error('Already in progress', status=204)
1✔
1278

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

1282
        pruned = {k: v for k, v in obj.as1.items()
1✔
1283
                  if k not in ('contentMap', 'replies', 'signature')}
1284
        delay = ''
1✔
1285
        retry = request.headers.get('X-AppEngine-TaskRetryCount')
1✔
1286
        if (received_at and retry in (None, '0')
1✔
1287
                and obj.type not in ('delete', 'undo')):  # we delay deletes/undos
1288
            delay_s = int((util.now().replace(tzinfo=None)
1✔
1289
                           - received_at.replace(tzinfo=None)
1290
                           ).total_seconds())
1291
            delay = f'({delay_s} s behind)'
1✔
1292
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
1293

1294
        # check authorization
1295
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1296
        actor = as1.get_owner(obj.as1)
1✔
1297
        if not actor:
1✔
1298
            error('Activity missing actor or author')
1✔
1299

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

1305
        assert authed_as
1✔
1306
        assert isinstance(authed_as, str)
1✔
1307
        authed_as = ids.normalize_user_id(id=authed_as, proto=from_user_cls)
1✔
1308
        actor = ids.normalize_user_id(id=actor, proto=from_user_cls)
1✔
1309
        if actor != authed_as and not internal:
1✔
1310
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
1311
                         user=f'{id} authed_as {authed_as} owner {actor}')
1312
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
1313

1314
        # update copy ids to originals
1315
        obj.normalize_ids()
1✔
1316
        obj.resolve_ids()
1✔
1317

1318
        if (obj.type == 'follow'
1✔
1319
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
1320
            # follows of bot user; refresh user profile first
1321
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
1322
            from_user = from_user_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1323
            from_user.reload_profile()
1✔
1324
        else:
1325
            # load actor user
1326
            from_user = from_user_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1327

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

1331
        # apply protocol-specific filters
1332
        if 'force' not in request.values:
1✔
1333
            for filter in from_cls.RECEIVE_FILTERS:
1✔
1334
                if filter(obj, from_user):
1✔
1335
                    error(f'Activity {id} blocked by filter {filter.__name__}')
1✔
1336

1337
        # check if this is a profile object coming in via a user with use_instead
1338
        # set. if so, override the object's id to be the final user id (from_user's),
1339
        # after following use_instead.
1340
        if obj.type in as1.ACTOR_TYPES and from_user.key.id() != actor:
1✔
1341
            as1_id = obj.as1.get('id')
1✔
1342
            if ids.normalize_user_id(id=as1_id, proto=from_user) == actor:
1✔
1343
                logger.info(f'Overriding AS1 object id {as1_id} with Object id {from_user.profile_id()}')
1✔
1344
                obj.our_as1 = {**obj.as1, 'id': from_user.profile_id()}
1✔
1345

1346
        # if this is an object, ie not an activity, wrap it in a create or update
1347
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as,
1✔
1348
                                          from_user=from_user)
1349
        obj.add('users', from_user.key)
1✔
1350

1351
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1352
        inner_obj_id = inner_obj_as1.get('id')
1✔
1353
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1354
            if not inner_obj_id:
1✔
1355
                error(f'{obj.type} object has no id!')
1✔
1356

1357
        # check age. we support backdated posts, but if they're over 2w old, we
1358
        # don't deliver them
1359
        if obj.type == 'post':
1✔
1360
            if published := inner_obj_as1.get('published'):
1✔
1361
                try:
1✔
1362
                    published_dt = util.parse_iso8601(published)
1✔
1363
                    if not published_dt.tzinfo:
1✔
1364
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
1365
                    age = util.now() - published_dt
1✔
1366
                    if (age > CREATE_MAX_AGE
1✔
1367
                            and 'force' not in request.values
1368
                            and not util.domain_or_parent_in(
1369
                                from_user.key.id(), CREATE_MAX_AGE_EXEMPT_DOMAINS)):
1370
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
1371
                              status=204)
1372
                except ValueError:  # from parse_iso8601
×
1373
                    logger.debug(f"Couldn't parse published {published}")
×
1374

1375
        # write Object to datastore
1376
        if obj.type in STORE_AS1_TYPES:
1✔
1377
            obj.put()
1✔
1378

1379
        # store inner object
1380
        # TODO: unify with big obj.type conditional below. would have to merge
1381
        # this with the DM handling block lower down.
1382
        crud_obj = None
1✔
1383
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1384
            # normalize_ids may have converted the inner object id to a user id
1385
            # (eg Web profile URL to domain), so normalize back to the profile
1386
            # object id to find the right existing Object in the datastore
1387
            crud_obj_id = (ids.normalize_object_id(id=inner_obj_id, proto=from_cls)
1✔
1388
                           or inner_obj_id)
1389
            crud_obj = Object.get_or_create(crud_obj_id, our_as1=inner_obj_as1,
1✔
1390
                                            source_protocol=obj.source_protocol,
1391
                                            authed_as=actor, users=[from_user.key],
1392
                                            deleted=False)
1393

1394
        actor = as1.get_object(obj.as1, 'actor')
1✔
1395
        actor_id = actor.get('id')
1✔
1396

1397
        # handle activity!
1398
        if obj.type == 'stop-following':
1✔
1399
            # TODO: unify with handle_follow?
1400
            # TODO: handle multiple followees
1401
            if not actor_id or not inner_obj_id:
1✔
1402
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1403

1404
            # deactivate Follower
1405
            from_ = from_user_cls.key_for(actor_id)
1✔
1406
            if not (to_cls := Protocol.for_id(inner_obj_id)):
1✔
1407
                error(f"Can't determine protocol for {inner_obj_id} , giving up")
1✔
1408
            to = to_cls.key_for(inner_obj_id)
1✔
1409
            follower = Follower.query(Follower.to == to,
1✔
1410
                                      Follower.from_ == from_,
1411
                                      Follower.status == 'active').get()
1412
            if follower:
1✔
1413
                follow_id = obj.as1.get('followId')
1✔
1414
                if (follow_id and follower.follow
1✔
1415
                        and follower.follow.id() != follow_id):
1416
                    logger.info(f"Ignoring stop-following: its follow id {follow_id} doesn't match current {follower.follow.id()}")
1✔
1417
                    return 'OK', 204
1✔
1418
                logger.info(f'Marking {follower} inactive')
1✔
1419
                follower.status = 'inactive'
1✔
1420
                follower.put()
1✔
1421
            else:
1422
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1423

1424
            # fall through to deliver to followee
1425
            # TODO: do we convert stop-following to webmention 410 of original
1426
            # follow?
1427

1428
            # fall through to deliver to followers
1429

1430
        elif obj.type in ('delete', 'undo'):
1✔
1431
            delete_obj_id = (from_user.profile_id()
1✔
1432
                            if inner_obj_id == from_user.key.id()
1433
                            else inner_obj_id)
1434

1435
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1436
            if not delete_obj:
1✔
1437
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1438
                return 'OK', 204
1✔
1439

1440
            # TODO: just delete altogether!
1441
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1442
            delete_obj.deleted = True
1✔
1443
            delete_obj.put()
1✔
1444

1445
            # if this is an actor, handle deleting it later so that
1446
            # in case it's from_user, user.enabled_protocols is still populated
1447
            #
1448
            # fall through to deliver to followers and delete copy if necessary.
1449
            # should happen via protocol-specific copy target and send of
1450
            # delete activity.
1451
            # https://github.com/snarfed/bridgy-fed/issues/63
1452

1453
        elif obj.type == 'block':
1✔
1454
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1455
                # blocking protocol bot user disables that protocol
1456
                from_user.delete(proto)
1✔
1457
                from_user.disable_protocol(proto)
1✔
1458
                return 'OK', 200
1✔
1459

1460
        elif obj.type == 'move':
1✔
1461
            from_cls.handle_move(obj, from_user=from_user)
1✔
1462
            # fall through to deliver the Move activity to remaining followers
1463

1464
        elif obj.type == 'post':
1✔
1465
            # handle DMs to bot users
1466
            if as1.is_dm(obj.as1):
1✔
1467
                return dms.receive(from_user=from_user, obj=obj)
1✔
1468

1469
        # fetch actor if necessary
1470
        is_user = from_user.is_profile(orig_obj)
1✔
1471
        if (actor and actor.keys() == set(['id'])
1✔
1472
                and not is_user and obj.type not in ('delete', 'undo')):
1473
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1474
            actor_obj = from_user_cls.load(
1✔
1475
                ids.profile_id(id=actor['id'], proto=from_cls), raise_=False)
1476
            if actor_obj and actor_obj.as1:
1✔
1477
                obj.our_as1 = {
1✔
1478
                    **obj.as1, 'actor': {
1479
                        **actor_obj.as1,
1480
                        # override profile id with actor id
1481
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1482
                        'id': actor['id'],
1483
                    }
1484
                }
1485

1486
        # fetch object if necessary
1487
        if (obj.type in ('post', 'update', 'share')
1✔
1488
                and inner_obj_as1.keys() == set(['id'])
1489
                and from_cls.owns_id(inner_obj_id) is not False):
1490
            logger.debug('Fetching inner object')
1✔
1491
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1492
                                      remote=(obj.type in ('post', 'update')))
1493
            if obj.type in ('post', 'update'):
1✔
1494
                crud_obj = inner_obj
1✔
1495
            if inner_obj and inner_obj.as1:
1✔
1496
                obj.our_as1 = {
1✔
1497
                    **obj.as1,
1498
                    'object': {
1499
                        **inner_obj_as1,
1500
                        **inner_obj.as1,
1501
                    }
1502
                }
1503

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

1509
        if obj.type == 'follow':
1✔
1510
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1511
                # follow of one of our protocol bot users; enable that protocol.
1512
                # fall through so that we send an accept.
1513
                try:
1✔
1514
                    from_user.enable_protocol(proto)
1✔
1515
                except ErrorButDoNotRetryTask:
1✔
1516
                    from web import Web
1✔
1517
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1518
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1519
                                               followee=bot, follow=obj)
1520
                    raise
1✔
1521
                proto.bot_maybe_follow_back(from_user)
1✔
1522
                from_cls.handle_follow(obj, from_user=from_user)
1✔
1523
                return 'OK', 202
1✔
1524

1525
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1526

1527
        # on update of the user's own actor/profile, set user.obj and store user back
1528
        # to datastore so that we recalculate computed properties like status etc
1529
        if is_user:
1✔
1530
            if obj.type == 'update' and crud_obj:
1✔
1531
                logger.info(f"update of the user's profile, re-storing user with obj_key {crud_obj.key.id()}")
1✔
1532
                from_user.obj = crud_obj
1✔
1533
                from_user.put()
1✔
1534

1535
        # deliver to targets
1536
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1537

1538
        # on user deleting themselves, deactivate their followers/followings.
1539
        # https://github.com/snarfed/bridgy-fed/issues/1304
1540
        #
1541
        # do this *after* delivering because delivery finds targets based on
1542
        # stored Followers
1543
        if is_user and obj.type == 'delete':
1✔
1544
            for proto in from_user.enabled_protocols:
1✔
1545
                from_user.disable_protocol(PROTOCOLS[proto])
1✔
1546

1547
            logger.info(f'Deactivating Followers from or to {from_user.key.id()}')
1✔
1548
            followers = Follower.query(
1✔
1549
                OR(Follower.to == from_user.key, Follower.from_ == from_user.key)
1550
            ).fetch()
1551
            for f in followers:
1✔
1552
                f.status = 'inactive'
1✔
1553
            ndb.put_multi(followers)
1✔
1554

1555
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1556
        return resp
1✔
1557

1558
    @classmethod
1✔
1559
    def handle_follow(from_cls, obj, from_user):
1✔
1560
        """Handles an incoming follow activity.
1561

1562
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1563
        happens in :meth:`deliver`.
1564

1565
        Args:
1566
          obj (models.Object): follow activity
1567
        """
1568
        logger.debug('Got follow. storing Follow(s), sending accept(s)')
1✔
1569
        from_id = from_user.key.id()
1✔
1570

1571
        # Prepare followee (to) users' data
1572
        to_as1s = as1.get_objects(obj.as1)
1✔
1573
        if not to_as1s:
1✔
1574
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1575

1576
        # Store Followers
1577
        for to_as1 in to_as1s:
1✔
1578
            to_id = to_as1.get('id')
1✔
1579
            if not to_id:
1✔
1580
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1581

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

1584
            to_cls = Protocol.for_id(to_id)
1✔
1585
            if not to_cls:
1✔
1586
                error(f"Couldn't determine protocol for {to_id}")
×
1587
            elif from_cls == to_cls:
1✔
1588
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1589
                continue
1✔
1590

1591
            to_key = to_cls.key_for(to_id)
1✔
1592
            if not to_key:
1✔
1593
                logger.info(f'Skipping invalid {to_cls.LABEL} user key: {to_id}')
×
1594
                continue
×
1595

1596
            to_user = to_cls.get_or_create(id=to_key.id())
1✔
1597
            if not to_user or not to_user.is_enabled(from_cls):
1✔
1598
                error(f'{to_id} not found')
1✔
1599

1600
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1601
                                                  follow=obj.key, status='active')
1602
            if (from_cls.USES_OBJECT_FEED
1✔
1603
                    and from_cls.LABEL not in to_user.has_object_feed_followers_on):
1604
                to_user.has_object_feed_followers_on.append(from_cls.LABEL)
1✔
1605
                to_user.put()
1✔
1606

1607
            obj.add('notify', to_key)
1✔
1608
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1609
                                       followee=to_user, follow=obj)
1610

1611
    @classmethod
1✔
1612
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1613
        """Sends an accept or reject activity for a follow.
1614

1615
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1616
        nothing.
1617

1618
        Args:
1619
          verb (str): ``accept`` or  ``reject``
1620
          follower (models.User)
1621
          followee (models.User)
1622
          follow (models.Object)
1623
        """
1624
        assert verb in ('accept', 'reject')
1✔
1625
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1626
            return
1✔
1627

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

1631
        # send. note that this is one response for the whole follow, even if it
1632
        # has multiple followees!
1633
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1634
        accept = {
1✔
1635
            'id': id,
1636
            'objectType': 'activity',
1637
            'verb': verb,
1638
            'actor': followee.key.id(),
1639
            'object': follow.as1,
1640
        }
1641
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1642
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1643

1644
    @classmethod
1✔
1645
    def bot_maybe_follow_back(bot_cls, user):
1✔
1646
        """Follow a user from a protocol bot user, if their protocol needs that.
1647

1648
        ...so that the protocol starts sending us their activities, if it needs
1649
        a follow for that (eg ActivityPub).
1650

1651
        Args:
1652
          user (User)
1653
        """
1654
        if not user.BOTS_FOLLOW_BACK:
1✔
1655
            return
1✔
1656

1657
        from web import Web
1✔
1658
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1659
        now = util.now().isoformat()
1✔
1660
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1661

1662
        if not user.obj:
1✔
1663
            logger.info("  can't follow, user has no profile obj")
1✔
1664
            return
1✔
1665

1666
        target = user.target_for(user.obj)
1✔
1667
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1668
        follow_back_as1 = {
1✔
1669
            'objectType': 'activity',
1670
            'verb': 'follow',
1671
            'id': follow_back_id,
1672
            'actor': bot.key.id(),
1673
            'object': user.key.id(),
1674
        }
1675
        common.create_task(queue='send', id=follow_back_id,
1✔
1676
                           our_as1=follow_back_as1, url=target,
1677
                           source_protocol='web', protocol=user.LABEL,
1678
                           user=bot.key.urlsafe())
1679

1680
    @classmethod
1✔
1681
    def handle_move(from_cls, obj, from_user):
1✔
1682
        """Handles an incoming move (account migration) activity.
1683

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

1686
        Args:
1687
          obj (models.Object): follow activity
1688
          from_user (models.User): user (actor) this activity/object is from
1689
        """
1690
        if not (target_id := as1.get_id(obj.as1, 'target')):
1✔
1691
            error(f'Move activity requires target. Got: {obj.as1}')
1✔
1692

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

1695
        # check that object is the actor (the account being moved)
1696
        actor_id = as1.get_id(obj.as1, 'actor')
1✔
1697
        object_id = as1.get_id(obj.as1, 'object')
1✔
1698
        if actor_id != object_id:
1✔
1699
            error(f"Move activity object {object_id} isn't actor {actor_id}")
1✔
1700

1701
        # get the target protocol and key
1702
        to_cls = Protocol.for_id(target_id)
1✔
1703
        if not to_cls:
1✔
1704
            error(f"Couldn't determine protocol for target {target_id}")
×
1705

1706
        to_user = to_cls.get_or_create(
1✔
1707
            target_id, manual_opt_out=False, allow_opt_out=True,
1708
            enabled_protocols=from_user.enabled_protocols)
1709
        if not to_user:
1✔
1710
            error(f"Couldn't create {to_cls.LABEL} user {target_id}", status=299)
×
1711

1712
        if from_user.enabled_protocols:
1✔
1713
            # from user has bridged copy accounts; transfer them to the new user
1714
            for label in from_user.enabled_protocols:
1✔
1715
                proto = PROTOCOLS[label]
1✔
1716
                if copy_id := from_user.get_copy(proto):
1✔
1717
                    from_user.remove_copies_on(proto)
1✔
1718
                    to_user.add('copies', Target(uri=copy_id, protocol=label))
1✔
1719

1720
            to_user.put()
1✔
1721
            from_user.enabled_protocols = []
1✔
1722
            from_user.put()
1✔
1723

1724
        # query for all active followers of the source account
1725
        followers = Follower.query(
1✔
1726
            Follower.to == from_user.key,
1727
            Follower.status == 'active'
1728
        ).fetch()
1729

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

1742
        if updated_followers:
1✔
1743
            ndb.put_multi(updated_followers)
1✔
1744

1745
    @classmethod
1✔
1746
    def handle_bare_object(cls, obj, *, authed_as, from_user):
1✔
1747
        """If obj is a bare object, wraps it in a create or update activity.
1748

1749
        Checks if we've seen it before.
1750

1751
        Args:
1752
          obj (models.Object)
1753
          authed_as (str): authenticated actor id who sent this activity
1754
          from_user (models.User): user (actor) this activity/object is from
1755

1756
        Returns:
1757
          models.Object: ``obj`` if it's an activity, otherwise a new object
1758
        """
1759
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1760
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1761
            return obj
1✔
1762

1763
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1764
        now = util.now().isoformat()
1✔
1765

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

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

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

1811
    @classmethod
1✔
1812
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1813
        """Delivers an activity to its external recipients.
1814

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

1824
        Returns:
1825
          (str, int) tuple: Flask response
1826
        """
1827
        if to_proto:
1✔
1828
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1829

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

1845
        # store object that targets() updated
1846
        if crud_obj and crud_obj.dirty:
1✔
1847
            crud_obj.put()
1✔
1848
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1849
            obj.put()
1✔
1850

1851
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1852
                      else obj.to_request())
1853

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

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

1870
        return 'OK', 202
1✔
1871

1872
    @classmethod
1✔
1873
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1874
        """Collects the targets to send a :class:`models.Object` to.
1875

1876
        Targets are both objects - original posts, events, etc - and actors.
1877

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

1886
        Returns:
1887
          dict: maps :class:`models.Target` to original (in response to)
1888
          :class:`models.Object`
1889
        """
1890
        logger.debug('Finding recipients and their targets')
1✔
1891

1892
        # we should only have crud_obj iff this is a create or update
1893
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1894
        write_obj = crud_obj or obj
1✔
1895
        write_obj.dirty = False
1✔
1896

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

1910
        original_ids = []
1✔
1911
        if is_reply:
1✔
1912
            original_ids = in_reply_tos
1✔
1913
        elif inner_obj_id:
1✔
1914
            if inner_obj_id == from_user.key.id():
1✔
1915
                inner_obj_id = from_user.profile_id()
1✔
1916
            original_ids = [inner_obj_id]
1✔
1917

1918
        # maps id to Object
1919
        original_objs = {}
1✔
1920
        for id in original_ids:
1✔
1921
            if proto := Protocol.for_id(id):
1✔
1922
                original_objs[id] = proto.load(id, raise_=False)
1✔
1923

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

1938
        target_uris = sorted(set(target_uris))
1✔
1939
        logger.info(f'Raw targets: {target_uris}')
1✔
1940

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

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

1953
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1954
                                     or is_reply):
1955
                origs_could_bridge = None
1✔
1956

1957
                for id in original_ids:
1✔
1958
                    if not (orig := original_objs.get(id)):
1✔
1959
                        continue
1✔
1960
                    elif orig.get_copy(proto):
1✔
1961
                        logger.info(f'Allowing {label}, original {id} was bridged there')
1✔
1962
                        break
1✔
1963
                    elif from_user.is_profile(orig):
1✔
1964
                        logger.info(f"Allowing {label}, this is the user's profile")
1✔
1965
                        break
1✔
1966

1967
                    if (origs_could_bridge is not False
1✔
1968
                            and (orig_author_id := as1.get_owner(orig.as1))
1969
                            and (orig_proto := orig.owner_protocol())
1970
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1971
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1972

1973
                else:
1974
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1975
                    last_retry = False
1✔
1976
                    if retries := request.headers.get(TASK_RETRIES_HEADER):
1✔
1977
                        if (last_retry := int(retries) >= TASK_RETRIES_RECEIVE):
1✔
1978
                            logger.info(f'last retry! skipping {proto.LABEL} and continuing')
1✔
1979

1980
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
1✔
1981
                            and origs_could_bridge and not last_retry):
1982
                        # retry later; original obj may still be bridging
1983
                        # TODO: limit to brief window, eg no older than 2h? 1d?
1984
                        error(msg, status=304)
1✔
1985

1986
                    logger.info(msg)
1✔
1987
                    continue
1✔
1988

1989
            util.add(to_protocols, proto)
1✔
1990

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

1993
        # process direct targets
1994
        for target_id in target_uris:
1✔
1995
            target_proto = Protocol.for_id(target_id)
1✔
1996
            if not target_proto:
1✔
1997
                logger.info(f"Can't determine protocol for {target_id}")
1✔
1998
                continue
1✔
1999
            elif target_proto.is_blocklisted(target_id):
1✔
2000
                logger.debug(f'{target_id} is blocklisted')
1✔
2001
                continue
1✔
2002

2003
            target_is_actor = (target_id in mentioned_urls
1✔
2004
                               or obj.type in as1.VERBS_WITH_ACTOR_OBJECT)
2005

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

2018
            target_author_key = (target_proto(id=target_id).key if target_is_actor
1✔
2019
                                 else target_proto.actor_key(orig_obj))
2020

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

2037
                continue
1✔
2038

2039
            # deliver self-replies to followers
2040
            # https://github.com/snarfed/bridgy-fed/issues/639
2041
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
2042
                is_self_reply = True
1✔
2043
                logger.info(f'self reply!')
1✔
2044

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

2055
            if target_proto == from_cls:
1✔
2056
                logger.debug(f'Skipping same-protocol target {target_id}')
1✔
2057
                continue
1✔
2058

2059
            target = target_proto.target_for(orig_obj)
1✔
2060
            if not target:
1✔
2061
                # TODO: surface errors like this somehow?
2062
                logger.error(f"Can't find delivery target for {target_id}")
×
2063
                continue
×
2064

2065
            target = util.normalize_url(target, trailing_slash=False)
1✔
2066
            logger.debug(f'Target for {target_id} is {target} {target_author_key}')
1✔
2067

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

2078
            if target_author_key:
1✔
2079
                logger.debug(f'Recipient is {target_author_key}')
1✔
2080
                if obj.type not in DONT_NOTIFY_TYPES:
1✔
2081
                    if write_obj.add('notify', target_author_key):
1✔
2082
                        write_obj.dirty = True
1✔
2083

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

2096
        if not to_protocols:
1✔
2097
            return {}
1✔
2098

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

2101
        # deliver to followers, if appropriate
2102
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
2103
        if not user_key:
1✔
2104
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
2105
            return targets
1✔
2106

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

2131
            logger.debug(f'  loaded {len(followers)} followers')
1✔
2132

2133
            user_keys = [f.from_ for f in followers]
1✔
2134
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
2135
            logger.debug(f'  loaded {len(users)} users')
1✔
2136

2137
            User.load_multi(users)
1✔
2138
            logger.debug(f'  loaded user objects')
1✔
2139

2140
            if (not followers and
1✔
2141
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
2142
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
2143
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
2144
                return {}
1✔
2145

2146
            # add to followers' feeds, if any
2147
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
2148
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
2149
                    write_obj.feed = [
1✔
2150
                        u.key for u in users
2151
                        if u.USES_OBJECT_FEED or u.key.id() in common.BETA_USER_IDS
2152
                    ]
2153
                    if write_obj.feed:
1✔
2154
                        write_obj.dirty = True
1✔
2155

2156
            # collect targets for followers
2157
            target_obj = (original_objs.get(inner_obj_id)
1✔
2158
                          if obj.type == 'share' else None)
2159
            for user in users:
1✔
2160
                if user.is_blocking(from_user):
1✔
2161
                    logger.debug(f'  {user.key.id()} blocks {from_user.key.id()}')
1✔
2162
                    continue
1✔
2163

2164
                # TODO: should we pass remote=False through here to Protocol.load?
2165
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
2166
                if not target:
1✔
2167
                    continue
1✔
2168

2169
                target = util.normalize_url(target, trailing_slash=False)
1✔
2170
                targets[Target(protocol=user.LABEL, uri=target)] = target_obj
1✔
2171

2172
            logger.debug(f'  collected {len(targets)} targets')
1✔
2173

2174
        # deliver to enabled HAS_COPIES protocols proactively
2175
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
2176
            for proto in to_protocols:
1✔
2177
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
2178
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
2179
                    targets.setdefault(
1✔
2180
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
2181

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

2203
            target, obj = candidates[url]
1✔
2204
            targets[target] = obj
1✔
2205

2206
        return targets
1✔
2207

2208
    @classmethod
1✔
2209
    def load(cls, id, remote=None, local=True, raise_=True, raw=False, csv=False,
1✔
2210
             **kwargs):
2211
        """Loads and returns an Object from datastore or HTTP fetch.
2212

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

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

2235
        Returns:
2236
          models.Object: loaded object, or None if it isn't fetchable, eg a
2237
          non-URL string for Web, or ``remote`` is False and it isn't in the
2238
          datastore
2239

2240
        Raises:
2241
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
2242
            is True
2243
        """
2244
        assert id
1✔
2245
        assert local or remote is not False
1✔
2246
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
2247

2248
        if not raw:
1✔
2249
            id = ids.normalize_object_id(id=id, proto=cls)
1✔
2250

2251
        obj = orig_as1 = None
1✔
2252
        if local:
1✔
2253
            if obj := Object.get_by_id(id):
1✔
2254
                if csv and not obj.is_csv:
1✔
2255
                    return None
1✔
2256
                elif obj.as1 or obj.csv or obj.raw or obj.deleted:
1✔
2257
                    # logger.debug(f'  {id} got from datastore')
2258
                    obj.new = False
1✔
2259

2260
        if remote is False:
1✔
2261
            return obj
1✔
2262
        elif remote is None and obj:
1✔
2263
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
2264
                # logger.debug(f'  last updated {obj.updated}, refreshing')
2265
                pass
1✔
2266
            else:
2267
                return obj
1✔
2268

2269
        if obj:
1✔
2270
            orig_as1 = obj.as1
1✔
2271
            obj.our_as1 = None
1✔
2272
            obj.new = False
1✔
2273
        else:
2274
            if cls == Protocol:
1✔
2275
                return None
1✔
2276
            obj = Object(id=id)
1✔
2277
            if local:
1✔
2278
                # logger.debug(f'  {id} not in datastore')
2279
                obj.new = True
1✔
2280
                obj.changed = False
1✔
2281

2282
        try:
1✔
2283
            fetched = cls.fetch(obj, csv=csv, **kwargs)
1✔
2284
        except (RequestException, HTTPException, InvalidStatus) as e:
1✔
2285
            if raise_:
1✔
2286
                raise
1✔
2287
            util.interpret_http_exception(e)
1✔
2288
            return None
1✔
2289

2290
        if not fetched:
1✔
2291
            return None
1✔
2292
        elif csv and not obj.is_csv:
1✔
2293
            return None
×
2294

2295
        # https://stackoverflow.com/a/3042250/186123
2296
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
2297
        if size > MAX_ENTITY_SIZE:
1✔
2298
            logger.warning(f'Object is too big! {size} bytes is over {MAX_ENTITY_SIZE}')
1✔
2299
            return None
1✔
2300

2301
        obj.resolve_ids()
1✔
2302
        obj.normalize_ids()
1✔
2303

2304
        if obj.new is False:
1✔
2305
            obj.changed = obj.activity_changed(orig_as1)
1✔
2306

2307
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
2308
            if obj.source_protocol:
1✔
2309
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
2310
            obj.source_protocol = cls.LABEL
1✔
2311

2312
        obj.put()
1✔
2313
        return obj
1✔
2314

2315
    @classmethod
1✔
2316
    def check_supported(cls, obj, direction):
1✔
2317
        """If this protocol doesn't support this activity, raises HTTP 204.
2318

2319
        Also reports an error.
2320

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

2325
        Args:
2326
          obj (Object)
2327
          direction (str): ``'receive'`` or  ``'send'``
2328

2329
        Raises:
2330
          werkzeug.HTTPException: if this protocol doesn't support this object
2331
        """
2332
        assert direction in ('receive', 'send')
1✔
2333
        if not obj.type:
1✔
2334
            return
×
2335

2336
        inner = as1.get_object(obj.as1)
1✔
2337
        inner_type = as1.object_type(inner) or ''
1✔
2338
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
2339
            or (obj.type in as1.CRUD_VERBS
2340
                and inner_type
2341
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
2342
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
2343

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

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

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

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

2384
    @classmethod
1✔
2385
    def block(cls, from_user, arg):
1✔
2386
        """Blocks a user or list.
2387

2388
        Args:
2389
          from_user (models.User): user doing the blocking
2390
          arg (str): handle or id of user/list to block
2391

2392
        Returns:
2393
          models.User or models.Object: user or list that was blocked
2394

2395
        Raises:
2396
          ValueError: if arg doesn't look like a user or list on this protocol
2397
        """
2398
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2399

2400
        def fail(msg):
1✔
2401
            logger.warning(msg)
1✔
2402
            raise ValueError(msg)
1✔
2403

2404
        blockee = None
1✔
2405
        try:
1✔
2406
            # first, try interpreting as a user handle or id
2407
            blockee = load_user(arg, proto=cls, create=True, allow_opt_out=True)
1✔
2408
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2409
            logger.info(err)
1✔
2410

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

2414
        # may not be a user, see if it's a list
2415
        if not blockee:
1✔
2416
            if not cls or cls == Protocol:
1✔
2417
                cls = Protocol.for_id(arg)
1✔
2418

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

2427
        logger.info(f'  blocking {blockee.key.id()}')
1✔
2428
        id = f'{from_user.profile_id()}#bridgy-fed-block-{util.now().isoformat()}'
1✔
2429
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2430
            'objectType': 'activity',
2431
            'verb': 'block',
2432
            'id': id,
2433
            'actor': from_user.key.id(),
2434
            'object': blockee.key.id(),
2435
        })
2436
        obj.put()
1✔
2437
        from_user.deliver(obj, from_user=from_user)
1✔
2438

2439
        return blockee
1✔
2440

2441
    @classmethod
1✔
2442
    def unblock(cls, from_user, arg):
1✔
2443
        """Unblocks a user or list.
2444

2445
        Args:
2446
          from_user (models.User): user doing the unblocking
2447
          arg (str): handle or id of user/list to unblock
2448

2449
        Returns:
2450
          models.User or models.Object: user or list that was unblocked
2451

2452
        Raises:
2453
          ValueError: if arg doesn't look like a user or list on this protocol
2454
        """
2455
        logger.info(f'user {from_user.key.id()} trying to unblock {arg}')
1✔
2456
        def fail(msg):
1✔
2457
            logger.warning(msg)
1✔
2458
            raise ValueError(msg)
1✔
2459

2460
        blockee = None
1✔
2461
        try:
1✔
2462
            # first, try interpreting as a user handle or id
2463
            blockee = load_user(arg, cls, create=True, allow_opt_out=True)
1✔
2464
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2465
            logger.info(err)
1✔
2466

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

2470
        # may not be a user, see if it's a list
2471
        if not blockee:
1✔
2472
            if not cls or cls == Protocol:
1✔
2473
                cls = Protocol.for_id(arg)
1✔
2474

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

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

2500
        return blockee
1✔
2501

2502

2503
@cloud_tasks_only(log=None)
1✔
2504
def receive_task():
1✔
2505
    """Task handler for a newly received :class:`models.Object`.
2506

2507
    Calls :meth:`Protocol.receive` with the form parameters.
2508

2509
    Parameters:
2510
      authed_as (str): passed to :meth:`Protocol.receive`
2511
      obj_id (str): key id of :class:`models.Object` to handle
2512
      received_at (str, ISO 8601 timestamp): when we first saw (received)
2513
        this activity
2514
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2515
        :class:`models.Object` to handle
2516

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

2526
    authed_as = form.pop('authed_as', None)
1✔
2527
    internal = authed_as == PRIMARY_DOMAIN or authed_as in PROTOCOL_DOMAINS
1✔
2528

2529
    obj = Object.from_request()
1✔
2530
    assert obj
1✔
2531
    assert obj.source_protocol
1✔
2532
    obj.new = True
1✔
2533

2534
    if received_at := form.pop('received_at', None):
1✔
2535
        received_at = datetime.fromisoformat(received_at)
1✔
2536

2537
    try:
1✔
2538
        return PROTOCOLS[obj.source_protocol].receive(
1✔
2539
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
2540
    except RequestException as e:
1✔
2541
        util.interpret_http_exception(e)
1✔
2542
        error(e, status=304)
1✔
2543
    except (RuntimeError, ValueError) as e:
1✔
2544
        logger.warning(e, exc_info=True)
×
2545
        error(e, status=304)
×
2546

2547

2548
@cloud_tasks_only(log=None)
1✔
2549
def send_task():
1✔
2550
    """Task handler for sending an activity to a single specific destination.
2551

2552
    Calls :meth:`Protocol.send` with the form parameters.
2553

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

2571
    # prepare
2572
    form = request.form.to_dict()
1✔
2573
    url = form.get('url')
1✔
2574
    protocol = form.get('protocol')
1✔
2575
    if not url or not protocol:
1✔
2576
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
2577
        return '', 204
1✔
2578

2579
    target = Target(uri=url, protocol=protocol)
1✔
2580
    obj = Object.from_request()
1✔
2581
    assert obj and obj.key and obj.key.id()
1✔
2582

2583
    PROTOCOLS[protocol].check_supported(obj, 'send')
1✔
2584
    allow_opt_out = (obj.type == 'delete')
1✔
2585

2586
    user = None
1✔
2587
    if user_key := form.get('user'):
1✔
2588
        key = ndb.Key(urlsafe=user_key)
1✔
2589
        # use get_by_id so that we follow use_instead
2590
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2591
            key.id(), allow_opt_out=allow_opt_out)
2592

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

2617
    if sent is False:
1✔
2618
        logger.info(f'Failed sending!')
1✔
2619

2620
    return '', 200 if sent else 204 if sent is False else 304
1✔
2621

2622

2623
@cloud_tasks_only(log=None)
1✔
2624
def user_enabled_task():
1✔
2625
    r"""Task handler for when a user enables a protocol.
2626

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

2631
    Parameters:
2632
      user (url-safe google.cloud.ndb.key.Key): the :class:`models.User` who
2633
        enabled bridging
2634
      protocol (str): ``LABEL`` of the protocol they enabled
2635
    """
2636
    common.log_request()
1✔
2637

2638
    proto = PROTOCOLS[request.form['protocol']]
1✔
2639
    user = ndb.Key(urlsafe=request.form['user']).get()
1✔
2640
    assert user
1✔
2641
    logger.info(f'{user.key.id()} is {user.status or "ok"}')
1✔
2642
    if user.status:
1✔
2643
        raise ErrorButDoNotRetryTask()
×
2644

2645
    followers = Follower.query(Follower.to == user.key,
1✔
2646
                               Follower.status == 'dormant').fetch()
2647
    from_users = ndb.get_multi(
1✔
2648
        f.from_ for f in followers if f.from_.kind() == proto._get_kind())
2649

2650
    for follower, from_user in zip(followers, from_users):
1✔
2651
        if from_user and not from_user.status:
1✔
2652
            logger.info('Updating and DMing Follower from {from_user.key.id()}')
1✔
2653
            follower.status = 'inactive'
1✔
2654
            follower.put()
1✔
2655

2656
            relationship = {
1✔
2657
                'bounce': ', who you originally followed before you Bounced,',
2658
                'requested': ', who you asked to bridge,',
2659
            }.get(follower.reason, '')
2660
            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✔
2661

2662
    return '', 200
1✔
2663

2664

2665
@cloud_tasks_only(log=None)
1✔
2666
def migrate_out_task():
1✔
2667
    """Task handler for finishing a migration out.
2668

2669
    Currently, for migrating out to ATProto, uploads the user's blobs to the new PDS.
2670
    Otherwise, does nothing.
2671

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

2681
    common.log_request()
1✔
2682

2683
    user = ndb.Key(urlsafe=request.form['user']).get()
1✔
2684
    if not user:
1✔
2685
        raise ErrorButDoNotRetryTask()
×
2686

2687
    if request.form['protocol'] == ATProto.LABEL:
1✔
2688
        auth = ndb.Key(urlsafe=request.form['auth']).get()
1✔
2689
        assert auth
1✔
2690
        ATProto.migrate_out_blobs(user, auth)
1✔
2691

2692
    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