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

snarfed / bridgy-fed / 9550e0b1-5a3e-4a43-b9b9-c9dae7259e53

30 Jul 2026 01:36AM UTC coverage: 92.797% (+0.004%) from 92.793%
9550e0b1-5a3e-4a43-b9b9-c9dae7259e53

push

circleci

snarfed
mastodon_api.accounts_statuses: implement exclude_replies, exclude_reblogs

for #2493

[deploy]

8451 of 9107 relevant lines covered (92.8%)

0.93 hits per line

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

95.92
/models.py
1
"""Datastore model classes."""
2
import copy
1✔
3
from datetime import timedelta, timezone
1✔
4
from functools import cached_property, 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
import csv
1✔
13
import io
1✔
14

15
from arroba.util import parse_at_uri
1✔
16
import cachetools
1✔
17
from Crypto.PublicKey import RSA
1✔
18
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
1✔
19
from cryptography.hazmat.primitives.serialization import (
1✔
20
    Encoding,
21
    NoEncryption,
22
    PrivateFormat,
23
    PublicFormat,
24
)
25
from flask import request
1✔
26
from google.cloud import ndb
1✔
27
from google.cloud.ndb.key import _MAX_KEYPART_BYTES
1✔
28
from google.protobuf import text_format
1✔
29
from granary import as1, as2, atom, bluesky, microformats2
1✔
30
from granary.bluesky import BSKY_APP_URL_RE
1✔
31
import granary.farcaster
1✔
32
from granary.generated.farcaster.message_pb2 import (
1✔
33
    Message,
34
    MESSAGE_TYPE_USER_DATA_ADD,
35
)
36
from granary.generated.farcaster.request_response_pb2 import MessagesResponse
1✔
37
import granary.nostr
1✔
38
from granary.source import html_to_text
1✔
39
import humanize
1✔
40
from lexrpc.base import AT_URI_RE
1✔
41
from requests import RequestException
1✔
42
import secp256k1
1✔
43
from webutil import util
1✔
44
from webutil.appengine_info import DEBUG
1✔
45
from webutil.flask_util import error
1✔
46
from webutil.models import (
1✔
47
    EncryptedProperty,
48
    JsonProperty,
49
    stored_value,
50
    StringIdModel,
51
)
52
from webutil.util import ellipsize, json_dumps, json_loads
1✔
53

54
import common
1✔
55
from common import (
1✔
56
    OLD_ACCOUNT_AGE,
57
    report_error,
58
)
59
import domains
1✔
60
from domains import (
1✔
61
    BLOG_REDIRECT_DOMAINS,
62
    DOMAIN_BLOCKLIST_CANARIES,
63
    DOMAIN_RE,
64
    PRIMARY_DOMAIN,
65
    PROTOCOL_DOMAINS,
66
    unwrap,
67
)
68
import ids
1✔
69
import memcache
1✔
70

71
# maps string label to Protocol subclass. values are populated by ProtocolUserMeta.
72
# (we used to wait for ProtocolUserMeta to populate the keys as well, but that was
73
# awkward to use in datastore model properties with choices, below; it required
74
# overriding them in reset_model_properties, which was always flaky.)
75
PROTOCOLS = {label: None for label in (
1✔
76
    'activitypub',
77
    'ap',
78
    'atproto',
79
    'bsky',
80
    'farcaster',
81
    'fc',
82
    'nostr',
83
    'ostatus',
84
    'web',
85
    'webmention',
86
    'ui',
87
)}
88
DEBUG_PROTOCOLS = (
1✔
89
    'fa',
90
    'fake',
91
    'efake',
92
    'other',
93
)
94
if DEBUG:
1✔
95
    PROTOCOLS.update({label: None for label in DEBUG_PROTOCOLS})
1✔
96

97
# maps string kind (eg 'MagicKey') to Protocol subclass.
98
# populated in ProtocolUserMeta
99
PROTOCOLS_BY_KIND = {}
1✔
100

101
# 2048 bits makes tests slow, so use 1024 for them
102
KEY_BITS = 1024 if DEBUG else 2048
1✔
103
PAGE_SIZE = 20
1✔
104

105
# auto delete most old objects via the Object.expire property
106
# https://cloud.google.com/datastore/docs/ttl
107
#
108
# need to keep follows because we attach them to Followers and use them for
109
# unfollows
110
DONT_EXPIRE_OBJECT_TYPES = as1.ACTOR_TYPES
1✔
111
EXPIRE_LATE_OBJECT_TYPES = as1.POST_TYPES | set([
1✔
112
    'block', 'flag', 'follow', 'like', 'share'])
113
OBJECT_EARLY_EXPIRE_AGE = timedelta(days=2 * 30)
1✔
114
OBJECT_LATE_EXPIRE_AGE = timedelta(days=6 * 30)
1✔
115

116
GET_ORIGINALS_CACHE_EXPIRATION = timedelta(days=1)
1✔
117
FOLLOWERS_CACHE_EXPIRATION = timedelta(hours=2)
1✔
118

119
# See https://www.cloudimage.io/
120
IMAGE_PROXY_URL_BASE = 'https://xaasg3w5.cloudimg.io/'
1✔
121
IMAGE_PROXY_DOMAINS = ('threads.net',)
1✔
122

123
# used by User.status_description. values are formatted with format(user=...)
124
USER_STATUS_DESCRIPTIONS = {  # keep in sync with DM.type's docstring!
1✔
125
    'moved': 'account has migrated to another account',
126
    'no-feed-or-webmention': "web site doesn't have an RSS or Atom feed or webmention endpoint",
127
    'nobot': "profile has 'nobot' in it",
128
    'nobridge': "profile has 'nobridge' in it",
129
    'no-nip05': "account's NIP-05 identifier is missing or invalid",
130
    'no-profile': 'profile is missing or empty',
131
    'opt-out': 'account or instance has requested to be opted out',
132
    'over-handle-domain-limit': "handle's domain has too many users on it",
133
    'owns-webfinger': 'web site looks like a fediverse instance because it already serves Webfinger',
134
    'private': 'account is set as private or protected',
135
    'requires-avatar': "account doesn't have a profile picture",
136
    'requires-name': "account's name and username are the same",
137
    'requires-old-account': f"account is less than {humanize.naturaldelta(OLD_ACCOUNT_AGE)} old",
138
    '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>",
139
}
140
# used as the User.handle_pay_level_domain value when there are too many users on
141
# the pay-level domain
142
OVER_LIMIT = 'too-many'
1✔
143

144
logger = logging.getLogger(__name__)
1✔
145

146

147
class Target(ndb.Model):
1✔
148
    r""":class:`protocol.Protocol` + URI pairs for identifying objects.
149

150
    These are currently used for:
151

152
    * delivery destinations, eg ActivityPub inboxes, webmention targets, etc.
153
    * copies of :class:`Object`\s and :class:`User`\s elsewhere,
154
      eg ``at://`` URIs for ATProto records, nevent etc bech32-encoded Nostr ids,
155
      ATProto user DIDs, etc.
156

157
    Used in :class:`google.cloud.ndb.model.StructuredProperty`\s inside
158
    :class:`Object` and :class:`User`; not stored as top-level entities in the
159
    datastore.
160

161
    ndb implements this by hoisting each property here into a corresponding
162
    property on the parent entity, prefixed by the StructuredProperty name
163
    below, eg ``delivered.uri``, ``delivered.protocol``, etc.
164

165
    For repeated StructuredPropertys, the hoisted properties are all repeated on
166
    the parent entity, and reconstructed into StructuredPropertys based on their
167
    order.
168
    """
169
    uri = ndb.StringProperty(required=True)
1✔
170
    ''
1✔
171
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
172
    ''
1✔
173

174
    def __eq__(self, other):
1✔
175
        """Equality excludes :class:`Key`."""
176
        if isinstance(other, Target):
1✔
177
            return self.uri == other.uri and self.protocol == other.protocol
1✔
178

179
    def __hash__(self):
1✔
180
        """Allow hashing so these can be dict keys."""
181
        return hash((self.protocol, self.uri))
1✔
182

183

184
class DM(ndb.Model):
1✔
185
    """:class:`protocol.Protocol` + type pairs for identifying sent DMs.
186

187
    Used in :attr:`User.sent_dms`.
188

189
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
190
    """
191
    type = ndb.StringProperty(required=True)
1✔
192
    """Known values (keep in sync with USER_STATUS_DESCRIPTIONS, the subset for
1✔
193
    ineligible users):
194

195
      * dms_not_supported-[RECIPIENT-USER-ID]
196
      * moved
197
      * no-feed-or-webmention
198
      * no-nip05
199
      * no-profile
200
      * opt-out
201
      * over-handle-domain-limit
202
      * owns-webfinger
203
      * private
204
      * replied_to_bridged_user
205
      * request_bridging
206
      * requires-avatar
207
      * requires-name
208
      * requires-old-account
209
      * unsupported-handle-ap
210
      * welcome
211
    """
212
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
213
    ''
1✔
214

215
    def __eq__(self, other):
1✔
216
        """Equality excludes :class:`Key`."""
217
        return self.type == other.type and self.protocol == other.protocol
1✔
218

219

220
class KeyPair(ndb.Model):
1✔
221
    """A user's public/private key pair for a single protocol.
222

223
    Used in :attr:`User.keypairs` ; not stored as top-level entities in the
224
    datastore. The private key is encrypted at rest; the public key is not.
225

226
    Format per ``algorithm``:
227

228
    * ``rsa``: ``private_key_bytes`` is PKCS#1 PEM
229
      (``-----BEGIN RSA PRIVATE KEY-----``); ``public_key_bytes`` is SPKI PEM
230
      (``-----BEGIN PUBLIC KEY-----``).
231
    * ``secp256k1``: 32 raw bytes private, 32 raw bytes BIP-340 x-only public.
232
    * ``ed25519``: 32 raw bytes private, 32 raw bytes public.
233

234
    Details for each protocol:
235

236
    * ActivityPub: RSA
237
    * ATProto: secp256k1, with ECDSA signatures
238
      (keypair is stored in :class:`arroba.datastore_storage.AtpRepo`, *not* here)
239
      https://atproto.com/specs/cryptography
240
    * Farcaster: Ed25519, with EdDSA signatures
241
      https://docs.farcaster.xyz/reference/farcaster/intent-urls#resource-urls
242
    * Nostr: secp256k1, with Schnorr signatures
243
      https://github.com/nostr-protocol/nips/blob/master/01.md#events-and-signatures
244
    """
245
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
246
    ''
1✔
247
    algorithm = ndb.StringProperty(choices=('ed25519', 'rsa', 'secp256k1'),
1✔
248
                                   required=True)
249
    ''
1✔
250
    public_key_bytes = ndb.BlobProperty(required=True)
1✔
251
    ''
1✔
252
    private_key_bytes = EncryptedProperty(required=True)
1✔
253
    ''
1✔
254

255
    def __eq__(self, other):
1✔
256
        """Equality excludes :class:`Key`."""
257
        if isinstance(other, KeyPair):
1✔
258
            return (self.protocol == other.protocol
1✔
259
                    and self.algorithm == other.algorithm
260
                    and self.public_key_bytes == other.public_key_bytes
261
                    and self.private_key_bytes == other.private_key_bytes)
262

263

264
class ProtocolUserMeta(type(ndb.Model)):
1✔
265
    """:class:`User` metaclass. Registers all subclasses in ``PROTOCOLS``."""
266
    def __new__(meta, name, bases, class_dict):
1✔
267
        cls = super().__new__(meta, name, bases, class_dict)
1✔
268

269
        label = getattr(cls, 'LABEL', None)
1✔
270
        if (label and label not in ('protocol', 'user')
1✔
271
                and (DEBUG or cls.LABEL not in DEBUG_PROTOCOLS)):
272
            for label in (label, cls.ABBREV) + cls.OTHER_LABELS:
1✔
273
                if label:
1✔
274
                    PROTOCOLS[label] = cls
1✔
275
            PROTOCOLS_BY_KIND[cls._get_kind()] = cls
1✔
276

277
        return cls
1✔
278

279

280
def reset_protocol_properties():
1✔
281
    """Recreates various protocol properties to include choices from ``PROTOCOLS``."""
282
    abbrevs = f'({"|".join(PROTOCOLS.keys())}|fed)'
1✔
283
    domains.SUBDOMAIN_BASE_URL_RE = re.compile(
1✔
284
        rf'^https?://({abbrevs}\.brid\.gy|localhost(:8080)?)/(convert/|r/)?({abbrevs}/)?(?P<path>.+)')
285
    ids.COPIES_PROTOCOLS = tuple(label for label, proto in PROTOCOLS.items()
1✔
286
                                 if proto and proto.HAS_COPIES)
287

288

289
@lru_cache(maxsize=100000)
1✔
290
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
291
def get_original_object_key(copy_id):
1✔
292
    """Finds the :class:`Object` with a given copy id, if any.
293

294
    Note that :meth:`Object.add` also updates this function's
295
    :func:`memcache.memoize` cache.
296

297
    Args:
298
      copy_id (str)
299

300
    Returns:
301
      google.cloud.ndb.Key or None
302
    """
303
    assert copy_id
1✔
304

305
    return Object.query(Object.copies.uri == copy_id).get(keys_only=True)
1✔
306

307

308
@lru_cache(maxsize=100000)
1✔
309
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
310
def get_original_user_key(copy_id):
1✔
311
    """Finds the user with a given copy id, if any.
312

313
    Note that :meth:`User.add` also updates this function's
314
    :func:`memcache.memoize` cache.
315

316
    Args:
317
      copy_id (str)
318

319
    Returns:
320
      google.cloud.ndb.Key or None
321
    """
322
    assert copy_id
1✔
323

324
    for proto in PROTOCOLS.values():
1✔
325
        if proto and proto.LABEL != 'ui' and not proto.owns_id(copy_id):
1✔
326
            if orig := proto.query(proto.copies.uri == copy_id).get(keys_only=True):
1✔
327
                return orig
1✔
328

329

330
class AddRemoveMixin:
1✔
331
    """Mixin class that defines the :meth:`add` and :meth:`remove` methods.
332

333
    If a subclass of this mixin defines the ``GET_ORIGINAL_FN`` class-level
334
    attribute, its memoize cache will be cleared when :meth:`remove` is called with
335
    the ``copies`` property.
336
    """
337

338
    lock = None
1✔
339
    """Synchronizes :meth:`add`, :meth:`remove`, etc."""
1✔
340

341
    def __init__(self, *args, **kwargs):
1✔
342
        super().__init__(*args, **kwargs)
1✔
343
        self.lock = Lock()
1✔
344

345
    def add(self, prop, val):
1✔
346
        """Adds a value to a multiply-valued property.
347

348
        Args:
349
          prop (str)
350
          val
351

352
        Returns:
353
          True if val was added, ie it wasn't already in prop, False otherwise
354
        """
355
        with self.lock:
1✔
356
            added = util.add(getattr(self, prop), val)
1✔
357

358
        if prop == 'copies' and added:
1✔
359
            if fn := getattr(self, 'GET_ORIGINAL_FN'):
1✔
360
                memcache.pickle_memcache.set(memcache.memoize_key(fn, val.uri),
1✔
361
                                             self.key)
362

363
        return added
1✔
364

365
    def remove(self, prop, val):
1✔
366
        """Removes a value from a multiply-valued property.
367

368
        Args:
369
          prop (str)
370
          val
371
        """
372
        with self.lock:
1✔
373
            existing = getattr(self, prop)
1✔
374
            if val in existing:
1✔
375
                existing.remove(val)
1✔
376

377
        if prop == 'copies':
1✔
378
            self.clear_get_original_cache(val.uri)
1✔
379

380
    def remove_copies_on(self, proto):
1✔
381
        """Removes all copies on a given protocol.
382

383
        ``proto.HAS_COPIES`` must be True.
384

385
        Args:
386
          proto (protocol.Protocol subclass)
387
        """
388
        assert proto.HAS_COPIES
1✔
389

390
        for copy in self.copies:
1✔
391
            if copy.protocol in (proto.ABBREV, proto.LABEL):
1✔
392
                self.remove('copies', copy)
1✔
393

394
    @classmethod
1✔
395
    def clear_get_original_cache(cls, uri):
1✔
396
        if fn := getattr(cls, 'GET_ORIGINAL_FN'):
1✔
397
            memcache.pickle_memcache.delete(memcache.memoize_key(fn, uri))
1✔
398

399

400
# WARNING: AddRemoveMixin *must* be before StringIdModel here so that its __init__
401
# gets called! Due to an (arguable) ndb.Model bug:
402
# https://github.com/googleapis/python-ndb/issues/1025
403
class User(AddRemoveMixin, StringIdModel, metaclass=ProtocolUserMeta):
1✔
404
    """Abstract base class for a Bridgy Fed user."""
405
    GET_ORIGINAL_FN = get_original_user_key
1✔
406
    'used by AddRemoveMixin'
1✔
407

408
    obj_key = ndb.KeyProperty(kind='Object')  # user profile
1✔
409
    ''
1✔
410
    use_instead = ndb.KeyProperty()
1✔
411
    ''
1✔
412

413
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
414
    """Proxy copies of this user elsewhere, eg DIDs for ATProto records, bech32
1✔
415
    npub Nostr ids, etc. Similar to ``rel-me`` links in microformats2,
416
    ``alsoKnownAs`` in DID docs (and now AS2), etc.
417
    """
418

419
    keypairs = ndb.StructuredProperty(KeyPair, repeated=True)
1✔
420
    """Key pairs for this user, one per bridged protocol. Encrypted at rest."""
1✔
421

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

425
    enabled_protocols = ndb.StringProperty(repeated=True,
1✔
426
                                           choices=list(PROTOCOLS.keys()))
427
    """Protocols that this user has explicitly opted into.
1✔
428

429
    Protocols that don't require explicit opt in are omitted here.
430
    """
431

432
    has_object_feed_followers_on = ndb.StringProperty(repeated=True,
1✔
433
                                                      choices=list(PROTOCOLS.keys()))
434
    """Protocol labels of protocols that use :attr:`~Protocol.USES_OBJECT_FEED` and have ever had a follower of this user."""
1✔
435

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

439
    send_notifs = ndb.StringProperty(default='all', choices=('all', 'none'))
1✔
440
    """Which notifications we should send this user."""
1✔
441

442
    blocks = ndb.KeyProperty(kind='Object', repeated=True)
1✔
443
    ''
1✔
444

445
    verified_domain = ndb.StringProperty()
1✔
446
    """Domain that we've verified this user owns, eg web site top-level NIP-05, etc."""
1✔
447

448
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
449
    ''
1✔
450
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
451
    ''
1✔
452

453
    # `existing` attr is set by get_or_create
454

455
    # OLD. some stored entities still have these; do not reuse.
456
    # direct = ndb.BooleanProperty(default=False)
457
    # actor_as2 = JsonProperty()
458
    # protocol-specific state
459
    # atproto_notifs_indexed_at = ndb.TextProperty()
460
    # atproto_feed_indexed_at = ndb.TextProperty()
461
    # mod = ndb.StringProperty()  # https://github.com/snarfed/bridgy-fed/issues/794
462
    # public_exponent = ndb.StringProperty()
463
    # private_exponent = ndb.StringProperty()
464
    # nostr_key_bytes = EncryptedProperty()
465

466
    def __init__(self, **kwargs):
1✔
467
        """Constructor.
468

469
        Sets :attr:`obj` explicitly because however
470
        :class:`google.cloud.ndb.model.Model` sets it doesn't work with
471
        ``@property`` and ``@obj.setter`` below.
472
        """
473
        obj = kwargs.pop('obj', None)
1✔
474
        super().__init__(**kwargs)
1✔
475

476
        if obj:
1✔
477
            self.obj = obj
1✔
478

479
    @classmethod
1✔
480
    def new(cls, **kwargs):
1✔
481
        """Try to prevent instantiation. Use subclasses instead."""
482
        raise NotImplementedError()
×
483

484
    def _post_put_hook(self, future):
1✔
485
        logger.debug(f'Wrote {self.key}')
1✔
486

487
    @classmethod
1✔
488
    def get_by_id(cls, id, allow_opt_out=False, **kwargs):
1✔
489
        """Override to follow ``use_instead`` property and ``status``.
490

491
        Returns None if the user is opted out.
492
        """
493
        user = cls._get_by_id(id, **kwargs)
1✔
494
        if user and user.use_instead:
1✔
495
            logger.debug(f'{user.key} use_instead => {user.use_instead}')
1✔
496
            user = user.use_instead.get()
1✔
497

498
        if not user:
1✔
499
            return None
1✔
500

501
        if user.status and not allow_opt_out:
1✔
502
            logger.info(f'{user.key} is {user.status}')
1✔
503
            return None
1✔
504

505
        return user
1✔
506

507
    @classmethod
1✔
508
    def get_or_create(cls, id, propagate=False, allow_opt_out=False,
1✔
509
                      reload=False, raise_=False, **kwargs):
510
        """Loads and returns a :class:`User`. Creates it if necessary.
511

512
        If ``allow_opt_out`` is False and ``id`` is the bridged id for a user in
513
        another protocol, returns that user instead. Note that they'll be a
514
        different type than ``cls``!
515

516
        Not transactional because transactions don't read or write memcache. :/
517
        Fortunately we don't really depend on atomicity for much, last writer wins
518
        is usually fine.
519

520
        Args:
521
          propagate (bool): whether to create copies of this user in push-based
522
            protocols, eg ATProto and Nostr.
523
          allow_opt_out (bool): whether to allow and create the user if they're
524
            currently opted out
525
          reload (bool): whether to reload profile always, vs only if necessary
526
          raise_ (bool): passed through to :meth:`User.reload_profile`. If False, and
527
            :meth:`User.reload_profile` returns None when fetching the user's profile,
528
            this method raises :class:`RuntimeError`
529
          kwargs: passed through to ``cls`` constructor
530

531
        Returns:
532
          User: existing or new user, or None if the user is opted out
533
        """
534
        assert cls != User
1✔
535

536
        if cls.owns_id(id) is False:
1✔
537
            logger.info(f"{cls.LABEL} doesn't own id {id}")
1✔
538
            return None
1✔
539

540
        # TODO?
541
        # id = ids.normalize_user_id(id=id, proto=cls)
542

543
        user = cls.get_by_id(id, allow_opt_out=True)
1✔
544
        if user:  # existing
1✔
545
            if reload:
1✔
546
                user.reload_profile(gateway=True, raise_=raise_)
1✔
547

548
            if user.status and not allow_opt_out:
1✔
549
                return None
1✔
550
            user.existing = True
1✔
551

552
            # TODO: propagate more fields?
553
            changed = False
1✔
554
            for field in ['obj', 'obj_key', 'manual_opt_out']:
1✔
555
                old_val = getattr(user, field, None)
1✔
556
                new_val = kwargs.get(field)
1✔
557
                if old_val is None and new_val is not None:
1✔
558
                    setattr(user, field, new_val)
1✔
559
                    changed = True
1✔
560

561
            if enabled_protocols := kwargs.get('enabled_protocols'):
1✔
562
                user.enabled_protocols = (set(user.enabled_protocols)
1✔
563
                                          | set(enabled_protocols))
564
                changed = True
1✔
565

566
            if not propagate:
1✔
567
                if changed:
1✔
568
                    try:
1✔
569
                        user.put()
1✔
570
                    except AssertionError as e:
×
571
                        logger.debug(e)
×
572
                        error(f'Bad {cls.__name__} id {id} : {e}')
×
573
                return user
1✔
574

575
        else:  # new, not existing
576
            if not allow_opt_out and (orig_key := get_original_user_key(id)):
1✔
577
                orig = orig_key.get()
1✔
578
                if orig.status:
1✔
579
                    return None
×
580
                orig.existing = False
1✔
581
                return orig
1✔
582

583
            user = cls(id=id, **kwargs)
1✔
584
            user.existing = False
1✔
585
            try:
1✔
586
                user.reload_profile(gateway=True, raise_=raise_)
1✔
587
            except AssertionError as e:
1✔
588
                logger.debug(e)
1✔
589
                error(f'Bad {cls.__name__} id {id} : {e}')
1✔
590

591
            if user.status and not allow_opt_out:
1✔
592
                return None
1✔
593

594
        if propagate and user.status in (None, 'private'):
1✔
595
            for label in user.enabled_protocols + list(user.DEFAULT_ENABLED_PROTOCOLS):
1✔
596
                proto = PROTOCOLS[label]
1✔
597
                if proto == cls:
1✔
598
                    continue
×
599
                elif proto.HAS_COPIES:
1✔
600
                    if not user.get_copy(proto) and user.is_enabled(proto):
1✔
601
                        try:
1✔
602
                            proto.create_for(user)
1✔
603
                        except (ValueError, AssertionError):
1✔
604
                            logger.info(f'failed creating {proto.LABEL} copy',
1✔
605
                                        exc_info=True)
606
                            user.remove('enabled_protocols', proto.LABEL)
1✔
607
                    else:
608
                        logger.debug(f'{proto.LABEL} not enabled or user copy already exists, skipping propagate')
1✔
609

610
        try:
1✔
611
            user.put()
1✔
612
        except AssertionError as e:
×
613
            error(f'Bad {cls.__name__} id {id} : {e}')
×
614

615
        logger.debug(('Updated ' if user.existing else 'Created new ') + str(user))
1✔
616
        return user
1✔
617

618
    @property
1✔
619
    def obj(self):
1✔
620
        """Convenience accessor that loads :attr:`obj_key` from the datastore."""
621
        if self.obj_key:
1✔
622
            if not hasattr(self, '_obj'):
1✔
623
                self._obj = self.obj_key.get()
1✔
624
            return self._obj
1✔
625

626
    @obj.setter
1✔
627
    def obj(self, obj):
1✔
628
        if obj:
1✔
629
            assert isinstance(obj, Object)
1✔
630
            assert obj.key
1✔
631
            self._obj = obj
1✔
632
            self.obj_key = obj.key
1✔
633
        else:
634
            self._obj = self.obj_key = None
1✔
635

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

639
        Args:
640
          proto (Protocol): optional
641
        """
642
        now = util.now().isoformat()
1✔
643
        proto_label = proto.LABEL if proto else 'all'
1✔
644
        delete_id = f'{self.profile_id()}#bridgy-fed-delete-user-{proto_label}-{now}'
1✔
645
        delete = Object(id=delete_id, source_protocol=self.LABEL, our_as1={
1✔
646
            'id': delete_id,
647
            'objectType': 'activity',
648
            'verb': 'delete',
649
            'actor': self.key.id(),
650
            'object': self.key.id(),
651
        })
652
        self.deliver(delete, from_user=self, to_proto=proto)
1✔
653

654
    @classmethod
1✔
655
    def load_multi(cls, users):
1✔
656
        """Loads :attr:`obj` for multiple users in parallel.
657

658
        Args:
659
          users (sequence of User)
660
        """
661
        objs = ndb.get_multi(u.obj_key for u in users if u.obj_key)
1✔
662
        keys_to_objs = {o.key: o for o in objs if o}
1✔
663

664
        for u in users:
1✔
665
            u._obj = keys_to_objs.get(u.obj_key)
1✔
666

667
    @ndb.ComputedProperty
1✔
668
    def handle(self):
1✔
669
        """This user's unique, human-chosen handle, eg ``@me@snarfed.org``.
670

671
        To be implemented by subclasses.
672
        """
673
        raise NotImplementedError()
×
674

675
    @ndb.ComputedProperty
1✔
676
    def handle_as_domain(self):
1✔
677
        """This user's handle in domain-like format, via :func:`id.handle_as_domain`.
678

679
        Returns:
680
          str or None: if handle is None
681
        """
682
        if not hasattr(self, '_stored_handle_as_domain'):
1✔
683
            self._stored_handle_as_domain = stored_value(self, 'handle_as_domain')
1✔
684

685
        if self.verified_domain:
1✔
686
            return self.verified_domain
1✔
687

688
        return ids.handle_as_domain(self.handle)
1✔
689

690
    @ndb.ComputedProperty
1✔
691
    def handle_pay_level_domain(self):
1✔
692
        """This user's handle's pay-level domain.
693

694
        Pay-level domains are domains at the registrar level, usually (but not
695
        always) one level below a TLD. For example, bar.com is the pay-level domain
696
        for both foo.bar.com and baz.bar.com, and bbc.co.uk is the pay-level domain
697
        for www.bbc.co.uk.
698

699
        WARNING: this is only set for the first accounts created before hitting the
700
        :attr:`Protocol.HANDLES_PER_PAY_LEVEL_DOMAIN` limit for their pay-level
701
        domain. Accounts after that limit will have 'too-many' as their value.
702
        """
703
        last_pld = stored_value(self, 'handle_pay_level_domain')
1✔
704

705
        if self.handle_as_domain == self._stored_handle_as_domain:
1✔
706
            # handle is unchanged. use our existing stored value for
707
            # handle_pay_level_domain to avoid re-querying the datastore for other
708
            # users on the same domain
709
            return last_pld
1✔
710

711
        if self.handle_as_domain:
1✔
712
            if extract := domains.tldextract(self.handle_as_domain):
1✔
713
                if cur_pld := extract.top_domain_under_public_suffix:
1✔
714
                    if cur_pld == last_pld or not self.HANDLES_PER_PAY_LEVEL_DOMAIN:
1✔
715
                        return cur_pld
1✔
716
                    num_others = self.query(User.handle_pay_level_domain == cur_pld,
1✔
717
                                            User.status == None).count()
718
                    if num_others < self.HANDLES_PER_PAY_LEVEL_DOMAIN:
1✔
719
                        return cur_pld
1✔
720
                    else:
721
                        return OVER_LIMIT
1✔
722

723
    @ndb.ComputedProperty
1✔
724
    def status(self):
1✔
725
        """Whether this user is blocked or opted out.
726

727
        Optional. See :attr:`USER_STATUS_DESCRIPTIONS` for possible values.
728
        """
729
        # TODO
730
        # if not hasattr(self, '_stored_status'):
731
        #     self._stored_status = stored_value(self, 'status')
732

733
        if self.manual_opt_out:
1✔
734
            return 'opt-out'
1✔
735
        elif self.manual_opt_out is False:
1✔
736
            return None
1✔
737

738
        # TODO: require profile for more protocols? all?
739
        if not self.obj or not self.obj.as1:
1✔
740
            return None
1✔
741

742
        if self.obj.as1.get('bridgeable') is False:  # FEP-0036
1✔
743
            return 'opt-out'
1✔
744

745
        if self.REQUIRES_AVATAR and not self.obj.as1.get('image'):
1✔
746
            return 'requires-avatar'
1✔
747

748
        name = self.obj.as1.get('displayName')
1✔
749
        if self.REQUIRES_NAME and (not name or name in (self.handle, self.key.id())):
1✔
750
            return 'requires-name'
1✔
751

752
        if self.REQUIRES_OLD_ACCOUNT:
1✔
753
            if published := self.obj.as1.get('published'):
1✔
754
                if util.now() - util.parse_iso8601(published) < OLD_ACCOUNT_AGE:
1✔
755
                    return 'requires-old-account'
1✔
756

757
        # https://swicg.github.io/miscellany/#movedTo
758
        # https://docs.joinmastodon.org/spec/activitypub/#as
759
        if self.obj.as1.get('movedTo'):
1✔
760
            return 'moved'
1✔
761

762
        summary = html_to_text(self.obj.as1.get('summary', ''), ignore_links=True)
1✔
763
        name = html_to_text(self.obj.as1.get('displayName', ''), ignore_links=True)
1✔
764

765
        # #nobridge overrides enabled_protocols
766
        if '#nobridge' in summary or '#nobridge' in name:
1✔
767
            return 'nobridge'
1✔
768

769
        if self.HANDLES_PER_PAY_LEVEL_DOMAIN:
1✔
770
            # TODO
771
            # if self._stored_status:
772
            #     self._values.pop('handle_pay_level_domain', None)
773
            if self.handle_pay_level_domain == OVER_LIMIT:
1✔
774
                return 'over-handle-domain-limit'
1✔
775

776
        # user has explicitly opted in. should go after spam filter (REQUIRES_*)
777
        # checks, but before is_public and #nobot
778
        #
779
        # !!! WARNING: keep in sync with User.enable_protocol!
780
        if self.enabled_protocols:
1✔
781
            return None
1✔
782

783
        if not as1.is_public(self.obj.as1, unlisted=False):
1✔
784
            return 'private'
1✔
785

786
        # enabled_protocols overrides #nobot
787
        if '#nobot' in summary or '#nobot' in name:
1✔
788
            return 'nobot'
1✔
789

790
    def status_description(self):
1✔
791
        """Returns a human-readable description of this user's status.
792

793
        ...or None if this user's status is None, or a description isn't available.
794

795
        Returns:
796
          str
797
        """
798
        if desc := USER_STATUS_DESCRIPTIONS.get(self.status):
1✔
799
            return desc.format(user=self)
1✔
800

801
    def is_enabled(self, to_proto, explicit=False):
1✔
802
        """Returns True if this user is bridged to a given protocol.
803

804
        Reasons this might return False:
805
        * We haven't turned on bridging these two protocols yet.
806
        * The user is opted out or blocked.
807
        * The user is on a domain that's opted out or blocked.
808
        * The from protocol requires opt in, and the user hasn't opted in.
809
        * ``explicit`` is True, and this protocol supports ``to_proto`` by, but the user hasn't explicitly opted into it.
810

811
        Args:
812
          to_proto (Protocol subclass)
813
          explicit (bool)
814

815
        Returns:
816
          bool:
817
        """
818
        from protocol import Protocol
1✔
819
        assert isinstance(to_proto, Protocol) or issubclass(to_proto, Protocol)
1✔
820

821
        if self.__class__ == to_proto:
1✔
822
            return True
1✔
823

824
        from_label = self.LABEL
1✔
825
        to_label = to_proto.LABEL
1✔
826

827
        if bot_protocol := Protocol.for_bridgy_subdomain(self.key.id()):
1✔
828
            return to_proto != bot_protocol
1✔
829

830
        elif self.manual_opt_out:
1✔
831
            return False
1✔
832

833
        elif to_label in self.enabled_protocols:
1✔
834
            return True
1✔
835

836
        elif self.status:
1✔
837
            return False
1✔
838

839
        elif to_label in self.DEFAULT_ENABLED_PROTOCOLS and not explicit:
1✔
840
            return True
1✔
841

842
        return False
1✔
843

844
    def enable_protocol(self, to_proto):
1✔
845
        """Adds ``to_proto`` to :attr:`enabled_protocols`.
846

847
        Also sends a welcome DM to the user (via a send task) if their protocol
848
        supports DMs.
849

850
        Args:
851
          to_proto (:class:`protocol.Protocol` subclass)
852
        """
853
        import dms
1✔
854

855
        # explicit opt-in overrides some status
856
        # !!! WARNING: keep in sync with User.status!
857
        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✔
858
        if self.status and self.status not in ('nobot', 'private'):
1✔
859
            if desc := self.status_description():
1✔
860
                dms.maybe_send(from_=to_proto, to_user=self, type=self.status,
1✔
861
                               text=ineligible.format(desc=desc))
862
            common.error(f'Nope, user {self.key.id()} is {self.status}', status=299)
1✔
863

864
        # check that our handle is supported in this protocol
865
        err = handle = None
1✔
866
        try:
1✔
867
            handle = self.handle_as(to_proto)
1✔
868
        except ValueError as e:
1✔
869
            err = e
1✔
870

871
        if not handle:
1✔
872
            err_text = str(err) if err else 'handle is unset'
1✔
873
            dms.maybe_send(from_=to_proto, to_user=self,
1✔
874
                           type=f'unsupported-handle-{to_proto.ABBREV}',
875
                           text=ineligible.format(desc=err_text))
876
            common.error(err_text, status=299)
1✔
877

878
        # add to enabled_protocols in memory so that create_for (below) etc see it,
879
        # including its effects on status, but don't store to datastore until after
880
        # create_for in case it fails
881
        self.add('enabled_protocols', to_proto.LABEL)
1✔
882

883
        if to_proto.LABEL in ids.COPIES_PROTOCOLS:
1✔
884
            # do this even if there's an existing copy since we might need to
885
            # reactivate it, which create_for should do
886
            to_proto.create_for(self)
1✔
887

888
        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.html_link(proto=to_proto, name=False)}. <a href="https://fed.brid.gy/docs">See the docs</a> and <a href="https://{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✔
889
        self.put()
1✔
890

891
        common.create_task(queue='user-enabled', user=self.key.urlsafe(),
1✔
892
                           protocol=to_proto.LABEL)
893
        logger.info(f'Enabled {to_proto.LABEL} for {self.key.id()}')
1✔
894

895
    def disable_protocol(self, to_proto):
1✔
896
        """Removes ``to_proto` from :attr:`enabled_protocols``.
897

898
        Args:
899
          to_proto (:class:`protocol.Protocol` subclass)
900
        """
901
        self.remove('enabled_protocols', to_proto.LABEL)
1✔
902
        self.put()
1✔
903
        msg = f'Disabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
904
        logger.info(msg)
1✔
905

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

909
        Args:
910
          to_proto (str or Protocol)
911
          short (bool): whether to return the full handle or a shortened form.
912
            Default False. Currently only affects ActivityPub; returns just
913
            ``@[user]`` instead of ``@[user]@[domain]``
914

915
        Returns:
916
          str:
917
        """
918
        if isinstance(to_proto, str):
1✔
919
            to_proto = PROTOCOLS[to_proto]
1✔
920

921
        return ids.translate_handle(from_=self, to=to_proto, short=short)
1✔
922

923
    def id_as(self, to_proto):
1✔
924
        """Returns this user's id in a different protocol.
925

926
        Args:
927
          to_proto (str or Protocol)
928

929
        Returns:
930
          str
931
        """
932
        if isinstance(to_proto, str):
1✔
933
            to_proto = PROTOCOLS[to_proto]
1✔
934

935
        return ids.translate_user_id(id=self.key.id(), from_=self.__class__,
1✔
936
                                     to=to_proto)
937

938
    def all_ids(self, default_protocols=False, except_=None):
1✔
939
        """Returns this user's ids in its native protocol and all bridged protocols.
940

941
        Args:
942
          default_protocols (bool): if False, only consider protocols in
943
            :attr:`enabled_protocols`. If True, also consider protocols that
944
            don't require explicit opt in, eg :attr:`DEFAULT_ENABLED_PROTOCOLS`.
945
          except_ (str or Protocol subclass): protocol to omit from the result,
946
            eg the protocol that's about to consume it
947

948
        Returns:
949
          list of str
950
        """
951
        found = []
1✔
952
        if self.__class__ != except_:
1✔
953
            found.append(self.id_uri())
1✔
954

955
        protos = (set(PROTOCOLS.values()) if default_protocols
1✔
956
                  else (PROTOCOLS[label] for label in self.enabled_protocols))
957

958
        for proto in protos:
1✔
959
            if (proto and proto != except_ and proto != self.__class__
1✔
960
                    and self.is_enabled(proto)
961
                    and (uri := self.id_as(proto))):
962
                found.append(uri)
1✔
963

964
        return found
1✔
965

966
    def handle_or_id(self):
1✔
967
        """Returns handle if we know it, otherwise id."""
968
        return self.handle or self.key.id()
1✔
969

970
    def _keypair(self, protocol):
1✔
971
        """Returns this user's :class:`KeyPair` for ``protocol``, or None."""
972
        for kp in self.keypairs:
1✔
973
            if kp.protocol == protocol:
1✔
974
                return kp
1✔
975

976
    @memcache.memoize(key=lambda self: self.key.id())
1✔
977
    def public_pem(self):
1✔
978
        """Returns the user's PEM-encoded ActivityPub public RSA key.
979

980
        Returns:
981
          bytes:
982
        """
983
        self._maybe_generate_ap_key()
1✔
984
        return self._keypair('activitypub').public_key_bytes
1✔
985

986
    @memcache.memoize(key=lambda self: self.key.id())
1✔
987
    def private_pem(self):
1✔
988
        """Returns the user's PEM-encoded ActivityPub private RSA key.
989

990
        Returns:
991
          bytes:
992
        """
993
        self._maybe_generate_ap_key()
1✔
994
        return self._keypair('activitypub').private_key_bytes
1✔
995

996
    def _maybe_generate_ap_key(self):
1✔
997
        """Generates this user's ActivityPub private key if necessary."""
998
        if self._keypair('activitypub'):
1✔
999
            return
1✔
1000

1001
        logger.info(f'generating AP keypair for {self.key.id()}')
1✔
1002
        key = RSA.generate(KEY_BITS, randfunc=random.randbytes if DEBUG else None)
1✔
1003
        self.keypairs.append(KeyPair(
1✔
1004
            protocol='activitypub', algorithm='rsa',
1005
            public_key_bytes=key.publickey().exportKey(format='PEM'),
1006
            private_key_bytes=key.exportKey(format='PEM'),
1007
        ))
1008
        self.put()
1✔
1009

1010
    def nsec(self):
1✔
1011
        """Returns the user's bech32-encoded Nostr private secp256k1 key.
1012

1013
        Returns:
1014
          str:
1015
        """
1016
        self._maybe_generate_nostr_key()
1✔
1017
        privkey = secp256k1.PrivateKey(
1✔
1018
            self._keypair('nostr').private_key_bytes, raw=True)
1019
        return granary.nostr.bech32_encode('nsec', privkey.serialize())
1✔
1020

1021
    def hex_pubkey(self):
1✔
1022
        """Returns the user's hex-encoded Nostr public secp256k1 key.
1023

1024
        Returns:
1025
          str:
1026
        """
1027
        self._maybe_generate_nostr_key()
1✔
1028
        return self._keypair('nostr').public_key_bytes.hex()
1✔
1029

1030
    def npub(self):
1✔
1031
        """Returns the user's bech32-encoded ActivityPub public secp256k1 key.
1032

1033
        Returns:
1034
          str:
1035
        """
1036
        return granary.nostr.bech32_encode('npub', self.hex_pubkey())
1✔
1037

1038
    def _maybe_generate_nostr_key(self):
1✔
1039
        """Generates this user's Nostr private key if necessary."""
1040
        if self._keypair('nostr'):
1✔
1041
            return
1✔
1042

1043
        logger.info(f'generating Nostr keypair for {self.key.id()}')
1✔
1044
        priv = secp256k1.PrivateKey()
1✔
1045
        pub_hex = granary.nostr.pubkey_from_privkey(priv.private_key.hex())
1✔
1046
        self.keypairs.append(KeyPair(
1✔
1047
            protocol='nostr', algorithm='secp256k1',
1048
            public_key_bytes=bytes.fromhex(pub_hex),
1049
            private_key_bytes=priv.private_key,
1050
        ))
1051
        self.put()
1✔
1052

1053
    def farcaster_key(self):
1✔
1054
        """Returns the user's Farcaster signing key.
1055

1056
        TODO: real per-user signer keys, registered on-chain via the
1057
        KeyRegistry. Messages signed with these stub keys will be rejected by
1058
        the hub.
1059

1060
        Returns:
1061
          cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey:
1062
        """
1063
        self._maybe_generate_farcaster_key()
1✔
1064
        return Ed25519PrivateKey.from_private_bytes(
1✔
1065
            self._keypair('farcaster').private_key_bytes)
1066

1067
    def _maybe_generate_farcaster_key(self):
1✔
1068
        """Generates this user's Farcaster Ed25519 keypair if necessary."""
1069
        if self._keypair('farcaster'):
1✔
1070
            return
1✔
1071

1072
        logger.info(f'generating Farcaster keypair for {self.key.id()}')
1✔
1073
        priv = Ed25519PrivateKey.generate()
1✔
1074
        self.keypairs.append(KeyPair(
1✔
1075
            protocol='farcaster', algorithm='ed25519',
1076
            public_key_bytes=priv.public_key().public_bytes(
1077
                Encoding.Raw, PublicFormat.Raw),
1078
            private_key_bytes=priv.private_bytes(
1079
                Encoding.Raw, PrivateFormat.Raw, NoEncryption()),
1080
        ))
1081
        self.put()
1✔
1082

1083
    def name(self):
1✔
1084
        """Returns this user's human-readable name, eg ``Ryan Barrett``."""
1085
        if self.obj and self.obj.as1:
1✔
1086
            if name := self.obj.as1.get('displayName'):
1✔
1087
                return name
1✔
1088

1089
        return self.handle_or_id()
1✔
1090

1091
    def web_url(self):
1✔
1092
        """Returns this user's user-facing profile page URL.
1093

1094
        ...eg ``https://bsky.app/profile/snarfed.org`` or ``https://foo.com/``.
1095

1096
        To be implemented by subclasses.
1097

1098
        Returns:
1099
          str
1100
        """
1101
        raise NotImplementedError()
×
1102

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

1106
        Args:
1107
          url (str)
1108
          ignore_www (bool): if True, ignores ``www.`` subdomains
1109

1110
        Returns:
1111
          bool:
1112
        """
1113
        if not url:
1✔
1114
            return False
1✔
1115

1116
        url = url.strip().rstrip('/')
1✔
1117
        url = re.sub(r'^(https?://)www\.', r'\1', url)
1✔
1118
        parsed_url = urlparse(url)
1✔
1119
        if parsed_url.scheme not in ('http', 'https', ''):
1✔
1120
            return False
1✔
1121

1122
        this = self.web_url().rstrip('/')
1✔
1123
        this = re.sub(r'^(https?://)www\.', r'\1', this)
1✔
1124
        parsed_this = urlparse(this)
1✔
1125

1126
        return (url == this or url == parsed_this.netloc or
1✔
1127
                parsed_url[1:] == parsed_this[1:])  # ignore http vs https
1128

1129
    def id_uri(self):
1✔
1130
        """Returns the user id as a URI.
1131

1132
        Sometimes this is the user id itself, eg ActivityPub actor ids.
1133
        Sometimes it's a bit different, eg at://did:plc:... for ATProto user,
1134
        https://site.com for Web users.
1135

1136
        Returns:
1137
          str
1138
        """
1139
        return self.key.id()
1✔
1140

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

1144
        Examples:
1145

1146
        * Web: home page URL, eg ``https://me.com/``
1147
        * ActivityPub: actor URL, eg ``https://instance.com/users/me``
1148
        * ATProto: profile AT URI, eg ``at://did:plc:123/app.bsky.actor.profile/self``
1149

1150
        Defaults to this user's key id.
1151

1152
        Returns:
1153
          str or None:
1154
        """
1155
        return ids.profile_id(id=self.key.id(), proto=self)
1✔
1156

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

1160
        Args:
1161
          obj (Object)
1162

1163
        Returns:
1164
          bool:
1165
        """
1166
        self_ids = [self.key.id(), self.profile_id()]
1✔
1167
        if self.obj_key:
1✔
1168
            self_ids.append(self.obj_key.id())
1✔
1169

1170
        if obj.key and obj.key.id() in self_ids:
1✔
1171
            return True
1✔
1172
        elif obj.as1:
1✔
1173
            obj_as1 = (as1.get_object(obj.as1) if obj.as1.get('verb') in as1.CRUD_VERBS
1✔
1174
                       else obj.as1)
1175
            if obj_as1.get('id') in self_ids:
1✔
1176
                return True
1✔
1177

1178
    def reload_profile(self, raise_=False, **kwargs):
1✔
1179
        """Reloads this user's identity and profile from their native protocol.
1180

1181
        Populates the reloaded profile :class:`Object` in ``self.obj``.
1182

1183
        Args:
1184
          raise_ (bool): passed through to :meth:`Protocol.load`. If False, and
1185
            :meth:`Protocol.load` returns None when fetching the user's profile,
1186
            this method raises :class:`RuntimeError`
1187
          kwargs: passed through to :meth:`Protocol.load`
1188

1189
        Raises:
1190
          RuntimeError: if the user's profile can't be loaded
1191
        """
1192
        id = self.profile_id()
1✔
1193
        obj = self.load(id, remote=True, raise_=raise_, **kwargs)
1✔
1194
        if obj:
1✔
1195
            if obj.type:
1✔
1196
                assert obj.type in as1.ACTOR_TYPES, obj.type
1✔
1197
            self.obj = obj
1✔
1198
        elif raise_:
1✔
1199
            raise RuntimeError(f"Couldn't load {id} on {self.PHRASE}")
1✔
1200

1201
        # write the user so that we re-populate any computed properties
1202
        self.put()
1✔
1203

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

1207
        Args:
1208
          rest (str): additional path and/or query to add to the end
1209
          prefer_id (bool): whether to prefer to use the account's id in the path
1210
            instead of handle. Defaults to ``False``.
1211
        """
1212
        path = f'/{self.ABBREV}/{self.key.id() if prefer_id else self.handle_or_id()}'
1✔
1213

1214
        if rest:
1✔
1215
            if not (rest.startswith('?') or rest.startswith('/')):
1✔
1216
                path += '/'
1✔
1217
            path += rest
1✔
1218

1219
        return path
1✔
1220

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

1224
        ...or None if no such copy exists. If ``proto`` is this user, returns
1225
        this user's key id.
1226

1227
        Args:
1228
          proto (str or :class:`Protocol` subclass)
1229

1230
        Returns:
1231
          str:
1232
        """
1233
        if not isinstance(proto, str):
1✔
1234
            proto = proto.LABEL
1✔
1235

1236
        # don't use isinstance because the testutil Fake protocol has subclasses
1237
        if self.LABEL == proto:
1✔
1238
            return self.key.id()
1✔
1239

1240
        for copy in self.copies:
1✔
1241
            if copy.protocol == proto:
1✔
1242
                return copy.uri
1✔
1243

1244
    def html_link(self, name=True, handle=True, pictures=False, logo=None,
1✔
1245
                  proto=None, proto_fallback=False):
1246
        """Returns a pretty HTML link to the user's profile.
1247

1248
        Can optionally include display name, handle, profile
1249
        picture, and/or link to a different protocol that they've enabled.
1250

1251
        TODO: unify with :meth:`Object.actor_link`?
1252

1253
        Args:
1254
          name (bool): include display name
1255
          handle (bool): True to include handle, False to exclude it, ``'short'``
1256
            to include a shortened version, if available
1257
          pictures (bool): include profile picture and protocol logo
1258
          logo (str): optional path to platform logo to show instead of the
1259
            protocol's default
1260
          proto (protocol.Protocol): link to this protocol instead of the user's
1261
            native protocol
1262
          proto_fallback (bool): if True, and ``proto`` is provided and has no
1263
            no canonical profile URL for bridged users, uses the user's profile
1264
            URL in their native protocol
1265
        """
1266
        img = name_str = full_handle = handle_str = dot = logo_html = a_open = a_close = ''
1✔
1267

1268
        if proto:
1✔
1269
            assert self.is_enabled(proto), f"{proto.LABEL} isn't enabled"
1✔
1270
            url = proto.bridged_web_url_for(self, fallback=proto_fallback)
1✔
1271
        else:
1272
            proto = self.__class__
1✔
1273
            url = self.web_url()
1✔
1274

1275
        if pictures:
1✔
1276
            if logo:
1✔
1277
                logo_html = f'<img class="logo" title="{proto.__name__}" src="{logo}" /> '
1✔
1278
            else:
1279
                logo_html = f'<span class="logo" title="{proto.__name__}">{proto.LOGO_HTML or proto.LOGO_EMOJI}</span> '
1✔
1280
            if pic := self.profile_picture():
1✔
1281
                img = f'<img src="{pic}" class="profile"> '
1✔
1282

1283
        if handle:
1✔
1284
            full_handle = self.handle_as(proto) or ''
1✔
1285
            handle_str = self.handle_as(proto, short=(handle == 'short')) or ''
1✔
1286

1287
        if name and self.name() != full_handle:
1✔
1288
            name_str = self.name() or ''
1✔
1289
            handle_str = ellipsize(handle_str, chars=40)
1✔
1290

1291
        if handle_str and name_str:
1✔
1292
            dot = ' &middot; '
1✔
1293

1294
        if url:
1✔
1295
            a_open = f'<a class="h-card u-author mention" rel="me" href="{url}" title="{name_str}{dot}{full_handle}">'
1✔
1296
            a_close = '</a>'
1✔
1297

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

1301
    def profile_picture(self):
1✔
1302
        """Returns the user's profile picture image URL, if available, or None."""
1303
        if self.obj and self.obj.as1:
1✔
1304
            return util.get_url(self.obj.as1, 'image')
1✔
1305

1306
    # can't use functools.lru_cache here because we want the cache key to be
1307
    # just the user id, not the whole entity
1308
    @cachetools.cached(
1✔
1309
        cachetools.TTLCache(50000, FOLLOWERS_CACHE_EXPIRATION.total_seconds()),
1310
        key=lambda user: user.key.id(), lock=Lock())
1311
    @memcache.memoize(key=lambda self: self.key.id(),
1✔
1312
                      expire=FOLLOWERS_CACHE_EXPIRATION)
1313
    def count_followers(self):
1✔
1314
        """Counts this user's followers and followings.
1315

1316
        Returns:
1317
          (int, int) tuple: (number of followers, number following)
1318
        """
1319
        if self.key.id() in PROTOCOL_DOMAINS:
1✔
1320
            # we don't store Followers for protocol bot users any more, so
1321
            # follower counts are inaccurate, so don't return them
1322
            return (0, 0)
1✔
1323

1324
        num_followers = Follower.query(Follower.to == self.key,
1✔
1325
                                       Follower.status == 'active')\
1326
                                .count_async()
1327
        num_following = Follower.query(Follower.from_ == self.key,
1✔
1328
                                       Follower.status == 'active')\
1329
                                .count_async()
1330
        return num_followers.get_result(), num_following.get_result()
1✔
1331

1332
    def is_blocking(self, user_or_id):
1✔
1333
        """Returns True if this user is is blocking ``user_or_id``, False otherwise.
1334

1335
        Looks at domain blocklists in :attr:`blocks`. Eventually we can add support
1336
        for blocking individual users in that too.
1337

1338
        Args:
1339
          user_or_id (User or str)
1340

1341
        Returns:
1342
          bool:
1343
        """
1344
        if not user_or_id or not (isinstance(user_or_id, User)
1✔
1345
                                  or util.is_url(user_or_id)
1346
                                  or DOMAIN_RE.fullmatch(user_or_id)):
1347
            return False
1✔
1348

1349
        blocklists = ndb.get_multi(key for key in self.blocks
1✔
1350
                                   if key.kind() == 'Object')
1351
        for list in blocklists:
1✔
1352
            if list.domain_blocklist_matches(user_or_id):
1✔
1353
                logger.info(f'{self.key.id()} is blocking {user_or_id}')
1✔
1354
                return True
1✔
1355

1356
    def add_domain_blocklist(self, url):
1✔
1357
        """Adds a domain blocklist to this user.
1358

1359
        Loads the CSV at the given URL adds it to :attr:`blocks` if it's
1360
        not already there.
1361

1362
        Args:
1363
          url (str): URL of CSV blocklist to add
1364

1365
        Returns:
1366
          Object: CSV blocklist, or None if it couldn't be loaded
1367
        """
1368
        from web import Web
1✔
1369

1370
        key = Object(id=maybe_truncate_key_id(url)).key
1✔
1371
        if key in self.blocks:
1✔
1372
            return key.get()
1✔
1373

1374
        if obj := Web.load(url, csv=True):
1✔
1375
            self.blocks.append(obj.key)
1✔
1376
            self.put()
1✔
1377
            return obj
1✔
1378

1379
    def remove_domain_blocklist(self, url):
1✔
1380
        """Removes a domain blocklist from this user.
1381

1382
        Args:
1383
          url (str): URL of CSV blocklist to remove
1384

1385
        Returns:
1386
          Object: CSV blocklist, or None if it couldn't be loaded
1387
        """
1388
        from web import Web
1✔
1389

1390
        key = Object(id=maybe_truncate_key_id(url)).key
1✔
1391
        if key in self.blocks:
1✔
1392
            self.blocks.remove(key)
1✔
1393
            self.put()
1✔
1394
            return key.get()
1✔
1395

1396
        if obj := Web.load(url, csv=True):
1✔
1397
            return obj
1✔
1398

1399

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

1406
    Key name is the id, generally a URI. We synthesize ids if necessary.
1407
    """
1408
    GET_ORIGINAL_FN = get_original_object_key
1✔
1409
    'used by AddRemoveMixin'
1✔
1410

1411
    users = ndb.KeyProperty(repeated=True)
1✔
1412
    'User(s) who created or otherwise own this object.'
1✔
1413

1414
    notify = ndb.KeyProperty(repeated=True)
1✔
1415
    """User who should see this in their user page, eg in reply to, reaction to,
1✔
1416
    share of, etc.
1417
    """
1418
    feed = ndb.KeyProperty(repeated=True)
1✔
1419
    'User who should see this in their feeds, eg followers of its creator'
1✔
1420

1421
    source_protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()))
1✔
1422
    """The protocol this object originally came from.
1✔
1423

1424
    TODO: nail down whether this is :attr:`ABBREV`` or :attr:`LABEL`
1425
    """
1426

1427
    # TODO: switch back to ndb.JsonProperty if/when they fix it for the web console
1428
    # https://github.com/googleapis/python-ndb/issues/874
1429
    as2 = JsonProperty()
1✔
1430
    'ActivityStreams 2, for ActivityPub'
1✔
1431
    bsky = JsonProperty()
1✔
1432
    'AT Protocol lexicon, for Bluesky'
1✔
1433
    csv = ndb.TextProperty()
1✔
1434
    'Other standalone CSV data, eg domain blocklist.'
1✔
1435
    farcaster = ndb.BlobProperty(repeated=True)
1✔
1436
    """List of binary serialized Farcaster :class:`Message` protobufs.
1✔
1437

1438
    Each blob is ``SerializeToString()`` and decodes via ``Message.FromString(blob)``.
1439
    Repeated to support actors as multiple ``USER_DATA_ADD`` messages.
1440
    """
1441
    mf2 = JsonProperty()
1✔
1442
    'HTML microformats2 item (*not* top level parse object with ``items`` field)'
1✔
1443
    nostr = JsonProperty()
1✔
1444
    'Nostr event'
1✔
1445
    our_as1 = JsonProperty()
1✔
1446
    'ActivityStreams 1, for activities that we generate or modify ourselves'
1✔
1447
    raw = JsonProperty()
1✔
1448
    'Other standalone data format, eg DID document'
1✔
1449

1450
    extra_as1 = JsonProperty()
1✔
1451
    "Additional individual fields to merge into this object's AS1 representation"
1✔
1452
    is_csv = ndb.BooleanProperty()
1✔
1453
    "Whether this object is a CSV. Needed because :attr:`csv` isn't indexed."
1✔
1454

1455
    # TODO: remove and actually delete Objects instead!
1456
    deleted = ndb.BooleanProperty()
1✔
1457
    ''
1✔
1458

1459
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
1460
    """Copies of this object elsewhere, eg at:// URIs for ATProto records and
1✔
1461
    nevent etc bech32-encoded Nostr ids, where this object is the original.
1462
    Similar to u-syndication links in microformats2 and
1463
    upstream/downstreamDuplicates in AS1.
1464
    """
1465

1466
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1467
    ''
1✔
1468
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1469
    ''
1✔
1470

1471
    new = None
1✔
1472
    """True if this object is new, ie this is the first time we've seen it,
1✔
1473
    False otherwise, None if we don't know.
1474
    """
1475
    changed = None
1✔
1476
    """True if this object's contents have changed from our existing copy in the
1✔
1477
    datastore, False otherwise, None if we don't know. :class:`Object` is
1478
    new/changed. See :meth:`activity_changed()` for more details.
1479
    """
1480

1481
    # DEPRECATED
1482
    # These were for full feeds with multiple items, not just this one, so they were
1483
    # stored as audit records only, not used in to_as1. for Atom/RSS
1484
    # based Objects, our_as1 was populated with an feed_index top-level
1485
    # integer field that indexed into one of these.
1486
    #
1487
    # atom = ndb.TextProperty() # Atom XML
1488
    # rss = ndb.TextProperty()  # RSS XML
1489

1490
    # DEPRECATED; these were for delivery tracking, but they were too expensive,
1491
    # so we stopped: https://github.com/snarfed/bridgy-fed/issues/1501
1492
    #
1493
    # STATUSES = ('new', 'in progress', 'complete', 'failed', 'ignored')
1494
    # status = ndb.StringProperty(choices=STATUSES)
1495
    # delivered = ndb.StructuredProperty(Target, repeated=True)
1496
    # undelivered = ndb.StructuredProperty(Target, repeated=True)
1497
    # failed = ndb.StructuredProperty(Target, repeated=True)
1498

1499
    # DEPRECATED but still used read only to maintain backward compatibility
1500
    # with old Objects in the datastore that we haven't bothered migrating.
1501
    #
1502
    # domains = ndb.StringProperty(repeated=True)
1503

1504
    # DEPRECATED; replaced by :attr:`users`, :attr:`notify`, :attr:`feed`
1505
    #
1506
    # labels = ndb.StringProperty(repeated=True,
1507
    #                             choices=('activity', 'feed', 'notification', 'user'))
1508

1509
    @property
1✔
1510
    def as1(self):
1✔
1511
        from protocol import Protocol
1✔
1512

1513
        def use_urls_as_ids(obj):
1✔
1514
            """If id field is missing or not a URL, use the url field."""
1515
            id = obj.get('id')
1✔
1516
            if not id or not (util.is_web(id) or DOMAIN_RE.fullmatch(id)):
1✔
1517
                if url := util.get_url(obj):
1✔
1518
                    obj['id'] = url
1✔
1519

1520
            for field in 'author', 'actor', 'object':
1✔
1521
                if inner := as1.get_object(obj, field):
1✔
1522
                    use_urls_as_ids(inner)
1✔
1523

1524
        if self.our_as1:
1✔
1525
            obj = self.our_as1
1✔
1526
            if self.source_protocol == 'web':
1✔
1527
                use_urls_as_ids(obj)
1✔
1528

1529
        elif self.as2:
1✔
1530
            obj = as2.to_as1(unwrap(self.as2))
1✔
1531

1532
        elif self.bsky:
1✔
1533
            owner, _, _ = parse_at_uri(self.key.id())
1✔
1534
            ATProto = PROTOCOLS['atproto']
1✔
1535
            handle = ATProto(id=owner).handle
1✔
1536
            try:
1✔
1537
                obj = bluesky.to_as1(self.bsky, repo_did=owner, repo_handle=handle,
1✔
1538
                                     uri=self.key.id(), pds=ATProto.pds_for(self))
1539
            except (ValueError, RequestException):
1✔
1540
                logger.info(f"Couldn't convert to AS1", exc_info=True)
1✔
1541
                return None
1✔
1542

1543
        elif self.mf2:
1✔
1544
            obj = microformats2.json_to_object(self.mf2,
1✔
1545
                                               rel_urls=self.mf2.get('rel-urls'))
1546
            use_urls_as_ids(obj)
1✔
1547

1548
            # use fetched final URL as id, not u-url
1549
            # https://github.com/snarfed/bridgy-fed/issues/829
1550
            if url := self.mf2.get('url'):
1✔
1551
                obj['id'] = (self.key.id() if self.key and '#' in self.key.id()
1✔
1552
                             else url)
1553

1554
            if self.key and (proto := Protocol.for_bridgy_subdomain(self.key.id())):
1✔
1555
                if util.domain_or_parent_in(as1.get_owner(obj), BLOG_REDIRECT_DOMAINS):
1✔
1556
                    logger.debug(f'overriding actor/author with {proto.bot_user_id()}')
1✔
1557
                    obj['actor'] = obj['author'] = proto.bot_user_id()
1✔
1558
                if util.domain_or_parent_in(obj.get('id'), BLOG_REDIRECT_DOMAINS):
1✔
1559
                    logger.debug(f'overriding id/url with {self.key.id()}')
1✔
1560
                    obj['id'] = obj['url'] = self.key.id()
1✔
1561

1562
        elif self.nostr:
1✔
1563
            obj = granary.nostr.to_as1(self.nostr)
1✔
1564

1565
        elif self.farcaster:
1✔
1566
            msgs = [Message.FromString(b) for b in self.farcaster]
1✔
1567
            # a single USER_DATA_ADD message is still profile data, not a cast
1568
            # or other object, so it needs the MessagesResponse wrapper too
1569
            if (len(msgs) > 1
1✔
1570
                or granary.farcaster.deserialize(msgs[0]).type
1571
                    == MESSAGE_TYPE_USER_DATA_ADD):
1572
                obj = granary.farcaster.to_as1(MessagesResponse(messages=msgs))
1✔
1573
            else:
1574
                obj = granary.farcaster.to_as1(msgs[0])
1✔
1575

1576
        else:
1577
            return None
1✔
1578

1579
        # populate id if necessary
1580
        if self.key:
1✔
1581
            obj.setdefault('id', self.key.id())
1✔
1582

1583
        if util.domain_or_parent_in(obj.get('id'), IMAGE_PROXY_DOMAINS):
1✔
1584
           as1.prefix_urls(obj, 'image', IMAGE_PROXY_URL_BASE)
1✔
1585

1586
        if self.extra_as1:
1✔
1587
            obj.update(self.extra_as1)
1✔
1588

1589
        return obj
1✔
1590

1591
    @ndb.ComputedProperty
1✔
1592
    def type(self):  # AS1 objectType, or verb if it's an activity
1✔
1593
        if self.as1:
1✔
1594
            return as1.object_type(self.as1)
1✔
1595

1596
    def _expire(self):
1✔
1597
        """Automatically delete most Objects after a while using a TTL policy.
1598

1599
        https://cloud.google.com/datastore/docs/ttl
1600

1601
        They recommend not indexing TTL properties:
1602
        https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
1603
        """
1604
        now = self.updated or util.now()
1✔
1605

1606
        if self.deleted:
1✔
1607
            return now + timedelta(days=1)
1✔
1608
        elif (self.key.id().startswith('internal:') or self.raw or self.is_csv
1✔
1609
              or self.type in DONT_EXPIRE_OBJECT_TYPES or self.copies):
1610
            return None
1✔
1611
        elif self.type in EXPIRE_LATE_OBJECT_TYPES:
1✔
1612
            return now + OBJECT_LATE_EXPIRE_AGE
1✔
1613

1614
        return now + OBJECT_EARLY_EXPIRE_AGE
1✔
1615

1616
    expire = ndb.ComputedProperty(_expire, indexed=False)
1✔
1617

1618
    def _pre_put_hook(self):
1✔
1619
        """
1620
        * Validate that at:// URIs have DIDs
1621
        * Validate that Nostr ids are nostr:[hex] ids
1622
        * Validate that multi-element farcaster lists are all USER_DATA_ADD messages
1623
        * Set/remove the activity label
1624
        * Strip @context from as2 (we don't do LD) to save disk space
1625
        """
1626
        if len(self.farcaster) > 1:
1✔
1627
            for msg in self.farcaster:
1✔
1628
                data = Message.FromString(msg).data
1✔
1629
                assert data.type == MESSAGE_TYPE_USER_DATA_ADD, f'multi-element farcaster lists must be all USER_DATA_ADD messages; got {data}'
1✔
1630

1631
        if self.as2:
1✔
1632
           self.as2.pop('@context', None)
1✔
1633
           for field in 'actor', 'attributedTo', 'author', 'object':
1✔
1634
               for val in util.get_list(self.as2, field):
1✔
1635
                   if isinstance(val, dict):
1✔
1636
                       val.pop('@context', None)
1✔
1637

1638
        def check_id(id, proto):
1✔
1639
            if proto in (None, 'ui'):
1✔
1640
                return
1✔
1641

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

1645
            if proto == 'nostr':
1✔
1646
                assert id.startswith('nostr:'), id
1✔
1647
                assert granary.nostr.ID_RE.match(id.removeprefix('nostr:')), id
1✔
1648

1649
            elif proto == 'atproto':
1✔
1650
                assert id.startswith('at://') or id.startswith('did:'), id
1✔
1651
                if id.startswith('at://'):
1✔
1652
                    repo, _, _ = parse_at_uri(id)
1✔
1653
                    if not repo.startswith('did:'):
1✔
1654
                        # TODO: if we hit this, that means the AppView gave us an AT
1655
                        # URI with a handle repo/authority instead of DID. that's
1656
                        # surprising! ...if so, and if we need to handle it, add a
1657
                        # new arroba.did.canonicalize_at_uri() function, then use it
1658
                        # here, or before.
1659
                        raise ValueError(f'at:// URI ids must have DID repos; got {id}')
1✔
1660

1661
        check_id(self.key.id(), self.source_protocol)
1✔
1662
        for target in self.copies:
1✔
1663
            check_id(target.uri, target.protocol)
1✔
1664

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

1669
    def html_link(self):
1✔
1670
        """Returns an HTML link to this object's user-facing web URL, if any.
1671

1672
        Returns:
1673
          str or None:
1674
        """
1675
        self_as1 = self.as1 or {}
1✔
1676
        if self.extra_as1:
1✔
1677
            self_as1.update(self.extra_as1)
1✔
1678

1679
        url = self_as1.get('url') or self.key.id()
1✔
1680
        return util.pretty_link(url, text=self_as1.get('displayName'))
1✔
1681

1682
    @classmethod
1✔
1683
    def get_by_id(cls, id, authed_as=None, **kwargs):
1✔
1684
        """Fetches the :class:`Object` with the given id, if it exists.
1685

1686
        Args:
1687
          id (str)
1688
          authed_as (str): optional; if provided, and a matching :class:`Object`
1689
            already exists, its ``author`` or ``actor`` must contain this actor
1690
            id. Implements basic authorization for updates and deletes.
1691

1692
        Returns:
1693
          Object:
1694

1695
        Raises:
1696
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1697
            the existing object
1698
        """
1699
        obj = super().get_by_id(maybe_truncate_key_id(id), **kwargs)
1✔
1700

1701
        if obj and obj.as1 and authed_as:
1✔
1702
            # authorization: check that the authed user is allowed to modify
1703
            # this object
1704
            # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1705
            proto = obj.owner_protocol()
1✔
1706
            assert proto, obj.source_protocol
1✔
1707
            owners = [ids.normalize_user_id(id=owner, proto=proto)
1✔
1708
                      for owner in (as1.get_ids(obj.as1, 'author')
1709
                                    + as1.get_ids(obj.as1, 'actor'))
1710
                      if owner]
1711
            if obj.type in as1.ACTOR_TYPES:
1✔
1712
                owners.append(id)
1✔
1713

1714
            user_id = ids.normalize_user_id(id=authed_as, proto=proto)
1✔
1715
            profile_id = ids.profile_id(id=authed_as, proto=proto)
1✔
1716
            if (owners and user_id not in owners and profile_id not in owners
1✔
1717
                    and authed_as not in (PRIMARY_DOMAIN,) + PROTOCOL_DOMAINS):
1718
                report_error("Auth: Object: authed_as doesn't match owner",
1✔
1719
                             user=f'{user_id} {profile_id} authed_as {authed_as} owners {owners}')
1720
                error(f"authed user {authed_as} ({user_id} {profile_id}) isn't object owner {owners}",
1✔
1721
                      status=403)
1722

1723
        # TODO
1724
        # if obj and obj.deleted:
1725
        #     return None
1726

1727
        return obj
1✔
1728

1729
    @classmethod
1✔
1730
    def get_or_create(cls, id, authed_as=None, **props):
1✔
1731
        """Returns an :class:`Object` with the given property values.
1732

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

1737
        Not transactional because transactions don't read or write memcache. :/
1738
        Fortunately we don't really depend on atomicity for much, last writer wins
1739
        is usually fine.
1740

1741
        Args:
1742
          authed_as (str): optional; if provided, and a matching :class:`Object`
1743
            already exists, its ``author`` or ``actor`` must contain this actor
1744
            id. Implements basic authorization for updates and deletes.
1745

1746
        Returns:
1747
          Object:
1748

1749
        Raises:
1750
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1751
            the existing object
1752
        """
1753
        key_id = maybe_truncate_key_id(id)
1✔
1754
        obj = cls.get_by_id(key_id, authed_as=authed_as)
1✔
1755

1756
        if not obj:
1✔
1757
            obj = Object(id=key_id, **props)
1✔
1758
            obj.new = True
1✔
1759
            obj.changed = False
1✔
1760
            obj.put()
1✔
1761
            return obj
1✔
1762

1763
        if orig_as1 := obj.as1:
1✔
1764
            # get_by_id() checks authorization if authed_as is set. make sure
1765
            # it's always set for existing objects.
1766
            assert authed_as
1✔
1767

1768
        dirty = False
1✔
1769
        for prop, val in props.items():
1✔
1770
            assert not isinstance(getattr(Object, prop), ndb.ComputedProperty)
1✔
1771
            if prop in ('copies', 'feed', 'notify', 'users'):
1✔
1772
                # merge repeated fields
1773
                for elem in val:
1✔
1774
                    if obj.add(prop, elem):
1✔
1775
                        dirty = True
1✔
1776
            elif val is not None and val != getattr(obj, prop):
1✔
1777
                setattr(obj, prop, val)
1✔
1778
                if (prop in ('as2', 'bsky', 'csv', 'mf2', 'nostr', 'raw')
1✔
1779
                        and not props.get('our_as1')):
1780
                    obj.our_as1 = None
1✔
1781
                dirty = True
1✔
1782

1783
        obj.new = False
1✔
1784
        obj.changed = obj.activity_changed(orig_as1)
1✔
1785
        if dirty:
1✔
1786
            obj.put()
1✔
1787
        return obj
1✔
1788

1789
    @staticmethod
1✔
1790
    def from_request():
1✔
1791
        """Creates and returns an :class:`Object` from form-encoded JSON parameters.
1792

1793
        Parameters:
1794
          obj_id (str): id of :class:`models.Object` to handle
1795
          *: If ``obj_id`` is unset, all other parameters are properties for a
1796
            new :class:`models.Object` to handle
1797
        """
1798
        if obj_id := request.form.get('obj_id'):
1✔
1799
            return Object.get_by_id(obj_id)
1✔
1800

1801
        props = {field: request.form.get(field)
1✔
1802
                 for field in ('id', 'source_protocol')}
1803

1804
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'nostr', 'raw':
1✔
1805
            if val := request.form.get(json_prop):
1✔
1806
                props[json_prop] = json_loads(val)
1✔
1807

1808
        if val := request.form.getlist('fc'):
1✔
1809
            props['farcaster'] = [text_format.Parse(v, Message()).SerializeToString()
1✔
1810
                                  for v in val]
1811

1812
        obj = Object(**props)
1✔
1813
        if not obj.key and obj.as1:
1✔
1814
            if id := obj.as1.get('id'):
1✔
1815
                obj.key = ndb.Key(Object, id)
1✔
1816

1817
        return obj
1✔
1818

1819
    def to_request(self):
1✔
1820
        """Returns a query parameter dict representing this :class:`Object`."""
1821
        form = {}
1✔
1822

1823
        for json_prop in 'as2', 'bsky', 'mf2', 'nostr', 'our_as1', 'raw':
1✔
1824
            if val := getattr(self, json_prop, None):
1✔
1825
                form[json_prop] = json_dumps(val, sort_keys=True)
1✔
1826

1827
        if self.farcaster:
1✔
1828
            form['fc'] = [
1✔
1829
                text_format.MessageToString(Message.FromString(val), as_one_line=True,
1830
                                            use_short_repeated_primitives=True)
1831
                for val in self.farcaster
1832
            ]
1833

1834
        for prop in ['source_protocol']:
1✔
1835
            if val := getattr(self, prop):
1✔
1836
                form[prop] = val
1✔
1837

1838
        if self.key:
1✔
1839
            form['id'] = self.key.id()
1✔
1840

1841
        return form
1✔
1842

1843
    def activity_changed(self, other_as1):
1✔
1844
        """Returns True if this activity is meaningfully changed from ``other_as1``.
1845

1846
        ...otherwise False.
1847

1848
        Used to populate :attr:`changed`.
1849

1850
        Args:
1851
          other_as1 (dict): AS1 object, or none
1852
        """
1853
        # ignore inReplyTo since we translate it between protocols
1854
        return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
1✔
1855
                if self.as1 and other_as1
1856
                else bool(self.as1) != bool(other_as1))
1857

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

1861
        TODO: unify with :meth:`User.html_link`?
1862

1863
        Args:
1864
          image (bool): whether to include an ``img`` tag with the actor's picture
1865
          sized (bool): whether to set an explicit (``width=32``) size on the
1866
            profile picture ``img`` tag
1867
          user (User): current user
1868

1869
        Returns:
1870
          str:
1871
        """
1872
        attrs = {'class': 'h-card u-author'}
1✔
1873

1874
        if user and user.key in self.users:
1✔
1875
            # outbound; show a nice link to the user
1876
            return user.html_link(handle=False, pictures=True)
1✔
1877

1878
        proto = self.owner_protocol()
1✔
1879
        actor = None
1✔
1880
        if self.as1:
1✔
1881
            actor = (as1.get_object(self.as1, 'actor')
1✔
1882
                     or as1.get_object(self.as1, 'author'))
1883
            # hydrate from datastore if available
1884
            # TODO: optimize! this is called serially in loops, eg in home.html
1885
            if set(actor.keys()) == {'id'} and proto:
1✔
1886
                actor_obj = proto.load(actor['id'], remote=False)
1✔
1887
                if actor_obj and actor_obj.as1:
1✔
1888
                    actor = actor_obj.as1
1✔
1889

1890
        if not actor:
1✔
1891
            return ''
1✔
1892
        elif set(actor.keys()) == {'id'}:
1✔
1893
            return common.pretty_link(actor['id'], attrs=attrs, user=user)
1✔
1894

1895
        url = as1.get_url(actor)
1✔
1896
        name = actor.get('displayName') or actor.get('username') or ''
1✔
1897
        img_url = util.get_url(actor, 'image')
1✔
1898
        if not image or not img_url:
1✔
1899
            return common.pretty_link(url, text=name, attrs=attrs, user=user)
1✔
1900

1901
        logo = ''
1✔
1902
        if proto:
1✔
1903
            logo = f'<span class="logo" title="{self.__class__.__name__}">{proto.LOGO_HTML or proto.LOGO_EMOJI}</span>'
×
1904

1905
        return f"""\
1✔
1906
        {logo}
1907
        <a class="h-card u-author" href="{url}" title="{name}">
1908
          <img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
1909
          <span style="unicode-bidi: isolate">{util.ellipsize(name, chars=40)}</span>
1910
        </a>"""
1911

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

1915
        ...or None if no such copy exists. If ``proto`` is ``source_protocol``,
1916
        returns this object's key id.
1917

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

1926
        Args:
1927
          proto (str or :class:`Protocol` subclass)
1928

1929
        Returns:
1930
          str:
1931
        """
1932
        if not isinstance(proto, str):
1✔
1933
            proto = proto.LABEL
1✔
1934

1935
        copies = self.get_copies(proto)
1✔
1936
        if not copies:
1✔
1937
            return None
1✔
1938

1939
        # for ATProto, prefer ids from types in granary.bluesky.FROM_AS1_TYPES order,
1940
        # eg for article, site.standard.document over app.bsky.feed.post
1941
        if proto == 'atproto':
1✔
1942
            for type in bluesky.FROM_AS1_TYPES.get(self.type, []):
1✔
1943
                for copy in copies:
1✔
1944
                    _, coll, _ = parse_at_uri(copy)
1✔
1945
                    if type == coll:
1✔
1946
                        return copy
1✔
1947

1948
        return copies[0]
1✔
1949

1950
    def get_copies(self, proto):
1✔
1951
        """Returns all ids of copies of this object in a given protocol.
1952

1953
        If ``proto`` is ``source_protocol``, returns this object's key id.
1954

1955
        Args:
1956
          proto (str or :class:`Protocol` subclass)
1957

1958
        Returns:
1959
          list of str:
1960
        """
1961
        if not isinstance(proto, str):
1✔
1962
            proto = proto.LABEL
1✔
1963

1964
        if self.source_protocol == proto:
1✔
1965
            return [self.key.id()]
1✔
1966

1967
        return [copy.uri for copy in self.copies if copy.protocol == proto]
1✔
1968

1969
    def resolve_ids(self):
1✔
1970
        """Replaces "copy" ids, subdomain ids, etc with their originals.
1971

1972
        The end result is that all ids are original "source" ids, ie in the
1973
        protocol that they first came from.
1974

1975
        Specifically, resolves:
1976

1977
        * ids in :class:`User.copies` and :class:`Object.copies`, eg ATProto
1978
          records and Nostr events that we bridged, to the ids of their
1979
          original objects in their source protocol, eg
1980
          ``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
1981
        * Bridgy Fed subdomain URLs to the ids embedded inside them, eg
1982
          ``https://bsky.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
1983
        * ATProto bsky.app URLs to their DIDs or `at://` URIs, eg
1984
          ``https://bsky.app/profile/a.com`` => ``did:plc:123``
1985

1986
        ...in these AS1 fields, in place:
1987

1988
        * ``id``
1989
        * ``actor``
1990
        * ``author``
1991
        * ``object``
1992
        * ``object.actor``
1993
        * ``object.author``
1994
        * ``object.id``
1995
        * ``object.inReplyTo``
1996
        * ``attachments.[objectType=note].id``
1997
        * ``tags.[objectType=mention].url``
1998

1999
        :meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
2000
        Much of the same logic is duplicated there!
2001

2002
        TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
2003
        """
2004
        if not self.as1:
1✔
2005
            return
1✔
2006

2007
        # extract ids, strip Bridgy Fed subdomain URLs
2008
        outer_obj = unwrap(self.as1)
1✔
2009
        if outer_obj != self.as1:
1✔
2010
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
2011

2012
        self_proto = PROTOCOLS.get(self.source_protocol)
1✔
2013
        if not self_proto:
1✔
2014
            return
1✔
2015

2016
        logger.debug(f'Resolving ids for {self.key.id()}')
1✔
2017
        inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
1✔
2018
        replaced = False
1✔
2019

2020
        def replace(val, orig_fn):
1✔
2021
            id = val.get('id') if isinstance(val, dict) else val
1✔
2022
            if not id or not self_proto.HAS_COPIES:
1✔
2023
                return id
1✔
2024

2025
            orig = orig_fn(id)
1✔
2026
            if not orig:
1✔
2027
                return val
1✔
2028

2029
            nonlocal replaced
2030
            replaced = True
1✔
2031
            logger.debug(f'Resolved copy id {val} to original {orig.id()}')
1✔
2032

2033
            if isinstance(val, dict) and util.trim_nulls(val).keys() > {'id'}:
1✔
2034
                val['id'] = orig.id()
1✔
2035
                return val
1✔
2036
            else:
2037
                return orig.id()
1✔
2038

2039
        # actually replace ids
2040
        #
2041
        # object field could be either object (eg repost) or actor (eg follow)
2042
        # TODO: handle better
2043
        # https://github.com/snarfed/bridgy-fed/issues/2281
2044
        outer_obj['object'] = replace(inner_obj, get_original_object_key)
1✔
2045
        if not replaced:
1✔
2046
            outer_obj['object'] = replace(inner_obj, get_original_user_key)
1✔
2047

2048
        for obj in outer_obj, inner_obj:
1✔
2049
            for tag in as1.get_objects(obj, 'tags'):
1✔
2050
                if tag.get('objectType') == 'mention':
1✔
2051
                    tag['url'] = replace(tag.get('url'), get_original_user_key)
1✔
2052
            for att in as1.get_objects(obj, 'attachments'):
1✔
2053
                if att.get('objectType') == 'note':
1✔
2054
                    att['id'] = replace(att.get('id'), get_original_object_key)
1✔
2055
            for field, fn in (
1✔
2056
                    ('actor', get_original_user_key),
2057
                    ('author', get_original_user_key),
2058
                    ('inReplyTo', get_original_object_key),
2059
                ):
2060
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
2061
                if len(obj[field]) == 1:
1✔
2062
                    obj[field] = obj[field][0]
1✔
2063

2064
        if replaced:
1✔
2065
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
2066

2067
    def normalize_ids(self):
1✔
2068
        """Normalizes ids to their protocol's canonical representation, if any.
2069

2070
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
2071
        for profiles, ``at://`` URIs for posts.
2072

2073
        Modifies this object in place.
2074

2075
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
2076
        """
2077
        from protocol import Protocol
1✔
2078

2079
        if not self.as1:
1✔
2080
            return
1✔
2081

2082
        logger.debug(f'Normalizing ids for {self.key.id()}')
1✔
2083
        outer_obj = copy.deepcopy(self.as1)
1✔
2084
        inner_objs = as1.get_objects(outer_obj)
1✔
2085
        replaced = False
1✔
2086

2087
        def replace(val, translate_fn):
1✔
2088
            nonlocal replaced
2089

2090
            orig = val.get('id') if isinstance(val, dict) else val
1✔
2091
            if not orig:
1✔
2092
                return val
1✔
2093

2094
            proto = Protocol.for_id(orig, remote=False)
1✔
2095
            if not proto:
1✔
2096
                return val
1✔
2097

2098
            translated = translate_fn(id=orig, from_=proto, to=proto)
1✔
2099
            if translated and translated != orig:
1✔
2100
                # logger.debug(f'Normalized {proto.LABEL} id {orig} to {translated}')
2101
                replaced = True
1✔
2102
                if isinstance(val, dict):
1✔
2103
                    val['id'] = translated
1✔
2104
                    return val
1✔
2105
                else:
2106
                    return translated
1✔
2107

2108
            return val
1✔
2109

2110
        # actually replace ids
2111
        for obj in [outer_obj] + inner_objs:
1✔
2112
            for tag in as1.get_objects(obj, 'tags'):
1✔
2113
                if tag.get('objectType') == 'mention':
1✔
2114
                    tag['url'] = replace(tag.get('url'), ids.translate_user_id)
1✔
2115
            for field in ['actor', 'author', 'inReplyTo']:
1✔
2116
                fn = (ids.translate_object_id if field == 'inReplyTo'
1✔
2117
                      else ids.translate_user_id)
2118
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
2119
                if len(obj[field]) == 1:
1✔
2120
                    obj[field] = obj[field][0]
1✔
2121

2122
        outer_obj['object'] = []
1✔
2123
        for inner_obj in inner_objs:
1✔
2124
            translate_fn = ids.translate_object_id
1✔
2125
            if as1.object_type(outer_obj) in as1.VERBS_WITH_ACTOR_OBJECT:
1✔
2126
                translate_fn = ids.translate_user_id
1✔
2127
            got = replace(inner_obj, translate_fn)
1✔
2128
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
2129
                got = got['id']
1✔
2130

2131
            outer_obj['object'].append(got)
1✔
2132

2133
        if len(outer_obj['object']) == 1:
1✔
2134
            outer_obj['object'] = outer_obj['object'][0]
1✔
2135

2136
        if replaced:
1✔
2137
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
2138

2139
    def owner_protocol(self):
1✔
2140
        """Wrapper around :attr:`source_protocol` that handles :class:`UIProtocol`.
2141

2142
        Returns:
2143
          Protocol subclass: :attr:`source_protocol` *unless* it's None or
2144
            :class:`UIProtocol`, in which case infers and returns ``author``'s or
2145
            ``actor``'s protocol instead.
2146
        """
2147
        from protocol import Protocol
1✔
2148

2149
        if self.source_protocol in (None, 'ui'):
1✔
2150
            return Protocol.for_id(as1.get_owner(self.as1))
1✔
2151

2152
        return PROTOCOLS.get(self.source_protocol)
1✔
2153

2154
    @cached_property
1✔
2155
    def domain_blocklist(self):
1✔
2156
        """Returns the domains in the domain blocklist in :attr:`raw` or :attr:`csv`.
2157

2158
        If :attr:`raw` is a list, returns it directly. Otherwise extracts the
2159
        'domain' or '#domain' column from :attr:`csv`.
2160

2161
        TODO: unify with :meth:`filters.blocklist_items`
2162

2163
        Returns:
2164
          list of str: domain names, or empty list if neither :attr:`raw` nor
2165
            :attr:`csv` is populated or parseable.
2166
        """
2167
        assert not (self.raw and self.csv)
1✔
2168

2169
        if self.raw:
1✔
2170
            return [val.split('#')[0].strip().lower() for val in self.raw]
1✔
2171

2172
        if not self.csv:
1✔
2173
            return []
1✔
2174

2175
        try:
1✔
2176
            reader = csv.DictReader(io.StringIO(self.csv))
1✔
2177
        except csv.Error:
×
2178
            return []
×
2179

2180
        if 'domain' in reader.fieldnames:
1✔
2181
            col = 'domain'
1✔
2182
        elif '#domain' in reader.fieldnames:
1✔
2183
            col = '#domain'
1✔
2184
        else:
2185
            return []
1✔
2186

2187
        return [row[col] for row in reader
1✔
2188
                if row[col] and row[col] not in DOMAIN_BLOCKLIST_CANARIES]
2189

2190
    def domain_blocklist_matches(self, user_or_id):
1✔
2191
        """Returns True if ``user_or_id`` is in this domain blocklist, False otherwise.
2192

2193
        For users, looks at id, handle, and delivery target.
2194

2195
        Args:
2196
          user_or_id (User or str)
2197

2198
        Returns:
2199
          bool:
2200

2201
        Raises:
2202
          AssertionError: if this object is not a domain blocklist
2203
        """
2204
        assert self.is_csv or self.csv or isinstance(self.raw, list)
1✔
2205

2206
        if isinstance(user_or_id, User):
1✔
2207
            user = user_or_id
1✔
2208
            inputs = [user.key.id(), user.handle_as_domain]
1✔
2209
            if user.obj:
1✔
2210
                inputs.append(user.target_for(user.obj))
1✔
2211
        else:
2212
            inputs = [user_or_id]
1✔
2213

2214
        for input in inputs:
1✔
2215
            if domain := util.domain_from_link(input):
1✔
2216
                if (util.domain_or_parent_in(domain, self.domain_blocklist)
1✔
2217
                        and not util.domain_or_parent_in(domain, domains.DOMAINS)):
2218
                    logger.info(f'{input} matches domain blocklist {self.key.id()}')
1✔
2219
                    return True
1✔
2220

2221

2222
class Follower(ndb.Model):
1✔
2223
    """A follower of a Bridgy Fed user."""
2224
    STATUSES = ('active', 'inactive', 'dormant')
1✔
2225
    REASONS = ('requested', 'bounce')
1✔
2226

2227
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
2228
    """The follower."""
1✔
2229
    to = ndb.KeyProperty(required=True)
1✔
2230
    """The followee, ie the user being followed."""
1✔
2231

2232
    follow = ndb.KeyProperty(Object)
1✔
2233
    """The last follow activity."""
1✔
2234
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
2235
    """Whether this follow is active or not.
1✔
2236

2237
    ``dormant`` means the followee isn't bridged (yet), so the follow can't be
2238
    delivered. If they enable the bridge, we notify the follower.
2239
    """
2240
    reason = ndb.StringProperty(choices=REASONS)
1✔
2241
    """Optional explanation for this follow's :attr:`status`, eg why it's
1✔
2242
    dormant. One of :attr:`REASONS`."""
2243

2244
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
2245
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
2246

2247
    # OLD. some stored entities still have these; do not reuse.
2248
    # src = ndb.StringProperty()
2249
    # dest = ndb.StringProperty()
2250
    # last_follow = JsonProperty()
2251

2252
    def _pre_put_hook(self):
1✔
2253
        # we're a bridge! stick with bridging.
2254
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
2255

2256
    def _post_put_hook(self, future):
1✔
2257
        logger.debug(f'Wrote {self.key}')
1✔
2258

2259
    @classmethod
1✔
2260
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
2261
        """Returns a Follower with the given ``from_`` and ``to`` users.
2262

2263
        Not transactional because transactions don't read or write memcache. :/
2264
        Fortunately we don't really depend on atomicity for much, last writer wins
2265
        is usually fine.
2266

2267
        If a matching :class:`Follower` doesn't exist in the datastore, creates
2268
        it first.
2269

2270
        If ``status='dormant'`` is passed and the existing Follower is
2271
        ``active``, the existing Follower is returned unchanged: we never
2272
        downgrade an active Follower to dormant.
2273

2274
        Args:
2275
          from_ (User or Key)
2276
          to (User or Key)
2277

2278
        Returns:
2279
          Follower:
2280
        """
2281
        from_key = from_ if isinstance(from_, ndb.Key) else from_.key
1✔
2282
        to_key = to if isinstance(to, ndb.Key) else to.key
1✔
2283

2284
        assert from_key
1✔
2285
        assert to_key
1✔
2286

2287
        follower = Follower.query(Follower.from_ == from_key,
1✔
2288
                                  Follower.to == to_key,
2289
                                  ).get()
2290
        if not follower:
1✔
2291
            follower = Follower(from_=from_key, to=to_key, **kwargs)
1✔
2292
        elif kwargs:
1✔
2293
            # update existing entity with new property values, eg to make an
2294
            # inactive Follower active again
2295
            for prop, val in kwargs.items():
1✔
2296
                if (prop == 'status' and val == 'dormant'
1✔
2297
                        and follower.status == 'active'):
2298
                    # don't downgrade an active Follower to dormant
2299
                    continue
1✔
2300
                setattr(follower, prop, val)
1✔
2301

2302
        follower.put()
1✔
2303
        return follower
1✔
2304

2305
    @staticmethod
1✔
2306
    def fetch_page(collection, user):
1✔
2307
        r"""Fetches a page of :class:`Follower`\s for a given user.
2308

2309
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
2310
        parameters, if available in the request.
2311

2312
        Args:
2313
          collection (str): ``followers`` or ``following``
2314
          user (User)
2315

2316
        Returns:
2317
          (list of Follower, str, str) tuple: results, annotated with an extra
2318
          ``user`` attribute that holds the follower or following :class:`User`,
2319
          and new str query param values for ``before`` and ``after`` to fetch
2320
          the previous and next pages, respectively
2321
        """
2322
        assert collection in ('followers', 'following'), collection
1✔
2323

2324
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
2325
        query = Follower.query(
1✔
2326
            Follower.status == 'active',
2327
            filter_prop == user.key,
2328
        )
2329

2330
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
2331
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
2332
                              for f in followers)
2333
        User.load_multi(u for u in users if u)
1✔
2334

2335
        for f, u in zip(followers, users):
1✔
2336
            f.user = u
1✔
2337

2338
        followers = [f for f in followers if f.user]
1✔
2339

2340
        # only show followers in protocols that this user is bridged into
2341
        if collection == 'followers':
1✔
2342
            followers = [f for f in followers if user.is_enabled(f.user)]
1✔
2343

2344
        return followers, before, after
1✔
2345

2346

2347
class Cursor(StringIdModel):
1✔
2348
    """The last cursor (eg sequence number) we've seen for an event stream.
2349

2350
    Examples:
2351

2352
    * https://atproto.com/specs/event-stream#sequence-numbers
2353
    * https://snapchain.farcaster.xyz/reference/grpcapi/events
2354
    * https://nips.nostr.com/1#from-client-to-relay-sending-events-and-creating-subscriptions
2355

2356
    Key id is generally ``[HOST][:PORT] [XRPC]``, where ``[METHOD]`` is the
2357
    subscription/event stream method name. Specifically:
2358

2359
    * ATProto: host is the relay, no port; method name is NSID of the XRPC method for
2360
      the event stream. For example, the cursor key id of the `subscribeRepos` on the
2361
      production relay is ``bsky.network com.atproto.sync.subscribeRepos``.
2362
    * Farcaster: host is the Snapchain hub, no port; method name is ``Subscribe``.
2363
      For example. the current production cursor is
2364
      ``crackle.farcaster.xyz Subscribe``.
2365

2366
    ``cursor`` is the latest event that we know we've seen, so when we re-subscribe,
2367
    we should start at ``cursor + 1``.
2368
    """
2369
    cursor = ndb.IntegerProperty()
1✔
2370
    ''
1✔
2371
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
2372
    ''
1✔
2373
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
2374
    ''
1✔
2375

2376

2377
def fetch_objects(query, by=None, user=None, max_age=None):
1✔
2378
    """Fetches a page of :class:`Object` entities from a datastore query.
2379

2380
    Wraps :func:`fetch_page` and adds attributes to the returned
2381
    :class:`Object` entities for rendering in ``objects.html``.
2382

2383
    Args:
2384
      query (ndb.Query)
2385
      by (ndb.model.Property): either :attr:`Object.updated` or
2386
        :attr:`Object.created`
2387
      user (User): current user
2388
      max_age (datetime.timedelta): passed through to :func:`fetch_page`
2389

2390
    Returns:
2391
      (list of Object, str, str) tuple:
2392
      (results, new ``before`` query param, new ``after`` query param)
2393
      to fetch the previous and next pages, respectively
2394
    """
2395
    assert by is Object.updated or by is Object.created
1✔
2396
    objects, new_before, new_after = fetch_page(query, Object, by=by,
1✔
2397
                                                max_age=max_age)
2398
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
2399

2400
    # synthesize human-friendly content for objects
2401
    for i, obj in enumerate(objects):
1✔
2402
        obj_as1 = obj.as1
1✔
2403
        type = as1.object_type(obj_as1)
1✔
2404

2405
        # AS1 verb => human-readable phrase
2406
        phrases = {
1✔
2407
            'accept': 'accepted',
2408
            'article': 'posted',
2409
            'comment': 'replied',
2410
            'delete': 'deleted',
2411
            'follow': 'followed',
2412
            'invite': 'is invited to',
2413
            'issue': 'filed issue',
2414
            'like': 'liked',
2415
            'note': 'posted',
2416
            'post': 'posted',
2417
            'repost': 'reposted',
2418
            'rsvp-interested': 'is interested in',
2419
            'rsvp-maybe': 'might attend',
2420
            'rsvp-no': 'is not attending',
2421
            'rsvp-yes': 'is attending',
2422
            'share': 'reposted',
2423
            'stop-following': 'unfollowed',
2424
            'undo': 'undid',
2425
            'update': 'updated',
2426
        }
2427
        phrases.update({type: 'profile refreshed:' for type in as1.ACTOR_TYPES})
1✔
2428

2429
        obj.phrase = phrases.get(type, '')
1✔
2430

2431
        content = (obj_as1.get('content')
1✔
2432
                   or obj_as1.get('displayName')
2433
                   or obj_as1.get('summary'))
2434
        if content:
1✔
2435
            content = util.parse_html(content).get_text()
1✔
2436

2437
        urls = as1.object_urls(obj_as1)
1✔
2438
        url = urls[0] if urls else None
1✔
2439
        if url and not content:
1✔
2440
            # heuristics for sniffing URLs and converting them to more friendly
2441
            # phrases and user handles.
2442
            # TODO: standardize this into granary.as2 somewhere?
2443
            from activitypub import FEDI_URL_RE
×
2444
            from atproto import COLLECTION_TO_TYPE, did_to_handle
×
2445

2446
            handle = suffix = ''
×
2447
            if match := FEDI_URL_RE.match(url):
×
2448
                handle = match.group('handle')
×
2449
                if match.group('post_id'):
×
2450
                    suffix = "'s post"
×
2451
            elif match := BSKY_APP_URL_RE.match(url):
×
2452
                handle = match.group('id')
×
2453
                if match.group('tid'):
×
2454
                    suffix = "'s post"
×
2455
            elif match := AT_URI_RE.match(url):
×
2456
                handle = match.group('repo')
×
2457
                if coll := match.group('collection'):
×
2458
                    suffix = f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
×
2459
                url = bluesky.at_uri_to_web_url(url)
×
2460
            elif url.startswith('did:'):
×
2461
                handle = url
×
2462
                url = bluesky.Bluesky.user_url(handle)
×
2463

2464
            if handle:
×
2465
                if handle.startswith('did:'):
×
2466
                    handle = did_to_handle(handle) or handle
×
2467
                content = f'@{handle}{suffix}'
×
2468

2469
            if url:
×
2470
                content = common.pretty_link(url, text=content, user=user)
×
2471

2472
        obj.content = (obj_as1.get('content')
1✔
2473
                       or obj_as1.get('displayName')
2474
                       or obj_as1.get('summary'))
2475
        obj.url = as1.get_url(obj_as1)
1✔
2476

2477
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
2478
            inner_as1 = as1.get_object(obj_as1)
1✔
2479
            obj.inner_url = as1.get_url(inner_as1) or inner_as1.get('id')
1✔
2480
            if obj.url:
1✔
2481
                obj.phrase = common.pretty_link(
1✔
2482
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
2483
            if content:
1✔
2484
                obj.content = content
1✔
2485
                obj.url = url
1✔
2486
            elif obj.inner_url:
1✔
2487
                obj.content = common.pretty_link(obj.inner_url, max_length=50)
1✔
2488

2489
    return objects, new_before, new_after
1✔
2490

2491

2492
def hydrate(activity, fields=('author', 'actor', 'object')):
1✔
2493
    """Hydrates fields in an AS1 activity, in place.
2494

2495
    Args:
2496
      activity (dict): AS1 activity
2497
      fields (sequence of str): names of fields to hydrate. If they're string ids,
2498
        loads them from the datastore, if possible, and replaces them with their dict
2499
        AS1 objects.
2500

2501
    Returns:
2502
      sequence of :class:`google.cloud.ndb.tasklets.Future`: tasklets for hydrating
2503
        each field. Wait on these before using ``activity``.
2504
    """
2505
    def _hydrate(field):
1✔
2506
        def maybe_set(future):
1✔
2507
            if future.result() and future.result().as1:
1✔
2508
                activity[field] = future.result().as1
1✔
2509
        return maybe_set
1✔
2510

2511
    futures = []
1✔
2512

2513
    for field in fields:
1✔
2514
        val = as1.get_object(activity, field)
1✔
2515
        if val and val.keys() == set(['id']):
1✔
2516
            # TODO: extract a Protocol class method out of User.profile_id,
2517
            # then use that here instead. the catch is that we'd need to
2518
            # determine Protocol for every id, which is expensive.
2519
            #
2520
            # same TODO is in models.fetch_objects
2521
            id = val['id']
1✔
2522
            if id.startswith('did:'):
1✔
2523
                id = f'at://{id}/app.bsky.actor.profile/self'
×
2524

2525
            future = Object.get_by_id_async(id)
1✔
2526
            future.add_done_callback(_hydrate(field))
1✔
2527
            futures.append(future)
1✔
2528

2529
    return futures
1✔
2530

2531

2532
def fetch_page(query, model_class, by=None, max_age=None):
1✔
2533
    """Fetches a page of results from a datastore query.
2534

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

2538
    Populates a ``log_url_path`` property on each result entity that points to a
2539
    its most recent logged request.
2540

2541
    Args:
2542
      query (google.cloud.ndb.query.Query)
2543
      model_class (class)
2544
      by (ndb.model.Property): paging property, eg :attr:`Object.updated`
2545
        or :attr:`Object.created`
2546
      max_age (datetime.timedelta): if provided, reject ``before``/``after``
2547
        params older than this, and don't generate paging links past it
2548

2549
    Returns:
2550
      (list of Object or Follower, str, str) tuple: (results, new_before,
2551
      new_after), where new_before and new_after are query param values for
2552
      ``before`` and ``after`` to fetch the previous and next pages,
2553
      respectively
2554
    """
2555
    assert by
1✔
2556

2557
    now = util.now().replace(tzinfo=None)
1✔
2558

2559
    # if there's a paging param ('before' or 'after'), update query with it
2560
    # TODO: unify this with Bridgy's user page
2561
    def get_paging_param(param):
1✔
2562
        val = request.values.get(param)
1✔
2563
        if val:
1✔
2564
            try:
1✔
2565
                dt = util.parse_iso8601(val.replace(' ', '+'))
1✔
2566
            except BaseException as e:
×
2567
                error(f"Couldn't parse {param}, {val!r} as ISO8601: {e}")
×
2568
            if dt.tzinfo:
1✔
2569
                dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
×
2570
            if max_age and now - dt > max_age:
1✔
2571
                error(f'{param} is too old')
×
2572
            return dt
1✔
2573

2574
    before = get_paging_param('before')
1✔
2575
    after = get_paging_param('after')
1✔
2576
    if before and after:
1✔
2577
        error("can't handle both before and after")
×
2578
    elif after:
1✔
2579
        query = query.filter(by >= after).order(by)
1✔
2580
    elif before:
1✔
2581
        query = query.filter(by < before).order(-by)
1✔
2582
    else:
2583
        query = query.order(-by)
1✔
2584

2585
    query_iter = query.iter()
1✔
2586
    results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
1✔
2587
                     key=lambda r: r.updated, reverse=True)
2588

2589
    # calculate new paging param(s)
2590
    has_next = results and query_iter.probably_has_next()
1✔
2591
    new_after = (
1✔
2592
        before if before
2593
        else results[0].updated if has_next and after
2594
        else None)
2595
    if new_after:
1✔
2596
        new_after = new_after.isoformat()
1✔
2597

2598
    new_before = (
1✔
2599
        after if after else
2600
        results[-1].updated if has_next
2601
        else None)
2602
    if new_before and max_age and now - new_before > max_age:
1✔
2603
        # don't link to pages older than the max paging age
2604
        new_before = None
×
2605
    if new_before:
1✔
2606
        new_before = new_before.isoformat()
1✔
2607

2608
    return results, new_before, new_after
1✔
2609

2610

2611
def load_user(handle_or_id, proto=None, create=False, allow_opt_out=False,
1✔
2612
              raise_=False):
2613
    """Loads a user by handle or id.
2614

2615
    Args:
2616
      handle_or_id (str): user handle or id
2617
      proto (Protocol subclass or None): protocol to use. If None, will try to
2618
        determine protocol via Protocol.for_id and Protocol.for_handle
2619
      create (bool): if True, use get_or_create; if False, use get_by_id
2620
      allow_opt_out (bool): whether to return a user if they're currently opted out
2621
      raise_ (bool): passed through to :meth:`User.reload_profile`. If False, and
2622
        :meth:`User.reload_profile` returns None when fetching the user's profile,
2623
        this method raises :class:`RuntimeError`
2624

2625
    Returns:
2626
      User:
2627

2628
    Raises:
2629
      RuntimeError: if no matching user was found
2630
    """
2631
    import protocol
1✔
2632

2633
    logger.debug(f'loading {handle_or_id}')
1✔
2634

2635
    if not proto or proto is protocol.Protocol:
1✔
2636
        if not (proto := protocol.Protocol.for_id(handle_or_id)):
1✔
2637
            proto, id = protocol.Protocol.for_handle(handle_or_id)
1✔
2638
            if id:
1✔
2639
                handle_or_id = id
×
2640

2641
    if not proto:
1✔
2642
        if handle_or_id.startswith('@'):
1✔
2643
            return load_user(handle_or_id.removeprefix('@'), create=create,
1✔
2644
                             allow_opt_out=allow_opt_out)
2645
        raise RuntimeError(f"Couldn't determine network for {handle_or_id}")
1✔
2646

2647
    if proto.owns_id(handle_or_id) is not False:
1✔
2648
        if proto.LABEL == 'web' and util.is_web(handle_or_id):
1✔
2649
            if not util.is_homepage(handle_or_id):
1✔
2650
                raise RuntimeError(f"{handle_or_id} isn't a web domain or homepage URL")
1✔
2651

2652
        # TODO: handle user vs object ids here. this incorrectly assumes that it's
2653
        # a user id. https://github.com/snarfed/bridgy-fed/issues/2281
2654
        id = ids.normalize_user_id(id=handle_or_id, proto=proto)
1✔
2655
        user = (proto.get_or_create(id, allow_opt_out=allow_opt_out, raise_=raise_)
1✔
2656
                if create else proto.get_by_id(id, allow_opt_out=allow_opt_out))
2657
        if not user:
1✔
2658
            raise RuntimeError(f"Couldn't load {handle_or_id} on {proto.PHRASE}")
1✔
2659
        return user
1✔
2660

2661
    logger.debug(f"doesn't look like a {proto.LABEL} user ID, trying as a handle")
1✔
2662

2663
    if proto.owns_handle(handle_or_id) is False:
1✔
2664
        if handle_or_id.startswith('@'):
1✔
2665
            return load_user(handle_or_id.removeprefix('@'), create=create,
1✔
2666
                             proto=proto, allow_opt_out=allow_opt_out)
2667
        raise RuntimeError(f"{handle_or_id} doesn't look like a user id or handle on {proto.PHRASE}")
1✔
2668

2669
    candidates = (handle_or_id, '@' + handle_or_id)
1✔
2670
    for user in proto.query(ndb.OR(proto.handle.IN(candidates),
1✔
2671
                                   proto.handle_as_domain == handle_or_id)):
2672
        # some users may have an old handle stored and indexed, but they've changed
2673
        # their handle since then, so check again in memory
2674
        if user.handle in candidates or user.handle_as_domain == handle_or_id:
1✔
2675
            if user.use_instead:
1✔
2676
                logger.debug(f'{user.key} use_instead => {user.use_instead}')
×
2677
                user = user.use_instead.get()
×
2678
            if (not user.status and user.enabled_protocols) or allow_opt_out:
1✔
2679
                return user
1✔
2680

2681
    if create:
1✔
2682
        id = proto.handle_to_id(handle_or_id)
1✔
2683
        if not id:
1✔
2684
            raise RuntimeError(f"{handle_or_id} doesn't look like a handle on {proto.PHRASE}")
1✔
2685
        user = proto.get_or_create(id, allow_opt_out=allow_opt_out, raise_=raise_)
1✔
2686
        if user and user.obj and user.obj.as1:
1✔
2687
            return user
1✔
2688

2689
    raise RuntimeError(f"Couldn't find bridged {proto.LABEL} account {handle_or_id}")
1✔
2690

2691

2692
def maybe_truncate_key_id(id):
1✔
2693
    """Returns id, truncated to ``_MAX_KEYPART_BYTES`` bytes if it's longer."""
2694
    encoded = id.encode('utf-8')
1✔
2695
    if len(encoded) > _MAX_KEYPART_BYTES:
1✔
2696
        truncated = encoded[:_MAX_KEYPART_BYTES].decode('utf-8', errors='ignore')
1✔
2697
        logger.warning(f'Truncating id {id} to {_MAX_KEYPART_BYTES} bytes: {truncated}')
1✔
2698
        return truncated
1✔
2699

2700
    return id
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc