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

snarfed / bridgy-fed / 0764ef91-8c50-4797-b47c-b9274c65b3c9

16 Mar 2026 11:35PM UTC coverage: 94.038% (+0.03%) from 94.011%
0764ef91-8c50-4797-b47c-b9274c65b3c9

push

circleci

snarfed
filters: add media_blocklisted, by hash (CID) of contents

for #1941

23 of 24 new or added lines in 2 files covered. (95.83%)

40 existing lines in 1 file now uncovered.

6987 of 7430 relevant lines covered (94.04%)

0.94 hits per line

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

95.96
/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
from websockets.exceptions import InvalidStatus
1✔
30
import werkzeug.exceptions
1✔
31
from werkzeug.exceptions import BadGateway, BadRequest, HTTPException
1✔
32

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

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

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

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

87
DONT_STORE_AS1_TYPES = as1.CRUD_VERBS | set((
1✔
88
    'accept',
89
    'reject',
90
    'stop-following',
91
    'undo',
92
))
93
STORE_AS1_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES | as1.VERBS_WITH_OBJECT
1✔
94
                   - DONT_STORE_AS1_TYPES)
95

96
logger = logging.getLogger(__name__)
1✔
97

98

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

103

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

107

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

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

159
    @staticmethod
1✔
160
    def for_request(fed=None):
1✔
161
        """Returns the protocol for the current request.
162

163
        ...based on the request's hostname.
164

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

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

175
    @staticmethod
1✔
176
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
177
        """Returns the protocol for a brid.gy subdomain.
178

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

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

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

199
    @classmethod
1✔
200
    def owns_id(cls, id):
1✔
201
        """Returns whether this protocol owns the id, or None if it's unclear.
202

203
        To be implemented by subclasses.
204

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

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

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

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

220
        Args:
221
          id (str): user id or object id
222

223
        Returns:
224
          bool or None:
225
        """
226
        return False
1✔
227

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

232
        To be implemented by subclasses.
233

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

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

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

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

252
        Returns:
253
          bool or None
254
        """
255
        return False
1✔
256

257
    @classmethod
1✔
258
    def handle_to_id(cls, handle):
1✔
259
        """Converts a handle to an id.
260

261
        To be implemented by subclasses.
262

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

266
        Args:
267
          handle (str)
268

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

274
    @classmethod
1✔
275
    def authed_user_for_request(cls):
1✔
276
        """Returns the authenticated user id for the current request.
277

278

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

282
        Returns:
283
          str: authenticated user id, or None if there is no authentication
284

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

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

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

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

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

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

318
        return cls(id=id).key
1✔
319

320
    @staticmethod
1✔
321
    def _for_id_memcache_key(id, remote=None):
1✔
322
        """If id is a URL, uses its domain, otherwise returns None.
323

324
        Args:
325
          id (str)
326

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

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

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

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

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

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

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

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

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

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

409
        # step 4: fetch over the network, if necessary
410
        if not remote:
1✔
411
            return None
1✔
412

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

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

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

444
        logger.info(f'No matching protocol found for {id} !')
1✔
445
        return None
1✔
446

447
    @cached(LRUCache(20000), lock=Lock())
1✔
448
    @staticmethod
1✔
449
    def for_handle(handle):
1✔
450
        """Returns the protocol for a given handle.
451

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

455
        Args:
456
          handle (str)
457

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

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

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

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

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

500
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
501
        return (None, None)
1✔
502

503
    @classmethod
1✔
504
    def is_user_at_domain(cls, handle, allow_internal=False):
1✔
505
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
506

507
        Example: ``@user@instance.com``
508

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

517
        user, domain = parts
1✔
518
        return bool(user and domain
1✔
519
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
520

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

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

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

533
        Returns:
534
          str, or None if there isn't a canonical URL
535
        """
536
        if fallback:
1✔
537
            return user.web_url()
1✔
538

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

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

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

554
    @classmethod
1✔
555
    def bot_user_id(cls):
1✔
556
        """Returns the Web user id for the bot user for this protocol.
557

558
        For example, ``'bsky.brid.gy'`` for ATProto.
559

560
        Returns:
561
          str:
562
        """
563
        return f'{cls.ABBREV}{SUPERDOMAIN}'
1✔
564

565
    @classmethod
1✔
566
    def create_for(cls, user):
1✔
567
        """Creates or re-activate a copy user in this protocol.
568

569
        Should add the copy user to :attr:`copies`.
570

571
        If the copy user already exists and active, should do nothing.
572

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

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

582
    @classmethod
1✔
583
    def send(to_cls, obj, target, from_user=None, orig_obj_id=None):
1✔
584
        """Sends an outgoing activity.
585

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

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

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

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

607
        Raises:
608
          werkzeug.HTTPException if the request fails
609
        """
UNCOV
610
        raise NotImplementedError()
×
611

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

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

620
        To be implemented by subclasses.
621

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

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

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

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

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

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

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

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

658
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
659
        is_crud = obj.as1.get('verb') in as1.CRUD_VERBS
1✔
660
        base_obj = as1.get_object(obj.as1) if is_crud else obj.as1
1✔
661
        orig_our_as1 = obj.our_as1
1✔
662

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

674
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
675
        obj.our_as1 = orig_our_as1
1✔
676
        return converted
1✔
677

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

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

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

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

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

701
        Uses HTML for protocols that support it, plain text otherwise.
702

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

711
        obj.our_as1 = copy.deepcopy(obj.as1)
1✔
712
        actor = (as1.get_object(obj.as1) if obj.type in as1.CRUD_VERBS
1✔
713
                 else obj.as1)
714
        actor['objectType'] = 'person'
1✔
715

716
        orig_summary = actor.setdefault('summary', '')
1✔
717
        summary_text = html_to_text(orig_summary, ignore_links=True)
1✔
718

719
        # Check if we've already added source links
720
        if '🌉 bridged' in summary_text:
1✔
721
            return
1✔
722

723
        actor_id = actor.get('id')
1✔
724

725
        url = (as1.get_url(actor)
1✔
726
               or (from_user.web_url() if from_user.profile_id() == actor_id
727
                   else actor_id))
728

729
        from web import Web
1✔
730
        bot_user = Web.get_by_id(from_user.bot_user_id())
1✔
731

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

739
            separator = '<br><br>'
1✔
740

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

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

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

768
        logo = f'{from_user.LOGO_EMOJI} ' if from_user.LOGO_EMOJI else ''
1✔
769
        source_links = f'{separator if orig_summary else ""}{bridged} from {logo}{from_}{suffix}'
1✔
770
        actor['summary'] = orig_summary + source_links
1✔
771

772
    @classmethod
1✔
773
    def set_username(to_cls, user, username):
1✔
774
        """Sets a custom username for a user's bridged account in this protocol.
775

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

780
        Raises:
781
          ValueError: if the username is invalid
782
          RuntimeError: if the username could not be set
783
        """
784
        raise NotImplementedError()
1✔
785

786
    @classmethod
1✔
787
    def migrate_out(cls, user, to_user_id):
1✔
788
        """Migrates a bridged account out to be a native account.
789

790
        Args:
791
          user (models.User)
792
          to_user_id (str)
793

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

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

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

807
        If the user is ready to migrate, returns ``None``.
808

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

812
        Args:
813
          user (models.User)
814
          to_user_id (str)
815

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

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

830
    @classmethod
1✔
831
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
832
        """Migrates a native account in to be a bridged account.
833

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

837
        Reloads the user's profile before calling :meth:`_migrate_in`.
838

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

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

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

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

863
        # reload profile
864
        try:
1✔
865
            user.reload_profile()
1✔
UNCOV
866
        except (RequestException, HTTPException) as e:
×
UNCOV
867
            _, msg = util.interpret_http_exception(e)
×
868

869
        # migrate!
870
        cls._migrate_in(user, from_user_id, **kwargs)
1✔
871
        user.add('enabled_protocols', cls.LABEL)
1✔
872
        user.put()
1✔
873

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

882
            common.create_task(queue='receive', obj_id=user.obj_key.id(),
1✔
883
                               authed_as=user.key.id())
884

885
    @classmethod
1✔
886
    def _migrate_in(cls, user, from_user_id, **kwargs):
1✔
887
        """Protocol-specific parts of migrating in external account.
888

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

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

900
    @classmethod
1✔
901
    def target_for(cls, obj, shared=False):
1✔
902
        """Returns an :class:`Object`'s delivery target (endpoint).
903

904
        To be implemented by subclasses.
905

906
        Examples:
907

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

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

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

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

929
        Default implementation here, subclasses may override.
930

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

943
    @classmethod
1✔
944
    def translate_ids(to_cls, obj):
1✔
945
        """Translates all ids in an AS1 object to a specific protocol.
946

947
        Infers source protocol for each id value separately.
948

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

953
        Wraps these AS1 fields:
954

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

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

975
        TODO: unify with :meth:`Object.resolve_ids`,
976
        :meth:`models.Object.normalize_ids`.
977

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

982
        Returns:
983
          dict: translated AS1 version of ``obj``
984
        """
985
        from ui import UIProtocol
1✔
986

987
        assert to_cls != Protocol
1✔
988
        if not obj:
1✔
989
            return obj
1✔
990

991
        outer_obj = to_cls.translate_mention_handles(copy.deepcopy(obj))
1✔
992
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
993

994
        def translate(elem, field, fn, uri=False):
1✔
995
            owner_id = as1.get_owner(elem)
1✔
996
            owner_proto = Protocol.for_id(owner_id)
1✔
997

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

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

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

1016
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
1017
                           for o in elem[field]]
1018

1019
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
1020
                elem[field] = elem[field][0]
1✔
1021

1022
        type = as1.object_type(outer_obj)
1✔
1023
        translate(outer_obj, 'id',
1✔
1024
                  ids.translate_user_id if type in as1.ACTOR_TYPES
1025
                  else ids.translate_object_id)
1026

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

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

1057
        outer_obj = util.trim_nulls(outer_obj)
1✔
1058

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

1064
        return outer_obj
1✔
1065

1066
    @classmethod
1✔
1067
    def translate_mention_handles(cls, obj):
1✔
1068
        """Translates @-mentions in ``obj.content`` to this protocol's handles.
1069

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

1074
        Called by :meth:`Protocol.translate_ids`.
1075

1076
        If ``obj.content`` is HTML, does nothing.
1077

1078
        Args:
1079
          obj (dict): AS1 object
1080

1081
        Returns:
1082
          dict: modified AS1 object
1083
        """
1084
        if not obj:
1✔
UNCOV
1085
            return None
×
1086

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

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

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

1103
        offset = 0
1✔
1104
        last_orig_end = 0
1✔
1105
        for tag in sorted(indexed, key=lambda t: t['startIndex']):
1✔
1106
            orig_start = tag['startIndex']
1✔
1107
            if orig_start < last_orig_end:
1✔
1108
                logger.warning(f'tags overlap! removing indices from {tag.get("url")}')
1✔
1109
                del tag['startIndex']
1✔
1110
                del tag['length']
1✔
1111
                continue
1✔
1112

1113
            orig_end = orig_start + tag['length']
1✔
1114
            last_orig_end = orig_end
1✔
1115
            tag['startIndex'] += offset
1✔
1116
            if tag.get('objectType') == 'mention' and (id := tag['url']):
1✔
1117
                if proto := Protocol.for_id(id):
1✔
1118
                    id = ids.normalize_user_id(id=id, proto=proto)
1✔
1119
                    if key := get_original_user_key(id):
1✔
UNCOV
1120
                        user = key.get()
×
1121
                    else:
1122
                        user = proto.get_or_create(id, allow_opt_out=True)
1✔
1123
                    if user:
1✔
1124
                        start = tag['startIndex']
1✔
1125
                        end = start + tag['length']
1✔
1126
                        if handle := user.handle_as(cls):
1✔
1127
                            content = content[:start] + handle + content[end:]
1✔
1128
                            offset += len(handle) - tag['length']
1✔
1129
                            tag.update({
1✔
1130
                                'displayName': handle,
1131
                                'length': len(handle),
1132
                            })
1133

1134
        obj['tags'] = tags
1✔
1135
        as2.set_content(obj, content)  # sets content *and* contentMap; obj is still AS1 here
1✔
1136
        return util.trim_nulls(obj)
1✔
1137

1138
    @classmethod
1✔
1139
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
1140
        """Handles an incoming activity.
1141

1142
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
1143
        unset, returns HTTP 299.
1144

1145
        Args:
1146
          obj (models.Object)
1147
          authed_as (str): authenticated actor id who sent this activity
1148
          internal (bool): whether to allow activity ids on internal domains,
1149
            from opted out/blocked users, etc.
1150
          received_at (datetime): when we first saw (received) this activity.
1151
            Right now only used for monitoring.
1152

1153
        Returns:
1154
          (str, int) tuple: (response body, HTTP status code) Flask response
1155

1156
        Raises:
1157
          werkzeug.HTTPException: if the request is invalid
1158
        """
1159
        # check some invariants
1160
        assert from_cls != Protocol
1✔
1161
        assert isinstance(obj, Object), obj
1✔
1162

1163
        if not obj.as1:
1✔
1164
            error('No object data provided')
1✔
1165

1166
        orig_obj = obj
1✔
1167
        id = None
1✔
1168
        if obj.key and obj.key.id():
1✔
1169
            id = obj.key.id()
1✔
1170

1171
        if not id:
1✔
1172
            id = obj.as1.get('id')
1✔
1173
            obj.key = ndb.Key(Object, id)
1✔
1174

1175
        if not id:
1✔
UNCOV
1176
            error('No id provided')
×
1177
        elif from_cls.owns_id(id) is False:
1✔
1178
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
1179
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
1180
            error(f'Activity {id} is blocklisted')
1✔
1181

1182
        # does this protocol support this activity/object type?
1183
        from_cls.check_supported(obj, 'receive')
1✔
1184

1185
        # apply protocol-specific filters
1186
        for filter in from_cls.RECEIVE_FILTERS:
1✔
1187
            if filter(obj):
1✔
1188
                error(f'Activity {id} blocked by filter {filter.__name__}')
1✔
1189

1190
        # lease this object, atomically
1191
        memcache_key = activity_id_memcache_key(id)
1✔
1192
        leased = memcache.memcache.add(
1✔
1193
            memcache_key, 'leased', noreply=False,
1194
            expire=int(MEMCACHE_LEASE_EXPIRATION.total_seconds()))
1195

1196
        # short circuit if we've already seen this activity id
1197
        if ('force' not in request.values
1✔
1198
            and (not leased
1199
                 or (obj.new is False and obj.changed is False))):
1200
            error(f'Already seen this activity {id}', status=204)
1✔
1201

1202
        pruned = {k: v for k, v in obj.as1.items()
1✔
1203
                  if k not in ('contentMap', 'replies', 'signature')}
1204
        delay = ''
1✔
1205
        retry = request.headers.get('X-AppEngine-TaskRetryCount')
1✔
1206
        if (received_at and retry in (None, '0')
1✔
1207
                and obj.type not in ('delete', 'undo')):  # we delay deletes/undos
1208
            delay_s = int((util.now().replace(tzinfo=None)
1✔
1209
                           - received_at.replace(tzinfo=None)
1210
                           ).total_seconds())
1211
            delay = f'({delay_s} s behind)'
1✔
1212
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
1213

1214
        # check authorization
1215
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1216
        actor = as1.get_owner(obj.as1)
1✔
1217
        if not actor:
1✔
1218
            error('Activity missing actor or author')
1✔
1219

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

1225
        assert authed_as
1✔
1226
        assert isinstance(authed_as, str)
1✔
1227
        authed_as = ids.normalize_user_id(id=authed_as, proto=from_user_cls)
1✔
1228
        actor = ids.normalize_user_id(id=actor, proto=from_user_cls)
1✔
1229
        # TODO: remove internal here once we've fixed #2237
1230
        if actor != authed_as and not internal:
1✔
1231
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
1232
                         user=f'{id} authed_as {authed_as} owner {actor}')
1233
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
1234

1235
        # update copy ids to originals
1236
        obj.normalize_ids()
1✔
1237
        obj.resolve_ids()
1✔
1238

1239
        if (obj.type == 'follow'
1✔
1240
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
1241
            # follows of bot user; refresh user profile first
1242
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
1243
            from_user = from_user_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1244
            from_user.reload_profile()
1✔
1245
        else:
1246
            # load actor user
1247
            from_user = from_user_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1248

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

1252
        # check if this is a profile object coming in via a user with use_instead
1253
        # set. if so, override the object's id to be the final user id (from_user's),
1254
        # after following use_instead.
1255
        if obj.type in as1.ACTOR_TYPES and from_user.key.id() != actor:
1✔
1256
            as1_id = obj.as1.get('id')
1✔
1257
            if ids.normalize_user_id(id=as1_id, proto=from_user) == actor:
1✔
1258
                logger.info(f'Overriding AS1 object id {as1_id} with Object id {from_user.profile_id()}')
1✔
1259
                obj.our_as1 = {**obj.as1, 'id': from_user.profile_id()}
1✔
1260

1261
        # if this is an object, ie not an activity, wrap it in a create or update
1262
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as,
1✔
1263
                                          from_user=from_user)
1264
        obj.add('users', from_user.key)
1✔
1265

1266
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1267
        inner_obj_id = inner_obj_as1.get('id')
1✔
1268
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1269
            if not inner_obj_id:
1✔
1270
                error(f'{obj.type} object has no id!')
1✔
1271

1272
        # check age. we support backdated posts, but if they're over 2w old, we
1273
        # don't deliver them
1274
        if obj.type == 'post':
1✔
1275
            if published := inner_obj_as1.get('published'):
1✔
1276
                try:
1✔
1277
                    published_dt = util.parse_iso8601(published)
1✔
1278
                    if not published_dt.tzinfo:
1✔
UNCOV
1279
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
1280
                    age = util.now() - published_dt
1✔
1281
                    if (age > CREATE_MAX_AGE
1✔
1282
                            and 'force' not in request.values
1283
                            and not util.domain_or_parent_in(
1284
                                from_user.key.id(), CREATE_MAX_AGE_EXEMPT_DOMAINS)):
UNCOV
1285
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
1286
                              status=204)
UNCOV
1287
                except ValueError:  # from parse_iso8601
×
UNCOV
1288
                    logger.debug(f"Couldn't parse published {published}")
×
1289

1290
        # write Object to datastore
1291
        if obj.type in STORE_AS1_TYPES:
1✔
1292
            obj.put()
1✔
1293

1294
        # store inner object
1295
        # TODO: unify with big obj.type conditional below. would have to merge
1296
        # this with the DM handling block lower down.
1297
        crud_obj = None
1✔
1298
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1299
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
1300
                                            source_protocol=obj.source_protocol,
1301
                                            authed_as=actor, users=[from_user.key],
1302
                                            deleted=False)
1303

1304
        actor = as1.get_object(obj.as1, 'actor')
1✔
1305
        actor_id = actor.get('id')
1✔
1306

1307
        # handle activity!
1308
        if obj.type == 'stop-following':
1✔
1309
            # TODO: unify with handle_follow?
1310
            # TODO: handle multiple followees
1311
            if not actor_id or not inner_obj_id:
1✔
UNCOV
1312
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1313

1314
            # deactivate Follower
1315
            from_ = from_user_cls.key_for(actor_id)
1✔
1316
            if not (to_cls := Protocol.for_id(inner_obj_id)):
1✔
1317
                error(f"Can't determine protocol for {inner_obj_id} , giving up")
1✔
1318
            to = to_cls.key_for(inner_obj_id)
1✔
1319
            follower = Follower.query(Follower.to == to,
1✔
1320
                                      Follower.from_ == from_,
1321
                                      Follower.status == 'active').get()
1322
            if follower:
1✔
1323
                logger.info(f'Marking {follower} inactive')
1✔
1324
                follower.status = 'inactive'
1✔
1325
                follower.put()
1✔
1326
            else:
1327
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1328

1329
            # fall through to deliver to followee
1330
            # TODO: do we convert stop-following to webmention 410 of original
1331
            # follow?
1332

1333
            # fall through to deliver to followers
1334

1335
        elif obj.type in ('delete', 'undo'):
1✔
1336
            delete_obj_id = (from_user.profile_id()
1✔
1337
                            if inner_obj_id == from_user.key.id()
1338
                            else inner_obj_id)
1339

1340
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1341
            if not delete_obj:
1✔
1342
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1343
                return 'OK', 204
1✔
1344

1345
            # TODO: just delete altogether!
1346
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1347
            delete_obj.deleted = True
1✔
1348
            delete_obj.put()
1✔
1349

1350
            # if this is an actor, handle deleting it later so that
1351
            # in case it's from_user, user.enabled_protocols is still populated
1352
            #
1353
            # fall through to deliver to followers and delete copy if necessary.
1354
            # should happen via protocol-specific copy target and send of
1355
            # delete activity.
1356
            # https://github.com/snarfed/bridgy-fed/issues/63
1357

1358
        elif obj.type == 'block':
1✔
1359
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1360
                # blocking protocol bot user disables that protocol
1361
                from_user.delete(proto)
1✔
1362
                from_user.disable_protocol(proto)
1✔
1363
                return 'OK', 200
1✔
1364

1365
        elif obj.type == 'post':
1✔
1366
            # handle DMs to bot users
1367
            if as1.is_dm(obj.as1):
1✔
1368
                return dms.receive(from_user=from_user, obj=obj)
1✔
1369

1370
        # fetch actor if necessary
1371
        is_user = (inner_obj_id in (from_user.key.id(), from_user.profile_id())
1✔
1372
                   or from_user.is_profile(orig_obj))
1373
        if (actor and actor.keys() == set(['id'])
1✔
1374
                and not is_user and obj.type not in ('delete', 'undo')):
1375
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1376
            actor_obj = from_user_cls.load(
1✔
1377
                ids.profile_id(id=actor['id'], proto=from_cls), raise_=False)
1378
            if actor_obj and actor_obj.as1:
1✔
1379
                obj.our_as1 = {
1✔
1380
                    **obj.as1, 'actor': {
1381
                        **actor_obj.as1,
1382
                        # override profile id with actor id
1383
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1384
                        'id': actor['id'],
1385
                    }
1386
                }
1387

1388
        # fetch object if necessary
1389
        if (obj.type in ('post', 'update', 'share')
1✔
1390
                and inner_obj_as1.keys() == set(['id'])
1391
                and from_cls.owns_id(inner_obj_id) is not False):
1392
            logger.debug('Fetching inner object')
1✔
1393
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1394
                                      remote=(obj.type in ('post', 'update')))
1395
            if obj.type in ('post', 'update'):
1✔
1396
                crud_obj = inner_obj
1✔
1397
            if inner_obj and inner_obj.as1:
1✔
1398
                obj.our_as1 = {
1✔
1399
                    **obj.as1,
1400
                    'object': {
1401
                        **inner_obj_as1,
1402
                        **inner_obj.as1,
1403
                    }
1404
                }
1405
            elif obj.type in ('post', 'update'):
1✔
1406
                error(f"Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1407

1408
        if obj.type == 'follow':
1✔
1409
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1410
                # follow of one of our protocol bot users; enable that protocol.
1411
                # fall through so that we send an accept.
1412
                try:
1✔
1413
                    from_user.enable_protocol(proto)
1✔
1414
                except ErrorButDoNotRetryTask:
1✔
1415
                    from web import Web
1✔
1416
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1417
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1418
                                               followee=bot, follow=obj)
1419
                    raise
1✔
1420
                proto.bot_maybe_follow_back(from_user)
1✔
1421
                from_cls.handle_follow(obj, from_user=from_user)
1✔
1422
                return 'OK', 202
1✔
1423

1424
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1425

1426
        # on update of the user's own actor/profile, set user.obj and store user back
1427
        # to datastore so that we recalculate computed properties like status etc
1428
        if is_user:
1✔
1429
            if obj.type == 'update' and crud_obj:
1✔
1430
                logger.info(f"update of the user's profile, re-storing user with obj_key {crud_obj.key.id()}")
1✔
1431
                from_user.obj = crud_obj
1✔
1432
                from_user.put()
1✔
1433

1434
        # deliver to targets
1435
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1436

1437
        # on user deleting themselves, deactivate their followers/followings.
1438
        # https://github.com/snarfed/bridgy-fed/issues/1304
1439
        #
1440
        # do this *after* delivering because delivery finds targets based on
1441
        # stored Followers
1442
        if is_user and obj.type == 'delete':
1✔
1443
            for proto in from_user.enabled_protocols:
1✔
1444
                from_user.disable_protocol(PROTOCOLS[proto])
1✔
1445

1446
            logger.info(f'Deactivating Followers from or to {from_user.key.id()}')
1✔
1447
            followers = Follower.query(
1✔
1448
                OR(Follower.to == from_user.key, Follower.from_ == from_user.key)
1449
            ).fetch()
1450
            for f in followers:
1✔
1451
                f.status = 'inactive'
1✔
1452
            ndb.put_multi(followers)
1✔
1453

1454
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1455
        return resp
1✔
1456

1457
    @classmethod
1✔
1458
    def handle_follow(from_cls, obj, from_user):
1✔
1459
        """Handles an incoming follow activity.
1460

1461
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1462
        happens in :meth:`deliver`.
1463

1464
        Args:
1465
          obj (models.Object): follow activity
1466
        """
1467
        logger.debug('Got follow. storing Follow(s), sending accept(s)')
1✔
1468
        from_id = from_user.key.id()
1✔
1469

1470
        # Prepare followee (to) users' data
1471
        to_as1s = as1.get_objects(obj.as1)
1✔
1472
        if not to_as1s:
1✔
UNCOV
1473
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1474

1475
        # Store Followers
1476
        for to_as1 in to_as1s:
1✔
1477
            to_id = to_as1.get('id')
1✔
1478
            if not to_id:
1✔
UNCOV
1479
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1480

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

1483
            to_cls = Protocol.for_id(to_id)
1✔
1484
            if not to_cls:
1✔
1485
                error(f"Couldn't determine protocol for {to_id}")
×
1486
            elif from_cls == to_cls:
1✔
1487
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1488
                continue
1✔
1489

1490
            to_key = to_cls.key_for(to_id)
1✔
1491
            if not to_key:
1✔
UNCOV
1492
                logger.info(f'Skipping invalid {to_cls.LABEL} user key: {to_id}')
×
UNCOV
1493
                continue
×
1494

1495
            to_user = to_cls.get_or_create(id=to_key.id())
1✔
1496
            if not to_user or not to_user.is_enabled(from_cls):
1✔
1497
                error(f'{to_id} not found')
1✔
1498

1499
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1500
                                                  follow=obj.key, status='active')
1501
            obj.add('notify', to_key)
1✔
1502
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1503
                                       followee=to_user, follow=obj)
1504

1505
    @classmethod
1✔
1506
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1507
        """Sends an accept or reject activity for a follow.
1508

1509
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1510
        nothing.
1511

1512
        Args:
1513
          verb (str): ``accept`` or  ``reject``
1514
          follower (models.User)
1515
          followee (models.User)
1516
          follow (models.Object)
1517
        """
1518
        assert verb in ('accept', 'reject')
1✔
1519
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1520
            return
1✔
1521

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

1525
        # send. note that this is one response for the whole follow, even if it
1526
        # has multiple followees!
1527
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1528
        accept = {
1✔
1529
            'id': id,
1530
            'objectType': 'activity',
1531
            'verb': verb,
1532
            'actor': followee.key.id(),
1533
            'object': follow.as1,
1534
        }
1535
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1536
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1537

1538
    @classmethod
1✔
1539
    def bot_maybe_follow_back(bot_cls, user):
1✔
1540
        """Follow a user from a protocol bot user, if their protocol needs that.
1541

1542
        ...so that the protocol starts sending us their activities, if it needs
1543
        a follow for that (eg ActivityPub).
1544

1545
        Args:
1546
          user (User)
1547
        """
1548
        if not user.BOTS_FOLLOW_BACK:
1✔
1549
            return
1✔
1550

1551
        from web import Web
1✔
1552
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1553
        now = util.now().isoformat()
1✔
1554
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1555

1556
        if not user.obj:
1✔
1557
            logger.info("  can't follow, user has no profile obj")
1✔
1558
            return
1✔
1559

1560
        target = user.target_for(user.obj)
1✔
1561
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1562
        follow_back_as1 = {
1✔
1563
            'objectType': 'activity',
1564
            'verb': 'follow',
1565
            'id': follow_back_id,
1566
            'actor': bot.key.id(),
1567
            'object': user.key.id(),
1568
        }
1569
        common.create_task(queue='send', id=follow_back_id,
1✔
1570
                           our_as1=follow_back_as1, url=target,
1571
                           source_protocol='web', protocol=user.LABEL,
1572
                           user=bot.key.urlsafe())
1573

1574
    @classmethod
1✔
1575
    def handle_bare_object(cls, obj, *, authed_as, from_user):
1✔
1576
        """If obj is a bare object, wraps it in a create or update activity.
1577

1578
        Checks if we've seen it before.
1579

1580
        Args:
1581
          obj (models.Object)
1582
          authed_as (str): authenticated actor id who sent this activity
1583
          from_user (models.User): user (actor) this activity/object is from
1584

1585
        Returns:
1586
          models.Object: ``obj`` if it's an activity, otherwise a new object
1587
        """
1588
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1589
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1590
            return obj
1✔
1591

1592
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1593
        now = util.now().isoformat()
1✔
1594

1595
        # this is a raw post; wrap it in a create or update activity
1596
        if obj.changed or is_actor:
1✔
1597
            if obj.changed:
1✔
1598
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1599
            else:
1600
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1601
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1602
            update_as1 = {
1✔
1603
                'objectType': 'activity',
1604
                'verb': 'update',
1605
                'id': id,
1606
                'actor': obj_actor,
1607
                'object': {
1608
                    # Mastodon requires the updated field for Updates, so
1609
                    # add a default value.
1610
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1611
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1612
                    # https://github.com/mastodon/documentation/pull/1150
1613
                    'updated': now,
1614
                    **obj.as1,
1615
                },
1616
            }
1617
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1618
            return Object(id=id, our_as1=update_as1,
1✔
1619
                          source_protocol=obj.source_protocol)
1620

1621
        if obj.new or 'force' in request.values:
1✔
1622
            create_id = f'{obj.key.id()}#bridgy-fed-create-{now}'
1✔
1623
            create_as1 = {
1✔
1624
                'objectType': 'activity',
1625
                'verb': 'post',
1626
                'id': create_id,
1627
                'actor': obj_actor,
1628
                'object': obj.as1,
1629
                'published': now,
1630
            }
1631
            logger.info(f'Wrapping in post')
1✔
1632
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1633
            return Object(id=create_id, our_as1=create_as1,
1✔
1634
                          source_protocol=obj.source_protocol)
1635

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

1638
    @classmethod
1✔
1639
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1640
        """Delivers an activity to its external recipients.
1641

1642
        Args:
1643
          obj (models.Object): activity to deliver
1644
          from_user (models.User): user (actor) this activity is from
1645
          crud_obj (models.Object): if this is a create, update, or delete/undo
1646
            activity, the inner object that's being written, otherwise None.
1647
            (This object's ``notify`` and ``feed`` properties may be updated.)
1648
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1649
            targets on this protocol
1650

1651
        Returns:
1652
          (str, int) tuple: Flask response
1653
        """
1654
        if to_proto:
1✔
1655
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1656

1657
        # find delivery targets. maps Target to Object or None
1658
        #
1659
        # ...then write the relevant object, since targets() has a side effect of
1660
        # setting the notify and feed properties (and dirty attribute)
1661
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1662
        if to_proto:
1✔
1663
            targets = {t: obj for t, obj in targets.items()
1✔
1664
                       if t.protocol == to_proto.LABEL}
1665
        if not targets:
1✔
1666
            # don't raise via error() because we call deliver in code paths where
1667
            # we want to continue after
1668
            msg = r'No targets, nothing to do ¯\_(ツ)_/¯'
1✔
1669
            logger.info(msg)
1✔
1670
            return msg, 204
1✔
1671

1672
        # store object that targets() updated
1673
        if crud_obj and crud_obj.dirty:
1✔
1674
            crud_obj.put()
1✔
1675
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1676
            obj.put()
1✔
1677

1678
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1679
                      else obj.to_request())
1680

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

1684
        # enqueue send task for each targets
1685
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1686
        user = from_user.key.urlsafe()
1✔
1687
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1688
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1689
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1690
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1691

1692
        return 'OK', 202
1✔
1693

1694
    @classmethod
1✔
1695
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1696
        """Collects the targets to send a :class:`models.Object` to.
1697

1698
        Targets are both objects - original posts, events, etc - and actors.
1699

1700
        Args:
1701
          obj (models.Object)
1702
          from_user (User)
1703
          crud_obj (models.Object): if this is a create, update, or delete/undo
1704
            activity, the inner object that's being written, otherwise None.
1705
            (This object's ``notify`` and ``feed`` properties may be updated.)
1706
          internal (bool): whether this is a recursive internal call
1707

1708
        Returns:
1709
          dict: maps :class:`models.Target` to original (in response to)
1710
          :class:`models.Object`
1711
        """
1712
        logger.debug('Finding recipients and their targets')
1✔
1713

1714
        # we should only have crud_obj iff this is a create or update
1715
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1716
        write_obj = crud_obj or obj
1✔
1717
        write_obj.dirty = False
1✔
1718

1719
        target_uris = as1.targets(obj.as1)
1✔
1720
        orig_obj = None
1✔
1721
        targets = {}  # maps Target (with *normalized* uri) to Object or None
1✔
1722
        owner = as1.get_owner(obj.as1)
1✔
1723
        allow_opt_out = (obj.type == 'delete')
1✔
1724
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1725
        inner_obj_id = inner_obj_as1.get('id')
1✔
1726
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1727
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1728
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1729
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1730
        is_self_reply = False
1✔
1731

1732
        original_ids = []
1✔
1733
        if is_reply:
1✔
1734
            original_ids = in_reply_tos
1✔
1735
        elif inner_obj_id:
1✔
1736
            if inner_obj_id == from_user.key.id():
1✔
1737
                inner_obj_id = from_user.profile_id()
1✔
1738
            original_ids = [inner_obj_id]
1✔
1739

1740
        # maps id to Object
1741
        original_objs = {}
1✔
1742
        for id in original_ids:
1✔
1743
            if proto := Protocol.for_id(id):
1✔
1744
                original_objs[id] = proto.load(id, raise_=False)
1✔
1745

1746
        # for AP, add in-reply-tos' mentions
1747
        # https://github.com/snarfed/bridgy-fed/issues/1608
1748
        # https://github.com/snarfed/bridgy-fed/issues/1218
1749
        orig_post_mentions = {}  # maps mentioned id to original post Object
1✔
1750
        for id in in_reply_tos:
1✔
1751
            if ((in_reply_to_obj := original_objs.get(id))
1✔
1752
                    and (proto := PROTOCOLS.get(in_reply_to_obj.source_protocol))
1753
                    and proto.SEND_REPLIES_TO_ORIG_POSTS_MENTIONS
1754
                    and (mentions := as1.mentions(in_reply_to_obj.as1))):
1755
                logger.info(f"Adding in-reply-to {id} 's mentions to targets: {mentions}")
1✔
1756
                target_uris.extend(mentions)
1✔
1757
                for mention in mentions:
1✔
1758
                    orig_post_mentions[mention] = in_reply_to_obj
1✔
1759

1760
        target_uris = sorted(set(target_uris))
1✔
1761
        logger.info(f'Raw targets: {target_uris}')
1✔
1762

1763
        # which protocols should we allow delivering to?
1764
        to_protocols = []  # elements are Protocol subclasses
1✔
1765
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1766
                      + from_user.enabled_protocols):
1767
            if not (proto := PROTOCOLS.get(label)):
1✔
1768
                report_error(f'unknown enabled protocol {label} for {from_user.key.id()}')
1✔
1769
                continue
1✔
1770

1771
            if (obj.type == 'post' and (orig := original_objs.get(inner_obj_id))
1✔
1772
                    and orig.get_copy(proto)):
1773
                logger.info(f'Already created {id} on {label}, cowardly refusing to create there again')
1✔
1774
                continue
1✔
1775

1776
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1777
                                     or is_reply):
1778
                origs_could_bridge = None
1✔
1779

1780
                for id in original_ids:
1✔
1781
                    if not (orig := original_objs.get(id)):
1✔
1782
                        continue
1✔
1783
                    elif orig.get_copy(proto):
1✔
1784
                        logger.info(f'Allowing {label}, original {id} was bridged there')
1✔
1785
                        break
1✔
1786
                    elif from_user.is_profile(orig):
1✔
1787
                        logger.info(f"Allowing {label}, this is the user's profile")
1✔
1788
                        break
1✔
1789

1790
                    if (origs_could_bridge is not False
1✔
1791
                            and (orig_author_id := as1.get_owner(orig.as1))
1792
                            and (orig_proto := orig.owner_protocol())
1793
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1794
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1795

1796
                else:
1797
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1798
                    last_retry = False
1✔
1799
                    if retries := request.headers.get(TASK_RETRIES_HEADER):
1✔
1800
                        if (last_retry := int(retries) >= TASK_RETRIES_RECEIVE):
1✔
1801
                            logger.info(f'last retry! skipping {proto.LABEL} and continuing')
1✔
1802

1803
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
1✔
1804
                            and origs_could_bridge and not last_retry):
1805
                        # retry later; original obj may still be bridging
1806
                        # TODO: limit to brief window, eg no older than 2h? 1d?
1807
                        error(msg, status=304)
1✔
1808

1809
                    logger.info(msg)
1✔
1810
                    continue
1✔
1811

1812
            util.add(to_protocols, proto)
1✔
1813

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

1816
        # process direct targets
1817
        for target_id in target_uris:
1✔
1818
            target_proto = Protocol.for_id(target_id)
1✔
1819
            if not target_proto:
1✔
1820
                logger.info(f"Can't determine protocol for {target_id}")
1✔
1821
                continue
1✔
1822
            elif target_proto.is_blocklisted(target_id):
1✔
1823
                logger.debug(f'{target_id} is blocklisted')
1✔
1824
                continue
1✔
1825

1826
            target_obj_id = target_id
1✔
1827
            if target_id in mentioned_urls or obj.type in as1.VERBS_WITH_ACTOR_OBJECT:
1✔
1828
                # not ideal. this can sometimes be a non-user, eg blocking a
1829
                # blocklist. ok right now since profile_id() returns its input id
1830
                # unchanged if it doesn't look like a user id, but that's brittle.
1831
                target_obj_id = ids.profile_id(id=target_id, proto=target_proto)
1✔
1832

1833
            orig_obj = target_proto.load(target_obj_id, raise_=False)
1✔
1834
            if not orig_obj or not orig_obj.as1:
1✔
1835
                logger.info(f"Couldn't load {target_obj_id}")
1✔
1836
                continue
1✔
1837

1838
            target_author_key = (target_proto(id=target_id).key
1✔
1839
                                 if target_id in mentioned_urls
1840
                                 else target_proto.actor_key(orig_obj))
1841
            if not from_user.is_enabled(target_proto):
1✔
1842
                # if author isn't bridged and target user is, DM a prompt and
1843
                # add a notif for the target user
1844
                if (target_id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
1845
                        and target_author_key):
1846
                    if target_author := target_author_key.get():
1✔
1847
                        if target_author.is_enabled(from_cls):
1✔
1848
                            notifications.add_notification(target_author, write_obj)
1✔
1849
                            verb, noun = (
1✔
1850
                                ('replied to', 'replies') if target_id in in_reply_tos
1851
                                else ('quoted', 'quotes') if target_id in quoted_posts
1852
                                else ('mentioned', 'mentions'))
1853
                            dms.maybe_send(from_=target_proto, to_user=from_user,
1✔
1854
                                           type='replied_to_bridged_user', text=f"""\
1855
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.""")
1856

1857
                continue
1✔
1858

1859
            # deliver self-replies to followers
1860
            # https://github.com/snarfed/bridgy-fed/issues/639
1861
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1862
                is_self_reply = True
1✔
1863
                logger.info(f'self reply!')
1✔
1864

1865
            # also add copies' targets
1866
            for copy in orig_obj.copies:
1✔
1867
                proto = PROTOCOLS[copy.protocol]
1✔
1868
                if proto in to_protocols:
1✔
1869
                    # copies generally won't have their own Objects
1870
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1871
                        target = util.normalize_url(target, trailing_slash=False)
1✔
1872
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {target_id}')
1✔
1873
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1874

1875
            if target_proto == from_cls:
1✔
1876
                logger.debug(f'Skipping same-protocol target {target_id}')
1✔
1877
                continue
1✔
1878

1879
            target = target_proto.target_for(orig_obj)
1✔
1880
            if not target:
1✔
1881
                # TODO: surface errors like this somehow?
UNCOV
1882
                logger.error(f"Can't find delivery target for {target_id}")
×
UNCOV
1883
                continue
×
1884

1885
            target = util.normalize_url(target, trailing_slash=False)
1✔
1886
            logger.debug(f'Target for {target_id} is {target}')
1✔
1887
            # only use orig_obj for inReplyTos, like/repost objects, reply's original
1888
            # post's mentions, etc
1889
            # https://github.com/snarfed/bridgy-fed/issues/1237
1890
            target_obj = None
1✔
1891
            if target_id in in_reply_tos + as1.get_ids(obj.as1, 'object'):
1✔
1892
                target_obj = orig_obj
1✔
1893
            elif target_id in orig_post_mentions:
1✔
1894
                target_obj = orig_post_mentions[target_id]
1✔
1895
            targets[Target(protocol=target_proto.LABEL, uri=target)] = target_obj
1✔
1896

1897
            if target_author_key:
1✔
1898
                logger.debug(f'Recipient is {target_author_key}')
1✔
1899
                if write_obj.add('notify', target_author_key):
1✔
1900
                    write_obj.dirty = True
1✔
1901

1902
        if obj.type == 'undo':
1✔
1903
            logger.info('Object is an undo; adding targets for inner object')
1✔
1904
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1905
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1906
            else:
1907
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1908
            if inner_obj:
1✔
1909
                for target, target_obj in from_cls.targets(
1✔
1910
                        inner_obj, from_user=from_user, internal=True).items():
1911
                    targets[target] = target_obj
1✔
1912
                    util.add(to_protocols, PROTOCOLS[target.protocol])
1✔
1913

1914
        if not to_protocols:
1✔
1915
            return {}
1✔
1916

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

1919
        # deliver to followers, if appropriate
1920
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1921
        if not user_key:
1✔
1922
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1923
            return targets
1✔
1924

1925
        followers = []
1✔
1926
        is_undo_block = obj.type == 'undo' and inner_obj_as1.get('verb') == 'block'
1✔
1927
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1928
                and (not is_reply or is_self_reply) and not is_undo_block):
1929
            logger.info(f'Delivering to followers of {user_key} on {[p.LABEL for p in to_protocols]}')
1✔
1930
            followers = []
1✔
1931
            for f in Follower.query(Follower.to == user_key,
1✔
1932
                                    Follower.status == 'active'):
1933
                proto = PROTOCOLS_BY_KIND[f.from_.kind()]
1✔
1934
                # skip protocol bot users
1935
                if (not Protocol.for_bridgy_subdomain(f.from_.id())
1✔
1936
                        # skip protocols this user hasn't enabled, or where the base
1937
                        # object of this activity hasn't been bridged
1938
                        and proto in to_protocols
1939
                        # we deliver to HAS_COPIES protocols separately, below. we
1940
                        # assume they have follower-independent targets.
1941
                        and not (proto.HAS_COPIES and proto.DEFAULT_TARGET)):
1942
                    followers.append(f)
1✔
1943

1944
            logger.info(f'  loaded {len(followers)} followers')
1✔
1945

1946
            user_keys = [f.from_ for f in followers]
1✔
1947
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1948
            logger.info(f'  loaded {len(users)} users')
1✔
1949

1950
            User.load_multi(users)
1✔
1951
            logger.info(f'  loaded user objects')
1✔
1952

1953
            if (not followers and
1✔
1954
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
1955
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
1956
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1957
                return {}
1✔
1958

1959
            # add to followers' feeds, if any
1960
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1961
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1962
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
1✔
1963
                    if write_obj.feed:
1✔
1964
                        write_obj.dirty = True
1✔
1965

1966
            # collect targets for followers
1967
            target_obj = (original_objs.get(inner_obj_id)
1✔
1968
                          if obj.type == 'share' else None)
1969
            for user in users:
1✔
1970
                if user.is_blocking(from_user):
1✔
1971
                    logger.debug(f'  {user.key.id()} blocks {from_user.key.id()}')
1✔
1972
                    continue
1✔
1973

1974
                # TODO: should we pass remote=False through here to Protocol.load?
1975
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1976
                if not target:
1✔
1977
                    continue
1✔
1978

1979
                target = util.normalize_url(target, trailing_slash=False)
1✔
1980
                targets[Target(protocol=user.LABEL, uri=target)] = target_obj
1✔
1981

1982
            logger.info(f'  collected {len(targets)} targets')
1✔
1983

1984
        # deliver to enabled HAS_COPIES protocols proactively
1985
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
1986
            for proto in to_protocols:
1✔
1987
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
1988
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
1989
                    targets.setdefault(
1✔
1990
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1991

1992
        # maps string target URL to (Target, Object) tuple
1993
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1994
        # maps Target to Object or None
1995
        targets = {}
1✔
1996
        source_domains = [
1✔
1997
            util.domain_from_link(url) for url in
1998
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1999
            if util.is_web(url)
2000
        ]
2001
        for url in sorted(util.dedupe_urls(
1✔
2002
                candidates.keys(),
2003
                # preserve our PDS URL without trailing slash in path
2004
                # https://atproto.com/specs/did#did-documents
2005
                trailing_slash=False)):
2006
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
UNCOV
2007
                logger.info(f'Skipping same-domain target {url}')
×
UNCOV
2008
                continue
×
2009
            elif from_user.is_blocking(url):
1✔
2010
                logger.debug(f'{from_user.key.id()} blocks {url}')
1✔
2011
                continue
1✔
2012

2013
            target, obj = candidates[url]
1✔
2014
            targets[target] = obj
1✔
2015

2016
        return targets
1✔
2017

2018
    @classmethod
1✔
2019
    def load(cls, id, remote=None, local=True, raise_=True, raw=False, csv=False,
1✔
2020
             **kwargs):
2021
        """Loads and returns an Object from datastore or HTTP fetch.
2022

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

2026
        Args:
2027
          id (str)
2028
          remote (bool): whether to fetch the object over the network. If True,
2029
            fetches even if we already have the object stored, and updates our
2030
            stored copy. If False and we don't have the object stored, returns
2031
            None. Default (None) means to fetch over the network only if we
2032
            don't already have it stored.
2033
          local (bool): whether to load from the datastore before
2034
            fetching over the network. If False, still stores back to the
2035
            datastore after a successful remote fetch.
2036
          raise_ (bool): if False, catches any :class:`request.RequestException`
2037
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
2038
            ``None`` instead
2039
          raw (bool): whether to load this as a "raw" id, as is, without
2040
            normalizing to an on-protocol object id. Exact meaning varies by subclass.
2041
          csv (bool): whether to specifically load a CSV object
2042
            TODO: merge this into raw, using returned Content-Type?
2043
          kwargs: passed through to :meth:`fetch()`
2044

2045
        Returns:
2046
          models.Object: loaded object, or None if it isn't fetchable, eg a
2047
          non-URL string for Web, or ``remote`` is False and it isn't in the
2048
          datastore
2049

2050
        Raises:
2051
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
2052
            is True
2053
        """
2054
        assert id
1✔
2055
        assert local or remote is not False
1✔
2056
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
2057

2058
        if not raw:
1✔
2059
            id = ids.normalize_object_id(id=id, proto=cls)
1✔
2060

2061
        obj = orig_as1 = None
1✔
2062
        if local:
1✔
2063
            if obj := Object.get_by_id(id):
1✔
2064
                if csv and not obj.is_csv:
1✔
2065
                    return None
1✔
2066
                elif obj.as1 or obj.csv or obj.raw or obj.deleted:
1✔
2067
                    # logger.debug(f'  {id} got from datastore')
2068
                    obj.new = False
1✔
2069

2070
        if remote is False:
1✔
2071
            return obj
1✔
2072
        elif remote is None and obj:
1✔
2073
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
2074
                # logger.debug(f'  last updated {obj.updated}, refreshing')
2075
                pass
1✔
2076
            else:
2077
                return obj
1✔
2078

2079
        if obj:
1✔
2080
            orig_as1 = obj.as1
1✔
2081
            obj.our_as1 = None
1✔
2082
            obj.new = False
1✔
2083
        else:
2084
            if cls == Protocol:
1✔
2085
                return None
1✔
2086
            obj = Object(id=id)
1✔
2087
            if local:
1✔
2088
                # logger.debug(f'  {id} not in datastore')
2089
                obj.new = True
1✔
2090
                obj.changed = False
1✔
2091

2092
        try:
1✔
2093
            fetched = cls.fetch(obj, csv=csv, **kwargs)
1✔
2094
        except (RequestException, HTTPException, InvalidStatus) as e:
1✔
2095
            if raise_:
1✔
2096
                raise
1✔
2097
            util.interpret_http_exception(e)
1✔
2098
            return None
1✔
2099

2100
        if not fetched:
1✔
2101
            return None
1✔
2102
        elif csv and not obj.is_csv:
1✔
UNCOV
2103
            return None
×
2104

2105
        # https://stackoverflow.com/a/3042250/186123
2106
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
2107
        if size > MAX_ENTITY_SIZE:
1✔
2108
            logger.warning(f'Object is too big! {size} bytes is over {MAX_ENTITY_SIZE}')
1✔
2109
            return None
1✔
2110

2111
        obj.resolve_ids()
1✔
2112
        obj.normalize_ids()
1✔
2113

2114
        if obj.new is False:
1✔
2115
            obj.changed = obj.activity_changed(orig_as1)
1✔
2116

2117
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
2118
            if obj.source_protocol:
1✔
UNCOV
2119
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
2120
            obj.source_protocol = cls.LABEL
1✔
2121

2122
        obj.put()
1✔
2123
        return obj
1✔
2124

2125
    @classmethod
1✔
2126
    def check_supported(cls, obj, direction):
1✔
2127
        """If this protocol doesn't support this activity, raises HTTP 204.
2128

2129
        Also reports an error.
2130

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

2135
        Args:
2136
          obj (Object)
2137
          direction (str): ``'receive'`` or  ``'send'``
2138

2139
        Raises:
2140
          werkzeug.HTTPException: if this protocol doesn't support this object
2141
        """
2142
        assert direction in ('receive', 'send')
1✔
2143
        if not obj.type:
1✔
UNCOV
2144
            return
×
2145

2146
        inner = as1.get_object(obj.as1)
1✔
2147
        inner_type = as1.object_type(inner) or ''
1✔
2148
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
2149
            or (obj.type in as1.CRUD_VERBS
2150
                and inner_type
2151
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
2152
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
2153

2154
        # don't allow posts with blank content and no image/video/audio
2155
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
2156
                    else obj.as1)
2157
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
2158
                and not util.get_url(crud_obj, key='image')
2159
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
2160
                # TODO: handle articles with displayName but not content
2161
                and not source.html_to_text(crud_obj.get('content')).strip()):
2162
            error('Blank content and no image or video or audio', status=204)
1✔
2163

2164
        # receiving DMs is only allowed to protocol bot accounts
2165
        if direction == 'receive':
1✔
2166
            if recip := as1.recipient_if_dm(obj.as1):
1✔
2167
                owner = as1.get_owner(obj.as1)
1✔
2168
                if (not cls.SUPPORTS_DMS or (recip not in common.bot_user_ids()
1✔
2169
                                             and owner not in common.bot_user_ids())):
2170
                    # reply and say DMs aren't supported
2171
                    from_proto = obj.owner_protocol()
1✔
2172
                    to_proto = Protocol.for_id(recip)
1✔
2173
                    if owner and from_proto and to_proto:
1✔
2174
                        if ((from_user := from_proto.get_or_create(id=owner))
1✔
2175
                                and (to_user := to_proto.get_or_create(id=recip))):
2176
                            in_reply_to = (inner.get('id') if obj.type == 'post'
1✔
2177
                                           else obj.as1.get('id'))
2178
                            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✔
2179
                            type = f'dms_not_supported-{to_user.key.id()}'
1✔
2180
                            dms.maybe_send(from_=to_user, to_user=from_user,
1✔
2181
                                           text=text, type=type,
2182
                                           in_reply_to=in_reply_to)
2183

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

2186
            # check that this activity is public. only do this for some activities,
2187
            # not eg likes or follows, since Mastodon doesn't currently mark those
2188
            # as explicitly public.
2189
            elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
2190
                  and not util.domain_or_parent_in(crud_obj.get('id'), NON_PUBLIC_DOMAINS)
2191
                  and not as1.is_public(obj.as1, unlisted=False)):
2192
                error('Bridgy Fed only supports public activities', status=204)
1✔
2193

2194
    @classmethod
1✔
2195
    def block(cls, from_user, arg):
1✔
2196
        """Blocks a user or list.
2197

2198
        Args:
2199
          from_user (models.User): user doing the blocking
2200
          arg (str): handle or id of user/list to block
2201

2202
        Returns:
2203
          models.User or models.Object: user or list that was blocked
2204

2205
        Raises:
2206
          ValueError: if arg doesn't look like a user or list on this protocol
2207
        """
2208
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2209

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, proto=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 blocking 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.add_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'  blocking {blockee.key.id()}')
1✔
2238
        id = f'{from_user.key.id()}#bridgy-fed-block-{util.now().isoformat()}'
1✔
2239
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2240
            'objectType': 'activity',
2241
            'verb': 'block',
2242
            'id': id,
2243
            'actor': from_user.key.id(),
2244
            'object': blockee.key.id(),
2245
        })
2246
        obj.put()
1✔
2247
        from_user.deliver(obj, from_user=from_user)
1✔
2248

2249
        return blockee
1✔
2250

2251
    @classmethod
1✔
2252
    def unblock(cls, from_user, arg):
1✔
2253
        """Unblocks a user or list.
2254

2255
        Args:
2256
          from_user (models.User): user doing the unblocking
2257
          arg (str): handle or id of user/list to unblock
2258

2259
        Returns:
2260
          models.User or models.Object: user or list that was unblocked
2261

2262
        Raises:
2263
          ValueError: if arg doesn't look like a user or list on this protocol
2264
        """
2265
        logger.info(f'user {from_user.key.id()} trying to unblock {arg}')
1✔
2266
        def fail(msg):
1✔
2267
            logger.warning(msg)
1✔
2268
            raise ValueError(msg)
1✔
2269

2270
        blockee = None
1✔
2271
        try:
1✔
2272
            # first, try interpreting as a user handle or id
2273
            blockee = load_user(arg, cls, create=True, allow_opt_out=True)
1✔
2274
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2275
            logger.info(err)
1✔
2276

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

2280
        # may not be a user, see if it's a list
2281
        if not blockee:
1✔
2282
            if not cls or cls == Protocol:
1✔
2283
                cls = Protocol.for_id(arg)
1✔
2284

2285
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2286
                if blockee.source_protocol == from_user.LABEL:
1✔
2287
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2288
            else:
2289
                if blocklist := from_user.remove_domain_blocklist(arg):
1✔
2290
                    return blocklist
1✔
2291
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2292

2293
        logger.info(f'  unblocking {blockee.key.id()}')
1✔
2294
        id = f'{from_user.key.id()}#bridgy-fed-unblock-{util.now().isoformat()}'
1✔
2295
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2296
            'objectType': 'activity',
2297
            'verb': 'undo',
2298
            'id': id,
2299
            'actor': from_user.key.id(),
2300
            'object': {
2301
                'objectType': 'activity',
2302
                'verb': 'block',
2303
                'actor': from_user.key.id(),
2304
                'object': blockee.key.id(),
2305
            },
2306
        })
2307
        obj.put()
1✔
2308
        from_user.deliver(obj, from_user=from_user)
1✔
2309

2310
        return blockee
1✔
2311

2312

2313
@cloud_tasks_only(log=None)
1✔
2314
def receive_task():
1✔
2315
    """Task handler for a newly received :class:`models.Object`.
2316

2317
    Calls :meth:`Protocol.receive` with the form parameters.
2318

2319
    Parameters:
2320
      authed_as (str): passed to :meth:`Protocol.receive`
2321
      obj_id (str): key id of :class:`models.Object` to handle
2322
      received_at (str, ISO 8601 timestamp): when we first saw (received)
2323
        this activity
2324
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2325
        :class:`models.Object` to handle
2326

2327
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
2328
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
2329
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
2330
    ``changed``, HTTP request details, etc. See stash for attempt at this for
2331
    :class:`web.Web`.
2332
    """
2333
    common.log_request()
1✔
2334
    form = request.form.to_dict()
1✔
2335

2336
    authed_as = form.pop('authed_as', None)
1✔
2337
    internal = authed_as == PRIMARY_DOMAIN or authed_as in PROTOCOL_DOMAINS
1✔
2338

2339
    obj = Object.from_request()
1✔
2340
    assert obj
1✔
2341
    assert obj.source_protocol
1✔
2342
    obj.new = True
1✔
2343

2344
    if received_at := form.pop('received_at', None):
1✔
2345
        received_at = datetime.fromisoformat(received_at)
1✔
2346

2347
    try:
1✔
2348
        return PROTOCOLS[obj.source_protocol].receive(
1✔
2349
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
2350
    except RequestException as e:
1✔
2351
        util.interpret_http_exception(e)
1✔
2352
        error(e, status=304)
1✔
2353
    except (RuntimeError, ValueError) as e:
1✔
UNCOV
2354
        logger.warning(e, exc_info=True)
×
UNCOV
2355
        error(e, status=304)
×
2356

2357

2358
@cloud_tasks_only(log=None)
1✔
2359
def send_task():
1✔
2360
    """Task handler for sending an activity to a single specific destination.
2361

2362
    Calls :meth:`Protocol.send` with the form parameters.
2363

2364
    Parameters:
2365
      protocol (str): :class:`Protocol` to send to
2366
      url (str): destination URL to send to
2367
      obj_id (str): key id of :class:`models.Object` to send
2368
      orig_obj_id (str): optional, :class:`models.Object` key id of the
2369
        "original object" that this object refers to, eg replies to or reposts
2370
        or likes
2371
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
2372
        this activity is from
2373
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2374
        :class:`models.Object` to handle
2375
    """
2376
    common.log_request()
1✔
2377

2378
    # prepare
2379
    form = request.form.to_dict()
1✔
2380
    url = form.get('url')
1✔
2381
    protocol = form.get('protocol')
1✔
2382
    if not url or not protocol:
1✔
2383
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
2384
        return '', 204
1✔
2385

2386
    target = Target(uri=url, protocol=protocol)
1✔
2387
    obj = Object.from_request()
1✔
2388
    assert obj and obj.key and obj.key.id()
1✔
2389

2390
    PROTOCOLS[protocol].check_supported(obj, 'send')
1✔
2391
    allow_opt_out = (obj.type == 'delete')
1✔
2392

2393
    user = None
1✔
2394
    if user_key := form.get('user'):
1✔
2395
        key = ndb.Key(urlsafe=user_key)
1✔
2396
        # use get_by_id so that we follow use_instead
2397
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2398
            key.id(), allow_opt_out=allow_opt_out)
2399

2400
    # send
2401
    delay = ''
1✔
2402
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
2403
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
2404
        delay = f'({delay_s} s behind)'
1✔
2405
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
2406
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
2407
    sent = None
1✔
2408
    try:
1✔
2409
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
2410
                                        orig_obj_id=form.get('orig_obj_id'))
2411
    except (MemcacheServerError, MemcacheUnexpectedCloseError,
1✔
2412
            MemcacheUnknownError) as e:
2413
        # our memorystore instance is probably undergoing maintenance. re-enqueue
2414
        # task with a delay.
2415
        # https://docs.cloud.google.com/memorystore/docs/memcached/about-maintenance
2416
        report_error(f'memcache error on send task, re-enqueuing in {MEMCACHE_DOWN_TASK_DELAY}: {e}')
1✔
2417
        common.create_task(queue='send', delay=MEMCACHE_DOWN_TASK_DELAY, **form)
1✔
2418
        sent = False
1✔
2419
    except BaseException as e:
1✔
2420
        code, body = util.interpret_http_exception(e)
1✔
2421
        if not code and not body:
1✔
2422
            raise
1✔
2423

2424
    if sent is False:
1✔
2425
        logger.info(f'Failed sending!')
1✔
2426

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

© 2026 Coveralls, Inc