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

snarfed / bridgy-fed / 79f863e6-d786-4abb-aa4c-bb2c0cf1f73b

17 Dec 2025 01:24AM UTC coverage: 93.126% (-0.004%) from 93.13%
79f863e6-d786-4abb-aa4c-bb2c0cf1f73b

push

circleci

snarfed
add manual_opt_out=False support to Web.status, Nostr.status

4 of 5 new or added lines in 2 files covered. (80.0%)

40 existing lines in 1 file now uncovered.

6394 of 6866 relevant lines covered (93.13%)

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
    DOMAIN_BLOCKLIST,
35
    DOMAIN_RE,
36
    DOMAINS,
37
    ErrorButDoNotRetryTask,
38
    PRIMARY_DOMAIN,
39
    PROTOCOL_DOMAINS,
40
    report_error,
41
    subdomain_wrap,
42
)
43
import dms
1✔
44
import ids
1✔
45
import memcache
1✔
46
from models import (
1✔
47
    DM,
48
    Follower,
49
    get_original_user_key,
50
    load_user,
51
    Object,
52
    PROTOCOLS,
53
    PROTOCOLS_BY_KIND,
54
    Target,
55
    User,
56
)
57
import notifications
1✔
58

59
OBJECT_REFRESH_AGE = timedelta(days=30)
1✔
60
DELETE_TASK_DELAY = timedelta(minutes=1)
1✔
61
CREATE_MAX_AGE = timedelta(weeks=2)
1✔
62
# WARNING: keep this below the receive queue's min_backoff_seconds in queue.yaml!
63
MEMCACHE_LEASE_EXPIRATION = timedelta(seconds=25)
1✔
64
MEMCACHE_DOWN_TASK_DELAY = timedelta(minutes=5)
1✔
65

66
# require a follow for users on these domains before we deliver anything from
67
# them other than their profile
68
LIMITED_DOMAINS = (os.getenv('LIMITED_DOMAINS', '').split()
1✔
69
                   or util.load_file_lines('limited_domains'))
70

71
# domains to allow non-public activities from
72
NON_PUBLIC_DOMAINS = (
1✔
73
    # bridged from twitter (X). bird.makeup, kilogram.makeup, etc federate
74
    # tweets as followers-only, but they're public on twitter itself
75
    '.makeup',
76
)
77

78
DONT_STORE_AS1_TYPES = as1.CRUD_VERBS | set((
1✔
79
    'accept',
80
    'reject',
81
    'stop-following',
82
    'undo',
83
))
84
STORE_AS1_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES | as1.VERBS_WITH_OBJECT
1✔
85
                   - DONT_STORE_AS1_TYPES)
86

87
logger = logging.getLogger(__name__)
1✔
88

89

90
def error(*args, status=299, **kwargs):
1✔
91
    """Default HTTP status code to 299 to prevent retrying task."""
92
    return common.error(*args, status=status, **kwargs)
1✔
93

94

95
def activity_id_memcache_key(id):
1✔
96
    return memcache.key(f'receive-{id}')
1✔
97

98

99
class Protocol:
1✔
100
    """Base protocol class. Not to be instantiated; classmethods only."""
101
    ABBREV = None
1✔
102
    """str: lower case abbreviation, used in URL paths"""
1✔
103
    PHRASE = None
1✔
104
    """str: human-readable name or phrase. Used in phrases like ``Follow this person on {PHRASE}``"""
1✔
105
    OTHER_LABELS = ()
1✔
106
    """sequence of str: label aliases"""
1✔
107
    LOGO_EMOJI = ''
1✔
108
    """str: logo emoji, if any"""
1✔
109
    LOGO_HTML = ''
1✔
110
    """str: logo ``<img>`` tag, if any"""
1✔
111
    CONTENT_TYPE = None
1✔
112
    """str: MIME type of this protocol's native data format, appropriate for the ``Content-Type`` HTTP header."""
1✔
113
    HAS_COPIES = False
1✔
114
    """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✔
115
    DEFAULT_TARGET = None
1✔
116
    """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✔
117
    REQUIRES_AVATAR = False
1✔
118
    """bool: whether accounts on this protocol are required to have a profile picture. If they don't, their ``User.status`` will be ``blocked``."""
1✔
119
    REQUIRES_NAME = False
1✔
120
    """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✔
121
    REQUIRES_OLD_ACCOUNT = False
1✔
122
    """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✔
123
    DEFAULT_ENABLED_PROTOCOLS = ()
1✔
124
    """sequence of str: labels of other protocols that are automatically enabled for this protocol to bridge into"""
1✔
125
    DEFAULT_SERVE_USER_PAGES = False
1✔
126
    """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✔
127
    SUPPORTED_AS1_TYPES = ()
1✔
128
    """sequence of str: AS1 objectTypes and verbs that this protocol supports receiving and sending"""
1✔
129
    SUPPORTS_DMS = False
1✔
130
    """bool: whether this protocol can receive DMs (chat messages)"""
1✔
131
    USES_OBJECT_FEED = False
1✔
132
    """bool: whether to store followers on this protocol in :attr:`Object.feed`."""
1✔
133
    HTML_PROFILES = False
1✔
134
    """bool: whether this protocol supports HTML in profile descriptions. If False, profile descriptions should be plain text."""
1✔
135
    SEND_REPLIES_TO_ORIG_POSTS_MENTIONS = False
1✔
136
    """bool: whether replies to this protocol should include the original post's mentions as delivery targets"""
1✔
137
    BOTS_FOLLOW_BACK = False
1✔
138
    """bool: when a user on this protocol follows a bot user to enable bridging, does the bot follow them back?"""
1✔
139

140
    @classmethod
1✔
141
    @property
1✔
142
    def LABEL(cls):
1✔
143
        """str: human-readable lower case name of this protocol, eg ``'activitypub``"""
144
        return cls.__name__.lower()
1✔
145

146
    @staticmethod
1✔
147
    def for_request(fed=None):
1✔
148
        """Returns the protocol for the current request.
149

150
        ...based on the request's hostname.
151

152
        Args:
153
          fed (str or protocol.Protocol): protocol to return if the current
154
            request is on ``fed.brid.gy``
155

156
        Returns:
157
          Protocol: protocol, or None if the provided domain or request hostname
158
          domain is not a subdomain of ``brid.gy`` or isn't a known protocol
159
        """
160
        return Protocol.for_bridgy_subdomain(request.host, fed=fed)
1✔
161

162
    @staticmethod
1✔
163
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
164
        """Returns the protocol for a brid.gy subdomain.
165

166
        Args:
167
          domain_or_url (str)
168
          fed (str or protocol.Protocol): protocol to return if the current
169
            request is on ``fed.brid.gy``
170

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

180
        if domain == common.PRIMARY_DOMAIN or domain in common.LOCAL_DOMAINS:
1✔
181
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
1✔
182
        elif domain and domain.endswith(common.SUPERDOMAIN):
1✔
183
            label = domain.removesuffix(common.SUPERDOMAIN)
1✔
184
            return PROTOCOLS.get(label)
1✔
185

186
    @classmethod
1✔
187
    def owns_id(cls, id):
1✔
188
        """Returns whether this protocol owns the id, or None if it's unclear.
189

190
        To be implemented by subclasses.
191

192
        IDs are string identities that uniquely identify users, and are intended
193
        primarily to be machine readable and usable. Compare to handles, which
194
        are human-chosen, human-meaningful, and often but not always unique.
195

196
        Some protocols' ids are more or less deterministic based on the id
197
        format, eg AT Protocol owns ``at://`` URIs. Others, like http(s) URLs,
198
        could be owned by eg Web or ActivityPub.
199

200
        This should be a quick guess without expensive side effects, eg no
201
        external HTTP fetches to fetch the id itself or otherwise perform
202
        discovery.
203

204
        Returns False if the id's domain is in :const:`common.DOMAIN_BLOCKLIST`.
205

206
        Args:
207
          id (str)
208

209
        Returns:
210
          bool or None:
211
        """
212
        return False
1✔
213

214
    @classmethod
1✔
215
    def owns_handle(cls, handle, allow_internal=False):
1✔
216
        """Returns whether this protocol owns the handle, or None if it's unclear.
217

218
        To be implemented by subclasses.
219

220
        Handles are string identities that are human-chosen, human-meaningful,
221
        and often but not always unique. Compare to IDs, which uniquely identify
222
        users, and are intended primarily to be machine readable and usable.
223

224
        Some protocols' handles are more or less deterministic based on the id
225
        format, eg ActivityPub (technically WebFinger) handles are
226
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
227
        ActivityPub, AT Protocol, or others.
228

229
        This should be a quick guess without expensive side effects, eg no
230
        external HTTP fetches to fetch the id itself or otherwise perform
231
        discovery.
232

233
        Args:
234
          handle (str)
235
          allow_internal (bool): whether to return False for internal domains
236
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
237

238
        Returns:
239
          bool or None
240
        """
241
        return False
1✔
242

243
    @classmethod
1✔
244
    def handle_to_id(cls, handle):
1✔
245
        """Converts a handle to an id.
246

247
        To be implemented by subclasses.
248

249
        May incur network requests, eg DNS queries or HTTP requests. Avoids
250
        blocked or opted out users.
251

252
        Args:
253
          handle (str)
254

255
        Returns:
256
          str: corresponding id, or None if the handle can't be found
257
        """
UNCOV
258
        raise NotImplementedError()
×
259

260
    @classmethod
1✔
261
    def authed_user_for_request(cls):
1✔
262
        """Returns the authenticated user id for the current request.
263

264

265
        Checks authentication on the current request, eg HTTP Signature for
266
        ActivityPub. To be implemented by subclasses.
267

268
        Returns:
269
          str: authenticated user id, or None if there is no authentication
270

271
        Raises:
272
          RuntimeError: if the request's authentication (eg signature) is
273
          invalid or otherwise can't be verified
274
        """
275
        return None
1✔
276

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

281
        If called via `Protocol.key_for`, infers the appropriate protocol with
282
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
283
        as is.
284

285
        Args:
286
          id (str):
287
          allow_opt_out (bool): whether to allow users who are currently opted out
288

289
        Returns:
290
          google.cloud.ndb.Key: matching key, or None if the given id is not a
291
          valid :class:`User` id for this protocol.
292
        """
293
        if cls == Protocol:
1✔
294
            proto = Protocol.for_id(id)
1✔
295
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
296

297
        # load user so that we follow use_instead
298
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
299
        if existing:
1✔
300
            if existing.status and not allow_opt_out:
1✔
301
                return None
1✔
302
            return existing.key
1✔
303

304
        return cls(id=id).key
1✔
305

306
    @staticmethod
1✔
307
    def _for_id_memcache_key(id, remote=None):
1✔
308
        """If id is a URL, uses its domain, otherwise returns None.
309

310
        Args:
311
          id (str)
312

313
        Returns:
314
          (str domain, bool remote) or None
315
        """
316
        domain = util.domain_from_link(id)
1✔
317
        if domain in PROTOCOL_DOMAINS:
1✔
318
            return id
1✔
319
        elif remote and util.is_web(id):
1✔
320
            return domain
1✔
321

322
    @cached(LRUCache(20000), lock=Lock())
1✔
323
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote=True: remote,
1✔
324
                      version=3)
325
    @staticmethod
1✔
326
    def for_id(id, remote=True):
1✔
327
        """Returns the protocol for a given id.
328

329
        Args:
330
          id (str)
331
          remote (bool): whether to perform expensive side effects like fetching
332
            the id itself over the network, or other discovery.
333

334
        Returns:
335
          Protocol subclass: matching protocol, or None if no single known
336
          protocol definitively owns this id
337
        """
338
        logger.debug(f'Determining protocol for id {id}')
1✔
339
        if not id:
1✔
340
            return None
1✔
341

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

351
        if util.is_web(id):
1✔
352
            # step 1: check for our per-protocol subdomains
353
            try:
1✔
354
                parsed = urlparse(id)
1✔
355
            except ValueError as e:
1✔
356
                logger.info(f'urlparse ValueError: {e}')
1✔
357
                return None
1✔
358

359
            is_homepage = parsed.path.strip('/') == ''
1✔
360
            is_internal = parsed.path.startswith(ids.INTERNAL_PATH_PREFIX)
1✔
361
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
362
            if by_subdomain and not (is_homepage or is_internal
1✔
363
                                     or id in ids.BOT_ACTOR_AP_IDS):
364
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
365
                return by_subdomain
1✔
366

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

380
        if len(candidates) == 1:
1✔
381
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
382
            return candidates[0]
1✔
383

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

395
        # step 4: fetch over the network, if necessary
396
        if not remote:
1✔
397
            return None
1✔
398

399
        for protocol in candidates:
1✔
400
            logger.debug(f'Trying {protocol.LABEL}')
1✔
401
            try:
1✔
402
                obj = protocol.load(id, local=False, remote=True)
1✔
403

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

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

430
        logger.info(f'No matching protocol found for {id} !')
1✔
431
        return None
1✔
432

433
    @cached(LRUCache(20000), lock=Lock())
1✔
434
    @staticmethod
1✔
435
    def for_handle(handle):
1✔
436
        """Returns the protocol for a given handle.
437

438
        May incur expensive side effects like resolving the handle itself over
439
        the network or other discovery.
440

441
        Args:
442
          handle (str)
443

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

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

466
        if len(candidates) == 1:
1✔
UNCOV
467
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
UNCOV
468
            return (candidates[0], None)
×
469

470
        # step 2: look for matching User in the datastore
471
        for proto in candidates:
1✔
472
            user = proto.query(proto.handle == handle).get()
1✔
473
            if user:
1✔
474
                if user.status:
1✔
475
                    return (None, None)
1✔
476
                logger.debug(f'  user {user.key} handle {handle}')
1✔
477
                return (proto, user.key.id())
1✔
478

479
        # step 3: resolve handle to id
480
        for proto in candidates:
1✔
481
            id = proto.handle_to_id(handle)
1✔
482
            if id:
1✔
483
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
484
                return (proto, id)
1✔
485

486
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
487
        return (None, None)
1✔
488

489
    @classmethod
1✔
490
    def is_user_at_domain(cls, handle, allow_internal=False):
1✔
491
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
492

493
        Example: ``@user@instance.com``
494

495
        Args:
496
          handle (str)
497
          allow_internal (bool): whether the domain can be a Bridgy Fed domain
498
        """
499
        parts = handle.split('@')
1✔
500
        if len(parts) != 2:
1✔
501
            return False
1✔
502

503
        user, domain = parts
1✔
504
        return bool(user and domain
1✔
505
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
506

507
    @classmethod
1✔
508
    def bridged_web_url_for(cls, user, fallback=False):
1✔
509
        """Returns the web URL for a user's bridged profile in this protocol.
510

511
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
512
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
513

514
        Args:
515
          user (models.User)
516
          fallback (bool): if True, and bridged users have no canonical user
517
            profile URL in this protocol, return the native protocol's profile URL
518

519
        Returns:
520
          str, or None if there isn't a canonical URL
521
        """
522
        if fallback:
1✔
523
            return user.web_url()
1✔
524

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

529
        Args:
530
          obj (models.Object)
531
          allow_opt_out (bool): whether to return a user key if they're opted out
532

533
        Returns:
534
          google.cloud.ndb.key.Key or None:
535
        """
536
        owner = as1.get_owner(obj.as1)
1✔
537
        if owner:
1✔
538
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
539

540
    @classmethod
1✔
541
    def bot_user_id(cls):
1✔
542
        """Returns the Web user id for the bot user for this protocol.
543

544
        For example, ``'bsky.brid.gy'`` for ATProto.
545

546
        Returns:
547
          str:
548
        """
549
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
550

551
    @classmethod
1✔
552
    def create_for(cls, user):
1✔
553
        """Creates or re-activate a copy user in this protocol.
554

555
        Should add the copy user to :attr:`copies`.
556

557
        If the copy user already exists and active, should do nothing.
558

559
        Args:
560
          user (models.User): original source user. Shouldn't already have a
561
            copy user for this protocol in :attr:`copies`.
562

563
        Raises:
564
          ValueError: if we can't create a copy of the given user in this protocol
565
        """
UNCOV
566
        raise NotImplementedError()
×
567

568
    @classmethod
1✔
569
    def send(to_cls, obj, target, from_user=None, orig_obj_id=None):
1✔
570
        """Sends an outgoing activity.
571

572
        To be implemented by subclasses. Should call
573
        ``to_cls.translate_ids(obj.as1)`` before converting it to this Protocol's
574
        format.
575

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

580
        Args:
581
          obj (models.Object): with activity to send
582
          target (str): destination URL to send to
583
          from_user (models.User): user (actor) this activity is from
584
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
585
            that this object refers to, eg replies to or reposts or likes
586

587
        Returns:
588
          bool: True if the activity is sent successfully, False if it is
589
          ignored or otherwise unsent due to protocol logic, eg no webmention
590
          endpoint, protocol doesn't support the activity type. (Failures are
591
          raised as exceptions.)
592

593
        Raises:
594
          werkzeug.HTTPException if the request fails
595
        """
UNCOV
596
        raise NotImplementedError()
×
597

598
    @classmethod
1✔
599
    def fetch(cls, obj, **kwargs):
1✔
600
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
601

602
        Errors are raised as exceptions. If this method returns False, the fetch
603
        didn't fail but didn't succeed either, eg the id isn't valid for this
604
        protocol, or the fetch didn't return valid data for this protocol.
605

606
        To be implemented by subclasses.
607

608
        Args:
609
          obj (models.Object): with the id to fetch. Data is filled into one of
610
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
611
          kwargs: subclass-specific
612

613
        Returns:
614
          bool: True if the object was fetched and populated successfully,
615
          False otherwise
616

617
        Raises:
618
          requests.RequestException, werkzeug.HTTPException,
619
          websockets.WebSocketException, etc: if the fetch fails
620
        """
UNCOV
621
        raise NotImplementedError()
×
622

623
    @classmethod
1✔
624
    def convert(cls, obj, from_user=None, **kwargs):
1✔
625
        """Converts an :class:`Object` to this protocol's data format.
626

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

630
        Just passes through to :meth:`_convert`, then does minor
631
        protocol-independent postprocessing.
632

633
        Args:
634
          obj (models.Object):
635
          from_user (models.User): user (actor) this activity/object is from
636
          kwargs: protocol-specific, passed through to :meth:`_convert`
637

638
        Returns:
639
          converted object in the protocol's native format, often a dict
640
        """
641
        if not obj or not obj.as1:
1✔
642
            return {}
1✔
643

644
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
645
        is_crud = obj.as1.get('verb') in as1.CRUD_VERBS
1✔
646
        base_obj = as1.get_object(obj.as1) if is_crud else obj.as1
1✔
647
        orig_our_as1 = obj.our_as1
1✔
648

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

660
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
661
        obj.our_as1 = orig_our_as1
1✔
662
        return converted
1✔
663

664
    @classmethod
1✔
665
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
666
        """Converts an :class:`Object` to this protocol's data format.
667

668
        To be implemented by subclasses. Implementations should generally call
669
        :meth:`Protocol.translate_ids` (as their own class) before converting to
670
        their format.
671

672
        Args:
673
          obj (models.Object):
674
          from_user (models.User): user (actor) this activity/object is from
675
          kwargs: protocol-specific
676

677
        Returns:
678
          converted object in the protocol's native format, often a dict. May
679
            return the ``{}`` empty dict if the object can't be converted.
680
        """
UNCOV
681
        raise NotImplementedError()
×
682

683
    @classmethod
1✔
684
    def add_source_links(cls, obj, from_user):
1✔
685
        """Adds "bridged from ... by Bridgy Fed" to the user's actor's ``summary``.
686

687
        Uses HTML for protocols that support it, plain text otherwise.
688

689
        Args:
690
          cls (Protocol subclass): protocol that the user is bridging into
691
          obj (models.Object): user's actor/profile object
692
          from_user (models.User): user (actor) this activity/object is from
693
        """
694
        assert obj and obj.as1
1✔
695
        assert from_user
1✔
696

697
        obj.our_as1 = copy.deepcopy(obj.as1)
1✔
698
        actor = (as1.get_object(obj.as1) if obj.type in as1.CRUD_VERBS
1✔
699
                 else obj.as1)
700
        actor['objectType'] = 'person'
1✔
701

702
        orig_summary = actor.setdefault('summary', '')
1✔
703
        summary_text = html_to_text(orig_summary, ignore_links=True)
1✔
704

705
        # Check if we've already added source links
706
        if '🌉 bridged' in summary_text:
1✔
707
            return
1✔
708

709
        actor_id = actor.get('id')
1✔
710

711
        url = (as1.get_url(actor)
1✔
712
               or (from_user.web_url() if from_user.profile_id() == actor_id
713
                   else actor_id))
714

715
        from web import Web
1✔
716
        bot_user = Web.get_by_id(from_user.bot_user_id())
1✔
717

718
        if cls.HTML_PROFILES:
1✔
719
            if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS:
1✔
720
                mention = bot_user.html_link(proto=cls, name=False, handle='short')
1✔
721
                suffix = f', follow {mention} to interact'
1✔
722
            else:
723
                suffix = f' by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>'
1✔
724

725
            separator = '<br><br>'
1✔
726

727
            is_user = from_user.key and actor_id in (from_user.key.id(),
1✔
728
                                                     from_user.profile_id())
729
            if is_user:
1✔
730
                bridged = f'🌉 <a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a>'
1✔
731
                from_ = f'<a href="{from_user.web_url()}">{from_user.handle}</a>'
1✔
732
            else:
733
                bridged = '🌉 bridged'
1✔
734
                from_ = util.pretty_link(url) if url else '?'
1✔
735

736
        else:  # plain text
737
            # TODO: unify with above. which is right?
738
            id = obj.key.id() if obj.key else obj.our_as1.get('id')
1✔
739
            is_user = from_user.key and id in (from_user.key.id(),
1✔
740
                                               from_user.profile_id())
741
            from_ = (from_user.web_url() if is_user else url) or '?'
1✔
742

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

754
        logo = f'{from_user.LOGO_EMOJI} ' if from_user.LOGO_EMOJI else ''
1✔
755
        source_links = f'{separator if orig_summary else ""}{bridged} from {logo}{from_}{suffix}'
1✔
756
        actor['summary'] = orig_summary + source_links
1✔
757

758
    @classmethod
1✔
759
    def set_username(to_cls, user, username):
1✔
760
        """Sets a custom username for a user's bridged account in this protocol.
761

762
        Args:
763
          user (models.User)
764
          username (str)
765

766
        Raises:
767
          ValueError: if the username is invalid
768
          RuntimeError: if the username could not be set
769
        """
770
        raise NotImplementedError()
1✔
771

772
    @classmethod
1✔
773
    def migrate_out(cls, user, to_user_id):
1✔
774
        """Migrates a bridged account out to be a native account.
775

776
        Args:
777
          user (models.User)
778
          to_user_id (str)
779

780
        Raises:
781
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
782
            ``user`` is on this protocol or not bridged to this protocol
783
        """
UNCOV
784
        raise NotImplementedError()
×
785

786
    @classmethod
1✔
787
    def check_can_migrate_out(cls, user, to_user_id):
1✔
788
        """Raises an exception if a user can't yet migrate to a native account.
789

790
        For example, if ``to_user_id`` isn't on this protocol, or if ``user`` is on
791
        this protocol, or isn't bridged to this protocol.
792

793
        If the user is ready to migrate, returns ``None``.
794

795
        Subclasses may override this to add more criteria, but they should call this
796
        implementation first.
797

798
        Args:
799
          user (models.User)
800
          to_user_id (str)
801

802
        Raises:
803
          ValueError: if ``user`` isn't ready to migrate to this protocol yet
804
        """
805
        def _error(msg):
1✔
806
            logger.warning(msg)
1✔
807
            raise ValueError(msg)
1✔
808

809
        if cls.owns_id(to_user_id) is False:
1✔
810
            _error(f"{to_user_id} doesn't look like an {cls.LABEL} id")
1✔
811
        elif isinstance(user, cls):
1✔
812
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
813
        elif not user.is_enabled(cls):
1✔
814
            _error(f"{user.handle_or_id()} isn't currently bridged to {cls.PHRASE}")
1✔
815

816
    @classmethod
1✔
817
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
818
        """Migrates a native account in to be a bridged account.
819

820
        The protocol independent parts are done here; protocol-specific parts are
821
        done in :meth:`_migrate_in`, which this wraps.
822

823
        Reloads the user's profile before calling :meth:`_migrate_in`.
824

825
        Args:
826
          user (models.User): native user on another protocol to attach the
827
            newly imported bridged account to
828
          from_user_id (str)
829
          kwargs: additional protocol-specific parameters
830

831
        Raises:
832
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
833
            ``user`` is on this protocol or already bridged to this protocol
834
        """
835
        def _error(msg):
1✔
836
            logger.warning(msg)
1✔
837
            raise ValueError(msg)
1✔
838

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

841
        # check req'ts
842
        if cls.owns_id(from_user_id) is False:
1✔
843
            _error(f"{from_user_id} doesn't look like an {cls.LABEL} id")
1✔
844
        elif isinstance(user, cls):
1✔
845
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
846
        elif cls.HAS_COPIES and cls.LABEL in user.enabled_protocols:
1✔
847
            _error(f"{user.handle_or_id()} is already bridged to {cls.PHRASE}")
1✔
848

849
        # reload profile
850
        try:
1✔
851
            user.reload_profile()
1✔
UNCOV
852
        except (RequestException, HTTPException) as e:
×
UNCOV
853
            _, msg = util.interpret_http_exception(e)
×
854

855
        # migrate!
856
        cls._migrate_in(user, from_user_id, **kwargs)
1✔
857
        user.add('enabled_protocols', cls.LABEL)
1✔
858
        user.put()
1✔
859

860
        # attach profile object
861
        if user.obj:
1✔
862
            if cls.HAS_COPIES:
1✔
863
                profile_id = ids.profile_id(id=from_user_id, proto=cls)
1✔
864
                user.obj.remove_copies_on(cls)
1✔
865
                user.obj.add('copies', Target(uri=profile_id, protocol=cls.LABEL))
1✔
866
                user.obj.put()
1✔
867

868
            common.create_task(queue='receive', obj_id=user.obj_key.id(),
1✔
869
                               authed_as=user.key.id())
870

871
    @classmethod
1✔
872
    def _migrate_in(cls, user, from_user_id, **kwargs):
1✔
873
        """Protocol-specific parts of migrating in external account.
874

875
        Called by :meth:`migrate_in`, which does most of the work, including calling
876
        :meth:`reload_profile` before this.
877

878
        Args:
879
          user (models.User): native user on another protocol to attach the
880
            newly imported account to. Unused.
881
          from_user_id (str): DID of the account to be migrated in
882
          kwargs: protocol dependent
883
        """
UNCOV
884
        raise NotImplementedError()
×
885

886
    @classmethod
1✔
887
    def target_for(cls, obj, shared=False):
1✔
888
        """Returns an :class:`Object`'s delivery target (endpoint).
889

890
        To be implemented by subclasses.
891

892
        Examples:
893

894
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
895
          webmention target.
896
        * If obj is an ``activitypub`` actor, returns its inbox.
897
        * If obj is an ``activitypub`` object, returns it's author's or actor's
898
          inbox.
899

900
        Args:
901
          obj (models.Object):
902
          shared (bool): optional. If True, returns a common/shared
903
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
904
            multiple recipients for efficiency
905

906
        Returns:
907
          str: target endpoint, or None if not available.
908
        """
UNCOV
909
        raise NotImplementedError()
×
910

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

915
        Default implementation here, subclasses may override.
916

917
        Args:
918
          url (str):
919
          allow_internal (bool): whether to return False for internal domains
920
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
921
        """
922
        blocklist = DOMAIN_BLOCKLIST
1✔
923
        if not DEBUG:
1✔
924
            blocklist += tuple(util.RESERVED_TLDS | util.LOCAL_TLDS)
1✔
925
        if not allow_internal:
1✔
926
            blocklist += DOMAINS
1✔
927
        return util.domain_or_parent_in(url, blocklist)
1✔
928

929
    @classmethod
1✔
930
    def translate_ids(to_cls, obj):
1✔
931
        """Translates all ids in an AS1 object to a specific protocol.
932

933
        Infers source protocol for each id value separately.
934

935
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
936
        ``at://did:plc:abc/coll/123`` will be converted to
937
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
938

939
        Wraps these AS1 fields:
940

941
        * ``id``
942
        * ``actor``
943
        * ``author``
944
        * ``bcc``
945
        * ``bto``
946
        * ``cc``
947
        * ``featured[].items``, ``featured[].orderedItems``
948
        * ``object``
949
        * ``object.actor``
950
        * ``object.author``
951
        * ``object.id``
952
        * ``object.inReplyTo``
953
        * ``object.object``
954
        * ``attachments[].id``
955
        * ``tags[objectType=mention].url``
956
        * ``to``
957

958
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
959
        same logic is duplicated there!
960

961
        TODO: unify with :meth:`Object.resolve_ids`,
962
        :meth:`models.Object.normalize_ids`.
963

964
        Args:
965
          to_proto (Protocol subclass)
966
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
967

968
        Returns:
969
          dict: translated AS1 version of ``obj``
970
        """
971
        assert to_cls != Protocol
1✔
972
        if not obj:
1✔
973
            return obj
1✔
974

975
        outer_obj = to_cls.translate_mention_handles(copy.deepcopy(obj))
1✔
976
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
977

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

992
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
993
                           for o in elem[field]]
994

995
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
996
                elem[field] = elem[field][0]
1✔
997

998
        type = as1.object_type(outer_obj)
1✔
999
        translate(outer_obj, 'id',
1✔
1000
                  ids.translate_user_id if type in as1.ACTOR_TYPES
1001
                  else ids.translate_object_id)
1002

1003
        for o in inner_objs:
1✔
1004
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
1005
                        or as1.get_owner(outer_obj) == o.get('id')
1006
                        or type in ('follow', 'stop-following'))
1007
            translate(o, 'id', (ids.translate_user_id if is_actor
1✔
1008
                                else ids.translate_object_id))
1009
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
1010
            translate(o, 'object', (ids.translate_user_id if obj_is_actor
1✔
1011
                                    else ids.translate_object_id))
1012

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

1031
        outer_obj = util.trim_nulls(outer_obj)
1✔
1032

1033
        if objs := util.get_list(outer_obj ,'object'):
1✔
1034
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
1035
            if len(outer_obj['object']) == 1:
1✔
1036
                outer_obj['object'] = outer_obj['object'][0]
1✔
1037

1038
        return outer_obj
1✔
1039

1040
    @classmethod
1✔
1041
    def translate_mention_handles(cls, obj):
1✔
1042
        """Translates @-mentions in ``obj.content`` to this protocol's handles.
1043

1044
        Specifically, for each ``mention`` tag in the object's tags that has
1045
        ``startIndex`` and ``length``, replaces it in ``obj.content`` with that
1046
        user's translated handle in this protocol and updates the tag's location.
1047

1048
        Called by :meth:`Protocol.translate_ids`.
1049

1050
        If ``obj.content`` is HTML, does nothing.
1051

1052
        Args:
1053
          obj (dict): AS2 object
1054

1055
        Returns:
1056
          dict: modified AS2 object
1057
        """
1058
        if not obj:
1✔
UNCOV
1059
            return None
×
1060

1061
        obj = copy.deepcopy(obj)
1✔
1062
        obj['object'] = [cls.translate_mention_handles(o)
1✔
1063
                                for o in as1.get_objects(obj)]
1064
        if len(obj['object']) == 1:
1✔
1065
            obj['object'] = obj['object'][0]
1✔
1066

1067
        content = obj.get('content')
1✔
1068
        tags = obj.get('tags')
1✔
1069
        if (not content or not tags
1✔
1070
                or obj.get('content_is_html')
1071
                or bool(BeautifulSoup(content, 'html.parser').find())
1072
                or HTML_ENTITY_RE.search(content)):
1073
            return util.trim_nulls(obj)
1✔
1074

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

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

1098
        obj['tags'] = tags
1✔
1099
        as2.set_content(obj, content)  # sets content *and* contentMap
1✔
1100
        return util.trim_nulls(obj)
1✔
1101

1102
    @classmethod
1✔
1103
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
1104
        """Handles an incoming activity.
1105

1106
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
1107
        unset, returns HTTP 299.
1108

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

1117
        Returns:
1118
          (str, int) tuple: (response body, HTTP status code) Flask response
1119

1120
        Raises:
1121
          werkzeug.HTTPException: if the request is invalid
1122
        """
1123
        # check some invariants
1124
        assert from_cls != Protocol
1✔
1125
        assert isinstance(obj, Object), obj
1✔
1126

1127
        if not obj.as1:
1✔
1128
            error('No object data provided')
1✔
1129

1130
        orig_obj = obj
1✔
1131
        id = None
1✔
1132
        if obj.key and obj.key.id():
1✔
1133
            id = obj.key.id()
1✔
1134

1135
        if not id:
1✔
1136
            id = obj.as1.get('id')
1✔
1137
            obj.key = ndb.Key(Object, id)
1✔
1138

1139
        if not id:
1✔
UNCOV
1140
            error('No id provided')
×
1141
        elif from_cls.owns_id(id) is False:
1✔
1142
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
1143
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
1144
            error(f'Activity {id} is blocklisted')
1✔
1145

1146
        # does this protocol support this activity/object type?
1147
        from_cls.check_supported(obj, 'receive')
1✔
1148

1149
        # lease this object, atomically
1150
        memcache_key = activity_id_memcache_key(id)
1✔
1151
        leased = memcache.memcache.add(
1✔
1152
            memcache_key, 'leased', noreply=False,
1153
            expire=int(MEMCACHE_LEASE_EXPIRATION.total_seconds()))
1154

1155
        # short circuit if we've already seen this activity id.
1156
        # (don't do this for bare objects since we need to check further down
1157
        # whether they've been updated since we saw them last.)
1158
        if (obj.as1.get('objectType') == 'activity'
1✔
1159
            and 'force' not in request.values
1160
            and (not leased
1161
                 or (obj.new is False and obj.changed is False))):
1162
            error(f'Already seen this activity {id}', status=204)
1✔
1163

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

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

1184
        assert authed_as
1✔
1185
        assert isinstance(authed_as, str)
1✔
1186
        authed_as = ids.normalize_user_id(id=authed_as, proto=from_cls)
1✔
1187
        actor = ids.normalize_user_id(id=actor, proto=from_cls)
1✔
1188
        if actor != authed_as:
1✔
1189
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
1190
                         user=f'{id} authed_as {authed_as} owner {actor}')
1191
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
1192

1193
        # update copy ids to originals
1194
        obj.normalize_ids()
1✔
1195
        obj.resolve_ids()
1✔
1196

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

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

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

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

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

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

1253
        # write Object to datastore
1254
        obj.source_protocol = from_cls.LABEL
1✔
1255
        if obj.type in STORE_AS1_TYPES:
1✔
1256
            obj.put()
1✔
1257

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

1268
        actor = as1.get_object(obj.as1, 'actor')
1✔
1269
        actor_id = actor.get('id')
1✔
1270

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

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

1293
            # fall through to deliver to followee
1294
            # TODO: do we convert stop-following to webmention 410 of original
1295
            # follow?
1296

1297
            # fall through to deliver to followers
1298

1299
        elif obj.type in ('delete', 'undo'):
1✔
1300
            delete_obj_id = (from_user.profile_id()
1✔
1301
                            if inner_obj_id == from_user.key.id()
1302
                            else inner_obj_id)
1303

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

1309
            # TODO: just delete altogether!
1310
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1311
            delete_obj.deleted = True
1✔
1312
            delete_obj.put()
1✔
1313

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

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

1329
        elif obj.type == 'post':
1✔
1330
            # handle DMs to bot users
1331
            if as1.is_dm(obj.as1):
1✔
1332
                return dms.receive(from_user=from_user, obj=obj)
1✔
1333

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

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

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

1386
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1387

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

1396
        # deliver to targets
1397
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1398

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

1408
            logger.info(f'Deactivating Followers from or to {from_user.key.id()}')
1✔
1409
            followers = Follower.query(
1✔
1410
                OR(Follower.to == from_user.key, Follower.from_ == from_user.key)
1411
            ).fetch()
1412
            for f in followers:
1✔
1413
                f.status = 'inactive'
1✔
1414
            ndb.put_multi(followers)
1✔
1415

1416
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1417
        return resp
1✔
1418

1419
    @classmethod
1✔
1420
    def handle_follow(from_cls, obj, from_user):
1✔
1421
        """Handles an incoming follow activity.
1422

1423
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1424
        happens in :meth:`deliver`.
1425

1426
        Args:
1427
          obj (models.Object): follow activity
1428
        """
1429
        logger.debug('Got follow. storing Follow(s), sending accept(s)')
1✔
1430
        from_id = from_user.key.id()
1✔
1431

1432
        # Prepare followee (to) users' data
1433
        to_as1s = as1.get_objects(obj.as1)
1✔
1434
        if not to_as1s:
1✔
1435
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1436

1437
        # Store Followers
1438
        for to_as1 in to_as1s:
1✔
1439
            to_id = to_as1.get('id')
1✔
1440
            if not to_id:
1✔
1441
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1442

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

1445
            to_cls = Protocol.for_id(to_id)
1✔
1446
            if not to_cls:
1✔
UNCOV
1447
                error(f"Couldn't determine protocol for {to_id}")
×
1448
            elif from_cls == to_cls:
1✔
1449
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1450
                continue
1✔
1451

1452
            to_key = to_cls.key_for(to_id)
1✔
1453
            if not to_key:
1✔
UNCOV
1454
                logger.info(f'Skipping invalid {to_cls.LABEL} user key: {to_id}')
×
UNCOV
1455
                continue
×
1456

1457
            to_user = to_cls.get_or_create(id=to_key.id())
1✔
1458
            if not to_user or not to_user.is_enabled(from_user):
1✔
1459
                error(f'{to_id} not found')
1✔
1460

1461
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1462
                                                  follow=obj.key, status='active')
1463
            obj.add('notify', to_key)
1✔
1464
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1465
                                       followee=to_user, follow=obj)
1466

1467
    @classmethod
1✔
1468
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1469
        """Sends an accept or reject activity for a follow.
1470

1471
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1472
        nothing.
1473

1474
        Args:
1475
          verb (str): ``accept`` or  ``reject``
1476
          follower (models.User)
1477
          followee (models.User)
1478
          follow (models.Object)
1479
        """
1480
        assert verb in ('accept', 'reject')
1✔
1481
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1482
            return
1✔
1483

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

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

1500
    @classmethod
1✔
1501
    def bot_maybe_follow_back(bot_cls, user):
1✔
1502
        """Follow a user from a protocol bot user, if their protocol needs that.
1503

1504
        ...so that the protocol starts sending us their activities, if it needs
1505
        a follow for that (eg ActivityPub).
1506

1507
        Args:
1508
          user (User)
1509
        """
1510
        if not user.BOTS_FOLLOW_BACK:
1✔
1511
            return
1✔
1512

1513
        from web import Web
1✔
1514
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1515
        now = util.now().isoformat()
1✔
1516
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1517

1518
        if not user.obj:
1✔
1519
            logger.info("  can't follow, user has no profile obj")
1✔
1520
            return
1✔
1521

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

1536
    @classmethod
1✔
1537
    def handle_bare_object(cls, obj, *, authed_as, from_user):
1✔
1538
        """If obj is a bare object, wraps it in a create or update activity.
1539

1540
        Checks if we've seen it before.
1541

1542
        Args:
1543
          obj (models.Object)
1544
          authed_as (str): authenticated actor id who sent this activity
1545
          from_user (models.User): user (actor) this activity/object is from
1546

1547
        Returns:
1548
          models.Object: ``obj`` if it's an activity, otherwise a new object
1549
        """
1550
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1551
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1552
            return obj
1✔
1553

1554
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1555
        now = util.now().isoformat()
1✔
1556

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

1583
        if (obj.new
1✔
1584
                # HACK: force query param here is specific to webmention
1585
                or 'force' in request.form):
1586
            create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1587
            create_as1 = {
1✔
1588
                'objectType': 'activity',
1589
                'verb': 'post',
1590
                'id': create_id,
1591
                'actor': obj_actor,
1592
                'object': obj.as1,
1593
                'published': now,
1594
            }
1595
            logger.info(f'Wrapping in post')
1✔
1596
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1597
            return Object(id=create_id, our_as1=create_as1,
1✔
1598
                          source_protocol=obj.source_protocol)
1599

1600
        error(f'{obj.key.id()} is unchanged, nothing to do', status=204)
1✔
1601

1602
    @classmethod
1✔
1603
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1604
        """Delivers an activity to its external recipients.
1605

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

1615
        Returns:
1616
          (str, int) tuple: Flask response
1617
        """
1618
        if to_proto:
1✔
1619
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1620

1621
        # find delivery targets. maps Target to Object or None
1622
        #
1623
        # ...then write the relevant object, since targets() has a side effect of
1624
        # setting the notify and feed properties (and dirty attribute)
1625
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1626
        if to_proto:
1✔
1627
            targets = {t: obj for t, obj in targets.items()
1✔
1628
                       if t.protocol == to_proto.LABEL}
1629
        if not targets:
1✔
1630
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1631

1632
        # store object that targets() updated
1633
        if crud_obj and crud_obj.dirty:
1✔
1634
            crud_obj.put()
1✔
1635
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1636
            obj.put()
1✔
1637

1638
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1639
                      else obj.to_request())
1640

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

1644
        # enqueue send task for each targets
1645
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1646
        user = from_user.key.urlsafe()
1✔
1647
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1648
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1649
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1650
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1651

1652
        return 'OK', 202
1✔
1653

1654
    @classmethod
1✔
1655
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1656
        """Collects the targets to send a :class:`models.Object` to.
1657

1658
        Targets are both objects - original posts, events, etc - and actors.
1659

1660
        Args:
1661
          obj (models.Object)
1662
          from_user (User)
1663
          crud_obj (models.Object): if this is a create, update, or delete/undo
1664
            activity, the inner object that's being written, otherwise None.
1665
            (This object's ``notify`` and ``feed`` properties may be updated.)
1666
          internal (bool): whether this is a recursive internal call
1667

1668
        Returns:
1669
          dict: maps :class:`models.Target` to original (in response to)
1670
          :class:`models.Object`, if any, otherwise None
1671
        """
1672
        logger.debug('Finding recipients and their targets')
1✔
1673

1674
        # we should only have crud_obj iff this is a create or update
1675
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1676
        write_obj = crud_obj or obj
1✔
1677
        write_obj.dirty = False
1✔
1678

1679
        target_uris = as1.targets(obj.as1)
1✔
1680
        orig_obj = None
1✔
1681
        targets = {}  # maps Target to Object or None
1✔
1682
        owner = as1.get_owner(obj.as1)
1✔
1683
        allow_opt_out = (obj.type == 'delete')
1✔
1684
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1685
        inner_obj_id = inner_obj_as1.get('id')
1✔
1686
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1687
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1688
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1689
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1690
        is_self_reply = False
1✔
1691

1692
        original_ids = []
1✔
1693
        if is_reply:
1✔
1694
            original_ids = in_reply_tos
1✔
1695
        elif inner_obj_id:
1✔
1696
            if inner_obj_id == from_user.key.id():
1✔
1697
                inner_obj_id = from_user.profile_id()
1✔
1698
            original_ids = [inner_obj_id]
1✔
1699

1700
        # maps id to Object
1701
        original_objs = {}
1✔
1702
        for id in original_ids:
1✔
1703
            if proto := Protocol.for_id(id):
1✔
1704
                original_objs[id] = proto.load(id, raise_=False)
1✔
1705

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

1720
        target_uris = sorted(set(target_uris))
1✔
1721
        logger.info(f'Raw targets: {target_uris}')
1✔
1722

1723
        # which protocols should we allow delivering to?
1724
        to_protocols = []
1✔
1725
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1726
                      + from_user.enabled_protocols):
1727
            if not (proto := PROTOCOLS.get(label)):
1✔
1728
                report_error(f'unknown enabled protocol {label} for {from_user.key.id()}')
1✔
1729
                continue
1✔
1730

1731
            if (obj.type == 'post' and (orig := original_objs.get(inner_obj_id))
1✔
1732
                    and orig.get_copy(proto)):
1733
                logger.info(f'Already created {id} on {label}, cowardly refusing to create there again')
1✔
1734
                continue
1✔
1735

1736
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1737
                                     or is_reply):
1738
                origs_could_bridge = None
1✔
1739

1740
                for id in original_ids:
1✔
1741
                    if not (orig := original_objs.get(id)):
1✔
1742
                        continue
1✔
1743
                    elif isinstance(orig, proto):
1✔
UNCOV
1744
                        logger.info(f'Allowing {label} for original {id}')
×
UNCOV
1745
                        break
×
1746
                    elif orig.get_copy(proto):
1✔
1747
                        logger.info(f'Allowing {label}, original {id} was bridged there')
1✔
1748
                        break
1✔
1749
                    elif from_user.is_profile(orig):
1✔
1750
                        logger.info(f"Allowing {label}, this is the user's profile")
1✔
1751
                        break
1✔
1752

1753
                    if (origs_could_bridge is not False
1✔
1754
                            and (orig_author_id := as1.get_owner(orig.as1))
1755
                            and (orig_proto := PROTOCOLS.get(orig.source_protocol))
1756
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1757
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1758

1759
                else:
1760
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1761
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
1✔
1762
                            and origs_could_bridge):
1763
                        # retry later; original obj may still be bridging
1764
                        # TODO: limit to brief window, eg no older than 2h? 1d?
1765
                        error(msg, status=304)
1✔
1766

1767
                    logger.info(msg)
1✔
1768
                    continue
1✔
1769

1770
            util.add(to_protocols, proto)
1✔
1771

1772
        # process direct targets
1773
        for target_id in target_uris:
1✔
1774
            target_proto = Protocol.for_id(target_id)
1✔
1775
            if not target_proto:
1✔
1776
                logger.info(f"Can't determine protocol for {target_id}")
1✔
1777
                continue
1✔
1778
            elif target_proto.is_blocklisted(target_id):
1✔
1779
                logger.debug(f'{target_id} is blocklisted')
1✔
1780
                continue
1✔
1781

1782
            target_obj_id = target_id
1✔
1783
            if target_id in mentioned_urls or obj.type in as1.VERBS_WITH_ACTOR_OBJECT:
1✔
1784
                # not ideal. this can sometimes be a non-user, eg blocking a
1785
                # blocklist. ok right now since profile_id() returns its input id
1786
                # unchanged if it doesn't look like a user id, but that's brittle.
1787
                target_obj_id = ids.profile_id(id=target_id, proto=target_proto)
1✔
1788

1789
            orig_obj = target_proto.load(target_obj_id, raise_=False)
1✔
1790
            if not orig_obj or not orig_obj.as1:
1✔
1791
                logger.info(f"Couldn't load {target_obj_id}")
1✔
1792
                continue
1✔
1793

1794
            target_author_key = (target_proto(id=target_id).key
1✔
1795
                                 if target_id in mentioned_urls
1796
                                 else target_proto.actor_key(orig_obj))
1797
            if not from_user.is_enabled(target_proto):
1✔
1798
                # if author isn't bridged and target user is, DM a prompt and
1799
                # add a notif for the target user
1800
                if (target_id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
1801
                        and target_author_key):
1802
                    if target_author := target_author_key.get():
1✔
1803
                        if target_author.is_enabled(from_cls):
1✔
1804
                            notifications.add_notification(target_author, write_obj)
1✔
1805
                            verb, noun = (
1✔
1806
                                ('replied to', 'replies') if target_id in in_reply_tos
1807
                                else ('quoted', 'quotes') if target_id in quoted_posts
1808
                                else ('mentioned', 'mentions'))
1809
                            dms.maybe_send(from_=target_proto, to_user=from_user,
1✔
1810
                                           type='replied_to_bridged_user', text=f"""\
1811
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.""")
1812

1813
                continue
1✔
1814

1815
            # deliver self-replies to followers
1816
            # https://github.com/snarfed/bridgy-fed/issues/639
1817
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1818
                is_self_reply = True
1✔
1819
                logger.info(f'self reply!')
1✔
1820

1821
            # also add copies' targets
1822
            for copy in orig_obj.copies:
1✔
1823
                proto = PROTOCOLS[copy.protocol]
1✔
1824
                if proto in to_protocols:
1✔
1825
                    # copies generally won't have their own Objects
1826
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1827
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {target_id}')
1✔
1828
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1829

1830
            if target_proto == from_cls:
1✔
1831
                logger.debug(f'Skipping same-protocol target {target_id}')
1✔
1832
                continue
1✔
1833

1834
            target = target_proto.target_for(orig_obj)
1✔
1835
            if not target:
1✔
1836
                # TODO: surface errors like this somehow?
UNCOV
1837
                logger.error(f"Can't find delivery target for {target_id}")
×
UNCOV
1838
                continue
×
1839

1840
            logger.debug(f'Target for {target_id} is {target}')
1✔
1841
            # only use orig_obj for inReplyTos, like/repost objects, reply's original
1842
            # post's mentions, etc
1843
            # https://github.com/snarfed/bridgy-fed/issues/1237
1844
            target_obj = None
1✔
1845
            if target_id in in_reply_tos + as1.get_ids(obj.as1, 'object'):
1✔
1846
                target_obj = orig_obj
1✔
1847
            elif target_id in orig_post_mentions:
1✔
1848
                target_obj = orig_post_mentions[target_id]
1✔
1849
            targets[Target(protocol=target_proto.LABEL, uri=target)] = target_obj
1✔
1850

1851
            if target_author_key:
1✔
1852
                logger.debug(f'Recipient is {target_author_key}')
1✔
1853
                if write_obj.add('notify', target_author_key):
1✔
1854
                    write_obj.dirty = True
1✔
1855

1856
        if obj.type == 'undo':
1✔
1857
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1858
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1859
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1860
            else:
1861
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1862
            if inner_obj:
1✔
1863
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1864
                                                internal=True))
1865

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

1868
        # deliver to followers, if appropriate
1869
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1870
        if not user_key:
1✔
1871
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1872
            return targets
1✔
1873

1874
        followers = []
1✔
1875
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1876
                and (not is_reply or is_self_reply)):
1877
            logger.info(f'Delivering to followers of {user_key}')
1✔
1878
            followers = []
1✔
1879
            for f in Follower.query(Follower.to == user_key,
1✔
1880
                                    Follower.status == 'active'):
1881
                proto = PROTOCOLS_BY_KIND[f.from_.kind()]
1✔
1882
                # skip protocol bot users
1883
                if (not Protocol.for_bridgy_subdomain(f.from_.id())
1✔
1884
                        # skip protocols this user hasn't enabled, or where the base
1885
                        # object of this activity hasn't been bridged
1886
                        and proto in to_protocols
1887
                        # we deliver to HAS_COPIES protocols separately, below. we
1888
                        # assume they have follower-independent targets.
1889
                        and not (proto.HAS_COPIES and proto.DEFAULT_TARGET)):
1890
                    followers.append(f)
1✔
1891

1892
            user_keys = [f.from_ for f in followers]
1✔
1893
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1894
            User.load_multi(users)
1✔
1895

1896
            if (not followers and
1✔
1897
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
1898
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
1899
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1900
                return {}
1✔
1901

1902
            # add to followers' feeds, if any
1903
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1904
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1905
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
1✔
1906
                    if write_obj.feed:
1✔
1907
                        write_obj.dirty = True
1✔
1908

1909
            # collect targets for followers
1910
            for user in users:
1✔
1911
                if user.is_blocking(from_user.key.id()):
1✔
1912
                    logger.debug(f'  {user.key.id()} blocks {from_user.key.id()}')
1✔
1913
                    continue
1✔
1914

1915
                # TODO: should we pass remote=False through here to Protocol.load?
1916
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1917
                if not target:
1✔
1918
                    # logger.error(f'Follower {user.key} has no delivery target')
1919
                    continue
1✔
1920

1921
                # normalize URL (lower case hostname, etc)
1922
                # ...but preserve our PDS URL without trailing slash in path
1923
                # https://atproto.com/specs/did#did-documents
1924
                target = util.dedupe_urls([target], trailing_slash=False)[0]
1✔
1925

1926
                targets[Target(protocol=user.LABEL, uri=target)] = \
1✔
1927
                    Object.get_by_id(inner_obj_id) if obj.type == 'share' else None
1928

1929
        # deliver to enabled HAS_COPIES protocols proactively
1930
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
1931
            for proto in to_protocols:
1✔
1932
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
1933
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
1934
                    targets.setdefault(
1✔
1935
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1936

1937
        # de-dupe targets, discard same-domain
1938
        # maps string target URL to (Target, Object) tuple
1939
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1940
        # maps Target to Object or None
1941
        targets = {}
1✔
1942
        source_domains = [
1✔
1943
            util.domain_from_link(url) for url in
1944
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1945
            if util.is_web(url)
1946
        ]
1947
        for url in sorted(util.dedupe_urls(
1✔
1948
                candidates.keys(),
1949
                # preserve our PDS URL without trailing slash in path
1950
                # https://atproto.com/specs/did#did-documents
1951
                trailing_slash=False)):
1952
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
UNCOV
1953
                logger.info(f'Skipping same-domain target {url}')
×
UNCOV
1954
                continue
×
1955
            elif from_user.is_blocking(url):
1✔
1956
                logger.debug(f'{from_user.key.id()} blocks {url}')
1✔
1957
                continue
1✔
1958

1959
            target, obj = candidates[url]
1✔
1960
            targets[target] = obj
1✔
1961

1962
        return targets
1✔
1963

1964
    @classmethod
1✔
1965
    def load(cls, id, remote=None, local=True, raise_=True, raw=False, csv=False,
1✔
1966
             **kwargs):
1967
        """Loads and returns an Object from datastore or HTTP fetch.
1968

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

1972
        Args:
1973
          id (str)
1974
          remote (bool): whether to fetch the object over the network. If True,
1975
            fetches even if we already have the object stored, and updates our
1976
            stored copy. If False and we don't have the object stored, returns
1977
            None. Default (None) means to fetch over the network only if we
1978
            don't already have it stored.
1979
          local (bool): whether to load from the datastore before
1980
            fetching over the network. If False, still stores back to the
1981
            datastore after a successful remote fetch.
1982
          raise_ (bool): if False, catches any :class:`request.RequestException`
1983
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1984
            ``None`` instead
1985
          raw (bool): whether to load this as a "raw" id, as is, without
1986
            normalizing to an on-protocol object id. Exact meaning varies by subclass.
1987
          csv (bool): whether to specifically load a CSV object
1988
            TODO: merge this into raw, using returned Content-Type?
1989
          kwargs: passed through to :meth:`fetch()`
1990

1991
        Returns:
1992
          models.Object: loaded object, or None if it isn't fetchable, eg a
1993
          non-URL string for Web, or ``remote`` is False and it isn't in the
1994
          datastore
1995

1996
        Raises:
1997
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1998
            is True
1999
        """
2000
        assert id
1✔
2001
        assert local or remote is not False
1✔
2002
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
2003

2004
        if not raw:
1✔
2005
            id = ids.normalize_object_id(id=id, proto=cls)
1✔
2006

2007
        obj = orig_as1 = None
1✔
2008
        if local:
1✔
2009
            if obj := Object.get_by_id(id):
1✔
2010
                if csv and not obj.is_csv:
1✔
2011
                    return None
1✔
2012
                elif obj.as1 or obj.csv or obj.raw or obj.deleted:
1✔
2013
                    # logger.debug(f'  {id} got from datastore')
2014
                    obj.new = False
1✔
2015

2016
        if remote is False:
1✔
2017
            return obj
1✔
2018
        elif remote is None and obj:
1✔
2019
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
2020
                # logger.debug(f'  last updated {obj.updated}, refreshing')
2021
                pass
1✔
2022
            else:
2023
                return obj
1✔
2024

2025
        if obj:
1✔
2026
            orig_as1 = obj.as1
1✔
2027
            obj.our_as1 = None
1✔
2028
            obj.new = False
1✔
2029
        else:
2030
            obj = Object(id=id)
1✔
2031
            if local:
1✔
2032
                # logger.debug(f'  {id} not in datastore')
2033
                obj.new = True
1✔
2034
                obj.changed = False
1✔
2035

2036
        try:
1✔
2037
            fetched = cls.fetch(obj, csv=csv, **kwargs)
1✔
2038
        except (RequestException, HTTPException) as e:
1✔
2039
            if raise_:
1✔
2040
                raise
1✔
2041
            util.interpret_http_exception(e)
1✔
2042
            return None
1✔
2043

2044
        if not fetched:
1✔
2045
            return None
1✔
2046
        elif csv and not obj.is_csv:
1✔
UNCOV
2047
            return None
×
2048

2049
        # https://stackoverflow.com/a/3042250/186123
2050
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
2051
        if size > MAX_ENTITY_SIZE:
1✔
2052
            logger.warning(f'Object is too big! {size} bytes is over {MAX_ENTITY_SIZE}')
1✔
2053
            return None
1✔
2054

2055
        obj.resolve_ids()
1✔
2056
        obj.normalize_ids()
1✔
2057

2058
        if obj.new is False:
1✔
2059
            obj.changed = obj.activity_changed(orig_as1)
1✔
2060

2061
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
2062
            if obj.source_protocol:
1✔
UNCOV
2063
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
2064
            obj.source_protocol = cls.LABEL
1✔
2065

2066
        obj.put()
1✔
2067
        return obj
1✔
2068

2069
    @classmethod
1✔
2070
    def check_supported(cls, obj, direction):
1✔
2071
        """If this protocol doesn't support this activity, raises HTTP 204.
2072

2073
        Also reports an error.
2074

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

2079
        Args:
2080
          obj (Object)
2081
          direction (str): ``'receive'`` or  ``'send'``
2082

2083
        Raises:
2084
          werkzeug.HTTPException: if this protocol doesn't support this object
2085
        """
2086
        assert direction in ('receive', 'send')
1✔
2087
        if not obj.type:
1✔
UNCOV
2088
            return
×
2089

2090
        inner = as1.get_object(obj.as1)
1✔
2091
        inner_type = as1.object_type(inner) or ''
1✔
2092
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
2093
            or (obj.type in as1.CRUD_VERBS
2094
                and inner_type
2095
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
2096
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
2097

2098
        # don't allow posts with blank content and no image/video/audio
2099
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
2100
                    else obj.as1)
2101
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
2102
                and not util.get_url(crud_obj, key='image')
2103
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
2104
                # TODO: handle articles with displayName but not content
2105
                and not source.html_to_text(crud_obj.get('content')).strip()):
2106
            error('Blank content and no image or video or audio', status=204)
1✔
2107

2108
        # receiving DMs is only allowed to protocol bot accounts
2109
        if direction == 'receive':
1✔
2110
            if recip := as1.recipient_if_dm(obj.as1):
1✔
2111
                owner = as1.get_owner(obj.as1)
1✔
2112
                if (not cls.SUPPORTS_DMS or (recip not in common.bot_user_ids()
1✔
2113
                                             and owner not in common.bot_user_ids())):
2114
                    # reply and say DMs aren't supported
2115
                    from_proto = PROTOCOLS.get(obj.source_protocol)
1✔
2116
                    to_proto = Protocol.for_id(recip)
1✔
2117
                    if owner and from_proto and to_proto:
1✔
2118
                        if ((from_user := from_proto.get_or_create(id=owner))
1✔
2119
                                and (to_user := to_proto.get_or_create(id=recip))):
2120
                            in_reply_to = (inner.get('id') if obj.type == 'post'
1✔
2121
                                           else obj.as1.get('id'))
2122
                            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✔
2123
                            type = f'dms_not_supported-{to_user.key.id()}'
1✔
2124
                            dms.maybe_send(from_=to_user, to_user=from_user,
1✔
2125
                                           text=text, type=type,
2126
                                           in_reply_to=in_reply_to)
2127

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

2130
            # check that this activity is public. only do this for some activities,
2131
            # not eg likes or follows, since Mastodon doesn't currently mark those
2132
            # as explicitly public.
2133
            elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
2134
                  and not util.domain_or_parent_in(crud_obj.get('id'), NON_PUBLIC_DOMAINS)
2135
                  and not as1.is_public(obj.as1, unlisted=False)):
2136
                error('Bridgy Fed only supports public activities', status=204)
1✔
2137

2138
    @classmethod
1✔
2139
    def block(cls, from_user, arg):
1✔
2140
        """Blocks a user or list.
2141

2142
        Args:
2143
          from_user (models.User): user doing the blocking
2144
          arg (str): handle or id of user/list to block
2145

2146
        Returns:
2147
          models.User or models.Object: user or list that was blocked
2148

2149
        Raises:
2150
          ValueError: if arg doesn't look like a user or list on this protocol
2151
        """
2152
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2153

2154
        def fail(msg):
1✔
2155
            logger.warning(msg)
1✔
2156
            raise ValueError(msg)
1✔
2157

2158
        blockee = None
1✔
2159
        try:
1✔
2160
            # first, try interpreting as a user handle or id
2161
            blockee = load_user(arg, proto=cls, create=True, allow_opt_out=True)
1✔
2162
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2163
            logger.info(err)
1✔
2164

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

2168
        # may not be a user, see if it's a list
2169
        if not blockee:
1✔
2170
            if not cls or cls == Protocol:
1✔
2171
                cls = Protocol.for_id(arg)
1✔
2172

2173
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2174
                if blockee.source_protocol == from_user.LABEL:
1✔
2175
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2176
            else:
2177
                if blocklist := from_user.add_domain_blocklist(arg):
1✔
2178
                    return blocklist
1✔
2179
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2180

2181
        logger.info(f'  blocking {blockee.key.id()}')
1✔
2182
        id = f'{from_user.key.id()}#bridgy-fed-block-{util.now().isoformat()}'
1✔
2183
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2184
            'objectType': 'activity',
2185
            'verb': 'block',
2186
            'id': id,
2187
            'actor': from_user.key.id(),
2188
            'object': blockee.key.id(),
2189
        })
2190
        obj.put()
1✔
2191
        from_user.deliver(obj, from_user=from_user)
1✔
2192

2193
        return blockee
1✔
2194

2195
    @classmethod
1✔
2196
    def unblock(cls, from_user, arg):
1✔
2197
        """Unblocks a user or list.
2198

2199
        Args:
2200
          from_user (models.User): user doing the unblocking
2201
          arg (str): handle or id of user/list to unblock
2202

2203
        Returns:
2204
          models.User or models.Object: user or list that was unblocked
2205

2206
        Raises:
2207
          ValueError: if arg doesn't look like a user or list on this protocol
2208
        """
2209
        logger.info(f'user {from_user.key.id()} trying to unblock {arg}')
1✔
2210
        def fail(msg):
1✔
2211
            logger.warning(msg)
1✔
2212
            raise ValueError(msg)
1✔
2213

2214
        blockee = None
1✔
2215
        try:
1✔
2216
            # first, try interpreting as a user handle or id
2217
            blockee = load_user(arg, cls, create=True, allow_opt_out=True)
1✔
2218
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2219
            logger.info(err)
1✔
2220

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

2224
        # may not be a user, see if it's a list
2225
        if not blockee:
1✔
2226
            if not cls or cls == Protocol:
1✔
2227
                cls = Protocol.for_id(arg)
1✔
2228

2229
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2230
                if blockee.source_protocol == from_user.LABEL:
1✔
2231
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2232
            else:
2233
                if blocklist := from_user.remove_domain_blocklist(arg):
1✔
2234
                    return blocklist
1✔
2235
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2236

2237
        logger.info(f'  unblocking {blockee.key.id()}')
1✔
2238
        id = f'{from_user.key.id()}#bridgy-fed-unblock-{util.now().isoformat()}'
1✔
2239
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2240
            'objectType': 'activity',
2241
            'verb': 'undo',
2242
            'id': id,
2243
            'actor': from_user.key.id(),
2244
            'object': {
2245
                'objectType': 'activity',
2246
                'verb': 'block',
2247
                'actor': from_user.key.id(),
2248
                'object': blockee.key.id(),
2249
            },
2250
        })
2251
        obj.put()
1✔
2252
        from_user.deliver(obj, from_user=from_user)
1✔
2253

2254
        return blockee
1✔
2255

2256

2257
@cloud_tasks_only(log=None)
1✔
2258
def receive_task():
1✔
2259
    """Task handler for a newly received :class:`models.Object`.
2260

2261
    Calls :meth:`Protocol.receive` with the form parameters.
2262

2263
    Parameters:
2264
      authed_as (str): passed to :meth:`Protocol.receive`
2265
      obj_id (str): key id of :class:`models.Object` to handle
2266
      received_at (str, ISO 8601 timestamp): when we first saw (received)
2267
        this activity
2268
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2269
        :class:`models.Object` to handle
2270

2271
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
2272
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
2273
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
2274
    ``changed``, HTTP request details, etc. See stash for attempt at this for
2275
    :class:`web.Web`.
2276
    """
2277
    common.log_request()
1✔
2278
    form = request.form.to_dict()
1✔
2279

2280
    authed_as = form.pop('authed_as', None)
1✔
2281
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
2282
                or authed_as in common.PROTOCOL_DOMAINS)
2283

2284
    obj = Object.from_request()
1✔
2285
    assert obj
1✔
2286
    assert obj.source_protocol
1✔
2287
    obj.new = True
1✔
2288

2289
    if received_at := form.pop('received_at', None):
1✔
2290
        received_at = datetime.fromisoformat(received_at)
1✔
2291

2292
    try:
1✔
2293
        return PROTOCOLS[obj.source_protocol].receive(
1✔
2294
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
2295
    except RequestException as e:
1✔
2296
        util.interpret_http_exception(e)
1✔
2297
        error(e, status=304)
1✔
2298
    except ValueError as e:
1✔
UNCOV
2299
        logger.warning(e, exc_info=True)
×
UNCOV
2300
        error(e, status=304)
×
2301

2302

2303
@cloud_tasks_only(log=None)
1✔
2304
def send_task():
1✔
2305
    """Task handler for sending an activity to a single specific destination.
2306

2307
    Calls :meth:`Protocol.send` with the form parameters.
2308

2309
    Parameters:
2310
      protocol (str): :class:`Protocol` to send to
2311
      url (str): destination URL to send to
2312
      obj_id (str): key id of :class:`models.Object` to send
2313
      orig_obj_id (str): optional, :class:`models.Object` key id of the
2314
        "original object" that this object refers to, eg replies to or reposts
2315
        or likes
2316
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
2317
        this activity is from
2318
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2319
        :class:`models.Object` to handle
2320
    """
2321
    common.log_request()
1✔
2322

2323
    # prepare
2324
    form = request.form.to_dict()
1✔
2325
    url = form.get('url')
1✔
2326
    protocol = form.get('protocol')
1✔
2327
    if not url or not protocol:
1✔
2328
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
2329
        return '', 204
1✔
2330

2331
    target = Target(uri=url, protocol=protocol)
1✔
2332
    obj = Object.from_request()
1✔
2333
    assert obj and obj.key and obj.key.id()
1✔
2334

2335
    PROTOCOLS[protocol].check_supported(obj, 'send')
1✔
2336
    allow_opt_out = (obj.type == 'delete')
1✔
2337

2338
    user = None
1✔
2339
    if user_key := form.get('user'):
1✔
2340
        key = ndb.Key(urlsafe=user_key)
1✔
2341
        # use get_by_id so that we follow use_instead
2342
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2343
            key.id(), allow_opt_out=allow_opt_out)
2344

2345
    # send
2346
    delay = ''
1✔
2347
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
2348
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
2349
        delay = f'({delay_s} s behind)'
1✔
2350
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
2351
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
2352
    sent = None
1✔
2353
    try:
1✔
2354
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
2355
                                        orig_obj_id=form.get('orig_obj_id'))
2356
    except (MemcacheServerError, MemcacheUnexpectedCloseError,
1✔
2357
            MemcacheUnknownError) as e:
2358
        # our memorystore instance is probably undergoing maintenance. re-enqueue
2359
        # task with a delay.
2360
        # https://docs.cloud.google.com/memorystore/docs/memcached/about-maintenance
2361
        report_error(f'memcache error on send task, re-enqueuing in {MEMCACHE_DOWN_TASK_DELAY}: {e}')
1✔
2362
        common.create_task(queue='send', delay=MEMCACHE_DOWN_TASK_DELAY, **form)
1✔
2363
        sent = False
1✔
2364
    except BaseException as e:
1✔
2365
        code, body = util.interpret_http_exception(e)
1✔
2366
        if not code and not body:
1✔
2367
            raise
1✔
2368

2369
    if sent is False:
1✔
2370
        logger.info(f'Failed sending!')
1✔
2371

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

© 2026 Coveralls, Inc