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

snarfed / bridgy-fed / 57b9c0ae-5882-4bb6-af5f-8ba18e451628

31 Jan 2025 12:33PM UTC coverage: 93.228% (+0.001%) from 93.227%
57b9c0ae-5882-4bb6-af5f-8ba18e451628

push

circleci

github-actions[bot]
build(deps): bump certifi from 2024.12.14 to 2025.1.31

Bumps [certifi](https://github.com/certifi/python-certifi) from 2024.12.14 to 2025.1.31.
- [Commits](https://github.com/certifi/python-certifi/compare/2024.12.14...2025.01.31)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

4543 of 4873 relevant lines covered (93.23%)

0.93 hits per line

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

95.84
/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
from oauth_dropins.webutil import util
1✔
22
from oauth_dropins.webutil.appengine_info import DEBUG
1✔
23
from oauth_dropins.webutil.flask_util import error
1✔
24
from oauth_dropins.webutil.models import JsonProperty, StringIdModel
1✔
25
from oauth_dropins.webutil.util import ellipsize, json_dumps, json_loads
1✔
26
from requests import RequestException
1✔
27

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

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

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

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
logger = logging.getLogger(__name__)
1✔
85

86

87
class Target(ndb.Model):
1✔
88
    r""":class:`protocol.Protocol` + URI pairs for identifying objects.
89

90
    These are currently used for:
91

92
    * delivery destinations, eg ActivityPub inboxes, webmention targets, etc.
93
    * copies of :class:`Object`\s and :class:`User`\s elsewhere,
94
      eg ``at://`` URIs for ATProto records, nevent etc bech32-encoded Nostr ids,
95
      ATProto user DIDs, etc.
96

97
    Used in :class:`google.cloud.ndb.model.StructuredProperty`\s inside
98
    :class:`Object` and :class:`User`; not stored as top-level entities in the
99
    datastore.
100

101
    ndb implements this by hoisting each property here into a corresponding
102
    property on the parent entity, prefixed by the StructuredProperty name
103
    below, eg ``delivered.uri``, ``delivered.protocol``, etc.
104

105
    For repeated StructuredPropertys, the hoisted properties are all repeated on
106
    the parent entity, and reconstructed into StructuredPropertys based on their
107
    order.
108

109
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
110
    """
111
    uri = ndb.StringProperty(required=True)
1✔
112
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
113

114
    def __eq__(self, other):
1✔
115
        """Equality excludes Targets' :class:`Key`."""
116
        return self.uri == other.uri and self.protocol == other.protocol
1✔
117

118
    def __hash__(self):
1✔
119
        """Allow hashing so these can be dict keys."""
120
        return hash((self.protocol, self.uri))
1✔
121

122

123
class DM(ndb.Model):
1✔
124
    """:class:`protocol.Protocol` + type pairs for identifying sent DMs.
125

126
    Used in :attr:`User.sent_dms`.
127

128
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
129
    """
130
    TYPES = (
1✔
131
        'request_bridging',
132
        'replied_to_bridged_user',
133
        'welcome',
134
    )
135
    type = ndb.StringProperty(choices=TYPES, required=True)
1✔
136
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
137

138
    def __eq__(self, other):
1✔
139
        """Equality excludes Targets' :class:`Key`."""
140
        return self.type == other.type and self.protocol == other.protocol
1✔
141

142

143
class ProtocolUserMeta(type(ndb.Model)):
1✔
144
    """:class:`User` metaclass. Registers all subclasses in the ``PROTOCOLS`` global."""
145
    def __new__(meta, name, bases, class_dict):
1✔
146
        cls = super().__new__(meta, name, bases, class_dict)
1✔
147

148
        if hasattr(cls, 'LABEL') and cls.LABEL not in ('protocol', 'user'):
1✔
149
            for label in (cls.LABEL, cls.ABBREV) + cls.OTHER_LABELS:
1✔
150
                if label:
1✔
151
                    PROTOCOLS[label] = cls
1✔
152

153
        PROTOCOLS_BY_KIND[cls._get_kind()] = cls
1✔
154

155
        return cls
1✔
156

157

158
def reset_protocol_properties():
1✔
159
    """Recreates various protocol properties to include choices from ``PROTOCOLS``."""
160
    abbrevs = f'({"|".join(PROTOCOLS.keys())}|fed)'
1✔
161
    common.SUBDOMAIN_BASE_URL_RE = re.compile(
1✔
162
        rf'^https?://({abbrevs}\.brid\.gy|localhost(:8080)?)/(convert/|r/)?({abbrevs}/)?(?P<path>.+)')
163
    ids.COPIES_PROTOCOLS = tuple(label for label, proto in PROTOCOLS.items()
1✔
164
                                 if proto and proto.HAS_COPIES)
165

166

167
class User(StringIdModel, metaclass=ProtocolUserMeta):
1✔
168
    """Abstract base class for a Bridgy Fed user.
169

170
    Stores some protocols' keypairs. Currently:
171

172
    * RSA keypair for ActivityPub HTTP Signatures
173
      properties: ``mod``, ``public_exponent``, ``private_exponent``, all
174
      encoded as base64url (ie URL-safe base64) strings as described in RFC
175
      4648 and section 5.1 of the Magic Signatures spec:
176
      https://tools.ietf.org/html/draft-cavage-http-signatures-12
177
    * *Not* K-256 signing or rotation keys for AT Protocol, those are stored in
178
      :class:`arroba.datastore_storage.AtpRepo` entities
179
    """
180
    obj_key = ndb.KeyProperty(kind='Object')  # user profile
1✔
181
    mod = ndb.StringProperty()
1✔
182
    use_instead = ndb.KeyProperty()
1✔
183

184
    # Proxy copies of this user elsewhere, eg DIDs for ATProto records, bech32
185
    # npub Nostr ids, etc. Similar to rel-me links in microformats2, alsoKnownAs
186
    # in DID docs (and now AS2), etc.
187
    # TODO: switch to using Object.copies on the user profile object?
188
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
189

190
    # these are for ActivityPub HTTP Signatures
191
    public_exponent = ndb.StringProperty()
1✔
192
    private_exponent = ndb.StringProperty()
1✔
193

194
    # set to True for users who asked me to be opted out instead of putting
195
    # #nobridge in their profile
196
    manual_opt_out = ndb.BooleanProperty()
1✔
197

198
    # protocols that this user has explicitly opted into. protocols that don't
199
    # require explicit opt in are omitted here. choices is populated in
200
    # reset_protocol_properties.
201
    enabled_protocols = ndb.StringProperty(repeated=True, choices=list(PROTOCOLS.keys()))
1✔
202

203
    # DMs that we've attempted to send to this user
204
    sent_dms = ndb.StructuredProperty(DM, repeated=True)
1✔
205

206
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
207
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
208

209
    # `existing` attr is set by get_or_create
210

211
    # OLD. some stored entities still have these; do not reuse.
212
    # direct = ndb.BooleanProperty(default=False)
213
    # actor_as2 = JsonProperty()
214
    # protocol-specific state
215
    # atproto_notifs_indexed_at = ndb.TextProperty()
216
    # atproto_feed_indexed_at = ndb.TextProperty()
217

218
    def __init__(self, **kwargs):
1✔
219
        """Constructor.
220

221
        Sets :attr:`obj` explicitly because however
222
        :class:`google.cloud.ndb.model.Model` sets it doesn't work with
223
        ``@property`` and ``@obj.setter`` below.
224
        """
225
        obj = kwargs.pop('obj', None)
1✔
226
        super().__init__(**kwargs)
1✔
227

228
        if obj:
1✔
229
            self.obj = obj
1✔
230

231
        self.lock = Lock()
1✔
232

233
    @classmethod
1✔
234
    def new(cls, **kwargs):
1✔
235
        """Try to prevent instantiation. Use subclasses instead."""
236
        raise NotImplementedError()
×
237

238
    def _post_put_hook(self, future):
1✔
239
        logger.debug(f'Wrote {self.key}')
1✔
240

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

244
        Args:
245
          prop (str)
246
          val
247
        """
248
        with self.lock:
1✔
249
            added = util.add(getattr(self, prop), val)
1✔
250

251
        if prop == 'copies' and added:
1✔
252
            memcache.pickle_memcache.set(memcache.memoize_key(
1✔
253
                get_original_user_key, val.uri), self.key)
254

255
    @classmethod
1✔
256
    def get_by_id(cls, id, allow_opt_out=False, **kwargs):
1✔
257
        """Override to follow ``use_instead`` property and ``opt-out`` status.
258

259
        Returns None if the user is opted out.
260
        """
261
        user = cls._get_by_id(id, **kwargs)
1✔
262
        if user and user.use_instead:
1✔
263
            logger.info(f'{user.key} use_instead => {user.use_instead}')
1✔
264
            user = user.use_instead.get()
1✔
265

266
        if not user:
1✔
267
            return None
1✔
268

269
        if user.status and not allow_opt_out:
1✔
270
            logger.info(f'{user.key} is {user.status}')
1✔
271
            return None
1✔
272

273
        return user
1✔
274

275
    @classmethod
1✔
276
    def get_or_create(cls, id, propagate=False, allow_opt_out=False,
1✔
277
                      reload=False, **kwargs):
278
        """Loads and returns a :class:`User`. Creates it if necessary.
279

280
        Not transactional because transactions don't read or write memcache. :/
281
        Fortunately we don't really depend on atomicity for anything, last
282
        writer wins is pretty much always fine.
283

284
        Args:
285
          propagate (bool): whether to create copies of this user in push-based
286
            protocols, eg ATProto and Nostr.
287
          allow_opt_out (bool): whether to allow and create the user if they're
288
            currently opted out
289
          reload (bool): whether to reload profile always, vs only if necessary
290
          kwargs: passed through to ``cls`` constructor
291

292
        Returns:
293
          User: existing or new user, or None if the user is opted out
294
        """
295
        assert cls != User
1✔
296

297
        user = cls.get_by_id(id, allow_opt_out=True)
1✔
298
        if user:
1✔
299
            if reload:
1✔
300
                user.reload_profile(gateway=True, raise_=False)
1✔
301

302
            if user.status and not allow_opt_out:
1✔
303
                return None
1✔
304
            user.existing = True
1✔
305

306
            # TODO: propagate more fields?
307
            changed = False
1✔
308
            for field in ['obj', 'obj_key']:
1✔
309
                old_val = getattr(user, field, None)
1✔
310
                new_val = kwargs.get(field)
1✔
311
                if old_val is None and new_val is not None:
1✔
312
                    setattr(user, field, new_val)
×
313
                    changed = True
×
314

315
            if enabled_protocols := kwargs.get('enabled_protocols'):
1✔
316
                user.enabled_protocols = (set(user.enabled_protocols)
1✔
317
                                          | set(enabled_protocols))
318
                changed = True
1✔
319

320
            if not propagate:
1✔
321
                if changed:
1✔
322
                    user.put()
1✔
323
                return user
1✔
324

325
        else:
326
            if orig_key := get_original_user_key(id):
1✔
327
                orig = orig_key.get()
1✔
328
                if orig.status and not allow_opt_out:
1✔
329
                    return None
×
330
                orig.existing = False
1✔
331
                return orig
1✔
332

333
            user = cls(id=id, **kwargs)
1✔
334
            user.existing = False
1✔
335
            user.reload_profile(gateway=True, raise_=False)
1✔
336
            if user.status and not allow_opt_out:
1✔
337
                return None
1✔
338

339
        if propagate and not user.status:
1✔
340
            for label in user.enabled_protocols + list(user.DEFAULT_ENABLED_PROTOCOLS):
1✔
341
                proto = PROTOCOLS[label]
1✔
342
                if proto == cls:
1✔
343
                    continue
×
344
                elif proto.HAS_COPIES:
1✔
345
                    if not user.get_copy(proto) and user.is_enabled(proto):
1✔
346
                        try:
1✔
347
                            proto.create_for(user)
1✔
348
                        except (ValueError, AssertionError):
1✔
349
                            logger.info(f'failed creating {proto.LABEL} copy',
1✔
350
                                        exc_info=True)
351
                            util.remove(user.enabled_protocols, proto.LABEL)
1✔
352
                    else:
353
                        logger.debug(f'{proto.LABEL} not enabled or user copy already exists, skipping propagate')
1✔
354

355
        # generate keys for all protocols _except_ our own
356
        #
357
        # these can use urandom() and do nontrivial math, so they can take time
358
        # depending on the amount of randomness available and compute needed.
359
        if cls.LABEL != 'activitypub':
1✔
360
            if (not user.public_exponent or not user.private_exponent or not user.mod):
1✔
361
                assert (not user.public_exponent and not user.private_exponent
1✔
362
                        and not user.mod), id
363
                key = RSA.generate(KEY_BITS,
1✔
364
                                   randfunc=random.randbytes if DEBUG else None)
365
                user.mod = long_to_base64(key.n)
1✔
366
                user.public_exponent = long_to_base64(key.e)
1✔
367
                user.private_exponent = long_to_base64(key.d)
1✔
368

369
        try:
1✔
370
            user.put()
1✔
371
        except AssertionError as e:
×
372
            error(f'Bad {cls.__name__} id {id} : {e}')
×
373

374
        logger.debug(('Updated ' if user.existing else 'Created new ') + str(user))
1✔
375
        return user
1✔
376

377
    @property
1✔
378
    def obj(self):
1✔
379
        """Convenience accessor that loads :attr:`obj_key` from the datastore."""
380
        if self.obj_key:
1✔
381
            if not hasattr(self, '_obj'):
1✔
382
                self._obj = self.obj_key.get()
1✔
383
            return self._obj
1✔
384

385
    @obj.setter
1✔
386
    def obj(self, obj):
1✔
387
        if obj:
1✔
388
            assert isinstance(obj, Object)
1✔
389
            assert obj.key
1✔
390
            self._obj = obj
1✔
391
            self.obj_key = obj.key
1✔
392
        else:
393
            self._obj = self.obj_key = None
1✔
394

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

398
        Args:
399
          proto (Protocol): optional
400
        """
401
        now = util.now().isoformat()
1✔
402
        proto_label = proto.LABEL if proto else 'all'
1✔
403
        delete_id = f'{self.profile_id()}#bridgy-fed-delete-user-{proto_label}-{now}'
1✔
404
        delete = Object(id=delete_id, source_protocol=self.LABEL, our_as1={
1✔
405
            'id': delete_id,
406
            'objectType': 'activity',
407
            'verb': 'delete',
408
            'actor': self.key.id(),
409
            'object': self.key.id(),
410
        })
411
        self.deliver(delete, from_user=self, to_proto=proto)
1✔
412

413
    @classmethod
1✔
414
    def load_multi(cls, users):
1✔
415
        """Loads :attr:`obj` for multiple users in parallel.
416

417
        Args:
418
          users (sequence of User)
419
        """
420
        objs = ndb.get_multi(u.obj_key for u in users if u.obj_key)
1✔
421
        keys_to_objs = {o.key: o for o in objs if o}
1✔
422

423
        for u in users:
1✔
424
            u._obj = keys_to_objs.get(u.obj_key)
1✔
425

426
    @ndb.ComputedProperty
1✔
427
    def handle(self):
1✔
428
        """This user's unique, human-chosen handle, eg ``@me@snarfed.org``.
429

430
        To be implemented by subclasses.
431
        """
432
        raise NotImplementedError()
×
433

434
    @ndb.ComputedProperty
1✔
435
    def readable_id(self):
1✔
436
        """DEPRECATED: replaced by handle. Kept for backward compatibility."""
437
        return None
1✔
438

439
    @ndb.ComputedProperty
1✔
440
    def status(self):
1✔
441
        """Whether this user is blocked or opted out.
442

443
        Optional. Current possible values:
444
          * ``opt-out``: if ``#nobridge`` or ``#nobot`` is in the profile
445
            description/bio, or if the user or domain has manually opted out.
446
            Some protocols also have protocol-specific opt out logic, eg Bluesky
447
            accounts that have disabled logged out view.
448
          * ``blocked``: if the user fails our validation checks, eg
449
            ``REQUIRES_NAME`` or ``REQUIRES_AVATAR`` if either of those are
450
            ``True` for this protocol.
451
          * `owns-webfinger`: a :class:`web.Web` user that looks like a
452
            fediverse server
453
          * `no-feed-or-webmention`: a :class:`web.Web` user that doesn't have
454
            an RSS or Atom feed or webmention endpoint and has never sent us a
455
            webmention
456

457
        Duplicates ``util.is_opt_out`` in Bridgy!
458

459
        https://github.com/snarfed/bridgy-fed/issues/666
460
        """
461
        if self.manual_opt_out:
1✔
462
            return 'opt-out'
1✔
463

464
        if not self.obj or not self.obj.as1:
1✔
465
            return None
1✔
466

467
        if self.REQUIRES_AVATAR and not self.obj.as1.get('image'):
1✔
468
            return 'blocked'
1✔
469

470
        name = self.obj.as1.get('displayName')
1✔
471
        if self.REQUIRES_NAME and (not name or name in (self.handle, self.key.id())):
1✔
472
            return 'blocked'
1✔
473

474
        if self.REQUIRES_OLD_ACCOUNT:
1✔
475
            if published := self.obj.as1.get('published'):
1✔
476
                if util.now() - util.parse_iso8601(published) < OLD_ACCOUNT_AGE:
1✔
477
                    return 'blocked'
1✔
478

479
        summary = html_to_text(self.obj.as1.get('summary', ''), ignore_links=True)
1✔
480
        name = self.obj.as1.get('displayName', '')
1✔
481

482
        # #nobridge overrides enabled_protocols
483
        if '#nobridge' in summary or '#nobridge' in name:
1✔
484
            return 'opt-out'
1✔
485

486
        # user has explicitly opted in. should go after spam filter (REQUIRES_*)
487
        # checks, but before is_public and #nobot
488
        if self.enabled_protocols:
1✔
489
            return None
1✔
490

491
        if not as1.is_public(self.obj.as1, unlisted=False):
1✔
492
            return 'opt-out'
1✔
493

494
        # enabled_protocols overrides #nobot
495
        if '#nobot' in summary or '#nobot' in name:
1✔
496
            return 'opt-out'
1✔
497

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

501
        Reasons this might return False:
502
        * We haven't turned on bridging these two protocols yet.
503
        * The user is opted out or blocked.
504
        * The user is on a domain that's opted out or blocked.
505
        * The from protocol requires opt in, and the user hasn't opted in.
506
        * ``explicit`` is True, and this protocol supports ``to_proto`` by, but the user hasn't explicitly opted into it.
507

508
        Args:
509
          to_proto (Protocol subclass)
510
          explicit (bool)
511

512
        Returns:
513
          bool:
514
        """
515
        from protocol import Protocol
1✔
516
        assert issubclass(to_proto, Protocol)
1✔
517

518
        if self.__class__ == to_proto:
1✔
519
            return True
1✔
520

521
        from_label = self.LABEL
1✔
522
        to_label = to_proto.LABEL
1✔
523

524
        if bot_protocol := Protocol.for_bridgy_subdomain(self.key.id()):
1✔
525
            return to_proto != bot_protocol
1✔
526

527
        elif self.manual_opt_out:
1✔
528
            return False
1✔
529

530
        elif to_label in self.enabled_protocols:
1✔
531
            return True
1✔
532

533
        elif self.status:
1✔
534
            return False
1✔
535

536
        elif to_label in self.DEFAULT_ENABLED_PROTOCOLS and not explicit:
1✔
537
            return True
1✔
538

539
        return False
1✔
540

541
    def enable_protocol(self, to_proto):
1✔
542
        """Adds ``to_proto`` to :attr:`enabled_protocols`.
543

544
        Also sends a welcome DM to the user (via a send task) if their protocol
545
        supports DMs.
546

547
        Args:
548
          to_proto (:class:`protocol.Protocol` subclass)
549
        """
550
        added = False
1✔
551

552
        if to_proto.LABEL in ids.COPIES_PROTOCOLS:
1✔
553
            # do this even if there's an existing copy since we might need to
554
            # reactivate it, which create_for should do
555
            to_proto.create_for(self)
1✔
556

557
        @ndb.transactional()
1✔
558
        def enable():
1✔
559
            user = self.key.get()
1✔
560
            if to_proto.LABEL not in user.enabled_protocols:
1✔
561
                user.enabled_protocols.append(to_proto.LABEL)
1✔
562
                util.add(user.sent_dms, DM(protocol=to_proto.LABEL, type='welcome'))
1✔
563
                user.put()
1✔
564
                nonlocal added
565
                added = True
1✔
566

567
            return user
1✔
568

569
        new_self = enable()
1✔
570
        # populate newly enabled protocol in this instance
571
        self.enabled_protocols = new_self.enabled_protocols
1✔
572
        self.copies = new_self.copies
1✔
573
        if self.obj:
1✔
574
            self.obj.copies = new_self.obj.copies
1✔
575

576
        if added:
1✔
577
            import dms
1✔
578
            dms.maybe_send(from_proto=to_proto, to_user=self, type='welcome',
1✔
579
                           text=f"""\
580
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.""")
581

582
        msg = f'Enabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
583
        logger.info(msg)
1✔
584

585
    def disable_protocol(self, to_proto):
1✔
586
        """Removes ``to_proto` from :attr:`enabled_protocols``.
587

588
        Args:
589
          to_proto (:class:`protocol.Protocol` subclass)
590
        """
591
        @ndb.transactional()
1✔
592
        def disable():
1✔
593
            user = self.key.get()
1✔
594
            util.remove(user.enabled_protocols, to_proto.LABEL)
1✔
595
            user.put()
1✔
596

597
        disable()
1✔
598
        util.remove(self.enabled_protocols, to_proto.LABEL)
1✔
599

600
        msg = f'Disabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
601
        logger.info(msg)
1✔
602

603
    def handle_as(self, to_proto):
1✔
604
        """Returns this user's handle in a different protocol.
605

606
        Args:
607
          to_proto (str or Protocol)
608

609
        Returns:
610
          str
611
        """
612
        if isinstance(to_proto, str):
1✔
613
            to_proto = PROTOCOLS[to_proto]
1✔
614

615
        # override to-ATProto to use custom domain handle in DID doc
616
        from atproto import ATProto, did_to_handle
1✔
617
        if to_proto == ATProto:
1✔
618
            if did := self.get_copy(ATProto):
1✔
619
                if handle := did_to_handle(did, remote=False):
1✔
620
                    return handle
1✔
621

622
        # override web users to always use domain instead of custom username
623
        # TODO: fall back to id if handle is unset?
624
        handle = self.key.id() if self.LABEL == 'web' else self.handle
1✔
625
        if not handle:
1✔
626
            return None
1✔
627

628
        return ids.translate_handle(handle=handle, from_=self.__class__,
1✔
629
                                    to=to_proto, enhanced=False)
630

631
    def id_as(self, to_proto):
1✔
632
        """Returns this user's id in a different protocol.
633

634
        Args:
635
          to_proto (str or Protocol)
636

637
        Returns:
638
          str
639
        """
640
        if isinstance(to_proto, str):
1✔
641
            to_proto = PROTOCOLS[to_proto]
1✔
642

643
        return ids.translate_user_id(id=self.key.id(), from_=self.__class__,
1✔
644
                                     to=to_proto)
645

646
    def handle_or_id(self):
1✔
647
        """Returns handle if we know it, otherwise id."""
648
        return self.handle or self.key.id()
1✔
649

650
    def public_pem(self):
1✔
651
        """
652
        Returns:
653
          bytes:
654
        """
655
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
656
                             base64_to_long(str(self.public_exponent))))
657
        return rsa.exportKey(format='PEM')
1✔
658

659
    def private_pem(self):
1✔
660
        """
661
        Returns:
662
          bytes:
663
        """
664
        assert self.mod and self.public_exponent and self.private_exponent, str(self)
1✔
665
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
666
                             base64_to_long(str(self.public_exponent)),
667
                             base64_to_long(str(self.private_exponent))))
668
        return rsa.exportKey(format='PEM')
1✔
669

670
    def name(self):
1✔
671
        """Returns this user's human-readable name, eg ``Ryan Barrett``."""
672
        if self.obj and self.obj.as1:
1✔
673
            name = self.obj.as1.get('displayName')
1✔
674
            if name:
1✔
675
                return name
1✔
676

677
        return self.handle_or_id()
1✔
678

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

682
        To be implemented by subclasses.
683

684
        Returns:
685
          str
686
        """
687
        raise NotImplementedError()
×
688

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

692
        Args:
693
          url (str)
694
          ignore_www (bool): if True, ignores ``www.`` subdomains
695

696
        Returns:
697
          bool:
698
        """
699
        if not url:
1✔
700
            return False
1✔
701

702
        url = url.strip().rstrip('/')
1✔
703
        url = re.sub(r'^(https?://)www\.', r'\1', url)
1✔
704
        parsed_url = urlparse(url)
1✔
705
        if parsed_url.scheme not in ('http', 'https', ''):
1✔
706
            return False
1✔
707

708
        this = self.web_url().rstrip('/')
1✔
709
        this = re.sub(r'^(https?://)www\.', r'\1', this)
1✔
710
        parsed_this = urlparse(this)
1✔
711

712
        return (url == this or url == parsed_this.netloc or
1✔
713
                parsed_url[1:] == parsed_this[1:])  # ignore http vs https
714

715
    def id_uri(self):
1✔
716
        """Returns the user id as a URI.
717

718
        Sometimes this is the user id itself, eg ActivityPub actor ids.
719
        Sometimes it's a bit different, eg at://did:plc:... for ATProto user,
720
        https://site.com for Web users.
721

722
        Returns:
723
          str
724
        """
725
        return self.key.id()
1✔
726

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

730
        Examples:
731

732
        * Web: home page URL, eg ``https://me.com/``
733
        * ActivityPub: actor URL, eg ``https://instance.com/users/me``
734
        * ATProto: profile AT URI, eg ``at://did:plc:123/app.bsky.actor.profile/self``
735

736
        Defaults to this user's key id.
737

738
        Returns:
739
          str or None:
740
        """
741
        return ids.profile_id(id=self.key.id(), proto=self)
1✔
742

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

746
        Populates the reloaded profile :class:`Object` in ``self.obj``.
747

748
        Args:
749
          kwargs: passed through to :meth:`Protocol.load`
750
        """
751
        obj = self.load(self.profile_id(), remote=True, **kwargs)
1✔
752
        if obj:
1✔
753
            self.obj = obj
1✔
754

755
    def user_page_path(self, rest=None):
1✔
756
        """Returns the user's Bridgy Fed user page path."""
757
        path = f'/{self.ABBREV}/{self.handle_or_id()}'
1✔
758

759
        if rest:
1✔
760
            if not rest.startswith('?'):
1✔
761
                path += '/'
1✔
762
            path += rest
1✔
763

764
        return path
1✔
765

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

769
        ...or None if no such copy exists. If ``proto`` is this user, returns
770
        this user's key id.
771

772
        Args:
773
          proto: :class:`Protocol` subclass
774

775
        Returns:
776
          str:
777
        """
778
        # don't use isinstance because the testutil Fake protocol has subclasses
779
        if self.LABEL == proto.LABEL:
1✔
780
            return self.key.id()
1✔
781

782
        for copy in self.copies:
1✔
783
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
784
                return copy.uri
1✔
785

786
    def user_link(self, name=True, handle=True, pictures=False, proto=None,
1✔
787
                  proto_fallback=False):
788
        """Returns a pretty HTML link to the user's profile.
789

790
        Can optionally include display name, handle, profile
791
        picture, and/or link to a different protocol that they've enabled.
792

793
        TODO: unify with :meth:`Object.actor_link`?
794

795
        Args:
796
          name (bool): include display name
797
          handle (bool): include handle
798
          pictures (bool): include profile picture and protocol logo
799
          proto (protocol.Protocol): link to this protocol instead of the user's
800
            native protocol
801
          proto_fallback (bool): if True, and ``proto`` is provided and has no
802
            no canonical profile URL for bridged users, uses the user's profile
803
            URL in their native protocol
804
        """
805
        img = name_str = handle_str = dot = logo = a_open = a_close = ''
1✔
806

807
        if proto:
1✔
808
            assert self.is_enabled(proto), f"{proto.LABEL} isn't enabled"
1✔
809
            url = proto.bridged_web_url_for(self, fallback=proto_fallback)
1✔
810
        else:
811
            proto = self.__class__
1✔
812
            url = self.web_url()
1✔
813

814
        if pictures:
1✔
815
            logo = f'<span class="logo" title="{proto.__name__}">{proto.LOGO_HTML}</span> '
1✔
816
            if pic := self.profile_picture():
1✔
817
                img = f'<img src="{pic}" class="profile"> '
1✔
818

819
        if handle:
1✔
820
            handle_str = self.handle_as(proto) or ''
1✔
821

822
        if name and self.name() != handle_str:
1✔
823
            name_str = self.name() or ''
1✔
824

825
        if handle_str and name_str:
1✔
826
            dot = ' &middot; '
1✔
827

828
        if url:
1✔
829
            a_open = f'<a class="h-card u-author" rel="me" href="{url}" title="{name_str}{dot}{handle_str}">'
1✔
830
            a_close = '</a>'
1✔
831

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

835
    def profile_picture(self):
1✔
836
        """Returns the user's profile picture image URL, if available, or None."""
837
        if self.obj and self.obj.as1:
1✔
838
            return util.get_url(self.obj.as1, 'image')
1✔
839

840
    # can't use functools.lru_cache here because we want the cache key to be
841
    # just the user id, not the whole entity
842
    @cachetools.cached(
1✔
843
        cachetools.TTLCache(50000, FOLLOWERS_CACHE_EXPIRATION.total_seconds()),
844
        key=lambda user: user.key.id(), lock=Lock())
845
    @memcache.memoize(key=lambda self: self.key.id(),
1✔
846
                      expire=FOLLOWERS_CACHE_EXPIRATION)
847
    def count_followers(self):
1✔
848
        """Counts this user's followers and followings.
849

850
        Returns:
851
          (int, int) tuple: (number of followers, number following)
852
        """
853
        if self.key.id() in PROTOCOL_DOMAINS:
1✔
854
            # we don't store Followers for protocol bot users any more, so
855
            # follower counts are inaccurate, so don't return them
856
            return (0, 0)
1✔
857

858
        num_followers = Follower.query(Follower.to == self.key,
1✔
859
                                       Follower.status == 'active')\
860
                                .count_async()
861
        num_following = Follower.query(Follower.from_ == self.key,
1✔
862
                                       Follower.status == 'active')\
863
                                .count_async()
864
        return num_followers.get_result(), num_following.get_result()
1✔
865

866

867
class Object(StringIdModel):
1✔
868
    """An activity or other object, eg actor.
869

870
    Key name is the id. We synthesize ids if necessary.
871
    """
872
    LABELS = ('activity',
1✔
873
              # DEPRECATED, replaced by users, notify, feed
874
              'feed', 'notification', 'user')
875

876
    users = ndb.KeyProperty(repeated=True)
1✔
877
    """Keys of user(s) who created or otherwise own this activity."""
1✔
878

879
    # User keys who should see this activity in their user page, eg in reply to,
880
    # reaction to, share of, etc.
881
    notify = ndb.KeyProperty(repeated=True)
1✔
882
    # User keys who should see this activity in their feeds, eg followers of its
883
    # creator
884
    feed = ndb.KeyProperty(repeated=True)
1✔
885

886
    # DEPRECATED but still used read only to maintain backward compatibility
887
    # with old Objects in the datastore that we haven't bothered migrating.
888
    domains = ndb.StringProperty(repeated=True)
1✔
889

890
    # choices is populated in reset_protocol_properties, after all User
891
    # subclasses are created, so that PROTOCOLS is fully populated.
892
    # TODO: nail down whether this is ABBREV or LABEL
893
    source_protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()))
1✔
894
    labels = ndb.StringProperty(repeated=True, choices=LABELS)
1✔
895

896
    # TODO: switch back to ndb.JsonProperty if/when they fix it for the web console
897
    # https://github.com/googleapis/python-ndb/issues/874
898
    as2 = JsonProperty()      # only one of the rest will be populated...
1✔
899
    bsky = JsonProperty()     # Bluesky / AT Protocol
1✔
900
    mf2 = JsonProperty()      # HTML microformats2 item (ie _not_ the top level
1✔
901
                              # parse object with items inside an 'items' field)
902
    our_as1 = JsonProperty()  # AS1 for activities that we generate or modify ourselves
1✔
903
    raw = JsonProperty()      # other standalone data format, eg DID document
1✔
904

905
    # these are full feeds with multiple items, not just this one, so they're
906
    # stored as audit records only. they're not used in to_as1. for Atom/RSS
907
    # based Objects, our_as1 will be populated with an feed_index top-level
908
    # integer field that indexes into one of these.
909
    atom = ndb.TextProperty() # Atom XML
1✔
910
    rss = ndb.TextProperty()  # RSS XML
1✔
911

912
    # TODO: remove and actually delete Objects instead!
913
    deleted = ndb.BooleanProperty()
1✔
914

915
    # Copies of this object elsewhere, eg at:// URIs for ATProto records and
916
    # nevent etc bech32-encoded Nostr ids, where this object is the original.
917
    # Similar to u-syndication links in microformats2 and
918
    # upstream/downstreamDuplicates in AS1.
919
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
920

921
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
922
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
923

924
    new = None
1✔
925
    changed = None
1✔
926
    """Protocol and subclasses set these in fetch if this :class:`Object` is
1✔
927
    new or if its contents have changed from what was originally loaded from the
928
    datastore. If either one is None, that means we don't know whether this
929
    :class:`Object` is new/changed.
930

931
    :attr:`changed` is populated by :meth:`activity_changed()`.
932
    """
933

934
    lock = None
1✔
935
    """Initialized in __init__, synchronizes :meth:`add` and :meth:`remove`."""
1✔
936

937
    # these were used for delivery tracking, but they were too expensive,
938
    # so we stopped: https://github.com/snarfed/bridgy-fed/issues/1501
939
    STATUSES = ('new', 'in progress', 'complete', 'failed', 'ignored')
1✔
940
    status = ndb.StringProperty(choices=STATUSES)
1✔
941
    delivered = ndb.StructuredProperty(Target, repeated=True)
1✔
942
    undelivered = ndb.StructuredProperty(Target, repeated=True)
1✔
943
    failed = ndb.StructuredProperty(Target, repeated=True)
1✔
944

945
    @property
1✔
946
    def as1(self):
1✔
947
        def use_urls_as_ids(obj):
1✔
948
            """If id field is missing or not a URL, use the url field."""
949
            id = obj.get('id')
1✔
950
            if not id or not (util.is_web(id) or re.match(DOMAIN_RE, id)):
1✔
951
                if url := util.get_url(obj):
1✔
952
                    obj['id'] = url
1✔
953

954
            for field in 'author', 'actor', 'object':
1✔
955
                if inner := as1.get_object(obj, field):
1✔
956
                    use_urls_as_ids(inner)
1✔
957

958
        if self.our_as1:
1✔
959
            obj = self.our_as1
1✔
960
            if self.atom or self.rss:
1✔
961
                use_urls_as_ids(obj)
1✔
962

963
        elif self.as2:
1✔
964
            obj = as2.to_as1(unwrap(self.as2))
1✔
965

966
        elif self.bsky:
1✔
967
            owner, _, _ = parse_at_uri(self.key.id())
1✔
968
            ATProto = PROTOCOLS['atproto']
1✔
969
            handle = ATProto(id=owner).handle
1✔
970
            try:
1✔
971
                obj = bluesky.to_as1(self.bsky, repo_did=owner, repo_handle=handle,
1✔
972
                                     uri=self.key.id(), pds=ATProto.pds_for(self))
973
            except (ValueError, RequestException):
1✔
974
                logger.info(f"Couldn't convert to ATProto", exc_info=True)
1✔
975
                return None
1✔
976

977
        elif self.mf2:
1✔
978
            obj = microformats2.json_to_object(self.mf2,
1✔
979
                                               rel_urls=self.mf2.get('rel-urls'))
980
            use_urls_as_ids(obj)
1✔
981

982
            # use fetched final URL as id, not u-url
983
            # https://github.com/snarfed/bridgy-fed/issues/829
984
            if url := self.mf2.get('url'):
1✔
985
                obj['id'] = (self.key.id() if self.key and '#' in self.key.id()
1✔
986
                             else url)
987

988
        else:
989
            return None
1✔
990

991
        # populate id if necessary
992
        if self.key:
1✔
993
            obj.setdefault('id', self.key.id())
1✔
994

995
        return obj
1✔
996

997
    @ndb.ComputedProperty
1✔
998
    def type(self):  # AS1 objectType, or verb if it's an activity
1✔
999
        if self.as1:
1✔
1000
            return as1.object_type(self.as1)
1✔
1001

1002
    def __init__(self, *args, **kwargs):
1✔
1003
        super().__init__(*args, **kwargs)
1✔
1004
        self.lock = Lock()
1✔
1005

1006
    def _expire(self):
1✔
1007
        """Automatically delete most Objects after a while using a TTL policy.
1008

1009
        https://cloud.google.com/datastore/docs/ttl
1010

1011
        They recommend not indexing TTL properties:
1012
        https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
1013
        """
1014
        if self.deleted or self.type not in DONT_EXPIRE_OBJECT_TYPES:
1✔
1015
            return (self.updated or util.now()) + OBJECT_EXPIRE_AGE
1✔
1016

1017
    expire = ndb.ComputedProperty(_expire, indexed=False)
1✔
1018

1019
    def _pre_put_hook(self):
1✔
1020
        """
1021
        * Validate that at:// URIs have DID repos
1022
        * Set/remove the activity label
1023
        * Strip @context from as2 (we don't do LD) to save disk space
1024
        """
1025
        id = self.key.id()
1✔
1026

1027
        if self.source_protocol not in (None, 'ui'):
1✔
1028
            proto = PROTOCOLS[self.source_protocol]
1✔
1029
            assert proto.owns_id(id) is not False, \
1✔
1030
                f'Protocol {proto.LABEL} does not own id {id}'
1031

1032
        if id.startswith('at://'):
1✔
1033
            repo, _, _ = parse_at_uri(id)
1✔
1034
            if not repo.startswith('did:'):
1✔
1035
                # TODO: if we hit this, that means the AppView gave us an AT URI
1036
                # with a handle repo/authority instead of DID. that's surprising!
1037
                # ...if so, and if we need to handle it, add a new
1038
                # arroba.did.canonicalize_at_uri() function, then use it here,
1039
                # or before.
1040
                raise ValueError(
1✔
1041
                    f'at:// URI ids must have DID repos; got {id}')
1042

1043
        if self.as1 and self.as1.get('objectType') == 'activity':
1✔
1044
            self.add('labels', 'activity')
1✔
1045
        elif 'activity' in self.labels:
1✔
1046
            self.remove('labels', 'activity')
1✔
1047

1048
        if self.as2:
1✔
1049
           self.as2.pop('@context', None)
1✔
1050
           for field in 'actor', 'attributedTo', 'author', 'object':
1✔
1051
               for val in util.get_list(self.as2, field):
1✔
1052
                   if isinstance(val, dict):
1✔
1053
                       val.pop('@context', None)
1✔
1054

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

1059
    @classmethod
1✔
1060
    def get_by_id(cls, id, authed_as=None, **kwargs):
1✔
1061
        """Fetches the :class:`Object` with the given id, if it exists.
1062

1063
        Args:
1064
          id (str)
1065
          authed_as (str): optional; if provided, and a matching :class:`Object`
1066
            already exists, its ``author`` or ``actor`` must contain this actor
1067
            id. Implements basic authorization for updates and deletes.
1068

1069
        Returns:
1070
          Object:
1071

1072
        Raises:
1073
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1074
            the existing object
1075
        """
1076
        obj = super().get_by_id(id, **kwargs)
1✔
1077

1078
        if obj and obj.as1 and authed_as:
1✔
1079
            # authorization: check that the authed user is allowed to modify
1080
            # this object
1081
            # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1082
            proto = PROTOCOLS.get(obj.source_protocol)
1✔
1083
            assert proto, obj.source_protocol
1✔
1084
            owners = [ids.normalize_user_id(id=owner, proto=proto)
1✔
1085
                      for owner in (as1.get_ids(obj.as1, 'author')
1086
                                    + as1.get_ids(obj.as1, 'actor'))
1087
                                    + [id]]
1088
            if (ids.normalize_user_id(id=authed_as, proto=proto) not in owners
1✔
1089
                    and ids.profile_id(id=authed_as, proto=proto) not in owners):
1090
                report_error("Auth: Object: authed_as doesn't match owner",
1✔
1091
                             user=f'{id} authed_as {authed_as} owners {owners}')
1092
                error(f"authed user {authed_as} isn't object owner {owners}",
1✔
1093
                      status=403)
1094

1095
        return obj
1✔
1096

1097
    @classmethod
1✔
1098
    def get_or_create(cls, id, authed_as=None, **props):
1✔
1099
        """Returns an :class:`Object` with the given property values.
1100

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

1105
        Not transactional because transactions don't read or write memcache. :/
1106
        Fortunately we don't really depend on atomicity for anything, last
1107
        writer wins is pretty much always fine.
1108

1109
        Args:
1110
          authed_as (str): optional; if provided, and a matching :class:`Object`
1111
            already exists, its ``author`` or ``actor`` must contain this actor
1112
            id. Implements basic authorization for updates and deletes.
1113

1114
        Returns:
1115
          Object:
1116

1117
        Raises:
1118
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1119
            the existing object
1120
        """
1121
        obj = cls.get_by_id(id, authed_as=authed_as)
1✔
1122

1123
        if not obj:
1✔
1124
            obj = Object(id=id, **props)
1✔
1125
            obj.new = True
1✔
1126
            obj.changed = False
1✔
1127
            obj.put()
1✔
1128
            return obj
1✔
1129

1130
        if orig_as1 := obj.as1:
1✔
1131
            # get_by_id() checks authorization if authed_as is set. make sure
1132
            # it's always set for existing objects.
1133
            assert authed_as
1✔
1134

1135
        dirty = False
1✔
1136
        for prop, val in props.items():
1✔
1137
            assert not isinstance(getattr(Object, prop), ndb.ComputedProperty)
1✔
1138
            if prop in ('feed', 'copies', 'labels', 'notify', 'users'):
1✔
1139
                # merge repeated fields
1140
                for elem in val:
1✔
1141
                    if obj.add(prop, elem):
1✔
1142
                        dirty = True
1✔
1143
            elif val and val != getattr(obj, prop):
1✔
1144
                setattr(obj, prop, val)
1✔
1145
                if prop in ('as2', 'bsky', 'mf2', 'raw') and not props.get('our_as1'):
1✔
1146
                    obj.our_as1 = None
1✔
1147
                dirty = True
1✔
1148

1149
        obj.new = False
1✔
1150
        obj.changed = obj.activity_changed(orig_as1)
1✔
1151
        if dirty:
1✔
1152
            obj.put()
1✔
1153
        return obj
1✔
1154

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

1158
        Args:
1159
          prop (str)
1160
          val
1161

1162
        Returns:
1163
          True if val was added, ie it wasn't already in prop, False otherwise
1164
        """
1165
        with self.lock:
1✔
1166
            added = util.add(getattr(self, prop), val)
1✔
1167

1168
        if prop == 'copies' and added:
1✔
1169
            memcache.pickle_memcache.set(memcache.memoize_key(
1✔
1170
                get_original_object_key, val.uri), self.key)
1171

1172
        return added
1✔
1173

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

1177
        Args:
1178
          prop (str)
1179
          val
1180
        """
1181
        with self.lock:
1✔
1182
            getattr(self, prop).remove(val)
1✔
1183

1184
    @staticmethod
1✔
1185
    def from_request():
1✔
1186
        """Creates and returns an :class:`Object` from form-encoded JSON parameters.
1187

1188
        Parameters:
1189
          obj_id (str): id of :class:`models.Object` to handle
1190
          *: If ``obj_id`` is unset, all other parameters are properties for a
1191
            new :class:`models.Object` to handle
1192
        """
1193
        if obj_id := request.form.get('obj_id'):
1✔
1194
            return Object.get_by_id(obj_id)
1✔
1195

1196
        props = {field: request.form.get(field)
1✔
1197
                 for field in ('id', 'atom', 'rss', 'source_protocol')}
1198

1199
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1200
            if val := request.form.get(json_prop):
1✔
1201
                props[json_prop] = json_loads(val)
1✔
1202

1203
        obj = Object(**props)
1✔
1204
        if not obj.key and obj.as1:
1✔
1205
            if id := obj.as1.get('id'):
1✔
1206
                obj.key = ndb.Key(Object, id)
1✔
1207

1208
        return obj
1✔
1209

1210
    def to_request(self):
1✔
1211
        """Returns a query parameter dict representing this :class:`Object`."""
1212
        form = {}
1✔
1213

1214
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1215
            if val := getattr(self, json_prop, None):
1✔
1216
                form[json_prop] = json_dumps(val, sort_keys=True)
1✔
1217

1218
        for prop in 'atom', 'rss', 'source_protocol':
1✔
1219
            if val := getattr(self, prop):
1✔
1220
                form[prop] = val
1✔
1221

1222
        if self.key:
1✔
1223
            form['id'] = self.key.id()
1✔
1224

1225
        return form
1✔
1226

1227
    def activity_changed(self, other_as1):
1✔
1228
        """Returns True if this activity is meaningfully changed from ``other_as1``.
1229

1230
        ...otherwise False.
1231

1232
        Used to populate :attr:`changed`.
1233

1234
        Args:
1235
          other_as1 (dict): AS1 object, or none
1236
        """
1237
        # ignore inReplyTo since we translate it between protocols
1238
        return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
1✔
1239
                if self.as1 and other_as1
1240
                else bool(self.as1) != bool(other_as1))
1241

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

1245
        TODO: unify with :meth:`User.user_link`?
1246

1247
        Args:
1248
          image (bool): whether to include an ``img`` tag with the actor's picture
1249
          sized (bool): whether to set an explicit (``width=32``) size on the
1250
            profile picture ``img`` tag
1251
          user (User): current user
1252

1253
        Returns:
1254
          str:
1255
        """
1256
        attrs = {'class': 'h-card u-author'}
1✔
1257

1258
        if user and (user.key in self.users or user.key.id() in self.domains):
1✔
1259
            # outbound; show a nice link to the user
1260
            return user.user_link(handle=False, pictures=True)
1✔
1261

1262
        proto = PROTOCOLS.get(self.source_protocol)
1✔
1263

1264
        actor = None
1✔
1265
        if self.as1:
1✔
1266
            actor = (as1.get_object(self.as1, 'actor')
1✔
1267
                     or as1.get_object(self.as1, 'author'))
1268
            # hydrate from datastore if available
1269
            # TODO: optimize! this is called serially in loops, eg in home.html
1270
            if set(actor.keys()) == {'id'} and self.source_protocol:
1✔
1271
                actor_obj = proto.load(actor['id'], remote=False)
1✔
1272
                if actor_obj and actor_obj.as1:
1✔
1273
                    actor = actor_obj.as1
1✔
1274

1275
        if not actor:
1✔
1276
            return ''
1✔
1277
        elif set(actor.keys()) == {'id'}:
1✔
1278
            return common.pretty_link(actor['id'], attrs=attrs, user=user)
1✔
1279

1280
        url = as1.get_url(actor)
1✔
1281
        name = actor.get('displayName') or actor.get('username') or ''
1✔
1282
        img_url = util.get_url(actor, 'image')
1✔
1283
        if not image or not img_url:
1✔
1284
            return common.pretty_link(url, text=name, attrs=attrs, user=user)
1✔
1285

1286
        logo = ''
1✔
1287
        if proto:
1✔
1288
            logo = f'<span class="logo" title="{self.__class__.__name__}">{proto.LOGO_HTML}</span>'
×
1289

1290
        return f"""\
1✔
1291
        {logo}
1292
        <a class="h-card u-author" href="{url}" title="{name}">
1293
          <img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
1294
          <span style="unicode-bidi: isolate">{util.ellipsize(name, chars=40)}</span>
1295
        </a>"""
1296

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

1300
        ...or None if no such copy exists. If ``proto`` is ``source_protocol``,
1301
        returns this object's key id.
1302

1303
        Args:
1304
          proto: :class:`Protocol` subclass
1305

1306
        Returns:
1307
          str:
1308
        """
1309
        if self.source_protocol in (proto.LABEL, proto.ABBREV):
1✔
1310
            return self.key.id()
1✔
1311

1312
        for copy in self.copies:
1✔
1313
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
1314
                return copy.uri
1✔
1315

1316
    def resolve_ids(self):
1✔
1317
        """Resolves "copy" ids, subdomain ids, etc with their originals.
1318

1319
        The end result is that all ids are original "source" ids, ie in the
1320
        protocol that they first came from.
1321

1322
        Specifically, resolves:
1323

1324
        * ids in :class:`User.copies` and :class:`Object.copies`, eg ATProto
1325
          records and Nostr events that we bridged, to the ids of their
1326
          original objects in their source protocol, eg
1327
          ``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
1328
        * Bridgy Fed subdomain URLs to the ids embedded inside them, eg
1329
          ``https://bsky.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
1330
        * ATProto bsky.app URLs to their DIDs or `at://` URIs, eg
1331
          ``https://bsky.app/profile/a.com`` => ``did:plc:123``
1332

1333
        ...in these AS1 fields, in place:
1334

1335
        * ``id``
1336
        * ``actor``
1337
        * ``author``
1338
        * ``object``
1339
        * ``object.actor``
1340
        * ``object.author``
1341
        * ``object.id``
1342
        * ``object.inReplyTo``
1343
        * ``tags.[objectType=mention].url``
1344

1345
        :meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
1346
        Much of the same logic is duplicated there!
1347

1348
        TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
1349
        """
1350
        if not self.as1:
1✔
1351
            return
1✔
1352

1353
        # extract ids, strip Bridgy Fed subdomain URLs
1354
        outer_obj = unwrap(self.as1)
1✔
1355
        if outer_obj != self.as1:
1✔
1356
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1357

1358
        self_proto = PROTOCOLS.get(self.source_protocol)
1✔
1359
        if not self_proto:
1✔
1360
            return
1✔
1361

1362
        inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
1✔
1363
        replaced = False
1✔
1364

1365
        def replace(val, orig_fn):
1✔
1366
            id = val.get('id') if isinstance(val, dict) else val
1✔
1367
            if not id or not self_proto.HAS_COPIES:
1✔
1368
                return id
1✔
1369

1370
            orig = orig_fn(id)
1✔
1371
            if not orig:
1✔
1372
                return val
1✔
1373

1374
            nonlocal replaced
1375
            replaced = True
1✔
1376
            logger.debug(f'Resolved copy id {val} to original {orig.id()}')
1✔
1377

1378
            if isinstance(val, dict) and util.trim_nulls(val).keys() > {'id'}:
1✔
1379
                val['id'] = orig.id()
1✔
1380
                return val
1✔
1381
            else:
1382
                return orig.id()
1✔
1383

1384
        # actually replace ids
1385
        #
1386
        # object field could be either object (eg repost) or actor (eg follow)
1387
        outer_obj['object'] = replace(inner_obj, get_original_object_key)
1✔
1388
        if not replaced:
1✔
1389
            outer_obj['object'] = replace(inner_obj, get_original_user_key)
1✔
1390

1391
        for obj in outer_obj, inner_obj:
1✔
1392
            for tag in as1.get_objects(obj, 'tags'):
1✔
1393
                if tag.get('objectType') == 'mention':
1✔
1394
                    tag['url'] = replace(tag.get('url'), get_original_user_key)
1✔
1395
            for field, fn in (
1✔
1396
                    ('actor', get_original_user_key),
1397
                    ('author', get_original_user_key),
1398
                    ('inReplyTo', get_original_object_key),
1399
                ):
1400
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1401
                if len(obj[field]) == 1:
1✔
1402
                    obj[field] = obj[field][0]
1✔
1403

1404
        if replaced:
1✔
1405
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1406

1407
    def normalize_ids(self):
1✔
1408
        """Normalizes ids to their protocol's canonical representation, if any.
1409

1410
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
1411
        for profiles, ``at://`` URIs for posts.
1412

1413
        Modifies this object in place.
1414

1415
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
1416
        """
1417
        from protocol import Protocol
1✔
1418

1419
        if not self.as1:
1✔
1420
            return
1✔
1421

1422
        logger.debug(f'Normalizing ids')
1✔
1423
        outer_obj = copy.deepcopy(self.as1)
1✔
1424
        inner_objs = as1.get_objects(outer_obj)
1✔
1425
        replaced = False
1✔
1426

1427
        def replace(val, translate_fn):
1✔
1428
            nonlocal replaced
1429

1430
            orig = val.get('id') if isinstance(val, dict) else val
1✔
1431
            if not orig:
1✔
1432
                return val
1✔
1433

1434
            proto = Protocol.for_id(orig, remote=False)
1✔
1435
            if not proto:
1✔
1436
                return val
1✔
1437

1438
            translated = translate_fn(id=orig, from_=proto, to=proto)
1✔
1439
            if translated and translated != orig:
1✔
1440
                # logger.debug(f'Normalized {proto.LABEL} id {orig} to {translated}')
1441
                replaced = True
1✔
1442
                if isinstance(val, dict):
1✔
1443
                    val['id'] = translated
1✔
1444
                    return val
1✔
1445
                else:
1446
                    return translated
1✔
1447

1448
            return val
1✔
1449

1450
        # actually replace ids
1451
        for obj in [outer_obj] + inner_objs:
1✔
1452
            for tag in as1.get_objects(obj, 'tags'):
1✔
1453
                if tag.get('objectType') == 'mention':
1✔
1454
                    tag['url'] = replace(tag.get('url'), ids.translate_user_id)
1✔
1455
            for field in ['actor', 'author', 'inReplyTo']:
1✔
1456
                fn = (ids.translate_object_id if field == 'inReplyTo'
1✔
1457
                      else ids.translate_user_id)
1458
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1459
                if len(obj[field]) == 1:
1✔
1460
                    obj[field] = obj[field][0]
1✔
1461

1462
        outer_obj['object'] = []
1✔
1463
        for inner_obj in inner_objs:
1✔
1464
            translate_fn = (ids.translate_user_id
1✔
1465
                            if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
1466
                                or as1.object_type(outer_obj) in
1467
                                ('follow', 'stop-following'))
1468
                            else ids.translate_object_id)
1469

1470
            got = replace(inner_obj, translate_fn)
1✔
1471
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
1472
                got = got['id']
1✔
1473

1474
            outer_obj['object'].append(got)
1✔
1475

1476
        if len(outer_obj['object']) == 1:
1✔
1477
            outer_obj['object'] = outer_obj['object'][0]
1✔
1478

1479
        if replaced:
1✔
1480
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1481

1482

1483
class Follower(ndb.Model):
1✔
1484
    """A follower of a Bridgy Fed user."""
1485
    STATUSES = ('active', 'inactive')
1✔
1486

1487
    # these are both subclasses of User
1488
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
1489
    to = ndb.KeyProperty(required=True)
1✔
1490

1491
    follow = ndb.KeyProperty(Object)  # last follow activity
1✔
1492
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
1493

1494
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1495
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1496

1497
    # OLD. some stored entities still have these; do not reuse.
1498
    # src = ndb.StringProperty()
1499
    # dest = ndb.StringProperty()
1500
    # last_follow = JsonProperty()
1501

1502
    def _pre_put_hook(self):
1✔
1503
        # we're a bridge! stick with bridging.
1504
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
1505

1506
    def _post_put_hook(self, future):
1✔
1507
        logger.debug(f'Wrote {self.key}')
1✔
1508

1509
    @classmethod
1✔
1510
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
1511
        """Returns a Follower with the given ``from_`` and ``to`` users.
1512

1513
        Not transactional because transactions don't read or write memcache. :/
1514
        Fortunately we don't really depend on atomicity for anything, last
1515
        writer wins is pretty much always fine.
1516

1517
        If a matching :class:`Follower` doesn't exist in the datastore, creates
1518
        it first.
1519

1520
        Args:
1521
          from_ (User)
1522
          to (User)
1523

1524
        Returns:
1525
          Follower:
1526
        """
1527
        assert from_
1✔
1528
        assert to
1✔
1529

1530
        follower = Follower.query(Follower.from_ == from_.key,
1✔
1531
                                  Follower.to == to.key,
1532
                                  ).get()
1533
        if not follower:
1✔
1534
            follower = Follower(from_=from_.key, to=to.key, **kwargs)
1✔
1535
            follower.put()
1✔
1536
        elif kwargs:
1✔
1537
            # update existing entity with new property values, eg to make an
1538
            # inactive Follower active again
1539
            for prop, val in kwargs.items():
1✔
1540
                setattr(follower, prop, val)
1✔
1541
            follower.put()
1✔
1542

1543
        return follower
1✔
1544

1545
    @staticmethod
1✔
1546
    def fetch_page(collection, user):
1✔
1547
        r"""Fetches a page of :class:`Follower`\s for a given user.
1548

1549
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
1550
        parameters, if available in the request.
1551

1552
        Args:
1553
          collection (str): ``followers`` or ``following``
1554
          user (User)
1555

1556
        Returns:
1557
          (list of Follower, str, str) tuple: results, annotated with an extra
1558
          ``user`` attribute that holds the follower or following :class:`User`,
1559
          and new str query param values for ``before`` and ``after`` to fetch
1560
          the previous and next pages, respectively
1561
        """
1562
        assert collection in ('followers', 'following'), collection
1✔
1563

1564
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1565
        query = Follower.query(
1✔
1566
            Follower.status == 'active',
1567
            filter_prop == user.key,
1568
        )
1569

1570
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
1571
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
1572
                              for f in followers)
1573
        User.load_multi(u for u in users if u)
1✔
1574

1575
        for f, u in zip(followers, users):
1✔
1576
            f.user = u
1✔
1577
        followers = [f for f in followers if not f.user.status]
1✔
1578

1579
        return followers, before, after
1✔
1580

1581

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

1585
    Wraps :func:`fetch_page` and adds attributes to the returned
1586
    :class:`Object` entities for rendering in ``objects.html``.
1587

1588
    Args:
1589
      query (ndb.Query)
1590
      by (ndb.model.Property): either :attr:`Object.updated` or
1591
        :attr:`Object.created`
1592
      user (User): current user
1593

1594
    Returns:
1595
      (list of Object, str, str) tuple:
1596
      (results, new ``before`` query param, new ``after`` query param)
1597
      to fetch the previous and next pages, respectively
1598
    """
1599
    assert by is Object.updated or by is Object.created
1✔
1600
    objects, new_before, new_after = fetch_page(query, Object, by=by)
1✔
1601
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
1602

1603
    # synthesize human-friendly content for objects
1604
    for i, obj in enumerate(objects):
1✔
1605
        obj_as1 = obj.as1
1✔
1606
        inner_obj = as1.get_object(obj_as1)
1✔
1607

1608
        # synthesize text snippet
1609
        type = as1.object_type(obj_as1)
1✔
1610
        if type == 'post':
1✔
1611
            inner_type = inner_obj.get('objectType')
×
1612
            if inner_type:
×
1613
                type = inner_type
×
1614

1615
        # AS1 verb => human-readable phrase
1616
        phrases = {
1✔
1617
            'accept': 'accepted',
1618
            'article': 'posted',
1619
            'comment': 'replied',
1620
            'delete': 'deleted',
1621
            'follow': 'followed',
1622
            'invite': 'is invited to',
1623
            'issue': 'filed issue',
1624
            'like': 'liked',
1625
            'note': 'posted',
1626
            'post': 'posted',
1627
            'repost': 'reposted',
1628
            'rsvp-interested': 'is interested in',
1629
            'rsvp-maybe': 'might attend',
1630
            'rsvp-no': 'is not attending',
1631
            'rsvp-yes': 'is attending',
1632
            'share': 'reposted',
1633
            'stop-following': 'unfollowed',
1634
            'undo': 'undid',
1635
            'update': 'updated',
1636
        }
1637
        obj.phrase = phrases.get(type)
1✔
1638

1639
        content = (inner_obj.get('content')
1✔
1640
                   or inner_obj.get('displayName')
1641
                   or inner_obj.get('summary'))
1642
        if content:
1✔
1643
            content = util.parse_html(content).get_text()
×
1644

1645
        urls = as1.object_urls(inner_obj)
1✔
1646
        id = unwrap(inner_obj.get('id', ''))
1✔
1647
        url = urls[0] if urls else id
1✔
1648
        if (type == 'update' and
1✔
1649
            (obj.users and (user.is_web_url(id)
1650
                            or id.strip('/') == obj.users[0].id())
1651
             or obj.domains and id.strip('/') == f'https://{obj.domains[0]}')):
1652
            obj.phrase = 'updated'
×
1653
            obj_as1.update({
×
1654
                'content': 'their profile',
1655
                'url': id,
1656
            })
1657
        elif url and not content:
1✔
1658
            # heuristics for sniffing URLs and converting them to more friendly
1659
            # phrases and user handles.
1660
            # TODO: standardize this into granary.as2 somewhere?
1661
            from activitypub import FEDI_URL_RE
1✔
1662
            from atproto import COLLECTION_TO_TYPE, did_to_handle
1✔
1663

1664
            handle = suffix = ''
1✔
1665
            if match := FEDI_URL_RE.match(url):
1✔
1666
                handle = match.group(2)
×
1667
                if match.group(4):
×
1668
                    suffix = "'s post"
×
1669
            elif match := BSKY_APP_URL_RE.match(url):
1✔
1670
                handle = match.group('id')
×
1671
                if match.group('tid'):
×
1672
                    suffix = "'s post"
×
1673
            elif match := AT_URI_PATTERN.match(url):
1✔
1674
                handle = match.group('repo')
×
1675
                if coll := match.group('collection'):
×
1676
                    suffix = f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
×
1677
                url = bluesky.at_uri_to_web_url(url)
×
1678
            elif url.startswith('did:'):
1✔
1679
                handle = url
×
1680
                url = bluesky.Bluesky.user_url(handle)
×
1681

1682
            if handle:
1✔
1683
                if handle.startswith('did:'):
×
1684
                    handle = did_to_handle(handle) or handle
×
1685
                content = f'@{handle}{suffix}'
×
1686

1687
            if url:
1✔
1688
                content = common.pretty_link(url, text=content, user=user)
1✔
1689

1690
        obj.content = (obj_as1.get('content')
1✔
1691
                       or obj_as1.get('displayName')
1692
                       or obj_as1.get('summary'))
1693
        obj.url = util.get_first(obj_as1, 'url')
1✔
1694

1695
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
1696
            if obj.url:
1✔
1697
                obj.phrase = common.pretty_link(
×
1698
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
1699
            if content:
1✔
1700
                obj.content = content
1✔
1701
                obj.url = url
1✔
1702

1703
    return objects, new_before, new_after
1✔
1704

1705

1706
def fetch_page(query, model_class, by=None):
1✔
1707
    """Fetches a page of results from a datastore query.
1708

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

1712
    Populates a ``log_url_path`` property on each result entity that points to a
1713
    its most recent logged request.
1714

1715
    Args:
1716
      query (google.cloud.ndb.query.Query)
1717
      model_class (class)
1718
      by (ndb.model.Property): paging property, eg :attr:`Object.updated`
1719
        or :attr:`Object.created`
1720

1721
    Returns:
1722
      (list of Object or Follower, str, str) tuple: (results, new_before,
1723
      new_after), where new_before and new_after are query param values for
1724
      ``before`` and ``after`` to fetch the previous and next pages,
1725
      respectively
1726
    """
1727
    assert by
1✔
1728

1729
    # if there's a paging param ('before' or 'after'), update query with it
1730
    # TODO: unify this with Bridgy's user page
1731
    def get_paging_param(param):
1✔
1732
        val = request.values.get(param)
1✔
1733
        if val:
1✔
1734
            try:
1✔
1735
                dt = util.parse_iso8601(val.replace(' ', '+'))
1✔
1736
            except BaseException as e:
1✔
1737
                error(f"Couldn't parse {param}, {val!r} as ISO8601: {e}")
1✔
1738
            if dt.tzinfo:
1✔
1739
                dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
1✔
1740
            return dt
1✔
1741

1742
    before = get_paging_param('before')
1✔
1743
    after = get_paging_param('after')
1✔
1744
    if before and after:
1✔
1745
        error("can't handle both before and after")
×
1746
    elif after:
1✔
1747
        query = query.filter(by >= after).order(by)
1✔
1748
    elif before:
1✔
1749
        query = query.filter(by < before).order(-by)
1✔
1750
    else:
1751
        query = query.order(-by)
1✔
1752

1753
    query_iter = query.iter()
1✔
1754
    results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
1✔
1755
                     key=lambda r: r.updated, reverse=True)
1756

1757
    # calculate new paging param(s)
1758
    has_next = results and query_iter.probably_has_next()
1✔
1759
    new_after = (
1✔
1760
        before if before
1761
        else results[0].updated if has_next and after
1762
        else None)
1763
    if new_after:
1✔
1764
        new_after = new_after.isoformat()
1✔
1765

1766
    new_before = (
1✔
1767
        after if after else
1768
        results[-1].updated if has_next
1769
        else None)
1770
    if new_before:
1✔
1771
        new_before = new_before.isoformat()
1✔
1772

1773
    return results, new_before, new_after
1✔
1774

1775

1776
@lru_cache(maxsize=100000)
1✔
1777
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
1778
def get_original_object_key(copy_id):
1✔
1779
    """Finds the :class:`Object` with a given copy id, if any.
1780

1781
    Note that :meth:`Object.add` also updates this function's
1782
    :func:`memcache.memoize` cache.
1783

1784
    Args:
1785
      copy_id (str)
1786

1787
    Returns:
1788
      google.cloud.ndb.Key or None
1789
    """
1790
    assert copy_id
1✔
1791

1792
    return Object.query(Object.copies.uri == copy_id).get(keys_only=True)
1✔
1793

1794

1795
@lru_cache(maxsize=100000)
1✔
1796
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
1797
def get_original_user_key(copy_id):
1✔
1798
    """Finds the user with a given copy id, if any.
1799

1800
    Note that :meth:`User.add` also updates this function's
1801
    :func:`memcache.memoize` cache.
1802

1803
    Args:
1804
      copy_id (str)
1805
      not_proto (Protocol): optional, don't query this protocol
1806

1807
    Returns:
1808
      google.cloud.ndb.Key or None
1809
    """
1810
    assert copy_id
1✔
1811

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