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

snarfed / bridgy-fed / 8c3c87c7-5be0-4b1e-87b6-ee137e658f02

06 Mar 2026 12:47AM UTC coverage: 94.016% (+0.001%) from 94.015%
8c3c87c7-5be0-4b1e-87b6-ee137e658f02

push

circleci

snarfed
add protocol.CREATE_MAX_AGE_EXEMPT_DOMAINS

fixes #2374

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

1 existing line in 1 file now uncovered.

6897 of 7336 relevant lines covered (94.02%)

0.94 hits per line

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

96.0
/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

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

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

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

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

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

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

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

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

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

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

201
        To be implemented by subclasses.
202

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

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

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

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

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

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

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

230
        To be implemented by subclasses.
231

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

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

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

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

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

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

259
        To be implemented by subclasses.
260

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

264
        Args:
265
          handle (str)
266

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

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

276

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

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

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

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

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

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

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

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

316
        return cls(id=id).key
1✔
317

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

322
        Args:
323
          id (str)
324

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

453
        Args:
454
          handle (str)
455

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

618
        To be implemented by subclasses.
619

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

721
        actor_id = actor.get('id')
1✔
722

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

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

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

737
            separator = '<br><br>'
1✔
738

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

902
        To be implemented by subclasses.
903

904
        Examples:
905

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

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

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

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

927
        Default implementation here, subclasses may override.
928

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

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

945
        Infers source protocol for each id value separately.
946

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

951
        Wraps these AS1 fields:
952

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1055
        outer_obj = util.trim_nulls(outer_obj)
1✔
1056

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

1062
        return outer_obj
1✔
1063

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1222
        if (obj.type == 'follow'
1✔
1223
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
1224
            # follows of bot user; refresh user profile first
1225
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
1226
            from_user = from_user_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1227
            from_user.reload_profile()
1✔
1228
        else:
1229
            # load actor user
1230
            #
1231
            # TODO: we should maybe eventually allow non-None status users here if
1232
            # this is a profile update, so that we store the user again below and
1233
            # re-calculate its status. right now, if a bridged user updates their
1234
            # profile and invalidates themselves, eg by removing their profile
1235
            # picture, and then updates again to make themselves valid again, we'll
1236
            # ignore the second update. they'll have to un-bridge and re-bridge
1237
            # themselves to get back working again.
1238
            from_user = from_user_cls.get_or_create(
1✔
1239
                id=actor, allow_opt_out=internal or obj.type == 'follow')
1240

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

1244
        # check if this is a profile object coming in via a user with use_instead
1245
        # set. if so, override the object's id to be the final user id (from_user's),
1246
        # after following use_instead.
1247
        if obj.type in as1.ACTOR_TYPES and from_user.key.id() != actor:
1✔
1248
            as1_id = obj.as1.get('id')
1✔
1249
            if ids.normalize_user_id(id=as1_id, proto=from_user) == actor:
1✔
1250
                logger.info(f'Overriding AS1 object id {as1_id} with Object id {from_user.profile_id()}')
1✔
1251
                obj.our_as1 = {**obj.as1, 'id': from_user.profile_id()}
1✔
1252

1253
        # if this is an object, ie not an activity, wrap it in a create or update
1254
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as,
1✔
1255
                                          from_user=from_user)
1256
        obj.add('users', from_user.key)
1✔
1257

1258
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1259
        inner_obj_id = inner_obj_as1.get('id')
1✔
1260
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1261
            if not inner_obj_id:
1✔
1262
                error(f'{obj.type} object has no id!')
1✔
1263

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

1282
        # write Object to datastore
1283
        if obj.type in STORE_AS1_TYPES:
1✔
1284
            obj.put()
1✔
1285

1286
        # store inner object
1287
        # TODO: unify with big obj.type conditional below. would have to merge
1288
        # this with the DM handling block lower down.
1289
        crud_obj = None
1✔
1290
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1291
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
1292
                                            source_protocol=obj.source_protocol,
1293
                                            authed_as=actor, users=[from_user.key],
1294
                                            deleted=False)
1295

1296
        actor = as1.get_object(obj.as1, 'actor')
1✔
1297
        actor_id = actor.get('id')
1✔
1298

1299
        # handle activity!
1300
        if obj.type == 'stop-following':
1✔
1301
            # TODO: unify with handle_follow?
1302
            # TODO: handle multiple followees
1303
            if not actor_id or not inner_obj_id:
1✔
1304
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1305

1306
            # deactivate Follower
1307
            from_ = from_user_cls.key_for(actor_id)
1✔
1308
            if not (to_cls := Protocol.for_id(inner_obj_id)):
1✔
1309
                error(f"Can't determine protocol for {inner_obj_id} , giving up")
1✔
1310
            to = to_cls.key_for(inner_obj_id)
1✔
1311
            follower = Follower.query(Follower.to == to,
1✔
1312
                                      Follower.from_ == from_,
1313
                                      Follower.status == 'active').get()
1314
            if follower:
1✔
1315
                logger.info(f'Marking {follower} inactive')
1✔
1316
                follower.status = 'inactive'
1✔
1317
                follower.put()
1✔
1318
            else:
1319
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1320

1321
            # fall through to deliver to followee
1322
            # TODO: do we convert stop-following to webmention 410 of original
1323
            # follow?
1324

1325
            # fall through to deliver to followers
1326

1327
        elif obj.type in ('delete', 'undo'):
1✔
1328
            delete_obj_id = (from_user.profile_id()
1✔
1329
                            if inner_obj_id == from_user.key.id()
1330
                            else inner_obj_id)
1331

1332
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1333
            if not delete_obj:
1✔
1334
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1335
                return 'OK', 204
1✔
1336

1337
            # TODO: just delete altogether!
1338
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1339
            delete_obj.deleted = True
1✔
1340
            delete_obj.put()
1✔
1341

1342
            # if this is an actor, handle deleting it later so that
1343
            # in case it's from_user, user.enabled_protocols is still populated
1344
            #
1345
            # fall through to deliver to followers and delete copy if necessary.
1346
            # should happen via protocol-specific copy target and send of
1347
            # delete activity.
1348
            # https://github.com/snarfed/bridgy-fed/issues/63
1349

1350
        elif obj.type == 'block':
1✔
1351
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1352
                # blocking protocol bot user disables that protocol
1353
                from_user.delete(proto)
1✔
1354
                from_user.disable_protocol(proto)
1✔
1355
                return 'OK', 200
1✔
1356

1357
        elif obj.type == 'post':
1✔
1358
            # handle DMs to bot users
1359
            if as1.is_dm(obj.as1):
1✔
1360
                return dms.receive(from_user=from_user, obj=obj)
1✔
1361

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

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

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

1416
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1417

1418
        # on update of the user's own actor/profile, set user.obj and store user back
1419
        # to datastore so that we recalculate computed properties like status etc
1420
        if is_user:
1✔
1421
            if obj.type == 'update' and crud_obj:
1✔
1422
                logger.info(f"update of the user's profile, re-storing user with obj_key {crud_obj.key.id()}")
1✔
1423
                from_user.obj = crud_obj
1✔
1424
                from_user.put()
1✔
1425

1426
        # deliver to targets
1427
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1428

1429
        # on user deleting themselves, deactivate their followers/followings.
1430
        # https://github.com/snarfed/bridgy-fed/issues/1304
1431
        #
1432
        # do this *after* delivering because delivery finds targets based on
1433
        # stored Followers
1434
        if is_user and obj.type == 'delete':
1✔
1435
            for proto in from_user.enabled_protocols:
1✔
1436
                from_user.disable_protocol(PROTOCOLS[proto])
1✔
1437

1438
            logger.info(f'Deactivating Followers from or to {from_user.key.id()}')
1✔
1439
            followers = Follower.query(
1✔
1440
                OR(Follower.to == from_user.key, Follower.from_ == from_user.key)
1441
            ).fetch()
1442
            for f in followers:
1✔
1443
                f.status = 'inactive'
1✔
1444
            ndb.put_multi(followers)
1✔
1445

1446
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1447
        return resp
1✔
1448

1449
    @classmethod
1✔
1450
    def handle_follow(from_cls, obj, from_user):
1✔
1451
        """Handles an incoming follow activity.
1452

1453
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1454
        happens in :meth:`deliver`.
1455

1456
        Args:
1457
          obj (models.Object): follow activity
1458
        """
1459
        logger.debug('Got follow. storing Follow(s), sending accept(s)')
1✔
1460
        from_id = from_user.key.id()
1✔
1461

1462
        # Prepare followee (to) users' data
1463
        to_as1s = as1.get_objects(obj.as1)
1✔
1464
        if not to_as1s:
1✔
1465
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1466

1467
        # Store Followers
1468
        for to_as1 in to_as1s:
1✔
1469
            to_id = to_as1.get('id')
1✔
1470
            if not to_id:
1✔
1471
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1472

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

1475
            to_cls = Protocol.for_id(to_id)
1✔
1476
            if not to_cls:
1✔
1477
                error(f"Couldn't determine protocol for {to_id}")
×
1478
            elif from_cls == to_cls:
1✔
1479
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1480
                continue
1✔
1481

1482
            to_key = to_cls.key_for(to_id)
1✔
1483
            if not to_key:
1✔
1484
                logger.info(f'Skipping invalid {to_cls.LABEL} user key: {to_id}')
×
1485
                continue
×
1486

1487
            to_user = to_cls.get_or_create(id=to_key.id())
1✔
1488
            if not to_user or not to_user.is_enabled(from_cls):
1✔
1489
                error(f'{to_id} not found')
1✔
1490

1491
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1492
                                                  follow=obj.key, status='active')
1493
            obj.add('notify', to_key)
1✔
1494
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1495
                                       followee=to_user, follow=obj)
1496

1497
    @classmethod
1✔
1498
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1499
        """Sends an accept or reject activity for a follow.
1500

1501
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1502
        nothing.
1503

1504
        Args:
1505
          verb (str): ``accept`` or  ``reject``
1506
          follower (models.User)
1507
          followee (models.User)
1508
          follow (models.Object)
1509
        """
1510
        assert verb in ('accept', 'reject')
1✔
1511
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1512
            return
1✔
1513

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

1517
        # send. note that this is one response for the whole follow, even if it
1518
        # has multiple followees!
1519
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1520
        accept = {
1✔
1521
            'id': id,
1522
            'objectType': 'activity',
1523
            'verb': verb,
1524
            'actor': followee.key.id(),
1525
            'object': follow.as1,
1526
        }
1527
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1528
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1529

1530
    @classmethod
1✔
1531
    def bot_maybe_follow_back(bot_cls, user):
1✔
1532
        """Follow a user from a protocol bot user, if their protocol needs that.
1533

1534
        ...so that the protocol starts sending us their activities, if it needs
1535
        a follow for that (eg ActivityPub).
1536

1537
        Args:
1538
          user (User)
1539
        """
1540
        if not user.BOTS_FOLLOW_BACK:
1✔
1541
            return
1✔
1542

1543
        from web import Web
1✔
1544
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1545
        now = util.now().isoformat()
1✔
1546
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1547

1548
        if not user.obj:
1✔
1549
            logger.info("  can't follow, user has no profile obj")
1✔
1550
            return
1✔
1551

1552
        target = user.target_for(user.obj)
1✔
1553
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1554
        follow_back_as1 = {
1✔
1555
            'objectType': 'activity',
1556
            'verb': 'follow',
1557
            'id': follow_back_id,
1558
            'actor': bot.key.id(),
1559
            'object': user.key.id(),
1560
        }
1561
        common.create_task(queue='send', id=follow_back_id,
1✔
1562
                           our_as1=follow_back_as1, url=target,
1563
                           source_protocol='web', protocol=user.LABEL,
1564
                           user=bot.key.urlsafe())
1565

1566
    @classmethod
1✔
1567
    def handle_bare_object(cls, obj, *, authed_as, from_user):
1✔
1568
        """If obj is a bare object, wraps it in a create or update activity.
1569

1570
        Checks if we've seen it before.
1571

1572
        Args:
1573
          obj (models.Object)
1574
          authed_as (str): authenticated actor id who sent this activity
1575
          from_user (models.User): user (actor) this activity/object is from
1576

1577
        Returns:
1578
          models.Object: ``obj`` if it's an activity, otherwise a new object
1579
        """
1580
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1581
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1582
            return obj
1✔
1583

1584
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1585
        now = util.now().isoformat()
1✔
1586

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

1613
        if obj.new or 'force' in request.values:
1✔
1614
            create_id = f'{obj.key.id()}#bridgy-fed-create-{now}'
1✔
1615
            create_as1 = {
1✔
1616
                'objectType': 'activity',
1617
                'verb': 'post',
1618
                'id': create_id,
1619
                'actor': obj_actor,
1620
                'object': obj.as1,
1621
                'published': now,
1622
            }
1623
            logger.info(f'Wrapping in post')
1✔
1624
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1625
            return Object(id=create_id, our_as1=create_as1,
1✔
1626
                          source_protocol=obj.source_protocol)
1627

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

1630
    @classmethod
1✔
1631
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1632
        """Delivers an activity to its external recipients.
1633

1634
        Args:
1635
          obj (models.Object): activity to deliver
1636
          from_user (models.User): user (actor) this activity is from
1637
          crud_obj (models.Object): if this is a create, update, or delete/undo
1638
            activity, the inner object that's being written, otherwise None.
1639
            (This object's ``notify`` and ``feed`` properties may be updated.)
1640
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1641
            targets on this protocol
1642

1643
        Returns:
1644
          (str, int) tuple: Flask response
1645
        """
1646
        if to_proto:
1✔
1647
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1648

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

1664
        # store object that targets() updated
1665
        if crud_obj and crud_obj.dirty:
1✔
1666
            crud_obj.put()
1✔
1667
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1668
            obj.put()
1✔
1669

1670
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1671
                      else obj.to_request())
1672

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

1676
        # enqueue send task for each targets
1677
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1678
        user = from_user.key.urlsafe()
1✔
1679
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1680
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1681
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1682
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1683

1684
        return 'OK', 202
1✔
1685

1686
    @classmethod
1✔
1687
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1688
        """Collects the targets to send a :class:`models.Object` to.
1689

1690
        Targets are both objects - original posts, events, etc - and actors.
1691

1692
        Args:
1693
          obj (models.Object)
1694
          from_user (User)
1695
          crud_obj (models.Object): if this is a create, update, or delete/undo
1696
            activity, the inner object that's being written, otherwise None.
1697
            (This object's ``notify`` and ``feed`` properties may be updated.)
1698
          internal (bool): whether this is a recursive internal call
1699

1700
        Returns:
1701
          dict: maps :class:`models.Target` to original (in response to)
1702
          :class:`models.Object`
1703
        """
1704
        logger.debug('Finding recipients and their targets')
1✔
1705

1706
        # we should only have crud_obj iff this is a create or update
1707
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1708
        write_obj = crud_obj or obj
1✔
1709
        write_obj.dirty = False
1✔
1710

1711
        target_uris = as1.targets(obj.as1)
1✔
1712
        orig_obj = None
1✔
1713
        targets = {}  # maps Target (with *normalized* uri) to Object or None
1✔
1714
        owner = as1.get_owner(obj.as1)
1✔
1715
        allow_opt_out = (obj.type == 'delete')
1✔
1716
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1717
        inner_obj_id = inner_obj_as1.get('id')
1✔
1718
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1719
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1720
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1721
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1722
        is_self_reply = False
1✔
1723

1724
        original_ids = []
1✔
1725
        if is_reply:
1✔
1726
            original_ids = in_reply_tos
1✔
1727
        elif inner_obj_id:
1✔
1728
            if inner_obj_id == from_user.key.id():
1✔
1729
                inner_obj_id = from_user.profile_id()
1✔
1730
            original_ids = [inner_obj_id]
1✔
1731

1732
        # maps id to Object
1733
        original_objs = {}
1✔
1734
        for id in original_ids:
1✔
1735
            if proto := Protocol.for_id(id):
1✔
1736
                original_objs[id] = proto.load(id, raise_=False)
1✔
1737

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

1752
        target_uris = sorted(set(target_uris))
1✔
1753
        logger.info(f'Raw targets: {target_uris}')
1✔
1754

1755
        # which protocols should we allow delivering to?
1756
        to_protocols = []  # elements are Protocol subclasses
1✔
1757
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1758
                      + from_user.enabled_protocols):
1759
            if not (proto := PROTOCOLS.get(label)):
1✔
1760
                report_error(f'unknown enabled protocol {label} for {from_user.key.id()}')
1✔
1761
                continue
1✔
1762

1763
            if (obj.type == 'post' and (orig := original_objs.get(inner_obj_id))
1✔
1764
                    and orig.get_copy(proto)):
1765
                logger.info(f'Already created {id} on {label}, cowardly refusing to create there again')
1✔
1766
                continue
1✔
1767

1768
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1769
                                     or is_reply):
1770
                origs_could_bridge = None
1✔
1771

1772
                for id in original_ids:
1✔
1773
                    if not (orig := original_objs.get(id)):
1✔
1774
                        continue
1✔
1775
                    elif orig.get_copy(proto):
1✔
1776
                        logger.info(f'Allowing {label}, original {id} was bridged there')
1✔
1777
                        break
1✔
1778
                    elif from_user.is_profile(orig):
1✔
1779
                        logger.info(f"Allowing {label}, this is the user's profile")
1✔
1780
                        break
1✔
1781

1782
                    if (origs_could_bridge is not False
1✔
1783
                            and (orig_author_id := as1.get_owner(orig.as1))
1784
                            and (orig_proto := orig.owner_protocol())
1785
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1786
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1787

1788
                else:
1789
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1790
                    last_retry = False
1✔
1791
                    if retries := request.headers.get(TASK_RETRIES_HEADER):
1✔
1792
                        if (last_retry := int(retries) >= TASK_RETRIES_RECEIVE):
1✔
1793
                            logger.info(f'last retry! skipping {proto.LABEL} and continuing')
1✔
1794

1795
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
1✔
1796
                            and origs_could_bridge and not last_retry):
1797
                        # retry later; original obj may still be bridging
1798
                        # TODO: limit to brief window, eg no older than 2h? 1d?
1799
                        error(msg, status=304)
1✔
1800

1801
                    logger.info(msg)
1✔
1802
                    continue
1✔
1803

1804
            util.add(to_protocols, proto)
1✔
1805

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

1808
        # process direct targets
1809
        for target_id in target_uris:
1✔
1810
            target_proto = Protocol.for_id(target_id)
1✔
1811
            if not target_proto:
1✔
1812
                logger.info(f"Can't determine protocol for {target_id}")
1✔
1813
                continue
1✔
1814
            elif target_proto.is_blocklisted(target_id):
1✔
1815
                logger.debug(f'{target_id} is blocklisted')
1✔
1816
                continue
1✔
1817

1818
            target_obj_id = target_id
1✔
1819
            if target_id in mentioned_urls or obj.type in as1.VERBS_WITH_ACTOR_OBJECT:
1✔
1820
                # not ideal. this can sometimes be a non-user, eg blocking a
1821
                # blocklist. ok right now since profile_id() returns its input id
1822
                # unchanged if it doesn't look like a user id, but that's brittle.
1823
                target_obj_id = ids.profile_id(id=target_id, proto=target_proto)
1✔
1824

1825
            orig_obj = target_proto.load(target_obj_id, raise_=False)
1✔
1826
            if not orig_obj or not orig_obj.as1:
1✔
1827
                logger.info(f"Couldn't load {target_obj_id}")
1✔
1828
                continue
1✔
1829

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

1849
                continue
1✔
1850

1851
            # deliver self-replies to followers
1852
            # https://github.com/snarfed/bridgy-fed/issues/639
1853
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1854
                is_self_reply = True
1✔
1855
                logger.info(f'self reply!')
1✔
1856

1857
            # also add copies' targets
1858
            for copy in orig_obj.copies:
1✔
1859
                proto = PROTOCOLS[copy.protocol]
1✔
1860
                if proto in to_protocols:
1✔
1861
                    # copies generally won't have their own Objects
1862
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1863
                        target = util.normalize_url(target, trailing_slash=False)
1✔
1864
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {target_id}')
1✔
1865
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1866

1867
            if target_proto == from_cls:
1✔
1868
                logger.debug(f'Skipping same-protocol target {target_id}')
1✔
1869
                continue
1✔
1870

1871
            target = target_proto.target_for(orig_obj)
1✔
1872
            if not target:
1✔
1873
                # TODO: surface errors like this somehow?
1874
                logger.error(f"Can't find delivery target for {target_id}")
×
1875
                continue
×
1876

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

1889
            if target_author_key:
1✔
1890
                logger.debug(f'Recipient is {target_author_key}')
1✔
1891
                if write_obj.add('notify', target_author_key):
1✔
1892
                    write_obj.dirty = True
1✔
1893

1894
        if obj.type == 'undo':
1✔
1895
            logger.info('Object is an undo; adding targets for inner object')
1✔
1896
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1897
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1898
            else:
1899
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1900
            if inner_obj:
1✔
1901
                for target, target_obj in from_cls.targets(
1✔
1902
                        inner_obj, from_user=from_user, internal=True).items():
1903
                    targets[target] = target_obj
1✔
1904
                    util.add(to_protocols, PROTOCOLS[target.protocol])
1✔
1905

1906
        if not to_protocols:
1✔
1907
            return {}
1✔
1908

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

1911
        # deliver to followers, if appropriate
1912
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1913
        if not user_key:
1✔
1914
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1915
            return targets
1✔
1916

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

1936
            logger.info(f'  loaded {len(followers)} followers')
1✔
1937

1938
            user_keys = [f.from_ for f in followers]
1✔
1939
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1940
            logger.info(f'  loaded {len(users)} users')
1✔
1941

1942
            User.load_multi(users)
1✔
1943
            logger.info(f'  loaded user objects')
1✔
1944

1945
            if (not followers and
1✔
1946
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
1947
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
1948
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1949
                return {}
1✔
1950

1951
            # add to followers' feeds, if any
1952
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1953
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1954
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
1✔
1955
                    if write_obj.feed:
1✔
1956
                        write_obj.dirty = True
1✔
1957

1958
            # collect targets for followers
1959
            target_obj = (original_objs.get(inner_obj_id)
1✔
1960
                          if obj.type == 'share' else None)
1961
            for user in users:
1✔
1962
                if user.is_blocking(from_user):
1✔
1963
                    logger.debug(f'  {user.key.id()} blocks {from_user.key.id()}')
1✔
1964
                    continue
1✔
1965

1966
                # TODO: should we pass remote=False through here to Protocol.load?
1967
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1968
                if not target:
1✔
1969
                    continue
1✔
1970

1971
                target = util.normalize_url(target, trailing_slash=False)
1✔
1972
                targets[Target(protocol=user.LABEL, uri=target)] = target_obj
1✔
1973

1974
            logger.info(f'  collected {len(targets)} targets')
1✔
1975

1976
        # deliver to enabled HAS_COPIES protocols proactively
1977
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
1978
            for proto in to_protocols:
1✔
1979
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
1980
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
1981
                    targets.setdefault(
1✔
1982
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1983

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

2005
            target, obj = candidates[url]
1✔
2006
            targets[target] = obj
1✔
2007

2008
        return targets
1✔
2009

2010
    @classmethod
1✔
2011
    def load(cls, id, remote=None, local=True, raise_=True, raw=False, csv=False,
1✔
2012
             **kwargs):
2013
        """Loads and returns an Object from datastore or HTTP fetch.
2014

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

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

2037
        Returns:
2038
          models.Object: loaded object, or None if it isn't fetchable, eg a
2039
          non-URL string for Web, or ``remote`` is False and it isn't in the
2040
          datastore
2041

2042
        Raises:
2043
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
2044
            is True
2045
        """
2046
        assert id
1✔
2047
        assert local or remote is not False
1✔
2048
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
2049

2050
        if not raw:
1✔
2051
            id = ids.normalize_object_id(id=id, proto=cls)
1✔
2052

2053
        obj = orig_as1 = None
1✔
2054
        if local:
1✔
2055
            if obj := Object.get_by_id(id):
1✔
2056
                if csv and not obj.is_csv:
1✔
2057
                    return None
1✔
2058
                elif obj.as1 or obj.csv or obj.raw or obj.deleted:
1✔
2059
                    # logger.debug(f'  {id} got from datastore')
2060
                    obj.new = False
1✔
2061

2062
        if remote is False:
1✔
2063
            return obj
1✔
2064
        elif remote is None and obj:
1✔
2065
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
2066
                # logger.debug(f'  last updated {obj.updated}, refreshing')
2067
                pass
1✔
2068
            else:
2069
                return obj
1✔
2070

2071
        if obj:
1✔
2072
            orig_as1 = obj.as1
1✔
2073
            obj.our_as1 = None
1✔
2074
            obj.new = False
1✔
2075
        else:
2076
            if cls == Protocol:
1✔
2077
                return None
1✔
2078
            obj = Object(id=id)
1✔
2079
            if local:
1✔
2080
                # logger.debug(f'  {id} not in datastore')
2081
                obj.new = True
1✔
2082
                obj.changed = False
1✔
2083

2084
        try:
1✔
2085
            fetched = cls.fetch(obj, csv=csv, **kwargs)
1✔
2086
        except (RequestException, HTTPException, InvalidStatus) as e:
1✔
2087
            if raise_:
1✔
2088
                raise
1✔
2089
            util.interpret_http_exception(e)
1✔
2090
            return None
1✔
2091

2092
        if not fetched:
1✔
2093
            return None
1✔
2094
        elif csv and not obj.is_csv:
1✔
2095
            return None
×
2096

2097
        # https://stackoverflow.com/a/3042250/186123
2098
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
2099
        if size > MAX_ENTITY_SIZE:
1✔
2100
            logger.warning(f'Object is too big! {size} bytes is over {MAX_ENTITY_SIZE}')
1✔
2101
            return None
1✔
2102

2103
        obj.resolve_ids()
1✔
2104
        obj.normalize_ids()
1✔
2105

2106
        if obj.new is False:
1✔
2107
            obj.changed = obj.activity_changed(orig_as1)
1✔
2108

2109
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
2110
            if obj.source_protocol:
1✔
2111
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
2112
            obj.source_protocol = cls.LABEL
1✔
2113

2114
        obj.put()
1✔
2115
        return obj
1✔
2116

2117
    @classmethod
1✔
2118
    def check_supported(cls, obj, direction):
1✔
2119
        """If this protocol doesn't support this activity, raises HTTP 204.
2120

2121
        Also reports an error.
2122

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

2127
        Args:
2128
          obj (Object)
2129
          direction (str): ``'receive'`` or  ``'send'``
2130

2131
        Raises:
2132
          werkzeug.HTTPException: if this protocol doesn't support this object
2133
        """
2134
        assert direction in ('receive', 'send')
1✔
2135
        if not obj.type:
1✔
2136
            return
×
2137

2138
        inner = as1.get_object(obj.as1)
1✔
2139
        inner_type = as1.object_type(inner) or ''
1✔
2140
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
2141
            or (obj.type in as1.CRUD_VERBS
2142
                and inner_type
2143
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
2144
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
2145

2146
        # don't allow posts with blank content and no image/video/audio
2147
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
2148
                    else obj.as1)
2149
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
2150
                and not util.get_url(crud_obj, key='image')
2151
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
2152
                # TODO: handle articles with displayName but not content
2153
                and not source.html_to_text(crud_obj.get('content')).strip()):
2154
            error('Blank content and no image or video or audio', status=204)
1✔
2155

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

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

2178
            # check that this activity is public. only do this for some activities,
2179
            # not eg likes or follows, since Mastodon doesn't currently mark those
2180
            # as explicitly public.
2181
            elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
2182
                  and not util.domain_or_parent_in(crud_obj.get('id'), NON_PUBLIC_DOMAINS)
2183
                  and not as1.is_public(obj.as1, unlisted=False)):
2184
                error('Bridgy Fed only supports public activities', status=204)
1✔
2185

2186
    @classmethod
1✔
2187
    def block(cls, from_user, arg):
1✔
2188
        """Blocks a user or list.
2189

2190
        Args:
2191
          from_user (models.User): user doing the blocking
2192
          arg (str): handle or id of user/list to block
2193

2194
        Returns:
2195
          models.User or models.Object: user or list that was blocked
2196

2197
        Raises:
2198
          ValueError: if arg doesn't look like a user or list on this protocol
2199
        """
2200
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2201

2202
        def fail(msg):
1✔
2203
            logger.warning(msg)
1✔
2204
            raise ValueError(msg)
1✔
2205

2206
        blockee = None
1✔
2207
        try:
1✔
2208
            # first, try interpreting as a user handle or id
2209
            blockee = load_user(arg, proto=cls, create=True, allow_opt_out=True)
1✔
2210
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2211
            logger.info(err)
1✔
2212

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

2216
        # may not be a user, see if it's a list
2217
        if not blockee:
1✔
2218
            if not cls or cls == Protocol:
1✔
2219
                cls = Protocol.for_id(arg)
1✔
2220

2221
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2222
                if blockee.source_protocol == from_user.LABEL:
1✔
2223
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2224
            else:
2225
                if blocklist := from_user.add_domain_blocklist(arg):
1✔
2226
                    return blocklist
1✔
2227
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2228

2229
        logger.info(f'  blocking {blockee.key.id()}')
1✔
2230
        id = f'{from_user.key.id()}#bridgy-fed-block-{util.now().isoformat()}'
1✔
2231
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2232
            'objectType': 'activity',
2233
            'verb': 'block',
2234
            'id': id,
2235
            'actor': from_user.key.id(),
2236
            'object': blockee.key.id(),
2237
        })
2238
        obj.put()
1✔
2239
        from_user.deliver(obj, from_user=from_user)
1✔
2240

2241
        return blockee
1✔
2242

2243
    @classmethod
1✔
2244
    def unblock(cls, from_user, arg):
1✔
2245
        """Unblocks a user or list.
2246

2247
        Args:
2248
          from_user (models.User): user doing the unblocking
2249
          arg (str): handle or id of user/list to unblock
2250

2251
        Returns:
2252
          models.User or models.Object: user or list that was unblocked
2253

2254
        Raises:
2255
          ValueError: if arg doesn't look like a user or list on this protocol
2256
        """
2257
        logger.info(f'user {from_user.key.id()} trying to unblock {arg}')
1✔
2258
        def fail(msg):
1✔
2259
            logger.warning(msg)
1✔
2260
            raise ValueError(msg)
1✔
2261

2262
        blockee = None
1✔
2263
        try:
1✔
2264
            # first, try interpreting as a user handle or id
2265
            blockee = load_user(arg, cls, create=True, allow_opt_out=True)
1✔
2266
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2267
            logger.info(err)
1✔
2268

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

2272
        # may not be a user, see if it's a list
2273
        if not blockee:
1✔
2274
            if not cls or cls == Protocol:
1✔
2275
                cls = Protocol.for_id(arg)
1✔
2276

2277
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2278
                if blockee.source_protocol == from_user.LABEL:
1✔
2279
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2280
            else:
2281
                if blocklist := from_user.remove_domain_blocklist(arg):
1✔
2282
                    return blocklist
1✔
2283
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2284

2285
        logger.info(f'  unblocking {blockee.key.id()}')
1✔
2286
        id = f'{from_user.key.id()}#bridgy-fed-unblock-{util.now().isoformat()}'
1✔
2287
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2288
            'objectType': 'activity',
2289
            'verb': 'undo',
2290
            'id': id,
2291
            'actor': from_user.key.id(),
2292
            'object': {
2293
                'objectType': 'activity',
2294
                'verb': 'block',
2295
                'actor': from_user.key.id(),
2296
                'object': blockee.key.id(),
2297
            },
2298
        })
2299
        obj.put()
1✔
2300
        from_user.deliver(obj, from_user=from_user)
1✔
2301

2302
        return blockee
1✔
2303

2304

2305
@cloud_tasks_only(log=None)
1✔
2306
def receive_task():
1✔
2307
    """Task handler for a newly received :class:`models.Object`.
2308

2309
    Calls :meth:`Protocol.receive` with the form parameters.
2310

2311
    Parameters:
2312
      authed_as (str): passed to :meth:`Protocol.receive`
2313
      obj_id (str): key id of :class:`models.Object` to handle
2314
      received_at (str, ISO 8601 timestamp): when we first saw (received)
2315
        this activity
2316
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2317
        :class:`models.Object` to handle
2318

2319
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
2320
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
2321
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
2322
    ``changed``, HTTP request details, etc. See stash for attempt at this for
2323
    :class:`web.Web`.
2324
    """
2325
    common.log_request()
1✔
2326
    form = request.form.to_dict()
1✔
2327

2328
    authed_as = form.pop('authed_as', None)
1✔
2329
    internal = authed_as == PRIMARY_DOMAIN or authed_as in PROTOCOL_DOMAINS
1✔
2330

2331
    obj = Object.from_request()
1✔
2332
    assert obj
1✔
2333
    assert obj.source_protocol
1✔
2334
    obj.new = True
1✔
2335

2336
    if received_at := form.pop('received_at', None):
1✔
2337
        received_at = datetime.fromisoformat(received_at)
1✔
2338

2339
    try:
1✔
2340
        return PROTOCOLS[obj.source_protocol].receive(
1✔
2341
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
2342
    except RequestException as e:
1✔
2343
        util.interpret_http_exception(e)
1✔
2344
        error(e, status=304)
1✔
2345
    except ValueError as e:
1✔
2346
        logger.warning(e, exc_info=True)
×
2347
        error(e, status=304)
×
2348

2349

2350
@cloud_tasks_only(log=None)
1✔
2351
def send_task():
1✔
2352
    """Task handler for sending an activity to a single specific destination.
2353

2354
    Calls :meth:`Protocol.send` with the form parameters.
2355

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

2370
    # prepare
2371
    form = request.form.to_dict()
1✔
2372
    url = form.get('url')
1✔
2373
    protocol = form.get('protocol')
1✔
2374
    if not url or not protocol:
1✔
2375
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
2376
        return '', 204
1✔
2377

2378
    target = Target(uri=url, protocol=protocol)
1✔
2379
    obj = Object.from_request()
1✔
2380
    assert obj and obj.key and obj.key.id()
1✔
2381

2382
    PROTOCOLS[protocol].check_supported(obj, 'send')
1✔
2383
    allow_opt_out = (obj.type == 'delete')
1✔
2384

2385
    user = None
1✔
2386
    if user_key := form.get('user'):
1✔
2387
        key = ndb.Key(urlsafe=user_key)
1✔
2388
        # use get_by_id so that we follow use_instead
2389
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2390
            key.id(), allow_opt_out=allow_opt_out)
2391

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

2416
    if sent is False:
1✔
2417
        logger.info(f'Failed sending!')
1✔
2418

2419
    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