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

snarfed / bridgy-fed / db371e75-8fb6-4330-8f61-3d20c4f7cd71

06 Dec 2025 05:46AM UTC coverage: 93.014% (-0.1%) from 93.115%
db371e75-8fb6-4330-8f61-3d20c4f7cd71

push

circleci

snarfed
switch models.load_user to raise RuntimeError when it can't load a user

25 of 26 new or added lines in 5 files covered. (96.15%)

29 existing lines in 4 files now uncovered.

6311 of 6785 relevant lines covered (93.01%)

0.93 hits per line

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

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

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

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

54
OBJECT_REFRESH_AGE = timedelta(days=30)
1✔
55
DELETE_TASK_DELAY = timedelta(minutes=1)
1✔
56
CREATE_MAX_AGE = timedelta(weeks=2)
1✔
57
# WARNING: keep this below the receive queue's min_backoff_seconds in queue.yaml!
58
MEMCACHE_LEASE_EXPIRATION = timedelta(seconds=25)
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
# domains to allow non-public activities from
66
NON_PUBLIC_DOMAINS = (
1✔
67
    # bridged from twitter (X). bird.makeup, kilogram.makeup, etc federate
68
    # tweets as followers-only, but they're public on twitter itself
69
    '.makeup',
70
)
71

72
DONT_STORE_AS1_TYPES = as1.CRUD_VERBS | set((
1✔
73
    'accept',
74
    'reject',
75
    'stop-following',
76
    'undo',
77
))
78
STORE_AS1_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES | as1.VERBS_WITH_OBJECT
1✔
79
                   - DONT_STORE_AS1_TYPES)
80

81
logger = logging.getLogger(__name__)
1✔
82

83

84
def error(*args, status=299, **kwargs):
1✔
85
    """Default HTTP status code to 299 to prevent retrying task."""
86
    return common.error(*args, status=status, **kwargs)
1✔
87

88

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

92

93
class Protocol:
1✔
94
    """Base protocol class. Not to be instantiated; classmethods only."""
95
    ABBREV = None
1✔
96
    """str: lower case abbreviation, used in URL paths"""
1✔
97
    PHRASE = None
1✔
98
    """str: human-readable name or phrase. Used in phrases like ``Follow this person on {PHRASE}``"""
1✔
99
    OTHER_LABELS = ()
1✔
100
    """sequence of str: label aliases"""
1✔
101
    LOGO_EMOJI = ''
1✔
102
    """str: logo emoji, if any"""
1✔
103
    LOGO_HTML = ''
1✔
104
    """str: logo ``<img>`` tag, if any"""
1✔
105
    CONTENT_TYPE = None
1✔
106
    """str: MIME type of this protocol's native data format, appropriate for the ``Content-Type`` HTTP header."""
1✔
107
    HAS_COPIES = False
1✔
108
    """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✔
109
    DEFAULT_TARGET = None
1✔
110
    """str: optional, the default target URI to send this protocol's activities to. May be used as the "shared" target. Often only set if ``HAS_COPIES`` is true."""
1✔
111
    REQUIRES_AVATAR = False
1✔
112
    """bool: whether accounts on this protocol are required to have a profile picture. If they don't, their ``User.status`` will be ``blocked``."""
1✔
113
    REQUIRES_NAME = False
1✔
114
    """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✔
115
    REQUIRES_OLD_ACCOUNT = False
1✔
116
    """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✔
117
    DEFAULT_ENABLED_PROTOCOLS = ()
1✔
118
    """sequence of str: labels of other protocols that are automatically enabled for this protocol to bridge into"""
1✔
119
    DEFAULT_SERVE_USER_PAGES = False
1✔
120
    """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✔
121
    SUPPORTED_AS1_TYPES = ()
1✔
122
    """sequence of str: AS1 objectTypes and verbs that this protocol supports receiving and sending"""
1✔
123
    SUPPORTS_DMS = False
1✔
124
    """bool: whether this protocol can receive DMs (chat messages)"""
1✔
125
    USES_OBJECT_FEED = False
1✔
126
    """bool: whether to store followers on this protocol in :attr:`Object.feed`."""
1✔
127
    HTML_PROFILES = False
1✔
128
    """bool: whether this protocol supports HTML in profile descriptions. If False, profile descriptions should be plain text."""
1✔
129
    SEND_REPLIES_TO_ORIG_POSTS_MENTIONS = False
1✔
130
    """bool: whether replies to this protocol should include the original post's mentions as delivery targets"""
1✔
131
    BOTS_FOLLOW_BACK = False
1✔
132
    """bool: when a user on this protocol follows a bot user to enable bridging, does the bot follow them back?"""
1✔
133

134
    @classmethod
1✔
135
    @property
1✔
136
    def LABEL(cls):
1✔
137
        """str: human-readable lower case name of this protocol, eg ``'activitypub``"""
138
        return cls.__name__.lower()
1✔
139

140
    @staticmethod
1✔
141
    def for_request(fed=None):
1✔
142
        """Returns the protocol for the current request.
143

144
        ...based on the request's hostname.
145

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

150
        Returns:
151
          Protocol: protocol, or None if the provided domain or request hostname
152
          domain is not a subdomain of ``brid.gy`` or isn't a known protocol
153
        """
154
        return Protocol.for_bridgy_subdomain(request.host, fed=fed)
1✔
155

156
    @staticmethod
1✔
157
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
158
        """Returns the protocol for a brid.gy subdomain.
159

160
        Args:
161
          domain_or_url (str)
162
          fed (str or protocol.Protocol): protocol to return if the current
163
            request is on ``fed.brid.gy``
164

165
        Returns:
166
          class: :class:`Protocol` subclass, or None if the provided domain or request
167
          hostname domain is not a subdomain of ``brid.gy`` or isn't a known
168
          protocol
169
        """
170
        domain = (util.domain_from_link(domain_or_url, minimize=False)
1✔
171
                  if util.is_web(domain_or_url)
172
                  else domain_or_url)
173

174
        if domain == common.PRIMARY_DOMAIN or domain in common.LOCAL_DOMAINS:
1✔
175
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
1✔
176
        elif domain and domain.endswith(common.SUPERDOMAIN):
1✔
177
            label = domain.removesuffix(common.SUPERDOMAIN)
1✔
178
            return PROTOCOLS.get(label)
1✔
179

180
    @classmethod
1✔
181
    def owns_id(cls, id):
1✔
182
        """Returns whether this protocol owns the id, or None if it's unclear.
183

184
        To be implemented by subclasses.
185

186
        IDs are string identities that uniquely identify users, and are intended
187
        primarily to be machine readable and usable. Compare to handles, which
188
        are human-chosen, human-meaningful, and often but not always unique.
189

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

194
        This should be a quick guess without expensive side effects, eg no
195
        external HTTP fetches to fetch the id itself or otherwise perform
196
        discovery.
197

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

200
        Args:
201
          id (str)
202

203
        Returns:
204
          bool or None:
205
        """
206
        return False
1✔
207

208
    @classmethod
1✔
209
    def owns_handle(cls, handle, allow_internal=False):
1✔
210
        """Returns whether this protocol owns the handle, or None if it's unclear.
211

212
        To be implemented by subclasses.
213

214
        Handles are string identities that are human-chosen, human-meaningful,
215
        and often but not always unique. Compare to IDs, which uniquely identify
216
        users, and are intended primarily to be machine readable and usable.
217

218
        Some protocols' handles are more or less deterministic based on the id
219
        format, eg ActivityPub (technically WebFinger) handles are
220
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
221
        ActivityPub, AT Protocol, or others.
222

223
        This should be a quick guess without expensive side effects, eg no
224
        external HTTP fetches to fetch the id itself or otherwise perform
225
        discovery.
226

227
        Args:
228
          handle (str)
229
          allow_internal (bool): whether to return False for internal domains
230
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
231

232
        Returns:
233
          bool or None
234
        """
235
        return False
1✔
236

237
    @classmethod
1✔
238
    def handle_to_id(cls, handle):
1✔
239
        """Converts a handle to an id.
240

241
        To be implemented by subclasses.
242

243
        May incur network requests, eg DNS queries or HTTP requests. Avoids
244
        blocked or opted out users.
245

246
        Args:
247
          handle (str)
248

249
        Returns:
250
          str: corresponding id, or None if the handle can't be found
251
        """
252
        raise NotImplementedError()
×
253

254
    @classmethod
1✔
255
    def authed_user_for_request(cls):
1✔
256
        """Returns the authenticated user id for the current request.
257

258

259
        Checks authentication on the current request, eg HTTP Signature for
260
        ActivityPub. To be implemented by subclasses.
261

262
        Returns:
263
          str: authenticated user id, or None if there is no authentication
264

265
        Raises:
266
          RuntimeError: if the request's authentication (eg signature) is
267
          invalid or otherwise can't be verified
268
        """
269
        return None
1✔
270

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

275
        If called via `Protocol.key_for`, infers the appropriate protocol with
276
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
277
        as is.
278

279
        Args:
280
          id (str):
281
          allow_opt_out (bool): whether to allow users who are currently opted out
282

283
        Returns:
284
          google.cloud.ndb.Key: matching key, or None if the given id is not a
285
          valid :class:`User` id for this protocol.
286
        """
287
        if cls == Protocol:
1✔
288
            proto = Protocol.for_id(id)
1✔
289
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
290

291
        # load user so that we follow use_instead
292
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
293
        if existing:
1✔
294
            if existing.status and not allow_opt_out:
1✔
295
                return None
1✔
296
            return existing.key
1✔
297

298
        return cls(id=id).key
1✔
299

300
    @staticmethod
1✔
301
    def _for_id_memcache_key(id, remote=None):
1✔
302
        """If id is a URL, uses its domain, otherwise returns None.
303

304
        Args:
305
          id (str)
306

307
        Returns:
308
          (str domain, bool remote) or None
309
        """
310
        domain = util.domain_from_link(id)
1✔
311
        if domain in PROTOCOL_DOMAINS:
1✔
312
            return id
1✔
313
        elif remote and util.is_web(id):
1✔
314
            return domain
1✔
315

316
    @cached(LRUCache(20000), lock=Lock())
1✔
317
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote=True: remote,
1✔
318
                      version=3)
319
    @staticmethod
1✔
320
    def for_id(id, remote=True):
1✔
321
        """Returns the protocol for a given id.
322

323
        Args:
324
          id (str)
325
          remote (bool): whether to perform expensive side effects like fetching
326
            the id itself over the network, or other discovery.
327

328
        Returns:
329
          Protocol subclass: matching protocol, or None if no single known
330
          protocol definitively owns this id
331
        """
332
        logger.debug(f'Determining protocol for id {id}')
1✔
333
        if not id:
1✔
334
            return None
1✔
335

336
        # remove our synthetic id fragment, if any
337
        #
338
        # will this eventually cause false positives for other services that
339
        # include our full ids inside their own ids, non-URL-encoded? guess
340
        # we'll figure that out if/when it happens.
341
        id = id.partition('#bridgy-fed-')[0]
1✔
342
        if not id:
1✔
343
            return None
1✔
344

345
        if util.is_web(id):
1✔
346
            # step 1: check for our per-protocol subdomains
347
            try:
1✔
348
                parsed = urlparse(id)
1✔
349
            except ValueError as e:
1✔
350
                logger.info(f'urlparse ValueError: {e}')
1✔
351
                return None
1✔
352

353
            is_homepage = parsed.path.strip('/') == ''
1✔
354
            is_internal = parsed.path.startswith(ids.INTERNAL_PATH_PREFIX)
1✔
355
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
356
            if by_subdomain and not (is_homepage or is_internal
1✔
357
                                     or id in ids.BOT_ACTOR_AP_IDS):
358
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
359
                return by_subdomain
1✔
360

361
        # step 2: check if any Protocols say conclusively that they own it
362
        # sort to be deterministic
363
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
364
                           key=lambda p: p.LABEL)
365
        candidates = []
1✔
366
        for protocol in protocols:
1✔
367
            owns = protocol.owns_id(id)
1✔
368
            if owns:
1✔
369
                logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
370
                return protocol
1✔
371
            elif owns is not False:
1✔
372
                candidates.append(protocol)
1✔
373

374
        if len(candidates) == 1:
1✔
375
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
376
            return candidates[0]
1✔
377

378
        # step 3: look for existing Objects in the datastore
379
        #
380
        # note that we don't currently see if this is a copy id because I have FUD
381
        # over which Protocol for_id should return in that case...and also because a
382
        # protocol may already say definitively above that it owns the id, eg ATProto
383
        # with DIDs and at:// URIs.
384
        obj = Protocol.load(id, remote=False)
1✔
385
        if obj and obj.source_protocol:
1✔
386
            logger.debug(f'  {obj.key.id()} owned by source_protocol {obj.source_protocol}')
1✔
387
            return PROTOCOLS[obj.source_protocol]
1✔
388

389
        # step 4: fetch over the network, if necessary
390
        if not remote:
1✔
391
            return None
1✔
392

393
        for protocol in candidates:
1✔
394
            logger.debug(f'Trying {protocol.LABEL}')
1✔
395
            try:
1✔
396
                obj = protocol.load(id, local=False, remote=True)
1✔
397

398
                if protocol.ABBREV == 'web':
1✔
399
                    # for web, if we fetch and get HTML without microformats,
400
                    # load returns False but the object will be stored in the
401
                    # datastore with source_protocol web, and in cache. load it
402
                    # again manually to check for that.
403
                    obj = Object.get_by_id(id)
1✔
404
                    if obj and obj.source_protocol != 'web':
1✔
405
                        obj = None
×
406

407
                if obj:
1✔
408
                    logger.debug(f'  {protocol.LABEL} owns id {id}')
1✔
409
                    return protocol
1✔
410
            except BadGateway:
1✔
411
                # we tried and failed fetching the id over the network.
412
                # this depends on ActivityPub.fetch raising this!
413
                return None
1✔
414
            except HTTPException as e:
×
415
                # internal error we generated ourselves; try next protocol
416
                pass
×
417
            except Exception as e:
×
418
                code, _ = util.interpret_http_exception(e)
×
419
                if code:
×
420
                    # we tried and failed fetching the id over the network
421
                    return None
×
422
                raise
×
423

424
        logger.info(f'No matching protocol found for {id} !')
1✔
425
        return None
1✔
426

427
    @cached(LRUCache(20000), lock=Lock())
1✔
428
    @staticmethod
1✔
429
    def for_handle(handle):
1✔
430
        """Returns the protocol for a given handle.
431

432
        May incur expensive side effects like resolving the handle itself over
433
        the network or other discovery.
434

435
        Args:
436
          handle (str)
437

438
        Returns:
439
          (Protocol subclass, str) tuple: matching protocol and optional id (if
440
          resolved), or ``(None, None)`` if no known protocol owns this handle
441
        """
442
        # TODO: normalize, eg convert domains to lower case
443
        logger.debug(f'Determining protocol for handle {handle}')
1✔
444
        if not handle:
1✔
445
            return (None, None)
1✔
446

447
        # step 1: check if any Protocols say conclusively that they own it.
448
        # sort to be deterministic.
449
        protocols = sorted(set(p for p in PROTOCOLS.values() if p),
1✔
450
                           key=lambda p: p.LABEL)
451
        candidates = []
1✔
452
        for proto in protocols:
1✔
453
            owns = proto.owns_handle(handle)
1✔
454
            if owns:
1✔
455
                logger.debug(f'  {proto.LABEL} owns handle {handle}')
1✔
456
                return (proto, None)
1✔
457
            elif owns is not False:
1✔
458
                candidates.append(proto)
1✔
459

460
        if len(candidates) == 1:
1✔
UNCOV
461
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
UNCOV
462
            return (candidates[0], None)
×
463

464
        # step 2: look for matching User in the datastore
465
        for proto in candidates:
1✔
466
            user = proto.query(proto.handle == handle).get()
1✔
467
            if user:
1✔
468
                if user.status:
1✔
469
                    return (None, None)
1✔
470
                logger.debug(f'  user {user.key} handle {handle}')
1✔
471
                return (proto, user.key.id())
1✔
472

473
        # step 3: resolve handle to id
474
        for proto in candidates:
1✔
475
            id = proto.handle_to_id(handle)
1✔
476
            if id:
1✔
477
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
478
                return (proto, id)
1✔
479

480
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
481
        return (None, None)
1✔
482

483
    @classmethod
1✔
484
    def is_user_at_domain(cls, handle, allow_internal=False):
1✔
485
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
486

487
        Example: ``@user@instance.com``
488

489
        Args:
490
          handle (str)
491
          allow_internal (bool): whether the domain can be a Bridgy Fed domain
492
        """
493
        parts = handle.split('@')
1✔
494
        if len(parts) != 2:
1✔
495
            return False
1✔
496

497
        user, domain = parts
1✔
498
        return bool(user and domain
1✔
499
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
500

501
    @classmethod
1✔
502
    def bridged_web_url_for(cls, user, fallback=False):
1✔
503
        """Returns the web URL for a user's bridged profile in this protocol.
504

505
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
506
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
507

508
        Args:
509
          user (models.User)
510
          fallback (bool): if True, and bridged users have no canonical user
511
            profile URL in this protocol, return the native protocol's profile URL
512

513
        Returns:
514
          str, or None if there isn't a canonical URL
515
        """
516
        if fallback:
1✔
517
            return user.web_url()
1✔
518

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

523
        Args:
524
          obj (models.Object)
525
          allow_opt_out (bool): whether to return a user key if they're opted out
526

527
        Returns:
528
          google.cloud.ndb.key.Key or None:
529
        """
530
        owner = as1.get_owner(obj.as1)
1✔
531
        if owner:
1✔
532
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
533

534
    @classmethod
1✔
535
    def bot_user_id(cls):
1✔
536
        """Returns the Web user id for the bot user for this protocol.
537

538
        For example, ``'bsky.brid.gy'`` for ATProto.
539

540
        Returns:
541
          str:
542
        """
543
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
544

545
    @classmethod
1✔
546
    def create_for(cls, user):
1✔
547
        """Creates or re-activate a copy user in this protocol.
548

549
        Should add the copy user to :attr:`copies`.
550

551
        If the copy user already exists and active, should do nothing.
552

553
        Args:
554
          user (models.User): original source user. Shouldn't already have a
555
            copy user for this protocol in :attr:`copies`.
556

557
        Raises:
558
          ValueError: if we can't create a copy of the given user in this protocol
559
        """
560
        raise NotImplementedError()
×
561

562
    @classmethod
1✔
563
    def send(to_cls, obj, target, from_user=None, orig_obj_id=None):
1✔
564
        """Sends an outgoing activity.
565

566
        To be implemented by subclasses. Should call
567
        ``to_cls.translate_ids(obj.as1)`` before converting it to this Protocol's
568
        format.
569

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

574
        Args:
575
          obj (models.Object): with activity to send
576
          target (str): destination URL to send to
577
          from_user (models.User): user (actor) this activity is from
578
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
579
            that this object refers to, eg replies to or reposts or likes
580

581
        Returns:
582
          bool: True if the activity is sent successfully, False if it is
583
          ignored or otherwise unsent due to protocol logic, eg no webmention
584
          endpoint, protocol doesn't support the activity type. (Failures are
585
          raised as exceptions.)
586

587
        Raises:
588
          werkzeug.HTTPException if the request fails
589
        """
590
        raise NotImplementedError()
×
591

592
    @classmethod
1✔
593
    def fetch(cls, obj, **kwargs):
1✔
594
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
595

596
        Errors are raised as exceptions. If this method returns False, the fetch
597
        didn't fail but didn't succeed either, eg the id isn't valid for this
598
        protocol, or the fetch didn't return valid data for this protocol.
599

600
        To be implemented by subclasses.
601

602
        Args:
603
          obj (models.Object): with the id to fetch. Data is filled into one of
604
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
605
          kwargs: subclass-specific
606

607
        Returns:
608
          bool: True if the object was fetched and populated successfully,
609
          False otherwise
610

611
        Raises:
612
          requests.RequestException, werkzeug.HTTPException,
613
          websockets.WebSocketException, etc: if the fetch fails
614
        """
615
        raise NotImplementedError()
×
616

617
    @classmethod
1✔
618
    def convert(cls, obj, from_user=None, **kwargs):
1✔
619
        """Converts an :class:`Object` to this protocol's data format.
620

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

624
        Just passes through to :meth:`_convert`, then does minor
625
        protocol-independent postprocessing.
626

627
        Args:
628
          obj (models.Object):
629
          from_user (models.User): user (actor) this activity/object is from
630
          kwargs: protocol-specific, passed through to :meth:`_convert`
631

632
        Returns:
633
          converted object in the protocol's native format, often a dict
634
        """
635
        if not obj or not obj.as1:
1✔
636
            return {}
1✔
637

638
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
639
        is_crud = obj.as1.get('verb') in as1.CRUD_VERBS
1✔
640
        base_obj = as1.get_object(obj.as1) if is_crud else obj.as1
1✔
641
        orig_our_as1 = obj.our_as1
1✔
642

643
        # mark bridged actors as bots and add "bridged by Bridgy Fed" to their bios
644
        if (from_user and base_obj
1✔
645
            and base_obj.get('objectType') in as1.ACTOR_TYPES
646
            and PROTOCOLS.get(obj.source_protocol) != cls
647
            and Protocol.for_bridgy_subdomain(id) not in DOMAINS
648
            # Web users are special cased, they don't get the label if they've
649
            # explicitly enabled Bridgy Fed with redirects or webmentions
650
            and not (from_user.LABEL == 'web'
651
                     and (from_user.last_webmention_in or from_user.has_redirects))):
652
            cls.add_source_links(obj=obj, from_user=from_user)
1✔
653

654
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
655
        obj.our_as1 = orig_our_as1
1✔
656
        return converted
1✔
657

658
    @classmethod
1✔
659
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
660
        """Converts an :class:`Object` to this protocol's data format.
661

662
        To be implemented by subclasses. Implementations should generally call
663
        :meth:`Protocol.translate_ids` (as their own class) before converting to
664
        their format.
665

666
        Args:
667
          obj (models.Object):
668
          from_user (models.User): user (actor) this activity/object is from
669
          kwargs: protocol-specific
670

671
        Returns:
672
          converted object in the protocol's native format, often a dict. May
673
            return the ``{}`` empty dict if the object can't be converted.
674
        """
675
        raise NotImplementedError()
×
676

677
    @classmethod
1✔
678
    def add_source_links(cls, obj, from_user):
1✔
679
        """Adds "bridged from ... by Bridgy Fed" to the user's actor's ``summary``.
680

681
        Uses HTML for protocols that support it, plain text otherwise.
682

683
        Args:
684
          cls (Protocol subclass): protocol that the user is bridging into
685
          obj (models.Object): user's actor/profile object
686
          from_user (models.User): user (actor) this activity/object is from
687
        """
688
        assert obj and obj.as1
1✔
689
        assert from_user
1✔
690

691
        obj.our_as1 = copy.deepcopy(obj.as1)
1✔
692
        actor = (as1.get_object(obj.as1) if obj.type in as1.CRUD_VERBS
1✔
693
                 else obj.as1)
694
        actor['objectType'] = 'person'
1✔
695

696
        orig_summary = actor.setdefault('summary', '')
1✔
697
        summary_text = html_to_text(orig_summary, ignore_links=True)
1✔
698

699
        # Check if we've already added source links
700
        if '🌉 bridged' in summary_text:
1✔
701
            return
1✔
702

703
        actor_id = actor.get('id')
1✔
704

705
        url = (as1.get_url(actor)
1✔
706
               or (from_user.web_url() if from_user.profile_id() == actor_id
707
                   else actor_id))
708

709
        from web import Web
1✔
710
        bot_user = Web.get_by_id(from_user.bot_user_id())
1✔
711

712
        if cls.HTML_PROFILES:
1✔
713
            if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS:
1✔
714
                mention = bot_user.user_link(proto=cls, name=False, handle='short')
1✔
715
                suffix = f', follow {mention} to interact'
1✔
716
            else:
717
                suffix = f' by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>'
1✔
718

719
            separator = '<br><br>'
1✔
720

721
            is_user = from_user.key and actor_id in (from_user.key.id(),
1✔
722
                                                     from_user.profile_id())
723
            if is_user:
1✔
724
                bridged = f'🌉 <a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a>'
1✔
725
                from_ = f'<a href="{from_user.web_url()}">{from_user.handle}</a>'
1✔
726
            else:
727
                bridged = '🌉 bridged'
1✔
728
                from_ = util.pretty_link(url) if url else '?'
1✔
729

730
        else:  # plain text
731
            # TODO: unify with above. which is right?
732
            id = obj.key.id() if obj.key else obj.our_as1.get('id')
1✔
733
            is_user = from_user.key and id in (from_user.key.id(),
1✔
734
                                               from_user.profile_id())
735
            from_ = (from_user.web_url() if is_user else url) or '?'
1✔
736

737
            bridged = '🌉 bridged'
1✔
738
            suffix = (
1✔
739
                f': https://{PRIMARY_DOMAIN}{from_user.user_page_path()}'
740
                # link web users to their user pages
741
                if from_user.LABEL == 'web'
742
                else f', follow @{bot_user.handle_as(cls)} to interact'
743
                if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS
744
                else f' by https://{PRIMARY_DOMAIN}/')
745
            separator = '\n\n'
1✔
746
            orig_summary = summary_text
1✔
747

748
        logo = f'{from_user.LOGO_EMOJI} ' if from_user.LOGO_EMOJI else ''
1✔
749
        source_links = f'{separator if orig_summary else ""}{bridged} from {logo}{from_}{suffix}'
1✔
750
        actor['summary'] = orig_summary + source_links
1✔
751

752
    @classmethod
1✔
753
    def set_username(to_cls, user, username):
1✔
754
        """Sets a custom username for a user's bridged account in this protocol.
755

756
        Args:
757
          user (models.User)
758
          username (str)
759

760
        Raises:
761
          ValueError: if the username is invalid
762
          RuntimeError: if the username could not be set
763
        """
764
        raise NotImplementedError()
1✔
765

766
    @classmethod
1✔
767
    def migrate_out(cls, user, to_user_id):
1✔
768
        """Migrates a bridged account out to be a native account.
769

770
        Args:
771
          user (models.User)
772
          to_user_id (str)
773

774
        Raises:
775
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
776
            ``user`` is on this protocol or not bridged to this protocol
777
        """
778
        raise NotImplementedError()
×
779

780
    @classmethod
1✔
781
    def check_can_migrate_out(cls, user, to_user_id):
1✔
782
        """Raises an exception if a user can't yet migrate to a native account.
783

784
        For example, if ``to_user_id`` isn't on this protocol, or if ``user`` is on
785
        this protocol, or isn't bridged to this protocol.
786

787
        If the user is ready to migrate, returns ``None``.
788

789
        Subclasses may override this to add more criteria, but they should call this
790
        implementation first.
791

792
        Args:
793
          user (models.User)
794
          to_user_id (str)
795

796
        Raises:
797
          ValueError: if ``user`` isn't ready to migrate to this protocol yet
798
        """
799
        def _error(msg):
1✔
800
            logger.warning(msg)
1✔
801
            raise ValueError(msg)
1✔
802

803
        if cls.owns_id(to_user_id) is False:
1✔
804
            _error(f"{to_user_id} doesn't look like an {cls.LABEL} id")
1✔
805
        elif isinstance(user, cls):
1✔
806
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
807
        elif not user.is_enabled(cls):
1✔
808
            _error(f"{user.handle_or_id()} isn't currently bridged to {cls.PHRASE}")
1✔
809

810
    @classmethod
1✔
811
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
812
        """Migrates a native account in to be a bridged account.
813

814
        The protocol independent parts are done here; protocol-specific parts are
815
        done in :meth:`_migrate_in`, which this wraps.
816

817
        Reloads the user's profile before calling :meth:`_migrate_in`.
818

819
        Args:
820
          user (models.User): native user on another protocol to attach the
821
            newly imported bridged account to
822
          from_user_id (str)
823
          kwargs: additional protocol-specific parameters
824

825
        Raises:
826
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
827
            ``user`` is on this protocol or already bridged to this protocol
828
        """
829
        def _error(msg):
1✔
830
            logger.warning(msg)
1✔
831
            raise ValueError(msg)
1✔
832

833
        logger.info(f"Migrating in {from_user_id} for {user.key.id()}")
1✔
834

835
        # check req'ts
836
        if cls.owns_id(from_user_id) is False:
1✔
837
            _error(f"{from_user_id} doesn't look like an {cls.LABEL} id")
1✔
838
        elif isinstance(user, cls):
1✔
839
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
840
        elif cls.HAS_COPIES and cls.LABEL in user.enabled_protocols:
1✔
841
            _error(f"{user.handle_or_id()} is already bridged to {cls.PHRASE}")
1✔
842

843
        # reload profile
844
        try:
1✔
845
            user.reload_profile()
1✔
846
        except (RequestException, HTTPException) as e:
×
847
            _, msg = util.interpret_http_exception(e)
×
848

849
        # migrate!
850
        cls._migrate_in(user, from_user_id, **kwargs)
1✔
851
        user.add('enabled_protocols', cls.LABEL)
1✔
852
        user.put()
1✔
853

854
        # attach profile object
855
        if user.obj:
1✔
856
            if cls.HAS_COPIES:
1✔
857
                profile_id = ids.profile_id(id=from_user_id, proto=cls)
1✔
858
                user.obj.remove_copies_on(cls)
1✔
859
                user.obj.add('copies', Target(uri=profile_id, protocol=cls.LABEL))
1✔
860
                user.obj.put()
1✔
861

862
            common.create_task(queue='receive', obj_id=user.obj_key.id(),
1✔
863
                               authed_as=user.key.id())
864

865
    @classmethod
1✔
866
    def _migrate_in(cls, user, from_user_id, **kwargs):
1✔
867
        """Protocol-specific parts of migrating in external account.
868

869
        Called by :meth:`migrate_in`, which does most of the work, including calling
870
        :meth:`reload_profile` before this.
871

872
        Args:
873
          user (models.User): native user on another protocol to attach the
874
            newly imported account to. Unused.
875
          from_user_id (str): DID of the account to be migrated in
876
          kwargs: protocol dependent
877
        """
878
        raise NotImplementedError()
×
879

880
    @classmethod
1✔
881
    def target_for(cls, obj, shared=False):
1✔
882
        """Returns an :class:`Object`'s delivery target (endpoint).
883

884
        To be implemented by subclasses.
885

886
        Examples:
887

888
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
889
          webmention target.
890
        * If obj is an ``activitypub`` actor, returns its inbox.
891
        * If obj is an ``activitypub`` object, returns it's author's or actor's
892
          inbox.
893

894
        Args:
895
          obj (models.Object):
896
          shared (bool): optional. If True, returns a common/shared
897
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
898
            multiple recipients for efficiency
899

900
        Returns:
901
          str: target endpoint, or None if not available.
902
        """
903
        raise NotImplementedError()
×
904

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

909
        Default implementation here, subclasses may override.
910

911
        Args:
912
          url (str):
913
          allow_internal (bool): whether to return False for internal domains
914
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
915
        """
916
        blocklist = DOMAIN_BLOCKLIST
1✔
917
        if not DEBUG:
1✔
918
            blocklist += tuple(util.RESERVED_TLDS | util.LOCAL_TLDS)
1✔
919
        if not allow_internal:
1✔
920
            blocklist += DOMAINS
1✔
921
        return util.domain_or_parent_in(url, blocklist)
1✔
922

923
    @classmethod
1✔
924
    def translate_ids(to_cls, obj):
1✔
925
        """Translates all ids in an AS1 object to a specific protocol.
926

927
        Infers source protocol for each id value separately.
928

929
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
930
        ``at://did:plc:abc/coll/123`` will be converted to
931
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
932

933
        Wraps these AS1 fields:
934

935
        * ``id``
936
        * ``actor``
937
        * ``author``
938
        * ``bcc``
939
        * ``bto``
940
        * ``cc``
941
        * ``featured[].items``, ``featured[].orderedItems``
942
        * ``object``
943
        * ``object.actor``
944
        * ``object.author``
945
        * ``object.id``
946
        * ``object.inReplyTo``
947
        * ``object.object``
948
        * ``attachments[].id``
949
        * ``tags[objectType=mention].url``
950
        * ``to``
951

952
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
953
        same logic is duplicated there!
954

955
        TODO: unify with :meth:`Object.resolve_ids`,
956
        :meth:`models.Object.normalize_ids`.
957

958
        Args:
959
          to_proto (Protocol subclass)
960
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
961

962
        Returns:
963
          dict: translated AS1 version of ``obj``
964
        """
965
        assert to_cls != Protocol
1✔
966
        if not obj:
1✔
967
            return obj
1✔
968

969
        outer_obj = to_cls.translate_mention_handles(copy.deepcopy(obj))
1✔
970
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
971

972
        def translate(elem, field, fn, uri=False):
1✔
973
            elem[field] = as1.get_objects(elem, field)
1✔
974
            for obj in elem[field]:
1✔
975
                if id := obj.get('id'):
1✔
976
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
977
                        continue
1✔
978
                    from_cls = Protocol.for_id(id)
1✔
979
                    # TODO: what if from_cls is None? relax translate_object_id,
980
                    # make it a noop if we don't know enough about from/to?
981
                    if from_cls and from_cls != to_cls:
1✔
982
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
983
                    if obj['id'] and uri:
1✔
984
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
985

986
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
987
                           for o in elem[field]]
988

989
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
990
                elem[field] = elem[field][0]
1✔
991

992
        type = as1.object_type(outer_obj)
1✔
993
        translate(outer_obj, 'id',
1✔
994
                  ids.translate_user_id if type in as1.ACTOR_TYPES
995
                  else ids.translate_object_id)
996

997
        for o in inner_objs:
1✔
998
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
999
                        or as1.get_owner(outer_obj) == o.get('id')
1000
                        or type in ('follow', 'stop-following'))
1001
            translate(o, 'id', (ids.translate_user_id if is_actor
1✔
1002
                                else ids.translate_object_id))
1003
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
1004
            translate(o, 'object', (ids.translate_user_id if obj_is_actor
1✔
1005
                                    else ids.translate_object_id))
1006

1007
        for o in [outer_obj] + inner_objs:
1✔
1008
            translate(o, 'inReplyTo', ids.translate_object_id)
1✔
1009
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
1010
                translate(o, field, ids.translate_user_id)
1✔
1011
            for tag in as1.get_objects(o, 'tags'):
1✔
1012
                if tag.get('objectType') == 'mention':
1✔
1013
                    translate(tag, 'url', ids.translate_user_id, uri=True)
1✔
1014
            for att in as1.get_objects(o, 'attachments'):
1✔
1015
                translate(att, 'id', ids.translate_object_id)
1✔
1016
                url = att.get('url')
1✔
1017
                if url and not att.get('id'):
1✔
1018
                    if from_cls := Protocol.for_id(url):
1✔
1019
                        att['id'] = ids.translate_object_id(from_=from_cls, to=to_cls,
1✔
1020
                                                            id=url)
1021
            if feat := as1.get_object(o, 'featured'):
1✔
1022
                translate(feat, 'orderedItems', ids.translate_object_id)
1✔
1023
                translate(feat, 'items', ids.translate_object_id)
1✔
1024

1025
        outer_obj = util.trim_nulls(outer_obj)
1✔
1026

1027
        if objs := util.get_list(outer_obj ,'object'):
1✔
1028
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
1029
            if len(outer_obj['object']) == 1:
1✔
1030
                outer_obj['object'] = outer_obj['object'][0]
1✔
1031

1032
        return outer_obj
1✔
1033

1034
    @classmethod
1✔
1035
    def translate_mention_handles(cls, obj):
1✔
1036
        """Translates @-mentions in ``obj.content`` to this protocol's handles.
1037

1038
        Specifically, for each ``mention`` tag in the object's tags that has
1039
        ``startIndex`` and ``length``, replaces it in ``obj.content`` with that
1040
        user's translated handle in this protocol and updates the tag's location.
1041

1042
        Called by :meth:`Protocol.translate_ids`.
1043

1044
        If ``obj.content`` is HTML, does nothing.
1045

1046
        Args:
1047
          obj (dict): AS2 object
1048

1049
        Returns:
1050
          dict: modified AS2 object
1051
        """
1052
        if not obj:
1✔
1053
            return None
×
1054

1055
        obj = copy.deepcopy(obj)
1✔
1056
        obj['object'] = [cls.translate_mention_handles(o)
1✔
1057
                                for o in as1.get_objects(obj)]
1058
        if len(obj['object']) == 1:
1✔
1059
            obj['object'] = obj['object'][0]
1✔
1060

1061
        content = obj.get('content')
1✔
1062
        tags = obj.get('tags')
1✔
1063
        if (not content or not tags
1✔
1064
                or obj.get('content_is_html')
1065
                or bool(BeautifulSoup(content, 'html.parser').find())
1066
                or HTML_ENTITY_RE.search(content)):
1067
            return util.trim_nulls(obj)
1✔
1068

1069
        indexed = [tag for tag in tags if tag.get('startIndex') and tag.get('length')]
1✔
1070

1071
        offset = 0
1✔
1072
        for tag in sorted(indexed, key=lambda t: t['startIndex']):
1✔
1073
            tag['startIndex'] += offset
1✔
1074
            if tag.get('objectType') == 'mention' and (id := tag['url']):
1✔
1075
                if proto := Protocol.for_id(id):
1✔
1076
                    id = ids.normalize_user_id(id=id, proto=proto)
1✔
1077
                    if key := get_original_user_key(id):
1✔
1078
                        user = key.get()
×
1079
                    else:
1080
                        user = proto.get_or_create(id, allow_opt_out=True)
1✔
1081
                    if user:
1✔
1082
                        start = tag['startIndex']
1✔
1083
                        end = start + tag['length']
1✔
1084
                        if handle := user.handle_as(cls):
1✔
1085
                            content = content[:start] + handle + content[end:]
1✔
1086
                            offset += len(handle) - tag['length']
1✔
1087
                            tag.update({
1✔
1088
                                'displayName': handle,
1089
                                'length': len(handle),
1090
                            })
1091

1092
        obj['tags'] = tags
1✔
1093
        as2.set_content(obj, content)  # sets content *and* contentMap
1✔
1094
        return util.trim_nulls(obj)
1✔
1095

1096
    @classmethod
1✔
1097
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
1098
        """Handles an incoming activity.
1099

1100
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
1101
        unset, returns HTTP 299.
1102

1103
        Args:
1104
          obj (models.Object)
1105
          authed_as (str): authenticated actor id who sent this activity
1106
          internal (bool): whether to allow activity ids on internal domains,
1107
            from opted out/blocked users, etc.
1108
          received_at (datetime): when we first saw (received) this activity.
1109
            Right now only used for monitoring.
1110

1111
        Returns:
1112
          (str, int) tuple: (response body, HTTP status code) Flask response
1113

1114
        Raises:
1115
          werkzeug.HTTPException: if the request is invalid
1116
        """
1117
        # check some invariants
1118
        assert from_cls != Protocol
1✔
1119
        assert isinstance(obj, Object), obj
1✔
1120

1121
        if not obj.as1:
1✔
1122
            error('No object data provided')
1✔
1123

1124
        orig_obj = obj
1✔
1125
        id = None
1✔
1126
        if obj.key and obj.key.id():
1✔
1127
            id = obj.key.id()
1✔
1128

1129
        if not id:
1✔
1130
            id = obj.as1.get('id')
1✔
1131
            obj.key = ndb.Key(Object, id)
1✔
1132

1133
        if not id:
1✔
1134
            error('No id provided')
×
1135
        elif from_cls.owns_id(id) is False:
1✔
1136
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
1137
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
1138
            error(f'Activity {id} is blocklisted')
1✔
1139

1140
        # does this protocol support this activity/object type?
1141
        from_cls.check_supported(obj, 'receive')
1✔
1142

1143
        # lease this object, atomically
1144
        memcache_key = activity_id_memcache_key(id)
1✔
1145
        leased = memcache.memcache.add(
1✔
1146
            memcache_key, 'leased', noreply=False,
1147
            expire=int(MEMCACHE_LEASE_EXPIRATION.total_seconds()))
1148

1149
        # short circuit if we've already seen this activity id.
1150
        # (don't do this for bare objects since we need to check further down
1151
        # whether they've been updated since we saw them last.)
1152
        if (obj.as1.get('objectType') == 'activity'
1✔
1153
            and 'force' not in request.values
1154
            and (not leased
1155
                 or (obj.new is False and obj.changed is False))):
1156
            error(f'Already seen this activity {id}', status=204)
1✔
1157

1158
        pruned = {k: v for k, v in obj.as1.items()
1✔
1159
                  if k not in ('contentMap', 'replies', 'signature')}
1160
        delay = ''
1✔
1161
        retry = request.headers.get('X-AppEngine-TaskRetryCount')
1✔
1162
        if (received_at and retry in (None, '0')
1✔
1163
                and obj.type not in ('delete', 'undo')):  # we delay deletes/undos
1164
            delay_s = int((util.now().replace(tzinfo=None)
1✔
1165
                           - received_at.replace(tzinfo=None)
1166
                           ).total_seconds())
1167
            delay = f'({delay_s} s behind)'
1✔
1168
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
1169

1170
        # check authorization
1171
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1172
        actor = as1.get_owner(obj.as1)
1✔
1173
        if not actor:
1✔
1174
            error('Activity missing actor or author')
1✔
1175
        elif from_cls.owns_id(actor) is False:
1✔
1176
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
1177

1178
        assert authed_as
1✔
1179
        assert isinstance(authed_as, str)
1✔
1180
        authed_as = ids.normalize_user_id(id=authed_as, proto=from_cls)
1✔
1181
        actor = ids.normalize_user_id(id=actor, proto=from_cls)
1✔
1182
        if actor != authed_as:
1✔
1183
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
1184
                         user=f'{id} authed_as {authed_as} owner {actor}')
1185
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
1186

1187
        # update copy ids to originals
1188
        obj.normalize_ids()
1✔
1189
        obj.resolve_ids()
1✔
1190

1191
        if (obj.type == 'follow'
1✔
1192
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
1193
            # follows of bot user; refresh user profile first
1194
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
1195
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1196
            from_user.reload_profile()
1✔
1197
        else:
1198
            # load actor user
1199
            #
1200
            # TODO: we should maybe eventually allow non-None status users here if
1201
            # this is a profile update, so that we store the user again below and
1202
            # re-calculate its status. right now, if a bridged user updates their
1203
            # profile and invalidates themselves, eg by removing their profile
1204
            # picture, and then updates again to make themselves valid again, we'll
1205
            # ignore the second update. they'll have to un-bridge and re-bridge
1206
            # themselves to get back working again.
1207
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
1208

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

1212
        # check if this is a profile object coming in via a user with use_instead
1213
        # set. if so, override the object's id to be the final user id (from_user's),
1214
        # after following use_instead.
1215
        if obj.type in as1.ACTOR_TYPES and from_user.key.id() != actor:
1✔
1216
            as1_id = obj.as1.get('id')
1✔
1217
            if ids.normalize_user_id(id=as1_id, proto=from_user) == actor:
1✔
1218
                logger.info(f'Overriding AS1 object id {as1_id} with Object id {from_user.profile_id()}')
1✔
1219
                obj.our_as1 = {**obj.as1, 'id': from_user.profile_id()}
1✔
1220

1221
        # if this is an object, ie not an activity, wrap it in a create or update
1222
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as,
1✔
1223
                                          from_user=from_user)
1224
        obj.add('users', from_user.key)
1✔
1225

1226
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1227
        inner_obj_id = inner_obj_as1.get('id')
1✔
1228
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1229
            if not inner_obj_id:
1✔
1230
                error(f'{obj.type} object has no id!')
1✔
1231

1232
        # check age. we support backdated posts, but if they're over 2w old, we
1233
        # don't deliver them
1234
        if obj.type == 'post':
1✔
1235
            if published := inner_obj_as1.get('published'):
1✔
1236
                try:
1✔
1237
                    published_dt = util.parse_iso8601(published)
1✔
1238
                    if not published_dt.tzinfo:
1✔
1239
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
1240
                    age = util.now() - published_dt
1✔
1241
                    if age > CREATE_MAX_AGE and 'force' not in request.values:
1✔
1242
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
1243
                              status=204)
1244
                except ValueError:  # from parse_iso8601
×
1245
                    logger.debug(f"Couldn't parse published {published}")
×
1246

1247
        # write Object to datastore
1248
        obj.source_protocol = from_cls.LABEL
1✔
1249
        if obj.type in STORE_AS1_TYPES:
1✔
1250
            obj.put()
1✔
1251

1252
        # store inner object
1253
        # TODO: unify with big obj.type conditional below. would have to merge
1254
        # this with the DM handling block lower down.
1255
        crud_obj = None
1✔
1256
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1257
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
1258
                                            source_protocol=from_cls.LABEL,
1259
                                            authed_as=actor, users=[from_user.key],
1260
                                            deleted=False)
1261

1262
        actor = as1.get_object(obj.as1, 'actor')
1✔
1263
        actor_id = actor.get('id')
1✔
1264

1265
        # handle activity!
1266
        if obj.type == 'stop-following':
1✔
1267
            # TODO: unify with handle_follow?
1268
            # TODO: handle multiple followees
1269
            if not actor_id or not inner_obj_id:
1✔
1270
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1271

1272
            # deactivate Follower
1273
            from_ = from_cls.key_for(actor_id)
1✔
1274
            if not (to_cls := Protocol.for_id(inner_obj_id)):
1✔
1275
                error(f"Can't determine protocol for {inner_obj_id} , giving up")
1✔
1276
            to = to_cls.key_for(inner_obj_id)
1✔
1277
            follower = Follower.query(Follower.to == to,
1✔
1278
                                      Follower.from_ == from_,
1279
                                      Follower.status == 'active').get()
1280
            if follower:
1✔
1281
                logger.info(f'Marking {follower} inactive')
1✔
1282
                follower.status = 'inactive'
1✔
1283
                follower.put()
1✔
1284
            else:
1285
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1286

1287
            # fall through to deliver to followee
1288
            # TODO: do we convert stop-following to webmention 410 of original
1289
            # follow?
1290

1291
            # fall through to deliver to followers
1292

1293
        elif obj.type in ('delete', 'undo'):
1✔
1294
            delete_obj_id = (from_user.profile_id()
1✔
1295
                            if inner_obj_id == from_user.key.id()
1296
                            else inner_obj_id)
1297

1298
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1299
            if not delete_obj:
1✔
1300
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1301
                return 'OK', 204
1✔
1302

1303
            # TODO: just delete altogether!
1304
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1305
            delete_obj.deleted = True
1✔
1306
            delete_obj.put()
1✔
1307

1308
            # if this is an actor, handle deleting it later so that
1309
            # in case it's from_user, user.enabled_protocols is still populated
1310
            #
1311
            # fall through to deliver to followers and delete copy if necessary.
1312
            # should happen via protocol-specific copy target and send of
1313
            # delete activity.
1314
            # https://github.com/snarfed/bridgy-fed/issues/63
1315

1316
        elif obj.type == 'block':
1✔
1317
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1318
                # blocking protocol bot user disables that protocol
1319
                from_user.delete(proto)
1✔
1320
                from_user.disable_protocol(proto)
1✔
1321
                return 'OK', 200
1✔
1322

1323
        elif obj.type == 'post':
1✔
1324
            # handle DMs to bot users
1325
            if as1.is_dm(obj.as1):
1✔
1326
                return dms.receive(from_user=from_user, obj=obj)
1✔
1327

1328
        # fetch actor if necessary
1329
        is_user = (inner_obj_id in (from_user.key.id(), from_user.profile_id())
1✔
1330
                   or from_user.is_profile(orig_obj))
1331
        if (actor and actor.keys() == set(['id'])
1✔
1332
                and not is_user and obj.type not in ('delete', 'undo')):
1333
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1334
            actor_obj = from_cls.load(ids.profile_id(id=actor['id'], proto=from_cls),
1✔
1335
                                      raise_=False)
1336
            if actor_obj and actor_obj.as1:
1✔
1337
                obj.our_as1 = {
1✔
1338
                    **obj.as1, 'actor': {
1339
                        **actor_obj.as1,
1340
                        # override profile id with actor id
1341
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1342
                        'id': actor['id'],
1343
                    }
1344
                }
1345

1346
        # fetch object if necessary
1347
        if (obj.type in ('post', 'update', 'share')
1✔
1348
                and inner_obj_as1.keys() == set(['id'])
1349
                and from_cls.owns_id(inner_obj_id) is not False):
1350
            logger.debug('Fetching inner object')
1✔
1351
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1352
                                      remote=(obj.type in ('post', 'update')))
1353
            if obj.type in ('post', 'update'):
1✔
1354
                crud_obj = inner_obj
1✔
1355
            if inner_obj and inner_obj.as1:
1✔
1356
                obj.our_as1 = {
1✔
1357
                    **obj.as1,
1358
                    'object': {
1359
                        **inner_obj_as1,
1360
                        **inner_obj.as1,
1361
                    }
1362
                }
1363
            elif obj.type in ('post', 'update'):
1✔
1364
                error(f"Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1365

1366
        if obj.type == 'follow':
1✔
1367
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1368
                # follow of one of our protocol bot users; enable that protocol.
1369
                # fall through so that we send an accept.
1370
                try:
1✔
1371
                    from_user.enable_protocol(proto)
1✔
1372
                except ErrorButDoNotRetryTask:
1✔
1373
                    from web import Web
1✔
1374
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1375
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1376
                                               followee=bot, follow=obj)
1377
                    raise
1✔
1378
                proto.bot_maybe_follow_back(from_user)
1✔
1379

1380
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1381

1382
        # on update of the user's own actor/profile, set user.obj and store user back
1383
        # to datastore so that we recalculate computed properties like status etc
1384
        if is_user:
1✔
1385
            if obj.type == 'update' and crud_obj:
1✔
1386
                logger.info("update of the user's profile, re-storing user")
1✔
1387
                from_user.obj = crud_obj
1✔
1388
                from_user.put()
1✔
1389

1390
        # deliver to targets
1391
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1392

1393
        # on user deleting themselves, deactivate their followers/followings.
1394
        # https://github.com/snarfed/bridgy-fed/issues/1304
1395
        #
1396
        # do this *after* delivering because delivery finds targets based on
1397
        # stored Followers
1398
        if is_user and obj.type == 'delete':
1✔
1399
            for proto in from_user.enabled_protocols:
1✔
1400
                from_user.disable_protocol(PROTOCOLS[proto])
1✔
1401

1402
            logger.info(f'Deactivating Followers from or to {from_user.key.id()}')
1✔
1403
            followers = Follower.query(
1✔
1404
                OR(Follower.to == from_user.key, Follower.from_ == from_user.key)
1405
            ).fetch()
1406
            for f in followers:
1✔
1407
                f.status = 'inactive'
1✔
1408
            ndb.put_multi(followers)
1✔
1409

1410
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1411
        return resp
1✔
1412

1413
    @classmethod
1✔
1414
    def handle_follow(from_cls, obj, from_user):
1✔
1415
        """Handles an incoming follow activity.
1416

1417
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1418
        happens in :meth:`deliver`.
1419

1420
        Args:
1421
          obj (models.Object): follow activity
1422
        """
1423
        logger.debug('Got follow. storing Follow(s), sending accept(s)')
1✔
1424
        from_id = from_user.key.id()
1✔
1425

1426
        # Prepare followee (to) users' data
1427
        to_as1s = as1.get_objects(obj.as1)
1✔
1428
        if not to_as1s:
1✔
1429
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1430

1431
        # Store Followers
1432
        for to_as1 in to_as1s:
1✔
1433
            to_id = to_as1.get('id')
1✔
1434
            if not to_id:
1✔
1435
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1436

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

1439
            to_cls = Protocol.for_id(to_id)
1✔
1440
            if not to_cls:
1✔
1441
                error(f"Couldn't determine protocol for {to_id}")
×
1442
            elif from_cls == to_cls:
1✔
1443
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1444
                continue
1✔
1445

1446
            to_key = to_cls.key_for(to_id)
1✔
1447
            if not to_key:
1✔
1448
                logger.info(f'Skipping invalid {to_cls.LABEL} user key: {to_id}')
×
1449
                continue
×
1450

1451
            to_user = to_cls.get_or_create(id=to_key.id())
1✔
1452
            if not to_user or not to_user.is_enabled(from_user):
1✔
1453
                error(f'{to_id} not found')
1✔
1454

1455
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1456
                                                  follow=obj.key, status='active')
1457
            obj.add('notify', to_key)
1✔
1458
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1459
                                       followee=to_user, follow=obj)
1460

1461
    @classmethod
1✔
1462
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1463
        """Sends an accept or reject activity for a follow.
1464

1465
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1466
        nothing.
1467

1468
        Args:
1469
          verb (str): ``accept`` or  ``reject``
1470
          follower (models.User)
1471
          followee (models.User)
1472
          follow (models.Object)
1473
        """
1474
        assert verb in ('accept', 'reject')
1✔
1475
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1476
            return
1✔
1477

1478
        if not follower.obj or not (target := follower.target_for(follower.obj)):
1✔
1479
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
1✔
1480

1481
        # send. note that this is one response for the whole follow, even if it
1482
        # has multiple followees!
1483
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1484
        accept = {
1✔
1485
            'id': id,
1486
            'objectType': 'activity',
1487
            'verb': verb,
1488
            'actor': followee.key.id(),
1489
            'object': follow.as1,
1490
        }
1491
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1492
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1493

1494
    @classmethod
1✔
1495
    def bot_maybe_follow_back(bot_cls, user):
1✔
1496
        """Follow a user from a protocol bot user, if their protocol needs that.
1497

1498
        ...so that the protocol starts sending us their activities, if it needs
1499
        a follow for that (eg ActivityPub).
1500

1501
        Args:
1502
          user (User)
1503
        """
1504
        if not user.BOTS_FOLLOW_BACK:
1✔
1505
            return
1✔
1506

1507
        from web import Web
1✔
1508
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1509
        now = util.now().isoformat()
1✔
1510
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1511

1512
        if not user.obj:
1✔
1513
            logger.info("  can't follow, user has no profile obj")
1✔
1514
            return
1✔
1515

1516
        target = user.target_for(user.obj)
1✔
1517
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1518
        follow_back_as1 = {
1✔
1519
            'objectType': 'activity',
1520
            'verb': 'follow',
1521
            'id': follow_back_id,
1522
            'actor': bot.key.id(),
1523
            'object': user.key.id(),
1524
        }
1525
        common.create_task(queue='send', id=follow_back_id,
1✔
1526
                           our_as1=follow_back_as1, url=target,
1527
                           source_protocol='web', protocol=user.LABEL,
1528
                           user=bot.key.urlsafe())
1529

1530
    @classmethod
1✔
1531
    def handle_bare_object(cls, obj, *, authed_as, from_user):
1✔
1532
        """If obj is a bare object, wraps it in a create or update activity.
1533

1534
        Checks if we've seen it before.
1535

1536
        Args:
1537
          obj (models.Object)
1538
          authed_as (str): authenticated actor id who sent this activity
1539
          from_user (models.User): user (actor) this activity/object is from
1540

1541
        Returns:
1542
          models.Object: ``obj`` if it's an activity, otherwise a new object
1543
        """
1544
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1545
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1546
            return obj
1✔
1547

1548
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1549
        now = util.now().isoformat()
1✔
1550

1551
        # this is a raw post; wrap it in a create or update activity
1552
        if obj.changed or is_actor:
1✔
1553
            if obj.changed:
1✔
1554
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1555
            else:
1556
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1557
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1558
            update_as1 = {
1✔
1559
                'objectType': 'activity',
1560
                'verb': 'update',
1561
                'id': id,
1562
                'actor': obj_actor,
1563
                'object': {
1564
                    # Mastodon requires the updated field for Updates, so
1565
                    # add a default value.
1566
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1567
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1568
                    # https://github.com/mastodon/documentation/pull/1150
1569
                    'updated': now,
1570
                    **obj.as1,
1571
                },
1572
            }
1573
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1574
            return Object(id=id, our_as1=update_as1,
1✔
1575
                          source_protocol=obj.source_protocol)
1576

1577
        if (obj.new
1✔
1578
                # HACK: force query param here is specific to webmention
1579
                or 'force' in request.form):
1580
            create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1581
            create_as1 = {
1✔
1582
                'objectType': 'activity',
1583
                'verb': 'post',
1584
                'id': create_id,
1585
                'actor': obj_actor,
1586
                'object': obj.as1,
1587
                'published': now,
1588
            }
1589
            logger.info(f'Wrapping in post')
1✔
1590
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1591
            return Object(id=create_id, our_as1=create_as1,
1✔
1592
                          source_protocol=obj.source_protocol)
1593

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

1596
    @classmethod
1✔
1597
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1598
        """Delivers an activity to its external recipients.
1599

1600
        Args:
1601
          obj (models.Object): activity to deliver
1602
          from_user (models.User): user (actor) this activity is from
1603
          crud_obj (models.Object): if this is a create, update, or delete/undo
1604
            activity, the inner object that's being written, otherwise None.
1605
            (This object's ``notify`` and ``feed`` properties may be updated.)
1606
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1607
            targets on this protocol
1608

1609
        Returns:
1610
          (str, int) tuple: Flask response
1611
        """
1612
        if to_proto:
1✔
1613
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1614

1615
        # find delivery targets. maps Target to Object or None
1616
        #
1617
        # ...then write the relevant object, since targets() has a side effect of
1618
        # setting the notify and feed properties (and dirty attribute)
1619
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1620
        if to_proto:
1✔
1621
            targets = {t: obj for t, obj in targets.items()
1✔
1622
                       if t.protocol == to_proto.LABEL}
1623
        if not targets:
1✔
1624
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1625

1626
        # store object that targets() updated
1627
        if crud_obj and crud_obj.dirty:
1✔
1628
            crud_obj.put()
1✔
1629
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1630
            obj.put()
1✔
1631

1632
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1633
                      else obj.to_request())
1634

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

1638
        # enqueue send task for each targets
1639
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1640
        user = from_user.key.urlsafe()
1✔
1641
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1642
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1643
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1644
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1645

1646
        return 'OK', 202
1✔
1647

1648
    @classmethod
1✔
1649
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1650
        """Collects the targets to send a :class:`models.Object` to.
1651

1652
        Targets are both objects - original posts, events, etc - and actors.
1653

1654
        Args:
1655
          obj (models.Object)
1656
          from_user (User)
1657
          crud_obj (models.Object): if this is a create, update, or delete/undo
1658
            activity, the inner object that's being written, otherwise None.
1659
            (This object's ``notify`` and ``feed`` properties may be updated.)
1660
          internal (bool): whether this is a recursive internal call
1661

1662
        Returns:
1663
          dict: maps :class:`models.Target` to original (in response to)
1664
          :class:`models.Object`, if any, otherwise None
1665
        """
1666
        logger.debug('Finding recipients and their targets')
1✔
1667

1668
        # we should only have crud_obj iff this is a create or update
1669
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1670
        write_obj = crud_obj or obj
1✔
1671
        write_obj.dirty = False
1✔
1672

1673
        target_uris = as1.targets(obj.as1)
1✔
1674
        orig_obj = None
1✔
1675
        targets = {}  # maps Target to Object or None
1✔
1676
        owner = as1.get_owner(obj.as1)
1✔
1677
        allow_opt_out = (obj.type == 'delete')
1✔
1678
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1679
        inner_obj_id = inner_obj_as1.get('id')
1✔
1680
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1681
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1682
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1683
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1684
        is_self_reply = False
1✔
1685

1686
        original_ids = []
1✔
1687
        if is_reply:
1✔
1688
            original_ids = in_reply_tos
1✔
1689
        elif inner_obj_id:
1✔
1690
            if inner_obj_id == from_user.key.id():
1✔
1691
                inner_obj_id = from_user.profile_id()
1✔
1692
            original_ids = [inner_obj_id]
1✔
1693

1694
        # maps id to Object
1695
        original_objs = {}
1✔
1696
        for id in original_ids:
1✔
1697
            if proto := Protocol.for_id(id):
1✔
1698
                original_objs[id] = proto.load(id, raise_=False)
1✔
1699

1700
        # for AP, add in-reply-tos' mentions
1701
        # https://github.com/snarfed/bridgy-fed/issues/1608
1702
        # https://github.com/snarfed/bridgy-fed/issues/1218
1703
        orig_post_mentions = {}  # maps mentioned id to original post Object
1✔
1704
        for id in in_reply_tos:
1✔
1705
            if ((in_reply_to_obj := original_objs.get(id))
1✔
1706
                    and (proto := PROTOCOLS.get(in_reply_to_obj.source_protocol))
1707
                    and proto.SEND_REPLIES_TO_ORIG_POSTS_MENTIONS
1708
                    and (mentions := as1.mentions(in_reply_to_obj.as1))):
1709
                logger.info(f"Adding in-reply-to {id} 's mentions to targets: {mentions}")
1✔
1710
                target_uris.extend(mentions)
1✔
1711
                for mention in mentions:
1✔
1712
                    orig_post_mentions[mention] = in_reply_to_obj
1✔
1713

1714
        target_uris = sorted(set(target_uris))
1✔
1715
        logger.info(f'Raw targets: {target_uris}')
1✔
1716

1717
        # which protocols should we allow delivering to?
1718
        to_protocols = []
1✔
1719
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1720
                      + from_user.enabled_protocols):
1721
            if not (proto := PROTOCOLS.get(label)):
1✔
1722
                report_error(f'unknown enabled protocol {label} for {from_user.key.id()}')
1✔
1723
                continue
1✔
1724

1725
            if (obj.type == 'post' and (orig := original_objs.get(inner_obj_id))
1✔
1726
                    and orig.get_copy(proto)):
1727
                logger.info(f'Already created {id} on {label}, cowardly refusing to create there again')
1✔
1728
                continue
1✔
1729

1730
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1731
                                     or is_reply):
1732
                origs_could_bridge = None
1✔
1733

1734
                for id in original_ids:
1✔
1735
                    if not (orig := original_objs.get(id)):
1✔
1736
                        continue
1✔
1737
                    elif isinstance(orig, proto):
1✔
1738
                        logger.info(f'Allowing {label} for original {id}')
×
1739
                        break
×
1740
                    elif orig.get_copy(proto):
1✔
1741
                        logger.info(f'Allowing {label}, original {id} was bridged there')
1✔
1742
                        break
1✔
1743
                    elif from_user.is_profile(orig):
1✔
1744
                        logger.info(f"Allowing {label}, this is the user's profile")
1✔
1745
                        break
1✔
1746

1747
                    if (origs_could_bridge is not False
1✔
1748
                            and (orig_author_id := as1.get_owner(orig.as1))
1749
                            and (orig_proto := PROTOCOLS.get(orig.source_protocol))
1750
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1751
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1752

1753
                else:
1754
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1755
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
1✔
1756
                            and origs_could_bridge):
1757
                        # retry later; original obj may still be bridging
1758
                        # TODO: limit to brief window, eg no older than 2h? 1d?
1759
                        error(msg, status=304)
1✔
1760

1761
                    logger.info(msg)
1✔
1762
                    continue
1✔
1763

1764
            util.add(to_protocols, proto)
1✔
1765

1766
        # process direct targets
1767
        for target_id in target_uris:
1✔
1768
            target_proto = Protocol.for_id(target_id)
1✔
1769
            if not target_proto:
1✔
1770
                logger.info(f"Can't determine protocol for {target_id}")
1✔
1771
                continue
1✔
1772
            elif target_proto.is_blocklisted(target_id):
1✔
1773
                logger.debug(f'{target_id} is blocklisted')
1✔
1774
                continue
1✔
1775

1776
            target_obj_id = target_id
1✔
1777
            if target_id in mentioned_urls:
1✔
1778
                target_obj_id = ids.profile_id(id=target_id, proto=target_proto)
1✔
1779

1780
            orig_obj = target_proto.load(target_obj_id, raise_=False)
1✔
1781
            if not orig_obj or not orig_obj.as1:
1✔
1782
                logger.info(f"Couldn't load {target_obj_id}")
1✔
1783
                continue
1✔
1784

1785
            target_author_key = (target_proto(id=target_id).key
1✔
1786
                                 if target_id in mentioned_urls
1787
                                 else target_proto.actor_key(orig_obj))
1788
            if not from_user.is_enabled(target_proto):
1✔
1789
                # if author isn't bridged and target user is, DM a prompt and
1790
                # add a notif for the target user
1791
                if (target_id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
1792
                        and target_author_key):
1793
                    if target_author := target_author_key.get():
1✔
1794
                        if target_author.is_enabled(from_cls):
1✔
1795
                            notifications.add_notification(target_author, write_obj)
1✔
1796
                            verb, noun = (
1✔
1797
                                ('replied to', 'replies') if target_id in in_reply_tos
1798
                                else ('quoted', 'quotes') if target_id in quoted_posts
1799
                                else ('mentioned', 'mentions'))
1800
                            dms.maybe_send(from_=target_proto, to_user=from_user,
1✔
1801
                                           type='replied_to_bridged_user', text=f"""\
1802
Hi! You <a href="{inner_obj_as1.get('url') or inner_obj_id}">recently {verb}</a> {target_author.user_link()}, who's bridged here from {target_proto.PHRASE}. If you want them to see your {noun}, you can bridge your account into {target_proto.PHRASE} by following this account. <a href="https://fed.brid.gy/docs">See the docs</a> for more information.""")
1803

1804
                continue
1✔
1805

1806
            # deliver self-replies to followers
1807
            # https://github.com/snarfed/bridgy-fed/issues/639
1808
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1809
                is_self_reply = True
1✔
1810
                logger.info(f'self reply!')
1✔
1811

1812
            # also add copies' targets
1813
            for copy in orig_obj.copies:
1✔
1814
                proto = PROTOCOLS[copy.protocol]
1✔
1815
                if proto in to_protocols:
1✔
1816
                    # copies generally won't have their own Objects
1817
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1818
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {target_id}')
1✔
1819
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1820

1821
            if target_proto == from_cls:
1✔
1822
                logger.debug(f'Skipping same-protocol target {target_id}')
1✔
1823
                continue
1✔
1824

1825
            target = target_proto.target_for(orig_obj)
1✔
1826
            if not target:
1✔
1827
                # TODO: surface errors like this somehow?
1828
                logger.error(f"Can't find delivery target for {target_id}")
×
1829
                continue
×
1830

1831
            logger.debug(f'Target for {target_id} is {target}')
1✔
1832
            # only use orig_obj for inReplyTos, like/repost objects, reply's original
1833
            # post's mentions, etc
1834
            # https://github.com/snarfed/bridgy-fed/issues/1237
1835
            target_obj = None
1✔
1836
            if target_id in in_reply_tos + as1.get_ids(obj.as1, 'object'):
1✔
1837
                target_obj = orig_obj
1✔
1838
            elif target_id in orig_post_mentions:
1✔
1839
                target_obj = orig_post_mentions[target_id]
1✔
1840
            targets[Target(protocol=target_proto.LABEL, uri=target)] = target_obj
1✔
1841

1842
            if target_author_key:
1✔
1843
                logger.debug(f'Recipient is {target_author_key}')
1✔
1844
                if write_obj.add('notify', target_author_key):
1✔
1845
                    write_obj.dirty = True
1✔
1846

1847
        if obj.type == 'undo':
1✔
1848
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1849
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1850
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1851
            else:
1852
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1853
            if inner_obj:
1✔
1854
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1855
                                                internal=True))
1856

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

1859
        # deliver to followers, if appropriate
1860
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1861
        if not user_key:
1✔
1862
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1863
            return targets
1✔
1864

1865
        followers = []
1✔
1866
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1867
                and (not is_reply or is_self_reply)):
1868
            logger.info(f'Delivering to followers of {user_key}')
1✔
1869
            followers = []
1✔
1870
            for f in Follower.query(Follower.to == user_key,
1✔
1871
                                    Follower.status == 'active'):
1872
                proto = PROTOCOLS_BY_KIND[f.from_.kind()]
1✔
1873
                # skip protocol bot users
1874
                if (not Protocol.for_bridgy_subdomain(f.from_.id())
1✔
1875
                        # skip protocols this user hasn't enabled, or where the base
1876
                        # object of this activity hasn't been bridged
1877
                        and proto in to_protocols
1878
                        # we deliver to HAS_COPIES protocols separately, below. we
1879
                        # assume they have follower-independent targets.
1880
                        and not (proto.HAS_COPIES and proto.DEFAULT_TARGET)):
1881
                    followers.append(f)
1✔
1882

1883
            user_keys = [f.from_ for f in followers]
1✔
1884
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1885
            User.load_multi(users)
1✔
1886

1887
            if (not followers and
1✔
1888
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
1889
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
1890
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1891
                return {}
1✔
1892

1893
            # add to followers' feeds, if any
1894
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1895
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1896
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
1✔
1897
                    if write_obj.feed:
1✔
1898
                        write_obj.dirty = True
1✔
1899

1900
            # collect targets for followers
1901
            for user in users:
1✔
1902
                if user.is_blocking(from_user.key.id()):
1✔
1903
                    logger.debug(f'  {user.key.id()} blocks {from_user.key.id()}')
1✔
1904
                    continue
1✔
1905

1906
                # TODO: should we pass remote=False through here to Protocol.load?
1907
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1908
                if not target:
1✔
1909
                    # logger.error(f'Follower {user.key} has no delivery target')
1910
                    continue
1✔
1911

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

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

1920
        # deliver to enabled HAS_COPIES protocols proactively
1921
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
1922
            for proto in to_protocols:
1✔
1923
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
1924
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
1925
                    targets.setdefault(
1✔
1926
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1927

1928
        # de-dupe targets, discard same-domain
1929
        # maps string target URL to (Target, Object) tuple
1930
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1931
        # maps Target to Object or None
1932
        targets = {}
1✔
1933
        source_domains = [
1✔
1934
            util.domain_from_link(url) for url in
1935
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1936
            if util.is_web(url)
1937
        ]
1938
        for url in sorted(util.dedupe_urls(
1✔
1939
                candidates.keys(),
1940
                # preserve our PDS URL without trailing slash in path
1941
                # https://atproto.com/specs/did#did-documents
1942
                trailing_slash=False)):
1943
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
1944
                logger.info(f'Skipping same-domain target {url}')
×
1945
                continue
×
1946
            elif from_user.is_blocking(url):
1✔
1947
                logger.debug(f'{from_user.key.id()} blocks {url}')
1✔
1948
                continue
1✔
1949

1950
            target, obj = candidates[url]
1✔
1951
            targets[target] = obj
1✔
1952

1953
        return targets
1✔
1954

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

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

1962
        Args:
1963
          id (str)
1964
          remote (bool): whether to fetch the object over the network. If True,
1965
            fetches even if we already have the object stored, and updates our
1966
            stored copy. If False and we don't have the object stored, returns
1967
            None. Default (None) means to fetch over the network only if we
1968
            don't already have it stored.
1969
          local (bool): whether to load from the datastore before
1970
            fetching over the network. If False, still stores back to the
1971
            datastore after a successful remote fetch.
1972
          raise_ (bool): if False, catches any :class:`request.RequestException`
1973
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1974
            ``None`` instead
1975
          kwargs: passed through to :meth:`fetch()`
1976

1977
        Returns:
1978
          models.Object: loaded object, or None if it isn't fetchable, eg a
1979
          non-URL string for Web, or ``remote`` is False and it isn't in the
1980
          datastore
1981

1982
        Raises:
1983
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1984
            is True
1985
        """
1986
        assert id
1✔
1987
        assert local or remote is not False
1✔
1988
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1989

1990
        id = ids.normalize_object_id(id=id, proto=cls)
1✔
1991

1992
        obj = orig_as1 = None
1✔
1993
        if local:
1✔
1994
            obj = Object.get_by_id(id)
1✔
1995
            if not obj:
1✔
1996
                # logger.debug(f' {id} not in datastore')
1997
                pass
1✔
1998
            elif obj.as1 or obj.csv or obj.raw or obj.deleted:
1✔
1999
                # logger.debug(f'  {id} got from datastore')
2000
                obj.new = False
1✔
2001

2002
        if remote is False:
1✔
2003
            return obj
1✔
2004
        elif remote is None and obj:
1✔
2005
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
2006
                # logger.debug(f'  last updated {obj.updated}, refreshing')
2007
                pass
1✔
2008
            else:
2009
                return obj
1✔
2010

2011
        if obj:
1✔
2012
            orig_as1 = obj.as1
1✔
2013
            obj.our_as1 = None
1✔
2014
            obj.new = False
1✔
2015
        else:
2016
            obj = Object(id=id)
1✔
2017
            if local:
1✔
2018
                # logger.debug(f'  {id} not in datastore')
2019
                obj.new = True
1✔
2020
                obj.changed = False
1✔
2021

2022
        try:
1✔
2023
            fetched = cls.fetch(obj, **kwargs)
1✔
2024
        except (RequestException, HTTPException) as e:
1✔
2025
            if raise_:
1✔
2026
                raise
1✔
2027
            util.interpret_http_exception(e)
1✔
2028
            return None
1✔
2029

2030
        if not fetched:
1✔
2031
            return None
1✔
2032

2033
        # https://stackoverflow.com/a/3042250/186123
2034
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
2035
        if size > MAX_ENTITY_SIZE:
1✔
2036
            logger.warning(f'Object is too big! {size} bytes is over {MAX_ENTITY_SIZE}')
1✔
2037
            return None
1✔
2038

2039
        obj.resolve_ids()
1✔
2040
        obj.normalize_ids()
1✔
2041

2042
        if obj.new is False:
1✔
2043
            obj.changed = obj.activity_changed(orig_as1)
1✔
2044

2045
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
2046
            if obj.source_protocol:
1✔
2047
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
2048
            obj.source_protocol = cls.LABEL
1✔
2049

2050
        obj.put()
1✔
2051
        return obj
1✔
2052

2053
    @classmethod
1✔
2054
    def check_supported(cls, obj, direction):
1✔
2055
        """If this protocol doesn't support this activity, raises HTTP 204.
2056

2057
        Also reports an error.
2058

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

2063
        Args:
2064
          obj (Object)
2065
          direction (str): ``'receive'`` or  ``'send'``
2066

2067
        Raises:
2068
          werkzeug.HTTPException: if this protocol doesn't support this object
2069
        """
2070
        assert direction in ('receive', 'send')
1✔
2071
        if not obj.type:
1✔
2072
            return
×
2073

2074
        inner = as1.get_object(obj.as1)
1✔
2075
        inner_type = as1.object_type(inner) or ''
1✔
2076
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
2077
            or (obj.type in as1.CRUD_VERBS
2078
                and inner_type
2079
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
2080
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
2081

2082
        # don't allow posts with blank content and no image/video/audio
2083
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
2084
                    else obj.as1)
2085
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
2086
                and not util.get_url(crud_obj, key='image')
2087
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
2088
                # TODO: handle articles with displayName but not content
2089
                and not source.html_to_text(crud_obj.get('content')).strip()):
2090
            error('Blank content and no image or video or audio', status=204)
1✔
2091

2092
        # receiving DMs is only allowed to protocol bot accounts
2093
        if direction == 'receive':
1✔
2094
            if recip := as1.recipient_if_dm(obj.as1):
1✔
2095
                owner = as1.get_owner(obj.as1)
1✔
2096
                if (not cls.SUPPORTS_DMS or (recip not in common.bot_user_ids()
1✔
2097
                                             and owner not in common.bot_user_ids())):
2098
                    # reply and say DMs aren't supported
2099
                    from_proto = PROTOCOLS.get(obj.source_protocol)
1✔
2100
                    to_proto = Protocol.for_id(recip)
1✔
2101
                    if owner and from_proto and to_proto:
1✔
2102
                        if ((from_user := from_proto.get_or_create(id=owner))
1✔
2103
                                and (to_user := to_proto.get_or_create(id=recip))):
2104
                            in_reply_to = (inner.get('id') if obj.type == 'post'
1✔
2105
                                           else obj.as1.get('id'))
2106
                            text = f"Hi! Sorry, this account is bridged from {to_user.PHRASE}, so it doesn't support DMs. Try getting in touch another way!"
1✔
2107
                            type = f'dms_not_supported-{to_user.key.id()}'
1✔
2108
                            dms.maybe_send(from_=to_user, to_user=from_user,
1✔
2109
                                           text=text, type=type,
2110
                                           in_reply_to=in_reply_to)
2111

2112
                    error("Bridgy Fed doesn't support DMs", status=204)
1✔
2113

2114
            # check that this activity is public. only do this for some activities,
2115
            # not eg likes or follows, since Mastodon doesn't currently mark those
2116
            # as explicitly public.
2117
            elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
2118
                  and not util.domain_or_parent_in(crud_obj.get('id'), NON_PUBLIC_DOMAINS)
2119
                  and not as1.is_public(obj.as1, unlisted=False)):
2120
                error('Bridgy Fed only supports public activities', status=204)
1✔
2121

2122
    @classmethod
1✔
2123
    def block(cls, from_user, arg):
1✔
2124
        """Blocks a user or list.
2125

2126
        Args:
2127
          from_user (models.User): user doing the blocking
2128
          arg (str): handle or id of user/list to block
2129

2130
        Returns:
2131
          models.User or models.Object: user or list that was blocked
2132

2133
        Raises:
2134
          ValueError: if arg doesn't look like a user or list on this protocol
2135
        """
2136
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2137

2138
        blockee = None
1✔
2139
        err = None
1✔
2140
        try:
1✔
2141
            # first, try interpreting as a user handle or id
2142
            blockee = load_user(arg, cls, create=True, allow_opt_out=True)
1✔
2143
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2144
            logger.info(err)
1✔
2145

2146
        # may not be a user, see if it's a list
2147
        if not blockee:
1✔
2148
            blockee = cls.load(arg)
1✔
2149
            if not blockee or blockee.type != 'collection':
1✔
2150
                err = f"{arg} doesn't look like a user or list on {cls.PHRASE}, or we couldn't fetch it"
1✔
2151
                logger.warning(err)
1✔
2152
                raise ValueError(err)
1✔
2153

2154
        logger.info(f'  blocking {blockee.key.id()}')
1✔
2155
        id = f'{from_user.key.id()}#bridgy-fed-block-{util.now().isoformat()}'
1✔
2156
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2157
            'objectType': 'activity',
2158
            'verb': 'block',
2159
            'id': id,
2160
            'actor': from_user.key.id(),
2161
            'object': blockee.key.id(),
2162
        })
2163
        obj.put()
1✔
2164
        from_user.deliver(obj, from_user=from_user)
1✔
2165

2166
        return blockee
1✔
2167

2168
    @classmethod
1✔
2169
    def unblock(cls, from_user, arg):
1✔
2170
        """Unblocks a user or list.
2171

2172
        Args:
2173
          from_user (models.User): user doing the unblocking
2174
          arg (str): handle or id of user/list to unblock
2175

2176
        Returns:
2177
          models.User or models.Object: user or list that was blocked
2178

2179
        Raises:
2180
          ValueError: if arg doesn't look like a user or list on this protocol
2181
        """
2182
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2183

2184
        blockee = None
1✔
2185
        try:
1✔
2186
            # first, try interpreting as a user handle or id
2187
            blockee = load_user(arg, cls, create=True, allow_opt_out=True)
1✔
2188
        except (AssertionError, AttributeError, BadRequest, RuntimeError, ValueError) as err:
1✔
2189
            logger.info(err)
1✔
2190

2191
        # may not be a user, see if it's a list
2192
        if not blockee:
1✔
2193
            blockee = cls.load(arg)
1✔
2194
            if not blockee or blockee.type != 'collection':
1✔
2195
                err = f"{arg} doesn't look like a user or list on {cls.PHRASE}"
1✔
2196
                logger.warning(err)
1✔
2197
                raise ValueError(err)
1✔
2198

2199
        logger.info(f'  unblocking {blockee.key.id()}')
1✔
2200
        id = f'{from_user.key.id()}#bridgy-fed-unblock-{util.now().isoformat()}'
1✔
2201
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2202
            'objectType': 'activity',
2203
            'verb': 'undo',
2204
            'id': id,
2205
            'actor': from_user.key.id(),
2206
            'object': {
2207
                'objectType': 'activity',
2208
                'verb': 'block',
2209
                'actor': from_user.key.id(),
2210
                'object': blockee.key.id(),
2211
            },
2212
        })
2213
        obj.put()
1✔
2214
        from_user.deliver(obj, from_user=from_user)
1✔
2215

2216
        return blockee
1✔
2217

2218

2219
@cloud_tasks_only(log=None)
1✔
2220
def receive_task():
1✔
2221
    """Task handler for a newly received :class:`models.Object`.
2222

2223
    Calls :meth:`Protocol.receive` with the form parameters.
2224

2225
    Parameters:
2226
      authed_as (str): passed to :meth:`Protocol.receive`
2227
      obj_id (str): key id of :class:`models.Object` to handle
2228
      received_at (str, ISO 8601 timestamp): when we first saw (received)
2229
        this activity
2230
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2231
        :class:`models.Object` to handle
2232

2233
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
2234
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
2235
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
2236
    ``changed``, HTTP request details, etc. See stash for attempt at this for
2237
    :class:`web.Web`.
2238
    """
2239
    common.log_request()
1✔
2240
    form = request.form.to_dict()
1✔
2241

2242
    authed_as = form.pop('authed_as', None)
1✔
2243
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
2244
                or authed_as in common.PROTOCOL_DOMAINS)
2245

2246
    obj = Object.from_request()
1✔
2247
    assert obj
1✔
2248
    assert obj.source_protocol
1✔
2249
    obj.new = True
1✔
2250

2251
    if received_at := form.pop('received_at', None):
1✔
2252
        received_at = datetime.fromisoformat(received_at)
1✔
2253

2254
    try:
1✔
2255
        return PROTOCOLS[obj.source_protocol].receive(
1✔
2256
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
2257
    except RequestException as e:
1✔
2258
        util.interpret_http_exception(e)
1✔
2259
        error(e, status=304)
1✔
2260
    except ValueError as e:
1✔
2261
        logger.warning(e, exc_info=True)
×
2262
        error(e, status=304)
×
2263

2264

2265
@cloud_tasks_only(log=None)
1✔
2266
def send_task():
1✔
2267
    """Task handler for sending an activity to a single specific destination.
2268

2269
    Calls :meth:`Protocol.send` with the form parameters.
2270

2271
    Parameters:
2272
      protocol (str): :class:`Protocol` to send to
2273
      url (str): destination URL to send to
2274
      obj_id (str): key id of :class:`models.Object` to send
2275
      orig_obj_id (str): optional, :class:`models.Object` key id of the
2276
        "original object" that this object refers to, eg replies to or reposts
2277
        or likes
2278
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
2279
        this activity is from
2280
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2281
        :class:`models.Object` to handle
2282
    """
2283
    common.log_request()
1✔
2284

2285
    # prepare
2286
    form = request.form.to_dict()
1✔
2287
    url = form.get('url')
1✔
2288
    protocol = form.get('protocol')
1✔
2289
    if not url or not protocol:
1✔
2290
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
2291
        return '', 204
1✔
2292

2293
    target = Target(uri=url, protocol=protocol)
1✔
2294
    obj = Object.from_request()
1✔
2295
    assert obj and obj.key and obj.key.id()
1✔
2296

2297
    PROTOCOLS[protocol].check_supported(obj, 'send')
1✔
2298
    allow_opt_out = (obj.type == 'delete')
1✔
2299

2300
    user = None
1✔
2301
    if user_key := form.get('user'):
1✔
2302
        key = ndb.Key(urlsafe=user_key)
1✔
2303
        # use get_by_id so that we follow use_instead
2304
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2305
            key.id(), allow_opt_out=allow_opt_out)
2306

2307
    # send
2308
    delay = ''
1✔
2309
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
2310
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
2311
        delay = f'({delay_s} s behind)'
1✔
2312
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
2313
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
2314
    sent = None
1✔
2315
    try:
1✔
2316
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
2317
                                        orig_obj_id=form.get('orig_obj_id'))
2318
    except BaseException as e:
1✔
2319
        code, body = util.interpret_http_exception(e)
1✔
2320
        if not code and not body:
1✔
2321
            raise
1✔
2322

2323
    if sent is False:
1✔
2324
        logger.info(f'Failed sending!')
1✔
2325

2326
    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