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

snarfed / bridgy-fed / dc632e52-cd69-471a-b9dc-aaed3e630db6

21 Jan 2026 01:30AM UTC coverage: 93.278% (+0.02%) from 93.26%
dc632e52-cd69-471a-b9dc-aaed3e630db6

push

circleci

snarfed
noop: add integration test for ATProto unbridged repost of AP post

for #2042

6688 of 7170 relevant lines covered (93.28%)

0.93 hits per line

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

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

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

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

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

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

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

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

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

94

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

99

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

103

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

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

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

157
        ...based on the request's hostname.
158

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

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

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

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

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

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

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

197
        To be implemented by subclasses.
198

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

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

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

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

214
        Args:
215
          id (str): user id or object id
216

217
        Returns:
218
          bool or None:
219
        """
220
        return False
1✔
221

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

226
        To be implemented by subclasses.
227

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

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

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

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

246
        Returns:
247
          bool or None
248
        """
249
        return False
1✔
250

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

255
        To be implemented by subclasses.
256

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

260
        Args:
261
          handle (str)
262

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

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

272

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

276
        Returns:
277
          str: authenticated user id, or None if there is no authentication
278

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

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

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

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

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

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

312
        return cls(id=id).key
1✔
313

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

318
        Args:
319
          id (str)
320

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

449
        Args:
450
          handle (str)
451

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

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

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

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

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

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

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

501
        Example: ``@user@instance.com``
502

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

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

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

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

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

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

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

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

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

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

552
        For example, ``'bsky.brid.gy'`` for ATProto.
553

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

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

563
        Should add the copy user to :attr:`copies`.
564

565
        If the copy user already exists and active, should do nothing.
566

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

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

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

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

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

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

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

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

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

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

614
        To be implemented by subclasses.
615

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

695
        Uses HTML for protocols that support it, plain text otherwise.
696

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

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

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

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

717
        actor_id = actor.get('id')
1✔
718

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

723
        from web import Web
1✔
724
        bot_user = Web.get_by_id(from_user.bot_user_id())
1✔
725

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

733
            separator = '<br><br>'
1✔
734

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

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

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

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

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

770
        Args:
771
          user (models.User)
772
          username (str)
773

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

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

784
        Args:
785
          user (models.User)
786
          to_user_id (str)
787

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

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

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

801
        If the user is ready to migrate, returns ``None``.
802

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

806
        Args:
807
          user (models.User)
808
          to_user_id (str)
809

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

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

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

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

831
        Reloads the user's profile before calling :meth:`_migrate_in`.
832

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

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

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

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

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

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

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

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

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

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

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

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

898
        To be implemented by subclasses.
899

900
        Examples:
901

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

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

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

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

923
        Default implementation here, subclasses may override.
924

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

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

941
        Infers source protocol for each id value separately.
942

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

947
        Wraps these AS1 fields:
948

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

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

969
        TODO: unify with :meth:`Object.resolve_ids`,
970
        :meth:`models.Object.normalize_ids`.
971

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

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

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

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

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

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

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

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

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

1041
        outer_obj = util.trim_nulls(outer_obj)
1✔
1042

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

1048
        return outer_obj
1✔
1049

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

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

1058
        Called by :meth:`Protocol.translate_ids`.
1059

1060
        If ``obj.content`` is HTML, does nothing.
1061

1062
        Args:
1063
          obj (dict): AS2 object
1064

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

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

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

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

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

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

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

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

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

1127
        Returns:
1128
          (str, int) tuple: (response body, HTTP status code) Flask response
1129

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

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

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

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

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

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

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

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

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

1183
        # check authorization
1184
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1185
        actor = as1.get_owner(obj.as1)
1✔
1186
        if not actor:
1✔
1187
            error('Activity missing actor or author')
1✔
1188

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

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

1204
        # update copy ids to originals
1205
        obj.normalize_ids()
1✔
1206
        obj.resolve_ids()
1✔
1207

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

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

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

1239
        # if this is an object, ie not an activity, wrap it in a create or update
1240
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as,
1✔
1241
                                          from_user=from_user)
1242
        obj.add('users', from_user.key)
1✔
1243

1244
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1245
        inner_obj_id = inner_obj_as1.get('id')
1✔
1246
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1247
            if not inner_obj_id:
1✔
1248
                error(f'{obj.type} object has no id!')
1✔
1249

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

1265
        # write Object to datastore
1266
        if obj.type in STORE_AS1_TYPES:
1✔
1267
            obj.put()
1✔
1268

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

1279
        actor = as1.get_object(obj.as1, 'actor')
1✔
1280
        actor_id = actor.get('id')
1✔
1281

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

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

1304
            # fall through to deliver to followee
1305
            # TODO: do we convert stop-following to webmention 410 of original
1306
            # follow?
1307

1308
            # fall through to deliver to followers
1309

1310
        elif obj.type in ('delete', 'undo'):
1✔
1311
            delete_obj_id = (from_user.profile_id()
1✔
1312
                            if inner_obj_id == from_user.key.id()
1313
                            else inner_obj_id)
1314

1315
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1316
            if not delete_obj:
1✔
1317
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1318
                return 'OK', 204
1✔
1319

1320
            # TODO: just delete altogether!
1321
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1322
            delete_obj.deleted = True
1✔
1323
            delete_obj.put()
1✔
1324

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

1333
        elif obj.type == 'block':
1✔
1334
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1335
                # blocking protocol bot user disables that protocol
1336
                from_user.delete(proto)
1✔
1337
                from_user.disable_protocol(proto)
1✔
1338
                return 'OK', 200
1✔
1339

1340
        elif obj.type == 'post':
1✔
1341
            # handle DMs to bot users
1342
            if as1.is_dm(obj.as1):
1✔
1343
                return dms.receive(from_user=from_user, obj=obj)
1✔
1344

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

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

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

1399
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1400

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

1409
        # deliver to targets
1410
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1411

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

1421
            logger.info(f'Deactivating Followers from or to {from_user.key.id()}')
1✔
1422
            followers = Follower.query(
1✔
1423
                OR(Follower.to == from_user.key, Follower.from_ == from_user.key)
1424
            ).fetch()
1425
            for f in followers:
1✔
1426
                f.status = 'inactive'
1✔
1427
            ndb.put_multi(followers)
1✔
1428

1429
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1430
        return resp
1✔
1431

1432
    @classmethod
1✔
1433
    def handle_follow(from_cls, obj, from_user):
1✔
1434
        """Handles an incoming follow activity.
1435

1436
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1437
        happens in :meth:`deliver`.
1438

1439
        Args:
1440
          obj (models.Object): follow activity
1441
        """
1442
        logger.debug('Got follow. storing Follow(s), sending accept(s)')
1✔
1443
        from_id = from_user.key.id()
1✔
1444

1445
        # Prepare followee (to) users' data
1446
        to_as1s = as1.get_objects(obj.as1)
1✔
1447
        if not to_as1s:
1✔
1448
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1449

1450
        # Store Followers
1451
        for to_as1 in to_as1s:
1✔
1452
            to_id = to_as1.get('id')
1✔
1453
            if not to_id:
1✔
1454
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1455

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

1458
            to_cls = Protocol.for_id(to_id)
1✔
1459
            if not to_cls:
1✔
1460
                error(f"Couldn't determine protocol for {to_id}")
×
1461
            elif from_cls == to_cls:
1✔
1462
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1463
                continue
1✔
1464

1465
            to_key = to_cls.key_for(to_id)
1✔
1466
            if not to_key:
1✔
1467
                logger.info(f'Skipping invalid {to_cls.LABEL} user key: {to_id}')
×
1468
                continue
×
1469

1470
            to_user = to_cls.get_or_create(id=to_key.id())
1✔
1471
            if not to_user or not to_user.is_enabled(from_cls):
1✔
1472
                error(f'{to_id} not found')
1✔
1473

1474
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1475
                                                  follow=obj.key, status='active')
1476
            obj.add('notify', to_key)
1✔
1477
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1478
                                       followee=to_user, follow=obj)
1479

1480
    @classmethod
1✔
1481
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1482
        """Sends an accept or reject activity for a follow.
1483

1484
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1485
        nothing.
1486

1487
        Args:
1488
          verb (str): ``accept`` or  ``reject``
1489
          follower (models.User)
1490
          followee (models.User)
1491
          follow (models.Object)
1492
        """
1493
        assert verb in ('accept', 'reject')
1✔
1494
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1495
            return
1✔
1496

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

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

1513
    @classmethod
1✔
1514
    def bot_maybe_follow_back(bot_cls, user):
1✔
1515
        """Follow a user from a protocol bot user, if their protocol needs that.
1516

1517
        ...so that the protocol starts sending us their activities, if it needs
1518
        a follow for that (eg ActivityPub).
1519

1520
        Args:
1521
          user (User)
1522
        """
1523
        if not user.BOTS_FOLLOW_BACK:
1✔
1524
            return
1✔
1525

1526
        from web import Web
1✔
1527
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1528
        now = util.now().isoformat()
1✔
1529
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1530

1531
        if not user.obj:
1✔
1532
            logger.info("  can't follow, user has no profile obj")
1✔
1533
            return
1✔
1534

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

1549
    @classmethod
1✔
1550
    def handle_bare_object(cls, obj, *, authed_as, from_user):
1✔
1551
        """If obj is a bare object, wraps it in a create or update activity.
1552

1553
        Checks if we've seen it before.
1554

1555
        Args:
1556
          obj (models.Object)
1557
          authed_as (str): authenticated actor id who sent this activity
1558
          from_user (models.User): user (actor) this activity/object is from
1559

1560
        Returns:
1561
          models.Object: ``obj`` if it's an activity, otherwise a new object
1562
        """
1563
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1564
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1565
            return obj
1✔
1566

1567
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1568
        now = util.now().isoformat()
1✔
1569

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

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

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

1613
    @classmethod
1✔
1614
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1615
        """Delivers an activity to its external recipients.
1616

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

1626
        Returns:
1627
          (str, int) tuple: Flask response
1628
        """
1629
        if to_proto:
1✔
1630
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1631

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

1647
        # store object that targets() updated
1648
        if crud_obj and crud_obj.dirty:
1✔
1649
            crud_obj.put()
1✔
1650
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1651
            obj.put()
1✔
1652

1653
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1654
                      else obj.to_request())
1655

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

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

1667
        return 'OK', 202
1✔
1668

1669
    @classmethod
1✔
1670
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1671
        """Collects the targets to send a :class:`models.Object` to.
1672

1673
        Targets are both objects - original posts, events, etc - and actors.
1674

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

1683
        Returns:
1684
          dict: maps :class:`models.Target` to original (in response to)
1685
          :class:`models.Object`
1686
        """
1687
        logger.debug('Finding recipients and their targets')
1✔
1688

1689
        # we should only have crud_obj iff this is a create or update
1690
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1691
        write_obj = crud_obj or obj
1✔
1692
        write_obj.dirty = False
1✔
1693

1694
        target_uris = as1.targets(obj.as1)
1✔
1695
        orig_obj = None
1✔
1696
        targets = {}  # maps Target (with *normalized* uri) to Object or None
1✔
1697
        owner = as1.get_owner(obj.as1)
1✔
1698
        allow_opt_out = (obj.type == 'delete')
1✔
1699
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1700
        inner_obj_id = inner_obj_as1.get('id')
1✔
1701
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1702
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1703
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1704
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1705
        is_self_reply = False
1✔
1706

1707
        original_ids = []
1✔
1708
        if is_reply:
1✔
1709
            original_ids = in_reply_tos
1✔
1710
        elif inner_obj_id:
1✔
1711
            if inner_obj_id == from_user.key.id():
1✔
1712
                inner_obj_id = from_user.profile_id()
1✔
1713
            original_ids = [inner_obj_id]
1✔
1714

1715
        # maps id to Object
1716
        original_objs = {}
1✔
1717
        for id in original_ids:
1✔
1718
            if proto := Protocol.for_id(id):
1✔
1719
                original_objs[id] = proto.load(id, raise_=False)
1✔
1720

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

1735
        target_uris = sorted(set(target_uris))
1✔
1736
        logger.info(f'Raw targets: {target_uris}')
1✔
1737

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

1746
            if (obj.type == 'post' and (orig := original_objs.get(inner_obj_id))
1✔
1747
                    and orig.get_copy(proto)):
1748
                logger.info(f'Already created {id} on {label}, cowardly refusing to create there again')
1✔
1749
                continue
1✔
1750

1751
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1752
                                     or is_reply):
1753
                origs_could_bridge = None
1✔
1754

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

1765
                    if (origs_could_bridge is not False
1✔
1766
                            and (orig_author_id := as1.get_owner(orig.as1))
1767
                            and (orig_proto := orig.owner_protocol())
1768
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1769
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1770

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

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

1784
                    logger.info(msg)
1✔
1785
                    continue
1✔
1786

1787
            util.add(to_protocols, proto)
1✔
1788

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

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

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

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

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

1832
                continue
1✔
1833

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

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

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

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

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

1872
            if target_author_key:
1✔
1873
                logger.debug(f'Recipient is {target_author_key}')
1✔
1874
                if write_obj.add('notify', target_author_key):
1✔
1875
                    write_obj.dirty = True
1✔
1876

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

1889
        if not to_protocols:
1✔
1890
            return {}
1✔
1891

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

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

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

1919
            logger.info(f'  loaded {len(followers)} followers')
1✔
1920

1921
            user_keys = [f.from_ for f in followers]
1✔
1922
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1923
            logger.info(f'  loaded {len(users)} users')
1✔
1924

1925
            User.load_multi(users)
1✔
1926
            logger.info(f'  loaded user objects')
1✔
1927

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

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

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

1949
                # TODO: should we pass remote=False through here to Protocol.load?
1950
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1951
                if not target:
1✔
1952
                    continue
1✔
1953

1954
                target = util.normalize_url(target, trailing_slash=False)
1✔
1955
                targets[Target(protocol=user.LABEL, uri=target)] = target_obj
1✔
1956

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

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

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

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

1991
        return targets
1✔
1992

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2104
        Also reports an error.
2105

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2224
        return blockee
1✔
2225

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

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

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

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

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

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

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

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

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

2285
        return blockee
1✔
2286

2287

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

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

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

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

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

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

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

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

2332

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

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

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

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

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

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

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

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

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

2402
    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