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

snarfed / bridgy-fed / 34ebd6d1-f2b6-4e72-8938-fc502f6166fb

16 Aug 2025 10:14PM UTC coverage: 92.636% (+0.06%) from 92.573%
34ebd6d1-f2b6-4e72-8938-fc502f6166fb

push

circleci

snarfed
Object.resolve_ids: add attachments.id for quote posts

fixes sending quote posts from protocols with copies, #2045

thanks @lzcunt!

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

10 existing lines in 1 file now uncovered.

5623 of 6070 relevant lines covered (92.64%)

0.93 hits per line

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

94.74
/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
    SEND_REPLIES_TO_ORIG_POSTS_MENTIONS = False
1✔
122
    """bool: whether replies to this protocol should include the original post's mentions as delivery targets"""
1✔
123

124
    def __init__(self):
1✔
125
        assert False
×
126

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

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

137
        ...based on the request's hostname.
138

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

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

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

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

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

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

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

177
        To be implemented by subclasses.
178

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

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

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

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

193
        Args:
194
          id (str)
195

196
        Returns:
197
          bool or None:
198
        """
199
        return False
1✔
200

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

205
        To be implemented by subclasses.
206

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

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

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

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

225
        Returns:
226
          bool or None
227
        """
228
        return False
1✔
229

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

234
        To be implemented by subclasses.
235

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

239
        Args:
240
          handle (str)
241

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

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

251
        To be implemented by subclasses. Canonicalizes the id if necessary.
252

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

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

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

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

276
        return cls(id=id).key
1✔
277

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

282
        Args:
283
          id (str)
284

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

413
        Args:
414
          handle (str)
415

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

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

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

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

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

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

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

465
        Example: ``@user@instance.com``
466

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

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

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

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

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

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

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

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

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

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

516
        For example, ``'bsky.brid.gy'`` for ATProto.
517

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

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

527
        Should add the copy user to :attr:`copies`.
528

529
        If the copy user already exists and active, should do nothing.
530

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

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

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

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

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

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

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

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

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

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

578
        To be implemented by subclasses.
579

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

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

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

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

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

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

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

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

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

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

631
            cls.add_source_links(obj=obj, from_user=from_user)
1✔
632

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

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

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

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

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

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

660
        Uses HTML for protocols that support it, plain text otherwise.
661

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

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

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

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

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

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

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

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

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

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

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

721
        Args:
722
          user (models.User)
723
          username (str)
724

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

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

735
        Args:
736
          user (models.User)
737
          to_user_id (str)
738

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

745
    @classmethod
1✔
746
    def check_can_migrate_out(cls, user, to_user_id):
1✔
747
        """Raises an exception if a user can't yet migrate to a native account.
748

749
        For example, if ``to_user_id`` isn't on this protocol, or if ``user`` is on
750
        this protocol, or isn't bridged to this protocol.
751

752
        If the user is ready to migrate, returns ``None``.
753

754
        Subclasses may override this to add more criteria, but they should call this
755
        implementation first.
756

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

761
        Raises:
762
          ValueError: if ``user`` isn't ready to migrate to this protocol yet
763
        """
764
        def _error(msg):
1✔
765
            logger.warning(msg)
1✔
766
            raise ValueError(msg)
1✔
767

768
        if cls.owns_id(to_user_id) is False:
1✔
769
            _error(f"{to_user_id} doesn't look like an {cls.LABEL} id")
1✔
770
        elif isinstance(user, cls):
1✔
771
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
772
        elif not user.is_enabled(cls):
1✔
773
            _error(f"{user.handle_or_id()} isn't currently bridged to {cls.PHRASE}")
1✔
774

775
    @classmethod
1✔
776
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
777
        """Migrates a native account in to be a bridged account.
778

779
        Args:
780
          user (models.User): native user on another protocol to attach the
781
            newly imported bridged account to
782
          from_user_id (str)
783
          kwargs: additional protocol-specific parameters
784

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

791
    @classmethod
1✔
792
    def target_for(cls, obj, shared=False):
1✔
793
        """Returns an :class:`Object`'s delivery target (endpoint).
794

795
        To be implemented by subclasses.
796

797
        Examples:
798

799
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
800
          webmention target.
801
        * If obj is an ``activitypub`` actor, returns its inbox.
802
        * If obj is an ``activitypub`` object, returns it's author's or actor's
803
          inbox.
804

805
        Args:
806
          obj (models.Object):
807
          shared (bool): optional. If True, returns a common/shared
808
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
809
            multiple recipients for efficiency
810

811
        Returns:
812
          str: target endpoint, or None if not available.
813
        """
814
        raise NotImplementedError()
×
815

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

820
        Default implementation here, subclasses may override.
821

822
        Args:
823
          url (str):
824
          allow_internal (bool): whether to return False for internal domains
825
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
826
        """
827
        blocklist = DOMAIN_BLOCKLIST
1✔
828
        if not DEBUG:
1✔
829
            blocklist += tuple(util.RESERVED_TLDS | util.LOCAL_TLDS)
×
830
        if not allow_internal:
1✔
831
            blocklist += DOMAINS
1✔
832
        return util.domain_or_parent_in(url, blocklist)
1✔
833

834
    @classmethod
1✔
835
    def translate_ids(to_cls, obj):
1✔
836
        """Translates all ids in an AS1 object to a specific protocol.
837

838
        Infers source protocol for each id value separately.
839

840
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
841
        ``at://did:plc:abc/coll/123`` will be converted to
842
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
843

844
        Wraps these AS1 fields:
845

846
        * ``id``
847
        * ``actor``
848
        * ``author``
849
        * ``bcc``
850
        * ``bto``
851
        * ``cc``
852
        * ``featured[].items``, ``featured[].orderedItems``
853
        * ``object``
854
        * ``object.actor``
855
        * ``object.author``
856
        * ``object.id``
857
        * ``object.inReplyTo``
858
        * ``object.object``
859
        * ``attachments[].id``
860
        * ``tags[objectType=mention].url``
861
        * ``to``
862

863
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
864
        same logic is duplicated there!
865

866
        TODO: unify with :meth:`Object.resolve_ids`,
867
        :meth:`models.Object.normalize_ids`.
868

869
        Args:
870
          to_proto (Protocol subclass)
871
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
872

873
        Returns:
874
          dict: wrapped AS1 version of ``obj``
875
        """
876
        assert to_cls != Protocol
1✔
877
        if not obj:
1✔
878
            return obj
1✔
879

880
        outer_obj = copy.deepcopy(obj)
1✔
881
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
882

883
        def translate(elem, field, fn, uri=False):
1✔
884
            elem[field] = as1.get_objects(elem, field)
1✔
885
            for obj in elem[field]:
1✔
886
                if id := obj.get('id'):
1✔
887
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
888
                        continue
1✔
889
                    from_cls = Protocol.for_id(id)
1✔
890
                    # TODO: what if from_cls is None? relax translate_object_id,
891
                    # make it a noop if we don't know enough about from/to?
892
                    if from_cls and from_cls != to_cls:
1✔
893
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
894
                    if obj['id'] and uri:
1✔
895
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
896

897
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
898
                           for o in elem[field]]
899

900
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
901
                elem[field] = elem[field][0]
1✔
902

903
        type = as1.object_type(outer_obj)
1✔
904
        translate(outer_obj, 'id',
1✔
905
                  translate_user_id if type in as1.ACTOR_TYPES
906
                  else translate_object_id)
907

908
        for o in inner_objs:
1✔
909
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
910
                        or as1.get_owner(outer_obj) == o.get('id')
911
                        or type in ('follow', 'stop-following'))
912
            translate(o, 'id', translate_user_id if is_actor else translate_object_id)
1✔
913
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
914
            translate(o, 'object', translate_user_id if obj_is_actor
1✔
915
                      else translate_object_id)
916

917
        for o in [outer_obj] + inner_objs:
1✔
918
            translate(o, 'inReplyTo', translate_object_id)
1✔
919
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
920
                translate(o, field, translate_user_id)
1✔
921
            for tag in as1.get_objects(o, 'tags'):
1✔
922
                if tag.get('objectType') == 'mention':
1✔
923
                    translate(tag, 'url', translate_user_id, uri=True)
1✔
924
            for att in as1.get_objects(o, 'attachments'):
1✔
925
                translate(att, 'id', translate_object_id)
1✔
926
                url = att.get('url')
1✔
927
                if url and not att.get('id'):
1✔
928
                    if from_cls := Protocol.for_id(url):
1✔
929
                        att['id'] = translate_object_id(from_=from_cls, to=to_cls,
1✔
930
                                                        id=url)
931
            if feat := as1.get_object(o, 'featured'):
1✔
932
                translate(feat, 'orderedItems', translate_object_id)
1✔
933
                translate(feat, 'items', translate_object_id)
1✔
934

935
        outer_obj = util.trim_nulls(outer_obj)
1✔
936

937
        if objs := util.get_list(outer_obj ,'object'):
1✔
938
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
939
            if len(outer_obj['object']) == 1:
1✔
940
                outer_obj['object'] = outer_obj['object'][0]
1✔
941

942
        return outer_obj
1✔
943

944
    @classmethod
1✔
945
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
946
        """Handles an incoming activity.
947

948
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
949
        unset, returns HTTP 299.
950

951
        Args:
952
          obj (models.Object)
953
          authed_as (str): authenticated actor id who sent this activity
954
          internal (bool): whether to allow activity ids on internal domains,
955
            from opted out/blocked users, etc.
956
          received_at (datetime): when we first saw (received) this activity.
957
            Right now only used for monitoring.
958

959
        Returns:
960
          (str, int) tuple: (response body, HTTP status code) Flask response
961

962
        Raises:
963
          werkzeug.HTTPException: if the request is invalid
964
        """
965
        # check some invariants
966
        assert from_cls != Protocol
1✔
967
        assert isinstance(obj, Object), obj
1✔
968

969
        if not obj.as1:
1✔
970
            error('No object data provided')
×
971

972
        id = None
1✔
973
        if obj.key and obj.key.id():
1✔
974
            id = obj.key.id()
1✔
975

976
        if not id:
1✔
977
            id = obj.as1.get('id')
1✔
978
            obj.key = ndb.Key(Object, id)
1✔
979

980
        if not id:
1✔
981
            error('No id provided')
×
982
        elif from_cls.owns_id(id) is False:
1✔
983
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
984
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
985
            error(f'Activity {id} is blocklisted')
1✔
986
        # check that this activity is public. only do this for some activities,
987
        # not eg likes or follows, since Mastodon doesn't currently mark those
988
        # as explicitly public.
989
        elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
990
                  and not as1.is_public(obj.as1, unlisted=False)
991
                  and not as1.is_dm(obj.as1)):
992
              logger.info('Dropping non-public activity')
1✔
993
              return ('OK', 200)
1✔
994

995
        # lease this object, atomically
996
        memcache_key = activity_id_memcache_key(id)
1✔
997
        leased = memcache.memcache.add(memcache_key, 'leased', noreply=False,
1✔
998
                                       expire=5 * 60)  # 5 min
999
        # short circuit if we've already seen this activity id.
1000
        # (don't do this for bare objects since we need to check further down
1001
        # whether they've been updated since we saw them last.)
1002
        if (obj.as1.get('objectType') == 'activity'
1✔
1003
            and 'force' not in request.values
1004
            and (not leased
1005
                 or (obj.new is False and obj.changed is False))):
1006
            error(f'Already seen this activity {id}', status=204)
1✔
1007

1008
        pruned = {k: v for k, v in obj.as1.items()
1✔
1009
                  if k not in ('contentMap', 'replies', 'signature')}
1010
        delay = ''
1✔
1011
        if (received_at and request.headers.get('X-AppEngine-TaskRetryCount') == '0'
1✔
1012
                and obj.type != 'delete'):  # we delay deletes for 2m
1013
            delay_s = int((util.now().replace(tzinfo=None)
×
1014
                           - received_at.replace(tzinfo=None)
1015
                           ).total_seconds())
1016
            delay = f'({delay_s} s behind)'
×
1017
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
1018

1019
        # does this protocol support this activity/object type?
1020
        from_cls.check_supported(obj)
1✔
1021

1022
        # check authorization
1023
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1024
        actor = as1.get_owner(obj.as1)
1✔
1025
        if not actor:
1✔
1026
            error('Activity missing actor or author')
1✔
1027
        elif from_cls.owns_id(actor) is False:
1✔
1028
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
1029

1030
        assert authed_as
1✔
1031
        assert isinstance(authed_as, str)
1✔
1032
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
1033
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
1034
        if actor != authed_as:
1✔
1035
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
1036
                         user=f'{id} authed_as {authed_as} owner {actor}')
1037
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
1038

1039
        # update copy ids to originals
1040
        obj.normalize_ids()
1✔
1041
        obj.resolve_ids()
1✔
1042

1043
        if (obj.type == 'follow'
1✔
1044
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
1045
            # follows of bot user; refresh user profile first
1046
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
1047
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1048
            from_user.reload_profile()
1✔
1049
        else:
1050
            # load actor user
1051
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
1052

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

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

1060
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1061
        inner_obj_id = inner_obj_as1.get('id')
1✔
1062
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1063
            if not inner_obj_id:
1✔
1064
                error(f'{obj.type} object has no id!')
1✔
1065

1066
        # check age. we support backdated posts, but if they're over 2w old, we
1067
        # don't deliver them
1068
        if obj.type == 'post':
1✔
1069
            if published := inner_obj_as1.get('published'):
1✔
1070
                try:
1✔
1071
                    published_dt = util.parse_iso8601(published)
1✔
1072
                    if not published_dt.tzinfo:
1✔
1073
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
1074
                    age = util.now() - published_dt
1✔
1075
                    if age > CREATE_MAX_AGE:
1✔
1076
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
1077
                              status=204)
1078
                except ValueError:  # from parse_iso8601
×
1079
                    logger.debug(f"Couldn't parse published {published}")
×
1080

1081
        # write Object to datastore
1082
        obj.source_protocol = from_cls.LABEL
1✔
1083
        if obj.type in STORE_AS1_TYPES:
1✔
1084
            obj.put()
1✔
1085

1086
        # store inner object
1087
        # TODO: unify with big obj.type conditional below. would have to merge
1088
        # this with the DM handling block lower down.
1089
        crud_obj = None
1✔
1090
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1091
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
1092
                                            source_protocol=from_cls.LABEL,
1093
                                            authed_as=actor, users=[from_user.key],
1094
                                            deleted=False)
1095

1096
        actor = as1.get_object(obj.as1, 'actor')
1✔
1097
        actor_id = actor.get('id')
1✔
1098

1099
        # handle activity!
1100
        if obj.type == 'stop-following':
1✔
1101
            # TODO: unify with handle_follow?
1102
            # TODO: handle multiple followees
1103
            if not actor_id or not inner_obj_id:
1✔
1104
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1105

1106
            # deactivate Follower
1107
            from_ = from_cls.key_for(actor_id)
1✔
1108
            to_cls = Protocol.for_id(inner_obj_id)
1✔
1109
            to = to_cls.key_for(inner_obj_id)
1✔
1110
            follower = Follower.query(Follower.to == to,
1✔
1111
                                      Follower.from_ == from_,
1112
                                      Follower.status == 'active').get()
1113
            if follower:
1✔
1114
                logger.info(f'Marking {follower} inactive')
1✔
1115
                follower.status = 'inactive'
1✔
1116
                follower.put()
1✔
1117
            else:
1118
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1119

1120
            # fall through to deliver to followee
1121
            # TODO: do we convert stop-following to webmention 410 of original
1122
            # follow?
1123

1124
            # fall through to deliver to followers
1125

1126
        elif obj.type in ('delete', 'undo'):
1✔
1127
            delete_obj_id = (from_user.profile_id()
1✔
1128
                            if inner_obj_id == from_user.key.id()
1129
                            else inner_obj_id)
1130

1131
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1132
            if not delete_obj:
1✔
1133
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1134
                return 'OK', 204
1✔
1135

1136
            # TODO: just delete altogether!
1137
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1138
            delete_obj.deleted = True
1✔
1139
            delete_obj.put()
1✔
1140

1141
            # if this is an actor, handle deleting it later so that
1142
            # in case it's from_user, user.enabled_protocols is still populated
1143
            #
1144
            # fall through to deliver to followers and delete copy if necessary.
1145
            # should happen via protocol-specific copy target and send of
1146
            # delete activity.
1147
            # https://github.com/snarfed/bridgy-fed/issues/63
1148

1149
        elif obj.type == 'block':
1✔
1150
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1151
                # blocking protocol bot user disables that protocol
1152
                from_user.delete(proto)
1✔
1153
                from_user.disable_protocol(proto)
1✔
1154
                return 'OK', 200
1✔
1155

1156
        elif obj.type == 'post':
1✔
1157
            # handle DMs to bot users
1158
            if as1.is_dm(obj.as1):
1✔
1159
                return dms.receive(from_user=from_user, obj=obj)
1✔
1160

1161
        # fetch actor if necessary
1162
        if (actor and actor.keys() == set(['id'])
1✔
1163
                and obj.type not in ('delete', 'undo')):
1164
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1165
            actor_obj = from_cls.load(ids.profile_id(id=actor['id'], proto=from_cls),
1✔
1166
                                      raise_=False)
1167
            if actor_obj and actor_obj.as1:
1✔
1168
                obj.our_as1 = {
1✔
1169
                    **obj.as1, 'actor': {
1170
                        **actor_obj.as1,
1171
                        # override profile id with actor id
1172
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1173
                        'id': actor['id'],
1174
                    }
1175
                }
1176

1177
        # fetch object if necessary
1178
        if (obj.type in ('post', 'update', 'share')
1✔
1179
                and inner_obj_as1.keys() == set(['id'])
1180
                and from_cls.owns_id(inner_obj_id)):
1181
            logger.debug('Fetching inner object')
1✔
1182
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1183
                                      remote=(obj.type in ('post', 'update')))
1184
            if obj.type in ('post', 'update'):
1✔
1185
                crud_obj = inner_obj
1✔
1186
            if inner_obj and inner_obj.as1:
1✔
1187
                obj.our_as1 = {
1✔
1188
                    **obj.as1,
1189
                    'object': {
1190
                        **inner_obj_as1,
1191
                        **inner_obj.as1,
1192
                    }
1193
                }
1194
            elif obj.type in ('post', 'update'):
1✔
1195
                error("Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1196

1197
        if obj.type == 'follow':
1✔
1198
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1199
                # follow of one of our protocol bot users; enable that protocol.
1200
                # fall through so that we send an accept.
1201
                try:
1✔
1202
                    from_user.enable_protocol(proto)
1✔
1203
                except ErrorButDoNotRetryTask:
1✔
1204
                    from web import Web
1✔
1205
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1206
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1207
                                               followee=bot, follow=obj)
1208
                    raise
1✔
1209
                proto.bot_follow(from_user)
1✔
1210

1211
            from_cls.handle_follow(obj)
1✔
1212

1213
        # deliver to targets
1214
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1215

1216
        # if this is a user, deactivate its followers/followings
1217
        # https://github.com/snarfed/bridgy-fed/issues/1304
1218
        if obj.type == 'delete':
1✔
1219
            if user_key := from_cls.key_for(id=inner_obj_id):
1✔
1220
                if user := user_key.get():
1✔
1221
                    for proto in user.enabled_protocols:
1✔
1222
                        user.disable_protocol(PROTOCOLS[proto])
1✔
1223

1224
                    logger.info(f'Deactivating Followers from or to {user_key.id()}')
1✔
1225
                    followers = Follower.query(
1✔
1226
                        OR(Follower.to == user_key, Follower.from_ == user_key)
1227
                        ).fetch()
1228
                    for f in followers:
1✔
1229
                        f.status = 'inactive'
1✔
1230
                    ndb.put_multi(followers)
1✔
1231

1232
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1233
        return resp
1✔
1234

1235
    @classmethod
1✔
1236
    def handle_follow(from_cls, obj):
1✔
1237
        """Handles an incoming follow activity.
1238

1239
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1240
        happens in :meth:`deliver`.
1241

1242
        Args:
1243
          obj (models.Object): follow activity
1244
        """
1245
        logger.debug('Got follow. Loading users, storing Follow(s), sending accept(s)')
1✔
1246

1247
        # Prepare follower (from) users' data
1248
        # TODO: remove all of this and just use from_user
1249
        from_as1 = as1.get_object(obj.as1, 'actor')
1✔
1250
        from_id = from_as1.get('id')
1✔
1251
        if not from_id:
1✔
1252
            error(f'Follow activity requires actor. Got: {obj.as1}')
×
1253

1254
        from_obj = from_cls.load(from_id, raise_=False)
1✔
1255
        if not from_obj:
1✔
1256
            error(f"Couldn't load {from_id}", status=502)
×
1257

1258
        if not from_obj.as1:
1✔
1259
            from_obj.our_as1 = from_as1
1✔
1260
            from_obj.put()
1✔
1261

1262
        from_key = from_cls.key_for(from_id)
1✔
1263
        if not from_key:
1✔
1264
            error(f'Invalid {from_cls.LABEL} user key: {from_id}')
×
1265
        obj.users = [from_key]
1✔
1266
        from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
1✔
1267

1268
        # Prepare followee (to) users' data
1269
        to_as1s = as1.get_objects(obj.as1)
1✔
1270
        if not to_as1s:
1✔
1271
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1272

1273
        # Store Followers
1274
        for to_as1 in to_as1s:
1✔
1275
            to_id = to_as1.get('id')
1✔
1276
            if not to_id:
1✔
1277
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1278

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

1281
            to_cls = Protocol.for_id(to_id)
1✔
1282
            if not to_cls:
1✔
1283
                error(f"Couldn't determine protocol for {to_id}")
×
1284
            elif from_cls == to_cls:
1✔
1285
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1286
                continue
1✔
1287

1288
            to_obj = to_cls.load(to_id)
1✔
1289
            if to_obj and not to_obj.as1:
1✔
1290
                to_obj.our_as1 = to_as1
1✔
1291
                to_obj.put()
1✔
1292

1293
            to_key = to_cls.key_for(to_id)
1✔
1294
            if not to_key:
1✔
1295
                logger.info(f'Skipping invalid {from_cls.LABEL} user key: {from_id}')
×
1296
                continue
×
1297

1298
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj,
1✔
1299
                                           allow_opt_out=True)
1300
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1301
                                                  follow=obj.key, status='active')
1302
            obj.add('notify', to_key)
1✔
1303
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1304
                                       followee=to_user, follow=obj)
1305

1306
    @classmethod
1✔
1307
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1308
        """Sends an accept or reject activity for a follow.
1309

1310
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1311
        nothing.
1312

1313
        Args:
1314
          verb (str): ``accept`` or  ``reject``
1315
          follower (models.User)
1316
          followee (models.User)
1317
          follow (models.Object)
1318
        """
1319
        assert verb in ('accept', 'reject')
1✔
1320
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1321
            return
1✔
1322

1323
        target = follower.target_for(follower.obj)
1✔
1324
        if not target:
1✔
1325
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1326

1327
        # send. note that this is one response for the whole follow, even if it
1328
        # has multiple followees!
1329
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1330
        accept = {
1✔
1331
            'id': id,
1332
            'objectType': 'activity',
1333
            'verb': verb,
1334
            'actor': followee.key.id(),
1335
            'object': follow.as1,
1336
        }
1337
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1338
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1339

1340
    @classmethod
1✔
1341
    def bot_follow(bot_cls, user):
1✔
1342
        """Follow a user from a protocol bot user.
1343

1344
        ...so that the protocol starts sending us their activities, if it needs
1345
        a follow for that (eg ActivityPub).
1346

1347
        Args:
1348
          user (User)
1349
        """
1350
        from web import Web
1✔
1351
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1352
        now = util.now().isoformat()
1✔
1353
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1354

1355
        if not user.obj:
1✔
1356
            logger.info("  can't follow, user has no profile obj")
1✔
1357
            return
1✔
1358

1359
        target = user.target_for(user.obj)
1✔
1360
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1361
        follow_back_as1 = {
1✔
1362
            'objectType': 'activity',
1363
            'verb': 'follow',
1364
            'id': follow_back_id,
1365
            'actor': bot.key.id(),
1366
            'object': user.key.id(),
1367
        }
1368
        common.create_task(queue='send', id=follow_back_id,
1✔
1369
                           our_as1=follow_back_as1, url=target,
1370
                           source_protocol='web', protocol=user.LABEL,
1371
                           user=bot.key.urlsafe())
1372

1373
    @classmethod
1✔
1374
    def handle_bare_object(cls, obj, authed_as=None):
1✔
1375
        """If obj is a bare object, wraps it in a create or update activity.
1376

1377
        Checks if we've seen it before.
1378

1379
        Args:
1380
          obj (models.Object)
1381
          authed_as (str): authenticated actor id who sent this activity
1382

1383
        Returns:
1384
          models.Object: ``obj`` if it's an activity, otherwise a new object
1385
        """
1386
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1387
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1388
            return obj
1✔
1389

1390
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1391
        now = util.now().isoformat()
1✔
1392

1393
        # occasionally we override the object, eg if this is a profile object
1394
        # coming in via a user with use_instead set
1395
        obj_as1 = obj.as1
1✔
1396
        if obj_id := obj.key.id():
1✔
1397
            if obj_as1_id := obj_as1.get('id'):
1✔
1398
                if obj_id != obj_as1_id:
1✔
1399
                    logger.info(f'Overriding AS1 object id {obj_as1_id} with Object id {obj_id}')
1✔
1400
                    obj_as1['id'] = obj_id
1✔
1401

1402
        # this is a raw post; wrap it in a create or update activity
1403
        if obj.changed or is_actor:
1✔
1404
            if obj.changed:
1✔
1405
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1406
            else:
1407
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1408
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1409
            update_as1 = {
1✔
1410
                'objectType': 'activity',
1411
                'verb': 'update',
1412
                'id': id,
1413
                'actor': obj_actor,
1414
                'object': {
1415
                    # Mastodon requires the updated field for Updates, so
1416
                    # add a default value.
1417
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1418
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1419
                    # https://github.com/mastodon/documentation/pull/1150
1420
                    'updated': now,
1421
                    **obj_as1,
1422
                },
1423
            }
1424
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1425
            return Object(id=id, our_as1=update_as1,
1✔
1426
                          source_protocol=obj.source_protocol)
1427

1428
        if (obj.new
1✔
1429
                # HACK: force query param here is specific to webmention
1430
                or 'force' in request.form):
1431
            create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1432
            create_as1 = {
1✔
1433
                'objectType': 'activity',
1434
                'verb': 'post',
1435
                'id': create_id,
1436
                'actor': obj_actor,
1437
                'object': obj_as1,
1438
                'published': now,
1439
            }
1440
            logger.info(f'Wrapping in post')
1✔
1441
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1442
            return Object(id=create_id, our_as1=create_as1,
1✔
1443
                          source_protocol=obj.source_protocol)
1444

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

1447
    @classmethod
1✔
1448
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1449
        """Delivers an activity to its external recipients.
1450

1451
        Args:
1452
          obj (models.Object): activity to deliver
1453
          from_user (models.User): user (actor) this activity is from
1454
          crud_obj (models.Object): if this is a create, update, or delete/undo
1455
            activity, the inner object that's being written, otherwise None.
1456
            (This object's ``notify`` and ``feed`` properties may be updated.)
1457
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1458
            targets on this protocol
1459

1460
        Returns:
1461
          (str, int) tuple: Flask response
1462
        """
1463
        if to_proto:
1✔
1464
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1465

1466
        # find delivery targets. maps Target to Object or None
1467
        #
1468
        # ...then write the relevant object, since targets() has a side effect of
1469
        # setting the notify and feed properties (and dirty attribute)
1470
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1471
        if to_proto:
1✔
1472
            targets = {t: obj for t, obj in targets.items()
1✔
1473
                       if t.protocol == to_proto.LABEL}
1474
        if not targets:
1✔
1475
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1476

1477
        # store object that targets() updated
1478
        if crud_obj and crud_obj.dirty:
1✔
1479
            crud_obj.put()
1✔
1480
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1481
            obj.put()
1✔
1482

1483
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1484
                      else obj.to_request())
1485

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

1489
        # enqueue send task for each targets
1490
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1491
        user = from_user.key.urlsafe()
1✔
1492
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1493
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1494
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1495
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1496

1497
        return 'OK', 202
1✔
1498

1499
    @classmethod
1✔
1500
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1501
        """Collects the targets to send a :class:`models.Object` to.
1502

1503
        Targets are both objects - original posts, events, etc - and actors.
1504

1505
        Args:
1506
          obj (models.Object)
1507
          from_user (User)
1508
          crud_obj (models.Object): if this is a create, update, or delete/undo
1509
            activity, the inner object that's being written, otherwise None.
1510
            (This object's ``notify`` and ``feed`` properties may be updated.)
1511
          internal (bool): whether this is a recursive internal call
1512

1513
        Returns:
1514
          dict: maps :class:`models.Target` to original (in response to)
1515
          :class:`models.Object`, if any, otherwise None
1516
        """
1517
        logger.debug('Finding recipients and their targets')
1✔
1518

1519
        # we should only have crud_obj iff this is a create or update
1520
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1521
        write_obj = crud_obj or obj
1✔
1522
        write_obj.dirty = False
1✔
1523

1524
        target_uris = as1.targets(obj.as1)
1✔
1525
        orig_obj = None
1✔
1526
        targets = {}  # maps Target to Object or None
1✔
1527
        owner = as1.get_owner(obj.as1)
1✔
1528
        allow_opt_out = (obj.type == 'delete')
1✔
1529
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1530
        inner_obj_id = inner_obj_as1.get('id')
1✔
1531
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1532
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1533
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1534
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1535
        is_self_reply = False
1✔
1536

1537
        original_ids = []
1✔
1538
        if is_reply:
1✔
1539
            original_ids = in_reply_tos
1✔
1540
        elif inner_obj_id:
1✔
1541
            if inner_obj_id == from_user.key.id():
1✔
1542
                inner_obj_id = from_user.profile_id()
1✔
1543
            original_ids = [inner_obj_id]
1✔
1544

1545
        original_objs = {}
1✔
1546
        for id in original_ids:
1✔
1547
            if proto := Protocol.for_id(id):
1✔
1548
                original_objs[id] = proto.load(id, raise_=False)
1✔
1549

1550
        # for AP, add in-reply-tos' mentions
1551
        # https://github.com/snarfed/bridgy-fed/issues/1608
1552
        # https://github.com/snarfed/bridgy-fed/issues/1218
1553
        orig_post_mentions = {}  # maps mentioned id to original post Object
1✔
1554
        for id in in_reply_tos:
1✔
1555
            if ((in_reply_to_obj := original_objs.get(id))
1✔
1556
                    and (proto := PROTOCOLS.get(in_reply_to_obj.source_protocol))
1557
                    and proto.SEND_REPLIES_TO_ORIG_POSTS_MENTIONS
1558
                    and (mentions := as1.mentions(in_reply_to_obj.as1))):
1559
                logger.info(f"Adding in-reply-to {id} 's mentions to targets: {mentions}")
1✔
1560
                target_uris.extend(mentions)
1✔
1561
                for mention in mentions:
1✔
1562
                    orig_post_mentions[mention] = in_reply_to_obj
1✔
1563

1564
        target_uris = sorted(set(target_uris))
1✔
1565
        logger.info(f'Raw targets: {target_uris}')
1✔
1566

1567
        # which protocols should we allow delivering to?
1568
        to_protocols = []
1✔
1569
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1570
                      + from_user.enabled_protocols):
1571
            if not (proto := PROTOCOLS.get(label)):
1✔
1572
                report_error(f'unknown enabled protocol {label} for {from_user.key.id()}')
1✔
1573
                continue
1✔
1574

1575
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1576
                                     or is_reply):
1577
                origs_could_bridge = None
1✔
1578

1579
                for id in original_ids:
1✔
1580
                    if not (orig := original_objs.get(id)):
1✔
1581
                        continue
1✔
1582
                    elif isinstance(orig, proto):
1✔
UNCOV
1583
                        logger.info(f'Allowing {label} for original post {id}')
×
UNCOV
1584
                        break
×
1585
                    elif orig.get_copy(proto):
1✔
1586
                        logger.info(f'Allowing {label}, original post {id} was bridged there')
1✔
1587
                        break
1✔
1588

1589
                    if (origs_could_bridge is not False
1✔
1590
                            and (orig_author_id := as1.get_owner(orig.as1))
1591
                            and (orig_proto := PROTOCOLS.get(orig.source_protocol))
1592
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1593
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1594

1595
                else:
1596
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1597
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
1✔
1598
                            and origs_could_bridge):
1599
                        # retry later; original obj may still be bridging
1600
                        error(msg, status=304)
1✔
1601

1602
                    logger.info(msg)
1✔
1603
                    continue
1✔
1604

1605

1606
            util.add(to_protocols, proto)
1✔
1607

1608
        # process direct targets
1609
        for target_id in target_uris:
1✔
1610
            target_proto = Protocol.for_id(target_id)
1✔
1611
            if not target_proto:
1✔
1612
                logger.info(f"Can't determine protocol for {target_id}")
1✔
1613
                continue
1✔
1614
            elif target_proto.is_blocklisted(target_id):
1✔
1615
                logger.debug(f'{target_id} is blocklisted')
1✔
1616
                continue
1✔
1617

1618
            orig_obj = target_proto.load(target_id, raise_=False)
1✔
1619
            if not orig_obj or not orig_obj.as1:
1✔
1620
                logger.info(f"Couldn't load {target_id}")
1✔
1621
                continue
1✔
1622

1623
            target_author_key = (target_proto(id=target_id).key
1✔
1624
                                 if target_id in mentioned_urls
1625
                                 else target_proto.actor_key(orig_obj))
1626
            if not from_user.is_enabled(target_proto):
1✔
1627
                # if author isn't bridged and target user is, DM a prompt and
1628
                # add a notif for the target user
1629
                if (target_id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
1630
                        and target_author_key):
1631
                    if target_author := target_author_key.get():
1✔
1632
                        if target_author.is_enabled(from_cls):
1✔
1633
                            notifications.add_notification(target_author, write_obj)
1✔
1634
                            verb, noun = (
1✔
1635
                                ('replied to', 'replies') if target_id in in_reply_tos
1636
                                else ('quoted', 'quotes') if target_id in quoted_posts
1637
                                else ('mentioned', 'mentions'))
1638
                            dms.maybe_send(
1✔
1639
                                from_proto=target_proto, to_user=from_user,
1640
                                type='replied_to_bridged_user', text=f"""\
1641
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.""")
1642

1643
                continue
1✔
1644

1645
            # deliver self-replies to followers
1646
            # https://github.com/snarfed/bridgy-fed/issues/639
1647
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1648
                is_self_reply = True
1✔
1649
                logger.info(f'self reply!')
1✔
1650

1651
            # also add copies' targets
1652
            for copy in orig_obj.copies:
1✔
1653
                proto = PROTOCOLS[copy.protocol]
1✔
1654
                if proto in to_protocols:
1✔
1655
                    # copies generally won't have their own Objects
1656
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1657
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {target_id}')
1✔
1658
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1659

1660
            if target_proto == from_cls:
1✔
1661
                logger.debug(f'Skipping same-protocol target {target_id}')
1✔
1662
                continue
1✔
1663

1664
            target = target_proto.target_for(orig_obj)
1✔
1665
            if not target:
1✔
1666
                # TODO: surface errors like this somehow?
UNCOV
1667
                logger.error(f"Can't find delivery target for {target_id}")
×
UNCOV
1668
                continue
×
1669

1670
            logger.debug(f'Target for {target_id} is {target}')
1✔
1671
            # only use orig_obj for inReplyTos, like/repost objects, reply's original
1672
            # post's mentions, etc
1673
            # https://github.com/snarfed/bridgy-fed/issues/1237
1674
            target_obj = None
1✔
1675
            if target_id in in_reply_tos + as1.get_ids(obj.as1, 'object'):
1✔
1676
                target_obj = orig_obj
1✔
1677
            elif target_id in orig_post_mentions:
1✔
1678
                target_obj = orig_post_mentions[target_id]
1✔
1679
            targets[Target(protocol=target_proto.LABEL, uri=target)] = target_obj
1✔
1680

1681
            if target_author_key:
1✔
1682
                logger.debug(f'Recipient is {target_author_key}')
1✔
1683
                if write_obj.add('notify', target_author_key):
1✔
1684
                    write_obj.dirty = True
1✔
1685

1686
        if obj.type == 'undo':
1✔
1687
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1688
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1689
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1690
            else:
1691
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1692
            if inner_obj:
1✔
1693
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1694
                                                internal=True))
1695

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

1698
        # deliver to followers, if appropriate
1699
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1700
        if not user_key:
1✔
1701
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1702
            return targets
1✔
1703

1704
        followers = []
1✔
1705
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1706
                and (not is_reply or is_self_reply)):
1707
            logger.info(f'Delivering to followers of {user_key}')
1✔
1708
            followers = []
1✔
1709
            for f in Follower.query(Follower.to == user_key,
1✔
1710
                                    Follower.status == 'active'):
1711
                proto = PROTOCOLS_BY_KIND[f.from_.kind()]
1✔
1712
                # skip protocol bot users
1713
                if (not Protocol.for_bridgy_subdomain(f.from_.id())
1✔
1714
                        # skip protocols this user hasn't enabled, or where the base
1715
                        # object of this activity hasn't been bridged
1716
                        and proto in to_protocols
1717
                        # we deliver to HAS_COPIES protocols separately, below. we
1718
                        # assume they have follower-independent targets.
1719
                        and not (proto.HAS_COPIES and proto.DEFAULT_TARGET)):
1720
                    followers.append(f)
1✔
1721

1722
            user_keys = [f.from_ for f in followers]
1✔
1723
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1724
            User.load_multi(users)
1✔
1725

1726
            if (not followers and
1✔
1727
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
1728
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
1729
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1730
                return {}
1✔
1731

1732
            # add to followers' feeds, if any
1733
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1734
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1735
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
1✔
1736
                    if write_obj.feed:
1✔
1737
                        write_obj.dirty = True
1✔
1738

1739
            # collect targets for followers
1740
            for user in users:
1✔
1741
                # TODO: should we pass remote=False through here to Protocol.load?
1742
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1743
                if not target:
1✔
1744
                    # logger.error(f'Follower {user.key} has no delivery target')
1745
                    continue
1✔
1746

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

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

1755
        # deliver to enabled HAS_COPIES protocols proactively
1756
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
1757
            for proto in to_protocols:
1✔
1758
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
1759
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
1760
                    targets.setdefault(
1✔
1761
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1762

1763
        # de-dupe targets, discard same-domain
1764
        # maps string target URL to (Target, Object) tuple
1765
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1766
        # maps Target to Object or None
1767
        targets = {}
1✔
1768
        source_domains = [
1✔
1769
            util.domain_from_link(url) for url in
1770
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1771
            if util.is_web(url)
1772
        ]
1773
        for url in sorted(util.dedupe_urls(
1✔
1774
                candidates.keys(),
1775
                # preserve our PDS URL without trailing slash in path
1776
                # https://atproto.com/specs/did#did-documents
1777
                trailing_slash=False)):
1778
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
UNCOV
1779
                logger.info(f'Skipping same-domain target {url}')
×
UNCOV
1780
                continue
×
1781
            target, obj = candidates[url]
1✔
1782
            targets[target] = obj
1✔
1783

1784
        return targets
1✔
1785

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

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

1793
        Args:
1794
          id (str)
1795
          remote (bool): whether to fetch the object over the network. If True,
1796
            fetches even if we already have the object stored, and updates our
1797
            stored copy. If False and we don't have the object stored, returns
1798
            None. Default (None) means to fetch over the network only if we
1799
            don't already have it stored.
1800
          local (bool): whether to load from the datastore before
1801
            fetching over the network. If False, still stores back to the
1802
            datastore after a successful remote fetch.
1803
          raise_ (bool): if False, catches any :class:`request.RequestException`
1804
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1805
            ``None`` instead
1806
          kwargs: passed through to :meth:`fetch()`
1807

1808
        Returns:
1809
          models.Object: loaded object, or None if it isn't fetchable, eg a
1810
          non-URL string for Web, or ``remote`` is False and it isn't in the
1811
          datastore
1812

1813
        Raises:
1814
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1815
            is True
1816
        """
1817
        assert id
1✔
1818
        assert local or remote is not False
1✔
1819
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1820

1821
        obj = orig_as1 = None
1✔
1822
        if local:
1✔
1823
            obj = Object.get_by_id(id)
1✔
1824
            if not obj:
1✔
1825
                # logger.debug(f' {id} not in datastore')
1826
                pass
1✔
1827
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1828
                # logger.debug(f'  {id} got from datastore')
1829
                obj.new = False
1✔
1830

1831
        if remote is False:
1✔
1832
            return obj
1✔
1833
        elif remote is None and obj:
1✔
1834
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1835
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1836
                pass
1✔
1837
            else:
1838
                return obj
1✔
1839

1840
        if obj:
1✔
1841
            orig_as1 = obj.as1
1✔
1842
            obj.our_as1 = None
1✔
1843
            obj.new = False
1✔
1844
        else:
1845
            obj = Object(id=id)
1✔
1846
            if local:
1✔
1847
                # logger.debug(f'  {id} not in datastore')
1848
                obj.new = True
1✔
1849
                obj.changed = False
1✔
1850

1851
        try:
1✔
1852
            fetched = cls.fetch(obj, **kwargs)
1✔
1853
        except (RequestException, HTTPException) as e:
1✔
1854
            if raise_:
1✔
1855
                raise
1✔
1856
            util.interpret_http_exception(e)
1✔
1857
            return None
1✔
1858

1859
        if not fetched:
1✔
1860
            return None
1✔
1861

1862
        # https://stackoverflow.com/a/3042250/186123
1863
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1864
        if size > models.MAX_ENTITY_SIZE:
1✔
1865
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1866
            return None
1✔
1867

1868
        obj.resolve_ids()
1✔
1869
        obj.normalize_ids()
1✔
1870

1871
        if obj.new is False:
1✔
1872
            obj.changed = obj.activity_changed(orig_as1)
1✔
1873

1874
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1875
            if obj.source_protocol:
1✔
UNCOV
1876
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1877
            obj.source_protocol = cls.LABEL
1✔
1878

1879
        obj.put()
1✔
1880
        return obj
1✔
1881

1882
    @classmethod
1✔
1883
    def check_supported(cls, obj):
1✔
1884
        """If this protocol doesn't support this object, raises HTTP 204.
1885

1886
        Also reports an error.
1887

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

1892
        Args:
1893
          obj (Object)
1894

1895
        Raises:
1896
          werkzeug.HTTPException: if this protocol doesn't support this object
1897
        """
1898
        if not obj.type:
1✔
UNCOV
1899
            return
×
1900

1901
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1902
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1903
            or (obj.type in as1.CRUD_VERBS
1904
                and inner_type
1905
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1906
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1907

1908
        # don't allow posts with blank content and no image/video/audio
1909
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
1910
                    else obj.as1)
1911
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
1912
                and not util.get_url(crud_obj, key='image')
1913
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
1914
                # TODO: handle articles with displayName but not content
1915
                and not source.html_to_text(crud_obj.get('content')).strip()):
1916
            error('Blank content and no image or video or audio', status=204)
1✔
1917

1918
        # DMs are only allowed to/from protocol bot accounts
1919
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1920
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1921
            if (not cls.SUPPORTS_DMS
1✔
1922
                    or (recip not in protocol_user_ids
1923
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1924
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1925

1926

1927
@cloud_tasks_only(log=None)
1✔
1928
def receive_task():
1✔
1929
    """Task handler for a newly received :class:`models.Object`.
1930

1931
    Calls :meth:`Protocol.receive` with the form parameters.
1932

1933
    Parameters:
1934
      authed_as (str): passed to :meth:`Protocol.receive`
1935
      obj_id (str): key id of :class:`models.Object` to handle
1936
      received_at (str, ISO 8601 timestamp): when we first saw (received)
1937
        this activity
1938
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1939
        :class:`models.Object` to handle
1940

1941
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
1942
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
1943
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
1944
    ``changed``, HTTP request details, etc. See stash for attempt at this for
1945
    :class:`web.Web`.
1946
    """
1947
    common.log_request()
1✔
1948
    form = request.form.to_dict()
1✔
1949

1950
    authed_as = form.pop('authed_as', None)
1✔
1951
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1952
                or authed_as in common.PROTOCOL_DOMAINS)
1953

1954
    obj = Object.from_request()
1✔
1955
    assert obj
1✔
1956
    assert obj.source_protocol
1✔
1957
    obj.new = True
1✔
1958

1959
    if received_at := form.pop('received_at', None):
1✔
1960
        received_at = datetime.fromisoformat(received_at)
1✔
1961

1962
    try:
1✔
1963
        return PROTOCOLS[obj.source_protocol].receive(
1✔
1964
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
1965
    except RequestException as e:
1✔
1966
        util.interpret_http_exception(e)
1✔
1967
        error(e, status=304)
1✔
1968
    except ValueError as e:
1✔
UNCOV
1969
        logger.warning(e, exc_info=True)
×
UNCOV
1970
        error(e, status=304)
×
1971

1972

1973
@cloud_tasks_only(log=None)
1✔
1974
def send_task():
1✔
1975
    """Task handler for sending an activity to a single specific destination.
1976

1977
    Calls :meth:`Protocol.send` with the form parameters.
1978

1979
    Parameters:
1980
      protocol (str): :class:`Protocol` to send to
1981
      url (str): destination URL to send to
1982
      obj_id (str): key id of :class:`models.Object` to send
1983
      orig_obj_id (str): optional, :class:`models.Object` key id of the
1984
        "original object" that this object refers to, eg replies to or reposts
1985
        or likes
1986
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1987
        this activity is from
1988
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1989
        :class:`models.Object` to handle
1990
    """
1991
    common.log_request()
1✔
1992

1993
    # prepare
1994
    form = request.form.to_dict()
1✔
1995
    url = form.get('url')
1✔
1996
    protocol = form.get('protocol')
1✔
1997
    if not url or not protocol:
1✔
1998
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1999
        return '', 204
1✔
2000

2001
    target = Target(uri=url, protocol=protocol)
1✔
2002
    obj = Object.from_request()
1✔
2003
    assert obj and obj.key and obj.key.id()
1✔
2004

2005
    PROTOCOLS[protocol].check_supported(obj)
1✔
2006
    allow_opt_out = (obj.type == 'delete')
1✔
2007

2008
    user = None
1✔
2009
    if user_key := form.get('user'):
1✔
2010
        key = ndb.Key(urlsafe=user_key)
1✔
2011
        # use get_by_id so that we follow use_instead
2012
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2013
            key.id(), allow_opt_out=allow_opt_out)
2014

2015
    # send
2016
    delay = ''
1✔
2017
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
2018
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
2019
        delay = f'({delay_s} s behind)'
1✔
2020
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
2021
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
2022
    sent = None
1✔
2023
    try:
1✔
2024
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
2025
                                        orig_obj_id=form.get('orig_obj_id'))
2026
    except BaseException as e:
1✔
2027
        code, body = util.interpret_http_exception(e)
1✔
2028
        if not code and not body:
1✔
2029
            raise
1✔
2030

2031
    if sent is False:
1✔
2032
        logger.info(f'Failed sending!')
1✔
2033

2034
    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