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

snarfed / bridgy-fed / 091627e3-3bba-4430-9dd8-5cf9a2b950ca

19 Mar 2025 08:24PM UTC coverage: 93.189% (-0.002%) from 93.191%
091627e3-3bba-4430-9dd8-5cf9a2b950ca

push

circleci

snarfed
noop: drop deprecated User.readable_id property

1 of 1 new or added line in 1 file covered. (100.0%)

2 existing lines in 1 file now uncovered.

4748 of 5095 relevant lines covered (93.19%)

0.93 hits per line

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

95.21
/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 status(self):
1✔
467
        """Whether this user is blocked or opted out.
468

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

484
        Duplicates ``util.is_opt_out`` in Bridgy!
485

486
        https://github.com/snarfed/bridgy-fed/issues/666
487
        """
488
        if self.manual_opt_out:
1✔
489
            return 'opt-out'
1✔
490

491
        if not self.obj or not self.obj.as1:
1✔
492
            return None
1✔
493

494
        if self.obj.as1.get('bridgeable') is False:  # FEP-0036
1✔
495
            return 'opt-out'
1✔
496

497
        if self.REQUIRES_AVATAR and not self.obj.as1.get('image'):
1✔
498
            return 'requires-avatar'
1✔
499

500
        name = self.obj.as1.get('displayName')
1✔
501
        if self.REQUIRES_NAME and (not name or name in (self.handle, self.key.id())):
1✔
502
            return 'requires-name'
1✔
503

504
        if self.REQUIRES_OLD_ACCOUNT:
1✔
505
            if published := self.obj.as1.get('published'):
1✔
506
                if util.now() - util.parse_iso8601(published) < OLD_ACCOUNT_AGE:
1✔
507
                    return 'requires-old-account'
1✔
508

509
        summary = html_to_text(self.obj.as1.get('summary', ''), ignore_links=True)
1✔
510
        name = html_to_text(self.obj.as1.get('displayName', ''), ignore_links=True)
1✔
511

512
        # #nobridge overrides enabled_protocols
513
        if '#nobridge' in summary or '#nobridge' in name:
1✔
514
            return 'nobridge'
1✔
515

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

523
        if not as1.is_public(self.obj.as1, unlisted=False):
1✔
524
            return 'private'
1✔
525

526
        # enabled_protocols overrides #nobot
527
        if '#nobot' in summary or '#nobot' in name:
1✔
528
            return 'nobot'
1✔
529

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

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

540
        Args:
541
          to_proto (Protocol subclass)
542
          explicit (bool)
543

544
        Returns:
545
          bool:
546
        """
547
        from protocol import Protocol
1✔
548
        assert issubclass(to_proto, Protocol)
1✔
549

550
        if self.__class__ == to_proto:
1✔
551
            return True
1✔
552

553
        from_label = self.LABEL
1✔
554
        to_label = to_proto.LABEL
1✔
555

556
        if bot_protocol := Protocol.for_bridgy_subdomain(self.key.id()):
1✔
557
            return to_proto != bot_protocol
1✔
558

559
        elif self.manual_opt_out:
1✔
560
            return False
1✔
561

562
        elif to_label in self.enabled_protocols:
1✔
563
            return True
1✔
564

565
        elif self.status:
1✔
566
            return False
1✔
567

568
        elif to_label in self.DEFAULT_ENABLED_PROTOCOLS and not explicit:
1✔
569
            return True
1✔
570

571
        return False
1✔
572

573
    def enable_protocol(self, to_proto):
1✔
574
        """Adds ``to_proto`` to :attr:`enabled_protocols`.
575

576
        Also sends a welcome DM to the user (via a send task) if their protocol
577
        supports DMs.
578

579
        Args:
580
          to_proto (:class:`protocol.Protocol` subclass)
581
        """
582
        import dms
1✔
583

584
        # explicit opt-in overrides some status
585
        # !!! WARNING: keep in sync with User.status!
586
        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✔
587
        if self.status and self.status not in ('nobot', 'private'):
1✔
588
            if desc := USER_STATUS_DESCRIPTIONS.get(self.status):
1✔
589
                dms.maybe_send(from_proto=to_proto, to_user=self, type=self.status,
1✔
590
                               text=ineligible.format(desc=desc))
591
            common.error(f'Nope, user {self.key.id()} is {self.status}', status=299)
1✔
592

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

601
        added = False
1✔
602

603
        if to_proto.LABEL in ids.COPIES_PROTOCOLS:
1✔
604
            # do this even if there's an existing copy since we might need to
605
            # reactivate it, which create_for should do
606
            to_proto.create_for(self)
1✔
607

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

618
            return user
1✔
619

620
        new_self = enable()
1✔
621
        # populate newly enabled protocol in this instance
622
        self.enabled_protocols = new_self.enabled_protocols
1✔
623
        self.copies = new_self.copies
1✔
624
        if self.obj:
1✔
625
            self.obj.copies = new_self.obj.copies
1✔
626

627
        if added:
1✔
628
            dms.maybe_send(from_proto=to_proto, to_user=self, type='welcome', text=f"""\
1✔
629
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.""")
630

631
        msg = f'Enabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
632
        logger.info(msg)
1✔
633

634
    def disable_protocol(self, to_proto):
1✔
635
        """Removes ``to_proto` from :attr:`enabled_protocols``.
636

637
        Args:
638
          to_proto (:class:`protocol.Protocol` subclass)
639
        """
640
        @ndb.transactional()
1✔
641
        def disable():
1✔
642
            user = self.key.get()
1✔
643
            util.remove(user.enabled_protocols, to_proto.LABEL)
1✔
644
            user.put()
1✔
645

646
        disable()
1✔
647
        util.remove(self.enabled_protocols, to_proto.LABEL)
1✔
648

649
        msg = f'Disabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
650
        logger.info(msg)
1✔
651

652
    def handle_as(self, to_proto):
1✔
653
        """Returns this user's handle in a different protocol.
654

655
        Args:
656
          to_proto (str or Protocol)
657

658
        Returns:
659
          str
660
        """
661
        if isinstance(to_proto, str):
1✔
662
            to_proto = PROTOCOLS[to_proto]
1✔
663

664
        # override to-ATProto to use custom domain handle in DID doc
665
        from atproto import ATProto, did_to_handle
1✔
666
        if to_proto == ATProto:
1✔
667
            if did := self.get_copy(ATProto):
1✔
668
                if handle := did_to_handle(did, remote=False):
1✔
669
                    return handle
1✔
670

671
        # override web users to always use domain instead of custom username
672
        # TODO: fall back to id if handle is unset?
673
        handle = self.key.id() if self.LABEL == 'web' else self.handle
1✔
674
        if not handle:
1✔
675
            return None
1✔
676

677
        return ids.translate_handle(handle=handle, from_=self.__class__,
1✔
678
                                    to=to_proto, enhanced=False)
679

680
    def id_as(self, to_proto):
1✔
681
        """Returns this user's id in a different protocol.
682

683
        Args:
684
          to_proto (str or Protocol)
685

686
        Returns:
687
          str
688
        """
689
        if isinstance(to_proto, str):
1✔
690
            to_proto = PROTOCOLS[to_proto]
1✔
691

692
        return ids.translate_user_id(id=self.key.id(), from_=self.__class__,
1✔
693
                                     to=to_proto)
694

695
    def handle_or_id(self):
1✔
696
        """Returns handle if we know it, otherwise id."""
697
        return self.handle or self.key.id()
1✔
698

699
    def public_pem(self):
1✔
700
        """
701
        Returns:
702
          bytes:
703
        """
704
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
705
                             base64_to_long(str(self.public_exponent))))
706
        return rsa.exportKey(format='PEM')
1✔
707

708
    def private_pem(self):
1✔
709
        """
710
        Returns:
711
          bytes:
712
        """
713
        assert self.mod and self.public_exponent and self.private_exponent, str(self)
1✔
714
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
715
                             base64_to_long(str(self.public_exponent)),
716
                             base64_to_long(str(self.private_exponent))))
717
        return rsa.exportKey(format='PEM')
1✔
718

719
    def name(self):
1✔
720
        """Returns this user's human-readable name, eg ``Ryan Barrett``."""
721
        if self.obj and self.obj.as1:
1✔
722
            name = self.obj.as1.get('displayName')
1✔
723
            if name:
1✔
724
                return name
1✔
725

726
        return self.handle_or_id()
1✔
727

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

731
        To be implemented by subclasses.
732

733
        Returns:
734
          str
735
        """
736
        raise NotImplementedError()
×
737

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

741
        Args:
742
          url (str)
743
          ignore_www (bool): if True, ignores ``www.`` subdomains
744

745
        Returns:
746
          bool:
747
        """
748
        if not url:
1✔
749
            return False
1✔
750

751
        url = url.strip().rstrip('/')
1✔
752
        url = re.sub(r'^(https?://)www\.', r'\1', url)
1✔
753
        parsed_url = urlparse(url)
1✔
754
        if parsed_url.scheme not in ('http', 'https', ''):
1✔
755
            return False
1✔
756

757
        this = self.web_url().rstrip('/')
1✔
758
        this = re.sub(r'^(https?://)www\.', r'\1', this)
1✔
759
        parsed_this = urlparse(this)
1✔
760

761
        return (url == this or url == parsed_this.netloc or
1✔
762
                parsed_url[1:] == parsed_this[1:])  # ignore http vs https
763

764
    def id_uri(self):
1✔
765
        """Returns the user id as a URI.
766

767
        Sometimes this is the user id itself, eg ActivityPub actor ids.
768
        Sometimes it's a bit different, eg at://did:plc:... for ATProto user,
769
        https://site.com for Web users.
770

771
        Returns:
772
          str
773
        """
774
        return self.key.id()
1✔
775

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

779
        Examples:
780

781
        * Web: home page URL, eg ``https://me.com/``
782
        * ActivityPub: actor URL, eg ``https://instance.com/users/me``
783
        * ATProto: profile AT URI, eg ``at://did:plc:123/app.bsky.actor.profile/self``
784

785
        Defaults to this user's key id.
786

787
        Returns:
788
          str or None:
789
        """
790
        return ids.profile_id(id=self.key.id(), proto=self)
1✔
791

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

795
        Populates the reloaded profile :class:`Object` in ``self.obj``.
796

797
        Args:
798
          kwargs: passed through to :meth:`Protocol.load`
799
        """
800
        obj = self.load(self.profile_id(), remote=True, **kwargs)
1✔
801
        if obj:
1✔
802
            self.obj = obj
1✔
803

804
    def user_page_path(self, rest=None):
1✔
805
        """Returns the user's Bridgy Fed user page path."""
806
        path = f'/{self.ABBREV}/{self.handle_or_id()}'
1✔
807

808
        if rest:
1✔
809
            if not rest.startswith('?'):
1✔
810
                path += '/'
1✔
811
            path += rest
1✔
812

813
        return path
1✔
814

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

818
        ...or None if no such copy exists. If ``proto`` is this user, returns
819
        this user's key id.
820

821
        Args:
822
          proto: :class:`Protocol` subclass
823

824
        Returns:
825
          str:
826
        """
827
        # don't use isinstance because the testutil Fake protocol has subclasses
828
        if self.LABEL == proto.LABEL:
1✔
829
            return self.key.id()
1✔
830

831
        for copy in self.copies:
1✔
832
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
833
                return copy.uri
1✔
834

835
    def user_link(self, name=True, handle=True, pictures=False, proto=None,
1✔
836
                  proto_fallback=False):
837
        """Returns a pretty HTML link to the user's profile.
838

839
        Can optionally include display name, handle, profile
840
        picture, and/or link to a different protocol that they've enabled.
841

842
        TODO: unify with :meth:`Object.actor_link`?
843

844
        Args:
845
          name (bool): include display name
846
          handle (bool): include handle
847
          pictures (bool): include profile picture and protocol logo
848
          proto (protocol.Protocol): link to this protocol instead of the user's
849
            native protocol
850
          proto_fallback (bool): if True, and ``proto`` is provided and has no
851
            no canonical profile URL for bridged users, uses the user's profile
852
            URL in their native protocol
853
        """
854
        img = name_str = handle_str = dot = logo = a_open = a_close = ''
1✔
855

856
        if proto:
1✔
857
            assert self.is_enabled(proto), f"{proto.LABEL} isn't enabled"
1✔
858
            url = proto.bridged_web_url_for(self, fallback=proto_fallback)
1✔
859
        else:
860
            proto = self.__class__
1✔
861
            url = self.web_url()
1✔
862

863
        if pictures:
1✔
864
            logo = f'<span class="logo" title="{proto.__name__}">{proto.LOGO_HTML}</span> '
1✔
865
            if pic := self.profile_picture():
1✔
866
                img = f'<img src="{pic}" class="profile"> '
1✔
867

868
        if handle:
1✔
869
            handle_str = self.handle_as(proto) or ''
1✔
870

871
        if name and self.name() != handle_str:
1✔
872
            name_str = self.name() or ''
1✔
873

874
        if handle_str and name_str:
1✔
875
            dot = ' &middot; '
1✔
876

877
        if url:
1✔
878
            a_open = f'<a class="h-card u-author" rel="me" href="{url}" title="{name_str}{dot}{handle_str}">'
1✔
879
            a_close = '</a>'
1✔
880

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

884
    def profile_picture(self):
1✔
885
        """Returns the user's profile picture image URL, if available, or None."""
886
        if self.obj and self.obj.as1:
1✔
887
            return util.get_url(self.obj.as1, 'image')
1✔
888

889
    # can't use functools.lru_cache here because we want the cache key to be
890
    # just the user id, not the whole entity
891
    @cachetools.cached(
1✔
892
        cachetools.TTLCache(50000, FOLLOWERS_CACHE_EXPIRATION.total_seconds()),
893
        key=lambda user: user.key.id(), lock=Lock())
894
    @memcache.memoize(key=lambda self: self.key.id(),
1✔
895
                      expire=FOLLOWERS_CACHE_EXPIRATION)
896
    def count_followers(self):
1✔
897
        """Counts this user's followers and followings.
898

899
        Returns:
900
          (int, int) tuple: (number of followers, number following)
901
        """
902
        if self.key.id() in PROTOCOL_DOMAINS:
1✔
903
            # we don't store Followers for protocol bot users any more, so
904
            # follower counts are inaccurate, so don't return them
905
            return (0, 0)
1✔
906

907
        num_followers = Follower.query(Follower.to == self.key,
1✔
908
                                       Follower.status == 'active')\
909
                                .count_async()
910
        num_following = Follower.query(Follower.from_ == self.key,
1✔
911
                                       Follower.status == 'active')\
912
                                .count_async()
913
        return num_followers.get_result(), num_following.get_result()
1✔
914

915

916
class Object(StringIdModel):
1✔
917
    """An activity or other object, eg actor.
918

919
    Key name is the id. We synthesize ids if necessary.
920
    """
921
    users = ndb.KeyProperty(repeated=True)
1✔
922
    'User(s) who created or otherwise own this object.'
1✔
923

924
    notify = ndb.KeyProperty(repeated=True)
1✔
925
    """User who should see this in their user page, eg in reply to, reaction to,
1✔
926
    share of, etc.
927
    """
928
    feed = ndb.KeyProperty(repeated=True)
1✔
929
    'User who should see this in their feeds, eg followers of its creator'
1✔
930

931
    source_protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()))
1✔
932
    """The protocol this object originally came from.
1✔
933

934
    ``choices`` is populated in :func:`reset_protocol_properties`, after all
935
    :class:`User` subclasses are created, so that :attr:`PROTOCOLS` is fully
936
    populated.
937

938
    TODO: nail down whether this is :attr:`ABBREV`` or :attr:`LABEL`
939
    """
940

941
    # TODO: switch back to ndb.JsonProperty if/when they fix it for the web console
942
    # https://github.com/googleapis/python-ndb/issues/874
943
    as2 = JsonProperty()
1✔
944
    'ActivityStreams 2, for ActivityPub'
1✔
945
    bsky = JsonProperty()
1✔
946
    'AT Protocol lexicon, for Bluesky'
1✔
947
    mf2 = JsonProperty()
1✔
948
    'HTML microformats2 item, (ie _not_ top level parse object with ``items`` field'
1✔
949
    our_as1 = JsonProperty()
1✔
950
    'ActivityStreams 1, for activities that we generate or modify ourselves'
1✔
951
    raw = JsonProperty()
1✔
952
    'Other standalone data format, eg DID document'
1✔
953

954
    # TODO: remove and actually delete Objects instead!
955
    deleted = ndb.BooleanProperty()
1✔
956
    ''
1✔
957

958
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
959
    """Copies of this object elsewhere, eg at:// URIs for ATProto records and
1✔
960
    nevent etc bech32-encoded Nostr ids, where this object is the original.
961
    Similar to u-syndication links in microformats2 and
962
    upstream/downstreamDuplicates in AS1.
963
    """
964

965
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
966
    ''
1✔
967
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
968
    ''
1✔
969

970
    new = None
1✔
971
    """True if this object is new, ie this is the first time we've seen it,
1✔
972
    False otherwise, None if we don't know.
973
    """
974
    changed = None
1✔
975
    """True if this object's contents have changed from our existing copy in the
1✔
976
    datastore, False otherwise, None if we don't know. :class:`Object` is
977
    new/changed. See :meth:`activity_changed()` for more details.
978
    """
979

980
    lock = None
1✔
981
    """Synchronizes :meth:`add` and :meth:`remove`."""
1✔
982

983
    # DEPRECATED
984
    # These were for full feeds with multiple items, not just this one, so they were
985
    # stored as audit records only, not used in to_as1. for Atom/RSS
986
    # based Objects, our_as1 was populated with an feed_index top-level
987
    # integer field that indexed into one of these.
988
    #
989
    # atom = ndb.TextProperty() # Atom XML
990
    # rss = ndb.TextProperty()  # RSS XML
991

992
    # DEPRECATED; these were for delivery tracking, but they were too expensive,
993
    # so we stopped: https://github.com/snarfed/bridgy-fed/issues/1501
994
    #
995
    # STATUSES = ('new', 'in progress', 'complete', 'failed', 'ignored')
996
    # status = ndb.StringProperty(choices=STATUSES)
997
    # delivered = ndb.StructuredProperty(Target, repeated=True)
998
    # undelivered = ndb.StructuredProperty(Target, repeated=True)
999
    # failed = ndb.StructuredProperty(Target, repeated=True)
1000

1001
    # DEPRECATED but still used read only to maintain backward compatibility
1002
    # with old Objects in the datastore that we haven't bothered migrating.
1003
    #
1004
    # domains = ndb.StringProperty(repeated=True)
1005

1006
    # DEPRECATED; replaced by :attr:`users`, :attr:`notify`, :attr:`feed`
1007
    #
1008
    # labels = ndb.StringProperty(repeated=True,
1009
    #                             choices=('activity', 'feed', 'notification', 'user'))
1010

1011
    @property
1✔
1012
    def as1(self):
1✔
1013
        def use_urls_as_ids(obj):
1✔
1014
            """If id field is missing or not a URL, use the url field."""
1015
            id = obj.get('id')
1✔
1016
            if not id or not (util.is_web(id) or re.match(DOMAIN_RE, id)):
1✔
1017
                if url := util.get_url(obj):
1✔
1018
                    obj['id'] = url
1✔
1019

1020
            for field in 'author', 'actor', 'object':
1✔
1021
                if inner := as1.get_object(obj, field):
1✔
1022
                    use_urls_as_ids(inner)
1✔
1023

1024
        if self.our_as1:
1✔
1025
            obj = self.our_as1
1✔
1026
            if self.source_protocol == 'web':
1✔
1027
                use_urls_as_ids(obj)
1✔
1028

1029
        elif self.as2:
1✔
1030
            obj = as2.to_as1(unwrap(self.as2))
1✔
1031

1032
        elif self.bsky:
1✔
1033
            owner, _, _ = parse_at_uri(self.key.id())
1✔
1034
            ATProto = PROTOCOLS['atproto']
1✔
1035
            handle = ATProto(id=owner).handle
1✔
1036
            try:
1✔
1037
                obj = bluesky.to_as1(self.bsky, repo_did=owner, repo_handle=handle,
1✔
1038
                                     uri=self.key.id(), pds=ATProto.pds_for(self))
1039
            except (ValueError, RequestException):
1✔
1040
                logger.info(f"Couldn't convert to ATProto", exc_info=True)
1✔
1041
                return None
1✔
1042

1043
        elif self.mf2:
1✔
1044
            obj = microformats2.json_to_object(self.mf2,
1✔
1045
                                               rel_urls=self.mf2.get('rel-urls'))
1046
            use_urls_as_ids(obj)
1✔
1047

1048
            # use fetched final URL as id, not u-url
1049
            # https://github.com/snarfed/bridgy-fed/issues/829
1050
            if url := self.mf2.get('url'):
1✔
1051
                obj['id'] = (self.key.id() if self.key and '#' in self.key.id()
1✔
1052
                             else url)
1053

1054
        else:
1055
            return None
1✔
1056

1057
        # populate id if necessary
1058
        if self.key:
1✔
1059
            obj.setdefault('id', self.key.id())
1✔
1060

1061
        return obj
1✔
1062

1063
    @ndb.ComputedProperty
1✔
1064
    def type(self):  # AS1 objectType, or verb if it's an activity
1✔
1065
        if self.as1:
1✔
1066
            return as1.object_type(self.as1)
1✔
1067

1068
    def __init__(self, *args, **kwargs):
1✔
1069
        super().__init__(*args, **kwargs)
1✔
1070
        self.lock = Lock()
1✔
1071

1072
    def _expire(self):
1✔
1073
        """Automatically delete most Objects after a while using a TTL policy.
1074

1075
        https://cloud.google.com/datastore/docs/ttl
1076

1077
        They recommend not indexing TTL properties:
1078
        https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
1079
        """
1080
        if self.deleted or self.type not in DONT_EXPIRE_OBJECT_TYPES:
1✔
1081
            return (self.updated or util.now()) + OBJECT_EXPIRE_AGE
1✔
1082

1083
    expire = ndb.ComputedProperty(_expire, indexed=False)
1✔
1084

1085
    def _pre_put_hook(self):
1✔
1086
        """
1087
        * Validate that at:// URIs have DID repos
1088
        * Set/remove the activity label
1089
        * Strip @context from as2 (we don't do LD) to save disk space
1090
        """
1091
        id = self.key.id()
1✔
1092

1093
        if self.source_protocol not in (None, 'ui'):
1✔
1094
            proto = PROTOCOLS[self.source_protocol]
1✔
1095
            assert proto.owns_id(id) is not False, \
1✔
1096
                f'Protocol {proto.LABEL} does not own id {id}'
1097

1098
        if id.startswith('at://'):
1✔
1099
            repo, _, _ = parse_at_uri(id)
1✔
1100
            if not repo.startswith('did:'):
1✔
1101
                # TODO: if we hit this, that means the AppView gave us an AT URI
1102
                # with a handle repo/authority instead of DID. that's surprising!
1103
                # ...if so, and if we need to handle it, add a new
1104
                # arroba.did.canonicalize_at_uri() function, then use it here,
1105
                # or before.
1106
                raise ValueError(
1✔
1107
                    f'at:// URI ids must have DID repos; got {id}')
1108

1109
        if self.as2:
1✔
1110
           self.as2.pop('@context', None)
1✔
1111
           for field in 'actor', 'attributedTo', 'author', 'object':
1✔
1112
               for val in util.get_list(self.as2, field):
1✔
1113
                   if isinstance(val, dict):
1✔
1114
                       val.pop('@context', None)
1✔
1115

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

1120
    @classmethod
1✔
1121
    def get_by_id(cls, id, authed_as=None, **kwargs):
1✔
1122
        """Fetches the :class:`Object` with the given id, if it exists.
1123

1124
        Args:
1125
          id (str)
1126
          authed_as (str): optional; if provided, and a matching :class:`Object`
1127
            already exists, its ``author`` or ``actor`` must contain this actor
1128
            id. Implements basic authorization for updates and deletes.
1129

1130
        Returns:
1131
          Object:
1132

1133
        Raises:
1134
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1135
            the existing object
1136
        """
1137
        obj = super().get_by_id(id, **kwargs)
1✔
1138

1139
        if obj and obj.as1 and authed_as:
1✔
1140
            # authorization: check that the authed user is allowed to modify
1141
            # this object
1142
            # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1143
            proto = PROTOCOLS.get(obj.source_protocol)
1✔
1144
            assert proto, obj.source_protocol
1✔
1145
            owners = [ids.normalize_user_id(id=owner, proto=proto)
1✔
1146
                      for owner in (as1.get_ids(obj.as1, 'author')
1147
                                    + as1.get_ids(obj.as1, 'actor'))
1148
                                    + [id]]
1149
            if (ids.normalize_user_id(id=authed_as, proto=proto) not in owners
1✔
1150
                    and ids.profile_id(id=authed_as, proto=proto) not in owners):
1151
                report_error("Auth: Object: authed_as doesn't match owner",
1✔
1152
                             user=f'{id} authed_as {authed_as} owners {owners}')
1153
                error(f"authed user {authed_as} isn't object owner {owners}",
1✔
1154
                      status=403)
1155

1156
        return obj
1✔
1157

1158
    @classmethod
1✔
1159
    def get_or_create(cls, id, authed_as=None, **props):
1✔
1160
        """Returns an :class:`Object` with the given property values.
1161

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

1166
        Not transactional because transactions don't read or write memcache. :/
1167
        Fortunately we don't really depend on atomicity for anything, last
1168
        writer wins is pretty much always fine.
1169

1170
        Args:
1171
          authed_as (str): optional; if provided, and a matching :class:`Object`
1172
            already exists, its ``author`` or ``actor`` must contain this actor
1173
            id. Implements basic authorization for updates and deletes.
1174

1175
        Returns:
1176
          Object:
1177

1178
        Raises:
1179
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1180
            the existing object
1181
        """
1182
        obj = cls.get_by_id(id, authed_as=authed_as)
1✔
1183

1184
        if not obj:
1✔
1185
            obj = Object(id=id, **props)
1✔
1186
            obj.new = True
1✔
1187
            obj.changed = False
1✔
1188
            obj.put()
1✔
1189
            return obj
1✔
1190

1191
        if orig_as1 := obj.as1:
1✔
1192
            # get_by_id() checks authorization if authed_as is set. make sure
1193
            # it's always set for existing objects.
1194
            assert authed_as
1✔
1195

1196
        dirty = False
1✔
1197
        for prop, val in props.items():
1✔
1198
            assert not isinstance(getattr(Object, prop), ndb.ComputedProperty)
1✔
1199
            if prop in ('feed', 'copies', 'notify', 'users'):
1✔
1200
                # merge repeated fields
1201
                for elem in val:
1✔
1202
                    if obj.add(prop, elem):
1✔
1203
                        dirty = True
1✔
1204
            elif val and val != getattr(obj, prop):
1✔
1205
                setattr(obj, prop, val)
1✔
1206
                if prop in ('as2', 'bsky', 'mf2', 'raw') and not props.get('our_as1'):
1✔
1207
                    obj.our_as1 = None
1✔
1208
                dirty = True
1✔
1209

1210
        obj.new = False
1✔
1211
        obj.changed = obj.activity_changed(orig_as1)
1✔
1212
        if dirty:
1✔
1213
            obj.put()
1✔
1214
        return obj
1✔
1215

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

1219
        Args:
1220
          prop (str)
1221
          val
1222

1223
        Returns:
1224
          True if val was added, ie it wasn't already in prop, False otherwise
1225
        """
1226
        with self.lock:
1✔
1227
            added = util.add(getattr(self, prop), val)
1✔
1228

1229
        if prop == 'copies' and added:
1✔
1230
            memcache.pickle_memcache.set(memcache.memoize_key(
1✔
1231
                get_original_object_key, val.uri), self.key)
1232

1233
        return added
1✔
1234

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

1238
        Args:
1239
          prop (str)
1240
          val
1241
        """
1242
        with self.lock:
×
1243
            getattr(self, prop).remove(val)
×
1244

1245
    @staticmethod
1✔
1246
    def from_request():
1✔
1247
        """Creates and returns an :class:`Object` from form-encoded JSON parameters.
1248

1249
        Parameters:
1250
          obj_id (str): id of :class:`models.Object` to handle
1251
          *: If ``obj_id`` is unset, all other parameters are properties for a
1252
            new :class:`models.Object` to handle
1253
        """
1254
        if obj_id := request.form.get('obj_id'):
1✔
1255
            return Object.get_by_id(obj_id)
1✔
1256

1257
        props = {field: request.form.get(field)
1✔
1258
                 for field in ('id', 'source_protocol')}
1259

1260
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1261
            if val := request.form.get(json_prop):
1✔
1262
                props[json_prop] = json_loads(val)
1✔
1263

1264
        obj = Object(**props)
1✔
1265
        if not obj.key and obj.as1:
1✔
1266
            if id := obj.as1.get('id'):
1✔
1267
                obj.key = ndb.Key(Object, id)
1✔
1268

1269
        return obj
1✔
1270

1271
    def to_request(self):
1✔
1272
        """Returns a query parameter dict representing this :class:`Object`."""
1273
        form = {}
1✔
1274

1275
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1276
            if val := getattr(self, json_prop, None):
1✔
1277
                form[json_prop] = json_dumps(val, sort_keys=True)
1✔
1278

1279
        for prop in ['source_protocol']:
1✔
1280
            if val := getattr(self, prop):
1✔
1281
                form[prop] = val
1✔
1282

1283
        if self.key:
1✔
1284
            form['id'] = self.key.id()
1✔
1285

1286
        return form
1✔
1287

1288
    def activity_changed(self, other_as1):
1✔
1289
        """Returns True if this activity is meaningfully changed from ``other_as1``.
1290

1291
        ...otherwise False.
1292

1293
        Used to populate :attr:`changed`.
1294

1295
        Args:
1296
          other_as1 (dict): AS1 object, or none
1297
        """
1298
        # ignore inReplyTo since we translate it between protocols
1299
        return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
1✔
1300
                if self.as1 and other_as1
1301
                else bool(self.as1) != bool(other_as1))
1302

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

1306
        TODO: unify with :meth:`User.user_link`?
1307

1308
        Args:
1309
          image (bool): whether to include an ``img`` tag with the actor's picture
1310
          sized (bool): whether to set an explicit (``width=32``) size on the
1311
            profile picture ``img`` tag
1312
          user (User): current user
1313

1314
        Returns:
1315
          str:
1316
        """
1317
        attrs = {'class': 'h-card u-author'}
1✔
1318

1319
        if user and user.key in self.users:
1✔
1320
            # outbound; show a nice link to the user
1321
            return user.user_link(handle=False, pictures=True)
1✔
1322

1323
        proto = PROTOCOLS.get(self.source_protocol)
1✔
1324

1325
        actor = None
1✔
1326
        if self.as1:
1✔
1327
            actor = (as1.get_object(self.as1, 'actor')
1✔
1328
                     or as1.get_object(self.as1, 'author'))
1329
            # hydrate from datastore if available
1330
            # TODO: optimize! this is called serially in loops, eg in home.html
1331
            if set(actor.keys()) == {'id'} and self.source_protocol:
1✔
1332
                actor_obj = proto.load(actor['id'], remote=False)
1✔
1333
                if actor_obj and actor_obj.as1:
1✔
1334
                    actor = actor_obj.as1
1✔
1335

1336
        if not actor:
1✔
1337
            return ''
1✔
1338
        elif set(actor.keys()) == {'id'}:
1✔
1339
            return common.pretty_link(actor['id'], attrs=attrs, user=user)
1✔
1340

1341
        url = as1.get_url(actor)
1✔
1342
        name = actor.get('displayName') or actor.get('username') or ''
1✔
1343
        img_url = util.get_url(actor, 'image')
1✔
1344
        if not image or not img_url:
1✔
1345
            return common.pretty_link(url, text=name, attrs=attrs, user=user)
1✔
1346

1347
        logo = ''
1✔
1348
        if proto:
1✔
1349
            logo = f'<span class="logo" title="{self.__class__.__name__}">{proto.LOGO_HTML}</span>'
×
1350

1351
        return f"""\
1✔
1352
        {logo}
1353
        <a class="h-card u-author" href="{url}" title="{name}">
1354
          <img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
1355
          <span style="unicode-bidi: isolate">{util.ellipsize(name, chars=40)}</span>
1356
        </a>"""
1357

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

1361
        ...or None if no such copy exists. If ``proto`` is ``source_protocol``,
1362
        returns this object's key id.
1363

1364
        Args:
1365
          proto: :class:`Protocol` subclass
1366

1367
        Returns:
1368
          str:
1369
        """
1370
        if self.source_protocol in (proto.LABEL, proto.ABBREV):
1✔
1371
            return self.key.id()
1✔
1372

1373
        for copy in self.copies:
1✔
1374
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
1375
                return copy.uri
1✔
1376

1377
    def resolve_ids(self):
1✔
1378
        """Resolves "copy" ids, subdomain ids, etc with their originals.
1379

1380
        The end result is that all ids are original "source" ids, ie in the
1381
        protocol that they first came from.
1382

1383
        Specifically, resolves:
1384

1385
        * ids in :class:`User.copies` and :class:`Object.copies`, eg ATProto
1386
          records and Nostr events that we bridged, to the ids of their
1387
          original objects in their source protocol, eg
1388
          ``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
1389
        * Bridgy Fed subdomain URLs to the ids embedded inside them, eg
1390
          ``https://bsky.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
1391
        * ATProto bsky.app URLs to their DIDs or `at://` URIs, eg
1392
          ``https://bsky.app/profile/a.com`` => ``did:plc:123``
1393

1394
        ...in these AS1 fields, in place:
1395

1396
        * ``id``
1397
        * ``actor``
1398
        * ``author``
1399
        * ``object``
1400
        * ``object.actor``
1401
        * ``object.author``
1402
        * ``object.id``
1403
        * ``object.inReplyTo``
1404
        * ``tags.[objectType=mention].url``
1405

1406
        :meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
1407
        Much of the same logic is duplicated there!
1408

1409
        TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
1410
        """
1411
        if not self.as1:
1✔
1412
            return
1✔
1413

1414
        # extract ids, strip Bridgy Fed subdomain URLs
1415
        outer_obj = unwrap(self.as1)
1✔
1416
        if outer_obj != self.as1:
1✔
1417
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1418

1419
        self_proto = PROTOCOLS.get(self.source_protocol)
1✔
1420
        if not self_proto:
1✔
1421
            return
1✔
1422

1423
        inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
1✔
1424
        replaced = False
1✔
1425

1426
        def replace(val, orig_fn):
1✔
1427
            id = val.get('id') if isinstance(val, dict) else val
1✔
1428
            if not id or not self_proto.HAS_COPIES:
1✔
1429
                return id
1✔
1430

1431
            orig = orig_fn(id)
1✔
1432
            if not orig:
1✔
1433
                return val
1✔
1434

1435
            nonlocal replaced
1436
            replaced = True
1✔
1437
            logger.debug(f'Resolved copy id {val} to original {orig.id()}')
1✔
1438

1439
            if isinstance(val, dict) and util.trim_nulls(val).keys() > {'id'}:
1✔
1440
                val['id'] = orig.id()
1✔
1441
                return val
1✔
1442
            else:
1443
                return orig.id()
1✔
1444

1445
        # actually replace ids
1446
        #
1447
        # object field could be either object (eg repost) or actor (eg follow)
1448
        outer_obj['object'] = replace(inner_obj, get_original_object_key)
1✔
1449
        if not replaced:
1✔
1450
            outer_obj['object'] = replace(inner_obj, get_original_user_key)
1✔
1451

1452
        for obj in outer_obj, inner_obj:
1✔
1453
            for tag in as1.get_objects(obj, 'tags'):
1✔
1454
                if tag.get('objectType') == 'mention':
1✔
1455
                    tag['url'] = replace(tag.get('url'), get_original_user_key)
1✔
1456
            for field, fn in (
1✔
1457
                    ('actor', get_original_user_key),
1458
                    ('author', get_original_user_key),
1459
                    ('inReplyTo', get_original_object_key),
1460
                ):
1461
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1462
                if len(obj[field]) == 1:
1✔
1463
                    obj[field] = obj[field][0]
1✔
1464

1465
        if replaced:
1✔
1466
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1467

1468
    def normalize_ids(self):
1✔
1469
        """Normalizes ids to their protocol's canonical representation, if any.
1470

1471
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
1472
        for profiles, ``at://`` URIs for posts.
1473

1474
        Modifies this object in place.
1475

1476
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
1477
        """
1478
        from protocol import Protocol
1✔
1479

1480
        if not self.as1:
1✔
1481
            return
1✔
1482

1483
        logger.debug(f'Normalizing ids')
1✔
1484
        outer_obj = copy.deepcopy(self.as1)
1✔
1485
        inner_objs = as1.get_objects(outer_obj)
1✔
1486
        replaced = False
1✔
1487

1488
        def replace(val, translate_fn):
1✔
1489
            nonlocal replaced
1490

1491
            orig = val.get('id') if isinstance(val, dict) else val
1✔
1492
            if not orig:
1✔
1493
                return val
1✔
1494

1495
            proto = Protocol.for_id(orig, remote=False)
1✔
1496
            if not proto:
1✔
1497
                return val
1✔
1498

1499
            translated = translate_fn(id=orig, from_=proto, to=proto)
1✔
1500
            if translated and translated != orig:
1✔
1501
                # logger.debug(f'Normalized {proto.LABEL} id {orig} to {translated}')
1502
                replaced = True
1✔
1503
                if isinstance(val, dict):
1✔
1504
                    val['id'] = translated
1✔
1505
                    return val
1✔
1506
                else:
1507
                    return translated
1✔
1508

1509
            return val
1✔
1510

1511
        # actually replace ids
1512
        for obj in [outer_obj] + inner_objs:
1✔
1513
            for tag in as1.get_objects(obj, 'tags'):
1✔
1514
                if tag.get('objectType') == 'mention':
1✔
1515
                    tag['url'] = replace(tag.get('url'), ids.translate_user_id)
1✔
1516
            for field in ['actor', 'author', 'inReplyTo']:
1✔
1517
                fn = (ids.translate_object_id if field == 'inReplyTo'
1✔
1518
                      else ids.translate_user_id)
1519
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1520
                if len(obj[field]) == 1:
1✔
1521
                    obj[field] = obj[field][0]
1✔
1522

1523
        outer_obj['object'] = []
1✔
1524
        for inner_obj in inner_objs:
1✔
1525
            translate_fn = (ids.translate_user_id
1✔
1526
                            if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
1527
                                or as1.object_type(outer_obj) in
1528
                                ('follow', 'stop-following'))
1529
                            else ids.translate_object_id)
1530

1531
            got = replace(inner_obj, translate_fn)
1✔
1532
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
1533
                got = got['id']
1✔
1534

1535
            outer_obj['object'].append(got)
1✔
1536

1537
        if len(outer_obj['object']) == 1:
1✔
1538
            outer_obj['object'] = outer_obj['object'][0]
1✔
1539

1540
        if replaced:
1✔
1541
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1542

1543

1544
class Follower(ndb.Model):
1✔
1545
    """A follower of a Bridgy Fed user."""
1546
    STATUSES = ('active', 'inactive')
1✔
1547

1548
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
1549
    """The follower."""
1✔
1550
    to = ndb.KeyProperty(required=True)
1✔
1551
    """The followee, ie the user being followed."""
1✔
1552

1553
    follow = ndb.KeyProperty(Object)
1✔
1554
    """The last follow activity."""
1✔
1555
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
1556
    """Whether this follow is active or note."""
1✔
1557

1558
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1559
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1560

1561
    # OLD. some stored entities still have these; do not reuse.
1562
    # src = ndb.StringProperty()
1563
    # dest = ndb.StringProperty()
1564
    # last_follow = JsonProperty()
1565

1566
    def _pre_put_hook(self):
1✔
1567
        # we're a bridge! stick with bridging.
1568
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
1569

1570
    def _post_put_hook(self, future):
1✔
1571
        logger.debug(f'Wrote {self.key}')
1✔
1572

1573
    @classmethod
1✔
1574
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
1575
        """Returns a Follower with the given ``from_`` and ``to`` users.
1576

1577
        Not transactional because transactions don't read or write memcache. :/
1578
        Fortunately we don't really depend on atomicity for anything, last
1579
        writer wins is pretty much always fine.
1580

1581
        If a matching :class:`Follower` doesn't exist in the datastore, creates
1582
        it first.
1583

1584
        Args:
1585
          from_ (User)
1586
          to (User)
1587

1588
        Returns:
1589
          Follower:
1590
        """
1591
        assert from_
1✔
1592
        assert to
1✔
1593

1594
        follower = Follower.query(Follower.from_ == from_.key,
1✔
1595
                                  Follower.to == to.key,
1596
                                  ).get()
1597
        if not follower:
1✔
1598
            follower = Follower(from_=from_.key, to=to.key, **kwargs)
1✔
1599
            follower.put()
1✔
1600
        elif kwargs:
1✔
1601
            # update existing entity with new property values, eg to make an
1602
            # inactive Follower active again
1603
            for prop, val in kwargs.items():
1✔
1604
                setattr(follower, prop, val)
1✔
1605
            follower.put()
1✔
1606

1607
        return follower
1✔
1608

1609
    @staticmethod
1✔
1610
    def fetch_page(collection, user):
1✔
1611
        r"""Fetches a page of :class:`Follower`\s for a given user.
1612

1613
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
1614
        parameters, if available in the request.
1615

1616
        Args:
1617
          collection (str): ``followers`` or ``following``
1618
          user (User)
1619

1620
        Returns:
1621
          (list of Follower, str, str) tuple: results, annotated with an extra
1622
          ``user`` attribute that holds the follower or following :class:`User`,
1623
          and new str query param values for ``before`` and ``after`` to fetch
1624
          the previous and next pages, respectively
1625
        """
1626
        assert collection in ('followers', 'following'), collection
1✔
1627

1628
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1629
        query = Follower.query(
1✔
1630
            Follower.status == 'active',
1631
            filter_prop == user.key,
1632
        )
1633

1634
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
1635
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
1636
                              for f in followers)
1637
        User.load_multi(u for u in users if u)
1✔
1638

1639
        for f, u in zip(followers, users):
1✔
1640
            f.user = u
1✔
1641
        followers = [f for f in followers if not f.user.status]
1✔
1642

1643
        return followers, before, after
1✔
1644

1645

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

1649
    Wraps :func:`fetch_page` and adds attributes to the returned
1650
    :class:`Object` entities for rendering in ``objects.html``.
1651

1652
    Args:
1653
      query (ndb.Query)
1654
      by (ndb.model.Property): either :attr:`Object.updated` or
1655
        :attr:`Object.created`
1656
      user (User): current user
1657

1658
    Returns:
1659
      (list of Object, str, str) tuple:
1660
      (results, new ``before`` query param, new ``after`` query param)
1661
      to fetch the previous and next pages, respectively
1662
    """
1663
    assert by is Object.updated or by is Object.created
1✔
1664
    objects, new_before, new_after = fetch_page(query, Object, by=by)
1✔
1665
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
1666

1667
    # synthesize human-friendly content for objects
1668
    for i, obj in enumerate(objects):
1✔
1669
        obj_as1 = obj.as1
1✔
1670
        type = as1.object_type(obj_as1)
1✔
1671

1672
        # AS1 verb => human-readable phrase
1673
        phrases = {
1✔
1674
            'accept': 'accepted',
1675
            'article': 'posted',
1676
            'comment': 'replied',
1677
            'delete': 'deleted',
1678
            'follow': 'followed',
1679
            'invite': 'is invited to',
1680
            'issue': 'filed issue',
1681
            'like': 'liked',
1682
            'note': 'posted',
1683
            'post': 'posted',
1684
            'repost': 'reposted',
1685
            'rsvp-interested': 'is interested in',
1686
            'rsvp-maybe': 'might attend',
1687
            'rsvp-no': 'is not attending',
1688
            'rsvp-yes': 'is attending',
1689
            'share': 'reposted',
1690
            'stop-following': 'unfollowed',
1691
            'undo': 'undid',
1692
            'update': 'updated',
1693
        }
1694
        phrases.update({type: 'profile refreshed:' for type in as1.ACTOR_TYPES})
1✔
1695

1696
        obj.phrase = phrases.get(type, '')
1✔
1697

1698
        content = (obj_as1.get('content')
1✔
1699
                   or obj_as1.get('displayName')
1700
                   or obj_as1.get('summary'))
1701
        if content:
1✔
1702
            content = util.parse_html(content).get_text()
1✔
1703

1704
        urls = as1.object_urls(obj_as1)
1✔
1705
        url = urls[0] if urls else None
1✔
1706
        if url and not content:
1✔
1707
            # heuristics for sniffing URLs and converting them to more friendly
1708
            # phrases and user handles.
1709
            # TODO: standardize this into granary.as2 somewhere?
1710
            from activitypub import FEDI_URL_RE
×
1711
            from atproto import COLLECTION_TO_TYPE, did_to_handle
×
1712

1713
            handle = suffix = ''
×
1714
            if match := FEDI_URL_RE.match(url):
×
1715
                handle = match.group(2)
×
1716
                if match.group(4):
×
1717
                    suffix = "'s post"
×
1718
            elif match := BSKY_APP_URL_RE.match(url):
×
1719
                handle = match.group('id')
×
1720
                if match.group('tid'):
×
1721
                    suffix = "'s post"
×
1722
            elif match := AT_URI_PATTERN.match(url):
×
1723
                handle = match.group('repo')
×
1724
                if coll := match.group('collection'):
×
1725
                    suffix = f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
×
1726
                url = bluesky.at_uri_to_web_url(url)
×
1727
            elif url.startswith('did:'):
×
1728
                handle = url
×
1729
                url = bluesky.Bluesky.user_url(handle)
×
1730

1731
            if handle:
×
1732
                if handle.startswith('did:'):
×
1733
                    handle = did_to_handle(handle) or handle
×
1734
                content = f'@{handle}{suffix}'
×
1735

1736
            if url:
×
1737
                content = common.pretty_link(url, text=content, user=user)
×
1738

1739
        obj.content = (obj_as1.get('content')
1✔
1740
                       or obj_as1.get('displayName')
1741
                       or obj_as1.get('summary'))
1742
        obj.url = util.get_first(obj_as1, 'url')
1✔
1743

1744
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
1745
            inner_as1 = as1.get_object(obj_as1)
1✔
1746
            obj.inner_url = inner_as1.get('url') or inner_as1.get('id')
1✔
1747
            if obj.url:
1✔
UNCOV
1748
                obj.phrase = common.pretty_link(
×
1749
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
1750
            if content:
1✔
1751
                obj.content = content
1✔
1752
                obj.url = url
1✔
1753

1754
    return objects, new_before, new_after
1✔
1755

1756

1757
def fetch_page(query, model_class, by=None):
1✔
1758
    """Fetches a page of results from a datastore query.
1759

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

1763
    Populates a ``log_url_path`` property on each result entity that points to a
1764
    its most recent logged request.
1765

1766
    Args:
1767
      query (google.cloud.ndb.query.Query)
1768
      model_class (class)
1769
      by (ndb.model.Property): paging property, eg :attr:`Object.updated`
1770
        or :attr:`Object.created`
1771

1772
    Returns:
1773
      (list of Object or Follower, str, str) tuple: (results, new_before,
1774
      new_after), where new_before and new_after are query param values for
1775
      ``before`` and ``after`` to fetch the previous and next pages,
1776
      respectively
1777
    """
1778
    assert by
1✔
1779

1780
    # if there's a paging param ('before' or 'after'), update query with it
1781
    # TODO: unify this with Bridgy's user page
1782
    def get_paging_param(param):
1✔
1783
        val = request.values.get(param)
1✔
1784
        if val:
1✔
1785
            try:
1✔
1786
                dt = util.parse_iso8601(val.replace(' ', '+'))
1✔
1787
            except BaseException as e:
1✔
1788
                error(f"Couldn't parse {param}, {val!r} as ISO8601: {e}")
1✔
1789
            if dt.tzinfo:
1✔
1790
                dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
1✔
1791
            return dt
1✔
1792

1793
    before = get_paging_param('before')
1✔
1794
    after = get_paging_param('after')
1✔
1795
    if before and after:
1✔
UNCOV
1796
        error("can't handle both before and after")
×
1797
    elif after:
1✔
1798
        query = query.filter(by >= after).order(by)
1✔
1799
    elif before:
1✔
1800
        query = query.filter(by < before).order(-by)
1✔
1801
    else:
1802
        query = query.order(-by)
1✔
1803

1804
    query_iter = query.iter()
1✔
1805
    results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
1✔
1806
                     key=lambda r: r.updated, reverse=True)
1807

1808
    # calculate new paging param(s)
1809
    has_next = results and query_iter.probably_has_next()
1✔
1810
    new_after = (
1✔
1811
        before if before
1812
        else results[0].updated if has_next and after
1813
        else None)
1814
    if new_after:
1✔
1815
        new_after = new_after.isoformat()
1✔
1816

1817
    new_before = (
1✔
1818
        after if after else
1819
        results[-1].updated if has_next
1820
        else None)
1821
    if new_before:
1✔
1822
        new_before = new_before.isoformat()
1✔
1823

1824
    return results, new_before, new_after
1✔
1825

1826

1827
@lru_cache(maxsize=100000)
1✔
1828
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
1829
def get_original_object_key(copy_id):
1✔
1830
    """Finds the :class:`Object` with a given copy id, if any.
1831

1832
    Note that :meth:`Object.add` also updates this function's
1833
    :func:`memcache.memoize` cache.
1834

1835
    Args:
1836
      copy_id (str)
1837

1838
    Returns:
1839
      google.cloud.ndb.Key or None
1840
    """
1841
    assert copy_id
1✔
1842

1843
    return Object.query(Object.copies.uri == copy_id).get(keys_only=True)
1✔
1844

1845

1846
@lru_cache(maxsize=100000)
1✔
1847
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
1848
def get_original_user_key(copy_id):
1✔
1849
    """Finds the user with a given copy id, if any.
1850

1851
    Note that :meth:`User.add` also updates this function's
1852
    :func:`memcache.memoize` cache.
1853

1854
    Args:
1855
      copy_id (str)
1856
      not_proto (Protocol): optional, don't query this protocol
1857

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

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