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

snarfed / bridgy-fed / 5d0e3171-3b3d-49e3-a012-e1200f8e9e26

10 May 2025 04:03PM UTC coverage: 91.598%. Remained the same
5d0e3171-3b3d-49e3-a012-e1200f8e9e26

push

circleci

snarfed
use Protocol.create_for for all protocols, not just HAS_COPIES ones

we can now use create_for for all protocol-specific setup for new users, eg creating AP keypairs. for #1881

24 of 26 new or added lines in 4 files covered. (92.31%)

114 existing lines in 4 files now uncovered.

4775 of 5213 relevant lines covered (91.6%)

0.92 hits per line

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

93.9
/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✔
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:
×
389
                code, _ = util.interpret_http_exception(e)
×
390
                if code:
×
391
                    # we tried and failed fetching the id over the network
392
                    return None
×
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✔
432
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
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 user in this protocol.
501

502
        If this protocol has copies, adds the new copy user to :attr:`copies`.
503
        If the copy user already exists and active, does nothing.
504

505
        By default, does nothing.
506

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

511
        Raises:
512
          ValueError: if we can't create the given user in this protocol
513
        """
514
        if cls.HAS_COPIES:
1✔
NEW
515
            raise NotImplementedError()
×
516

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

521
        To be implemented by subclasses.
522

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

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

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

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

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

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

553
        To be implemented by subclasses.
554

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

637
        Default implementation; subclasses may override.
638

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

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

655
        if from_user.key and id in (from_user.key.id(), from_user.profile_id()):
1✔
656
            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✔
657

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

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

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

671
        Args:
672
          user (models.User)
673
          username (str)
674

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

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

685
        Args:
686
          user (models.User)
687
          to_user_id (str)
688

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

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

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

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

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

715
        To be implemented by subclasses.
716

717
        Examples:
718

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

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

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

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

740
        Default implementation here, subclasses may override.
741

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

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

756
        Infers source protocol for each id value separately.
757

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

762
        Wraps these AS1 fields:
763

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

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

783
        TODO: unify with :meth:`Object.resolve_ids`,
784
        :meth:`models.Object.normalize_ids`.
785

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

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

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

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

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

817
            if len(elem[field]) == 1:
1✔
818
                elem[field] = elem[field][0]
1✔
819

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

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

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

849
        outer_obj = util.trim_nulls(outer_obj)
1✔
850

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

856
        return outer_obj
1✔
857

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

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

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

873
        Returns:
874
          (str, int) tuple: (response body, HTTP status code) Flask response
875

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

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

886
        id = None
1✔
887
        if obj.key and obj.key.id():
1✔
888
            id = obj.key.id()
1✔
889

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1037
            # fall through to deliver to followers
1038

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

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

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

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

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

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

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

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

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

1124
            from_cls.handle_follow(obj)
1✔
1125

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1290
        Checks if we've seen it before.
1291

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1409
        return 'OK', 202
1✔
1410

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

1415
        Targets are both objects - original posts, events, etc - and actors.
1416

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

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

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

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

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

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

1475
            util.add(to_protocols, proto)
1✔
1476

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

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

1492
            target_author_key = target_proto.actor_key(orig_obj)
1✔
1493
            if not from_user.is_enabled(target_proto):
1✔
1494
                # if author isn't bridged and inReplyTo author is, DM a prompt
1495
                if id in in_reply_tos and target_author_key:
1✔
1496
                    if target_author := target_author_key.get():
1✔
1497
                        if target_author.is_enabled(from_cls):
1✔
1498
                            dms.maybe_send(
1✔
1499
                                from_proto=target_proto, to_user=from_user,
1500
                                type='replied_to_bridged_user', text=f"""\
1501
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.""")
1502

1503
                continue
1✔
1504

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1638
        return targets
1✔
1639

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

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

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

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

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

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

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

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

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

1713
        if not fetched:
1✔
1714
            return None
1✔
1715

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

1722
        obj.resolve_ids()
1✔
1723
        obj.normalize_ids()
1✔
1724

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

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

1733
        obj.put()
1✔
1734
        return obj
1✔
1735

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

1740
        Also reports an error.
1741

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

1746
        Args:
1747
          obj (Object)
1748

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

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

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

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

1780

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

1785
    Calls :meth:`Protocol.receive` with the form parameters.
1786

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

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

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

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

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

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

1826

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

1831
    Calls :meth:`Protocol.send` with the form parameters.
1832

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

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

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

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

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

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

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

1888
    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