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

snarfed / bridgy-fed / 1f61e5e4-9687-401d-b0a0-8b6880d8a597

08 Jan 2026 06:13PM UTC coverage: 93.288% (+0.02%) from 93.268%
1f61e5e4-9687-401d-b0a0-8b6880d8a597

push

circleci

snarfed
noop: test_dms fix for c0790e505

6518 of 6987 relevant lines covered (93.29%)

0.93 hits per line

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

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

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

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

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

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

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

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

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

94

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

99

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

103

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

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

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

155
        ...based on the request's hostname.
156

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

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

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

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

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

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

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

195
        To be implemented by subclasses.
196

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

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

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

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

212
        Args:
213
          id (str): user id or object id
214

215
        Returns:
216
          bool or None:
217
        """
218
        return False
1✔
219

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

224
        To be implemented by subclasses.
225

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

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

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

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

244
        Returns:
245
          bool or None
246
        """
247
        return False
1✔
248

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

253
        To be implemented by subclasses.
254

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

258
        Args:
259
          handle (str)
260

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

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

270

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

274
        Returns:
275
          str: authenticated user id, or None if there is no authentication
276

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

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

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

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

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

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

310
        return cls(id=id).key
1✔
311

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

316
        Args:
317
          id (str)
318

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

447
        Args:
448
          handle (str)
449

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

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

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

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

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

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

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

499
        Example: ``@user@instance.com``
500

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

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

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

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

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

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

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

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

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

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

550
        For example, ``'bsky.brid.gy'`` for ATProto.
551

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

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

561
        Should add the copy user to :attr:`copies`.
562

563
        If the copy user already exists and active, should do nothing.
564

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

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

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

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

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

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

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

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

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

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

612
        To be implemented by subclasses.
613

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

693
        Uses HTML for protocols that support it, plain text otherwise.
694

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

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

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

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

715
        actor_id = actor.get('id')
1✔
716

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

721
        from web import Web
1✔
722
        bot_user = Web.get_by_id(from_user.bot_user_id())
1✔
723

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

731
            separator = '<br><br>'
1✔
732

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

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

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

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

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

768
        Args:
769
          user (models.User)
770
          username (str)
771

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

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

782
        Args:
783
          user (models.User)
784
          to_user_id (str)
785

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

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

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

799
        If the user is ready to migrate, returns ``None``.
800

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

804
        Args:
805
          user (models.User)
806
          to_user_id (str)
807

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

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

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

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

829
        Reloads the user's profile before calling :meth:`_migrate_in`.
830

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

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

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

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

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

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

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

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

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

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

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

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

896
        To be implemented by subclasses.
897

898
        Examples:
899

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

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

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

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

921
        Default implementation here, subclasses may override.
922

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

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

939
        Infers source protocol for each id value separately.
940

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

945
        Wraps these AS1 fields:
946

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

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

967
        TODO: unify with :meth:`Object.resolve_ids`,
968
        :meth:`models.Object.normalize_ids`.
969

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

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

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

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

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

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

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

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

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

1039
        outer_obj = util.trim_nulls(outer_obj)
1✔
1040

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

1046
        return outer_obj
1✔
1047

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

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

1056
        Called by :meth:`Protocol.translate_ids`.
1057

1058
        If ``obj.content`` is HTML, does nothing.
1059

1060
        Args:
1061
          obj (dict): AS2 object
1062

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

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

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

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

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

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

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

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

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

1125
        Returns:
1126
          (str, int) tuple: (response body, HTTP status code) Flask response
1127

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1303
            # fall through to deliver to followers
1304

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

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

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

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

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

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

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

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

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

1394
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1395

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1548
        Checks if we've seen it before.
1549

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1662
        return 'OK', 202
1✔
1663

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

1668
        Targets are both objects - original posts, events, etc - and actors.
1669

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1782
                    logger.info(msg)
1✔
1783
                    continue
1✔
1784

1785
            util.add(to_protocols, proto)
1✔
1786

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

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

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

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

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

1830
                continue
1✔
1831

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

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

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

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

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

1868
            if target_author_key:
1✔
1869
                logger.debug(f'Recipient is {target_author_key}')
1✔
1870
                if write_obj.add('notify', target_author_key):
1✔
1871
                    write_obj.dirty = True
1✔
1872

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

1885
        if not to_protocols:
1✔
1886
            return {}
1✔
1887

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

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

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

1915
            logger.info(f'  loaded {len(followers)} followers')
1✔
1916

1917
            user_keys = [f.from_ for f in followers]
1✔
1918
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1919
            logger.info(f'  loaded {len(users)} users')
1✔
1920

1921
            User.load_multi(users)
1✔
1922
            logger.info(f'  loaded user objects')
1✔
1923

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

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

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

1945
                # TODO: should we pass remote=False through here to Protocol.load?
1946
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1947
                if not target:
1✔
1948
                    # logger.error(f'Follower {user.key} has no delivery target')
1949
                    continue
1✔
1950

1951
                # normalize URL (lower case hostname, etc)
1952
                # ...but preserve our PDS URL without trailing slash in path
1953
                # https://atproto.com/specs/did#did-documents
1954
                target = util.dedupe_urls([target], trailing_slash=False)[0]
1✔
1955
                targets[Target(protocol=user.LABEL, uri=target)] = target_obj
1✔
1956

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

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

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

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

1992
        return targets
1✔
1993

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2105
        Also reports an error.
2106

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2225
        return blockee
1✔
2226

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

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

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

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

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

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

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

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

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

2286
        return blockee
1✔
2287

2288

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

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

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

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

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

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

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

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

2333

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

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

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

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

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

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

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

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

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

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

© 2026 Coveralls, Inc