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

snarfed / bridgy-fed / 81379d96-43fe-45d4-a87b-08be2d6a94a7

17 Apr 2025 11:53PM UTC coverage: 93.157% (-0.03%) from 93.184%
81379d96-43fe-45d4-a87b-08be2d6a94a7

push

circleci

snarfed
ids.translate_user_id: handle our own subdomain-wrapped ids

eg https://bsky.brid.gy/ap/did:plc:456

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

84 existing lines in 3 files now uncovered.

4765 of 5115 relevant lines covered (93.16%)

0.93 hits per line

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

95.24
/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
# See https://www.cloudimage.io/
85
IMAGE_PROXY_URL_BASE = 'https://aujtzahimq.cloudimg.io/v7/'
1✔
86
IMAGE_PROXY_DOMAINS = ('threads.net',)
1✔
87

88
USER_STATUS_DESCRIPTIONS = {  # keep in sync with DM.type!
1✔
89
    'no-feed-or-webmention': "your web site doesn't have an RSS or Atom feed or webmention endpoint",
90
    'nobot': "your profile has 'nobot' in it",
91
    'nobridge': "your profile has 'nobridge' in it",
92
    'opt-out': 'your account or instance has requested to be opted out',
93
    'owns-webfinger': 'your web site looks like a fediverse instance because it already serves Webfinger',
94
    'private': 'your account is set as private or protected',
95
    'requires-avatar': "you haven't set a profile picture",
96
    'requires-name': "you haven't set a profile name that's different from your username",
97
    'requires-old-account': f"your account is less than {humanize.naturaldelta(OLD_ACCOUNT_AGE)} old",
98
    'unsupported-handle-ap': f"<a href='https://fed.brid.gy/docs#fediverse-get-started'>your username has characters that Bridgy Fed doesn't currently support</a>",
99
}
100

101
logger = logging.getLogger(__name__)
1✔
102

103

104
class Target(ndb.Model):
1✔
105
    r""":class:`protocol.Protocol` + URI pairs for identifying objects.
106

107
    These are currently used for:
108

109
    * delivery destinations, eg ActivityPub inboxes, webmention targets, etc.
110
    * copies of :class:`Object`\s and :class:`User`\s elsewhere,
111
      eg ``at://`` URIs for ATProto records, nevent etc bech32-encoded Nostr ids,
112
      ATProto user DIDs, etc.
113

114
    Used in :class:`google.cloud.ndb.model.StructuredProperty`\s inside
115
    :class:`Object` and :class:`User`; not stored as top-level entities in the
116
    datastore.
117

118
    ndb implements this by hoisting each property here into a corresponding
119
    property on the parent entity, prefixed by the StructuredProperty name
120
    below, eg ``delivered.uri``, ``delivered.protocol``, etc.
121

122
    For repeated StructuredPropertys, the hoisted properties are all repeated on
123
    the parent entity, and reconstructed into StructuredPropertys based on their
124
    order.
125

126
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
127
    """
128
    uri = ndb.StringProperty(required=True)
1✔
129
    ''
1✔
130
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
131
    ''
1✔
132

133
    def __eq__(self, other):
1✔
134
        """Equality excludes Targets' :class:`Key`."""
135
        return self.uri == other.uri and self.protocol == other.protocol
1✔
136

137
    def __hash__(self):
1✔
138
        """Allow hashing so these can be dict keys."""
139
        return hash((self.protocol, self.uri))
1✔
140

141

142
class DM(ndb.Model):
1✔
143
    """:class:`protocol.Protocol` + type pairs for identifying sent DMs.
144

145
    Used in :attr:`User.sent_dms`.
146

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

166
    def __eq__(self, other):
1✔
167
        """Equality excludes Targets' :class:`Key`."""
168
        return self.type == other.type and self.protocol == other.protocol
1✔
169

170

171
class ProtocolUserMeta(type(ndb.Model)):
1✔
172
    """:class:`User` metaclass. Registers all subclasses in the ``PROTOCOLS`` global."""
173
    def __new__(meta, name, bases, class_dict):
1✔
174
        cls = super().__new__(meta, name, bases, class_dict)
1✔
175

176
        if hasattr(cls, 'LABEL') and cls.LABEL not in ('protocol', 'user'):
1✔
177
            for label in (cls.LABEL, cls.ABBREV) + cls.OTHER_LABELS:
1✔
178
                if label:
1✔
179
                    PROTOCOLS[label] = cls
1✔
180

181
        PROTOCOLS_BY_KIND[cls._get_kind()] = cls
1✔
182

183
        return cls
1✔
184

185

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

194

195
class User(StringIdModel, metaclass=ProtocolUserMeta):
1✔
196
    """Abstract base class for a Bridgy Fed user.
197

198
    Stores some protocols' keypairs. Currently:
199

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

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

221
    public_exponent = ndb.StringProperty()
1✔
222
    """Part of this user's bridged ActivityPub actor's private key."""
1✔
223
    private_exponent = ndb.StringProperty()
1✔
224
    """Part of this user's bridged ActivityPub actor's private key."""
1✔
225

226
    manual_opt_out = ndb.BooleanProperty()
1✔
227
    """Set to True for users who asked to be opted out."""
1✔
228

229
    enabled_protocols = ndb.StringProperty(repeated=True,
1✔
230
                                           choices=list(PROTOCOLS.keys()))
231
    """Protocols that this user has explicitly opted into.
1✔
232

233
    Protocols that don't require explicit opt in are omitted here. ``choices``
234
    is populated in :func:`reset_protocol_properties`.
235
    """
236

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

240
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
241
    ''
1✔
242
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
243
    ''
1✔
244

245
    # `existing` attr is set by get_or_create
246

247
    # OLD. some stored entities still have these; do not reuse.
248
    # direct = ndb.BooleanProperty(default=False)
249
    # actor_as2 = JsonProperty()
250
    # protocol-specific state
251
    # atproto_notifs_indexed_at = ndb.TextProperty()
252
    # atproto_feed_indexed_at = ndb.TextProperty()
253

254
    def __init__(self, **kwargs):
1✔
255
        """Constructor.
256

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

264
        if obj:
1✔
265
            self.obj = obj
1✔
266

267
        self.lock = Lock()
1✔
268

269
    @classmethod
1✔
270
    def new(cls, **kwargs):
1✔
271
        """Try to prevent instantiation. Use subclasses instead."""
UNCOV
272
        raise NotImplementedError()
×
273

274
    def _post_put_hook(self, future):
1✔
275
        logger.debug(f'Wrote {self.key}')
1✔
276

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

280
        Args:
281
          prop (str)
282
          val
283
        """
284
        with self.lock:
1✔
285
            added = util.add(getattr(self, prop), val)
1✔
286

287
        if prop == 'copies' and added:
1✔
288
            memcache.pickle_memcache.set(memcache.memoize_key(
1✔
289
                get_original_user_key, val.uri), self.key)
290

291
    @classmethod
1✔
292
    def get_by_id(cls, id, allow_opt_out=False, **kwargs):
1✔
293
        """Override to follow ``use_instead`` property and ``status``.
294

295
        Returns None if the user is opted out.
296
        """
297
        user = cls._get_by_id(id, **kwargs)
1✔
298
        if user and user.use_instead:
1✔
299
            logger.info(f'{user.key} use_instead => {user.use_instead}')
1✔
300
            user = user.use_instead.get()
1✔
301

302
        if not user:
1✔
303
            return None
1✔
304

305
        if user.status and not allow_opt_out:
1✔
306
            logger.info(f'{user.key} is {user.status}')
1✔
307
            return None
1✔
308

309
        return user
1✔
310

311
    @classmethod
1✔
312
    def get_or_create(cls, id, propagate=False, allow_opt_out=False,
1✔
313
                      reload=False, **kwargs):
314
        """Loads and returns a :class:`User`. Creates it if necessary.
315

316
        Not transactional because transactions don't read or write memcache. :/
317
        Fortunately we don't really depend on atomicity for anything, last
318
        writer wins is pretty much always fine.
319

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

328
        Returns:
329
          User: existing or new user, or None if the user is opted out
330
        """
331
        assert cls != User
1✔
332

333
        user = cls.get_by_id(id, allow_opt_out=True)
1✔
334
        if user:
1✔
335
            if reload:
1✔
336
                user.reload_profile(gateway=True, raise_=False)
1✔
337

338
            if user.status and not allow_opt_out:
1✔
339
                return None
1✔
340
            user.existing = True
1✔
341

342
            # TODO: propagate more fields?
343
            changed = False
1✔
344
            for field in ['obj', 'obj_key']:
1✔
345
                old_val = getattr(user, field, None)
1✔
346
                new_val = kwargs.get(field)
1✔
347
                if old_val is None and new_val is not None:
1✔
348
                    setattr(user, field, new_val)
×
UNCOV
349
                    changed = True
×
350

351
            if enabled_protocols := kwargs.get('enabled_protocols'):
1✔
352
                user.enabled_protocols = (set(user.enabled_protocols)
1✔
353
                                          | set(enabled_protocols))
354
                changed = True
1✔
355

356
            if not propagate:
1✔
357
                if changed:
1✔
358
                    user.put()
1✔
359
                return user
1✔
360

361
        else:
362
            if orig_key := get_original_user_key(id):
1✔
363
                orig = orig_key.get()
1✔
364
                if orig.status and not allow_opt_out:
1✔
UNCOV
365
                    return None
×
366
                orig.existing = False
1✔
367
                return orig
1✔
368

369
            user = cls(id=id, **kwargs)
1✔
370
            user.existing = False
1✔
371
            user.reload_profile(gateway=True, raise_=False)
1✔
372
            if user.status and not allow_opt_out:
1✔
373
                return None
1✔
374

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

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

405
        try:
1✔
406
            user.put()
1✔
407
        except AssertionError as e:
×
UNCOV
408
            error(f'Bad {cls.__name__} id {id} : {e}')
×
409

410
        logger.debug(('Updated ' if user.existing else 'Created new ') + str(user))
1✔
411
        return user
1✔
412

413
    @property
1✔
414
    def obj(self):
1✔
415
        """Convenience accessor that loads :attr:`obj_key` from the datastore."""
416
        if self.obj_key:
1✔
417
            if not hasattr(self, '_obj'):
1✔
418
                self._obj = self.obj_key.get()
1✔
419
            return self._obj
1✔
420

421
    @obj.setter
1✔
422
    def obj(self, obj):
1✔
423
        if obj:
1✔
424
            assert isinstance(obj, Object)
1✔
425
            assert obj.key
1✔
426
            self._obj = obj
1✔
427
            self.obj_key = obj.key
1✔
428
        else:
429
            self._obj = self.obj_key = None
1✔
430

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

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

449
    @classmethod
1✔
450
    def load_multi(cls, users):
1✔
451
        """Loads :attr:`obj` for multiple users in parallel.
452

453
        Args:
454
          users (sequence of User)
455
        """
456
        objs = ndb.get_multi(u.obj_key for u in users if u.obj_key)
1✔
457
        keys_to_objs = {o.key: o for o in objs if o}
1✔
458

459
        for u in users:
1✔
460
            u._obj = keys_to_objs.get(u.obj_key)
1✔
461

462
    @ndb.ComputedProperty
1✔
463
    def handle(self):
1✔
464
        """This user's unique, human-chosen handle, eg ``@me@snarfed.org``.
465

466
        To be implemented by subclasses.
467
        """
UNCOV
468
        raise NotImplementedError()
×
469

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

576
        return False
1✔
577

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

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

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

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

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

606
        added = False
1✔
607

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

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

623
            return user
1✔
624

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

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

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

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

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

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

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

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

660
        Args:
661
          to_proto (str or Protocol)
662

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

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

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

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

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

688
        Args:
689
          to_proto (str or Protocol)
690

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

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

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

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

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

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

731
        return self.handle_or_id()
1✔
732

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

736
        To be implemented by subclasses.
737

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

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

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

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

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

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

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

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

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

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

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

784
        Examples:
785

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

790
        Defaults to this user's key id.
791

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

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

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

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

809
        # write the user so that we re-populate any computed properties
810
        self.put()
1✔
811

812
    def user_page_path(self, rest=None, prefer_id=False):
1✔
813
        """Returns the user's Bridgy Fed user page path.
814

815
        Args:
816
          rest (str): additional path and/or query to add to the end
817
          prefer_id (bool): whether to prefer to use the account's id in the path
818
            instead of handle. Defaults to ``False``.
819
        """
820
        path = f'/{self.ABBREV}/{self.key.id() if prefer_id else self.handle_or_id()}'
1✔
821

822
        if rest:
1✔
823
            if not (rest.startswith('?') or rest.startswith('/')):
1✔
824
                path += '/'
1✔
825
            path += rest
1✔
826

827
        return path
1✔
828

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

832
        ...or None if no such copy exists. If ``proto`` is this user, returns
833
        this user's key id.
834

835
        Args:
836
          proto: :class:`Protocol` subclass
837

838
        Returns:
839
          str:
840
        """
841
        # don't use isinstance because the testutil Fake protocol has subclasses
842
        if self.LABEL == proto.LABEL:
1✔
843
            return self.key.id()
1✔
844

845
        for copy in self.copies:
1✔
846
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
847
                return copy.uri
1✔
848

849
    def user_link(self, name=True, handle=True, pictures=False, proto=None,
1✔
850
                  proto_fallback=False):
851
        """Returns a pretty HTML link to the user's profile.
852

853
        Can optionally include display name, handle, profile
854
        picture, and/or link to a different protocol that they've enabled.
855

856
        TODO: unify with :meth:`Object.actor_link`?
857

858
        Args:
859
          name (bool): include display name
860
          handle (bool): include handle
861
          pictures (bool): include profile picture and protocol logo
862
          proto (protocol.Protocol): link to this protocol instead of the user's
863
            native protocol
864
          proto_fallback (bool): if True, and ``proto`` is provided and has no
865
            no canonical profile URL for bridged users, uses the user's profile
866
            URL in their native protocol
867
        """
868
        img = name_str = handle_str = dot = logo = a_open = a_close = ''
1✔
869

870
        if proto:
1✔
871
            assert self.is_enabled(proto), f"{proto.LABEL} isn't enabled"
1✔
872
            url = proto.bridged_web_url_for(self, fallback=proto_fallback)
1✔
873
        else:
874
            proto = self.__class__
1✔
875
            url = self.web_url()
1✔
876

877
        if pictures:
1✔
878
            logo = f'<span class="logo" title="{proto.__name__}">{proto.LOGO_HTML}</span> '
1✔
879
            if pic := self.profile_picture():
1✔
880
                img = f'<img src="{pic}" class="profile"> '
1✔
881

882
        if handle:
1✔
883
            handle_str = self.handle_as(proto) or ''
1✔
884

885
        if name and self.name() != handle_str:
1✔
886
            name_str = self.name() or ''
1✔
887

888
        if handle_str and name_str:
1✔
889
            dot = ' &middot; '
1✔
890

891
        if url:
1✔
892
            a_open = f'<a class="h-card u-author" rel="me" href="{url}" title="{name_str}{dot}{handle_str}">'
1✔
893
            a_close = '</a>'
1✔
894

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

898
    def profile_picture(self):
1✔
899
        """Returns the user's profile picture image URL, if available, or None."""
900
        if self.obj and self.obj.as1:
1✔
901
            return util.get_url(self.obj.as1, 'image')
1✔
902

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

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

921
        num_followers = Follower.query(Follower.to == self.key,
1✔
922
                                       Follower.status == 'active')\
923
                                .count_async()
924
        num_following = Follower.query(Follower.from_ == self.key,
1✔
925
                                       Follower.status == 'active')\
926
                                .count_async()
927
        return num_followers.get_result(), num_following.get_result()
1✔
928

929

930
class Object(StringIdModel):
1✔
931
    """An activity or other object, eg actor.
932

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

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

945
    source_protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()))
1✔
946
    """The protocol this object originally came from.
1✔
947

948
    ``choices`` is populated in :func:`reset_protocol_properties`, after all
949
    :class:`User` subclasses are created, so that :attr:`PROTOCOLS` is fully
950
    populated.
951

952
    TODO: nail down whether this is :attr:`ABBREV`` or :attr:`LABEL`
953
    """
954

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

968
    # TODO: remove and actually delete Objects instead!
969
    deleted = ndb.BooleanProperty()
1✔
970
    ''
1✔
971

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

979
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
980
    ''
1✔
981
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
982
    ''
1✔
983

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

994
    lock = None
1✔
995
    """Synchronizes :meth:`add` and :meth:`remove`."""
1✔
996

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

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

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

1020
    # DEPRECATED; replaced by :attr:`users`, :attr:`notify`, :attr:`feed`
1021
    #
1022
    # labels = ndb.StringProperty(repeated=True,
1023
    #                             choices=('activity', 'feed', 'notification', 'user'))
1024

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

1034
            for field in 'author', 'actor', 'object':
1✔
1035
                if inner := as1.get_object(obj, field):
1✔
1036
                    use_urls_as_ids(inner)
1✔
1037

1038
        if self.our_as1:
1✔
1039
            obj = self.our_as1
1✔
1040
            if self.source_protocol == 'web':
1✔
1041
                use_urls_as_ids(obj)
1✔
1042

1043
        elif self.as2:
1✔
1044
            obj = as2.to_as1(unwrap(self.as2))
1✔
1045

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

1057
        elif self.mf2:
1✔
1058
            obj = microformats2.json_to_object(self.mf2,
1✔
1059
                                               rel_urls=self.mf2.get('rel-urls'))
1060
            use_urls_as_ids(obj)
1✔
1061

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

1068
        else:
1069
            return None
1✔
1070

1071
        # populate id if necessary
1072
        if self.key:
1✔
1073
            obj.setdefault('id', self.key.id())
1✔
1074

1075
        if util.domain_or_parent_in(util.domain_from_link(obj.get('id')),
1✔
1076
                                    IMAGE_PROXY_DOMAINS):
1077
           as1.prefix_urls(obj, 'image', IMAGE_PROXY_URL_BASE)
1✔
1078

1079
        return obj
1✔
1080

1081
    @ndb.ComputedProperty
1✔
1082
    def type(self):  # AS1 objectType, or verb if it's an activity
1✔
1083
        if self.as1:
1✔
1084
            return as1.object_type(self.as1)
1✔
1085

1086
    def __init__(self, *args, **kwargs):
1✔
1087
        super().__init__(*args, **kwargs)
1✔
1088
        self.lock = Lock()
1✔
1089

1090
    def _expire(self):
1✔
1091
        """Automatically delete most Objects after a while using a TTL policy.
1092

1093
        https://cloud.google.com/datastore/docs/ttl
1094

1095
        They recommend not indexing TTL properties:
1096
        https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
1097
        """
1098
        if self.deleted or self.type not in DONT_EXPIRE_OBJECT_TYPES:
1✔
1099
            return (self.updated or util.now()) + OBJECT_EXPIRE_AGE
1✔
1100

1101
    expire = ndb.ComputedProperty(_expire, indexed=False)
1✔
1102

1103
    def _pre_put_hook(self):
1✔
1104
        """
1105
        * Validate that at:// URIs have DID repos
1106
        * Set/remove the activity label
1107
        * Strip @context from as2 (we don't do LD) to save disk space
1108
        """
1109
        id = self.key.id()
1✔
1110

1111
        if self.source_protocol not in (None, 'ui'):
1✔
1112
            proto = PROTOCOLS[self.source_protocol]
1✔
1113
            assert proto.owns_id(id) is not False, \
1✔
1114
                f'Protocol {proto.LABEL} does not own id {id}'
1115

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

1127
        if self.as2:
1✔
1128
           self.as2.pop('@context', None)
1✔
1129
           for field in 'actor', 'attributedTo', 'author', 'object':
1✔
1130
               for val in util.get_list(self.as2, field):
1✔
1131
                   if isinstance(val, dict):
1✔
1132
                       val.pop('@context', None)
1✔
1133

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

1138
    @classmethod
1✔
1139
    def get_by_id(cls, id, authed_as=None, **kwargs):
1✔
1140
        """Fetches the :class:`Object` with the given id, if it exists.
1141

1142
        Args:
1143
          id (str)
1144
          authed_as (str): optional; if provided, and a matching :class:`Object`
1145
            already exists, its ``author`` or ``actor`` must contain this actor
1146
            id. Implements basic authorization for updates and deletes.
1147

1148
        Returns:
1149
          Object:
1150

1151
        Raises:
1152
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1153
            the existing object
1154
        """
1155
        obj = super().get_by_id(id, **kwargs)
1✔
1156

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

1174
        return obj
1✔
1175

1176
    @classmethod
1✔
1177
    def get_or_create(cls, id, authed_as=None, **props):
1✔
1178
        """Returns an :class:`Object` with the given property values.
1179

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

1184
        Not transactional because transactions don't read or write memcache. :/
1185
        Fortunately we don't really depend on atomicity for anything, last
1186
        writer wins is pretty much always fine.
1187

1188
        Args:
1189
          authed_as (str): optional; if provided, and a matching :class:`Object`
1190
            already exists, its ``author`` or ``actor`` must contain this actor
1191
            id. Implements basic authorization for updates and deletes.
1192

1193
        Returns:
1194
          Object:
1195

1196
        Raises:
1197
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1198
            the existing object
1199
        """
1200
        obj = cls.get_by_id(id, authed_as=authed_as)
1✔
1201

1202
        if not obj:
1✔
1203
            obj = Object(id=id, **props)
1✔
1204
            obj.new = True
1✔
1205
            obj.changed = False
1✔
1206
            obj.put()
1✔
1207
            return obj
1✔
1208

1209
        if orig_as1 := obj.as1:
1✔
1210
            # get_by_id() checks authorization if authed_as is set. make sure
1211
            # it's always set for existing objects.
1212
            assert authed_as
1✔
1213

1214
        dirty = False
1✔
1215
        for prop, val in props.items():
1✔
1216
            assert not isinstance(getattr(Object, prop), ndb.ComputedProperty)
1✔
1217
            if prop in ('feed', 'copies', 'notify', 'users'):
1✔
1218
                # merge repeated fields
1219
                for elem in val:
1✔
1220
                    if obj.add(prop, elem):
1✔
1221
                        dirty = True
1✔
1222
            elif val and val != getattr(obj, prop):
1✔
1223
                setattr(obj, prop, val)
1✔
1224
                if prop in ('as2', 'bsky', 'mf2', 'raw') and not props.get('our_as1'):
1✔
1225
                    obj.our_as1 = None
1✔
1226
                dirty = True
1✔
1227

1228
        obj.new = False
1✔
1229
        obj.changed = obj.activity_changed(orig_as1)
1✔
1230
        if dirty:
1✔
1231
            obj.put()
1✔
1232
        return obj
1✔
1233

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

1237
        Args:
1238
          prop (str)
1239
          val
1240

1241
        Returns:
1242
          True if val was added, ie it wasn't already in prop, False otherwise
1243
        """
1244
        with self.lock:
1✔
1245
            added = util.add(getattr(self, prop), val)
1✔
1246

1247
        if prop == 'copies' and added:
1✔
1248
            memcache.pickle_memcache.set(memcache.memoize_key(
1✔
1249
                get_original_object_key, val.uri), self.key)
1250

1251
        return added
1✔
1252

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

1256
        Args:
1257
          prop (str)
1258
          val
1259
        """
1260
        with self.lock:
×
UNCOV
1261
            getattr(self, prop).remove(val)
×
1262

1263
    @staticmethod
1✔
1264
    def from_request():
1✔
1265
        """Creates and returns an :class:`Object` from form-encoded JSON parameters.
1266

1267
        Parameters:
1268
          obj_id (str): id of :class:`models.Object` to handle
1269
          *: If ``obj_id`` is unset, all other parameters are properties for a
1270
            new :class:`models.Object` to handle
1271
        """
1272
        if obj_id := request.form.get('obj_id'):
1✔
1273
            return Object.get_by_id(obj_id)
1✔
1274

1275
        props = {field: request.form.get(field)
1✔
1276
                 for field in ('id', 'source_protocol')}
1277

1278
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1279
            if val := request.form.get(json_prop):
1✔
1280
                props[json_prop] = json_loads(val)
1✔
1281

1282
        obj = Object(**props)
1✔
1283
        if not obj.key and obj.as1:
1✔
1284
            if id := obj.as1.get('id'):
1✔
1285
                obj.key = ndb.Key(Object, id)
1✔
1286

1287
        return obj
1✔
1288

1289
    def to_request(self):
1✔
1290
        """Returns a query parameter dict representing this :class:`Object`."""
1291
        form = {}
1✔
1292

1293
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1294
            if val := getattr(self, json_prop, None):
1✔
1295
                form[json_prop] = json_dumps(val, sort_keys=True)
1✔
1296

1297
        for prop in ['source_protocol']:
1✔
1298
            if val := getattr(self, prop):
1✔
1299
                form[prop] = val
1✔
1300

1301
        if self.key:
1✔
1302
            form['id'] = self.key.id()
1✔
1303

1304
        return form
1✔
1305

1306
    def activity_changed(self, other_as1):
1✔
1307
        """Returns True if this activity is meaningfully changed from ``other_as1``.
1308

1309
        ...otherwise False.
1310

1311
        Used to populate :attr:`changed`.
1312

1313
        Args:
1314
          other_as1 (dict): AS1 object, or none
1315
        """
1316
        # ignore inReplyTo since we translate it between protocols
1317
        return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
1✔
1318
                if self.as1 and other_as1
1319
                else bool(self.as1) != bool(other_as1))
1320

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

1324
        TODO: unify with :meth:`User.user_link`?
1325

1326
        Args:
1327
          image (bool): whether to include an ``img`` tag with the actor's picture
1328
          sized (bool): whether to set an explicit (``width=32``) size on the
1329
            profile picture ``img`` tag
1330
          user (User): current user
1331

1332
        Returns:
1333
          str:
1334
        """
1335
        attrs = {'class': 'h-card u-author'}
1✔
1336

1337
        if user and user.key in self.users:
1✔
1338
            # outbound; show a nice link to the user
1339
            return user.user_link(handle=False, pictures=True)
1✔
1340

1341
        proto = PROTOCOLS.get(self.source_protocol)
1✔
1342

1343
        actor = None
1✔
1344
        if self.as1:
1✔
1345
            actor = (as1.get_object(self.as1, 'actor')
1✔
1346
                     or as1.get_object(self.as1, 'author'))
1347
            # hydrate from datastore if available
1348
            # TODO: optimize! this is called serially in loops, eg in home.html
1349
            if set(actor.keys()) == {'id'} and self.source_protocol:
1✔
1350
                actor_obj = proto.load(actor['id'], remote=False)
1✔
1351
                if actor_obj and actor_obj.as1:
1✔
1352
                    actor = actor_obj.as1
1✔
1353

1354
        if not actor:
1✔
1355
            return ''
1✔
1356
        elif set(actor.keys()) == {'id'}:
1✔
1357
            return common.pretty_link(actor['id'], attrs=attrs, user=user)
1✔
1358

1359
        url = as1.get_url(actor)
1✔
1360
        name = actor.get('displayName') or actor.get('username') or ''
1✔
1361
        img_url = util.get_url(actor, 'image')
1✔
1362
        if not image or not img_url:
1✔
1363
            return common.pretty_link(url, text=name, attrs=attrs, user=user)
1✔
1364

1365
        logo = ''
1✔
1366
        if proto:
1✔
UNCOV
1367
            logo = f'<span class="logo" title="{self.__class__.__name__}">{proto.LOGO_HTML}</span>'
×
1368

1369
        return f"""\
1✔
1370
        {logo}
1371
        <a class="h-card u-author" href="{url}" title="{name}">
1372
          <img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
1373
          <span style="unicode-bidi: isolate">{util.ellipsize(name, chars=40)}</span>
1374
        </a>"""
1375

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

1379
        ...or None if no such copy exists. If ``proto`` is ``source_protocol``,
1380
        returns this object's key id.
1381

1382
        Args:
1383
          proto: :class:`Protocol` subclass
1384

1385
        Returns:
1386
          str:
1387
        """
1388
        if self.source_protocol in (proto.LABEL, proto.ABBREV):
1✔
1389
            return self.key.id()
1✔
1390

1391
        for copy in self.copies:
1✔
1392
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
1393
                return copy.uri
1✔
1394

1395
    def resolve_ids(self):
1✔
1396
        """Resolves "copy" ids, subdomain ids, etc with their originals.
1397

1398
        The end result is that all ids are original "source" ids, ie in the
1399
        protocol that they first came from.
1400

1401
        Specifically, resolves:
1402

1403
        * ids in :class:`User.copies` and :class:`Object.copies`, eg ATProto
1404
          records and Nostr events that we bridged, to the ids of their
1405
          original objects in their source protocol, eg
1406
          ``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
1407
        * Bridgy Fed subdomain URLs to the ids embedded inside them, eg
1408
          ``https://bsky.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
1409
        * ATProto bsky.app URLs to their DIDs or `at://` URIs, eg
1410
          ``https://bsky.app/profile/a.com`` => ``did:plc:123``
1411

1412
        ...in these AS1 fields, in place:
1413

1414
        * ``id``
1415
        * ``actor``
1416
        * ``author``
1417
        * ``object``
1418
        * ``object.actor``
1419
        * ``object.author``
1420
        * ``object.id``
1421
        * ``object.inReplyTo``
1422
        * ``tags.[objectType=mention].url``
1423

1424
        :meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
1425
        Much of the same logic is duplicated there!
1426

1427
        TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
1428
        """
1429
        if not self.as1:
1✔
1430
            return
1✔
1431

1432
        # extract ids, strip Bridgy Fed subdomain URLs
1433
        outer_obj = unwrap(self.as1)
1✔
1434
        if outer_obj != self.as1:
1✔
1435
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1436

1437
        self_proto = PROTOCOLS.get(self.source_protocol)
1✔
1438
        if not self_proto:
1✔
1439
            return
1✔
1440

1441
        inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
1✔
1442
        replaced = False
1✔
1443

1444
        def replace(val, orig_fn):
1✔
1445
            id = val.get('id') if isinstance(val, dict) else val
1✔
1446
            if not id or not self_proto.HAS_COPIES:
1✔
1447
                return id
1✔
1448

1449
            orig = orig_fn(id)
1✔
1450
            if not orig:
1✔
1451
                return val
1✔
1452

1453
            nonlocal replaced
1454
            replaced = True
1✔
1455
            logger.debug(f'Resolved copy id {val} to original {orig.id()}')
1✔
1456

1457
            if isinstance(val, dict) and util.trim_nulls(val).keys() > {'id'}:
1✔
1458
                val['id'] = orig.id()
1✔
1459
                return val
1✔
1460
            else:
1461
                return orig.id()
1✔
1462

1463
        # actually replace ids
1464
        #
1465
        # object field could be either object (eg repost) or actor (eg follow)
1466
        outer_obj['object'] = replace(inner_obj, get_original_object_key)
1✔
1467
        if not replaced:
1✔
1468
            outer_obj['object'] = replace(inner_obj, get_original_user_key)
1✔
1469

1470
        for obj in outer_obj, inner_obj:
1✔
1471
            for tag in as1.get_objects(obj, 'tags'):
1✔
1472
                if tag.get('objectType') == 'mention':
1✔
1473
                    tag['url'] = replace(tag.get('url'), get_original_user_key)
1✔
1474
            for field, fn in (
1✔
1475
                    ('actor', get_original_user_key),
1476
                    ('author', get_original_user_key),
1477
                    ('inReplyTo', get_original_object_key),
1478
                ):
1479
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1480
                if len(obj[field]) == 1:
1✔
1481
                    obj[field] = obj[field][0]
1✔
1482

1483
        if replaced:
1✔
1484
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1485

1486
    def normalize_ids(self):
1✔
1487
        """Normalizes ids to their protocol's canonical representation, if any.
1488

1489
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
1490
        for profiles, ``at://`` URIs for posts.
1491

1492
        Modifies this object in place.
1493

1494
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
1495
        """
1496
        from protocol import Protocol
1✔
1497

1498
        if not self.as1:
1✔
1499
            return
1✔
1500

1501
        logger.debug(f'Normalizing ids')
1✔
1502
        outer_obj = copy.deepcopy(self.as1)
1✔
1503
        inner_objs = as1.get_objects(outer_obj)
1✔
1504
        replaced = False
1✔
1505

1506
        def replace(val, translate_fn):
1✔
1507
            nonlocal replaced
1508

1509
            orig = val.get('id') if isinstance(val, dict) else val
1✔
1510
            if not orig:
1✔
1511
                return val
1✔
1512

1513
            proto = Protocol.for_id(orig, remote=False)
1✔
1514
            if not proto:
1✔
1515
                return val
1✔
1516

1517
            translated = translate_fn(id=orig, from_=proto, to=proto)
1✔
1518
            if translated and translated != orig:
1✔
1519
                # logger.debug(f'Normalized {proto.LABEL} id {orig} to {translated}')
1520
                replaced = True
1✔
1521
                if isinstance(val, dict):
1✔
1522
                    val['id'] = translated
1✔
1523
                    return val
1✔
1524
                else:
1525
                    return translated
1✔
1526

1527
            return val
1✔
1528

1529
        # actually replace ids
1530
        for obj in [outer_obj] + inner_objs:
1✔
1531
            for tag in as1.get_objects(obj, 'tags'):
1✔
1532
                if tag.get('objectType') == 'mention':
1✔
1533
                    tag['url'] = replace(tag.get('url'), ids.translate_user_id)
1✔
1534
            for field in ['actor', 'author', 'inReplyTo']:
1✔
1535
                fn = (ids.translate_object_id if field == 'inReplyTo'
1✔
1536
                      else ids.translate_user_id)
1537
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1538
                if len(obj[field]) == 1:
1✔
1539
                    obj[field] = obj[field][0]
1✔
1540

1541
        outer_obj['object'] = []
1✔
1542
        for inner_obj in inner_objs:
1✔
1543
            translate_fn = (ids.translate_user_id
1✔
1544
                            if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
1545
                                or as1.object_type(outer_obj) in
1546
                                ('follow', 'stop-following'))
1547
                            else ids.translate_object_id)
1548

1549
            got = replace(inner_obj, translate_fn)
1✔
1550
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
1551
                got = got['id']
1✔
1552

1553
            outer_obj['object'].append(got)
1✔
1554

1555
        if len(outer_obj['object']) == 1:
1✔
1556
            outer_obj['object'] = outer_obj['object'][0]
1✔
1557

1558
        if replaced:
1✔
1559
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1560

1561

1562
class Follower(ndb.Model):
1✔
1563
    """A follower of a Bridgy Fed user."""
1564
    STATUSES = ('active', 'inactive')
1✔
1565

1566
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
1567
    """The follower."""
1✔
1568
    to = ndb.KeyProperty(required=True)
1✔
1569
    """The followee, ie the user being followed."""
1✔
1570

1571
    follow = ndb.KeyProperty(Object)
1✔
1572
    """The last follow activity."""
1✔
1573
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
1574
    """Whether this follow is active or note."""
1✔
1575

1576
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1577
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1578

1579
    # OLD. some stored entities still have these; do not reuse.
1580
    # src = ndb.StringProperty()
1581
    # dest = ndb.StringProperty()
1582
    # last_follow = JsonProperty()
1583

1584
    def _pre_put_hook(self):
1✔
1585
        # we're a bridge! stick with bridging.
1586
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
1587

1588
    def _post_put_hook(self, future):
1✔
1589
        logger.debug(f'Wrote {self.key}')
1✔
1590

1591
    @classmethod
1✔
1592
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
1593
        """Returns a Follower with the given ``from_`` and ``to`` users.
1594

1595
        Not transactional because transactions don't read or write memcache. :/
1596
        Fortunately we don't really depend on atomicity for anything, last
1597
        writer wins is pretty much always fine.
1598

1599
        If a matching :class:`Follower` doesn't exist in the datastore, creates
1600
        it first.
1601

1602
        Args:
1603
          from_ (User)
1604
          to (User)
1605

1606
        Returns:
1607
          Follower:
1608
        """
1609
        assert from_
1✔
1610
        assert to
1✔
1611

1612
        follower = Follower.query(Follower.from_ == from_.key,
1✔
1613
                                  Follower.to == to.key,
1614
                                  ).get()
1615
        if not follower:
1✔
1616
            follower = Follower(from_=from_.key, to=to.key, **kwargs)
1✔
1617
            follower.put()
1✔
1618
        elif kwargs:
1✔
1619
            # update existing entity with new property values, eg to make an
1620
            # inactive Follower active again
1621
            for prop, val in kwargs.items():
1✔
1622
                setattr(follower, prop, val)
1✔
1623
            follower.put()
1✔
1624

1625
        return follower
1✔
1626

1627
    @staticmethod
1✔
1628
    def fetch_page(collection, user):
1✔
1629
        r"""Fetches a page of :class:`Follower`\s for a given user.
1630

1631
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
1632
        parameters, if available in the request.
1633

1634
        Args:
1635
          collection (str): ``followers`` or ``following``
1636
          user (User)
1637

1638
        Returns:
1639
          (list of Follower, str, str) tuple: results, annotated with an extra
1640
          ``user`` attribute that holds the follower or following :class:`User`,
1641
          and new str query param values for ``before`` and ``after`` to fetch
1642
          the previous and next pages, respectively
1643
        """
1644
        assert collection in ('followers', 'following'), collection
1✔
1645

1646
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1647
        query = Follower.query(
1✔
1648
            Follower.status == 'active',
1649
            filter_prop == user.key,
1650
        )
1651

1652
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
1653
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
1654
                              for f in followers)
1655
        User.load_multi(u for u in users if u)
1✔
1656

1657
        for f, u in zip(followers, users):
1✔
1658
            f.user = u
1✔
1659
        followers = [f for f in followers if not f.user.status]
1✔
1660

1661
        return followers, before, after
1✔
1662

1663

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

1667
    Wraps :func:`fetch_page` and adds attributes to the returned
1668
    :class:`Object` entities for rendering in ``objects.html``.
1669

1670
    Args:
1671
      query (ndb.Query)
1672
      by (ndb.model.Property): either :attr:`Object.updated` or
1673
        :attr:`Object.created`
1674
      user (User): current user
1675

1676
    Returns:
1677
      (list of Object, str, str) tuple:
1678
      (results, new ``before`` query param, new ``after`` query param)
1679
      to fetch the previous and next pages, respectively
1680
    """
1681
    assert by is Object.updated or by is Object.created
1✔
1682
    objects, new_before, new_after = fetch_page(query, Object, by=by)
1✔
1683
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
1684

1685
    # synthesize human-friendly content for objects
1686
    for i, obj in enumerate(objects):
1✔
1687
        obj_as1 = obj.as1
1✔
1688
        type = as1.object_type(obj_as1)
1✔
1689

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

1714
        obj.phrase = phrases.get(type, '')
1✔
1715

1716
        content = (obj_as1.get('content')
1✔
1717
                   or obj_as1.get('displayName')
1718
                   or obj_as1.get('summary'))
1719
        if content:
1✔
1720
            content = util.parse_html(content).get_text()
1✔
1721

1722
        urls = as1.object_urls(obj_as1)
1✔
1723
        url = urls[0] if urls else None
1✔
1724
        if url and not content:
1✔
1725
            # heuristics for sniffing URLs and converting them to more friendly
1726
            # phrases and user handles.
1727
            # TODO: standardize this into granary.as2 somewhere?
1728
            from activitypub import FEDI_URL_RE
×
UNCOV
1729
            from atproto import COLLECTION_TO_TYPE, did_to_handle
×
1730

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

1749
            if handle:
×
1750
                if handle.startswith('did:'):
×
1751
                    handle = did_to_handle(handle) or handle
×
UNCOV
1752
                content = f'@{handle}{suffix}'
×
1753

1754
            if url:
×
UNCOV
1755
                content = common.pretty_link(url, text=content, user=user)
×
1756

1757
        obj.content = (obj_as1.get('content')
1✔
1758
                       or obj_as1.get('displayName')
1759
                       or obj_as1.get('summary'))
1760
        obj.url = util.get_first(obj_as1, 'url')
1✔
1761

1762
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
1763
            inner_as1 = as1.get_object(obj_as1)
1✔
1764
            obj.inner_url = inner_as1.get('url') or inner_as1.get('id')
1✔
1765
            if obj.url:
1✔
UNCOV
1766
                obj.phrase = common.pretty_link(
×
1767
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
1768
            if content:
1✔
1769
                obj.content = content
1✔
1770
                obj.url = url
1✔
1771

1772
    return objects, new_before, new_after
1✔
1773

1774

1775
def fetch_page(query, model_class, by=None):
1✔
1776
    """Fetches a page of results from a datastore query.
1777

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

1781
    Populates a ``log_url_path`` property on each result entity that points to a
1782
    its most recent logged request.
1783

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

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

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

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

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

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

1835
    new_before = (
1✔
1836
        after if after else
1837
        results[-1].updated if has_next
1838
        else None)
1839
    if new_before:
1✔
1840
        new_before = new_before.isoformat()
1✔
1841

1842
    return results, new_before, new_after
1✔
1843

1844

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

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

1853
    Args:
1854
      copy_id (str)
1855

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

1861
    return Object.query(Object.copies.uri == copy_id).get(keys_only=True)
1✔
1862

1863

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

1869
    Note that :meth:`User.add` also updates this function's
1870
    :func:`memcache.memoize` cache.
1871

1872
    Args:
1873
      copy_id (str)
1874
      not_proto (Protocol): optional, don't query this protocol
1875

1876
    Returns:
1877
      google.cloud.ndb.Key or None
1878
    """
1879
    assert copy_id
1✔
1880

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