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

snarfed / bridgy-fed / 96b24e63-cd9f-46af-9a9c-8aa884b18d4b

25 Aug 2025 01:54AM UTC coverage: 92.679%. First build
96b24e63-cd9f-46af-9a9c-8aa884b18d4b

push

circleci

snarfed
change Protocol.source_links language to say "follow [bot] to interact"

for #1903

25 of 26 new or added lines in 6 files covered. (96.15%)

5646 of 6092 relevant lines covered (92.68%)

0.93 hits per line

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

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

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

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

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

70
# maps string kind (eg 'MagicKey') to Protocol subclass.
71
# populated in ProtocolUserMeta
72
PROTOCOLS_BY_KIND = {}
1✔
73

74
# 2048 bits makes tests slow, so use 1024 for them
75
KEY_BITS = 1024 if DEBUG else 2048
1✔
76
PAGE_SIZE = 20
1✔
77

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

87
GET_ORIGINALS_CACHE_EXPIRATION = timedelta(days=1)
1✔
88
FOLLOWERS_CACHE_EXPIRATION = timedelta(hours=2)
1✔
89

90
# See https://www.cloudimage.io/
91
IMAGE_PROXY_URL_BASE = 'https://aujtzahimq.cloudimg.io/v7/'
1✔
92
IMAGE_PROXY_DOMAINS = ('threads.net',)
1✔
93

94
USER_STATUS_DESCRIPTIONS = {  # keep in sync with DM.type!
1✔
95
    'no-feed-or-webmention': "web site doesn't have an RSS or Atom feed or webmention endpoint",
96
    'nobot': "profile has 'nobot' in it",
97
    'nobridge': "profile has 'nobridge' in it",
98
    'no-nip05': "account's NIP-05 identifier is missing or invalid",
99
    'no-profile': 'profile is missing or empty',
100
    'opt-out': 'account or instance has requested to be opted out',
101
    'owns-webfinger': 'web site looks like a fediverse instance because it already serves Webfinger',
102
    'private': 'account is set as private or protected',
103
    'requires-avatar': "account doesn't have a profile picture",
104
    'requires-name': "account's name and username are the same",
105
    'requires-old-account': f"account is less than {humanize.naturaldelta(OLD_ACCOUNT_AGE)} old",
106
    'unsupported-handle-ap': f"<a href='https://fed.brid.gy/docs#fediverse-get-started'>username has characters that Bridgy Fed doesn't currently support</a>",
107
}
108

109
logger = logging.getLogger(__name__)
1✔
110

111

112
class Target(ndb.Model):
1✔
113
    r""":class:`protocol.Protocol` + URI pairs for identifying objects.
114

115
    These are currently used for:
116

117
    * delivery destinations, eg ActivityPub inboxes, webmention targets, etc.
118
    * copies of :class:`Object`\s and :class:`User`\s elsewhere,
119
      eg ``at://`` URIs for ATProto records, nevent etc bech32-encoded Nostr ids,
120
      ATProto user DIDs, etc.
121

122
    Used in :class:`google.cloud.ndb.model.StructuredProperty`\s inside
123
    :class:`Object` and :class:`User`; not stored as top-level entities in the
124
    datastore.
125

126
    ndb implements this by hoisting each property here into a corresponding
127
    property on the parent entity, prefixed by the StructuredProperty name
128
    below, eg ``delivered.uri``, ``delivered.protocol``, etc.
129

130
    For repeated StructuredPropertys, the hoisted properties are all repeated on
131
    the parent entity, and reconstructed into StructuredPropertys based on their
132
    order.
133

134
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
135
    """
136
    uri = ndb.StringProperty(required=True)
1✔
137
    ''
1✔
138
    # TODO: remove for Nostr launch
139
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()) + ['nostr'],
1✔
140
                                  required=True)
141
    ''
1✔
142

143
    def __eq__(self, other):
1✔
144
        """Equality excludes Targets' :class:`Key`."""
145
        if isinstance(other, Target):
1✔
146
            return self.uri == other.uri and self.protocol == other.protocol
1✔
147

148
    def __hash__(self):
1✔
149
        """Allow hashing so these can be dict keys."""
150
        return hash((self.protocol, self.uri))
1✔
151

152

153
class DM(ndb.Model):
1✔
154
    """:class:`protocol.Protocol` + type pairs for identifying sent DMs.
155

156
    Used in :attr:`User.sent_dms`.
157

158
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
159
    """
160
    type = ndb.StringProperty(required=True)
1✔
161
    """Known values (keep in sync with USER_STATUS_DESCRIPTIONS!):
1✔
162
      * no-feed-or-webmention
163
      * no-nip05
164
      * no-profile
165
      * opt-out
166
      * owns-webfinger
167
      * private
168
      * replied_to_bridged_user
169
      * request_bridging
170
      * requires-avatar
171
      * requires-name
172
      * requires-old-account
173
      * unsupported-handle-ap
174
      * welcome
175
    """
176
    # TODO: remove for Nostr launch
177
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()) + ['nostr'],
1✔
178
                                  required=True)
179
    ''
1✔
180

181
    def __eq__(self, other):
1✔
182
        """Equality excludes Targets' :class:`Key`."""
183
        return self.type == other.type and self.protocol == other.protocol
1✔
184

185

186
class ProtocolUserMeta(type(ndb.Model)):
1✔
187
    """:class:`User` metaclass. Registers all subclasses in ``PROTOCOLS``."""
188
    def __new__(meta, name, bases, class_dict):
1✔
189
        cls = super().__new__(meta, name, bases, class_dict)
1✔
190

191
        label = getattr(cls, 'LABEL', None)
1✔
192
        if (label and label not in ('protocol', 'user')
1✔
193
                and (DEBUG or cls.LABEL not in DEBUG_PROTOCOLS)):
194
            for label in (label, cls.ABBREV) + cls.OTHER_LABELS:
1✔
195
                if label:
1✔
196
                    PROTOCOLS[label] = cls
1✔
197
            PROTOCOLS_BY_KIND[cls._get_kind()] = cls
1✔
198

199
        return cls
1✔
200

201

202
def reset_protocol_properties():
1✔
203
    """Recreates various protocol properties to include choices from ``PROTOCOLS``."""
204
    abbrevs = f'({"|".join(PROTOCOLS.keys())}|fed)'
1✔
205
    common.SUBDOMAIN_BASE_URL_RE = re.compile(
1✔
206
        rf'^https?://({abbrevs}\.brid\.gy|localhost(:8080)?)/(convert/|r/)?({abbrevs}/)?(?P<path>.+)')
207
    ids.COPIES_PROTOCOLS = tuple(label for label, proto in PROTOCOLS.items()
1✔
208
                                 if proto and proto.HAS_COPIES)
209

210

211
@lru_cache(maxsize=100000)
1✔
212
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
213
def get_original_object_key(copy_id):
1✔
214
    """Finds the :class:`Object` with a given copy id, if any.
215

216
    Note that :meth:`Object.add` also updates this function's
217
    :func:`memcache.memoize` cache.
218

219
    Args:
220
      copy_id (str)
221

222
    Returns:
223
      google.cloud.ndb.Key or None
224
    """
225
    assert copy_id
1✔
226

227
    return Object.query(Object.copies.uri == copy_id).get(keys_only=True)
1✔
228

229

230
@lru_cache(maxsize=100000)
1✔
231
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
232
def get_original_user_key(copy_id):
1✔
233
    """Finds the user with a given copy id, if any.
234

235
    Note that :meth:`User.add` also updates this function's
236
    :func:`memcache.memoize` cache.
237

238
    Args:
239
      copy_id (str)
240

241
    Returns:
242
      google.cloud.ndb.Key or None
243
    """
244
    assert copy_id
1✔
245

246
    for proto in PROTOCOLS.values():
1✔
247
        if proto and proto.LABEL != 'ui' and not proto.owns_id(copy_id):
1✔
248
            if orig := proto.query(proto.copies.uri == copy_id).get(keys_only=True):
1✔
249
                return orig
1✔
250

251

252
class AddRemoveMixin:
1✔
253
    """Mixin class that defines the :meth:`add` and :meth:`remove` methods.
254

255
    If a subclass of this mixin defines the ``GET_ORIGINAL_FN`` class-level
256
    attribute, its memoize cache will be cleared when :meth:`remove` is called with
257
    the ``copies`` property.
258
    """
259

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

263
        Args:
264
          prop (str)
265
          val
266

267
        Returns:
268
          True if val was added, ie it wasn't already in prop, False otherwise
269
        """
270
        with self.lock:
1✔
271
            added = util.add(getattr(self, prop), val)
1✔
272

273
        if prop == 'copies' and added:
1✔
274
            if fn := getattr(self, 'GET_ORIGINAL_FN'):
1✔
275
                memcache.pickle_memcache.set(memcache.memoize_key(fn, val.uri),
1✔
276
                                             self.key)
277

278
        return added
1✔
279

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

283
        Args:
284
          prop (str)
285
          val
286
        """
287
        with self.lock:
1✔
288
            existing = getattr(self, prop)
1✔
289
            if val in existing:
1✔
290
                existing.remove(val)
1✔
291

292
        if prop == 'copies':
1✔
293
            self.clear_get_original_cache(val.uri)
1✔
294

295
    @classmethod
1✔
296
    def clear_get_original_cache(cls, uri):
1✔
297
        if fn := getattr(cls, 'GET_ORIGINAL_FN'):
1✔
298
            memcache.pickle_memcache.delete(memcache.memoize_key(fn, uri))
1✔
299

300

301
class User(StringIdModel, AddRemoveMixin, metaclass=ProtocolUserMeta):
1✔
302
    """Abstract base class for a Bridgy Fed user.
303

304
    Stores some protocols' keypairs. Currently:
305

306
    * RSA keypair for ActivityPub HTTP Signatures
307
      properties: ``mod``, ``public_exponent``, ``private_exponent``, all
308
      encoded as base64url (ie URL-safe base64) strings as described in RFC
309
      4648 and section 5.1 of the Magic Signatures spec:
310
      https://tools.ietf.org/html/draft-cavage-http-signatures-12
311
    * *Not* K-256 signing or rotation keys for AT Protocol, those are stored in
312
      :class:`arroba.datastore_storage.AtpRepo` entities
313
    """
314
    GET_ORIGINAL_FN = get_original_user_key
1✔
315
    'used by AddRemoveMixin'
1✔
316

317
    obj_key = ndb.KeyProperty(kind='Object')  # user profile
1✔
318
    ''
1✔
319
    mod = ndb.StringProperty()
1✔
320
    ''
1✔
321
    use_instead = ndb.KeyProperty()
1✔
322
    ''
1✔
323

324
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
325
    """Proxy copies of this user elsewhere, eg DIDs for ATProto records, bech32
1✔
326
    npub Nostr ids, etc. Similar to ``rel-me`` links in microformats2,
327
    ``alsoKnownAs`` in DID docs (and now AS2), etc.
328
    """
329

330
    public_exponent = ndb.StringProperty()
1✔
331
    """Part of the bridged ActivityPub actor's private key."""
1✔
332
    private_exponent = ndb.StringProperty()
1✔
333
    """Part of the bridged ActivityPub actor's private key."""
1✔
334
    nostr_key_bytes = EncryptedProperty()
1✔
335
    """The bridged Nostr account's secp256k1 private key, in raw bytes."""
1✔
336

337
    manual_opt_out = ndb.BooleanProperty()
1✔
338
    """Set to True for users who asked to be opted out."""
1✔
339

340
    enabled_protocols = ndb.StringProperty(repeated=True,
1✔
341
                                           # TODO: remove for Nostr launch
342
                                           choices=list(PROTOCOLS.keys()) + ['nostr'])
343
    """Protocols that this user has explicitly opted into.
1✔
344

345
    Protocols that don't require explicit opt in are omitted here. ``choices``
346
    is populated in :func:`reset_protocol_properties`.
347
    """
348

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

352
    send_notifs = ndb.StringProperty(default='all', choices=('all', 'none'))
1✔
353
    """Which notifications we should send this user."""
1✔
354

355
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
356
    ''
1✔
357
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
358
    ''
1✔
359

360
    # `existing` attr is set by get_or_create
361

362
    # OLD. some stored entities still have these; do not reuse.
363
    # direct = ndb.BooleanProperty(default=False)
364
    # actor_as2 = JsonProperty()
365
    # protocol-specific state
366
    # atproto_notifs_indexed_at = ndb.TextProperty()
367
    # atproto_feed_indexed_at = ndb.TextProperty()
368

369
    def __init__(self, **kwargs):
1✔
370
        """Constructor.
371

372
        Sets :attr:`obj` explicitly because however
373
        :class:`google.cloud.ndb.model.Model` sets it doesn't work with
374
        ``@property`` and ``@obj.setter`` below.
375
        """
376
        obj = kwargs.pop('obj', None)
1✔
377
        super().__init__(**kwargs)
1✔
378

379
        if obj:
1✔
380
            self.obj = obj
1✔
381

382
        self.lock = Lock()
1✔
383

384
    @classmethod
1✔
385
    def new(cls, **kwargs):
1✔
386
        """Try to prevent instantiation. Use subclasses instead."""
387
        raise NotImplementedError()
×
388

389
    def _post_put_hook(self, future):
1✔
390
        logger.debug(f'Wrote {self.key}')
1✔
391

392
    @classmethod
1✔
393
    def get_by_id(cls, id, allow_opt_out=False, **kwargs):
1✔
394
        """Override to follow ``use_instead`` property and ``status``.
395

396
        Returns None if the user is opted out.
397
        """
398
        user = cls._get_by_id(id, **kwargs)
1✔
399
        if user and user.use_instead:
1✔
400
            logger.info(f'{user.key} use_instead => {user.use_instead}')
1✔
401
            user = user.use_instead.get()
1✔
402

403
        if not user:
1✔
404
            return None
1✔
405

406
        if user.status and not allow_opt_out:
1✔
407
            logger.info(f'{user.key} is {user.status}')
1✔
408
            return None
1✔
409

410
        return user
1✔
411

412
    @classmethod
1✔
413
    def get_or_create(cls, id, propagate=False, allow_opt_out=False,
1✔
414
                      reload=False, **kwargs):
415
        """Loads and returns a :class:`User`. Creates it if necessary.
416

417
        Not transactional because transactions don't read or write memcache. :/
418
        Fortunately we don't really depend on atomicity for much, last writer wins
419
        is usually fine.
420

421
        Args:
422
          propagate (bool): whether to create copies of this user in push-based
423
            protocols, eg ATProto and Nostr.
424
          allow_opt_out (bool): whether to allow and create the user if they're
425
            currently opted out
426
          reload (bool): whether to reload profile always, vs only if necessary
427
          kwargs: passed through to ``cls`` constructor
428

429
        Returns:
430
          User: existing or new user, or None if the user is opted out
431
        """
432
        assert cls != User
1✔
433

434
        user = cls.get_by_id(id, allow_opt_out=True)
1✔
435
        if user:
1✔
436
            if reload:
1✔
437
                user.reload_profile(gateway=True, raise_=False)
1✔
438

439
            if user.status and not allow_opt_out:
1✔
440
                return None
1✔
441
            user.existing = True
1✔
442

443
            # TODO: propagate more fields?
444
            changed = False
1✔
445
            for field in ['obj', 'obj_key']:
1✔
446
                old_val = getattr(user, field, None)
1✔
447
                new_val = kwargs.get(field)
1✔
448
                if old_val is None and new_val is not None:
1✔
449
                    setattr(user, field, new_val)
×
450
                    changed = True
×
451

452
            if enabled_protocols := kwargs.get('enabled_protocols'):
1✔
453
                user.enabled_protocols = (set(user.enabled_protocols)
1✔
454
                                          | set(enabled_protocols))
455
                changed = True
1✔
456

457
            if not propagate:
1✔
458
                if changed:
1✔
459
                    user.put()
1✔
460
                return user
1✔
461

462
        else:  # new, not existing
463
            if orig_key := get_original_user_key(id):
1✔
464
                orig = orig_key.get()
1✔
465
                if orig.status and not allow_opt_out:
1✔
466
                    return None
×
467
                orig.existing = False
1✔
468
                return orig
1✔
469

470
            user = cls(id=id, **kwargs)
1✔
471
            user.existing = False
1✔
472
            user.reload_profile(gateway=True, raise_=False)
1✔
473
            if user.status and not allow_opt_out:
1✔
474
                return None
1✔
475

476
        if propagate and user.status in (None, 'private'):
1✔
477
            for label in user.enabled_protocols + list(user.DEFAULT_ENABLED_PROTOCOLS):
1✔
478
                proto = PROTOCOLS[label]
1✔
479
                if proto == cls:
1✔
480
                    continue
×
481
                elif proto.HAS_COPIES:
1✔
482
                    if not user.get_copy(proto) and user.is_enabled(proto):
1✔
483
                        try:
1✔
484
                            proto.create_for(user)
1✔
485
                        except (ValueError, AssertionError):
1✔
486
                            logger.info(f'failed creating {proto.LABEL} copy',
1✔
487
                                        exc_info=True)
488
                            user.remove('enabled_protocols', proto.LABEL)
1✔
489
                    else:
490
                        logger.debug(f'{proto.LABEL} not enabled or user copy already exists, skipping propagate')
1✔
491

492
        # generate keys for all protocols _except_ our own
493
        #
494
        # these can use urandom() and do nontrivial math, so they can take time
495
        # depending on the amount of randomness available and compute needed.
496
        if cls.LABEL != 'activitypub':
1✔
497
            if (not user.public_exponent or not user.private_exponent or not user.mod):
1✔
498
                assert (not user.public_exponent and not user.private_exponent
1✔
499
                        and not user.mod), id
500
                key = RSA.generate(KEY_BITS,
1✔
501
                                   randfunc=random.randbytes if DEBUG else None)
502
                user.mod = long_to_base64(key.n)
1✔
503
                user.public_exponent = long_to_base64(key.e)
1✔
504
                user.private_exponent = long_to_base64(key.d)
1✔
505

506
        try:
1✔
507
            user.put()
1✔
508
        except AssertionError as e:
×
509
            error(f'Bad {cls.__name__} id {id} : {e}')
×
510

511
        logger.debug(('Updated ' if user.existing else 'Created new ') + str(user))
1✔
512
        return user
1✔
513

514
    @property
1✔
515
    def obj(self):
1✔
516
        """Convenience accessor that loads :attr:`obj_key` from the datastore."""
517
        if self.obj_key:
1✔
518
            if not hasattr(self, '_obj'):
1✔
519
                self._obj = self.obj_key.get()
1✔
520
            return self._obj
1✔
521

522
    @obj.setter
1✔
523
    def obj(self, obj):
1✔
524
        if obj:
1✔
525
            assert isinstance(obj, Object)
1✔
526
            assert obj.key
1✔
527
            self._obj = obj
1✔
528
            self.obj_key = obj.key
1✔
529
        else:
530
            self._obj = self.obj_key = None
1✔
531

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

535
        Args:
536
          proto (Protocol): optional
537
        """
538
        now = util.now().isoformat()
1✔
539
        proto_label = proto.LABEL if proto else 'all'
1✔
540
        delete_id = f'{self.profile_id()}#bridgy-fed-delete-user-{proto_label}-{now}'
1✔
541
        delete = Object(id=delete_id, source_protocol=self.LABEL, our_as1={
1✔
542
            'id': delete_id,
543
            'objectType': 'activity',
544
            'verb': 'delete',
545
            'actor': self.key.id(),
546
            'object': self.key.id(),
547
        })
548
        self.deliver(delete, from_user=self, to_proto=proto)
1✔
549

550
    @classmethod
1✔
551
    def load_multi(cls, users):
1✔
552
        """Loads :attr:`obj` for multiple users in parallel.
553

554
        Args:
555
          users (sequence of User)
556
        """
557
        objs = ndb.get_multi(u.obj_key for u in users if u.obj_key)
1✔
558
        keys_to_objs = {o.key: o for o in objs if o}
1✔
559

560
        for u in users:
1✔
561
            u._obj = keys_to_objs.get(u.obj_key)
1✔
562

563
    @ndb.ComputedProperty
1✔
564
    def handle(self):
1✔
565
        """This user's unique, human-chosen handle, eg ``@me@snarfed.org``.
566

567
        To be implemented by subclasses.
568
        """
569
        raise NotImplementedError()
×
570

571
    @ndb.ComputedProperty
1✔
572
    def handle_as_domain(self):
1✔
573
        """This user's handle in domain-like format, via :func:`id.handle_as_domain`.
574

575
        Returns:
576
          str or None: if handle is None
577
        """
578
        return ids.handle_as_domain(self.handle)
1✔
579

580
    @ndb.ComputedProperty
1✔
581
    def status(self):
1✔
582
        """Whether this user is blocked or opted out.
583

584
        Optional. See :attr:`USER_STATUS_DESCRIPTIONS` for possible values.
585
        """
586
        if self.manual_opt_out:
1✔
587
            return 'opt-out'
1✔
588

589
        # TODO: require profile for more protocols? all?
590
        if not self.obj or not self.obj.as1:
1✔
591
            return None
1✔
592

593
        if self.obj.as1.get('bridgeable') is False:  # FEP-0036
1✔
594
            return 'opt-out'
1✔
595

596
        if self.REQUIRES_AVATAR and not self.obj.as1.get('image'):
1✔
597
            return 'requires-avatar'
1✔
598

599
        name = self.obj.as1.get('displayName')
1✔
600
        if self.REQUIRES_NAME and (not name or name in (self.handle, self.key.id())):
1✔
601
            return 'requires-name'
1✔
602

603
        if self.REQUIRES_OLD_ACCOUNT:
1✔
604
            if published := self.obj.as1.get('published'):
1✔
605
                if util.now() - util.parse_iso8601(published) < OLD_ACCOUNT_AGE:
1✔
606
                    return 'requires-old-account'
1✔
607

608
        summary = html_to_text(self.obj.as1.get('summary', ''), ignore_links=True)
1✔
609
        name = html_to_text(self.obj.as1.get('displayName', ''), ignore_links=True)
1✔
610

611
        # #nobridge overrides enabled_protocols
612
        if '#nobridge' in summary or '#nobridge' in name:
1✔
613
            return 'nobridge'
1✔
614

615
        # user has explicitly opted in. should go after spam filter (REQUIRES_*)
616
        # checks, but before is_public and #nobot
617
        #
618
        # !!! WARNING: keep in sync with User.enable_protocol!
619
        if self.enabled_protocols:
1✔
620
            return None
1✔
621

622
        if not as1.is_public(self.obj.as1, unlisted=False):
1✔
623
            return 'private'
1✔
624

625
        # enabled_protocols overrides #nobot
626
        if '#nobot' in summary or '#nobot' in name:
1✔
627
            return 'nobot'
1✔
628

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

632
        Reasons this might return False:
633
        * We haven't turned on bridging these two protocols yet.
634
        * The user is opted out or blocked.
635
        * The user is on a domain that's opted out or blocked.
636
        * The from protocol requires opt in, and the user hasn't opted in.
637
        * ``explicit`` is True, and this protocol supports ``to_proto`` by, but the user hasn't explicitly opted into it.
638

639
        Args:
640
          to_proto (Protocol subclass)
641
          explicit (bool)
642

643
        Returns:
644
          bool:
645
        """
646
        from protocol import Protocol
1✔
647
        assert isinstance(to_proto, Protocol) or issubclass(to_proto, Protocol)
1✔
648

649
        if self.__class__ == to_proto:
1✔
650
            return True
1✔
651

652
        from_label = self.LABEL
1✔
653
        to_label = to_proto.LABEL
1✔
654

655
        if bot_protocol := Protocol.for_bridgy_subdomain(self.key.id()):
1✔
656
            return to_proto != bot_protocol
1✔
657

658
        elif self.manual_opt_out:
1✔
659
            return False
1✔
660

661
        elif to_label in self.enabled_protocols:
1✔
662
            return True
1✔
663

664
        elif self.status:
1✔
665
            return False
1✔
666

667
        elif to_label in self.DEFAULT_ENABLED_PROTOCOLS and not explicit:
1✔
668
            return True
1✔
669

670
        return False
1✔
671

672
    def enable_protocol(self, to_proto):
1✔
673
        """Adds ``to_proto`` to :attr:`enabled_protocols`.
674

675
        Also sends a welcome DM to the user (via a send task) if their protocol
676
        supports DMs.
677

678
        Args:
679
          to_proto (:class:`protocol.Protocol` subclass)
680
        """
681
        import dms
1✔
682

683
        # explicit opt-in overrides some status
684
        # !!! WARNING: keep in sync with User.status!
685
        ineligible = """Hi! Your account isn't eligible for bridging yet because your {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✔
686
        if self.status and self.status not in ('nobot', 'private'):
1✔
687
            if desc := USER_STATUS_DESCRIPTIONS.get(self.status):
1✔
688
                dms.maybe_send(from_proto=to_proto, to_user=self, type=self.status,
1✔
689
                               text=ineligible.format(desc=desc))
690
            common.error(f'Nope, user {self.key.id()} is {self.status}', status=299)
1✔
691

692
        try:
1✔
693
            self.handle_as(to_proto)
1✔
694
        except ValueError as e:
1✔
695
            dms.maybe_send(from_proto=to_proto, to_user=self,
1✔
696
                           type=f'unsupported-handle-{to_proto.ABBREV}',
697
                           text=ineligible.format(desc=e))
698
            common.error(str(e), status=299)
1✔
699

700
        if to_proto.LABEL in ids.COPIES_PROTOCOLS:
1✔
701
            # do this even if there's an existing copy since we might need to
702
            # reactivate it, which create_for should do
703
            to_proto.create_for(self)
1✔
704

705
        if to_proto.LABEL not in self.enabled_protocols:
1✔
706
            self.enabled_protocols.append(to_proto.LABEL)
1✔
707
            dms.maybe_send(from_proto=to_proto, to_user=self, type='welcome', text=f"""\
1✔
708
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.""")
709
            self.put()
1✔
710

711
        msg = f'Enabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
712
        logger.info(msg)
1✔
713

714
    def disable_protocol(self, to_proto):
1✔
715
        """Removes ``to_proto` from :attr:`enabled_protocols``.
716

717
        Args:
718
          to_proto (:class:`protocol.Protocol` subclass)
719
        """
720
        self.remove('enabled_protocols', to_proto.LABEL)
1✔
721
        self.put()
1✔
722
        msg = f'Disabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
723
        logger.info(msg)
1✔
724

725
    def handle_as(self, to_proto):
1✔
726
        """Returns this user's handle in a different protocol.
727

728
        Args:
729
          to_proto (str or Protocol)
730

731
        Returns:
732
          str
733
        """
734
        if isinstance(to_proto, str):
1✔
735
            to_proto = PROTOCOLS[to_proto]
1✔
736

737
        # override to-ATProto to use custom domain handle in DID doc
738
        from atproto import ATProto, did_to_handle
1✔
739
        if to_proto == ATProto:
1✔
740
            if did := self.get_copy(ATProto):
1✔
741
                if handle := did_to_handle(did, remote=False):
1✔
742
                    return handle
1✔
743

744
        # override web users to always use domain instead of custom username
745
        # TODO: fall back to id if handle is unset?
746
        handle = self.key.id() if self.LABEL == 'web' else self.handle
1✔
747
        if not handle:
1✔
748
            return None
1✔
749

750
        return ids.translate_handle(handle=handle, from_=self.__class__,
1✔
751
                                    to=to_proto, enhanced=False)
752

753
    def id_as(self, to_proto):
1✔
754
        """Returns this user's id in a different protocol.
755

756
        Args:
757
          to_proto (str or Protocol)
758

759
        Returns:
760
          str
761
        """
762
        if isinstance(to_proto, str):
1✔
763
            to_proto = PROTOCOLS[to_proto]
1✔
764

765
        return ids.translate_user_id(id=self.key.id(), from_=self.__class__,
1✔
766
                                     to=to_proto)
767

768
    def handle_or_id(self):
1✔
769
        """Returns handle if we know it, otherwise id."""
770
        return self.handle or self.key.id()
1✔
771

772
    def public_pem(self):
1✔
773
        """Returns the user's PEM-encoded ActivityPub public RSA key.
774

775
        Returns:
776
          bytes:
777
        """
778
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
779
                             base64_to_long(str(self.public_exponent))))
780
        return rsa.exportKey(format='PEM')
1✔
781

782
    def private_pem(self):
1✔
783
        """Returns the user's PEM-encoded ActivityPub private RSA key.
784

785
        Returns:
786
          bytes:
787
        """
788
        assert self.mod and self.public_exponent and self.private_exponent, str(self)
1✔
789
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
790
                             base64_to_long(str(self.public_exponent)),
791
                             base64_to_long(str(self.private_exponent))))
792
        return rsa.exportKey(format='PEM')
1✔
793

794
    def nsec(self):
1✔
795
        """Returns the user's bech32-encoded Nostr private secp256k1 key.
796

797
        Returns:
798
          str:
799
        """
800
        assert self.nostr_key_bytes
1✔
801
        privkey = secp256k1.PrivateKey(self.nostr_key_bytes, raw=True)
1✔
802
        return granary.nostr.bech32_encode('nsec', privkey.serialize())
1✔
803

804
    def hex_pubkey(self):
1✔
805
        """Returns the user's hex-encoded Nostr public secp256k1 key.
806

807
        Returns:
808
          str:
809
        """
810
        assert self.nostr_key_bytes
1✔
811
        return granary.nostr.pubkey_from_privkey(self.nostr_key_bytes.hex())
1✔
812

813
    def npub(self):
1✔
814
        """Returns the user's bech32-encoded ActivityPub public secp256k1 key.
815

816
        Returns:
817
          str:
818
        """
819
        return granary.nostr.bech32_encode('npub', self.hex_pubkey())
1✔
820

821
    def name(self):
1✔
822
        """Returns this user's human-readable name, eg ``Ryan Barrett``."""
823
        if self.obj and self.obj.as1:
1✔
824
            name = self.obj.as1.get('displayName')
1✔
825
            if name:
1✔
826
                return name
1✔
827

828
        return self.handle_or_id()
1✔
829

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

833
        To be implemented by subclasses.
834

835
        Returns:
836
          str
837
        """
838
        raise NotImplementedError()
×
839

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

843
        Args:
844
          url (str)
845
          ignore_www (bool): if True, ignores ``www.`` subdomains
846

847
        Returns:
848
          bool:
849
        """
850
        if not url:
1✔
851
            return False
1✔
852

853
        url = url.strip().rstrip('/')
1✔
854
        url = re.sub(r'^(https?://)www\.', r'\1', url)
1✔
855
        parsed_url = urlparse(url)
1✔
856
        if parsed_url.scheme not in ('http', 'https', ''):
1✔
857
            return False
1✔
858

859
        this = self.web_url().rstrip('/')
1✔
860
        this = re.sub(r'^(https?://)www\.', r'\1', this)
1✔
861
        parsed_this = urlparse(this)
1✔
862

863
        return (url == this or url == parsed_this.netloc or
1✔
864
                parsed_url[1:] == parsed_this[1:])  # ignore http vs https
865

866
    def id_uri(self):
1✔
867
        """Returns the user id as a URI.
868

869
        Sometimes this is the user id itself, eg ActivityPub actor ids.
870
        Sometimes it's a bit different, eg at://did:plc:... for ATProto user,
871
        https://site.com for Web users.
872

873
        Returns:
874
          str
875
        """
876
        return self.key.id()
1✔
877

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

881
        Examples:
882

883
        * Web: home page URL, eg ``https://me.com/``
884
        * ActivityPub: actor URL, eg ``https://instance.com/users/me``
885
        * ATProto: profile AT URI, eg ``at://did:plc:123/app.bsky.actor.profile/self``
886

887
        Defaults to this user's key id.
888

889
        Returns:
890
          str or None:
891
        """
892
        return ids.profile_id(id=self.key.id(), proto=self)
1✔
893

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

897
        Populates the reloaded profile :class:`Object` in ``self.obj``.
898

899
        Args:
900
          kwargs: passed through to :meth:`Protocol.load`
901
        """
902
        obj = self.load(self.profile_id(), remote=True, **kwargs)
1✔
903
        if obj:
1✔
904
            self.obj = obj
1✔
905

906
        # write the user so that we re-populate any computed properties
907
        self.put()
1✔
908

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

912
        Args:
913
          rest (str): additional path and/or query to add to the end
914
          prefer_id (bool): whether to prefer to use the account's id in the path
915
            instead of handle. Defaults to ``False``.
916
        """
917
        path = f'/{self.ABBREV}/{self.key.id() if prefer_id else self.handle_or_id()}'
1✔
918

919
        if rest:
1✔
920
            if not (rest.startswith('?') or rest.startswith('/')):
1✔
921
                path += '/'
1✔
922
            path += rest
1✔
923

924
        return path
1✔
925

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

929
        ...or None if no such copy exists. If ``proto`` is this user, returns
930
        this user's key id.
931

932
        Args:
933
          proto: :class:`Protocol` subclass
934

935
        Returns:
936
          str:
937
        """
938
        # don't use isinstance because the testutil Fake protocol has subclasses
939
        if self.LABEL == proto.LABEL:
1✔
940
            return self.key.id()
1✔
941

942
        for copy in self.copies:
1✔
943
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
944
                return copy.uri
1✔
945

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

950
        Can optionally include display name, handle, profile
951
        picture, and/or link to a different protocol that they've enabled.
952

953
        TODO: unify with :meth:`Object.actor_link`?
954

955
        Args:
956
          name (bool): include display name
957
          handle (bool): include handle
958
          pictures (bool): include profile picture and protocol logo
959
          logo (str): optional path to platform logo to show instead of the
960
            protocol's default
961
          proto (protocol.Protocol): link to this protocol instead of the user's
962
            native protocol
963
          proto_fallback (bool): if True, and ``proto`` is provided and has no
964
            no canonical profile URL for bridged users, uses the user's profile
965
            URL in their native protocol
966
        """
967
        img = name_str = handle_str = dot = logo_html = a_open = a_close = ''
1✔
968

969
        if proto:
1✔
970
            assert self.is_enabled(proto), f"{proto.LABEL} isn't enabled"
1✔
971
            url = proto.bridged_web_url_for(self, fallback=proto_fallback)
1✔
972
        else:
973
            proto = self.__class__
1✔
974
            url = self.web_url()
1✔
975

976
        if pictures:
1✔
977
            if logo:
1✔
978
                logo_html = f'<img class="logo" src="{logo}" /> '
1✔
979
            else:
980
                logo_html = f'<span class="logo" title="{proto.__name__}">{proto.LOGO_HTML or proto.LOGO_EMOJI}</span> '
1✔
981
            if pic := self.profile_picture():
1✔
982
                img = f'<img src="{pic}" class="profile"> '
1✔
983

984
        if handle:
1✔
985
            handle_str = self.handle_as(proto) or ''
1✔
986

987
        if name and self.name() != handle_str:
1✔
988
            name_str = self.name() or ''
1✔
989

990
        if handle_str and name_str:
1✔
991
            dot = ' &middot; '
1✔
992

993
        if url:
1✔
994
            a_open = f'<a class="h-card u-author" rel="me" href="{url}" title="{name_str}{dot}{handle_str}">'
1✔
995
            a_close = '</a>'
1✔
996

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

1000
    def profile_picture(self):
1✔
1001
        """Returns the user's profile picture image URL, if available, or None."""
1002
        if self.obj and self.obj.as1:
1✔
1003
            return util.get_url(self.obj.as1, 'image')
1✔
1004

1005
    # can't use functools.lru_cache here because we want the cache key to be
1006
    # just the user id, not the whole entity
1007
    @cachetools.cached(
1✔
1008
        cachetools.TTLCache(50000, FOLLOWERS_CACHE_EXPIRATION.total_seconds()),
1009
        key=lambda user: user.key.id(), lock=Lock())
1010
    @memcache.memoize(key=lambda self: self.key.id(),
1✔
1011
                      expire=FOLLOWERS_CACHE_EXPIRATION)
1012
    def count_followers(self):
1✔
1013
        """Counts this user's followers and followings.
1014

1015
        Returns:
1016
          (int, int) tuple: (number of followers, number following)
1017
        """
1018
        if self.key.id() in PROTOCOL_DOMAINS:
1✔
1019
            # we don't store Followers for protocol bot users any more, so
1020
            # follower counts are inaccurate, so don't return them
1021
            return (0, 0)
1✔
1022

1023
        num_followers = Follower.query(Follower.to == self.key,
1✔
1024
                                       Follower.status == 'active')\
1025
                                .count_async()
1026
        num_following = Follower.query(Follower.from_ == self.key,
1✔
1027
                                       Follower.status == 'active')\
1028
                                .count_async()
1029
        return num_followers.get_result(), num_following.get_result()
1✔
1030

1031

1032
class Object(StringIdModel, AddRemoveMixin):
1✔
1033
    """An activity or other object, eg actor.
1034

1035
    Key name is the id, generally a URI. We synthesize ids if necessary.
1036
    """
1037
    GET_ORIGINAL_FN = get_original_object_key
1✔
1038
    'used by AddRemoveMixin'
1✔
1039

1040
    users = ndb.KeyProperty(repeated=True)
1✔
1041
    'User(s) who created or otherwise own this object.'
1✔
1042

1043
    notify = ndb.KeyProperty(repeated=True)
1✔
1044
    """User who should see this in their user page, eg in reply to, reaction to,
1✔
1045
    share of, etc.
1046
    """
1047
    feed = ndb.KeyProperty(repeated=True)
1✔
1048
    'User who should see this in their feeds, eg followers of its creator'
1✔
1049

1050
    # TODO: remove for Nostr launch
1051
    source_protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()) + ['nostr'])
1✔
1052
    """The protocol this object originally came from.
1✔
1053

1054
    ``choices`` is populated in :func:`reset_protocol_properties`, after all
1055
    :class:`User` subclasses are created, so that :attr:`PROTOCOLS` is fully
1056
    populated.
1057

1058
    TODO: nail down whether this is :attr:`ABBREV`` or :attr:`LABEL`
1059
    """
1060

1061
    # TODO: switch back to ndb.JsonProperty if/when they fix it for the web console
1062
    # https://github.com/googleapis/python-ndb/issues/874
1063
    as2 = JsonProperty()
1✔
1064
    'ActivityStreams 2, for ActivityPub'
1✔
1065
    bsky = JsonProperty()
1✔
1066
    'AT Protocol lexicon, for Bluesky'
1✔
1067
    mf2 = JsonProperty()
1✔
1068
    'HTML microformats2 item (*not* top level parse object with ``items`` field)'
1✔
1069
    nostr = JsonProperty()
1✔
1070
    'Nostr event'
1✔
1071
    our_as1 = JsonProperty()
1✔
1072
    'ActivityStreams 1, for activities that we generate or modify ourselves'
1✔
1073
    raw = JsonProperty()
1✔
1074
    'Other standalone data format, eg DID document'
1✔
1075

1076
    # TODO: remove and actually delete Objects instead!
1077
    deleted = ndb.BooleanProperty()
1✔
1078
    ''
1✔
1079

1080
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
1081
    """Copies of this object elsewhere, eg at:// URIs for ATProto records and
1✔
1082
    nevent etc bech32-encoded Nostr ids, where this object is the original.
1083
    Similar to u-syndication links in microformats2 and
1084
    upstream/downstreamDuplicates in AS1.
1085
    """
1086

1087
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1088
    ''
1✔
1089
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1090
    ''
1✔
1091

1092
    new = None
1✔
1093
    """True if this object is new, ie this is the first time we've seen it,
1✔
1094
    False otherwise, None if we don't know.
1095
    """
1096
    changed = None
1✔
1097
    """True if this object's contents have changed from our existing copy in the
1✔
1098
    datastore, False otherwise, None if we don't know. :class:`Object` is
1099
    new/changed. See :meth:`activity_changed()` for more details.
1100
    """
1101

1102
    lock = None
1✔
1103
    """Synchronizes :meth:`add` and :meth:`remove`."""
1✔
1104

1105
    # DEPRECATED
1106
    # These were for full feeds with multiple items, not just this one, so they were
1107
    # stored as audit records only, not used in to_as1. for Atom/RSS
1108
    # based Objects, our_as1 was populated with an feed_index top-level
1109
    # integer field that indexed into one of these.
1110
    #
1111
    # atom = ndb.TextProperty() # Atom XML
1112
    # rss = ndb.TextProperty()  # RSS XML
1113

1114
    # DEPRECATED; these were for delivery tracking, but they were too expensive,
1115
    # so we stopped: https://github.com/snarfed/bridgy-fed/issues/1501
1116
    #
1117
    # STATUSES = ('new', 'in progress', 'complete', 'failed', 'ignored')
1118
    # status = ndb.StringProperty(choices=STATUSES)
1119
    # delivered = ndb.StructuredProperty(Target, repeated=True)
1120
    # undelivered = ndb.StructuredProperty(Target, repeated=True)
1121
    # failed = ndb.StructuredProperty(Target, repeated=True)
1122

1123
    # DEPRECATED but still used read only to maintain backward compatibility
1124
    # with old Objects in the datastore that we haven't bothered migrating.
1125
    #
1126
    # domains = ndb.StringProperty(repeated=True)
1127

1128
    # DEPRECATED; replaced by :attr:`users`, :attr:`notify`, :attr:`feed`
1129
    #
1130
    # labels = ndb.StringProperty(repeated=True,
1131
    #                             choices=('activity', 'feed', 'notification', 'user'))
1132

1133
    @property
1✔
1134
    def as1(self):
1✔
1135
        def use_urls_as_ids(obj):
1✔
1136
            """If id field is missing or not a URL, use the url field."""
1137
            id = obj.get('id')
1✔
1138
            if not id or not (util.is_web(id) or re.match(DOMAIN_RE, id)):
1✔
1139
                if url := util.get_url(obj):
1✔
1140
                    obj['id'] = url
1✔
1141

1142
            for field in 'author', 'actor', 'object':
1✔
1143
                if inner := as1.get_object(obj, field):
1✔
1144
                    use_urls_as_ids(inner)
1✔
1145

1146
        if self.our_as1:
1✔
1147
            obj = self.our_as1
1✔
1148
            if self.source_protocol == 'web':
1✔
1149
                use_urls_as_ids(obj)
1✔
1150

1151
        elif self.as2:
1✔
1152
            obj = as2.to_as1(unwrap(self.as2))
1✔
1153

1154
        elif self.bsky:
1✔
1155
            owner, _, _ = parse_at_uri(self.key.id())
1✔
1156
            ATProto = PROTOCOLS['atproto']
1✔
1157
            handle = ATProto(id=owner).handle
1✔
1158
            try:
1✔
1159
                obj = bluesky.to_as1(self.bsky, repo_did=owner, repo_handle=handle,
1✔
1160
                                     uri=self.key.id(), pds=ATProto.pds_for(self))
1161
            except (ValueError, RequestException):
1✔
1162
                logger.info(f"Couldn't convert to ATProto", exc_info=True)
1✔
1163
                return None
1✔
1164

1165
        elif self.mf2:
1✔
1166
            obj = microformats2.json_to_object(self.mf2,
1✔
1167
                                               rel_urls=self.mf2.get('rel-urls'))
1168
            use_urls_as_ids(obj)
1✔
1169

1170
            # use fetched final URL as id, not u-url
1171
            # https://github.com/snarfed/bridgy-fed/issues/829
1172
            if url := self.mf2.get('url'):
1✔
1173
                obj['id'] = (self.key.id() if self.key and '#' in self.key.id()
1✔
1174
                             else url)
1175

1176
        elif self.nostr:
1✔
1177
            obj = granary.nostr.to_as1(self.nostr)
1✔
1178

1179
        else:
1180
            return None
1✔
1181

1182
        # populate id if necessary
1183
        if self.key:
1✔
1184
            obj.setdefault('id', self.key.id())
1✔
1185

1186
        if util.domain_or_parent_in(obj.get('id'), IMAGE_PROXY_DOMAINS):
1✔
1187
           as1.prefix_urls(obj, 'image', IMAGE_PROXY_URL_BASE)
1✔
1188

1189
        return obj
1✔
1190

1191
    @ndb.ComputedProperty
1✔
1192
    def type(self):  # AS1 objectType, or verb if it's an activity
1✔
1193
        if self.as1:
1✔
1194
            return as1.object_type(self.as1)
1✔
1195

1196
    def __init__(self, *args, **kwargs):
1✔
1197
        super().__init__(*args, **kwargs)
1✔
1198
        self.lock = Lock()
1✔
1199

1200
    def _expire(self):
1✔
1201
        """Automatically delete most Objects after a while using a TTL policy.
1202

1203
        https://cloud.google.com/datastore/docs/ttl
1204

1205
        They recommend not indexing TTL properties:
1206
        https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
1207
        """
1208
        now = self.updated or util.now()
1✔
1209
        if self.deleted:
1✔
1210
            return now + timedelta(days=1)
1✔
1211
        elif self.type not in DONT_EXPIRE_OBJECT_TYPES:
1✔
1212
            return now + OBJECT_EXPIRE_AGE
1✔
1213

1214
    expire = ndb.ComputedProperty(_expire, indexed=False)
1✔
1215

1216
    def _pre_put_hook(self):
1✔
1217
        """
1218
        * Validate that at:// URIs have DID repos
1219
        * Set/remove the activity label
1220
        * Strip @context from as2 (we don't do LD) to save disk space
1221
        """
1222
        id = self.key.id()
1✔
1223

1224
        if self.source_protocol not in (None, 'ui'):
1✔
1225
            proto = PROTOCOLS[self.source_protocol]
1✔
1226
            assert proto.owns_id(id) is not False, \
1✔
1227
                f'Protocol {proto.LABEL} does not own id {id}'
1228

1229
        if id.startswith('at://'):
1✔
1230
            repo, _, _ = parse_at_uri(id)
1✔
1231
            if not repo.startswith('did:'):
1✔
1232
                # TODO: if we hit this, that means the AppView gave us an AT URI
1233
                # with a handle repo/authority instead of DID. that's surprising!
1234
                # ...if so, and if we need to handle it, add a new
1235
                # arroba.did.canonicalize_at_uri() function, then use it here,
1236
                # or before.
1237
                raise ValueError(
1✔
1238
                    f'at:// URI ids must have DID repos; got {id}')
1239

1240
        if self.as2:
1✔
1241
           self.as2.pop('@context', None)
1✔
1242
           for field in 'actor', 'attributedTo', 'author', 'object':
1✔
1243
               for val in util.get_list(self.as2, field):
1✔
1244
                   if isinstance(val, dict):
1✔
1245
                       val.pop('@context', None)
1✔
1246

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

1251
    @classmethod
1✔
1252
    def get_by_id(cls, id, authed_as=None, **kwargs):
1✔
1253
        """Fetches the :class:`Object` with the given id, if it exists.
1254

1255
        Args:
1256
          id (str)
1257
          authed_as (str): optional; if provided, and a matching :class:`Object`
1258
            already exists, its ``author`` or ``actor`` must contain this actor
1259
            id. Implements basic authorization for updates and deletes.
1260

1261
        Returns:
1262
          Object:
1263

1264
        Raises:
1265
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1266
            the existing object
1267
        """
1268
        key_id = id
1✔
1269
        if len(key_id) > _MAX_KEYPART_BYTES:
1✔
1270
            # TODO: handle Unicode chars. naive approach is to UTF-8 encode,
1271
            # truncate, then decode, but that might cut mid character. easier to just
1272
            # hope/assume the URL is already URL-encoded.
1273
            key_id = key_id[:_MAX_KEYPART_BYTES]
1✔
1274
            logger.warning(f'Truncating id to {_MAX_KEYPART_BYTES} chars: {key_id}')
1✔
1275

1276
        obj = super().get_by_id(key_id, **kwargs)
1✔
1277

1278
        if obj and obj.as1 and authed_as:
1✔
1279
            # authorization: check that the authed user is allowed to modify
1280
            # this object
1281
            # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1282
            proto = PROTOCOLS.get(obj.source_protocol)
1✔
1283
            assert proto, obj.source_protocol
1✔
1284
            owners = [ids.normalize_user_id(id=owner, proto=proto)
1✔
1285
                      for owner in (as1.get_ids(obj.as1, 'author')
1286
                                    + as1.get_ids(obj.as1, 'actor'))
1287
                                    + [id]]
1288
            if (ids.normalize_user_id(id=authed_as, proto=proto) not in owners
1✔
1289
                    and ids.profile_id(id=authed_as, proto=proto) not in owners):
1290
                report_error("Auth: Object: authed_as doesn't match owner",
1✔
1291
                             user=f'{id} authed_as {authed_as} owners {owners}')
1292
                error(f"authed user {authed_as} isn't object owner {owners}",
1✔
1293
                      status=403)
1294

1295
        return obj
1✔
1296

1297
    @classmethod
1✔
1298
    def get_or_create(cls, id, authed_as=None, **props):
1✔
1299
        """Returns an :class:`Object` with the given property values.
1300

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

1305
        Not transactional because transactions don't read or write memcache. :/
1306
        Fortunately we don't really depend on atomicity for much, last writer wins
1307
        is usually fine.
1308

1309
        Args:
1310
          authed_as (str): optional; if provided, and a matching :class:`Object`
1311
            already exists, its ``author`` or ``actor`` must contain this actor
1312
            id. Implements basic authorization for updates and deletes.
1313

1314
        Returns:
1315
          Object:
1316

1317
        Raises:
1318
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1319
            the existing object
1320
        """
1321
        key_id = id
1✔
1322
        if len(key_id) > _MAX_KEYPART_BYTES:
1✔
1323
            # TODO: handle Unicode chars. naive approach is to UTF-8 encode,
1324
            # truncate, then decode, but that might cut mid character. easier to just
1325
            # hope/assume the URL is already URL-encoded.
1326
            key_id = key_id[:_MAX_KEYPART_BYTES]
1✔
1327
            logger.warning(f'Truncating id to {_MAX_KEYPART_BYTES} chars: {key_id}')
1✔
1328

1329
        obj = cls.get_by_id(key_id, authed_as=authed_as)
1✔
1330

1331
        if not obj:
1✔
1332
            obj = Object(id=key_id, **props)
1✔
1333
            obj.new = True
1✔
1334
            obj.changed = False
1✔
1335
            obj.put()
1✔
1336
            return obj
1✔
1337

1338
        if orig_as1 := obj.as1:
1✔
1339
            # get_by_id() checks authorization if authed_as is set. make sure
1340
            # it's always set for existing objects.
1341
            assert authed_as
1✔
1342

1343
        dirty = False
1✔
1344
        for prop, val in props.items():
1✔
1345
            assert not isinstance(getattr(Object, prop), ndb.ComputedProperty)
1✔
1346
            if prop in ('copies', 'feed', 'notify', 'users'):
1✔
1347
                # merge repeated fields
1348
                for elem in val:
1✔
1349
                    if obj.add(prop, elem):
1✔
1350
                        dirty = True
1✔
1351
            elif val is not None and val != getattr(obj, prop):
1✔
1352
                setattr(obj, prop, val)
1✔
1353
                if prop in ('as2', 'bsky', 'mf2', 'raw') and not props.get('our_as1'):
1✔
1354
                    obj.our_as1 = None
1✔
1355
                dirty = True
1✔
1356

1357
        obj.new = False
1✔
1358
        obj.changed = obj.activity_changed(orig_as1)
1✔
1359
        if dirty:
1✔
1360
            obj.put()
1✔
1361
        return obj
1✔
1362

1363
    @staticmethod
1✔
1364
    def from_request():
1✔
1365
        """Creates and returns an :class:`Object` from form-encoded JSON parameters.
1366

1367
        Parameters:
1368
          obj_id (str): id of :class:`models.Object` to handle
1369
          *: If ``obj_id`` is unset, all other parameters are properties for a
1370
            new :class:`models.Object` to handle
1371
        """
1372
        if obj_id := request.form.get('obj_id'):
1✔
1373
            return Object.get_by_id(obj_id)
1✔
1374

1375
        props = {field: request.form.get(field)
1✔
1376
                 for field in ('id', 'source_protocol')}
1377

1378
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'nostr', 'raw':
1✔
1379
            if val := request.form.get(json_prop):
1✔
1380
                props[json_prop] = json_loads(val)
1✔
1381

1382
        obj = Object(**props)
1✔
1383
        if not obj.key and obj.as1:
1✔
1384
            if id := obj.as1.get('id'):
1✔
1385
                obj.key = ndb.Key(Object, id)
1✔
1386

1387
        return obj
1✔
1388

1389
    def to_request(self):
1✔
1390
        """Returns a query parameter dict representing this :class:`Object`."""
1391
        form = {}
1✔
1392

1393
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1394
            if val := getattr(self, json_prop, None):
1✔
1395
                form[json_prop] = json_dumps(val, sort_keys=True)
1✔
1396

1397
        for prop in ['source_protocol']:
1✔
1398
            if val := getattr(self, prop):
1✔
1399
                form[prop] = val
1✔
1400

1401
        if self.key:
1✔
1402
            form['id'] = self.key.id()
1✔
1403

1404
        return form
1✔
1405

1406
    def activity_changed(self, other_as1):
1✔
1407
        """Returns True if this activity is meaningfully changed from ``other_as1``.
1408

1409
        ...otherwise False.
1410

1411
        Used to populate :attr:`changed`.
1412

1413
        Args:
1414
          other_as1 (dict): AS1 object, or none
1415
        """
1416
        # ignore inReplyTo since we translate it between protocols
1417
        return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
1✔
1418
                if self.as1 and other_as1
1419
                else bool(self.as1) != bool(other_as1))
1420

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

1424
        TODO: unify with :meth:`User.user_link`?
1425

1426
        Args:
1427
          image (bool): whether to include an ``img`` tag with the actor's picture
1428
          sized (bool): whether to set an explicit (``width=32``) size on the
1429
            profile picture ``img`` tag
1430
          user (User): current user
1431

1432
        Returns:
1433
          str:
1434
        """
1435
        attrs = {'class': 'h-card u-author'}
1✔
1436

1437
        if user and user.key in self.users:
1✔
1438
            # outbound; show a nice link to the user
1439
            return user.user_link(handle=False, pictures=True)
1✔
1440

1441
        proto = PROTOCOLS.get(self.source_protocol)
1✔
1442

1443
        actor = None
1✔
1444
        if self.as1:
1✔
1445
            actor = (as1.get_object(self.as1, 'actor')
1✔
1446
                     or as1.get_object(self.as1, 'author'))
1447
            # hydrate from datastore if available
1448
            # TODO: optimize! this is called serially in loops, eg in home.html
1449
            if set(actor.keys()) == {'id'} and self.source_protocol:
1✔
1450
                actor_obj = proto.load(actor['id'], remote=False)
1✔
1451
                if actor_obj and actor_obj.as1:
1✔
1452
                    actor = actor_obj.as1
1✔
1453

1454
        if not actor:
1✔
1455
            return ''
1✔
1456
        elif set(actor.keys()) == {'id'}:
1✔
1457
            return common.pretty_link(actor['id'], attrs=attrs, user=user)
1✔
1458

1459
        url = as1.get_url(actor)
1✔
1460
        name = actor.get('displayName') or actor.get('username') or ''
1✔
1461
        img_url = util.get_url(actor, 'image')
1✔
1462
        if not image or not img_url:
1✔
1463
            return common.pretty_link(url, text=name, attrs=attrs, user=user)
1✔
1464

1465
        logo = ''
1✔
1466
        if proto:
1✔
NEW
1467
            logo = f'<span class="logo" title="{self.__class__.__name__}">{proto.LOGO_HTML or proto.LOGO_EMOJI}</span>'
×
1468

1469
        return f"""\
1✔
1470
        {logo}
1471
        <a class="h-card u-author" href="{url}" title="{name}">
1472
          <img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
1473
          <span style="unicode-bidi: isolate">{util.ellipsize(name, chars=40)}</span>
1474
        </a>"""
1475

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

1479
        ...or None if no such copy exists. If ``proto`` is ``source_protocol``,
1480
        returns this object's key id.
1481

1482
        Args:
1483
          proto: :class:`Protocol` subclass
1484

1485
        Returns:
1486
          str:
1487
        """
1488
        if self.source_protocol in (proto.LABEL, proto.ABBREV):
1✔
1489
            return self.key.id()
1✔
1490

1491
        for copy in self.copies:
1✔
1492
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
1493
                return copy.uri
1✔
1494

1495
    def resolve_ids(self):
1✔
1496
        """Replaces "copy" ids, subdomain ids, etc with their originals.
1497

1498
        The end result is that all ids are original "source" ids, ie in the
1499
        protocol that they first came from.
1500

1501
        Specifically, resolves:
1502

1503
        * ids in :class:`User.copies` and :class:`Object.copies`, eg ATProto
1504
          records and Nostr events that we bridged, to the ids of their
1505
          original objects in their source protocol, eg
1506
          ``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
1507
        * Bridgy Fed subdomain URLs to the ids embedded inside them, eg
1508
          ``https://bsky.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
1509
        * ATProto bsky.app URLs to their DIDs or `at://` URIs, eg
1510
          ``https://bsky.app/profile/a.com`` => ``did:plc:123``
1511

1512
        ...in these AS1 fields, in place:
1513

1514
        * ``id``
1515
        * ``actor``
1516
        * ``author``
1517
        * ``object``
1518
        * ``object.actor``
1519
        * ``object.author``
1520
        * ``object.id``
1521
        * ``object.inReplyTo``
1522
        * ``attachments.[objectType=note].id``
1523
        * ``tags.[objectType=mention].url``
1524

1525
        :meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
1526
        Much of the same logic is duplicated there!
1527

1528
        TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
1529
        """
1530
        if not self.as1:
1✔
1531
            return
1✔
1532

1533
        # extract ids, strip Bridgy Fed subdomain URLs
1534
        outer_obj = unwrap(self.as1)
1✔
1535
        if outer_obj != self.as1:
1✔
1536
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1537

1538
        self_proto = PROTOCOLS.get(self.source_protocol)
1✔
1539
        if not self_proto:
1✔
1540
            return
1✔
1541

1542
        inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
1✔
1543
        replaced = False
1✔
1544

1545
        def replace(val, orig_fn):
1✔
1546
            id = val.get('id') if isinstance(val, dict) else val
1✔
1547
            if not id or not self_proto.HAS_COPIES:
1✔
1548
                return id
1✔
1549

1550
            orig = orig_fn(id)
1✔
1551
            if not orig:
1✔
1552
                return val
1✔
1553

1554
            nonlocal replaced
1555
            replaced = True
1✔
1556
            logger.debug(f'Resolved copy id {val} to original {orig.id()}')
1✔
1557

1558
            if isinstance(val, dict) and util.trim_nulls(val).keys() > {'id'}:
1✔
1559
                val['id'] = orig.id()
1✔
1560
                return val
1✔
1561
            else:
1562
                return orig.id()
1✔
1563

1564
        # actually replace ids
1565
        #
1566
        # object field could be either object (eg repost) or actor (eg follow)
1567
        outer_obj['object'] = replace(inner_obj, get_original_object_key)
1✔
1568
        if not replaced:
1✔
1569
            outer_obj['object'] = replace(inner_obj, get_original_user_key)
1✔
1570

1571
        for obj in outer_obj, inner_obj:
1✔
1572
            for tag in as1.get_objects(obj, 'tags'):
1✔
1573
                if tag.get('objectType') == 'mention':
1✔
1574
                    tag['url'] = replace(tag.get('url'), get_original_user_key)
1✔
1575
            for att in as1.get_objects(obj, 'attachments'):
1✔
1576
                if att.get('objectType') == 'note':
1✔
1577
                    att['id'] = replace(att.get('id'), get_original_object_key)
1✔
1578
            for field, fn in (
1✔
1579
                    ('actor', get_original_user_key),
1580
                    ('author', get_original_user_key),
1581
                    ('inReplyTo', get_original_object_key),
1582
                ):
1583
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1584
                if len(obj[field]) == 1:
1✔
1585
                    obj[field] = obj[field][0]
1✔
1586

1587
        if replaced:
1✔
1588
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1589

1590
    def normalize_ids(self):
1✔
1591
        """Normalizes ids to their protocol's canonical representation, if any.
1592

1593
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
1594
        for profiles, ``at://`` URIs for posts.
1595

1596
        Modifies this object in place.
1597

1598
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
1599
        """
1600
        from protocol import Protocol
1✔
1601

1602
        if not self.as1:
1✔
1603
            return
1✔
1604

1605
        logger.debug(f'Normalizing ids')
1✔
1606
        outer_obj = copy.deepcopy(self.as1)
1✔
1607
        inner_objs = as1.get_objects(outer_obj)
1✔
1608
        replaced = False
1✔
1609

1610
        def replace(val, translate_fn):
1✔
1611
            nonlocal replaced
1612

1613
            orig = val.get('id') if isinstance(val, dict) else val
1✔
1614
            if not orig:
1✔
1615
                return val
1✔
1616

1617
            proto = Protocol.for_id(orig, remote=False)
1✔
1618
            if not proto:
1✔
1619
                return val
1✔
1620

1621
            translated = translate_fn(id=orig, from_=proto, to=proto)
1✔
1622
            if translated and translated != orig:
1✔
1623
                # logger.debug(f'Normalized {proto.LABEL} id {orig} to {translated}')
1624
                replaced = True
1✔
1625
                if isinstance(val, dict):
1✔
1626
                    val['id'] = translated
1✔
1627
                    return val
1✔
1628
                else:
1629
                    return translated
1✔
1630

1631
            return val
1✔
1632

1633
        # actually replace ids
1634
        for obj in [outer_obj] + inner_objs:
1✔
1635
            for tag in as1.get_objects(obj, 'tags'):
1✔
1636
                if tag.get('objectType') == 'mention':
1✔
1637
                    tag['url'] = replace(tag.get('url'), ids.translate_user_id)
1✔
1638
            for field in ['actor', 'author', 'inReplyTo']:
1✔
1639
                fn = (ids.translate_object_id if field == 'inReplyTo'
1✔
1640
                      else ids.translate_user_id)
1641
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1642
                if len(obj[field]) == 1:
1✔
1643
                    obj[field] = obj[field][0]
1✔
1644

1645
        outer_obj['object'] = []
1✔
1646
        for inner_obj in inner_objs:
1✔
1647
            translate_fn = (ids.translate_user_id
1✔
1648
                            if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
1649
                                or as1.object_type(outer_obj) in
1650
                                ('follow', 'stop-following'))
1651
                            else ids.translate_object_id)
1652

1653
            got = replace(inner_obj, translate_fn)
1✔
1654
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
1655
                got = got['id']
1✔
1656

1657
            outer_obj['object'].append(got)
1✔
1658

1659
        if len(outer_obj['object']) == 1:
1✔
1660
            outer_obj['object'] = outer_obj['object'][0]
1✔
1661

1662
        if replaced:
1✔
1663
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1664

1665

1666
class Follower(ndb.Model):
1✔
1667
    """A follower of a Bridgy Fed user."""
1668
    STATUSES = ('active', 'inactive')
1✔
1669

1670
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
1671
    """The follower."""
1✔
1672
    to = ndb.KeyProperty(required=True)
1✔
1673
    """The followee, ie the user being followed."""
1✔
1674

1675
    follow = ndb.KeyProperty(Object)
1✔
1676
    """The last follow activity."""
1✔
1677
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
1678
    """Whether this follow is active or not."""
1✔
1679

1680
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1681
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1682

1683
    # OLD. some stored entities still have these; do not reuse.
1684
    # src = ndb.StringProperty()
1685
    # dest = ndb.StringProperty()
1686
    # last_follow = JsonProperty()
1687

1688
    def _pre_put_hook(self):
1✔
1689
        # we're a bridge! stick with bridging.
1690
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
1691

1692
    def _post_put_hook(self, future):
1✔
1693
        logger.debug(f'Wrote {self.key}')
1✔
1694

1695
    @classmethod
1✔
1696
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
1697
        """Returns a Follower with the given ``from_`` and ``to`` users.
1698

1699
        Not transactional because transactions don't read or write memcache. :/
1700
        Fortunately we don't really depend on atomicity for much, last writer wins
1701
        is usually fine.
1702

1703
        If a matching :class:`Follower` doesn't exist in the datastore, creates
1704
        it first.
1705

1706
        Args:
1707
          from_ (User)
1708
          to (User)
1709

1710
        Returns:
1711
          Follower:
1712
        """
1713
        assert from_
1✔
1714
        assert to
1✔
1715

1716
        follower = Follower.query(Follower.from_ == from_.key,
1✔
1717
                                  Follower.to == to.key,
1718
                                  ).get()
1719
        if not follower:
1✔
1720
            follower = Follower(from_=from_.key, to=to.key, **kwargs)
1✔
1721
            follower.put()
1✔
1722
        elif kwargs:
1✔
1723
            # update existing entity with new property values, eg to make an
1724
            # inactive Follower active again
1725
            for prop, val in kwargs.items():
1✔
1726
                setattr(follower, prop, val)
1✔
1727
            follower.put()
1✔
1728

1729
        return follower
1✔
1730

1731
    @staticmethod
1✔
1732
    def fetch_page(collection, user):
1✔
1733
        r"""Fetches a page of :class:`Follower`\s for a given user.
1734

1735
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
1736
        parameters, if available in the request.
1737

1738
        Args:
1739
          collection (str): ``followers`` or ``following``
1740
          user (User)
1741

1742
        Returns:
1743
          (list of Follower, str, str) tuple: results, annotated with an extra
1744
          ``user`` attribute that holds the follower or following :class:`User`,
1745
          and new str query param values for ``before`` and ``after`` to fetch
1746
          the previous and next pages, respectively
1747
        """
1748
        assert collection in ('followers', 'following'), collection
1✔
1749

1750
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1751
        query = Follower.query(
1✔
1752
            Follower.status == 'active',
1753
            filter_prop == user.key,
1754
        )
1755

1756
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
1757
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
1758
                              for f in followers)
1759
        User.load_multi(u for u in users if u)
1✔
1760

1761
        for f, u in zip(followers, users):
1✔
1762
            f.user = u
1✔
1763

1764
        # only show followers in protocols that this user is bridged into
1765
        followers = [f for f in followers if f.user and user.is_enabled(f.user)]
1✔
1766

1767
        return followers, before, after
1✔
1768

1769

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

1773
    Wraps :func:`fetch_page` and adds attributes to the returned
1774
    :class:`Object` entities for rendering in ``objects.html``.
1775

1776
    Args:
1777
      query (ndb.Query)
1778
      by (ndb.model.Property): either :attr:`Object.updated` or
1779
        :attr:`Object.created`
1780
      user (User): current user
1781

1782
    Returns:
1783
      (list of Object, str, str) tuple:
1784
      (results, new ``before`` query param, new ``after`` query param)
1785
      to fetch the previous and next pages, respectively
1786
    """
1787
    assert by is Object.updated or by is Object.created
1✔
1788
    objects, new_before, new_after = fetch_page(query, Object, by=by)
1✔
1789
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
1790

1791
    # synthesize human-friendly content for objects
1792
    for i, obj in enumerate(objects):
1✔
1793
        obj_as1 = obj.as1
1✔
1794
        type = as1.object_type(obj_as1)
1✔
1795

1796
        # AS1 verb => human-readable phrase
1797
        phrases = {
1✔
1798
            'accept': 'accepted',
1799
            'article': 'posted',
1800
            'comment': 'replied',
1801
            'delete': 'deleted',
1802
            'follow': 'followed',
1803
            'invite': 'is invited to',
1804
            'issue': 'filed issue',
1805
            'like': 'liked',
1806
            'note': 'posted',
1807
            'post': 'posted',
1808
            'repost': 'reposted',
1809
            'rsvp-interested': 'is interested in',
1810
            'rsvp-maybe': 'might attend',
1811
            'rsvp-no': 'is not attending',
1812
            'rsvp-yes': 'is attending',
1813
            'share': 'reposted',
1814
            'stop-following': 'unfollowed',
1815
            'undo': 'undid',
1816
            'update': 'updated',
1817
        }
1818
        phrases.update({type: 'profile refreshed:' for type in as1.ACTOR_TYPES})
1✔
1819

1820
        obj.phrase = phrases.get(type, '')
1✔
1821

1822
        content = (obj_as1.get('content')
1✔
1823
                   or obj_as1.get('displayName')
1824
                   or obj_as1.get('summary'))
1825
        if content:
1✔
1826
            content = util.parse_html(content).get_text()
1✔
1827

1828
        urls = as1.object_urls(obj_as1)
1✔
1829
        url = urls[0] if urls else None
1✔
1830
        if url and not content:
1✔
1831
            # heuristics for sniffing URLs and converting them to more friendly
1832
            # phrases and user handles.
1833
            # TODO: standardize this into granary.as2 somewhere?
1834
            from activitypub import FEDI_URL_RE
×
1835
            from atproto import COLLECTION_TO_TYPE, did_to_handle
×
1836

1837
            handle = suffix = ''
×
1838
            if match := FEDI_URL_RE.match(url):
×
1839
                handle = match.group(2)
×
1840
                if match.group(4):
×
1841
                    suffix = "'s post"
×
1842
            elif match := BSKY_APP_URL_RE.match(url):
×
1843
                handle = match.group('id')
×
1844
                if match.group('tid'):
×
1845
                    suffix = "'s post"
×
1846
            elif match := AT_URI_PATTERN.match(url):
×
1847
                handle = match.group('repo')
×
1848
                if coll := match.group('collection'):
×
1849
                    suffix = f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
×
1850
                url = bluesky.at_uri_to_web_url(url)
×
1851
            elif url.startswith('did:'):
×
1852
                handle = url
×
1853
                url = bluesky.Bluesky.user_url(handle)
×
1854

1855
            if handle:
×
1856
                if handle.startswith('did:'):
×
1857
                    handle = did_to_handle(handle) or handle
×
1858
                content = f'@{handle}{suffix}'
×
1859

1860
            if url:
×
1861
                content = common.pretty_link(url, text=content, user=user)
×
1862

1863
        obj.content = (obj_as1.get('content')
1✔
1864
                       or obj_as1.get('displayName')
1865
                       or obj_as1.get('summary'))
1866
        obj.url = as1.get_url(obj_as1)
1✔
1867

1868
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
1869
            inner_as1 = as1.get_object(obj_as1)
1✔
1870
            obj.inner_url = as1.get_url(inner_as1) or inner_as1.get('id')
1✔
1871
            if obj.url:
1✔
1872
                obj.phrase = common.pretty_link(
1✔
1873
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
1874
            if content:
1✔
1875
                obj.content = content
1✔
1876
                obj.url = url
1✔
1877
            elif obj.inner_url:
1✔
1878
                obj.content = common.pretty_link(obj.inner_url, max_length=50)
1✔
1879

1880
    return objects, new_before, new_after
1✔
1881

1882

1883
def hydrate(activity, fields=('author', 'actor', 'object')):
1✔
1884
    """Hydrates fields in an AS1 activity, in place.
1885

1886
    Args:
1887
      activity (dict): AS1 activity
1888
      fields (sequence of str): names of fields to hydrate. If they're string ids,
1889
        loads them from the datastore, if possible, and replaces them with their dict
1890
        AS1 objects.
1891

1892
    Returns:
1893
      sequence of :class:`google.cloud.ndb.tasklets.Future`: tasklets for hydrating
1894
        each field. Wait on these before using ``activity``.
1895
    """
1896
    def _hydrate(field):
1✔
1897
        def maybe_set(future):
1✔
1898
            if future.result() and future.result().as1:
1✔
1899
                activity[field] = future.result().as1
1✔
1900
        return maybe_set
1✔
1901

1902
    futures = []
1✔
1903

1904
    for field in fields:
1✔
1905
        val = as1.get_object(activity, field)
1✔
1906
        if val and val.keys() <= set(['id']):
1✔
1907
            # TODO: extract a Protocol class method out of User.profile_id,
1908
            # then use that here instead. the catch is that we'd need to
1909
            # determine Protocol for every id, which is expensive.
1910
            #
1911
            # same TODO is in models.fetch_objects
1912
            id = val['id']
1✔
1913
            if id.startswith('did:'):
1✔
1914
                id = f'at://{id}/app.bsky.actor.profile/self'
×
1915

1916
            future = Object.get_by_id_async(id)
1✔
1917
            future.add_done_callback(_hydrate(field))
1✔
1918
            futures.append(future)
1✔
1919

1920
    return futures
1✔
1921

1922

1923
def fetch_page(query, model_class, by=None):
1✔
1924
    """Fetches a page of results from a datastore query.
1925

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

1929
    Populates a ``log_url_path`` property on each result entity that points to a
1930
    its most recent logged request.
1931

1932
    Args:
1933
      query (google.cloud.ndb.query.Query)
1934
      model_class (class)
1935
      by (ndb.model.Property): paging property, eg :attr:`Object.updated`
1936
        or :attr:`Object.created`
1937

1938
    Returns:
1939
      (list of Object or Follower, str, str) tuple: (results, new_before,
1940
      new_after), where new_before and new_after are query param values for
1941
      ``before`` and ``after`` to fetch the previous and next pages,
1942
      respectively
1943
    """
1944
    assert by
1✔
1945

1946
    # if there's a paging param ('before' or 'after'), update query with it
1947
    # TODO: unify this with Bridgy's user page
1948
    def get_paging_param(param):
1✔
1949
        val = request.values.get(param)
1✔
1950
        if val:
1✔
1951
            try:
1✔
1952
                dt = util.parse_iso8601(val.replace(' ', '+'))
1✔
1953
            except BaseException as e:
1✔
1954
                error(f"Couldn't parse {param}, {val!r} as ISO8601: {e}")
1✔
1955
            if dt.tzinfo:
1✔
1956
                dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
1✔
1957
            return dt
1✔
1958

1959
    before = get_paging_param('before')
1✔
1960
    after = get_paging_param('after')
1✔
1961
    if before and after:
1✔
1962
        error("can't handle both before and after")
×
1963
    elif after:
1✔
1964
        query = query.filter(by >= after).order(by)
1✔
1965
    elif before:
1✔
1966
        query = query.filter(by < before).order(-by)
1✔
1967
    else:
1968
        query = query.order(-by)
1✔
1969

1970
    query_iter = query.iter()
1✔
1971
    results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
1✔
1972
                     key=lambda r: r.updated, reverse=True)
1973

1974
    # calculate new paging param(s)
1975
    has_next = results and query_iter.probably_has_next()
1✔
1976
    new_after = (
1✔
1977
        before if before
1978
        else results[0].updated if has_next and after
1979
        else None)
1980
    if new_after:
1✔
1981
        new_after = new_after.isoformat()
1✔
1982

1983
    new_before = (
1✔
1984
        after if after else
1985
        results[-1].updated if has_next
1986
        else None)
1987
    if new_before:
1✔
1988
        new_before = new_before.isoformat()
1✔
1989

1990
    return results, new_before, new_after
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