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

snarfed / bridgy-fed / d7980eee-5414-4267-a73e-cb7cfd409dbe

12 Nov 2025 05:32AM UTC coverage: 93.003% (+0.05%) from 92.951%
d7980eee-5414-4267-a73e-cb7cfd409dbe

push

circleci

snarfed
update test_models test_as1_from_nostr_note for content_is_html

for snarfed/granary@070b5239f

6141 of 6603 relevant lines covered (93.0%)

0.93 hits per line

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

95.81
/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."""
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.debug(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)
×
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:
×
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✔
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✔
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:
×
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
        """
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 user-facing profile page URL.
875

876
        ...eg ``https://bsky.app/profile/snarfed.org`` or ``https://foo.com/``.
877

878
        To be implemented by subclasses.
879

880
        Returns:
881
          str
882
        """
883
        raise NotImplementedError()
×
884

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

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

892
        Returns:
893
          bool:
894
        """
895
        if not url:
1✔
896
            return False
1✔
897

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

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

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

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

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

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

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

926
        Examples:
927

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

932
        Defaults to this user's key id.
933

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

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

942
        Args:
943
          obj (Object)
944

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

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

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

957
        Populates the reloaded profile :class:`Object` in ``self.obj``.
958

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

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

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

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

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

986
        return path
1✔
987

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

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

994
        Args:
995
          proto: :class:`Protocol` subclass
996

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

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

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

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

1015
        TODO: unify with :meth:`Object.actor_link`?
1016

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

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

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

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

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

1054
        if handle_str and name_str:
1✔
1055
            dot = ' &middot; '
1✔
1056

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

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

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

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

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

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

1095

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

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

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

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

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

1120
    TODO: nail down whether this is :attr:`ABBREV`` or :attr:`LABEL`
1121
    """
1122

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

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

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

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

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

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

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

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

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

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

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

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

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

1213
        elif self.as2:
1✔
1214
            obj = as2.to_as1(unwrap(self.as2))
1✔
1215

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

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

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

1238
        elif self.nostr:
1✔
1239
            obj = granary.nostr.to_as1(self.nostr)
1✔
1240

1241
            # convert NIP-27 user mentions in content to AS1 tags, and replace
1242
            # URIs in content with mentioned users' handles
1243
            #
1244
            # can't easily do this in granary.nostr.to_as1 because we need to fetch
1245
            # the Nostr user
1246
            for match in granary.nostr.URI_RE.finditer(obj.get('content') or ''):
1✔
1247
                if match['prefix'] not in ('npub', 'nprofile'):
1✔
1248
                    continue
1✔
1249
                uri = match.group(0)
1✔
1250
                id = 'nostr:' + granary.nostr.uri_to_id(uri)
1✔
1251
                if user_key := get_original_user_key(id):
1✔
1252
                    user = user_key.get()
1✔
1253
                else:
1254
                    user = PROTOCOLS['nostr'].get_or_create(id, allow_opt_out=True)
1✔
1255
                if user and user.obj:
1✔
1256
                    obj['content'] = obj['content'].replace(uri, user.handle_or_id())
1✔
1257
                    obj.setdefault('tags', []).append({
1✔
1258
                        'objectType': 'mention',
1259
                        'url': user.id_uri(),
1260
                    })
1261

1262
        else:
1263
            return None
1✔
1264

1265
        # populate id if necessary
1266
        if self.key:
1✔
1267
            obj.setdefault('id', self.key.id())
1✔
1268

1269
        if util.domain_or_parent_in(obj.get('id'), IMAGE_PROXY_DOMAINS):
1✔
1270
           as1.prefix_urls(obj, 'image', IMAGE_PROXY_URL_BASE)
1✔
1271

1272
        if self.extra_as1:
1✔
1273
            obj.update(self.extra_as1)
1✔
1274

1275
        return obj
1✔
1276

1277
    @ndb.ComputedProperty
1✔
1278
    def type(self):  # AS1 objectType, or verb if it's an activity
1✔
1279
        if self.as1:
1✔
1280
            return as1.object_type(self.as1)
1✔
1281

1282
    def _expire(self):
1✔
1283
        """Automatically delete most Objects after a while using a TTL policy.
1284

1285
        https://cloud.google.com/datastore/docs/ttl
1286

1287
        They recommend not indexing TTL properties:
1288
        https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
1289
        """
1290
        now = self.updated or util.now()
1✔
1291
        if self.deleted:
1✔
1292
            return now + timedelta(days=1)
1✔
1293
        elif self.type not in DONT_EXPIRE_OBJECT_TYPES:
1✔
1294
            return now + OBJECT_EXPIRE_AGE
1✔
1295

1296
    expire = ndb.ComputedProperty(_expire, indexed=False)
1✔
1297

1298
    def _pre_put_hook(self):
1✔
1299
        """
1300
        * Validate that at:// URIs have DIDs
1301
        * Validate that Nostr ids are nostr:[hex] ids
1302
        * Set/remove the activity label
1303
        * Strip @context from as2 (we don't do LD) to save disk space
1304
        """
1305
        if self.as2:
1✔
1306
           self.as2.pop('@context', None)
1✔
1307
           for field in 'actor', 'attributedTo', 'author', 'object':
1✔
1308
               for val in util.get_list(self.as2, field):
1✔
1309
                   if isinstance(val, dict):
1✔
1310
                       val.pop('@context', None)
1✔
1311

1312
        def check_id(id, proto):
1✔
1313
            if proto in (None, 'ui'):
1✔
1314
                return
1✔
1315

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

1319
            if proto == 'nostr':
1✔
1320
                assert id.startswith('nostr:'), id
1✔
1321
                assert granary.nostr.ID_RE.match(id.removeprefix('nostr:')), id
1✔
1322

1323
            elif proto == 'atproto':
1✔
1324
                assert id.startswith('at://') or id.startswith('did:'), id
1✔
1325
                if id.startswith('at://'):
1✔
1326
                    repo, _, _ = parse_at_uri(id)
1✔
1327
                    if not repo.startswith('did:'):
1✔
1328
                        # TODO: if we hit this, that means the AppView gave us an AT
1329
                        # URI with a handle repo/authority instead of DID. that's
1330
                        # surprising! ...if so, and if we need to handle it, add a
1331
                        # new arroba.did.canonicalize_at_uri() function, then use it
1332
                        # here, or before.
1333
                        raise ValueError(f'at:// URI ids must have DID repos; got {id}')
1✔
1334

1335
        check_id(self.key.id(), self.source_protocol)
1✔
1336
        for target in self.copies:
1✔
1337
            check_id(target.uri, target.protocol)
1✔
1338

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

1343
    @classmethod
1✔
1344
    def get_by_id(cls, id, authed_as=None, **kwargs):
1✔
1345
        """Fetches the :class:`Object` with the given id, if it exists.
1346

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

1353
        Returns:
1354
          Object:
1355

1356
        Raises:
1357
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1358
            the existing object
1359
        """
1360
        obj = super().get_by_id(maybe_truncate_key_id(id), **kwargs)
1✔
1361

1362
        if obj and obj.as1 and authed_as:
1✔
1363
            # authorization: check that the authed user is allowed to modify
1364
            # this object
1365
            # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1366
            proto = PROTOCOLS.get(obj.source_protocol)
1✔
1367
            assert proto, obj.source_protocol
1✔
1368
            owners = [ids.normalize_user_id(id=owner, proto=proto)
1✔
1369
                      for owner in (as1.get_ids(obj.as1, 'author')
1370
                                    + as1.get_ids(obj.as1, 'actor'))
1371
                                    + [id]]
1372
            if (ids.normalize_user_id(id=authed_as, proto=proto) not in owners
1✔
1373
                    and ids.profile_id(id=authed_as, proto=proto) not in owners):
1374
                report_error("Auth: Object: authed_as doesn't match owner",
1✔
1375
                             user=f'{id} authed_as {authed_as} owners {owners}')
1376
                error(f"authed user {authed_as} isn't object owner {owners}",
1✔
1377
                      status=403)
1378

1379
        return obj
1✔
1380

1381
    @classmethod
1✔
1382
    def get_or_create(cls, id, authed_as=None, **props):
1✔
1383
        """Returns an :class:`Object` with the given property values.
1384

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

1389
        Not transactional because transactions don't read or write memcache. :/
1390
        Fortunately we don't really depend on atomicity for much, last writer wins
1391
        is usually fine.
1392

1393
        Args:
1394
          authed_as (str): optional; if provided, and a matching :class:`Object`
1395
            already exists, its ``author`` or ``actor`` must contain this actor
1396
            id. Implements basic authorization for updates and deletes.
1397

1398
        Returns:
1399
          Object:
1400

1401
        Raises:
1402
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1403
            the existing object
1404
        """
1405
        key_id = maybe_truncate_key_id(id)
1✔
1406
        obj = cls.get_by_id(key_id, authed_as=authed_as)
1✔
1407

1408
        if not obj:
1✔
1409
            obj = Object(id=key_id, **props)
1✔
1410
            obj.new = True
1✔
1411
            obj.changed = False
1✔
1412
            obj.put()
1✔
1413
            return obj
1✔
1414

1415
        if orig_as1 := obj.as1:
1✔
1416
            # get_by_id() checks authorization if authed_as is set. make sure
1417
            # it's always set for existing objects.
1418
            assert authed_as
1✔
1419

1420
        dirty = False
1✔
1421
        for prop, val in props.items():
1✔
1422
            assert not isinstance(getattr(Object, prop), ndb.ComputedProperty)
1✔
1423
            if prop in ('copies', 'feed', 'notify', 'users'):
1✔
1424
                # merge repeated fields
1425
                for elem in val:
1✔
1426
                    if obj.add(prop, elem):
1✔
1427
                        dirty = True
1✔
1428
            elif val is not None and val != getattr(obj, prop):
1✔
1429
                setattr(obj, prop, val)
1✔
1430
                if (prop in ('as2', 'bsky', 'mf2', 'nostr', 'raw')
1✔
1431
                        and not props.get('our_as1')):
1432
                    obj.our_as1 = None
1✔
1433
                dirty = True
1✔
1434

1435
        obj.new = False
1✔
1436
        obj.changed = obj.activity_changed(orig_as1)
1✔
1437
        if dirty:
1✔
1438
            obj.put()
1✔
1439
        return obj
1✔
1440

1441
    @staticmethod
1✔
1442
    def from_request():
1✔
1443
        """Creates and returns an :class:`Object` from form-encoded JSON parameters.
1444

1445
        Parameters:
1446
          obj_id (str): id of :class:`models.Object` to handle
1447
          *: If ``obj_id`` is unset, all other parameters are properties for a
1448
            new :class:`models.Object` to handle
1449
        """
1450
        if obj_id := request.form.get('obj_id'):
1✔
1451
            return Object.get_by_id(obj_id)
1✔
1452

1453
        props = {field: request.form.get(field)
1✔
1454
                 for field in ('id', 'source_protocol')}
1455

1456
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'nostr', 'raw':
1✔
1457
            if val := request.form.get(json_prop):
1✔
1458
                props[json_prop] = json_loads(val)
1✔
1459

1460
        obj = Object(**props)
1✔
1461
        if not obj.key and obj.as1:
1✔
1462
            if id := obj.as1.get('id'):
1✔
1463
                obj.key = ndb.Key(Object, id)
1✔
1464

1465
        return obj
1✔
1466

1467
    def to_request(self):
1✔
1468
        """Returns a query parameter dict representing this :class:`Object`."""
1469
        form = {}
1✔
1470

1471
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1472
            if val := getattr(self, json_prop, None):
1✔
1473
                form[json_prop] = json_dumps(val, sort_keys=True)
1✔
1474

1475
        for prop in ['source_protocol']:
1✔
1476
            if val := getattr(self, prop):
1✔
1477
                form[prop] = val
1✔
1478

1479
        if self.key:
1✔
1480
            form['id'] = self.key.id()
1✔
1481

1482
        return form
1✔
1483

1484
    def activity_changed(self, other_as1):
1✔
1485
        """Returns True if this activity is meaningfully changed from ``other_as1``.
1486

1487
        ...otherwise False.
1488

1489
        Used to populate :attr:`changed`.
1490

1491
        Args:
1492
          other_as1 (dict): AS1 object, or none
1493
        """
1494
        # ignore inReplyTo since we translate it between protocols
1495
        return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
1✔
1496
                if self.as1 and other_as1
1497
                else bool(self.as1) != bool(other_as1))
1498

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

1502
        TODO: unify with :meth:`User.user_link`?
1503

1504
        Args:
1505
          image (bool): whether to include an ``img`` tag with the actor's picture
1506
          sized (bool): whether to set an explicit (``width=32``) size on the
1507
            profile picture ``img`` tag
1508
          user (User): current user
1509

1510
        Returns:
1511
          str:
1512
        """
1513
        attrs = {'class': 'h-card u-author'}
1✔
1514

1515
        if user and user.key in self.users:
1✔
1516
            # outbound; show a nice link to the user
1517
            return user.user_link(handle=False, pictures=True)
1✔
1518

1519
        proto = PROTOCOLS.get(self.source_protocol)
1✔
1520

1521
        actor = None
1✔
1522
        if self.as1:
1✔
1523
            actor = (as1.get_object(self.as1, 'actor')
1✔
1524
                     or as1.get_object(self.as1, 'author'))
1525
            # hydrate from datastore if available
1526
            # TODO: optimize! this is called serially in loops, eg in home.html
1527
            if set(actor.keys()) == {'id'} and self.source_protocol:
1✔
1528
                actor_obj = proto.load(actor['id'], remote=False)
1✔
1529
                if actor_obj and actor_obj.as1:
1✔
1530
                    actor = actor_obj.as1
1✔
1531

1532
        if not actor:
1✔
1533
            return ''
1✔
1534
        elif set(actor.keys()) == {'id'}:
1✔
1535
            return common.pretty_link(actor['id'], attrs=attrs, user=user)
1✔
1536

1537
        url = as1.get_url(actor)
1✔
1538
        name = actor.get('displayName') or actor.get('username') or ''
1✔
1539
        img_url = util.get_url(actor, 'image')
1✔
1540
        if not image or not img_url:
1✔
1541
            return common.pretty_link(url, text=name, attrs=attrs, user=user)
1✔
1542

1543
        logo = ''
1✔
1544
        if proto:
1✔
1545
            logo = f'<span class="logo" title="{self.__class__.__name__}">{proto.LOGO_HTML or proto.LOGO_EMOJI}</span>'
×
1546

1547
        return f"""\
1✔
1548
        {logo}
1549
        <a class="h-card u-author" href="{url}" title="{name}">
1550
          <img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
1551
          <span style="unicode-bidi: isolate">{util.ellipsize(name, chars=40)}</span>
1552
        </a>"""
1553

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

1557
        ...or None if no such copy exists. If ``proto`` is ``source_protocol``,
1558
        returns this object's key id.
1559

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

1568
        Args:
1569
          proto: :class:`Protocol` subclass
1570

1571
        Returns:
1572
          str:
1573
        """
1574
        if self.source_protocol in (proto.LABEL, proto.ABBREV):
1✔
1575
            return self.key.id()
1✔
1576

1577
        for copy in self.copies:
1✔
1578
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
1579
                return copy.uri
1✔
1580

1581
    def resolve_ids(self):
1✔
1582
        """Replaces "copy" ids, subdomain ids, etc with their originals.
1583

1584
        The end result is that all ids are original "source" ids, ie in the
1585
        protocol that they first came from.
1586

1587
        Specifically, resolves:
1588

1589
        * ids in :class:`User.copies` and :class:`Object.copies`, eg ATProto
1590
          records and Nostr events that we bridged, to the ids of their
1591
          original objects in their source protocol, eg
1592
          ``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
1593
        * Bridgy Fed subdomain URLs to the ids embedded inside them, eg
1594
          ``https://bsky.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
1595
        * ATProto bsky.app URLs to their DIDs or `at://` URIs, eg
1596
          ``https://bsky.app/profile/a.com`` => ``did:plc:123``
1597

1598
        ...in these AS1 fields, in place:
1599

1600
        * ``id``
1601
        * ``actor``
1602
        * ``author``
1603
        * ``object``
1604
        * ``object.actor``
1605
        * ``object.author``
1606
        * ``object.id``
1607
        * ``object.inReplyTo``
1608
        * ``attachments.[objectType=note].id``
1609
        * ``tags.[objectType=mention].url``
1610

1611
        :meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
1612
        Much of the same logic is duplicated there!
1613

1614
        TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
1615
        """
1616
        if not self.as1:
1✔
1617
            return
1✔
1618

1619
        # extract ids, strip Bridgy Fed subdomain URLs
1620
        outer_obj = unwrap(self.as1)
1✔
1621
        if outer_obj != self.as1:
1✔
1622
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1623

1624
        self_proto = PROTOCOLS.get(self.source_protocol)
1✔
1625
        if not self_proto:
1✔
1626
            return
1✔
1627

1628
        logger.debug(f'Resolving ids for {self.key.id()}')
1✔
1629
        inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
1✔
1630
        replaced = False
1✔
1631

1632
        def replace(val, orig_fn):
1✔
1633
            id = val.get('id') if isinstance(val, dict) else val
1✔
1634
            if not id or not self_proto.HAS_COPIES:
1✔
1635
                return id
1✔
1636

1637
            orig = orig_fn(id)
1✔
1638
            if not orig:
1✔
1639
                return val
1✔
1640

1641
            nonlocal replaced
1642
            replaced = True
1✔
1643
            logger.debug(f'Resolved copy id {val} to original {orig.id()}')
1✔
1644

1645
            if isinstance(val, dict) and util.trim_nulls(val).keys() > {'id'}:
1✔
1646
                val['id'] = orig.id()
1✔
1647
                return val
1✔
1648
            else:
1649
                return orig.id()
1✔
1650

1651
        # actually replace ids
1652
        #
1653
        # object field could be either object (eg repost) or actor (eg follow)
1654
        outer_obj['object'] = replace(inner_obj, get_original_object_key)
1✔
1655
        if not replaced:
1✔
1656
            outer_obj['object'] = replace(inner_obj, get_original_user_key)
1✔
1657

1658
        for obj in outer_obj, inner_obj:
1✔
1659
            for tag in as1.get_objects(obj, 'tags'):
1✔
1660
                if tag.get('objectType') == 'mention':
1✔
1661
                    tag['url'] = replace(tag.get('url'), get_original_user_key)
1✔
1662
            for att in as1.get_objects(obj, 'attachments'):
1✔
1663
                if att.get('objectType') == 'note':
1✔
1664
                    att['id'] = replace(att.get('id'), get_original_object_key)
1✔
1665
            for field, fn in (
1✔
1666
                    ('actor', get_original_user_key),
1667
                    ('author', get_original_user_key),
1668
                    ('inReplyTo', get_original_object_key),
1669
                ):
1670
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1671
                if len(obj[field]) == 1:
1✔
1672
                    obj[field] = obj[field][0]
1✔
1673

1674
        if replaced:
1✔
1675
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1676

1677
    def normalize_ids(self):
1✔
1678
        """Normalizes ids to their protocol's canonical representation, if any.
1679

1680
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
1681
        for profiles, ``at://`` URIs for posts.
1682

1683
        Modifies this object in place.
1684

1685
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
1686
        """
1687
        from protocol import Protocol
1✔
1688

1689
        if not self.as1:
1✔
1690
            return
1✔
1691

1692
        logger.debug(f'Normalizing ids for {self.key.id()}')
1✔
1693
        outer_obj = copy.deepcopy(self.as1)
1✔
1694
        inner_objs = as1.get_objects(outer_obj)
1✔
1695
        replaced = False
1✔
1696

1697
        def replace(val, translate_fn):
1✔
1698
            nonlocal replaced
1699

1700
            orig = val.get('id') if isinstance(val, dict) else val
1✔
1701
            if not orig:
1✔
1702
                return val
1✔
1703

1704
            proto = Protocol.for_id(orig, remote=False)
1✔
1705
            if not proto:
1✔
1706
                return val
1✔
1707

1708
            translated = translate_fn(id=orig, from_=proto, to=proto)
1✔
1709
            if translated and translated != orig:
1✔
1710
                # logger.debug(f'Normalized {proto.LABEL} id {orig} to {translated}')
1711
                replaced = True
1✔
1712
                if isinstance(val, dict):
1✔
1713
                    val['id'] = translated
1✔
1714
                    return val
1✔
1715
                else:
1716
                    return translated
1✔
1717

1718
            return val
1✔
1719

1720
        # actually replace ids
1721
        for obj in [outer_obj] + inner_objs:
1✔
1722
            for tag in as1.get_objects(obj, 'tags'):
1✔
1723
                if tag.get('objectType') == 'mention':
1✔
1724
                    tag['url'] = replace(tag.get('url'), ids.translate_user_id)
1✔
1725
            for field in ['actor', 'author', 'inReplyTo']:
1✔
1726
                fn = (ids.translate_object_id if field == 'inReplyTo'
1✔
1727
                      else ids.translate_user_id)
1728
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1729
                if len(obj[field]) == 1:
1✔
1730
                    obj[field] = obj[field][0]
1✔
1731

1732
        outer_obj['object'] = []
1✔
1733
        for inner_obj in inner_objs:
1✔
1734
            translate_fn = ids.translate_object_id
1✔
1735
            if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
1✔
1736
                    or as1.object_type(outer_obj) in as1.VERBS_WITH_ACTOR_OBJECT):
1737
                translate_fn = ids.translate_user_id
1✔
1738

1739
            got = replace(inner_obj, translate_fn)
1✔
1740
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
1741
                got = got['id']
1✔
1742

1743
            outer_obj['object'].append(got)
1✔
1744

1745
        if len(outer_obj['object']) == 1:
1✔
1746
            outer_obj['object'] = outer_obj['object'][0]
1✔
1747

1748
        if replaced:
1✔
1749
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1750

1751

1752
class Follower(ndb.Model):
1✔
1753
    """A follower of a Bridgy Fed user."""
1754
    STATUSES = ('active', 'inactive')
1✔
1755

1756
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
1757
    """The follower."""
1✔
1758
    to = ndb.KeyProperty(required=True)
1✔
1759
    """The followee, ie the user being followed."""
1✔
1760

1761
    follow = ndb.KeyProperty(Object)
1✔
1762
    """The last follow activity."""
1✔
1763
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
1764
    """Whether this follow is active or not."""
1✔
1765

1766
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1767
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1768

1769
    # OLD. some stored entities still have these; do not reuse.
1770
    # src = ndb.StringProperty()
1771
    # dest = ndb.StringProperty()
1772
    # last_follow = JsonProperty()
1773

1774
    def _pre_put_hook(self):
1✔
1775
        # we're a bridge! stick with bridging.
1776
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
1777

1778
    def _post_put_hook(self, future):
1✔
1779
        logger.debug(f'Wrote {self.key}')
1✔
1780

1781
    @classmethod
1✔
1782
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
1783
        """Returns a Follower with the given ``from_`` and ``to`` users.
1784

1785
        Not transactional because transactions don't read or write memcache. :/
1786
        Fortunately we don't really depend on atomicity for much, last writer wins
1787
        is usually fine.
1788

1789
        If a matching :class:`Follower` doesn't exist in the datastore, creates
1790
        it first.
1791

1792
        Args:
1793
          from_ (User)
1794
          to (User)
1795

1796
        Returns:
1797
          Follower:
1798
        """
1799
        assert from_
1✔
1800
        assert to
1✔
1801

1802
        follower = Follower.query(Follower.from_ == from_.key,
1✔
1803
                                  Follower.to == to.key,
1804
                                  ).get()
1805
        if not follower:
1✔
1806
            follower = Follower(from_=from_.key, to=to.key, **kwargs)
1✔
1807
            follower.put()
1✔
1808
        elif kwargs:
1✔
1809
            # update existing entity with new property values, eg to make an
1810
            # inactive Follower active again
1811
            for prop, val in kwargs.items():
1✔
1812
                setattr(follower, prop, val)
1✔
1813
            follower.put()
1✔
1814

1815
        return follower
1✔
1816

1817
    @staticmethod
1✔
1818
    def fetch_page(collection, user):
1✔
1819
        r"""Fetches a page of :class:`Follower`\s for a given user.
1820

1821
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
1822
        parameters, if available in the request.
1823

1824
        Args:
1825
          collection (str): ``followers`` or ``following``
1826
          user (User)
1827

1828
        Returns:
1829
          (list of Follower, str, str) tuple: results, annotated with an extra
1830
          ``user`` attribute that holds the follower or following :class:`User`,
1831
          and new str query param values for ``before`` and ``after`` to fetch
1832
          the previous and next pages, respectively
1833
        """
1834
        assert collection in ('followers', 'following'), collection
1✔
1835

1836
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1837
        query = Follower.query(
1✔
1838
            Follower.status == 'active',
1839
            filter_prop == user.key,
1840
        )
1841

1842
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
1843
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
1844
                              for f in followers)
1845
        User.load_multi(u for u in users if u)
1✔
1846

1847
        for f, u in zip(followers, users):
1✔
1848
            f.user = u
1✔
1849

1850
        followers = [f for f in followers if f.user]
1✔
1851

1852
        # only show followers in protocols that this user is bridged into
1853
        if collection == 'followers':
1✔
1854
            followers = [f for f in followers if user.is_enabled(f.user)]
1✔
1855

1856
        return followers, before, after
1✔
1857

1858

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

1862
    Wraps :func:`fetch_page` and adds attributes to the returned
1863
    :class:`Object` entities for rendering in ``objects.html``.
1864

1865
    Args:
1866
      query (ndb.Query)
1867
      by (ndb.model.Property): either :attr:`Object.updated` or
1868
        :attr:`Object.created`
1869
      user (User): current user
1870

1871
    Returns:
1872
      (list of Object, str, str) tuple:
1873
      (results, new ``before`` query param, new ``after`` query param)
1874
      to fetch the previous and next pages, respectively
1875
    """
1876
    assert by is Object.updated or by is Object.created
1✔
1877
    objects, new_before, new_after = fetch_page(query, Object, by=by)
1✔
1878
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
1879

1880
    # synthesize human-friendly content for objects
1881
    for i, obj in enumerate(objects):
1✔
1882
        obj_as1 = obj.as1
1✔
1883
        type = as1.object_type(obj_as1)
1✔
1884

1885
        # AS1 verb => human-readable phrase
1886
        phrases = {
1✔
1887
            'accept': 'accepted',
1888
            'article': 'posted',
1889
            'comment': 'replied',
1890
            'delete': 'deleted',
1891
            'follow': 'followed',
1892
            'invite': 'is invited to',
1893
            'issue': 'filed issue',
1894
            'like': 'liked',
1895
            'note': 'posted',
1896
            'post': 'posted',
1897
            'repost': 'reposted',
1898
            'rsvp-interested': 'is interested in',
1899
            'rsvp-maybe': 'might attend',
1900
            'rsvp-no': 'is not attending',
1901
            'rsvp-yes': 'is attending',
1902
            'share': 'reposted',
1903
            'stop-following': 'unfollowed',
1904
            'undo': 'undid',
1905
            'update': 'updated',
1906
        }
1907
        phrases.update({type: 'profile refreshed:' for type in as1.ACTOR_TYPES})
1✔
1908

1909
        obj.phrase = phrases.get(type, '')
1✔
1910

1911
        content = (obj_as1.get('content')
1✔
1912
                   or obj_as1.get('displayName')
1913
                   or obj_as1.get('summary'))
1914
        if content:
1✔
1915
            content = util.parse_html(content).get_text()
1✔
1916

1917
        urls = as1.object_urls(obj_as1)
1✔
1918
        url = urls[0] if urls else None
1✔
1919
        if url and not content:
1✔
1920
            # heuristics for sniffing URLs and converting them to more friendly
1921
            # phrases and user handles.
1922
            # TODO: standardize this into granary.as2 somewhere?
1923
            from activitypub import FEDI_URL_RE
×
1924
            from atproto import COLLECTION_TO_TYPE, did_to_handle
×
1925

1926
            handle = suffix = ''
×
1927
            if match := FEDI_URL_RE.match(url):
×
1928
                handle = match.group(2)
×
1929
                if match.group(4):
×
1930
                    suffix = "'s post"
×
1931
            elif match := BSKY_APP_URL_RE.match(url):
×
1932
                handle = match.group('id')
×
1933
                if match.group('tid'):
×
1934
                    suffix = "'s post"
×
1935
            elif match := AT_URI_RE.match(url):
×
1936
                handle = match.group('repo')
×
1937
                if coll := match.group('collection'):
×
1938
                    suffix = f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
×
1939
                url = bluesky.at_uri_to_web_url(url)
×
1940
            elif url.startswith('did:'):
×
1941
                handle = url
×
1942
                url = bluesky.Bluesky.user_url(handle)
×
1943

1944
            if handle:
×
1945
                if handle.startswith('did:'):
×
1946
                    handle = did_to_handle(handle) or handle
×
1947
                content = f'@{handle}{suffix}'
×
1948

1949
            if url:
×
1950
                content = common.pretty_link(url, text=content, user=user)
×
1951

1952
        obj.content = (obj_as1.get('content')
1✔
1953
                       or obj_as1.get('displayName')
1954
                       or obj_as1.get('summary'))
1955
        obj.url = as1.get_url(obj_as1)
1✔
1956

1957
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
1958
            inner_as1 = as1.get_object(obj_as1)
1✔
1959
            obj.inner_url = as1.get_url(inner_as1) or inner_as1.get('id')
1✔
1960
            if obj.url:
1✔
1961
                obj.phrase = common.pretty_link(
1✔
1962
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
1963
            if content:
1✔
1964
                obj.content = content
1✔
1965
                obj.url = url
1✔
1966
            elif obj.inner_url:
1✔
1967
                obj.content = common.pretty_link(obj.inner_url, max_length=50)
1✔
1968

1969
    return objects, new_before, new_after
1✔
1970

1971

1972
def hydrate(activity, fields=('author', 'actor', 'object')):
1✔
1973
    """Hydrates fields in an AS1 activity, in place.
1974

1975
    Args:
1976
      activity (dict): AS1 activity
1977
      fields (sequence of str): names of fields to hydrate. If they're string ids,
1978
        loads them from the datastore, if possible, and replaces them with their dict
1979
        AS1 objects.
1980

1981
    Returns:
1982
      sequence of :class:`google.cloud.ndb.tasklets.Future`: tasklets for hydrating
1983
        each field. Wait on these before using ``activity``.
1984
    """
1985
    def _hydrate(field):
1✔
1986
        def maybe_set(future):
1✔
1987
            if future.result() and future.result().as1:
1✔
1988
                activity[field] = future.result().as1
1✔
1989
        return maybe_set
1✔
1990

1991
    futures = []
1✔
1992

1993
    for field in fields:
1✔
1994
        val = as1.get_object(activity, field)
1✔
1995
        if val and val.keys() <= set(['id']):
1✔
1996
            # TODO: extract a Protocol class method out of User.profile_id,
1997
            # then use that here instead. the catch is that we'd need to
1998
            # determine Protocol for every id, which is expensive.
1999
            #
2000
            # same TODO is in models.fetch_objects
2001
            id = val['id']
1✔
2002
            if id.startswith('did:'):
1✔
2003
                id = f'at://{id}/app.bsky.actor.profile/self'
×
2004

2005
            future = Object.get_by_id_async(id)
1✔
2006
            future.add_done_callback(_hydrate(field))
1✔
2007
            futures.append(future)
1✔
2008

2009
    return futures
1✔
2010

2011

2012
def fetch_page(query, model_class, by=None):
1✔
2013
    """Fetches a page of results from a datastore query.
2014

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

2018
    Populates a ``log_url_path`` property on each result entity that points to a
2019
    its most recent logged request.
2020

2021
    Args:
2022
      query (google.cloud.ndb.query.Query)
2023
      model_class (class)
2024
      by (ndb.model.Property): paging property, eg :attr:`Object.updated`
2025
        or :attr:`Object.created`
2026

2027
    Returns:
2028
      (list of Object or Follower, str, str) tuple: (results, new_before,
2029
      new_after), where new_before and new_after are query param values for
2030
      ``before`` and ``after`` to fetch the previous and next pages,
2031
      respectively
2032
    """
2033
    assert by
1✔
2034

2035
    # if there's a paging param ('before' or 'after'), update query with it
2036
    # TODO: unify this with Bridgy's user page
2037
    def get_paging_param(param):
1✔
2038
        val = request.values.get(param)
1✔
2039
        if val:
1✔
2040
            try:
1✔
2041
                dt = util.parse_iso8601(val.replace(' ', '+'))
1✔
2042
            except BaseException as e:
1✔
2043
                error(f"Couldn't parse {param}, {val!r} as ISO8601: {e}")
1✔
2044
            if dt.tzinfo:
1✔
2045
                dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
1✔
2046
            return dt
1✔
2047

2048
    before = get_paging_param('before')
1✔
2049
    after = get_paging_param('after')
1✔
2050
    if before and after:
1✔
2051
        error("can't handle both before and after")
×
2052
    elif after:
1✔
2053
        query = query.filter(by >= after).order(by)
1✔
2054
    elif before:
1✔
2055
        query = query.filter(by < before).order(-by)
1✔
2056
    else:
2057
        query = query.order(-by)
1✔
2058

2059
    query_iter = query.iter()
1✔
2060
    results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
1✔
2061
                     key=lambda r: r.updated, reverse=True)
2062

2063
    # calculate new paging param(s)
2064
    has_next = results and query_iter.probably_has_next()
1✔
2065
    new_after = (
1✔
2066
        before if before
2067
        else results[0].updated if has_next and after
2068
        else None)
2069
    if new_after:
1✔
2070
        new_after = new_after.isoformat()
1✔
2071

2072
    new_before = (
1✔
2073
        after if after else
2074
        results[-1].updated if has_next
2075
        else None)
2076
    if new_before:
1✔
2077
        new_before = new_before.isoformat()
1✔
2078

2079
    return results, new_before, new_after
1✔
2080

2081

2082
def maybe_truncate_key_id(id):
1✔
2083
    """Returns id, truncated to ``_MAX_KEYPART_BYTES`` if it's longer."""
2084
    if len(id) > _MAX_KEYPART_BYTES:
1✔
2085
        # TODO: handle Unicode chars. naive approach is to UTF-8 encode,
2086
        # truncate, then decode, but that might cut mid character. easier to just
2087
        # hope/assume the URL is already URL-encoded.
2088
        truncated = id[:_MAX_KEYPART_BYTES]
1✔
2089
        logger.warning(f'Truncating id {id} to {_MAX_KEYPART_BYTES} chars: {truncated}')
1✔
2090
        return truncated
1✔
2091

2092
    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