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

snarfed / bridgy-fed / ec44e7ea-657d-4f17-bb0e-45c134bd2c0b

18 Jun 2025 02:02AM UTC coverage: 92.276% (+0.1%) from 92.153%
ec44e7ea-657d-4f17-bb0e-45c134bd2c0b

push

circleci

snarfed
noop: tests: add tz kwarg to mocked util.now

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

74 existing lines in 4 files now uncovered.

5436 of 5891 relevant lines covered (92.28%)

0.92 hits per line

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

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

10
from cachetools import cached, LRUCache
1✔
11
from flask import request
1✔
12
from google.cloud import ndb
1✔
13
from google.cloud.ndb import OR
1✔
14
from google.cloud.ndb.model import _entity_to_protobuf
1✔
15
from granary import as1, as2, source
1✔
16
from granary.source import html_to_text
1✔
17
from oauth_dropins.webutil.appengine_info import DEBUG
1✔
18
from oauth_dropins.webutil.flask_util import cloud_tasks_only
1✔
19
from oauth_dropins.webutil import models
1✔
20
from oauth_dropins.webutil import util
1✔
21
from oauth_dropins.webutil.util import json_dumps, json_loads
1✔
22
from requests import RequestException
1✔
23
import werkzeug.exceptions
1✔
24
from werkzeug.exceptions import BadGateway, HTTPException
1✔
25

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

57
OBJECT_REFRESH_AGE = timedelta(days=30)
1✔
58
DELETE_TASK_DELAY = timedelta(minutes=2)
1✔
59
CREATE_MAX_AGE = timedelta(weeks=2)
1✔
60

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

66
DONT_STORE_AS1_TYPES = as1.CRUD_VERBS | set((
1✔
67
    'accept',
68
    'reject',
69
    'stop-following',
70
    'undo',
71
))
72
STORE_AS1_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES | as1.VERBS_WITH_OBJECT
1✔
73
                   - DONT_STORE_AS1_TYPES)
74

75
logger = logging.getLogger(__name__)
1✔
76

77

78
def error(*args, status=299, **kwargs):
1✔
79
    """Default HTTP status code to 299 to prevent retrying task."""
80
    return common.error(*args, status=status, **kwargs)
1✔
81

82

83
def activity_id_memcache_key(id):
1✔
84
    return memcache.key(f'receive-{id}')
1✔
85

86

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

122
    def __init__(self):
1✔
123
        assert False
×
124

125
    @classmethod
1✔
126
    @property
1✔
127
    def LABEL(cls):
1✔
128
        """str: human-readable lower case name of this protocol, eg ``'activitypub``"""
129
        return cls.__name__.lower()
1✔
130

131
    @staticmethod
1✔
132
    def for_request(fed=None):
1✔
133
        """Returns the protocol for the current request.
134

135
        ...based on the request's hostname.
136

137
        Args:
138
          fed (str or protocol.Protocol): protocol to return if the current
139
            request is on ``fed.brid.gy``
140

141
        Returns:
142
          Protocol: protocol, or None if the provided domain or request hostname
143
          domain is not a subdomain of ``brid.gy`` or isn't a known protocol
144
        """
145
        return Protocol.for_bridgy_subdomain(request.host, fed=fed)
1✔
146

147
    @staticmethod
1✔
148
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
149
        """Returns the protocol for a brid.gy subdomain.
150

151
        Args:
152
          domain_or_url (str)
153
          fed (str or protocol.Protocol): protocol to return if the current
154
            request is on ``fed.brid.gy``
155

156
        Returns:
157
          class: :class:`Protocol` subclass, or None if the provided domain or request
158
          hostname domain is not a subdomain of ``brid.gy`` or isn't a known
159
          protocol
160
        """
161
        domain = (util.domain_from_link(domain_or_url, minimize=False)
1✔
162
                  if util.is_web(domain_or_url)
163
                  else domain_or_url)
164

165
        if domain == common.PRIMARY_DOMAIN or domain in common.LOCAL_DOMAINS:
1✔
166
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
1✔
167
        elif domain and domain.endswith(common.SUPERDOMAIN):
1✔
168
            label = domain.removesuffix(common.SUPERDOMAIN)
1✔
169
            return PROTOCOLS.get(label)
1✔
170

171
    @classmethod
1✔
172
    def owns_id(cls, id):
1✔
173
        """Returns whether this protocol owns the id, or None if it's unclear.
174

175
        To be implemented by subclasses.
176

177
        IDs are string identities that uniquely identify users, and are intended
178
        primarily to be machine readable and usable. Compare to handles, which
179
        are human-chosen, human-meaningful, and often but not always unique.
180

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

185
        This should be a quick guess without expensive side effects, eg no
186
        external HTTP fetches to fetch the id itself or otherwise perform
187
        discovery.
188

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

191
        Args:
192
          id (str)
193

194
        Returns:
195
          bool or None:
196
        """
197
        return False
1✔
198

199
    @classmethod
1✔
200
    def owns_handle(cls, handle, allow_internal=False):
1✔
201
        """Returns whether this protocol owns the handle, or None if it's unclear.
202

203
        To be implemented by subclasses.
204

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

209
        Some protocols' handles are more or less deterministic based on the id
210
        format, eg ActivityPub (technically WebFinger) handles are
211
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
212
        ActivityPub, AT Protocol, or others.
213

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

218
        Args:
219
          handle (str)
220
          allow_internal (bool): whether to return False for internal domains
221
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
222

223
        Returns:
224
          bool or None
225
        """
226
        return False
1✔
227

228
    @classmethod
1✔
229
    def handle_to_id(cls, handle):
1✔
230
        """Converts a handle to an id.
231

232
        To be implemented by subclasses.
233

234
        May incur network requests, eg DNS queries or HTTP requests. Avoids
235
        blocked or opted out users.
236

237
        Args:
238
          handle (str)
239

240
        Returns:
241
          str: corresponding id, or None if the handle can't be found
242
        """
243
        raise NotImplementedError()
×
244

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

249
        To be implemented by subclasses. Canonicalizes the id if necessary.
250

251
        If called via `Protocol.key_for`, infers the appropriate protocol with
252
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
253
        as is.
254

255
        Args:
256
          id (str):
257
          allow_opt_out (bool): whether to allow users who are currently opted out
258

259
        Returns:
260
          google.cloud.ndb.Key: matching key, or None if the given id is not a
261
          valid :class:`User` id for this protocol.
262
        """
263
        if cls == Protocol:
1✔
264
            proto = Protocol.for_id(id)
1✔
265
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
266

267
        # load user so that we follow use_instead
268
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
269
        if existing:
1✔
270
            if existing.status and not allow_opt_out:
1✔
271
                return None
1✔
272
            return existing.key
1✔
273

274
        return cls(id=id).key
1✔
275

276
    @staticmethod
1✔
277
    def _for_id_memcache_key(id, remote=None):
1✔
278
        """If id is a URL, uses its domain, otherwise returns None.
279

280
        Args:
281
          id (str)
282

283
        Returns:
284
          (str domain, bool remote) or None
285
        """
286
        domain = util.domain_from_link(id)
1✔
287
        if domain in PROTOCOL_DOMAINS:
1✔
288
            return id
1✔
289
        elif remote and util.is_web(id):
1✔
290
            return domain
1✔
291

292
    @cached(LRUCache(20000), lock=Lock())
1✔
293
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote=True: remote,
1✔
294
                      version=3)
295
    @staticmethod
1✔
296
    def for_id(id, remote=True):
1✔
297
        """Returns the protocol for a given id.
298

299
        Args:
300
          id (str)
301
          remote (bool): whether to perform expensive side effects like fetching
302
            the id itself over the network, or other discovery.
303

304
        Returns:
305
          Protocol subclass: matching protocol, or None if no single known
306
          protocol definitively owns this id
307
        """
308
        logger.debug(f'Determining protocol for id {id}')
1✔
309
        if not id:
1✔
310
            return None
1✔
311

312
        # remove our synthetic id fragment, if any
313
        #
314
        # will this eventually cause false positives for other services that
315
        # include our full ids inside their own ids, non-URL-encoded? guess
316
        # we'll figure that out if/when it happens.
317
        id = id.partition('#bridgy-fed-')[0]
1✔
318
        if not id:
1✔
319
            return None
1✔
320

321
        if util.is_web(id):
1✔
322
            # step 1: check for our per-protocol subdomains
323
            try:
1✔
324
                parsed = urlparse(id)
1✔
325
            except ValueError as e:
1✔
326
                logger.info(f'urlparse ValueError: {e}')
1✔
327
                return None
1✔
328

329
            is_homepage = parsed.path.strip('/') == ''
1✔
330
            is_internal = parsed.path.startswith(ids.INTERNAL_PATH_PREFIX)
1✔
331
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
332
            if by_subdomain and not (is_homepage or is_internal
1✔
333
                                     or id in BOT_ACTOR_AP_IDS):
334
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
335
                return by_subdomain
1✔
336

337
        # step 2: check if any Protocols say conclusively that they own it
338
        # sort to be deterministic
339
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
340
                           key=lambda p: p.LABEL)
341
        candidates = []
1✔
342
        for protocol in protocols:
1✔
343
            owns = protocol.owns_id(id)
1✔
344
            if owns:
1✔
345
                logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
346
                return protocol
1✔
347
            elif owns is not False:
1✔
348
                candidates.append(protocol)
1✔
349

350
        if len(candidates) == 1:
1✔
351
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
352
            return candidates[0]
1✔
353

354
        # step 3: look for existing Objects in the datastore
355
        #
356
        # note that we don't currently see if this is a copy id because I have FUD
357
        # over which Protocol for_id should return in that case...and also because a
358
        # protocol may already say definitively above that it owns the id, eg ATProto
359
        # with DIDs and at:// URIs.
360
        obj = Protocol.load(id, remote=False)
1✔
361
        if obj and obj.source_protocol:
1✔
362
            logger.debug(f'  {obj.key.id()} owned by source_protocol {obj.source_protocol}')
1✔
363
            return PROTOCOLS[obj.source_protocol]
1✔
364

365
        # step 4: fetch over the network, if necessary
366
        if not remote:
1✔
367
            return None
1✔
368

369
        for protocol in candidates:
1✔
370
            logger.debug(f'Trying {protocol.LABEL}')
1✔
371
            try:
1✔
372
                obj = protocol.load(id, local=False, remote=True)
1✔
373

374
                if protocol.ABBREV == 'web':
1✔
375
                    # for web, if we fetch and get HTML without microformats,
376
                    # load returns False but the object will be stored in the
377
                    # datastore with source_protocol web, and in cache. load it
378
                    # again manually to check for that.
379
                    obj = Object.get_by_id(id)
1✔
380
                    if obj and obj.source_protocol != 'web':
1✔
381
                        obj = None
×
382

383
                if obj:
1✔
384
                    logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
385
                    return protocol
1✔
386
            except BadGateway:
1✔
387
                # we tried and failed fetching the id over the network.
388
                # this depends on ActivityPub.fetch raising this!
389
                return None
1✔
390
            except HTTPException as e:
×
391
                # internal error we generated ourselves; try next protocol
392
                pass
×
393
            except Exception as e:
×
394
                code, _ = util.interpret_http_exception(e)
×
395
                if code:
×
396
                    # we tried and failed fetching the id over the network
397
                    return None
×
398
                raise
×
399

400
        logger.info(f'No matching protocol found for {id} !')
1✔
401
        return None
1✔
402

403
    @cached(LRUCache(20000), lock=Lock())
1✔
404
    @staticmethod
1✔
405
    def for_handle(handle):
1✔
406
        """Returns the protocol for a given handle.
407

408
        May incur expensive side effects like resolving the handle itself over
409
        the network or other discovery.
410

411
        Args:
412
          handle (str)
413

414
        Returns:
415
          (Protocol subclass, str) tuple: matching protocol and optional id (if
416
          resolved), or ``(None, None)`` if no known protocol owns this handle
417
        """
418
        # TODO: normalize, eg convert domains to lower case
419
        logger.debug(f'Determining protocol for handle {handle}')
1✔
420
        if not handle:
1✔
421
            return (None, None)
1✔
422

423
        # step 1: check if any Protocols say conclusively that they own it.
424
        # sort to be deterministic.
425
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
426
                           key=lambda p: p.LABEL)
427
        candidates = []
1✔
428
        for proto in protocols:
1✔
429
            owns = proto.owns_handle(handle)
1✔
430
            if owns:
1✔
431
                logger.debug(f'  {proto.LABEL} owns handle {handle}')
1✔
432
                return (proto, None)
1✔
433
            elif owns is not False:
1✔
434
                candidates.append(proto)
1✔
435

436
        if len(candidates) == 1:
1✔
437
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
438
            return (candidates[0], None)
×
439

440
        # step 2: look for matching User in the datastore
441
        for proto in candidates:
1✔
442
            user = proto.query(proto.handle == handle).get()
1✔
443
            if user:
1✔
444
                if user.status:
1✔
445
                    return (None, None)
1✔
446
                logger.debug(f'  user {user.key} handle {handle}')
1✔
447
                return (proto, user.key.id())
1✔
448

449
        # step 3: resolve handle to id
450
        for proto in candidates:
1✔
451
            id = proto.handle_to_id(handle)
1✔
452
            if id:
1✔
453
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
454
                return (proto, id)
1✔
455

456
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
457
        return (None, None)
1✔
458

459
    @classmethod
1✔
460
    def is_user_at_domain(cls, handle, allow_internal=False):
1✔
461
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
462

463
        Example: ``@user@instance.com``
464

465
        Args:
466
          handle (str)
467
          allow_internal (bool): whether the domain can be a Bridgy Fed domain
468
        """
469
        parts = handle.split('@')
1✔
470
        if len(parts) != 2:
1✔
471
            return False
1✔
472

473
        user, domain = parts
1✔
474
        return bool(user and domain
1✔
475
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
476

477
    @classmethod
1✔
478
    def bridged_web_url_for(cls, user, fallback=False):
1✔
479
        """Returns the web URL for a user's bridged profile in this protocol.
480

481
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
482
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
483

484
        Args:
485
          user (models.User)
486
          fallback (bool): if True, and bridged users have no canonical user
487
            profile URL in this protocol, return the native protocol's profile URL
488

489
        Returns:
490
          str, or None if there isn't a canonical URL
491
        """
492
        if fallback:
1✔
493
            return user.web_url()
1✔
494

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

499
        Args:
500
          obj (models.Object)
501
          allow_opt_out (bool): whether to return a user key if they're opted out
502

503
        Returns:
504
          google.cloud.ndb.key.Key or None:
505
        """
506
        owner = as1.get_owner(obj.as1)
1✔
507
        if owner:
1✔
508
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
509

510
    @classmethod
1✔
511
    def bot_user_id(cls):
1✔
512
        """Returns the Web user id for the bot user for this protocol.
513

514
        For example, ``'bsky.brid.gy'`` for ATProto.
515

516
        Returns:
517
          str:
518
        """
519
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
520

521
    @classmethod
1✔
522
    def create_for(cls, user):
1✔
523
        """Creates or re-activate a copy user in this protocol.
524

525
        Should add the copy user to :attr:`copies`.
526

527
        If the copy user already exists and active, should do nothing.
528

529
        Args:
530
          user (models.User): original source user. Shouldn't already have a
531
            copy user for this protocol in :attr:`copies`.
532

533
        Raises:
534
          ValueError: if we can't create a copy of the given user in this protocol
535
        """
536
        raise NotImplementedError()
×
537

538
    @classmethod
1✔
539
    def send(to_cls, obj, target, from_user=None, orig_obj_id=None):
1✔
540
        """Sends an outgoing activity.
541

542
        To be implemented by subclasses. Should call
543
        ``to_cls.translate_ids(obj.as1)`` before converting it to this Protocol's
544
        format.
545

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

550
        Args:
551
          obj (models.Object): with activity to send
552
          target (str): destination URL to send to
553
          from_user (models.User): user (actor) this activity is from
554
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
555
            that this object refers to, eg replies to or reposts or likes
556

557
        Returns:
558
          bool: True if the activity is sent successfully, False if it is
559
          ignored or otherwise unsent due to protocol logic, eg no webmention
560
          endpoint, protocol doesn't support the activity type. (Failures are
561
          raised as exceptions.)
562

563
        Raises:
564
          werkzeug.HTTPException if the request fails
565
        """
566
        raise NotImplementedError()
×
567

568
    @classmethod
1✔
569
    def fetch(cls, obj, **kwargs):
1✔
570
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
571

572
        Errors are raised as exceptions. If this method returns False, the fetch
573
        didn't fail but didn't succeed either, eg the id isn't valid for this
574
        protocol, or the fetch didn't return valid data for this protocol.
575

576
        To be implemented by subclasses.
577

578
        Args:
579
          obj (models.Object): with the id to fetch. Data is filled into one of
580
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
581
          kwargs: subclass-specific
582

583
        Returns:
584
          bool: True if the object was fetched and populated successfully,
585
          False otherwise
586

587
        Raises:
588
          requests.RequestException, werkzeug.HTTPException,
589
          websockets.WebSocketException, etc: if the fetch fails
590
        """
591
        raise NotImplementedError()
×
592

593
    @classmethod
1✔
594
    def convert(cls, obj, from_user=None, **kwargs):
1✔
595
        """Converts an :class:`Object` to this protocol's data format.
596

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

600
        Just passes through to :meth:`_convert`, then does minor
601
        protocol-independent postprocessing.
602

603
        Args:
604
          obj (models.Object):
605
          from_user (models.User): user (actor) this activity/object is from
606
          kwargs: protocol-specific, passed through to :meth:`_convert`
607

608
        Returns:
609
          converted object in the protocol's native format, often a dict
610
        """
611
        if not obj or not obj.as1:
1✔
612
            return {}
1✔
613

614
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
615
        is_activity = obj.as1.get('verb') in as1.CRUD_VERBS
1✔
616
        base_obj = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
617
        orig_our_as1 = obj.our_as1
1✔
618

619
        # mark bridged actors as bots and add "bridged by Bridgy Fed" to their bios
620
        if (from_user and base_obj
1✔
621
            and base_obj.get('objectType') in as1.ACTOR_TYPES
622
            and PROTOCOLS.get(obj.source_protocol) != cls
623
            and Protocol.for_bridgy_subdomain(id) not in DOMAINS
624
            # Web users are special cased, they don't get the label if they've
625
            # explicitly enabled Bridgy Fed with redirects or webmentions
626
            and not (from_user.LABEL == 'web'
627
                     and (from_user.last_webmention_in or from_user.has_redirects))):
628

629
            cls.add_source_links(obj=obj, from_user=from_user)
1✔
630

631
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
632
        obj.our_as1 = orig_our_as1
1✔
633
        return converted
1✔
634

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

639
        To be implemented by subclasses. Implementations should generally call
640
        :meth:`Protocol.translate_ids` (as their own class) before converting to
641
        their format.
642

643
        Args:
644
          obj (models.Object):
645
          from_user (models.User): user (actor) this activity/object is from
646
          kwargs: protocol-specific
647

648
        Returns:
649
          converted object in the protocol's native format, often a dict. May
650
            return the ``{}`` empty dict if the object can't be converted.
651
        """
652
        raise NotImplementedError()
×
653

654
    @classmethod
1✔
655
    def add_source_links(cls, obj, from_user):
1✔
656
        """Adds "bridged from ... by Bridgy Fed" to the user's actor's ``summary``.
657

658
        Uses HTML for protocols that support it, plain text otherwise.
659

660
        Args:
661
          obj (models.Object): user's actor/profile object
662
          from_user (models.User): user (actor) this activity/object is from
663
        """
664
        assert obj and obj.as1
1✔
665
        assert from_user
1✔
666

667
        obj.our_as1 = copy.deepcopy(obj.as1)
1✔
668
        actor = (as1.get_object(obj.as1) if obj.as1.get('verb') in as1.CRUD_VERBS
1✔
669
                 else obj.as1)
670
        actor['objectType'] = 'person'
1✔
671

672
        orig_summary = actor.setdefault('summary', '')
1✔
673
        summary_text = html_to_text(orig_summary, ignore_links=True)
1✔
674

675
        # Check if we've already added source links
676
        if '🌉 bridged' in summary_text:
1✔
677
            return
1✔
678

679
        actor_id = actor.get('id')
1✔
680
        proto_phrase = (f' on {PROTOCOLS[obj.source_protocol].PHRASE}'
1✔
681
                        if obj.source_protocol else '')
682
        url = as1.get_url(actor) or obj.key.id() if obj.key else actor_id
1✔
683

684
        if cls.HTML_PROFILES:
1✔
685
            by = f' by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>'
1✔
686
            separator = '<br><br>'
1✔
687

688
            is_user = from_user.key and actor_id in (from_user.key.id(),
1✔
689
                                                     from_user.profile_id())
690
            if is_user:
1✔
691
                bridged = f'🌉 <a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a>'
1✔
692
                from_ = f'<a href="{from_user.web_url()}">{from_user.handle}</a>'
1✔
693
            else:
694
                bridged = '🌉 bridged'
1✔
695
                from_ = util.pretty_link(url) if url else '?'
1✔
696

697
        else:  # plain text
698
            # TODO: unify with above. which is right?
699
            id = obj.key.id() if obj.key else obj.our_as1.get('id')
1✔
700
            is_user = from_user.key and id in (from_user.key.id(),
1✔
701
                                               from_user.profile_id())
702
            from_ = (from_user.web_url() if is_user else url) or '?'
1✔
703

704
            bridged = '🌉 bridged'
1✔
705
            by = (f': https://{PRIMARY_DOMAIN}{from_user.user_page_path()}'
1✔
706
                  # link web users to their user pages
707
                  if from_user.LABEL == 'web'
708
                  else f' by https://{PRIMARY_DOMAIN}/')
709
            separator = '\n\n'
1✔
710
            orig_summary = summary_text
1✔
711

712
        source_links = f'{separator if orig_summary else ""}{bridged} from {from_}{proto_phrase}{by}'
1✔
713
        actor['summary'] = orig_summary + source_links
1✔
714

715
    @classmethod
1✔
716
    def set_username(to_cls, user, username):
1✔
717
        """Sets a custom username for a user's bridged account in this protocol.
718

719
        Args:
720
          user (models.User)
721
          username (str)
722

723
        Raises:
724
          ValueError: if the username is invalid
725
          RuntimeError: if the username could not be set
726
        """
727
        raise NotImplementedError()
1✔
728

729
    @classmethod
1✔
730
    def migrate_out(cls, user, to_user_id):
1✔
731
        """Migrates a bridged account out to be a native account.
732

733
        Args:
734
          user (models.User)
735
          to_user_id (str)
736

737
        Raises:
738
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
739
            ``user`` is on this protocol or not bridged to this protocol
740
        """
741
        raise NotImplementedError()
×
742

743
    @classmethod
1✔
744
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
745
        """Migrates a native account in to be a bridged account.
746

747
        Args:
748
          user (models.User): native user on another protocol to attach the
749
            newly imported bridged account to
750
          from_user_id (str)
751
          kwargs: additional protocol-specific parameters
752

753
        Raises:
754
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
755
            ``user`` is on this protocol or already bridged to this protocol
756
        """
757
        raise NotImplementedError()
×
758

759
    @classmethod
1✔
760
    def target_for(cls, obj, shared=False):
1✔
761
        """Returns an :class:`Object`'s delivery target (endpoint).
762

763
        To be implemented by subclasses.
764

765
        Examples:
766

767
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
768
          webmention target.
769
        * If obj is an ``activitypub`` actor, returns its inbox.
770
        * If obj is an ``activitypub`` object, returns it's author's or actor's
771
          inbox.
772

773
        Args:
774
          obj (models.Object):
775
          shared (bool): optional. If True, returns a common/shared
776
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
777
            multiple recipients for efficiency
778

779
        Returns:
780
          str: target endpoint, or None if not available.
781
        """
782
        raise NotImplementedError()
×
783

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

788
        Default implementation here, subclasses may override.
789

790
        Args:
791
          url (str):
792
          allow_internal (bool): whether to return False for internal domains
793
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
794
        """
795
        blocklist = DOMAIN_BLOCKLIST
1✔
796
        if not DEBUG:
1✔
UNCOV
797
            blocklist += tuple(util.RESERVED_TLDS | util.LOCAL_TLDS)
×
798
        if not allow_internal:
1✔
799
            blocklist += DOMAINS
1✔
800
        return util.domain_or_parent_in(util.domain_from_link(url), blocklist)
1✔
801

802
    @classmethod
1✔
803
    def translate_ids(to_cls, obj):
1✔
804
        """Translates all ids in an AS1 object to a specific protocol.
805

806
        Infers source protocol for each id value separately.
807

808
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
809
        ``at://did:plc:abc/coll/123`` will be converted to
810
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
811

812
        Wraps these AS1 fields:
813

814
        * ``id``
815
        * ``actor``
816
        * ``author``
817
        * ``bcc``
818
        * ``bto``
819
        * ``cc``
820
        * ``featured[].items``, ``featured[].orderedItems``
821
        * ``object``
822
        * ``object.actor``
823
        * ``object.author``
824
        * ``object.id``
825
        * ``object.inReplyTo``
826
        * ``object.object``
827
        * ``attachments[].id``
828
        * ``tags[objectType=mention].url``
829
        * ``to``
830

831
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
832
        same logic is duplicated there!
833

834
        TODO: unify with :meth:`Object.resolve_ids`,
835
        :meth:`models.Object.normalize_ids`.
836

837
        Args:
838
          to_proto (Protocol subclass)
839
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
840

841
        Returns:
842
          dict: wrapped AS1 version of ``obj``
843
        """
844
        assert to_cls != Protocol
1✔
845
        if not obj:
1✔
846
            return obj
1✔
847

848
        outer_obj = copy.deepcopy(obj)
1✔
849
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
850

851
        def translate(elem, field, fn, uri=False):
1✔
852
            elem[field] = as1.get_objects(elem, field)
1✔
853
            for obj in elem[field]:
1✔
854
                if id := obj.get('id'):
1✔
855
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
856
                        continue
1✔
857
                    from_cls = Protocol.for_id(id)
1✔
858
                    # TODO: what if from_cls is None? relax translate_object_id,
859
                    # make it a noop if we don't know enough about from/to?
860
                    if from_cls and from_cls != to_cls:
1✔
861
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
862
                    if obj['id'] and uri:
1✔
863
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
864

865
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
866
                           for o in elem[field]]
867

868
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
869
                elem[field] = elem[field][0]
1✔
870

871
        type = as1.object_type(outer_obj)
1✔
872
        translate(outer_obj, 'id',
1✔
873
                  translate_user_id if type in as1.ACTOR_TYPES
874
                  else translate_object_id)
875

876
        for o in inner_objs:
1✔
877
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
878
                        or as1.get_owner(outer_obj) == o.get('id')
879
                        or type in ('follow', 'stop-following'))
880
            translate(o, 'id', translate_user_id if is_actor else translate_object_id)
1✔
881
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
882
            translate(o, 'object', translate_user_id if obj_is_actor
1✔
883
                      else translate_object_id)
884

885
        for o in [outer_obj] + inner_objs:
1✔
886
            translate(o, 'inReplyTo', translate_object_id)
1✔
887
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
888
                translate(o, field, translate_user_id)
1✔
889
            for tag in as1.get_objects(o, 'tags'):
1✔
890
                if tag.get('objectType') == 'mention':
1✔
891
                    translate(tag, 'url', translate_user_id, uri=True)
1✔
892
            for att in as1.get_objects(o, 'attachments'):
1✔
893
                translate(att, 'id', translate_object_id)
1✔
894
                url = att.get('url')
1✔
895
                if url and not att.get('id'):
1✔
896
                    if from_cls := Protocol.for_id(url):
1✔
897
                        att['id'] = translate_object_id(from_=from_cls, to=to_cls,
1✔
898
                                                        id=url)
899
            if feat := as1.get_object(o, 'featured'):
1✔
900
                translate(feat, 'orderedItems', translate_object_id)
1✔
901
                translate(feat, 'items', translate_object_id)
1✔
902

903
        outer_obj = util.trim_nulls(outer_obj)
1✔
904

905
        if objs := util.get_list(outer_obj ,'object'):
1✔
906
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
907
            if len(outer_obj['object']) == 1:
1✔
908
                outer_obj['object'] = outer_obj['object'][0]
1✔
909

910
        return outer_obj
1✔
911

912
    @classmethod
1✔
913
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
914
        """Handles an incoming activity.
915

916
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
917
        unset, returns HTTP 299.
918

919
        Args:
920
          obj (models.Object)
921
          authed_as (str): authenticated actor id who sent this activity
922
          internal (bool): whether to allow activity ids on internal domains,
923
            from opted out/blocked users, etc.
924
          received_at (datetime): when we first saw (received) this activity.
925
            Right now only used for monitoring.
926

927
        Returns:
928
          (str, int) tuple: (response body, HTTP status code) Flask response
929

930
        Raises:
931
          werkzeug.HTTPException: if the request is invalid
932
        """
933
        # check some invariants
934
        assert from_cls != Protocol
1✔
935
        assert isinstance(obj, Object), obj
1✔
936

937
        if not obj.as1:
1✔
UNCOV
938
            error('No object data provided')
×
939

940
        id = None
1✔
941
        if obj.key and obj.key.id():
1✔
942
            id = obj.key.id()
1✔
943

944
        if not id:
1✔
945
            id = obj.as1.get('id')
1✔
946
            obj.key = ndb.Key(Object, id)
1✔
947

948
        if not id:
1✔
UNCOV
949
            error('No id provided')
×
950
        elif from_cls.owns_id(id) is False:
1✔
951
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
952
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
953
            error(f'Activity {id} is blocklisted')
1✔
954
        # check that this activity is public. only do this for some activities,
955
        # not eg likes or follows, since Mastodon doesn't currently mark those
956
        # as explicitly public.
957
        elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
958
                  and not as1.is_public(obj.as1, unlisted=False)
959
                  and not as1.is_dm(obj.as1)):
960
              logger.info('Dropping non-public activity')
1✔
961
              return ('OK', 200)
1✔
962

963
        # lease this object, atomically
964
        memcache_key = activity_id_memcache_key(id)
1✔
965
        leased = memcache.memcache.add(memcache_key, 'leased', noreply=False,
1✔
966
                                       expire=5 * 60)  # 5 min
967
        # short circuit if we've already seen this activity id.
968
        # (don't do this for bare objects since we need to check further down
969
        # whether they've been updated since we saw them last.)
970
        if (obj.as1.get('objectType') == 'activity'
1✔
971
            and 'force' not in request.values
972
            and (not leased
973
                 or (obj.new is False and obj.changed is False))):
974
            error(f'Already seen this activity {id}', status=204)
1✔
975

976
        pruned = {k: v for k, v in obj.as1.items()
1✔
977
                  if k not in ('contentMap', 'replies', 'signature')}
978
        delay = ''
1✔
979
        if (received_at and request.headers.get('X-AppEngine-TaskRetryCount') == '0'
1✔
980
                and obj.type != 'delete'):  # we delay deletes for 2m
UNCOV
981
            delay_s = int((util.now().replace(tzinfo=None)
×
982
                           - received_at.replace(tzinfo=None)
983
                           ).total_seconds())
UNCOV
984
            delay = f'({delay_s} s behind)'
×
985
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
986

987
        # does this protocol support this activity/object type?
988
        from_cls.check_supported(obj)
1✔
989

990
        # check authorization
991
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
992
        actor = as1.get_owner(obj.as1)
1✔
993
        if not actor:
1✔
994
            error('Activity missing actor or author')
1✔
995
        elif from_cls.owns_id(actor) is False:
1✔
996
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
997

998
        assert authed_as
1✔
999
        assert isinstance(authed_as, str)
1✔
1000
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
1001
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
1002
        if actor != authed_as:
1✔
1003
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
1004
                         user=f'{id} authed_as {authed_as} owner {actor}')
1005
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
1006

1007
        # update copy ids to originals
1008
        obj.normalize_ids()
1✔
1009
        obj.resolve_ids()
1✔
1010

1011
        if (obj.type == 'follow'
1✔
1012
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
1013
            # follows of bot user; refresh user profile first
1014
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
1015
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1016
            from_user.reload_profile()
1✔
1017
        else:
1018
            # load actor user
1019
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
1020

1021
        if not internal and (not from_user or from_user.manual_opt_out):
1✔
1022
            error(f'Actor {actor} is manually opted out', status=204)
1✔
1023

1024
        # if this is an object, ie not an activity, wrap it in a create or update
1025
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as)
1✔
1026
        obj.add('users', from_user.key)
1✔
1027

1028
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1029
        inner_obj_id = inner_obj_as1.get('id')
1✔
1030
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1031
            if not inner_obj_id:
1✔
1032
                error(f'{obj.type} object has no id!')
1✔
1033

1034
        # check age. we support backdated posts, but if they're over 2w old, we
1035
        # don't deliver them
1036
        if obj.type == 'post':
1✔
1037
            if published := inner_obj_as1.get('published'):
1✔
1038
                try:
×
1039
                    published_dt = util.parse_iso8601(published)
×
1040
                    if not published_dt.tzinfo:
×
1041
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
1042
                    age = util.now() - published_dt
×
UNCOV
1043
                    if age > CREATE_MAX_AGE:
×
1044
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
1045
                              status=204)
UNCOV
1046
                except ValueError:  # from parse_iso8601
×
UNCOV
1047
                    logger.debug(f"Couldn't parse published {published}")
×
1048

1049
        # write Object to datastore
1050
        obj.source_protocol = from_cls.LABEL
1✔
1051
        if obj.type in STORE_AS1_TYPES:
1✔
1052
            obj.put()
1✔
1053

1054
        # store inner object
1055
        # TODO: unify with big obj.type conditional below. would have to merge
1056
        # this with the DM handling block lower down.
1057
        crud_obj = None
1✔
1058
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1059
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
1060
                                            source_protocol=from_cls.LABEL,
1061
                                            authed_as=actor, users=[from_user.key])
1062

1063
        actor = as1.get_object(obj.as1, 'actor')
1✔
1064
        actor_id = actor.get('id')
1✔
1065

1066
        # handle activity!
1067
        if obj.type == 'stop-following':
1✔
1068
            # TODO: unify with handle_follow?
1069
            # TODO: handle multiple followees
1070
            if not actor_id or not inner_obj_id:
1✔
UNCOV
1071
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1072

1073
            # deactivate Follower
1074
            from_ = from_cls.key_for(actor_id)
1✔
1075
            to_cls = Protocol.for_id(inner_obj_id)
1✔
1076
            to = to_cls.key_for(inner_obj_id)
1✔
1077
            follower = Follower.query(Follower.to == to,
1✔
1078
                                      Follower.from_ == from_,
1079
                                      Follower.status == 'active').get()
1080
            if follower:
1✔
1081
                logger.info(f'Marking {follower} inactive')
1✔
1082
                follower.status = 'inactive'
1✔
1083
                follower.put()
1✔
1084
            else:
1085
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1086

1087
            # fall through to deliver to followee
1088
            # TODO: do we convert stop-following to webmention 410 of original
1089
            # follow?
1090

1091
            # fall through to deliver to followers
1092

1093
        elif obj.type in ('delete', 'undo'):
1✔
1094
            delete_obj_id = (from_user.profile_id()
1✔
1095
                            if inner_obj_id == from_user.key.id()
1096
                            else inner_obj_id)
1097

1098
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1099
            if not delete_obj:
1✔
1100
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1101
                return 'OK', 204
1✔
1102

1103
            # TODO: just delete altogether!
1104
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1105
            delete_obj.deleted = True
1✔
1106
            delete_obj.put()
1✔
1107

1108
            # if this is an actor, handle deleting it later so that
1109
            # in case it's from_user, user.enabled_protocols is still populated
1110
            #
1111
            # fall through to deliver to followers and delete copy if necessary.
1112
            # should happen via protocol-specific copy target and send of
1113
            # delete activity.
1114
            # https://github.com/snarfed/bridgy-fed/issues/63
1115

1116
        elif obj.type == 'block':
1✔
1117
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1118
                # blocking protocol bot user disables that protocol
1119
                from_user.delete(proto)
1✔
1120
                from_user.disable_protocol(proto)
1✔
1121
                return 'OK', 200
1✔
1122

1123
        elif obj.type == 'post':
1✔
1124
            # handle DMs to bot users
1125
            if as1.is_dm(obj.as1):
1✔
1126
                return dms.receive(from_user=from_user, obj=obj)
1✔
1127

1128
        # fetch actor if necessary
1129
        if (actor and actor.keys() == set(['id'])
1✔
1130
                and obj.type not in ('delete', 'undo')):
1131
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1132
            actor_obj = from_cls.load(ids.profile_id(id=actor['id'], proto=from_cls),
1✔
1133
                                      raise_=False)
1134
            if actor_obj and actor_obj.as1:
1✔
1135
                obj.our_as1 = {
1✔
1136
                    **obj.as1, 'actor': {
1137
                        **actor_obj.as1,
1138
                        # override profile id with actor id
1139
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1140
                        'id': actor['id'],
1141
                    }
1142
                }
1143

1144
        # fetch object if necessary
1145
        if (obj.type in ('post', 'update', 'share')
1✔
1146
                and inner_obj_as1.keys() == set(['id'])
1147
                and from_cls.owns_id(inner_obj_id)):
1148
            logger.debug('Fetching inner object')
1✔
1149
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1150
                                      remote=(obj.type in ('post', 'update')))
1151
            if obj.type in ('post', 'update'):
1✔
1152
                crud_obj = inner_obj
1✔
1153
            if inner_obj and inner_obj.as1:
1✔
1154
                obj.our_as1 = {
1✔
1155
                    **obj.as1,
1156
                    'object': {
1157
                        **inner_obj_as1,
1158
                        **inner_obj.as1,
1159
                    }
1160
                }
1161
            elif obj.type in ('post', 'update'):
1✔
1162
                error("Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1163

1164
        if obj.type == 'follow':
1✔
1165
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1166
                # follow of one of our protocol bot users; enable that protocol.
1167
                # fall through so that we send an accept.
1168
                try:
1✔
1169
                    from_user.enable_protocol(proto)
1✔
1170
                except ErrorButDoNotRetryTask:
1✔
1171
                    from web import Web
1✔
1172
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1173
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1174
                                               followee=bot, follow=obj)
1175
                    raise
1✔
1176
                proto.bot_follow(from_user)
1✔
1177

1178
            from_cls.handle_follow(obj)
1✔
1179

1180
        # deliver to targets
1181
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1182

1183
        # if this is a user, deactivate its followers/followings
1184
        # https://github.com/snarfed/bridgy-fed/issues/1304
1185
        if obj.type == 'delete':
1✔
1186
            if user_key := from_cls.key_for(id=inner_obj_id):
1✔
1187
                if user := user_key.get():
1✔
1188
                    for proto in user.enabled_protocols:
1✔
1189
                        user.disable_protocol(PROTOCOLS[proto])
1✔
1190

1191
                    logger.info(f'Deactivating Followers from or to {user_key.id()}')
1✔
1192
                    followers = Follower.query(
1✔
1193
                        OR(Follower.to == user_key, Follower.from_ == user_key)
1194
                        ).fetch()
1195
                    for f in followers:
1✔
1196
                        f.status = 'inactive'
1✔
1197
                    ndb.put_multi(followers)
1✔
1198

1199
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1200
        return resp
1✔
1201

1202
    @classmethod
1✔
1203
    def handle_follow(from_cls, obj):
1✔
1204
        """Handles an incoming follow activity.
1205

1206
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1207
        happens in :meth:`deliver`.
1208

1209
        Args:
1210
          obj (models.Object): follow activity
1211
        """
1212
        logger.debug('Got follow. Loading users, storing Follow(s), sending accept(s)')
1✔
1213

1214
        # Prepare follower (from) users' data
1215
        # TODO: remove all of this and just use from_user
1216
        from_as1 = as1.get_object(obj.as1, 'actor')
1✔
1217
        from_id = from_as1.get('id')
1✔
1218
        if not from_id:
1✔
UNCOV
1219
            error(f'Follow activity requires actor. Got: {obj.as1}')
×
1220

1221
        from_obj = from_cls.load(from_id, raise_=False)
1✔
1222
        if not from_obj:
1✔
UNCOV
1223
            error(f"Couldn't load {from_id}", status=502)
×
1224

1225
        if not from_obj.as1:
1✔
1226
            from_obj.our_as1 = from_as1
1✔
1227
            from_obj.put()
1✔
1228

1229
        from_key = from_cls.key_for(from_id)
1✔
1230
        if not from_key:
1✔
UNCOV
1231
            error(f'Invalid {from_cls.LABEL} user key: {from_id}')
×
1232
        obj.users = [from_key]
1✔
1233
        from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
1✔
1234

1235
        # Prepare followee (to) users' data
1236
        to_as1s = as1.get_objects(obj.as1)
1✔
1237
        if not to_as1s:
1✔
UNCOV
1238
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1239

1240
        # Store Followers
1241
        for to_as1 in to_as1s:
1✔
1242
            to_id = to_as1.get('id')
1✔
1243
            if not to_id:
1✔
UNCOV
1244
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1245

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

1248
            to_cls = Protocol.for_id(to_id)
1✔
1249
            if not to_cls:
1✔
UNCOV
1250
                error(f"Couldn't determine protocol for {to_id}")
×
1251
            elif from_cls == to_cls:
1✔
1252
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1253
                continue
1✔
1254

1255
            to_obj = to_cls.load(to_id)
1✔
1256
            if to_obj and not to_obj.as1:
1✔
1257
                to_obj.our_as1 = to_as1
1✔
1258
                to_obj.put()
1✔
1259

1260
            to_key = to_cls.key_for(to_id)
1✔
1261
            if not to_key:
1✔
UNCOV
1262
                logger.info(f'Skipping invalid {from_cls.LABEL} user key: {from_id}')
×
UNCOV
1263
                continue
×
1264

1265
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj,
1✔
1266
                                           allow_opt_out=True)
1267
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1268
                                                  follow=obj.key, status='active')
1269
            obj.add('notify', to_key)
1✔
1270
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1271
                                       followee=to_user, follow=obj)
1272

1273
    @classmethod
1✔
1274
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1275
        """Sends an accept or reject activity for a follow.
1276

1277
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1278
        nothing.
1279

1280
        Args:
1281
          verb (str): ``accept`` or  ``reject``
1282
          follower (models.User)
1283
          followee (models.User)
1284
          follow (models.Object)
1285
        """
1286
        assert verb in ('accept', 'reject')
1✔
1287
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1288
            return
1✔
1289

1290
        target = follower.target_for(follower.obj)
1✔
1291
        if not target:
1✔
UNCOV
1292
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1293

1294
        # send. note that this is one response for the whole follow, even if it
1295
        # has multiple followees!
1296
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1297
        accept = {
1✔
1298
            'id': id,
1299
            'objectType': 'activity',
1300
            'verb': verb,
1301
            'actor': followee.key.id(),
1302
            'object': follow.as1,
1303
        }
1304
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1305
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1306

1307
    @classmethod
1✔
1308
    def bot_follow(bot_cls, user):
1✔
1309
        """Follow a user from a protocol bot user.
1310

1311
        ...so that the protocol starts sending us their activities, if it needs
1312
        a follow for that (eg ActivityPub).
1313

1314
        Args:
1315
          user (User)
1316
        """
1317
        from web import Web
1✔
1318
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1319
        now = util.now().isoformat()
1✔
1320
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1321

1322
        if not user.obj:
1✔
1323
            logger.info("  can't follow, user has no profile obj")
1✔
1324
            return
1✔
1325

1326
        target = user.target_for(user.obj)
1✔
1327
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1328
        follow_back_as1 = {
1✔
1329
            'objectType': 'activity',
1330
            'verb': 'follow',
1331
            'id': follow_back_id,
1332
            'actor': bot.key.id(),
1333
            'object': user.key.id(),
1334
        }
1335
        common.create_task(queue='send', id=follow_back_id,
1✔
1336
                           our_as1=follow_back_as1, url=target,
1337
                           source_protocol='web', protocol=user.LABEL,
1338
                           user=bot.key.urlsafe())
1339

1340
    @classmethod
1✔
1341
    def handle_bare_object(cls, obj, authed_as=None):
1✔
1342
        """If obj is a bare object, wraps it in a create or update activity.
1343

1344
        Checks if we've seen it before.
1345

1346
        Args:
1347
          obj (models.Object)
1348
          authed_as (str): authenticated actor id who sent this activity
1349

1350
        Returns:
1351
          models.Object: ``obj`` if it's an activity, otherwise a new object
1352
        """
1353
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1354
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1355
            return obj
1✔
1356

1357
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1358
        now = util.now().isoformat()
1✔
1359

1360
        # occasionally we override the object, eg if this is a profile object
1361
        # coming in via a user with use_instead set
1362
        obj_as1 = obj.as1
1✔
1363
        if obj_id := obj.key.id():
1✔
1364
            if obj_as1_id := obj_as1.get('id'):
1✔
1365
                if obj_id != obj_as1_id:
1✔
1366
                    logger.info(f'Overriding AS1 object id {obj_as1_id} with Object id {obj_id}')
1✔
1367
                    obj_as1['id'] = obj_id
1✔
1368

1369
        # this is a raw post; wrap it in a create or update activity
1370
        if obj.changed or is_actor:
1✔
1371
            if obj.changed:
1✔
1372
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1373
            else:
1374
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1375
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1376
            update_as1 = {
1✔
1377
                'objectType': 'activity',
1378
                'verb': 'update',
1379
                'id': id,
1380
                'actor': obj_actor,
1381
                'object': {
1382
                    # Mastodon requires the updated field for Updates, so
1383
                    # add a default value.
1384
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1385
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1386
                    # https://github.com/mastodon/documentation/pull/1150
1387
                    'updated': now,
1388
                    **obj_as1,
1389
                },
1390
            }
1391
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1392
            return Object(id=id, our_as1=update_as1,
1✔
1393
                          source_protocol=obj.source_protocol)
1394

1395
        if (obj.new
1✔
1396
                # HACK: force query param here is specific to webmention
1397
                or 'force' in request.form):
1398
            create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1399
            create_as1 = {
1✔
1400
                'objectType': 'activity',
1401
                'verb': 'post',
1402
                'id': create_id,
1403
                'actor': obj_actor,
1404
                'object': obj_as1,
1405
                'published': now,
1406
            }
1407
            logger.info(f'Wrapping in post')
1✔
1408
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1409
            return Object(id=create_id, our_as1=create_as1,
1✔
1410
                          source_protocol=obj.source_protocol)
1411

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

1414
    @classmethod
1✔
1415
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1416
        """Delivers an activity to its external recipients.
1417

1418
        Args:
1419
          obj (models.Object): activity to deliver
1420
          from_user (models.User): user (actor) this activity is from
1421
          crud_obj (models.Object): if this is a create, update, or delete/undo
1422
            activity, the inner object that's being written, otherwise None.
1423
            (This object's ``notify`` and ``feed`` properties may be updated.)
1424
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1425
            targets on this protocol
1426

1427
        Returns:
1428
          (str, int) tuple: Flask response
1429
        """
1430
        if to_proto:
1✔
1431
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1432

1433
        # find delivery targets. maps Target to Object or None
1434
        #
1435
        # ...then write the relevant object, since targets() has a side effect of
1436
        # setting the notify and feed properties (and dirty attribute)
1437
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1438
        if not targets:
1✔
1439
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1440

1441
        # store object that targets() updated
1442
        if crud_obj and crud_obj.dirty:
1✔
1443
            crud_obj.put()
1✔
1444
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1445
            obj.put()
1✔
1446

1447
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1448
                      else obj.to_request())
1449

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

1453
        # enqueue send task for each targets
1454
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1455
        user = from_user.key.urlsafe()
1✔
1456
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1457
            if to_proto and target.protocol != to_proto.LABEL:
1✔
UNCOV
1458
                continue
×
1459
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1460
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1461
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1462

1463
        return 'OK', 202
1✔
1464

1465
    @classmethod
1✔
1466
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1467
        """Collects the targets to send a :class:`models.Object` to.
1468

1469
        Targets are both objects - original posts, events, etc - and actors.
1470

1471
        Args:
1472
          obj (models.Object)
1473
          from_user (User)
1474
          crud_obj (models.Object): if this is a create, update, or delete/undo
1475
            activity, the inner object that's being written, otherwise None.
1476
            (This object's ``notify`` and ``feed`` properties may be updated.)
1477
          internal (bool): whether this is a recursive internal call
1478

1479
        Returns:
1480
          dict: maps :class:`models.Target` to original (in response to)
1481
          :class:`models.Object`, if any, otherwise None
1482
        """
1483
        logger.debug('Finding recipients and their targets')
1✔
1484

1485
        # we should only have crud_obj iff this is a create or update
1486
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1487
        write_obj = crud_obj or obj
1✔
1488
        write_obj.dirty = False
1✔
1489

1490
        target_uris = sorted(set(as1.targets(obj.as1)))
1✔
1491
        logger.info(f'Raw targets: {target_uris}')
1✔
1492
        orig_obj = None
1✔
1493
        targets = {}  # maps Target to Object or None
1✔
1494
        owner = as1.get_owner(obj.as1)
1✔
1495
        allow_opt_out = (obj.type == 'delete')
1✔
1496
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1497
        inner_obj_id = inner_obj_as1.get('id')
1✔
1498
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1499
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1500
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1501
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1502
        is_self_reply = False
1✔
1503

1504
        original_ids = []
1✔
1505
        if is_reply:
1✔
1506
            original_ids = in_reply_tos
1✔
1507
        elif inner_obj_id:
1✔
1508
            if inner_obj_id == from_user.key.id():
1✔
1509
                inner_obj_id = from_user.profile_id()
1✔
1510
            original_ids = [inner_obj_id]
1✔
1511

1512
        # which protocols should we allow delivering to?
1513
        to_protocols = []
1✔
1514
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1515
                      + from_user.enabled_protocols):
1516
            proto = PROTOCOLS[label]
1✔
1517
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1518
                                     or is_reply):
1519
                for id in original_ids:
1✔
1520
                    if Protocol.for_id(id) == proto:
1✔
1521
                        logger.info(f'Allowing {label} for original post {id}')
1✔
1522
                        break
1✔
1523
                    elif orig := from_user.load(id, remote=False):
1✔
1524
                        if orig.get_copy(proto):
1✔
1525
                            logger.info(f'Allowing {label}, original post {id} was bridged there')
1✔
1526
                            break
1✔
1527
                else:
1528
                    logger.info(f"Skipping {label}, original objects {original_ids} weren't bridged there")
1✔
1529
                    continue
1✔
1530

1531
            util.add(to_protocols, proto)
1✔
1532

1533
        # process direct targets
1534
        for id in sorted(target_uris):
1✔
1535
            target_proto = Protocol.for_id(id)
1✔
1536
            if not target_proto:
1✔
1537
                logger.info(f"Can't determine protocol for {id}")
1✔
1538
                continue
1✔
1539
            elif target_proto.is_blocklisted(id):
1✔
1540
                logger.debug(f'{id} is blocklisted')
1✔
1541
                continue
1✔
1542

1543
            orig_obj = target_proto.load(id, raise_=False)
1✔
1544
            if not orig_obj or not orig_obj.as1:
1✔
1545
                logger.info(f"Couldn't load {id}")
1✔
1546
                continue
1✔
1547

1548
            target_author_key = (target_proto(id=id).key if id in mentioned_urls
1✔
1549
                                 else target_proto.actor_key(orig_obj))
1550
            if not from_user.is_enabled(target_proto):
1✔
1551
                # if author isn't bridged and target user is, DM a prompt and
1552
                # add a notif for the target user
1553
                if (id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
1554
                        and target_author_key):
1555
                    if target_author := target_author_key.get():
1✔
1556
                        if target_author.is_enabled(from_cls):
1✔
1557
                            notifications.add_notification(target_author, write_obj)
1✔
1558
                            verb, noun = (
1✔
1559
                                ('replied to', 'replies') if id in in_reply_tos
1560
                                else ('quoted', 'quotes') if id in quoted_posts
1561
                                else ('mentioned', 'mentions'))
1562
                            dms.maybe_send(
1✔
1563
                                from_proto=target_proto, to_user=from_user,
1564
                                type='replied_to_bridged_user', text=f"""\
1565
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.""")
1566

1567
                continue
1✔
1568

1569
            # deliver self-replies to followers
1570
            # https://github.com/snarfed/bridgy-fed/issues/639
1571
            if id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1572
                is_self_reply = True
1✔
1573
                logger.info(f'self reply!')
1✔
1574

1575
            # also add copies' targets
1576
            for copy in orig_obj.copies:
1✔
1577
                proto = PROTOCOLS[copy.protocol]
1✔
1578
                if proto in to_protocols:
1✔
1579
                    # copies generally won't have their own Objects
1580
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1581
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {id}')
1✔
1582
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1583

1584
            if target_proto == from_cls:
1✔
1585
                logger.debug(f'Skipping same-protocol target {id}')
1✔
1586
                continue
1✔
1587

1588
            target = target_proto.target_for(orig_obj)
1✔
1589
            if not target:
1✔
1590
                # TODO: surface errors like this somehow?
UNCOV
1591
                logger.error(f"Can't find delivery target for {id}")
×
UNCOV
1592
                continue
×
1593

1594
            logger.debug(f'Target for {id} is {target}')
1✔
1595
            # only use orig_obj for inReplyTos, like/repost objects, etc
1596
            # https://github.com/snarfed/bridgy-fed/issues/1237
1597
            targets[Target(protocol=target_proto.LABEL, uri=target)] = (
1✔
1598
                orig_obj if id in in_reply_tos or id in as1.get_ids(obj.as1, 'object')
1599
                else None)
1600

1601
            if target_author_key:
1✔
1602
                logger.debug(f'Recipient is {target_author_key}')
1✔
1603
                if write_obj.add('notify', target_author_key):
1✔
1604
                    write_obj.dirty = True
1✔
1605

1606
        if obj.type == 'undo':
1✔
1607
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1608
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1609
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1610
            else:
1611
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1612
            if inner_obj:
1✔
1613
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1614
                                                internal=True))
1615

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

1618
        # deliver to followers, if appropriate
1619
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1620
        if not user_key:
1✔
1621
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1622
            return targets
1✔
1623

1624
        followers = []
1✔
1625
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1626
                and (not is_reply or is_self_reply)):
1627
            logger.info(f'Delivering to followers of {user_key}')
1✔
1628
            followers = []
1✔
1629
            for f in Follower.query(Follower.to == user_key,
1✔
1630
                                    Follower.status == 'active'):
1631
                proto = PROTOCOLS_BY_KIND[f.from_.kind()]
1✔
1632
                # skip protocol bot users
1633
                if (not Protocol.for_bridgy_subdomain(f.from_.id())
1✔
1634
                        # skip protocols this user hasn't enabled, or where the base
1635
                        # object of this activity hasn't been bridged
1636
                        and proto in to_protocols
1637
                        # we deliver to HAS_COPIES protocols separately, below. we
1638
                        # assume they have follower-independent targets.
1639
                        and not (proto.HAS_COPIES and proto.DEFAULT_TARGET)):
1640
                    followers.append(f)
1✔
1641

1642
            user_keys = [f.from_ for f in followers]
1✔
1643
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1644
            User.load_multi(users)
1✔
1645

1646
            if (not followers and
1✔
1647
                (util.domain_or_parent_in(
1648
                    util.domain_from_link(from_user.key.id()), LIMITED_DOMAINS)
1649
                 or util.domain_or_parent_in(
1650
                     util.domain_from_link(obj.key.id()), LIMITED_DOMAINS))):
1651
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1652
                return {}
1✔
1653

1654
            # add to followers' feeds, if any
1655
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1656
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1657
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
1✔
1658
                    if write_obj.feed:
1✔
1659
                        write_obj.dirty = True
1✔
1660

1661
            # collect targets for followers
1662
            for user in users:
1✔
1663
                # TODO: should we pass remote=False through here to Protocol.load?
1664
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1665
                if not target:
1✔
1666
                    # TODO: surface errors like this somehow?
1667
                    logger.error(f'Follower {user.key} has no delivery target')
1✔
1668
                    continue
1✔
1669

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

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

1678
        # deliver to enabled HAS_COPIES protocols proactively
1679
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
1680
            for proto in to_protocols:
1✔
1681
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
1682
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
1683
                    targets.setdefault(
1✔
1684
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1685

1686
        # de-dupe targets, discard same-domain
1687
        # maps string target URL to (Target, Object) tuple
1688
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1689
        # maps Target to Object or None
1690
        targets = {}
1✔
1691
        source_domains = [
1✔
1692
            util.domain_from_link(url) for url in
1693
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1694
            if util.is_web(url)
1695
        ]
1696
        for url in sorted(util.dedupe_urls(
1✔
1697
                candidates.keys(),
1698
                # preserve our PDS URL without trailing slash in path
1699
                # https://atproto.com/specs/did#did-documents
1700
                trailing_slash=False)):
1701
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
UNCOV
1702
                logger.info(f'Skipping same-domain target {url}')
×
UNCOV
1703
                continue
×
1704
            target, obj = candidates[url]
1✔
1705
            targets[target] = obj
1✔
1706

1707
        return targets
1✔
1708

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

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

1716
        Args:
1717
          id (str)
1718
          remote (bool): whether to fetch the object over the network. If True,
1719
            fetches even if we already have the object stored, and updates our
1720
            stored copy. If False and we don't have the object stored, returns
1721
            None. Default (None) means to fetch over the network only if we
1722
            don't already have it stored.
1723
          local (bool): whether to load from the datastore before
1724
            fetching over the network. If False, still stores back to the
1725
            datastore after a successful remote fetch.
1726
          raise_ (bool): if False, catches any :class:`request.RequestException`
1727
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1728
            ``None`` instead
1729
          kwargs: passed through to :meth:`fetch()`
1730

1731
        Returns:
1732
          models.Object: loaded object, or None if it isn't fetchable, eg a
1733
          non-URL string for Web, or ``remote`` is False and it isn't in the
1734
          datastore
1735

1736
        Raises:
1737
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1738
            is True
1739
        """
1740
        assert id
1✔
1741
        assert local or remote is not False
1✔
1742
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1743

1744
        obj = orig_as1 = None
1✔
1745
        if local:
1✔
1746
            obj = Object.get_by_id(id)
1✔
1747
            if not obj:
1✔
1748
                # logger.debug(f' {id} not in datastore')
1749
                pass
1✔
1750
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1751
                # logger.debug(f'  {id} got from datastore')
1752
                obj.new = False
1✔
1753

1754
        if remote is False:
1✔
1755
            return obj
1✔
1756
        elif remote is None and obj:
1✔
1757
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1758
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1759
                pass
1✔
1760
            else:
1761
                return obj
1✔
1762

1763
        if obj:
1✔
1764
            orig_as1 = obj.as1
1✔
1765
            obj.our_as1 = None
1✔
1766
            obj.new = False
1✔
1767
        else:
1768
            obj = Object(id=id)
1✔
1769
            if local:
1✔
1770
                # logger.debug(f'  {id} not in datastore')
1771
                obj.new = True
1✔
1772
                obj.changed = False
1✔
1773

1774
        try:
1✔
1775
            fetched = cls.fetch(obj, **kwargs)
1✔
1776
        except (RequestException, HTTPException) as e:
1✔
1777
            if raise_:
1✔
1778
                raise
1✔
1779
            util.interpret_http_exception(e)
1✔
1780
            return None
1✔
1781

1782
        if not fetched:
1✔
1783
            return None
1✔
1784

1785
        # https://stackoverflow.com/a/3042250/186123
1786
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1787
        if size > models.MAX_ENTITY_SIZE:
1✔
1788
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1789
            return None
1✔
1790

1791
        obj.resolve_ids()
1✔
1792
        obj.normalize_ids()
1✔
1793

1794
        if obj.new is False:
1✔
1795
            obj.changed = obj.activity_changed(orig_as1)
1✔
1796

1797
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1798
            if obj.source_protocol:
1✔
UNCOV
1799
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1800
            obj.source_protocol = cls.LABEL
1✔
1801

1802
        obj.put()
1✔
1803
        return obj
1✔
1804

1805
    @classmethod
1✔
1806
    def check_supported(cls, obj):
1✔
1807
        """If this protocol doesn't support this object, raises HTTP 204.
1808

1809
        Also reports an error.
1810

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

1815
        Args:
1816
          obj (Object)
1817

1818
        Raises:
1819
          werkzeug.HTTPException: if this protocol doesn't support this object
1820
        """
1821
        if not obj.type:
1✔
UNCOV
1822
            return
×
1823

1824
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1825
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1826
            or (obj.type in as1.CRUD_VERBS
1827
                and inner_type
1828
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1829
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1830

1831
        # don't allow posts with blank content and no image/video/audio
1832
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
1833
                    else obj.as1)
1834
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
1835
                and not util.get_url(crud_obj, key='image')
1836
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
1837
                # TODO: handle articles with displayName but not content
1838
                and not source.html_to_text(crud_obj.get('content')).strip()):
1839
            error('Blank content and no image or video or audio', status=204)
1✔
1840

1841
        # DMs are only allowed to/from protocol bot accounts
1842
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1843
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1844
            if (not cls.SUPPORTS_DMS
1✔
1845
                    or (recip not in protocol_user_ids
1846
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1847
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1848

1849

1850
@cloud_tasks_only(log=None)
1✔
1851
def receive_task():
1✔
1852
    """Task handler for a newly received :class:`models.Object`.
1853

1854
    Calls :meth:`Protocol.receive` with the form parameters.
1855

1856
    Parameters:
1857
      authed_as (str): passed to :meth:`Protocol.receive`
1858
      obj_id (str): key id of :class:`models.Object` to handle
1859
      received_at (str, ISO 8601 timestamp): when we first saw (received)
1860
        this activity
1861
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1862
        :class:`models.Object` to handle
1863

1864
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
1865
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
1866
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
1867
    ``changed``, HTTP request details, etc. See stash for attempt at this for
1868
    :class:`web.Web`.
1869
    """
1870
    common.log_request()
1✔
1871
    form = request.form.to_dict()
1✔
1872

1873
    authed_as = form.pop('authed_as', None)
1✔
1874
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1875
                or authed_as in common.PROTOCOL_DOMAINS)
1876

1877
    obj = Object.from_request()
1✔
1878
    assert obj
1✔
1879
    assert obj.source_protocol
1✔
1880
    obj.new = True
1✔
1881

1882
    if received_at := form.pop('received_at', None):
1✔
1883
        received_at = datetime.fromisoformat(received_at)
1✔
1884

1885
    try:
1✔
1886
        return PROTOCOLS[obj.source_protocol].receive(
1✔
1887
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
1888
    except RequestException as e:
1✔
1889
        util.interpret_http_exception(e)
1✔
1890
        error(e, status=304)
1✔
1891
    except ValueError as e:
1✔
UNCOV
1892
        logger.warning(e, exc_info=True)
×
UNCOV
1893
        error(e, status=304)
×
1894

1895

1896
@cloud_tasks_only(log=None)
1✔
1897
def send_task():
1✔
1898
    """Task handler for sending an activity to a single specific destination.
1899

1900
    Calls :meth:`Protocol.send` with the form parameters.
1901

1902
    Parameters:
1903
      protocol (str): :class:`Protocol` to send to
1904
      url (str): destination URL to send to
1905
      obj_id (str): key id of :class:`models.Object` to send
1906
      orig_obj_id (str): optional, :class:`models.Object` key id of the
1907
        "original object" that this object refers to, eg replies to or reposts
1908
        or likes
1909
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1910
        this activity is from
1911
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1912
        :class:`models.Object` to handle
1913
    """
1914
    common.log_request()
1✔
1915

1916
    # prepare
1917
    form = request.form.to_dict()
1✔
1918
    url = form.get('url')
1✔
1919
    protocol = form.get('protocol')
1✔
1920
    if not url or not protocol:
1✔
1921
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1922
        return '', 204
1✔
1923

1924
    target = Target(uri=url, protocol=protocol)
1✔
1925
    obj = Object.from_request()
1✔
1926
    assert obj and obj.key and obj.key.id()
1✔
1927

1928
    PROTOCOLS[protocol].check_supported(obj)
1✔
1929
    allow_opt_out = (obj.type == 'delete')
1✔
1930

1931
    user = None
1✔
1932
    if user_key := form.get('user'):
1✔
1933
        key = ndb.Key(urlsafe=user_key)
1✔
1934
        # use get_by_id so that we follow use_instead
1935
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
1936
            key.id(), allow_opt_out=allow_opt_out)
1937

1938
    # send
1939
    delay = ''
1✔
1940
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
1941
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
1942
        delay = f'({delay_s} s behind)'
1✔
1943
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
1944
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
1945
    sent = None
1✔
1946
    try:
1✔
1947
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
1948
                                        orig_obj_id=form.get('orig_obj_id'))
1949
    except BaseException as e:
1✔
1950
        code, body = util.interpret_http_exception(e)
1✔
1951
        if not code and not body:
1✔
1952
            raise
1✔
1953

1954
    if sent is False:
1✔
1955
        logger.info(f'Failed sending!')
1✔
1956

1957
    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