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

snarfed / bridgy-fed / 4d407a56-d783-4f38-b08a-6ac346225a30

22 May 2026 03:11PM UTC coverage: 94.17% (+0.02%) from 94.152%
4d407a56-d783-4f38-b08a-6ac346225a30

push

circleci

snarfed
add new migrate-out task queue, use it to copy ATProto blobs to new PDS

adds new ATProto.migrate_out_blobs classmethod. will also switch Bounce to use this.

for #1137

38 of 39 new or added lines in 5 files covered. (97.44%)

9 existing lines in 1 file now uncovered.

7543 of 8010 relevant lines covered (94.17%)

0.94 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 flask import request
1✔
19
from google.cloud import ndb
1✔
20
from google.cloud.ndb.key import _MAX_KEYPART_BYTES
1✔
21
from granary import as1, as2, atom, bluesky, microformats2
1✔
22
from granary.bluesky import BSKY_APP_URL_RE
1✔
23
import granary.nostr
1✔
24
from granary.source import html_to_text
1✔
25
import humanize
1✔
26
from lexrpc.base import AT_URI_RE
1✔
27
from oauth_dropins.webutil import util
1✔
28
from oauth_dropins.webutil.appengine_info import DEBUG
1✔
29
from oauth_dropins.webutil.flask_util import error
1✔
30
from oauth_dropins.webutil.models import (
1✔
31
    EncryptedProperty,
32
    JsonProperty,
33
    stored_value,
34
    StringIdModel,
35
)
36
from oauth_dropins.webutil.util import ellipsize, json_dumps, json_loads
1✔
37
from requests import RequestException
1✔
38
import secp256k1
1✔
39

40
import common
1✔
41
from common import (
1✔
42
    base64_to_long,
43
    long_to_base64,
44
    OLD_ACCOUNT_AGE,
45
    report_error,
46
)
47
import domains
1✔
48
from domains import (
1✔
49
    BLOG_REDIRECT_DOMAINS,
50
    DOMAIN_BLOCKLIST_CANARIES,
51
    DOMAIN_RE,
52
    PRIMARY_DOMAIN,
53
    PROTOCOL_DOMAINS,
54
    unwrap,
55
)
56
import ids
1✔
57
import memcache
1✔
58

59
# maps string label to Protocol subclass. values are populated by ProtocolUserMeta.
60
# (we used to wait for ProtocolUserMeta to populate the keys as well, but that was
61
# awkward to use in datastore model properties with choices, below; it required
62
# overriding them in reset_model_properties, which was always flaky.)
63
PROTOCOLS = {label: None for label in (
1✔
64
    'activitypub',
65
    'ap',
66
    'atproto',
67
    'bsky',
68
    'nostr',
69
    'ostatus',
70
    'web',
71
    'webmention',
72
    'ui',
73
)}
74
DEBUG_PROTOCOLS = (
1✔
75
    'fa',
76
    'fake',
77
    'efake',
78
    'farcaster',
79
    'fc',
80
    'other',
81
)
82
if DEBUG:
1✔
83
    PROTOCOLS.update({label: None for label in DEBUG_PROTOCOLS})
1✔
84

85
# maps string kind (eg 'MagicKey') to Protocol subclass.
86
# populated in ProtocolUserMeta
87
PROTOCOLS_BY_KIND = {}
1✔
88

89
# 2048 bits makes tests slow, so use 1024 for them
90
KEY_BITS = 1024 if DEBUG else 2048
1✔
91
PAGE_SIZE = 20
1✔
92

93
# auto delete most old objects via the Object.expire property
94
# https://cloud.google.com/datastore/docs/ttl
95
#
96
# need to keep follows because we attach them to Followers and use them for
97
# unfollows
98
DONT_EXPIRE_OBJECT_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES
1✔
99
                            | set(['block', 'flag', 'follow', 'like', 'share']))
100
OBJECT_EXPIRE_AGE = timedelta(days=90)
1✔
101

102
GET_ORIGINALS_CACHE_EXPIRATION = timedelta(days=1)
1✔
103
FOLLOWERS_CACHE_EXPIRATION = timedelta(hours=2)
1✔
104

105
# See https://www.cloudimage.io/
106
IMAGE_PROXY_URL_BASE = 'https://xaasg3w5.cloudimg.io/'
1✔
107
IMAGE_PROXY_DOMAINS = ('threads.net',)
1✔
108

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

130
logger = logging.getLogger(__name__)
1✔
131

132

133
class Target(ndb.Model):
1✔
134
    r""":class:`protocol.Protocol` + URI pairs for identifying objects.
135

136
    These are currently used for:
137

138
    * delivery destinations, eg ActivityPub inboxes, webmention targets, etc.
139
    * copies of :class:`Object`\s and :class:`User`\s elsewhere,
140
      eg ``at://`` URIs for ATProto records, nevent etc bech32-encoded Nostr ids,
141
      ATProto user DIDs, etc.
142

143
    Used in :class:`google.cloud.ndb.model.StructuredProperty`\s inside
144
    :class:`Object` and :class:`User`; not stored as top-level entities in the
145
    datastore.
146

147
    ndb implements this by hoisting each property here into a corresponding
148
    property on the parent entity, prefixed by the StructuredProperty name
149
    below, eg ``delivered.uri``, ``delivered.protocol``, etc.
150

151
    For repeated StructuredPropertys, the hoisted properties are all repeated on
152
    the parent entity, and reconstructed into StructuredPropertys based on their
153
    order.
154

155
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
156
    """
157
    uri = ndb.StringProperty(required=True)
1✔
158
    ''
1✔
159
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
160
    ''
1✔
161

162
    def __eq__(self, other):
1✔
163
        """Equality excludes Targets' :class:`Key`."""
164
        if isinstance(other, Target):
1✔
165
            return self.uri == other.uri and self.protocol == other.protocol
1✔
166

167
    def __hash__(self):
1✔
168
        """Allow hashing so these can be dict keys."""
169
        return hash((self.protocol, self.uri))
1✔
170

171

172
class DM(ndb.Model):
1✔
173
    """:class:`protocol.Protocol` + type pairs for identifying sent DMs.
174

175
    Used in :attr:`User.sent_dms`.
176

177
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
178
    """
179
    type = ndb.StringProperty(required=True)
1✔
180
    """Known values (keep in sync with USER_STATUS_DESCRIPTIONS, the subset for
1✔
181
    ineligible users):
182

183
      * dms_not_supported-[RECIPIENT-USER-ID]
184
      * moved
185
      * no-feed-or-webmention
186
      * no-nip05
187
      * no-profile
188
      * opt-out
189
      * over-handle-domain-limit
190
      * owns-webfinger
191
      * private
192
      * replied_to_bridged_user
193
      * request_bridging
194
      * requires-avatar
195
      * requires-name
196
      * requires-old-account
197
      * unsupported-handle-ap
198
      * welcome
199
    """
200
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
201
    ''
1✔
202

203
    def __eq__(self, other):
1✔
204
        """Equality excludes Targets' :class:`Key`."""
205
        return self.type == other.type and self.protocol == other.protocol
1✔
206

207

208
class ProtocolUserMeta(type(ndb.Model)):
1✔
209
    """:class:`User` metaclass. Registers all subclasses in ``PROTOCOLS``."""
210
    def __new__(meta, name, bases, class_dict):
1✔
211
        cls = super().__new__(meta, name, bases, class_dict)
1✔
212

213
        label = getattr(cls, 'LABEL', None)
1✔
214
        if (label and label not in ('protocol', 'user')
1✔
215
                and (DEBUG or cls.LABEL not in DEBUG_PROTOCOLS)):
216
            for label in (label, cls.ABBREV) + cls.OTHER_LABELS:
1✔
217
                if label:
1✔
218
                    PROTOCOLS[label] = cls
1✔
219
            PROTOCOLS_BY_KIND[cls._get_kind()] = cls
1✔
220

221
        return cls
1✔
222

223

224
def reset_protocol_properties():
1✔
225
    """Recreates various protocol properties to include choices from ``PROTOCOLS``."""
226
    abbrevs = f'({"|".join(PROTOCOLS.keys())}|fed)'
1✔
227
    domains.SUBDOMAIN_BASE_URL_RE = re.compile(
1✔
228
        rf'^https?://({abbrevs}\.brid\.gy|localhost(:8080)?)/(convert/|r/)?({abbrevs}/)?(?P<path>.+)')
229
    ids.COPIES_PROTOCOLS = tuple(label for label, proto in PROTOCOLS.items()
1✔
230
                                 if proto and proto.HAS_COPIES)
231

232

233
@lru_cache(maxsize=100000)
1✔
234
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
235
def get_original_object_key(copy_id):
1✔
236
    """Finds the :class:`Object` with a given copy id, if any.
237

238
    Note that :meth:`Object.add` also updates this function's
239
    :func:`memcache.memoize` cache.
240

241
    Args:
242
      copy_id (str)
243

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

249
    return Object.query(Object.copies.uri == copy_id).get(keys_only=True)
1✔
250

251

252
@lru_cache(maxsize=100000)
1✔
253
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
254
def get_original_user_key(copy_id):
1✔
255
    """Finds the user with a given copy id, if any.
256

257
    Note that :meth:`User.add` also updates this function's
258
    :func:`memcache.memoize` cache.
259

260
    Args:
261
      copy_id (str)
262

263
    Returns:
264
      google.cloud.ndb.Key or None
265
    """
266
    assert copy_id
1✔
267

268
    for proto in PROTOCOLS.values():
1✔
269
        if proto and proto.LABEL != 'ui' and not proto.owns_id(copy_id):
1✔
270
            if orig := proto.query(proto.copies.uri == copy_id).get(keys_only=True):
1✔
271
                return orig
1✔
272

273

274
class AddRemoveMixin:
1✔
275
    """Mixin class that defines the :meth:`add` and :meth:`remove` methods.
276

277
    If a subclass of this mixin defines the ``GET_ORIGINAL_FN`` class-level
278
    attribute, its memoize cache will be cleared when :meth:`remove` is called with
279
    the ``copies`` property.
280
    """
281

282
    lock = None
1✔
283
    """Synchronizes :meth:`add`, :meth:`remove`, etc."""
1✔
284

285
    def __init__(self, *args, **kwargs):
1✔
286
        super().__init__(*args, **kwargs)
1✔
287
        self.lock = Lock()
1✔
288

289
    def add(self, prop, val):
1✔
290
        """Adds a value to a multiply-valued property.
291

292
        Args:
293
          prop (str)
294
          val
295

296
        Returns:
297
          True if val was added, ie it wasn't already in prop, False otherwise
298
        """
299
        with self.lock:
1✔
300
            added = util.add(getattr(self, prop), val)
1✔
301

302
        if prop == 'copies' and added:
1✔
303
            if fn := getattr(self, 'GET_ORIGINAL_FN'):
1✔
304
                memcache.pickle_memcache.set(memcache.memoize_key(fn, val.uri),
1✔
305
                                             self.key)
306

307
        return added
1✔
308

309
    def remove(self, prop, val):
1✔
310
        """Removes a value from a multiply-valued property.
311

312
        Args:
313
          prop (str)
314
          val
315
        """
316
        with self.lock:
1✔
317
            existing = getattr(self, prop)
1✔
318
            if val in existing:
1✔
319
                existing.remove(val)
1✔
320

321
        if prop == 'copies':
1✔
322
            self.clear_get_original_cache(val.uri)
1✔
323

324
    def remove_copies_on(self, proto):
1✔
325
        """Removes all copies on a given protocol.
326

327
        ``proto.HAS_COPIES`` must be True.
328

329
        Args:
330
          proto (protocol.Protocol subclass)
331
        """
332
        assert proto.HAS_COPIES
1✔
333

334
        for copy in self.copies:
1✔
335
            if copy.protocol in (proto.ABBREV, proto.LABEL):
1✔
336
                self.remove('copies', copy)
1✔
337

338
    @classmethod
1✔
339
    def clear_get_original_cache(cls, uri):
1✔
340
        if fn := getattr(cls, 'GET_ORIGINAL_FN'):
1✔
341
            memcache.pickle_memcache.delete(memcache.memoize_key(fn, uri))
1✔
342

343

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

350
    Stores some protocols' keypairs. Currently:
351

352
    * RSA keypair for ActivityPub HTTP Signatures
353
      properties: ``mod``, ``public_exponent``, ``private_exponent``, all
354
      encoded as base64url (ie URL-safe base64) strings as described in RFC
355
      4648 and section 5.1 of the Magic Signatures spec:
356
      https://tools.ietf.org/html/draft-cavage-http-signatures-12
357
    * *Not* K-256 signing or rotation keys for AT Protocol, those are stored in
358
      :class:`arroba.datastore_storage.AtpRepo` entities
359
    """
360
    GET_ORIGINAL_FN = get_original_user_key
1✔
361
    'used by AddRemoveMixin'
1✔
362

363
    obj_key = ndb.KeyProperty(kind='Object')  # user profile
1✔
364
    ''
1✔
365
    use_instead = ndb.KeyProperty()
1✔
366
    ''
1✔
367

368
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
369
    """Proxy copies of this user elsewhere, eg DIDs for ATProto records, bech32
1✔
370
    npub Nostr ids, etc. Similar to ``rel-me`` links in microformats2,
371
    ``alsoKnownAs`` in DID docs (and now AS2), etc.
372
    """
373

374
    mod = ndb.StringProperty()
1✔
375
    """Part of the bridged ActivityPub actor's private key."""
1✔
376
    public_exponent = ndb.StringProperty()
1✔
377
    """Part of the bridged ActivityPub actor's private key."""
1✔
378
    private_exponent = ndb.StringProperty()
1✔
379
    """Part of the bridged ActivityPub actor's private key."""
1✔
380
    nostr_key_bytes = EncryptedProperty()
1✔
381
    """The bridged Nostr account's secp256k1 private key, in raw bytes."""
1✔
382

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

386
    enabled_protocols = ndb.StringProperty(repeated=True,
1✔
387
                                           choices=list(PROTOCOLS.keys()))
388
    """Protocols that this user has explicitly opted into.
1✔
389

390
    Protocols that don't require explicit opt in are omitted here.
391
    """
392

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

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

400
    send_notifs = ndb.StringProperty(default='all', choices=('all', 'none'))
1✔
401
    """Which notifications we should send this user."""
1✔
402

403
    blocks = ndb.KeyProperty(kind='Object', repeated=True)
1✔
404
    ''
1✔
405

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

409
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
410
    ''
1✔
411
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
412
    ''
1✔
413

414
    # `existing` attr is set by get_or_create
415

416
    # OLD. some stored entities still have these; do not reuse.
417
    # direct = ndb.BooleanProperty(default=False)
418
    # actor_as2 = JsonProperty()
419
    # protocol-specific state
420
    # atproto_notifs_indexed_at = ndb.TextProperty()
421
    # atproto_feed_indexed_at = ndb.TextProperty()
422

423
    def __init__(self, **kwargs):
1✔
424
        """Constructor.
425

426
        Sets :attr:`obj` explicitly because however
427
        :class:`google.cloud.ndb.model.Model` sets it doesn't work with
428
        ``@property`` and ``@obj.setter`` below.
429
        """
430
        obj = kwargs.pop('obj', None)
1✔
431
        super().__init__(**kwargs)
1✔
432

433
        if obj:
1✔
434
            self.obj = obj
1✔
435

436
    @classmethod
1✔
437
    def new(cls, **kwargs):
1✔
438
        """Try to prevent instantiation. Use subclasses instead."""
439
        raise NotImplementedError()
×
440

441
    def _post_put_hook(self, future):
1✔
442
        logger.debug(f'Wrote {self.key}')
1✔
443

444
    @classmethod
1✔
445
    def get_by_id(cls, id, allow_opt_out=False, **kwargs):
1✔
446
        """Override to follow ``use_instead`` property and ``status``.
447

448
        Returns None if the user is opted out.
449
        """
450
        user = cls._get_by_id(id, **kwargs)
1✔
451
        if user and user.use_instead:
1✔
452
            logger.debug(f'{user.key} use_instead => {user.use_instead}')
1✔
453
            user = user.use_instead.get()
1✔
454

455
        if not user:
1✔
456
            return None
1✔
457

458
        if user.status and not allow_opt_out:
1✔
459
            logger.info(f'{user.key} is {user.status}')
1✔
460
            return None
1✔
461

462
        return user
1✔
463

464
    @classmethod
1✔
465
    def get_or_create(cls, id, propagate=False, allow_opt_out=False,
1✔
466
                      reload=False, raise_=False, **kwargs):
467
        """Loads and returns a :class:`User`. Creates it if necessary.
468

469
        If ``allow_opt_out`` is False and ``id`` is the bridged id for a user in
470
        another protocol, returns that user instead. Note that they'll be a
471
        different type than ``cls``!
472

473
        Not transactional because transactions don't read or write memcache. :/
474
        Fortunately we don't really depend on atomicity for much, last writer wins
475
        is usually fine.
476

477
        Args:
478
          propagate (bool): whether to create copies of this user in push-based
479
            protocols, eg ATProto and Nostr.
480
          allow_opt_out (bool): whether to allow and create the user if they're
481
            currently opted out
482
          reload (bool): whether to reload profile always, vs only if necessary
483
          raise_ (bool): passed through to :meth:`User.reload_profile`. If False, and
484
            :meth:`User.reload_profile` returns None when fetching the user's profile,
485
            this method raises :class:`RuntimeError`
486
          kwargs: passed through to ``cls`` constructor
487

488
        Returns:
489
          User: existing or new user, or None if the user is opted out
490
        """
491
        assert cls != User
1✔
492

493
        # TODO?
494
        # id = ids.normalize_user_id(id=id, proto=cls)
495

496
        user = cls.get_by_id(id, allow_opt_out=True)
1✔
497
        if user:  # existing
1✔
498
            if reload:
1✔
499
                user.reload_profile(gateway=True, raise_=raise_)
1✔
500

501
            if user.status and not allow_opt_out:
1✔
502
                return None
1✔
503
            user.existing = True
1✔
504

505
            # TODO: propagate more fields?
506
            changed = False
1✔
507
            for field in ['obj', 'obj_key']:
1✔
508
                old_val = getattr(user, field, None)
1✔
509
                new_val = kwargs.get(field)
1✔
510
                if old_val is None and new_val is not None:
1✔
511
                    setattr(user, field, new_val)
×
512
                    changed = True
×
513

514
            if enabled_protocols := kwargs.get('enabled_protocols'):
1✔
515
                user.enabled_protocols = (set(user.enabled_protocols)
1✔
516
                                          | set(enabled_protocols))
517
                changed = True
1✔
518

519
            if not propagate:
1✔
520
                if changed:
1✔
521
                    try:
1✔
522
                        user.put()
1✔
523
                    except AssertionError as e:
×
524
                        logger.debug(e)
×
525
                        error(f'Bad {cls.__name__} id {id} : {e}')
×
526
                return user
1✔
527

528
        else:  # new, not existing
529
            if not allow_opt_out and (orig_key := get_original_user_key(id)):
1✔
530
                orig = orig_key.get()
1✔
531
                if orig.status:
1✔
532
                    return None
×
533
                orig.existing = False
1✔
534
                return orig
1✔
535

536
            user = cls(id=id, **kwargs)
1✔
537
            user.existing = False
1✔
538
            try:
1✔
539
                user.reload_profile(gateway=True, raise_=raise_)
1✔
540
            except AssertionError as e:
1✔
541
                logger.debug(e)
1✔
542
                error(f'Bad {cls.__name__} id {id} : {e}')
1✔
543

544
            if user.status and not allow_opt_out:
1✔
545
                return None
1✔
546

547
        if propagate and user.status in (None, 'private'):
1✔
548
            for label in user.enabled_protocols + list(user.DEFAULT_ENABLED_PROTOCOLS):
1✔
549
                proto = PROTOCOLS[label]
1✔
550
                if proto == cls:
1✔
551
                    continue
×
552
                elif proto.HAS_COPIES:
1✔
553
                    if not user.get_copy(proto) and user.is_enabled(proto):
1✔
554
                        try:
1✔
555
                            proto.create_for(user)
1✔
556
                        except (ValueError, AssertionError):
1✔
557
                            logger.info(f'failed creating {proto.LABEL} copy',
1✔
558
                                        exc_info=True)
559
                            user.remove('enabled_protocols', proto.LABEL)
1✔
560
                    else:
561
                        logger.debug(f'{proto.LABEL} not enabled or user copy already exists, skipping propagate')
1✔
562

563
        try:
1✔
564
            user.put()
1✔
565
        except AssertionError as e:
×
566
            error(f'Bad {cls.__name__} id {id} : {e}')
×
567

568
        logger.debug(('Updated ' if user.existing else 'Created new ') + str(user))
1✔
569
        return user
1✔
570

571
    @property
1✔
572
    def obj(self):
1✔
573
        """Convenience accessor that loads :attr:`obj_key` from the datastore."""
574
        if self.obj_key:
1✔
575
            if not hasattr(self, '_obj'):
1✔
576
                self._obj = self.obj_key.get()
1✔
577
            return self._obj
1✔
578

579
    @obj.setter
1✔
580
    def obj(self, obj):
1✔
581
        if obj:
1✔
582
            assert isinstance(obj, Object)
1✔
583
            assert obj.key
1✔
584
            self._obj = obj
1✔
585
            self.obj_key = obj.key
1✔
586
        else:
587
            self._obj = self.obj_key = None
1✔
588

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

592
        Args:
593
          proto (Protocol): optional
594
        """
595
        now = util.now().isoformat()
1✔
596
        proto_label = proto.LABEL if proto else 'all'
1✔
597
        delete_id = f'{self.profile_id()}#bridgy-fed-delete-user-{proto_label}-{now}'
1✔
598
        delete = Object(id=delete_id, source_protocol=self.LABEL, our_as1={
1✔
599
            'id': delete_id,
600
            'objectType': 'activity',
601
            'verb': 'delete',
602
            'actor': self.key.id(),
603
            'object': self.key.id(),
604
        })
605
        self.deliver(delete, from_user=self, to_proto=proto)
1✔
606

607
    @classmethod
1✔
608
    def load_multi(cls, users):
1✔
609
        """Loads :attr:`obj` for multiple users in parallel.
610

611
        Args:
612
          users (sequence of User)
613
        """
614
        objs = ndb.get_multi(u.obj_key for u in users if u.obj_key)
1✔
615
        keys_to_objs = {o.key: o for o in objs if o}
1✔
616

617
        for u in users:
1✔
618
            u._obj = keys_to_objs.get(u.obj_key)
1✔
619

620
    @ndb.ComputedProperty
1✔
621
    def handle(self):
1✔
622
        """This user's unique, human-chosen handle, eg ``@me@snarfed.org``.
623

624
        To be implemented by subclasses.
625
        """
626
        raise NotImplementedError()
×
627

628
    @ndb.ComputedProperty
1✔
629
    def handle_as_domain(self):
1✔
630
        """This user's handle in domain-like format, via :func:`id.handle_as_domain`.
631

632
        Returns:
633
          str or None: if handle is None
634
        """
635
        if not hasattr(self, '_stored_handle_as_domain'):
1✔
636
            self._stored_handle_as_domain = stored_value(self, 'handle_as_domain')
1✔
637

638
        if self.verified_domain:
1✔
639
            return self.verified_domain
1✔
640

641
        return ids.handle_as_domain(self.handle)
1✔
642

643
    @ndb.ComputedProperty
1✔
644
    def handle_pay_level_domain(self):
1✔
645
        """This user's handle's pay-level domain.
646

647
        Pay-level domains are domains at the registrar level, usually (but not
648
        always) one level below a TLD. For example, bar.com is the pay-level domain
649
        for both foo.bar.com and baz.bar.com, and bbc.co.uk is the pay-level domain
650
        for www.bbc.co.uk.
651

652
        WARNING: this is only set for the first accounts created before hitting the
653
        :attr:`Protocol.HANDLES_PER_PAY_LEVEL_DOMAIN` limit for their pay-level
654
        domain. Accounts after that limit will have 'too-many' as their value.
655
        """
656
        last_pld = stored_value(self, 'handle_pay_level_domain')
1✔
657

658
        if self.handle_as_domain == self._stored_handle_as_domain:
1✔
659
            # handle is unchanged. use our existing stored value for
660
            # handle_pay_level_domain to avoid re-querying the datastore for other
661
            # users on the same domain
662
            return last_pld
1✔
663

664
        if self.handle_as_domain:
1✔
665
            if extract := domains.tldextract(self.handle_as_domain):
1✔
666
                if cur_pld := extract.top_domain_under_public_suffix:
1✔
667
                    if cur_pld == last_pld or not self.HANDLES_PER_PAY_LEVEL_DOMAIN:
1✔
668
                        return cur_pld
1✔
669
                    num_others = self.query(User.handle_pay_level_domain == cur_pld,
1✔
670
                                            User.status == None).count()
671
                    if num_others < self.HANDLES_PER_PAY_LEVEL_DOMAIN:
1✔
672
                        return cur_pld
1✔
673
                    else:
674
                        return OVER_LIMIT
1✔
675

676
    @ndb.ComputedProperty
1✔
677
    def status(self):
1✔
678
        """Whether this user is blocked or opted out.
679

680
        Optional. See :attr:`USER_STATUS_DESCRIPTIONS` for possible values.
681
        """
682
        # TODO
683
        # if not hasattr(self, '_stored_status'):
684
        #     self._stored_status = stored_value(self, 'status')
685

686
        if self.manual_opt_out:
1✔
687
            return 'opt-out'
1✔
688
        elif self.manual_opt_out is False:
1✔
689
            return None
1✔
690

691
        # TODO: require profile for more protocols? all?
692
        if not self.obj or not self.obj.as1:
1✔
693
            return None
1✔
694

695
        if self.obj.as1.get('bridgeable') is False:  # FEP-0036
1✔
696
            return 'opt-out'
1✔
697

698
        if self.REQUIRES_AVATAR and not self.obj.as1.get('image'):
1✔
699
            return 'requires-avatar'
1✔
700

701
        name = self.obj.as1.get('displayName')
1✔
702
        if self.REQUIRES_NAME and (not name or name in (self.handle, self.key.id())):
1✔
703
            return 'requires-name'
1✔
704

705
        if self.REQUIRES_OLD_ACCOUNT:
1✔
706
            if published := self.obj.as1.get('published'):
1✔
707
                if util.now() - util.parse_iso8601(published) < OLD_ACCOUNT_AGE:
1✔
708
                    return 'requires-old-account'
1✔
709

710
        # https://swicg.github.io/miscellany/#movedTo
711
        # https://docs.joinmastodon.org/spec/activitypub/#as
712
        if self.obj.as1.get('movedTo'):
1✔
713
            return 'moved'
1✔
714

715
        summary = html_to_text(self.obj.as1.get('summary', ''), ignore_links=True)
1✔
716
        name = html_to_text(self.obj.as1.get('displayName', ''), ignore_links=True)
1✔
717

718
        # #nobridge overrides enabled_protocols
719
        if '#nobridge' in summary or '#nobridge' in name:
1✔
720
            return 'nobridge'
1✔
721

722
        if self.HANDLES_PER_PAY_LEVEL_DOMAIN:
1✔
723
            # TODO
724
            # if self._stored_status:
725
            #     self._values.pop('handle_pay_level_domain', None)
726
            if self.handle_pay_level_domain == OVER_LIMIT:
1✔
727
                return 'over-handle-domain-limit'
1✔
728

729
        # user has explicitly opted in. should go after spam filter (REQUIRES_*)
730
        # checks, but before is_public and #nobot
731
        #
732
        # !!! WARNING: keep in sync with User.enable_protocol!
733
        if self.enabled_protocols:
1✔
734
            return None
1✔
735

736
        if not as1.is_public(self.obj.as1, unlisted=False):
1✔
737
            return 'private'
1✔
738

739
        # enabled_protocols overrides #nobot
740
        if '#nobot' in summary or '#nobot' in name:
1✔
741
            return 'nobot'
1✔
742

743
    def status_description(self):
1✔
744
        """Returns a human-readable description of this user's status.
745

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

748
        Returns:
749
          str
750
        """
751
        if desc := USER_STATUS_DESCRIPTIONS.get(self.status):
1✔
752
            return desc.format(user=self)
1✔
753

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

757
        Reasons this might return False:
758
        * We haven't turned on bridging these two protocols yet.
759
        * The user is opted out or blocked.
760
        * The user is on a domain that's opted out or blocked.
761
        * The from protocol requires opt in, and the user hasn't opted in.
762
        * ``explicit`` is True, and this protocol supports ``to_proto`` by, but the user hasn't explicitly opted into it.
763

764
        Args:
765
          to_proto (Protocol subclass)
766
          explicit (bool)
767

768
        Returns:
769
          bool:
770
        """
771
        from protocol import Protocol
1✔
772
        assert isinstance(to_proto, Protocol) or issubclass(to_proto, Protocol)
1✔
773

774
        if self.__class__ == to_proto:
1✔
775
            return True
1✔
776

777
        from_label = self.LABEL
1✔
778
        to_label = to_proto.LABEL
1✔
779

780
        if bot_protocol := Protocol.for_bridgy_subdomain(self.key.id()):
1✔
781
            return to_proto != bot_protocol
1✔
782

783
        elif self.manual_opt_out:
1✔
784
            return False
1✔
785

786
        elif to_label in self.enabled_protocols:
1✔
787
            return True
1✔
788

789
        elif self.status:
1✔
790
            return False
1✔
791

792
        elif to_label in self.DEFAULT_ENABLED_PROTOCOLS and not explicit:
1✔
793
            return True
1✔
794

795
        return False
1✔
796

797
    def enable_protocol(self, to_proto):
1✔
798
        """Adds ``to_proto`` to :attr:`enabled_protocols`.
799

800
        Also sends a welcome DM to the user (via a send task) if their protocol
801
        supports DMs.
802

803
        Args:
804
          to_proto (:class:`protocol.Protocol` subclass)
805
        """
806
        import dms
1✔
807

808
        # explicit opt-in overrides some status
809
        # !!! WARNING: keep in sync with User.status!
810
        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✔
811
        if self.status and self.status not in ('nobot', 'private'):
1✔
812
            if desc := self.status_description():
1✔
813
                dms.maybe_send(from_=to_proto, to_user=self, type=self.status,
1✔
814
                               text=ineligible.format(desc=desc))
815
            common.error(f'Nope, user {self.key.id()} is {self.status}', status=299)
1✔
816

817
        # check that our handle is supported in this protocol
818
        err = handle = None
1✔
819
        try:
1✔
820
            handle = self.handle_as(to_proto)
1✔
821
        except ValueError as e:
1✔
822
            err = e
1✔
823

824
        if not handle:
1✔
825
            err_text = str(err) if err else 'handle is unset'
1✔
826
            dms.maybe_send(from_=to_proto, to_user=self,
1✔
827
                           type=f'unsupported-handle-{to_proto.ABBREV}',
828
                           text=ineligible.format(desc=err_text))
829
            common.error(err_text, status=299)
1✔
830

831
        # add to enabled_protocols in memory so that create_for (below) etc see it,
832
        # including its effects on status, but don't store to datastore until after
833
        # create_for in case it fails
834
        self.add('enabled_protocols', to_proto.LABEL)
1✔
835

836
        if to_proto.LABEL in ids.COPIES_PROTOCOLS:
1✔
837
            # do this even if there's an existing copy since we might need to
838
            # reactivate it, which create_for should do
839
            to_proto.create_for(self)
1✔
840

841
        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✔
842
        self.put()
1✔
843

844
        common.create_task(queue='user-enabled', user=self.key.urlsafe(),
1✔
845
                           protocol=to_proto.LABEL)
846
        logger.info(f'Enabled {to_proto.LABEL} for {self.key.id()}')
1✔
847

848
    def disable_protocol(self, to_proto):
1✔
849
        """Removes ``to_proto` from :attr:`enabled_protocols``.
850

851
        Args:
852
          to_proto (:class:`protocol.Protocol` subclass)
853
        """
854
        self.remove('enabled_protocols', to_proto.LABEL)
1✔
855
        self.put()
1✔
856
        msg = f'Disabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
857
        logger.info(msg)
1✔
858

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

862
        Args:
863
          to_proto (str or Protocol)
864
          short (bool): whether to return the full handle or a shortened form.
865
            Default False. Currently only affects ActivityPub; returns just
866
            ``@[user]`` instead of ``@[user]@[domain]``
867

868
        Returns:
869
          str:
870
        """
871
        if isinstance(to_proto, str):
1✔
872
            to_proto = PROTOCOLS[to_proto]
1✔
873

874
        # override to-ATProto to use custom domain handle in DID doc
875
        from atproto import ATProto, did_to_handle
1✔
876
        if to_proto == ATProto:
1✔
877
            if did := self.get_copy(ATProto):
1✔
878
                if handle := did_to_handle(did, remote=False):
1✔
879
                    return handle
1✔
880

881
        # override web users to always use domain instead of custom username
882
        # TODO: fall back to id if handle is unset?
883
        handle = self.key.id() if self.LABEL == 'web' else self.handle
1✔
884
        if not handle:
1✔
885
            return None
1✔
886

887
        return ids.translate_handle(handle=handle, from_=self.__class__,
1✔
888
                                    to=to_proto, short=short)
889

890
    def id_as(self, to_proto):
1✔
891
        """Returns this user's id in a different protocol.
892

893
        Args:
894
          to_proto (str or Protocol)
895

896
        Returns:
897
          str
898
        """
899
        if isinstance(to_proto, str):
1✔
900
            to_proto = PROTOCOLS[to_proto]
1✔
901

902
        return ids.translate_user_id(id=self.key.id(), from_=self.__class__,
1✔
903
                                     to=to_proto)
904

905
    def handle_or_id(self):
1✔
906
        """Returns handle if we know it, otherwise id."""
907
        return self.handle or self.key.id()
1✔
908

909
    @memcache.memoize(key=lambda self: self.key.id())
1✔
910
    def public_pem(self):
1✔
911
        """Returns the user's PEM-encoded ActivityPub public RSA key.
912

913
        Returns:
914
          bytes:
915
        """
916
        self._maybe_generate_ap_key()
1✔
917
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
918
                             base64_to_long(str(self.public_exponent))),
919
                            # optimization. consistency check is very CPU-expensive,
920
                            # and unnecessary for keys we own
921
                            # https://github.com/snarfed/bridgy-fed/issues/2488
922
                            consistency_check=False)
923
        return rsa.exportKey(format='PEM')
1✔
924

925
    @memcache.memoize(key=lambda self: self.key.id())
1✔
926
    def private_pem(self):
1✔
927
        """Returns the user's PEM-encoded ActivityPub private RSA key.
928

929
        Returns:
930
          bytes:
931
        """
932
        self._maybe_generate_ap_key()
1✔
933
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
934
                             base64_to_long(str(self.public_exponent)),
935
                             base64_to_long(str(self.private_exponent))),
936
                            # optimization. consistency check is very CPU-expensive,
937
                            # and unnecessary for keys we own
938
                            # https://github.com/snarfed/bridgy-fed/issues/2488
939
                            consistency_check=False)
940
        return rsa.exportKey(format='PEM')
1✔
941

942
    def _maybe_generate_ap_key(self):
1✔
943
        """Generates this user's ActivityPub private key if necessary."""
944
        if not self.public_exponent or not self.private_exponent or not self.mod:
1✔
945
            logger.info(f'generating AP keypair for {self.key}')
1✔
946
            assert (not self.public_exponent and not self.private_exponent
1✔
947
                    and not self.mod), id
948
            key = RSA.generate(KEY_BITS, randfunc=random.randbytes if DEBUG else None)
1✔
949
            self.mod = long_to_base64(key.n)
1✔
950
            self.public_exponent = long_to_base64(key.e)
1✔
951
            self.private_exponent = long_to_base64(key.d)
1✔
952
            self.put()
1✔
953

954
    def nsec(self):
1✔
955
        """Returns the user's bech32-encoded Nostr private secp256k1 key.
956

957
        Returns:
958
          str:
959
        """
960
        self._maybe_generate_nostr_key()
1✔
961
        privkey = secp256k1.PrivateKey(self.nostr_key_bytes, raw=True)
1✔
962
        return granary.nostr.bech32_encode('nsec', privkey.serialize())
1✔
963

964
    def hex_pubkey(self):
1✔
965
        """Returns the user's hex-encoded Nostr public secp256k1 key.
966

967
        Returns:
968
          str:
969
        """
970
        self._maybe_generate_nostr_key()
1✔
971
        return granary.nostr.pubkey_from_privkey(self.nostr_key_bytes.hex())
1✔
972

973
    def npub(self):
1✔
974
        """Returns the user's bech32-encoded ActivityPub public secp256k1 key.
975

976
        Returns:
977
          str:
978
        """
979
        return granary.nostr.bech32_encode('npub', self.hex_pubkey())
1✔
980

981
    def _maybe_generate_nostr_key(self):
1✔
982
        """Generates this user's Nostr private key if necessary."""
983
        if not self.nostr_key_bytes:
1✔
984
            logger.info(f'generating Nostr keypair for {self.key}')
1✔
985
            self.nostr_key_bytes = secp256k1.PrivateKey().private_key
1✔
986
            self.put()
1✔
987

988
    def name(self):
1✔
989
        """Returns this user's human-readable name, eg ``Ryan Barrett``."""
990
        if self.obj and self.obj.as1:
1✔
991
            name = self.obj.as1.get('displayName')
1✔
992
            if name:
1✔
993
                return name
1✔
994

995
        return self.handle_or_id()
1✔
996

997
    def web_url(self):
1✔
998
        """Returns this user's user-facing profile page URL.
999

1000
        ...eg ``https://bsky.app/profile/snarfed.org`` or ``https://foo.com/``.
1001

1002
        To be implemented by subclasses.
1003

1004
        Returns:
1005
          str
1006
        """
1007
        raise NotImplementedError()
×
1008

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

1012
        Args:
1013
          url (str)
1014
          ignore_www (bool): if True, ignores ``www.`` subdomains
1015

1016
        Returns:
1017
          bool:
1018
        """
1019
        if not url:
1✔
1020
            return False
1✔
1021

1022
        url = url.strip().rstrip('/')
1✔
1023
        url = re.sub(r'^(https?://)www\.', r'\1', url)
1✔
1024
        parsed_url = urlparse(url)
1✔
1025
        if parsed_url.scheme not in ('http', 'https', ''):
1✔
1026
            return False
1✔
1027

1028
        this = self.web_url().rstrip('/')
1✔
1029
        this = re.sub(r'^(https?://)www\.', r'\1', this)
1✔
1030
        parsed_this = urlparse(this)
1✔
1031

1032
        return (url == this or url == parsed_this.netloc or
1✔
1033
                parsed_url[1:] == parsed_this[1:])  # ignore http vs https
1034

1035
    def id_uri(self):
1✔
1036
        """Returns the user id as a URI.
1037

1038
        Sometimes this is the user id itself, eg ActivityPub actor ids.
1039
        Sometimes it's a bit different, eg at://did:plc:... for ATProto user,
1040
        https://site.com for Web users.
1041

1042
        Returns:
1043
          str
1044
        """
1045
        return self.key.id()
1✔
1046

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

1050
        Examples:
1051

1052
        * Web: home page URL, eg ``https://me.com/``
1053
        * ActivityPub: actor URL, eg ``https://instance.com/users/me``
1054
        * ATProto: profile AT URI, eg ``at://did:plc:123/app.bsky.actor.profile/self``
1055

1056
        Defaults to this user's key id.
1057

1058
        Returns:
1059
          str or None:
1060
        """
1061
        return ids.profile_id(id=self.key.id(), proto=self)
1✔
1062

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

1066
        Args:
1067
          obj (Object)
1068

1069
        Returns:
1070
          bool:
1071
        """
1072
        self_ids = [self.key.id(), self.profile_id()]
1✔
1073
        if self.obj_key:
1✔
1074
            self_ids.append(self.obj_key.id())
1✔
1075

1076
        if obj.key and obj.key.id() in self_ids:
1✔
1077
            return True
1✔
1078
        elif obj.as1:
1✔
1079
            obj_as1 = (as1.get_object(obj.as1) if obj.as1.get('verb') in as1.CRUD_VERBS
1✔
1080
                       else obj.as1)
1081
            if obj_as1.get('id') in self_ids:
1✔
1082
                return True
1✔
1083

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

1087
        Populates the reloaded profile :class:`Object` in ``self.obj``.
1088

1089
        Args:
1090
          raise_ (bool): passed through to :meth:`Protocol.load`. If False, and
1091
            :meth:`Protocol.load` returns None when fetching the user's profile,
1092
            this method raises :class:`RuntimeError`
1093
          kwargs: passed through to :meth:`Protocol.load`
1094

1095
        Raises:
1096
          RuntimeError: if the user's profile can't be loaded
1097
        """
1098
        id = self.profile_id()
1✔
1099
        obj = self.load(id, remote=True, raise_=raise_, **kwargs)
1✔
1100
        if obj:
1✔
1101
            if obj.type:
1✔
1102
                assert obj.type in as1.ACTOR_TYPES, obj.type
1✔
1103
            self.obj = obj
1✔
1104
        elif raise_:
1✔
1105
            raise RuntimeError(f"Couldn't load {id} on {self.PHRASE}")
1✔
1106

1107
        # write the user so that we re-populate any computed properties
1108
        self.put()
1✔
1109

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

1113
        Args:
1114
          rest (str): additional path and/or query to add to the end
1115
          prefer_id (bool): whether to prefer to use the account's id in the path
1116
            instead of handle. Defaults to ``False``.
1117
        """
1118
        path = f'/{self.ABBREV}/{self.key.id() if prefer_id else self.handle_or_id()}'
1✔
1119

1120
        if rest:
1✔
1121
            if not (rest.startswith('?') or rest.startswith('/')):
1✔
1122
                path += '/'
1✔
1123
            path += rest
1✔
1124

1125
        return path
1✔
1126

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

1130
        ...or None if no such copy exists. If ``proto`` is this user, returns
1131
        this user's key id.
1132

1133
        Args:
1134
          proto: :class:`Protocol` subclass
1135

1136
        Returns:
1137
          str:
1138
        """
1139
        # don't use isinstance because the testutil Fake protocol has subclasses
1140
        if self.LABEL == proto.LABEL:
1✔
1141
            return self.key.id()
1✔
1142

1143
        for copy in self.copies:
1✔
1144
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
1145
                return copy.uri
1✔
1146

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

1151
        Can optionally include display name, handle, profile
1152
        picture, and/or link to a different protocol that they've enabled.
1153

1154
        TODO: unify with :meth:`Object.actor_link`?
1155

1156
        Args:
1157
          name (bool): include display name
1158
          handle (bool): True to include handle, False to exclude it, ``'short'``
1159
            to include a shortened version, if available
1160
          pictures (bool): include profile picture and protocol logo
1161
          logo (str): optional path to platform logo to show instead of the
1162
            protocol's default
1163
          proto (protocol.Protocol): link to this protocol instead of the user's
1164
            native protocol
1165
          proto_fallback (bool): if True, and ``proto`` is provided and has no
1166
            no canonical profile URL for bridged users, uses the user's profile
1167
            URL in their native protocol
1168
        """
1169
        img = name_str = full_handle = handle_str = dot = logo_html = a_open = a_close = ''
1✔
1170

1171
        if proto:
1✔
1172
            assert self.is_enabled(proto), f"{proto.LABEL} isn't enabled"
1✔
1173
            url = proto.bridged_web_url_for(self, fallback=proto_fallback)
1✔
1174
        else:
1175
            proto = self.__class__
1✔
1176
            url = self.web_url()
1✔
1177

1178
        if pictures:
1✔
1179
            if logo:
1✔
1180
                logo_html = f'<img class="logo" title="{proto.__name__}" src="{logo}" /> '
1✔
1181
            else:
1182
                logo_html = f'<span class="logo" title="{proto.__name__}">{proto.LOGO_HTML or proto.LOGO_EMOJI}</span> '
1✔
1183
            if pic := self.profile_picture():
1✔
1184
                img = f'<img src="{pic}" class="profile"> '
1✔
1185

1186
        if handle:
1✔
1187
            full_handle = self.handle_as(proto) or ''
1✔
1188
            handle_str = self.handle_as(proto, short=(handle == 'short')) or ''
1✔
1189

1190
        if name and self.name() != full_handle:
1✔
1191
            name_str = self.name() or ''
1✔
1192
            handle_str = ellipsize(handle_str, chars=40)
1✔
1193

1194
        if handle_str and name_str:
1✔
1195
            dot = ' &middot; '
1✔
1196

1197
        if url:
1✔
1198
            a_open = f'<a class="h-card u-author mention" rel="me" href="{url}" title="{name_str}{dot}{full_handle}">'
1✔
1199
            a_close = '</a>'
1✔
1200

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

1204
    def profile_picture(self):
1✔
1205
        """Returns the user's profile picture image URL, if available, or None."""
1206
        if self.obj and self.obj.as1:
1✔
1207
            return util.get_url(self.obj.as1, 'image')
1✔
1208

1209
    # can't use functools.lru_cache here because we want the cache key to be
1210
    # just the user id, not the whole entity
1211
    @cachetools.cached(
1✔
1212
        cachetools.TTLCache(50000, FOLLOWERS_CACHE_EXPIRATION.total_seconds()),
1213
        key=lambda user: user.key.id(), lock=Lock())
1214
    @memcache.memoize(key=lambda self: self.key.id(),
1✔
1215
                      expire=FOLLOWERS_CACHE_EXPIRATION)
1216
    def count_followers(self):
1✔
1217
        """Counts this user's followers and followings.
1218

1219
        Returns:
1220
          (int, int) tuple: (number of followers, number following)
1221
        """
1222
        if self.key.id() in PROTOCOL_DOMAINS:
1✔
1223
            # we don't store Followers for protocol bot users any more, so
1224
            # follower counts are inaccurate, so don't return them
1225
            return (0, 0)
1✔
1226

1227
        num_followers = Follower.query(Follower.to == self.key,
1✔
1228
                                       Follower.status == 'active')\
1229
                                .count_async()
1230
        num_following = Follower.query(Follower.from_ == self.key,
1✔
1231
                                       Follower.status == 'active')\
1232
                                .count_async()
1233
        return num_followers.get_result(), num_following.get_result()
1✔
1234

1235
    def is_blocking(self, user_or_id):
1✔
1236
        """Returns True if this user is is blocking ``user_or_id``, False otherwise.
1237

1238
        Looks at domain blocklists in :attr:`blocks`. Eventually we can add support
1239
        for blocking individual users in that too.
1240

1241
        Args:
1242
          user_or_id (User or str)
1243

1244
        Returns:
1245
          bool:
1246
        """
1247
        if not user_or_id or not (isinstance(user_or_id, User)
1✔
1248
                                  or util.is_url(user_or_id)
1249
                                  or DOMAIN_RE.fullmatch(user_or_id)):
1250
            return False
1✔
1251

1252
        blocklists = ndb.get_multi(key for key in self.blocks
1✔
1253
                                   if key.kind() == 'Object')
1254
        for list in blocklists:
1✔
1255
            if list.domain_blocklist_matches(user_or_id):
1✔
1256
                logger.info(f'{self.key.id()} is blocking {user_or_id}')
1✔
1257
                return True
1✔
1258

1259
    def add_domain_blocklist(self, url):
1✔
1260
        """Adds a domain blocklist to this user.
1261

1262
        Loads the CSV at the given URL adds it to :attr:`blocks` if it's
1263
        not already there.
1264

1265
        Args:
1266
          url (str): URL of CSV blocklist to add
1267

1268
        Returns:
1269
          Object: CSV blocklist, or None if it couldn't be loaded
1270
        """
1271
        from web import Web
1✔
1272

1273
        key = Object(id=maybe_truncate_key_id(url)).key
1✔
1274
        if key in self.blocks:
1✔
1275
            return key.get()
1✔
1276

1277
        if obj := Web.load(url, csv=True):
1✔
1278
            self.blocks.append(obj.key)
1✔
1279
            self.put()
1✔
1280
            return obj
1✔
1281

1282
    def remove_domain_blocklist(self, url):
1✔
1283
        """Removes a domain blocklist from this user.
1284

1285
        Args:
1286
          url (str): URL of CSV blocklist to remove
1287

1288
        Returns:
1289
          Object: CSV blocklist, or None if it couldn't be loaded
1290
        """
1291
        from web import Web
1✔
1292

1293
        key = Object(id=maybe_truncate_key_id(url)).key
1✔
1294
        if key in self.blocks:
1✔
1295
            self.blocks.remove(key)
1✔
1296
            self.put()
1✔
1297
            return key.get()
1✔
1298

1299
        if obj := Web.load(url, csv=True):
1✔
1300
            return obj
1✔
1301

1302

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

1309
    Key name is the id, generally a URI. We synthesize ids if necessary.
1310
    """
1311
    GET_ORIGINAL_FN = get_original_object_key
1✔
1312
    'used by AddRemoveMixin'
1✔
1313

1314
    users = ndb.KeyProperty(repeated=True)
1✔
1315
    'User(s) who created or otherwise own this object.'
1✔
1316

1317
    notify = ndb.KeyProperty(repeated=True)
1✔
1318
    """User who should see this in their user page, eg in reply to, reaction to,
1✔
1319
    share of, etc.
1320
    """
1321
    feed = ndb.KeyProperty(repeated=True)
1✔
1322
    'User who should see this in their feeds, eg followers of its creator'
1✔
1323

1324
    source_protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()))
1✔
1325
    """The protocol this object originally came from.
1✔
1326

1327
    TODO: nail down whether this is :attr:`ABBREV`` or :attr:`LABEL`
1328
    """
1329

1330
    # TODO: switch back to ndb.JsonProperty if/when they fix it for the web console
1331
    # https://github.com/googleapis/python-ndb/issues/874
1332
    as2 = JsonProperty()
1✔
1333
    'ActivityStreams 2, for ActivityPub'
1✔
1334
    bsky = JsonProperty()
1✔
1335
    'AT Protocol lexicon, for Bluesky'
1✔
1336
    csv = ndb.TextProperty()
1✔
1337
    'Other standalone CSV data, eg domain blocklist.'
1✔
1338
    mf2 = JsonProperty()
1✔
1339
    'HTML microformats2 item (*not* top level parse object with ``items`` field)'
1✔
1340
    nostr = JsonProperty()
1✔
1341
    'Nostr event'
1✔
1342
    our_as1 = JsonProperty()
1✔
1343
    'ActivityStreams 1, for activities that we generate or modify ourselves'
1✔
1344
    raw = JsonProperty()
1✔
1345
    'Other standalone data format, eg DID document'
1✔
1346

1347
    extra_as1 = JsonProperty()
1✔
1348
    "Additional individual fields to merge into this object's AS1 representation"
1✔
1349
    is_csv = ndb.BooleanProperty()
1✔
1350
    "Whether this object is a CSV. Needed because :attr:`csv` isn't indexed."
1✔
1351

1352
    # TODO: remove and actually delete Objects instead!
1353
    deleted = ndb.BooleanProperty()
1✔
1354
    ''
1✔
1355

1356
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
1357
    """Copies of this object elsewhere, eg at:// URIs for ATProto records and
1✔
1358
    nevent etc bech32-encoded Nostr ids, where this object is the original.
1359
    Similar to u-syndication links in microformats2 and
1360
    upstream/downstreamDuplicates in AS1.
1361
    """
1362

1363
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1364
    ''
1✔
1365
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1366
    ''
1✔
1367

1368
    new = None
1✔
1369
    """True if this object is new, ie this is the first time we've seen it,
1✔
1370
    False otherwise, None if we don't know.
1371
    """
1372
    changed = None
1✔
1373
    """True if this object's contents have changed from our existing copy in the
1✔
1374
    datastore, False otherwise, None if we don't know. :class:`Object` is
1375
    new/changed. See :meth:`activity_changed()` for more details.
1376
    """
1377

1378
    # DEPRECATED
1379
    # These were for full feeds with multiple items, not just this one, so they were
1380
    # stored as audit records only, not used in to_as1. for Atom/RSS
1381
    # based Objects, our_as1 was populated with an feed_index top-level
1382
    # integer field that indexed into one of these.
1383
    #
1384
    # atom = ndb.TextProperty() # Atom XML
1385
    # rss = ndb.TextProperty()  # RSS XML
1386

1387
    # DEPRECATED; these were for delivery tracking, but they were too expensive,
1388
    # so we stopped: https://github.com/snarfed/bridgy-fed/issues/1501
1389
    #
1390
    # STATUSES = ('new', 'in progress', 'complete', 'failed', 'ignored')
1391
    # status = ndb.StringProperty(choices=STATUSES)
1392
    # delivered = ndb.StructuredProperty(Target, repeated=True)
1393
    # undelivered = ndb.StructuredProperty(Target, repeated=True)
1394
    # failed = ndb.StructuredProperty(Target, repeated=True)
1395

1396
    # DEPRECATED but still used read only to maintain backward compatibility
1397
    # with old Objects in the datastore that we haven't bothered migrating.
1398
    #
1399
    # domains = ndb.StringProperty(repeated=True)
1400

1401
    # DEPRECATED; replaced by :attr:`users`, :attr:`notify`, :attr:`feed`
1402
    #
1403
    # labels = ndb.StringProperty(repeated=True,
1404
    #                             choices=('activity', 'feed', 'notification', 'user'))
1405

1406
    @property
1✔
1407
    def as1(self):
1✔
1408
        from protocol import Protocol
1✔
1409

1410
        def use_urls_as_ids(obj):
1✔
1411
            """If id field is missing or not a URL, use the url field."""
1412
            id = obj.get('id')
1✔
1413
            if not id or not (util.is_web(id) or DOMAIN_RE.fullmatch(id)):
1✔
1414
                if url := util.get_url(obj):
1✔
1415
                    obj['id'] = url
1✔
1416

1417
            for field in 'author', 'actor', 'object':
1✔
1418
                if inner := as1.get_object(obj, field):
1✔
1419
                    use_urls_as_ids(inner)
1✔
1420

1421
        if self.our_as1:
1✔
1422
            obj = self.our_as1
1✔
1423
            if self.source_protocol == 'web':
1✔
1424
                use_urls_as_ids(obj)
1✔
1425

1426
        elif self.as2:
1✔
1427
            obj = as2.to_as1(unwrap(self.as2))
1✔
1428

1429
        elif self.bsky:
1✔
1430
            owner, _, _ = parse_at_uri(self.key.id())
1✔
1431
            ATProto = PROTOCOLS['atproto']
1✔
1432
            handle = ATProto(id=owner).handle
1✔
1433
            try:
1✔
1434
                obj = bluesky.to_as1(self.bsky, repo_did=owner, repo_handle=handle,
1✔
1435
                                     uri=self.key.id(), pds=ATProto.pds_for(self))
1436
            except (ValueError, RequestException):
1✔
1437
                logger.info(f"Couldn't convert to AS1", exc_info=True)
1✔
1438
                return None
1✔
1439

1440
        elif self.mf2:
1✔
1441
            obj = microformats2.json_to_object(self.mf2,
1✔
1442
                                               rel_urls=self.mf2.get('rel-urls'))
1443
            use_urls_as_ids(obj)
1✔
1444

1445
            # use fetched final URL as id, not u-url
1446
            # https://github.com/snarfed/bridgy-fed/issues/829
1447
            if url := self.mf2.get('url'):
1✔
1448
                obj['id'] = (self.key.id() if self.key and '#' in self.key.id()
1✔
1449
                             else url)
1450

1451
            if self.key and (proto := Protocol.for_bridgy_subdomain(self.key.id())):
1✔
1452
                if util.domain_or_parent_in(as1.get_owner(obj), BLOG_REDIRECT_DOMAINS):
1✔
1453
                    logger.debug(f'overriding actor/author with {proto.bot_user_id()}')
1✔
1454
                    obj['actor'] = obj['author'] = proto.bot_user_id()
1✔
1455
                if util.domain_or_parent_in(obj.get('id'), BLOG_REDIRECT_DOMAINS):
1✔
1456
                    logger.debug(f'overriding id/url with {self.key.id()}')
1✔
1457
                    obj['id'] = obj['url'] = self.key.id()
1✔
1458

1459
        elif self.nostr:
1✔
1460
            obj = granary.nostr.to_as1(self.nostr)
1✔
1461

1462
        else:
1463
            return None
1✔
1464

1465
        # populate id if necessary
1466
        if self.key:
1✔
1467
            obj.setdefault('id', self.key.id())
1✔
1468

1469
        if util.domain_or_parent_in(obj.get('id'), IMAGE_PROXY_DOMAINS):
1✔
1470
           as1.prefix_urls(obj, 'image', IMAGE_PROXY_URL_BASE)
1✔
1471

1472
        if self.extra_as1:
1✔
1473
            obj.update(self.extra_as1)
1✔
1474

1475
        return obj
1✔
1476

1477
    @ndb.ComputedProperty
1✔
1478
    def type(self):  # AS1 objectType, or verb if it's an activity
1✔
1479
        if self.as1:
1✔
1480
            return as1.object_type(self.as1)
1✔
1481

1482
    def _expire(self):
1✔
1483
        """Automatically delete most Objects after a while using a TTL policy.
1484

1485
        https://cloud.google.com/datastore/docs/ttl
1486

1487
        They recommend not indexing TTL properties:
1488
        https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
1489
        """
1490
        now = self.updated or util.now()
1✔
1491
        if self.deleted:
1✔
1492
            return now + timedelta(days=1)
1✔
1493
        elif (self.type not in DONT_EXPIRE_OBJECT_TYPES
1✔
1494
              and not self.key.id().startswith('internal:')
1495
              and not self.is_csv):
1496
            return now + OBJECT_EXPIRE_AGE
1✔
1497

1498
    expire = ndb.ComputedProperty(_expire, indexed=False)
1✔
1499

1500
    def _pre_put_hook(self):
1✔
1501
        """
1502
        * Validate that at:// URIs have DIDs
1503
        * Validate that Nostr ids are nostr:[hex] ids
1504
        * Set/remove the activity label
1505
        * Strip @context from as2 (we don't do LD) to save disk space
1506
        """
1507
        if self.as2:
1✔
1508
           self.as2.pop('@context', None)
1✔
1509
           for field in 'actor', 'attributedTo', 'author', 'object':
1✔
1510
               for val in util.get_list(self.as2, field):
1✔
1511
                   if isinstance(val, dict):
1✔
1512
                       val.pop('@context', None)
1✔
1513

1514
        def check_id(id, proto):
1✔
1515
            if proto in (None, 'ui'):
1✔
1516
                return
1✔
1517

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

1521
            if proto == 'nostr':
1✔
1522
                assert id.startswith('nostr:'), id
1✔
1523
                assert granary.nostr.ID_RE.match(id.removeprefix('nostr:')), id
1✔
1524

1525
            elif proto == 'atproto':
1✔
1526
                assert id.startswith('at://') or id.startswith('did:'), id
1✔
1527
                if id.startswith('at://'):
1✔
1528
                    repo, _, _ = parse_at_uri(id)
1✔
1529
                    if not repo.startswith('did:'):
1✔
1530
                        # TODO: if we hit this, that means the AppView gave us an AT
1531
                        # URI with a handle repo/authority instead of DID. that's
1532
                        # surprising! ...if so, and if we need to handle it, add a
1533
                        # new arroba.did.canonicalize_at_uri() function, then use it
1534
                        # here, or before.
1535
                        raise ValueError(f'at:// URI ids must have DID repos; got {id}')
1✔
1536

1537
        check_id(self.key.id(), self.source_protocol)
1✔
1538
        for target in self.copies:
1✔
1539
            check_id(target.uri, target.protocol)
1✔
1540

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

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

1548
        Returns:
1549
          str or None:
1550
        """
1551
        self_as1 = self.as1 or {}
1✔
1552
        if self.extra_as1:
1✔
1553
            self_as1.update(self.extra_as1)
1✔
1554

1555
        url = self_as1.get('url') or self.key.id()
1✔
1556
        return util.pretty_link(url, text=self_as1.get('displayName'))
1✔
1557

1558
    @classmethod
1✔
1559
    def get_by_id(cls, id, authed_as=None, **kwargs):
1✔
1560
        """Fetches the :class:`Object` with the given id, if it exists.
1561

1562
        Args:
1563
          id (str)
1564
          authed_as (str): optional; if provided, and a matching :class:`Object`
1565
            already exists, its ``author`` or ``actor`` must contain this actor
1566
            id. Implements basic authorization for updates and deletes.
1567

1568
        Returns:
1569
          Object:
1570

1571
        Raises:
1572
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1573
            the existing object
1574
        """
1575
        obj = super().get_by_id(maybe_truncate_key_id(id), **kwargs)
1✔
1576

1577
        if obj and obj.as1 and authed_as:
1✔
1578
            # authorization: check that the authed user is allowed to modify
1579
            # this object
1580
            # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1581
            proto = obj.owner_protocol()
1✔
1582
            assert proto, obj.source_protocol
1✔
1583
            owners = [ids.normalize_user_id(id=owner, proto=proto)
1✔
1584
                      for owner in (as1.get_ids(obj.as1, 'author')
1585
                                    + as1.get_ids(obj.as1, 'actor'))
1586
                      if owner]
1587
            if obj.type in as1.ACTOR_TYPES:
1✔
1588
                owners.append(id)
1✔
1589

1590
            user_id = ids.normalize_user_id(id=authed_as, proto=proto)
1✔
1591
            profile_id = ids.profile_id(id=authed_as, proto=proto)
1✔
1592
            if (owners and user_id not in owners and profile_id not in owners
1✔
1593
                    and authed_as not in (PRIMARY_DOMAIN,) + PROTOCOL_DOMAINS):
1594
                report_error("Auth: Object: authed_as doesn't match owner",
1✔
1595
                             user=f'{user_id} {profile_id} authed_as {authed_as} owners {owners}')
1596
                error(f"authed user {authed_as} ({user_id} {profile_id}) isn't object owner {owners}",
1✔
1597
                      status=403)
1598

1599
        return obj
1✔
1600

1601
    @classmethod
1✔
1602
    def get_or_create(cls, id, authed_as=None, **props):
1✔
1603
        """Returns an :class:`Object` with the given property values.
1604

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

1609
        Not transactional because transactions don't read or write memcache. :/
1610
        Fortunately we don't really depend on atomicity for much, last writer wins
1611
        is usually fine.
1612

1613
        Args:
1614
          authed_as (str): optional; if provided, and a matching :class:`Object`
1615
            already exists, its ``author`` or ``actor`` must contain this actor
1616
            id. Implements basic authorization for updates and deletes.
1617

1618
        Returns:
1619
          Object:
1620

1621
        Raises:
1622
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1623
            the existing object
1624
        """
1625
        key_id = maybe_truncate_key_id(id)
1✔
1626
        obj = cls.get_by_id(key_id, authed_as=authed_as)
1✔
1627

1628
        if not obj:
1✔
1629
            obj = Object(id=key_id, **props)
1✔
1630
            obj.new = True
1✔
1631
            obj.changed = False
1✔
1632
            obj.put()
1✔
1633
            return obj
1✔
1634

1635
        if orig_as1 := obj.as1:
1✔
1636
            # get_by_id() checks authorization if authed_as is set. make sure
1637
            # it's always set for existing objects.
1638
            assert authed_as
1✔
1639

1640
        dirty = False
1✔
1641
        for prop, val in props.items():
1✔
1642
            assert not isinstance(getattr(Object, prop), ndb.ComputedProperty)
1✔
1643
            if prop in ('copies', 'feed', 'notify', 'users'):
1✔
1644
                # merge repeated fields
1645
                for elem in val:
1✔
1646
                    if obj.add(prop, elem):
1✔
1647
                        dirty = True
1✔
1648
            elif val is not None and val != getattr(obj, prop):
1✔
1649
                setattr(obj, prop, val)
1✔
1650
                if (prop in ('as2', 'bsky', 'csv', 'mf2', 'nostr', 'raw')
1✔
1651
                        and not props.get('our_as1')):
1652
                    obj.our_as1 = None
1✔
1653
                dirty = True
1✔
1654

1655
        obj.new = False
1✔
1656
        obj.changed = obj.activity_changed(orig_as1)
1✔
1657
        if dirty:
1✔
1658
            obj.put()
1✔
1659
        return obj
1✔
1660

1661
    @staticmethod
1✔
1662
    def from_request():
1✔
1663
        """Creates and returns an :class:`Object` from form-encoded JSON parameters.
1664

1665
        Parameters:
1666
          obj_id (str): id of :class:`models.Object` to handle
1667
          *: If ``obj_id`` is unset, all other parameters are properties for a
1668
            new :class:`models.Object` to handle
1669
        """
1670
        if obj_id := request.form.get('obj_id'):
1✔
1671
            return Object.get_by_id(obj_id)
1✔
1672

1673
        props = {field: request.form.get(field)
1✔
1674
                 for field in ('id', 'source_protocol')}
1675

1676
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'nostr', 'raw':
1✔
1677
            if val := request.form.get(json_prop):
1✔
1678
                props[json_prop] = json_loads(val)
1✔
1679

1680
        obj = Object(**props)
1✔
1681
        if not obj.key and obj.as1:
1✔
1682
            if id := obj.as1.get('id'):
1✔
1683
                obj.key = ndb.Key(Object, id)
1✔
1684

1685
        return obj
1✔
1686

1687
    def to_request(self):
1✔
1688
        """Returns a query parameter dict representing this :class:`Object`."""
1689
        form = {}
1✔
1690

1691
        for json_prop in 'as2', 'bsky', 'mf2', 'nostr', 'our_as1', 'raw':
1✔
1692
            if val := getattr(self, json_prop, None):
1✔
1693
                form[json_prop] = json_dumps(val, sort_keys=True)
1✔
1694

1695
        for prop in ['source_protocol']:
1✔
1696
            if val := getattr(self, prop):
1✔
1697
                form[prop] = val
1✔
1698

1699
        if self.key:
1✔
1700
            form['id'] = self.key.id()
1✔
1701

1702
        return form
1✔
1703

1704
    def activity_changed(self, other_as1):
1✔
1705
        """Returns True if this activity is meaningfully changed from ``other_as1``.
1706

1707
        ...otherwise False.
1708

1709
        Used to populate :attr:`changed`.
1710

1711
        Args:
1712
          other_as1 (dict): AS1 object, or none
1713
        """
1714
        # ignore inReplyTo since we translate it between protocols
1715
        return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
1✔
1716
                if self.as1 and other_as1
1717
                else bool(self.as1) != bool(other_as1))
1718

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

1722
        TODO: unify with :meth:`User.html_link`?
1723

1724
        Args:
1725
          image (bool): whether to include an ``img`` tag with the actor's picture
1726
          sized (bool): whether to set an explicit (``width=32``) size on the
1727
            profile picture ``img`` tag
1728
          user (User): current user
1729

1730
        Returns:
1731
          str:
1732
        """
1733
        attrs = {'class': 'h-card u-author'}
1✔
1734

1735
        if user and user.key in self.users:
1✔
1736
            # outbound; show a nice link to the user
1737
            return user.html_link(handle=False, pictures=True)
1✔
1738

1739
        proto = self.owner_protocol()
1✔
1740
        actor = None
1✔
1741
        if self.as1:
1✔
1742
            actor = (as1.get_object(self.as1, 'actor')
1✔
1743
                     or as1.get_object(self.as1, 'author'))
1744
            # hydrate from datastore if available
1745
            # TODO: optimize! this is called serially in loops, eg in home.html
1746
            if set(actor.keys()) == {'id'} and proto:
1✔
1747
                actor_obj = proto.load(actor['id'], remote=False)
1✔
1748
                if actor_obj and actor_obj.as1:
1✔
1749
                    actor = actor_obj.as1
1✔
1750

1751
        if not actor:
1✔
1752
            return ''
1✔
1753
        elif set(actor.keys()) == {'id'}:
1✔
1754
            return common.pretty_link(actor['id'], attrs=attrs, user=user)
1✔
1755

1756
        url = as1.get_url(actor)
1✔
1757
        name = actor.get('displayName') or actor.get('username') or ''
1✔
1758
        img_url = util.get_url(actor, 'image')
1✔
1759
        if not image or not img_url:
1✔
1760
            return common.pretty_link(url, text=name, attrs=attrs, user=user)
1✔
1761

1762
        logo = ''
1✔
1763
        if proto:
1✔
1764
            logo = f'<span class="logo" title="{self.__class__.__name__}">{proto.LOGO_HTML or proto.LOGO_EMOJI}</span>'
×
1765

1766
        return f"""\
1✔
1767
        {logo}
1768
        <a class="h-card u-author" href="{url}" title="{name}">
1769
          <img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
1770
          <span style="unicode-bidi: isolate">{util.ellipsize(name, chars=40)}</span>
1771
        </a>"""
1772

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

1776
        ...or None if no such copy exists. If ``proto`` is ``source_protocol``,
1777
        returns this object's key id.
1778

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

1787
        Args:
1788
          proto: :class:`Protocol` subclass
1789

1790
        Returns:
1791
          str:
1792
        """
1793
        copies = self.get_copies(proto)
1✔
1794
        return copies[0] if copies else None
1✔
1795

1796
    def get_copies(self, proto):
1✔
1797
        """Returns all ids of copies of this object in a given protocol.
1798

1799
        If ``proto`` is ``source_protocol``, returns this object's key id.
1800

1801
        Args:
1802
          proto: :class:`Protocol` subclass
1803

1804
        Returns:
1805
          list of str:
1806
        """
1807
        if self.source_protocol in (proto.LABEL, proto.ABBREV):
1✔
1808
            return [self.key.id()]
1✔
1809

1810
        return [copy.uri for copy in self.copies
1✔
1811
                if copy.protocol in (proto.LABEL, proto.ABBREV)]
1812

1813
    def resolve_ids(self):
1✔
1814
        """Replaces "copy" ids, subdomain ids, etc with their originals.
1815

1816
        The end result is that all ids are original "source" ids, ie in the
1817
        protocol that they first came from.
1818

1819
        Specifically, resolves:
1820

1821
        * ids in :class:`User.copies` and :class:`Object.copies`, eg ATProto
1822
          records and Nostr events that we bridged, to the ids of their
1823
          original objects in their source protocol, eg
1824
          ``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
1825
        * Bridgy Fed subdomain URLs to the ids embedded inside them, eg
1826
          ``https://bsky.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
1827
        * ATProto bsky.app URLs to their DIDs or `at://` URIs, eg
1828
          ``https://bsky.app/profile/a.com`` => ``did:plc:123``
1829

1830
        ...in these AS1 fields, in place:
1831

1832
        * ``id``
1833
        * ``actor``
1834
        * ``author``
1835
        * ``object``
1836
        * ``object.actor``
1837
        * ``object.author``
1838
        * ``object.id``
1839
        * ``object.inReplyTo``
1840
        * ``attachments.[objectType=note].id``
1841
        * ``tags.[objectType=mention].url``
1842

1843
        :meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
1844
        Much of the same logic is duplicated there!
1845

1846
        TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
1847
        """
1848
        if not self.as1:
1✔
1849
            return
1✔
1850

1851
        # extract ids, strip Bridgy Fed subdomain URLs
1852
        outer_obj = unwrap(self.as1)
1✔
1853
        if outer_obj != self.as1:
1✔
1854
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1855

1856
        self_proto = PROTOCOLS.get(self.source_protocol)
1✔
1857
        if not self_proto:
1✔
1858
            return
1✔
1859

1860
        logger.debug(f'Resolving ids for {self.key.id()}')
1✔
1861
        inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
1✔
1862
        replaced = False
1✔
1863

1864
        def replace(val, orig_fn):
1✔
1865
            id = val.get('id') if isinstance(val, dict) else val
1✔
1866
            if not id or not self_proto.HAS_COPIES:
1✔
1867
                return id
1✔
1868

1869
            orig = orig_fn(id)
1✔
1870
            if not orig:
1✔
1871
                return val
1✔
1872

1873
            nonlocal replaced
1874
            replaced = True
1✔
1875
            logger.debug(f'Resolved copy id {val} to original {orig.id()}')
1✔
1876

1877
            if isinstance(val, dict) and util.trim_nulls(val).keys() > {'id'}:
1✔
1878
                val['id'] = orig.id()
1✔
1879
                return val
1✔
1880
            else:
1881
                return orig.id()
1✔
1882

1883
        # actually replace ids
1884
        #
1885
        # object field could be either object (eg repost) or actor (eg follow)
1886
        # TODO: handle better
1887
        # https://github.com/snarfed/bridgy-fed/issues/2281
1888
        outer_obj['object'] = replace(inner_obj, get_original_object_key)
1✔
1889
        if not replaced:
1✔
1890
            outer_obj['object'] = replace(inner_obj, get_original_user_key)
1✔
1891

1892
        for obj in outer_obj, inner_obj:
1✔
1893
            for tag in as1.get_objects(obj, 'tags'):
1✔
1894
                if tag.get('objectType') == 'mention':
1✔
1895
                    tag['url'] = replace(tag.get('url'), get_original_user_key)
1✔
1896
            for att in as1.get_objects(obj, 'attachments'):
1✔
1897
                if att.get('objectType') == 'note':
1✔
1898
                    att['id'] = replace(att.get('id'), get_original_object_key)
1✔
1899
            for field, fn in (
1✔
1900
                    ('actor', get_original_user_key),
1901
                    ('author', get_original_user_key),
1902
                    ('inReplyTo', get_original_object_key),
1903
                ):
1904
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1905
                if len(obj[field]) == 1:
1✔
1906
                    obj[field] = obj[field][0]
1✔
1907

1908
        if replaced:
1✔
1909
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1910

1911
    def normalize_ids(self):
1✔
1912
        """Normalizes ids to their protocol's canonical representation, if any.
1913

1914
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
1915
        for profiles, ``at://`` URIs for posts.
1916

1917
        Modifies this object in place.
1918

1919
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
1920
        """
1921
        from protocol import Protocol
1✔
1922

1923
        if not self.as1:
1✔
1924
            return
1✔
1925

1926
        logger.debug(f'Normalizing ids for {self.key.id()}')
1✔
1927
        outer_obj = copy.deepcopy(self.as1)
1✔
1928
        inner_objs = as1.get_objects(outer_obj)
1✔
1929
        replaced = False
1✔
1930

1931
        def replace(val, translate_fn):
1✔
1932
            nonlocal replaced
1933

1934
            orig = val.get('id') if isinstance(val, dict) else val
1✔
1935
            if not orig:
1✔
1936
                return val
1✔
1937

1938
            proto = Protocol.for_id(orig, remote=False)
1✔
1939
            if not proto:
1✔
1940
                return val
1✔
1941

1942
            translated = translate_fn(id=orig, from_=proto, to=proto)
1✔
1943
            if translated and translated != orig:
1✔
1944
                # logger.debug(f'Normalized {proto.LABEL} id {orig} to {translated}')
1945
                replaced = True
1✔
1946
                if isinstance(val, dict):
1✔
1947
                    val['id'] = translated
1✔
1948
                    return val
1✔
1949
                else:
1950
                    return translated
1✔
1951

1952
            return val
1✔
1953

1954
        # actually replace ids
1955
        for obj in [outer_obj] + inner_objs:
1✔
1956
            for tag in as1.get_objects(obj, 'tags'):
1✔
1957
                if tag.get('objectType') == 'mention':
1✔
1958
                    tag['url'] = replace(tag.get('url'), ids.translate_user_id)
1✔
1959
            for field in ['actor', 'author', 'inReplyTo']:
1✔
1960
                fn = (ids.translate_object_id if field == 'inReplyTo'
1✔
1961
                      else ids.translate_user_id)
1962
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1963
                if len(obj[field]) == 1:
1✔
1964
                    obj[field] = obj[field][0]
1✔
1965

1966
        outer_obj['object'] = []
1✔
1967
        for inner_obj in inner_objs:
1✔
1968
            translate_fn = ids.translate_object_id
1✔
1969
            if as1.object_type(outer_obj) in as1.VERBS_WITH_ACTOR_OBJECT:
1✔
1970
                translate_fn = ids.translate_user_id
1✔
1971
            got = replace(inner_obj, translate_fn)
1✔
1972
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
1973
                got = got['id']
1✔
1974

1975
            outer_obj['object'].append(got)
1✔
1976

1977
        if len(outer_obj['object']) == 1:
1✔
1978
            outer_obj['object'] = outer_obj['object'][0]
1✔
1979

1980
        if replaced:
1✔
1981
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1982

1983
    def owner_protocol(self):
1✔
1984
        """Wrapper around :attr:`source_protocol` that handles :class:`UIProtocol`.
1985

1986
        Returns:
1987
          Protocol subclass: :attr:`source_protocol` *unless* it's None or
1988
            :class:`UIProtocol`, in which case infers and returns ``author``'s or
1989
            ``actor``'s protocol instead.
1990
        """
1991
        from protocol import Protocol
1✔
1992

1993
        if self.source_protocol in (None, 'ui'):
1✔
1994
            return Protocol.for_id(as1.get_owner(self.as1))
1✔
1995

1996
        return PROTOCOLS.get(self.source_protocol)
1✔
1997

1998
    @cached_property
1✔
1999
    def domain_blocklist(self):
1✔
2000
        """Returns the domains in the domain blocklist in :attr:`raw` or :attr:`csv`.
2001

2002
        If :attr:`raw` is a list, returns it directly. Otherwise extracts the
2003
        'domain' or '#domain' column from :attr:`csv`.
2004

2005
        TODO: unify with :meth:`filters.blocklist_items`
2006

2007
        Returns:
2008
          list of str: domain names, or empty list if neither :attr:`raw` nor
2009
            :attr:`csv` is populated or parseable.
2010
        """
2011
        assert not (self.raw and self.csv)
1✔
2012

2013
        if self.raw:
1✔
2014
            return [val.split('#')[0].strip().lower() for val in self.raw]
1✔
2015

2016
        if not self.csv:
1✔
2017
            return []
1✔
2018

2019
        try:
1✔
2020
            reader = csv.DictReader(io.StringIO(self.csv))
1✔
2021
        except csv.Error:
×
2022
            return []
×
2023

2024
        if 'domain' in reader.fieldnames:
1✔
2025
            col = 'domain'
1✔
2026
        elif '#domain' in reader.fieldnames:
1✔
2027
            col = '#domain'
1✔
2028
        else:
2029
            return []
1✔
2030

2031
        return [row[col] for row in reader
1✔
2032
                if row[col] and row[col] not in DOMAIN_BLOCKLIST_CANARIES]
2033

2034
    def domain_blocklist_matches(self, user_or_id):
1✔
2035
        """Returns True if ``user_or_id`` is in this domain blocklist, False otherwise.
2036

2037
        For users, looks at id, handle, and delivery target.
2038

2039
        Args:
2040
          user_or_id (User or str)
2041

2042
        Returns:
2043
          bool:
2044

2045
        Raises:
2046
          AssertionError: if this object is not a domain blocklist
2047
        """
2048
        assert self.is_csv or self.csv or isinstance(self.raw, list)
1✔
2049

2050
        if isinstance(user_or_id, User):
1✔
2051
            user = user_or_id
1✔
2052
            inputs = [user.key.id(), user.handle_as_domain]
1✔
2053
            if user.obj:
1✔
2054
                inputs.append(user.target_for(user.obj))
1✔
2055
        else:
2056
            inputs = [user_or_id]
1✔
2057

2058
        for input in inputs:
1✔
2059
            if domain := util.domain_from_link(input):
1✔
2060
                if (util.domain_or_parent_in(domain, self.domain_blocklist)
1✔
2061
                        and not util.domain_or_parent_in(domain, domains.DOMAINS)):
2062
                    logger.info(f'{input} matches domain blocklist {self.key.id()}')
1✔
2063
                    return True
1✔
2064

2065

2066
class Follower(ndb.Model):
1✔
2067
    """A follower of a Bridgy Fed user."""
2068
    STATUSES = ('active', 'inactive', 'dormant')
1✔
2069
    REASONS = ('requested', 'bounce')
1✔
2070

2071
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
2072
    """The follower."""
1✔
2073
    to = ndb.KeyProperty(required=True)
1✔
2074
    """The followee, ie the user being followed."""
1✔
2075

2076
    follow = ndb.KeyProperty(Object)
1✔
2077
    """The last follow activity."""
1✔
2078
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
2079
    """Whether this follow is active or not.
1✔
2080

2081
    ``dormant`` means the followee isn't bridged (yet), so the follow can't be
2082
    delivered. If they enable the bridge, we notify the follower.
2083
    """
2084
    reason = ndb.StringProperty(choices=REASONS)
1✔
2085
    """Optional explanation for this follow's :attr:`status`, eg why it's
1✔
2086
    dormant. One of :attr:`REASONS`."""
2087

2088
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
2089
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
2090

2091
    # OLD. some stored entities still have these; do not reuse.
2092
    # src = ndb.StringProperty()
2093
    # dest = ndb.StringProperty()
2094
    # last_follow = JsonProperty()
2095

2096
    def _pre_put_hook(self):
1✔
2097
        # we're a bridge! stick with bridging.
2098
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
2099

2100
    def _post_put_hook(self, future):
1✔
2101
        logger.debug(f'Wrote {self.key}')
1✔
2102

2103
    @classmethod
1✔
2104
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
2105
        """Returns a Follower with the given ``from_`` and ``to`` users.
2106

2107
        Not transactional because transactions don't read or write memcache. :/
2108
        Fortunately we don't really depend on atomicity for much, last writer wins
2109
        is usually fine.
2110

2111
        If a matching :class:`Follower` doesn't exist in the datastore, creates
2112
        it first.
2113

2114
        Args:
2115
          from_ (User or Key)
2116
          to (User or Key)
2117

2118
        Returns:
2119
          Follower:
2120
        """
2121
        from_key = from_ if isinstance(from_, ndb.Key) else from_.key
1✔
2122
        to_key = to if isinstance(to, ndb.Key) else to.key
1✔
2123

2124
        assert from_key
1✔
2125
        assert to_key
1✔
2126

2127
        follower = Follower.query(Follower.from_ == from_key,
1✔
2128
                                  Follower.to == to_key,
2129
                                  ).get()
2130
        if not follower:
1✔
2131
            follower = Follower(from_=from_key, to=to_key, **kwargs)
1✔
2132
            follower.put()
1✔
2133
        elif kwargs:
1✔
2134
            # update existing entity with new property values, eg to make an
2135
            # inactive Follower active again
2136
            assert not (kwargs.get('status') == 'dormant'
1✔
2137
                        and follower.status == 'active'), \
2138
                f"can't make active Follower {follower.key} dormant"
2139
            for prop, val in kwargs.items():
1✔
2140
                setattr(follower, prop, val)
1✔
2141
            follower.put()
1✔
2142

2143
        return follower
1✔
2144

2145
    @staticmethod
1✔
2146
    def fetch_page(collection, user):
1✔
2147
        r"""Fetches a page of :class:`Follower`\s for a given user.
2148

2149
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
2150
        parameters, if available in the request.
2151

2152
        Args:
2153
          collection (str): ``followers`` or ``following``
2154
          user (User)
2155

2156
        Returns:
2157
          (list of Follower, str, str) tuple: results, annotated with an extra
2158
          ``user`` attribute that holds the follower or following :class:`User`,
2159
          and new str query param values for ``before`` and ``after`` to fetch
2160
          the previous and next pages, respectively
2161
        """
2162
        assert collection in ('followers', 'following'), collection
1✔
2163

2164
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
2165
        query = Follower.query(
1✔
2166
            Follower.status == 'active',
2167
            filter_prop == user.key,
2168
        )
2169

2170
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
2171
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
2172
                              for f in followers)
2173
        User.load_multi(u for u in users if u)
1✔
2174

2175
        for f, u in zip(followers, users):
1✔
2176
            f.user = u
1✔
2177

2178
        followers = [f for f in followers if f.user]
1✔
2179

2180
        # only show followers in protocols that this user is bridged into
2181
        if collection == 'followers':
1✔
2182
            followers = [f for f in followers if user.is_enabled(f.user)]
1✔
2183

2184
        return followers, before, after
1✔
2185

2186

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

2190
    Wraps :func:`fetch_page` and adds attributes to the returned
2191
    :class:`Object` entities for rendering in ``objects.html``.
2192

2193
    Args:
2194
      query (ndb.Query)
2195
      by (ndb.model.Property): either :attr:`Object.updated` or
2196
        :attr:`Object.created`
2197
      user (User): current user
2198

2199
    Returns:
2200
      (list of Object, str, str) tuple:
2201
      (results, new ``before`` query param, new ``after`` query param)
2202
      to fetch the previous and next pages, respectively
2203
    """
2204
    assert by is Object.updated or by is Object.created
1✔
2205
    objects, new_before, new_after = fetch_page(query, Object, by=by)
1✔
2206
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
2207

2208
    # synthesize human-friendly content for objects
2209
    for i, obj in enumerate(objects):
1✔
2210
        obj_as1 = obj.as1
1✔
2211
        type = as1.object_type(obj_as1)
1✔
2212

2213
        # AS1 verb => human-readable phrase
2214
        phrases = {
1✔
2215
            'accept': 'accepted',
2216
            'article': 'posted',
2217
            'comment': 'replied',
2218
            'delete': 'deleted',
2219
            'follow': 'followed',
2220
            'invite': 'is invited to',
2221
            'issue': 'filed issue',
2222
            'like': 'liked',
2223
            'note': 'posted',
2224
            'post': 'posted',
2225
            'repost': 'reposted',
2226
            'rsvp-interested': 'is interested in',
2227
            'rsvp-maybe': 'might attend',
2228
            'rsvp-no': 'is not attending',
2229
            'rsvp-yes': 'is attending',
2230
            'share': 'reposted',
2231
            'stop-following': 'unfollowed',
2232
            'undo': 'undid',
2233
            'update': 'updated',
2234
        }
2235
        phrases.update({type: 'profile refreshed:' for type in as1.ACTOR_TYPES})
1✔
2236

2237
        obj.phrase = phrases.get(type, '')
1✔
2238

2239
        content = (obj_as1.get('content')
1✔
2240
                   or obj_as1.get('displayName')
2241
                   or obj_as1.get('summary'))
2242
        if content:
1✔
2243
            content = util.parse_html(content).get_text()
1✔
2244

2245
        urls = as1.object_urls(obj_as1)
1✔
2246
        url = urls[0] if urls else None
1✔
2247
        if url and not content:
1✔
2248
            # heuristics for sniffing URLs and converting them to more friendly
2249
            # phrases and user handles.
2250
            # TODO: standardize this into granary.as2 somewhere?
2251
            from activitypub import FEDI_URL_RE
×
2252
            from atproto import COLLECTION_TO_TYPE, did_to_handle
×
2253

2254
            handle = suffix = ''
×
2255
            if match := FEDI_URL_RE.match(url):
×
2256
                handle = match.group('handle')
×
2257
                if match.group('post_id'):
×
2258
                    suffix = "'s post"
×
2259
            elif match := BSKY_APP_URL_RE.match(url):
×
2260
                handle = match.group('id')
×
2261
                if match.group('tid'):
×
2262
                    suffix = "'s post"
×
2263
            elif match := AT_URI_RE.match(url):
×
2264
                handle = match.group('repo')
×
2265
                if coll := match.group('collection'):
×
2266
                    suffix = f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
×
2267
                url = bluesky.at_uri_to_web_url(url)
×
UNCOV
2268
            elif url.startswith('did:'):
×
2269
                handle = url
×
2270
                url = bluesky.Bluesky.user_url(handle)
×
2271

2272
            if handle:
×
UNCOV
2273
                if handle.startswith('did:'):
×
2274
                    handle = did_to_handle(handle) or handle
×
2275
                content = f'@{handle}{suffix}'
×
2276

UNCOV
2277
            if url:
×
UNCOV
2278
                content = common.pretty_link(url, text=content, user=user)
×
2279

2280
        obj.content = (obj_as1.get('content')
1✔
2281
                       or obj_as1.get('displayName')
2282
                       or obj_as1.get('summary'))
2283
        obj.url = as1.get_url(obj_as1)
1✔
2284

2285
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
2286
            inner_as1 = as1.get_object(obj_as1)
1✔
2287
            obj.inner_url = as1.get_url(inner_as1) or inner_as1.get('id')
1✔
2288
            if obj.url:
1✔
2289
                obj.phrase = common.pretty_link(
1✔
2290
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
2291
            if content:
1✔
2292
                obj.content = content
1✔
2293
                obj.url = url
1✔
2294
            elif obj.inner_url:
1✔
2295
                obj.content = common.pretty_link(obj.inner_url, max_length=50)
1✔
2296

2297
    return objects, new_before, new_after
1✔
2298

2299

2300
def hydrate(activity, fields=('author', 'actor', 'object')):
1✔
2301
    """Hydrates fields in an AS1 activity, in place.
2302

2303
    Args:
2304
      activity (dict): AS1 activity
2305
      fields (sequence of str): names of fields to hydrate. If they're string ids,
2306
        loads them from the datastore, if possible, and replaces them with their dict
2307
        AS1 objects.
2308

2309
    Returns:
2310
      sequence of :class:`google.cloud.ndb.tasklets.Future`: tasklets for hydrating
2311
        each field. Wait on these before using ``activity``.
2312
    """
2313
    def _hydrate(field):
1✔
2314
        def maybe_set(future):
1✔
2315
            if future.result() and future.result().as1:
1✔
2316
                activity[field] = future.result().as1
1✔
2317
        return maybe_set
1✔
2318

2319
    futures = []
1✔
2320

2321
    for field in fields:
1✔
2322
        val = as1.get_object(activity, field)
1✔
2323
        if val and val.keys() <= set(['id']):
1✔
2324
            # TODO: extract a Protocol class method out of User.profile_id,
2325
            # then use that here instead. the catch is that we'd need to
2326
            # determine Protocol for every id, which is expensive.
2327
            #
2328
            # same TODO is in models.fetch_objects
2329
            id = val['id']
1✔
2330
            if id.startswith('did:'):
1✔
UNCOV
2331
                id = f'at://{id}/app.bsky.actor.profile/self'
×
2332

2333
            future = Object.get_by_id_async(id)
1✔
2334
            future.add_done_callback(_hydrate(field))
1✔
2335
            futures.append(future)
1✔
2336

2337
    return futures
1✔
2338

2339

2340
def fetch_page(query, model_class, by=None):
1✔
2341
    """Fetches a page of results from a datastore query.
2342

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

2346
    Populates a ``log_url_path`` property on each result entity that points to a
2347
    its most recent logged request.
2348

2349
    Args:
2350
      query (google.cloud.ndb.query.Query)
2351
      model_class (class)
2352
      by (ndb.model.Property): paging property, eg :attr:`Object.updated`
2353
        or :attr:`Object.created`
2354

2355
    Returns:
2356
      (list of Object or Follower, str, str) tuple: (results, new_before,
2357
      new_after), where new_before and new_after are query param values for
2358
      ``before`` and ``after`` to fetch the previous and next pages,
2359
      respectively
2360
    """
2361
    assert by
1✔
2362

2363
    # if there's a paging param ('before' or 'after'), update query with it
2364
    # TODO: unify this with Bridgy's user page
2365
    def get_paging_param(param):
1✔
2366
        val = request.values.get(param)
1✔
2367
        if val:
1✔
2368
            try:
1✔
2369
                dt = util.parse_iso8601(val.replace(' ', '+'))
1✔
2370
            except BaseException as e:
1✔
2371
                error(f"Couldn't parse {param}, {val!r} as ISO8601: {e}")
1✔
2372
            if dt.tzinfo:
1✔
2373
                dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
1✔
2374
            return dt
1✔
2375

2376
    before = get_paging_param('before')
1✔
2377
    after = get_paging_param('after')
1✔
2378
    if before and after:
1✔
UNCOV
2379
        error("can't handle both before and after")
×
2380
    elif after:
1✔
2381
        query = query.filter(by >= after).order(by)
1✔
2382
    elif before:
1✔
2383
        query = query.filter(by < before).order(-by)
1✔
2384
    else:
2385
        query = query.order(-by)
1✔
2386

2387
    query_iter = query.iter()
1✔
2388
    results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
1✔
2389
                     key=lambda r: r.updated, reverse=True)
2390

2391
    # calculate new paging param(s)
2392
    has_next = results and query_iter.probably_has_next()
1✔
2393
    new_after = (
1✔
2394
        before if before
2395
        else results[0].updated if has_next and after
2396
        else None)
2397
    if new_after:
1✔
2398
        new_after = new_after.isoformat()
1✔
2399

2400
    new_before = (
1✔
2401
        after if after else
2402
        results[-1].updated if has_next
2403
        else None)
2404
    if new_before:
1✔
2405
        new_before = new_before.isoformat()
1✔
2406

2407
    return results, new_before, new_after
1✔
2408

2409

2410
def load_user(handle_or_id, proto=None, create=False, allow_opt_out=False,
1✔
2411
              raise_=False):
2412
    """Loads a user by handle or id.
2413

2414
    Args:
2415
      handle_or_id (str): user handle or id
2416
      proto (Protocol subclass or None): protocol to use. If None, will try to
2417
        determine protocol via Protocol.for_id and Protocol.for_handle
2418
      create (bool): if True, use get_or_create; if False, use get_by_id
2419
      allow_opt_out (bool): whether to return a user if they're currently opted out
2420
      raise_ (bool): passed through to :meth:`User.reload_profile`. If False, and
2421
        :meth:`User.reload_profile` returns None when fetching the user's profile,
2422
        this method raises :class:`RuntimeError`
2423

2424
    Returns:
2425
      User:
2426

2427
    Raises:
2428
      RuntimeError: if no matching user was found
2429
    """
2430
    import protocol
1✔
2431

2432
    logger.debug(f'loading {handle_or_id}')
1✔
2433

2434
    if not proto or proto is protocol.Protocol:
1✔
2435
        if not (proto := protocol.Protocol.for_id(handle_or_id)):
1✔
2436
            proto, id = protocol.Protocol.for_handle(handle_or_id)
1✔
2437
            if id:
1✔
UNCOV
2438
                handle_or_id = id
×
2439

2440
    if not proto:
1✔
2441
        if handle_or_id.startswith('@'):
1✔
2442
            return load_user(handle_or_id.removeprefix('@'), create=create,
1✔
2443
                             allow_opt_out=allow_opt_out)
2444
        raise RuntimeError(f"Couldn't determine network for {handle_or_id}")
1✔
2445

2446
    if proto.owns_id(handle_or_id) is not False:
1✔
2447
        if proto.LABEL == 'web' and util.is_web(handle_or_id):
1✔
2448
            if not util.is_homepage(handle_or_id):
1✔
2449
                raise RuntimeError(f"{handle_or_id} isn't a web domain or homepage URL")
1✔
2450

2451
        # TODO: handle user vs object ids here. this incorrectly assumes that it's
2452
        # a user id. https://github.com/snarfed/bridgy-fed/issues/2281
2453
        id = ids.normalize_user_id(id=handle_or_id, proto=proto)
1✔
2454
        user = (proto.get_or_create(id, allow_opt_out=allow_opt_out, raise_=raise_)
1✔
2455
                if create else proto.get_by_id(id, allow_opt_out=allow_opt_out))
2456
        if not user:
1✔
2457
            raise RuntimeError(f"Couldn't load {handle_or_id} on {proto.PHRASE}")
1✔
2458
        return user
1✔
2459

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

2462
    if proto.owns_handle(handle_or_id) is False:
1✔
2463
        if handle_or_id.startswith('@'):
1✔
2464
            return load_user(handle_or_id.removeprefix('@'), create=create,
1✔
2465
                             proto=proto, allow_opt_out=allow_opt_out)
2466
        raise RuntimeError(f"{handle_or_id} doesn't look like a user id or handle on {proto.PHRASE}")
1✔
2467

2468
    for user in proto.query(ndb.OR(proto.handle == handle_or_id,
1✔
2469
                                   proto.handle_as_domain == handle_or_id)):
2470
        # some users may have an old handle stored and indexed, but they've changed
2471
        # their handle since then, so check again in memory
2472
        if user.handle == handle_or_id or user.handle_as_domain == handle_or_id:
1✔
2473
            if user.use_instead:
1✔
UNCOV
2474
                logger.debug(f'{user.key} use_instead => {user.use_instead}')
×
UNCOV
2475
                user = user.use_instead.get()
×
2476
            if (not user.status and user.enabled_protocols) or allow_opt_out:
1✔
2477
                return user
1✔
2478

2479
    if create:
1✔
2480
        id = proto.handle_to_id(handle_or_id)
1✔
2481
        if not id:
1✔
2482
            raise RuntimeError(f"{handle_or_id} doesn't look like a handle on {proto.PHRASE}")
1✔
2483
        user = proto.get_or_create(id, allow_opt_out=allow_opt_out, raise_=raise_)
1✔
2484
        if user and user.obj and user.obj.as1:
1✔
2485
            return user
1✔
2486

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

2489

2490
def maybe_truncate_key_id(id):
1✔
2491
    """Returns id, truncated to ``_MAX_KEYPART_BYTES`` bytes if it's longer."""
2492
    encoded = id.encode('utf-8')
1✔
2493
    if len(encoded) > _MAX_KEYPART_BYTES:
1✔
2494
        truncated = encoded[:_MAX_KEYPART_BYTES].decode('utf-8', errors='ignore')
1✔
2495
        logger.warning(f'Truncating id {id} to {_MAX_KEYPART_BYTES} bytes: {truncated}')
1✔
2496
        return truncated
1✔
2497

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

© 2026 Coveralls, Inc