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

snarfed / bridgy-fed / bcc0aa0c-8ddf-4dff-9e6b-7eb5d4fee266

29 Dec 2025 12:56AM UTC coverage: 93.118%. Remained the same
bcc0aa0c-8ddf-4dff-9e6b-7eb5d4fee266

push

circleci

snarfed
Protocol.receive: bug fix for detecting last retry

X-AppEngine-TaskExecutionCount doesn't count the current task, and also only counts attempts that actually ran the task and received a response. https://docs.cloud.google.com/tasks/docs/creating-appengine-handlers#reading-headers , #2113

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

33 existing lines in 1 file now uncovered.

6414 of 6888 relevant lines covered (93.12%)

0.93 hits per line

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

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

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

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

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

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

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

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

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

93

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

98

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

102

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

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

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

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

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

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

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

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

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

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

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

194
        To be implemented by subclasses.
195

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

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

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

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

210
        Args:
211
          id (str)
212

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

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

222
        To be implemented by subclasses.
223

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

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

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

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

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

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

251
        To be implemented by subclasses.
252

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

256
        Args:
257
          handle (str)
258

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

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

268

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

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

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

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

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

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

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

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

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

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

314
        Args:
315
          id (str)
316

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

445
        Args:
446
          handle (str)
447

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

610
        To be implemented by subclasses.
611

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

894
        To be implemented by subclasses.
895

896
        Examples:
897

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

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

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

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

919
        Default implementation here, subclasses may override.
920

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

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

937
        Infers source protocol for each id value separately.
938

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

943
        Wraps these AS1 fields:
944

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1042
        return outer_obj
1✔
1043

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1298
            # fall through to deliver to followers
1299

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

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

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

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

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

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

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

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

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

1389
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1390

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1543
        Checks if we've seen it before.
1544

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1657
        return 'OK', 202
1✔
1658

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

1663
        Targets are both objects - original posts, events, etc - and actors.
1664

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1776
                    logger.info(msg)
1✔
1777
                    continue
1✔
1778

1779
            util.add(to_protocols, proto)
1✔
1780

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

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

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

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

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

1824
                continue
1✔
1825

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

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

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

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

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

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

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

1879
        if not to_protocols:
1✔
1880
            return {}
1✔
1881

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

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

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

1909
            user_keys = [f.from_ for f in followers]
1✔
1910
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1911
            User.load_multi(users)
1✔
1912

1913
            if (not followers and
1✔
1914
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
1915
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
1916
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1917
                return {}
1✔
1918

1919
            # add to followers' feeds, if any
1920
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1921
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1922
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
1✔
1923
                    if write_obj.feed:
1✔
1924
                        write_obj.dirty = True
1✔
1925

1926
            # collect targets for followers
1927
            for user in users:
1✔
1928
                if user.is_blocking(from_user.key.id()):
1✔
1929
                    logger.debug(f'  {user.key.id()} blocks {from_user.key.id()}')
1✔
1930
                    continue
1✔
1931

1932
                # TODO: should we pass remote=False through here to Protocol.load?
1933
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1934
                if not target:
1✔
1935
                    # logger.error(f'Follower {user.key} has no delivery target')
1936
                    continue
1✔
1937

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

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

1946
        # deliver to enabled HAS_COPIES protocols proactively
1947
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
1948
            for proto in to_protocols:
1✔
1949
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
1950
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
1951
                    targets.setdefault(
1✔
1952
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1953

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

1976
            target, obj = candidates[url]
1✔
1977
            targets[target] = obj
1✔
1978

1979
        return targets
1✔
1980

1981
    @classmethod
1✔
1982
    def load(cls, id, remote=None, local=True, raise_=True, raw=False, csv=False,
1✔
1983
             **kwargs):
1984
        """Loads and returns an Object from datastore or HTTP fetch.
1985

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

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

2008
        Returns:
2009
          models.Object: loaded object, or None if it isn't fetchable, eg a
2010
          non-URL string for Web, or ``remote`` is False and it isn't in the
2011
          datastore
2012

2013
        Raises:
2014
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
2015
            is True
2016
        """
2017
        assert id
1✔
2018
        assert local or remote is not False
1✔
2019
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
2020

2021
        if not raw:
1✔
2022
            id = ids.normalize_object_id(id=id, proto=cls)
1✔
2023

2024
        obj = orig_as1 = None
1✔
2025
        if local:
1✔
2026
            if obj := Object.get_by_id(id):
1✔
2027
                if csv and not obj.is_csv:
1✔
2028
                    return None
1✔
2029
                elif obj.as1 or obj.csv or obj.raw or obj.deleted:
1✔
2030
                    # logger.debug(f'  {id} got from datastore')
2031
                    obj.new = False
1✔
2032

2033
        if remote is False:
1✔
2034
            return obj
1✔
2035
        elif remote is None and obj:
1✔
2036
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
2037
                # logger.debug(f'  last updated {obj.updated}, refreshing')
2038
                pass
1✔
2039
            else:
2040
                return obj
1✔
2041

2042
        if obj:
1✔
2043
            orig_as1 = obj.as1
1✔
2044
            obj.our_as1 = None
1✔
2045
            obj.new = False
1✔
2046
        else:
2047
            obj = Object(id=id)
1✔
2048
            if local:
1✔
2049
                # logger.debug(f'  {id} not in datastore')
2050
                obj.new = True
1✔
2051
                obj.changed = False
1✔
2052

2053
        try:
1✔
2054
            fetched = cls.fetch(obj, csv=csv, **kwargs)
1✔
2055
        except (RequestException, HTTPException) as e:
1✔
2056
            if raise_:
1✔
2057
                raise
1✔
2058
            util.interpret_http_exception(e)
1✔
2059
            return None
1✔
2060

2061
        if not fetched:
1✔
2062
            return None
1✔
2063
        elif csv and not obj.is_csv:
1✔
UNCOV
2064
            return None
×
2065

2066
        # https://stackoverflow.com/a/3042250/186123
2067
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
2068
        if size > MAX_ENTITY_SIZE:
1✔
2069
            logger.warning(f'Object is too big! {size} bytes is over {MAX_ENTITY_SIZE}')
1✔
2070
            return None
1✔
2071

2072
        obj.resolve_ids()
1✔
2073
        obj.normalize_ids()
1✔
2074

2075
        if obj.new is False:
1✔
2076
            obj.changed = obj.activity_changed(orig_as1)
1✔
2077

2078
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
2079
            if obj.source_protocol:
1✔
UNCOV
2080
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
2081
            obj.source_protocol = cls.LABEL
1✔
2082

2083
        obj.put()
1✔
2084
        return obj
1✔
2085

2086
    @classmethod
1✔
2087
    def check_supported(cls, obj, direction):
1✔
2088
        """If this protocol doesn't support this activity, raises HTTP 204.
2089

2090
        Also reports an error.
2091

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

2096
        Args:
2097
          obj (Object)
2098
          direction (str): ``'receive'`` or  ``'send'``
2099

2100
        Raises:
2101
          werkzeug.HTTPException: if this protocol doesn't support this object
2102
        """
2103
        assert direction in ('receive', 'send')
1✔
2104
        if not obj.type:
1✔
UNCOV
2105
            return
×
2106

2107
        inner = as1.get_object(obj.as1)
1✔
2108
        inner_type = as1.object_type(inner) or ''
1✔
2109
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
2110
            or (obj.type in as1.CRUD_VERBS
2111
                and inner_type
2112
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
2113
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
2114

2115
        # don't allow posts with blank content and no image/video/audio
2116
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
2117
                    else obj.as1)
2118
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
2119
                and not util.get_url(crud_obj, key='image')
2120
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
2121
                # TODO: handle articles with displayName but not content
2122
                and not source.html_to_text(crud_obj.get('content')).strip()):
2123
            error('Blank content and no image or video or audio', status=204)
1✔
2124

2125
        # receiving DMs is only allowed to protocol bot accounts
2126
        if direction == 'receive':
1✔
2127
            if recip := as1.recipient_if_dm(obj.as1):
1✔
2128
                owner = as1.get_owner(obj.as1)
1✔
2129
                if (not cls.SUPPORTS_DMS or (recip not in common.bot_user_ids()
1✔
2130
                                             and owner not in common.bot_user_ids())):
2131
                    # reply and say DMs aren't supported
2132
                    from_proto = PROTOCOLS.get(obj.source_protocol)
1✔
2133
                    to_proto = Protocol.for_id(recip)
1✔
2134
                    if owner and from_proto and to_proto:
1✔
2135
                        if ((from_user := from_proto.get_or_create(id=owner))
1✔
2136
                                and (to_user := to_proto.get_or_create(id=recip))):
2137
                            in_reply_to = (inner.get('id') if obj.type == 'post'
1✔
2138
                                           else obj.as1.get('id'))
2139
                            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✔
2140
                            type = f'dms_not_supported-{to_user.key.id()}'
1✔
2141
                            dms.maybe_send(from_=to_user, to_user=from_user,
1✔
2142
                                           text=text, type=type,
2143
                                           in_reply_to=in_reply_to)
2144

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

2147
            # check that this activity is public. only do this for some activities,
2148
            # not eg likes or follows, since Mastodon doesn't currently mark those
2149
            # as explicitly public.
2150
            elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
2151
                  and not util.domain_or_parent_in(crud_obj.get('id'), NON_PUBLIC_DOMAINS)
2152
                  and not as1.is_public(obj.as1, unlisted=False)):
2153
                error('Bridgy Fed only supports public activities', status=204)
1✔
2154

2155
    @classmethod
1✔
2156
    def block(cls, from_user, arg):
1✔
2157
        """Blocks a user or list.
2158

2159
        Args:
2160
          from_user (models.User): user doing the blocking
2161
          arg (str): handle or id of user/list to block
2162

2163
        Returns:
2164
          models.User or models.Object: user or list that was blocked
2165

2166
        Raises:
2167
          ValueError: if arg doesn't look like a user or list on this protocol
2168
        """
2169
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2170

2171
        def fail(msg):
1✔
2172
            logger.warning(msg)
1✔
2173
            raise ValueError(msg)
1✔
2174

2175
        blockee = None
1✔
2176
        try:
1✔
2177
            # first, try interpreting as a user handle or id
2178
            blockee = load_user(arg, proto=cls, create=True, allow_opt_out=True)
1✔
2179
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2180
            logger.info(err)
1✔
2181

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

2185
        # may not be a user, see if it's a list
2186
        if not blockee:
1✔
2187
            if not cls or cls == Protocol:
1✔
2188
                cls = Protocol.for_id(arg)
1✔
2189

2190
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2191
                if blockee.source_protocol == from_user.LABEL:
1✔
2192
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2193
            else:
2194
                if blocklist := from_user.add_domain_blocklist(arg):
1✔
2195
                    return blocklist
1✔
2196
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2197

2198
        logger.info(f'  blocking {blockee.key.id()}')
1✔
2199
        id = f'{from_user.key.id()}#bridgy-fed-block-{util.now().isoformat()}'
1✔
2200
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2201
            'objectType': 'activity',
2202
            'verb': 'block',
2203
            'id': id,
2204
            'actor': from_user.key.id(),
2205
            'object': blockee.key.id(),
2206
        })
2207
        obj.put()
1✔
2208
        from_user.deliver(obj, from_user=from_user)
1✔
2209

2210
        return blockee
1✔
2211

2212
    @classmethod
1✔
2213
    def unblock(cls, from_user, arg):
1✔
2214
        """Unblocks a user or list.
2215

2216
        Args:
2217
          from_user (models.User): user doing the unblocking
2218
          arg (str): handle or id of user/list to unblock
2219

2220
        Returns:
2221
          models.User or models.Object: user or list that was unblocked
2222

2223
        Raises:
2224
          ValueError: if arg doesn't look like a user or list on this protocol
2225
        """
2226
        logger.info(f'user {from_user.key.id()} trying to unblock {arg}')
1✔
2227
        def fail(msg):
1✔
2228
            logger.warning(msg)
1✔
2229
            raise ValueError(msg)
1✔
2230

2231
        blockee = None
1✔
2232
        try:
1✔
2233
            # first, try interpreting as a user handle or id
2234
            blockee = load_user(arg, cls, create=True, allow_opt_out=True)
1✔
2235
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2236
            logger.info(err)
1✔
2237

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

2241
        # may not be a user, see if it's a list
2242
        if not blockee:
1✔
2243
            if not cls or cls == Protocol:
1✔
2244
                cls = Protocol.for_id(arg)
1✔
2245

2246
            if cls and (blockee := cls.load(arg)) and blockee.type == 'collection':
1✔
2247
                if blockee.source_protocol == from_user.LABEL:
1✔
2248
                    fail(f'{blockee.html_link()} is on {from_user.PHRASE}! Try blocking it there.')
1✔
2249
            else:
2250
                if blocklist := from_user.remove_domain_blocklist(arg):
1✔
2251
                    return blocklist
1✔
2252
                fail(f"{arg} doesn't look like a user or list{' on ' + cls.PHRASE if cls else ''}, or we couldn't fetch it")
1✔
2253

2254
        logger.info(f'  unblocking {blockee.key.id()}')
1✔
2255
        id = f'{from_user.key.id()}#bridgy-fed-unblock-{util.now().isoformat()}'
1✔
2256
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2257
            'objectType': 'activity',
2258
            'verb': 'undo',
2259
            'id': id,
2260
            'actor': from_user.key.id(),
2261
            'object': {
2262
                'objectType': 'activity',
2263
                'verb': 'block',
2264
                'actor': from_user.key.id(),
2265
                'object': blockee.key.id(),
2266
            },
2267
        })
2268
        obj.put()
1✔
2269
        from_user.deliver(obj, from_user=from_user)
1✔
2270

2271
        return blockee
1✔
2272

2273

2274
@cloud_tasks_only(log=None)
1✔
2275
def receive_task():
1✔
2276
    """Task handler for a newly received :class:`models.Object`.
2277

2278
    Calls :meth:`Protocol.receive` with the form parameters.
2279

2280
    Parameters:
2281
      authed_as (str): passed to :meth:`Protocol.receive`
2282
      obj_id (str): key id of :class:`models.Object` to handle
2283
      received_at (str, ISO 8601 timestamp): when we first saw (received)
2284
        this activity
2285
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2286
        :class:`models.Object` to handle
2287

2288
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
2289
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
2290
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
2291
    ``changed``, HTTP request details, etc. See stash for attempt at this for
2292
    :class:`web.Web`.
2293
    """
2294
    common.log_request()
1✔
2295
    form = request.form.to_dict()
1✔
2296

2297
    authed_as = form.pop('authed_as', None)
1✔
2298
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
2299
                or authed_as in common.PROTOCOL_DOMAINS)
2300

2301
    obj = Object.from_request()
1✔
2302
    assert obj
1✔
2303
    assert obj.source_protocol
1✔
2304
    obj.new = True
1✔
2305

2306
    if received_at := form.pop('received_at', None):
1✔
2307
        received_at = datetime.fromisoformat(received_at)
1✔
2308

2309
    try:
1✔
2310
        return PROTOCOLS[obj.source_protocol].receive(
1✔
2311
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
2312
    except RequestException as e:
1✔
2313
        util.interpret_http_exception(e)
1✔
2314
        error(e, status=304)
1✔
2315
    except ValueError as e:
1✔
2316
        logger.warning(e, exc_info=True)
×
UNCOV
2317
        error(e, status=304)
×
2318

2319

2320
@cloud_tasks_only(log=None)
1✔
2321
def send_task():
1✔
2322
    """Task handler for sending an activity to a single specific destination.
2323

2324
    Calls :meth:`Protocol.send` with the form parameters.
2325

2326
    Parameters:
2327
      protocol (str): :class:`Protocol` to send to
2328
      url (str): destination URL to send to
2329
      obj_id (str): key id of :class:`models.Object` to send
2330
      orig_obj_id (str): optional, :class:`models.Object` key id of the
2331
        "original object" that this object refers to, eg replies to or reposts
2332
        or likes
2333
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
2334
        this activity is from
2335
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2336
        :class:`models.Object` to handle
2337
    """
2338
    common.log_request()
1✔
2339

2340
    # prepare
2341
    form = request.form.to_dict()
1✔
2342
    url = form.get('url')
1✔
2343
    protocol = form.get('protocol')
1✔
2344
    if not url or not protocol:
1✔
2345
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
2346
        return '', 204
1✔
2347

2348
    target = Target(uri=url, protocol=protocol)
1✔
2349
    obj = Object.from_request()
1✔
2350
    assert obj and obj.key and obj.key.id()
1✔
2351

2352
    PROTOCOLS[protocol].check_supported(obj, 'send')
1✔
2353
    allow_opt_out = (obj.type == 'delete')
1✔
2354

2355
    user = None
1✔
2356
    if user_key := form.get('user'):
1✔
2357
        key = ndb.Key(urlsafe=user_key)
1✔
2358
        # use get_by_id so that we follow use_instead
2359
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2360
            key.id(), allow_opt_out=allow_opt_out)
2361

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

2386
    if sent is False:
1✔
2387
        logger.info(f'Failed sending!')
1✔
2388

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

© 2026 Coveralls, Inc