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

snarfed / bridgy-fed / 7a3a12e9-6f5e-4796-9e68-edb22210e779

24 Feb 2025 04:06PM UTC coverage: 92.861% (+0.4%) from 92.505%
7a3a12e9-6f5e-4796-9e68-edb22210e779

push

circleci

snarfed
pages: add tests for login, logout, settings, enable/disable

for #1680

9 of 10 new or added lines in 1 file covered. (90.0%)

82 existing lines in 3 files now uncovered.

4813 of 5183 relevant lines covered (92.86%)

0.93 hits per line

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

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

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

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

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

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

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

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

76

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

81

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

85

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

115
    def __init__(self):
1✔
UNCOV
116
        assert False
×
117

118
    @classmethod
1✔
119
    @property
1✔
120
    def LABEL(cls):
1✔
121
        """str: human-readable lower case name of this protocol, eg ``'activitypub``"""
122
        return cls.__name__.lower()
1✔
123

124
    @staticmethod
1✔
125
    def for_request(fed=None):
1✔
126
        """Returns the protocol for the current request.
127

128
        ...based on the request's hostname.
129

130
        Args:
131
          fed (str or protocol.Protocol): protocol to return if the current
132
            request is on ``fed.brid.gy``
133

134
        Returns:
135
          Protocol: protocol, or None if the provided domain or request hostname
136
          domain is not a subdomain of ``brid.gy`` or isn't a known protocol
137
        """
138
        return Protocol.for_bridgy_subdomain(request.host, fed=fed)
1✔
139

140
    @staticmethod
1✔
141
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
142
        """Returns the protocol for a brid.gy subdomain.
143

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

149
        Returns:
150
          class: :class:`Protocol` subclass, or None if the provided domain or request
151
          hostname domain is not a subdomain of ``brid.gy`` or isn't a known
152
          protocol
153
        """
154
        domain = (util.domain_from_link(domain_or_url, minimize=False)
1✔
155
                  if util.is_web(domain_or_url)
156
                  else domain_or_url)
157

158
        if domain == common.PRIMARY_DOMAIN or domain in common.LOCAL_DOMAINS:
1✔
159
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
1✔
160
        elif domain and domain.endswith(common.SUPERDOMAIN):
1✔
161
            label = domain.removesuffix(common.SUPERDOMAIN)
1✔
162
            return PROTOCOLS.get(label)
1✔
163

164
    @classmethod
1✔
165
    def owns_id(cls, id):
1✔
166
        """Returns whether this protocol owns the id, or None if it's unclear.
167

168
        To be implemented by subclasses.
169

170
        IDs are string identities that uniquely identify users, and are intended
171
        primarily to be machine readable and usable. Compare to handles, which
172
        are human-chosen, human-meaningful, and often but not always unique.
173

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

178
        This should be a quick guess without expensive side effects, eg no
179
        external HTTP fetches to fetch the id itself or otherwise perform
180
        discovery.
181

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

184
        Args:
185
          id (str)
186

187
        Returns:
188
          bool or None:
189
        """
190
        return False
1✔
191

192
    @classmethod
1✔
193
    def owns_handle(cls, handle, allow_internal=False):
1✔
194
        """Returns whether this protocol owns the handle, or None if it's unclear.
195

196
        To be implemented by subclasses.
197

198
        Handles are string identities that are human-chosen, human-meaningful,
199
        and often but not always unique. Compare to IDs, which uniquely identify
200
        users, and are intended primarily to be machine readable and usable.
201

202
        Some protocols' handles are more or less deterministic based on the id
203
        format, eg ActivityPub (technically WebFinger) handles are
204
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
205
        ActivityPub, AT Protocol, or others.
206

207
        This should be a quick guess without expensive side effects, eg no
208
        external HTTP fetches to fetch the id itself or otherwise perform
209
        discovery.
210

211
        Args:
212
          handle (str)
213
          allow_internal (bool): whether to return False for internal domains
214
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
215

216
        Returns:
217
          bool or None
218
        """
219
        return False
1✔
220

221
    @classmethod
1✔
222
    def handle_to_id(cls, handle):
1✔
223
        """Converts a handle to an id.
224

225
        To be implemented by subclasses.
226

227
        May incur network requests, eg DNS queries or HTTP requests. Avoids
228
        blocked or opted out users.
229

230
        Args:
231
          handle (str)
232

233
        Returns:
234
          str: corresponding id, or None if the handle can't be found
235
        """
UNCOV
236
        raise NotImplementedError()
×
237

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

242
        To be implemented by subclasses. Canonicalizes the id if necessary.
243

244
        If called via `Protocol.key_for`, infers the appropriate protocol with
245
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
246
        as is.
247

248
        Args:
249
          id (str):
250
          allow_opt_out (bool): whether to allow users who are currently opted out
251

252
        Returns:
253
          google.cloud.ndb.Key: matching key, or None if the given id is not a
254
          valid :class:`User` id for this protocol.
255
        """
256
        if cls == Protocol:
1✔
257
            proto = Protocol.for_id(id)
1✔
258
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
259

260
        # load user so that we follow use_instead
261
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
262
        if existing:
1✔
263
            if existing.status and not allow_opt_out:
1✔
264
                return None
1✔
265
            return existing.key
1✔
266

267
        return cls(id=id).key
1✔
268

269
    @staticmethod
1✔
270
    def _for_id_memcache_key(id, remote=None):
1✔
271
        """If id is a URL, uses its domain, otherwise returns None.
272

273
        Args:
274
          id (str)
275

276
        Returns:
277
          (str domain, bool remote) or None
278
        """
279
        if remote and util.is_web(id):
1✔
280
            return util.domain_from_link(id)
1✔
281

282
    @cached(LRUCache(20000), lock=Lock())
1✔
283
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote: remote,
1✔
284
                      version=3)
285
    @staticmethod
1✔
286
    def for_id(id, remote=True):
1✔
287
        """Returns the protocol for a given id.
288

289
        Args:
290
          id (str)
291
          remote (bool): whether to perform expensive side effects like fetching
292
            the id itself over the network, or other discovery.
293

294
        Returns:
295
          Protocol subclass: matching protocol, or None if no single known
296
          protocol definitively owns this id
297
        """
298
        logger.debug(f'Determining protocol for id {id}')
1✔
299
        if not id:
1✔
300
            return None
1✔
301

302
        # remove our synthetic id fragment, if any
303
        #
304
        # will this eventually cause false positives for other services that
305
        # include our full ids inside their own ids, non-URL-encoded? guess
306
        # we'll figure that out if/when it happens.
307
        id = id.partition('#bridgy-fed-')[0]
1✔
308
        if not id:
1✔
309
            return None
1✔
310

311
        if util.is_web(id):
1✔
312
            # step 1: check for our per-protocol subdomains
313
            try:
1✔
314
                is_homepage = urlparse(id).path.strip('/') == ''
1✔
315
            except ValueError as e:
1✔
316
                logger.info(f'urlparse ValueError: {e}')
1✔
317
                return None
1✔
318

319
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
320
            if by_subdomain and not is_homepage and id not in BOT_ACTOR_AP_IDS:
1✔
321
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
322
                return by_subdomain
1✔
323

324
        # step 2: check if any Protocols say conclusively that they own it
325
        # sort to be deterministic
326
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
327
                           key=lambda p: p.LABEL)
328
        candidates = []
1✔
329
        for protocol in protocols:
1✔
330
            owns = protocol.owns_id(id)
1✔
331
            if owns:
1✔
332
                logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
333
                return protocol
1✔
334
            elif owns is not False:
1✔
335
                candidates.append(protocol)
1✔
336

337
        if len(candidates) == 1:
1✔
338
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
339
            return candidates[0]
1✔
340

341
        # step 3: look for existing Objects in the datastore
342
        obj = Protocol.load(id, remote=False)
1✔
343
        if obj and obj.source_protocol:
1✔
344
            logger.debug(f'  {obj.key.id()} owned by source_protocol {obj.source_protocol}')
1✔
345
            return PROTOCOLS[obj.source_protocol]
1✔
346

347
        # step 4: fetch over the network, if necessary
348
        if not remote:
1✔
349
            return None
1✔
350

351
        for protocol in candidates:
1✔
352
            logger.debug(f'Trying {protocol.LABEL}')
1✔
353
            try:
1✔
354
                obj = protocol.load(id, local=False, remote=True)
1✔
355

356
                if protocol.ABBREV == 'web':
1✔
357
                    # for web, if we fetch and get HTML without microformats,
358
                    # load returns False but the object will be stored in the
359
                    # datastore with source_protocol web, and in cache. load it
360
                    # again manually to check for that.
361
                    obj = Object.get_by_id(id)
1✔
362
                    if obj and obj.source_protocol != 'web':
1✔
UNCOV
363
                        obj = None
×
364

365
                if obj:
1✔
366
                    logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
367
                    return protocol
1✔
368
            except BadGateway:
1✔
369
                # we tried and failed fetching the id over the network.
370
                # this depends on ActivityPub.fetch raising this!
371
                return None
1✔
UNCOV
372
            except HTTPException as e:
×
373
                # internal error we generated ourselves; try next protocol
UNCOV
374
                pass
×
UNCOV
375
            except Exception as e:
×
UNCOV
376
                code, _ = util.interpret_http_exception(e)
×
UNCOV
377
                if code:
×
378
                    # we tried and failed fetching the id over the network
UNCOV
379
                    return None
×
380
                raise
×
381

382
        logger.info(f'No matching protocol found for {id} !')
1✔
383
        return None
1✔
384

385
    @cached(LRUCache(20000), lock=Lock())
1✔
386
    @staticmethod
1✔
387
    def for_handle(handle):
1✔
388
        """Returns the protocol for a given handle.
389

390
        May incur expensive side effects like resolving the handle itself over
391
        the network or other discovery.
392

393
        Args:
394
          handle (str)
395

396
        Returns:
397
          (Protocol subclass, str) tuple: matching protocol and optional id (if
398
          resolved), or ``(None, None)`` if no known protocol owns this handle
399
        """
400
        # TODO: normalize, eg convert domains to lower case
401
        logger.debug(f'Determining protocol for handle {handle}')
1✔
402
        if not handle:
1✔
403
            return (None, None)
1✔
404

405
        # step 1: check if any Protocols say conclusively that they own it.
406
        # sort to be deterministic.
407
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
408
                           key=lambda p: p.LABEL)
409
        candidates = []
1✔
410
        for proto in protocols:
1✔
411
            owns = proto.owns_handle(handle)
1✔
412
            if owns:
1✔
413
                logger.debug(f'  {proto.LABEL} owns handle {handle}')
1✔
414
                return (proto, None)
1✔
415
            elif owns is not False:
1✔
416
                candidates.append(proto)
1✔
417

418
        if len(candidates) == 1:
1✔
UNCOV
419
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
UNCOV
420
            return (candidates[0], None)
×
421

422
        # step 2: look for matching User in the datastore
423
        for proto in candidates:
1✔
424
            user = proto.query(proto.handle == handle).get()
1✔
425
            if user:
1✔
426
                if user.status:
1✔
427
                    return (None, None)
1✔
428
                logger.debug(f'  user {user.key} handle {handle}')
1✔
429
                return (proto, user.key.id())
1✔
430

431
        # step 3: resolve handle to id
432
        for proto in candidates:
1✔
433
            id = proto.handle_to_id(handle)
1✔
434
            if id:
1✔
435
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
436
                return (proto, id)
1✔
437

438
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
439
        return (None, None)
1✔
440

441
    @classmethod
1✔
442
    def bridged_web_url_for(cls, user, fallback=False):
1✔
443
        """Returns the web URL for a user's bridged profile in this protocol.
444

445
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
446
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
447

448
        Args:
449
          user (models.User)
450
          fallback (bool): if True, and bridged users have no canonical user
451
            profile URL in this protocol, return the native protocol's profile URL
452

453
        Returns:
454
          str, or None if there isn't a canonical URL
455
        """
456
        if fallback:
1✔
457
            return user.web_url()
1✔
458

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

463
        Args:
464
          obj (models.Object)
465
          allow_opt_out (bool): whether to return a user key if they're opted out
466

467
        Returns:
468
          google.cloud.ndb.key.Key or None:
469
        """
470
        owner = as1.get_owner(obj.as1)
1✔
471
        if owner:
1✔
472
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
473

474
    @classmethod
1✔
475
    def bot_user_id(cls):
1✔
476
        """Returns the Web user id for the bot user for this protocol.
477

478
        For example, ``'bsky.brid.gy'`` for ATProto.
479

480
        Returns:
481
          str:
482
        """
483
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
484

485
    @classmethod
1✔
486
    def create_for(cls, user):
1✔
487
        """Creates or re-activate a copy user in this protocol.
488

489
        Should add the copy user to :attr:`copies`.
490

491
        If the copy user already exists and active, should do nothing.
492

493
        Args:
494
          user (models.User): original source user. Shouldn't already have a
495
            copy user for this protocol in :attr:`copies`.
496

497
        Raises:
498
          ValueError: if we can't create a copy of the given user in this protocol
499
        """
UNCOV
500
        raise NotImplementedError()
×
501

502
    @classmethod
1✔
503
    def send(to_cls, obj, url, from_user=None, orig_obj_id=None):
1✔
504
        """Sends an outgoing activity.
505

506
        To be implemented by subclasses.
507

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

512
        Args:
513
          obj (models.Object): with activity to send
514
          url (str): destination URL to send to
515
          from_user (models.User): user (actor) this activity is from
516
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
517
            that this object refers to, eg replies to or reposts or likes
518

519
        Returns:
520
          bool: True if the activity is sent successfully, False if it is
521
          ignored or otherwise unsent due to protocol logic, eg no webmention
522
          endpoint, protocol doesn't support the activity type. (Failures are
523
          raised as exceptions.)
524

525
        Raises:
526
          werkzeug.HTTPException if the request fails
527
        """
UNCOV
528
        raise NotImplementedError()
×
529

530
    @classmethod
1✔
531
    def fetch(cls, obj, **kwargs):
1✔
532
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
533

534
        Errors are raised as exceptions. If this method returns False, the fetch
535
        didn't fail but didn't succeed either, eg the id isn't valid for this
536
        protocol, or the fetch didn't return valid data for this protocol.
537

538
        To be implemented by subclasses.
539

540
        Args:
541
          obj (models.Object): with the id to fetch. Data is filled into one of
542
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
543
          kwargs: subclass-specific
544

545
        Returns:
546
          bool: True if the object was fetched and populated successfully,
547
          False otherwise
548

549
        Raises:
550
          requests.RequestException or werkzeug.HTTPException: if the fetch fails
551
        """
UNCOV
552
        raise NotImplementedError()
×
553

554
    @classmethod
1✔
555
    def convert(cls, obj, from_user=None, **kwargs):
1✔
556
        """Converts an :class:`Object` to this protocol's data format.
557

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

561
        Just passes through to :meth:`_convert`, then does minor
562
        protocol-independent postprocessing.
563

564
        Args:
565
          obj (models.Object):
566
          from_user (models.User): user (actor) this activity/object is from
567
          kwargs: protocol-specific, passed through to :meth:`_convert`
568

569
        Returns:
570
          converted object in the protocol's native format, often a dict
571
        """
572
        if not obj or not obj.as1:
1✔
573
            return {}
1✔
574

575
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
576
        is_activity = obj.as1.get('verb') in ('post', 'update')
1✔
577
        base_obj = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
578
        orig_our_as1 = obj.our_as1
1✔
579

580
        # mark bridged actors as bots and add "bridged by Bridgy Fed" to their bios
581
        if (from_user and base_obj
1✔
582
            and base_obj.get('objectType') in as1.ACTOR_TYPES
583
            and PROTOCOLS.get(obj.source_protocol) != cls
584
            and Protocol.for_bridgy_subdomain(id) not in DOMAINS
585
            # Web users are special cased, they don't get the label if they've
586
            # explicitly enabled Bridgy Fed with redirects or webmentions
587
            and not (from_user.LABEL == 'web'
588
                     and (from_user.last_webmention_in or from_user.has_redirects))):
589

590
            obj.our_as1 = copy.deepcopy(obj.as1)
1✔
591
            actor = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
592
            actor['objectType'] = 'person'
1✔
593
            cls.add_source_links(actor=actor, obj=obj, from_user=from_user)
1✔
594

595
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
596
        obj.our_as1 = orig_our_as1
1✔
597
        return converted
1✔
598

599
    @classmethod
1✔
600
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
601
        """Converts an :class:`Object` to this protocol's data format.
602

603
        To be implemented by subclasses. Implementations should generally call
604
        :meth:`Protocol.translate_ids` (as their own class) before converting to
605
        their format.
606

607
        Args:
608
          obj (models.Object):
609
          from_user (models.User): user (actor) this activity/object is from
610
          kwargs: protocol-specific
611

612
        Returns:
613
          converted object in the protocol's native format, often a dict. May
614
            return the ``{}`` empty dict if the object can't be converted.
615
        """
UNCOV
616
        raise NotImplementedError()
×
617

618
    @classmethod
1✔
619
    def add_source_links(cls, actor, obj, from_user):
1✔
620
        """Adds "bridged from ... by Bridgy Fed" HTML to ``actor['summary']``.
621

622
        Default implementation; subclasses may override.
623

624
        Args:
625
          actor (dict): AS1 actor
626
          obj (models.Object):
627
          from_user (models.User): user (actor) this activity/object is from
628
        """
629
        assert from_user
1✔
630
        summary = actor.setdefault('summary', '')
1✔
631
        if 'Bridgy Fed]' in html_to_text(summary, ignore_links=True):
1✔
632
            return
1✔
633

634
        id = actor.get('id')
1✔
635
        proto_phrase = (PROTOCOLS[obj.source_protocol].PHRASE
1✔
636
                        if obj.source_protocol else '')
637
        if proto_phrase:
1✔
638
            proto_phrase = f' on {proto_phrase}'
1✔
639

640
        if from_user.key and id in (from_user.key.id(), from_user.profile_id()):
1✔
641
            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✔
642

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

648
        if summary:
1✔
649
            summary += '<br><br>'
1✔
650
        actor['summary'] = summary + source_links
1✔
651

652
    @classmethod
1✔
653
    def set_username(to_cls, user, username):
1✔
654
        """Sets a custom username for a user's bridged account in this protocol.
655

656
        Args:
657
          user (models.User)
658
          username (str)
659

660
        Raises:
661
          ValueError: if the username is invalid
662
          RuntimeError: if the username could not be set
663
        """
664
        raise NotImplementedError()
1✔
665

666
    @classmethod
1✔
667
    def target_for(cls, obj, shared=False):
1✔
668
        """Returns an :class:`Object`'s delivery target (endpoint).
669

670
        To be implemented by subclasses.
671

672
        Examples:
673

674
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
675
          webmention target.
676
        * If obj is an ``activitypub`` actor, returns its inbox.
677
        * If obj is an ``activitypub`` object, returns it's author's or actor's
678
          inbox.
679

680
        Args:
681
          obj (models.Object):
682
          shared (bool): optional. If True, returns a common/shared
683
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
684
            multiple recipients for efficiency
685

686
        Returns:
687
          str: target endpoint, or None if not available.
688
        """
UNCOV
689
        raise NotImplementedError()
×
690

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

695
        Default implementation here, subclasses may override.
696

697
        Args:
698
          url (str):
699
          allow_internal (bool): whether to return False for internal domains
700
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
701
        """
702
        blocklist = DOMAIN_BLOCKLIST
1✔
703
        if not allow_internal:
1✔
704
            blocklist += DOMAINS
1✔
705
        return util.domain_or_parent_in(util.domain_from_link(url), blocklist)
1✔
706

707
    @classmethod
1✔
708
    def translate_ids(to_cls, obj):
1✔
709
        """Translates all ids in an AS1 object to a specific protocol.
710

711
        Infers source protocol for each id value separately.
712

713
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
714
        ``at://did:plc:abc/coll/123`` will be converted to
715
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
716

717
        Wraps these AS1 fields:
718

719
        * ``id``
720
        * ``actor``
721
        * ``author``
722
        * ``bcc``
723
        * ``bto``
724
        * ``cc``
725
        * ``object``
726
        * ``object.actor``
727
        * ``object.author``
728
        * ``object.id``
729
        * ``object.inReplyTo``
730
        * ``object.object``
731
        * ``attachments[].id``
732
        * ``tags[objectType=mention].url``
733
        * ``to``
734

735
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
736
        same logic is duplicated there!
737

738
        TODO: unify with :meth:`Object.resolve_ids`,
739
        :meth:`models.Object.normalize_ids`.
740

741
        Args:
742
          to_proto (Protocol subclass)
743
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
744

745
        Returns:
746
          dict: wrapped AS1 version of ``obj``
747
        """
748
        assert to_cls != Protocol
1✔
749
        if not obj:
1✔
750
            return obj
1✔
751

752
        outer_obj = copy.deepcopy(obj)
1✔
753
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
754

755
        def translate(elem, field, fn, uri=False):
1✔
756
            elem[field] = as1.get_objects(elem, field)
1✔
757
            for obj in elem[field]:
1✔
758
                if id := obj.get('id'):
1✔
759
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
760
                        continue
1✔
761
                    from_cls = Protocol.for_id(id)
1✔
762
                    # TODO: what if from_cls is None? relax translate_object_id,
763
                    # make it a noop if we don't know enough about from/to?
764
                    if from_cls and from_cls != to_cls:
1✔
765
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
766
                    if obj['id'] and uri:
1✔
767
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
768

769
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
770
                           for o in elem[field]]
771

772
            if len(elem[field]) == 1:
1✔
773
                elem[field] = elem[field][0]
1✔
774

775
        type = as1.object_type(outer_obj)
1✔
776
        translate(outer_obj, 'id',
1✔
777
                  translate_user_id if type in as1.ACTOR_TYPES
778
                  else translate_object_id)
779

780
        for o in inner_objs:
1✔
781
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
782
                        or as1.get_owner(outer_obj) == o.get('id')
783
                        or type in ('follow', 'stop-following'))
784
            translate(o, 'id', translate_user_id if is_actor else translate_object_id)
1✔
785
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
786
            translate(o, 'object', translate_user_id if obj_is_actor
1✔
787
                      else translate_object_id)
788

789
        for o in [outer_obj] + inner_objs:
1✔
790
            translate(o, 'inReplyTo', translate_object_id)
1✔
791
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
792
                translate(o, field, translate_user_id)
1✔
793
            for tag in as1.get_objects(o, 'tags'):
1✔
794
                if tag.get('objectType') == 'mention':
1✔
795
                    translate(tag, 'url', translate_user_id, uri=True)
1✔
796
            for att in as1.get_objects(o, 'attachments'):
1✔
797
                translate(att, 'id', translate_object_id)
1✔
798
                url = att.get('url')
1✔
799
                if url and not att.get('id'):
1✔
800
                    if from_cls := Protocol.for_id(url):
1✔
801
                        att['id'] = translate_object_id(from_=from_cls, to=to_cls,
1✔
802
                                                        id=url)
803

804
        outer_obj = util.trim_nulls(outer_obj)
1✔
805

806
        if objs := util.get_list(outer_obj ,'object'):
1✔
807
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
808
            if len(outer_obj['object']) == 1:
1✔
809
                outer_obj['object'] = outer_obj['object'][0]
1✔
810

811
        return outer_obj
1✔
812

813
    @classmethod
1✔
814
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
815
        """Handles an incoming activity.
816

817
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
818
        unset, returns HTTP 299.
819

820
        Args:
821
          obj (models.Object)
822
          authed_as (str): authenticated actor id who sent this activity
823
          internal (bool): whether to allow activity ids on internal domains,
824
            from opted out/blocked users, etc.
825
          received_at (datetime): when we first saw (received) this activity.
826
            Right now only used for monitoring.
827

828
        Returns:
829
          (str, int) tuple: (response body, HTTP status code) Flask response
830

831
        Raises:
832
          werkzeug.HTTPException: if the request is invalid
833
        """
834
        # check some invariants
835
        assert from_cls != Protocol
1✔
836
        assert isinstance(obj, Object), obj
1✔
837

838
        if not obj.as1:
1✔
UNCOV
839
            error('No object data provided')
×
840

841
        id = None
1✔
842
        if obj.key and obj.key.id():
1✔
843
            id = obj.key.id()
1✔
844

845
        if not id:
1✔
846
            id = obj.as1.get('id')
1✔
847
            obj.key = ndb.Key(Object, id)
1✔
848

849
        if not id:
1✔
UNCOV
850
            error('No id provided')
×
851
        elif from_cls.owns_id(id) is False:
1✔
852
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
853
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
854
            error(f'Activity {id} is blocklisted')
1✔
855
        # check that this activity is public. only do this for some activities,
856
        # not eg likes or follows, since Mastodon doesn't currently mark those
857
        # as explicitly public.
858
        elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
859
                  and not as1.is_public(obj.as1, unlisted=False)
860
                  and not as1.is_dm(obj.as1)):
861
              logger.info('Dropping non-public activity')
1✔
862
              return ('OK', 200)
1✔
863

864
        # lease this object, atomically
865
        memcache_key = activity_id_memcache_key(id)
1✔
866
        leased = memcache.memcache.add(memcache_key, 'leased', noreply=False,
1✔
867
                                     expire=5 * 60)  # 5 min
868
        # short circuit if we've already seen this activity id.
869
        # (don't do this for bare objects since we need to check further down
870
        # whether they've been updated since we saw them last.)
871
        if (obj.as1.get('objectType') == 'activity'
1✔
872
            and 'force' not in request.values
873
            and (not leased
874
                 or (obj.new is False and obj.changed is False))):
875
            error(f'Already seen this activity {id}', status=204)
1✔
876

877
        pruned = {k: v for k, v in obj.as1.items()
1✔
878
                  if k not in ('contentMap', 'replies', 'signature')}
879
        delay = ''
1✔
880
        if (received_at and request.headers.get('X-AppEngine-TaskRetryCount') == '0'
1✔
881
                and obj.type != 'delete'):  # we delay deletes for 2m
UNCOV
882
            delay_s = int((util.now().replace(tzinfo=None)
×
883
                           - received_at.replace(tzinfo=None)
884
                           ).total_seconds())
UNCOV
885
            delay = f'({delay_s} s behind)'
×
886
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
887

888
        # does this protocol support this activity/object type?
889
        from_cls.check_supported(obj)
1✔
890

891
        # check authorization
892
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
893
        actor = as1.get_owner(obj.as1)
1✔
894
        if not actor:
1✔
895
            error('Activity missing actor or author')
1✔
896
        elif from_cls.owns_id(actor) is False:
1✔
897
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
898

899
        assert authed_as
1✔
900
        assert isinstance(authed_as, str)
1✔
901
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
902
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
903
        if actor != authed_as:
1✔
904
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
905
                         user=f'{id} authed_as {authed_as} owner {actor}')
906
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
907

908
        # update copy ids to originals
909
        obj.normalize_ids()
1✔
910
        obj.resolve_ids()
1✔
911

912
        if (obj.type == 'follow'
1✔
913
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
914
            # follows of bot user; refresh user profile first
915
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
916
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
917
            from_user.reload_profile()
1✔
918
        else:
919
            # load actor user
920
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
921

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

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

929
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
930
        inner_obj_id = inner_obj_as1.get('id')
1✔
931
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
932
            if not inner_obj_id:
1✔
933
                error(f'{obj.type} object has no id!')
1✔
934

935
        # check age. we support backdated posts, but if they're over 2w old, we
936
        # don't deliver them
937
        if obj.type == 'post':
1✔
938
            if published := inner_obj_as1.get('published'):
1✔
UNCOV
939
                try:
×
UNCOV
940
                    published_dt = util.parse_iso8601(published)
×
UNCOV
941
                    if not published_dt.tzinfo:
×
UNCOV
942
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
UNCOV
943
                    age = util.now() - published_dt
×
UNCOV
944
                    if age > CREATE_MAX_AGE:
×
UNCOV
945
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
946
                              status=204)
947
                except ValueError:  # from parse_iso8601
×
948
                    logger.debug(f"Couldn't parse published {published}")
×
949

950
        # write Object to datastore
951
        obj.source_protocol = from_cls.LABEL
1✔
952
        if obj.type in STORE_AS1_TYPES:
1✔
953
            obj.put()
1✔
954

955
        # store inner object
956
        # TODO: unify with big obj.type conditional below. would have to merge
957
        # this with the DM handling block lower down.
958
        crud_obj = None
1✔
959
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
960
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
961
                                            source_protocol=from_cls.LABEL,
962
                                            authed_as=actor, users=[from_user.key])
963

964
        actor = as1.get_object(obj.as1, 'actor')
1✔
965
        actor_id = actor.get('id')
1✔
966

967
        # handle activity!
968
        if obj.type == 'stop-following':
1✔
969
            # TODO: unify with handle_follow?
970
            # TODO: handle multiple followees
971
            if not actor_id or not inner_obj_id:
1✔
UNCOV
972
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
973

974
            # deactivate Follower
975
            from_ = from_cls.key_for(actor_id)
1✔
976
            to_cls = Protocol.for_id(inner_obj_id)
1✔
977
            to = to_cls.key_for(inner_obj_id)
1✔
978
            follower = Follower.query(Follower.to == to,
1✔
979
                                      Follower.from_ == from_,
980
                                      Follower.status == 'active').get()
981
            if follower:
1✔
982
                logger.info(f'Marking {follower} inactive')
1✔
983
                follower.status = 'inactive'
1✔
984
                follower.put()
1✔
985
            else:
986
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
987

988
            # fall through to deliver to followee
989
            # TODO: do we convert stop-following to webmention 410 of original
990
            # follow?
991

992
            # fall through to deliver to followers
993

994
        elif obj.type in ('delete', 'undo'):
1✔
995
            delete_obj_id = (from_user.profile_id()
1✔
996
                            if inner_obj_id == from_user.key.id()
997
                            else inner_obj_id)
998

999
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1000
            if not delete_obj:
1✔
1001
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1002
                return 'OK', 204
1✔
1003

1004
            # TODO: just delete altogether!
1005
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1006
            delete_obj.deleted = True
1✔
1007
            delete_obj.put()
1✔
1008

1009
            # if this is an actor, handle deleting it later so that
1010
            # in case it's from_user, user.enabled_protocols is still populated
1011
            #
1012
            # fall through to deliver to followers and delete copy if necessary.
1013
            # should happen via protocol-specific copy target and send of
1014
            # delete activity.
1015
            # https://github.com/snarfed/bridgy-fed/issues/63
1016

1017
        elif obj.type == 'block':
1✔
1018
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1019
                # blocking protocol bot user disables that protocol
1020
                from_user.delete(proto)
1✔
1021
                from_user.disable_protocol(proto)
1✔
1022
                return 'OK', 200
1✔
1023

1024
        elif obj.type == 'post':
1✔
1025
            # handle DMs to bot users
1026
            if as1.is_dm(obj.as1):
1✔
1027
                return dms.receive(from_user=from_user, obj=obj)
1✔
1028

1029
        # fetch actor if necessary
1030
        if (actor and actor.keys() == set(['id'])
1✔
1031
                and obj.type not in ('delete', 'undo')):
1032
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1033
            actor_obj = from_cls.load(ids.profile_id(id=actor['id'], proto=from_cls),
1✔
1034
                                      raise_=False)
1035
            if actor_obj and actor_obj.as1:
1✔
1036
                obj.our_as1 = {
1✔
1037
                    **obj.as1, 'actor': {
1038
                        **actor_obj.as1,
1039
                        # override profile id with actor id
1040
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1041
                        'id': actor['id'],
1042
                    }
1043
                }
1044

1045
        # fetch object if necessary
1046
        if (obj.type in ('post', 'update', 'share')
1✔
1047
                and inner_obj_as1.keys() == set(['id'])
1048
                and from_cls.owns_id(inner_obj_id)):
1049
            logger.debug('Fetching inner object')
1✔
1050
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1051
                                      remote=(obj.type in ('post', 'update')))
1052
            if obj.type in ('post', 'update'):
1✔
1053
                crud_obj = inner_obj
1✔
1054
            if inner_obj and inner_obj.as1:
1✔
1055
                obj.our_as1 = {
1✔
1056
                    **obj.as1,
1057
                    'object': {
1058
                        **inner_obj_as1,
1059
                        **inner_obj.as1,
1060
                    }
1061
                }
1062
            elif obj.type in ('post', 'update'):
1✔
1063
                error("Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1064

1065
        if obj.type == 'follow':
1✔
1066
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1067
                # follow of one of our protocol bot users; enable that protocol.
1068
                # fall through so that we send an accept.
1069
                try:
1✔
1070
                    from_user.enable_protocol(proto)
1✔
1071
                except ErrorButDoNotRetryTask:
1✔
1072
                    from web import Web
1✔
1073
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1074
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1075
                                               followee=bot, follow=obj)
1076
                    raise
1✔
1077
                proto.bot_follow(from_user)
1✔
1078

1079
            from_cls.handle_follow(obj)
1✔
1080

1081
        # deliver to targets
1082
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1083

1084
        # if this is a user, deactivate its followers/followings
1085
        # https://github.com/snarfed/bridgy-fed/issues/1304
1086
        if obj.type == 'delete':
1✔
1087
            if user_key := from_cls.key_for(id=inner_obj_id):
1✔
1088
                if user := user_key.get():
1✔
1089
                    for proto in user.enabled_protocols:
1✔
1090
                        user.disable_protocol(PROTOCOLS[proto])
1✔
1091

1092
                    logger.info(f'Deactivating Followers from or to {user_key.id()}')
1✔
1093
                    followers = Follower.query(
1✔
1094
                        OR(Follower.to == user_key, Follower.from_ == user_key)
1095
                        ).fetch()
1096
                    for f in followers:
1✔
1097
                        f.status = 'inactive'
1✔
1098
                    ndb.put_multi(followers)
1✔
1099

1100
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1101
        return resp
1✔
1102

1103
    @classmethod
1✔
1104
    def handle_follow(from_cls, obj):
1✔
1105
        """Handles an incoming follow activity.
1106

1107
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1108
        happens in :meth:`deliver`.
1109

1110
        Args:
1111
          obj (models.Object): follow activity
1112
        """
1113
        logger.debug('Got follow. Loading users, storing Follow(s), sending accept(s)')
1✔
1114

1115
        # Prepare follower (from) users' data
1116
        # TODO: remove all of this and just use from_user
1117
        from_as1 = as1.get_object(obj.as1, 'actor')
1✔
1118
        from_id = from_as1.get('id')
1✔
1119
        if not from_id:
1✔
UNCOV
1120
            error(f'Follow activity requires actor. Got: {obj.as1}')
×
1121

1122
        from_obj = from_cls.load(from_id, raise_=False)
1✔
1123
        if not from_obj:
1✔
UNCOV
1124
            error(f"Couldn't load {from_id}", status=502)
×
1125

1126
        if not from_obj.as1:
1✔
1127
            from_obj.our_as1 = from_as1
1✔
1128
            from_obj.put()
1✔
1129

1130
        from_key = from_cls.key_for(from_id)
1✔
1131
        if not from_key:
1✔
1132
            error(f'Invalid {from_cls.LABEL} user key: {from_id}')
×
1133
        obj.users = [from_key]
1✔
1134
        from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
1✔
1135

1136
        # Prepare followee (to) users' data
1137
        to_as1s = as1.get_objects(obj.as1)
1✔
1138
        if not to_as1s:
1✔
UNCOV
1139
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1140

1141
        # Store Followers
1142
        for to_as1 in to_as1s:
1✔
1143
            to_id = to_as1.get('id')
1✔
1144
            if not to_id:
1✔
UNCOV
1145
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1146

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

1149
            to_cls = Protocol.for_id(to_id)
1✔
1150
            if not to_cls:
1✔
UNCOV
1151
                error(f"Couldn't determine protocol for {to_id}")
×
1152
            elif from_cls == to_cls:
1✔
1153
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1154
                continue
1✔
1155

1156
            to_obj = to_cls.load(to_id)
1✔
1157
            if to_obj and not to_obj.as1:
1✔
1158
                to_obj.our_as1 = to_as1
1✔
1159
                to_obj.put()
1✔
1160

1161
            to_key = to_cls.key_for(to_id)
1✔
1162
            if not to_key:
1✔
UNCOV
1163
                logger.info(f'Skipping invalid {from_cls.LABEL} user key: {from_id}')
×
UNCOV
1164
                continue
×
1165

1166
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj,
1✔
1167
                                           allow_opt_out=True)
1168
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1169
                                                  follow=obj.key, status='active')
1170
            obj.add('notify', to_key)
1✔
1171
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1172
                                       followee=to_user, follow=obj)
1173

1174
    @classmethod
1✔
1175
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1176
        """Sends an accept or reject activity for a follow.
1177

1178
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1179
        nothing.
1180

1181
        Args:
1182
          verb (str): ``accept`` or  ``reject``
1183
          follower (models.User)
1184
          followee (models.User)
1185
          follow (models.Object)
1186
        """
1187
        assert verb in ('accept', 'reject')
1✔
1188
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1189
            return
1✔
1190

1191
        target = follower.target_for(follower.obj)
1✔
1192
        if not target:
1✔
UNCOV
1193
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1194

1195
        # send. note that this is one response for the whole follow, even if it
1196
        # has multiple followees!
1197
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1198
        accept = {
1✔
1199
            'id': id,
1200
            'objectType': 'activity',
1201
            'verb': verb,
1202
            'actor': followee.key.id(),
1203
            'object': follow.as1,
1204
        }
1205
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1206
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1207

1208
    @classmethod
1✔
1209
    def bot_follow(bot_cls, user):
1✔
1210
        """Follow a user from a protocol bot user.
1211

1212
        ...so that the protocol starts sending us their activities, if it needs
1213
        a follow for that (eg ActivityPub).
1214

1215
        Args:
1216
          user (User)
1217
        """
1218
        from web import Web
1✔
1219
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1220
        now = util.now().isoformat()
1✔
1221
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1222

1223
        if not user.obj:
1✔
1224
            logger.info("  can't follow, user has no profile obj")
1✔
1225
            return
1✔
1226

1227
        target = user.target_for(user.obj)
1✔
1228
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1229
        follow_back_as1 = {
1✔
1230
            'objectType': 'activity',
1231
            'verb': 'follow',
1232
            'id': follow_back_id,
1233
            'actor': bot.key.id(),
1234
            'object': user.key.id(),
1235
        }
1236
        common.create_task(queue='send', id=follow_back_id,
1✔
1237
                           our_as1=follow_back_as1, url=target,
1238
                           source_protocol='web', protocol=user.LABEL,
1239
                           user=bot.key.urlsafe())
1240

1241
    @classmethod
1✔
1242
    def handle_bare_object(cls, obj, authed_as=None):
1✔
1243
        """If obj is a bare object, wraps it in a create or update activity.
1244

1245
        Checks if we've seen it before.
1246

1247
        Args:
1248
          obj (models.Object)
1249
          authed_as (str): authenticated actor id who sent this activity
1250

1251
        Returns:
1252
          models.Object: ``obj`` if it's an activity, otherwise a new object
1253
        """
1254
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1255
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1256
            return obj
1✔
1257

1258
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1259
        now = util.now().isoformat()
1✔
1260

1261
        # occasionally we override the object, eg if this is a profile object
1262
        # coming in via a user with use_instead set
1263
        obj_as1 = obj.as1
1✔
1264
        if obj_id := obj.key.id():
1✔
1265
            if obj_as1_id := obj_as1.get('id'):
1✔
1266
                if obj_id != obj_as1_id:
1✔
1267
                    logger.info(f'Overriding AS1 object id {obj_as1_id} with Object id {obj_id}')
1✔
1268
                    obj_as1['id'] = obj_id
1✔
1269

1270
        # this is a raw post; wrap it in a create or update activity
1271
        if obj.changed or is_actor:
1✔
1272
            if obj.changed:
1✔
1273
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1274
            else:
1275
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1276
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1277
            update_as1 = {
1✔
1278
                'objectType': 'activity',
1279
                'verb': 'update',
1280
                'id': id,
1281
                'actor': obj_actor,
1282
                'object': {
1283
                    # Mastodon requires the updated field for Updates, so
1284
                    # add a default value.
1285
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1286
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1287
                    # https://github.com/mastodon/documentation/pull/1150
1288
                    'updated': now,
1289
                    **obj_as1,
1290
                },
1291
            }
1292
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1293
            return Object(id=id, our_as1=update_as1,
1✔
1294
                          source_protocol=obj.source_protocol)
1295

1296
        if (obj.new
1✔
1297
                # HACK: force query param here is specific to webmention
1298
                or 'force' in request.form):
1299
            create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1300
            create_as1 = {
1✔
1301
                'objectType': 'activity',
1302
                'verb': 'post',
1303
                'id': create_id,
1304
                'actor': obj_actor,
1305
                'object': obj_as1,
1306
                'published': now,
1307
            }
1308
            logger.info(f'Wrapping in post')
1✔
1309
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1310
            return Object(id=create_id, our_as1=create_as1,
1✔
1311
                          source_protocol=obj.source_protocol)
1312

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

1315
    @classmethod
1✔
1316
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1317
        """Delivers an activity to its external recipients.
1318

1319
        Args:
1320
          obj (models.Object): activity to deliver
1321
          from_user (models.User): user (actor) this activity is from
1322
          crud_obj (models.Object): if this is a create, update, or delete/undo
1323
            activity, the inner object that's being written, otherwise None.
1324
            (This object's ``notify`` and ``feed`` properties may be updated.)
1325
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1326
            targets on this protocol
1327

1328
        Returns:
1329
          (str, int) tuple: Flask response
1330
        """
1331
        if to_proto:
1✔
1332
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1333

1334
        # find delivery targets. maps Target to Object or None
1335
        #
1336
        # ...then write the relevant object, since targets() has a side effect of
1337
        # setting the notify and feed properties (and dirty attribute)
1338
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1339
        if not targets:
1✔
1340
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1341

1342
        # store object that targets() updated
1343
        if crud_obj and crud_obj.dirty:
1✔
1344
            crud_obj.put()
1✔
1345
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1346
            obj.put()
1✔
1347

1348
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1349
                      else obj.to_request())
1350

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

1354
        # enqueue send task for each targets
1355
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1356
        user = from_user.key.urlsafe()
1✔
1357
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1358
            if to_proto and target.protocol != to_proto.LABEL:
1✔
UNCOV
1359
                continue
×
1360
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1361
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1362
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1363

1364
        return 'OK', 202
1✔
1365

1366
    @classmethod
1✔
1367
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1368
        """Collects the targets to send a :class:`models.Object` to.
1369

1370
        Targets are both objects - original posts, events, etc - and actors.
1371

1372
        Args:
1373
          obj (models.Object)
1374
          from_user (User)
1375
          crud_obj (models.Object): if this is a create, update, or delete/undo
1376
            activity, the inner object that's being written, otherwise None.
1377
            (This object's ``notify`` and ``feed`` properties may be updated.)
1378
          internal (bool): whether this is a recursive internal call
1379

1380
        Returns:
1381
          dict: maps :class:`models.Target` to original (in response to)
1382
          :class:`models.Object`, if any, otherwise None
1383
        """
1384
        logger.debug('Finding recipients and their targets')
1✔
1385

1386
        # we should only have crud_obj iff this is a create or update
1387
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1388
        write_obj = crud_obj or obj
1✔
1389
        write_obj.dirty = False
1✔
1390

1391
        target_uris = sorted(set(as1.targets(obj.as1)))
1✔
1392
        logger.info(f'Raw targets: {target_uris}')
1✔
1393
        orig_obj = None
1✔
1394
        targets = {}  # maps Target to Object or None
1✔
1395
        owner = as1.get_owner(obj.as1)
1✔
1396
        allow_opt_out = (obj.type == 'delete')
1✔
1397
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1398
        inner_obj_id = inner_obj_as1.get('id')
1✔
1399
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1400
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1401
        is_self_reply = False
1✔
1402

1403
        original_ids = []
1✔
1404
        if is_reply:
1✔
1405
            original_ids = in_reply_tos
1✔
1406
        elif inner_obj_id:
1✔
1407
            if inner_obj_id == from_user.key.id():
1✔
1408
                inner_obj_id = from_user.profile_id()
1✔
1409
            original_ids = [inner_obj_id]
1✔
1410

1411
        # which protocols should we allow delivering to?
1412
        to_protocols = []
1✔
1413
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1414
                      + from_user.enabled_protocols):
1415
            proto = PROTOCOLS[label]
1✔
1416
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1417
                                     or is_reply):
1418
                for id in original_ids:
1✔
1419
                    if Protocol.for_id(id) == proto:
1✔
1420
                        logger.info(f'Allowing {label} for original post {id}')
1✔
1421
                        break
1✔
1422
                    elif orig := from_user.load(id, remote=False):
1✔
1423
                        if orig.get_copy(proto):
1✔
1424
                            logger.info(f'Allowing {label}, original post {id} was bridged there')
1✔
1425
                            break
1✔
1426
                else:
1427
                    logger.info(f"Skipping {label}, original objects {original_ids} weren't bridged there")
1✔
1428
                    continue
1✔
1429

1430
            util.add(to_protocols, proto)
1✔
1431

1432
        # process direct targets
1433
        for id in sorted(target_uris):
1✔
1434
            target_proto = Protocol.for_id(id)
1✔
1435
            if not target_proto:
1✔
1436
                logger.info(f"Can't determine protocol for {id}")
1✔
1437
                continue
1✔
1438
            elif target_proto.is_blocklisted(id):
1✔
1439
                logger.debug(f'{id} is blocklisted')
1✔
1440
                continue
1✔
1441

1442
            orig_obj = target_proto.load(id, raise_=False)
1✔
1443
            if not orig_obj or not orig_obj.as1:
1✔
1444
                logger.info(f"Couldn't load {id}")
1✔
1445
                continue
1✔
1446

1447
            target_author_key = target_proto.actor_key(orig_obj)
1✔
1448
            if not from_user.is_enabled(target_proto):
1✔
1449
                # if author isn't bridged and inReplyTo author is, DM a prompt
1450
                if id in in_reply_tos and target_author_key:
1✔
1451
                    if target_author := target_author_key.get():
1✔
1452
                        if target_author.is_enabled(from_cls):
1✔
1453
                            dms.maybe_send(
1✔
1454
                                from_proto=target_proto, to_user=from_user,
1455
                                type='replied_to_bridged_user', text=f"""\
1456
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.""")
1457

1458
                continue
1✔
1459

1460
            # deliver self-replies to followers
1461
            # https://github.com/snarfed/bridgy-fed/issues/639
1462
            if id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1463
                is_self_reply = True
1✔
1464
                logger.info(f'self reply!')
1✔
1465

1466
            # also add copies' targets
1467
            for copy in orig_obj.copies:
1✔
1468
                proto = PROTOCOLS[copy.protocol]
1✔
1469
                if proto in to_protocols:
1✔
1470
                    # copies generally won't have their own Objects
1471
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1472
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {id}')
1✔
1473
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1474

1475
            if target_proto == from_cls:
1✔
1476
                logger.debug(f'Skipping same-protocol target {id}')
1✔
1477
                continue
1✔
1478

1479
            target = target_proto.target_for(orig_obj)
1✔
1480
            if not target:
1✔
1481
                # TODO: surface errors like this somehow?
UNCOV
1482
                logger.error(f"Can't find delivery target for {id}")
×
UNCOV
1483
                continue
×
1484

1485
            logger.debug(f'Target for {id} is {target}')
1✔
1486
            # only use orig_obj for inReplyTos, like/repost objects, etc
1487
            # https://github.com/snarfed/bridgy-fed/issues/1237
1488
            targets[Target(protocol=target_proto.LABEL, uri=target)] = (
1✔
1489
                orig_obj if id in in_reply_tos or id in as1.get_ids(obj.as1, 'object')
1490
                else None)
1491

1492
            if target_author_key:
1✔
1493
                logger.debug(f'Recipient is {target_author_key}')
1✔
1494
                if write_obj.add('notify', target_author_key):
1✔
1495
                    write_obj.dirty = True
1✔
1496

1497
        if obj.type == 'undo':
1✔
1498
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1499
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1500
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1501
            else:
1502
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1503
            if inner_obj:
1✔
1504
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1505
                                                internal=True))
1506

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

1509
        # deliver to followers, if appropriate
1510
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1511
        if not user_key:
1✔
1512
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1513
            return targets
1✔
1514

1515
        followers = []
1✔
1516
        if (obj.type in ('post', 'update', 'delete', 'share', 'undo')
1✔
1517
                and (not is_reply or is_self_reply)):
1518
            logger.info(f'Delivering to followers of {user_key}')
1✔
1519
            followers = [
1✔
1520
                f for f in Follower.query(Follower.to == user_key,
1521
                                          Follower.status == 'active')
1522
                # skip protocol bot users
1523
                if not Protocol.for_bridgy_subdomain(f.from_.id())
1524
                # skip protocols this user hasn't enabled, or where the base
1525
                # object of this activity hasn't been bridged
1526
                and PROTOCOLS_BY_KIND[f.from_.kind()] in to_protocols]
1527
            user_keys = [f.from_ for f in followers]
1✔
1528
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1529
            User.load_multi(users)
1✔
1530

1531
            if (not followers and
1✔
1532
                (util.domain_or_parent_in(
1533
                    util.domain_from_link(from_user.key.id()), LIMITED_DOMAINS)
1534
                 or util.domain_or_parent_in(
1535
                     util.domain_from_link(obj.key.id()), LIMITED_DOMAINS))):
1536
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1537
                return {}
1✔
1538

1539
            # add to followers' feeds, if any
1540
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1541
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1542
                    write_obj.feed = [u.key for u in users]
1✔
1543
                    if write_obj.feed:
1✔
1544
                        write_obj.dirty = True
1✔
1545

1546
            # collect targets for followers
1547
            for user in users:
1✔
1548
                # TODO: should we pass remote=False through here to Protocol.load?
1549
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1550
                if not target:
1✔
1551
                    # TODO: surface errors like this somehow?
1552
                    logger.error(f'Follower {user.key} has no delivery target')
1✔
1553
                    continue
1✔
1554

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

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

1563
        # deliver to enabled HAS_COPIES protocols proactively
1564
        # TODO: abstract for other protocols
1565
        from atproto import ATProto
1✔
1566
        if (ATProto in to_protocols
1✔
1567
                and obj.type in ('post', 'update', 'delete', 'share')):
1568
            logger.info(f'user has ATProto enabled, adding {ATProto.PDS_URL}')
1✔
1569
            targets.setdefault(
1✔
1570
                Target(protocol=ATProto.LABEL, uri=ATProto.PDS_URL), None)
1571

1572
        # de-dupe targets, discard same-domain
1573
        # maps string target URL to (Target, Object) tuple
1574
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1575
        # maps Target to Object or None
1576
        targets = {}
1✔
1577
        source_domains = [
1✔
1578
            util.domain_from_link(url) for url in
1579
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1580
            if util.is_web(url)
1581
        ]
1582
        for url in sorted(util.dedupe_urls(
1✔
1583
                candidates.keys(),
1584
                # preserve our PDS URL without trailing slash in path
1585
                # https://atproto.com/specs/did#did-documents
1586
                trailing_slash=False)):
1587
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
UNCOV
1588
                logger.info(f'Skipping same-domain target {url}')
×
UNCOV
1589
                continue
×
1590
            target, obj = candidates[url]
1✔
1591
            targets[target] = obj
1✔
1592

1593
        return targets
1✔
1594

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

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

1602
        Args:
1603
          id (str)
1604
          remote (bool): whether to fetch the object over the network. If True,
1605
            fetches even if we already have the object stored, and updates our
1606
            stored copy. If False and we don't have the object stored, returns
1607
            None. Default (None) means to fetch over the network only if we
1608
            don't already have it stored.
1609
          local (bool): whether to load from the datastore before
1610
            fetching over the network. If False, still stores back to the
1611
            datastore after a successful remote fetch.
1612
          raise_ (bool): if False, catches any :class:`request.RequestException`
1613
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1614
            ``None`` instead
1615
          kwargs: passed through to :meth:`fetch()`
1616

1617
        Returns:
1618
          models.Object: loaded object, or None if it isn't fetchable, eg a
1619
          non-URL string for Web, or ``remote`` is False and it isn't in the
1620
          datastore
1621

1622
        Raises:
1623
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1624
            is True
1625
        """
1626
        assert id
1✔
1627
        assert local or remote is not False
1✔
1628
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1629

1630
        obj = orig_as1 = None
1✔
1631
        if local:
1✔
1632
            obj = Object.get_by_id(id)
1✔
1633
            if not obj:
1✔
1634
                # logger.debug(f' {id} not in datastore')
1635
                pass
1✔
1636
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1637
                # logger.debug(f'  {id} got from datastore')
1638
                obj.new = False
1✔
1639

1640
        if remote is False:
1✔
1641
            return obj
1✔
1642
        elif remote is None and obj:
1✔
1643
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1644
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1645
                pass
1✔
1646
            else:
1647
                return obj
1✔
1648

1649
        if obj:
1✔
1650
            orig_as1 = obj.as1
1✔
1651
            obj.our_as1 = None
1✔
1652
            obj.new = False
1✔
1653
        else:
1654
            obj = Object(id=id)
1✔
1655
            if local:
1✔
1656
                # logger.debug(f'  {id} not in datastore')
1657
                obj.new = True
1✔
1658
                obj.changed = False
1✔
1659

1660
        try:
1✔
1661
            fetched = cls.fetch(obj, **kwargs)
1✔
1662
        except (RequestException, HTTPException) as e:
1✔
1663
            if raise_:
1✔
1664
                raise
1✔
1665
            util.interpret_http_exception(e)
1✔
1666
            return None
1✔
1667

1668
        if not fetched:
1✔
1669
            return None
1✔
1670

1671
        # https://stackoverflow.com/a/3042250/186123
1672
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1673
        if size > models.MAX_ENTITY_SIZE:
1✔
1674
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1675
            return None
1✔
1676

1677
        obj.resolve_ids()
1✔
1678
        obj.normalize_ids()
1✔
1679

1680
        if obj.new is False:
1✔
1681
            obj.changed = obj.activity_changed(orig_as1)
1✔
1682

1683
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1684
            if obj.source_protocol:
1✔
UNCOV
1685
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1686
            obj.source_protocol = cls.LABEL
1✔
1687

1688
        obj.put()
1✔
1689
        return obj
1✔
1690

1691
    @classmethod
1✔
1692
    def check_supported(cls, obj):
1✔
1693
        """If this protocol doesn't support this object, raises HTTP 204.
1694

1695
        Also reports an error.
1696

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

1701
        Args:
1702
          obj (Object)
1703

1704
        Raises:
1705
          werkzeug.HTTPException: if this protocol doesn't support this object
1706
        """
1707
        if not obj.type:
1✔
UNCOV
1708
            return
×
1709

1710
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1711
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1712
            or (obj.type in as1.CRUD_VERBS
1713
                and inner_type
1714
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1715
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1716

1717
        # don't allow posts with blank content and no image/video/audio
1718
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
1719
                    else obj.as1)
1720
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
1721
                and not util.get_url(crud_obj, key='image')
1722
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
1723
                # TODO: handle articles with displayName but not content
1724
                and not source.html_to_text(crud_obj.get('content')).strip()):
1725
            error('Blank content and no image or video or audio', status=204)
1✔
1726

1727
        # DMs are only allowed to/from protocol bot accounts
1728
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1729
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1730
            if (not cls.SUPPORTS_DMS
1✔
1731
                    or (recip not in protocol_user_ids
1732
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1733
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1734

1735

1736
@cloud_tasks_only(log=None)
1✔
1737
def receive_task():
1✔
1738
    """Task handler for a newly received :class:`models.Object`.
1739

1740
    Calls :meth:`Protocol.receive` with the form parameters.
1741

1742
    Parameters:
1743
      authed_as (str): passed to :meth:`Protocol.receive`
1744
      obj_id (str): key id of :class:`models.Object` to handle
1745
      received_at (str, ISO 8601 timestamp): when we first saw (received)
1746
        this activity
1747
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1748
        :class:`models.Object` to handle
1749

1750
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
1751
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
1752
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
1753
    ``changed``, HTTP request details, etc. See stash for attempt at this for
1754
    :class:`web.Web`.
1755
    """
1756
    common.log_request()
1✔
1757
    form = request.form.to_dict()
1✔
1758

1759
    authed_as = form.pop('authed_as', None)
1✔
1760
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1761
                or authed_as in common.PROTOCOL_DOMAINS)
1762

1763
    obj = Object.from_request()
1✔
1764
    assert obj
1✔
1765
    assert obj.source_protocol
1✔
1766
    obj.new = True
1✔
1767

1768
    if received_at := form.pop('received_at', None):
1✔
1769
        received_at = datetime.fromisoformat(received_at)
1✔
1770

1771
    try:
1✔
1772
        return PROTOCOLS[obj.source_protocol].receive(
1✔
1773
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
1774
    except RequestException as e:
1✔
1775
        util.interpret_http_exception(e)
1✔
1776
        error(e, status=304)
1✔
1777
    except ValueError as e:
1✔
UNCOV
1778
        logger.warning(e, exc_info=True)
×
UNCOV
1779
        error(e, status=304)
×
1780

1781

1782
@cloud_tasks_only(log=None)
1✔
1783
def send_task():
1✔
1784
    """Task handler for sending an activity to a single specific destination.
1785

1786
    Calls :meth:`Protocol.send` with the form parameters.
1787

1788
    Parameters:
1789
      protocol (str): :class:`Protocol` to send to
1790
      url (str): destination URL to send to
1791
      obj_id (str): key id of :class:`models.Object` to send
1792
      orig_obj_id (str): optional, :class:`models.Object` key id of the
1793
        "original object" that this object refers to, eg replies to or reposts
1794
        or likes
1795
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1796
        this activity is from
1797
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1798
        :class:`models.Object` to handle
1799
    """
1800
    common.log_request()
1✔
1801

1802
    # prepare
1803
    form = request.form.to_dict()
1✔
1804
    url = form.get('url')
1✔
1805
    protocol = form.get('protocol')
1✔
1806
    if not url or not protocol:
1✔
1807
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1808
        return '', 204
1✔
1809

1810
    target = Target(uri=url, protocol=protocol)
1✔
1811
    obj = Object.from_request()
1✔
1812
    assert obj and obj.key and obj.key.id()
1✔
1813

1814
    PROTOCOLS[protocol].check_supported(obj)
1✔
1815
    allow_opt_out = (obj.type == 'delete')
1✔
1816

1817
    user = None
1✔
1818
    if user_key := form.get('user'):
1✔
1819
        key = ndb.Key(urlsafe=user_key)
1✔
1820
        # use get_by_id so that we follow use_instead
1821
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
1822
            key.id(), allow_opt_out=allow_opt_out)
1823

1824
    # send
1825
    delay = ''
1✔
1826
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
1827
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
1828
        delay = f'({delay_s} s behind)'
1✔
1829
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
1830
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
1831
    sent = None
1✔
1832
    try:
1✔
1833
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
1834
                                        orig_obj_id=form.get('orig_obj_id'))
1835
    except BaseException as e:
1✔
1836
        code, body = util.interpret_http_exception(e)
1✔
1837
        if not code and not body:
1✔
1838
            raise
1✔
1839

1840
    if sent is False:
1✔
1841
        logger.info(f'Failed sending!')
1✔
1842

1843
    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