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

snarfed / bridgy-fed / 5472ea75-3c75-4520-9490-ac2f97bb7d08

18 Jul 2025 03:56AM UTC coverage: 92.452% (+0.003%) from 92.449%
5472ea75-3c75-4520-9490-ac2f97bb7d08

push

circleci

snarfed
docs: for instance-level custom Bluesky handles, just say to ping us

5524 of 5975 relevant lines covered (92.45%)

0.92 hits per line

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

95.62
/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 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
        return self.uri == other.uri and self.protocol == other.protocol
1✔
146

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

151

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

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

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

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

184

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

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

198
        return cls
1✔
199

200

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

209

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

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

218
    Args:
219
      copy_id (str)
220

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

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

228

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

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

237
    Args:
238
      copy_id (str)
239

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

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

250

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

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

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

262
        Args:
263
          prop (str)
264
          val
265

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

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

277
        return added
1✔
278

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

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

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

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

299

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

303
    Stores some protocols' keypairs. Currently:
304

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

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

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

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

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

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

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

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

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

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

359
    # `existing` attr is set by get_or_create
360

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

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

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

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

381
        self.lock = Lock()
1✔
382

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

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

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

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

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

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

409
        return user
1✔
410

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

669
        return False
1✔
670

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

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

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

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

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

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

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

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

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

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

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

727
        Args:
728
          to_proto (str or Protocol)
729

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

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

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

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

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

755
        Args:
756
          to_proto (str or Protocol)
757

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

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

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

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

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

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

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

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

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

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

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

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

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

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

827
        return self.handle_or_id()
1✔
828

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

832
        To be implemented by subclasses.
833

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

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

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

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

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

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

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

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

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

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

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

880
        Examples:
881

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

886
        Defaults to this user's key id.
887

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

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

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

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

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

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

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

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

923
        return path
1✔
924

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1030

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1178
        else:
1179
            return None
1✔
1180

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

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

1188
        return obj
1✔
1189

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

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

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

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

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

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

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

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

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

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

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

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

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

1260
        Returns:
1261
          Object:
1262

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

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

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

1294
        return obj
1✔
1295

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

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

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

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

1313
        Returns:
1314
          Object:
1315

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

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

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

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

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

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

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

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

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

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

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

1386
        return obj
1✔
1387

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

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

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

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

1403
        return form
1✔
1404

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

1408
        ...otherwise False.
1409

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1500
        Specifically, resolves:
1501

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

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

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

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

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

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

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

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

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

1548
            orig = orig_fn(id)
1✔
1549
            if not orig:
1✔
1550
                return val
1✔
1551

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

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

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

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

1582
        if replaced:
1✔
1583
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1584

1585
    def normalize_ids(self):
1✔
1586
        """Normalizes ids to their protocol's canonical representation, if any.
1587

1588
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
1589
        for profiles, ``at://`` URIs for posts.
1590

1591
        Modifies this object in place.
1592

1593
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
1594
        """
1595
        from protocol import Protocol
1✔
1596

1597
        if not self.as1:
1✔
1598
            return
1✔
1599

1600
        logger.debug(f'Normalizing ids')
1✔
1601
        outer_obj = copy.deepcopy(self.as1)
1✔
1602
        inner_objs = as1.get_objects(outer_obj)
1✔
1603
        replaced = False
1✔
1604

1605
        def replace(val, translate_fn):
1✔
1606
            nonlocal replaced
1607

1608
            orig = val.get('id') if isinstance(val, dict) else val
1✔
1609
            if not orig:
1✔
1610
                return val
1✔
1611

1612
            proto = Protocol.for_id(orig, remote=False)
1✔
1613
            if not proto:
1✔
1614
                return val
1✔
1615

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

1626
            return val
1✔
1627

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

1640
        outer_obj['object'] = []
1✔
1641
        for inner_obj in inner_objs:
1✔
1642
            translate_fn = (ids.translate_user_id
1✔
1643
                            if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
1644
                                or as1.object_type(outer_obj) in
1645
                                ('follow', 'stop-following'))
1646
                            else ids.translate_object_id)
1647

1648
            got = replace(inner_obj, translate_fn)
1✔
1649
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
1650
                got = got['id']
1✔
1651

1652
            outer_obj['object'].append(got)
1✔
1653

1654
        if len(outer_obj['object']) == 1:
1✔
1655
            outer_obj['object'] = outer_obj['object'][0]
1✔
1656

1657
        if replaced:
1✔
1658
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1659

1660

1661
class Follower(ndb.Model):
1✔
1662
    """A follower of a Bridgy Fed user."""
1663
    STATUSES = ('active', 'inactive')
1✔
1664

1665
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
1666
    """The follower."""
1✔
1667
    to = ndb.KeyProperty(required=True)
1✔
1668
    """The followee, ie the user being followed."""
1✔
1669

1670
    follow = ndb.KeyProperty(Object)
1✔
1671
    """The last follow activity."""
1✔
1672
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
1673
    """Whether this follow is active or note."""
1✔
1674

1675
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1676
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1677

1678
    # OLD. some stored entities still have these; do not reuse.
1679
    # src = ndb.StringProperty()
1680
    # dest = ndb.StringProperty()
1681
    # last_follow = JsonProperty()
1682

1683
    def _pre_put_hook(self):
1✔
1684
        # we're a bridge! stick with bridging.
1685
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
1686

1687
    def _post_put_hook(self, future):
1✔
1688
        logger.debug(f'Wrote {self.key}')
1✔
1689

1690
    @classmethod
1✔
1691
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
1692
        """Returns a Follower with the given ``from_`` and ``to`` users.
1693

1694
        Not transactional because transactions don't read or write memcache. :/
1695
        Fortunately we don't really depend on atomicity for much, last writer wins
1696
        is usually fine.
1697

1698
        If a matching :class:`Follower` doesn't exist in the datastore, creates
1699
        it first.
1700

1701
        Args:
1702
          from_ (User)
1703
          to (User)
1704

1705
        Returns:
1706
          Follower:
1707
        """
1708
        assert from_
1✔
1709
        assert to
1✔
1710

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

1724
        return follower
1✔
1725

1726
    @staticmethod
1✔
1727
    def fetch_page(collection, user):
1✔
1728
        r"""Fetches a page of :class:`Follower`\s for a given user.
1729

1730
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
1731
        parameters, if available in the request.
1732

1733
        Args:
1734
          collection (str): ``followers`` or ``following``
1735
          user (User)
1736

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

1745
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1746
        query = Follower.query(
1✔
1747
            Follower.status == 'active',
1748
            filter_prop == user.key,
1749
        )
1750

1751
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
1752
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
1753
                              for f in followers)
1754
        User.load_multi(u for u in users if u)
1✔
1755

1756
        for f, u in zip(followers, users):
1✔
1757
            f.user = u
1✔
1758
        followers = [f for f in followers if f.user.is_enabled(user)]
1✔
1759

1760
        return followers, before, after
1✔
1761

1762

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

1766
    Wraps :func:`fetch_page` and adds attributes to the returned
1767
    :class:`Object` entities for rendering in ``objects.html``.
1768

1769
    Args:
1770
      query (ndb.Query)
1771
      by (ndb.model.Property): either :attr:`Object.updated` or
1772
        :attr:`Object.created`
1773
      user (User): current user
1774

1775
    Returns:
1776
      (list of Object, str, str) tuple:
1777
      (results, new ``before`` query param, new ``after`` query param)
1778
      to fetch the previous and next pages, respectively
1779
    """
1780
    assert by is Object.updated or by is Object.created
1✔
1781
    objects, new_before, new_after = fetch_page(query, Object, by=by)
1✔
1782
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
1783

1784
    # synthesize human-friendly content for objects
1785
    for i, obj in enumerate(objects):
1✔
1786
        obj_as1 = obj.as1
1✔
1787
        type = as1.object_type(obj_as1)
1✔
1788

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

1813
        obj.phrase = phrases.get(type, '')
1✔
1814

1815
        content = (obj_as1.get('content')
1✔
1816
                   or obj_as1.get('displayName')
1817
                   or obj_as1.get('summary'))
1818
        if content:
1✔
1819
            content = util.parse_html(content).get_text()
1✔
1820

1821
        urls = as1.object_urls(obj_as1)
1✔
1822
        url = urls[0] if urls else None
1✔
1823
        if url and not content:
1✔
1824
            # heuristics for sniffing URLs and converting them to more friendly
1825
            # phrases and user handles.
1826
            # TODO: standardize this into granary.as2 somewhere?
1827
            from activitypub import FEDI_URL_RE
×
1828
            from atproto import COLLECTION_TO_TYPE, did_to_handle
×
1829

1830
            handle = suffix = ''
×
1831
            if match := FEDI_URL_RE.match(url):
×
1832
                handle = match.group(2)
×
1833
                if match.group(4):
×
1834
                    suffix = "'s post"
×
1835
            elif match := BSKY_APP_URL_RE.match(url):
×
1836
                handle = match.group('id')
×
1837
                if match.group('tid'):
×
1838
                    suffix = "'s post"
×
1839
            elif match := AT_URI_PATTERN.match(url):
×
1840
                handle = match.group('repo')
×
1841
                if coll := match.group('collection'):
×
1842
                    suffix = f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
×
1843
                url = bluesky.at_uri_to_web_url(url)
×
1844
            elif url.startswith('did:'):
×
1845
                handle = url
×
1846
                url = bluesky.Bluesky.user_url(handle)
×
1847

1848
            if handle:
×
1849
                if handle.startswith('did:'):
×
1850
                    handle = did_to_handle(handle) or handle
×
1851
                content = f'@{handle}{suffix}'
×
1852

1853
            if url:
×
1854
                content = common.pretty_link(url, text=content, user=user)
×
1855

1856
        obj.content = (obj_as1.get('content')
1✔
1857
                       or obj_as1.get('displayName')
1858
                       or obj_as1.get('summary'))
1859
        obj.url = as1.get_url(obj_as1)
1✔
1860

1861
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
1862
            inner_as1 = as1.get_object(obj_as1)
1✔
1863
            obj.inner_url = as1.get_url(inner_as1) or inner_as1.get('id')
1✔
1864
            if obj.url:
1✔
1865
                obj.phrase = common.pretty_link(
×
1866
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
1867
            if content:
1✔
1868
                obj.content = content
1✔
1869
                obj.url = url
1✔
1870
            elif obj.inner_url:
1✔
1871
                obj.content = common.pretty_link(obj.inner_url, max_length=50)
1✔
1872

1873
    return objects, new_before, new_after
1✔
1874

1875

1876
def hydrate(activity, fields=('author', 'actor', 'object')):
1✔
1877
    """Hydrates fields in an AS1 activity, in place.
1878

1879
    Args:
1880
      activity (dict): AS1 activity
1881
      fields (sequence of str): names of fields to hydrate. If they're string ids,
1882
        loads them from the datastore, if possible, and replaces them with their dict
1883
        AS1 objects.
1884

1885
    Returns:
1886
      sequence of :class:`google.cloud.ndb.tasklets.Future`: tasklets for hydrating
1887
        each field. Wait on these before using ``activity``.
1888
    """
1889
    def _hydrate(field):
1✔
1890
        def maybe_set(future):
1✔
1891
            if future.result() and future.result().as1:
1✔
1892
                activity[field] = future.result().as1
1✔
1893
        return maybe_set
1✔
1894

1895
    futures = []
1✔
1896

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

1909
            future = Object.get_by_id_async(id)
1✔
1910
            future.add_done_callback(_hydrate(field))
1✔
1911
            futures.append(future)
1✔
1912

1913
    return futures
1✔
1914

1915

1916
def fetch_page(query, model_class, by=None):
1✔
1917
    """Fetches a page of results from a datastore query.
1918

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

1922
    Populates a ``log_url_path`` property on each result entity that points to a
1923
    its most recent logged request.
1924

1925
    Args:
1926
      query (google.cloud.ndb.query.Query)
1927
      model_class (class)
1928
      by (ndb.model.Property): paging property, eg :attr:`Object.updated`
1929
        or :attr:`Object.created`
1930

1931
    Returns:
1932
      (list of Object or Follower, str, str) tuple: (results, new_before,
1933
      new_after), where new_before and new_after are query param values for
1934
      ``before`` and ``after`` to fetch the previous and next pages,
1935
      respectively
1936
    """
1937
    assert by
1✔
1938

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

1952
    before = get_paging_param('before')
1✔
1953
    after = get_paging_param('after')
1✔
1954
    if before and after:
1✔
1955
        error("can't handle both before and after")
×
1956
    elif after:
1✔
1957
        query = query.filter(by >= after).order(by)
1✔
1958
    elif before:
1✔
1959
        query = query.filter(by < before).order(-by)
1✔
1960
    else:
1961
        query = query.order(-by)
1✔
1962

1963
    query_iter = query.iter()
1✔
1964
    results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
1✔
1965
                     key=lambda r: r.updated, reverse=True)
1966

1967
    # calculate new paging param(s)
1968
    has_next = results and query_iter.probably_has_next()
1✔
1969
    new_after = (
1✔
1970
        before if before
1971
        else results[0].updated if has_next and after
1972
        else None)
1973
    if new_after:
1✔
1974
        new_after = new_after.isoformat()
1✔
1975

1976
    new_before = (
1✔
1977
        after if after else
1978
        results[-1].updated if has_next
1979
        else None)
1980
    if new_before:
1✔
1981
        new_before = new_before.isoformat()
1✔
1982

1983
    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