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

snarfed / bridgy-fed / c90b1677-c9b8-41db-b9c8-f4c9309ddafd

06 Jan 2026 11:18PM UTC coverage: 93.268% (-0.02%) from 93.292%
c90b1677-c9b8-41db-b9c8-f4c9309ddafd

push

circleci

snarfed
ATProto.send: for undo of block, translate block object id to ATProto

for https://console.cloud.google.com/errors/detail/CMGRorjE4tKjNg;time=P1D;locations=global?project=bridgy-federated

needs more work though, due to #2281

2 of 4 new or added lines in 1 file covered. (50.0%)

34 existing lines in 2 files now uncovered.

6512 of 6982 relevant lines covered (93.27%)

0.93 hits per line

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

95.86
/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
        """
UNCOV
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✔
UNCOV
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✔
UNCOV
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)
×
UNCOV
431
                if code:
×
432
                    # we tried and failed fetching the id over the network
433
                    return None
×
UNCOV
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}')
×
UNCOV
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
        """
UNCOV
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
        """
UNCOV
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
        """
UNCOV
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
        """
UNCOV
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
        """
UNCOV
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:
×
UNCOV
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
        """
UNCOV
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
        """
UNCOV
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
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
1016
            translate(o, 'object', (ids.translate_user_id if obj_is_actor
1✔
1017
                                    else ids.translate_object_id))
1018

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

1037
        outer_obj = util.trim_nulls(outer_obj)
1✔
1038

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

1044
        return outer_obj
1✔
1045

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

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

1054
        Called by :meth:`Protocol.translate_ids`.
1055

1056
        If ``obj.content`` is HTML, does nothing.
1057

1058
        Args:
1059
          obj (dict): AS2 object
1060

1061
        Returns:
1062
          dict: modified AS2 object
1063
        """
1064
        if not obj:
1✔
UNCOV
1065
            return None
×
1066

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

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

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

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

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

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

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

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

1123
        Returns:
1124
          (str, int) tuple: (response body, HTTP status code) Flask response
1125

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1301
            # fall through to deliver to followers
1302

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

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

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

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

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

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

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

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

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

1392
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1393

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1546
        Checks if we've seen it before.
1547

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1660
        return 'OK', 202
1✔
1661

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

1666
        Targets are both objects - original posts, events, etc - and actors.
1667

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

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

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

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

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

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

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

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

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

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

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

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

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

1767
                else:
1768
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1769
                    last_retry = False
1✔
1770
                    if retries := request.headers.get(TASK_RETRIES_HEADER):
1✔
1771
                        last_retry = int(retries) >= TASK_RETRIES_RECEIVE
1✔
1772

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

1779
                    logger.info(msg)
1✔
1780
                    continue
1✔
1781

1782
            util.add(to_protocols, proto)
1✔
1783

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

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

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

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

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

1827
                continue
1✔
1828

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

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

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

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

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

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

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

1882
        if not to_protocols:
1✔
1883
            return {}
1✔
1884

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

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

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

1912
            logger.info(f'  loaded {len(followers)} followers')
1✔
1913

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

1918
            User.load_multi(users)
1✔
1919
            logger.info(f'  loaded user objects')
1✔
1920

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

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

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

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

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

1954
            logger.info(f'  collected {len(targets)} targets')
1✔
1955

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

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

1986
            target, obj = candidates[url]
1✔
1987
            targets[target] = obj
1✔
1988

1989
        return targets
1✔
1990

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

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

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

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

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

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

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

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

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

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

2073
        if not fetched:
1✔
2074
            return None
1✔
2075
        elif csv and not obj.is_csv:
1✔
UNCOV
2076
            return None
×
2077

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

2084
        obj.resolve_ids()
1✔
2085
        obj.normalize_ids()
1✔
2086

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

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

2095
        obj.put()
1✔
2096
        return obj
1✔
2097

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

2102
        Also reports an error.
2103

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

2108
        Args:
2109
          obj (Object)
2110
          direction (str): ``'receive'`` or  ``'send'``
2111

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

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

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

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

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

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

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

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

2175
        Returns:
2176
          models.User or models.Object: user or list that was blocked
2177

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

2183
        def fail(msg):
1✔
2184
            logger.warning(msg)
1✔
2185
            raise ValueError(msg)
1✔
2186

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

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

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

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

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

2222
        return blockee
1✔
2223

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

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

2232
        Returns:
2233
          models.User or models.Object: user or list that was unblocked
2234

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

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

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

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

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

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

2283
        return blockee
1✔
2284

2285

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

2290
    Calls :meth:`Protocol.receive` with the form parameters.
2291

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

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

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

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

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

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

2330

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

2335
    Calls :meth:`Protocol.send` with the form parameters.
2336

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

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

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

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

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

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

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

2400
    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