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

snarfed / bridgy-fed / 6bb03b67-8218-41ab-96f6-f6409d110030

29 Nov 2025 06:35PM UTC coverage: 93.012% (+0.04%) from 92.969%
6bb03b67-8218-41ab-96f6-f6409d110030

push

circleci

snarfed
tweaks to id.normalize/translate_user_id, narrow somewhat to just user ids

TODO: what should they return if id is not a valid user id? add and use new is_user_id function?

7 of 7 new or added lines in 2 files covered. (100.0%)

40 existing lines in 4 files now uncovered.

6256 of 6726 relevant lines covered (93.01%)

0.93 hits per line

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

95.83
/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
    Object,
46
    PROTOCOLS,
47
    PROTOCOLS_BY_KIND,
48
    Target,
49
    User,
50
)
51
import notifications
1✔
52

53
OBJECT_REFRESH_AGE = timedelta(days=30)
1✔
54
DELETE_TASK_DELAY = timedelta(minutes=1)
1✔
55
CREATE_MAX_AGE = timedelta(weeks=2)
1✔
56
# WARNING: keep this below the receive queue's min_backoff_seconds in queue.yaml!
57
MEMCACHE_LEASE_EXPIRATION = timedelta(seconds=25)
1✔
58

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

64
# domains to allow non-public activities from
65
NON_PUBLIC_DOMAINS = (
1✔
66
    # bridged from twitter (X). bird.makeup, kilogram.makeup, etc federate
67
    # tweets as followers-only, but they're public on twitter itself
68
    '.makeup',
69
)
70

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

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

82

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

87

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

91

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

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

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

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

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

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

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

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

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

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

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

183
        To be implemented by subclasses.
184

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

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

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

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

199
        Args:
200
          id (str)
201

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

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

211
        To be implemented by subclasses.
212

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

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

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

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

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

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

240
        To be implemented by subclasses.
241

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

245
        Args:
246
          handle (str)
247

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

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

257
        To be implemented by subclasses. Canonicalizes the id if necessary.
258

259
        If called via `Protocol.key_for`, infers the appropriate protocol with
260
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
261
        as is.
262

263
        Args:
264
          id (str):
265
          allow_opt_out (bool): whether to allow users who are currently opted out
266

267
        Returns:
268
          google.cloud.ndb.Key: matching key, or None if the given id is not a
269
          valid :class:`User` id for this protocol.
270
        """
271
        if cls == Protocol:
1✔
272
            proto = Protocol.for_id(id)
1✔
273
            return proto.key_for(id, allow_opt_out=allow_opt_out) if proto else None
1✔
274

275
        # load user so that we follow use_instead
276
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
277
        if existing:
1✔
278
            if existing.status and not allow_opt_out:
1✔
279
                return None
1✔
280
            return existing.key
1✔
281

282
        return cls(id=id).key
1✔
283

284
    @staticmethod
1✔
285
    def _for_id_memcache_key(id, remote=None):
1✔
286
        """If id is a URL, uses its domain, otherwise returns None.
287

288
        Args:
289
          id (str)
290

291
        Returns:
292
          (str domain, bool remote) or None
293
        """
294
        domain = util.domain_from_link(id)
1✔
295
        if domain in PROTOCOL_DOMAINS:
1✔
296
            return id
1✔
297
        elif remote and util.is_web(id):
1✔
298
            return domain
1✔
299

300
    @cached(LRUCache(20000), lock=Lock())
1✔
301
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote=True: remote,
1✔
302
                      version=3)
303
    @staticmethod
1✔
304
    def for_id(id, remote=True):
1✔
305
        """Returns the protocol for a given id.
306

307
        Args:
308
          id (str)
309
          remote (bool): whether to perform expensive side effects like fetching
310
            the id itself over the network, or other discovery.
311

312
        Returns:
313
          Protocol subclass: matching protocol, or None if no single known
314
          protocol definitively owns this id
315
        """
316
        logger.debug(f'Determining protocol for id {id}')
1✔
317
        if not id:
1✔
318
            return None
1✔
319

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

329
        if util.is_web(id):
1✔
330
            # step 1: check for our per-protocol subdomains
331
            try:
1✔
332
                parsed = urlparse(id)
1✔
333
            except ValueError as e:
1✔
334
                logger.info(f'urlparse ValueError: {e}')
1✔
335
                return None
1✔
336

337
            is_homepage = parsed.path.strip('/') == ''
1✔
338
            is_internal = parsed.path.startswith(ids.INTERNAL_PATH_PREFIX)
1✔
339
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
340
            if by_subdomain and not (is_homepage or is_internal
1✔
341
                                     or id in ids.BOT_ACTOR_AP_IDS):
342
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
343
                return by_subdomain
1✔
344

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

358
        if len(candidates) == 1:
1✔
359
            logger.debug(f'  {candidates[0].LABEL} owns id {id}')
1✔
360
            return candidates[0]
1✔
361

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

373
        # step 4: fetch over the network, if necessary
374
        if not remote:
1✔
375
            return None
1✔
376

377
        for protocol in candidates:
1✔
378
            logger.debug(f'Trying {protocol.LABEL}')
1✔
379
            try:
1✔
380
                obj = protocol.load(id, local=False, remote=True)
1✔
381

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

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

408
        logger.info(f'No matching protocol found for {id} !')
1✔
409
        return None
1✔
410

411
    @cached(LRUCache(20000), lock=Lock())
1✔
412
    @staticmethod
1✔
413
    def for_handle(handle):
1✔
414
        """Returns the protocol for a given handle.
415

416
        May incur expensive side effects like resolving the handle itself over
417
        the network or other discovery.
418

419
        Args:
420
          handle (str)
421

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

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

444
        if len(candidates) == 1:
1✔
445
            logger.debug(f'  {candidates[0].LABEL} owns handle {handle}')
×
446
            return (candidates[0], None)
×
447

448
        # step 2: look for matching User in the datastore
449
        for proto in candidates:
1✔
450
            user = proto.query(proto.handle == handle).get()
1✔
451
            if user:
1✔
452
                if user.status:
1✔
453
                    return (None, None)
1✔
454
                logger.debug(f'  user {user.key} handle {handle}')
1✔
455
                return (proto, user.key.id())
1✔
456

457
        # step 3: resolve handle to id
458
        for proto in candidates:
1✔
459
            id = proto.handle_to_id(handle)
1✔
460
            if id:
1✔
461
                logger.debug(f'  {proto.LABEL} resolved handle {handle} to id {id}')
1✔
462
                return (proto, id)
1✔
463

464
        logger.info(f'No matching protocol found for handle {handle} !')
1✔
465
        return (None, None)
1✔
466

467
    @classmethod
1✔
468
    def is_user_at_domain(cls, handle, allow_internal=False):
1✔
469
        """Returns True if handle is formatted ``user@domain.tld``, False otherwise.
470

471
        Example: ``@user@instance.com``
472

473
        Args:
474
          handle (str)
475
          allow_internal (bool): whether the domain can be a Bridgy Fed domain
476
        """
477
        parts = handle.split('@')
1✔
478
        if len(parts) != 2:
1✔
479
            return False
1✔
480

481
        user, domain = parts
1✔
482
        return bool(user and domain
1✔
483
                    and not cls.is_blocklisted(domain, allow_internal=allow_internal))
484

485
    @classmethod
1✔
486
    def bridged_web_url_for(cls, user, fallback=False):
1✔
487
        """Returns the web URL for a user's bridged profile in this protocol.
488

489
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
490
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
491

492
        Args:
493
          user (models.User)
494
          fallback (bool): if True, and bridged users have no canonical user
495
            profile URL in this protocol, return the native protocol's profile URL
496

497
        Returns:
498
          str, or None if there isn't a canonical URL
499
        """
500
        if fallback:
1✔
501
            return user.web_url()
1✔
502

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

507
        Args:
508
          obj (models.Object)
509
          allow_opt_out (bool): whether to return a user key if they're opted out
510

511
        Returns:
512
          google.cloud.ndb.key.Key or None:
513
        """
514
        owner = as1.get_owner(obj.as1)
1✔
515
        if owner:
1✔
516
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
517

518
    @classmethod
1✔
519
    def bot_user_id(cls):
1✔
520
        """Returns the Web user id for the bot user for this protocol.
521

522
        For example, ``'bsky.brid.gy'`` for ATProto.
523

524
        Returns:
525
          str:
526
        """
527
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
528

529
    @classmethod
1✔
530
    def create_for(cls, user):
1✔
531
        """Creates or re-activate a copy user in this protocol.
532

533
        Should add the copy user to :attr:`copies`.
534

535
        If the copy user already exists and active, should do nothing.
536

537
        Args:
538
          user (models.User): original source user. Shouldn't already have a
539
            copy user for this protocol in :attr:`copies`.
540

541
        Raises:
542
          ValueError: if we can't create a copy of the given user in this protocol
543
        """
544
        raise NotImplementedError()
×
545

546
    @classmethod
1✔
547
    def send(to_cls, obj, target, from_user=None, orig_obj_id=None):
1✔
548
        """Sends an outgoing activity.
549

550
        To be implemented by subclasses. Should call
551
        ``to_cls.translate_ids(obj.as1)`` before converting it to this Protocol's
552
        format.
553

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

558
        Args:
559
          obj (models.Object): with activity to send
560
          target (str): destination URL to send to
561
          from_user (models.User): user (actor) this activity is from
562
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
563
            that this object refers to, eg replies to or reposts or likes
564

565
        Returns:
566
          bool: True if the activity is sent successfully, False if it is
567
          ignored or otherwise unsent due to protocol logic, eg no webmention
568
          endpoint, protocol doesn't support the activity type. (Failures are
569
          raised as exceptions.)
570

571
        Raises:
572
          werkzeug.HTTPException if the request fails
573
        """
574
        raise NotImplementedError()
×
575

576
    @classmethod
1✔
577
    def fetch(cls, obj, **kwargs):
1✔
578
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
579

580
        Errors are raised as exceptions. If this method returns False, the fetch
581
        didn't fail but didn't succeed either, eg the id isn't valid for this
582
        protocol, or the fetch didn't return valid data for this protocol.
583

584
        To be implemented by subclasses.
585

586
        Args:
587
          obj (models.Object): with the id to fetch. Data is filled into one of
588
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
589
          kwargs: subclass-specific
590

591
        Returns:
592
          bool: True if the object was fetched and populated successfully,
593
          False otherwise
594

595
        Raises:
596
          requests.RequestException, werkzeug.HTTPException,
597
          websockets.WebSocketException, etc: if the fetch fails
598
        """
599
        raise NotImplementedError()
×
600

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

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

608
        Just passes through to :meth:`_convert`, then does minor
609
        protocol-independent postprocessing.
610

611
        Args:
612
          obj (models.Object):
613
          from_user (models.User): user (actor) this activity/object is from
614
          kwargs: protocol-specific, passed through to :meth:`_convert`
615

616
        Returns:
617
          converted object in the protocol's native format, often a dict
618
        """
619
        if not obj or not obj.as1:
1✔
620
            return {}
1✔
621

622
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
623
        is_crud = obj.as1.get('verb') in as1.CRUD_VERBS
1✔
624
        base_obj = as1.get_object(obj.as1) if is_crud else obj.as1
1✔
625
        orig_our_as1 = obj.our_as1
1✔
626

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

638
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
639
        obj.our_as1 = orig_our_as1
1✔
640
        return converted
1✔
641

642
    @classmethod
1✔
643
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
644
        """Converts an :class:`Object` to this protocol's data format.
645

646
        To be implemented by subclasses. Implementations should generally call
647
        :meth:`Protocol.translate_ids` (as their own class) before converting to
648
        their format.
649

650
        Args:
651
          obj (models.Object):
652
          from_user (models.User): user (actor) this activity/object is from
653
          kwargs: protocol-specific
654

655
        Returns:
656
          converted object in the protocol's native format, often a dict. May
657
            return the ``{}`` empty dict if the object can't be converted.
658
        """
659
        raise NotImplementedError()
×
660

661
    @classmethod
1✔
662
    def add_source_links(cls, obj, from_user):
1✔
663
        """Adds "bridged from ... by Bridgy Fed" to the user's actor's ``summary``.
664

665
        Uses HTML for protocols that support it, plain text otherwise.
666

667
        Args:
668
          cls (Protocol subclass): protocol that the user is bridging into
669
          obj (models.Object): user's actor/profile object
670
          from_user (models.User): user (actor) this activity/object is from
671
        """
672
        assert obj and obj.as1
1✔
673
        assert from_user
1✔
674

675
        obj.our_as1 = copy.deepcopy(obj.as1)
1✔
676
        actor = (as1.get_object(obj.as1) if obj.type in as1.CRUD_VERBS
1✔
677
                 else obj.as1)
678
        actor['objectType'] = 'person'
1✔
679

680
        orig_summary = actor.setdefault('summary', '')
1✔
681
        summary_text = html_to_text(orig_summary, ignore_links=True)
1✔
682

683
        # Check if we've already added source links
684
        if '🌉 bridged' in summary_text:
1✔
685
            return
1✔
686

687
        actor_id = actor.get('id')
1✔
688

689
        url = (as1.get_url(actor)
1✔
690
               or (from_user.web_url() if from_user.profile_id() == actor_id
691
                   else actor_id))
692

693
        from web import Web
1✔
694
        bot_user = Web.get_by_id(from_user.bot_user_id())
1✔
695

696
        if cls.HTML_PROFILES:
1✔
697
            if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS:
1✔
698
                mention = bot_user.user_link(proto=cls, name=False, handle='short')
1✔
699
                suffix = f', follow {mention} to interact'
1✔
700
            else:
701
                suffix = f' by <a href="https://{PRIMARY_DOMAIN}/">Bridgy Fed</a>'
1✔
702

703
            separator = '<br><br>'
1✔
704

705
            is_user = from_user.key and actor_id in (from_user.key.id(),
1✔
706
                                                     from_user.profile_id())
707
            if is_user:
1✔
708
                bridged = f'🌉 <a href="https://{PRIMARY_DOMAIN}{from_user.user_page_path()}">bridged</a>'
1✔
709
                from_ = f'<a href="{from_user.web_url()}">{from_user.handle}</a>'
1✔
710
            else:
711
                bridged = '🌉 bridged'
1✔
712
                from_ = util.pretty_link(url) if url else '?'
1✔
713

714
        else:  # plain text
715
            # TODO: unify with above. which is right?
716
            id = obj.key.id() if obj.key else obj.our_as1.get('id')
1✔
717
            is_user = from_user.key and id in (from_user.key.id(),
1✔
718
                                               from_user.profile_id())
719
            from_ = (from_user.web_url() if is_user else url) or '?'
1✔
720

721
            bridged = '🌉 bridged'
1✔
722
            suffix = (
1✔
723
                f': https://{PRIMARY_DOMAIN}{from_user.user_page_path()}'
724
                # link web users to their user pages
725
                if from_user.LABEL == 'web'
726
                else f', follow @{bot_user.handle_as(cls)} to interact'
727
                if bot_user and from_user.LABEL not in cls.DEFAULT_ENABLED_PROTOCOLS
728
                else f' by https://{PRIMARY_DOMAIN}/')
729
            separator = '\n\n'
1✔
730
            orig_summary = summary_text
1✔
731

732
        logo = f'{from_user.LOGO_EMOJI} ' if from_user.LOGO_EMOJI else ''
1✔
733
        source_links = f'{separator if orig_summary else ""}{bridged} from {logo}{from_}{suffix}'
1✔
734
        actor['summary'] = orig_summary + source_links
1✔
735

736
    @classmethod
1✔
737
    def set_username(to_cls, user, username):
1✔
738
        """Sets a custom username for a user's bridged account in this protocol.
739

740
        Args:
741
          user (models.User)
742
          username (str)
743

744
        Raises:
745
          ValueError: if the username is invalid
746
          RuntimeError: if the username could not be set
747
        """
748
        raise NotImplementedError()
1✔
749

750
    @classmethod
1✔
751
    def migrate_out(cls, user, to_user_id):
1✔
752
        """Migrates a bridged account out to be a native account.
753

754
        Args:
755
          user (models.User)
756
          to_user_id (str)
757

758
        Raises:
759
          ValueError: eg if this protocol doesn't own ``to_user_id``, or if
760
            ``user`` is on this protocol or not bridged to this protocol
761
        """
762
        raise NotImplementedError()
×
763

764
    @classmethod
1✔
765
    def check_can_migrate_out(cls, user, to_user_id):
1✔
766
        """Raises an exception if a user can't yet migrate to a native account.
767

768
        For example, if ``to_user_id`` isn't on this protocol, or if ``user`` is on
769
        this protocol, or isn't bridged to this protocol.
770

771
        If the user is ready to migrate, returns ``None``.
772

773
        Subclasses may override this to add more criteria, but they should call this
774
        implementation first.
775

776
        Args:
777
          user (models.User)
778
          to_user_id (str)
779

780
        Raises:
781
          ValueError: if ``user`` isn't ready to migrate to this protocol yet
782
        """
783
        def _error(msg):
1✔
784
            logger.warning(msg)
1✔
785
            raise ValueError(msg)
1✔
786

787
        if cls.owns_id(to_user_id) is False:
1✔
788
            _error(f"{to_user_id} doesn't look like an {cls.LABEL} id")
1✔
789
        elif isinstance(user, cls):
1✔
790
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
791
        elif not user.is_enabled(cls):
1✔
792
            _error(f"{user.handle_or_id()} isn't currently bridged to {cls.PHRASE}")
1✔
793

794
    @classmethod
1✔
795
    def migrate_in(cls, user, from_user_id, **kwargs):
1✔
796
        """Migrates a native account in to be a bridged account.
797

798
        The protocol independent parts are done here; protocol-specific parts are
799
        done in :meth:`_migrate_in`, which this wraps.
800

801
        Reloads the user's profile before calling :meth:`_migrate_in`.
802

803
        Args:
804
          user (models.User): native user on another protocol to attach the
805
            newly imported bridged account to
806
          from_user_id (str)
807
          kwargs: additional protocol-specific parameters
808

809
        Raises:
810
          ValueError: eg if this protocol doesn't own ``from_user_id``, or if
811
            ``user`` is on this protocol or already bridged to this protocol
812
        """
813
        def _error(msg):
1✔
814
            logger.warning(msg)
1✔
815
            raise ValueError(msg)
1✔
816

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

819
        # check req'ts
820
        if cls.owns_id(from_user_id) is False:
1✔
821
            _error(f"{from_user_id} doesn't look like an {cls.LABEL} id")
1✔
822
        elif isinstance(user, cls):
1✔
823
            _error(f"{user.handle_or_id()} is on {cls.PHRASE}")
1✔
824
        elif cls.HAS_COPIES and cls.LABEL in user.enabled_protocols:
1✔
825
            _error(f"{user.handle_or_id()} is already bridged to {cls.PHRASE}")
1✔
826

827
        # reload profile
828
        try:
1✔
829
            user.reload_profile()
1✔
830
        except (RequestException, HTTPException) as e:
×
831
            _, msg = util.interpret_http_exception(e)
×
832

833
        # migrate!
834
        cls._migrate_in(user, from_user_id, **kwargs)
1✔
835
        user.add('enabled_protocols', cls.LABEL)
1✔
836
        user.put()
1✔
837

838
        # attach profile object
839
        if user.obj:
1✔
840
            if cls.HAS_COPIES:
1✔
841
                profile_id = ids.profile_id(id=from_user_id, proto=cls)
1✔
842
                user.obj.remove_copies_on(cls)
1✔
843
                user.obj.add('copies', Target(uri=profile_id, protocol=cls.LABEL))
1✔
844
                user.obj.put()
1✔
845

846
            common.create_task(queue='receive', obj_id=user.obj_key.id(),
1✔
847
                               authed_as=user.key.id())
848

849
    @classmethod
1✔
850
    def _migrate_in(cls, user, from_user_id, **kwargs):
1✔
851
        """Protocol-specific parts of migrating in external account.
852

853
        Called by :meth:`migrate_in`, which does most of the work, including calling
854
        :meth:`reload_profile` before this.
855

856
        Args:
857
          user (models.User): native user on another protocol to attach the
858
            newly imported account to. Unused.
859
          from_user_id (str): DID of the account to be migrated in
860
          kwargs: protocol dependent
861
        """
862
        raise NotImplementedError()
×
863

864
    @classmethod
1✔
865
    def target_for(cls, obj, shared=False):
1✔
866
        """Returns an :class:`Object`'s delivery target (endpoint).
867

868
        To be implemented by subclasses.
869

870
        Examples:
871

872
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
873
          webmention target.
874
        * If obj is an ``activitypub`` actor, returns its inbox.
875
        * If obj is an ``activitypub`` object, returns it's author's or actor's
876
          inbox.
877

878
        Args:
879
          obj (models.Object):
880
          shared (bool): optional. If True, returns a common/shared
881
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
882
            multiple recipients for efficiency
883

884
        Returns:
885
          str: target endpoint, or None if not available.
886
        """
887
        raise NotImplementedError()
×
888

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

893
        Default implementation here, subclasses may override.
894

895
        Args:
896
          url (str):
897
          allow_internal (bool): whether to return False for internal domains
898
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
899
        """
900
        blocklist = DOMAIN_BLOCKLIST
1✔
901
        if not DEBUG:
1✔
902
            blocklist += tuple(util.RESERVED_TLDS | util.LOCAL_TLDS)
1✔
903
        if not allow_internal:
1✔
904
            blocklist += DOMAINS
1✔
905
        return util.domain_or_parent_in(url, blocklist)
1✔
906

907
    @classmethod
1✔
908
    def translate_ids(to_cls, obj):
1✔
909
        """Translates all ids in an AS1 object to a specific protocol.
910

911
        Infers source protocol for each id value separately.
912

913
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
914
        ``at://did:plc:abc/coll/123`` will be converted to
915
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
916

917
        Wraps these AS1 fields:
918

919
        * ``id``
920
        * ``actor``
921
        * ``author``
922
        * ``bcc``
923
        * ``bto``
924
        * ``cc``
925
        * ``featured[].items``, ``featured[].orderedItems``
926
        * ``object``
927
        * ``object.actor``
928
        * ``object.author``
929
        * ``object.id``
930
        * ``object.inReplyTo``
931
        * ``object.object``
932
        * ``attachments[].id``
933
        * ``tags[objectType=mention].url``
934
        * ``to``
935

936
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
937
        same logic is duplicated there!
938

939
        TODO: unify with :meth:`Object.resolve_ids`,
940
        :meth:`models.Object.normalize_ids`.
941

942
        Args:
943
          to_proto (Protocol subclass)
944
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
945

946
        Returns:
947
          dict: translated AS1 version of ``obj``
948
        """
949
        assert to_cls != Protocol
1✔
950
        if not obj:
1✔
951
            return obj
1✔
952

953
        outer_obj = to_cls.translate_mention_handles(copy.deepcopy(obj))
1✔
954
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
955

956
        def translate(elem, field, fn, uri=False):
1✔
957
            elem[field] = as1.get_objects(elem, field)
1✔
958
            for obj in elem[field]:
1✔
959
                if id := obj.get('id'):
1✔
960
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
961
                        continue
1✔
962
                    from_cls = Protocol.for_id(id)
1✔
963
                    # TODO: what if from_cls is None? relax translate_object_id,
964
                    # make it a noop if we don't know enough about from/to?
965
                    if from_cls and from_cls != to_cls:
1✔
966
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
967
                    if obj['id'] and uri:
1✔
968
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
969

970
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
971
                           for o in elem[field]]
972

973
            if len(elem[field]) == 1 and field not in ('items', 'orderedItems'):
1✔
974
                elem[field] = elem[field][0]
1✔
975

976
        type = as1.object_type(outer_obj)
1✔
977
        translate(outer_obj, 'id',
1✔
978
                  ids.translate_user_id if type in as1.ACTOR_TYPES
979
                  else ids.translate_object_id)
980

981
        for o in inner_objs:
1✔
982
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
983
                        or as1.get_owner(outer_obj) == o.get('id')
984
                        or type in ('follow', 'stop-following'))
985
            translate(o, 'id', (ids.translate_user_id if is_actor
1✔
986
                                else ids.translate_object_id))
987
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
988
            translate(o, 'object', (ids.translate_user_id if obj_is_actor
1✔
989
                                    else ids.translate_object_id))
990

991
        for o in [outer_obj] + inner_objs:
1✔
992
            translate(o, 'inReplyTo', ids.translate_object_id)
1✔
993
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
994
                translate(o, field, ids.translate_user_id)
1✔
995
            for tag in as1.get_objects(o, 'tags'):
1✔
996
                if tag.get('objectType') == 'mention':
1✔
997
                    translate(tag, 'url', ids.translate_user_id, uri=True)
1✔
998
            for att in as1.get_objects(o, 'attachments'):
1✔
999
                translate(att, 'id', ids.translate_object_id)
1✔
1000
                url = att.get('url')
1✔
1001
                if url and not att.get('id'):
1✔
1002
                    if from_cls := Protocol.for_id(url):
1✔
1003
                        att['id'] = ids.translate_object_id(from_=from_cls, to=to_cls,
1✔
1004
                                                            id=url)
1005
            if feat := as1.get_object(o, 'featured'):
1✔
1006
                translate(feat, 'orderedItems', ids.translate_object_id)
1✔
1007
                translate(feat, 'items', ids.translate_object_id)
1✔
1008

1009
        outer_obj = util.trim_nulls(outer_obj)
1✔
1010

1011
        if objs := util.get_list(outer_obj ,'object'):
1✔
1012
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
1013
            if len(outer_obj['object']) == 1:
1✔
1014
                outer_obj['object'] = outer_obj['object'][0]
1✔
1015

1016
        return outer_obj
1✔
1017

1018
    @classmethod
1✔
1019
    def translate_mention_handles(cls, obj):
1✔
1020
        """Translates @-mentions in ``obj.content`` to this protocol's handles.
1021

1022
        Specifically, for each ``mention`` tag in the object's tags that has
1023
        ``startIndex`` and ``length``, replaces it in ``obj.content`` with that
1024
        user's translated handle in this protocol and updates the tag's location.
1025

1026
        Called by :meth:`Protocol.translate_ids`.
1027

1028
        If ``obj.content`` is HTML, does nothing.
1029

1030
        Args:
1031
          obj (dict): AS2 object
1032

1033
        Returns:
1034
          dict: modified AS2 object
1035
        """
1036
        if not obj:
1✔
1037
            return None
×
1038

1039
        obj = copy.deepcopy(obj)
1✔
1040
        obj['object'] = [cls.translate_mention_handles(o)
1✔
1041
                                for o in as1.get_objects(obj)]
1042
        if len(obj['object']) == 1:
1✔
1043
            obj['object'] = obj['object'][0]
1✔
1044

1045
        content = obj.get('content')
1✔
1046
        tags = obj.get('tags')
1✔
1047
        if (not content or not tags
1✔
1048
                or obj.get('content_is_html')
1049
                or bool(BeautifulSoup(content, 'html.parser').find())
1050
                or HTML_ENTITY_RE.search(content)):
1051
            return util.trim_nulls(obj)
1✔
1052

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

1055
        offset = 0
1✔
1056
        for tag in sorted(indexed, key=lambda t: t['startIndex']):
1✔
1057
            tag['startIndex'] += offset
1✔
1058
            if tag.get('objectType') == 'mention' and (id := tag['url']):
1✔
1059
                if proto := Protocol.for_id(id):
1✔
1060
                    id = ids.normalize_user_id(id=id, proto=proto)
1✔
1061
                    if key := get_original_user_key(id):
1✔
1062
                        user = key.get()
×
1063
                    else:
1064
                        user = proto.get_or_create(id, allow_opt_out=True)
1✔
1065
                    if user:
1✔
1066
                        start = tag['startIndex']
1✔
1067
                        end = start + tag['length']
1✔
1068
                        if handle := user.handle_as(cls):
1✔
1069
                            content = content[:start] + handle + content[end:]
1✔
1070
                            offset += len(handle) - tag['length']
1✔
1071
                            tag.update({
1✔
1072
                                'displayName': handle,
1073
                                'length': len(handle),
1074
                            })
1075

1076
        obj['tags'] = tags
1✔
1077
        as2.set_content(obj, content)  # sets content *and* contentMap
1✔
1078
        return util.trim_nulls(obj)
1✔
1079

1080
    @classmethod
1✔
1081
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
1082
        """Handles an incoming activity.
1083

1084
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
1085
        unset, returns HTTP 299.
1086

1087
        Args:
1088
          obj (models.Object)
1089
          authed_as (str): authenticated actor id who sent this activity
1090
          internal (bool): whether to allow activity ids on internal domains,
1091
            from opted out/blocked users, etc.
1092
          received_at (datetime): when we first saw (received) this activity.
1093
            Right now only used for monitoring.
1094

1095
        Returns:
1096
          (str, int) tuple: (response body, HTTP status code) Flask response
1097

1098
        Raises:
1099
          werkzeug.HTTPException: if the request is invalid
1100
        """
1101
        # check some invariants
1102
        assert from_cls != Protocol
1✔
1103
        assert isinstance(obj, Object), obj
1✔
1104

1105
        if not obj.as1:
1✔
1106
            error('No object data provided')
1✔
1107

1108
        orig_obj = obj
1✔
1109
        id = None
1✔
1110
        if obj.key and obj.key.id():
1✔
1111
            id = obj.key.id()
1✔
1112

1113
        if not id:
1✔
1114
            id = obj.as1.get('id')
1✔
1115
            obj.key = ndb.Key(Object, id)
1✔
1116

1117
        if not id:
1✔
1118
            error('No id provided')
×
1119
        elif from_cls.owns_id(id) is False:
1✔
1120
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
1121
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
1122
            error(f'Activity {id} is blocklisted')
1✔
1123

1124
        # does this protocol support this activity/object type?
1125
        from_cls.check_supported(obj, 'receive')
1✔
1126

1127
        # lease this object, atomically
1128
        memcache_key = activity_id_memcache_key(id)
1✔
1129
        leased = memcache.memcache.add(
1✔
1130
            memcache_key, 'leased', noreply=False,
1131
            expire=int(MEMCACHE_LEASE_EXPIRATION.total_seconds()))
1132

1133
        # short circuit if we've already seen this activity id.
1134
        # (don't do this for bare objects since we need to check further down
1135
        # whether they've been updated since we saw them last.)
1136
        if (obj.as1.get('objectType') == 'activity'
1✔
1137
            and 'force' not in request.values
1138
            and (not leased
1139
                 or (obj.new is False and obj.changed is False))):
1140
            error(f'Already seen this activity {id}', status=204)
1✔
1141

1142
        pruned = {k: v for k, v in obj.as1.items()
1✔
1143
                  if k not in ('contentMap', 'replies', 'signature')}
1144
        delay = ''
1✔
1145
        retry = request.headers.get('X-AppEngine-TaskRetryCount')
1✔
1146
        if (received_at and retry in (None, '0')
1✔
1147
                and obj.type not in ('delete', 'undo')):  # we delay deletes/undos
1148
            delay_s = int((util.now().replace(tzinfo=None)
1✔
1149
                           - received_at.replace(tzinfo=None)
1150
                           ).total_seconds())
1151
            delay = f'({delay_s} s behind)'
1✔
1152
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
1153

1154
        # check authorization
1155
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1156
        actor = as1.get_owner(obj.as1)
1✔
1157
        if not actor:
1✔
1158
            error('Activity missing actor or author')
1✔
1159
        elif from_cls.owns_id(actor) is False:
1✔
1160
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
1161

1162
        assert authed_as
1✔
1163
        assert isinstance(authed_as, str)
1✔
1164
        authed_as = ids.normalize_user_id(id=authed_as, proto=from_cls)
1✔
1165
        actor = ids.normalize_user_id(id=actor, proto=from_cls)
1✔
1166
        if actor != authed_as:
1✔
1167
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
1168
                         user=f'{id} authed_as {authed_as} owner {actor}')
1169
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
1170

1171
        # update copy ids to originals
1172
        obj.normalize_ids()
1✔
1173
        obj.resolve_ids()
1✔
1174

1175
        if (obj.type == 'follow'
1✔
1176
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
1177
            # follows of bot user; refresh user profile first
1178
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
1179
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
1180
            from_user.reload_profile()
1✔
1181
        else:
1182
            # load actor user
1183
            #
1184
            # TODO: we should maybe eventually allow non-None status users here if
1185
            # this is a profile update, so that we store the user again below and
1186
            # re-calculate its status. right now, if a bridged user updates their
1187
            # profile and invalidates themselves, eg by removing their profile
1188
            # picture, and then updates again to make themselves valid again, we'll
1189
            # ignore the second update. they'll have to un-bridge and re-bridge
1190
            # themselves to get back working again.
1191
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
1192

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

1196
        # check if this is a profile object coming in via a user with use_instead
1197
        # set. if so, override the object's id to be the final user id (from_user's),
1198
        # after following use_instead.
1199
        if obj.type in as1.ACTOR_TYPES and from_user.key.id() != actor:
1✔
1200
            as1_id = obj.as1.get('id')
1✔
1201
            if ids.normalize_user_id(id=as1_id, proto=from_user) == actor:
1✔
1202
                logger.info(f'Overriding AS1 object id {as1_id} with Object id {from_user.profile_id()}')
1✔
1203
                obj.our_as1 = {**obj.as1, 'id': from_user.profile_id()}
1✔
1204

1205
        # if this is an object, ie not an activity, wrap it in a create or update
1206
        obj = from_cls.handle_bare_object(obj, authed_as=authed_as,
1✔
1207
                                          from_user=from_user)
1208
        obj.add('users', from_user.key)
1✔
1209

1210
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1211
        inner_obj_id = inner_obj_as1.get('id')
1✔
1212
        if obj.type in as1.CRUD_VERBS | as1.VERBS_WITH_OBJECT:
1✔
1213
            if not inner_obj_id:
1✔
1214
                error(f'{obj.type} object has no id!')
1✔
1215

1216
        # check age. we support backdated posts, but if they're over 2w old, we
1217
        # don't deliver them
1218
        if obj.type == 'post':
1✔
1219
            if published := inner_obj_as1.get('published'):
1✔
1220
                try:
1✔
1221
                    published_dt = util.parse_iso8601(published)
1✔
1222
                    if not published_dt.tzinfo:
1✔
1223
                        published_dt = published_dt.replace(tzinfo=timezone.utc)
×
1224
                    age = util.now() - published_dt
1✔
1225
                    if age > CREATE_MAX_AGE and 'force' not in request.values:
1✔
1226
                        error(f'Ignoring, too old, {age} is over {CREATE_MAX_AGE}',
×
1227
                              status=204)
1228
                except ValueError:  # from parse_iso8601
×
1229
                    logger.debug(f"Couldn't parse published {published}")
×
1230

1231
        # write Object to datastore
1232
        obj.source_protocol = from_cls.LABEL
1✔
1233
        if obj.type in STORE_AS1_TYPES:
1✔
1234
            obj.put()
1✔
1235

1236
        # store inner object
1237
        # TODO: unify with big obj.type conditional below. would have to merge
1238
        # this with the DM handling block lower down.
1239
        crud_obj = None
1✔
1240
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
1241
            crud_obj = Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
1242
                                            source_protocol=from_cls.LABEL,
1243
                                            authed_as=actor, users=[from_user.key],
1244
                                            deleted=False)
1245

1246
        actor = as1.get_object(obj.as1, 'actor')
1✔
1247
        actor_id = actor.get('id')
1✔
1248

1249
        # handle activity!
1250
        if obj.type == 'stop-following':
1✔
1251
            # TODO: unify with handle_follow?
1252
            # TODO: handle multiple followees
1253
            if not actor_id or not inner_obj_id:
1✔
1254
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
1255

1256
            # deactivate Follower
1257
            from_ = from_cls.key_for(actor_id)
1✔
1258
            if not (to_cls := Protocol.for_id(inner_obj_id)):
1✔
1259
                error(f"Can't determine protocol for {inner_obj_id} , giving up")
1✔
1260
            to = to_cls.key_for(inner_obj_id)
1✔
1261
            follower = Follower.query(Follower.to == to,
1✔
1262
                                      Follower.from_ == from_,
1263
                                      Follower.status == 'active').get()
1264
            if follower:
1✔
1265
                logger.info(f'Marking {follower} inactive')
1✔
1266
                follower.status = 'inactive'
1✔
1267
                follower.put()
1✔
1268
            else:
1269
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1270

1271
            # fall through to deliver to followee
1272
            # TODO: do we convert stop-following to webmention 410 of original
1273
            # follow?
1274

1275
            # fall through to deliver to followers
1276

1277
        elif obj.type in ('delete', 'undo'):
1✔
1278
            delete_obj_id = (from_user.profile_id()
1✔
1279
                            if inner_obj_id == from_user.key.id()
1280
                            else inner_obj_id)
1281

1282
            delete_obj = Object.get_by_id(delete_obj_id, authed_as=authed_as)
1✔
1283
            if not delete_obj:
1✔
1284
                logger.info(f"Ignoring, we don't have {delete_obj_id} stored")
1✔
1285
                return 'OK', 204
1✔
1286

1287
            # TODO: just delete altogether!
1288
            logger.info(f'Marking Object {delete_obj_id} deleted')
1✔
1289
            delete_obj.deleted = True
1✔
1290
            delete_obj.put()
1✔
1291

1292
            # if this is an actor, handle deleting it later so that
1293
            # in case it's from_user, user.enabled_protocols is still populated
1294
            #
1295
            # fall through to deliver to followers and delete copy if necessary.
1296
            # should happen via protocol-specific copy target and send of
1297
            # delete activity.
1298
            # https://github.com/snarfed/bridgy-fed/issues/63
1299

1300
        elif obj.type == 'block':
1✔
1301
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1302
                # blocking protocol bot user disables that protocol
1303
                from_user.delete(proto)
1✔
1304
                from_user.disable_protocol(proto)
1✔
1305
                return 'OK', 200
1✔
1306

1307
        elif obj.type == 'post':
1✔
1308
            # handle DMs to bot users
1309
            if as1.is_dm(obj.as1):
1✔
1310
                return dms.receive(from_user=from_user, obj=obj)
1✔
1311

1312
        # fetch actor if necessary
1313
        is_user = (inner_obj_id in (from_user.key.id(), from_user.profile_id())
1✔
1314
                   or from_user.is_profile(orig_obj))
1315
        if (actor and actor.keys() == set(['id'])
1✔
1316
                and not is_user and obj.type not in ('delete', 'undo')):
1317
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1318
            actor_obj = from_cls.load(ids.profile_id(id=actor['id'], proto=from_cls),
1✔
1319
                                      raise_=False)
1320
            if actor_obj and actor_obj.as1:
1✔
1321
                obj.our_as1 = {
1✔
1322
                    **obj.as1, 'actor': {
1323
                        **actor_obj.as1,
1324
                        # override profile id with actor id
1325
                        # https://github.com/snarfed/bridgy-fed/issues/1720
1326
                        'id': actor['id'],
1327
                    }
1328
                }
1329

1330
        # fetch object if necessary
1331
        if (obj.type in ('post', 'update', 'share')
1✔
1332
                and inner_obj_as1.keys() == set(['id'])
1333
                and from_cls.owns_id(inner_obj_id) is not False):
1334
            logger.debug('Fetching inner object')
1✔
1335
            inner_obj = from_cls.load(inner_obj_id, raise_=False,
1✔
1336
                                      remote=(obj.type in ('post', 'update')))
1337
            if obj.type in ('post', 'update'):
1✔
1338
                crud_obj = inner_obj
1✔
1339
            if inner_obj and inner_obj.as1:
1✔
1340
                obj.our_as1 = {
1✔
1341
                    **obj.as1,
1342
                    'object': {
1343
                        **inner_obj_as1,
1344
                        **inner_obj.as1,
1345
                    }
1346
                }
1347
            elif obj.type in ('post', 'update'):
1✔
1348
                error(f"Need object {inner_obj_id} but couldn't fetch, giving up")
1✔
1349

1350
        if obj.type == 'follow':
1✔
1351
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1352
                # follow of one of our protocol bot users; enable that protocol.
1353
                # fall through so that we send an accept.
1354
                try:
1✔
1355
                    from_user.enable_protocol(proto)
1✔
1356
                except ErrorButDoNotRetryTask:
1✔
1357
                    from web import Web
1✔
1358
                    bot = Web.get_by_id(proto.bot_user_id())
1✔
1359
                    from_cls.respond_to_follow('reject', follower=from_user,
1✔
1360
                                               followee=bot, follow=obj)
1361
                    raise
1✔
1362
                proto.bot_maybe_follow_back(from_user)
1✔
1363

1364
            from_cls.handle_follow(obj, from_user=from_user)
1✔
1365

1366
        # on update of the user's own actor/profile, set user.obj and store user back
1367
        # to datastore so that we recalculate computed properties like status etc
1368
        if is_user:
1✔
1369
            if obj.type == 'update' and crud_obj:
1✔
1370
                logger.info("update of the user's profile, re-storing user")
1✔
1371
                from_user.obj = crud_obj
1✔
1372
                from_user.put()
1✔
1373

1374
        # deliver to targets
1375
        resp = from_cls.deliver(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1376

1377
        # on user deleting themselves, deactivate their followers/followings.
1378
        # https://github.com/snarfed/bridgy-fed/issues/1304
1379
        #
1380
        # do this *after* delivering because delivery finds targets based on
1381
        # stored Followers
1382
        if is_user and obj.type == 'delete':
1✔
1383
            for proto in from_user.enabled_protocols:
1✔
1384
                from_user.disable_protocol(PROTOCOLS[proto])
1✔
1385

1386
            logger.info(f'Deactivating Followers from or to {from_user.key.id()}')
1✔
1387
            followers = Follower.query(
1✔
1388
                OR(Follower.to == from_user.key, Follower.from_ == from_user.key)
1389
            ).fetch()
1390
            for f in followers:
1✔
1391
                f.status = 'inactive'
1✔
1392
            ndb.put_multi(followers)
1✔
1393

1394
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1395
        return resp
1✔
1396

1397
    @classmethod
1✔
1398
    def handle_follow(from_cls, obj, from_user):
1✔
1399
        """Handles an incoming follow activity.
1400

1401
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1402
        happens in :meth:`deliver`.
1403

1404
        Args:
1405
          obj (models.Object): follow activity
1406
        """
1407
        logger.debug('Got follow. storing Follow(s), sending accept(s)')
1✔
1408
        from_id = from_user.key.id()
1✔
1409

1410
        # Prepare followee (to) users' data
1411
        to_as1s = as1.get_objects(obj.as1)
1✔
1412
        if not to_as1s:
1✔
1413
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1414

1415
        # Store Followers
1416
        for to_as1 in to_as1s:
1✔
1417
            to_id = to_as1.get('id')
1✔
1418
            if not to_id:
1✔
1419
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1420

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

1423
            to_cls = Protocol.for_id(to_id)
1✔
1424
            if not to_cls:
1✔
1425
                error(f"Couldn't determine protocol for {to_id}")
×
1426
            elif from_cls == to_cls:
1✔
1427
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1428
                continue
1✔
1429

1430
            to_key = to_cls.key_for(to_id)
1✔
1431
            if not to_key:
1✔
1432
                logger.info(f'Skipping invalid {from_cls.LABEL} user key: {from_id}')
×
1433
                continue
×
1434

1435
            to_user = to_cls.get_or_create(id=to_key.id())
1✔
1436
            if not to_user or not to_user.is_enabled(from_user):
1✔
1437
                error(f'{to_id} not found')
1✔
1438

1439
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1440
                                                  follow=obj.key, status='active')
1441
            obj.add('notify', to_key)
1✔
1442
            from_cls.respond_to_follow('accept', follower=from_user,
1✔
1443
                                       followee=to_user, follow=obj)
1444

1445
    @classmethod
1✔
1446
    def respond_to_follow(_, verb, follower, followee, follow):
1✔
1447
        """Sends an accept or reject activity for a follow.
1448

1449
        ...if the follower's protocol supports accepts/rejects. Otherwise, does
1450
        nothing.
1451

1452
        Args:
1453
          verb (str): ``accept`` or  ``reject``
1454
          follower (models.User)
1455
          followee (models.User)
1456
          follow (models.Object)
1457
        """
1458
        assert verb in ('accept', 'reject')
1✔
1459
        if verb not in follower.SUPPORTED_AS1_TYPES:
1✔
1460
            return
1✔
1461

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

1465
        # send. note that this is one response for the whole follow, even if it
1466
        # has multiple followees!
1467
        id = f'{followee.key.id()}/followers#{verb}-{follow.key.id()}'
1✔
1468
        accept = {
1✔
1469
            'id': id,
1470
            'objectType': 'activity',
1471
            'verb': verb,
1472
            'actor': followee.key.id(),
1473
            'object': follow.as1,
1474
        }
1475
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1476
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1477

1478
    @classmethod
1✔
1479
    def bot_maybe_follow_back(bot_cls, user):
1✔
1480
        """Follow a user from a protocol bot user, if their protocol needs that.
1481

1482
        ...so that the protocol starts sending us their activities, if it needs
1483
        a follow for that (eg ActivityPub).
1484

1485
        Args:
1486
          user (User)
1487
        """
1488
        if not user.BOTS_FOLLOW_BACK:
1✔
1489
            return
1✔
1490

1491
        from web import Web
1✔
1492
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1493
        now = util.now().isoformat()
1✔
1494
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1495

1496
        if not user.obj:
1✔
1497
            logger.info("  can't follow, user has no profile obj")
1✔
1498
            return
1✔
1499

1500
        target = user.target_for(user.obj)
1✔
1501
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1502
        follow_back_as1 = {
1✔
1503
            'objectType': 'activity',
1504
            'verb': 'follow',
1505
            'id': follow_back_id,
1506
            'actor': bot.key.id(),
1507
            'object': user.key.id(),
1508
        }
1509
        common.create_task(queue='send', id=follow_back_id,
1✔
1510
                           our_as1=follow_back_as1, url=target,
1511
                           source_protocol='web', protocol=user.LABEL,
1512
                           user=bot.key.urlsafe())
1513

1514
    @classmethod
1✔
1515
    def handle_bare_object(cls, obj, *, authed_as, from_user):
1✔
1516
        """If obj is a bare object, wraps it in a create or update activity.
1517

1518
        Checks if we've seen it before.
1519

1520
        Args:
1521
          obj (models.Object)
1522
          authed_as (str): authenticated actor id who sent this activity
1523
          from_user (models.User): user (actor) this activity/object is from
1524

1525
        Returns:
1526
          models.Object: ``obj`` if it's an activity, otherwise a new object
1527
        """
1528
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1529
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1530
            return obj
1✔
1531

1532
        obj_actor = ids.normalize_user_id(id=as1.get_owner(obj.as1), proto=cls)
1✔
1533
        now = util.now().isoformat()
1✔
1534

1535
        # this is a raw post; wrap it in a create or update activity
1536
        if obj.changed or is_actor:
1✔
1537
            if obj.changed:
1✔
1538
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1539
            else:
1540
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1541
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1542
            update_as1 = {
1✔
1543
                'objectType': 'activity',
1544
                'verb': 'update',
1545
                'id': id,
1546
                'actor': obj_actor,
1547
                'object': {
1548
                    # Mastodon requires the updated field for Updates, so
1549
                    # add a default value.
1550
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1551
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1552
                    # https://github.com/mastodon/documentation/pull/1150
1553
                    'updated': now,
1554
                    **obj.as1,
1555
                },
1556
            }
1557
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1558
            return Object(id=id, our_as1=update_as1,
1✔
1559
                          source_protocol=obj.source_protocol)
1560

1561
        if (obj.new
1✔
1562
                # HACK: force query param here is specific to webmention
1563
                or 'force' in request.form):
1564
            create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1565
            create_as1 = {
1✔
1566
                'objectType': 'activity',
1567
                'verb': 'post',
1568
                'id': create_id,
1569
                'actor': obj_actor,
1570
                'object': obj.as1,
1571
                'published': now,
1572
            }
1573
            logger.info(f'Wrapping in post')
1✔
1574
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1575
            return Object(id=create_id, our_as1=create_as1,
1✔
1576
                          source_protocol=obj.source_protocol)
1577

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

1580
    @classmethod
1✔
1581
    def deliver(from_cls, obj, from_user, crud_obj=None, to_proto=None):
1✔
1582
        """Delivers an activity to its external recipients.
1583

1584
        Args:
1585
          obj (models.Object): activity to deliver
1586
          from_user (models.User): user (actor) this activity is from
1587
          crud_obj (models.Object): if this is a create, update, or delete/undo
1588
            activity, the inner object that's being written, otherwise None.
1589
            (This object's ``notify`` and ``feed`` properties may be updated.)
1590
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1591
            targets on this protocol
1592

1593
        Returns:
1594
          (str, int) tuple: Flask response
1595
        """
1596
        if to_proto:
1✔
1597
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1598

1599
        # find delivery targets. maps Target to Object or None
1600
        #
1601
        # ...then write the relevant object, since targets() has a side effect of
1602
        # setting the notify and feed properties (and dirty attribute)
1603
        targets = from_cls.targets(obj, from_user=from_user, crud_obj=crud_obj)
1✔
1604
        if to_proto:
1✔
1605
            targets = {t: obj for t, obj in targets.items()
1✔
1606
                       if t.protocol == to_proto.LABEL}
1607
        if not targets:
1✔
1608
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1609

1610
        # store object that targets() updated
1611
        if crud_obj and crud_obj.dirty:
1✔
1612
            crud_obj.put()
1✔
1613
        elif obj.type in STORE_AS1_TYPES and obj.dirty:
1✔
1614
            obj.put()
1✔
1615

1616
        obj_params = ({'obj_id': obj.key.id()} if obj.type in STORE_AS1_TYPES
1✔
1617
                      else obj.to_request())
1618

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

1622
        # enqueue send task for each targets
1623
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1624
        user = from_user.key.urlsafe()
1✔
1625
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1626
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1627
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1628
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1629

1630
        return 'OK', 202
1✔
1631

1632
    @classmethod
1✔
1633
    def targets(from_cls, obj, from_user, crud_obj=None, internal=False):
1✔
1634
        """Collects the targets to send a :class:`models.Object` to.
1635

1636
        Targets are both objects - original posts, events, etc - and actors.
1637

1638
        Args:
1639
          obj (models.Object)
1640
          from_user (User)
1641
          crud_obj (models.Object): if this is a create, update, or delete/undo
1642
            activity, the inner object that's being written, otherwise None.
1643
            (This object's ``notify`` and ``feed`` properties may be updated.)
1644
          internal (bool): whether this is a recursive internal call
1645

1646
        Returns:
1647
          dict: maps :class:`models.Target` to original (in response to)
1648
          :class:`models.Object`, if any, otherwise None
1649
        """
1650
        logger.debug('Finding recipients and their targets')
1✔
1651

1652
        # we should only have crud_obj iff this is a create or update
1653
        assert (crud_obj is not None) == (obj.type in ('post', 'update')), obj.type
1✔
1654
        write_obj = crud_obj or obj
1✔
1655
        write_obj.dirty = False
1✔
1656

1657
        target_uris = as1.targets(obj.as1)
1✔
1658
        orig_obj = None
1✔
1659
        targets = {}  # maps Target to Object or None
1✔
1660
        owner = as1.get_owner(obj.as1)
1✔
1661
        allow_opt_out = (obj.type == 'delete')
1✔
1662
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1663
        inner_obj_id = inner_obj_as1.get('id')
1✔
1664
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1665
        quoted_posts = as1.quoted_posts(inner_obj_as1)
1✔
1666
        mentioned_urls = as1.mentions(inner_obj_as1)
1✔
1667
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1668
        is_self_reply = False
1✔
1669

1670
        original_ids = []
1✔
1671
        if is_reply:
1✔
1672
            original_ids = in_reply_tos
1✔
1673
        elif inner_obj_id:
1✔
1674
            if inner_obj_id == from_user.key.id():
1✔
1675
                inner_obj_id = from_user.profile_id()
1✔
1676
            original_ids = [inner_obj_id]
1✔
1677

1678
        # maps id to Object
1679
        original_objs = {}
1✔
1680
        for id in original_ids:
1✔
1681
            if proto := Protocol.for_id(id):
1✔
1682
                original_objs[id] = proto.load(id, raise_=False)
1✔
1683

1684
        # for AP, add in-reply-tos' mentions
1685
        # https://github.com/snarfed/bridgy-fed/issues/1608
1686
        # https://github.com/snarfed/bridgy-fed/issues/1218
1687
        orig_post_mentions = {}  # maps mentioned id to original post Object
1✔
1688
        for id in in_reply_tos:
1✔
1689
            if ((in_reply_to_obj := original_objs.get(id))
1✔
1690
                    and (proto := PROTOCOLS.get(in_reply_to_obj.source_protocol))
1691
                    and proto.SEND_REPLIES_TO_ORIG_POSTS_MENTIONS
1692
                    and (mentions := as1.mentions(in_reply_to_obj.as1))):
1693
                logger.info(f"Adding in-reply-to {id} 's mentions to targets: {mentions}")
1✔
1694
                target_uris.extend(mentions)
1✔
1695
                for mention in mentions:
1✔
1696
                    orig_post_mentions[mention] = in_reply_to_obj
1✔
1697

1698
        target_uris = sorted(set(target_uris))
1✔
1699
        logger.info(f'Raw targets: {target_uris}')
1✔
1700

1701
        # which protocols should we allow delivering to?
1702
        to_protocols = []
1✔
1703
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1704
                      + from_user.enabled_protocols):
1705
            if not (proto := PROTOCOLS.get(label)):
1✔
1706
                report_error(f'unknown enabled protocol {label} for {from_user.key.id()}')
1✔
1707
                continue
1✔
1708

1709
            if (obj.type == 'post' and (orig := original_objs.get(inner_obj_id))
1✔
1710
                    and orig.get_copy(proto)):
1711
                logger.info(f'Already created {id} on {label}, cowardly refusing to create there again')
1✔
1712
                continue
1✔
1713

1714
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1715
                                     or is_reply):
1716
                origs_could_bridge = None
1✔
1717

1718
                for id in original_ids:
1✔
1719
                    if not (orig := original_objs.get(id)):
1✔
1720
                        continue
1✔
1721
                    elif isinstance(orig, proto):
1✔
1722
                        logger.info(f'Allowing {label} for original {id}')
×
1723
                        break
×
1724
                    elif orig.get_copy(proto):
1✔
1725
                        logger.info(f'Allowing {label}, original {id} was bridged there')
1✔
1726
                        break
1✔
1727
                    elif from_user.is_profile(orig):
1✔
1728
                        logger.info(f"Allowing {label}, this is the user's profile")
1✔
1729
                        break
1✔
1730

1731
                    if (origs_could_bridge is not False
1✔
1732
                            and (orig_author_id := as1.get_owner(orig.as1))
1733
                            and (orig_proto := PROTOCOLS.get(orig.source_protocol))
1734
                            and (orig_author := orig_proto.get_by_id(orig_author_id))):
1735
                        origs_could_bridge = orig_author.is_enabled(proto)
1✔
1736

1737
                else:
1738
                    msg = f"original object(s) {original_ids} weren't bridged to {label}"
1✔
1739
                    if (proto.LABEL not in from_user.DEFAULT_ENABLED_PROTOCOLS
1✔
1740
                            and origs_could_bridge):
1741
                        # retry later; original obj may still be bridging
1742
                        # TODO: limit to brief window, eg no older than 2h? 1d?
1743
                        error(msg, status=304)
1✔
1744

1745
                    logger.info(msg)
1✔
1746
                    continue
1✔
1747

1748
            util.add(to_protocols, proto)
1✔
1749

1750
        # process direct targets
1751
        for target_id in target_uris:
1✔
1752
            target_proto = Protocol.for_id(target_id)
1✔
1753
            if not target_proto:
1✔
1754
                logger.info(f"Can't determine protocol for {target_id}")
1✔
1755
                continue
1✔
1756
            elif target_proto.is_blocklisted(target_id):
1✔
1757
                logger.debug(f'{target_id} is blocklisted')
1✔
1758
                continue
1✔
1759

1760
            target_obj_id = target_id
1✔
1761
            if target_id in mentioned_urls:
1✔
1762
                target_obj_id = ids.profile_id(id=target_id, proto=target_proto)
1✔
1763

1764
            orig_obj = target_proto.load(target_obj_id, raise_=False)
1✔
1765
            if not orig_obj or not orig_obj.as1:
1✔
1766
                logger.info(f"Couldn't load {target_obj_id}")
1✔
1767
                continue
1✔
1768

1769
            target_author_key = (target_proto(id=target_id).key
1✔
1770
                                 if target_id in mentioned_urls
1771
                                 else target_proto.actor_key(orig_obj))
1772
            if not from_user.is_enabled(target_proto):
1✔
1773
                # if author isn't bridged and target user is, DM a prompt and
1774
                # add a notif for the target user
1775
                if (target_id in (in_reply_tos + quoted_posts + mentioned_urls)
1✔
1776
                        and target_author_key):
1777
                    if target_author := target_author_key.get():
1✔
1778
                        if target_author.is_enabled(from_cls):
1✔
1779
                            notifications.add_notification(target_author, write_obj)
1✔
1780
                            verb, noun = (
1✔
1781
                                ('replied to', 'replies') if target_id in in_reply_tos
1782
                                else ('quoted', 'quotes') if target_id in quoted_posts
1783
                                else ('mentioned', 'mentions'))
1784
                            dms.maybe_send(from_=target_proto, to_user=from_user,
1✔
1785
                                           type='replied_to_bridged_user', text=f"""\
1786
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.""")
1787

1788
                continue
1✔
1789

1790
            # deliver self-replies to followers
1791
            # https://github.com/snarfed/bridgy-fed/issues/639
1792
            if target_id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1793
                is_self_reply = True
1✔
1794
                logger.info(f'self reply!')
1✔
1795

1796
            # also add copies' targets
1797
            for copy in orig_obj.copies:
1✔
1798
                proto = PROTOCOLS[copy.protocol]
1✔
1799
                if proto in to_protocols:
1✔
1800
                    # copies generally won't have their own Objects
1801
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1802
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {target_id}')
1✔
1803
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1804

1805
            if target_proto == from_cls:
1✔
1806
                logger.debug(f'Skipping same-protocol target {target_id}')
1✔
1807
                continue
1✔
1808

1809
            target = target_proto.target_for(orig_obj)
1✔
1810
            if not target:
1✔
1811
                # TODO: surface errors like this somehow?
1812
                logger.error(f"Can't find delivery target for {target_id}")
×
1813
                continue
×
1814

1815
            logger.debug(f'Target for {target_id} is {target}')
1✔
1816
            # only use orig_obj for inReplyTos, like/repost objects, reply's original
1817
            # post's mentions, etc
1818
            # https://github.com/snarfed/bridgy-fed/issues/1237
1819
            target_obj = None
1✔
1820
            if target_id in in_reply_tos + as1.get_ids(obj.as1, 'object'):
1✔
1821
                target_obj = orig_obj
1✔
1822
            elif target_id in orig_post_mentions:
1✔
1823
                target_obj = orig_post_mentions[target_id]
1✔
1824
            targets[Target(protocol=target_proto.LABEL, uri=target)] = target_obj
1✔
1825

1826
            if target_author_key:
1✔
1827
                logger.debug(f'Recipient is {target_author_key}')
1✔
1828
                if write_obj.add('notify', target_author_key):
1✔
1829
                    write_obj.dirty = True
1✔
1830

1831
        if obj.type == 'undo':
1✔
1832
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1833
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1834
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1835
            else:
1836
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1837
            if inner_obj:
1✔
1838
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1839
                                                internal=True))
1840

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

1843
        # deliver to followers, if appropriate
1844
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1845
        if not user_key:
1✔
1846
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1847
            return targets
1✔
1848

1849
        followers = []
1✔
1850
        if (obj.type in ('post', 'update', 'delete', 'move', 'share', 'undo')
1✔
1851
                and (not is_reply or is_self_reply)):
1852
            logger.info(f'Delivering to followers of {user_key}')
1✔
1853
            followers = []
1✔
1854
            for f in Follower.query(Follower.to == user_key,
1✔
1855
                                    Follower.status == 'active'):
1856
                proto = PROTOCOLS_BY_KIND[f.from_.kind()]
1✔
1857
                # skip protocol bot users
1858
                if (not Protocol.for_bridgy_subdomain(f.from_.id())
1✔
1859
                        # skip protocols this user hasn't enabled, or where the base
1860
                        # object of this activity hasn't been bridged
1861
                        and proto in to_protocols
1862
                        # we deliver to HAS_COPIES protocols separately, below. we
1863
                        # assume they have follower-independent targets.
1864
                        and not (proto.HAS_COPIES and proto.DEFAULT_TARGET)):
1865
                    followers.append(f)
1✔
1866

1867
            user_keys = [f.from_ for f in followers]
1✔
1868
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1869
            User.load_multi(users)
1✔
1870

1871
            if (not followers and
1✔
1872
                (util.domain_or_parent_in(from_user.key.id(), LIMITED_DOMAINS)
1873
                 or util.domain_or_parent_in(obj.key.id(), LIMITED_DOMAINS))):
1874
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1875
                return {}
1✔
1876

1877
            # add to followers' feeds, if any
1878
            if not internal and obj.type in ('post', 'update', 'share'):
1✔
1879
                if write_obj.type not in as1.ACTOR_TYPES:
1✔
1880
                    write_obj.feed = [u.key for u in users if u.USES_OBJECT_FEED]
1✔
1881
                    if write_obj.feed:
1✔
1882
                        write_obj.dirty = True
1✔
1883

1884
            # collect targets for followers
1885
            for user in users:
1✔
1886
                # TODO: should we pass remote=False through here to Protocol.load?
1887
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1888
                if not target:
1✔
1889
                    # logger.error(f'Follower {user.key} has no delivery target')
1890
                    continue
1✔
1891

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

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

1900
        # deliver to enabled HAS_COPIES protocols proactively
1901
        if obj.type in ('post', 'update', 'delete', 'share'):
1✔
1902
            for proto in to_protocols:
1✔
1903
                if proto.HAS_COPIES and proto.DEFAULT_TARGET:
1✔
1904
                    logger.info(f'user has {proto.LABEL} enabled, adding {proto.DEFAULT_TARGET}')
1✔
1905
                    targets.setdefault(
1✔
1906
                        Target(protocol=proto.LABEL, uri=proto.DEFAULT_TARGET), None)
1907

1908
        # de-dupe targets, discard same-domain
1909
        # maps string target URL to (Target, Object) tuple
1910
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1911
        # maps Target to Object or None
1912
        targets = {}
1✔
1913
        source_domains = [
1✔
1914
            util.domain_from_link(url) for url in
1915
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1916
            if util.is_web(url)
1917
        ]
1918
        for url in sorted(util.dedupe_urls(
1✔
1919
                candidates.keys(),
1920
                # preserve our PDS URL without trailing slash in path
1921
                # https://atproto.com/specs/did#did-documents
1922
                trailing_slash=False)):
1923
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
1924
                logger.info(f'Skipping same-domain target {url}')
×
1925
                continue
×
1926
            target, obj = candidates[url]
1✔
1927
            targets[target] = obj
1✔
1928

1929
        return targets
1✔
1930

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

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

1938
        Args:
1939
          id (str)
1940
          remote (bool): whether to fetch the object over the network. If True,
1941
            fetches even if we already have the object stored, and updates our
1942
            stored copy. If False and we don't have the object stored, returns
1943
            None. Default (None) means to fetch over the network only if we
1944
            don't already have it stored.
1945
          local (bool): whether to load from the datastore before
1946
            fetching over the network. If False, still stores back to the
1947
            datastore after a successful remote fetch.
1948
          raise_ (bool): if False, catches any :class:`request.RequestException`
1949
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1950
            ``None`` instead
1951
          kwargs: passed through to :meth:`fetch()`
1952

1953
        Returns:
1954
          models.Object: loaded object, or None if it isn't fetchable, eg a
1955
          non-URL string for Web, or ``remote`` is False and it isn't in the
1956
          datastore
1957

1958
        Raises:
1959
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1960
            is True
1961
        """
1962
        assert id
1✔
1963
        assert local or remote is not False
1✔
1964
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1965

1966
        id = ids.normalize_object_id(id=id, proto=cls)
1✔
1967

1968
        obj = orig_as1 = None
1✔
1969
        if local:
1✔
1970
            obj = Object.get_by_id(id)
1✔
1971
            if not obj:
1✔
1972
                # logger.debug(f' {id} not in datastore')
1973
                pass
1✔
1974
            elif obj.as1 or obj.csv or obj.raw or obj.deleted:
1✔
1975
                # logger.debug(f'  {id} got from datastore')
1976
                obj.new = False
1✔
1977

1978
        if remote is False:
1✔
1979
            return obj
1✔
1980
        elif remote is None and obj:
1✔
1981
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1982
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1983
                pass
1✔
1984
            else:
1985
                return obj
1✔
1986

1987
        if obj:
1✔
1988
            orig_as1 = obj.as1
1✔
1989
            obj.our_as1 = None
1✔
1990
            obj.new = False
1✔
1991
        else:
1992
            obj = Object(id=id)
1✔
1993
            if local:
1✔
1994
                # logger.debug(f'  {id} not in datastore')
1995
                obj.new = True
1✔
1996
                obj.changed = False
1✔
1997

1998
        try:
1✔
1999
            fetched = cls.fetch(obj, **kwargs)
1✔
2000
        except (RequestException, HTTPException) as e:
1✔
2001
            if raise_:
1✔
2002
                raise
1✔
2003
            util.interpret_http_exception(e)
1✔
2004
            return None
1✔
2005

2006
        if not fetched:
1✔
2007
            return None
1✔
2008

2009
        # https://stackoverflow.com/a/3042250/186123
2010
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
2011
        if size > MAX_ENTITY_SIZE:
1✔
2012
            logger.warning(f'Object is too big! {size} bytes is over {MAX_ENTITY_SIZE}')
1✔
2013
            return None
1✔
2014

2015
        obj.resolve_ids()
1✔
2016
        obj.normalize_ids()
1✔
2017

2018
        if obj.new is False:
1✔
2019
            obj.changed = obj.activity_changed(orig_as1)
1✔
2020

2021
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
2022
            if obj.source_protocol:
1✔
2023
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
2024
            obj.source_protocol = cls.LABEL
1✔
2025

2026
        obj.put()
1✔
2027
        return obj
1✔
2028

2029
    @classmethod
1✔
2030
    def check_supported(cls, obj, direction):
1✔
2031
        """If this protocol doesn't support this activity, raises HTTP 204.
2032

2033
        Also reports an error.
2034

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

2039
        Args:
2040
          obj (Object)
2041
          direction (str): ``'receive'`` or  ``'send'``
2042

2043
        Raises:
2044
          werkzeug.HTTPException: if this protocol doesn't support this object
2045
        """
2046
        assert direction in ('receive', 'send')
1✔
2047
        if not obj.type:
1✔
2048
            return
×
2049

2050
        inner = as1.get_object(obj.as1)
1✔
2051
        inner_type = as1.object_type(inner) or ''
1✔
2052
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
2053
            or (obj.type in as1.CRUD_VERBS
2054
                and inner_type
2055
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
2056
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
2057

2058
        # don't allow posts with blank content and no image/video/audio
2059
        crud_obj = (as1.get_object(obj.as1) if obj.type in ('post', 'update')
1✔
2060
                    else obj.as1)
2061
        if (crud_obj.get('objectType') in as1.POST_TYPES
1✔
2062
                and not util.get_url(crud_obj, key='image')
2063
                and not any(util.get_urls(crud_obj, 'attachments', inner_key='stream'))
2064
                # TODO: handle articles with displayName but not content
2065
                and not source.html_to_text(crud_obj.get('content')).strip()):
2066
            error('Blank content and no image or video or audio', status=204)
1✔
2067

2068
        # receiving DMs is only allowed to protocol bot accounts
2069
        if direction == 'receive':
1✔
2070
            if recip := as1.recipient_if_dm(obj.as1):
1✔
2071
                owner = as1.get_owner(obj.as1)
1✔
2072
                if (not cls.SUPPORTS_DMS or (recip not in common.bot_user_ids()
1✔
2073
                                             and owner not in common.bot_user_ids())):
2074
                    # reply and say DMs aren't supported
2075
                    from_proto = PROTOCOLS.get(obj.source_protocol)
1✔
2076
                    to_proto = Protocol.for_id(recip)
1✔
2077
                    if owner and from_proto and to_proto:
1✔
2078
                        if ((from_user := from_proto.get_or_create(id=owner))
1✔
2079
                                and (to_user := to_proto.get_or_create(id=recip))):
2080
                            in_reply_to = (inner.get('id') if obj.type == 'post'
1✔
2081
                                           else obj.as1.get('id'))
2082
                            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✔
2083
                            type = f'dms_not_supported-{to_user.key.id()}'
1✔
2084
                            dms.maybe_send(from_=to_user, to_user=from_user,
1✔
2085
                                           text=text, type=type,
2086
                                           in_reply_to=in_reply_to)
2087

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

2090
            # check that this activity is public. only do this for some activities,
2091
            # not eg likes or follows, since Mastodon doesn't currently mark those
2092
            # as explicitly public.
2093
            elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
2094
                  and not util.domain_or_parent_in(crud_obj.get('id'), NON_PUBLIC_DOMAINS)
2095
                  and not as1.is_public(obj.as1, unlisted=False)):
2096
                error('Bridgy Fed only supports public activities', status=204)
1✔
2097

2098
    @classmethod
1✔
2099
    def block(cls, from_user, arg):
1✔
2100
        """Blocks a user or list.
2101

2102
        Args:
2103
          from_user (models.User): user doing the blocking
2104
          arg (str): handle or id of user/list to block
2105

2106
        Returns:
2107
          models.User or models.Object: user or list that was blocked
2108

2109
        Raises:
2110
          ValueError: if arg doesn't look like a user or list on this protocol
2111
        """
2112
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2113

2114
        blockee = None
1✔
2115
        err = None
1✔
2116
        try:
1✔
2117
            # first, try interpreting as a user handle or id
2118
            # TODO: move out of dms
2119
            blockee = dms._load_user(arg, cls)
1✔
2120
        except BadRequest as err:
1✔
2121
            logger.info(err)
1✔
2122

2123
        # may not be a user, see if it's a list
2124
        if not blockee:
1✔
2125
            blockee = cls.load(arg)
1✔
2126
            if not blockee or blockee.type != 'collection':
1✔
2127
                err = f"{arg} doesn't look like a user or list on {cls.PHRASE}, or we couldn't fetch it"
1✔
2128
                logger.warning(err)
1✔
2129
                raise ValueError(err)
1✔
2130

2131
        logger.info(f'  blocking {blockee.key.id()}')
1✔
2132
        id = f'{from_user.key.id()}#bridgy-fed-block-{util.now().isoformat()}'
1✔
2133
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2134
            'objectType': 'activity',
2135
            'verb': 'block',
2136
            'id': id,
2137
            'actor': from_user.key.id(),
2138
            'object': blockee.key.id(),
2139
        })
2140
        obj.put()
1✔
2141
        from_user.deliver(obj, from_user=from_user)
1✔
2142

2143
        return blockee
1✔
2144

2145
    @classmethod
1✔
2146
    def unblock(cls, from_user, arg):
1✔
2147
        """Unblocks a user or list.
2148

2149
        Args:
2150
          from_user (models.User): user doing the unblocking
2151
          arg (str): handle or id of user/list to unblock
2152

2153
        Returns:
2154
          models.User or models.Object: user or list that was blocked
2155

2156
        Raises:
2157
          ValueError: if arg doesn't look like a user or list on this protocol
2158
        """
2159
        logger.info(f'user {from_user.key.id()} trying to block {arg}')
1✔
2160

2161
        blockee = None
1✔
2162
        try:
1✔
2163
            # first, try interpreting as a user handle or id
2164
            blockee = dms._load_user(arg, cls)
1✔
2165
        except BadRequest:
1✔
2166
            pass
1✔
2167

2168
        # may not be a user, see if it's a list
2169
        if not blockee:
1✔
2170
            blockee = cls.load(arg)
1✔
2171
            if not blockee or blockee.type != 'collection':
1✔
2172
                err = f"{arg} doesn't look like a user or list on {cls.PHRASE}"
1✔
2173
                logger.warning(err)
1✔
2174
                raise ValueError(err)
1✔
2175

2176
        logger.info(f'  unblocking {blockee.key.id()}')
1✔
2177
        id = f'{from_user.key.id()}#bridgy-fed-unblock-{util.now().isoformat()}'
1✔
2178
        obj = Object(id=id, source_protocol=from_user.LABEL, our_as1={
1✔
2179
            'objectType': 'activity',
2180
            'verb': 'undo',
2181
            'id': id,
2182
            'actor': from_user.key.id(),
2183
            'object': {
2184
                'objectType': 'activity',
2185
                'verb': 'block',
2186
                'actor': from_user.key.id(),
2187
                'object': blockee.key.id(),
2188
            },
2189
        })
2190
        obj.put()
1✔
2191
        from_user.deliver(obj, from_user=from_user)
1✔
2192

2193
        return blockee
1✔
2194

2195

2196
@cloud_tasks_only(log=None)
1✔
2197
def receive_task():
1✔
2198
    """Task handler for a newly received :class:`models.Object`.
2199

2200
    Calls :meth:`Protocol.receive` with the form parameters.
2201

2202
    Parameters:
2203
      authed_as (str): passed to :meth:`Protocol.receive`
2204
      obj_id (str): key id of :class:`models.Object` to handle
2205
      received_at (str, ISO 8601 timestamp): when we first saw (received)
2206
        this activity
2207
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2208
        :class:`models.Object` to handle
2209

2210
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
2211
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
2212
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
2213
    ``changed``, HTTP request details, etc. See stash for attempt at this for
2214
    :class:`web.Web`.
2215
    """
2216
    common.log_request()
1✔
2217
    form = request.form.to_dict()
1✔
2218

2219
    authed_as = form.pop('authed_as', None)
1✔
2220
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
2221
                or authed_as in common.PROTOCOL_DOMAINS)
2222

2223
    obj = Object.from_request()
1✔
2224
    assert obj
1✔
2225
    assert obj.source_protocol
1✔
2226
    obj.new = True
1✔
2227

2228
    if received_at := form.pop('received_at', None):
1✔
2229
        received_at = datetime.fromisoformat(received_at)
1✔
2230

2231
    try:
1✔
2232
        return PROTOCOLS[obj.source_protocol].receive(
1✔
2233
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
2234
    except RequestException as e:
1✔
2235
        util.interpret_http_exception(e)
1✔
2236
        error(e, status=304)
1✔
2237
    except ValueError as e:
1✔
UNCOV
2238
        logger.warning(e, exc_info=True)
×
UNCOV
2239
        error(e, status=304)
×
2240

2241

2242
@cloud_tasks_only(log=None)
1✔
2243
def send_task():
1✔
2244
    """Task handler for sending an activity to a single specific destination.
2245

2246
    Calls :meth:`Protocol.send` with the form parameters.
2247

2248
    Parameters:
2249
      protocol (str): :class:`Protocol` to send to
2250
      url (str): destination URL to send to
2251
      obj_id (str): key id of :class:`models.Object` to send
2252
      orig_obj_id (str): optional, :class:`models.Object` key id of the
2253
        "original object" that this object refers to, eg replies to or reposts
2254
        or likes
2255
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
2256
        this activity is from
2257
      *: If ``obj_id`` is unset, all other parameters are properties for a new
2258
        :class:`models.Object` to handle
2259
    """
2260
    common.log_request()
1✔
2261

2262
    # prepare
2263
    form = request.form.to_dict()
1✔
2264
    url = form.get('url')
1✔
2265
    protocol = form.get('protocol')
1✔
2266
    if not url or not protocol:
1✔
2267
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
2268
        return '', 204
1✔
2269

2270
    target = Target(uri=url, protocol=protocol)
1✔
2271
    obj = Object.from_request()
1✔
2272
    assert obj and obj.key and obj.key.id()
1✔
2273

2274
    PROTOCOLS[protocol].check_supported(obj, 'send')
1✔
2275
    allow_opt_out = (obj.type == 'delete')
1✔
2276

2277
    user = None
1✔
2278
    if user_key := form.get('user'):
1✔
2279
        key = ndb.Key(urlsafe=user_key)
1✔
2280
        # use get_by_id so that we follow use_instead
2281
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
2282
            key.id(), allow_opt_out=allow_opt_out)
2283

2284
    # send
2285
    delay = ''
1✔
2286
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
2287
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
2288
        delay = f'({delay_s} s behind)'
1✔
2289
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
2290
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
2291
    sent = None
1✔
2292
    try:
1✔
2293
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
2294
                                        orig_obj_id=form.get('orig_obj_id'))
2295
    except BaseException as e:
1✔
2296
        code, body = util.interpret_http_exception(e)
1✔
2297
        if not code and not body:
1✔
2298
            raise
1✔
2299

2300
    if sent is False:
1✔
2301
        logger.info(f'Failed sending!')
1✔
2302

2303
    return '', 200 if sent else 204 if sent is False else 304
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc