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

snarfed / bridgy-fed / d999a45f-7ac1-4269-9f97-eddab2d80e35

25 Feb 2025 07:48PM UTC coverage: 92.671%. Remained the same
d999a45f-7ac1-4269-9f97-eddab2d80e35

push

circleci

snarfed
settings page: use platform logos instead of protocol logos

for #1680

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

34 existing lines in 2 files now uncovered.

4830 of 5212 relevant lines covered (92.67%)

0.93 hits per line

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

95.76
/models.py
1
"""Datastore model classes."""
2
import copy
1✔
3
from datetime import timedelta, timezone
1✔
4
from functools import lru_cache
1✔
5
import itertools
1✔
6
import json
1✔
7
import logging
1✔
8
import random
1✔
9
import re
1✔
10
from threading import Lock
1✔
11
from urllib.parse import quote, urlparse
1✔
12

13
from arroba.util import parse_at_uri
1✔
14
import cachetools
1✔
15
from Crypto.PublicKey import RSA
1✔
16
from flask import request
1✔
17
from google.cloud import ndb
1✔
18
from granary import as1, as2, atom, bluesky, microformats2
1✔
19
from granary.bluesky import AT_URI_PATTERN, BSKY_APP_URL_RE
1✔
20
from granary.source import html_to_text
1✔
21
import humanize
1✔
22
from oauth_dropins.webutil import util
1✔
23
from oauth_dropins.webutil.appengine_info import DEBUG
1✔
24
from oauth_dropins.webutil.flask_util import error
1✔
25
from oauth_dropins.webutil.models import JsonProperty, StringIdModel
1✔
26
from oauth_dropins.webutil.util import ellipsize, json_dumps, json_loads
1✔
27
from requests import RequestException
1✔
28

29
import common
1✔
30
from common import (
1✔
31
    base64_to_long,
32
    DOMAIN_RE,
33
    long_to_base64,
34
    OLD_ACCOUNT_AGE,
35
    PROTOCOL_DOMAINS,
36
    report_error,
37
    unwrap,
38
)
39
import ids
1✔
40
import memcache
1✔
41

42
# maps string label to Protocol subclass. values are populated by ProtocolUserMeta.
43
# (we used to wait for ProtocolUserMeta to populate the keys as well, but that was
44
# awkward to use in datastore model properties with choices, below; it required
45
# overriding them in reset_model_properties, which was always flaky.)
46
PROTOCOLS = {label: None for label in (
1✔
47
    'activitypub',
48
    'ap',
49
    'atproto',
50
    'bsky',
51
    'ostatus',
52
    'web',
53
    'webmention',
54
    'ui',
55
)}
56
if DEBUG:
1✔
57
    PROTOCOLS.update({label: None for label in (
1✔
58
        'fa',
59
        'fake',
60
        'efake',
61
        'other',
62
    )})
63

64
# maps string kind (eg 'MagicKey') to Protocol subclass.
65
# populated in ProtocolUserMeta
66
PROTOCOLS_BY_KIND = {}
1✔
67

68
# 2048 bits makes tests slow, so use 1024 for them
69
KEY_BITS = 1024 if DEBUG else 2048
1✔
70
PAGE_SIZE = 20
1✔
71

72
# auto delete most old objects via the Object.expire property
73
# https://cloud.google.com/datastore/docs/ttl
74
#
75
# need to keep follows because we attach them to Followers and use them for
76
# unfollows
77
DONT_EXPIRE_OBJECT_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES
1✔
78
                            | set(['block', 'flag', 'follow', 'like', 'share']))
79
OBJECT_EXPIRE_AGE = timedelta(days=90)
1✔
80

81
GET_ORIGINALS_CACHE_EXPIRATION = timedelta(days=1)
1✔
82
FOLLOWERS_CACHE_EXPIRATION = timedelta(hours=2)
1✔
83

84
USER_STATUS_DESCRIPTIONS = {
1✔
85
    'owns-webfinger': 'your web site looks like a fediverse instance because it already serves Webfinger',
86
    'opt-out': 'your account or instance has requested to be opted out',
87
    'nobridge': "your profile has 'nobridge' in it",
88
    'nobot': "your profile has 'nobot' in it",
89
    'no-feed-or-webmention': "your web site doesn't have an RSS or Atom feed or webmention endpoint",
90
    'private': 'your account is set as private or protected',
91
    'requires-avatar': "you haven't set a profile picture",
92
    'requires-name': "you haven't set a profile name that's different from your username",
93
    'requires-old-account': f"your account is less than {humanize.naturaldelta(OLD_ACCOUNT_AGE)} old",
94
}
95

96
logger = logging.getLogger(__name__)
1✔
97

98

99
class Target(ndb.Model):
1✔
100
    r""":class:`protocol.Protocol` + URI pairs for identifying objects.
101

102
    These are currently used for:
103

104
    * delivery destinations, eg ActivityPub inboxes, webmention targets, etc.
105
    * copies of :class:`Object`\s and :class:`User`\s elsewhere,
106
      eg ``at://`` URIs for ATProto records, nevent etc bech32-encoded Nostr ids,
107
      ATProto user DIDs, etc.
108

109
    Used in :class:`google.cloud.ndb.model.StructuredProperty`\s inside
110
    :class:`Object` and :class:`User`; not stored as top-level entities in the
111
    datastore.
112

113
    ndb implements this by hoisting each property here into a corresponding
114
    property on the parent entity, prefixed by the StructuredProperty name
115
    below, eg ``delivered.uri``, ``delivered.protocol``, etc.
116

117
    For repeated StructuredPropertys, the hoisted properties are all repeated on
118
    the parent entity, and reconstructed into StructuredPropertys based on their
119
    order.
120

121
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
122
    """
123
    uri = ndb.StringProperty(required=True)
1✔
124
    ''
1✔
125
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
126
    ''
1✔
127

128
    def __eq__(self, other):
1✔
129
        """Equality excludes Targets' :class:`Key`."""
130
        return self.uri == other.uri and self.protocol == other.protocol
1✔
131

132
    def __hash__(self):
1✔
133
        """Allow hashing so these can be dict keys."""
134
        return hash((self.protocol, self.uri))
1✔
135

136

137
class DM(ndb.Model):
1✔
138
    """:class:`protocol.Protocol` + type pairs for identifying sent DMs.
139

140
    Used in :attr:`User.sent_dms`.
141

142
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
143
    """
144
    type = ndb.StringProperty(required=True)
1✔
145
    """Known values:
1✔
146
      * no-feed-or-webmention
147
      * opt-out
148
      * owns-webfinger
149
      * private
150
      * replied_to_bridged_user
151
      * request_bridging
152
      * requires-avatar
153
      * requires-name
154
      * requires-old-account
155
      * unsupported-handle
156
      * welcome
157
    """
158
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
159
    ''
1✔
160

161
    def __eq__(self, other):
1✔
162
        """Equality excludes Targets' :class:`Key`."""
163
        return self.type == other.type and self.protocol == other.protocol
1✔
164

165

166
class ProtocolUserMeta(type(ndb.Model)):
1✔
167
    """:class:`User` metaclass. Registers all subclasses in the ``PROTOCOLS`` global."""
168
    def __new__(meta, name, bases, class_dict):
1✔
169
        cls = super().__new__(meta, name, bases, class_dict)
1✔
170

171
        if hasattr(cls, 'LABEL') and cls.LABEL not in ('protocol', 'user'):
1✔
172
            for label in (cls.LABEL, cls.ABBREV) + cls.OTHER_LABELS:
1✔
173
                if label:
1✔
174
                    PROTOCOLS[label] = cls
1✔
175

176
        PROTOCOLS_BY_KIND[cls._get_kind()] = cls
1✔
177

178
        return cls
1✔
179

180

181
def reset_protocol_properties():
1✔
182
    """Recreates various protocol properties to include choices from ``PROTOCOLS``."""
183
    abbrevs = f'({"|".join(PROTOCOLS.keys())}|fed)'
1✔
184
    common.SUBDOMAIN_BASE_URL_RE = re.compile(
1✔
185
        rf'^https?://({abbrevs}\.brid\.gy|localhost(:8080)?)/(convert/|r/)?({abbrevs}/)?(?P<path>.+)')
186
    ids.COPIES_PROTOCOLS = tuple(label for label, proto in PROTOCOLS.items()
1✔
187
                                 if proto and proto.HAS_COPIES)
188

189

190
class User(StringIdModel, metaclass=ProtocolUserMeta):
1✔
191
    """Abstract base class for a Bridgy Fed user.
192

193
    Stores some protocols' keypairs. Currently:
194

195
    * RSA keypair for ActivityPub HTTP Signatures
196
      properties: ``mod``, ``public_exponent``, ``private_exponent``, all
197
      encoded as base64url (ie URL-safe base64) strings as described in RFC
198
      4648 and section 5.1 of the Magic Signatures spec:
199
      https://tools.ietf.org/html/draft-cavage-http-signatures-12
200
    * *Not* K-256 signing or rotation keys for AT Protocol, those are stored in
201
      :class:`arroba.datastore_storage.AtpRepo` entities
202
    """
203
    obj_key = ndb.KeyProperty(kind='Object')  # user profile
1✔
204
    ''
1✔
205
    mod = ndb.StringProperty()
1✔
206
    ''
1✔
207
    use_instead = ndb.KeyProperty()
1✔
208
    ''
1✔
209

210
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
211
    """Proxy copies of this user elsewhere, eg DIDs for ATProto records, bech32
1✔
212
    npub Nostr ids, etc. Similar to ``rel-me`` links in microformats2,
213
    ``alsoKnownAs`` in DID docs (and now AS2), etc.
214
    """
215

216
    public_exponent = ndb.StringProperty()
1✔
217
    """Part of this user's bridged ActivityPub actor's private key."""
1✔
218
    private_exponent = ndb.StringProperty()
1✔
219
    """Part of this user's bridged ActivityPub actor's private key."""
1✔
220

221
    manual_opt_out = ndb.BooleanProperty()
1✔
222
    """Set to True for users who asked to be opted out."""
1✔
223

224
    enabled_protocols = ndb.StringProperty(repeated=True,
1✔
225
                                           choices=list(PROTOCOLS.keys()))
226
    """Protocols that this user has explicitly opted into.
1✔
227

228
    Protocols that don't require explicit opt in are omitted here. ``choices``
229
    is populated in :func:`reset_protocol_properties`.
230
    """
231

232
    sent_dms = ndb.StructuredProperty(DM, repeated=True)
1✔
233
    """DMs that we've attempted to send to this user."""
1✔
234

235
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
236
    ''
1✔
237
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
238
    ''
1✔
239

240
    # `existing` attr is set by get_or_create
241

242
    # OLD. some stored entities still have these; do not reuse.
243
    # direct = ndb.BooleanProperty(default=False)
244
    # actor_as2 = JsonProperty()
245
    # protocol-specific state
246
    # atproto_notifs_indexed_at = ndb.TextProperty()
247
    # atproto_feed_indexed_at = ndb.TextProperty()
248

249
    def __init__(self, **kwargs):
1✔
250
        """Constructor.
251

252
        Sets :attr:`obj` explicitly because however
253
        :class:`google.cloud.ndb.model.Model` sets it doesn't work with
254
        ``@property`` and ``@obj.setter`` below.
255
        """
256
        obj = kwargs.pop('obj', None)
1✔
257
        super().__init__(**kwargs)
1✔
258

259
        if obj:
1✔
260
            self.obj = obj
1✔
261

262
        self.lock = Lock()
1✔
263

264
    @classmethod
1✔
265
    def new(cls, **kwargs):
1✔
266
        """Try to prevent instantiation. Use subclasses instead."""
267
        raise NotImplementedError()
×
268

269
    def _post_put_hook(self, future):
1✔
270
        logger.debug(f'Wrote {self.key}')
1✔
271

272
    def add(self, prop, val):
1✔
273
        """Adds a value to a multiply-valued property. Uses ``self.lock``.
274

275
        Args:
276
          prop (str)
277
          val
278
        """
279
        with self.lock:
1✔
280
            added = util.add(getattr(self, prop), val)
1✔
281

282
        if prop == 'copies' and added:
1✔
283
            memcache.pickle_memcache.set(memcache.memoize_key(
1✔
284
                get_original_user_key, val.uri), self.key)
285

286
    @classmethod
1✔
287
    def get_by_id(cls, id, allow_opt_out=False, **kwargs):
1✔
288
        """Override to follow ``use_instead`` property and ``status``.
289

290
        Returns None if the user is opted out.
291
        """
292
        user = cls._get_by_id(id, **kwargs)
1✔
293
        if user and user.use_instead:
1✔
294
            logger.info(f'{user.key} use_instead => {user.use_instead}')
1✔
295
            user = user.use_instead.get()
1✔
296

297
        if not user:
1✔
298
            return None
1✔
299

300
        if user.status and not allow_opt_out:
1✔
301
            logger.info(f'{user.key} is {user.status}')
1✔
302
            return None
1✔
303

304
        return user
1✔
305

306
    @classmethod
1✔
307
    def get_or_create(cls, id, propagate=False, allow_opt_out=False,
1✔
308
                      reload=False, **kwargs):
309
        """Loads and returns a :class:`User`. Creates it if necessary.
310

311
        Not transactional because transactions don't read or write memcache. :/
312
        Fortunately we don't really depend on atomicity for anything, last
313
        writer wins is pretty much always fine.
314

315
        Args:
316
          propagate (bool): whether to create copies of this user in push-based
317
            protocols, eg ATProto and Nostr.
318
          allow_opt_out (bool): whether to allow and create the user if they're
319
            currently opted out
320
          reload (bool): whether to reload profile always, vs only if necessary
321
          kwargs: passed through to ``cls`` constructor
322

323
        Returns:
324
          User: existing or new user, or None if the user is opted out
325
        """
326
        assert cls != User
1✔
327

328
        user = cls.get_by_id(id, allow_opt_out=True)
1✔
329
        if user:
1✔
330
            if reload:
1✔
331
                user.reload_profile(gateway=True, raise_=False)
1✔
332

333
            if user.status and not allow_opt_out:
1✔
334
                return None
1✔
335
            user.existing = True
1✔
336

337
            # TODO: propagate more fields?
338
            changed = False
1✔
339
            for field in ['obj', 'obj_key']:
1✔
340
                old_val = getattr(user, field, None)
1✔
341
                new_val = kwargs.get(field)
1✔
342
                if old_val is None and new_val is not None:
1✔
343
                    setattr(user, field, new_val)
×
344
                    changed = True
×
345

346
            if enabled_protocols := kwargs.get('enabled_protocols'):
1✔
347
                user.enabled_protocols = (set(user.enabled_protocols)
1✔
348
                                          | set(enabled_protocols))
349
                changed = True
1✔
350

351
            if not propagate:
1✔
352
                if changed:
1✔
353
                    user.put()
1✔
354
                return user
1✔
355

356
        else:
357
            if orig_key := get_original_user_key(id):
1✔
358
                orig = orig_key.get()
1✔
359
                if orig.status and not allow_opt_out:
1✔
360
                    return None
×
361
                orig.existing = False
1✔
362
                return orig
1✔
363

364
            user = cls(id=id, **kwargs)
1✔
365
            user.existing = False
1✔
366
            user.reload_profile(gateway=True, raise_=False)
1✔
367
            if user.status and not allow_opt_out:
1✔
368
                return None
1✔
369

370
        if propagate and not user.status:
1✔
371
            for label in user.enabled_protocols + list(user.DEFAULT_ENABLED_PROTOCOLS):
1✔
372
                proto = PROTOCOLS[label]
1✔
373
                if proto == cls:
1✔
374
                    continue
×
375
                elif proto.HAS_COPIES:
1✔
376
                    if not user.get_copy(proto) and user.is_enabled(proto):
1✔
377
                        try:
1✔
378
                            proto.create_for(user)
1✔
379
                        except (ValueError, AssertionError):
1✔
380
                            logger.info(f'failed creating {proto.LABEL} copy',
1✔
381
                                        exc_info=True)
382
                            util.remove(user.enabled_protocols, proto.LABEL)
1✔
383
                    else:
384
                        logger.debug(f'{proto.LABEL} not enabled or user copy already exists, skipping propagate')
1✔
385

386
        # generate keys for all protocols _except_ our own
387
        #
388
        # these can use urandom() and do nontrivial math, so they can take time
389
        # depending on the amount of randomness available and compute needed.
390
        if cls.LABEL != 'activitypub':
1✔
391
            if (not user.public_exponent or not user.private_exponent or not user.mod):
1✔
392
                assert (not user.public_exponent and not user.private_exponent
1✔
393
                        and not user.mod), id
394
                key = RSA.generate(KEY_BITS,
1✔
395
                                   randfunc=random.randbytes if DEBUG else None)
396
                user.mod = long_to_base64(key.n)
1✔
397
                user.public_exponent = long_to_base64(key.e)
1✔
398
                user.private_exponent = long_to_base64(key.d)
1✔
399

400
        try:
1✔
401
            user.put()
1✔
402
        except AssertionError as e:
×
403
            error(f'Bad {cls.__name__} id {id} : {e}')
×
404

405
        logger.debug(('Updated ' if user.existing else 'Created new ') + str(user))
1✔
406
        return user
1✔
407

408
    @property
1✔
409
    def obj(self):
1✔
410
        """Convenience accessor that loads :attr:`obj_key` from the datastore."""
411
        if self.obj_key:
1✔
412
            if not hasattr(self, '_obj'):
1✔
413
                self._obj = self.obj_key.get()
1✔
414
            return self._obj
1✔
415

416
    @obj.setter
1✔
417
    def obj(self, obj):
1✔
418
        if obj:
1✔
419
            assert isinstance(obj, Object)
1✔
420
            assert obj.key
1✔
421
            self._obj = obj
1✔
422
            self.obj_key = obj.key
1✔
423
        else:
424
            self._obj = self.obj_key = None
1✔
425

426
    def delete(self, proto=None):
1✔
427
        """Deletes a user's bridged actors in all protocols or a specific one.
428

429
        Args:
430
          proto (Protocol): optional
431
        """
432
        now = util.now().isoformat()
1✔
433
        proto_label = proto.LABEL if proto else 'all'
1✔
434
        delete_id = f'{self.profile_id()}#bridgy-fed-delete-user-{proto_label}-{now}'
1✔
435
        delete = Object(id=delete_id, source_protocol=self.LABEL, our_as1={
1✔
436
            'id': delete_id,
437
            'objectType': 'activity',
438
            'verb': 'delete',
439
            'actor': self.key.id(),
440
            'object': self.key.id(),
441
        })
442
        self.deliver(delete, from_user=self, to_proto=proto)
1✔
443

444
    @classmethod
1✔
445
    def load_multi(cls, users):
1✔
446
        """Loads :attr:`obj` for multiple users in parallel.
447

448
        Args:
449
          users (sequence of User)
450
        """
451
        objs = ndb.get_multi(u.obj_key for u in users if u.obj_key)
1✔
452
        keys_to_objs = {o.key: o for o in objs if o}
1✔
453

454
        for u in users:
1✔
455
            u._obj = keys_to_objs.get(u.obj_key)
1✔
456

457
    @ndb.ComputedProperty
1✔
458
    def handle(self):
1✔
459
        """This user's unique, human-chosen handle, eg ``@me@snarfed.org``.
460

461
        To be implemented by subclasses.
462
        """
463
        raise NotImplementedError()
×
464

465
    @ndb.ComputedProperty
1✔
466
    def readable_id(self):
1✔
467
        """DEPRECATED: replaced by handle. Kept for backward compatibility."""
468
        return None
1✔
469

470
    @ndb.ComputedProperty
1✔
471
    def status(self):
1✔
472
        """Whether this user is blocked or opted out.
473

474
        Optional. Current possible values:
475
          * ``opt-out``: the user or domain has manually opted out
476
          * ``owns-webfinger``: a :class:`web.Web` user that looks like a
477
            fediverse server
478
          * ``nobridge``: the user's profile has ``#nobridge`` in it
479
          * ``nobot``: the user's profile has ``#nobot`` in it
480
          * ``no-feed-or-webmention``: a :class:`web.Web` user that doesn't have
481
            an RSS or Atom feed or webmention endpoint and has never sent us a
482
            webmention
483
          * ``private``: the account is set to be protected or private in its
484
            native protocol
485
          * ``requires-avatar``
486
          * ``requires-name``
487
          * ``requires-old-account``
488

489
        Duplicates ``util.is_opt_out`` in Bridgy!
490

491
        https://github.com/snarfed/bridgy-fed/issues/666
492
        """
493
        if self.manual_opt_out:
1✔
494
            return 'opt-out'
1✔
495

496
        if not self.obj or not self.obj.as1:
1✔
497
            return None
1✔
498

499
        if self.obj.as1.get('bridgeable') is False:  # FEP-0036
1✔
500
            return 'opt-out'
1✔
501

502
        if self.REQUIRES_AVATAR and not self.obj.as1.get('image'):
1✔
503
            return 'requires-avatar'
1✔
504

505
        name = self.obj.as1.get('displayName')
1✔
506
        if self.REQUIRES_NAME and (not name or name in (self.handle, self.key.id())):
1✔
507
            return 'requires-name'
1✔
508

509
        if self.REQUIRES_OLD_ACCOUNT:
1✔
510
            if published := self.obj.as1.get('published'):
1✔
511
                if util.now() - util.parse_iso8601(published) < OLD_ACCOUNT_AGE:
1✔
512
                    return 'requires-old-account'
1✔
513

514
        summary = html_to_text(self.obj.as1.get('summary', ''), ignore_links=True)
1✔
515
        name = html_to_text(self.obj.as1.get('displayName', ''), ignore_links=True)
1✔
516

517
        # #nobridge overrides enabled_protocols
518
        if '#nobridge' in summary or '#nobridge' in name:
1✔
519
            return 'nobridge'
1✔
520

521
        # user has explicitly opted in. should go after spam filter (REQUIRES_*)
522
        # checks, but before is_public and #nobot
523
        #
524
        # !!! WARNING: keep in sync with User.enable_protocol!
525
        if self.enabled_protocols:
1✔
526
            return None
1✔
527

528
        if not as1.is_public(self.obj.as1, unlisted=False):
1✔
529
            return 'private'
1✔
530

531
        # enabled_protocols overrides #nobot
532
        if '#nobot' in summary or '#nobot' in name:
1✔
533
            return 'nobot'
1✔
534

535
    def is_enabled(self, to_proto, explicit=False):
1✔
536
        """Returns True if this user can be bridged to a given protocol.
537

538
        Reasons this might return False:
539
        * We haven't turned on bridging these two protocols yet.
540
        * The user is opted out or blocked.
541
        * The user is on a domain that's opted out or blocked.
542
        * The from protocol requires opt in, and the user hasn't opted in.
543
        * ``explicit`` is True, and this protocol supports ``to_proto`` by, but the user hasn't explicitly opted into it.
544

545
        Args:
546
          to_proto (Protocol subclass)
547
          explicit (bool)
548

549
        Returns:
550
          bool:
551
        """
552
        from protocol import Protocol
1✔
553
        assert issubclass(to_proto, Protocol)
1✔
554

555
        if self.__class__ == to_proto:
1✔
556
            return True
1✔
557

558
        from_label = self.LABEL
1✔
559
        to_label = to_proto.LABEL
1✔
560

561
        if bot_protocol := Protocol.for_bridgy_subdomain(self.key.id()):
1✔
562
            return to_proto != bot_protocol
1✔
563

564
        elif self.manual_opt_out:
1✔
565
            return False
1✔
566

567
        elif to_label in self.enabled_protocols:
1✔
568
            return True
1✔
569

570
        elif self.status:
1✔
571
            return False
1✔
572

573
        elif to_label in self.DEFAULT_ENABLED_PROTOCOLS and not explicit:
1✔
574
            return True
1✔
575

576
        return False
1✔
577

578
    def enable_protocol(self, to_proto):
1✔
579
        """Adds ``to_proto`` to :attr:`enabled_protocols`.
580

581
        Also sends a welcome DM to the user (via a send task) if their protocol
582
        supports DMs.
583

584
        Args:
585
          to_proto (:class:`protocol.Protocol` subclass)
586
        """
587
        import dms
1✔
588

589
        # explicit opt-in overrides some status
590
        # !!! WARNING: keep in sync with User.status!
591
        ineligible = """Hi! Your account isn't eligible for bridging yet because {desc}. <a href="https://fed.brid.gy/docs#troubleshooting">More details here.</a> You can try again once that's fixed by unfollowing and re-following this account."""
1✔
592
        if self.status and self.status not in ('nobot', 'private'):
1✔
593
            if desc := USER_STATUS_DESCRIPTIONS.get(self.status):
1✔
594
                dms.maybe_send(from_proto=to_proto, to_user=self, type=self.status,
1✔
595
                               text=ineligible.format(desc=desc))
596
            common.error(f'Nope, user {self.key.id()} is {self.status}', status=299)
1✔
597

598
        try:
1✔
599
            self.handle_as(to_proto)
1✔
600
        except ValueError as e:
1✔
601
            dms.maybe_send(from_proto=to_proto, to_user=self,
1✔
602
                           type=f'unsupported-handle-{to_proto.ABBREV}',
603
                           text=ineligible.format(desc=e))
604
            common.error(str(e), status=299)
1✔
605

606
        added = False
1✔
607

608
        if to_proto.LABEL in ids.COPIES_PROTOCOLS:
1✔
609
            # do this even if there's an existing copy since we might need to
610
            # reactivate it, which create_for should do
611
            to_proto.create_for(self)
1✔
612

613
        @ndb.transactional()
1✔
614
        def enable():
1✔
615
            user = self.key.get()
1✔
616
            if to_proto.LABEL not in user.enabled_protocols:
1✔
617
                user.enabled_protocols.append(to_proto.LABEL)
1✔
618
                util.add(user.sent_dms, DM(protocol=to_proto.LABEL, type='welcome'))
1✔
619
                user.put()
1✔
620
                nonlocal added
621
                added = True
1✔
622

623
            return user
1✔
624

625
        new_self = enable()
1✔
626
        # populate newly enabled protocol in this instance
627
        self.enabled_protocols = new_self.enabled_protocols
1✔
628
        self.copies = new_self.copies
1✔
629
        if self.obj:
1✔
630
            self.obj.copies = new_self.obj.copies
1✔
631

632
        if added:
1✔
633
            dms.maybe_send(from_proto=to_proto, to_user=self, type='welcome', text=f"""\
1✔
634
Welcome to Bridgy Fed! Your account will soon be bridged to {to_proto.PHRASE} at {self.user_link(proto=to_proto, name=False)}. <a href="https://fed.brid.gy/docs">See the docs</a> and <a href="https://{common.PRIMARY_DOMAIN}{self.user_page_path()}">your user page</a> for more information. To disable this and delete your bridged profile, block this account.""")
635

636
        msg = f'Enabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
637
        logger.info(msg)
1✔
638

639
    def disable_protocol(self, to_proto):
1✔
640
        """Removes ``to_proto` from :attr:`enabled_protocols``.
641

642
        Args:
643
          to_proto (:class:`protocol.Protocol` subclass)
644
        """
645
        @ndb.transactional()
1✔
646
        def disable():
1✔
647
            user = self.key.get()
1✔
648
            util.remove(user.enabled_protocols, to_proto.LABEL)
1✔
649
            user.put()
1✔
650

651
        disable()
1✔
652
        util.remove(self.enabled_protocols, to_proto.LABEL)
1✔
653

654
        msg = f'Disabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
655
        logger.info(msg)
1✔
656

657
    def handle_as(self, to_proto):
1✔
658
        """Returns this user's handle in a different protocol.
659

660
        Args:
661
          to_proto (str or Protocol)
662

663
        Returns:
664
          str
665
        """
666
        if isinstance(to_proto, str):
1✔
667
            to_proto = PROTOCOLS[to_proto]
1✔
668

669
        # override to-ATProto to use custom domain handle in DID doc
670
        from atproto import ATProto, did_to_handle
1✔
671
        if to_proto == ATProto:
1✔
672
            if did := self.get_copy(ATProto):
1✔
673
                if handle := did_to_handle(did, remote=False):
1✔
674
                    return handle
1✔
675

676
        # override web users to always use domain instead of custom username
677
        # TODO: fall back to id if handle is unset?
678
        handle = self.key.id() if self.LABEL == 'web' else self.handle
1✔
679
        if not handle:
1✔
680
            return None
1✔
681

682
        return ids.translate_handle(handle=handle, from_=self.__class__,
1✔
683
                                    to=to_proto, enhanced=False)
684

685
    def id_as(self, to_proto):
1✔
686
        """Returns this user's id in a different protocol.
687

688
        Args:
689
          to_proto (str or Protocol)
690

691
        Returns:
692
          str
693
        """
694
        if isinstance(to_proto, str):
1✔
695
            to_proto = PROTOCOLS[to_proto]
1✔
696

697
        return ids.translate_user_id(id=self.key.id(), from_=self.__class__,
1✔
698
                                     to=to_proto)
699

700
    def handle_or_id(self):
1✔
701
        """Returns handle if we know it, otherwise id."""
702
        return self.handle or self.key.id()
1✔
703

704
    def public_pem(self):
1✔
705
        """
706
        Returns:
707
          bytes:
708
        """
709
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
710
                             base64_to_long(str(self.public_exponent))))
711
        return rsa.exportKey(format='PEM')
1✔
712

713
    def private_pem(self):
1✔
714
        """
715
        Returns:
716
          bytes:
717
        """
718
        assert self.mod and self.public_exponent and self.private_exponent, str(self)
1✔
719
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
720
                             base64_to_long(str(self.public_exponent)),
721
                             base64_to_long(str(self.private_exponent))))
722
        return rsa.exportKey(format='PEM')
1✔
723

724
    def name(self):
1✔
725
        """Returns this user's human-readable name, eg ``Ryan Barrett``."""
726
        if self.obj and self.obj.as1:
1✔
727
            name = self.obj.as1.get('displayName')
1✔
728
            if name:
1✔
729
                return name
1✔
730

731
        return self.handle_or_id()
1✔
732

733
    def web_url(self):
1✔
734
        """Returns this user's web URL (homepage), eg ``https://foo.com/``.
735

736
        To be implemented by subclasses.
737

738
        Returns:
739
          str
740
        """
741
        raise NotImplementedError()
×
742

743
    def is_web_url(self, url, ignore_www=False):
1✔
744
        """Returns True if the given URL is this user's web URL (homepage).
745

746
        Args:
747
          url (str)
748
          ignore_www (bool): if True, ignores ``www.`` subdomains
749

750
        Returns:
751
          bool:
752
        """
753
        if not url:
1✔
754
            return False
1✔
755

756
        url = url.strip().rstrip('/')
1✔
757
        url = re.sub(r'^(https?://)www\.', r'\1', url)
1✔
758
        parsed_url = urlparse(url)
1✔
759
        if parsed_url.scheme not in ('http', 'https', ''):
1✔
760
            return False
1✔
761

762
        this = self.web_url().rstrip('/')
1✔
763
        this = re.sub(r'^(https?://)www\.', r'\1', this)
1✔
764
        parsed_this = urlparse(this)
1✔
765

766
        return (url == this or url == parsed_this.netloc or
1✔
767
                parsed_url[1:] == parsed_this[1:])  # ignore http vs https
768

769
    def id_uri(self):
1✔
770
        """Returns the user id as a URI.
771

772
        Sometimes this is the user id itself, eg ActivityPub actor ids.
773
        Sometimes it's a bit different, eg at://did:plc:... for ATProto user,
774
        https://site.com for Web users.
775

776
        Returns:
777
          str
778
        """
779
        return self.key.id()
1✔
780

781
    def profile_id(self):
1✔
782
        """Returns the id of this user's profile object in its native protocol.
783

784
        Examples:
785

786
        * Web: home page URL, eg ``https://me.com/``
787
        * ActivityPub: actor URL, eg ``https://instance.com/users/me``
788
        * ATProto: profile AT URI, eg ``at://did:plc:123/app.bsky.actor.profile/self``
789

790
        Defaults to this user's key id.
791

792
        Returns:
793
          str or None:
794
        """
795
        return ids.profile_id(id=self.key.id(), proto=self)
1✔
796

797
    def reload_profile(self, **kwargs):
1✔
798
        """Reloads this user's identity and profile from their native protocol.
799

800
        Populates the reloaded profile :class:`Object` in ``self.obj``.
801

802
        Args:
803
          kwargs: passed through to :meth:`Protocol.load`
804
        """
805
        obj = self.load(self.profile_id(), remote=True, **kwargs)
1✔
806
        if obj:
1✔
807
            self.obj = obj
1✔
808

809
    def user_page_path(self, rest=None):
1✔
810
        """Returns the user's Bridgy Fed user page path."""
811
        path = f'/{self.ABBREV}/{self.handle_or_id()}'
1✔
812

813
        if rest:
1✔
814
            if not rest.startswith('?'):
1✔
815
                path += '/'
1✔
816
            path += rest
1✔
817

818
        return path
1✔
819

820
    def get_copy(self, proto):
1✔
821
        """Returns the id for the copy of this user in a given protocol.
822

823
        ...or None if no such copy exists. If ``proto`` is this user, returns
824
        this user's key id.
825

826
        Args:
827
          proto: :class:`Protocol` subclass
828

829
        Returns:
830
          str:
831
        """
832
        # don't use isinstance because the testutil Fake protocol has subclasses
833
        if self.LABEL == proto.LABEL:
1✔
834
            return self.key.id()
1✔
835

836
        for copy in self.copies:
1✔
837
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
838
                return copy.uri
1✔
839

840
    def user_link(self, name=True, handle=True, pictures=False, logo=None,
1✔
841
                  proto=None, proto_fallback=False):
842
        """Returns a pretty HTML link to the user's profile.
843

844
        Can optionally include display name, handle, profile
845
        picture, and/or link to a different protocol that they've enabled.
846

847
        TODO: unify with :meth:`Object.actor_link`?
848

849
        Args:
850
          name (bool): include display name
851
          handle (bool): include handle
852
          pictures (bool): include profile picture and protocol logo
853
          logo (str): optional path to platform logo to show instead of the
854
            protocol's default
855
          proto (protocol.Protocol): link to this protocol instead of the user's
856
            native protocol
857
          proto_fallback (bool): if True, and ``proto`` is provided and has no
858
            no canonical profile URL for bridged users, uses the user's profile
859
            URL in their native protocol
860
        """
861
        img = name_str = handle_str = dot = logo_html = a_open = a_close = ''
1✔
862

863
        if proto:
1✔
864
            assert self.is_enabled(proto), f"{proto.LABEL} isn't enabled"
1✔
865
            url = proto.bridged_web_url_for(self, fallback=proto_fallback)
1✔
866
        else:
867
            proto = self.__class__
1✔
868
            url = self.web_url()
1✔
869

870
        if pictures:
1✔
871
            if logo:
1✔
872
                logo_html = f'<img class="logo" src="{logo}" /> '
1✔
873
            else:
874
                logo_html = f'<span class="logo" title="{proto.__name__}">{proto.LOGO_HTML}</span> '
1✔
875
            if pic := self.profile_picture():
1✔
876
                img = f'<img src="{pic}" class="profile"> '
1✔
877

878
        if handle:
1✔
879
            handle_str = self.handle_as(proto) or ''
1✔
880

881
        if name and self.name() != handle_str:
1✔
882
            name_str = self.name() or ''
1✔
883

884
        if handle_str and name_str:
1✔
885
            dot = ' &middot; '
1✔
886

887
        if url:
1✔
888
            a_open = f'<a class="h-card u-author" rel="me" href="{url}" title="{name_str}{dot}{handle_str}">'
1✔
889
            a_close = '</a>'
1✔
890

891
        name_html = f'<span style="unicode-bidi: isolate">{ellipsize(name_str, chars=40)}</span>' if name_str else ''
1✔
892
        return f'{logo_html}{a_open}{img}{name_html}{dot}{ellipsize(handle_str, chars=40)}{a_close}'
1✔
893

894
    def profile_picture(self):
1✔
895
        """Returns the user's profile picture image URL, if available, or None."""
896
        if self.obj and self.obj.as1:
1✔
897
            return util.get_url(self.obj.as1, 'image')
1✔
898

899
    # can't use functools.lru_cache here because we want the cache key to be
900
    # just the user id, not the whole entity
901
    @cachetools.cached(
1✔
902
        cachetools.TTLCache(50000, FOLLOWERS_CACHE_EXPIRATION.total_seconds()),
903
        key=lambda user: user.key.id(), lock=Lock())
904
    @memcache.memoize(key=lambda self: self.key.id(),
1✔
905
                      expire=FOLLOWERS_CACHE_EXPIRATION)
906
    def count_followers(self):
1✔
907
        """Counts this user's followers and followings.
908

909
        Returns:
910
          (int, int) tuple: (number of followers, number following)
911
        """
912
        if self.key.id() in PROTOCOL_DOMAINS:
1✔
913
            # we don't store Followers for protocol bot users any more, so
914
            # follower counts are inaccurate, so don't return them
915
            return (0, 0)
1✔
916

917
        num_followers = Follower.query(Follower.to == self.key,
1✔
918
                                       Follower.status == 'active')\
919
                                .count_async()
920
        num_following = Follower.query(Follower.from_ == self.key,
1✔
921
                                       Follower.status == 'active')\
922
                                .count_async()
923
        return num_followers.get_result(), num_following.get_result()
1✔
924

925

926
class Object(StringIdModel):
1✔
927
    """An activity or other object, eg actor.
928

929
    Key name is the id. We synthesize ids if necessary.
930
    """
931
    users = ndb.KeyProperty(repeated=True)
1✔
932
    'User(s) who created or otherwise own this object.'
1✔
933

934
    notify = ndb.KeyProperty(repeated=True)
1✔
935
    """User who should see this in their user page, eg in reply to, reaction to,
1✔
936
    share of, etc.
937
    """
938
    feed = ndb.KeyProperty(repeated=True)
1✔
939
    'User who should see this in their feeds, eg followers of its creator'
1✔
940

941
    source_protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()))
1✔
942
    """The protocol this object originally came from.
1✔
943

944
    ``choices`` is populated in :func:`reset_protocol_properties`, after all
945
    :class:`User` subclasses are created, so that :attr:`PROTOCOLS` is fully
946
    populated.
947

948
    TODO: nail down whether this is :attr:`ABBREV`` or :attr:`LABEL`
949
    """
950

951
    # TODO: switch back to ndb.JsonProperty if/when they fix it for the web console
952
    # https://github.com/googleapis/python-ndb/issues/874
953
    as2 = JsonProperty()
1✔
954
    'ActivityStreams 2, for ActivityPub'
1✔
955
    bsky = JsonProperty()
1✔
956
    'AT Protocol lexicon, for Bluesky'
1✔
957
    mf2 = JsonProperty()
1✔
958
    'HTML microformats2 item, (ie _not_ top level parse object with ``items`` field'
1✔
959
    our_as1 = JsonProperty()
1✔
960
    'ActivityStreams 1, for activities that we generate or modify ourselves'
1✔
961
    raw = JsonProperty()
1✔
962
    'Other standalone data format, eg DID document'
1✔
963

964
    # TODO: remove and actually delete Objects instead!
965
    deleted = ndb.BooleanProperty()
1✔
966
    ''
1✔
967

968
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
969
    """Copies of this object elsewhere, eg at:// URIs for ATProto records and
1✔
970
    nevent etc bech32-encoded Nostr ids, where this object is the original.
971
    Similar to u-syndication links in microformats2 and
972
    upstream/downstreamDuplicates in AS1.
973
    """
974

975
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
976
    ''
1✔
977
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
978
    ''
1✔
979

980
    new = None
1✔
981
    """True if this object is new, ie this is the first time we've seen it,
1✔
982
    False otherwise, None if we don't know.
983
    """
984
    changed = None
1✔
985
    """True if this object's contents have changed from our existing copy in the
1✔
986
    datastore, False otherwise, None if we don't know. :class:`Object` is
987
    new/changed. See :meth:`activity_changed()` for more details.
988
    """
989

990
    lock = None
1✔
991
    """Synchronizes :meth:`add` and :meth:`remove`."""
1✔
992

993
    # DEPRECATED
994
    # These were for full feeds with multiple items, not just this one, so they were
995
    # stored as audit records only, not used in to_as1. for Atom/RSS
996
    # based Objects, our_as1 was populated with an feed_index top-level
997
    # integer field that indexed into one of these.
998
    #
999
    # atom = ndb.TextProperty() # Atom XML
1000
    # rss = ndb.TextProperty()  # RSS XML
1001

1002
    # DEPRECATED; these were for delivery tracking, but they were too expensive,
1003
    # so we stopped: https://github.com/snarfed/bridgy-fed/issues/1501
1004
    #
1005
    # STATUSES = ('new', 'in progress', 'complete', 'failed', 'ignored')
1006
    # status = ndb.StringProperty(choices=STATUSES)
1007
    # delivered = ndb.StructuredProperty(Target, repeated=True)
1008
    # undelivered = ndb.StructuredProperty(Target, repeated=True)
1009
    # failed = ndb.StructuredProperty(Target, repeated=True)
1010

1011
    # DEPRECATED but still used read only to maintain backward compatibility
1012
    # with old Objects in the datastore that we haven't bothered migrating.
1013
    #
1014
    # domains = ndb.StringProperty(repeated=True)
1015

1016
    # DEPRECATED; replaced by :attr:`users`, :attr:`notify`, :attr:`feed`
1017
    #
1018
    # labels = ndb.StringProperty(repeated=True,
1019
    #                             choices=('activity', 'feed', 'notification', 'user'))
1020

1021
    @property
1✔
1022
    def as1(self):
1✔
1023
        def use_urls_as_ids(obj):
1✔
1024
            """If id field is missing or not a URL, use the url field."""
1025
            id = obj.get('id')
1✔
1026
            if not id or not (util.is_web(id) or re.match(DOMAIN_RE, id)):
1✔
1027
                if url := util.get_url(obj):
1✔
1028
                    obj['id'] = url
1✔
1029

1030
            for field in 'author', 'actor', 'object':
1✔
1031
                if inner := as1.get_object(obj, field):
1✔
1032
                    use_urls_as_ids(inner)
1✔
1033

1034
        if self.our_as1:
1✔
1035
            obj = self.our_as1
1✔
1036
            if self.source_protocol == 'web':
1✔
1037
                use_urls_as_ids(obj)
1✔
1038

1039
        elif self.as2:
1✔
1040
            obj = as2.to_as1(unwrap(self.as2))
1✔
1041

1042
        elif self.bsky:
1✔
1043
            owner, _, _ = parse_at_uri(self.key.id())
1✔
1044
            ATProto = PROTOCOLS['atproto']
1✔
1045
            handle = ATProto(id=owner).handle
1✔
1046
            try:
1✔
1047
                obj = bluesky.to_as1(self.bsky, repo_did=owner, repo_handle=handle,
1✔
1048
                                     uri=self.key.id(), pds=ATProto.pds_for(self))
1049
            except (ValueError, RequestException):
1✔
1050
                logger.info(f"Couldn't convert to ATProto", exc_info=True)
1✔
1051
                return None
1✔
1052

1053
        elif self.mf2:
1✔
1054
            obj = microformats2.json_to_object(self.mf2,
1✔
1055
                                               rel_urls=self.mf2.get('rel-urls'))
1056
            use_urls_as_ids(obj)
1✔
1057

1058
            # use fetched final URL as id, not u-url
1059
            # https://github.com/snarfed/bridgy-fed/issues/829
1060
            if url := self.mf2.get('url'):
1✔
1061
                obj['id'] = (self.key.id() if self.key and '#' in self.key.id()
1✔
1062
                             else url)
1063

1064
        else:
1065
            return None
1✔
1066

1067
        # populate id if necessary
1068
        if self.key:
1✔
1069
            obj.setdefault('id', self.key.id())
1✔
1070

1071
        return obj
1✔
1072

1073
    @ndb.ComputedProperty
1✔
1074
    def type(self):  # AS1 objectType, or verb if it's an activity
1✔
1075
        if self.as1:
1✔
1076
            return as1.object_type(self.as1)
1✔
1077

1078
    def __init__(self, *args, **kwargs):
1✔
1079
        super().__init__(*args, **kwargs)
1✔
1080
        self.lock = Lock()
1✔
1081

1082
    def _expire(self):
1✔
1083
        """Automatically delete most Objects after a while using a TTL policy.
1084

1085
        https://cloud.google.com/datastore/docs/ttl
1086

1087
        They recommend not indexing TTL properties:
1088
        https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
1089
        """
1090
        if self.deleted or self.type not in DONT_EXPIRE_OBJECT_TYPES:
1✔
1091
            return (self.updated or util.now()) + OBJECT_EXPIRE_AGE
1✔
1092

1093
    expire = ndb.ComputedProperty(_expire, indexed=False)
1✔
1094

1095
    def _pre_put_hook(self):
1✔
1096
        """
1097
        * Validate that at:// URIs have DID repos
1098
        * Set/remove the activity label
1099
        * Strip @context from as2 (we don't do LD) to save disk space
1100
        """
1101
        id = self.key.id()
1✔
1102

1103
        if self.source_protocol not in (None, 'ui'):
1✔
1104
            proto = PROTOCOLS[self.source_protocol]
1✔
1105
            assert proto.owns_id(id) is not False, \
1✔
1106
                f'Protocol {proto.LABEL} does not own id {id}'
1107

1108
        if id.startswith('at://'):
1✔
1109
            repo, _, _ = parse_at_uri(id)
1✔
1110
            if not repo.startswith('did:'):
1✔
1111
                # TODO: if we hit this, that means the AppView gave us an AT URI
1112
                # with a handle repo/authority instead of DID. that's surprising!
1113
                # ...if so, and if we need to handle it, add a new
1114
                # arroba.did.canonicalize_at_uri() function, then use it here,
1115
                # or before.
1116
                raise ValueError(
1✔
1117
                    f'at:// URI ids must have DID repos; got {id}')
1118

1119
        if self.as2:
1✔
1120
           self.as2.pop('@context', None)
1✔
1121
           for field in 'actor', 'attributedTo', 'author', 'object':
1✔
1122
               for val in util.get_list(self.as2, field):
1✔
1123
                   if isinstance(val, dict):
1✔
1124
                       val.pop('@context', None)
1✔
1125

1126
    def _post_put_hook(self, future):
1✔
1127
        # TODO: assert that as1 id is same as key id? in pre put hook?
1128
        logger.debug(f'Wrote {self.key}')
1✔
1129

1130
    @classmethod
1✔
1131
    def get_by_id(cls, id, authed_as=None, **kwargs):
1✔
1132
        """Fetches the :class:`Object` with the given id, if it exists.
1133

1134
        Args:
1135
          id (str)
1136
          authed_as (str): optional; if provided, and a matching :class:`Object`
1137
            already exists, its ``author`` or ``actor`` must contain this actor
1138
            id. Implements basic authorization for updates and deletes.
1139

1140
        Returns:
1141
          Object:
1142

1143
        Raises:
1144
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1145
            the existing object
1146
        """
1147
        obj = super().get_by_id(id, **kwargs)
1✔
1148

1149
        if obj and obj.as1 and authed_as:
1✔
1150
            # authorization: check that the authed user is allowed to modify
1151
            # this object
1152
            # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1153
            proto = PROTOCOLS.get(obj.source_protocol)
1✔
1154
            assert proto, obj.source_protocol
1✔
1155
            owners = [ids.normalize_user_id(id=owner, proto=proto)
1✔
1156
                      for owner in (as1.get_ids(obj.as1, 'author')
1157
                                    + as1.get_ids(obj.as1, 'actor'))
1158
                                    + [id]]
1159
            if (ids.normalize_user_id(id=authed_as, proto=proto) not in owners
1✔
1160
                    and ids.profile_id(id=authed_as, proto=proto) not in owners):
1161
                report_error("Auth: Object: authed_as doesn't match owner",
1✔
1162
                             user=f'{id} authed_as {authed_as} owners {owners}')
1163
                error(f"authed user {authed_as} isn't object owner {owners}",
1✔
1164
                      status=403)
1165

1166
        return obj
1✔
1167

1168
    @classmethod
1✔
1169
    def get_or_create(cls, id, authed_as=None, **props):
1✔
1170
        """Returns an :class:`Object` with the given property values.
1171

1172
        If a matching :class:`Object` doesn't exist in the datastore, creates it
1173
        first. Only populates non-False/empty property values in props into the
1174
        object. Also populates the :attr:`new` and :attr:`changed` properties.
1175

1176
        Not transactional because transactions don't read or write memcache. :/
1177
        Fortunately we don't really depend on atomicity for anything, last
1178
        writer wins is pretty much always fine.
1179

1180
        Args:
1181
          authed_as (str): optional; if provided, and a matching :class:`Object`
1182
            already exists, its ``author`` or ``actor`` must contain this actor
1183
            id. Implements basic authorization for updates and deletes.
1184

1185
        Returns:
1186
          Object:
1187

1188
        Raises:
1189
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1190
            the existing object
1191
        """
1192
        obj = cls.get_by_id(id, authed_as=authed_as)
1✔
1193

1194
        if not obj:
1✔
1195
            obj = Object(id=id, **props)
1✔
1196
            obj.new = True
1✔
1197
            obj.changed = False
1✔
1198
            obj.put()
1✔
1199
            return obj
1✔
1200

1201
        if orig_as1 := obj.as1:
1✔
1202
            # get_by_id() checks authorization if authed_as is set. make sure
1203
            # it's always set for existing objects.
1204
            assert authed_as
1✔
1205

1206
        dirty = False
1✔
1207
        for prop, val in props.items():
1✔
1208
            assert not isinstance(getattr(Object, prop), ndb.ComputedProperty)
1✔
1209
            if prop in ('feed', 'copies', 'notify', 'users'):
1✔
1210
                # merge repeated fields
1211
                for elem in val:
1✔
1212
                    if obj.add(prop, elem):
1✔
1213
                        dirty = True
1✔
1214
            elif val and val != getattr(obj, prop):
1✔
1215
                setattr(obj, prop, val)
1✔
1216
                if prop in ('as2', 'bsky', 'mf2', 'raw') and not props.get('our_as1'):
1✔
1217
                    obj.our_as1 = None
1✔
1218
                dirty = True
1✔
1219

1220
        obj.new = False
1✔
1221
        obj.changed = obj.activity_changed(orig_as1)
1✔
1222
        if dirty:
1✔
1223
            obj.put()
1✔
1224
        return obj
1✔
1225

1226
    def add(self, prop, val):
1✔
1227
        """Adds a value to a multiply-valued property. Uses ``self.lock``.
1228

1229
        Args:
1230
          prop (str)
1231
          val
1232

1233
        Returns:
1234
          True if val was added, ie it wasn't already in prop, False otherwise
1235
        """
1236
        with self.lock:
1✔
1237
            added = util.add(getattr(self, prop), val)
1✔
1238

1239
        if prop == 'copies' and added:
1✔
1240
            memcache.pickle_memcache.set(memcache.memoize_key(
1✔
1241
                get_original_object_key, val.uri), self.key)
1242

1243
        return added
1✔
1244

1245
    def remove(self, prop, val):
1✔
1246
        """Removes a value from a multiply-valued property. Uses ``self.lock``.
1247

1248
        Args:
1249
          prop (str)
1250
          val
1251
        """
UNCOV
1252
        with self.lock:
×
UNCOV
1253
            getattr(self, prop).remove(val)
×
1254

1255
    @staticmethod
1✔
1256
    def from_request():
1✔
1257
        """Creates and returns an :class:`Object` from form-encoded JSON parameters.
1258

1259
        Parameters:
1260
          obj_id (str): id of :class:`models.Object` to handle
1261
          *: If ``obj_id`` is unset, all other parameters are properties for a
1262
            new :class:`models.Object` to handle
1263
        """
1264
        if obj_id := request.form.get('obj_id'):
1✔
1265
            return Object.get_by_id(obj_id)
1✔
1266

1267
        props = {field: request.form.get(field)
1✔
1268
                 for field in ('id', 'source_protocol')}
1269

1270
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1271
            if val := request.form.get(json_prop):
1✔
1272
                props[json_prop] = json_loads(val)
1✔
1273

1274
        obj = Object(**props)
1✔
1275
        if not obj.key and obj.as1:
1✔
1276
            if id := obj.as1.get('id'):
1✔
1277
                obj.key = ndb.Key(Object, id)
1✔
1278

1279
        return obj
1✔
1280

1281
    def to_request(self):
1✔
1282
        """Returns a query parameter dict representing this :class:`Object`."""
1283
        form = {}
1✔
1284

1285
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1286
            if val := getattr(self, json_prop, None):
1✔
1287
                form[json_prop] = json_dumps(val, sort_keys=True)
1✔
1288

1289
        for prop in ['source_protocol']:
1✔
1290
            if val := getattr(self, prop):
1✔
1291
                form[prop] = val
1✔
1292

1293
        if self.key:
1✔
1294
            form['id'] = self.key.id()
1✔
1295

1296
        return form
1✔
1297

1298
    def activity_changed(self, other_as1):
1✔
1299
        """Returns True if this activity is meaningfully changed from ``other_as1``.
1300

1301
        ...otherwise False.
1302

1303
        Used to populate :attr:`changed`.
1304

1305
        Args:
1306
          other_as1 (dict): AS1 object, or none
1307
        """
1308
        # ignore inReplyTo since we translate it between protocols
1309
        return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
1✔
1310
                if self.as1 and other_as1
1311
                else bool(self.as1) != bool(other_as1))
1312

1313
    def actor_link(self, image=True, sized=False, user=None):
1✔
1314
        """Returns a pretty HTML link with the actor's name and picture.
1315

1316
        TODO: unify with :meth:`User.user_link`?
1317

1318
        Args:
1319
          image (bool): whether to include an ``img`` tag with the actor's picture
1320
          sized (bool): whether to set an explicit (``width=32``) size on the
1321
            profile picture ``img`` tag
1322
          user (User): current user
1323

1324
        Returns:
1325
          str:
1326
        """
1327
        attrs = {'class': 'h-card u-author'}
1✔
1328

1329
        if user and user.key in self.users:
1✔
1330
            # outbound; show a nice link to the user
1331
            return user.user_link(handle=False, pictures=True)
1✔
1332

1333
        proto = PROTOCOLS.get(self.source_protocol)
1✔
1334

1335
        actor = None
1✔
1336
        if self.as1:
1✔
1337
            actor = (as1.get_object(self.as1, 'actor')
1✔
1338
                     or as1.get_object(self.as1, 'author'))
1339
            # hydrate from datastore if available
1340
            # TODO: optimize! this is called serially in loops, eg in home.html
1341
            if set(actor.keys()) == {'id'} and self.source_protocol:
1✔
1342
                actor_obj = proto.load(actor['id'], remote=False)
1✔
1343
                if actor_obj and actor_obj.as1:
1✔
1344
                    actor = actor_obj.as1
1✔
1345

1346
        if not actor:
1✔
1347
            return ''
1✔
1348
        elif set(actor.keys()) == {'id'}:
1✔
1349
            return common.pretty_link(actor['id'], attrs=attrs, user=user)
1✔
1350

1351
        url = as1.get_url(actor)
1✔
1352
        name = actor.get('displayName') or actor.get('username') or ''
1✔
1353
        img_url = util.get_url(actor, 'image')
1✔
1354
        if not image or not img_url:
1✔
1355
            return common.pretty_link(url, text=name, attrs=attrs, user=user)
1✔
1356

1357
        logo = ''
1✔
1358
        if proto:
1✔
UNCOV
1359
            logo = f'<span class="logo" title="{self.__class__.__name__}">{proto.LOGO_HTML}</span>'
×
1360

1361
        return f"""\
1✔
1362
        {logo}
1363
        <a class="h-card u-author" href="{url}" title="{name}">
1364
          <img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
1365
          <span style="unicode-bidi: isolate">{util.ellipsize(name, chars=40)}</span>
1366
        </a>"""
1367

1368
    def get_copy(self, proto):
1✔
1369
        """Returns the id for the copy of this object in a given protocol.
1370

1371
        ...or None if no such copy exists. If ``proto`` is ``source_protocol``,
1372
        returns this object's key id.
1373

1374
        Args:
1375
          proto: :class:`Protocol` subclass
1376

1377
        Returns:
1378
          str:
1379
        """
1380
        if self.source_protocol in (proto.LABEL, proto.ABBREV):
1✔
1381
            return self.key.id()
1✔
1382

1383
        for copy in self.copies:
1✔
1384
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
1385
                return copy.uri
1✔
1386

1387
    def resolve_ids(self):
1✔
1388
        """Resolves "copy" ids, subdomain ids, etc with their originals.
1389

1390
        The end result is that all ids are original "source" ids, ie in the
1391
        protocol that they first came from.
1392

1393
        Specifically, resolves:
1394

1395
        * ids in :class:`User.copies` and :class:`Object.copies`, eg ATProto
1396
          records and Nostr events that we bridged, to the ids of their
1397
          original objects in their source protocol, eg
1398
          ``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
1399
        * Bridgy Fed subdomain URLs to the ids embedded inside them, eg
1400
          ``https://bsky.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
1401
        * ATProto bsky.app URLs to their DIDs or `at://` URIs, eg
1402
          ``https://bsky.app/profile/a.com`` => ``did:plc:123``
1403

1404
        ...in these AS1 fields, in place:
1405

1406
        * ``id``
1407
        * ``actor``
1408
        * ``author``
1409
        * ``object``
1410
        * ``object.actor``
1411
        * ``object.author``
1412
        * ``object.id``
1413
        * ``object.inReplyTo``
1414
        * ``tags.[objectType=mention].url``
1415

1416
        :meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
1417
        Much of the same logic is duplicated there!
1418

1419
        TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
1420
        """
1421
        if not self.as1:
1✔
1422
            return
1✔
1423

1424
        # extract ids, strip Bridgy Fed subdomain URLs
1425
        outer_obj = unwrap(self.as1)
1✔
1426
        if outer_obj != self.as1:
1✔
1427
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1428

1429
        self_proto = PROTOCOLS.get(self.source_protocol)
1✔
1430
        if not self_proto:
1✔
1431
            return
1✔
1432

1433
        inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
1✔
1434
        replaced = False
1✔
1435

1436
        def replace(val, orig_fn):
1✔
1437
            id = val.get('id') if isinstance(val, dict) else val
1✔
1438
            if not id or not self_proto.HAS_COPIES:
1✔
1439
                return id
1✔
1440

1441
            orig = orig_fn(id)
1✔
1442
            if not orig:
1✔
1443
                return val
1✔
1444

1445
            nonlocal replaced
1446
            replaced = True
1✔
1447
            logger.debug(f'Resolved copy id {val} to original {orig.id()}')
1✔
1448

1449
            if isinstance(val, dict) and util.trim_nulls(val).keys() > {'id'}:
1✔
1450
                val['id'] = orig.id()
1✔
1451
                return val
1✔
1452
            else:
1453
                return orig.id()
1✔
1454

1455
        # actually replace ids
1456
        #
1457
        # object field could be either object (eg repost) or actor (eg follow)
1458
        outer_obj['object'] = replace(inner_obj, get_original_object_key)
1✔
1459
        if not replaced:
1✔
1460
            outer_obj['object'] = replace(inner_obj, get_original_user_key)
1✔
1461

1462
        for obj in outer_obj, inner_obj:
1✔
1463
            for tag in as1.get_objects(obj, 'tags'):
1✔
1464
                if tag.get('objectType') == 'mention':
1✔
1465
                    tag['url'] = replace(tag.get('url'), get_original_user_key)
1✔
1466
            for field, fn in (
1✔
1467
                    ('actor', get_original_user_key),
1468
                    ('author', get_original_user_key),
1469
                    ('inReplyTo', get_original_object_key),
1470
                ):
1471
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1472
                if len(obj[field]) == 1:
1✔
1473
                    obj[field] = obj[field][0]
1✔
1474

1475
        if replaced:
1✔
1476
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1477

1478
    def normalize_ids(self):
1✔
1479
        """Normalizes ids to their protocol's canonical representation, if any.
1480

1481
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
1482
        for profiles, ``at://`` URIs for posts.
1483

1484
        Modifies this object in place.
1485

1486
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
1487
        """
1488
        from protocol import Protocol
1✔
1489

1490
        if not self.as1:
1✔
1491
            return
1✔
1492

1493
        logger.debug(f'Normalizing ids')
1✔
1494
        outer_obj = copy.deepcopy(self.as1)
1✔
1495
        inner_objs = as1.get_objects(outer_obj)
1✔
1496
        replaced = False
1✔
1497

1498
        def replace(val, translate_fn):
1✔
1499
            nonlocal replaced
1500

1501
            orig = val.get('id') if isinstance(val, dict) else val
1✔
1502
            if not orig:
1✔
1503
                return val
1✔
1504

1505
            proto = Protocol.for_id(orig, remote=False)
1✔
1506
            if not proto:
1✔
1507
                return val
1✔
1508

1509
            translated = translate_fn(id=orig, from_=proto, to=proto)
1✔
1510
            if translated and translated != orig:
1✔
1511
                # logger.debug(f'Normalized {proto.LABEL} id {orig} to {translated}')
1512
                replaced = True
1✔
1513
                if isinstance(val, dict):
1✔
1514
                    val['id'] = translated
1✔
1515
                    return val
1✔
1516
                else:
1517
                    return translated
1✔
1518

1519
            return val
1✔
1520

1521
        # actually replace ids
1522
        for obj in [outer_obj] + inner_objs:
1✔
1523
            for tag in as1.get_objects(obj, 'tags'):
1✔
1524
                if tag.get('objectType') == 'mention':
1✔
1525
                    tag['url'] = replace(tag.get('url'), ids.translate_user_id)
1✔
1526
            for field in ['actor', 'author', 'inReplyTo']:
1✔
1527
                fn = (ids.translate_object_id if field == 'inReplyTo'
1✔
1528
                      else ids.translate_user_id)
1529
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1530
                if len(obj[field]) == 1:
1✔
1531
                    obj[field] = obj[field][0]
1✔
1532

1533
        outer_obj['object'] = []
1✔
1534
        for inner_obj in inner_objs:
1✔
1535
            translate_fn = (ids.translate_user_id
1✔
1536
                            if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
1537
                                or as1.object_type(outer_obj) in
1538
                                ('follow', 'stop-following'))
1539
                            else ids.translate_object_id)
1540

1541
            got = replace(inner_obj, translate_fn)
1✔
1542
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
1543
                got = got['id']
1✔
1544

1545
            outer_obj['object'].append(got)
1✔
1546

1547
        if len(outer_obj['object']) == 1:
1✔
1548
            outer_obj['object'] = outer_obj['object'][0]
1✔
1549

1550
        if replaced:
1✔
1551
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1552

1553

1554
class Follower(ndb.Model):
1✔
1555
    """A follower of a Bridgy Fed user."""
1556
    STATUSES = ('active', 'inactive')
1✔
1557

1558
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
1559
    """The follower."""
1✔
1560
    to = ndb.KeyProperty(required=True)
1✔
1561
    """The followee, ie the user being followed."""
1✔
1562

1563
    follow = ndb.KeyProperty(Object)
1✔
1564
    """The last follow activity."""
1✔
1565
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
1566
    """Whether this follow is active or note."""
1✔
1567

1568
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1569
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1570

1571
    # OLD. some stored entities still have these; do not reuse.
1572
    # src = ndb.StringProperty()
1573
    # dest = ndb.StringProperty()
1574
    # last_follow = JsonProperty()
1575

1576
    def _pre_put_hook(self):
1✔
1577
        # we're a bridge! stick with bridging.
1578
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
1579

1580
    def _post_put_hook(self, future):
1✔
1581
        logger.debug(f'Wrote {self.key}')
1✔
1582

1583
    @classmethod
1✔
1584
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
1585
        """Returns a Follower with the given ``from_`` and ``to`` users.
1586

1587
        Not transactional because transactions don't read or write memcache. :/
1588
        Fortunately we don't really depend on atomicity for anything, last
1589
        writer wins is pretty much always fine.
1590

1591
        If a matching :class:`Follower` doesn't exist in the datastore, creates
1592
        it first.
1593

1594
        Args:
1595
          from_ (User)
1596
          to (User)
1597

1598
        Returns:
1599
          Follower:
1600
        """
1601
        assert from_
1✔
1602
        assert to
1✔
1603

1604
        follower = Follower.query(Follower.from_ == from_.key,
1✔
1605
                                  Follower.to == to.key,
1606
                                  ).get()
1607
        if not follower:
1✔
1608
            follower = Follower(from_=from_.key, to=to.key, **kwargs)
1✔
1609
            follower.put()
1✔
1610
        elif kwargs:
1✔
1611
            # update existing entity with new property values, eg to make an
1612
            # inactive Follower active again
1613
            for prop, val in kwargs.items():
1✔
1614
                setattr(follower, prop, val)
1✔
1615
            follower.put()
1✔
1616

1617
        return follower
1✔
1618

1619
    @staticmethod
1✔
1620
    def fetch_page(collection, user):
1✔
1621
        r"""Fetches a page of :class:`Follower`\s for a given user.
1622

1623
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
1624
        parameters, if available in the request.
1625

1626
        Args:
1627
          collection (str): ``followers`` or ``following``
1628
          user (User)
1629

1630
        Returns:
1631
          (list of Follower, str, str) tuple: results, annotated with an extra
1632
          ``user`` attribute that holds the follower or following :class:`User`,
1633
          and new str query param values for ``before`` and ``after`` to fetch
1634
          the previous and next pages, respectively
1635
        """
1636
        assert collection in ('followers', 'following'), collection
1✔
1637

1638
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1639
        query = Follower.query(
1✔
1640
            Follower.status == 'active',
1641
            filter_prop == user.key,
1642
        )
1643

1644
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
1645
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
1646
                              for f in followers)
1647
        User.load_multi(u for u in users if u)
1✔
1648

1649
        for f, u in zip(followers, users):
1✔
1650
            f.user = u
1✔
1651
        followers = [f for f in followers if not f.user.status]
1✔
1652

1653
        return followers, before, after
1✔
1654

1655

1656
def fetch_objects(query, by=None, user=None):
1✔
1657
    """Fetches a page of :class:`Object` entities from a datastore query.
1658

1659
    Wraps :func:`fetch_page` and adds attributes to the returned
1660
    :class:`Object` entities for rendering in ``objects.html``.
1661

1662
    Args:
1663
      query (ndb.Query)
1664
      by (ndb.model.Property): either :attr:`Object.updated` or
1665
        :attr:`Object.created`
1666
      user (User): current user
1667

1668
    Returns:
1669
      (list of Object, str, str) tuple:
1670
      (results, new ``before`` query param, new ``after`` query param)
1671
      to fetch the previous and next pages, respectively
1672
    """
1673
    assert by is Object.updated or by is Object.created
1✔
1674
    objects, new_before, new_after = fetch_page(query, Object, by=by)
1✔
1675
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
1676

1677
    # synthesize human-friendly content for objects
1678
    for i, obj in enumerate(objects):
1✔
1679
        obj_as1 = obj.as1
1✔
1680
        inner_obj = as1.get_object(obj_as1)
1✔
1681

1682
        # synthesize text snippet
1683
        type = as1.object_type(obj_as1)
1✔
1684
        if type == 'post':
1✔
UNCOV
1685
            inner_type = inner_obj.get('objectType')
×
UNCOV
1686
            if inner_type:
×
UNCOV
1687
                type = inner_type
×
1688

1689
        # AS1 verb => human-readable phrase
1690
        phrases = {
1✔
1691
            'accept': 'accepted',
1692
            'article': 'posted',
1693
            'comment': 'replied',
1694
            'delete': 'deleted',
1695
            'follow': 'followed',
1696
            'invite': 'is invited to',
1697
            'issue': 'filed issue',
1698
            'like': 'liked',
1699
            'note': 'posted',
1700
            'post': 'posted',
1701
            'repost': 'reposted',
1702
            'rsvp-interested': 'is interested in',
1703
            'rsvp-maybe': 'might attend',
1704
            'rsvp-no': 'is not attending',
1705
            'rsvp-yes': 'is attending',
1706
            'share': 'reposted',
1707
            'stop-following': 'unfollowed',
1708
            'undo': 'undid',
1709
            'update': 'updated',
1710
        }
1711
        obj.phrase = phrases.get(type)
1✔
1712

1713
        content = (inner_obj.get('content')
1✔
1714
                   or inner_obj.get('displayName')
1715
                   or inner_obj.get('summary'))
1716
        if content:
1✔
UNCOV
1717
            content = util.parse_html(content).get_text()
×
1718

1719
        urls = as1.object_urls(inner_obj)
1✔
1720
        id = unwrap(inner_obj.get('id', ''))
1✔
1721
        url = urls[0] if urls else id
1✔
1722
        if (type == 'update' and obj.users
1✔
1723
                and (user.is_web_url(id) or id.strip('/') == obj.users[0].id())):
UNCOV
1724
            obj.phrase = 'updated'
×
UNCOV
1725
            obj_as1.update({
×
1726
                'content': 'their profile',
1727
                'url': id,
1728
            })
1729
        elif url and not content:
1✔
1730
            # heuristics for sniffing URLs and converting them to more friendly
1731
            # phrases and user handles.
1732
            # TODO: standardize this into granary.as2 somewhere?
1733
            from activitypub import FEDI_URL_RE
1✔
1734
            from atproto import COLLECTION_TO_TYPE, did_to_handle
1✔
1735

1736
            handle = suffix = ''
1✔
1737
            if match := FEDI_URL_RE.match(url):
1✔
UNCOV
1738
                handle = match.group(2)
×
UNCOV
1739
                if match.group(4):
×
UNCOV
1740
                    suffix = "'s post"
×
1741
            elif match := BSKY_APP_URL_RE.match(url):
1✔
UNCOV
1742
                handle = match.group('id')
×
1743
                if match.group('tid'):
×
1744
                    suffix = "'s post"
×
1745
            elif match := AT_URI_PATTERN.match(url):
1✔
UNCOV
1746
                handle = match.group('repo')
×
1747
                if coll := match.group('collection'):
×
1748
                    suffix = f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
×
1749
                url = bluesky.at_uri_to_web_url(url)
×
1750
            elif url.startswith('did:'):
1✔
1751
                handle = url
×
1752
                url = bluesky.Bluesky.user_url(handle)
×
1753

1754
            if handle:
1✔
UNCOV
1755
                if handle.startswith('did:'):
×
1756
                    handle = did_to_handle(handle) or handle
×
1757
                content = f'@{handle}{suffix}'
×
1758

1759
            if url:
1✔
1760
                content = common.pretty_link(url, text=content, user=user)
1✔
1761

1762
        obj.content = (obj_as1.get('content')
1✔
1763
                       or obj_as1.get('displayName')
1764
                       or obj_as1.get('summary'))
1765
        obj.url = util.get_first(obj_as1, 'url')
1✔
1766

1767
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
1768
            if obj.url:
1✔
UNCOV
1769
                obj.phrase = common.pretty_link(
×
1770
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
1771
            if content:
1✔
1772
                obj.content = content
1✔
1773
                obj.url = url
1✔
1774

1775
    return objects, new_before, new_after
1✔
1776

1777

1778
def fetch_page(query, model_class, by=None):
1✔
1779
    """Fetches a page of results from a datastore query.
1780

1781
    Uses the ``before`` and ``after`` query params (if provided; should be
1782
    ISO8601 timestamps) and the ``by`` property to identify the page to fetch.
1783

1784
    Populates a ``log_url_path`` property on each result entity that points to a
1785
    its most recent logged request.
1786

1787
    Args:
1788
      query (google.cloud.ndb.query.Query)
1789
      model_class (class)
1790
      by (ndb.model.Property): paging property, eg :attr:`Object.updated`
1791
        or :attr:`Object.created`
1792

1793
    Returns:
1794
      (list of Object or Follower, str, str) tuple: (results, new_before,
1795
      new_after), where new_before and new_after are query param values for
1796
      ``before`` and ``after`` to fetch the previous and next pages,
1797
      respectively
1798
    """
1799
    assert by
1✔
1800

1801
    # if there's a paging param ('before' or 'after'), update query with it
1802
    # TODO: unify this with Bridgy's user page
1803
    def get_paging_param(param):
1✔
1804
        val = request.values.get(param)
1✔
1805
        if val:
1✔
1806
            try:
1✔
1807
                dt = util.parse_iso8601(val.replace(' ', '+'))
1✔
1808
            except BaseException as e:
1✔
1809
                error(f"Couldn't parse {param}, {val!r} as ISO8601: {e}")
1✔
1810
            if dt.tzinfo:
1✔
1811
                dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
1✔
1812
            return dt
1✔
1813

1814
    before = get_paging_param('before')
1✔
1815
    after = get_paging_param('after')
1✔
1816
    if before and after:
1✔
UNCOV
1817
        error("can't handle both before and after")
×
1818
    elif after:
1✔
1819
        query = query.filter(by >= after).order(by)
1✔
1820
    elif before:
1✔
1821
        query = query.filter(by < before).order(-by)
1✔
1822
    else:
1823
        query = query.order(-by)
1✔
1824

1825
    query_iter = query.iter()
1✔
1826
    results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
1✔
1827
                     key=lambda r: r.updated, reverse=True)
1828

1829
    # calculate new paging param(s)
1830
    has_next = results and query_iter.probably_has_next()
1✔
1831
    new_after = (
1✔
1832
        before if before
1833
        else results[0].updated if has_next and after
1834
        else None)
1835
    if new_after:
1✔
1836
        new_after = new_after.isoformat()
1✔
1837

1838
    new_before = (
1✔
1839
        after if after else
1840
        results[-1].updated if has_next
1841
        else None)
1842
    if new_before:
1✔
1843
        new_before = new_before.isoformat()
1✔
1844

1845
    return results, new_before, new_after
1✔
1846

1847

1848
@lru_cache(maxsize=100000)
1✔
1849
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
1850
def get_original_object_key(copy_id):
1✔
1851
    """Finds the :class:`Object` with a given copy id, if any.
1852

1853
    Note that :meth:`Object.add` also updates this function's
1854
    :func:`memcache.memoize` cache.
1855

1856
    Args:
1857
      copy_id (str)
1858

1859
    Returns:
1860
      google.cloud.ndb.Key or None
1861
    """
1862
    assert copy_id
1✔
1863

1864
    return Object.query(Object.copies.uri == copy_id).get(keys_only=True)
1✔
1865

1866

1867
@lru_cache(maxsize=100000)
1✔
1868
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
1869
def get_original_user_key(copy_id):
1✔
1870
    """Finds the user with a given copy id, if any.
1871

1872
    Note that :meth:`User.add` also updates this function's
1873
    :func:`memcache.memoize` cache.
1874

1875
    Args:
1876
      copy_id (str)
1877
      not_proto (Protocol): optional, don't query this protocol
1878

1879
    Returns:
1880
      google.cloud.ndb.Key or None
1881
    """
1882
    assert copy_id
1✔
1883

1884
    for proto in PROTOCOLS.values():
1✔
1885
        if proto and proto.LABEL != 'ui' and not proto.owns_id(copy_id):
1✔
1886
            if orig := proto.query(proto.copies.uri == copy_id).get(keys_only=True):
1✔
1887
                return orig
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