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

snarfed / bridgy-fed / 3cd1d0d6-6477-432e-a032-fafe4361bf08

03 Nov 2025 08:23PM UTC coverage: 92.903% (+0.03%) from 92.878%
3cd1d0d6-6477-432e-a032-fafe4361bf08

push

circleci

snarfed
extend the `block` DM command to allow blocking a list

for #1632

10 of 10 new or added lines in 2 files covered. (100.0%)

20 existing lines in 2 files now uncovered.

6022 of 6482 relevant lines covered (92.9%)

0.93 hits per line

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

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

13
from arroba.util import parse_at_uri
1✔
14
import cachetools
1✔
15
from Crypto.PublicKey import RSA
1✔
16
from flask import request
1✔
17
from google.cloud import ndb
1✔
18
from google.cloud.ndb.key import _MAX_KEYPART_BYTES
1✔
19
from granary import as1, as2, atom, bluesky, microformats2
1✔
20
from granary.bluesky import 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 lexrpc.base import AT_URI_RE
1✔
25
from oauth_dropins.webutil import util
1✔
26
from oauth_dropins.webutil.appengine_info import DEBUG
1✔
27
from oauth_dropins.webutil.flask_util import error
1✔
28
from oauth_dropins.webutil.models import EncryptedProperty, JsonProperty, StringIdModel
1✔
29
from oauth_dropins.webutil.util import ellipsize, json_dumps, json_loads
1✔
30
from requests import RequestException
1✔
31
import secp256k1
1✔
32

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

46
# maps string label to Protocol subclass. values are populated by ProtocolUserMeta.
47
# (we used to wait for ProtocolUserMeta to populate the keys as well, but that was
48
# awkward to use in datastore model properties with choices, below; it required
49
# overriding them in reset_model_properties, which was always flaky.)
50
PROTOCOLS = {label: None for label in (
1✔
51
    'activitypub',
52
    'ap',
53
    'atproto',
54
    'bsky',
55
    'nostr',
56
    'ostatus',
57
    'web',
58
    'webmention',
59
    'ui',
60
)}
61
DEBUG_PROTOCOLS = (
1✔
62
    'fa',
63
    'fake',
64
    'efake',
65
    'other',
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://xaasg3w5.cloudimg.io/'
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
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
140
    ''
1✔
141

142
    def __eq__(self, other):
1✔
143
        """Equality excludes Targets' :class:`Key`."""
144
        if isinstance(other, Target):
1✔
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, the subset for
1✔
161
    ineligible users):
162

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

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

186

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

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

200
        return cls
1✔
201

202

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

211

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

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

220
    Args:
221
      copy_id (str)
222

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

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

230

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

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

239
    Args:
240
      copy_id (str)
241

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

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

252

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

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

261
    lock = None
1✔
262
    """Synchronizes :meth:`add`, :meth:`remove`, etc."""
1✔
263

264
    def __init__(self, *args, **kwargs):
1✔
265
        super().__init__(*args, **kwargs)
1✔
266
        self.lock = Lock()
1✔
267

268
    def add(self, prop, val):
1✔
269
        """Adds a value to a multiply-valued property.
270

271
        Args:
272
          prop (str)
273
          val
274

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

281
        if prop == 'copies' and added:
1✔
282
            if fn := getattr(self, 'GET_ORIGINAL_FN'):
1✔
283
                memcache.pickle_memcache.set(memcache.memoize_key(fn, val.uri),
1✔
284
                                             self.key)
285

286
        return added
1✔
287

288
    def remove(self, prop, val):
1✔
289
        """Removes a value from a multiply-valued property.
290

291
        Args:
292
          prop (str)
293
          val
294
        """
295
        with self.lock:
1✔
296
            existing = getattr(self, prop)
1✔
297
            if val in existing:
1✔
298
                existing.remove(val)
1✔
299

300
        if prop == 'copies':
1✔
301
            self.clear_get_original_cache(val.uri)
1✔
302

303
    def remove_copies_on(self, proto):
1✔
304
        """Removes all copies on a given protocol.
305

306
        ``proto.HAS_COPIES`` must be True.
307

308
        Args:
309
          proto (protocol.Protocol subclass)
310
        """
311
        assert proto.HAS_COPIES
1✔
312

313
        for copy in self.copies:
1✔
314
            if copy.protocol in (proto.ABBREV, proto.LABEL):
1✔
315
                self.remove('copies', copy)
1✔
316

317
    @classmethod
1✔
318
    def clear_get_original_cache(cls, uri):
1✔
319
        if fn := getattr(cls, 'GET_ORIGINAL_FN'):
1✔
320
            memcache.pickle_memcache.delete(memcache.memoize_key(fn, uri))
1✔
321

322

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

329
    Stores some protocols' keypairs. Currently:
330

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

342
    obj_key = ndb.KeyProperty(kind='Object')  # user profile
1✔
343
    ''
1✔
344
    use_instead = ndb.KeyProperty()
1✔
345
    ''
1✔
346

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

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

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

365
    enabled_protocols = ndb.StringProperty(repeated=True,
1✔
366
                                           choices=list(PROTOCOLS.keys()))
367
    """Protocols that this user has explicitly opted into.
1✔
368

369
    Protocols that don't require explicit opt in are omitted here.
370
    """
371

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

375
    send_notifs = ndb.StringProperty(default='all', choices=('all', 'none'))
1✔
376
    """Which notifications we should send this user."""
1✔
377

378
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
379
    ''
1✔
380
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
381
    ''
1✔
382

383
    # `existing` attr is set by get_or_create
384

385
    # OLD. some stored entities still have these; do not reuse.
386
    # direct = ndb.BooleanProperty(default=False)
387
    # actor_as2 = JsonProperty()
388
    # protocol-specific state
389
    # atproto_notifs_indexed_at = ndb.TextProperty()
390
    # atproto_feed_indexed_at = ndb.TextProperty()
391

392
    def __init__(self, **kwargs):
1✔
393
        """Constructor.
394

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

402
        if obj:
1✔
403
            self.obj = obj
1✔
404

405
    @classmethod
1✔
406
    def new(cls, **kwargs):
1✔
407
        """Try to prevent instantiation. Use subclasses instead."""
UNCOV
408
        raise NotImplementedError()
×
409

410
    def _post_put_hook(self, future):
1✔
411
        logger.debug(f'Wrote {self.key}')
1✔
412

413
    @classmethod
1✔
414
    def get_by_id(cls, id, allow_opt_out=False, **kwargs):
1✔
415
        """Override to follow ``use_instead`` property and ``status``.
416

417
        Returns None if the user is opted out.
418
        """
419
        user = cls._get_by_id(id, **kwargs)
1✔
420
        if user and user.use_instead:
1✔
421
            logger.info(f'{user.key} use_instead => {user.use_instead}')
1✔
422
            user = user.use_instead.get()
1✔
423

424
        if not user:
1✔
425
            return None
1✔
426

427
        if user.status and not allow_opt_out:
1✔
428
            logger.info(f'{user.key} is {user.status}')
1✔
429
            return None
1✔
430

431
        return user
1✔
432

433
    @classmethod
1✔
434
    def get_or_create(cls, id, propagate=False, allow_opt_out=False,
1✔
435
                      reload=False, **kwargs):
436
        """Loads and returns a :class:`User`. Creates it if necessary.
437

438
        Not transactional because transactions don't read or write memcache. :/
439
        Fortunately we don't really depend on atomicity for much, last writer wins
440
        is usually fine.
441

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

450
        Returns:
451
          User: existing or new user, or None if the user is opted out
452
        """
453
        assert cls != User
1✔
454

455
        user = cls.get_by_id(id, allow_opt_out=True)
1✔
456
        if user:  # existing
1✔
457
            if reload:
1✔
458
                user.reload_profile(gateway=True, raise_=False)
1✔
459

460
            if user.status and not allow_opt_out:
1✔
461
                return None
1✔
462
            user.existing = True
1✔
463

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

473
            if enabled_protocols := kwargs.get('enabled_protocols'):
1✔
474
                user.enabled_protocols = (set(user.enabled_protocols)
1✔
475
                                          | set(enabled_protocols))
476
                changed = True
1✔
477

478
            if not propagate:
1✔
479
                if changed:
1✔
480
                    try:
1✔
481
                        user.put()
1✔
482
                    except AssertionError as e:
×
UNCOV
483
                        error(f'Bad {cls.__name__} id {id} : {e}')
×
484
                return user
1✔
485

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

494
            user = cls(id=id, **kwargs)
1✔
495
            user.existing = False
1✔
496
            try:
1✔
497
                user.reload_profile(gateway=True, raise_=False)
1✔
498
            except AssertionError as e:
1✔
499
                error(f'Bad {cls.__name__} id {id} : {e}')
1✔
500

501
            if user.status and not allow_opt_out:
1✔
502
                return None
1✔
503

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

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

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

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

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

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

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

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

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

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

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

581
        To be implemented by subclasses.
582
        """
UNCOV
583
        raise NotImplementedError()
×
584

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

660
        Args:
661
          to_proto (Protocol subclass)
662
          explicit (bool)
663

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

670
        if self.__class__ == to_proto:
1✔
671
            return True
1✔
672

673
        from_label = self.LABEL
1✔
674
        to_label = to_proto.LABEL
1✔
675

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

679
        elif self.manual_opt_out:
1✔
680
            return False
1✔
681

682
        elif to_label in self.enabled_protocols:
1✔
683
            return True
1✔
684

685
        elif self.status:
1✔
686
            return False
1✔
687

688
        elif to_label in self.DEFAULT_ENABLED_PROTOCOLS and not explicit:
1✔
689
            return True
1✔
690

691
        return False
1✔
692

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

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

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

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

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

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

726
        if to_proto.LABEL not in self.enabled_protocols:
1✔
727
            self.enabled_protocols.append(to_proto.LABEL)
1✔
728
            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✔
729
            self.put()
1✔
730

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

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

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

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

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

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

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

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

773
        return ids.translate_handle(handle=handle, from_=self.__class__,
1✔
774
                                    to=to_proto, short=short)
775

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

779
        Args:
780
          to_proto (str or Protocol)
781

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

871
        return self.handle_or_id()
1✔
872

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

876
        To be implemented by subclasses.
877

878
        Returns:
879
          str
880
        """
UNCOV
881
        raise NotImplementedError()
×
882

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

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

890
        Returns:
891
          bool:
892
        """
893
        if not url:
1✔
894
            return False
1✔
895

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

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

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

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

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

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

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

924
        Examples:
925

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

930
        Defaults to this user's key id.
931

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

937
    def is_profile(self, obj):
1✔
938
        """Returns True if ``obj`` is this user's profile/actor, False otherwise.
939

940
        Args:
941
          obj (Object)
942

943
        Returns:
944
          bool:
945
        """
946
        if obj.key.id() in (self.key.id(), self.profile_id()):
1✔
947
            return True
1✔
948

949
        if self.obj_key and obj.key.id() == self.obj_key.id():
1✔
950
            return True
1✔
951

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

955
        Populates the reloaded profile :class:`Object` in ``self.obj``.
956

957
        Args:
958
          kwargs: passed through to :meth:`Protocol.load`
959
        """
960
        obj = self.load(self.profile_id(), remote=True, **kwargs)
1✔
961
        if obj:
1✔
962
            if obj.type:
1✔
963
                assert obj.type in as1.ACTOR_TYPES, obj.type
1✔
964
            self.obj = obj
1✔
965

966
        # write the user so that we re-populate any computed properties
967
        self.put()
1✔
968

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

972
        Args:
973
          rest (str): additional path and/or query to add to the end
974
          prefer_id (bool): whether to prefer to use the account's id in the path
975
            instead of handle. Defaults to ``False``.
976
        """
977
        path = f'/{self.ABBREV}/{self.key.id() if prefer_id else self.handle_or_id()}'
1✔
978

979
        if rest:
1✔
980
            if not (rest.startswith('?') or rest.startswith('/')):
1✔
981
                path += '/'
1✔
982
            path += rest
1✔
983

984
        return path
1✔
985

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

989
        ...or None if no such copy exists. If ``proto`` is this user, returns
990
        this user's key id.
991

992
        Args:
993
          proto: :class:`Protocol` subclass
994

995
        Returns:
996
          str:
997
        """
998
        # don't use isinstance because the testutil Fake protocol has subclasses
999
        if self.LABEL == proto.LABEL:
1✔
1000
            return self.key.id()
1✔
1001

1002
        for copy in self.copies:
1✔
1003
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
1004
                return copy.uri
1✔
1005

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

1010
        Can optionally include display name, handle, profile
1011
        picture, and/or link to a different protocol that they've enabled.
1012

1013
        TODO: unify with :meth:`Object.actor_link`?
1014

1015
        Args:
1016
          name (bool): include display name
1017
          handle (bool): True to include handle, False to exclude it, ``'short'``
1018
            to include a shortened version, if available
1019
          pictures (bool): include profile picture and protocol logo
1020
          logo (str): optional path to platform logo to show instead of the
1021
            protocol's default
1022
          proto (protocol.Protocol): link to this protocol instead of the user's
1023
            native protocol
1024
          proto_fallback (bool): if True, and ``proto`` is provided and has no
1025
            no canonical profile URL for bridged users, uses the user's profile
1026
            URL in their native protocol
1027
        """
1028
        img = name_str = full_handle = handle_str = dot = logo_html = a_open = a_close = ''
1✔
1029

1030
        if proto:
1✔
1031
            assert self.is_enabled(proto), f"{proto.LABEL} isn't enabled"
1✔
1032
            url = proto.bridged_web_url_for(self, fallback=proto_fallback)
1✔
1033
        else:
1034
            proto = self.__class__
1✔
1035
            url = self.web_url()
1✔
1036

1037
        if pictures:
1✔
1038
            if logo:
1✔
1039
                logo_html = f'<img class="logo" src="{logo}" /> '
1✔
1040
            else:
1041
                logo_html = f'<span class="logo" title="{proto.__name__}">{proto.LOGO_HTML or proto.LOGO_EMOJI}</span> '
1✔
1042
            if pic := self.profile_picture():
1✔
1043
                img = f'<img src="{pic}" class="profile"> '
1✔
1044

1045
        if handle:
1✔
1046
            full_handle = self.handle_as(proto) or ''
1✔
1047
            handle_str = self.handle_as(proto, short=(handle == 'short')) or ''
1✔
1048

1049
        if name and self.name() != full_handle:
1✔
1050
            name_str = self.name() or ''
1✔
1051

1052
        if handle_str and name_str:
1✔
1053
            dot = ' &middot; '
1✔
1054

1055
        if url:
1✔
1056
            a_open = f'<a class="h-card u-author mention" rel="me" href="{url}" title="{name_str}{dot}{full_handle}">'
1✔
1057
            a_close = '</a>'
1✔
1058

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

1062
    def profile_picture(self):
1✔
1063
        """Returns the user's profile picture image URL, if available, or None."""
1064
        if self.obj and self.obj.as1:
1✔
1065
            return util.get_url(self.obj.as1, 'image')
1✔
1066

1067
    # can't use functools.lru_cache here because we want the cache key to be
1068
    # just the user id, not the whole entity
1069
    @cachetools.cached(
1✔
1070
        cachetools.TTLCache(50000, FOLLOWERS_CACHE_EXPIRATION.total_seconds()),
1071
        key=lambda user: user.key.id(), lock=Lock())
1072
    @memcache.memoize(key=lambda self: self.key.id(),
1✔
1073
                      expire=FOLLOWERS_CACHE_EXPIRATION)
1074
    def count_followers(self):
1✔
1075
        """Counts this user's followers and followings.
1076

1077
        Returns:
1078
          (int, int) tuple: (number of followers, number following)
1079
        """
1080
        if self.key.id() in PROTOCOL_DOMAINS:
1✔
1081
            # we don't store Followers for protocol bot users any more, so
1082
            # follower counts are inaccurate, so don't return them
1083
            return (0, 0)
1✔
1084

1085
        num_followers = Follower.query(Follower.to == self.key,
1✔
1086
                                       Follower.status == 'active')\
1087
                                .count_async()
1088
        num_following = Follower.query(Follower.from_ == self.key,
1✔
1089
                                       Follower.status == 'active')\
1090
                                .count_async()
1091
        return num_followers.get_result(), num_following.get_result()
1✔
1092

1093

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

1100
    Key name is the id, generally a URI. We synthesize ids if necessary.
1101
    """
1102
    GET_ORIGINAL_FN = get_original_object_key
1✔
1103
    'used by AddRemoveMixin'
1✔
1104

1105
    users = ndb.KeyProperty(repeated=True)
1✔
1106
    'User(s) who created or otherwise own this object.'
1✔
1107

1108
    notify = ndb.KeyProperty(repeated=True)
1✔
1109
    """User who should see this in their user page, eg in reply to, reaction to,
1✔
1110
    share of, etc.
1111
    """
1112
    feed = ndb.KeyProperty(repeated=True)
1✔
1113
    'User who should see this in their feeds, eg followers of its creator'
1✔
1114

1115
    source_protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()))
1✔
1116
    """The protocol this object originally came from.
1✔
1117

1118
    TODO: nail down whether this is :attr:`ABBREV`` or :attr:`LABEL`
1119
    """
1120

1121
    # TODO: switch back to ndb.JsonProperty if/when they fix it for the web console
1122
    # https://github.com/googleapis/python-ndb/issues/874
1123
    as2 = JsonProperty()
1✔
1124
    'ActivityStreams 2, for ActivityPub'
1✔
1125
    bsky = JsonProperty()
1✔
1126
    'AT Protocol lexicon, for Bluesky'
1✔
1127
    mf2 = JsonProperty()
1✔
1128
    'HTML microformats2 item (*not* top level parse object with ``items`` field)'
1✔
1129
    nostr = JsonProperty()
1✔
1130
    'Nostr event'
1✔
1131
    our_as1 = JsonProperty()
1✔
1132
    'ActivityStreams 1, for activities that we generate or modify ourselves'
1✔
1133
    raw = JsonProperty()
1✔
1134
    'Other standalone data format, eg DID document'
1✔
1135

1136
    extra_as1 = JsonProperty()
1✔
1137
    "Additional individual fields to merge into this object's AS1 representation"
1✔
1138

1139
    # TODO: remove and actually delete Objects instead!
1140
    deleted = ndb.BooleanProperty()
1✔
1141
    ''
1✔
1142

1143
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
1144
    """Copies of this object elsewhere, eg at:// URIs for ATProto records and
1✔
1145
    nevent etc bech32-encoded Nostr ids, where this object is the original.
1146
    Similar to u-syndication links in microformats2 and
1147
    upstream/downstreamDuplicates in AS1.
1148
    """
1149

1150
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1151
    ''
1✔
1152
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1153
    ''
1✔
1154

1155
    new = None
1✔
1156
    """True if this object is new, ie this is the first time we've seen it,
1✔
1157
    False otherwise, None if we don't know.
1158
    """
1159
    changed = None
1✔
1160
    """True if this object's contents have changed from our existing copy in the
1✔
1161
    datastore, False otherwise, None if we don't know. :class:`Object` is
1162
    new/changed. See :meth:`activity_changed()` for more details.
1163
    """
1164

1165
    # DEPRECATED
1166
    # These were for full feeds with multiple items, not just this one, so they were
1167
    # stored as audit records only, not used in to_as1. for Atom/RSS
1168
    # based Objects, our_as1 was populated with an feed_index top-level
1169
    # integer field that indexed into one of these.
1170
    #
1171
    # atom = ndb.TextProperty() # Atom XML
1172
    # rss = ndb.TextProperty()  # RSS XML
1173

1174
    # DEPRECATED; these were for delivery tracking, but they were too expensive,
1175
    # so we stopped: https://github.com/snarfed/bridgy-fed/issues/1501
1176
    #
1177
    # STATUSES = ('new', 'in progress', 'complete', 'failed', 'ignored')
1178
    # status = ndb.StringProperty(choices=STATUSES)
1179
    # delivered = ndb.StructuredProperty(Target, repeated=True)
1180
    # undelivered = ndb.StructuredProperty(Target, repeated=True)
1181
    # failed = ndb.StructuredProperty(Target, repeated=True)
1182

1183
    # DEPRECATED but still used read only to maintain backward compatibility
1184
    # with old Objects in the datastore that we haven't bothered migrating.
1185
    #
1186
    # domains = ndb.StringProperty(repeated=True)
1187

1188
    # DEPRECATED; replaced by :attr:`users`, :attr:`notify`, :attr:`feed`
1189
    #
1190
    # labels = ndb.StringProperty(repeated=True,
1191
    #                             choices=('activity', 'feed', 'notification', 'user'))
1192

1193
    @property
1✔
1194
    def as1(self):
1✔
1195
        def use_urls_as_ids(obj):
1✔
1196
            """If id field is missing or not a URL, use the url field."""
1197
            id = obj.get('id')
1✔
1198
            if not id or not (util.is_web(id) or re.match(DOMAIN_RE, id)):
1✔
1199
                if url := util.get_url(obj):
1✔
1200
                    obj['id'] = url
1✔
1201

1202
            for field in 'author', 'actor', 'object':
1✔
1203
                if inner := as1.get_object(obj, field):
1✔
1204
                    use_urls_as_ids(inner)
1✔
1205

1206
        if self.our_as1:
1✔
1207
            obj = self.our_as1
1✔
1208
            if self.source_protocol == 'web':
1✔
1209
                use_urls_as_ids(obj)
1✔
1210

1211
        elif self.as2:
1✔
1212
            obj = as2.to_as1(unwrap(self.as2))
1✔
1213

1214
        elif self.bsky:
1✔
1215
            owner, _, _ = parse_at_uri(self.key.id())
1✔
1216
            ATProto = PROTOCOLS['atproto']
1✔
1217
            handle = ATProto(id=owner).handle
1✔
1218
            try:
1✔
1219
                obj = bluesky.to_as1(self.bsky, repo_did=owner, repo_handle=handle,
1✔
1220
                                     uri=self.key.id(), pds=ATProto.pds_for(self))
1221
            except (ValueError, RequestException):
1✔
1222
                logger.info(f"Couldn't convert to ATProto", exc_info=True)
1✔
1223
                return None
1✔
1224

1225
        elif self.mf2:
1✔
1226
            obj = microformats2.json_to_object(self.mf2,
1✔
1227
                                               rel_urls=self.mf2.get('rel-urls'))
1228
            use_urls_as_ids(obj)
1✔
1229

1230
            # use fetched final URL as id, not u-url
1231
            # https://github.com/snarfed/bridgy-fed/issues/829
1232
            if url := self.mf2.get('url'):
1✔
1233
                obj['id'] = (self.key.id() if self.key and '#' in self.key.id()
1✔
1234
                             else url)
1235

1236
        elif self.nostr:
1✔
1237
            obj = granary.nostr.to_as1(self.nostr)
1✔
1238

1239
        else:
1240
            return None
1✔
1241

1242
        # populate id if necessary
1243
        if self.key:
1✔
1244
            obj.setdefault('id', self.key.id())
1✔
1245

1246
        if util.domain_or_parent_in(obj.get('id'), IMAGE_PROXY_DOMAINS):
1✔
1247
           as1.prefix_urls(obj, 'image', IMAGE_PROXY_URL_BASE)
1✔
1248

1249
        if self.extra_as1:
1✔
1250
            obj.update(self.extra_as1)
1✔
1251

1252
        return obj
1✔
1253

1254
    @ndb.ComputedProperty
1✔
1255
    def type(self):  # AS1 objectType, or verb if it's an activity
1✔
1256
        if self.as1:
1✔
1257
            return as1.object_type(self.as1)
1✔
1258

1259
    def _expire(self):
1✔
1260
        """Automatically delete most Objects after a while using a TTL policy.
1261

1262
        https://cloud.google.com/datastore/docs/ttl
1263

1264
        They recommend not indexing TTL properties:
1265
        https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
1266
        """
1267
        now = self.updated or util.now()
1✔
1268
        if self.deleted:
1✔
1269
            return now + timedelta(days=1)
1✔
1270
        elif self.type not in DONT_EXPIRE_OBJECT_TYPES:
1✔
1271
            return now + OBJECT_EXPIRE_AGE
1✔
1272

1273
    expire = ndb.ComputedProperty(_expire, indexed=False)
1✔
1274

1275
    def _pre_put_hook(self):
1✔
1276
        """
1277
        * Validate that at:// URIs have DIDs
1278
        * Validate that Nostr ids are nostr:[hex] ids
1279
        * Set/remove the activity label
1280
        * Strip @context from as2 (we don't do LD) to save disk space
1281
        """
1282
        if self.as2:
1✔
1283
           self.as2.pop('@context', None)
1✔
1284
           for field in 'actor', 'attributedTo', 'author', 'object':
1✔
1285
               for val in util.get_list(self.as2, field):
1✔
1286
                   if isinstance(val, dict):
1✔
1287
                       val.pop('@context', None)
1✔
1288

1289
        def check_id(id, proto):
1✔
1290
            if proto in (None, 'ui'):
1✔
1291
                return
1✔
1292

1293
            assert PROTOCOLS[proto].owns_id(id) is not False, \
1✔
1294
                f'Protocol {PROTOCOLS[proto].LABEL} does not own id {id}'
1295

1296
            if proto == 'nostr':
1✔
1297
                assert id.startswith('nostr:'), id
1✔
1298
                assert granary.nostr.ID_RE.match(id.removeprefix('nostr:')), id
1✔
1299

1300
            elif proto == 'atproto':
1✔
1301
                assert id.startswith('at://') or id.startswith('did:'), id
1✔
1302
                if id.startswith('at://'):
1✔
1303
                    repo, _, _ = parse_at_uri(id)
1✔
1304
                    if not repo.startswith('did:'):
1✔
1305
                        # TODO: if we hit this, that means the AppView gave us an AT
1306
                        # URI with a handle repo/authority instead of DID. that's
1307
                        # surprising! ...if so, and if we need to handle it, add a
1308
                        # new arroba.did.canonicalize_at_uri() function, then use it
1309
                        # here, or before.
1310
                        raise ValueError(f'at:// URI ids must have DID repos; got {id}')
1✔
1311

1312
        check_id(self.key.id(), self.source_protocol)
1✔
1313
        for target in self.copies:
1✔
1314
            check_id(target.uri, target.protocol)
1✔
1315

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

1320
    @classmethod
1✔
1321
    def get_by_id(cls, id, authed_as=None, **kwargs):
1✔
1322
        """Fetches the :class:`Object` with the given id, if it exists.
1323

1324
        Args:
1325
          id (str)
1326
          authed_as (str): optional; if provided, and a matching :class:`Object`
1327
            already exists, its ``author`` or ``actor`` must contain this actor
1328
            id. Implements basic authorization for updates and deletes.
1329

1330
        Returns:
1331
          Object:
1332

1333
        Raises:
1334
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1335
            the existing object
1336
        """
1337
        obj = super().get_by_id(maybe_truncate_key_id(id), **kwargs)
1✔
1338

1339
        if obj and obj.as1 and authed_as:
1✔
1340
            # authorization: check that the authed user is allowed to modify
1341
            # this object
1342
            # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1343
            proto = PROTOCOLS.get(obj.source_protocol)
1✔
1344
            assert proto, obj.source_protocol
1✔
1345
            owners = [ids.normalize_user_id(id=owner, proto=proto)
1✔
1346
                      for owner in (as1.get_ids(obj.as1, 'author')
1347
                                    + as1.get_ids(obj.as1, 'actor'))
1348
                                    + [id]]
1349
            if (ids.normalize_user_id(id=authed_as, proto=proto) not in owners
1✔
1350
                    and ids.profile_id(id=authed_as, proto=proto) not in owners):
1351
                report_error("Auth: Object: authed_as doesn't match owner",
1✔
1352
                             user=f'{id} authed_as {authed_as} owners {owners}')
1353
                error(f"authed user {authed_as} isn't object owner {owners}",
1✔
1354
                      status=403)
1355

1356
        return obj
1✔
1357

1358
    @classmethod
1✔
1359
    def get_or_create(cls, id, authed_as=None, **props):
1✔
1360
        """Returns an :class:`Object` with the given property values.
1361

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

1366
        Not transactional because transactions don't read or write memcache. :/
1367
        Fortunately we don't really depend on atomicity for much, last writer wins
1368
        is usually fine.
1369

1370
        Args:
1371
          authed_as (str): optional; if provided, and a matching :class:`Object`
1372
            already exists, its ``author`` or ``actor`` must contain this actor
1373
            id. Implements basic authorization for updates and deletes.
1374

1375
        Returns:
1376
          Object:
1377

1378
        Raises:
1379
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1380
            the existing object
1381
        """
1382
        key_id = maybe_truncate_key_id(id)
1✔
1383
        obj = cls.get_by_id(key_id, authed_as=authed_as)
1✔
1384

1385
        if not obj:
1✔
1386
            obj = Object(id=key_id, **props)
1✔
1387
            obj.new = True
1✔
1388
            obj.changed = False
1✔
1389
            obj.put()
1✔
1390
            return obj
1✔
1391

1392
        if orig_as1 := obj.as1:
1✔
1393
            # get_by_id() checks authorization if authed_as is set. make sure
1394
            # it's always set for existing objects.
1395
            assert authed_as
1✔
1396

1397
        dirty = False
1✔
1398
        for prop, val in props.items():
1✔
1399
            assert not isinstance(getattr(Object, prop), ndb.ComputedProperty)
1✔
1400
            if prop in ('copies', 'feed', 'notify', 'users'):
1✔
1401
                # merge repeated fields
1402
                for elem in val:
1✔
1403
                    if obj.add(prop, elem):
1✔
1404
                        dirty = True
1✔
1405
            elif val is not None and val != getattr(obj, prop):
1✔
1406
                setattr(obj, prop, val)
1✔
1407
                if (prop in ('as2', 'bsky', 'mf2', 'nostr', 'raw')
1✔
1408
                        and not props.get('our_as1')):
1409
                    obj.our_as1 = None
1✔
1410
                dirty = True
1✔
1411

1412
        obj.new = False
1✔
1413
        obj.changed = obj.activity_changed(orig_as1)
1✔
1414
        if dirty:
1✔
1415
            obj.put()
1✔
1416
        return obj
1✔
1417

1418
    @staticmethod
1✔
1419
    def from_request():
1✔
1420
        """Creates and returns an :class:`Object` from form-encoded JSON parameters.
1421

1422
        Parameters:
1423
          obj_id (str): id of :class:`models.Object` to handle
1424
          *: If ``obj_id`` is unset, all other parameters are properties for a
1425
            new :class:`models.Object` to handle
1426
        """
1427
        if obj_id := request.form.get('obj_id'):
1✔
1428
            return Object.get_by_id(obj_id)
1✔
1429

1430
        props = {field: request.form.get(field)
1✔
1431
                 for field in ('id', 'source_protocol')}
1432

1433
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'nostr', 'raw':
1✔
1434
            if val := request.form.get(json_prop):
1✔
1435
                props[json_prop] = json_loads(val)
1✔
1436

1437
        obj = Object(**props)
1✔
1438
        if not obj.key and obj.as1:
1✔
1439
            if id := obj.as1.get('id'):
1✔
1440
                obj.key = ndb.Key(Object, id)
1✔
1441

1442
        return obj
1✔
1443

1444
    def to_request(self):
1✔
1445
        """Returns a query parameter dict representing this :class:`Object`."""
1446
        form = {}
1✔
1447

1448
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1449
            if val := getattr(self, json_prop, None):
1✔
1450
                form[json_prop] = json_dumps(val, sort_keys=True)
1✔
1451

1452
        for prop in ['source_protocol']:
1✔
1453
            if val := getattr(self, prop):
1✔
1454
                form[prop] = val
1✔
1455

1456
        if self.key:
1✔
1457
            form['id'] = self.key.id()
1✔
1458

1459
        return form
1✔
1460

1461
    def activity_changed(self, other_as1):
1✔
1462
        """Returns True if this activity is meaningfully changed from ``other_as1``.
1463

1464
        ...otherwise False.
1465

1466
        Used to populate :attr:`changed`.
1467

1468
        Args:
1469
          other_as1 (dict): AS1 object, or none
1470
        """
1471
        # ignore inReplyTo since we translate it between protocols
1472
        return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
1✔
1473
                if self.as1 and other_as1
1474
                else bool(self.as1) != bool(other_as1))
1475

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

1479
        TODO: unify with :meth:`User.user_link`?
1480

1481
        Args:
1482
          image (bool): whether to include an ``img`` tag with the actor's picture
1483
          sized (bool): whether to set an explicit (``width=32``) size on the
1484
            profile picture ``img`` tag
1485
          user (User): current user
1486

1487
        Returns:
1488
          str:
1489
        """
1490
        attrs = {'class': 'h-card u-author'}
1✔
1491

1492
        if user and user.key in self.users:
1✔
1493
            # outbound; show a nice link to the user
1494
            return user.user_link(handle=False, pictures=True)
1✔
1495

1496
        proto = PROTOCOLS.get(self.source_protocol)
1✔
1497

1498
        actor = None
1✔
1499
        if self.as1:
1✔
1500
            actor = (as1.get_object(self.as1, 'actor')
1✔
1501
                     or as1.get_object(self.as1, 'author'))
1502
            # hydrate from datastore if available
1503
            # TODO: optimize! this is called serially in loops, eg in home.html
1504
            if set(actor.keys()) == {'id'} and self.source_protocol:
1✔
1505
                actor_obj = proto.load(actor['id'], remote=False)
1✔
1506
                if actor_obj and actor_obj.as1:
1✔
1507
                    actor = actor_obj.as1
1✔
1508

1509
        if not actor:
1✔
1510
            return ''
1✔
1511
        elif set(actor.keys()) == {'id'}:
1✔
1512
            return common.pretty_link(actor['id'], attrs=attrs, user=user)
1✔
1513

1514
        url = as1.get_url(actor)
1✔
1515
        name = actor.get('displayName') or actor.get('username') or ''
1✔
1516
        img_url = util.get_url(actor, 'image')
1✔
1517
        if not image or not img_url:
1✔
1518
            return common.pretty_link(url, text=name, attrs=attrs, user=user)
1✔
1519

1520
        logo = ''
1✔
1521
        if proto:
1✔
UNCOV
1522
            logo = f'<span class="logo" title="{self.__class__.__name__}">{proto.LOGO_HTML or proto.LOGO_EMOJI}</span>'
×
1523

1524
        return f"""\
1✔
1525
        {logo}
1526
        <a class="h-card u-author" href="{url}" title="{name}">
1527
          <img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
1528
          <span style="unicode-bidi: isolate">{util.ellipsize(name, chars=40)}</span>
1529
        </a>"""
1530

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

1534
        ...or None if no such copy exists. If ``proto`` is ``source_protocol``,
1535
        returns this object's key id.
1536

1537
        TODO: for some protocols, we should try harder to find the *right* copy id.
1538
        Eg if if copies has some old garbage entries for this protocol, and we can
1539
        tell that they don't belong to the user's copy account in this protocol, eg
1540
        if the DID in the at:// URI doesn't match, we should skip those and look for
1541
        the matching copy. We'd need the user here though.
1542
        This would help with or fix:
1543
        https://console.cloud.google.com/errors/detail/COK22a6w4O2JVg;locations=global;time=P30D?project=bridgy-federated
1544

1545
        Args:
1546
          proto: :class:`Protocol` subclass
1547

1548
        Returns:
1549
          str:
1550
        """
1551
        if self.source_protocol in (proto.LABEL, proto.ABBREV):
1✔
1552
            return self.key.id()
1✔
1553

1554
        for copy in self.copies:
1✔
1555
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
1556
                return copy.uri
1✔
1557

1558
    def resolve_ids(self):
1✔
1559
        """Replaces "copy" ids, subdomain ids, etc with their originals.
1560

1561
        The end result is that all ids are original "source" ids, ie in the
1562
        protocol that they first came from.
1563

1564
        Specifically, resolves:
1565

1566
        * ids in :class:`User.copies` and :class:`Object.copies`, eg ATProto
1567
          records and Nostr events that we bridged, to the ids of their
1568
          original objects in their source protocol, eg
1569
          ``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
1570
        * Bridgy Fed subdomain URLs to the ids embedded inside them, eg
1571
          ``https://bsky.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
1572
        * ATProto bsky.app URLs to their DIDs or `at://` URIs, eg
1573
          ``https://bsky.app/profile/a.com`` => ``did:plc:123``
1574

1575
        ...in these AS1 fields, in place:
1576

1577
        * ``id``
1578
        * ``actor``
1579
        * ``author``
1580
        * ``object``
1581
        * ``object.actor``
1582
        * ``object.author``
1583
        * ``object.id``
1584
        * ``object.inReplyTo``
1585
        * ``attachments.[objectType=note].id``
1586
        * ``tags.[objectType=mention].url``
1587

1588
        :meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
1589
        Much of the same logic is duplicated there!
1590

1591
        TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
1592
        """
1593
        if not self.as1:
1✔
1594
            return
1✔
1595

1596
        # extract ids, strip Bridgy Fed subdomain URLs
1597
        outer_obj = unwrap(self.as1)
1✔
1598
        if outer_obj != self.as1:
1✔
1599
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1600

1601
        self_proto = PROTOCOLS.get(self.source_protocol)
1✔
1602
        if not self_proto:
1✔
1603
            return
1✔
1604

1605
        logger.debug(f'Resolving ids for {self.key.id()}')
1✔
1606
        inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
1✔
1607
        replaced = False
1✔
1608

1609
        def replace(val, orig_fn):
1✔
1610
            id = val.get('id') if isinstance(val, dict) else val
1✔
1611
            if not id or not self_proto.HAS_COPIES:
1✔
1612
                return id
1✔
1613

1614
            orig = orig_fn(id)
1✔
1615
            if not orig:
1✔
1616
                return val
1✔
1617

1618
            nonlocal replaced
1619
            replaced = True
1✔
1620
            logger.debug(f'Resolved copy id {val} to original {orig.id()}')
1✔
1621

1622
            if isinstance(val, dict) and util.trim_nulls(val).keys() > {'id'}:
1✔
1623
                val['id'] = orig.id()
1✔
1624
                return val
1✔
1625
            else:
1626
                return orig.id()
1✔
1627

1628
        # actually replace ids
1629
        #
1630
        # object field could be either object (eg repost) or actor (eg follow)
1631
        outer_obj['object'] = replace(inner_obj, get_original_object_key)
1✔
1632
        if not replaced:
1✔
1633
            outer_obj['object'] = replace(inner_obj, get_original_user_key)
1✔
1634

1635
        for obj in outer_obj, inner_obj:
1✔
1636
            for tag in as1.get_objects(obj, 'tags'):
1✔
1637
                if tag.get('objectType') == 'mention':
1✔
1638
                    tag['url'] = replace(tag.get('url'), get_original_user_key)
1✔
1639
            for att in as1.get_objects(obj, 'attachments'):
1✔
1640
                if att.get('objectType') == 'note':
1✔
1641
                    att['id'] = replace(att.get('id'), get_original_object_key)
1✔
1642
            for field, fn in (
1✔
1643
                    ('actor', get_original_user_key),
1644
                    ('author', get_original_user_key),
1645
                    ('inReplyTo', get_original_object_key),
1646
                ):
1647
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1648
                if len(obj[field]) == 1:
1✔
1649
                    obj[field] = obj[field][0]
1✔
1650

1651
        if replaced:
1✔
1652
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1653

1654
    def normalize_ids(self):
1✔
1655
        """Normalizes ids to their protocol's canonical representation, if any.
1656

1657
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
1658
        for profiles, ``at://`` URIs for posts.
1659

1660
        Modifies this object in place.
1661

1662
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
1663
        """
1664
        from protocol import Protocol
1✔
1665

1666
        if not self.as1:
1✔
1667
            return
1✔
1668

1669
        logger.debug(f'Normalizing ids for {self.key.id()}')
1✔
1670
        outer_obj = copy.deepcopy(self.as1)
1✔
1671
        inner_objs = as1.get_objects(outer_obj)
1✔
1672
        replaced = False
1✔
1673

1674
        def replace(val, translate_fn):
1✔
1675
            nonlocal replaced
1676

1677
            orig = val.get('id') if isinstance(val, dict) else val
1✔
1678
            if not orig:
1✔
1679
                return val
1✔
1680

1681
            proto = Protocol.for_id(orig, remote=False)
1✔
1682
            if not proto:
1✔
1683
                return val
1✔
1684

1685
            translated = translate_fn(id=orig, from_=proto, to=proto)
1✔
1686
            if translated and translated != orig:
1✔
1687
                # logger.debug(f'Normalized {proto.LABEL} id {orig} to {translated}')
1688
                replaced = True
1✔
1689
                if isinstance(val, dict):
1✔
1690
                    val['id'] = translated
1✔
1691
                    return val
1✔
1692
                else:
1693
                    return translated
1✔
1694

1695
            return val
1✔
1696

1697
        # actually replace ids
1698
        for obj in [outer_obj] + inner_objs:
1✔
1699
            for tag in as1.get_objects(obj, 'tags'):
1✔
1700
                if tag.get('objectType') == 'mention':
1✔
1701
                    tag['url'] = replace(tag.get('url'), ids.translate_user_id)
1✔
1702
            for field in ['actor', 'author', 'inReplyTo']:
1✔
1703
                fn = (ids.translate_object_id if field == 'inReplyTo'
1✔
1704
                      else ids.translate_user_id)
1705
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1706
                if len(obj[field]) == 1:
1✔
1707
                    obj[field] = obj[field][0]
1✔
1708

1709
        outer_obj['object'] = []
1✔
1710
        for inner_obj in inner_objs:
1✔
1711
            translate_fn = ids.translate_object_id
1✔
1712
            if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
1✔
1713
                    or as1.object_type(outer_obj) in as1.VERBS_WITH_ACTOR_OBJECT):
1714
                translate_fn = ids.translate_user_id
1✔
1715

1716
            got = replace(inner_obj, translate_fn)
1✔
1717
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
1718
                got = got['id']
1✔
1719

1720
            outer_obj['object'].append(got)
1✔
1721

1722
        if len(outer_obj['object']) == 1:
1✔
1723
            outer_obj['object'] = outer_obj['object'][0]
1✔
1724

1725
        if replaced:
1✔
1726
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1727

1728

1729
class Follower(ndb.Model):
1✔
1730
    """A follower of a Bridgy Fed user."""
1731
    STATUSES = ('active', 'inactive')
1✔
1732

1733
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
1734
    """The follower."""
1✔
1735
    to = ndb.KeyProperty(required=True)
1✔
1736
    """The followee, ie the user being followed."""
1✔
1737

1738
    follow = ndb.KeyProperty(Object)
1✔
1739
    """The last follow activity."""
1✔
1740
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
1741
    """Whether this follow is active or not."""
1✔
1742

1743
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1744
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1745

1746
    # OLD. some stored entities still have these; do not reuse.
1747
    # src = ndb.StringProperty()
1748
    # dest = ndb.StringProperty()
1749
    # last_follow = JsonProperty()
1750

1751
    def _pre_put_hook(self):
1✔
1752
        # we're a bridge! stick with bridging.
1753
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
1754

1755
    def _post_put_hook(self, future):
1✔
1756
        logger.debug(f'Wrote {self.key}')
1✔
1757

1758
    @classmethod
1✔
1759
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
1760
        """Returns a Follower with the given ``from_`` and ``to`` users.
1761

1762
        Not transactional because transactions don't read or write memcache. :/
1763
        Fortunately we don't really depend on atomicity for much, last writer wins
1764
        is usually fine.
1765

1766
        If a matching :class:`Follower` doesn't exist in the datastore, creates
1767
        it first.
1768

1769
        Args:
1770
          from_ (User)
1771
          to (User)
1772

1773
        Returns:
1774
          Follower:
1775
        """
1776
        assert from_
1✔
1777
        assert to
1✔
1778

1779
        follower = Follower.query(Follower.from_ == from_.key,
1✔
1780
                                  Follower.to == to.key,
1781
                                  ).get()
1782
        if not follower:
1✔
1783
            follower = Follower(from_=from_.key, to=to.key, **kwargs)
1✔
1784
            follower.put()
1✔
1785
        elif kwargs:
1✔
1786
            # update existing entity with new property values, eg to make an
1787
            # inactive Follower active again
1788
            for prop, val in kwargs.items():
1✔
1789
                setattr(follower, prop, val)
1✔
1790
            follower.put()
1✔
1791

1792
        return follower
1✔
1793

1794
    @staticmethod
1✔
1795
    def fetch_page(collection, user):
1✔
1796
        r"""Fetches a page of :class:`Follower`\s for a given user.
1797

1798
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
1799
        parameters, if available in the request.
1800

1801
        Args:
1802
          collection (str): ``followers`` or ``following``
1803
          user (User)
1804

1805
        Returns:
1806
          (list of Follower, str, str) tuple: results, annotated with an extra
1807
          ``user`` attribute that holds the follower or following :class:`User`,
1808
          and new str query param values for ``before`` and ``after`` to fetch
1809
          the previous and next pages, respectively
1810
        """
1811
        assert collection in ('followers', 'following'), collection
1✔
1812

1813
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1814
        query = Follower.query(
1✔
1815
            Follower.status == 'active',
1816
            filter_prop == user.key,
1817
        )
1818

1819
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
1820
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
1821
                              for f in followers)
1822
        User.load_multi(u for u in users if u)
1✔
1823

1824
        for f, u in zip(followers, users):
1✔
1825
            f.user = u
1✔
1826

1827
        followers = [f for f in followers if f.user]
1✔
1828

1829
        # only show followers in protocols that this user is bridged into
1830
        if collection == 'followers':
1✔
1831
            followers = [f for f in followers if user.is_enabled(f.user)]
1✔
1832

1833
        return followers, before, after
1✔
1834

1835

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

1839
    Wraps :func:`fetch_page` and adds attributes to the returned
1840
    :class:`Object` entities for rendering in ``objects.html``.
1841

1842
    Args:
1843
      query (ndb.Query)
1844
      by (ndb.model.Property): either :attr:`Object.updated` or
1845
        :attr:`Object.created`
1846
      user (User): current user
1847

1848
    Returns:
1849
      (list of Object, str, str) tuple:
1850
      (results, new ``before`` query param, new ``after`` query param)
1851
      to fetch the previous and next pages, respectively
1852
    """
1853
    assert by is Object.updated or by is Object.created
1✔
1854
    objects, new_before, new_after = fetch_page(query, Object, by=by)
1✔
1855
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
1856

1857
    # synthesize human-friendly content for objects
1858
    for i, obj in enumerate(objects):
1✔
1859
        obj_as1 = obj.as1
1✔
1860
        type = as1.object_type(obj_as1)
1✔
1861

1862
        # AS1 verb => human-readable phrase
1863
        phrases = {
1✔
1864
            'accept': 'accepted',
1865
            'article': 'posted',
1866
            'comment': 'replied',
1867
            'delete': 'deleted',
1868
            'follow': 'followed',
1869
            'invite': 'is invited to',
1870
            'issue': 'filed issue',
1871
            'like': 'liked',
1872
            'note': 'posted',
1873
            'post': 'posted',
1874
            'repost': 'reposted',
1875
            'rsvp-interested': 'is interested in',
1876
            'rsvp-maybe': 'might attend',
1877
            'rsvp-no': 'is not attending',
1878
            'rsvp-yes': 'is attending',
1879
            'share': 'reposted',
1880
            'stop-following': 'unfollowed',
1881
            'undo': 'undid',
1882
            'update': 'updated',
1883
        }
1884
        phrases.update({type: 'profile refreshed:' for type in as1.ACTOR_TYPES})
1✔
1885

1886
        obj.phrase = phrases.get(type, '')
1✔
1887

1888
        content = (obj_as1.get('content')
1✔
1889
                   or obj_as1.get('displayName')
1890
                   or obj_as1.get('summary'))
1891
        if content:
1✔
1892
            content = util.parse_html(content).get_text()
1✔
1893

1894
        urls = as1.object_urls(obj_as1)
1✔
1895
        url = urls[0] if urls else None
1✔
1896
        if url and not content:
1✔
1897
            # heuristics for sniffing URLs and converting them to more friendly
1898
            # phrases and user handles.
1899
            # TODO: standardize this into granary.as2 somewhere?
1900
            from activitypub import FEDI_URL_RE
×
1901
            from atproto import COLLECTION_TO_TYPE, did_to_handle
×
1902

1903
            handle = suffix = ''
×
1904
            if match := FEDI_URL_RE.match(url):
×
1905
                handle = match.group(2)
×
1906
                if match.group(4):
×
1907
                    suffix = "'s post"
×
1908
            elif match := BSKY_APP_URL_RE.match(url):
×
1909
                handle = match.group('id')
×
1910
                if match.group('tid'):
×
1911
                    suffix = "'s post"
×
1912
            elif match := AT_URI_RE.match(url):
×
1913
                handle = match.group('repo')
×
1914
                if coll := match.group('collection'):
×
1915
                    suffix = f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
×
1916
                url = bluesky.at_uri_to_web_url(url)
×
UNCOV
1917
            elif url.startswith('did:'):
×
1918
                handle = url
×
1919
                url = bluesky.Bluesky.user_url(handle)
×
1920

1921
            if handle:
×
UNCOV
1922
                if handle.startswith('did:'):
×
1923
                    handle = did_to_handle(handle) or handle
×
1924
                content = f'@{handle}{suffix}'
×
1925

UNCOV
1926
            if url:
×
UNCOV
1927
                content = common.pretty_link(url, text=content, user=user)
×
1928

1929
        obj.content = (obj_as1.get('content')
1✔
1930
                       or obj_as1.get('displayName')
1931
                       or obj_as1.get('summary'))
1932
        obj.url = as1.get_url(obj_as1)
1✔
1933

1934
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
1935
            inner_as1 = as1.get_object(obj_as1)
1✔
1936
            obj.inner_url = as1.get_url(inner_as1) or inner_as1.get('id')
1✔
1937
            if obj.url:
1✔
1938
                obj.phrase = common.pretty_link(
1✔
1939
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
1940
            if content:
1✔
1941
                obj.content = content
1✔
1942
                obj.url = url
1✔
1943
            elif obj.inner_url:
1✔
1944
                obj.content = common.pretty_link(obj.inner_url, max_length=50)
1✔
1945

1946
    return objects, new_before, new_after
1✔
1947

1948

1949
def hydrate(activity, fields=('author', 'actor', 'object')):
1✔
1950
    """Hydrates fields in an AS1 activity, in place.
1951

1952
    Args:
1953
      activity (dict): AS1 activity
1954
      fields (sequence of str): names of fields to hydrate. If they're string ids,
1955
        loads them from the datastore, if possible, and replaces them with their dict
1956
        AS1 objects.
1957

1958
    Returns:
1959
      sequence of :class:`google.cloud.ndb.tasklets.Future`: tasklets for hydrating
1960
        each field. Wait on these before using ``activity``.
1961
    """
1962
    def _hydrate(field):
1✔
1963
        def maybe_set(future):
1✔
1964
            if future.result() and future.result().as1:
1✔
1965
                activity[field] = future.result().as1
1✔
1966
        return maybe_set
1✔
1967

1968
    futures = []
1✔
1969

1970
    for field in fields:
1✔
1971
        val = as1.get_object(activity, field)
1✔
1972
        if val and val.keys() <= set(['id']):
1✔
1973
            # TODO: extract a Protocol class method out of User.profile_id,
1974
            # then use that here instead. the catch is that we'd need to
1975
            # determine Protocol for every id, which is expensive.
1976
            #
1977
            # same TODO is in models.fetch_objects
1978
            id = val['id']
1✔
1979
            if id.startswith('did:'):
1✔
UNCOV
1980
                id = f'at://{id}/app.bsky.actor.profile/self'
×
1981

1982
            future = Object.get_by_id_async(id)
1✔
1983
            future.add_done_callback(_hydrate(field))
1✔
1984
            futures.append(future)
1✔
1985

1986
    return futures
1✔
1987

1988

1989
def fetch_page(query, model_class, by=None):
1✔
1990
    """Fetches a page of results from a datastore query.
1991

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

1995
    Populates a ``log_url_path`` property on each result entity that points to a
1996
    its most recent logged request.
1997

1998
    Args:
1999
      query (google.cloud.ndb.query.Query)
2000
      model_class (class)
2001
      by (ndb.model.Property): paging property, eg :attr:`Object.updated`
2002
        or :attr:`Object.created`
2003

2004
    Returns:
2005
      (list of Object or Follower, str, str) tuple: (results, new_before,
2006
      new_after), where new_before and new_after are query param values for
2007
      ``before`` and ``after`` to fetch the previous and next pages,
2008
      respectively
2009
    """
2010
    assert by
1✔
2011

2012
    # if there's a paging param ('before' or 'after'), update query with it
2013
    # TODO: unify this with Bridgy's user page
2014
    def get_paging_param(param):
1✔
2015
        val = request.values.get(param)
1✔
2016
        if val:
1✔
2017
            try:
1✔
2018
                dt = util.parse_iso8601(val.replace(' ', '+'))
1✔
2019
            except BaseException as e:
1✔
2020
                error(f"Couldn't parse {param}, {val!r} as ISO8601: {e}")
1✔
2021
            if dt.tzinfo:
1✔
2022
                dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
1✔
2023
            return dt
1✔
2024

2025
    before = get_paging_param('before')
1✔
2026
    after = get_paging_param('after')
1✔
2027
    if before and after:
1✔
UNCOV
2028
        error("can't handle both before and after")
×
2029
    elif after:
1✔
2030
        query = query.filter(by >= after).order(by)
1✔
2031
    elif before:
1✔
2032
        query = query.filter(by < before).order(-by)
1✔
2033
    else:
2034
        query = query.order(-by)
1✔
2035

2036
    query_iter = query.iter()
1✔
2037
    results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
1✔
2038
                     key=lambda r: r.updated, reverse=True)
2039

2040
    # calculate new paging param(s)
2041
    has_next = results and query_iter.probably_has_next()
1✔
2042
    new_after = (
1✔
2043
        before if before
2044
        else results[0].updated if has_next and after
2045
        else None)
2046
    if new_after:
1✔
2047
        new_after = new_after.isoformat()
1✔
2048

2049
    new_before = (
1✔
2050
        after if after else
2051
        results[-1].updated if has_next
2052
        else None)
2053
    if new_before:
1✔
2054
        new_before = new_before.isoformat()
1✔
2055

2056
    return results, new_before, new_after
1✔
2057

2058

2059
def maybe_truncate_key_id(id):
1✔
2060
    """Returns id, truncated to ``_MAX_KEYPART_BYTES`` if it's longer."""
2061
    if len(id) > _MAX_KEYPART_BYTES:
1✔
2062
        # TODO: handle Unicode chars. naive approach is to UTF-8 encode,
2063
        # truncate, then decode, but that might cut mid character. easier to just
2064
        # hope/assume the URL is already URL-encoded.
2065
        truncated = id[:_MAX_KEYPART_BYTES]
1✔
2066
        logger.warning(f'Truncating id {id} to {_MAX_KEYPART_BYTES} chars: {truncated}')
1✔
2067
        return truncated
1✔
2068

2069
    return id
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