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

snarfed / bridgy-fed / 6705e936-98bf-4372-ba52-4c4e15613b66

02 Dec 2025 08:32PM UTC coverage: 93.007% (-0.02%) from 93.027%
6705e936-98bf-4372-ba52-4c4e15613b66

push

circleci

snarfed
/convert: check that the object's owner isn't blocking the authed user, if any

for #2194

9 of 12 new or added lines in 3 files covered. (75.0%)

75 existing lines in 4 files now uncovered.

6291 of 6764 relevant lines covered (93.01%)

0.93 hits per line

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

95.87
/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 requests import RequestException
1✔
24
import werkzeug.exceptions
1✔
25
from werkzeug.exceptions import BadGateway, BadRequest, HTTPException
1✔
26

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

53
OBJECT_REFRESH_AGE = timedelta(days=30)
1✔
54
DELETE_TASK_DELAY = timedelta(minutes=1)
1✔
55
CREATE_MAX_AGE = timedelta(weeks=2)
1✔
56
# WARNING: keep this below the receive queue's min_backoff_seconds in queue.yaml!
57
MEMCACHE_LEASE_EXPIRATION = timedelta(seconds=25)
1✔
58

59
# require a follow for users on these domains before we deliver anything from
60
# them other than their profile
61
LIMITED_DOMAINS = (os.getenv('LIMITED_DOMAINS', '').split()
1✔
62
                   or util.load_file_lines('limited_domains'))
63

64
# domains to allow non-public activities from
65
NON_PUBLIC_DOMAINS = (
1✔
66
    # bridged from twitter (X). bird.makeup, kilogram.makeup, etc federate
67
    # tweets as followers-only, but they're public on twitter itself
68
    '.makeup',
69
)
70

71
DONT_STORE_AS1_TYPES = as1.CRUD_VERBS | set((
1✔
72
    'accept',
73
    'reject',
74
    'stop-following',
75
    'undo',
76
))
77
STORE_AS1_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES | as1.VERBS_WITH_OBJECT
1✔
78
                   - DONT_STORE_AS1_TYPES)
79

80
logger = logging.getLogger(__name__)
1✔
81

82

83
def error(*args, status=299, **kwargs):
1✔
84
    """Default HTTP status code to 299 to prevent retrying task."""
85
    return common.error(*args, status=status, **kwargs)
1✔
86

87

88
def activity_id_memcache_key(id):
1✔
89
    return memcache.key(f'receive-{id}')
1✔
90

91

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

133
    @classmethod
1✔
134
    @property
1✔
135
    def LABEL(cls):
1✔
136
        """str: human-readable lower case name of this protocol, eg ``'activitypub``"""
137
        return cls.__name__.lower()
1✔
138

139
    @staticmethod
1✔
140
    def for_request(fed=None):
1✔
141
        """Returns the protocol for the current request.
142

143
        ...based on the request's hostname.
144

145
        Args:
146
          fed (str or protocol.Protocol): protocol to return if the current
147
            request is on ``fed.brid.gy``
148

149
        Returns:
150
          Protocol: protocol, or None if the provided domain or request hostname
151
          domain is not a subdomain of ``brid.gy`` or isn't a known protocol
152
        """
153
        return Protocol.for_bridgy_subdomain(request.host, fed=fed)
1✔
154

155
    @staticmethod
1✔
156
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
157
        """Returns the protocol for a brid.gy subdomain.
158

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

164
        Returns:
165
          class: :class:`Protocol` subclass, or None if the provided domain or request
166
          hostname domain is not a subdomain of ``brid.gy`` or isn't a known
167
          protocol
168
        """
169
        domain = (util.domain_from_link(domain_or_url, minimize=False)
1✔
170
                  if util.is_web(domain_or_url)
171
                  else domain_or_url)
172

173
        if domain == common.PRIMARY_DOMAIN or domain in common.LOCAL_DOMAINS:
1✔
174
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
1✔
175
        elif domain and domain.endswith(common.SUPERDOMAIN):
1✔
176
            label = domain.removesuffix(common.SUPERDOMAIN)
1✔
177
            return PROTOCOLS.get(label)
1✔
178

179
    @classmethod
1✔
180
    def owns_id(cls, id):
1✔
181
        """Returns whether this protocol owns the id, or None if it's unclear.
182

183
        To be implemented by subclasses.
184

185
        IDs are string identities that uniquely identify users, and are intended
186
        primarily to be machine readable and usable. Compare to handles, which
187
        are human-chosen, human-meaningful, and often but not always unique.
188

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

193
        This should be a quick guess without expensive side effects, eg no
194
        external HTTP fetches to fetch the id itself or otherwise perform
195
        discovery.
196

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

199
        Args:
200
          id (str)
201

202
        Returns:
203
          bool or None:
204
        """
205
        return False
1✔
206

207
    @classmethod
1✔
208
    def owns_handle(cls, handle, allow_internal=False):
1✔
209
        """Returns whether this protocol owns the handle, or None if it's unclear.
210

211
        To be implemented by subclasses.
212

213
        Handles are string identities that are human-chosen, human-meaningful,
214
        and often but not always unique. Compare to IDs, which uniquely identify
215
        users, and are intended primarily to be machine readable and usable.
216

217
        Some protocols' handles are more or less deterministic based on the id
218
        format, eg ActivityPub (technically WebFinger) handles are
219
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
220
        ActivityPub, AT Protocol, or others.
221

222
        This should be a quick guess without expensive side effects, eg no
223
        external HTTP fetches to fetch the id itself or otherwise perform
224
        discovery.
225

226
        Args:
227
          handle (str)
228
          allow_internal (bool): whether to return False for internal domains
229
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
230

231
        Returns:
232
          bool or None
233
        """
234
        return False
1✔
235

236
    @classmethod
1✔
237
    def handle_to_id(cls, handle):
1✔
238
        """Converts a handle to an id.
239

240
        To be implemented by subclasses.
241

242
        May incur network requests, eg DNS queries or HTTP requests. Avoids
243
        blocked or opted out users.
244

245
        Args:
246
          handle (str)
247

248
        Returns:
249
          str: corresponding id, or None if the handle can't be found
250
        """
251
        raise NotImplementedError()
×
252

253
    @classmethod
1✔
254
    def authed_user_for_request(cls):
1✔
255
        """Returns the authenticated user id for the current request.
256

257

258
        Checks authentication on the current request, eg HTTP Signature for
259
        ActivityPub. To be implemented by subclasses.
260

261
        Returns:
262
          str: authenticated user id, or None if there is no authentication
263

264
        Raises:
265
          RuntimeError: if the request's authentication (eg signature) is
266
          invalid or otherwise can't be verified
267
        """
268
        return None
1✔
269

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

274
        To be implemented by subclasses. Canonicalizes the id if necessary.
275

276
        If called via `Protocol.key_for`, infers the appropriate protocol with
277
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
278
        as is.
279

280
        Args:
281
          id (str):
282
          allow_opt_out (bool): whether to allow users who are currently opted out
283

284
        Returns:
285
          google.cloud.ndb.Key: matching key, or None if the given id is not a
286
          valid :class:`User` id for this protocol.
287
        """
288
        if cls == Protocol:
1✔
289
            proto = Protocol.for_id(id)
1✔
290
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
291

292
        # load user so that we follow use_instead
293
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
294
        if existing:
1✔
295
            if existing.status and not allow_opt_out:
1✔
296
                return None
1✔
297
            return existing.key
1✔
298

299
        return cls(id=id).key
1✔
300

301
    @staticmethod
1✔
302
    def _for_id_memcache_key(id, remote=None):
1✔
303
        """If id is a URL, uses its domain, otherwise returns None.
304

305
        Args:
306
          id (str)
307

308
        Returns:
309
          (str domain, bool remote) or None
310
        """
311
        domain = util.domain_from_link(id)
1✔
312
        if domain in PROTOCOL_DOMAINS:
1✔
313
            return id
1✔
314
        elif remote and util.is_web(id):
1✔
315
            return domain
1✔
316

317
    @cached(LRUCache(20000), lock=Lock())
1✔
318
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote=True: remote,
1✔
319
                      version=3)
320
    @staticmethod
1✔
321
    def for_id(id, remote=True):
1✔
322
        """Returns the protocol for a given id.
323

324
        Args:
325
          id (str)
326
          remote (bool): whether to perform expensive side effects like fetching
327
            the id itself over the network, or other discovery.
328

329
        Returns:
330
          Protocol subclass: matching protocol, or None if no single known
331
          protocol definitively owns this id
332
        """
333
        logger.debug(f'Determining protocol for id {id}')
1✔
334
        if not id:
1✔
335
            return None
1✔
336

337
        # remove our synthetic id fragment, if any
338
        #
339
        # will this eventually cause false positives for other services that
340
        # include our full ids inside their own ids, non-URL-encoded? guess
341
        # we'll figure that out if/when it happens.
342
        id = id.partition('#bridgy-fed-')[0]
1✔
343
        if not id:
1✔
344
            return None
1✔
345

346
        if util.is_web(id):
1✔
347
            # step 1: check for our per-protocol subdomains
348
            try:
1✔
349
                parsed = urlparse(id)
1✔
350
            except ValueError as e:
1✔
351
                logger.info(f'urlparse ValueError: {e}')
1✔
352
                return None
1✔
353

354
            is_homepage = parsed.path.strip('/') == ''
1✔
355
            is_internal = parsed.path.startswith(ids.INTERNAL_PATH_PREFIX)
1✔
356
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
357
            if by_subdomain and not (is_homepage or is_internal
1✔
358
                                     or id in ids.BOT_ACTOR_AP_IDS):
359
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
360
                return by_subdomain
1✔
361

362
        # step 2: check if any Protocols say conclusively that they own it
363
        # sort to be deterministic
364
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
365
                           key=lambda p: p.LABEL)
366
        candidates = []
1✔
367
        for protocol in protocols:
1✔
368
            owns = protocol.owns_id(id)
1✔
369
            if owns:
1✔
370
                logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
371
                return protocol
1✔
372
            elif owns is not False:
1✔
373
                candidates.append(protocol)
1✔
374

375
        if len(candidates) == 1:
1✔
376
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
377
            return candidates[0]
1✔
378

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

390
        # step 4: fetch over the network, if necessary
391
        if not remote:
1✔
392
            return None
1✔
393

394
        for protocol in candidates:
1✔
395
            logger.debug(f'Trying {protocol.LABEL}')
1✔
396
            try:
1✔
397
                obj = protocol.load(id, local=False, remote=True)
1✔
398

399
                if protocol.ABBREV == 'web':
1✔
400
                    # for web, if we fetch and get HTML without microformats,
401
                    # load returns False but the object will be stored in the
402
                    # datastore with source_protocol web, and in cache. load it
403
                    # again manually to check for that.
404
                    obj = Object.get_by_id(id)
1✔
405
                    if obj and obj.source_protocol != 'web':
1✔
406
                        obj = None
×
407

408
                if obj:
1✔
409
                    logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
410
                    return protocol
1✔
411
            except BadGateway:
1✔
412
                # we tried and failed fetching the id over the network.
413
                # this depends on ActivityPub.fetch raising this!
414
                return None
1✔
UNCOV
415
            except HTTPException as e:
×
416
                # internal error we generated ourselves; try next protocol
UNCOV
417
                pass
×
UNCOV
418
            except Exception as e:
×
UNCOV
419
                code, _ = util.interpret_http_exception(e)
×
UNCOV
420
                if code:
×
421
                    # we tried and failed fetching the id over the network
UNCOV
422
                    return None
×
UNCOV
423
                raise
×
424

425
        logger.info(f'No matching protocol found for {id} !')
1✔
426
        return None
1✔
427

428
    @cached(LRUCache(20000), lock=Lock())
1✔
429
    @staticmethod
1✔
430
    def for_handle(handle):
1✔
431
        """Returns the protocol for a given handle.
432

433
        May incur expensive side effects like resolving the handle itself over
434
        the network or other discovery.
435

436
        Args:
437
          handle (str)
438

439
        Returns:
440
          (Protocol subclass, str) tuple: matching protocol and optional id (if
441
          resolved), or ``(None, None)`` if no known protocol owns this handle
442
        """
443
        # TODO: normalize, eg convert domains to lower case
444
        logger.debug(f'Determining protocol for handle {handle}')
1✔
445
        if not handle:
1✔
446
            return (None, None)
1✔
447

448
        # step 1: check if any Protocols say conclusively that they own it.
449
        # sort to be deterministic.
450
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
451
                           key=lambda p: p.LABEL)
452
        candidates = []
1✔
453
        for proto in protocols:
1✔
454
            owns = proto.owns_handle(handle)
1✔
455
            if owns:
1✔
456
                logger.debug(f'  {proto.LABEL} owns handle {handle}')
1✔
457
                return (proto, None)
1✔
458
            elif owns is not False:
1✔
459
                candidates.append(proto)
1✔
460

461
        if len(candidates) == 1:
1✔
UNCOV
462
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
UNCOV
463
            return (candidates[0], None)
×
464

465
        # step 2: look for matching User in the datastore
466
        for proto in candidates:
1✔
467
            user = proto.query(proto.handle == handle).get()
1✔
468
            if user:
1✔
469
                if user.status:
1✔
470
                    return (None, None)
1✔
471
                logger.debug(f'  user {user.key} handle {handle}')
1✔
472
                return (proto, user.key.id())
1✔
473

474
        # step 3: resolve handle to id
475
        for proto in candidates:
1✔
476
            id = proto.handle_to_id(handle)
1✔
477
            if id:
1✔
478
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
479
                return (proto, id)
1✔
480

481
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
482
        return (None, None)
1✔
483

484
    @classmethod
1✔
485
    def is_user_at_domain(cls, handle, allow_internal=False):
1✔
486
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
487

488
        Example: ``@user@instance.com``
489

490
        Args:
491
          handle (str)
492
          allow_internal (bool): whether the domain can be a Bridgy Fed domain
493
        """
494
        parts = handle.split('@')
1✔
495
        if len(parts) != 2:
1✔
496
            return False
1✔
497

498
        user, domain = parts
1✔
499
        return bool(user and domain
1✔
500
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
501

502
    @classmethod
1✔
503
    def bridged_web_url_for(cls, user, fallback=False):
1✔
504
        """Returns the web URL for a user's bridged profile in this protocol.
505

506
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
507
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
508

509
        Args:
510
          user (models.User)
511
          fallback (bool): if True, and bridged users have no canonical user
512
            profile URL in this protocol, return the native protocol's profile URL
513

514
        Returns:
515
          str, or None if there isn't a canonical URL
516
        """
517
        if fallback:
1✔
518
            return user.web_url()
1✔
519

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

524
        Args:
525
          obj (models.Object)
526
          allow_opt_out (bool): whether to return a user key if they're opted out
527

528
        Returns:
529
          google.cloud.ndb.key.Key or None:
530
        """
531
        owner = as1.get_owner(obj.as1)
1✔
532
        if owner:
1✔
533
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
534

535
    @classmethod
1✔
536
    def bot_user_id(cls):
1✔
537
        """Returns the Web user id for the bot user for this protocol.
538

539
        For example, ``'bsky.brid.gy'`` for ATProto.
540

541
        Returns:
542
          str:
543
        """
544
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
545

546
    @classmethod
1✔
547
    def create_for(cls, user):
1✔
548
        """Creates or re-activate a copy user in this protocol.
549

550
        Should add the copy user to :attr:`copies`.
551

552
        If the copy user already exists and active, should do nothing.
553

554
        Args:
555
          user (models.User): original source user. Shouldn't already have a
556
            copy user for this protocol in :attr:`copies`.
557

558
        Raises:
559
          ValueError: if we can't create a copy of the given user in this protocol
560
        """
UNCOV
561
        raise NotImplementedError()
×
562

563
    @classmethod
1✔
564
    def send(to_cls, obj, target, from_user=None, orig_obj_id=None):
1✔
565
        """Sends an outgoing activity.
566

567
        To be implemented by subclasses. Should call
568
        ``to_cls.translate_ids(obj.as1)`` before converting it to this Protocol's
569
        format.
570

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

575
        Args:
576
          obj (models.Object): with activity to send
577
          target (str): destination URL to send to
578
          from_user (models.User): user (actor) this activity is from
579
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
580
            that this object refers to, eg replies to or reposts or likes
581

582
        Returns:
583
          bool: True if the activity is sent successfully, False if it is
584
          ignored or otherwise unsent due to protocol logic, eg no webmention
585
          endpoint, protocol doesn't support the activity type. (Failures are
586
          raised as exceptions.)
587

588
        Raises:
589
          werkzeug.HTTPException if the request fails
590
        """
UNCOV
591
        raise NotImplementedError()
×
592

593
    @classmethod
1✔
594
    def fetch(cls, obj, **kwargs):
1✔
595
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
596

597
        Errors are raised as exceptions. If this method returns False, the fetch
598
        didn't fail but didn't succeed either, eg the id isn't valid for this
599
        protocol, or the fetch didn't return valid data for this protocol.
600

601
        To be implemented by subclasses.
602

603
        Args:
604
          obj (models.Object): with the id to fetch. Data is filled into one of
605
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
606
          kwargs: subclass-specific
607

608
        Returns:
609
          bool: True if the object was fetched and populated successfully,
610
          False otherwise
611

612
        Raises:
613
          requests.RequestException, werkzeug.HTTPException,
614
          websockets.WebSocketException, etc: if the fetch fails
615
        """
UNCOV
616
        raise NotImplementedError()
×
617

618
    @classmethod
1✔
619
    def convert(cls, obj, from_user=None, **kwargs):
1✔
620
        """Converts an :class:`Object` to this protocol's data format.
621

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

625
        Just passes through to :meth:`_convert`, then does minor
626
        protocol-independent postprocessing.
627

628
        Args:
629
          obj (models.Object):
630
          from_user (models.User): user (actor) this activity/object is from
631
          kwargs: protocol-specific, passed through to :meth:`_convert`
632

633
        Returns:
634
          converted object in the protocol's native format, often a dict
635
        """
636
        if not obj or not obj.as1:
1✔
637
            return {}
1✔
638

639
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
640
        is_crud = obj.as1.get('verb') in as1.CRUD_VERBS
1✔
641
        base_obj = as1.get_object(obj.as1) if is_crud else obj.as1
1✔
642
        orig_our_as1 = obj.our_as1
1✔
643

644
        # mark bridged actors as bots and add "bridged by Bridgy Fed" to their bios
645
        if (from_user and base_obj
1✔
646
            and base_obj.get('objectType') in as1.ACTOR_TYPES
647
            and PROTOCOLS.get(obj.source_protocol) != cls
648
            and Protocol.for_bridgy_subdomain(id) not in DOMAINS
649
            # Web users are special cased, they don't get the label if they've
650
            # explicitly enabled Bridgy Fed with redirects or webmentions
651
            and not (from_user.LABEL == 'web'
652
                     and (from_user.last_webmention_in or from_user.has_redirects))):
653
            cls.add_source_links(obj=obj, from_user=from_user)
1✔
654

655
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
656
        obj.our_as1 = orig_our_as1
1✔
657
        return converted
1✔
658

659
    @classmethod
1✔
660
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
661
        """Converts an :class:`Object` to this protocol's data format.
662

663
        To be implemented by subclasses. Implementations should generally call
664
        :meth:`Protocol.translate_ids` (as their own class) before converting to
665
        their format.
666

667
        Args:
668
          obj (models.Object):
669
          from_user (models.User): user (actor) this activity/object is from
670
          kwargs: protocol-specific
671

672
        Returns:
673
          converted object in the protocol's native format, often a dict. May
674
            return the ``{}`` empty dict if the object can't be converted.
675
        """
UNCOV
676
        raise NotImplementedError()
×
677

678
    @classmethod
1✔
679
    def add_source_links(cls, obj, from_user):
1✔
680
        """Adds "bridged from ... by Bridgy Fed" to the user's actor's ``summary``.
681

682
        Uses HTML for protocols that support it, plain text otherwise.
683

684
        Args:
685
          cls (Protocol subclass): protocol that the user is bridging into
686
          obj (models.Object): user's actor/profile object
687
          from_user (models.User): user (actor) this activity/object is from
688
        """
689
        assert obj and obj.as1
1✔
690
        assert from_user
1✔
691

692
        obj.our_as1 = copy.deepcopy(obj.as1)
1✔
693
        actor = (as1.get_object(obj.as1) if obj.type in as1.CRUD_VERBS
1✔
694
                 else obj.as1)
695
        actor['objectType'] = 'person'
1✔
696

697
        orig_summary = actor.setdefault('summary', '')
1✔
698
        summary_text = html_to_text(orig_summary, ignore_links=True)
1✔
699

700
        # Check if we've already added source links
701
        if '🌉 bridged' in summary_text:
1✔
702
            return
1✔
703

704
        actor_id = actor.get('id')
1✔
705

706
        url = (as1.get_url(actor)
1✔
707
               or (from_user.web_url() if from_user.profile_id() == actor_id
708
                   else actor_id))
709

710
        from web import Web
1✔
711
        bot_user = Web.get_by_id(from_user.bot_user_id())
1✔
712

713
        if cls.HTML_PROFILES:
1✔
714
            if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS:
1✔
715
                mention = bot_user.user_link(proto=cls, name=False, handle='short')
1✔
716
                suffix = f', follow {mention} to interact'
1✔
717
            else:
718
                suffix = f' by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>'
1✔
719

720
            separator = '<br><br>'
1✔
721

722
            is_user = from_user.key and actor_id in (from_user.key.id(),
1✔
723
                                                     from_user.profile_id())
724
            if is_user:
1✔
725
                bridged = f'🌉 <a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a>'
1✔
726
                from_ = f'<a href="{from_user.web_url()}">{from_user.handle}</a>'
1✔
727
            else:
728
                bridged = '🌉 bridged'
1✔
729
                from_ = util.pretty_link(url) if url else '?'
1✔
730

731
        else:  # plain text
732
            # TODO: unify with above. which is right?
733
            id = obj.key.id() if obj.key else obj.our_as1.get('id')
1✔
734
            is_user = from_user.key and id in (from_user.key.id(),
1✔
735
                                               from_user.profile_id())
736
            from_ = (from_user.web_url() if is_user else url) or '?'
1✔
737

738
            bridged = '🌉 bridged'
1✔
739
            suffix = (
1✔
740
                f': https://{PRIMARY_DOMAIN}{from_user.user_page_path()}'
741
                # link web users to their user pages
742
                if from_user.LABEL == 'web'
743
                else f', follow @{bot_user.handle_as(cls)} to interact'
744
                if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS
745
                else f' by https://{PRIMARY_DOMAIN}/')
746
            separator = '\n\n'
1✔
747
            orig_summary = summary_text
1✔
748

749
        logo = f'{from_user.LOGO_EMOJI} ' if from_user.LOGO_EMOJI else ''
1✔
750
        source_links = f'{separator if orig_summary else ""}{bridged} from {logo}{from_}{suffix}'
1✔
751
        actor['summary'] = orig_summary + source_links
1✔
752

753
    @classmethod
1✔
754
    def set_username(to_cls, user, username):
1✔
755
        """Sets a custom username for a user's bridged account in this protocol.
756

757
        Args:
758
          user (models.User)
759
          username (str)
760

761
        Raises:
762
          ValueError: if the username is invalid
763
          RuntimeError: if the username could not be set
764
        """
765
        raise NotImplementedError()
1✔
766

767
    @classmethod
1✔
768
    def migrate_out(cls, user, to_user_id):
1✔
769
        """Migrates a bridged account out to be a native account.
770

771
        Args:
772
          user (models.User)
773
          to_user_id (str)
774

775
        Raises:
776
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
777
            ``user`` is on this protocol or not bridged to this protocol
778
        """
UNCOV
779
        raise NotImplementedError()
×
780

781
    @classmethod
1✔
782
    def check_can_migrate_out(cls, user, to_user_id):
1✔
783
        """Raises an exception if a user can't yet migrate to a native account.
784

785
        For example, if ``to_user_id`` isn't on this protocol, or if ``user`` is on
786
        this protocol, or isn't bridged to this protocol.
787

788
        If the user is ready to migrate, returns ``None``.
789

790
        Subclasses may override this to add more criteria, but they should call this
791
        implementation first.
792

793
        Args:
794
          user (models.User)
795
          to_user_id (str)
796

797
        Raises:
798
          ValueError: if ``user`` isn't ready to migrate to this protocol yet
799
        """
800
        def _error(msg):
1✔
801
            logger.warning(msg)
1✔
802
            raise ValueError(msg)
1✔
803

804
        if cls.owns_id(to_user_id) is False:
1✔
805
            _error(f"{to_user_id} doesn't look like an {cls.LABEL} id")
1✔
806
        elif isinstance(user, cls):
1✔
807
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
808
        elif not user.is_enabled(cls):
1✔
809
            _error(f"{user.handle_or_id()} isn't currently bridged to {cls.PHRASE}")
1✔
810

811
    @classmethod
1✔
812
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
813
        """Migrates a native account in to be a bridged account.
814

815
        The protocol independent parts are done here; protocol-specific parts are
816
        done in :meth:`_migrate_in`, which this wraps.
817

818
        Reloads the user's profile before calling :meth:`_migrate_in`.
819

820
        Args:
821
          user (models.User): native user on another protocol to attach the
822
            newly imported bridged account to
823
          from_user_id (str)
824
          kwargs: additional protocol-specific parameters
825

826
        Raises:
827
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
828
            ``user`` is on this protocol or already bridged to this protocol
829
        """
830
        def _error(msg):
1✔
831
            logger.warning(msg)
1✔
832
            raise ValueError(msg)
1✔
833

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

836
        # check req'ts
837
        if cls.owns_id(from_user_id) is False:
1✔
838
            _error(f"{from_user_id} doesn't look like an {cls.LABEL} id")
1✔
839
        elif isinstance(user, cls):
1✔
840
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
841
        elif cls.HAS_COPIES and cls.LABEL in user.enabled_protocols:
1✔
842
            _error(f"{user.handle_or_id()} is already bridged to {cls.PHRASE}")
1✔
843

844
        # reload profile
845
        try:
1✔
846
            user.reload_profile()
1✔
UNCOV
847
        except (RequestException, HTTPException) as e:
×
UNCOV
848
            _, msg = util.interpret_http_exception(e)
×
849

850
        # migrate!
851
        cls._migrate_in(user, from_user_id, **kwargs)
1✔
852
        user.add('enabled_protocols', cls.LABEL)
1✔
853
        user.put()
1✔
854

855
        # attach profile object
856
        if user.obj:
1✔
857
            if cls.HAS_COPIES:
1✔
858
                profile_id = ids.profile_id(id=from_user_id, proto=cls)
1✔
859
                user.obj.remove_copies_on(cls)
1✔
860
                user.obj.add('copies', Target(uri=profile_id, protocol=cls.LABEL))
1✔
861
                user.obj.put()
1✔
862

863
            common.create_task(queue='receive', obj_id=user.obj_key.id(),
1✔
864
                               authed_as=user.key.id())
865

866
    @classmethod
1✔
867
    def _migrate_in(cls, user, from_user_id, **kwargs):
1✔
868
        """Protocol-specific parts of migrating in external account.
869

870
        Called by :meth:`migrate_in`, which does most of the work, including calling
871
        :meth:`reload_profile` before this.
872

873
        Args:
874
          user (models.User): native user on another protocol to attach the
875
            newly imported account to. Unused.
876
          from_user_id (str): DID of the account to be migrated in
877
          kwargs: protocol dependent
878
        """
UNCOV
879
        raise NotImplementedError()
×
880

881
    @classmethod
1✔
882
    def target_for(cls, obj, shared=False):
1✔
883
        """Returns an :class:`Object`'s delivery target (endpoint).
884

885
        To be implemented by subclasses.
886

887
        Examples:
888

889
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
890
          webmention target.
891
        * If obj is an ``activitypub`` actor, returns its inbox.
892
        * If obj is an ``activitypub`` object, returns it's author's or actor's
893
          inbox.
894

895
        Args:
896
          obj (models.Object):
897
          shared (bool): optional. If True, returns a common/shared
898
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
899
            multiple recipients for efficiency
900

901
        Returns:
902
          str: target endpoint, or None if not available.
903
        """
UNCOV
904
        raise NotImplementedError()
×
905

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

910
        Default implementation here, subclasses may override.
911

912
        Args:
913
          url (str):
914
          allow_internal (bool): whether to return False for internal domains
915
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
916
        """
917
        blocklist = DOMAIN_BLOCKLIST
1✔
918
        if not DEBUG:
1✔
919
            blocklist += tuple(util.RESERVED_TLDS | util.LOCAL_TLDS)
1✔
920
        if not allow_internal:
1✔
921
            blocklist += DOMAINS
1✔
922
        return util.domain_or_parent_in(url, blocklist)
1✔
923

924
    @classmethod
1✔
925
    def translate_ids(to_cls, obj):
1✔
926
        """Translates all ids in an AS1 object to a specific protocol.
927

928
        Infers source protocol for each id value separately.
929

930
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
931
        ``at://did:plc:abc/coll/123`` will be converted to
932
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
933

934
        Wraps these AS1 fields:
935

936
        * ``id``
937
        * ``actor``
938
        * ``author``
939
        * ``bcc``
940
        * ``bto``
941
        * ``cc``
942
        * ``featured[].items``, ``featured[].orderedItems``
943
        * ``object``
944
        * ``object.actor``
945
        * ``object.author``
946
        * ``object.id``
947
        * ``object.inReplyTo``
948
        * ``object.object``
949
        * ``attachments[].id``
950
        * ``tags[objectType=mention].url``
951
        * ``to``
952

953
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
954
        same logic is duplicated there!
955

956
        TODO: unify with :meth:`Object.resolve_ids`,
957
        :meth:`models.Object.normalize_ids`.
958

959
        Args:
960
          to_proto (Protocol subclass)
961
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
962

963
        Returns:
964
          dict: translated AS1 version of ``obj``
965
        """
966
        assert to_cls != Protocol
1✔
967
        if not obj:
1✔
968
            return obj
1✔
969

970
        outer_obj = to_cls.translate_mention_handles(copy.deepcopy(obj))
1✔
971
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
972

973
        def translate(elem, field, fn, uri=False):
1✔
974
            elem[field] = as1.get_objects(elem, field)
1✔
975
            for obj in elem[field]:
1✔
976
                if id := obj.get('id'):
1✔
977
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
978
                        continue
1✔
979
                    from_cls = Protocol.for_id(id)
1✔
980
                    # TODO: what if from_cls is None? relax translate_object_id,
981
                    # make it a noop if we don't know enough about from/to?
982
                    if from_cls and from_cls != to_cls:
1✔
983
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
984
                    if obj['id'] and uri:
1✔
985
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
986

987
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
988
                           for o in elem[field]]
989

990
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
991
                elem[field] = elem[field][0]
1✔
992

993
        type = as1.object_type(outer_obj)
1✔
994
        translate(outer_obj, 'id',
1✔
995
                  ids.translate_user_id if type in as1.ACTOR_TYPES
996
                  else ids.translate_object_id)
997

998
        for o in inner_objs:
1✔
999
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
1000
                        or as1.get_owner(outer_obj) == o.get('id')
1001
                        or type in ('follow', 'stop-following'))
1002
            translate(o, 'id', (ids.translate_user_id if is_actor
1✔
1003
                                else ids.translate_object_id))
1004
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
1005
            translate(o, 'object', (ids.translate_user_id if obj_is_actor
1✔
1006
                                    else ids.translate_object_id))
1007

1008
        for o in [outer_obj] + inner_objs:
1✔
1009
            translate(o, 'inReplyTo', ids.translate_object_id)
1✔
1010
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
1011
                translate(o, field, ids.translate_user_id)
1✔
1012
            for tag in as1.get_objects(o, 'tags'):
1✔
1013
                if tag.get('objectType') == 'mention':
1✔
1014
                    translate(tag, 'url', ids.translate_user_id, uri=True)
1✔
1015
            for att in as1.get_objects(o, 'attachments'):
1✔
1016
                translate(att, 'id', ids.translate_object_id)
1✔
1017
                url = att.get('url')
1✔
1018
                if url and not att.get('id'):
1✔
1019
                    if from_cls := Protocol.for_id(url):
1✔
1020
                        att['id'] = ids.translate_object_id(from_=from_cls, to=to_cls,
1✔
1021
                                                            id=url)
1022
            if feat := as1.get_object(o, 'featured'):
1✔
1023
                translate(feat, 'orderedItems', ids.translate_object_id)
1✔
1024
                translate(feat, 'items', ids.translate_object_id)
1✔
1025

1026
        outer_obj = util.trim_nulls(outer_obj)
1✔
1027

1028
        if objs := util.get_list(outer_obj ,'object'):
1✔
1029
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
1030
            if len(outer_obj['object']) == 1:
1✔
1031
                outer_obj['object'] = outer_obj['object'][0]
1✔
1032

1033
        return outer_obj
1✔
1034

1035
    @classmethod
1✔
1036
    def translate_mention_handles(cls, obj):
1✔
1037
        """Translates @-mentions in ``obj.content`` to this protocol's handles.
1038

1039
        Specifically, for each ``mention`` tag in the object's tags that has
1040
        ``startIndex`` and ``length``, replaces it in ``obj.content`` with that
1041
        user's translated handle in this protocol and updates the tag's location.
1042

1043
        Called by :meth:`Protocol.translate_ids`.
1044

1045
        If ``obj.content`` is HTML, does nothing.
1046

1047
        Args:
1048
          obj (dict): AS2 object
1049

1050
        Returns:
1051
          dict: modified AS2 object
1052
        """
1053
        if not obj:
1✔
UNCOV
1054
            return None
×
1055

1056
        obj = copy.deepcopy(obj)
1✔
1057
        obj['object'] = [cls.translate_mention_handles(o)
1✔
1058
                                for o in as1.get_objects(obj)]
1059
        if len(obj['object']) == 1:
1✔
1060
            obj['object'] = obj['object'][0]
1✔
1061

1062
        content = obj.get('content')
1✔
1063
        tags = obj.get('tags')
1✔
1064
        if (not content or not tags
1✔
1065
                or obj.get('content_is_html')
1066
                or bool(BeautifulSoup(content, 'html.parser').find())
1067
                or HTML_ENTITY_RE.search(content)):
1068
            return util.trim_nulls(obj)
1✔
1069

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

1072
        offset = 0
1✔
1073
        for tag in sorted(indexed, key=lambda t: t['startIndex']):
1✔
1074
            tag['startIndex'] += offset
1✔
1075
            if tag.get('objectType') == 'mention' and (id := tag['url']):
1✔
1076
                if proto := Protocol.for_id(id):
1✔
1077
                    id = ids.normalize_user_id(id=id, proto=proto)
1✔
1078
                    if key := get_original_user_key(id):
1✔
UNCOV
1079
                        user = key.get()
×
1080
                    else:
1081
                        user = proto.get_or_create(id, allow_opt_out=True)
1✔
1082
                    if user:
1✔
1083
                        start = tag['startIndex']
1✔
1084
                        end = start + tag['length']
1✔
1085
                        if handle := user.handle_as(cls):
1✔
1086
                            content = content[:start] + handle + content[end:]
1✔
1087
                            offset += len(handle) - tag['length']
1✔
1088
                            tag.update({
1✔
1089
                                'displayName': handle,
1090
                                'length': len(handle),
1091
                            })
1092

1093
        obj['tags'] = tags
1✔
1094
        as2.set_content(obj, content)  # sets content *and* contentMap
1✔
1095
        return util.trim_nulls(obj)
1✔
1096

1097
    @classmethod
1✔
1098
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
1099
        """Handles an incoming activity.
1100

1101
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
1102
        unset, returns HTTP 299.
1103

1104
        Args:
1105
          obj (models.Object)
1106
          authed_as (str): authenticated actor id who sent this activity
1107
          internal (bool): whether to allow activity ids on internal domains,
1108
            from opted out/blocked users, etc.
1109
          received_at (datetime): when we first saw (received) this activity.
1110
            Right now only used for monitoring.
1111

1112
        Returns:
1113
          (str, int) tuple: (response body, HTTP status code) Flask response
1114

1115
        Raises:
1116
          werkzeug.HTTPException: if the request is invalid
1117
        """
1118
        # check some invariants
1119
        assert from_cls != Protocol
1✔
1120
        assert isinstance(obj, Object), obj
1✔
1121

1122
        if not obj.as1:
1✔
1123
            error('No object data provided')
1✔
1124

1125
        orig_obj = obj
1✔
1126
        id = None
1✔
1127
        if obj.key and obj.key.id():
1✔
1128
            id = obj.key.id()
1✔
1129

1130
        if not id:
1✔
1131
            id = obj.as1.get('id')
1✔
1132
            obj.key = ndb.Key(Object, id)
1✔
1133

1134
        if not id:
1✔
UNCOV
1135
            error('No id provided')
×
1136
        elif from_cls.owns_id(id) is False:
1✔
1137
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
1138
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
1139
            error(f'Activity {id} is blocklisted')
1✔
1140

1141
        # does this protocol support this activity/object type?
1142
        from_cls.check_supported(obj, 'receive')
1✔
1143

1144
        # lease this object, atomically
1145
        memcache_key = activity_id_memcache_key(id)
1✔
1146
        leased = memcache.memcache.add(
1✔
1147
            memcache_key, 'leased', noreply=False,
1148
            expire=int(MEMCACHE_LEASE_EXPIRATION.total_seconds()))
1149

1150
        # short circuit if we've already seen this activity id.
1151
        # (don't do this for bare objects since we need to check further down
1152
        # whether they've been updated since we saw them last.)
1153
        if (obj.as1.get('objectType') == 'activity'
1✔
1154
            and 'force' not in request.values
1155
            and (not leased
1156
                 or (obj.new is False and obj.changed is False))):
1157
            error(f'Already seen this activity {id}', status=204)
1✔
1158

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

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

1179
        assert authed_as
1✔
1180
        assert isinstance(authed_as, str)
1✔
1181
        authed_as = ids.normalize_user_id(id=authed_as, proto=from_cls)
1✔
1182
        actor = ids.normalize_user_id(id=actor, proto=from_cls)
1✔
1183
        if actor != authed_as:
1✔
1184
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
1185
                         user=f'{id} authed_as {authed_as} owner {actor}')
1186
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
1187

1188
        # update copy ids to originals
1189
        obj.normalize_ids()
1✔
1190
        obj.resolve_ids()
1✔
1191

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

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

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

1222
        # if this is an object, ie not an activity, wrap it in a create or update
1223
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as,
1✔
1224
                                          from_user=from_user)
1225
        obj.add('users', from_user.key)
1✔
1226

1227
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1228
        inner_obj_id = inner_obj_as1.get('id')
1✔
1229
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1230
            if not inner_obj_id:
1✔
1231
                error(f'{obj.type} object has no id!')
1✔
1232

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

1248
        # write Object to datastore
1249
        obj.source_protocol = from_cls.LABEL
1✔
1250
        if obj.type in STORE_AS1_TYPES:
1✔
1251
            obj.put()
1✔
1252

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

1263
        actor = as1.get_object(obj.as1, 'actor')
1✔
1264
        actor_id = actor.get('id')
1✔
1265

1266
        # handle activity!
1267
        if obj.type == 'stop-following':
1✔
1268
            # TODO: unify with handle_follow?
1269
            # TODO: handle multiple followees
1270
            if not actor_id or not inner_obj_id:
1✔
UNCOV
1271
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1272

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

1288
            # fall through to deliver to followee
1289
            # TODO: do we convert stop-following to webmention 410 of original
1290
            # follow?
1291

1292
            # fall through to deliver to followers
1293

1294
        elif obj.type in ('delete', 'undo'):
1✔
1295
            delete_obj_id = (from_user.profile_id()
1✔
1296
                            if inner_obj_id == from_user.key.id()
1297
                            else inner_obj_id)
1298

1299
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1300
            if not delete_obj:
1✔
1301
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1302
                return 'OK', 204
1✔
1303

1304
            # TODO: just delete altogether!
1305
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1306
            delete_obj.deleted = True
1✔
1307
            delete_obj.put()
1✔
1308

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

1317
        elif obj.type == 'block':
1✔
1318
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1319
                # blocking protocol bot user disables that protocol
1320
                from_user.delete(proto)
1✔
1321
                from_user.disable_protocol(proto)
1✔
1322
                return 'OK', 200
1✔
1323

1324
        elif obj.type == 'post':
1✔
1325
            # handle DMs to bot users
1326
            if as1.is_dm(obj.as1):
1✔
1327
                return dms.receive(from_user=from_user, obj=obj)
1✔
1328

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

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

1367
        if obj.type == 'follow':
1✔
1368
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1369
                # follow of one of our protocol bot users; enable that protocol.
1370
                # fall through so that we send an accept.
1371
                try:
1✔
1372
                    from_user.enable_protocol(proto)
1✔
1373
                except ErrorButDoNotRetryTask:
1✔
1374
                    from web import Web
1✔
1375
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1376
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1377
                                               followee=bot, follow=obj)
1378
                    raise
1✔
1379
                proto.bot_maybe_follow_back(from_user)
1✔
1380

1381
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1382

1383
        # on update of the user's own actor/profile, set user.obj and store user back
1384
        # to datastore so that we recalculate computed properties like status etc
1385
        if is_user:
1✔
1386
            if obj.type == 'update' and crud_obj:
1✔
1387
                logger.info("update of the user's profile, re-storing user")
1✔
1388
                from_user.obj = crud_obj
1✔
1389
                from_user.put()
1✔
1390

1391
        # deliver to targets
1392
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1393

1394
        # on user deleting themselves, deactivate their followers/followings.
1395
        # https://github.com/snarfed/bridgy-fed/issues/1304
1396
        #
1397
        # do this *after* delivering because delivery finds targets based on
1398
        # stored Followers
1399
        if is_user and obj.type == 'delete':
1✔
1400
            for proto in from_user.enabled_protocols:
1✔
1401
                from_user.disable_protocol(PROTOCOLS[proto])
1✔
1402

1403
            logger.info(f'Deactivating Followers from or to {from_user.key.id()}')
1✔
1404
            followers = Follower.query(
1✔
1405
                OR(Follower.to == from_user.key, Follower.from_ == from_user.key)
1406
            ).fetch()
1407
            for f in followers:
1✔
1408
                f.status = 'inactive'
1✔
1409
            ndb.put_multi(followers)
1✔
1410

1411
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1412
        return resp
1✔
1413

1414
    @classmethod
1✔
1415
    def handle_follow(from_cls, obj, from_user):
1✔
1416
        """Handles an incoming follow activity.
1417

1418
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1419
        happens in :meth:`deliver`.
1420

1421
        Args:
1422
          obj (models.Object): follow activity
1423
        """
1424
        logger.debug('Got follow. storing Follow(s), sending accept(s)')
1✔
1425
        from_id = from_user.key.id()
1✔
1426

1427
        # Prepare followee (to) users' data
1428
        to_as1s = as1.get_objects(obj.as1)
1✔
1429
        if not to_as1s:
1✔
UNCOV
1430
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1431

1432
        # Store Followers
1433
        for to_as1 in to_as1s:
1✔
1434
            to_id = to_as1.get('id')
1✔
1435
            if not to_id:
1✔
UNCOV
1436
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1437

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

1440
            to_cls = Protocol.for_id(to_id)
1✔
1441
            if not to_cls:
1✔
UNCOV
1442
                error(f"Couldn't determine protocol for {to_id}")
×
1443
            elif from_cls == to_cls:
1✔
1444
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1445
                continue
1✔
1446

1447
            to_key = to_cls.key_for(to_id)
1✔
1448
            if not to_key:
1✔
UNCOV
1449
                logger.info(f'Skipping invalid {from_cls.LABEL} user key: {from_id}')
×
UNCOV
1450
                continue
×
1451

1452
            to_user = to_cls.get_or_create(id=to_key.id())
1✔
1453
            if not to_user or not to_user.is_enabled(from_user):
1✔
1454
                error(f'{to_id} not found')
1✔
1455

1456
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1457
                                                  follow=obj.key, status='active')
1458
            obj.add('notify', to_key)
1✔
1459
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1460
                                       followee=to_user, follow=obj)
1461

1462
    @classmethod
1✔
1463
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1464
        """Sends an accept or reject activity for a follow.
1465

1466
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1467
        nothing.
1468

1469
        Args:
1470
          verb (str): ``accept`` or  ``reject``
1471
          follower (models.User)
1472
          followee (models.User)
1473
          follow (models.Object)
1474
        """
1475
        assert verb in ('accept', 'reject')
1✔
1476
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1477
            return
1✔
1478

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

1482
        # send. note that this is one response for the whole follow, even if it
1483
        # has multiple followees!
1484
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1485
        accept = {
1✔
1486
            'id': id,
1487
            'objectType': 'activity',
1488
            'verb': verb,
1489
            'actor': followee.key.id(),
1490
            'object': follow.as1,
1491
        }
1492
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1493
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1494

1495
    @classmethod
1✔
1496
    def bot_maybe_follow_back(bot_cls, user):
1✔
1497
        """Follow a user from a protocol bot user, if their protocol needs that.
1498

1499
        ...so that the protocol starts sending us their activities, if it needs
1500
        a follow for that (eg ActivityPub).
1501

1502
        Args:
1503
          user (User)
1504
        """
1505
        if not user.BOTS_FOLLOW_BACK:
1✔
1506
            return
1✔
1507

1508
        from web import Web
1✔
1509
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1510
        now = util.now().isoformat()
1✔
1511
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1512

1513
        if not user.obj:
1✔
1514
            logger.info("  can't follow, user has no profile obj")
1✔
1515
            return
1✔
1516

1517
        target = user.target_for(user.obj)
1✔
1518
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1519
        follow_back_as1 = {
1✔
1520
            'objectType': 'activity',
1521
            'verb': 'follow',
1522
            'id': follow_back_id,
1523
            'actor': bot.key.id(),
1524
            'object': user.key.id(),
1525
        }
1526
        common.create_task(queue='send', id=follow_back_id,
1✔
1527
                           our_as1=follow_back_as1, url=target,
1528
                           source_protocol='web', protocol=user.LABEL,
1529
                           user=bot.key.urlsafe())
1530

1531
    @classmethod
1✔
1532
    def handle_bare_object(cls, obj, *, authed_as, from_user):
1✔
1533
        """If obj is a bare object, wraps it in a create or update activity.
1534

1535
        Checks if we've seen it before.
1536

1537
        Args:
1538
          obj (models.Object)
1539
          authed_as (str): authenticated actor id who sent this activity
1540
          from_user (models.User): user (actor) this activity/object is from
1541

1542
        Returns:
1543
          models.Object: ``obj`` if it's an activity, otherwise a new object
1544
        """
1545
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1546
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1547
            return obj
1✔
1548

1549
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1550
        now = util.now().isoformat()
1✔
1551

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

1578
        if (obj.new
1✔
1579
                # HACK: force query param here is specific to webmention
1580
                or 'force' in request.form):
1581
            create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1582
            create_as1 = {
1✔
1583
                'objectType': 'activity',
1584
                'verb': 'post',
1585
                'id': create_id,
1586
                'actor': obj_actor,
1587
                'object': obj.as1,
1588
                'published': now,
1589
            }
1590
            logger.info(f'Wrapping in post')
1✔
1591
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1592
            return Object(id=create_id, our_as1=create_as1,
1✔
1593
                          source_protocol=obj.source_protocol)
1594

1595
        error(f'{obj.key.id()} is unchanged, nothing to do', status=204)
1✔
1596

1597
    @classmethod
1✔
1598
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1599
        """Delivers an activity to its external recipients.
1600

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

1610
        Returns:
1611
          (str, int) tuple: Flask response
1612
        """
1613
        if to_proto:
1✔
1614
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1615

1616
        # find delivery targets. maps Target to Object or None
1617
        #
1618
        # ...then write the relevant object, since targets() has a side effect of
1619
        # setting the notify and feed properties (and dirty attribute)
1620
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1621
        if to_proto:
1✔
1622
            targets = {t: obj for t, obj in targets.items()
1✔
1623
                       if t.protocol == to_proto.LABEL}
1624
        if not targets:
1✔
1625
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1626

1627
        # store object that targets() updated
1628
        if crud_obj and crud_obj.dirty:
1✔
1629
            crud_obj.put()
1✔
1630
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1631
            obj.put()
1✔
1632

1633
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1634
                      else obj.to_request())
1635

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

1639
        # enqueue send task for each targets
1640
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1641
        user = from_user.key.urlsafe()
1✔
1642
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1643
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1644
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1645
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1646

1647
        return 'OK', 202
1✔
1648

1649
    @classmethod
1✔
1650
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1651
        """Collects the targets to send a :class:`models.Object` to.
1652

1653
        Targets are both objects - original posts, events, etc - and actors.
1654

1655
        Args:
1656
          obj (models.Object)
1657
          from_user (User)
1658
          crud_obj (models.Object): if this is a create, update, or delete/undo
1659
            activity, the inner object that's being written, otherwise None.
1660
            (This object's ``notify`` and ``feed`` properties may be updated.)
1661
          internal (bool): whether this is a recursive internal call
1662

1663
        Returns:
1664
          dict: maps :class:`models.Target` to original (in response to)
1665
          :class:`models.Object`, if any, otherwise None
1666
        """
1667
        logger.debug('Finding recipients and their targets')
1✔
1668

1669
        # we should only have crud_obj iff this is a create or update
1670
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1671
        write_obj = crud_obj or obj
1✔
1672
        write_obj.dirty = False
1✔
1673

1674
        target_uris = as1.targets(obj.as1)
1✔
1675
        orig_obj = None
1✔
1676
        targets = {}  # maps Target to Object or None
1✔
1677
        owner = as1.get_owner(obj.as1)
1✔
1678
        allow_opt_out = (obj.type == 'delete')
1✔
1679
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1680
        inner_obj_id = inner_obj_as1.get('id')
1✔
1681
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1682
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1683
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1684
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1685
        is_self_reply = False
1✔
1686

1687
        original_ids = []
1✔
1688
        if is_reply:
1✔
1689
            original_ids = in_reply_tos
1✔
1690
        elif inner_obj_id:
1✔
1691
            if inner_obj_id == from_user.key.id():
1✔
1692
                inner_obj_id = from_user.profile_id()
1✔
1693
            original_ids = [inner_obj_id]
1✔
1694

1695
        # maps id to Object
1696
        original_objs = {}
1✔
1697
        for id in original_ids:
1✔
1698
            if proto := Protocol.for_id(id):
1✔
1699
                original_objs[id] = proto.load(id, raise_=False)
1✔
1700

1701
        # for AP, add in-reply-tos' mentions
1702
        # https://github.com/snarfed/bridgy-fed/issues/1608
1703
        # https://github.com/snarfed/bridgy-fed/issues/1218
1704
        orig_post_mentions = {}  # maps mentioned id to original post Object
1✔
1705
        for id in in_reply_tos:
1✔
1706
            if ((in_reply_to_obj := original_objs.get(id))
1✔
1707
                    and (proto := PROTOCOLS.get(in_reply_to_obj.source_protocol))
1708
                    and proto.SEND_REPLIES_TO_ORIG_POSTS_MENTIONS
1709
                    and (mentions := as1.mentions(in_reply_to_obj.as1))):
1710
                logger.info(f"Adding in-reply-to {id} 's mentions to targets: {mentions}")
1✔
1711
                target_uris.extend(mentions)
1✔
1712
                for mention in mentions:
1✔
1713
                    orig_post_mentions[mention] = in_reply_to_obj
1✔
1714

1715
        target_uris = sorted(set(target_uris))
1✔
1716
        logger.info(f'Raw targets: {target_uris}')
1✔
1717

1718
        # which protocols should we allow delivering to?
1719
        to_protocols = []
1✔
1720
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1721
                      + from_user.enabled_protocols):
1722
            if not (proto := PROTOCOLS.get(label)):
1✔
1723
                report_error(f'unknown enabled protocol {label} for {from_user.key.id()}')
1✔
1724
                continue
1✔
1725

1726
            if (obj.type == 'post' and (orig := original_objs.get(inner_obj_id))
1✔
1727
                    and orig.get_copy(proto)):
1728
                logger.info(f'Already created {id} on {label}, cowardly refusing to create there again')
1✔
1729
                continue
1✔
1730

1731
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1732
                                     or is_reply):
1733
                origs_could_bridge = None
1✔
1734

1735
                for id in original_ids:
1✔
1736
                    if not (orig := original_objs.get(id)):
1✔
1737
                        continue
1✔
1738
                    elif isinstance(orig, proto):
1✔
UNCOV
1739
                        logger.info(f'Allowing {label} for original {id}')
×
UNCOV
1740
                        break
×
1741
                    elif orig.get_copy(proto):
1✔
1742
                        logger.info(f'Allowing {label}, original {id} was bridged there')
1✔
1743
                        break
1✔
1744
                    elif from_user.is_profile(orig):
1✔
1745
                        logger.info(f"Allowing {label}, this is the user's profile")
1✔
1746
                        break
1✔
1747

1748
                    if (origs_could_bridge is not False
1✔
1749
                            and (orig_author_id := as1.get_owner(orig.as1))
1750
                            and (orig_proto := PROTOCOLS.get(orig.source_protocol))
1751
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1752
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1753

1754
                else:
1755
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1756
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
1✔
1757
                            and origs_could_bridge):
1758
                        # retry later; original obj may still be bridging
1759
                        # TODO: limit to brief window, eg no older than 2h? 1d?
1760
                        error(msg, status=304)
1✔
1761

1762
                    logger.info(msg)
1✔
1763
                    continue
1✔
1764

1765
            util.add(to_protocols, proto)
1✔
1766

1767
        # process direct targets
1768
        for target_id in target_uris:
1✔
1769
            target_proto = Protocol.for_id(target_id)
1✔
1770
            if not target_proto:
1✔
1771
                logger.info(f"Can't determine protocol for {target_id}")
1✔
1772
                continue
1✔
1773
            elif target_proto.is_blocklisted(target_id):
1✔
1774
                logger.debug(f'{target_id} is blocklisted')
1✔
1775
                continue
1✔
1776

1777
            target_obj_id = target_id
1✔
1778
            if target_id in mentioned_urls:
1✔
1779
                target_obj_id = ids.profile_id(id=target_id, proto=target_proto)
1✔
1780

1781
            orig_obj = target_proto.load(target_obj_id, raise_=False)
1✔
1782
            if not orig_obj or not orig_obj.as1:
1✔
1783
                logger.info(f"Couldn't load {target_obj_id}")
1✔
1784
                continue
1✔
1785

1786
            target_author_key = (target_proto(id=target_id).key
1✔
1787
                                 if target_id in mentioned_urls
1788
                                 else target_proto.actor_key(orig_obj))
1789
            if not from_user.is_enabled(target_proto):
1✔
1790
                # if author isn't bridged and target user is, DM a prompt and
1791
                # add a notif for the target user
1792
                if (target_id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
1793
                        and target_author_key):
1794
                    if target_author := target_author_key.get():
1✔
1795
                        if target_author.is_enabled(from_cls):
1✔
1796
                            notifications.add_notification(target_author, write_obj)
1✔
1797
                            verb, noun = (
1✔
1798
                                ('replied to', 'replies') if target_id in in_reply_tos
1799
                                else ('quoted', 'quotes') if target_id in quoted_posts
1800
                                else ('mentioned', 'mentions'))
1801
                            dms.maybe_send(from_=target_proto, to_user=from_user,
1✔
1802
                                           type='replied_to_bridged_user', text=f"""\
1803
Hi! You <a href="{inner_obj_as1.get('url') or inner_obj_id}">recently {verb}</a> {target_author.user_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.""")
1804

1805
                continue
1✔
1806

1807
            # deliver self-replies to followers
1808
            # https://github.com/snarfed/bridgy-fed/issues/639
1809
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1810
                is_self_reply = True
1✔
1811
                logger.info(f'self reply!')
1✔
1812

1813
            # also add copies' targets
1814
            for copy in orig_obj.copies:
1✔
1815
                proto = PROTOCOLS[copy.protocol]
1✔
1816
                if proto in to_protocols:
1✔
1817
                    # copies generally won't have their own Objects
1818
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1819
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {target_id}')
1✔
1820
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1821

1822
            if target_proto == from_cls:
1✔
1823
                logger.debug(f'Skipping same-protocol target {target_id}')
1✔
1824
                continue
1✔
1825

1826
            target = target_proto.target_for(orig_obj)
1✔
1827
            if not target:
1✔
1828
                # TODO: surface errors like this somehow?
UNCOV
1829
                logger.error(f"Can't find delivery target for {target_id}")
×
UNCOV
1830
                continue
×
1831

1832
            logger.debug(f'Target for {target_id} is {target}')
1✔
1833
            # only use orig_obj for inReplyTos, like/repost objects, reply's original
1834
            # post's mentions, etc
1835
            # https://github.com/snarfed/bridgy-fed/issues/1237
1836
            target_obj = None
1✔
1837
            if target_id in in_reply_tos + as1.get_ids(obj.as1, 'object'):
1✔
1838
                target_obj = orig_obj
1✔
1839
            elif target_id in orig_post_mentions:
1✔
1840
                target_obj = orig_post_mentions[target_id]
1✔
1841
            targets[Target(protocol=target_proto.LABEL, uri=target)] = target_obj
1✔
1842

1843
            if target_author_key:
1✔
1844
                logger.debug(f'Recipient is {target_author_key}')
1✔
1845
                if write_obj.add('notify', target_author_key):
1✔
1846
                    write_obj.dirty = True
1✔
1847

1848
        if obj.type == 'undo':
1✔
1849
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1850
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1851
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1852
            else:
1853
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1854
            if inner_obj:
1✔
1855
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1856
                                                internal=True))
1857

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

1860
        # deliver to followers, if appropriate
1861
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1862
        if not user_key:
1✔
1863
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1864
            return targets
1✔
1865

1866
        followers = []
1✔
1867
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1868
                and (not is_reply or is_self_reply)):
1869
            logger.info(f'Delivering to followers of {user_key}')
1✔
1870
            followers = []
1✔
1871
            for f in Follower.query(Follower.to == user_key,
1✔
1872
                                    Follower.status == 'active'):
1873
                proto = PROTOCOLS_BY_KIND[f.from_.kind()]
1✔
1874
                # skip protocol bot users
1875
                if (not Protocol.for_bridgy_subdomain(f.from_.id())
1✔
1876
                        # skip protocols this user hasn't enabled, or where the base
1877
                        # object of this activity hasn't been bridged
1878
                        and proto in to_protocols
1879
                        # we deliver to HAS_COPIES protocols separately, below. we
1880
                        # assume they have follower-independent targets.
1881
                        and not (proto.HAS_COPIES and proto.DEFAULT_TARGET)):
1882
                    followers.append(f)
1✔
1883

1884
            user_keys = [f.from_ for f in followers]
1✔
1885
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1886
            User.load_multi(users)
1✔
1887

1888
            if (not followers and
1✔
1889
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
1890
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
1891
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1892
                return {}
1✔
1893

1894
            # add to followers' feeds, if any
1895
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1896
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1897
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
1✔
1898
                    if write_obj.feed:
1✔
1899
                        write_obj.dirty = True
1✔
1900

1901
            # collect targets for followers
1902
            for user in users:
1✔
1903
                if user.is_blocking(from_user.key.id()):
1✔
1904
                    logger.debug(f'  {user.key.id()} blocks {from_user.key.id()}')
1✔
1905
                    continue
1✔
1906

1907
                # TODO: should we pass remote=False through here to Protocol.load?
1908
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1909
                if not target:
1✔
1910
                    # logger.error(f'Follower {user.key} has no delivery target')
1911
                    continue
1✔
1912

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

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

1921
        # deliver to enabled HAS_COPIES protocols proactively
1922
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
1923
            for proto in to_protocols:
1✔
1924
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
1925
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
1926
                    targets.setdefault(
1✔
1927
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1928

1929
        # de-dupe targets, discard same-domain
1930
        # maps string target URL to (Target, Object) tuple
1931
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1932
        # maps Target to Object or None
1933
        targets = {}
1✔
1934
        source_domains = [
1✔
1935
            util.domain_from_link(url) for url in
1936
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1937
            if util.is_web(url)
1938
        ]
1939
        for url in sorted(util.dedupe_urls(
1✔
1940
                candidates.keys(),
1941
                # preserve our PDS URL without trailing slash in path
1942
                # https://atproto.com/specs/did#did-documents
1943
                trailing_slash=False)):
1944
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
UNCOV
1945
                logger.info(f'Skipping same-domain target {url}')
×
UNCOV
1946
                continue
×
1947
            elif from_user.is_blocking(url):
1✔
1948
                logger.debug(f'{from_user.key.id()} blocks {url}')
1✔
1949
                continue
1✔
1950

1951
            target, obj = candidates[url]
1✔
1952
            targets[target] = obj
1✔
1953

1954
        return targets
1✔
1955

1956
    @classmethod
1✔
1957
    def load(cls, id, remote=None, local=True, raise_=True, **kwargs):
1✔
1958
        """Loads and returns an Object from datastore or HTTP fetch.
1959

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

1963
        Args:
1964
          id (str)
1965
          remote (bool): whether to fetch the object over the network. If True,
1966
            fetches even if we already have the object stored, and updates our
1967
            stored copy. If False and we don't have the object stored, returns
1968
            None. Default (None) means to fetch over the network only if we
1969
            don't already have it stored.
1970
          local (bool): whether to load from the datastore before
1971
            fetching over the network. If False, still stores back to the
1972
            datastore after a successful remote fetch.
1973
          raise_ (bool): if False, catches any :class:`request.RequestException`
1974
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1975
            ``None`` instead
1976
          kwargs: passed through to :meth:`fetch()`
1977

1978
        Returns:
1979
          models.Object: loaded object, or None if it isn't fetchable, eg a
1980
          non-URL string for Web, or ``remote`` is False and it isn't in the
1981
          datastore
1982

1983
        Raises:
1984
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1985
            is True
1986
        """
1987
        assert id
1✔
1988
        assert local or remote is not False
1✔
1989
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1990

1991
        id = ids.normalize_object_id(id=id, proto=cls)
1✔
1992

1993
        obj = orig_as1 = None
1✔
1994
        if local:
1✔
1995
            obj = Object.get_by_id(id)
1✔
1996
            if not obj:
1✔
1997
                # logger.debug(f' {id} not in datastore')
1998
                pass
1✔
1999
            elif obj.as1 or obj.csv or obj.raw or obj.deleted:
1✔
2000
                # logger.debug(f'  {id} got from datastore')
2001
                obj.new = False
1✔
2002

2003
        if remote is False:
1✔
2004
            return obj
1✔
2005
        elif remote is None and obj:
1✔
2006
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
2007
                # logger.debug(f'  last updated {obj.updated}, refreshing')
2008
                pass
1✔
2009
            else:
2010
                return obj
1✔
2011

2012
        if obj:
1✔
2013
            orig_as1 = obj.as1
1✔
2014
            obj.our_as1 = None
1✔
2015
            obj.new = False
1✔
2016
        else:
2017
            obj = Object(id=id)
1✔
2018
            if local:
1✔
2019
                # logger.debug(f'  {id} not in datastore')
2020
                obj.new = True
1✔
2021
                obj.changed = False
1✔
2022

2023
        try:
1✔
2024
            fetched = cls.fetch(obj, **kwargs)
1✔
2025
        except (RequestException, HTTPException) as e:
1✔
2026
            if raise_:
1✔
2027
                raise
1✔
2028
            util.interpret_http_exception(e)
1✔
2029
            return None
1✔
2030

2031
        if not fetched:
1✔
2032
            return None
1✔
2033

2034
        # https://stackoverflow.com/a/3042250/186123
2035
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
2036
        if size > MAX_ENTITY_SIZE:
1✔
2037
            logger.warning(f'Object is too big! {size} bytes is over {MAX_ENTITY_SIZE}')
1✔
2038
            return None
1✔
2039

2040
        obj.resolve_ids()
1✔
2041
        obj.normalize_ids()
1✔
2042

2043
        if obj.new is False:
1✔
2044
            obj.changed = obj.activity_changed(orig_as1)
1✔
2045

2046
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
2047
            if obj.source_protocol:
1✔
UNCOV
2048
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
2049
            obj.source_protocol = cls.LABEL
1✔
2050

2051
        obj.put()
1✔
2052
        return obj
1✔
2053

2054
    @classmethod
1✔
2055
    def check_supported(cls, obj, direction):
1✔
2056
        """If this protocol doesn't support this activity, raises HTTP 204.
2057

2058
        Also reports an error.
2059

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

2064
        Args:
2065
          obj (Object)
2066
          direction (str): ``'receive'`` or  ``'send'``
2067

2068
        Raises:
2069
          werkzeug.HTTPException: if this protocol doesn't support this object
2070
        """
2071
        assert direction in ('receive', 'send')
1✔
2072
        if not obj.type:
1✔
UNCOV
2073
            return
×
2074

2075
        inner = as1.get_object(obj.as1)
1✔
2076
        inner_type = as1.object_type(inner) or ''
1✔
2077
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
2078
            or (obj.type in as1.CRUD_VERBS
2079
                and inner_type
2080
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
2081
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
2082

2083
        # don't allow posts with blank content and no image/video/audio
2084
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
2085
                    else obj.as1)
2086
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
2087
                and not util.get_url(crud_obj, key='image')
2088
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
2089
                # TODO: handle articles with displayName but not content
2090
                and not source.html_to_text(crud_obj.get('content')).strip()):
2091
            error('Blank content and no image or video or audio', status=204)
1✔
2092

2093
        # receiving DMs is only allowed to protocol bot accounts
2094
        if direction == 'receive':
1✔
2095
            if recip := as1.recipient_if_dm(obj.as1):
1✔
2096
                owner = as1.get_owner(obj.as1)
1✔
2097
                if (not cls.SUPPORTS_DMS or (recip not in common.bot_user_ids()
1✔
2098
                                             and owner not in common.bot_user_ids())):
2099
                    # reply and say DMs aren't supported
2100
                    from_proto = PROTOCOLS.get(obj.source_protocol)
1✔
2101
                    to_proto = Protocol.for_id(recip)
1✔
2102
                    if owner and from_proto and to_proto:
1✔
2103
                        if ((from_user := from_proto.get_or_create(id=owner))
1✔
2104
                                and (to_user := to_proto.get_or_create(id=recip))):
2105
                            in_reply_to = (inner.get('id') if obj.type == 'post'
1✔
2106
                                           else obj.as1.get('id'))
2107
                            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✔
2108
                            type = f'dms_not_supported-{to_user.key.id()}'
1✔
2109
                            dms.maybe_send(from_=to_user, to_user=from_user,
1✔
2110
                                           text=text, type=type,
2111
                                           in_reply_to=in_reply_to)
2112

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

2115
            # check that this activity is public. only do this for some activities,
2116
            # not eg likes or follows, since Mastodon doesn't currently mark those
2117
            # as explicitly public.
2118
            elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
2119
                  and not util.domain_or_parent_in(crud_obj.get('id'), NON_PUBLIC_DOMAINS)
2120
                  and not as1.is_public(obj.as1, unlisted=False)):
2121
                error('Bridgy Fed only supports public activities', status=204)
1✔
2122

2123
    @classmethod
1✔
2124
    def block(cls, from_user, arg):
1✔
2125
        """Blocks a user or list.
2126

2127
        Args:
2128
          from_user (models.User): user doing the blocking
2129
          arg (str): handle or id of user/list to block
2130

2131
        Returns:
2132
          models.User or models.Object: user or list that was blocked
2133

2134
        Raises:
2135
          ValueError: if arg doesn't look like a user or list on this protocol
2136
        """
2137
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2138

2139
        blockee = None
1✔
2140
        err = None
1✔
2141
        try:
1✔
2142
            # first, try interpreting as a user handle or id
2143
            # TODO: move out of dms
2144
            blockee = dms._load_user(arg, cls)
1✔
2145
        except (AssertionError, BadRequest) as err:
1✔
2146
            logger.info(err)
1✔
2147

2148
        # may not be a user, see if it's a list
2149
        if not blockee:
1✔
2150
            blockee = cls.load(arg)
1✔
2151
            if not blockee or blockee.type != 'collection':
1✔
2152
                err = f"{arg} doesn't look like a user or list on {cls.PHRASE}, or we couldn't fetch it"
1✔
2153
                logger.warning(err)
1✔
2154
                raise ValueError(err)
1✔
2155

2156
        logger.info(f'  blocking {blockee.key.id()}')
1✔
2157
        id = f'{from_user.key.id()}#bridgy-fed-block-{util.now().isoformat()}'
1✔
2158
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2159
            'objectType': 'activity',
2160
            'verb': 'block',
2161
            'id': id,
2162
            'actor': from_user.key.id(),
2163
            'object': blockee.key.id(),
2164
        })
2165
        obj.put()
1✔
2166
        from_user.deliver(obj, from_user=from_user)
1✔
2167

2168
        return blockee
1✔
2169

2170
    @classmethod
1✔
2171
    def unblock(cls, from_user, arg):
1✔
2172
        """Unblocks a user or list.
2173

2174
        Args:
2175
          from_user (models.User): user doing the unblocking
2176
          arg (str): handle or id of user/list to unblock
2177

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

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

2186
        blockee = None
1✔
2187
        try:
1✔
2188
            # first, try interpreting as a user handle or id
2189
            blockee = dms._load_user(arg, cls)
1✔
2190
        except BadRequest:
1✔
2191
            pass
1✔
2192

2193
        # may not be a user, see if it's a list
2194
        if not blockee:
1✔
2195
            blockee = cls.load(arg)
1✔
2196
            if not blockee or blockee.type != 'collection':
1✔
2197
                err = f"{arg} doesn't look like a user or list on {cls.PHRASE}"
1✔
2198
                logger.warning(err)
1✔
2199
                raise ValueError(err)
1✔
2200

2201
        logger.info(f'  unblocking {blockee.key.id()}')
1✔
2202
        id = f'{from_user.key.id()}#bridgy-fed-unblock-{util.now().isoformat()}'
1✔
2203
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2204
            'objectType': 'activity',
2205
            'verb': 'undo',
2206
            'id': id,
2207
            'actor': from_user.key.id(),
2208
            'object': {
2209
                'objectType': 'activity',
2210
                'verb': 'block',
2211
                'actor': from_user.key.id(),
2212
                'object': blockee.key.id(),
2213
            },
2214
        })
2215
        obj.put()
1✔
2216
        from_user.deliver(obj, from_user=from_user)
1✔
2217

2218
        return blockee
1✔
2219

2220

2221
@cloud_tasks_only(log=None)
1✔
2222
def receive_task():
1✔
2223
    """Task handler for a newly received :class:`models.Object`.
2224

2225
    Calls :meth:`Protocol.receive` with the form parameters.
2226

2227
    Parameters:
2228
      authed_as (str): passed to :meth:`Protocol.receive`
2229
      obj_id (str): key id of :class:`models.Object` to handle
2230
      received_at (str, ISO 8601 timestamp): when we first saw (received)
2231
        this activity
2232
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2233
        :class:`models.Object` to handle
2234

2235
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
2236
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
2237
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
2238
    ``changed``, HTTP request details, etc. See stash for attempt at this for
2239
    :class:`web.Web`.
2240
    """
2241
    common.log_request()
1✔
2242
    form = request.form.to_dict()
1✔
2243

2244
    authed_as = form.pop('authed_as', None)
1✔
2245
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
2246
                or authed_as in common.PROTOCOL_DOMAINS)
2247

2248
    obj = Object.from_request()
1✔
2249
    assert obj
1✔
2250
    assert obj.source_protocol
1✔
2251
    obj.new = True
1✔
2252

2253
    if received_at := form.pop('received_at', None):
1✔
2254
        received_at = datetime.fromisoformat(received_at)
1✔
2255

2256
    try:
1✔
2257
        return PROTOCOLS[obj.source_protocol].receive(
1✔
2258
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
2259
    except RequestException as e:
1✔
2260
        util.interpret_http_exception(e)
1✔
2261
        error(e, status=304)
1✔
2262
    except ValueError as e:
1✔
UNCOV
2263
        logger.warning(e, exc_info=True)
×
UNCOV
2264
        error(e, status=304)
×
2265

2266

2267
@cloud_tasks_only(log=None)
1✔
2268
def send_task():
1✔
2269
    """Task handler for sending an activity to a single specific destination.
2270

2271
    Calls :meth:`Protocol.send` with the form parameters.
2272

2273
    Parameters:
2274
      protocol (str): :class:`Protocol` to send to
2275
      url (str): destination URL to send to
2276
      obj_id (str): key id of :class:`models.Object` to send
2277
      orig_obj_id (str): optional, :class:`models.Object` key id of the
2278
        "original object" that this object refers to, eg replies to or reposts
2279
        or likes
2280
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
2281
        this activity is from
2282
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2283
        :class:`models.Object` to handle
2284
    """
2285
    common.log_request()
1✔
2286

2287
    # prepare
2288
    form = request.form.to_dict()
1✔
2289
    url = form.get('url')
1✔
2290
    protocol = form.get('protocol')
1✔
2291
    if not url or not protocol:
1✔
2292
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
2293
        return '', 204
1✔
2294

2295
    target = Target(uri=url, protocol=protocol)
1✔
2296
    obj = Object.from_request()
1✔
2297
    assert obj and obj.key and obj.key.id()
1✔
2298

2299
    PROTOCOLS[protocol].check_supported(obj, 'send')
1✔
2300
    allow_opt_out = (obj.type == 'delete')
1✔
2301

2302
    user = None
1✔
2303
    if user_key := form.get('user'):
1✔
2304
        key = ndb.Key(urlsafe=user_key)
1✔
2305
        # use get_by_id so that we follow use_instead
2306
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2307
            key.id(), allow_opt_out=allow_opt_out)
2308

2309
    # send
2310
    delay = ''
1✔
2311
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
2312
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
2313
        delay = f'({delay_s} s behind)'
1✔
2314
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
2315
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
2316
    sent = None
1✔
2317
    try:
1✔
2318
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
2319
                                        orig_obj_id=form.get('orig_obj_id'))
2320
    except BaseException as e:
1✔
2321
        code, body = util.interpret_http_exception(e)
1✔
2322
        if not code and not body:
1✔
2323
            raise
1✔
2324

2325
    if sent is False:
1✔
2326
        logger.info(f'Failed sending!')
1✔
2327

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

© 2026 Coveralls, Inc