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

snarfed / bridgy-fed / ab85911c-744b-4e76-98fd-e555da1f75b1

06 Mar 2026 09:53PM UTC coverage: 0.095% (-93.2%) from 93.26%
ab85911c-744b-4e76-98fd-e555da1f75b1

push

circleci

snarfed
circle: parallelize tests with pytest-xdist

re 3b73fc947

7 of 7343 relevant lines covered (0.1%)

0.0 hits per line

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

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

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

33
import common
×
34
from common import (
×
35
    ErrorButDoNotRetryTask,
36
    report_error,
37
)
38
from domains import (
×
39
    DOMAINS,
40
    LOCAL_DOMAINS,
41
    PRIMARY_DOMAIN,
42
    PROTOCOL_DOMAINS,
43
    SUPERDOMAIN,
44
)
45
import dms
×
46
from domains import DOMAIN_BLOCKLIST
×
47
import ids
×
48
import memcache
×
49
from models import (
×
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
×
60

61
OBJECT_REFRESH_AGE = timedelta(days=30)
×
62
DELETE_TASK_DELAY = timedelta(minutes=1)
×
63
CREATE_MAX_AGE = timedelta(weeks=2)
×
64
CREATE_MAX_AGE_EXEMPT_DOMAINS = (
×
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)
×
69
MEMCACHE_DOWN_TASK_DELAY = timedelta(minutes=5)
×
70
# WARNING: keep this in sync with queue.yaml's receive and webmention task_retry_limit!
71
TASK_RETRIES_RECEIVE = 4
×
72
# https://docs.cloud.google.com/tasks/docs/creating-appengine-handlers#reading-headers
73
TASK_RETRIES_HEADER = 'X-AppEngine-TaskRetryCount'
×
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()
×
78
                   or util.load_file_lines('limited_domains'))
79

80
# domains to allow non-public activities from
81
NON_PUBLIC_DOMAINS = (
×
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((
×
88
    'accept',
89
    'reject',
90
    'stop-following',
91
    'undo',
92
))
93
STORE_AS1_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES | as1.VERBS_WITH_OBJECT
×
94
                   - DONT_STORE_AS1_TYPES)
95

96
logger = logging.getLogger(__name__)
×
97

98

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

103

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

107

108
class Protocol:
×
109
    """Base protocol class. Not to be instantiated; classmethods only."""
110
    ABBREV = None
×
111
    """str: lower case abbreviation, used in URL paths"""
×
112
    PHRASE = None
×
113
    """str: human-readable name or phrase. Used in phrases like ``Follow this person on {PHRASE}``"""
×
114
    OTHER_LABELS = ()
×
115
    """sequence of str: label aliases"""
×
116
    LOGO_EMOJI = ''
×
117
    """str: logo emoji, if any"""
×
118
    LOGO_HTML = ''
×
119
    """str: logo ``<img>`` tag, if any"""
×
120
    CONTENT_TYPE = None
×
121
    """str: MIME type of this protocol's native data format, appropriate for the ``Content-Type`` HTTP header."""
×
122
    HAS_COPIES = False
×
123
    """bool: whether this protocol is push and needs us to proactively create "copy" users and objects, as opposed to pulling converted objects on demand"""
×
124
    DEFAULT_TARGET = None
×
125
    """str: optional, the default target URI to send this protocol's activities to. May be used as the "shared" target. Often only set if ``HAS_COPIES`` is true."""
×
126
    REQUIRES_AVATAR = False
×
127
    """bool: whether accounts on this protocol are required to have a profile picture. If they don't, their ``User.status`` will be ``blocked``."""
×
128
    REQUIRES_NAME = False
×
129
    """bool: whether accounts on this protocol are required to have a profile name that's different than their handle or id. If they don't, their ``User.status`` will be ``blocked``."""
×
130
    REQUIRES_OLD_ACCOUNT = False
×
131
    """bool: whether accounts on this protocol are required to be at least :const:`common.OLD_ACCOUNT_AGE` old. If their profile includes creation date and it's not old enough, their ``User.status`` will be ``blocked``."""
×
132
    DEFAULT_ENABLED_PROTOCOLS = ()
×
133
    """sequence of str: labels of other protocols that are automatically enabled for this protocol to bridge into"""
×
134
    DEFAULT_SERVE_USER_PAGES = False
×
135
    """bool: whether to serve user pages for all of this protocol's users on the fed.brid.gy. If ``False``, user pages will only be served for users who have explictly opted in."""
×
136
    SUPPORTED_AS1_TYPES = ()
×
137
    """sequence of str: AS1 objectTypes and verbs that this protocol supports receiving and sending"""
×
138
    SUPPORTS_DMS = False
×
139
    """bool: whether this protocol can receive DMs (chat messages)"""
×
140
    USES_OBJECT_FEED = False
×
141
    """bool: whether to store followers on this protocol in :attr:`Object.feed`."""
×
142
    HTML_PROFILES = False
×
143
    """bool: whether this protocol supports HTML in profile descriptions. If False, profile descriptions should be plain text."""
×
144
    SEND_REPLIES_TO_ORIG_POSTS_MENTIONS = False
×
145
    """bool: whether replies to this protocol should include the original post's mentions as delivery targets"""
×
146
    BOTS_FOLLOW_BACK = False
×
147
    """bool: when a user on this protocol follows a bot user to enable bridging, does the bot follow them back?"""
×
148
    HANDLES_PER_PAY_LEVEL_DOMAIN = None
×
149
    """int: how many users to allow with handles on the same pay-level domain. None for no limit."""
×
150

151
    @classmethod
×
152
    @property
×
153
    def LABEL(cls):
×
154
        """str: human-readable lower case name of this protocol, eg ``'activitypub``"""
155
        return cls.__name__.lower()
×
156

157
    @staticmethod
×
158
    def for_request(fed=None):
×
159
        """Returns the protocol for the current request.
160

161
        ...based on the request's hostname.
162

163
        Args:
164
          fed (str or protocol.Protocol): protocol to return if the current
165
            request is on ``fed.brid.gy``
166

167
        Returns:
168
          Protocol: protocol, or None if the provided domain or request hostname
169
          domain is not a subdomain of ``brid.gy`` or isn't a known protocol
170
        """
171
        return Protocol.for_bridgy_subdomain(request.host, fed=fed)
×
172

173
    @staticmethod
×
174
    def for_bridgy_subdomain(domain_or_url, fed=None):
×
175
        """Returns the protocol for a brid.gy subdomain.
176

177
        Args:
178
          domain_or_url (str)
179
          fed (str or protocol.Protocol): protocol to return if the current
180
            request is on ``fed.brid.gy``
181

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

191
        if domain == PRIMARY_DOMAIN or domain in LOCAL_DOMAINS:
×
192
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
×
193
        elif domain and domain.endswith(SUPERDOMAIN):
×
194
            label = domain.removesuffix(SUPERDOMAIN)
×
195
            return PROTOCOLS.get(label)
×
196

197
    @classmethod
×
198
    def owns_id(cls, id):
×
199
        """Returns whether this protocol owns the id, or None if it's unclear.
200

201
        To be implemented by subclasses.
202

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

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

212
        This should be a quick guess without expensive side effects, eg no
213
        external HTTP fetches to fetch the id itself or otherwise perform
214
        discovery.
215

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

218
        Args:
219
          id (str): user id or object id
220

221
        Returns:
222
          bool or None:
223
        """
224
        return False
×
225

226
    @classmethod
×
227
    def owns_handle(cls, handle, allow_internal=False):
×
228
        """Returns whether this protocol owns the handle, or None if it's unclear.
229

230
        To be implemented by subclasses.
231

232
        Handles are string identities that are human-chosen, human-meaningful,
233
        and often but not always unique. Compare to IDs, which uniquely identify
234
        users, and are intended primarily to be machine readable and usable.
235

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

241
        This should be a quick guess without expensive side effects, eg no
242
        external HTTP fetches to fetch the id itself or otherwise perform
243
        discovery.
244

245
        Args:
246
          handle (str)
247
          allow_internal (bool): whether to return False for internal domains
248
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
249

250
        Returns:
251
          bool or None
252
        """
253
        return False
×
254

255
    @classmethod
×
256
    def handle_to_id(cls, handle):
×
257
        """Converts a handle to an id.
258

259
        To be implemented by subclasses.
260

261
        May incur network requests, eg DNS queries or HTTP requests. Avoids
262
        blocked or opted out users.
263

264
        Args:
265
          handle (str)
266

267
        Returns:
268
          str: corresponding id, or None if the handle can't be found
269
        """
270
        raise NotImplementedError()
×
271

272
    @classmethod
×
273
    def authed_user_for_request(cls):
×
274
        """Returns the authenticated user id for the current request.
275

276

277
        Checks authentication on the current request, eg HTTP Signature for
278
        ActivityPub. To be implemented by subclasses.
279

280
        Returns:
281
          str: authenticated user id, or None if there is no authentication
282

283
        Raises:
284
          RuntimeError: if the request's authentication (eg signature) is
285
          invalid or otherwise can't be verified
286
        """
287
        return None
×
288

289
    @classmethod
×
290
    def key_for(cls, id, allow_opt_out=False):
×
291
        """Returns the :class:`google.cloud.ndb.Key` for a given id's :class:`models.User`.
292

293
        If called via `Protocol.key_for`, infers the appropriate protocol with
294
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
295
        as is.
296

297
        Args:
298
          id (str):
299
          allow_opt_out (bool): whether to allow users who are currently opted out
300

301
        Returns:
302
          google.cloud.ndb.Key: matching key, or None if the given id is not a
303
          valid :class:`User` id for this protocol.
304
        """
305
        if cls == Protocol:
×
306
            proto = Protocol.for_id(id)
×
307
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
×
308

309
        # load user so that we follow use_instead
310
        existing = cls.get_by_id(id, allow_opt_out=True)
×
311
        if existing:
×
312
            if existing.status and not allow_opt_out:
×
313
                return None
×
314
            return existing.key
×
315

316
        return cls(id=id).key
×
317

318
    @staticmethod
×
319
    def _for_id_memcache_key(id, remote=None):
×
320
        """If id is a URL, uses its domain, otherwise returns None.
321

322
        Args:
323
          id (str)
324

325
        Returns:
326
          (str domain, bool remote) or None
327
        """
328
        domain = util.domain_from_link(id)
×
329
        if domain in PROTOCOL_DOMAINS:
×
330
            return id
×
331
        elif remote and util.is_web(id):
×
332
            return domain
×
333

334
    @cached(LRUCache(20000), lock=Lock())
×
335
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote=True: remote,
×
336
                      version=3)
337
    @staticmethod
×
338
    def for_id(id, remote=True):
×
339
        """Returns the protocol for a given id.
340

341
        Args:
342
          id (str)
343
          remote (bool): whether to perform expensive side effects like fetching
344
            the id itself over the network, or other discovery.
345

346
        Returns:
347
          Protocol subclass: matching protocol, or None if no single known
348
          protocol definitively owns this id
349
        """
350
        logger.debug(f'Determining protocol for id {id}')
×
351
        if not id:
×
352
            return None
×
353

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

363
        if util.is_web(id):
×
364
            # step 1: check for our per-protocol subdomains
365
            try:
×
366
                parsed = urlparse(id)
×
367
            except ValueError as e:
×
368
                logger.info(f'urlparse ValueError: {e}')
×
369
                return None
×
370

371
            is_homepage = parsed.path.strip('/') == ''
×
372
            is_internal = parsed.path.startswith(ids.INTERNAL_PATH_PREFIX)
×
373
            by_subdomain = Protocol.for_bridgy_subdomain(id)
×
374
            if by_subdomain and not (is_homepage or is_internal
×
375
                                     or id in ids.BOT_ACTOR_AP_IDS):
376
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
×
377
                return by_subdomain
×
378

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

392
        if len(candidates) == 1:
×
393
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
×
394
            return candidates[0]
×
395

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

407
        # step 4: fetch over the network, if necessary
408
        if not remote:
×
409
            return None
×
410

411
        for protocol in candidates:
×
412
            logger.debug(f'Trying {protocol.LABEL}')
×
413
            try:
×
414
                obj = protocol.load(id, local=False, remote=True)
×
415

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

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

442
        logger.info(f'No matching protocol found for {id} !')
×
443
        return None
×
444

445
    @cached(LRUCache(20000), lock=Lock())
×
446
    @staticmethod
×
447
    def for_handle(handle):
×
448
        """Returns the protocol for a given handle.
449

450
        May incur expensive side effects like resolving the handle itself over
451
        the network or other discovery.
452

453
        Args:
454
          handle (str)
455

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

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

478
        if len(candidates) == 1:
×
479
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
480
            return (candidates[0], None)
×
481

482
        # step 2: look for matching User in the datastore
483
        for proto in candidates:
×
484
            user = proto.query(proto.handle == handle).get()
×
485
            if user:
×
486
                if user.status:
×
487
                    return (None, None)
×
488
                logger.debug(f'  user {user.key} handle {handle}')
×
489
                return (proto, user.key.id())
×
490

491
        # step 3: resolve handle to id
492
        for proto in candidates:
×
493
            id = proto.handle_to_id(handle)
×
494
            if id:
×
495
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
×
496
                return (proto, id)
×
497

498
        logger.info(f'No matching protocol found for handle {handle} !')
×
499
        return (None, None)
×
500

501
    @classmethod
×
502
    def is_user_at_domain(cls, handle, allow_internal=False):
×
503
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
504

505
        Example: ``@user@instance.com``
506

507
        Args:
508
          handle (str)
509
          allow_internal (bool): whether the domain can be a Bridgy Fed domain
510
        """
511
        parts = handle.split('@')
×
512
        if len(parts) != 2:
×
513
            return False
×
514

515
        user, domain = parts
×
516
        return bool(user and domain
×
517
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
518

519
    @classmethod
×
520
    def bridged_web_url_for(cls, user, fallback=False):
×
521
        """Returns the web URL for a user's bridged profile in this protocol.
522

523
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
524
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
525

526
        Args:
527
          user (models.User)
528
          fallback (bool): if True, and bridged users have no canonical user
529
            profile URL in this protocol, return the native protocol's profile URL
530

531
        Returns:
532
          str, or None if there isn't a canonical URL
533
        """
534
        if fallback:
×
535
            return user.web_url()
×
536

537
    @classmethod
×
538
    def actor_key(cls, obj, allow_opt_out=False):
×
539
        """Returns the :class:`User`: key for a given object's author or actor.
540

541
        Args:
542
          obj (models.Object)
543
          allow_opt_out (bool): whether to return a user key if they're opted out
544

545
        Returns:
546
          google.cloud.ndb.key.Key or None:
547
        """
548
        owner = as1.get_owner(obj.as1)
×
549
        if owner:
×
550
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
×
551

552
    @classmethod
×
553
    def bot_user_id(cls):
×
554
        """Returns the Web user id for the bot user for this protocol.
555

556
        For example, ``'bsky.brid.gy'`` for ATProto.
557

558
        Returns:
559
          str:
560
        """
561
        return f'{cls.ABBREV}{SUPERDOMAIN}'
×
562

563
    @classmethod
×
564
    def create_for(cls, user):
×
565
        """Creates or re-activate a copy user in this protocol.
566

567
        Should add the copy user to :attr:`copies`.
568

569
        If the copy user already exists and active, should do nothing.
570

571
        Args:
572
          user (models.User): original source user. Shouldn't already have a
573
            copy user for this protocol in :attr:`copies`.
574

575
        Raises:
576
          ValueError: if we can't create a copy of the given user in this protocol
577
        """
578
        raise NotImplementedError()
×
579

580
    @classmethod
×
581
    def send(to_cls, obj, target, from_user=None, orig_obj_id=None):
×
582
        """Sends an outgoing activity.
583

584
        To be implemented by subclasses. Should call
585
        ``to_cls.translate_ids(obj.as1)`` before converting it to this Protocol's
586
        format.
587

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

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

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

605
        Raises:
606
          werkzeug.HTTPException if the request fails
607
        """
608
        raise NotImplementedError()
×
609

610
    @classmethod
×
611
    def fetch(cls, obj, **kwargs):
×
612
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
613

614
        Errors are raised as exceptions. If this method returns False, the fetch
615
        didn't fail but didn't succeed either, eg the id isn't valid for this
616
        protocol, or the fetch didn't return valid data for this protocol.
617

618
        To be implemented by subclasses.
619

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

625
        Returns:
626
          bool: True if the object was fetched and populated successfully,
627
          False otherwise
628

629
        Raises:
630
          requests.RequestException, werkzeug.HTTPException,
631
          websockets.WebSocketException, etc: if the fetch fails
632
        """
633
        raise NotImplementedError()
×
634

635
    @classmethod
×
636
    def convert(cls, obj, from_user=None, **kwargs):
×
637
        """Converts an :class:`Object` to this protocol's data format.
638

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

642
        Just passes through to :meth:`_convert`, then does minor
643
        protocol-independent postprocessing.
644

645
        Args:
646
          obj (models.Object):
647
          from_user (models.User): user (actor) this activity/object is from
648
          kwargs: protocol-specific, passed through to :meth:`_convert`
649

650
        Returns:
651
          converted object in the protocol's native format, often a dict
652
        """
653
        if not obj or not obj.as1:
×
654
            return {}
×
655

656
        id = obj.key.id() if obj.key else obj.as1.get('id')
×
657
        is_crud = obj.as1.get('verb') in as1.CRUD_VERBS
×
658
        base_obj = as1.get_object(obj.as1) if is_crud else obj.as1
×
659
        orig_our_as1 = obj.our_as1
×
660

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

672
        converted = cls._convert(obj, from_user=from_user, **kwargs)
×
673
        obj.our_as1 = orig_our_as1
×
674
        return converted
×
675

676
    @classmethod
×
677
    def _convert(cls, obj, from_user=None, **kwargs):
×
678
        """Converts an :class:`Object` to this protocol's data format.
679

680
        To be implemented by subclasses. Implementations should generally call
681
        :meth:`Protocol.translate_ids` (as their own class) before converting to
682
        their format.
683

684
        Args:
685
          obj (models.Object):
686
          from_user (models.User): user (actor) this activity/object is from
687
          kwargs: protocol-specific
688

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

695
    @classmethod
×
696
    def add_source_links(cls, obj, from_user):
×
697
        """Adds "bridged from ... by Bridgy Fed" to the user's actor's ``summary``.
698

699
        Uses HTML for protocols that support it, plain text otherwise.
700

701
        Args:
702
          cls (Protocol subclass): protocol that the user is bridging into
703
          obj (models.Object): user's actor/profile object
704
          from_user (models.User): user (actor) this activity/object is from
705
        """
706
        assert obj and obj.as1
×
707
        assert from_user
×
708

709
        obj.our_as1 = copy.deepcopy(obj.as1)
×
710
        actor = (as1.get_object(obj.as1) if obj.type in as1.CRUD_VERBS
×
711
                 else obj.as1)
712
        actor['objectType'] = 'person'
×
713

714
        orig_summary = actor.setdefault('summary', '')
×
715
        summary_text = html_to_text(orig_summary, ignore_links=True)
×
716

717
        # Check if we've already added source links
718
        if '🌉 bridged' in summary_text:
×
719
            return
×
720

721
        actor_id = actor.get('id')
×
722

723
        url = (as1.get_url(actor)
×
724
               or (from_user.web_url() if from_user.profile_id() == actor_id
725
                   else actor_id))
726

727
        from web import Web
×
728
        bot_user = Web.get_by_id(from_user.bot_user_id())
×
729

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

737
            separator = '<br><br>'
×
738

739
            is_user = from_user.key and actor_id in (from_user.key.id(),
×
740
                                                     from_user.profile_id())
741
            if is_user:
×
742
                bridged = f'🌉 <a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a>'
×
743
                from_ = f'<a href="{from_user.web_url()}">{from_user.handle}</a>'
×
744
            else:
745
                bridged = '🌉 bridged'
×
746
                from_ = util.pretty_link(url) if url else '?'
×
747

748
        else:  # plain text
749
            # TODO: unify with above. which is right?
750
            id = obj.key.id() if obj.key else obj.our_as1.get('id')
×
751
            is_user = from_user.key and id in (from_user.key.id(),
×
752
                                               from_user.profile_id())
753
            from_ = (from_user.web_url() if is_user else url) or '?'
×
754

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

766
        logo = f'{from_user.LOGO_EMOJI} ' if from_user.LOGO_EMOJI else ''
×
767
        source_links = f'{separator if orig_summary else ""}{bridged} from {logo}{from_}{suffix}'
×
768
        actor['summary'] = orig_summary + source_links
×
769

770
    @classmethod
×
771
    def set_username(to_cls, user, username):
×
772
        """Sets a custom username for a user's bridged account in this protocol.
773

774
        Args:
775
          user (models.User)
776
          username (str)
777

778
        Raises:
779
          ValueError: if the username is invalid
780
          RuntimeError: if the username could not be set
781
        """
782
        raise NotImplementedError()
×
783

784
    @classmethod
×
785
    def migrate_out(cls, user, to_user_id):
×
786
        """Migrates a bridged account out to be a native account.
787

788
        Args:
789
          user (models.User)
790
          to_user_id (str)
791

792
        Raises:
793
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
794
            ``user`` is on this protocol or not bridged to this protocol
795
        """
796
        raise NotImplementedError()
×
797

798
    @classmethod
×
799
    def check_can_migrate_out(cls, user, to_user_id):
×
800
        """Raises an exception if a user can't yet migrate to a native account.
801

802
        For example, if ``to_user_id`` isn't on this protocol, or if ``user`` is on
803
        this protocol, or isn't bridged to this protocol.
804

805
        If the user is ready to migrate, returns ``None``.
806

807
        Subclasses may override this to add more criteria, but they should call this
808
        implementation first.
809

810
        Args:
811
          user (models.User)
812
          to_user_id (str)
813

814
        Raises:
815
          ValueError: if ``user`` isn't ready to migrate to this protocol yet
816
        """
817
        def _error(msg):
×
818
            logger.warning(msg)
×
819
            raise ValueError(msg)
×
820

821
        if cls.owns_id(to_user_id) is False:
×
822
            _error(f"{to_user_id} doesn't look like an {cls.LABEL} id")
×
823
        elif isinstance(user, cls):
×
824
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
×
825
        elif not user.is_enabled(cls):
×
826
            _error(f"{user.handle_or_id()} isn't currently bridged to {cls.PHRASE}")
×
827

828
    @classmethod
×
829
    def migrate_in(cls, user, from_user_id, **kwargs):
×
830
        """Migrates a native account in to be a bridged account.
831

832
        The protocol independent parts are done here; protocol-specific parts are
833
        done in :meth:`_migrate_in`, which this wraps.
834

835
        Reloads the user's profile before calling :meth:`_migrate_in`.
836

837
        Args:
838
          user (models.User): native user on another protocol to attach the
839
            newly imported bridged account to
840
          from_user_id (str)
841
          kwargs: additional protocol-specific parameters
842

843
        Raises:
844
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
845
            ``user`` is on this protocol or already bridged to this protocol
846
        """
847
        def _error(msg):
×
848
            logger.warning(msg)
×
849
            raise ValueError(msg)
×
850

851
        logger.info(f"Migrating in {from_user_id} for {user.key.id()}")
×
852

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

861
        # reload profile
862
        try:
×
863
            user.reload_profile()
×
864
        except (RequestException, HTTPException) as e:
×
865
            _, msg = util.interpret_http_exception(e)
×
866

867
        # migrate!
868
        cls._migrate_in(user, from_user_id, **kwargs)
×
869
        user.add('enabled_protocols', cls.LABEL)
×
870
        user.put()
×
871

872
        # attach profile object
873
        if user.obj:
×
874
            if cls.HAS_COPIES:
×
875
                profile_id = ids.profile_id(id=from_user_id, proto=cls)
×
876
                user.obj.remove_copies_on(cls)
×
877
                user.obj.add('copies', Target(uri=profile_id, protocol=cls.LABEL))
×
878
                user.obj.put()
×
879

880
            common.create_task(queue='receive', obj_id=user.obj_key.id(),
×
881
                               authed_as=user.key.id())
882

883
    @classmethod
×
884
    def _migrate_in(cls, user, from_user_id, **kwargs):
×
885
        """Protocol-specific parts of migrating in external account.
886

887
        Called by :meth:`migrate_in`, which does most of the work, including calling
888
        :meth:`reload_profile` before this.
889

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

898
    @classmethod
×
899
    def target_for(cls, obj, shared=False):
×
900
        """Returns an :class:`Object`'s delivery target (endpoint).
901

902
        To be implemented by subclasses.
903

904
        Examples:
905

906
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
907
          webmention target.
908
        * If obj is an ``activitypub`` actor, returns its inbox.
909
        * If obj is an ``activitypub`` object, returns it's author's or actor's
910
          inbox.
911

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

918
        Returns:
919
          str: target endpoint, or None if not available.
920
        """
921
        raise NotImplementedError()
×
922

923
    @classmethod
×
924
    def is_blocklisted(cls, url, allow_internal=False):
×
925
        """Returns True if we block the given URL and shouldn't deliver to it.
926

927
        Default implementation here, subclasses may override.
928

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

941
    @classmethod
×
942
    def translate_ids(to_cls, obj):
×
943
        """Translates all ids in an AS1 object to a specific protocol.
944

945
        Infers source protocol for each id value separately.
946

947
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
948
        ``at://did:plc:abc/coll/123`` will be converted to
949
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
950

951
        Wraps these AS1 fields:
952

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

970
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
971
        same logic is duplicated there!
972

973
        TODO: unify with :meth:`Object.resolve_ids`,
974
        :meth:`models.Object.normalize_ids`.
975

976
        Args:
977
          to_proto (Protocol subclass)
978
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
979

980
        Returns:
981
          dict: translated AS1 version of ``obj``
982
        """
983
        from ui import UIProtocol
×
984

985
        assert to_cls != Protocol
×
986
        if not obj:
×
987
            return obj
×
988

989
        outer_obj = to_cls.translate_mention_handles(copy.deepcopy(obj))
×
990
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
×
991

992
        def translate(elem, field, fn, uri=False):
×
993
            owner_id = as1.get_owner(elem)
×
994
            owner_proto = Protocol.for_id(owner_id)
×
995

996
            elem[field] = as1.get_objects(elem, field)
×
997
            for obj in elem[field]:
×
998
                if id := obj.get('id'):
×
999
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
×
1000
                        continue
×
1001

1002
                    from_cls = Protocol.for_id(id)
×
1003
                    if field == 'id' and from_cls == UIProtocol and owner_proto:
×
1004
                        logger.info(f'owner of {id} {owner_id} is {owner_proto.LABEL}, translating id from that protocol')
×
1005
                        from_cls = owner_proto
×
1006

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

1014
            elem[field] = [o['id'] if o.keys() == {'id'} else o
×
1015
                           for o in elem[field]]
1016

1017
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
×
1018
                elem[field] = elem[field][0]
×
1019

1020
        type = as1.object_type(outer_obj)
×
1021
        translate(outer_obj, 'id',
×
1022
                  ids.translate_user_id if type in as1.ACTOR_TYPES
1023
                  else ids.translate_object_id)
1024

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

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

1055
        outer_obj = util.trim_nulls(outer_obj)
×
1056

1057
        if objs := util.get_list(outer_obj ,'object'):
×
1058
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
×
1059
            if len(outer_obj['object']) == 1:
×
1060
                outer_obj['object'] = outer_obj['object'][0]
×
1061

1062
        return outer_obj
×
1063

1064
    @classmethod
×
1065
    def translate_mention_handles(cls, obj):
×
1066
        """Translates @-mentions in ``obj.content`` to this protocol's handles.
1067

1068
        Specifically, for each ``mention`` tag in the object's tags that has
1069
        ``startIndex`` and ``length``, replaces it in ``obj.content`` with that
1070
        user's translated handle in this protocol and updates the tag's location.
1071

1072
        Called by :meth:`Protocol.translate_ids`.
1073

1074
        If ``obj.content`` is HTML, does nothing.
1075

1076
        Args:
1077
          obj (dict): AS2 object
1078

1079
        Returns:
1080
          dict: modified AS2 object
1081
        """
1082
        if not obj:
×
1083
            return None
×
1084

1085
        obj = copy.deepcopy(obj)
×
1086
        obj['object'] = [cls.translate_mention_handles(o)
×
1087
                                for o in as1.get_objects(obj)]
1088
        if len(obj['object']) == 1:
×
1089
            obj['object'] = obj['object'][0]
×
1090

1091
        content = obj.get('content')
×
1092
        tags = obj.get('tags')
×
1093
        if (not content or not tags
×
1094
                or obj.get('content_is_html')
1095
                or bool(BeautifulSoup(content, 'html.parser').find())
1096
                or HTML_ENTITY_RE.search(content)):
1097
            return util.trim_nulls(obj)
×
1098

1099
        indexed = [tag for tag in tags if tag.get('startIndex') and tag.get('length')]
×
1100

1101
        offset = 0
×
1102
        for tag in sorted(indexed, key=lambda t: t['startIndex']):
×
1103
            tag['startIndex'] += offset
×
1104
            if tag.get('objectType') == 'mention' and (id := tag['url']):
×
1105
                if proto := Protocol.for_id(id):
×
1106
                    id = ids.normalize_user_id(id=id, proto=proto)
×
1107
                    if key := get_original_user_key(id):
×
1108
                        user = key.get()
×
1109
                    else:
1110
                        user = proto.get_or_create(id, allow_opt_out=True)
×
1111
                    if user:
×
1112
                        start = tag['startIndex']
×
1113
                        end = start + tag['length']
×
1114
                        if handle := user.handle_as(cls):
×
1115
                            content = content[:start] + handle + content[end:]
×
1116
                            offset += len(handle) - tag['length']
×
1117
                            tag.update({
×
1118
                                'displayName': handle,
1119
                                'length': len(handle),
1120
                            })
1121

1122
        obj['tags'] = tags
×
1123
        as2.set_content(obj, content)  # sets content *and* contentMap
×
1124
        return util.trim_nulls(obj)
×
1125

1126
    @classmethod
×
1127
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
×
1128
        """Handles an incoming activity.
1129

1130
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
1131
        unset, returns HTTP 299.
1132

1133
        Args:
1134
          obj (models.Object)
1135
          authed_as (str): authenticated actor id who sent this activity
1136
          internal (bool): whether to allow activity ids on internal domains,
1137
            from opted out/blocked users, etc.
1138
          received_at (datetime): when we first saw (received) this activity.
1139
            Right now only used for monitoring.
1140

1141
        Returns:
1142
          (str, int) tuple: (response body, HTTP status code) Flask response
1143

1144
        Raises:
1145
          werkzeug.HTTPException: if the request is invalid
1146
        """
1147
        # check some invariants
1148
        assert from_cls != Protocol
×
1149
        assert isinstance(obj, Object), obj
×
1150

1151
        if not obj.as1:
×
1152
            error('No object data provided')
×
1153

1154
        orig_obj = obj
×
1155
        id = None
×
1156
        if obj.key and obj.key.id():
×
1157
            id = obj.key.id()
×
1158

1159
        if not id:
×
1160
            id = obj.as1.get('id')
×
1161
            obj.key = ndb.Key(Object, id)
×
1162

1163
        if not id:
×
1164
            error('No id provided')
×
1165
        elif from_cls.owns_id(id) is False:
×
1166
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
×
1167
        elif from_cls.is_blocklisted(id, allow_internal=internal):
×
1168
            error(f'Activity {id} is blocklisted')
×
1169

1170
        # does this protocol support this activity/object type?
1171
        from_cls.check_supported(obj, 'receive')
×
1172

1173
        # lease this object, atomically
1174
        memcache_key = activity_id_memcache_key(id)
×
1175
        leased = memcache.memcache.add(
×
1176
            memcache_key, 'leased', noreply=False,
1177
            expire=int(MEMCACHE_LEASE_EXPIRATION.total_seconds()))
1178

1179
        # short circuit if we've already seen this activity id
1180
        if ('force' not in request.values
×
1181
            and (not leased
1182
                 or (obj.new is False and obj.changed is False))):
1183
            error(f'Already seen this activity {id}', status=204)
×
1184

1185
        pruned = {k: v for k, v in obj.as1.items()
×
1186
                  if k not in ('contentMap', 'replies', 'signature')}
1187
        delay = ''
×
1188
        retry = request.headers.get('X-AppEngine-TaskRetryCount')
×
1189
        if (received_at and retry in (None, '0')
×
1190
                and obj.type not in ('delete', 'undo')):  # we delay deletes/undos
1191
            delay_s = int((util.now().replace(tzinfo=None)
×
1192
                           - received_at.replace(tzinfo=None)
1193
                           ).total_seconds())
1194
            delay = f'({delay_s} s behind)'
×
1195
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
×
1196

1197
        # check authorization
1198
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1199
        actor = as1.get_owner(obj.as1)
×
1200
        if not actor:
×
1201
            error('Activity missing actor or author')
×
1202

1203
        if not (from_user_cls := obj.owner_protocol()):
×
1204
            error(f"couldn't determine owner protocol for {obj.key.id()} source_protocol {obj.source_protocol}", status=204)
×
1205
        elif from_user_cls.owns_id(actor) is False:
×
1206
            error(f"{from_user_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
×
1207

1208
        assert authed_as
×
1209
        assert isinstance(authed_as, str)
×
1210
        authed_as = ids.normalize_user_id(id=authed_as, proto=from_user_cls)
×
1211
        actor = ids.normalize_user_id(id=actor, proto=from_user_cls)
×
1212
        # TODO: remove internal here once we've fixed #2237
1213
        if actor != authed_as and not internal:
×
1214
            report_error("Auth: receive: authed_as doesn't match owner",
×
1215
                         user=f'{id} authed_as {authed_as} owner {actor}')
1216
            error(f"actor {actor} isn't authed user {authed_as}")
×
1217

1218
        # update copy ids to originals
1219
        obj.normalize_ids()
×
1220
        obj.resolve_ids()
×
1221

1222
        if (obj.type == 'follow'
×
1223
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
1224
            # follows of bot user; refresh user profile first
1225
            logger.info(f'Follow of bot user, reloading {actor}')
×
1226
            from_user = from_user_cls.get_or_create(id=actor, allow_opt_out=True)
×
1227
            from_user.reload_profile()
×
1228
        else:
1229
            # load actor user
1230
            from_user = from_user_cls.get_or_create(id=actor, allow_opt_out=True)
×
1231

1232
        if not internal and (not from_user or from_user.manual_opt_out):
×
1233
            error(f"Couldn't load actor {actor}", status=204)
×
1234

1235
        # check if this is a profile object coming in via a user with use_instead
1236
        # set. if so, override the object's id to be the final user id (from_user's),
1237
        # after following use_instead.
1238
        if obj.type in as1.ACTOR_TYPES and from_user.key.id() != actor:
×
1239
            as1_id = obj.as1.get('id')
×
1240
            if ids.normalize_user_id(id=as1_id, proto=from_user) == actor:
×
1241
                logger.info(f'Overriding AS1 object id {as1_id} with Object id {from_user.profile_id()}')
×
1242
                obj.our_as1 = {**obj.as1, 'id': from_user.profile_id()}
×
1243

1244
        # if this is an object, ie not an activity, wrap it in a create or update
1245
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as,
×
1246
                                          from_user=from_user)
1247
        obj.add('users', from_user.key)
×
1248

1249
        inner_obj_as1 = as1.get_object(obj.as1)
×
1250
        inner_obj_id = inner_obj_as1.get('id')
×
1251
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
×
1252
            if not inner_obj_id:
×
1253
                error(f'{obj.type} object has no id!')
×
1254

1255
        # check age. we support backdated posts, but if they're over 2w old, we
1256
        # don't deliver them
1257
        if obj.type == 'post':
×
1258
            if published := inner_obj_as1.get('published'):
×
1259
                try:
×
1260
                    published_dt = util.parse_iso8601(published)
×
1261
                    if not published_dt.tzinfo:
×
1262
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
1263
                    age = util.now() - published_dt
×
1264
                    if (age > CREATE_MAX_AGE
×
1265
                            and 'force' not in request.values
1266
                            and not util.domain_or_parent_in(
1267
                                from_user.key.id(), CREATE_MAX_AGE_EXEMPT_DOMAINS)):
1268
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
1269
                              status=204)
1270
                except ValueError:  # from parse_iso8601
×
1271
                    logger.debug(f"Couldn't parse published {published}")
×
1272

1273
        # write Object to datastore
1274
        if obj.type in STORE_AS1_TYPES:
×
1275
            obj.put()
×
1276

1277
        # store inner object
1278
        # TODO: unify with big obj.type conditional below. would have to merge
1279
        # this with the DM handling block lower down.
1280
        crud_obj = None
×
1281
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
×
1282
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
×
1283
                                            source_protocol=obj.source_protocol,
1284
                                            authed_as=actor, users=[from_user.key],
1285
                                            deleted=False)
1286

1287
        actor = as1.get_object(obj.as1, 'actor')
×
1288
        actor_id = actor.get('id')
×
1289

1290
        # handle activity!
1291
        if obj.type == 'stop-following':
×
1292
            # TODO: unify with handle_follow?
1293
            # TODO: handle multiple followees
1294
            if not actor_id or not inner_obj_id:
×
1295
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1296

1297
            # deactivate Follower
1298
            from_ = from_user_cls.key_for(actor_id)
×
1299
            if not (to_cls := Protocol.for_id(inner_obj_id)):
×
1300
                error(f"Can't determine protocol for {inner_obj_id} , giving up")
×
1301
            to = to_cls.key_for(inner_obj_id)
×
1302
            follower = Follower.query(Follower.to == to,
×
1303
                                      Follower.from_ == from_,
1304
                                      Follower.status == 'active').get()
1305
            if follower:
×
1306
                logger.info(f'Marking {follower} inactive')
×
1307
                follower.status = 'inactive'
×
1308
                follower.put()
×
1309
            else:
1310
                logger.warning(f'No Follower found for {from_} => {to}')
×
1311

1312
            # fall through to deliver to followee
1313
            # TODO: do we convert stop-following to webmention 410 of original
1314
            # follow?
1315

1316
            # fall through to deliver to followers
1317

1318
        elif obj.type in ('delete', 'undo'):
×
1319
            delete_obj_id = (from_user.profile_id()
×
1320
                            if inner_obj_id == from_user.key.id()
1321
                            else inner_obj_id)
1322

1323
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
×
1324
            if not delete_obj:
×
1325
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
×
1326
                return 'OK', 204
×
1327

1328
            # TODO: just delete altogether!
1329
            logger.info(f'Marking Object {delete_obj_id} deleted')
×
1330
            delete_obj.deleted = True
×
1331
            delete_obj.put()
×
1332

1333
            # if this is an actor, handle deleting it later so that
1334
            # in case it's from_user, user.enabled_protocols is still populated
1335
            #
1336
            # fall through to deliver to followers and delete copy if necessary.
1337
            # should happen via protocol-specific copy target and send of
1338
            # delete activity.
1339
            # https://github.com/snarfed/bridgy-fed/issues/63
1340

1341
        elif obj.type == 'block':
×
1342
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
×
1343
                # blocking protocol bot user disables that protocol
1344
                from_user.delete(proto)
×
1345
                from_user.disable_protocol(proto)
×
1346
                return 'OK', 200
×
1347

1348
        elif obj.type == 'post':
×
1349
            # handle DMs to bot users
1350
            if as1.is_dm(obj.as1):
×
1351
                return dms.receive(from_user=from_user, obj=obj)
×
1352

1353
        # fetch actor if necessary
1354
        is_user = (inner_obj_id in (from_user.key.id(), from_user.profile_id())
×
1355
                   or from_user.is_profile(orig_obj))
1356
        if (actor and actor.keys() == set(['id'])
×
1357
                and not is_user and obj.type not in ('delete', 'undo')):
1358
            logger.debug('Fetching actor so we have name, profile photo, etc')
×
1359
            actor_obj = from_user_cls.load(
×
1360
                ids.profile_id(id=actor['id'], proto=from_cls), raise_=False)
1361
            if actor_obj and actor_obj.as1:
×
1362
                obj.our_as1 = {
×
1363
                    **obj.as1, 'actor': {
1364
                        **actor_obj.as1,
1365
                        # override profile id with actor id
1366
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1367
                        'id': actor['id'],
1368
                    }
1369
                }
1370

1371
        # fetch object if necessary
1372
        if (obj.type in ('post', 'update', 'share')
×
1373
                and inner_obj_as1.keys() == set(['id'])
1374
                and from_cls.owns_id(inner_obj_id) is not False):
1375
            logger.debug('Fetching inner object')
×
1376
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
×
1377
                                      remote=(obj.type in ('post', 'update')))
1378
            if obj.type in ('post', 'update'):
×
1379
                crud_obj = inner_obj
×
1380
            if inner_obj and inner_obj.as1:
×
1381
                obj.our_as1 = {
×
1382
                    **obj.as1,
1383
                    'object': {
1384
                        **inner_obj_as1,
1385
                        **inner_obj.as1,
1386
                    }
1387
                }
1388
            elif obj.type in ('post', 'update'):
×
1389
                error(f"Need object {inner_obj_id} but couldn't fetch, giving up")
×
1390

1391
        if obj.type == 'follow':
×
1392
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
×
1393
                # follow of one of our protocol bot users; enable that protocol.
1394
                # fall through so that we send an accept.
1395
                try:
×
1396
                    from_user.enable_protocol(proto)
×
1397
                except ErrorButDoNotRetryTask:
×
1398
                    from web import Web
×
1399
                    bot = Web.get_by_id(proto.bot_user_id())
×
1400
                    from_cls.respond_to_follow('reject', follower=from_user,
×
1401
                                               followee=bot, follow=obj)
1402
                    raise
×
1403
                proto.bot_maybe_follow_back(from_user)
×
1404
                from_cls.handle_follow(obj, from_user=from_user)
×
1405
                return 'OK', 202
×
1406

1407
            from_cls.handle_follow(obj, from_user=from_user)
×
1408

1409
        # on update of the user's own actor/profile, set user.obj and store user back
1410
        # to datastore so that we recalculate computed properties like status etc
1411
        if is_user:
×
1412
            if obj.type == 'update' and crud_obj:
×
1413
                logger.info(f"update of the user's profile, re-storing user with obj_key {crud_obj.key.id()}")
×
1414
                from_user.obj = crud_obj
×
1415
                from_user.put()
×
1416

1417
        # deliver to targets
1418
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
×
1419

1420
        # on user deleting themselves, deactivate their followers/followings.
1421
        # https://github.com/snarfed/bridgy-fed/issues/1304
1422
        #
1423
        # do this *after* delivering because delivery finds targets based on
1424
        # stored Followers
1425
        if is_user and obj.type == 'delete':
×
1426
            for proto in from_user.enabled_protocols:
×
1427
                from_user.disable_protocol(PROTOCOLS[proto])
×
1428

1429
            logger.info(f'Deactivating Followers from or to {from_user.key.id()}')
×
1430
            followers = Follower.query(
×
1431
                OR(Follower.to == from_user.key, Follower.from_ == from_user.key)
1432
            ).fetch()
1433
            for f in followers:
×
1434
                f.status = 'inactive'
×
1435
            ndb.put_multi(followers)
×
1436

1437
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
×
1438
        return resp
×
1439

1440
    @classmethod
×
1441
    def handle_follow(from_cls, obj, from_user):
×
1442
        """Handles an incoming follow activity.
1443

1444
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1445
        happens in :meth:`deliver`.
1446

1447
        Args:
1448
          obj (models.Object): follow activity
1449
        """
1450
        logger.debug('Got follow. storing Follow(s), sending accept(s)')
×
1451
        from_id = from_user.key.id()
×
1452

1453
        # Prepare followee (to) users' data
1454
        to_as1s = as1.get_objects(obj.as1)
×
1455
        if not to_as1s:
×
1456
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1457

1458
        # Store Followers
1459
        for to_as1 in to_as1s:
×
1460
            to_id = to_as1.get('id')
×
1461
            if not to_id:
×
1462
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1463

1464
            logger.info(f'Follow {from_id} => {to_id}')
×
1465

1466
            to_cls = Protocol.for_id(to_id)
×
1467
            if not to_cls:
×
1468
                error(f"Couldn't determine protocol for {to_id}")
×
1469
            elif from_cls == to_cls:
×
1470
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
×
1471
                continue
×
1472

1473
            to_key = to_cls.key_for(to_id)
×
1474
            if not to_key:
×
1475
                logger.info(f'Skipping invalid {to_cls.LABEL} user key: {to_id}')
×
1476
                continue
×
1477

1478
            to_user = to_cls.get_or_create(id=to_key.id())
×
1479
            if not to_user or not to_user.is_enabled(from_cls):
×
1480
                error(f'{to_id} not found')
×
1481

1482
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
×
1483
                                                  follow=obj.key, status='active')
1484
            obj.add('notify', to_key)
×
1485
            from_cls.respond_to_follow('accept', follower=from_user,
×
1486
                                       followee=to_user, follow=obj)
1487

1488
    @classmethod
×
1489
    def respond_to_follow(_, verb, follower, followee, follow):
×
1490
        """Sends an accept or reject activity for a follow.
1491

1492
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1493
        nothing.
1494

1495
        Args:
1496
          verb (str): ``accept`` or  ``reject``
1497
          follower (models.User)
1498
          followee (models.User)
1499
          follow (models.Object)
1500
        """
1501
        assert verb in ('accept', 'reject')
×
1502
        if verb not in follower.SUPPORTED_AS1_TYPES:
×
1503
            return
×
1504

1505
        if not follower.obj or not (target := follower.target_for(follower.obj)):
×
1506
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1507

1508
        # send. note that this is one response for the whole follow, even if it
1509
        # has multiple followees!
1510
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
×
1511
        accept = {
×
1512
            'id': id,
1513
            'objectType': 'activity',
1514
            'verb': verb,
1515
            'actor': followee.key.id(),
1516
            'object': follow.as1,
1517
        }
1518
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
×
1519
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1520

1521
    @classmethod
×
1522
    def bot_maybe_follow_back(bot_cls, user):
×
1523
        """Follow a user from a protocol bot user, if their protocol needs that.
1524

1525
        ...so that the protocol starts sending us their activities, if it needs
1526
        a follow for that (eg ActivityPub).
1527

1528
        Args:
1529
          user (User)
1530
        """
1531
        if not user.BOTS_FOLLOW_BACK:
×
1532
            return
×
1533

1534
        from web import Web
×
1535
        bot = Web.get_by_id(bot_cls.bot_user_id())
×
1536
        now = util.now().isoformat()
×
1537
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
×
1538

1539
        if not user.obj:
×
1540
            logger.info("  can't follow, user has no profile obj")
×
1541
            return
×
1542

1543
        target = user.target_for(user.obj)
×
1544
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
×
1545
        follow_back_as1 = {
×
1546
            'objectType': 'activity',
1547
            'verb': 'follow',
1548
            'id': follow_back_id,
1549
            'actor': bot.key.id(),
1550
            'object': user.key.id(),
1551
        }
1552
        common.create_task(queue='send', id=follow_back_id,
×
1553
                           our_as1=follow_back_as1, url=target,
1554
                           source_protocol='web', protocol=user.LABEL,
1555
                           user=bot.key.urlsafe())
1556

1557
    @classmethod
×
1558
    def handle_bare_object(cls, obj, *, authed_as, from_user):
×
1559
        """If obj is a bare object, wraps it in a create or update activity.
1560

1561
        Checks if we've seen it before.
1562

1563
        Args:
1564
          obj (models.Object)
1565
          authed_as (str): authenticated actor id who sent this activity
1566
          from_user (models.User): user (actor) this activity/object is from
1567

1568
        Returns:
1569
          models.Object: ``obj`` if it's an activity, otherwise a new object
1570
        """
1571
        is_actor = obj.type in as1.ACTOR_TYPES
×
1572
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
×
1573
            return obj
×
1574

1575
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
×
1576
        now = util.now().isoformat()
×
1577

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

1604
        if obj.new or 'force' in request.values:
×
1605
            create_id = f'{obj.key.id()}#bridgy-fed-create-{now}'
×
1606
            create_as1 = {
×
1607
                'objectType': 'activity',
1608
                'verb': 'post',
1609
                'id': create_id,
1610
                'actor': obj_actor,
1611
                'object': obj.as1,
1612
                'published': now,
1613
            }
1614
            logger.info(f'Wrapping in post')
×
1615
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
×
1616
            return Object(id=create_id, our_as1=create_as1,
×
1617
                          source_protocol=obj.source_protocol)
1618

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

1621
    @classmethod
×
1622
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
×
1623
        """Delivers an activity to its external recipients.
1624

1625
        Args:
1626
          obj (models.Object): activity to deliver
1627
          from_user (models.User): user (actor) this activity is from
1628
          crud_obj (models.Object): if this is a create, update, or delete/undo
1629
            activity, the inner object that's being written, otherwise None.
1630
            (This object's ``notify`` and ``feed`` properties may be updated.)
1631
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1632
            targets on this protocol
1633

1634
        Returns:
1635
          (str, int) tuple: Flask response
1636
        """
1637
        if to_proto:
×
1638
            logger.info(f'Only delivering to {to_proto.LABEL}')
×
1639

1640
        # find delivery targets. maps Target to Object or None
1641
        #
1642
        # ...then write the relevant object, since targets() has a side effect of
1643
        # setting the notify and feed properties (and dirty attribute)
1644
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
×
1645
        if to_proto:
×
1646
            targets = {t: obj for t, obj in targets.items()
×
1647
                       if t.protocol == to_proto.LABEL}
1648
        if not targets:
×
1649
            # don't raise via error() because we call deliver in code paths where
1650
            # we want to continue after
1651
            msg = r'No targets, nothing to do ¯\_(ツ)_/¯'
×
1652
            logger.info(msg)
×
1653
            return msg, 204
×
1654

1655
        # store object that targets() updated
1656
        if crud_obj and crud_obj.dirty:
×
1657
            crud_obj.put()
×
1658
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
×
1659
            obj.put()
×
1660

1661
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
×
1662
                      else obj.to_request())
1663

1664
        # sort targets so order is deterministic for tests, debugging, etc
1665
        sorted_targets = sorted(targets.items(), key=lambda t: t[0].uri)
×
1666

1667
        # enqueue send task for each targets
1668
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
×
1669
        user = from_user.key.urlsafe()
×
1670
        for i, (target, orig_obj) in enumerate(sorted_targets):
×
1671
            orig_obj_id = orig_obj.key.id() if orig_obj else None
×
1672
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
×
1673
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1674

1675
        return 'OK', 202
×
1676

1677
    @classmethod
×
1678
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
×
1679
        """Collects the targets to send a :class:`models.Object` to.
1680

1681
        Targets are both objects - original posts, events, etc - and actors.
1682

1683
        Args:
1684
          obj (models.Object)
1685
          from_user (User)
1686
          crud_obj (models.Object): if this is a create, update, or delete/undo
1687
            activity, the inner object that's being written, otherwise None.
1688
            (This object's ``notify`` and ``feed`` properties may be updated.)
1689
          internal (bool): whether this is a recursive internal call
1690

1691
        Returns:
1692
          dict: maps :class:`models.Target` to original (in response to)
1693
          :class:`models.Object`
1694
        """
1695
        logger.debug('Finding recipients and their targets')
×
1696

1697
        # we should only have crud_obj iff this is a create or update
1698
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
×
1699
        write_obj = crud_obj or obj
×
1700
        write_obj.dirty = False
×
1701

1702
        target_uris = as1.targets(obj.as1)
×
1703
        orig_obj = None
×
1704
        targets = {}  # maps Target (with *normalized* uri) to Object or None
×
1705
        owner = as1.get_owner(obj.as1)
×
1706
        allow_opt_out = (obj.type == 'delete')
×
1707
        inner_obj_as1 = as1.get_object(obj.as1)
×
1708
        inner_obj_id = inner_obj_as1.get('id')
×
1709
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
×
1710
        quoted_posts = as1.quoted_posts(inner_obj_as1)
×
1711
        mentioned_urls = as1.mentions(inner_obj_as1)
×
1712
        is_reply = obj.type == 'comment' or in_reply_tos
×
1713
        is_self_reply = False
×
1714

1715
        original_ids = []
×
1716
        if is_reply:
×
1717
            original_ids = in_reply_tos
×
1718
        elif inner_obj_id:
×
1719
            if inner_obj_id == from_user.key.id():
×
1720
                inner_obj_id = from_user.profile_id()
×
1721
            original_ids = [inner_obj_id]
×
1722

1723
        # maps id to Object
1724
        original_objs = {}
×
1725
        for id in original_ids:
×
1726
            if proto := Protocol.for_id(id):
×
1727
                original_objs[id] = proto.load(id, raise_=False)
×
1728

1729
        # for AP, add in-reply-tos' mentions
1730
        # https://github.com/snarfed/bridgy-fed/issues/1608
1731
        # https://github.com/snarfed/bridgy-fed/issues/1218
1732
        orig_post_mentions = {}  # maps mentioned id to original post Object
×
1733
        for id in in_reply_tos:
×
1734
            if ((in_reply_to_obj := original_objs.get(id))
×
1735
                    and (proto := PROTOCOLS.get(in_reply_to_obj.source_protocol))
1736
                    and proto.SEND_REPLIES_TO_ORIG_POSTS_MENTIONS
1737
                    and (mentions := as1.mentions(in_reply_to_obj.as1))):
1738
                logger.info(f"Adding in-reply-to {id} 's mentions to targets: {mentions}")
×
1739
                target_uris.extend(mentions)
×
1740
                for mention in mentions:
×
1741
                    orig_post_mentions[mention] = in_reply_to_obj
×
1742

1743
        target_uris = sorted(set(target_uris))
×
1744
        logger.info(f'Raw targets: {target_uris}')
×
1745

1746
        # which protocols should we allow delivering to?
1747
        to_protocols = []  # elements are Protocol subclasses
×
1748
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
×
1749
                      + from_user.enabled_protocols):
1750
            if not (proto := PROTOCOLS.get(label)):
×
1751
                report_error(f'unknown enabled protocol {label} for {from_user.key.id()}')
×
1752
                continue
×
1753

1754
            if (obj.type == 'post' and (orig := original_objs.get(inner_obj_id))
×
1755
                    and orig.get_copy(proto)):
1756
                logger.info(f'Already created {id} on {label}, cowardly refusing to create there again')
×
1757
                continue
×
1758

1759
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
×
1760
                                     or is_reply):
1761
                origs_could_bridge = None
×
1762

1763
                for id in original_ids:
×
1764
                    if not (orig := original_objs.get(id)):
×
1765
                        continue
×
1766
                    elif orig.get_copy(proto):
×
1767
                        logger.info(f'Allowing {label}, original {id} was bridged there')
×
1768
                        break
×
1769
                    elif from_user.is_profile(orig):
×
1770
                        logger.info(f"Allowing {label}, this is the user's profile")
×
1771
                        break
×
1772

1773
                    if (origs_could_bridge is not False
×
1774
                            and (orig_author_id := as1.get_owner(orig.as1))
1775
                            and (orig_proto := orig.owner_protocol())
1776
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1777
                        origs_could_bridge = orig_author.is_enabled(proto)
×
1778

1779
                else:
1780
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
×
1781
                    last_retry = False
×
1782
                    if retries := request.headers.get(TASK_RETRIES_HEADER):
×
1783
                        if (last_retry := int(retries) >= TASK_RETRIES_RECEIVE):
×
1784
                            logger.info(f'last retry! skipping {proto.LABEL} and continuing')
×
1785

1786
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
×
1787
                            and origs_could_bridge and not last_retry):
1788
                        # retry later; original obj may still be bridging
1789
                        # TODO: limit to brief window, eg no older than 2h? 1d?
1790
                        error(msg, status=304)
×
1791

1792
                    logger.info(msg)
×
1793
                    continue
×
1794

1795
            util.add(to_protocols, proto)
×
1796

1797
        logger.info(f'allowed protocols {[p.LABEL for p in to_protocols]}')
×
1798

1799
        # process direct targets
1800
        for target_id in target_uris:
×
1801
            target_proto = Protocol.for_id(target_id)
×
1802
            if not target_proto:
×
1803
                logger.info(f"Can't determine protocol for {target_id}")
×
1804
                continue
×
1805
            elif target_proto.is_blocklisted(target_id):
×
1806
                logger.debug(f'{target_id} is blocklisted')
×
1807
                continue
×
1808

1809
            target_obj_id = target_id
×
1810
            if target_id in mentioned_urls or obj.type in as1.VERBS_WITH_ACTOR_OBJECT:
×
1811
                # not ideal. this can sometimes be a non-user, eg blocking a
1812
                # blocklist. ok right now since profile_id() returns its input id
1813
                # unchanged if it doesn't look like a user id, but that's brittle.
1814
                target_obj_id = ids.profile_id(id=target_id, proto=target_proto)
×
1815

1816
            orig_obj = target_proto.load(target_obj_id, raise_=False)
×
1817
            if not orig_obj or not orig_obj.as1:
×
1818
                logger.info(f"Couldn't load {target_obj_id}")
×
1819
                continue
×
1820

1821
            target_author_key = (target_proto(id=target_id).key
×
1822
                                 if target_id in mentioned_urls
1823
                                 else target_proto.actor_key(orig_obj))
1824
            if not from_user.is_enabled(target_proto):
×
1825
                # if author isn't bridged and target user is, DM a prompt and
1826
                # add a notif for the target user
1827
                if (target_id in (in_reply_tos + quoted_posts + mentioned_urls)
×
1828
                        and target_author_key):
1829
                    if target_author := target_author_key.get():
×
1830
                        if target_author.is_enabled(from_cls):
×
1831
                            notifications.add_notification(target_author, write_obj)
×
1832
                            verb, noun = (
×
1833
                                ('replied to', 'replies') if target_id in in_reply_tos
1834
                                else ('quoted', 'quotes') if target_id in quoted_posts
1835
                                else ('mentioned', 'mentions'))
1836
                            dms.maybe_send(from_=target_proto, to_user=from_user,
×
1837
                                           type='replied_to_bridged_user', text=f"""\
1838
Hi! You <a href="{inner_obj_as1.get('url') or inner_obj_id}">recently {verb}</a> {target_author.html_link()}, who's bridged here from {target_proto.PHRASE}. If you want them to see your {noun}, you can bridge your account into {target_proto.PHRASE} by following this account. <a href="https://fed.brid.gy/docs">See the docs</a> for more information.""")
1839

1840
                continue
×
1841

1842
            # deliver self-replies to followers
1843
            # https://github.com/snarfed/bridgy-fed/issues/639
1844
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
×
1845
                is_self_reply = True
×
1846
                logger.info(f'self reply!')
×
1847

1848
            # also add copies' targets
1849
            for copy in orig_obj.copies:
×
1850
                proto = PROTOCOLS[copy.protocol]
×
1851
                if proto in to_protocols:
×
1852
                    # copies generally won't have their own Objects
1853
                    if target := proto.target_for(Object(id=copy.uri)):
×
1854
                        target = util.normalize_url(target, trailing_slash=False)
×
1855
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {target_id}')
×
1856
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
×
1857

1858
            if target_proto == from_cls:
×
1859
                logger.debug(f'Skipping same-protocol target {target_id}')
×
1860
                continue
×
1861

1862
            target = target_proto.target_for(orig_obj)
×
1863
            if not target:
×
1864
                # TODO: surface errors like this somehow?
1865
                logger.error(f"Can't find delivery target for {target_id}")
×
1866
                continue
×
1867

1868
            target = util.normalize_url(target, trailing_slash=False)
×
1869
            logger.debug(f'Target for {target_id} is {target}')
×
1870
            # only use orig_obj for inReplyTos, like/repost objects, reply's original
1871
            # post's mentions, etc
1872
            # https://github.com/snarfed/bridgy-fed/issues/1237
1873
            target_obj = None
×
1874
            if target_id in in_reply_tos + as1.get_ids(obj.as1, 'object'):
×
1875
                target_obj = orig_obj
×
1876
            elif target_id in orig_post_mentions:
×
1877
                target_obj = orig_post_mentions[target_id]
×
1878
            targets[Target(protocol=target_proto.LABEL, uri=target)] = target_obj
×
1879

1880
            if target_author_key:
×
1881
                logger.debug(f'Recipient is {target_author_key}')
×
1882
                if write_obj.add('notify', target_author_key):
×
1883
                    write_obj.dirty = True
×
1884

1885
        if obj.type == 'undo':
×
1886
            logger.info('Object is an undo; adding targets for inner object')
×
1887
            if set(inner_obj_as1.keys()) == {'id'}:
×
1888
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
×
1889
            else:
1890
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
×
1891
            if inner_obj:
×
1892
                for target, target_obj in from_cls.targets(
×
1893
                        inner_obj, from_user=from_user, internal=True).items():
1894
                    targets[target] = target_obj
×
1895
                    util.add(to_protocols, PROTOCOLS[target.protocol])
×
1896

1897
        if not to_protocols:
×
1898
            return {}
×
1899

1900
        logger.info(f'Direct targets: {[t.uri for t in targets.keys()]}')
×
1901

1902
        # deliver to followers, if appropriate
1903
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
×
1904
        if not user_key:
×
1905
            logger.info("Can't tell who this is from! Skipping followers.")
×
1906
            return targets
×
1907

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

1927
            logger.info(f'  loaded {len(followers)} followers')
×
1928

1929
            user_keys = [f.from_ for f in followers]
×
1930
            users = [u for u in ndb.get_multi(user_keys) if u]
×
1931
            logger.info(f'  loaded {len(users)} users')
×
1932

1933
            User.load_multi(users)
×
1934
            logger.info(f'  loaded user objects')
×
1935

1936
            if (not followers and
×
1937
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
1938
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
1939
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
×
1940
                return {}
×
1941

1942
            # add to followers' feeds, if any
1943
            if not internal and obj.type in ('post', 'update', 'share'):
×
1944
                if write_obj.type not in as1.ACTOR_TYPES:
×
1945
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
×
1946
                    if write_obj.feed:
×
1947
                        write_obj.dirty = True
×
1948

1949
            # collect targets for followers
1950
            target_obj = (original_objs.get(inner_obj_id)
×
1951
                          if obj.type == 'share' else None)
1952
            for user in users:
×
1953
                if user.is_blocking(from_user):
×
1954
                    logger.debug(f'  {user.key.id()} blocks {from_user.key.id()}')
×
1955
                    continue
×
1956

1957
                # TODO: should we pass remote=False through here to Protocol.load?
1958
                target = user.target_for(user.obj, shared=True) if user.obj else None
×
1959
                if not target:
×
1960
                    continue
×
1961

1962
                target = util.normalize_url(target, trailing_slash=False)
×
1963
                targets[Target(protocol=user.LABEL, uri=target)] = target_obj
×
1964

1965
            logger.info(f'  collected {len(targets)} targets')
×
1966

1967
        # deliver to enabled HAS_COPIES protocols proactively
1968
        if obj.type in ('post', 'update', 'delete', 'share'):
×
1969
            for proto in to_protocols:
×
1970
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
×
1971
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
×
1972
                    targets.setdefault(
×
1973
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1974

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

1996
            target, obj = candidates[url]
×
1997
            targets[target] = obj
×
1998

1999
        return targets
×
2000

2001
    @classmethod
×
2002
    def load(cls, id, remote=None, local=True, raise_=True, raw=False, csv=False,
×
2003
             **kwargs):
2004
        """Loads and returns an Object from datastore or HTTP fetch.
2005

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

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

2028
        Returns:
2029
          models.Object: loaded object, or None if it isn't fetchable, eg a
2030
          non-URL string for Web, or ``remote`` is False and it isn't in the
2031
          datastore
2032

2033
        Raises:
2034
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
2035
            is True
2036
        """
2037
        assert id
×
2038
        assert local or remote is not False
×
2039
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
2040

2041
        if not raw:
×
2042
            id = ids.normalize_object_id(id=id, proto=cls)
×
2043

2044
        obj = orig_as1 = None
×
2045
        if local:
×
2046
            if obj := Object.get_by_id(id):
×
2047
                if csv and not obj.is_csv:
×
2048
                    return None
×
2049
                elif obj.as1 or obj.csv or obj.raw or obj.deleted:
×
2050
                    # logger.debug(f'  {id} got from datastore')
2051
                    obj.new = False
×
2052

2053
        if remote is False:
×
2054
            return obj
×
2055
        elif remote is None and obj:
×
2056
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
×
2057
                # logger.debug(f'  last updated {obj.updated}, refreshing')
2058
                pass
×
2059
            else:
2060
                return obj
×
2061

2062
        if obj:
×
2063
            orig_as1 = obj.as1
×
2064
            obj.our_as1 = None
×
2065
            obj.new = False
×
2066
        else:
2067
            if cls == Protocol:
×
2068
                return None
×
2069
            obj = Object(id=id)
×
2070
            if local:
×
2071
                # logger.debug(f'  {id} not in datastore')
2072
                obj.new = True
×
2073
                obj.changed = False
×
2074

2075
        try:
×
2076
            fetched = cls.fetch(obj, csv=csv, **kwargs)
×
2077
        except (RequestException, HTTPException, InvalidStatus) as e:
×
2078
            if raise_:
×
2079
                raise
×
2080
            util.interpret_http_exception(e)
×
2081
            return None
×
2082

2083
        if not fetched:
×
2084
            return None
×
2085
        elif csv and not obj.is_csv:
×
2086
            return None
×
2087

2088
        # https://stackoverflow.com/a/3042250/186123
2089
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
×
2090
        if size > MAX_ENTITY_SIZE:
×
2091
            logger.warning(f'Object is too big! {size} bytes is over {MAX_ENTITY_SIZE}')
×
2092
            return None
×
2093

2094
        obj.resolve_ids()
×
2095
        obj.normalize_ids()
×
2096

2097
        if obj.new is False:
×
2098
            obj.changed = obj.activity_changed(orig_as1)
×
2099

2100
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
×
2101
            if obj.source_protocol:
×
2102
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
2103
            obj.source_protocol = cls.LABEL
×
2104

2105
        obj.put()
×
2106
        return obj
×
2107

2108
    @classmethod
×
2109
    def check_supported(cls, obj, direction):
×
2110
        """If this protocol doesn't support this activity, raises HTTP 204.
2111

2112
        Also reports an error.
2113

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

2118
        Args:
2119
          obj (Object)
2120
          direction (str): ``'receive'`` or  ``'send'``
2121

2122
        Raises:
2123
          werkzeug.HTTPException: if this protocol doesn't support this object
2124
        """
2125
        assert direction in ('receive', 'send')
×
2126
        if not obj.type:
×
2127
            return
×
2128

2129
        inner = as1.get_object(obj.as1)
×
2130
        inner_type = as1.object_type(inner) or ''
×
2131
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
×
2132
            or (obj.type in as1.CRUD_VERBS
2133
                and inner_type
2134
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
2135
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
×
2136

2137
        # don't allow posts with blank content and no image/video/audio
2138
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
×
2139
                    else obj.as1)
2140
        if (crud_obj.get('objectType') in as1.POST_TYPES
×
2141
                and not util.get_url(crud_obj, key='image')
2142
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
2143
                # TODO: handle articles with displayName but not content
2144
                and not source.html_to_text(crud_obj.get('content')).strip()):
2145
            error('Blank content and no image or video or audio', status=204)
×
2146

2147
        # receiving DMs is only allowed to protocol bot accounts
2148
        if direction == 'receive':
×
2149
            if recip := as1.recipient_if_dm(obj.as1):
×
2150
                owner = as1.get_owner(obj.as1)
×
2151
                if (not cls.SUPPORTS_DMS or (recip not in common.bot_user_ids()
×
2152
                                             and owner not in common.bot_user_ids())):
2153
                    # reply and say DMs aren't supported
2154
                    from_proto = obj.owner_protocol()
×
2155
                    to_proto = Protocol.for_id(recip)
×
2156
                    if owner and from_proto and to_proto:
×
2157
                        if ((from_user := from_proto.get_or_create(id=owner))
×
2158
                                and (to_user := to_proto.get_or_create(id=recip))):
2159
                            in_reply_to = (inner.get('id') if obj.type == 'post'
×
2160
                                           else obj.as1.get('id'))
2161
                            text = f"Hi! Sorry, this account is bridged from {to_user.PHRASE}, so it doesn't support DMs. Try getting in touch another way!"
×
2162
                            type = f'dms_not_supported-{to_user.key.id()}'
×
2163
                            dms.maybe_send(from_=to_user, to_user=from_user,
×
2164
                                           text=text, type=type,
2165
                                           in_reply_to=in_reply_to)
2166

2167
                    error("Bridgy Fed doesn't support DMs", status=204)
×
2168

2169
            # check that this activity is public. only do this for some activities,
2170
            # not eg likes or follows, since Mastodon doesn't currently mark those
2171
            # as explicitly public.
2172
            elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
×
2173
                  and not util.domain_or_parent_in(crud_obj.get('id'), NON_PUBLIC_DOMAINS)
2174
                  and not as1.is_public(obj.as1, unlisted=False)):
2175
                error('Bridgy Fed only supports public activities', status=204)
×
2176

2177
    @classmethod
×
2178
    def block(cls, from_user, arg):
×
2179
        """Blocks a user or list.
2180

2181
        Args:
2182
          from_user (models.User): user doing the blocking
2183
          arg (str): handle or id of user/list to block
2184

2185
        Returns:
2186
          models.User or models.Object: user or list that was blocked
2187

2188
        Raises:
2189
          ValueError: if arg doesn't look like a user or list on this protocol
2190
        """
2191
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
×
2192

2193
        def fail(msg):
×
2194
            logger.warning(msg)
×
2195
            raise ValueError(msg)
×
2196

2197
        blockee = None
×
2198
        try:
×
2199
            # first, try interpreting as a user handle or id
2200
            blockee = load_user(arg, proto=cls, create=True, allow_opt_out=True)
×
2201
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
×
2202
            logger.info(err)
×
2203

2204
        if type(from_user) == type(blockee):
×
2205
            fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking them there.')
×
2206

2207
        # may not be a user, see if it's a list
2208
        if not blockee:
×
2209
            if not cls or cls == Protocol:
×
2210
                cls = Protocol.for_id(arg)
×
2211

2212
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
×
2213
                if blockee.source_protocol == from_user.LABEL:
×
2214
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
×
2215
            else:
2216
                if blocklist := from_user.add_domain_blocklist(arg):
×
2217
                    return blocklist
×
2218
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
×
2219

2220
        logger.info(f'  blocking {blockee.key.id()}')
×
2221
        id = f'{from_user.key.id()}#bridgy-fed-block-{util.now().isoformat()}'
×
2222
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
×
2223
            'objectType': 'activity',
2224
            'verb': 'block',
2225
            'id': id,
2226
            'actor': from_user.key.id(),
2227
            'object': blockee.key.id(),
2228
        })
2229
        obj.put()
×
2230
        from_user.deliver(obj, from_user=from_user)
×
2231

2232
        return blockee
×
2233

2234
    @classmethod
×
2235
    def unblock(cls, from_user, arg):
×
2236
        """Unblocks a user or list.
2237

2238
        Args:
2239
          from_user (models.User): user doing the unblocking
2240
          arg (str): handle or id of user/list to unblock
2241

2242
        Returns:
2243
          models.User or models.Object: user or list that was unblocked
2244

2245
        Raises:
2246
          ValueError: if arg doesn't look like a user or list on this protocol
2247
        """
2248
        logger.info(f'user {from_user.key.id()} trying to unblock {arg}')
×
2249
        def fail(msg):
×
2250
            logger.warning(msg)
×
2251
            raise ValueError(msg)
×
2252

2253
        blockee = None
×
2254
        try:
×
2255
            # first, try interpreting as a user handle or id
2256
            blockee = load_user(arg, cls, create=True, allow_opt_out=True)
×
2257
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
×
2258
            logger.info(err)
×
2259

2260
        if type(from_user) == type(blockee):
×
2261
            fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try unblocking them there.')
×
2262

2263
        # may not be a user, see if it's a list
2264
        if not blockee:
×
2265
            if not cls or cls == Protocol:
×
2266
                cls = Protocol.for_id(arg)
×
2267

2268
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
×
2269
                if blockee.source_protocol == from_user.LABEL:
×
2270
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
×
2271
            else:
2272
                if blocklist := from_user.remove_domain_blocklist(arg):
×
2273
                    return blocklist
×
2274
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
×
2275

2276
        logger.info(f'  unblocking {blockee.key.id()}')
×
2277
        id = f'{from_user.key.id()}#bridgy-fed-unblock-{util.now().isoformat()}'
×
2278
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
×
2279
            'objectType': 'activity',
2280
            'verb': 'undo',
2281
            'id': id,
2282
            'actor': from_user.key.id(),
2283
            'object': {
2284
                'objectType': 'activity',
2285
                'verb': 'block',
2286
                'actor': from_user.key.id(),
2287
                'object': blockee.key.id(),
2288
            },
2289
        })
2290
        obj.put()
×
2291
        from_user.deliver(obj, from_user=from_user)
×
2292

2293
        return blockee
×
2294

2295

2296
@cloud_tasks_only(log=None)
×
2297
def receive_task():
×
2298
    """Task handler for a newly received :class:`models.Object`.
2299

2300
    Calls :meth:`Protocol.receive` with the form parameters.
2301

2302
    Parameters:
2303
      authed_as (str): passed to :meth:`Protocol.receive`
2304
      obj_id (str): key id of :class:`models.Object` to handle
2305
      received_at (str, ISO 8601 timestamp): when we first saw (received)
2306
        this activity
2307
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2308
        :class:`models.Object` to handle
2309

2310
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
2311
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
2312
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
2313
    ``changed``, HTTP request details, etc. See stash for attempt at this for
2314
    :class:`web.Web`.
2315
    """
2316
    common.log_request()
×
2317
    form = request.form.to_dict()
×
2318

2319
    authed_as = form.pop('authed_as', None)
×
2320
    internal = authed_as == PRIMARY_DOMAIN or authed_as in PROTOCOL_DOMAINS
×
2321

2322
    obj = Object.from_request()
×
2323
    assert obj
×
2324
    assert obj.source_protocol
×
2325
    obj.new = True
×
2326

2327
    if received_at := form.pop('received_at', None):
×
2328
        received_at = datetime.fromisoformat(received_at)
×
2329

2330
    try:
×
2331
        return PROTOCOLS[obj.source_protocol].receive(
×
2332
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
2333
    except RequestException as e:
×
2334
        util.interpret_http_exception(e)
×
2335
        error(e, status=304)
×
2336
    except ValueError as e:
×
2337
        logger.warning(e, exc_info=True)
×
2338
        error(e, status=304)
×
2339

2340

2341
@cloud_tasks_only(log=None)
×
2342
def send_task():
×
2343
    """Task handler for sending an activity to a single specific destination.
2344

2345
    Calls :meth:`Protocol.send` with the form parameters.
2346

2347
    Parameters:
2348
      protocol (str): :class:`Protocol` to send to
2349
      url (str): destination URL to send to
2350
      obj_id (str): key id of :class:`models.Object` to send
2351
      orig_obj_id (str): optional, :class:`models.Object` key id of the
2352
        "original object" that this object refers to, eg replies to or reposts
2353
        or likes
2354
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
2355
        this activity is from
2356
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2357
        :class:`models.Object` to handle
2358
    """
2359
    common.log_request()
×
2360

2361
    # prepare
2362
    form = request.form.to_dict()
×
2363
    url = form.get('url')
×
2364
    protocol = form.get('protocol')
×
2365
    if not url or not protocol:
×
2366
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
×
2367
        return '', 204
×
2368

2369
    target = Target(uri=url, protocol=protocol)
×
2370
    obj = Object.from_request()
×
2371
    assert obj and obj.key and obj.key.id()
×
2372

2373
    PROTOCOLS[protocol].check_supported(obj, 'send')
×
2374
    allow_opt_out = (obj.type == 'delete')
×
2375

2376
    user = None
×
2377
    if user_key := form.get('user'):
×
2378
        key = ndb.Key(urlsafe=user_key)
×
2379
        # use get_by_id so that we follow use_instead
2380
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
×
2381
            key.id(), allow_opt_out=allow_opt_out)
2382

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

2407
    if sent is False:
×
2408
        logger.info(f'Failed sending!')
×
2409

2410
    return '', 200 if sent else 204 if sent is False else 304
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc