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

snarfed / bridgy-fed / c7343da8-115a-4a86-a425-518c166fdc80

14 Sep 2025 03:03AM UTC coverage: 92.591% (-0.05%) from 92.639%
c7343da8-115a-4a86-a425-518c166fdc80

push

circleci

snarfed
add ATProto.migrate_out, right now a noop

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

35 existing lines in 3 files now uncovered.

5749 of 6209 relevant lines covered (92.59%)

0.93 hits per line

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

95.86
/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
    'moved': 'account has migrated to another account',
96
    'no-feed-or-webmention': "web site doesn't have an RSS or Atom feed or webmention endpoint",
97
    'nobot': "profile has 'nobot' in it",
98
    'nobridge': "profile has 'nobridge' in it",
99
    'no-nip05': "account's NIP-05 identifier is missing or invalid",
100
    'no-profile': 'profile is missing or empty',
101
    'opt-out': 'account or instance has requested to be opted out',
102
    'owns-webfinger': 'web site looks like a fediverse instance because it already serves Webfinger',
103
    'private': 'account is set as private or protected',
104
    'requires-avatar': "account doesn't have a profile picture",
105
    'requires-name': "account's name and username are the same",
106
    'requires-old-account': f"account is less than {humanize.naturaldelta(OLD_ACCOUNT_AGE)} old",
107
    '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>",
108
}
109

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

112

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

116
    These are currently used for:
117

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

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

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

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

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

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

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

153

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

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

159
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
160
    """
161
    type = ndb.StringProperty(required=True)
1✔
162
    """Known values (keep in sync with USER_STATUS_DESCRIPTIONS, the subset for
1✔
163
    ineligible users):
164

165
      * dms_not_supported-[RECIPIENT-USER-ID]
166
      * moved
167
      * no-feed-or-webmention
168
      * no-nip05
169
      * no-profile
170
      * opt-out
171
      * owns-webfinger
172
      * private
173
      * replied_to_bridged_user
174
      * request_bridging
175
      * requires-avatar
176
      * requires-name
177
      * requires-old-account
178
      * unsupported-handle-ap
179
      * welcome
180
    """
181
    # TODO: remove for Nostr launch
182
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()) + ['nostr'],
1✔
183
                                  required=True)
184
    ''
1✔
185

186
    def __eq__(self, other):
1✔
187
        """Equality excludes Targets' :class:`Key`."""
188
        return self.type == other.type and self.protocol == other.protocol
1✔
189

190

191
class ProtocolUserMeta(type(ndb.Model)):
1✔
192
    """:class:`User` metaclass. Registers all subclasses in ``PROTOCOLS``."""
193
    def __new__(meta, name, bases, class_dict):
1✔
194
        cls = super().__new__(meta, name, bases, class_dict)
1✔
195

196
        label = getattr(cls, 'LABEL', None)
1✔
197
        if (label and label not in ('protocol', 'user')
1✔
198
                and (DEBUG or cls.LABEL not in DEBUG_PROTOCOLS)):
199
            for label in (label, cls.ABBREV) + cls.OTHER_LABELS:
1✔
200
                if label:
1✔
201
                    PROTOCOLS[label] = cls
1✔
202
            PROTOCOLS_BY_KIND[cls._get_kind()] = cls
1✔
203

204
        return cls
1✔
205

206

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

215

216
@lru_cache(maxsize=100000)
1✔
217
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
218
def get_original_object_key(copy_id):
1✔
219
    """Finds the :class:`Object` with a given copy id, if any.
220

221
    Note that :meth:`Object.add` also updates this function's
222
    :func:`memcache.memoize` cache.
223

224
    Args:
225
      copy_id (str)
226

227
    Returns:
228
      google.cloud.ndb.Key or None
229
    """
230
    assert copy_id
1✔
231

232
    return Object.query(Object.copies.uri == copy_id).get(keys_only=True)
1✔
233

234

235
@lru_cache(maxsize=100000)
1✔
236
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
237
def get_original_user_key(copy_id):
1✔
238
    """Finds the user with a given copy id, if any.
239

240
    Note that :meth:`User.add` also updates this function's
241
    :func:`memcache.memoize` cache.
242

243
    Args:
244
      copy_id (str)
245

246
    Returns:
247
      google.cloud.ndb.Key or None
248
    """
249
    assert copy_id
1✔
250

251
    for proto in PROTOCOLS.values():
1✔
252
        if proto and proto.LABEL != 'ui' and not proto.owns_id(copy_id):
1✔
253
            if orig := proto.query(proto.copies.uri == copy_id).get(keys_only=True):
1✔
254
                return orig
1✔
255

256

257
class AddRemoveMixin:
1✔
258
    """Mixin class that defines the :meth:`add` and :meth:`remove` methods.
259

260
    If a subclass of this mixin defines the ``GET_ORIGINAL_FN`` class-level
261
    attribute, its memoize cache will be cleared when :meth:`remove` is called with
262
    the ``copies`` property.
263
    """
264

265
    lock = None
1✔
266
    """Synchronizes :meth:`add`, :meth:`remove`, etc."""
1✔
267

268
    def __init__(self, *args, **kwargs):
1✔
269
        super().__init__(*args, **kwargs)
1✔
270
        self.lock = Lock()
1✔
271

272
    def add(self, prop, val):
1✔
273
        """Adds a value to a multiply-valued property.
274

275
        Args:
276
          prop (str)
277
          val
278

279
        Returns:
280
          True if val was added, ie it wasn't already in prop, False otherwise
281
        """
282
        with self.lock:
1✔
283
            added = util.add(getattr(self, prop), val)
1✔
284

285
        if prop == 'copies' and added:
1✔
286
            if fn := getattr(self, 'GET_ORIGINAL_FN'):
1✔
287
                memcache.pickle_memcache.set(memcache.memoize_key(fn, val.uri),
1✔
288
                                             self.key)
289

290
        return added
1✔
291

292
    def remove(self, prop, val):
1✔
293
        """Removes a value from a multiply-valued property.
294

295
        Args:
296
          prop (str)
297
          val
298
        """
299
        with self.lock:
1✔
300
            existing = getattr(self, prop)
1✔
301
            if val in existing:
1✔
302
                existing.remove(val)
1✔
303

304
        if prop == 'copies':
1✔
305
            self.clear_get_original_cache(val.uri)
1✔
306

307
    def remove_copies_on(self, proto):
1✔
308
        """Removes all copies on a given protocol.
309

310
        ``proto.HAS_COPIES`` must be True.
311

312
        Args:
313
          proto (protocol.Protocol subclass)
314
        """
315
        assert proto.HAS_COPIES
1✔
316

317
        for copy in self.copies:
1✔
318
            if copy.protocol in (proto.ABBREV, proto.LABEL):
1✔
319
                self.remove('copies', copy)
1✔
320

321
    @classmethod
1✔
322
    def clear_get_original_cache(cls, uri):
1✔
323
        if fn := getattr(cls, 'GET_ORIGINAL_FN'):
1✔
324
            memcache.pickle_memcache.delete(memcache.memoize_key(fn, uri))
1✔
325

326

327
# WARNING: AddRemoveMixin *must* be before StringIdModel here so that its __init__
328
# gets called! Due to an (arguable) ndb.Model bug:
329
# https://github.com/googleapis/python-ndb/issues/1025
330
class User(AddRemoveMixin, StringIdModel, metaclass=ProtocolUserMeta):
1✔
331
    """Abstract base class for a Bridgy Fed user.
332

333
    Stores some protocols' keypairs. Currently:
334

335
    * RSA keypair for ActivityPub HTTP Signatures
336
      properties: ``mod``, ``public_exponent``, ``private_exponent``, all
337
      encoded as base64url (ie URL-safe base64) strings as described in RFC
338
      4648 and section 5.1 of the Magic Signatures spec:
339
      https://tools.ietf.org/html/draft-cavage-http-signatures-12
340
    * *Not* K-256 signing or rotation keys for AT Protocol, those are stored in
341
      :class:`arroba.datastore_storage.AtpRepo` entities
342
    """
343
    GET_ORIGINAL_FN = get_original_user_key
1✔
344
    'used by AddRemoveMixin'
1✔
345

346
    obj_key = ndb.KeyProperty(kind='Object')  # user profile
1✔
347
    ''
1✔
348
    use_instead = ndb.KeyProperty()
1✔
349
    ''
1✔
350

351
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
352
    """Proxy copies of this user elsewhere, eg DIDs for ATProto records, bech32
1✔
353
    npub Nostr ids, etc. Similar to ``rel-me`` links in microformats2,
354
    ``alsoKnownAs`` in DID docs (and now AS2), etc.
355
    """
356

357
    mod = ndb.StringProperty()
1✔
358
    """Part of the bridged ActivityPub actor's private key."""
1✔
359
    public_exponent = ndb.StringProperty()
1✔
360
    """Part of the bridged ActivityPub actor's private key."""
1✔
361
    private_exponent = ndb.StringProperty()
1✔
362
    """Part of the bridged ActivityPub actor's private key."""
1✔
363
    nostr_key_bytes = EncryptedProperty()
1✔
364
    """The bridged Nostr account's secp256k1 private key, in raw bytes."""
1✔
365

366
    manual_opt_out = ndb.BooleanProperty()
1✔
367
    """Set to True to manually disable this user. Set to False to override spam filters and forcibly enable this user."""
1✔
368

369
    enabled_protocols = ndb.StringProperty(repeated=True,
1✔
370
                                           # TODO: remove for Nostr launch
371
                                           choices=list(PROTOCOLS.keys()) + ['nostr'])
372
    """Protocols that this user has explicitly opted into.
1✔
373

374
    Protocols that don't require explicit opt in are omitted here.
375
    """
376

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

380
    send_notifs = ndb.StringProperty(default='all', choices=('all', 'none'))
1✔
381
    """Which notifications we should send this user."""
1✔
382

383
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
384
    ''
1✔
385
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
386
    ''
1✔
387

388
    # `existing` attr is set by get_or_create
389

390
    # OLD. some stored entities still have these; do not reuse.
391
    # direct = ndb.BooleanProperty(default=False)
392
    # actor_as2 = JsonProperty()
393
    # protocol-specific state
394
    # atproto_notifs_indexed_at = ndb.TextProperty()
395
    # atproto_feed_indexed_at = ndb.TextProperty()
396

397
    def __init__(self, **kwargs):
1✔
398
        """Constructor.
399

400
        Sets :attr:`obj` explicitly because however
401
        :class:`google.cloud.ndb.model.Model` sets it doesn't work with
402
        ``@property`` and ``@obj.setter`` below.
403
        """
404
        obj = kwargs.pop('obj', None)
1✔
405
        super().__init__(**kwargs)
1✔
406

407
        if obj:
1✔
408
            self.obj = obj
1✔
409

410
    @classmethod
1✔
411
    def new(cls, **kwargs):
1✔
412
        """Try to prevent instantiation. Use subclasses instead."""
UNCOV
413
        raise NotImplementedError()
×
414

415
    def _post_put_hook(self, future):
1✔
416
        logger.debug(f'Wrote {self.key}')
1✔
417

418
    @classmethod
1✔
419
    def get_by_id(cls, id, allow_opt_out=False, **kwargs):
1✔
420
        """Override to follow ``use_instead`` property and ``status``.
421

422
        Returns None if the user is opted out.
423
        """
424
        user = cls._get_by_id(id, **kwargs)
1✔
425
        if user and user.use_instead:
1✔
426
            logger.info(f'{user.key} use_instead => {user.use_instead}')
1✔
427
            user = user.use_instead.get()
1✔
428

429
        if not user:
1✔
430
            return None
1✔
431

432
        if user.status and not allow_opt_out:
1✔
433
            logger.info(f'{user.key} is {user.status}')
1✔
434
            return None
1✔
435

436
        return user
1✔
437

438
    @classmethod
1✔
439
    def get_or_create(cls, id, propagate=False, allow_opt_out=False,
1✔
440
                      reload=False, **kwargs):
441
        """Loads and returns a :class:`User`. Creates it if necessary.
442

443
        Not transactional because transactions don't read or write memcache. :/
444
        Fortunately we don't really depend on atomicity for much, last writer wins
445
        is usually fine.
446

447
        Args:
448
          propagate (bool): whether to create copies of this user in push-based
449
            protocols, eg ATProto and Nostr.
450
          allow_opt_out (bool): whether to allow and create the user if they're
451
            currently opted out
452
          reload (bool): whether to reload profile always, vs only if necessary
453
          kwargs: passed through to ``cls`` constructor
454

455
        Returns:
456
          User: existing or new user, or None if the user is opted out
457
        """
458
        assert cls != User
1✔
459

460
        user = cls.get_by_id(id, allow_opt_out=True)
1✔
461
        if user:  # existing
1✔
462
            if reload:
1✔
463
                user.reload_profile(gateway=True, raise_=False)
1✔
464

465
            if user.status and not allow_opt_out:
1✔
466
                return None
1✔
467
            user.existing = True
1✔
468

469
            # TODO: propagate more fields?
470
            changed = False
1✔
471
            for field in ['obj', 'obj_key']:
1✔
472
                old_val = getattr(user, field, None)
1✔
473
                new_val = kwargs.get(field)
1✔
474
                if old_val is None and new_val is not None:
1✔
UNCOV
475
                    setattr(user, field, new_val)
×
UNCOV
476
                    changed = True
×
477

478
            if enabled_protocols := kwargs.get('enabled_protocols'):
1✔
479
                user.enabled_protocols = (set(user.enabled_protocols)
1✔
480
                                          | set(enabled_protocols))
481
                changed = True
1✔
482

483
            if not propagate:
1✔
484
                if changed:
1✔
485
                    user.put()
1✔
486
                return user
1✔
487

488
        else:  # new, not existing
489
            if orig_key := get_original_user_key(id):
1✔
490
                orig = orig_key.get()
1✔
491
                if orig.status and not allow_opt_out:
1✔
UNCOV
492
                    return None
×
493
                orig.existing = False
1✔
494
                return orig
1✔
495

496
            user = cls(id=id, **kwargs)
1✔
497
            user.existing = False
1✔
498
            user.reload_profile(gateway=True, raise_=False)
1✔
499
            if user.status and not allow_opt_out:
1✔
500
                return None
1✔
501

502
        if propagate and user.status in (None, 'private'):
1✔
503
            for label in user.enabled_protocols + list(user.DEFAULT_ENABLED_PROTOCOLS):
1✔
504
                proto = PROTOCOLS[label]
1✔
505
                if proto == cls:
1✔
UNCOV
506
                    continue
×
507
                elif proto.HAS_COPIES:
1✔
508
                    if not user.get_copy(proto) and user.is_enabled(proto):
1✔
509
                        try:
1✔
510
                            proto.create_for(user)
1✔
511
                        except (ValueError, AssertionError):
1✔
512
                            logger.info(f'failed creating {proto.LABEL} copy',
1✔
513
                                        exc_info=True)
514
                            user.remove('enabled_protocols', proto.LABEL)
1✔
515
                    else:
516
                        logger.debug(f'{proto.LABEL} not enabled or user copy already exists, skipping propagate')
1✔
517

518
        try:
1✔
519
            user.put()
1✔
UNCOV
520
        except AssertionError as e:
×
UNCOV
521
            error(f'Bad {cls.__name__} id {id} : {e}')
×
522

523
        logger.debug(('Updated ' if user.existing else 'Created new ') + str(user))
1✔
524
        return user
1✔
525

526
    @property
1✔
527
    def obj(self):
1✔
528
        """Convenience accessor that loads :attr:`obj_key` from the datastore."""
529
        if self.obj_key:
1✔
530
            if not hasattr(self, '_obj'):
1✔
531
                self._obj = self.obj_key.get()
1✔
532
            return self._obj
1✔
533

534
    @obj.setter
1✔
535
    def obj(self, obj):
1✔
536
        if obj:
1✔
537
            assert isinstance(obj, Object)
1✔
538
            assert obj.key
1✔
539
            self._obj = obj
1✔
540
            self.obj_key = obj.key
1✔
541
        else:
542
            self._obj = self.obj_key = None
1✔
543

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

547
        Args:
548
          proto (Protocol): optional
549
        """
550
        now = util.now().isoformat()
1✔
551
        proto_label = proto.LABEL if proto else 'all'
1✔
552
        delete_id = f'{self.profile_id()}#bridgy-fed-delete-user-{proto_label}-{now}'
1✔
553
        delete = Object(id=delete_id, source_protocol=self.LABEL, our_as1={
1✔
554
            'id': delete_id,
555
            'objectType': 'activity',
556
            'verb': 'delete',
557
            'actor': self.key.id(),
558
            'object': self.key.id(),
559
        })
560
        self.deliver(delete, from_user=self, to_proto=proto)
1✔
561

562
    @classmethod
1✔
563
    def load_multi(cls, users):
1✔
564
        """Loads :attr:`obj` for multiple users in parallel.
565

566
        Args:
567
          users (sequence of User)
568
        """
569
        objs = ndb.get_multi(u.obj_key for u in users if u.obj_key)
1✔
570
        keys_to_objs = {o.key: o for o in objs if o}
1✔
571

572
        for u in users:
1✔
573
            u._obj = keys_to_objs.get(u.obj_key)
1✔
574

575
    @ndb.ComputedProperty
1✔
576
    def handle(self):
1✔
577
        """This user's unique, human-chosen handle, eg ``@me@snarfed.org``.
578

579
        To be implemented by subclasses.
580
        """
UNCOV
581
        raise NotImplementedError()
×
582

583
    @ndb.ComputedProperty
1✔
584
    def handle_as_domain(self):
1✔
585
        """This user's handle in domain-like format, via :func:`id.handle_as_domain`.
586

587
        Returns:
588
          str or None: if handle is None
589
        """
590
        return ids.handle_as_domain(self.handle)
1✔
591

592
    @ndb.ComputedProperty
1✔
593
    def status(self):
1✔
594
        """Whether this user is blocked or opted out.
595

596
        Optional. See :attr:`USER_STATUS_DESCRIPTIONS` for possible values.
597
        """
598
        if self.manual_opt_out:
1✔
599
            return 'opt-out'
1✔
600
        elif self.manual_opt_out is False:
1✔
601
            return None
1✔
602

603
        # TODO: require profile for more protocols? all?
604
        if not self.obj or not self.obj.as1:
1✔
605
            return None
1✔
606

607
        if self.obj.as1.get('bridgeable') is False:  # FEP-0036
1✔
608
            return 'opt-out'
1✔
609

610
        if self.REQUIRES_AVATAR and not self.obj.as1.get('image'):
1✔
611
            return 'requires-avatar'
1✔
612

613
        name = self.obj.as1.get('displayName')
1✔
614
        if self.REQUIRES_NAME and (not name or name in (self.handle, self.key.id())):
1✔
615
            return 'requires-name'
1✔
616

617
        if self.REQUIRES_OLD_ACCOUNT:
1✔
618
            if published := self.obj.as1.get('published'):
1✔
619
                if util.now() - util.parse_iso8601(published) < OLD_ACCOUNT_AGE:
1✔
620
                    return 'requires-old-account'
1✔
621

622
        # https://swicg.github.io/miscellany/#movedTo
623
        # https://docs.joinmastodon.org/spec/activitypub/#as
624
        if self.obj.as1.get('movedTo'):
1✔
625
            return 'moved'
1✔
626

627
        summary = html_to_text(self.obj.as1.get('summary', ''), ignore_links=True)
1✔
628
        name = html_to_text(self.obj.as1.get('displayName', ''), ignore_links=True)
1✔
629

630
        # #nobridge overrides enabled_protocols
631
        if '#nobridge' in summary or '#nobridge' in name:
1✔
632
            return 'nobridge'
1✔
633

634
        # user has explicitly opted in. should go after spam filter (REQUIRES_*)
635
        # checks, but before is_public and #nobot
636
        #
637
        # !!! WARNING: keep in sync with User.enable_protocol!
638
        if self.enabled_protocols:
1✔
639
            return None
1✔
640

641
        if not as1.is_public(self.obj.as1, unlisted=False):
1✔
642
            return 'private'
1✔
643

644
        # enabled_protocols overrides #nobot
645
        if '#nobot' in summary or '#nobot' in name:
1✔
646
            return 'nobot'
1✔
647

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

651
        Reasons this might return False:
652
        * We haven't turned on bridging these two protocols yet.
653
        * The user is opted out or blocked.
654
        * The user is on a domain that's opted out or blocked.
655
        * The from protocol requires opt in, and the user hasn't opted in.
656
        * ``explicit`` is True, and this protocol supports ``to_proto`` by, but the user hasn't explicitly opted into it.
657

658
        Args:
659
          to_proto (Protocol subclass)
660
          explicit (bool)
661

662
        Returns:
663
          bool:
664
        """
665
        from protocol import Protocol
1✔
666
        assert isinstance(to_proto, Protocol) or issubclass(to_proto, Protocol)
1✔
667

668
        if self.__class__ == to_proto:
1✔
669
            return True
1✔
670

671
        from_label = self.LABEL
1✔
672
        to_label = to_proto.LABEL
1✔
673

674
        if bot_protocol := Protocol.for_bridgy_subdomain(self.key.id()):
1✔
675
            return to_proto != bot_protocol
1✔
676

677
        elif self.manual_opt_out:
1✔
678
            return False
1✔
679

680
        elif to_label in self.enabled_protocols:
1✔
681
            return True
1✔
682

683
        elif self.status:
1✔
684
            return False
1✔
685

686
        elif to_label in self.DEFAULT_ENABLED_PROTOCOLS and not explicit:
1✔
687
            return True
1✔
688

689
        return False
1✔
690

691
    def enable_protocol(self, to_proto):
1✔
692
        """Adds ``to_proto`` to :attr:`enabled_protocols`.
693

694
        Also sends a welcome DM to the user (via a send task) if their protocol
695
        supports DMs.
696

697
        Args:
698
          to_proto (:class:`protocol.Protocol` subclass)
699
        """
700
        import dms
1✔
701

702
        # explicit opt-in overrides some status
703
        # !!! WARNING: keep in sync with User.status!
704
        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✔
705
        if self.status and self.status not in ('nobot', 'private'):
1✔
706
            if desc := USER_STATUS_DESCRIPTIONS.get(self.status):
1✔
707
                dms.maybe_send(from_=to_proto, to_user=self, type=self.status,
1✔
708
                               text=ineligible.format(desc=desc))
709
            common.error(f'Nope, user {self.key.id()} is {self.status}', status=299)
1✔
710

711
        try:
1✔
712
            self.handle_as(to_proto)
1✔
713
        except ValueError as e:
1✔
714
            dms.maybe_send(from_=to_proto, to_user=self,
1✔
715
                           type=f'unsupported-handle-{to_proto.ABBREV}',
716
                           text=ineligible.format(desc=e))
717
            common.error(str(e), status=299)
1✔
718

719
        if to_proto.LABEL in ids.COPIES_PROTOCOLS:
1✔
720
            # do this even if there's an existing copy since we might need to
721
            # reactivate it, which create_for should do
722
            to_proto.create_for(self)
1✔
723

724
        if to_proto.LABEL not in self.enabled_protocols:
1✔
725
            self.enabled_protocols.append(to_proto.LABEL)
1✔
726
            dms.maybe_send(from_=to_proto, to_user=self, type='welcome', text=f"""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.""")
1✔
727
            self.put()
1✔
728

729
        msg = f'Enabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
730
        logger.info(msg)
1✔
731

732
    def disable_protocol(self, to_proto):
1✔
733
        """Removes ``to_proto` from :attr:`enabled_protocols``.
734

735
        Args:
736
          to_proto (:class:`protocol.Protocol` subclass)
737
        """
738
        self.remove('enabled_protocols', to_proto.LABEL)
1✔
739
        self.put()
1✔
740
        msg = f'Disabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
741
        logger.info(msg)
1✔
742

743
    def handle_as(self, to_proto, short=False):
1✔
744
        """Returns this user's handle in a different protocol.
745

746
        Args:
747
          to_proto (str or Protocol)
748
          short (bool): whether to return the full handle or a shortened form.
749
            Default False. Currently only affects ActivityPub; returns just
750
            ``@[user]`` instead of ``@[user]@[domain]``
751

752
        Returns:
753
          str
754
        """
755
        if isinstance(to_proto, str):
1✔
756
            to_proto = PROTOCOLS[to_proto]
1✔
757

758
        # override to-ATProto to use custom domain handle in DID doc
759
        from atproto import ATProto, did_to_handle
1✔
760
        if to_proto == ATProto:
1✔
761
            if did := self.get_copy(ATProto):
1✔
762
                if handle := did_to_handle(did, remote=False):
1✔
763
                    return handle
1✔
764

765
        # override web users to always use domain instead of custom username
766
        # TODO: fall back to id if handle is unset?
767
        handle = self.key.id() if self.LABEL == 'web' else self.handle
1✔
768
        if not handle:
1✔
769
            return None
1✔
770

771
        return ids.translate_handle(handle=handle, from_=self.__class__,
1✔
772
                                    to=to_proto, enhanced=False, short=short)
773

774
    def id_as(self, to_proto):
1✔
775
        """Returns this user's id in a different protocol.
776

777
        Args:
778
          to_proto (str or Protocol)
779

780
        Returns:
781
          str
782
        """
783
        if isinstance(to_proto, str):
1✔
784
            to_proto = PROTOCOLS[to_proto]
1✔
785

786
        return ids.translate_user_id(id=self.key.id(), from_=self.__class__,
1✔
787
                                     to=to_proto)
788

789
    def handle_or_id(self):
1✔
790
        """Returns handle if we know it, otherwise id."""
791
        return self.handle or self.key.id()
1✔
792

793
    def public_pem(self):
1✔
794
        """Returns the user's PEM-encoded ActivityPub public RSA key.
795

796
        Returns:
797
          bytes:
798
        """
799
        self._maybe_generate_ap_key()
1✔
800
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
801
                             base64_to_long(str(self.public_exponent))))
802
        return rsa.exportKey(format='PEM')
1✔
803

804
    def private_pem(self):
1✔
805
        """Returns the user's PEM-encoded ActivityPub private RSA key.
806

807
        Returns:
808
          bytes:
809
        """
810
        self._maybe_generate_ap_key()
1✔
811
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
812
                             base64_to_long(str(self.public_exponent)),
813
                             base64_to_long(str(self.private_exponent))))
814
        return rsa.exportKey(format='PEM')
1✔
815

816
    def _maybe_generate_ap_key(self):
1✔
817
        """Generates this user's ActivityPub private key if necessary."""
818
        if not self.public_exponent or not self.private_exponent or not self.mod:
1✔
819
            logger.info(f'generating AP keypair for {self.key}')
1✔
820
            assert (not self.public_exponent and not self.private_exponent
1✔
821
                    and not self.mod), id
822
            key = RSA.generate(KEY_BITS, randfunc=random.randbytes if DEBUG else None)
1✔
823
            self.mod = long_to_base64(key.n)
1✔
824
            self.public_exponent = long_to_base64(key.e)
1✔
825
            self.private_exponent = long_to_base64(key.d)
1✔
826
            self.put()
1✔
827

828
    def nsec(self):
1✔
829
        """Returns the user's bech32-encoded Nostr private secp256k1 key.
830

831
        Returns:
832
          str:
833
        """
834
        self._maybe_generate_nostr_key()
1✔
835
        privkey = secp256k1.PrivateKey(self.nostr_key_bytes, raw=True)
1✔
836
        return granary.nostr.bech32_encode('nsec', privkey.serialize())
1✔
837

838
    def hex_pubkey(self):
1✔
839
        """Returns the user's hex-encoded Nostr public secp256k1 key.
840

841
        Returns:
842
          str:
843
        """
844
        self._maybe_generate_nostr_key()
1✔
845
        return granary.nostr.pubkey_from_privkey(self.nostr_key_bytes.hex())
1✔
846

847
    def npub(self):
1✔
848
        """Returns the user's bech32-encoded ActivityPub public secp256k1 key.
849

850
        Returns:
851
          str:
852
        """
853
        return granary.nostr.bech32_encode('npub', self.hex_pubkey())
1✔
854

855
    def _maybe_generate_nostr_key(self):
1✔
856
        """Generates this user's Nostr private key if necessary."""
857
        if not self.nostr_key_bytes:
1✔
858
            logger.info(f'generating Nostr keypair for {self.key}')
1✔
859
            self.nostr_key_bytes = secp256k1.PrivateKey().private_key
1✔
860
            self.put()
1✔
861

862
    def name(self):
1✔
863
        """Returns this user's human-readable name, eg ``Ryan Barrett``."""
864
        if self.obj and self.obj.as1:
1✔
865
            name = self.obj.as1.get('displayName')
1✔
866
            if name:
1✔
867
                return name
1✔
868

869
        return self.handle_or_id()
1✔
870

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

874
        To be implemented by subclasses.
875

876
        Returns:
877
          str
878
        """
UNCOV
879
        raise NotImplementedError()
×
880

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

884
        Args:
885
          url (str)
886
          ignore_www (bool): if True, ignores ``www.`` subdomains
887

888
        Returns:
889
          bool:
890
        """
891
        if not url:
1✔
892
            return False
1✔
893

894
        url = url.strip().rstrip('/')
1✔
895
        url = re.sub(r'^(https?://)www\.', r'\1', url)
1✔
896
        parsed_url = urlparse(url)
1✔
897
        if parsed_url.scheme not in ('http', 'https', ''):
1✔
898
            return False
1✔
899

900
        this = self.web_url().rstrip('/')
1✔
901
        this = re.sub(r'^(https?://)www\.', r'\1', this)
1✔
902
        parsed_this = urlparse(this)
1✔
903

904
        return (url == this or url == parsed_this.netloc or
1✔
905
                parsed_url[1:] == parsed_this[1:])  # ignore http vs https
906

907
    def id_uri(self):
1✔
908
        """Returns the user id as a URI.
909

910
        Sometimes this is the user id itself, eg ActivityPub actor ids.
911
        Sometimes it's a bit different, eg at://did:plc:... for ATProto user,
912
        https://site.com for Web users.
913

914
        Returns:
915
          str
916
        """
917
        return self.key.id()
1✔
918

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

922
        Examples:
923

924
        * Web: home page URL, eg ``https://me.com/``
925
        * ActivityPub: actor URL, eg ``https://instance.com/users/me``
926
        * ATProto: profile AT URI, eg ``at://did:plc:123/app.bsky.actor.profile/self``
927

928
        Defaults to this user's key id.
929

930
        Returns:
931
          str or None:
932
        """
933
        return ids.profile_id(id=self.key.id(), proto=self)
1✔
934

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

938
        Populates the reloaded profile :class:`Object` in ``self.obj``.
939

940
        Args:
941
          kwargs: passed through to :meth:`Protocol.load`
942
        """
943
        obj = self.load(self.profile_id(), remote=True, **kwargs)
1✔
944
        if obj:
1✔
945
            self.obj = obj
1✔
946

947
        # write the user so that we re-populate any computed properties
948
        self.put()
1✔
949

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

953
        Args:
954
          rest (str): additional path and/or query to add to the end
955
          prefer_id (bool): whether to prefer to use the account's id in the path
956
            instead of handle. Defaults to ``False``.
957
        """
958
        path = f'/{self.ABBREV}/{self.key.id() if prefer_id else self.handle_or_id()}'
1✔
959

960
        if rest:
1✔
961
            if not (rest.startswith('?') or rest.startswith('/')):
1✔
962
                path += '/'
1✔
963
            path += rest
1✔
964

965
        return path
1✔
966

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

970
        ...or None if no such copy exists. If ``proto`` is this user, returns
971
        this user's key id.
972

973
        Args:
974
          proto: :class:`Protocol` subclass
975

976
        Returns:
977
          str:
978
        """
979
        # don't use isinstance because the testutil Fake protocol has subclasses
980
        if self.LABEL == proto.LABEL:
1✔
981
            return self.key.id()
1✔
982

983
        for copy in self.copies:
1✔
984
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
985
                return copy.uri
1✔
986

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

991
        Can optionally include display name, handle, profile
992
        picture, and/or link to a different protocol that they've enabled.
993

994
        TODO: unify with :meth:`Object.actor_link`?
995

996
        Args:
997
          name (bool): include display name
998
          handle (bool): True to include handle, False to exclude it, ``'short'``
999
            to include a shortened version, if available
1000
          pictures (bool): include profile picture and protocol logo
1001
          logo (str): optional path to platform logo to show instead of the
1002
            protocol's default
1003
          proto (protocol.Protocol): link to this protocol instead of the user's
1004
            native protocol
1005
          proto_fallback (bool): if True, and ``proto`` is provided and has no
1006
            no canonical profile URL for bridged users, uses the user's profile
1007
            URL in their native protocol
1008
        """
1009
        img = name_str = full_handle = handle_str = dot = logo_html = a_open = a_close = ''
1✔
1010

1011
        if proto:
1✔
1012
            assert self.is_enabled(proto), f"{proto.LABEL} isn't enabled"
1✔
1013
            url = proto.bridged_web_url_for(self, fallback=proto_fallback)
1✔
1014
        else:
1015
            proto = self.__class__
1✔
1016
            url = self.web_url()
1✔
1017

1018
        if pictures:
1✔
1019
            if logo:
1✔
1020
                logo_html = f'<img class="logo" src="{logo}" /> '
1✔
1021
            else:
1022
                logo_html = f'<span class="logo" title="{proto.__name__}">{proto.LOGO_HTML or proto.LOGO_EMOJI}</span> '
1✔
1023
            if pic := self.profile_picture():
1✔
1024
                img = f'<img src="{pic}" class="profile"> '
1✔
1025

1026
        if handle:
1✔
1027
            full_handle = self.handle_as(proto) or ''
1✔
1028
            handle_str = self.handle_as(proto, short=(handle == 'short')) or ''
1✔
1029

1030
        if name and self.name() != full_handle:
1✔
1031
            name_str = self.name() or ''
1✔
1032

1033
        if handle_str and name_str:
1✔
1034
            dot = ' &middot; '
1✔
1035

1036
        if url:
1✔
1037
            a_open = f'<a class="h-card u-author mention" rel="me" href="{url}" title="{name_str}{dot}{full_handle}">'
1✔
1038
            a_close = '</a>'
1✔
1039

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

1043
    def profile_picture(self):
1✔
1044
        """Returns the user's profile picture image URL, if available, or None."""
1045
        if self.obj and self.obj.as1:
1✔
1046
            return util.get_url(self.obj.as1, 'image')
1✔
1047

1048
    # can't use functools.lru_cache here because we want the cache key to be
1049
    # just the user id, not the whole entity
1050
    @cachetools.cached(
1✔
1051
        cachetools.TTLCache(50000, FOLLOWERS_CACHE_EXPIRATION.total_seconds()),
1052
        key=lambda user: user.key.id(), lock=Lock())
1053
    @memcache.memoize(key=lambda self: self.key.id(),
1✔
1054
                      expire=FOLLOWERS_CACHE_EXPIRATION)
1055
    def count_followers(self):
1✔
1056
        """Counts this user's followers and followings.
1057

1058
        Returns:
1059
          (int, int) tuple: (number of followers, number following)
1060
        """
1061
        if self.key.id() in PROTOCOL_DOMAINS:
1✔
1062
            # we don't store Followers for protocol bot users any more, so
1063
            # follower counts are inaccurate, so don't return them
1064
            return (0, 0)
1✔
1065

1066
        num_followers = Follower.query(Follower.to == self.key,
1✔
1067
                                       Follower.status == 'active')\
1068
                                .count_async()
1069
        num_following = Follower.query(Follower.from_ == self.key,
1✔
1070
                                       Follower.status == 'active')\
1071
                                .count_async()
1072
        return num_followers.get_result(), num_following.get_result()
1✔
1073

1074

1075
# WARNING: AddRemoveMixin *must* be before StringIdModel here so that its __init__
1076
# gets called! Due to an (arguable) ndb.Model bug:
1077
# https://github.com/googleapis/python-ndb/issues/1025
1078
class Object(AddRemoveMixin, StringIdModel):
1✔
1079
    """An activity or other object, eg actor.
1080

1081
    Key name is the id, generally a URI. We synthesize ids if necessary.
1082
    """
1083
    GET_ORIGINAL_FN = get_original_object_key
1✔
1084
    'used by AddRemoveMixin'
1✔
1085

1086
    users = ndb.KeyProperty(repeated=True)
1✔
1087
    'User(s) who created or otherwise own this object.'
1✔
1088

1089
    notify = ndb.KeyProperty(repeated=True)
1✔
1090
    """User who should see this in their user page, eg in reply to, reaction to,
1✔
1091
    share of, etc.
1092
    """
1093
    feed = ndb.KeyProperty(repeated=True)
1✔
1094
    'User who should see this in their feeds, eg followers of its creator'
1✔
1095

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

1100
    TODO: nail down whether this is :attr:`ABBREV`` or :attr:`LABEL`
1101
    """
1102

1103
    # TODO: switch back to ndb.JsonProperty if/when they fix it for the web console
1104
    # https://github.com/googleapis/python-ndb/issues/874
1105
    as2 = JsonProperty()
1✔
1106
    'ActivityStreams 2, for ActivityPub'
1✔
1107
    bsky = JsonProperty()
1✔
1108
    'AT Protocol lexicon, for Bluesky'
1✔
1109
    mf2 = JsonProperty()
1✔
1110
    'HTML microformats2 item (*not* top level parse object with ``items`` field)'
1✔
1111
    nostr = JsonProperty()
1✔
1112
    'Nostr event'
1✔
1113
    our_as1 = JsonProperty()
1✔
1114
    'ActivityStreams 1, for activities that we generate or modify ourselves'
1✔
1115
    raw = JsonProperty()
1✔
1116
    'Other standalone data format, eg DID document'
1✔
1117

1118
    # TODO: remove and actually delete Objects instead!
1119
    deleted = ndb.BooleanProperty()
1✔
1120
    ''
1✔
1121

1122
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
1123
    """Copies of this object elsewhere, eg at:// URIs for ATProto records and
1✔
1124
    nevent etc bech32-encoded Nostr ids, where this object is the original.
1125
    Similar to u-syndication links in microformats2 and
1126
    upstream/downstreamDuplicates in AS1.
1127
    """
1128

1129
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1130
    ''
1✔
1131
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1132
    ''
1✔
1133

1134
    new = None
1✔
1135
    """True if this object is new, ie this is the first time we've seen it,
1✔
1136
    False otherwise, None if we don't know.
1137
    """
1138
    changed = None
1✔
1139
    """True if this object's contents have changed from our existing copy in the
1✔
1140
    datastore, False otherwise, None if we don't know. :class:`Object` is
1141
    new/changed. See :meth:`activity_changed()` for more details.
1142
    """
1143

1144
    # DEPRECATED
1145
    # These were for full feeds with multiple items, not just this one, so they were
1146
    # stored as audit records only, not used in to_as1. for Atom/RSS
1147
    # based Objects, our_as1 was populated with an feed_index top-level
1148
    # integer field that indexed into one of these.
1149
    #
1150
    # atom = ndb.TextProperty() # Atom XML
1151
    # rss = ndb.TextProperty()  # RSS XML
1152

1153
    # DEPRECATED; these were for delivery tracking, but they were too expensive,
1154
    # so we stopped: https://github.com/snarfed/bridgy-fed/issues/1501
1155
    #
1156
    # STATUSES = ('new', 'in progress', 'complete', 'failed', 'ignored')
1157
    # status = ndb.StringProperty(choices=STATUSES)
1158
    # delivered = ndb.StructuredProperty(Target, repeated=True)
1159
    # undelivered = ndb.StructuredProperty(Target, repeated=True)
1160
    # failed = ndb.StructuredProperty(Target, repeated=True)
1161

1162
    # DEPRECATED but still used read only to maintain backward compatibility
1163
    # with old Objects in the datastore that we haven't bothered migrating.
1164
    #
1165
    # domains = ndb.StringProperty(repeated=True)
1166

1167
    # DEPRECATED; replaced by :attr:`users`, :attr:`notify`, :attr:`feed`
1168
    #
1169
    # labels = ndb.StringProperty(repeated=True,
1170
    #                             choices=('activity', 'feed', 'notification', 'user'))
1171

1172
    @property
1✔
1173
    def as1(self):
1✔
1174
        def use_urls_as_ids(obj):
1✔
1175
            """If id field is missing or not a URL, use the url field."""
1176
            id = obj.get('id')
1✔
1177
            if not id or not (util.is_web(id) or re.match(DOMAIN_RE, id)):
1✔
1178
                if url := util.get_url(obj):
1✔
1179
                    obj['id'] = url
1✔
1180

1181
            for field in 'author', 'actor', 'object':
1✔
1182
                if inner := as1.get_object(obj, field):
1✔
1183
                    use_urls_as_ids(inner)
1✔
1184

1185
        if self.our_as1:
1✔
1186
            obj = self.our_as1
1✔
1187
            if self.source_protocol == 'web':
1✔
1188
                use_urls_as_ids(obj)
1✔
1189

1190
        elif self.as2:
1✔
1191
            obj = as2.to_as1(unwrap(self.as2))
1✔
1192

1193
        elif self.bsky:
1✔
1194
            owner, _, _ = parse_at_uri(self.key.id())
1✔
1195
            ATProto = PROTOCOLS['atproto']
1✔
1196
            handle = ATProto(id=owner).handle
1✔
1197
            try:
1✔
1198
                obj = bluesky.to_as1(self.bsky, repo_did=owner, repo_handle=handle,
1✔
1199
                                     uri=self.key.id(), pds=ATProto.pds_for(self))
1200
            except (ValueError, RequestException):
1✔
1201
                logger.info(f"Couldn't convert to ATProto", exc_info=True)
1✔
1202
                return None
1✔
1203

1204
        elif self.mf2:
1✔
1205
            obj = microformats2.json_to_object(self.mf2,
1✔
1206
                                               rel_urls=self.mf2.get('rel-urls'))
1207
            use_urls_as_ids(obj)
1✔
1208

1209
            # use fetched final URL as id, not u-url
1210
            # https://github.com/snarfed/bridgy-fed/issues/829
1211
            if url := self.mf2.get('url'):
1✔
1212
                obj['id'] = (self.key.id() if self.key and '#' in self.key.id()
1✔
1213
                             else url)
1214

1215
        elif self.nostr:
1✔
1216
            obj = granary.nostr.to_as1(self.nostr)
1✔
1217

1218
        else:
1219
            return None
1✔
1220

1221
        # populate id if necessary
1222
        if self.key:
1✔
1223
            obj.setdefault('id', self.key.id())
1✔
1224

1225
        if util.domain_or_parent_in(obj.get('id'), IMAGE_PROXY_DOMAINS):
1✔
1226
           as1.prefix_urls(obj, 'image', IMAGE_PROXY_URL_BASE)
1✔
1227

1228
        return obj
1✔
1229

1230
    @ndb.ComputedProperty
1✔
1231
    def type(self):  # AS1 objectType, or verb if it's an activity
1✔
1232
        if self.as1:
1✔
1233
            return as1.object_type(self.as1)
1✔
1234

1235
    def _expire(self):
1✔
1236
        """Automatically delete most Objects after a while using a TTL policy.
1237

1238
        https://cloud.google.com/datastore/docs/ttl
1239

1240
        They recommend not indexing TTL properties:
1241
        https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
1242
        """
1243
        now = self.updated or util.now()
1✔
1244
        if self.deleted:
1✔
1245
            return now + timedelta(days=1)
1✔
1246
        elif self.type not in DONT_EXPIRE_OBJECT_TYPES:
1✔
1247
            return now + OBJECT_EXPIRE_AGE
1✔
1248

1249
    expire = ndb.ComputedProperty(_expire, indexed=False)
1✔
1250

1251
    def _pre_put_hook(self):
1✔
1252
        """
1253
        * Validate that at:// URIs have DID repos
1254
        * Set/remove the activity label
1255
        * Strip @context from as2 (we don't do LD) to save disk space
1256
        """
1257
        id = self.key.id()
1✔
1258

1259
        if self.source_protocol not in (None, 'ui'):
1✔
1260
            proto = PROTOCOLS[self.source_protocol]
1✔
1261
            assert proto.owns_id(id) is not False, \
1✔
1262
                f'Protocol {proto.LABEL} does not own id {id}'
1263

1264
        if id.startswith('at://'):
1✔
1265
            repo, _, _ = parse_at_uri(id)
1✔
1266
            if not repo.startswith('did:'):
1✔
1267
                # TODO: if we hit this, that means the AppView gave us an AT URI
1268
                # with a handle repo/authority instead of DID. that's surprising!
1269
                # ...if so, and if we need to handle it, add a new
1270
                # arroba.did.canonicalize_at_uri() function, then use it here,
1271
                # or before.
1272
                raise ValueError(
1✔
1273
                    f'at:// URI ids must have DID repos; got {id}')
1274

1275
        if self.as2:
1✔
1276
           self.as2.pop('@context', None)
1✔
1277
           for field in 'actor', 'attributedTo', 'author', 'object':
1✔
1278
               for val in util.get_list(self.as2, field):
1✔
1279
                   if isinstance(val, dict):
1✔
1280
                       val.pop('@context', None)
1✔
1281

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

1286
    @classmethod
1✔
1287
    def get_by_id(cls, id, authed_as=None, **kwargs):
1✔
1288
        """Fetches the :class:`Object` with the given id, if it exists.
1289

1290
        Args:
1291
          id (str)
1292
          authed_as (str): optional; if provided, and a matching :class:`Object`
1293
            already exists, its ``author`` or ``actor`` must contain this actor
1294
            id. Implements basic authorization for updates and deletes.
1295

1296
        Returns:
1297
          Object:
1298

1299
        Raises:
1300
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1301
            the existing object
1302
        """
1303
        key_id = id
1✔
1304
        if len(key_id) > _MAX_KEYPART_BYTES:
1✔
1305
            # TODO: handle Unicode chars. naive approach is to UTF-8 encode,
1306
            # truncate, then decode, but that might cut mid character. easier to just
1307
            # hope/assume the URL is already URL-encoded.
1308
            key_id = key_id[:_MAX_KEYPART_BYTES]
1✔
1309
            logger.warning(f'Truncating id to {_MAX_KEYPART_BYTES} chars: {key_id}')
1✔
1310

1311
        obj = super().get_by_id(key_id, **kwargs)
1✔
1312

1313
        if obj and obj.as1 and authed_as:
1✔
1314
            # authorization: check that the authed user is allowed to modify
1315
            # this object
1316
            # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1317
            proto = PROTOCOLS.get(obj.source_protocol)
1✔
1318
            assert proto, obj.source_protocol
1✔
1319
            owners = [ids.normalize_user_id(id=owner, proto=proto)
1✔
1320
                      for owner in (as1.get_ids(obj.as1, 'author')
1321
                                    + as1.get_ids(obj.as1, 'actor'))
1322
                                    + [id]]
1323
            if (ids.normalize_user_id(id=authed_as, proto=proto) not in owners
1✔
1324
                    and ids.profile_id(id=authed_as, proto=proto) not in owners):
1325
                report_error("Auth: Object: authed_as doesn't match owner",
1✔
1326
                             user=f'{id} authed_as {authed_as} owners {owners}')
1327
                error(f"authed user {authed_as} isn't object owner {owners}",
1✔
1328
                      status=403)
1329

1330
        return obj
1✔
1331

1332
    @classmethod
1✔
1333
    def get_or_create(cls, id, authed_as=None, **props):
1✔
1334
        """Returns an :class:`Object` with the given property values.
1335

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

1340
        Not transactional because transactions don't read or write memcache. :/
1341
        Fortunately we don't really depend on atomicity for much, last writer wins
1342
        is usually fine.
1343

1344
        Args:
1345
          authed_as (str): optional; if provided, and a matching :class:`Object`
1346
            already exists, its ``author`` or ``actor`` must contain this actor
1347
            id. Implements basic authorization for updates and deletes.
1348

1349
        Returns:
1350
          Object:
1351

1352
        Raises:
1353
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1354
            the existing object
1355
        """
1356
        key_id = id
1✔
1357
        if len(key_id) > _MAX_KEYPART_BYTES:
1✔
1358
            # TODO: handle Unicode chars. naive approach is to UTF-8 encode,
1359
            # truncate, then decode, but that might cut mid character. easier to just
1360
            # hope/assume the URL is already URL-encoded.
1361
            key_id = key_id[:_MAX_KEYPART_BYTES]
1✔
1362
            logger.warning(f'Truncating id to {_MAX_KEYPART_BYTES} chars: {key_id}')
1✔
1363

1364
        obj = cls.get_by_id(key_id, authed_as=authed_as)
1✔
1365

1366
        if not obj:
1✔
1367
            obj = Object(id=key_id, **props)
1✔
1368
            obj.new = True
1✔
1369
            obj.changed = False
1✔
1370
            obj.put()
1✔
1371
            return obj
1✔
1372

1373
        if orig_as1 := obj.as1:
1✔
1374
            # get_by_id() checks authorization if authed_as is set. make sure
1375
            # it's always set for existing objects.
1376
            assert authed_as
1✔
1377

1378
        dirty = False
1✔
1379
        for prop, val in props.items():
1✔
1380
            assert not isinstance(getattr(Object, prop), ndb.ComputedProperty)
1✔
1381
            if prop in ('copies', 'feed', 'notify', 'users'):
1✔
1382
                # merge repeated fields
1383
                for elem in val:
1✔
1384
                    if obj.add(prop, elem):
1✔
1385
                        dirty = True
1✔
1386
            elif val is not None and val != getattr(obj, prop):
1✔
1387
                setattr(obj, prop, val)
1✔
1388
                if prop in ('as2', 'bsky', 'mf2', 'raw') and not props.get('our_as1'):
1✔
1389
                    obj.our_as1 = None
1✔
1390
                dirty = True
1✔
1391

1392
        obj.new = False
1✔
1393
        obj.changed = obj.activity_changed(orig_as1)
1✔
1394
        if dirty:
1✔
1395
            obj.put()
1✔
1396
        return obj
1✔
1397

1398
    @staticmethod
1✔
1399
    def from_request():
1✔
1400
        """Creates and returns an :class:`Object` from form-encoded JSON parameters.
1401

1402
        Parameters:
1403
          obj_id (str): id of :class:`models.Object` to handle
1404
          *: If ``obj_id`` is unset, all other parameters are properties for a
1405
            new :class:`models.Object` to handle
1406
        """
1407
        if obj_id := request.form.get('obj_id'):
1✔
1408
            return Object.get_by_id(obj_id)
1✔
1409

1410
        props = {field: request.form.get(field)
1✔
1411
                 for field in ('id', 'source_protocol')}
1412

1413
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'nostr', 'raw':
1✔
1414
            if val := request.form.get(json_prop):
1✔
1415
                props[json_prop] = json_loads(val)
1✔
1416

1417
        obj = Object(**props)
1✔
1418
        if not obj.key and obj.as1:
1✔
1419
            if id := obj.as1.get('id'):
1✔
1420
                obj.key = ndb.Key(Object, id)
1✔
1421

1422
        return obj
1✔
1423

1424
    def to_request(self):
1✔
1425
        """Returns a query parameter dict representing this :class:`Object`."""
1426
        form = {}
1✔
1427

1428
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1429
            if val := getattr(self, json_prop, None):
1✔
1430
                form[json_prop] = json_dumps(val, sort_keys=True)
1✔
1431

1432
        for prop in ['source_protocol']:
1✔
1433
            if val := getattr(self, prop):
1✔
1434
                form[prop] = val
1✔
1435

1436
        if self.key:
1✔
1437
            form['id'] = self.key.id()
1✔
1438

1439
        return form
1✔
1440

1441
    def activity_changed(self, other_as1):
1✔
1442
        """Returns True if this activity is meaningfully changed from ``other_as1``.
1443

1444
        ...otherwise False.
1445

1446
        Used to populate :attr:`changed`.
1447

1448
        Args:
1449
          other_as1 (dict): AS1 object, or none
1450
        """
1451
        # ignore inReplyTo since we translate it between protocols
1452
        return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
1✔
1453
                if self.as1 and other_as1
1454
                else bool(self.as1) != bool(other_as1))
1455

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

1459
        TODO: unify with :meth:`User.user_link`?
1460

1461
        Args:
1462
          image (bool): whether to include an ``img`` tag with the actor's picture
1463
          sized (bool): whether to set an explicit (``width=32``) size on the
1464
            profile picture ``img`` tag
1465
          user (User): current user
1466

1467
        Returns:
1468
          str:
1469
        """
1470
        attrs = {'class': 'h-card u-author'}
1✔
1471

1472
        if user and user.key in self.users:
1✔
1473
            # outbound; show a nice link to the user
1474
            return user.user_link(handle=False, pictures=True)
1✔
1475

1476
        proto = PROTOCOLS.get(self.source_protocol)
1✔
1477

1478
        actor = None
1✔
1479
        if self.as1:
1✔
1480
            actor = (as1.get_object(self.as1, 'actor')
1✔
1481
                     or as1.get_object(self.as1, 'author'))
1482
            # hydrate from datastore if available
1483
            # TODO: optimize! this is called serially in loops, eg in home.html
1484
            if set(actor.keys()) == {'id'} and self.source_protocol:
1✔
1485
                actor_obj = proto.load(actor['id'], remote=False)
1✔
1486
                if actor_obj and actor_obj.as1:
1✔
1487
                    actor = actor_obj.as1
1✔
1488

1489
        if not actor:
1✔
1490
            return ''
1✔
1491
        elif set(actor.keys()) == {'id'}:
1✔
1492
            return common.pretty_link(actor['id'], attrs=attrs, user=user)
1✔
1493

1494
        url = as1.get_url(actor)
1✔
1495
        name = actor.get('displayName') or actor.get('username') or ''
1✔
1496
        img_url = util.get_url(actor, 'image')
1✔
1497
        if not image or not img_url:
1✔
1498
            return common.pretty_link(url, text=name, attrs=attrs, user=user)
1✔
1499

1500
        logo = ''
1✔
1501
        if proto:
1✔
UNCOV
1502
            logo = f'<span class="logo" title="{self.__class__.__name__}">{proto.LOGO_HTML or proto.LOGO_EMOJI}</span>'
×
1503

1504
        return f"""\
1✔
1505
        {logo}
1506
        <a class="h-card u-author" href="{url}" title="{name}">
1507
          <img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
1508
          <span style="unicode-bidi: isolate">{util.ellipsize(name, chars=40)}</span>
1509
        </a>"""
1510

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

1514
        ...or None if no such copy exists. If ``proto`` is ``source_protocol``,
1515
        returns this object's key id.
1516

1517
        Args:
1518
          proto: :class:`Protocol` subclass
1519

1520
        Returns:
1521
          str:
1522
        """
1523
        if self.source_protocol in (proto.LABEL, proto.ABBREV):
1✔
1524
            return self.key.id()
1✔
1525

1526
        for copy in self.copies:
1✔
1527
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
1528
                return copy.uri
1✔
1529

1530
    def resolve_ids(self):
1✔
1531
        """Replaces "copy" ids, subdomain ids, etc with their originals.
1532

1533
        The end result is that all ids are original "source" ids, ie in the
1534
        protocol that they first came from.
1535

1536
        Specifically, resolves:
1537

1538
        * ids in :class:`User.copies` and :class:`Object.copies`, eg ATProto
1539
          records and Nostr events that we bridged, to the ids of their
1540
          original objects in their source protocol, eg
1541
          ``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
1542
        * Bridgy Fed subdomain URLs to the ids embedded inside them, eg
1543
          ``https://bsky.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
1544
        * ATProto bsky.app URLs to their DIDs or `at://` URIs, eg
1545
          ``https://bsky.app/profile/a.com`` => ``did:plc:123``
1546

1547
        ...in these AS1 fields, in place:
1548

1549
        * ``id``
1550
        * ``actor``
1551
        * ``author``
1552
        * ``object``
1553
        * ``object.actor``
1554
        * ``object.author``
1555
        * ``object.id``
1556
        * ``object.inReplyTo``
1557
        * ``attachments.[objectType=note].id``
1558
        * ``tags.[objectType=mention].url``
1559

1560
        :meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
1561
        Much of the same logic is duplicated there!
1562

1563
        TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
1564
        """
1565
        if not self.as1:
1✔
1566
            return
1✔
1567

1568
        # extract ids, strip Bridgy Fed subdomain URLs
1569
        outer_obj = unwrap(self.as1)
1✔
1570
        if outer_obj != self.as1:
1✔
1571
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1572

1573
        self_proto = PROTOCOLS.get(self.source_protocol)
1✔
1574
        if not self_proto:
1✔
1575
            return
1✔
1576

1577
        inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
1✔
1578
        replaced = False
1✔
1579

1580
        def replace(val, orig_fn):
1✔
1581
            id = val.get('id') if isinstance(val, dict) else val
1✔
1582
            if not id or not self_proto.HAS_COPIES:
1✔
1583
                return id
1✔
1584

1585
            orig = orig_fn(id)
1✔
1586
            if not orig:
1✔
1587
                return val
1✔
1588

1589
            nonlocal replaced
1590
            replaced = True
1✔
1591
            logger.debug(f'Resolved copy id {val} to original {orig.id()}')
1✔
1592

1593
            if isinstance(val, dict) and util.trim_nulls(val).keys() > {'id'}:
1✔
1594
                val['id'] = orig.id()
1✔
1595
                return val
1✔
1596
            else:
1597
                return orig.id()
1✔
1598

1599
        # actually replace ids
1600
        #
1601
        # object field could be either object (eg repost) or actor (eg follow)
1602
        outer_obj['object'] = replace(inner_obj, get_original_object_key)
1✔
1603
        if not replaced:
1✔
1604
            outer_obj['object'] = replace(inner_obj, get_original_user_key)
1✔
1605

1606
        for obj in outer_obj, inner_obj:
1✔
1607
            for tag in as1.get_objects(obj, 'tags'):
1✔
1608
                if tag.get('objectType') == 'mention':
1✔
1609
                    tag['url'] = replace(tag.get('url'), get_original_user_key)
1✔
1610
            for att in as1.get_objects(obj, 'attachments'):
1✔
1611
                if att.get('objectType') == 'note':
1✔
1612
                    att['id'] = replace(att.get('id'), get_original_object_key)
1✔
1613
            for field, fn in (
1✔
1614
                    ('actor', get_original_user_key),
1615
                    ('author', get_original_user_key),
1616
                    ('inReplyTo', get_original_object_key),
1617
                ):
1618
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1619
                if len(obj[field]) == 1:
1✔
1620
                    obj[field] = obj[field][0]
1✔
1621

1622
        if replaced:
1✔
1623
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1624

1625
    def normalize_ids(self):
1✔
1626
        """Normalizes ids to their protocol's canonical representation, if any.
1627

1628
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
1629
        for profiles, ``at://`` URIs for posts.
1630

1631
        Modifies this object in place.
1632

1633
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
1634
        """
1635
        from protocol import Protocol
1✔
1636

1637
        if not self.as1:
1✔
1638
            return
1✔
1639

1640
        logger.debug(f'Normalizing ids')
1✔
1641
        outer_obj = copy.deepcopy(self.as1)
1✔
1642
        inner_objs = as1.get_objects(outer_obj)
1✔
1643
        replaced = False
1✔
1644

1645
        def replace(val, translate_fn):
1✔
1646
            nonlocal replaced
1647

1648
            orig = val.get('id') if isinstance(val, dict) else val
1✔
1649
            if not orig:
1✔
1650
                return val
1✔
1651

1652
            proto = Protocol.for_id(orig, remote=False)
1✔
1653
            if not proto:
1✔
1654
                return val
1✔
1655

1656
            translated = translate_fn(id=orig, from_=proto, to=proto)
1✔
1657
            if translated and translated != orig:
1✔
1658
                # logger.debug(f'Normalized {proto.LABEL} id {orig} to {translated}')
1659
                replaced = True
1✔
1660
                if isinstance(val, dict):
1✔
1661
                    val['id'] = translated
1✔
1662
                    return val
1✔
1663
                else:
1664
                    return translated
1✔
1665

1666
            return val
1✔
1667

1668
        # actually replace ids
1669
        for obj in [outer_obj] + inner_objs:
1✔
1670
            for tag in as1.get_objects(obj, 'tags'):
1✔
1671
                if tag.get('objectType') == 'mention':
1✔
1672
                    tag['url'] = replace(tag.get('url'), ids.translate_user_id)
1✔
1673
            for field in ['actor', 'author', 'inReplyTo']:
1✔
1674
                fn = (ids.translate_object_id if field == 'inReplyTo'
1✔
1675
                      else ids.translate_user_id)
1676
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1677
                if len(obj[field]) == 1:
1✔
1678
                    obj[field] = obj[field][0]
1✔
1679

1680
        outer_obj['object'] = []
1✔
1681
        for inner_obj in inner_objs:
1✔
1682
            translate_fn = ids.translate_object_id
1✔
1683
            if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
1✔
1684
                    or as1.object_type(outer_obj) in as1.VERBS_WITH_ACTOR_OBJECT):
1685
                translate_fn = ids.translate_user_id
1✔
1686

1687
            got = replace(inner_obj, translate_fn)
1✔
1688
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
1689
                got = got['id']
1✔
1690

1691
            outer_obj['object'].append(got)
1✔
1692

1693
        if len(outer_obj['object']) == 1:
1✔
1694
            outer_obj['object'] = outer_obj['object'][0]
1✔
1695

1696
        if replaced:
1✔
1697
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1698

1699

1700
class Follower(ndb.Model):
1✔
1701
    """A follower of a Bridgy Fed user."""
1702
    STATUSES = ('active', 'inactive')
1✔
1703

1704
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
1705
    """The follower."""
1✔
1706
    to = ndb.KeyProperty(required=True)
1✔
1707
    """The followee, ie the user being followed."""
1✔
1708

1709
    follow = ndb.KeyProperty(Object)
1✔
1710
    """The last follow activity."""
1✔
1711
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
1712
    """Whether this follow is active or not."""
1✔
1713

1714
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1715
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1716

1717
    # OLD. some stored entities still have these; do not reuse.
1718
    # src = ndb.StringProperty()
1719
    # dest = ndb.StringProperty()
1720
    # last_follow = JsonProperty()
1721

1722
    def _pre_put_hook(self):
1✔
1723
        # we're a bridge! stick with bridging.
1724
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
1725

1726
    def _post_put_hook(self, future):
1✔
1727
        logger.debug(f'Wrote {self.key}')
1✔
1728

1729
    @classmethod
1✔
1730
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
1731
        """Returns a Follower with the given ``from_`` and ``to`` users.
1732

1733
        Not transactional because transactions don't read or write memcache. :/
1734
        Fortunately we don't really depend on atomicity for much, last writer wins
1735
        is usually fine.
1736

1737
        If a matching :class:`Follower` doesn't exist in the datastore, creates
1738
        it first.
1739

1740
        Args:
1741
          from_ (User)
1742
          to (User)
1743

1744
        Returns:
1745
          Follower:
1746
        """
1747
        assert from_
1✔
1748
        assert to
1✔
1749

1750
        follower = Follower.query(Follower.from_ == from_.key,
1✔
1751
                                  Follower.to == to.key,
1752
                                  ).get()
1753
        if not follower:
1✔
1754
            follower = Follower(from_=from_.key, to=to.key, **kwargs)
1✔
1755
            follower.put()
1✔
1756
        elif kwargs:
1✔
1757
            # update existing entity with new property values, eg to make an
1758
            # inactive Follower active again
1759
            for prop, val in kwargs.items():
1✔
1760
                setattr(follower, prop, val)
1✔
1761
            follower.put()
1✔
1762

1763
        return follower
1✔
1764

1765
    @staticmethod
1✔
1766
    def fetch_page(collection, user):
1✔
1767
        r"""Fetches a page of :class:`Follower`\s for a given user.
1768

1769
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
1770
        parameters, if available in the request.
1771

1772
        Args:
1773
          collection (str): ``followers`` or ``following``
1774
          user (User)
1775

1776
        Returns:
1777
          (list of Follower, str, str) tuple: results, annotated with an extra
1778
          ``user`` attribute that holds the follower or following :class:`User`,
1779
          and new str query param values for ``before`` and ``after`` to fetch
1780
          the previous and next pages, respectively
1781
        """
1782
        assert collection in ('followers', 'following'), collection
1✔
1783

1784
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1785
        query = Follower.query(
1✔
1786
            Follower.status == 'active',
1787
            filter_prop == user.key,
1788
        )
1789

1790
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
1791
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
1792
                              for f in followers)
1793
        User.load_multi(u for u in users if u)
1✔
1794

1795
        for f, u in zip(followers, users):
1✔
1796
            f.user = u
1✔
1797

1798
        followers = [f for f in followers if f.user]
1✔
1799

1800
        # only show followers in protocols that this user is bridged into
1801
        if collection == 'followers':
1✔
1802
            followers = [f for f in followers if user.is_enabled(f.user)]
1✔
1803

1804
        return followers, before, after
1✔
1805

1806

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

1810
    Wraps :func:`fetch_page` and adds attributes to the returned
1811
    :class:`Object` entities for rendering in ``objects.html``.
1812

1813
    Args:
1814
      query (ndb.Query)
1815
      by (ndb.model.Property): either :attr:`Object.updated` or
1816
        :attr:`Object.created`
1817
      user (User): current user
1818

1819
    Returns:
1820
      (list of Object, str, str) tuple:
1821
      (results, new ``before`` query param, new ``after`` query param)
1822
      to fetch the previous and next pages, respectively
1823
    """
1824
    assert by is Object.updated or by is Object.created
1✔
1825
    objects, new_before, new_after = fetch_page(query, Object, by=by)
1✔
1826
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
1827

1828
    # synthesize human-friendly content for objects
1829
    for i, obj in enumerate(objects):
1✔
1830
        obj_as1 = obj.as1
1✔
1831
        type = as1.object_type(obj_as1)
1✔
1832

1833
        # AS1 verb => human-readable phrase
1834
        phrases = {
1✔
1835
            'accept': 'accepted',
1836
            'article': 'posted',
1837
            'comment': 'replied',
1838
            'delete': 'deleted',
1839
            'follow': 'followed',
1840
            'invite': 'is invited to',
1841
            'issue': 'filed issue',
1842
            'like': 'liked',
1843
            'note': 'posted',
1844
            'post': 'posted',
1845
            'repost': 'reposted',
1846
            'rsvp-interested': 'is interested in',
1847
            'rsvp-maybe': 'might attend',
1848
            'rsvp-no': 'is not attending',
1849
            'rsvp-yes': 'is attending',
1850
            'share': 'reposted',
1851
            'stop-following': 'unfollowed',
1852
            'undo': 'undid',
1853
            'update': 'updated',
1854
        }
1855
        phrases.update({type: 'profile refreshed:' for type in as1.ACTOR_TYPES})
1✔
1856

1857
        obj.phrase = phrases.get(type, '')
1✔
1858

1859
        content = (obj_as1.get('content')
1✔
1860
                   or obj_as1.get('displayName')
1861
                   or obj_as1.get('summary'))
1862
        if content:
1✔
1863
            content = util.parse_html(content).get_text()
1✔
1864

1865
        urls = as1.object_urls(obj_as1)
1✔
1866
        url = urls[0] if urls else None
1✔
1867
        if url and not content:
1✔
1868
            # heuristics for sniffing URLs and converting them to more friendly
1869
            # phrases and user handles.
1870
            # TODO: standardize this into granary.as2 somewhere?
1871
            from activitypub import FEDI_URL_RE
×
1872
            from atproto import COLLECTION_TO_TYPE, did_to_handle
×
1873

1874
            handle = suffix = ''
×
1875
            if match := FEDI_URL_RE.match(url):
×
1876
                handle = match.group(2)
×
1877
                if match.group(4):
×
1878
                    suffix = "'s post"
×
1879
            elif match := BSKY_APP_URL_RE.match(url):
×
1880
                handle = match.group('id')
×
1881
                if match.group('tid'):
×
1882
                    suffix = "'s post"
×
1883
            elif match := AT_URI_PATTERN.match(url):
×
UNCOV
1884
                handle = match.group('repo')
×
1885
                if coll := match.group('collection'):
×
1886
                    suffix = f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
×
1887
                url = bluesky.at_uri_to_web_url(url)
×
1888
            elif url.startswith('did:'):
×
UNCOV
1889
                handle = url
×
1890
                url = bluesky.Bluesky.user_url(handle)
×
1891

UNCOV
1892
            if handle:
×
UNCOV
1893
                if handle.startswith('did:'):
×
UNCOV
1894
                    handle = did_to_handle(handle) or handle
×
UNCOV
1895
                content = f'@{handle}{suffix}'
×
1896

UNCOV
1897
            if url:
×
UNCOV
1898
                content = common.pretty_link(url, text=content, user=user)
×
1899

1900
        obj.content = (obj_as1.get('content')
1✔
1901
                       or obj_as1.get('displayName')
1902
                       or obj_as1.get('summary'))
1903
        obj.url = as1.get_url(obj_as1)
1✔
1904

1905
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
1906
            inner_as1 = as1.get_object(obj_as1)
1✔
1907
            obj.inner_url = as1.get_url(inner_as1) or inner_as1.get('id')
1✔
1908
            if obj.url:
1✔
1909
                obj.phrase = common.pretty_link(
1✔
1910
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
1911
            if content:
1✔
1912
                obj.content = content
1✔
1913
                obj.url = url
1✔
1914
            elif obj.inner_url:
1✔
1915
                obj.content = common.pretty_link(obj.inner_url, max_length=50)
1✔
1916

1917
    return objects, new_before, new_after
1✔
1918

1919

1920
def hydrate(activity, fields=('author', 'actor', 'object')):
1✔
1921
    """Hydrates fields in an AS1 activity, in place.
1922

1923
    Args:
1924
      activity (dict): AS1 activity
1925
      fields (sequence of str): names of fields to hydrate. If they're string ids,
1926
        loads them from the datastore, if possible, and replaces them with their dict
1927
        AS1 objects.
1928

1929
    Returns:
1930
      sequence of :class:`google.cloud.ndb.tasklets.Future`: tasklets for hydrating
1931
        each field. Wait on these before using ``activity``.
1932
    """
1933
    def _hydrate(field):
1✔
1934
        def maybe_set(future):
1✔
1935
            if future.result() and future.result().as1:
1✔
1936
                activity[field] = future.result().as1
1✔
1937
        return maybe_set
1✔
1938

1939
    futures = []
1✔
1940

1941
    for field in fields:
1✔
1942
        val = as1.get_object(activity, field)
1✔
1943
        if val and val.keys() <= set(['id']):
1✔
1944
            # TODO: extract a Protocol class method out of User.profile_id,
1945
            # then use that here instead. the catch is that we'd need to
1946
            # determine Protocol for every id, which is expensive.
1947
            #
1948
            # same TODO is in models.fetch_objects
1949
            id = val['id']
1✔
1950
            if id.startswith('did:'):
1✔
UNCOV
1951
                id = f'at://{id}/app.bsky.actor.profile/self'
×
1952

1953
            future = Object.get_by_id_async(id)
1✔
1954
            future.add_done_callback(_hydrate(field))
1✔
1955
            futures.append(future)
1✔
1956

1957
    return futures
1✔
1958

1959

1960
def fetch_page(query, model_class, by=None):
1✔
1961
    """Fetches a page of results from a datastore query.
1962

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

1966
    Populates a ``log_url_path`` property on each result entity that points to a
1967
    its most recent logged request.
1968

1969
    Args:
1970
      query (google.cloud.ndb.query.Query)
1971
      model_class (class)
1972
      by (ndb.model.Property): paging property, eg :attr:`Object.updated`
1973
        or :attr:`Object.created`
1974

1975
    Returns:
1976
      (list of Object or Follower, str, str) tuple: (results, new_before,
1977
      new_after), where new_before and new_after are query param values for
1978
      ``before`` and ``after`` to fetch the previous and next pages,
1979
      respectively
1980
    """
1981
    assert by
1✔
1982

1983
    # if there's a paging param ('before' or 'after'), update query with it
1984
    # TODO: unify this with Bridgy's user page
1985
    def get_paging_param(param):
1✔
1986
        val = request.values.get(param)
1✔
1987
        if val:
1✔
1988
            try:
1✔
1989
                dt = util.parse_iso8601(val.replace(' ', '+'))
1✔
1990
            except BaseException as e:
1✔
1991
                error(f"Couldn't parse {param}, {val!r} as ISO8601: {e}")
1✔
1992
            if dt.tzinfo:
1✔
1993
                dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
1✔
1994
            return dt
1✔
1995

1996
    before = get_paging_param('before')
1✔
1997
    after = get_paging_param('after')
1✔
1998
    if before and after:
1✔
UNCOV
1999
        error("can't handle both before and after")
×
2000
    elif after:
1✔
2001
        query = query.filter(by >= after).order(by)
1✔
2002
    elif before:
1✔
2003
        query = query.filter(by < before).order(-by)
1✔
2004
    else:
2005
        query = query.order(-by)
1✔
2006

2007
    query_iter = query.iter()
1✔
2008
    results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
1✔
2009
                     key=lambda r: r.updated, reverse=True)
2010

2011
    # calculate new paging param(s)
2012
    has_next = results and query_iter.probably_has_next()
1✔
2013
    new_after = (
1✔
2014
        before if before
2015
        else results[0].updated if has_next and after
2016
        else None)
2017
    if new_after:
1✔
2018
        new_after = new_after.isoformat()
1✔
2019

2020
    new_before = (
1✔
2021
        after if after else
2022
        results[-1].updated if has_next
2023
        else None)
2024
    if new_before:
1✔
2025
        new_before = new_before.isoformat()
1✔
2026

2027
    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