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

snarfed / bridgy-fed / 81379d96-43fe-45d4-a87b-08be2d6a94a7

17 Apr 2025 11:53PM UTC coverage: 93.157% (-0.03%) from 93.184%
81379d96-43fe-45d4-a87b-08be2d6a94a7

push

circleci

snarfed
ids.translate_user_id: handle our own subdomain-wrapped ids

eg https://bsky.brid.gy/ap/did:plc:456

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

84 existing lines in 3 files now uncovered.

4765 of 5115 relevant lines covered (93.16%)

0.93 hits per line

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

93.89
/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
    PRIMARY_DOMAIN,
32
    PROTOCOL_DOMAINS,
33
    report_error,
34
    subdomain_wrap,
35
)
36
import dms
1✔
37
import ids
1✔
38
from ids import (
1✔
39
    BOT_ACTOR_AP_IDS,
40
    normalize_user_id,
41
    translate_object_id,
42
    translate_user_id,
43
)
44
import memcache
1✔
45
from models import (
1✔
46
    DM,
47
    Follower,
48
    Object,
49
    PROTOCOLS,
50
    PROTOCOLS_BY_KIND,
51
    Target,
52
    User,
53
)
54

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

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

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

73
logger = logging.getLogger(__name__)
1✔
74

75

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

80

81
class ErrorButDoNotRetryTask(HTTPException):
1✔
82
    code = 299
1✔
83
    description = 'ErrorButDoNotRetryTask'
1✔
84

85
# https://github.com/pallets/flask/issues/1837#issuecomment-304996942
86
werkzeug.exceptions.default_exceptions.setdefault(299, ErrorButDoNotRetryTask)
1✔
87
werkzeug.exceptions._aborter.mapping.setdefault(299, ErrorButDoNotRetryTask)
1✔
88

89

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

93

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

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

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

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

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

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

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

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

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

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

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

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

176
        To be implemented by subclasses.
177

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

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

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

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

192
        Args:
193
          id (str)
194

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

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

204
        To be implemented by subclasses.
205

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

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

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

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

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

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

233
        To be implemented by subclasses.
234

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

238
        Args:
239
          handle (str)
240

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

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

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

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

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

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

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

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

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

281
        Args:
282
          id (str)
283

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

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

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

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

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

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

327
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
328
            if by_subdomain and not is_homepage and id not in BOT_ACTOR_AP_IDS:
1✔
329
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
330
                return by_subdomain
1✔
331

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

345
        if len(candidates) == 1:
1✔
346
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
347
            return candidates[0]
1✔
348

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

360
        # step 4: fetch over the network, if necessary
361
        if not remote:
1✔
362
            return None
1✔
363

364
        for protocol in candidates:
1✔
365
            logger.debug(f'Trying {protocol.LABEL}')
1✔
366
            try:
1✔
367
                obj = protocol.load(id, local=False, remote=True)
1✔
368

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

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

395
        logger.info(f'No matching protocol found for {id} !')
1✔
396
        return None
1✔
397

398
    @cached(LRUCache(20000), lock=Lock())
1✔
399
    @staticmethod
1✔
400
    def for_handle(handle):
1✔
401
        """Returns the protocol for a given handle.
402

403
        May incur expensive side effects like resolving the handle itself over
404
        the network or other discovery.
405

406
        Args:
407
          handle (str)
408

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

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

431
        if len(candidates) == 1:
1✔
UNCOV
432
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
UNCOV
433
            return (candidates[0], None)
×
434

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

444
        # step 3: resolve handle to id
445
        for proto in candidates:
1✔
446
            id = proto.handle_to_id(handle)
1✔
447
            if id:
1✔
448
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
449
                return (proto, id)
1✔
450

451
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
452
        return (None, None)
1✔
453

454
    @classmethod
1✔
455
    def bridged_web_url_for(cls, user, fallback=False):
1✔
456
        """Returns the web URL for a user's bridged profile in this protocol.
457

458
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
459
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
460

461
        Args:
462
          user (models.User)
463
          fallback (bool): if True, and bridged users have no canonical user
464
            profile URL in this protocol, return the native protocol's profile URL
465

466
        Returns:
467
          str, or None if there isn't a canonical URL
468
        """
469
        if fallback:
1✔
470
            return user.web_url()
1✔
471

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

476
        Args:
477
          obj (models.Object)
478
          allow_opt_out (bool): whether to return a user key if they're opted out
479

480
        Returns:
481
          google.cloud.ndb.key.Key or None:
482
        """
483
        owner = as1.get_owner(obj.as1)
1✔
484
        if owner:
1✔
485
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
486

487
    @classmethod
1✔
488
    def bot_user_id(cls):
1✔
489
        """Returns the Web user id for the bot user for this protocol.
490

491
        For example, ``'bsky.brid.gy'`` for ATProto.
492

493
        Returns:
494
          str:
495
        """
496
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
497

498
    @classmethod
1✔
499
    def create_for(cls, user):
1✔
500
        """Creates or re-activate a copy user in this protocol.
501

502
        Should add the copy user to :attr:`copies`.
503

504
        If the copy user already exists and active, should do nothing.
505

506
        Args:
507
          user (models.User): original source user. Shouldn't already have a
508
            copy user for this protocol in :attr:`copies`.
509

510
        Raises:
511
          ValueError: if we can't create a copy of the given user in this protocol
512
        """
UNCOV
513
        raise NotImplementedError()
×
514

515
    @classmethod
1✔
516
    def send(to_cls, obj, url, from_user=None, orig_obj_id=None):
1✔
517
        """Sends an outgoing activity.
518

519
        To be implemented by subclasses.
520

521
        NOTE: if this protocol's ``HAS_COPIES`` is True, and this method creates
522
        a copy and sends it, it *must* add that copy to the *object*'s (not
523
        activity's) :attr:`copies`!
524

525
        Args:
526
          obj (models.Object): with activity to send
527
          url (str): destination URL to send to
528
          from_user (models.User): user (actor) this activity is from
529
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
530
            that this object refers to, eg replies to or reposts or likes
531

532
        Returns:
533
          bool: True if the activity is sent successfully, False if it is
534
          ignored or otherwise unsent due to protocol logic, eg no webmention
535
          endpoint, protocol doesn't support the activity type. (Failures are
536
          raised as exceptions.)
537

538
        Raises:
539
          werkzeug.HTTPException if the request fails
540
        """
UNCOV
541
        raise NotImplementedError()
×
542

543
    @classmethod
1✔
544
    def fetch(cls, obj, **kwargs):
1✔
545
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
546

547
        Errors are raised as exceptions. If this method returns False, the fetch
548
        didn't fail but didn't succeed either, eg the id isn't valid for this
549
        protocol, or the fetch didn't return valid data for this protocol.
550

551
        To be implemented by subclasses.
552

553
        Args:
554
          obj (models.Object): with the id to fetch. Data is filled into one of
555
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
556
          kwargs: subclass-specific
557

558
        Returns:
559
          bool: True if the object was fetched and populated successfully,
560
          False otherwise
561

562
        Raises:
563
          requests.RequestException or werkzeug.HTTPException: if the fetch fails
564
        """
UNCOV
565
        raise NotImplementedError()
×
566

567
    @classmethod
1✔
568
    def convert(cls, obj, from_user=None, **kwargs):
1✔
569
        """Converts an :class:`Object` to this protocol's data format.
570

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

574
        Just passes through to :meth:`_convert`, then does minor
575
        protocol-independent postprocessing.
576

577
        Args:
578
          obj (models.Object):
579
          from_user (models.User): user (actor) this activity/object is from
580
          kwargs: protocol-specific, passed through to :meth:`_convert`
581

582
        Returns:
583
          converted object in the protocol's native format, often a dict
584
        """
585
        if not obj or not obj.as1:
1✔
586
            return {}
1✔
587

588
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
589
        is_activity = obj.as1.get('verb') in ('post', 'update')
1✔
590
        base_obj = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
591
        orig_our_as1 = obj.our_as1
1✔
592

593
        # mark bridged actors as bots and add "bridged by Bridgy Fed" to their bios
594
        if (from_user and base_obj
1✔
595
            and base_obj.get('objectType') in as1.ACTOR_TYPES
596
            and PROTOCOLS.get(obj.source_protocol) != cls
597
            and Protocol.for_bridgy_subdomain(id) not in DOMAINS
598
            # Web users are special cased, they don't get the label if they've
599
            # explicitly enabled Bridgy Fed with redirects or webmentions
600
            and not (from_user.LABEL == 'web'
601
                     and (from_user.last_webmention_in or from_user.has_redirects))):
602

603
            obj.our_as1 = copy.deepcopy(obj.as1)
1✔
604
            actor = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
605
            actor['objectType'] = 'person'
1✔
606
            cls.add_source_links(actor=actor, obj=obj, from_user=from_user)
1✔
607

608
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
609
        obj.our_as1 = orig_our_as1
1✔
610
        return converted
1✔
611

612
    @classmethod
1✔
613
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
614
        """Converts an :class:`Object` to this protocol's data format.
615

616
        To be implemented by subclasses. Implementations should generally call
617
        :meth:`Protocol.translate_ids` (as their own class) before converting to
618
        their format.
619

620
        Args:
621
          obj (models.Object):
622
          from_user (models.User): user (actor) this activity/object is from
623
          kwargs: protocol-specific
624

625
        Returns:
626
          converted object in the protocol's native format, often a dict. May
627
            return the ``{}`` empty dict if the object can't be converted.
628
        """
UNCOV
629
        raise NotImplementedError()
×
630

631
    @classmethod
1✔
632
    def add_source_links(cls, actor, obj, from_user):
1✔
633
        """Adds "bridged from ... by Bridgy Fed" HTML to ``actor['summary']``.
634

635
        Default implementation; subclasses may override.
636

637
        Args:
638
          actor (dict): AS1 actor
639
          obj (models.Object):
640
          from_user (models.User): user (actor) this activity/object is from
641
        """
642
        assert from_user
1✔
643
        summary = actor.setdefault('summary', '')
1✔
644
        if 'Bridgy Fed]' in html_to_text(summary, ignore_links=True):
1✔
645
            return
1✔
646

647
        id = actor.get('id')
1✔
648
        proto_phrase = (PROTOCOLS[obj.source_protocol].PHRASE
1✔
649
                        if obj.source_protocol else '')
650
        if proto_phrase:
1✔
651
            proto_phrase = f' on {proto_phrase}'
1✔
652

653
        if from_user.key and id in (from_user.key.id(), from_user.profile_id()):
1✔
654
            source_links = f'[<a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a> from <a href="{from_user.web_url()}">{from_user.handle}</a>{proto_phrase} by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>]'
1✔
655

656
        else:
657
            url = as1.get_url(actor) or id
1✔
658
            source = util.pretty_link(url) if url else '?'
1✔
659
            source_links = f'[bridged from {source}{proto_phrase} by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>]'
1✔
660

661
        if summary:
1✔
662
            summary += '<br><br>'
1✔
663
        actor['summary'] = summary + source_links
1✔
664

665
    @classmethod
1✔
666
    def set_username(to_cls, user, username):
1✔
667
        """Sets a custom username for a user's bridged account in this protocol.
668

669
        Args:
670
          user (models.User)
671
          username (str)
672

673
        Raises:
674
          ValueError: if the username is invalid
675
          RuntimeError: if the username could not be set
676
        """
677
        raise NotImplementedError()
1✔
678

679
    @classmethod
1✔
680
    def migrate_out(cls, user, to_user_id):
1✔
681
        """Migrates a bridged account out to be a native account.
682

683
        Args:
684
          user (models.User)
685
          to_user_id (str)
686

687
        Raises:
688
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
689
            ``user`` is on this protocol or not bridged to this protocol
690
        """
UNCOV
691
        raise NotImplementedError()
×
692

693
    @classmethod
1✔
694
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
695
        """Migrates a native account in to be a bridged account.
696

697
        Args:
698
          user (models.User): native user on another protocol to attach the
699
            newly imported bridged account to
700
          from_user_id (str)
701
          kwargs: additional protocol-specific parameters
702

703
        Raises:
704
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
705
            ``user`` is on this protocol or already bridged to this protocol
706
        """
UNCOV
707
        raise NotImplementedError()
×
708

709
    @classmethod
1✔
710
    def target_for(cls, obj, shared=False):
1✔
711
        """Returns an :class:`Object`'s delivery target (endpoint).
712

713
        To be implemented by subclasses.
714

715
        Examples:
716

717
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
718
          webmention target.
719
        * If obj is an ``activitypub`` actor, returns its inbox.
720
        * If obj is an ``activitypub`` object, returns it's author's or actor's
721
          inbox.
722

723
        Args:
724
          obj (models.Object):
725
          shared (bool): optional. If True, returns a common/shared
726
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
727
            multiple recipients for efficiency
728

729
        Returns:
730
          str: target endpoint, or None if not available.
731
        """
UNCOV
732
        raise NotImplementedError()
×
733

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

738
        Default implementation here, subclasses may override.
739

740
        Args:
741
          url (str):
742
          allow_internal (bool): whether to return False for internal domains
743
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
744
        """
745
        blocklist = DOMAIN_BLOCKLIST
1✔
746
        if not allow_internal:
1✔
747
            blocklist += DOMAINS
1✔
748
        return util.domain_or_parent_in(util.domain_from_link(url), blocklist)
1✔
749

750
    @classmethod
1✔
751
    def translate_ids(to_cls, obj):
1✔
752
        """Translates all ids in an AS1 object to a specific protocol.
753

754
        Infers source protocol for each id value separately.
755

756
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
757
        ``at://did:plc:abc/coll/123`` will be converted to
758
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
759

760
        Wraps these AS1 fields:
761

762
        * ``id``
763
        * ``actor``
764
        * ``author``
765
        * ``bcc``
766
        * ``bto``
767
        * ``cc``
768
        * ``object``
769
        * ``object.actor``
770
        * ``object.author``
771
        * ``object.id``
772
        * ``object.inReplyTo``
773
        * ``object.object``
774
        * ``attachments[].id``
775
        * ``tags[objectType=mention].url``
776
        * ``to``
777

778
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
779
        same logic is duplicated there!
780

781
        TODO: unify with :meth:`Object.resolve_ids`,
782
        :meth:`models.Object.normalize_ids`.
783

784
        Args:
785
          to_proto (Protocol subclass)
786
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
787

788
        Returns:
789
          dict: wrapped AS1 version of ``obj``
790
        """
791
        assert to_cls != Protocol
1✔
792
        if not obj:
1✔
793
            return obj
1✔
794

795
        outer_obj = copy.deepcopy(obj)
1✔
796
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
797

798
        def translate(elem, field, fn, uri=False):
1✔
799
            elem[field] = as1.get_objects(elem, field)
1✔
800
            for obj in elem[field]:
1✔
801
                if id := obj.get('id'):
1✔
802
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
803
                        continue
1✔
804
                    from_cls = Protocol.for_id(id)
1✔
805
                    # TODO: what if from_cls is None? relax translate_object_id,
806
                    # make it a noop if we don't know enough about from/to?
807
                    if from_cls and from_cls != to_cls:
1✔
808
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
809
                    if obj['id'] and uri:
1✔
810
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
811

812
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
813
                           for o in elem[field]]
814

815
            if len(elem[field]) == 1:
1✔
816
                elem[field] = elem[field][0]
1✔
817

818
        type = as1.object_type(outer_obj)
1✔
819
        translate(outer_obj, 'id',
1✔
820
                  translate_user_id if type in as1.ACTOR_TYPES
821
                  else translate_object_id)
822

823
        for o in inner_objs:
1✔
824
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
825
                        or as1.get_owner(outer_obj) == o.get('id')
826
                        or type in ('follow', 'stop-following'))
827
            translate(o, 'id', translate_user_id if is_actor else translate_object_id)
1✔
828
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
829
            translate(o, 'object', translate_user_id if obj_is_actor
1✔
830
                      else translate_object_id)
831

832
        for o in [outer_obj] + inner_objs:
1✔
833
            translate(o, 'inReplyTo', translate_object_id)
1✔
834
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
835
                translate(o, field, translate_user_id)
1✔
836
            for tag in as1.get_objects(o, 'tags'):
1✔
837
                if tag.get('objectType') == 'mention':
1✔
838
                    translate(tag, 'url', translate_user_id, uri=True)
1✔
839
            for att in as1.get_objects(o, 'attachments'):
1✔
840
                translate(att, 'id', translate_object_id)
1✔
841
                url = att.get('url')
1✔
842
                if url and not att.get('id'):
1✔
843
                    if from_cls := Protocol.for_id(url):
1✔
844
                        att['id'] = translate_object_id(from_=from_cls, to=to_cls,
1✔
845
                                                        id=url)
846

847
        outer_obj = util.trim_nulls(outer_obj)
1✔
848

849
        if objs := util.get_list(outer_obj ,'object'):
1✔
850
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
851
            if len(outer_obj['object']) == 1:
1✔
852
                outer_obj['object'] = outer_obj['object'][0]
1✔
853

854
        return outer_obj
1✔
855

856
    @classmethod
1✔
857
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
858
        """Handles an incoming activity.
859

860
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
861
        unset, returns HTTP 299.
862

863
        Args:
864
          obj (models.Object)
865
          authed_as (str): authenticated actor id who sent this activity
866
          internal (bool): whether to allow activity ids on internal domains,
867
            from opted out/blocked users, etc.
868
          received_at (datetime): when we first saw (received) this activity.
869
            Right now only used for monitoring.
870

871
        Returns:
872
          (str, int) tuple: (response body, HTTP status code) Flask response
873

874
        Raises:
875
          werkzeug.HTTPException: if the request is invalid
876
        """
877
        # check some invariants
878
        assert from_cls != Protocol
1✔
879
        assert isinstance(obj, Object), obj
1✔
880

881
        if not obj.as1:
1✔
UNCOV
882
            error('No object data provided')
×
883

884
        id = None
1✔
885
        if obj.key and obj.key.id():
1✔
886
            id = obj.key.id()
1✔
887

888
        if not id:
1✔
889
            id = obj.as1.get('id')
1✔
890
            obj.key = ndb.Key(Object, id)
1✔
891

892
        if not id:
1✔
UNCOV
893
            error('No id provided')
×
894
        elif from_cls.owns_id(id) is False:
1✔
895
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
896
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
897
            error(f'Activity {id} is blocklisted')
1✔
898
        # check that this activity is public. only do this for some activities,
899
        # not eg likes or follows, since Mastodon doesn't currently mark those
900
        # as explicitly public.
901
        elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
902
                  and not as1.is_public(obj.as1, unlisted=False)
903
                  and not as1.is_dm(obj.as1)):
904
              logger.info('Dropping non-public activity')
1✔
905
              return ('OK', 200)
1✔
906

907
        # lease this object, atomically
908
        memcache_key = activity_id_memcache_key(id)
1✔
909
        leased = memcache.memcache.add(memcache_key, 'leased', noreply=False,
1✔
910
                                     expire=5 * 60)  # 5 min
911
        # short circuit if we've already seen this activity id.
912
        # (don't do this for bare objects since we need to check further down
913
        # whether they've been updated since we saw them last.)
914
        if (obj.as1.get('objectType') == 'activity'
1✔
915
            and 'force' not in request.values
916
            and (not leased
917
                 or (obj.new is False and obj.changed is False))):
918
            error(f'Already seen this activity {id}', status=204)
1✔
919

920
        pruned = {k: v for k, v in obj.as1.items()
1✔
921
                  if k not in ('contentMap', 'replies', 'signature')}
922
        delay = ''
1✔
923
        if (received_at and request.headers.get('X-AppEngine-TaskRetryCount') == '0'
1✔
924
                and obj.type != 'delete'):  # we delay deletes for 2m
UNCOV
925
            delay_s = int((util.now().replace(tzinfo=None)
×
926
                           - received_at.replace(tzinfo=None)
927
                           ).total_seconds())
UNCOV
928
            delay = f'({delay_s} s behind)'
×
929
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
930

931
        # does this protocol support this activity/object type?
932
        from_cls.check_supported(obj)
1✔
933

934
        # check authorization
935
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
936
        actor = as1.get_owner(obj.as1)
1✔
937
        if not actor:
1✔
938
            error('Activity missing actor or author')
1✔
939
        elif from_cls.owns_id(actor) is False:
1✔
940
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
941

942
        assert authed_as
1✔
943
        assert isinstance(authed_as, str)
1✔
944
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
945
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
946
        if actor != authed_as:
1✔
947
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
948
                         user=f'{id} authed_as {authed_as} owner {actor}')
949
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
950

951
        # update copy ids to originals
952
        obj.normalize_ids()
1✔
953
        obj.resolve_ids()
1✔
954

955
        if (obj.type == 'follow'
1✔
956
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
957
            # follows of bot user; refresh user profile first
958
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
959
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
960
            from_user.reload_profile()
1✔
961
        else:
962
            # load actor user
963
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
964

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

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

972
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
973
        inner_obj_id = inner_obj_as1.get('id')
1✔
974
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
975
            if not inner_obj_id:
1✔
976
                error(f'{obj.type} object has no id!')
1✔
977

978
        # check age. we support backdated posts, but if they're over 2w old, we
979
        # don't deliver them
980
        if obj.type == 'post':
1✔
981
            if published := inner_obj_as1.get('published'):
1✔
982
                try:
×
983
                    published_dt = util.parse_iso8601(published)
×
UNCOV
984
                    if not published_dt.tzinfo:
×
985
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
986
                    age = util.now() - published_dt
×
UNCOV
987
                    if age > CREATE_MAX_AGE:
×
UNCOV
988
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
989
                              status=204)
UNCOV
990
                except ValueError:  # from parse_iso8601
×
UNCOV
991
                    logger.debug(f"Couldn't parse published {published}")
×
992

993
        # write Object to datastore
994
        obj.source_protocol = from_cls.LABEL
1✔
995
        if obj.type in STORE_AS1_TYPES:
1✔
996
            obj.put()
1✔
997

998
        # store inner object
999
        # TODO: unify with big obj.type conditional below. would have to merge
1000
        # this with the DM handling block lower down.
1001
        crud_obj = None
1✔
1002
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1003
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
1004
                                            source_protocol=from_cls.LABEL,
1005
                                            authed_as=actor, users=[from_user.key])
1006

1007
        actor = as1.get_object(obj.as1, 'actor')
1✔
1008
        actor_id = actor.get('id')
1✔
1009

1010
        # handle activity!
1011
        if obj.type == 'stop-following':
1✔
1012
            # TODO: unify with handle_follow?
1013
            # TODO: handle multiple followees
1014
            if not actor_id or not inner_obj_id:
1✔
UNCOV
1015
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1016

1017
            # deactivate Follower
1018
            from_ = from_cls.key_for(actor_id)
1✔
1019
            to_cls = Protocol.for_id(inner_obj_id)
1✔
1020
            to = to_cls.key_for(inner_obj_id)
1✔
1021
            follower = Follower.query(Follower.to == to,
1✔
1022
                                      Follower.from_ == from_,
1023
                                      Follower.status == 'active').get()
1024
            if follower:
1✔
1025
                logger.info(f'Marking {follower} inactive')
1✔
1026
                follower.status = 'inactive'
1✔
1027
                follower.put()
1✔
1028
            else:
1029
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1030

1031
            # fall through to deliver to followee
1032
            # TODO: do we convert stop-following to webmention 410 of original
1033
            # follow?
1034

1035
            # fall through to deliver to followers
1036

1037
        elif obj.type in ('delete', 'undo'):
1✔
1038
            delete_obj_id = (from_user.profile_id()
1✔
1039
                            if inner_obj_id == from_user.key.id()
1040
                            else inner_obj_id)
1041

1042
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1043
            if not delete_obj:
1✔
1044
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1045
                return 'OK', 204
1✔
1046

1047
            # TODO: just delete altogether!
1048
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1049
            delete_obj.deleted = True
1✔
1050
            delete_obj.put()
1✔
1051

1052
            # if this is an actor, handle deleting it later so that
1053
            # in case it's from_user, user.enabled_protocols is still populated
1054
            #
1055
            # fall through to deliver to followers and delete copy if necessary.
1056
            # should happen via protocol-specific copy target and send of
1057
            # delete activity.
1058
            # https://github.com/snarfed/bridgy-fed/issues/63
1059

1060
        elif obj.type == 'block':
1✔
1061
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1062
                # blocking protocol bot user disables that protocol
1063
                from_user.delete(proto)
1✔
1064
                from_user.disable_protocol(proto)
1✔
1065
                return 'OK', 200
1✔
1066

1067
        elif obj.type == 'post':
1✔
1068
            # handle DMs to bot users
1069
            if as1.is_dm(obj.as1):
1✔
1070
                return dms.receive(from_user=from_user, obj=obj)
1✔
1071

1072
        # fetch actor if necessary
1073
        if (actor and actor.keys() == set(['id'])
1✔
1074
                and obj.type not in ('delete', 'undo')):
1075
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1076
            actor_obj = from_cls.load(ids.profile_id(id=actor['id'], proto=from_cls),
1✔
1077
                                      raise_=False)
1078
            if actor_obj and actor_obj.as1:
1✔
1079
                obj.our_as1 = {
1✔
1080
                    **obj.as1, 'actor': {
1081
                        **actor_obj.as1,
1082
                        # override profile id with actor id
1083
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1084
                        'id': actor['id'],
1085
                    }
1086
                }
1087

1088
        # fetch object if necessary
1089
        if (obj.type in ('post', 'update', 'share')
1✔
1090
                and inner_obj_as1.keys() == set(['id'])
1091
                and from_cls.owns_id(inner_obj_id)):
1092
            logger.debug('Fetching inner object')
1✔
1093
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1094
                                      remote=(obj.type in ('post', 'update')))
1095
            if obj.type in ('post', 'update'):
1✔
1096
                crud_obj = inner_obj
1✔
1097
            if inner_obj and inner_obj.as1:
1✔
1098
                obj.our_as1 = {
1✔
1099
                    **obj.as1,
1100
                    'object': {
1101
                        **inner_obj_as1,
1102
                        **inner_obj.as1,
1103
                    }
1104
                }
1105
            elif obj.type in ('post', 'update'):
1✔
1106
                error("Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1107

1108
        if obj.type == 'follow':
1✔
1109
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1110
                # follow of one of our protocol bot users; enable that protocol.
1111
                # fall through so that we send an accept.
1112
                try:
1✔
1113
                    from_user.enable_protocol(proto)
1✔
1114
                except ErrorButDoNotRetryTask:
1✔
1115
                    from web import Web
1✔
1116
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1117
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1118
                                               followee=bot, follow=obj)
1119
                    raise
1✔
1120
                proto.bot_follow(from_user)
1✔
1121

1122
            from_cls.handle_follow(obj)
1✔
1123

1124
        # deliver to targets
1125
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1126

1127
        # if this is a user, deactivate its followers/followings
1128
        # https://github.com/snarfed/bridgy-fed/issues/1304
1129
        if obj.type == 'delete':
1✔
1130
            if user_key := from_cls.key_for(id=inner_obj_id):
1✔
1131
                if user := user_key.get():
1✔
1132
                    for proto in user.enabled_protocols:
1✔
1133
                        user.disable_protocol(PROTOCOLS[proto])
1✔
1134

1135
                    logger.info(f'Deactivating Followers from or to {user_key.id()}')
1✔
1136
                    followers = Follower.query(
1✔
1137
                        OR(Follower.to == user_key, Follower.from_ == user_key)
1138
                        ).fetch()
1139
                    for f in followers:
1✔
1140
                        f.status = 'inactive'
1✔
1141
                    ndb.put_multi(followers)
1✔
1142

1143
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1144
        return resp
1✔
1145

1146
    @classmethod
1✔
1147
    def handle_follow(from_cls, obj):
1✔
1148
        """Handles an incoming follow activity.
1149

1150
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1151
        happens in :meth:`deliver`.
1152

1153
        Args:
1154
          obj (models.Object): follow activity
1155
        """
1156
        logger.debug('Got follow. Loading users, storing Follow(s), sending accept(s)')
1✔
1157

1158
        # Prepare follower (from) users' data
1159
        # TODO: remove all of this and just use from_user
1160
        from_as1 = as1.get_object(obj.as1, 'actor')
1✔
1161
        from_id = from_as1.get('id')
1✔
1162
        if not from_id:
1✔
UNCOV
1163
            error(f'Follow activity requires actor. Got: {obj.as1}')
×
1164

1165
        from_obj = from_cls.load(from_id, raise_=False)
1✔
1166
        if not from_obj:
1✔
UNCOV
1167
            error(f"Couldn't load {from_id}", status=502)
×
1168

1169
        if not from_obj.as1:
1✔
1170
            from_obj.our_as1 = from_as1
1✔
1171
            from_obj.put()
1✔
1172

1173
        from_key = from_cls.key_for(from_id)
1✔
1174
        if not from_key:
1✔
UNCOV
1175
            error(f'Invalid {from_cls.LABEL} user key: {from_id}')
×
1176
        obj.users = [from_key]
1✔
1177
        from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
1✔
1178

1179
        # Prepare followee (to) users' data
1180
        to_as1s = as1.get_objects(obj.as1)
1✔
1181
        if not to_as1s:
1✔
UNCOV
1182
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1183

1184
        # Store Followers
1185
        for to_as1 in to_as1s:
1✔
1186
            to_id = to_as1.get('id')
1✔
1187
            if not to_id:
1✔
UNCOV
1188
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1189

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

1192
            to_cls = Protocol.for_id(to_id)
1✔
1193
            if not to_cls:
1✔
UNCOV
1194
                error(f"Couldn't determine protocol for {to_id}")
×
1195
            elif from_cls == to_cls:
1✔
1196
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1197
                continue
1✔
1198

1199
            to_obj = to_cls.load(to_id)
1✔
1200
            if to_obj and not to_obj.as1:
1✔
1201
                to_obj.our_as1 = to_as1
1✔
1202
                to_obj.put()
1✔
1203

1204
            to_key = to_cls.key_for(to_id)
1✔
1205
            if not to_key:
1✔
UNCOV
1206
                logger.info(f'Skipping invalid {from_cls.LABEL} user key: {from_id}')
×
UNCOV
1207
                continue
×
1208

1209
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj,
1✔
1210
                                           allow_opt_out=True)
1211
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1212
                                                  follow=obj.key, status='active')
1213
            obj.add('notify', to_key)
1✔
1214
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1215
                                       followee=to_user, follow=obj)
1216

1217
    @classmethod
1✔
1218
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1219
        """Sends an accept or reject activity for a follow.
1220

1221
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1222
        nothing.
1223

1224
        Args:
1225
          verb (str): ``accept`` or  ``reject``
1226
          follower (models.User)
1227
          followee (models.User)
1228
          follow (models.Object)
1229
        """
1230
        assert verb in ('accept', 'reject')
1✔
1231
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1232
            return
1✔
1233

1234
        target = follower.target_for(follower.obj)
1✔
1235
        if not target:
1✔
UNCOV
1236
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1237

1238
        # send. note that this is one response for the whole follow, even if it
1239
        # has multiple followees!
1240
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1241
        accept = {
1✔
1242
            'id': id,
1243
            'objectType': 'activity',
1244
            'verb': verb,
1245
            'actor': followee.key.id(),
1246
            'object': follow.as1,
1247
        }
1248
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1249
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1250

1251
    @classmethod
1✔
1252
    def bot_follow(bot_cls, user):
1✔
1253
        """Follow a user from a protocol bot user.
1254

1255
        ...so that the protocol starts sending us their activities, if it needs
1256
        a follow for that (eg ActivityPub).
1257

1258
        Args:
1259
          user (User)
1260
        """
1261
        from web import Web
1✔
1262
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1263
        now = util.now().isoformat()
1✔
1264
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1265

1266
        if not user.obj:
1✔
1267
            logger.info("  can't follow, user has no profile obj")
1✔
1268
            return
1✔
1269

1270
        target = user.target_for(user.obj)
1✔
1271
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1272
        follow_back_as1 = {
1✔
1273
            'objectType': 'activity',
1274
            'verb': 'follow',
1275
            'id': follow_back_id,
1276
            'actor': bot.key.id(),
1277
            'object': user.key.id(),
1278
        }
1279
        common.create_task(queue='send', id=follow_back_id,
1✔
1280
                           our_as1=follow_back_as1, url=target,
1281
                           source_protocol='web', protocol=user.LABEL,
1282
                           user=bot.key.urlsafe())
1283

1284
    @classmethod
1✔
1285
    def handle_bare_object(cls, obj, authed_as=None):
1✔
1286
        """If obj is a bare object, wraps it in a create or update activity.
1287

1288
        Checks if we've seen it before.
1289

1290
        Args:
1291
          obj (models.Object)
1292
          authed_as (str): authenticated actor id who sent this activity
1293

1294
        Returns:
1295
          models.Object: ``obj`` if it's an activity, otherwise a new object
1296
        """
1297
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1298
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1299
            return obj
1✔
1300

1301
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1302
        now = util.now().isoformat()
1✔
1303

1304
        # occasionally we override the object, eg if this is a profile object
1305
        # coming in via a user with use_instead set
1306
        obj_as1 = obj.as1
1✔
1307
        if obj_id := obj.key.id():
1✔
1308
            if obj_as1_id := obj_as1.get('id'):
1✔
1309
                if obj_id != obj_as1_id:
1✔
1310
                    logger.info(f'Overriding AS1 object id {obj_as1_id} with Object id {obj_id}')
1✔
1311
                    obj_as1['id'] = obj_id
1✔
1312

1313
        # this is a raw post; wrap it in a create or update activity
1314
        if obj.changed or is_actor:
1✔
1315
            if obj.changed:
1✔
1316
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1317
            else:
1318
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1319
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1320
            update_as1 = {
1✔
1321
                'objectType': 'activity',
1322
                'verb': 'update',
1323
                'id': id,
1324
                'actor': obj_actor,
1325
                'object': {
1326
                    # Mastodon requires the updated field for Updates, so
1327
                    # add a default value.
1328
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1329
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1330
                    # https://github.com/mastodon/documentation/pull/1150
1331
                    'updated': now,
1332
                    **obj_as1,
1333
                },
1334
            }
1335
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1336
            return Object(id=id, our_as1=update_as1,
1✔
1337
                          source_protocol=obj.source_protocol)
1338

1339
        if (obj.new
1✔
1340
                # HACK: force query param here is specific to webmention
1341
                or 'force' in request.form):
1342
            create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1343
            create_as1 = {
1✔
1344
                'objectType': 'activity',
1345
                'verb': 'post',
1346
                'id': create_id,
1347
                'actor': obj_actor,
1348
                'object': obj_as1,
1349
                'published': now,
1350
            }
1351
            logger.info(f'Wrapping in post')
1✔
1352
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1353
            return Object(id=create_id, our_as1=create_as1,
1✔
1354
                          source_protocol=obj.source_protocol)
1355

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

1358
    @classmethod
1✔
1359
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1360
        """Delivers an activity to its external recipients.
1361

1362
        Args:
1363
          obj (models.Object): activity to deliver
1364
          from_user (models.User): user (actor) this activity is from
1365
          crud_obj (models.Object): if this is a create, update, or delete/undo
1366
            activity, the inner object that's being written, otherwise None.
1367
            (This object's ``notify`` and ``feed`` properties may be updated.)
1368
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1369
            targets on this protocol
1370

1371
        Returns:
1372
          (str, int) tuple: Flask response
1373
        """
1374
        if to_proto:
1✔
1375
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1376

1377
        # find delivery targets. maps Target to Object or None
1378
        #
1379
        # ...then write the relevant object, since targets() has a side effect of
1380
        # setting the notify and feed properties (and dirty attribute)
1381
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1382
        if not targets:
1✔
1383
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1384

1385
        # store object that targets() updated
1386
        if crud_obj and crud_obj.dirty:
1✔
1387
            crud_obj.put()
1✔
1388
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1389
            obj.put()
1✔
1390

1391
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1392
                      else obj.to_request())
1393

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

1397
        # enqueue send task for each targets
1398
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1399
        user = from_user.key.urlsafe()
1✔
1400
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1401
            if to_proto and target.protocol != to_proto.LABEL:
1✔
UNCOV
1402
                continue
×
1403
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1404
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1405
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1406

1407
        return 'OK', 202
1✔
1408

1409
    @classmethod
1✔
1410
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1411
        """Collects the targets to send a :class:`models.Object` to.
1412

1413
        Targets are both objects - original posts, events, etc - and actors.
1414

1415
        Args:
1416
          obj (models.Object)
1417
          from_user (User)
1418
          crud_obj (models.Object): if this is a create, update, or delete/undo
1419
            activity, the inner object that's being written, otherwise None.
1420
            (This object's ``notify`` and ``feed`` properties may be updated.)
1421
          internal (bool): whether this is a recursive internal call
1422

1423
        Returns:
1424
          dict: maps :class:`models.Target` to original (in response to)
1425
          :class:`models.Object`, if any, otherwise None
1426
        """
1427
        logger.debug('Finding recipients and their targets')
1✔
1428

1429
        # we should only have crud_obj iff this is a create or update
1430
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1431
        write_obj = crud_obj or obj
1✔
1432
        write_obj.dirty = False
1✔
1433

1434
        target_uris = sorted(set(as1.targets(obj.as1)))
1✔
1435
        logger.info(f'Raw targets: {target_uris}')
1✔
1436
        orig_obj = None
1✔
1437
        targets = {}  # maps Target to Object or None
1✔
1438
        owner = as1.get_owner(obj.as1)
1✔
1439
        allow_opt_out = (obj.type == 'delete')
1✔
1440
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1441
        inner_obj_id = inner_obj_as1.get('id')
1✔
1442
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1443
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1444
        is_self_reply = False
1✔
1445

1446
        original_ids = []
1✔
1447
        if is_reply:
1✔
1448
            original_ids = in_reply_tos
1✔
1449
        elif inner_obj_id:
1✔
1450
            if inner_obj_id == from_user.key.id():
1✔
1451
                inner_obj_id = from_user.profile_id()
1✔
1452
            original_ids = [inner_obj_id]
1✔
1453

1454
        # which protocols should we allow delivering to?
1455
        to_protocols = []
1✔
1456
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1457
                      + from_user.enabled_protocols):
1458
            proto = PROTOCOLS[label]
1✔
1459
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1460
                                     or is_reply):
1461
                for id in original_ids:
1✔
1462
                    if Protocol.for_id(id) == proto:
1✔
1463
                        logger.info(f'Allowing {label} for original post {id}')
1✔
1464
                        break
1✔
1465
                    elif orig := from_user.load(id, remote=False):
1✔
1466
                        if orig.get_copy(proto):
1✔
1467
                            logger.info(f'Allowing {label}, original post {id} was bridged there')
1✔
1468
                            break
1✔
1469
                else:
1470
                    logger.info(f"Skipping {label}, original objects {original_ids} weren't bridged there")
1✔
1471
                    continue
1✔
1472

1473
            util.add(to_protocols, proto)
1✔
1474

1475
        # process direct targets
1476
        for id in sorted(target_uris):
1✔
1477
            target_proto = Protocol.for_id(id)
1✔
1478
            if not target_proto:
1✔
1479
                logger.info(f"Can't determine protocol for {id}")
1✔
1480
                continue
1✔
1481
            elif target_proto.is_blocklisted(id):
1✔
1482
                logger.debug(f'{id} is blocklisted')
1✔
1483
                continue
1✔
1484

1485
            orig_obj = target_proto.load(id, raise_=False)
1✔
1486
            if not orig_obj or not orig_obj.as1:
1✔
1487
                logger.info(f"Couldn't load {id}")
1✔
1488
                continue
1✔
1489

1490
            target_author_key = target_proto.actor_key(orig_obj)
1✔
1491
            if not from_user.is_enabled(target_proto):
1✔
1492
                # if author isn't bridged and inReplyTo author is, DM a prompt
1493
                if id in in_reply_tos and target_author_key:
1✔
1494
                    if target_author := target_author_key.get():
1✔
1495
                        if target_author.is_enabled(from_cls):
1✔
1496
                            dms.maybe_send(
1✔
1497
                                from_proto=target_proto, to_user=from_user,
1498
                                type='replied_to_bridged_user', text=f"""\
1499
Hi! You <a href="{inner_obj_as1.get('url') or inner_obj_id}">recently replied</a> to {orig_obj.actor_link(image=False)}, who's bridged here from {target_proto.PHRASE}. If you want them to see your replies, 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.""")
1500

1501
                continue
1✔
1502

1503
            # deliver self-replies to followers
1504
            # https://github.com/snarfed/bridgy-fed/issues/639
1505
            if id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1506
                is_self_reply = True
1✔
1507
                logger.info(f'self reply!')
1✔
1508

1509
            # also add copies' targets
1510
            for copy in orig_obj.copies:
1✔
1511
                proto = PROTOCOLS[copy.protocol]
1✔
1512
                if proto in to_protocols:
1✔
1513
                    # copies generally won't have their own Objects
1514
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1515
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {id}')
1✔
1516
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1517

1518
            if target_proto == from_cls:
1✔
1519
                logger.debug(f'Skipping same-protocol target {id}')
1✔
1520
                continue
1✔
1521

1522
            target = target_proto.target_for(orig_obj)
1✔
1523
            if not target:
1✔
1524
                # TODO: surface errors like this somehow?
UNCOV
1525
                logger.error(f"Can't find delivery target for {id}")
×
UNCOV
1526
                continue
×
1527

1528
            logger.debug(f'Target for {id} is {target}')
1✔
1529
            # only use orig_obj for inReplyTos, like/repost objects, etc
1530
            # https://github.com/snarfed/bridgy-fed/issues/1237
1531
            targets[Target(protocol=target_proto.LABEL, uri=target)] = (
1✔
1532
                orig_obj if id in in_reply_tos or id in as1.get_ids(obj.as1, 'object')
1533
                else None)
1534

1535
            if target_author_key:
1✔
1536
                logger.debug(f'Recipient is {target_author_key}')
1✔
1537
                if write_obj.add('notify', target_author_key):
1✔
1538
                    write_obj.dirty = True
1✔
1539

1540
        if obj.type == 'undo':
1✔
1541
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1542
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1543
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1544
            else:
1545
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1546
            if inner_obj:
1✔
1547
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1548
                                                internal=True))
1549

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

1552
        # deliver to followers, if appropriate
1553
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1554
        if not user_key:
1✔
1555
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1556
            return targets
1✔
1557

1558
        followers = []
1✔
1559
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1560
                and (not is_reply or is_self_reply)):
1561
            logger.info(f'Delivering to followers of {user_key}')
1✔
1562
            followers = [
1✔
1563
                f for f in Follower.query(Follower.to == user_key,
1564
                                          Follower.status == 'active')
1565
                # skip protocol bot users
1566
                if not Protocol.for_bridgy_subdomain(f.from_.id())
1567
                # skip protocols this user hasn't enabled, or where the base
1568
                # object of this activity hasn't been bridged
1569
                and PROTOCOLS_BY_KIND[f.from_.kind()] in to_protocols]
1570
            user_keys = [f.from_ for f in followers]
1✔
1571
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1572
            User.load_multi(users)
1✔
1573

1574
            if (not followers and
1✔
1575
                (util.domain_or_parent_in(
1576
                    util.domain_from_link(from_user.key.id()), LIMITED_DOMAINS)
1577
                 or util.domain_or_parent_in(
1578
                     util.domain_from_link(obj.key.id()), LIMITED_DOMAINS))):
1579
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1580
                return {}
1✔
1581

1582
            # add to followers' feeds, if any
1583
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1584
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1585
                    write_obj.feed = [u.key for u in users]
1✔
1586
                    if write_obj.feed:
1✔
1587
                        write_obj.dirty = True
1✔
1588

1589
            # collect targets for followers
1590
            for user in users:
1✔
1591
                # TODO: should we pass remote=False through here to Protocol.load?
1592
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1593
                if not target:
1✔
1594
                    # TODO: surface errors like this somehow?
1595
                    logger.error(f'Follower {user.key} has no delivery target')
1✔
1596
                    continue
1✔
1597

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

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

1606
        # deliver to enabled HAS_COPIES protocols proactively
1607
        # TODO: abstract for other protocols
1608
        from atproto import ATProto
1✔
1609
        if (ATProto in to_protocols
1✔
1610
                and obj.type in ('post', 'update', 'delete', 'share')):
1611
            logger.info(f'user has ATProto enabled, adding {ATProto.PDS_URL}')
1✔
1612
            targets.setdefault(
1✔
1613
                Target(protocol=ATProto.LABEL, uri=ATProto.PDS_URL), None)
1614

1615
        # de-dupe targets, discard same-domain
1616
        # maps string target URL to (Target, Object) tuple
1617
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1618
        # maps Target to Object or None
1619
        targets = {}
1✔
1620
        source_domains = [
1✔
1621
            util.domain_from_link(url) for url in
1622
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1623
            if util.is_web(url)
1624
        ]
1625
        for url in sorted(util.dedupe_urls(
1✔
1626
                candidates.keys(),
1627
                # preserve our PDS URL without trailing slash in path
1628
                # https://atproto.com/specs/did#did-documents
1629
                trailing_slash=False)):
1630
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
UNCOV
1631
                logger.info(f'Skipping same-domain target {url}')
×
UNCOV
1632
                continue
×
1633
            target, obj = candidates[url]
1✔
1634
            targets[target] = obj
1✔
1635

1636
        return targets
1✔
1637

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

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

1645
        Args:
1646
          id (str)
1647
          remote (bool): whether to fetch the object over the network. If True,
1648
            fetches even if we already have the object stored, and updates our
1649
            stored copy. If False and we don't have the object stored, returns
1650
            None. Default (None) means to fetch over the network only if we
1651
            don't already have it stored.
1652
          local (bool): whether to load from the datastore before
1653
            fetching over the network. If False, still stores back to the
1654
            datastore after a successful remote fetch.
1655
          raise_ (bool): if False, catches any :class:`request.RequestException`
1656
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1657
            ``None`` instead
1658
          kwargs: passed through to :meth:`fetch()`
1659

1660
        Returns:
1661
          models.Object: loaded object, or None if it isn't fetchable, eg a
1662
          non-URL string for Web, or ``remote`` is False and it isn't in the
1663
          datastore
1664

1665
        Raises:
1666
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1667
            is True
1668
        """
1669
        assert id
1✔
1670
        assert local or remote is not False
1✔
1671
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1672

1673
        obj = orig_as1 = None
1✔
1674
        if local:
1✔
1675
            obj = Object.get_by_id(id)
1✔
1676
            if not obj:
1✔
1677
                # logger.debug(f' {id} not in datastore')
1678
                pass
1✔
1679
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1680
                # logger.debug(f'  {id} got from datastore')
1681
                obj.new = False
1✔
1682

1683
        if remote is False:
1✔
1684
            return obj
1✔
1685
        elif remote is None and obj:
1✔
1686
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1687
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1688
                pass
1✔
1689
            else:
1690
                return obj
1✔
1691

1692
        if obj:
1✔
1693
            orig_as1 = obj.as1
1✔
1694
            obj.our_as1 = None
1✔
1695
            obj.new = False
1✔
1696
        else:
1697
            obj = Object(id=id)
1✔
1698
            if local:
1✔
1699
                # logger.debug(f'  {id} not in datastore')
1700
                obj.new = True
1✔
1701
                obj.changed = False
1✔
1702

1703
        try:
1✔
1704
            fetched = cls.fetch(obj, **kwargs)
1✔
1705
        except (RequestException, HTTPException) as e:
1✔
1706
            if raise_:
1✔
1707
                raise
1✔
1708
            util.interpret_http_exception(e)
1✔
1709
            return None
1✔
1710

1711
        if not fetched:
1✔
1712
            return None
1✔
1713

1714
        # https://stackoverflow.com/a/3042250/186123
1715
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1716
        if size > models.MAX_ENTITY_SIZE:
1✔
1717
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1718
            return None
1✔
1719

1720
        obj.resolve_ids()
1✔
1721
        obj.normalize_ids()
1✔
1722

1723
        if obj.new is False:
1✔
1724
            obj.changed = obj.activity_changed(orig_as1)
1✔
1725

1726
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1727
            if obj.source_protocol:
1✔
UNCOV
1728
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1729
            obj.source_protocol = cls.LABEL
1✔
1730

1731
        obj.put()
1✔
1732
        return obj
1✔
1733

1734
    @classmethod
1✔
1735
    def check_supported(cls, obj):
1✔
1736
        """If this protocol doesn't support this object, raises HTTP 204.
1737

1738
        Also reports an error.
1739

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

1744
        Args:
1745
          obj (Object)
1746

1747
        Raises:
1748
          werkzeug.HTTPException: if this protocol doesn't support this object
1749
        """
1750
        if not obj.type:
1✔
UNCOV
1751
            return
×
1752

1753
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1754
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1755
            or (obj.type in as1.CRUD_VERBS
1756
                and inner_type
1757
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1758
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1759

1760
        # don't allow posts with blank content and no image/video/audio
1761
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
1762
                    else obj.as1)
1763
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
1764
                and not util.get_url(crud_obj, key='image')
1765
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
1766
                # TODO: handle articles with displayName but not content
1767
                and not source.html_to_text(crud_obj.get('content')).strip()):
1768
            error('Blank content and no image or video or audio', status=204)
1✔
1769

1770
        # DMs are only allowed to/from protocol bot accounts
1771
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1772
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1773
            if (not cls.SUPPORTS_DMS
1✔
1774
                    or (recip not in protocol_user_ids
1775
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1776
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1777

1778

1779
@cloud_tasks_only(log=None)
1✔
1780
def receive_task():
1✔
1781
    """Task handler for a newly received :class:`models.Object`.
1782

1783
    Calls :meth:`Protocol.receive` with the form parameters.
1784

1785
    Parameters:
1786
      authed_as (str): passed to :meth:`Protocol.receive`
1787
      obj_id (str): key id of :class:`models.Object` to handle
1788
      received_at (str, ISO 8601 timestamp): when we first saw (received)
1789
        this activity
1790
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1791
        :class:`models.Object` to handle
1792

1793
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
1794
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
1795
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
1796
    ``changed``, HTTP request details, etc. See stash for attempt at this for
1797
    :class:`web.Web`.
1798
    """
1799
    common.log_request()
1✔
1800
    form = request.form.to_dict()
1✔
1801

1802
    authed_as = form.pop('authed_as', None)
1✔
1803
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1804
                or authed_as in common.PROTOCOL_DOMAINS)
1805

1806
    obj = Object.from_request()
1✔
1807
    assert obj
1✔
1808
    assert obj.source_protocol
1✔
1809
    obj.new = True
1✔
1810

1811
    if received_at := form.pop('received_at', None):
1✔
1812
        received_at = datetime.fromisoformat(received_at)
1✔
1813

1814
    try:
1✔
1815
        return PROTOCOLS[obj.source_protocol].receive(
1✔
1816
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
1817
    except RequestException as e:
1✔
1818
        util.interpret_http_exception(e)
1✔
1819
        error(e, status=304)
1✔
1820
    except ValueError as e:
1✔
UNCOV
1821
        logger.warning(e, exc_info=True)
×
UNCOV
1822
        error(e, status=304)
×
1823

1824

1825
@cloud_tasks_only(log=None)
1✔
1826
def send_task():
1✔
1827
    """Task handler for sending an activity to a single specific destination.
1828

1829
    Calls :meth:`Protocol.send` with the form parameters.
1830

1831
    Parameters:
1832
      protocol (str): :class:`Protocol` to send to
1833
      url (str): destination URL to send to
1834
      obj_id (str): key id of :class:`models.Object` to send
1835
      orig_obj_id (str): optional, :class:`models.Object` key id of the
1836
        "original object" that this object refers to, eg replies to or reposts
1837
        or likes
1838
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1839
        this activity is from
1840
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1841
        :class:`models.Object` to handle
1842
    """
1843
    common.log_request()
1✔
1844

1845
    # prepare
1846
    form = request.form.to_dict()
1✔
1847
    url = form.get('url')
1✔
1848
    protocol = form.get('protocol')
1✔
1849
    if not url or not protocol:
1✔
1850
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1851
        return '', 204
1✔
1852

1853
    target = Target(uri=url, protocol=protocol)
1✔
1854
    obj = Object.from_request()
1✔
1855
    assert obj and obj.key and obj.key.id()
1✔
1856

1857
    PROTOCOLS[protocol].check_supported(obj)
1✔
1858
    allow_opt_out = (obj.type == 'delete')
1✔
1859

1860
    user = None
1✔
1861
    if user_key := form.get('user'):
1✔
1862
        key = ndb.Key(urlsafe=user_key)
1✔
1863
        # use get_by_id so that we follow use_instead
1864
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
1865
            key.id(), allow_opt_out=allow_opt_out)
1866

1867
    # send
1868
    delay = ''
1✔
1869
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
1870
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
1871
        delay = f'({delay_s} s behind)'
1✔
1872
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
1873
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
1874
    sent = None
1✔
1875
    try:
1✔
1876
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
1877
                                        orig_obj_id=form.get('orig_obj_id'))
1878
    except BaseException as e:
1✔
1879
        code, body = util.interpret_http_exception(e)
1✔
1880
        if not code and not body:
1✔
1881
            raise
1✔
1882

1883
    if sent is False:
1✔
1884
        logger.info(f'Failed sending!')
1✔
1885

1886
    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