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

snarfed / bridgy-fed / 31ee72eb-7bb5-4de0-aab2-47188e526a51

05 Jan 2026 09:33PM UTC coverage: 93.292% (+0.02%) from 93.276%
31ee72eb-7bb5-4de0-aab2-47188e526a51

push

circleci

snarfed
test_pages: test invalid input to POST /user-page

for https://console.cloud.google.com/errors/detail/CJyV59KDmvuWaQ;locations=global;time=P30D?project=bridgy-federated , snarfed/granary@70fa72848

6509 of 6977 relevant lines covered (93.29%)

0.93 hits per line

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

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

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

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

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

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

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

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

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

94

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

99

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

103

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

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

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

155
        ...based on the request's hostname.
156

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

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

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

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

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

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

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

195
        To be implemented by subclasses.
196

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

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

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

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

211
        Args:
212
          id (str)
213

214
        Returns:
215
          bool or None:
216
        """
217
        return False
1✔
218

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

223
        To be implemented by subclasses.
224

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

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

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

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

243
        Returns:
244
          bool or None
245
        """
246
        return False
1✔
247

248
    @classmethod
1✔
249
    def handle_to_id(cls, handle):
1✔
250
        """Converts a handle to an id.
251

252
        To be implemented by subclasses.
253

254
        May incur network requests, eg DNS queries or HTTP requests. Avoids
255
        blocked or opted out users.
256

257
        Args:
258
          handle (str)
259

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

265
    @classmethod
1✔
266
    def authed_user_for_request(cls):
1✔
267
        """Returns the authenticated user id for the current request.
268

269

270
        Checks authentication on the current request, eg HTTP Signature for
271
        ActivityPub. To be implemented by subclasses.
272

273
        Returns:
274
          str: authenticated user id, or None if there is no authentication
275

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

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

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

290
        Args:
291
          id (str):
292
          allow_opt_out (bool): whether to allow users who are currently opted out
293

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

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

309
        return cls(id=id).key
1✔
310

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

315
        Args:
316
          id (str)
317

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

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

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

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

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

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

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

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

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

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

400
        # step 4: fetch over the network, if necessary
401
        if not remote:
1✔
402
            return None
1✔
403

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

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

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

435
        logger.info(f'No matching protocol found for {id} !')
1✔
436
        return None
1✔
437

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

443
        May incur expensive side effects like resolving the handle itself over
444
        the network or other discovery.
445

446
        Args:
447
          handle (str)
448

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

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

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

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

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

491
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
492
        return (None, None)
1✔
493

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

498
        Example: ``@user@instance.com``
499

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

508
        user, domain = parts
1✔
509
        return bool(user and domain
1✔
510
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
511

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

516
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
517
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
518

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

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

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

534
        Args:
535
          obj (models.Object)
536
          allow_opt_out (bool): whether to return a user key if they're opted out
537

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

545
    @classmethod
1✔
546
    def bot_user_id(cls):
1✔
547
        """Returns the Web user id for the bot user for this protocol.
548

549
        For example, ``'bsky.brid.gy'`` for ATProto.
550

551
        Returns:
552
          str:
553
        """
554
        return f'{cls.ABBREV}{SUPERDOMAIN}'
1✔
555

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

560
        Should add the copy user to :attr:`copies`.
561

562
        If the copy user already exists and active, should do nothing.
563

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

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

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

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

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

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

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

598
        Raises:
599
          werkzeug.HTTPException if the request fails
600
        """
601
        raise NotImplementedError()
×
602

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

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

611
        To be implemented by subclasses.
612

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

618
        Returns:
619
          bool: True if the object was fetched and populated successfully,
620
          False otherwise
621

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

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

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

635
        Just passes through to :meth:`_convert`, then does minor
636
        protocol-independent postprocessing.
637

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

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

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

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

665
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
666
        obj.our_as1 = orig_our_as1
1✔
667
        return converted
1✔
668

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

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

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

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

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

692
        Uses HTML for protocols that support it, plain text otherwise.
693

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

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

707
        orig_summary = actor.setdefault('summary', '')
1✔
708
        summary_text = html_to_text(orig_summary, ignore_links=True)
1✔
709

710
        # Check if we've already added source links
711
        if '🌉 bridged' in summary_text:
1✔
712
            return
1✔
713

714
        actor_id = actor.get('id')
1✔
715

716
        url = (as1.get_url(actor)
1✔
717
               or (from_user.web_url() if from_user.profile_id() == actor_id
718
                   else actor_id))
719

720
        from web import Web
1✔
721
        bot_user = Web.get_by_id(from_user.bot_user_id())
1✔
722

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

730
            separator = '<br><br>'
1✔
731

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

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

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

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

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

767
        Args:
768
          user (models.User)
769
          username (str)
770

771
        Raises:
772
          ValueError: if the username is invalid
773
          RuntimeError: if the username could not be set
774
        """
775
        raise NotImplementedError()
1✔
776

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

781
        Args:
782
          user (models.User)
783
          to_user_id (str)
784

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

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

795
        For example, if ``to_user_id`` isn't on this protocol, or if ``user`` is on
796
        this protocol, or isn't bridged to this protocol.
797

798
        If the user is ready to migrate, returns ``None``.
799

800
        Subclasses may override this to add more criteria, but they should call this
801
        implementation first.
802

803
        Args:
804
          user (models.User)
805
          to_user_id (str)
806

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

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

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

825
        The protocol independent parts are done here; protocol-specific parts are
826
        done in :meth:`_migrate_in`, which this wraps.
827

828
        Reloads the user's profile before calling :meth:`_migrate_in`.
829

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

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

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

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

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

860
        # migrate!
861
        cls._migrate_in(user, from_user_id, **kwargs)
1✔
862
        user.add('enabled_protocols', cls.LABEL)
1✔
863
        user.put()
1✔
864

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

873
            common.create_task(queue='receive', obj_id=user.obj_key.id(),
1✔
874
                               authed_as=user.key.id())
875

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

880
        Called by :meth:`migrate_in`, which does most of the work, including calling
881
        :meth:`reload_profile` before this.
882

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

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

895
        To be implemented by subclasses.
896

897
        Examples:
898

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

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

911
        Returns:
912
          str: target endpoint, or None if not available.
913
        """
914
        raise NotImplementedError()
×
915

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

920
        Default implementation here, subclasses may override.
921

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

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

938
        Infers source protocol for each id value separately.
939

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

944
        Wraps these AS1 fields:
945

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

963
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
964
        same logic is duplicated there!
965

966
        TODO: unify with :meth:`Object.resolve_ids`,
967
        :meth:`models.Object.normalize_ids`.
968

969
        Args:
970
          to_proto (Protocol subclass)
971
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
972

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

980
        outer_obj = to_cls.translate_mention_handles(copy.deepcopy(obj))
1✔
981
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
982

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

997
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
998
                           for o in elem[field]]
999

1000
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
1001
                elem[field] = elem[field][0]
1✔
1002

1003
        type = as1.object_type(outer_obj)
1✔
1004
        translate(outer_obj, 'id',
1✔
1005
                  ids.translate_user_id if type in as1.ACTOR_TYPES
1006
                  else ids.translate_object_id)
1007

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

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

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

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

1043
        return outer_obj
1✔
1044

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1300
            # fall through to deliver to followers
1301

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1545
        Checks if we've seen it before.
1546

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1659
        return 'OK', 202
1✔
1660

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1826
                continue
1✔
1827

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1988
        return targets
1✔
1989

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2101
        Also reports an error.
2102

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2221
        return blockee
1✔
2222

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

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

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

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

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

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

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

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

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

2282
        return blockee
1✔
2283

2284

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

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

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

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

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

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

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

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

2329

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

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

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

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

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

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

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

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

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

2399
    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