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

snarfed / bridgy-fed / c72817c9-39df-4f93-b3fb-7a41f0d799f6

19 Jan 2025 04:12PM UTC coverage: 93.183% (+0.002%) from 93.181%
c72817c9-39df-4f93-b3fb-7a41f0d799f6

push

circleci

snarfed
add rumble.com to web fetch blocklist, it serves us infinite HTTP 307 redirects

4538 of 4870 relevant lines covered (93.18%)

0.93 hits per line

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

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

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

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

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

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

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

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

74

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

79

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

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

88

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

92

93
class Protocol:
1✔
94
    """Base protocol class. Not to be instantiated; classmethods only.
95

96
    Attributes:
97
      LABEL (str): human-readable lower case name
98
      OTHER_LABELS (list of str): label aliases
99
      ABBREV (str): lower case abbreviation, used in URL paths
100
      PHRASE (str): human-readable name or phrase. Used in phrases like
101
        ``Follow this person on {PHRASE}``
102
      LOGO_HTML (str): logo emoji or ``<img>`` tag
103
      CONTENT_TYPE (str): MIME type of this protocol's native data format,
104
        appropriate for the ``Content-Type`` HTTP header.
105
      HAS_COPIES (bool): whether this protocol is push and needs us to
106
        proactively create "copy" users and objects, as opposed to pulling
107
        converted objects on demand
108
      REQUIRES_AVATAR (bool): whether accounts on this protocol are required
109
        to have a profile picture. If they don't, their ``User.status`` will be
110
        ``blocked``.
111
      REQUIRES_NAME (bool): whether accounts on this protocol are required to
112
        have a profile name that's different than their handle or id. If they
113
        don't, their ``User.status`` will be ``blocked``.
114
      REQUIRES_OLD_ACCOUNT: (bool): whether accounts on this protocol are
115
        required to be at least :const:`common.OLD_ACCOUNT_AGE` old. If their
116
        profile includes creation date and it's not old enough, their
117
        ``User.status`` will be ``blocked``.
118
      DEFAULT_ENABLED_PROTOCOLS (sequence of str): labels of other protocols
119
        that are automatically enabled for this protocol to bridge into
120
      DEFAULT_SERVE_USER_PAGES (bool): whether to serve user pages for all of
121
        this protocol's users on the fed.brid.gy. If ``False``, user pages will
122
        only be served for users who have explictly opted in.
123
      SUPPORTED_AS1_TYPES (sequence of str): AS1 objectTypes and verbs that this
124
        protocol supports receiving and sending.
125
      SUPPORTS_DMS (bool): whether this protocol can receive DMs (chat messages)
126

127
    """
128
    ABBREV = None
1✔
129
    PHRASE = None
1✔
130
    OTHER_LABELS = ()
1✔
131
    LOGO_HTML = ''
1✔
132
    CONTENT_TYPE = None
1✔
133
    HAS_COPIES = False
1✔
134
    REQUIRES_AVATAR = False
1✔
135
    REQUIRES_NAME = False
1✔
136
    REQUIRES_OLD_ACCOUNT = False
1✔
137
    DEFAULT_ENABLED_PROTOCOLS = ()
1✔
138
    DEFAULT_SERVE_USER_PAGES = False
1✔
139
    SUPPORTED_AS1_TYPES = ()
1✔
140
    SUPPORTS_DMS = False
1✔
141

142
    def __init__(self):
1✔
143
        assert False
×
144

145
    @classmethod
1✔
146
    @property
1✔
147
    def LABEL(cls):
1✔
148
        return cls.__name__.lower()
1✔
149

150
    @staticmethod
1✔
151
    def for_request(fed=None):
1✔
152
        """Returns the protocol for the current request.
153

154
        ...based on the request's hostname.
155

156
        Args:
157
          fed (str or protocol.Protocol): protocol to return if the current
158
            request is on ``fed.brid.gy``
159

160
        Returns:
161
          Protocol: protocol, or None if the provided domain or request hostname
162
          domain is not a subdomain of ``brid.gy`` or isn't a known protocol
163
        """
164
        return Protocol.for_bridgy_subdomain(request.host, fed=fed)
1✔
165

166
    @staticmethod
1✔
167
    def for_bridgy_subdomain(domain_or_url, fed=None):
1✔
168
        """Returns the protocol for a brid.gy subdomain.
169

170
        Args:
171
          domain_or_url (str)
172
          fed (str or protocol.Protocol): protocol to return if the current
173
            request is on ``fed.brid.gy``
174

175
        Returns:
176
          class: :class:`Protocol` subclass, or None if the provided domain or request
177
          hostname domain is not a subdomain of ``brid.gy`` or isn't a known
178
          protocol
179
        """
180
        domain = (util.domain_from_link(domain_or_url, minimize=False)
1✔
181
                  if util.is_web(domain_or_url)
182
                  else domain_or_url)
183

184
        if domain == common.PRIMARY_DOMAIN or domain in common.LOCAL_DOMAINS:
1✔
185
            return PROTOCOLS[fed] if isinstance(fed, str) else fed
1✔
186
        elif domain and domain.endswith(common.SUPERDOMAIN):
1✔
187
            label = domain.removesuffix(common.SUPERDOMAIN)
1✔
188
            return PROTOCOLS.get(label)
1✔
189

190
    @classmethod
1✔
191
    def owns_id(cls, id):
1✔
192
        """Returns whether this protocol owns the id, or None if it's unclear.
193

194
        To be implemented by subclasses.
195

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

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

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

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

210
        Args:
211
          id (str)
212

213
        Returns:
214
          bool or None:
215
        """
216
        return False
1✔
217

218
    @classmethod
1✔
219
    def owns_handle(cls, handle, allow_internal=False):
1✔
220
        """Returns whether this protocol owns the handle, or None if it's unclear.
221

222
        To be implemented by subclasses.
223

224
        Handles are string identities that are human-chosen, human-meaningful,
225
        and often but not always unique. Compare to IDs, which uniquely identify
226
        users, and are intended primarily to be machine readable and usable.
227

228
        Some protocols' handles are more or less deterministic based on the id
229
        format, eg ActivityPub (technically WebFinger) handles are
230
        ``@user@instance.com``. Others, like domains, could be owned by eg Web,
231
        ActivityPub, AT Protocol, or others.
232

233
        This should be a quick guess without expensive side effects, eg no
234
        external HTTP fetches to fetch the id itself or otherwise perform
235
        discovery.
236

237
        Args:
238
          handle (str)
239
          allow_internal (bool): whether to return False for internal domains
240
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
241

242
        Returns:
243
          bool or None
244
        """
245
        return False
1✔
246

247
    @classmethod
1✔
248
    def handle_to_id(cls, handle):
1✔
249
        """Converts a handle to an id.
250

251
        To be implemented by subclasses.
252

253
        May incur network requests, eg DNS queries or HTTP requests. Avoids
254
        blocked or opted out users.
255

256
        Args:
257
          handle (str)
258

259
        Returns:
260
          str: corresponding id, or None if the handle can't be found
261
        """
262
        raise NotImplementedError()
×
263

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

268
        To be implemented by subclasses. Canonicalizes the id if necessary.
269

270
        If called via `Protocol.key_for`, infers the appropriate protocol with
271
        :meth:`for_id`. If called with a concrete subclass, uses that subclass
272
        as is.
273

274
        Args:
275
          id (str):
276
          allow_opt_out (bool): whether to allow users who are currently opted out
277

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

286
        # load user so that we follow use_instead
287
        existing = cls.get_by_id(id, allow_opt_out=True)
1✔
288
        if existing:
1✔
289
            if existing.status and not allow_opt_out:
1✔
290
                return None
1✔
291
            return existing.key
1✔
292

293
        return cls(id=id).key
1✔
294

295
    @staticmethod
1✔
296
    def _for_id_memcache_key(id, remote=None):
1✔
297
        """If id is a URL, uses its domain, otherwise returns None.
298

299
        Args:
300
          id (str)
301

302
        Returns:
303
          (str domain, bool remote) or None
304
        """
305
        if remote and util.is_web(id):
1✔
306
            return util.domain_from_link(id)
1✔
307

308
    @cached(LRUCache(20000), lock=Lock())
1✔
309
    @memcache.memoize(key=_for_id_memcache_key, write=lambda id, remote: remote,
1✔
310
                      version=3)
311
    @staticmethod
1✔
312
    def for_id(id, remote=True):
1✔
313
        """Returns the protocol for a given id.
314

315
        Args:
316
          id (str)
317
          remote (bool): whether to perform expensive side effects like fetching
318
            the id itself over the network, or other discovery.
319

320
        Returns:
321
          Protocol subclass: matching protocol, or None if no single known
322
          protocol definitively owns this id
323
        """
324
        logger.debug(f'Determining protocol for id {id}')
1✔
325
        if not id:
1✔
326
            return None
1✔
327

328
        # remove our synthetic id fragment, if any
329
        #
330
        # will this eventually cause false positives for other services that
331
        # include our full ids inside their own ids, non-URL-encoded? guess
332
        # we'll figure that out if/when it happens.
333
        id = id.partition('#bridgy-fed-')[0]
1✔
334

335
        if util.is_web(id):
1✔
336
            # step 1: check for our per-protocol subdomains
337
            try:
1✔
338
                is_homepage = urlparse(id).path.strip('/') == ''
1✔
339
            except ValueError as e:
1✔
340
                logger.info(f'urlparse ValueError: {e}')
1✔
341
                return None
1✔
342

343
            by_subdomain = Protocol.for_bridgy_subdomain(id)
1✔
344
            if by_subdomain and not is_homepage and id not in BOT_ACTOR_AP_IDS:
1✔
345
                logger.debug(f'  {by_subdomain.LABEL} owns id {id}')
1✔
346
                return by_subdomain
1✔
347

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

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

365
        # step 3: look for existing Objects in the datastore
366
        obj = Protocol.load(id, remote=False)
1✔
367
        if obj and obj.source_protocol:
1✔
368
            logger.debug(f'  {obj.key.id()} owned by source_protocol {obj.source_protocol}')
1✔
369
            return PROTOCOLS[obj.source_protocol]
1✔
370

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

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

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

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

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

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

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

417
        Args:
418
          handle (str)
419

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

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

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

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

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

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

465
    @classmethod
1✔
466
    def bridged_web_url_for(cls, user, fallback=False):
1✔
467
        """Returns the web URL for a user's bridged profile in this protocol.
468

469
        For example, for Web user ``alice.com``, :meth:`ATProto.bridged_web_url_for`
470
        returns ``https://bsky.app/profile/alice.com.web.brid.gy``
471

472
        Args:
473
          user (models.User)
474
          fallback (bool): if True, and bridged users have no canonical user
475
            profile URL in this protocol, return the native protocol's profile URL
476

477
        Returns:
478
          str, or None if there isn't a canonical URL
479
        """
480
        if fallback:
1✔
481
            return user.web_url()
1✔
482

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

487
        Args:
488
          obj (models.Object)
489
          allow_opt_out (bool): whether to return a user key if they're opted out
490

491
        Returns:
492
          google.cloud.ndb.key.Key or None:
493
        """
494
        owner = as1.get_owner(obj.as1)
1✔
495
        if owner:
1✔
496
            return cls.key_for(owner, allow_opt_out=allow_opt_out)
1✔
497

498
    @classmethod
1✔
499
    def bot_user_id(cls):
1✔
500
        """Returns the Web user id for the bot user for this protocol.
501

502
        For example, ``'bsky.brid.gy'`` for ATProto.
503

504
        Returns:
505
          str:
506
        """
507
        return f'{cls.ABBREV}{common.SUPERDOMAIN}'
1✔
508

509
    @classmethod
1✔
510
    def create_for(cls, user):
1✔
511
        """Creates or re-activate a copy user in this protocol.
512

513
        Should add the copy user to :attr:`copies`.
514

515
        If the copy user already exists and active, should do nothing.
516

517
        Args:
518
          user (models.User): original source user. Shouldn't already have a
519
            copy user for this protocol in :attr:`copies`.
520

521
        Raises:
522
          ValueError: if we can't create a copy of the given user in this protocol
523
        """
524
        raise NotImplementedError()
×
525

526
    @classmethod
1✔
527
    def send(to_cls, obj, url, from_user=None, orig_obj_id=None):
1✔
528
        """Sends an outgoing activity.
529

530
        To be implemented by subclasses.
531

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

536
        Args:
537
          obj (models.Object): with activity to send
538
          url (str): destination URL to send to
539
          from_user (models.User): user (actor) this activity is from
540
          orig_obj_id (str): :class:`models.Object` key id of the "original object"
541
            that this object refers to, eg replies to or reposts or likes
542

543
        Returns:
544
          bool: True if the activity is sent successfully, False if it is
545
          ignored or otherwise unsent due to protocol logic, eg no webmention
546
          endpoint, protocol doesn't support the activity type. (Failures are
547
          raised as exceptions.)
548

549
        Raises:
550
          werkzeug.HTTPException if the request fails
551
        """
552
        raise NotImplementedError()
×
553

554
    @classmethod
1✔
555
    def fetch(cls, obj, **kwargs):
1✔
556
        """Fetches a protocol-specific object and populates it in an :class:`Object`.
557

558
        Errors are raised as exceptions. If this method returns False, the fetch
559
        didn't fail but didn't succeed either, eg the id isn't valid for this
560
        protocol, or the fetch didn't return valid data for this protocol.
561

562
        To be implemented by subclasses.
563

564
        Args:
565
          obj (models.Object): with the id to fetch. Data is filled into one of
566
            the protocol-specific properties, eg ``as2``, ``mf2``, ``bsky``.
567
          kwargs: subclass-specific
568

569
        Returns:
570
          bool: True if the object was fetched and populated successfully,
571
          False otherwise
572

573
        Raises:
574
          requests.RequestException or werkzeug.HTTPException: if the fetch fails
575
        """
576
        raise NotImplementedError()
×
577

578
    @classmethod
1✔
579
    def convert(cls, obj, from_user=None, **kwargs):
1✔
580
        """Converts an :class:`Object` to this protocol's data format.
581

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

585
        Just passes through to :meth:`_convert`, then does minor
586
        protocol-independent postprocessing.
587

588
        Args:
589
          obj (models.Object):
590
          from_user (models.User): user (actor) this activity/object is from
591
          kwargs: protocol-specific, passed through to :meth:`_convert`
592

593
        Returns:
594
          converted object in the protocol's native format, often a dict
595
        """
596
        if not obj or not obj.as1:
1✔
597
            return {}
1✔
598

599
        id = obj.key.id() if obj.key else obj.as1.get('id')
1✔
600
        is_activity = obj.as1.get('verb') in ('post', 'update')
1✔
601
        base_obj = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
602
        orig_our_as1 = obj.our_as1
1✔
603

604
        # mark bridged actors as bots and add "bridged by Bridgy Fed" to their bios
605
        if (from_user and base_obj
1✔
606
            and base_obj.get('objectType') in as1.ACTOR_TYPES
607
            and PROTOCOLS.get(obj.source_protocol) != cls
608
            and Protocol.for_bridgy_subdomain(id) not in DOMAINS
609
            # Web users are special cased, they don't get the label if they've
610
            # explicitly enabled Bridgy Fed with redirects or webmentions
611
            and not (from_user.LABEL == 'web'
612
                     and (from_user.last_webmention_in or from_user.has_redirects))):
613

614
            obj.our_as1 = copy.deepcopy(obj.as1)
1✔
615
            actor = as1.get_object(obj.as1) if is_activity else obj.as1
1✔
616
            actor['objectType'] = 'person'
1✔
617
            cls.add_source_links(actor=actor, obj=obj, from_user=from_user)
1✔
618

619
        converted = cls._convert(obj, from_user=from_user, **kwargs)
1✔
620
        obj.our_as1 = orig_our_as1
1✔
621
        return converted
1✔
622

623
    @classmethod
1✔
624
    def _convert(cls, obj, from_user=None, **kwargs):
1✔
625
        """Converts an :class:`Object` to this protocol's data format.
626

627
        To be implemented by subclasses. Implementations should generally call
628
        :meth:`Protocol.translate_ids` (as their own class) before converting to
629
        their format.
630

631
        Args:
632
          obj (models.Object):
633
          from_user (models.User): user (actor) this activity/object is from
634
          kwargs: protocol-specific
635

636
        Returns:
637
          converted object in the protocol's native format, often a dict. May
638
            return the ``{}`` empty dict if the object can't be converted.
639
        """
640
        raise NotImplementedError()
×
641

642
    @classmethod
1✔
643
    def add_source_links(cls, actor, obj, from_user):
1✔
644
        """Adds "bridged from ... by Bridgy Fed" HTML to ``actor['summary']``.
645

646
        Default implementation; subclasses may override.
647

648
        Args:
649
          actor (dict): AS1 actor
650
          obj (models.Object):
651
          from_user (models.User): user (actor) this activity/object is from
652
        """
653
        assert from_user
1✔
654
        summary = actor.setdefault('summary', '')
1✔
655
        if 'Bridgy Fed]' in html_to_text(summary, ignore_links=True):
1✔
656
            return
1✔
657

658
        id = actor.get('id')
1✔
659
        proto_phrase = (PROTOCOLS[obj.source_protocol].PHRASE
1✔
660
                        if obj.source_protocol else '')
661
        if proto_phrase:
1✔
662
            proto_phrase = f' on {proto_phrase}'
1✔
663

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

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

672
        if summary:
1✔
673
            summary += '<br><br>'
1✔
674
        actor['summary'] = summary + source_links
1✔
675

676
    @classmethod
1✔
677
    def set_username(to_cls, user, username):
1✔
678
        """Sets a custom username for a user's bridged account in this protocol.
679

680
        Args:
681
          user (models.User)
682
          username (str)
683

684
        Raises:
685
          ValueError: if the username is invalid
686
          RuntimeError: if the username could not be set
687
        """
688
        raise NotImplementedError()
1✔
689

690
    @classmethod
1✔
691
    def target_for(cls, obj, shared=False):
1✔
692
        """Returns an :class:`Object`'s delivery target (endpoint).
693

694
        To be implemented by subclasses.
695

696
        Examples:
697

698
        * If obj has ``source_protocol`` ``web``, returns its URL, as a
699
          webmention target.
700
        * If obj is an ``activitypub`` actor, returns its inbox.
701
        * If obj is an ``activitypub`` object, returns it's author's or actor's
702
          inbox.
703

704
        Args:
705
          obj (models.Object):
706
          shared (bool): optional. If True, returns a common/shared
707
            endpoint, eg ActivityPub's ``sharedInbox``, that can be reused for
708
            multiple recipients for efficiency
709

710
        Returns:
711
          str: target endpoint, or None if not available.
712
        """
713
        raise NotImplementedError()
×
714

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

719
        Default implementation here, subclasses may override.
720

721
        Args:
722
          url (str):
723
          allow_internal (bool): whether to return False for internal domains
724
            like ``fed.brid.gy``, ``bsky.brid.gy``, etc
725
        """
726
        blocklist = DOMAIN_BLOCKLIST
1✔
727
        if not allow_internal:
1✔
728
            blocklist += DOMAINS
1✔
729
        return util.domain_or_parent_in(util.domain_from_link(url), blocklist)
1✔
730

731
    @classmethod
1✔
732
    def translate_ids(to_cls, obj):
1✔
733
        """Translates all ids in an AS1 object to a specific protocol.
734

735
        Infers source protocol for each id value separately.
736

737
        For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
738
        ``at://did:plc:abc/coll/123`` will be converted to
739
        ``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
740

741
        Wraps these AS1 fields:
742

743
        * ``id``
744
        * ``actor``
745
        * ``author``
746
        * ``bcc``
747
        * ``bto``
748
        * ``cc``
749
        * ``object``
750
        * ``object.actor``
751
        * ``object.author``
752
        * ``object.id``
753
        * ``object.inReplyTo``
754
        * ``object.object``
755
        * ``attachments[].id``
756
        * ``tags[objectType=mention].url``
757
        * ``to``
758

759
        This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
760
        same logic is duplicated there!
761

762
        TODO: unify with :meth:`Object.resolve_ids`,
763
        :meth:`models.Object.normalize_ids`.
764

765
        Args:
766
          to_proto (Protocol subclass)
767
          obj (dict): AS1 object or activity (not :class:`models.Object`!)
768

769
        Returns:
770
          dict: wrapped AS1 version of ``obj``
771
        """
772
        assert to_cls != Protocol
1✔
773
        if not obj:
1✔
774
            return obj
1✔
775

776
        outer_obj = copy.deepcopy(obj)
1✔
777
        inner_objs = outer_obj['object'] = as1.get_objects(outer_obj)
1✔
778

779
        def translate(elem, field, fn, uri=False):
1✔
780
            elem[field] = as1.get_objects(elem, field)
1✔
781
            for obj in elem[field]:
1✔
782
                if id := obj.get('id'):
1✔
783
                    if field in ('to', 'cc', 'bcc', 'bto') and as1.is_audience(id):
1✔
784
                        continue
1✔
785
                    from_cls = Protocol.for_id(id)
1✔
786
                    # TODO: what if from_cls is None? relax translate_object_id,
787
                    # make it a noop if we don't know enough about from/to?
788
                    if from_cls and from_cls != to_cls:
1✔
789
                        obj['id'] = fn(id=id, from_=from_cls, to=to_cls)
1✔
790
                    if obj['id'] and uri:
1✔
791
                        obj['id'] = to_cls(id=obj['id']).id_uri()
1✔
792

793
            elem[field] = [o['id'] if o.keys() == {'id'} else o
1✔
794
                           for o in elem[field]]
795

796
            if len(elem[field]) == 1:
1✔
797
                elem[field] = elem[field][0]
1✔
798

799
        type = as1.object_type(outer_obj)
1✔
800
        translate(outer_obj, 'id',
1✔
801
                  translate_user_id if type in as1.ACTOR_TYPES
802
                  else translate_object_id)
803

804
        for o in inner_objs:
1✔
805
            is_actor = (as1.object_type(o) in as1.ACTOR_TYPES
1✔
806
                        or as1.get_owner(outer_obj) == o.get('id')
807
                        or type in ('follow', 'stop-following'))
808
            translate(o, 'id', translate_user_id if is_actor else translate_object_id)
1✔
809
            obj_is_actor = o.get('verb') in as1.VERBS_WITH_ACTOR_OBJECT
1✔
810
            translate(o, 'object', translate_user_id if obj_is_actor
1✔
811
                      else translate_object_id)
812

813
        for o in [outer_obj] + inner_objs:
1✔
814
            translate(o, 'inReplyTo', translate_object_id)
1✔
815
            for field in 'actor', 'author', 'to', 'cc', 'bto', 'bcc':
1✔
816
                translate(o, field, translate_user_id)
1✔
817
            for tag in as1.get_objects(o, 'tags'):
1✔
818
                if tag.get('objectType') == 'mention':
1✔
819
                    translate(tag, 'url', translate_user_id, uri=True)
1✔
820
            for att in as1.get_objects(o, 'attachments'):
1✔
821
                translate(att, 'id', translate_object_id)
1✔
822
                url = att.get('url')
1✔
823
                if url and not att.get('id'):
1✔
824
                    if from_cls := Protocol.for_id(url):
1✔
825
                        att['id'] = translate_object_id(from_=from_cls, to=to_cls,
1✔
826
                                                        id=url)
827

828
        outer_obj = util.trim_nulls(outer_obj)
1✔
829

830
        if objs := util.get_list(outer_obj ,'object'):
1✔
831
            outer_obj['object'] = [o['id'] if o.keys() == {'id'} else o for o in objs]
1✔
832
            if len(outer_obj['object']) == 1:
1✔
833
                outer_obj['object'] = outer_obj['object'][0]
1✔
834

835
        return outer_obj
1✔
836

837
    @classmethod
1✔
838
    def receive(from_cls, obj, authed_as=None, internal=False, received_at=None):
1✔
839
        """Handles an incoming activity.
840

841
        If ``obj``'s key is unset, ``obj.as1``'s id field is used. If both are
842
        unset, returns HTTP 299.
843

844
        Args:
845
          obj (models.Object)
846
          authed_as (str): authenticated actor id who sent this activity
847
          internal (bool): whether to allow activity ids on internal domains,
848
            from opted out/blocked users, etc.
849
          received_at (datetime): when we first saw (received) this activity.
850
            Right now only used for monitoring.
851

852
        Returns:
853
          (str, int) tuple: (response body, HTTP status code) Flask response
854

855
        Raises:
856
          werkzeug.HTTPException: if the request is invalid
857
        """
858
        # check some invariants
859
        assert from_cls != Protocol
1✔
860
        assert isinstance(obj, Object), obj
1✔
861

862
        if not obj.as1:
1✔
863
            error('No object data provided')
×
864

865
        id = None
1✔
866
        if obj.key and obj.key.id():
1✔
867
            id = obj.key.id()
1✔
868

869
        if not id:
1✔
870
            id = obj.as1.get('id')
1✔
871
            obj.key = ndb.Key(Object, id)
1✔
872

873
        if not id:
1✔
874
            error('No id provided')
×
875
        elif from_cls.owns_id(id) is False:
1✔
876
            error(f'Protocol {from_cls.LABEL} does not own id {id}')
1✔
877
        elif from_cls.is_blocklisted(id, allow_internal=internal):
1✔
878
            error(f'Activity {id} is blocklisted')
1✔
879
        # check that this activity is public. only do this for some activities,
880
        # not eg likes or follows, since Mastodon doesn't currently mark those
881
        # as explicitly public.
882
        elif (obj.type in set(('post', 'update')) | as1.POST_TYPES | as1.ACTOR_TYPES
1✔
883
                  and not as1.is_public(obj.as1, unlisted=False)
884
                  and not as1.is_dm(obj.as1)):
885
              logger.info('Dropping non-public activity')
1✔
886
              return ('OK', 200)
1✔
887

888
        # lease this object, atomically
889
        memcache_key = activity_id_memcache_key(id)
1✔
890
        leased = memcache.memcache.add(memcache_key, 'leased', noreply=False,
1✔
891
                                     expire=5 * 60)  # 5 min
892
        # short circuit if we've already seen this activity id.
893
        # (don't do this for bare objects since we need to check further down
894
        # whether they've been updated since we saw them last.)
895
        if (obj.as1.get('objectType') == 'activity'
1✔
896
            and 'force' not in request.values
897
            and (not leased
898
                 or (obj.new is False and obj.changed is False))):
899
            error(f'Already seen this activity {id}', status=204)
1✔
900

901
        pruned = {k: v for k, v in obj.as1.items()
1✔
902
                  if k not in ('contentMap', 'replies', 'signature')}
903
        delay = ''
1✔
904
        if received_at and request.headers.get('X-AppEngine-TaskRetryCount') == '0':
1✔
905
            delay_s = int((util.now().replace(tzinfo=None)
×
906
                           - received_at.replace(tzinfo=None)
907
                           ).total_seconds())
908
            delay = f'({delay_s} s behind)'
×
909
        logger.info(f'Receiving {from_cls.LABEL} {obj.type} {id} {delay} AS1: {json_dumps(pruned, indent=2)}')
1✔
910

911
        # does this protocol support this activity/object type?
912
        from_cls.check_supported(obj)
1✔
913

914
        # load actor user, check authorization
915
        # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
916
        actor = as1.get_owner(obj.as1)
1✔
917
        if not actor:
1✔
918
            error('Activity missing actor or author')
1✔
919
        elif from_cls.owns_id(actor) is False:
1✔
920
            error(f"{from_cls.LABEL} doesn't own actor {actor}, this is probably a bridged activity. Skipping.", status=204)
1✔
921

922
        assert authed_as
1✔
923
        assert isinstance(authed_as, str)
1✔
924
        authed_as = normalize_user_id(id=authed_as, proto=from_cls)
1✔
925
        actor = normalize_user_id(id=actor, proto=from_cls)
1✔
926
        if actor != authed_as:
1✔
927
            report_error("Auth: receive: authed_as doesn't match owner",
1✔
928
                         user=f'{id} authed_as {authed_as} owner {actor}')
929
            error(f"actor {actor} isn't authed user {authed_as}")
1✔
930

931
        # update copy ids to originals
932
        obj.normalize_ids()
1✔
933
        obj.resolve_ids()
1✔
934

935
        if (obj.type == 'follow'
1✔
936
                and Protocol.for_bridgy_subdomain(as1.get_object(obj.as1).get('id'))):
937
            # follows of bot user; refresh user profile first
938
            logger.info(f'Follow of bot user, reloading {actor}')
1✔
939
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=True)
1✔
940
            from_user.reload_profile()
1✔
941
        else:
942
            # load actor user
943
            from_user = from_cls.get_or_create(id=actor, allow_opt_out=internal)
1✔
944

945
        if not internal and (not from_user
1✔
946
                             or from_user.manual_opt_out
947
                             # we want to override opt-out but not manual or blocked
948
                             or (from_user.status and from_user.status != 'opt-out')):
949
            error(f'Actor {actor} is opted out or blocked', status=204)
1✔
950

951
        # write Object to datastore
952
        orig_props = {
1✔
953
            **obj.to_dict(),
954
            # structured properties
955
            'copies': obj.copies,
956
        }
957
        obj = Object.get_or_create(id, new=obj.new, changed=obj.changed,
1✔
958
                                   authed_as=actor, **orig_props)
959

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

964
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
965
        inner_obj_id = inner_obj_as1.get('id')
1✔
966
        if obj.type in as1.CRUD_VERBS | set(('like', 'share')):
1✔
967
            if not inner_obj_id:
1✔
968
                error(f'{obj.type} object has no id!')
1✔
969

970
        if obj.type in as1.CRUD_VERBS:
1✔
971
            if inner_owner := as1.get_owner(inner_obj_as1):
1✔
972
                if inner_owner_key := from_cls.key_for(inner_owner):
1✔
973
                    obj.add('users', inner_owner_key)
1✔
974

975
        obj.source_protocol = from_cls.LABEL
1✔
976
        obj.put()
1✔
977

978
        # store inner object
979
        if obj.type in ('post', 'update') and inner_obj_as1.keys() > set(['id']):
1✔
980
            Object.get_or_create(inner_obj_id, our_as1=inner_obj_as1,
1✔
981
                                 source_protocol=from_cls.LABEL, authed_as=actor)
982

983
        actor = as1.get_object(obj.as1, 'actor')
1✔
984
        actor_id = actor.get('id')
1✔
985

986
        # handle activity!
987
        if obj.type == 'stop-following':
1✔
988
            # TODO: unify with handle_follow?
989
            # TODO: handle multiple followees
990
            if not actor_id or not inner_obj_id:
1✔
991
                error(f'stop-following requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
×
992

993
            # deactivate Follower
994
            from_ = from_cls.key_for(actor_id)
1✔
995
            to_cls = Protocol.for_id(inner_obj_id)
1✔
996
            to = to_cls.key_for(inner_obj_id)
1✔
997
            follower = Follower.query(Follower.to == to,
1✔
998
                                      Follower.from_ == from_,
999
                                      Follower.status == 'active').get()
1000
            if follower:
1✔
1001
                logger.info(f'Marking {follower} inactive')
1✔
1002
                follower.status = 'inactive'
1✔
1003
                follower.put()
1✔
1004
            else:
1005
                logger.warning(f'No Follower found for {from_} => {to}')
1✔
1006

1007
            # fall through to deliver to followee
1008
            # TODO: do we convert stop-following to webmention 410 of original
1009
            # follow?
1010

1011
            # fall through to deliver to followers
1012

1013
        elif obj.type in ('delete', 'undo'):
1✔
1014
            assert inner_obj_id
1✔
1015
            inner_obj = Object.get_by_id(inner_obj_id, authed_as=authed_as)
1✔
1016
            if not inner_obj:
1✔
1017
                logger.info(f"Ignoring, we don't have {inner_obj_id} stored")
1✔
1018
                return 'OK', 204
1✔
1019

1020
            logger.info(f'Marking Object {inner_obj_id} deleted')
1✔
1021
            inner_obj.deleted = True
1✔
1022
            inner_obj.put()
1✔
1023

1024
            # if this is an actor, handle deleting it later so that
1025
            # in case it's from_user, user.enabled_protocols is still populated
1026
            #
1027
            # fall through to deliver to followers and delete copy if necessary.
1028
            # should happen via protocol-specific copy target and send of
1029
            # delete activity.
1030
            # https://github.com/snarfed/bridgy-fed/issues/63
1031

1032
        elif obj.type == 'block':
1✔
1033
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1034
                # blocking protocol bot user disables that protocol
1035
                from_user.delete(proto)
1✔
1036
                from_user.disable_protocol(proto)
1✔
1037
                return 'OK', 200
1✔
1038

1039
        elif obj.type == 'post':
1✔
1040
            # handle DMs to bot users
1041
            if as1.is_dm(obj.as1):
1✔
1042
                return dms.receive(from_user=from_user, obj=obj)
1✔
1043

1044
        # fetch actor if necessary
1045
        if (actor and actor.keys() == set(['id'])
1✔
1046
                and obj.type not in ('delete', 'undo')):
1047
            logger.debug('Fetching actor so we have name, profile photo, etc')
1✔
1048
            actor_obj = from_cls.load(actor['id'], raise_=False)
1✔
1049
            if actor_obj and actor_obj.as1:
1✔
1050
                obj.our_as1 = {**obj.as1, 'actor': actor_obj.as1}
1✔
1051

1052
        # fetch object if necessary so we can render it in feeds
1053
        if (obj.type == 'share'
1✔
1054
                and inner_obj_as1.keys() == set(['id'])
1055
                and from_cls.owns_id(inner_obj_id)):
1056
            logger.debug('Fetching object so we can render it in feeds')
1✔
1057
            inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1058
            if inner_obj and inner_obj.as1:
1✔
1059
                obj.our_as1 = {
1✔
1060
                    **obj.as1,
1061
                    'object': {
1062
                        **inner_obj_as1,
1063
                        **inner_obj.as1,
1064
                    }
1065
                }
1066

1067
        if obj.type == 'follow':
1✔
1068
            if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
1✔
1069
                # follow of one of our protocol bot users; enable that protocol.
1070
                # fall through so that we send an accept.
1071
                from_user.enable_protocol(proto)
1✔
1072
                proto.bot_follow(from_user)
1✔
1073

1074
            from_cls.handle_follow(obj)
1✔
1075

1076
        # deliver to targets
1077
        resp = from_cls.deliver(obj, from_user=from_user)
1✔
1078

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

1087
                    logger.info(f'Deactivating Followers from or to = {inner_obj_id}')
1✔
1088
                    followers = Follower.query(
1✔
1089
                        OR(Follower.to == user_key, Follower.from_ == user_key)
1090
                        ).fetch()
1091
                    for f in followers:
1✔
1092
                        f.status = 'inactive'
1✔
1093
                    ndb.put_multi(followers)
1✔
1094

1095
        memcache.memcache.set(memcache_key, 'done', expire=7 * 24 * 60 * 60)  # 1w
1✔
1096
        return resp
1✔
1097

1098
    @classmethod
1✔
1099
    def handle_follow(from_cls, obj):
1✔
1100
        """Handles an incoming follow activity.
1101

1102
        Sends an ``Accept`` back, but doesn't send the ``Follow`` itself. That
1103
        happens in :meth:`deliver`.
1104

1105
        Args:
1106
          obj (models.Object): follow activity
1107
        """
1108
        logger.debug('Got follow. Loading users, storing Follow(s), sending accept(s)')
1✔
1109

1110
        # Prepare follower (from) users' data
1111
        from_as1 = as1.get_object(obj.as1, 'actor')
1✔
1112
        from_id = from_as1.get('id')
1✔
1113
        if not from_id:
1✔
1114
            error(f'Follow activity requires actor. Got: {obj.as1}')
×
1115

1116
        from_obj = from_cls.load(from_id, raise_=False)
1✔
1117
        if not from_obj:
1✔
1118
            error(f"Couldn't load {from_id}", status=502)
×
1119

1120
        if not from_obj.as1:
1✔
1121
            from_obj.our_as1 = from_as1
1✔
1122
            from_obj.put()
1✔
1123

1124
        from_key = from_cls.key_for(from_id)
1✔
1125
        if not from_key:
1✔
1126
            error(f'Invalid {from_cls.LABEL} user key: {from_id}')
×
1127
        obj.users = [from_key]
1✔
1128
        from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
1✔
1129

1130
        # Prepare followee (to) users' data
1131
        to_as1s = as1.get_objects(obj.as1)
1✔
1132
        if not to_as1s:
1✔
1133
            error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1134

1135
        # Store Followers
1136
        for to_as1 in to_as1s:
1✔
1137
            to_id = to_as1.get('id')
1✔
1138
            if not to_id:
1✔
1139
                error(f'Follow activity requires object(s). Got: {obj.as1}')
×
1140

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

1143
            to_cls = Protocol.for_id(to_id)
1✔
1144
            if not to_cls:
1✔
1145
                error(f"Couldn't determine protocol for {to_id}")
×
1146
            elif from_cls == to_cls:
1✔
1147
                logger.info(f'Skipping same-protocol Follower {from_id} => {to_id}')
1✔
1148
                continue
1✔
1149

1150
            to_obj = to_cls.load(to_id)
1✔
1151
            if to_obj and not to_obj.as1:
1✔
1152
                to_obj.our_as1 = to_as1
1✔
1153
                to_obj.put()
1✔
1154

1155
            to_key = to_cls.key_for(to_id)
1✔
1156
            if not to_key:
1✔
1157
                logger.info(f'Skipping invalid {from_cls.LABEL} user key: {from_id}')
×
1158
                continue
×
1159

1160
            to_user = to_cls.get_or_create(id=to_key.id(), obj=to_obj,
1✔
1161
                                           allow_opt_out=True)
1162
            follower_obj = Follower.get_or_create(to=to_user, from_=from_user,
1✔
1163
                                                  follow=obj.key, status='active')
1164
            obj.add('notify', to_key)
1✔
1165
            from_cls.maybe_accept_follow(follower=from_user, followee=to_user,
1✔
1166
                                         follow=obj)
1167

1168
    @classmethod
1✔
1169
    def maybe_accept_follow(_, follower, followee, follow):
1✔
1170
        """Sends an accept activity for a follow.
1171

1172
        ...if the follower protocol handles accepts. Otherwise, does nothing.
1173

1174
        Args:
1175
          follower: :class:`models.User`
1176
          followee: :class:`models.User`
1177
          follow: :class:`models.Object`
1178
        """
1179
        if 'accept' not in follower.SUPPORTED_AS1_TYPES:
1✔
1180
            return
1✔
1181

1182
        target = follower.target_for(follower.obj)
1✔
1183
        if not target:
1✔
1184
            error(f"Couldn't find delivery target for follower {follower.key.id()}")
×
1185

1186
        # send accept. note that this is one accept for the whole
1187
        # follow, even if it has multiple followees!
1188
        id = f'{followee.key.id()}/followers#accept-{follow.key.id()}'
1✔
1189
        accept = {
1✔
1190
            'id': id,
1191
            'objectType': 'activity',
1192
            'verb': 'accept',
1193
            'actor': followee.key.id(),
1194
            'object': follow.as1,
1195
        }
1196
        common.create_task(queue='send', id=id, our_as1=accept, url=target,
1✔
1197
                           protocol=follower.LABEL, user=followee.key.urlsafe())
1198

1199
    @classmethod
1✔
1200
    def bot_follow(bot_cls, user):
1✔
1201
        """Follow a user from a protocol bot user.
1202

1203
        ...so that the protocol starts sending us their activities, if it needs
1204
        a follow for that (eg ActivityPub).
1205

1206
        Args:
1207
          user (User)
1208
        """
1209
        from web import Web
1✔
1210
        bot = Web.get_by_id(bot_cls.bot_user_id())
1✔
1211
        now = util.now().isoformat()
1✔
1212
        logger.info(f'Following {user.key.id()} back from bot user {bot.key.id()}')
1✔
1213

1214
        if not user.obj:
1✔
1215
            logger.info("  can't follow, user has no profile obj")
1✔
1216
            return
1✔
1217

1218
        target = user.target_for(user.obj)
1✔
1219
        follow_back_id = f'https://{bot.key.id()}/#follow-back-{user.key.id()}-{now}'
1✔
1220
        follow_back_as1 = {
1✔
1221
            'objectType': 'activity',
1222
            'verb': 'follow',
1223
            'id': follow_back_id,
1224
            'actor': bot.key.id(),
1225
            'object': user.key.id(),
1226
        }
1227
        common.create_task(queue='send', id=follow_back_id,
1✔
1228
                           our_as1=follow_back_as1, url=target,
1229
                           source_protocol='web', protocol=user.LABEL,
1230
                           user=bot.key.urlsafe())
1231

1232
    @classmethod
1✔
1233
    def handle_bare_object(cls, obj, authed_as=None):
1✔
1234
        """If obj is a bare object, wraps it in a create or update activity.
1235

1236
        Checks if we've seen it before.
1237

1238
        Args:
1239
          obj (models.Object)
1240
          authed_as (str): authenticated actor id who sent this activity
1241

1242
        Returns:
1243
          models.Object: ``obj`` if it's an activity, otherwise a new object
1244
        """
1245
        is_actor = obj.type in as1.ACTOR_TYPES
1✔
1246
        if not is_actor and obj.type not in ('note', 'article', 'comment'):
1✔
1247
            return obj
1✔
1248

1249
        obj_actor = as1.get_owner(obj.as1)
1✔
1250
        now = util.now().isoformat()
1✔
1251

1252
        # this is a raw post; wrap it in a create or update activity
1253
        if obj.changed or is_actor:
1✔
1254
            if obj.changed:
1✔
1255
                logger.info(f'Content has changed from last time at {obj.updated}! Redelivering to all inboxes')
1✔
1256
            else:
1257
                logger.info(f'Got actor profile object, wrapping in update')
1✔
1258
            id = f'{obj.key.id()}#bridgy-fed-update-{now}'
1✔
1259
            update_as1 = {
1✔
1260
                'objectType': 'activity',
1261
                'verb': 'update',
1262
                'id': id,
1263
                'actor': obj_actor,
1264
                'object': {
1265
                    # Mastodon requires the updated field for Updates, so
1266
                    # add a default value.
1267
                    # https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses
1268
                    # https://socialhub.activitypub.rocks/t/what-could-be-the-reason-that-my-update-activity-does-not-work/2893/4
1269
                    # https://github.com/mastodon/documentation/pull/1150
1270
                    'updated': now,
1271
                    **obj.as1,
1272
                },
1273
            }
1274
            logger.debug(f'  AS1: {json_dumps(update_as1, indent=2)}')
1✔
1275
            return Object(id=id, our_as1=update_as1,
1✔
1276
                          source_protocol=obj.source_protocol)
1277

1278
        create_id = f'{obj.key.id()}#bridgy-fed-create'
1✔
1279
        create = cls.load(create_id, remote=False)
1✔
1280
        if (obj.new or not create
1✔
1281
                # HACK: force query param here is specific to webmention
1282
                or 'force' in request.form):
1283
            if create:
1✔
1284
                logger.info(f'Existing create {create.key.id()}')
1✔
1285
            else:
1286
                logger.info(f'No existing create activity')
1✔
1287
            create_as1 = {
1✔
1288
                'objectType': 'activity',
1289
                'verb': 'post',
1290
                'id': create_id,
1291
                'actor': obj_actor,
1292
                'object': obj.as1,
1293
                'published': now,
1294
            }
1295
            logger.info(f'Wrapping in post')
1✔
1296
            logger.debug(f'  AS1: {json_dumps(create_as1, indent=2)}')
1✔
1297
            return Object.get_or_create(create_id, our_as1=create_as1,
1✔
1298
                                        source_protocol=obj.source_protocol,
1299
                                        authed_as=authed_as)
1300

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

1303
    @classmethod
1✔
1304
    def deliver(from_cls, obj, from_user, to_proto=None):
1✔
1305
        """Delivers an activity to its external recipients.
1306

1307
        Args:
1308
          obj (models.Object): activity to deliver
1309
          from_user (models.User): user (actor) this activity is from
1310
          to_proto (protocol.Protocol): optional; if provided, only deliver to
1311
            targets on this protocol
1312

1313
        Returns:
1314
          (str, int) tuple: Flask response
1315
        """
1316
        if to_proto:
1✔
1317
            logger.info(f'Only delivering to {to_proto.LABEL}')
1✔
1318

1319
        # find delivery targets. maps Target to Object or None
1320
        targets = from_cls.targets(obj, from_user=from_user)
1✔
1321

1322
        # TODO: this would be clearer if it was at the end of receive(), which
1323
        # that *should* be equivalent, but oddly tests fail if it's moved there
1324
        if not targets:
1✔
1325
            return r'No targets, nothing to do ¯\_(ツ)_/¯', 204
1✔
1326

1327
        if obj.type in STORE_AS1_TYPES:
1✔
1328
            obj.put()
1✔
1329
            obj_params = {'obj_id': obj.key.id()}
1✔
1330
        else:
1331
            assert obj.type in DONT_STORE_AS1_TYPES
1✔
1332
            obj_params = obj.to_request()
1✔
1333

1334
        # sort targets so order is deterministic for tests, debugging, etc
1335
        sorted_targets = sorted(targets.items(), key=lambda t: t[0].uri)
1✔
1336
        logger.info(f'Delivering to: {[t for t, _ in sorted_targets]}')
1✔
1337

1338
        # enqueue send task for each targets
1339
        user = from_user.key.urlsafe()
1✔
1340
        for i, (target, orig_obj) in enumerate(sorted_targets):
1✔
1341
            if to_proto and target.protocol != to_proto.LABEL:
1✔
1342
                continue
×
1343
            orig_obj_id = orig_obj.key.id() if orig_obj else None
1✔
1344
            common.create_task(queue='send', url=target.uri, protocol=target.protocol,
1✔
1345
                               orig_obj_id=orig_obj_id, user=user, **obj_params)
1346

1347
        return 'OK', 202
1✔
1348

1349
    @classmethod
1✔
1350
    def targets(from_cls, obj, from_user, internal=False):
1✔
1351
        """Collects the targets to send a :class:`models.Object` to.
1352

1353
        Targets are both objects - original posts, events, etc - and actors.
1354

1355
        Args:
1356
          obj (models.Object)
1357
          from_user (User)
1358
          internal (bool): whether this is a recursive internal call
1359

1360
        Returns:
1361
          dict: maps :class:`models.Target` to original (in response to)
1362
          :class:`models.Object`, if any, otherwise None
1363
        """
1364
        logger.debug('Finding recipients and their targets')
1✔
1365

1366
        target_uris = sorted(set(as1.targets(obj.as1)))
1✔
1367
        logger.info(f'Raw targets: {target_uris}')
1✔
1368
        orig_obj = None
1✔
1369
        targets = {}  # maps Target to Object or None
1✔
1370
        owner = as1.get_owner(obj.as1)
1✔
1371
        allow_opt_out = (obj.type == 'delete')
1✔
1372
        inner_obj_as1 = as1.get_object(obj.as1)
1✔
1373
        inner_obj_id = inner_obj_as1.get('id')
1✔
1374
        in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
1✔
1375
        is_reply = obj.type == 'comment' or in_reply_tos
1✔
1376
        is_self_reply = False
1✔
1377

1378
        if is_reply:
1✔
1379
            original_ids = in_reply_tos
1✔
1380
        else:
1381
            if inner_obj_id == from_user.key.id():
1✔
1382
                inner_obj_id = from_user.profile_id()
1✔
1383
            original_ids = [inner_obj_id]
1✔
1384

1385
        # which protocols should we allow delivering to?
1386
        to_protocols = []
1✔
1387
        for label in (list(from_user.DEFAULT_ENABLED_PROTOCOLS)
1✔
1388
                      + from_user.enabled_protocols):
1389
            proto = PROTOCOLS[label]
1✔
1390
            if proto.HAS_COPIES and (obj.type in ('update', 'delete', 'share', 'undo')
1✔
1391
                                     or is_reply):
1392
                for id in original_ids:
1✔
1393
                    if Protocol.for_id(id) == proto:
1✔
1394
                        logger.info(f'Allowing {label} for original post {id}')
1✔
1395
                        break
1✔
1396
                    elif orig := from_user.load(id, remote=False):
1✔
1397
                        if orig.get_copy(proto):
1✔
1398
                            logger.info(f'Allowing {label}, original post {id} was bridged there')
1✔
1399
                            break
1✔
1400
                else:
1401
                    logger.info(f"Skipping {label}, original posts {original_ids} weren't bridged there")
1✔
1402
                    continue
1✔
1403

1404
            util.add(to_protocols, proto)
1✔
1405

1406
        # process direct targets
1407
        for id in sorted(target_uris):
1✔
1408
            target_proto = Protocol.for_id(id)
1✔
1409
            if not target_proto:
1✔
1410
                logger.info(f"Can't determine protocol for {id}")
1✔
1411
                continue
1✔
1412
            elif target_proto.is_blocklisted(id):
1✔
1413
                logger.debug(f'{id} is blocklisted')
1✔
1414
                continue
1✔
1415

1416
            orig_obj = target_proto.load(id, raise_=False)
1✔
1417
            if not orig_obj or not orig_obj.as1:
1✔
1418
                logger.info(f"Couldn't load {id}")
1✔
1419
                continue
1✔
1420

1421
            target_author_key = target_proto.actor_key(orig_obj)
1✔
1422
            if not from_user.is_enabled(target_proto):
1✔
1423
                # if author isn't bridged and inReplyTo author is, DM a prompt
1424
                if id in in_reply_tos:
1✔
1425
                    if target_author := target_author_key.get():
1✔
1426
                        if target_author.is_enabled(from_cls):
1✔
1427
                            dms.maybe_send(
1✔
1428
                                from_proto=target_proto, to_user=from_user,
1429
                                type='replied_to_bridged_user', text=f"""\
1430
Hi! You <a href="{inner_obj_as1.get('url') or inner_obj_id}">recently replied</a> to {orig_obj.actor_link(image=False)}, who's bridged here from {target_proto.PHRASE}. If you want them to see your replies, you can bridge your account into {target_proto.PHRASE} by following this account. <a href="https://fed.brid.gy/docs">See the docs</a> for more information.""")
1431

1432
                continue
1✔
1433

1434
            # deliver self-replies to followers
1435
            # https://github.com/snarfed/bridgy-fed/issues/639
1436
            if id in in_reply_tos and owner == as1.get_owner(orig_obj.as1):
1✔
1437
                is_self_reply = True
1✔
1438
                logger.info(f'self reply!')
1✔
1439

1440
            # also add copies' targets
1441
            for copy in orig_obj.copies:
1✔
1442
                proto = PROTOCOLS[copy.protocol]
1✔
1443
                if proto in to_protocols:
1✔
1444
                    # copies generally won't have their own Objects
1445
                    if target := proto.target_for(Object(id=copy.uri)):
1✔
1446
                        logger.debug(f'Adding target {target} for copy {copy.uri} of original {id}')
1✔
1447
                        targets[Target(protocol=copy.protocol, uri=target)] = orig_obj
1✔
1448

1449
            if target_proto == from_cls:
1✔
1450
                logger.debug(f'Skipping same-protocol target {id}')
1✔
1451
                continue
1✔
1452

1453
            target = target_proto.target_for(orig_obj)
1✔
1454
            if not target:
1✔
1455
                # TODO: surface errors like this somehow?
1456
                logger.error(f"Can't find delivery target for {id}")
×
1457
                continue
×
1458

1459
            logger.debug(f'Target for {id} is {target}')
1✔
1460
            # only use orig_obj for inReplyTos, like/repost objects, etc
1461
            # https://github.com/snarfed/bridgy-fed/issues/1237
1462
            targets[Target(protocol=target_proto.LABEL, uri=target)] = (
1✔
1463
                orig_obj if id in in_reply_tos or id in as1.get_ids(obj.as1, 'object')
1464
                else None)
1465

1466
            if target_author_key:
1✔
1467
                logger.debug(f'Recipient is {target_author_key}')
1✔
1468
                obj.add('notify', target_author_key)
1✔
1469

1470
        if obj.type == 'undo':
1✔
1471
            logger.debug('Object is an undo; adding targets for inner object')
1✔
1472
            if set(inner_obj_as1.keys()) == {'id'}:
1✔
1473
                inner_obj = from_cls.load(inner_obj_id, raise_=False)
1✔
1474
            else:
1475
                inner_obj = Object(id=inner_obj_id, our_as1=inner_obj_as1)
1✔
1476
            if inner_obj:
1✔
1477
                targets.update(from_cls.targets(inner_obj, from_user=from_user,
1✔
1478
                                                internal=True))
1479

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

1482
        # deliver to followers, if appropriate
1483
        user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
1✔
1484
        if not user_key:
1✔
1485
            logger.info("Can't tell who this is from! Skipping followers.")
1✔
1486
            return targets
1✔
1487

1488
        followers = []
1✔
1489
        if (obj.type in ('post', 'update', 'delete', 'share')
1✔
1490
                and (not is_reply or is_self_reply)):
1491
            logger.info(f'Delivering to followers of {user_key}')
1✔
1492
            followers = [
1✔
1493
                f for f in Follower.query(Follower.to == user_key,
1494
                                          Follower.status == 'active')
1495
                # skip protocol bot users
1496
                if not Protocol.for_bridgy_subdomain(f.from_.id())
1497
                # skip protocols this user hasn't enabled, or where the base
1498
                # object of this activity hasn't been bridged
1499
                and PROTOCOLS_BY_KIND[f.from_.kind()] in to_protocols]
1500
            user_keys = [f.from_ for f in followers]
1✔
1501
            users = [u for u in ndb.get_multi(user_keys) if u]
1✔
1502
            User.load_multi(users)
1✔
1503

1504
            if (not followers and
1✔
1505
                (util.domain_or_parent_in(
1506
                    util.domain_from_link(from_user.key.id()), LIMITED_DOMAINS)
1507
                 or util.domain_or_parent_in(
1508
                     util.domain_from_link(obj.key.id()), LIMITED_DOMAINS))):
1509
                logger.info(f'skipping, {from_user.key.id()} is on a limited domain and has no followers')
1✔
1510
                return {}
1✔
1511

1512
            # which object should we add to followers' feeds, if any
1513
            feed_obj = None
1✔
1514
            if not internal:
1✔
1515
                if obj.type == 'share':
1✔
1516
                    feed_obj = obj
1✔
1517
                elif obj.type not in ('delete', 'undo', 'stop-following'):
1✔
1518
                    inner = as1.get_object(obj.as1)
1✔
1519
                    # don't add profile updates to feeds
1520
                    if not (obj.type == 'update'
1✔
1521
                            and inner.get('objectType') in as1.ACTOR_TYPES):
1522
                        inner_id = inner.get('id')
1✔
1523
                        if inner_id:
1✔
1524
                            feed_obj = from_cls.load(inner_id, raise_=False)
1✔
1525

1526
            for user in users:
1✔
1527
                if feed_obj:
1✔
1528
                    feed_obj.add('feed', user.key)
1✔
1529

1530
                # TODO: should we pass remote=False through here to Protocol.load?
1531
                target = user.target_for(user.obj, shared=True) if user.obj else None
1✔
1532
                if not target:
1✔
1533
                    # TODO: surface errors like this somehow?
1534
                    logger.error(f'Follower {user.key} has no delivery target')
1✔
1535
                    continue
1✔
1536

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

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

1545
            if feed_obj:
1✔
1546
                feed_obj.put()
1✔
1547

1548
        # deliver to enabled HAS_COPIES protocols proactively
1549
        # TODO: abstract for other protocols
1550
        from atproto import ATProto
1✔
1551
        if (ATProto in to_protocols
1✔
1552
                and obj.type in ('post', 'update', 'delete', 'share')):
1553
            logger.info(f'user has ATProto enabled, adding {ATProto.PDS_URL}')
1✔
1554
            targets.setdefault(
1✔
1555
                Target(protocol=ATProto.LABEL, uri=ATProto.PDS_URL), None)
1556

1557
        # de-dupe targets, discard same-domain
1558
        # maps string target URL to (Target, Object) tuple
1559
        candidates = {t.uri: (t, obj) for t, obj in targets.items()}
1✔
1560
        # maps Target to Object or None
1561
        targets = {}
1✔
1562
        source_domains = [
1✔
1563
            util.domain_from_link(url) for url in
1564
            (obj.as1.get('id'), obj.as1.get('url'), as1.get_owner(obj.as1))
1565
            if util.is_web(url)
1566
        ]
1567
        for url in sorted(util.dedupe_urls(
1✔
1568
                candidates.keys(),
1569
                # preserve our PDS URL without trailing slash in path
1570
                # https://atproto.com/specs/did#did-documents
1571
                trailing_slash=False)):
1572
            if util.is_web(url) and util.domain_from_link(url) in source_domains:
1✔
1573
                logger.info(f'Skipping same-domain target {url}')
×
1574
                continue
×
1575
            target, obj = candidates[url]
1✔
1576
            targets[target] = obj
1✔
1577

1578
        return targets
1✔
1579

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

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

1587
        Args:
1588
          id (str)
1589
          remote (bool): whether to fetch the object over the network. If True,
1590
            fetches even if we already have the object stored, and updates our
1591
            stored copy. If False and we don't have the object stored, returns
1592
            None. Default (None) means to fetch over the network only if we
1593
            don't already have it stored.
1594
          local (bool): whether to load from the datastore before
1595
            fetching over the network. If False, still stores back to the
1596
            datastore after a successful remote fetch.
1597
          raise_ (bool): if False, catches any :class:`request.RequestException`
1598
            or :class:`HTTPException` raised by :meth:`fetch()` and returns
1599
            ``None`` instead
1600
          kwargs: passed through to :meth:`fetch()`
1601

1602
        Returns:
1603
          models.Object: loaded object, or None if it isn't fetchable, eg a
1604
          non-URL string for Web, or ``remote`` is False and it isn't in the
1605
          datastore
1606

1607
        Raises:
1608
          requests.HTTPError: anything that :meth:`fetch` raises, if ``raise_``
1609
            is True
1610
        """
1611
        assert id
1✔
1612
        assert local or remote is not False
1✔
1613
        # logger.debug(f'Loading Object {id} local={local} remote={remote}')
1614

1615
        obj = orig_as1 = None
1✔
1616
        if local:
1✔
1617
            obj = Object.get_by_id(id)
1✔
1618
            if not obj:
1✔
1619
                # logger.debug(f' {id} not in datastore')
1620
                pass
1✔
1621
            elif obj.as1 or obj.raw or obj.deleted:
1✔
1622
                # logger.debug(f'  {id} got from datastore')
1623
                obj.new = False
1✔
1624

1625
        if remote is False:
1✔
1626
            return obj
1✔
1627
        elif remote is None and obj:
1✔
1628
            if obj.updated < util.as_utc(util.now() - OBJECT_REFRESH_AGE):
1✔
1629
                # logger.debug(f'  last updated {obj.updated}, refreshing')
1630
                pass
1✔
1631
            else:
1632
                return obj
1✔
1633

1634
        if obj:
1✔
1635
            orig_as1 = obj.as1
1✔
1636
            obj.clear()
1✔
1637
            obj.new = False
1✔
1638
        else:
1639
            obj = Object(id=id)
1✔
1640
            if local:
1✔
1641
                # logger.debug(f'  {id} not in datastore')
1642
                obj.new = True
1✔
1643
                obj.changed = False
1✔
1644

1645
        try:
1✔
1646
            fetched = cls.fetch(obj, **kwargs)
1✔
1647
        except (RequestException, HTTPException) as e:
1✔
1648
            if raise_:
1✔
1649
                raise
1✔
1650
            util.interpret_http_exception(e)
1✔
1651
            return None
1✔
1652

1653
        if not fetched:
1✔
1654
            return None
1✔
1655

1656
        # https://stackoverflow.com/a/3042250/186123
1657
        size = len(_entity_to_protobuf(obj)._pb.SerializeToString())
1✔
1658
        if size > models.MAX_ENTITY_SIZE:
1✔
1659
            logger.warning(f'Object is too big! {size} bytes is over {models.MAX_ENTITY_SIZE}')
1✔
1660
            return None
1✔
1661

1662
        obj.resolve_ids()
1✔
1663
        obj.normalize_ids()
1✔
1664

1665
        if obj.new is False:
1✔
1666
            obj.changed = obj.activity_changed(orig_as1)
1✔
1667

1668
        if obj.source_protocol not in (cls.LABEL, cls.ABBREV):
1✔
1669
            if obj.source_protocol:
1✔
1670
                logger.warning(f'Object {obj.key.id()} changed protocol from {obj.source_protocol} to {cls.LABEL} ?!')
×
1671
            obj.source_protocol = cls.LABEL
1✔
1672

1673
        obj.put()
1✔
1674
        return obj
1✔
1675

1676
    @classmethod
1✔
1677
    def check_supported(cls, obj):
1✔
1678
        """If this protocol doesn't support this object, return 204.
1679

1680
        Also reports an error.
1681

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

1686
        Args:
1687
          obj (Object)
1688
        """
1689
        if not obj.type:
1✔
1690
            return
×
1691

1692
        inner_type = as1.object_type(as1.get_object(obj.as1)) or ''
1✔
1693
        if (obj.type not in cls.SUPPORTED_AS1_TYPES
1✔
1694
            or (obj.type in as1.CRUD_VERBS
1695
                and inner_type
1696
                and inner_type not in cls.SUPPORTED_AS1_TYPES)):
1697
            error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
1✔
1698

1699
        # DMs are only allowed to/from protocol bot accounts
1700
        if recip := as1.recipient_if_dm(obj.as1):
1✔
1701
            protocol_user_ids = PROTOCOL_DOMAINS + common.protocol_user_copy_ids()
1✔
1702
            if (not cls.SUPPORTS_DMS
1✔
1703
                    or (recip not in protocol_user_ids
1704
                        and as1.get_owner(obj.as1) not in protocol_user_ids)):
1705
                error(f"Bridgy Fed doesn't support DMs", status=204)
1✔
1706

1707

1708
@cloud_tasks_only(log=None)
1✔
1709
def receive_task():
1✔
1710
    """Task handler for a newly received :class:`models.Object`.
1711

1712
    Calls :meth:`Protocol.receive` with the form parameters.
1713

1714
    Parameters:
1715
      authed_as (str): passed to :meth:`Protocol.receive`
1716
      obj_id (str): key id of :class:`models.Object` to handle
1717
      received_at (str, ISO 8601 timestamp): when we first saw (received)
1718
        this activity
1719
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1720
        :class:`models.Object` to handle
1721

1722
    TODO: migrate incoming webmentions to this. See how we did it for AP. The
1723
    difficulty is that parts of :meth:`protocol.Protocol.receive` depend on
1724
    setup in :func:`web.webmention`, eg :class:`models.Object` with ``new`` and
1725
    ``changed``, HTTP request details, etc. See stash for attempt at this for
1726
    :class:`web.Web`.
1727
    """
1728
    form = request.form.to_dict()
1✔
1729
    logger.info(f'Params:\n' + '\n'.join(f'{k} = {v[:100]}' for k, v in form.items()))
1✔
1730

1731
    authed_as = form.pop('authed_as', None)
1✔
1732
    internal = (authed_as == common.PRIMARY_DOMAIN
1✔
1733
                or authed_as in common.PROTOCOL_DOMAINS)
1734

1735
    obj = Object.from_request()
1✔
1736
    assert obj
1✔
1737
    assert obj.source_protocol
1✔
1738
    obj.new = True
1✔
1739

1740
    if received_at := form.pop('received_at', None):
1✔
1741
        received_at = datetime.fromisoformat(received_at)
1✔
1742

1743
    try:
1✔
1744
        return PROTOCOLS[obj.source_protocol].receive(
1✔
1745
            obj=obj, authed_as=authed_as, internal=internal, received_at=received_at)
1746
    except RequestException as e:
1✔
1747
        util.interpret_http_exception(e)
1✔
1748
        error(e, status=304)
1✔
1749
    except ValueError as e:
1✔
1750
        logger.warning(e, exc_info=True)
×
1751
        error(e, status=304)
×
1752

1753

1754
@cloud_tasks_only(log=None)
1✔
1755
def send_task():
1✔
1756
    """Task handler for sending an activity to a single specific destination.
1757

1758
    Calls :meth:`Protocol.send` with the form parameters.
1759

1760
    Parameters:
1761
      protocol (str): :class:`Protocol` to send to
1762
      url (str): destination URL to send to
1763
      obj_id (str): key id of :class:`models.Object` to send
1764
      orig_obj_id (str): optional, :class:`models.Object` key id of the
1765
        "original object" that this object refers to, eg replies to or reposts
1766
        or likes
1767
      user (url-safe google.cloud.ndb.key.Key): :class:`models.User` (actor)
1768
        this activity is from
1769
      *: If ``obj_id`` is unset, all other parameters are properties for a new
1770
        :class:`models.Object` to handle
1771
    """
1772
    form = request.form.to_dict()
1✔
1773
    logger.info(f'Params: {list(form.items())}')
1✔
1774

1775
    # prepare
1776
    url = form.get('url')
1✔
1777
    protocol = form.get('protocol')
1✔
1778
    if not url or not protocol:
1✔
1779
        logger.warning(f'Missing protocol or url; got {protocol} {url}')
1✔
1780
        return '', 204
1✔
1781

1782
    target = Target(uri=url, protocol=protocol)
1✔
1783
    obj = Object.from_request()
1✔
1784
    assert obj and obj.key and obj.key.id()
1✔
1785

1786
    PROTOCOLS[protocol].check_supported(obj)
1✔
1787
    allow_opt_out = (obj.type == 'delete')
1✔
1788

1789
    user = None
1✔
1790
    if user_key := form.get('user'):
1✔
1791
        key = ndb.Key(urlsafe=user_key)
1✔
1792
        # use get_by_id so that we follow use_instead
1793
        user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(
1✔
1794
            key.id(), allow_opt_out=allow_opt_out)
1795

1796
    # send
1797
    delay = ''
1✔
1798
    if request.headers.get('X-AppEngine-TaskRetryCount') == '0' and obj.created:
1✔
1799
        delay_s = int((util.now().replace(tzinfo=None) - obj.created).total_seconds())
1✔
1800
        delay = f'({delay_s} s behind)'
1✔
1801
    logger.info(f'Sending {obj.source_protocol} {obj.type} {obj.key.id()} to {protocol} {url} {delay}')
1✔
1802
    logger.debug(f'  AS1: {json_dumps(obj.as1, indent=2)}')
1✔
1803
    sent = None
1✔
1804
    try:
1✔
1805
        sent = PROTOCOLS[protocol].send(obj, url, from_user=user,
1✔
1806
                                        orig_obj_id=form.get('orig_obj_id'))
1807
    except BaseException as e:
1✔
1808
        code, body = util.interpret_http_exception(e)
1✔
1809
        if not code and not body:
1✔
1810
            raise
1✔
1811

1812
    if sent is False:
1✔
1813
        logger.info(f'Failed sending!')
1✔
1814

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

© 2026 Coveralls, Inc