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

snarfed / bridgy-fed / 1bdad4f4-6f9b-4ebb-8b0a-c2e25b6783f4

16 Oct 2025 11:22PM UTC coverage: 92.857% (+0.08%) from 92.779%
1bdad4f4-6f9b-4ebb-8b0a-c2e25b6783f4

push

circleci

snarfed
deps: drop pillow

snarfed/arroba@3f6ad45b9

5928 of 6384 relevant lines covered (92.86%)

0.93 hits per line

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

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

13
from arroba.util import parse_at_uri
1✔
14
import cachetools
1✔
15
from Crypto.PublicKey import RSA
1✔
16
from flask import request
1✔
17
from google.cloud import ndb
1✔
18
from google.cloud.ndb.key import _MAX_KEYPART_BYTES
1✔
19
from granary import as1, as2, atom, bluesky, microformats2
1✔
20
from granary.bluesky import AT_URI_PATTERN, BSKY_APP_URL_RE
1✔
21
import granary.nostr
1✔
22
from granary.source import html_to_text
1✔
23
import humanize
1✔
24
from oauth_dropins.webutil import util
1✔
25
from oauth_dropins.webutil.appengine_info import DEBUG
1✔
26
from oauth_dropins.webutil.flask_util import error
1✔
27
from oauth_dropins.webutil.models import EncryptedProperty, JsonProperty, StringIdModel
1✔
28
from oauth_dropins.webutil.util import ellipsize, json_dumps, json_loads
1✔
29
from requests import RequestException
1✔
30
import secp256k1
1✔
31

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

45
# maps string label to Protocol subclass. values are populated by ProtocolUserMeta.
46
# (we used to wait for ProtocolUserMeta to populate the keys as well, but that was
47
# awkward to use in datastore model properties with choices, below; it required
48
# overriding them in reset_model_properties, which was always flaky.)
49
PROTOCOLS = {label: None for label in (
1✔
50
    'activitypub',
51
    'ap',
52
    'atproto',
53
    'bsky',
54
    'ostatus',
55
    'web',
56
    'webmention',
57
    'ui',
58
)}
59
DEBUG_PROTOCOLS = (
1✔
60
    'fa',
61
    'fake',
62
    'efake',
63
    'other',
64
    # TODO: move to PROTOCOLS for launch
65
    #
66
    # can't do this yet because we create AP server/instance actors automatically
67
    # bridged into all protocols
68
    # activitypub.py:1403
69
    'nostr',
70
)
71
if DEBUG:
1✔
72
    PROTOCOLS.update({label: None for label in DEBUG_PROTOCOLS})
1✔
73

74
# maps string kind (eg 'MagicKey') to Protocol subclass.
75
# populated in ProtocolUserMeta
76
PROTOCOLS_BY_KIND = {}
1✔
77

78
# 2048 bits makes tests slow, so use 1024 for them
79
KEY_BITS = 1024 if DEBUG else 2048
1✔
80
PAGE_SIZE = 20
1✔
81

82
# auto delete most old objects via the Object.expire property
83
# https://cloud.google.com/datastore/docs/ttl
84
#
85
# need to keep follows because we attach them to Followers and use them for
86
# unfollows
87
DONT_EXPIRE_OBJECT_TYPES = (as1.ACTOR_TYPES | as1.POST_TYPES
1✔
88
                            | set(['block', 'flag', 'follow', 'like', 'share']))
89
OBJECT_EXPIRE_AGE = timedelta(days=90)
1✔
90

91
GET_ORIGINALS_CACHE_EXPIRATION = timedelta(days=1)
1✔
92
FOLLOWERS_CACHE_EXPIRATION = timedelta(hours=2)
1✔
93

94
# See https://www.cloudimage.io/
95
IMAGE_PROXY_URL_BASE = 'https://aujtzahimq.cloudimg.io/v7/'
1✔
96
IMAGE_PROXY_DOMAINS = ('threads.net',)
1✔
97

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

114
logger = logging.getLogger(__name__)
1✔
115

116

117
class Target(ndb.Model):
1✔
118
    r""":class:`protocol.Protocol` + URI pairs for identifying objects.
119

120
    These are currently used for:
121

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

127
    Used in :class:`google.cloud.ndb.model.StructuredProperty`\s inside
128
    :class:`Object` and :class:`User`; not stored as top-level entities in the
129
    datastore.
130

131
    ndb implements this by hoisting each property here into a corresponding
132
    property on the parent entity, prefixed by the StructuredProperty name
133
    below, eg ``delivered.uri``, ``delivered.protocol``, etc.
134

135
    For repeated StructuredPropertys, the hoisted properties are all repeated on
136
    the parent entity, and reconstructed into StructuredPropertys based on their
137
    order.
138

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

148
    def __eq__(self, other):
1✔
149
        """Equality excludes Targets' :class:`Key`."""
150
        if isinstance(other, Target):
1✔
151
            return self.uri == other.uri and self.protocol == other.protocol
1✔
152

153
    def __hash__(self):
1✔
154
        """Allow hashing so these can be dict keys."""
155
        return hash((self.protocol, self.uri))
1✔
156

157

158
class DM(ndb.Model):
1✔
159
    """:class:`protocol.Protocol` + type pairs for identifying sent DMs.
160

161
    Used in :attr:`User.sent_dms`.
162

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

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

190
    def __eq__(self, other):
1✔
191
        """Equality excludes Targets' :class:`Key`."""
192
        return self.type == other.type and self.protocol == other.protocol
1✔
193

194

195
class ProtocolUserMeta(type(ndb.Model)):
1✔
196
    """:class:`User` metaclass. Registers all subclasses in ``PROTOCOLS``."""
197
    def __new__(meta, name, bases, class_dict):
1✔
198
        cls = super().__new__(meta, name, bases, class_dict)
1✔
199

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

208
        return cls
1✔
209

210

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

219

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

225
    Note that :meth:`Object.add` also updates this function's
226
    :func:`memcache.memoize` cache.
227

228
    Args:
229
      copy_id (str)
230

231
    Returns:
232
      google.cloud.ndb.Key or None
233
    """
234
    assert copy_id
1✔
235

236
    return Object.query(Object.copies.uri == copy_id).get(keys_only=True)
1✔
237

238

239
@lru_cache(maxsize=100000)
1✔
240
@memcache.memoize(expire=GET_ORIGINALS_CACHE_EXPIRATION)
1✔
241
def get_original_user_key(copy_id):
1✔
242
    """Finds the user with a given copy id, if any.
243

244
    Note that :meth:`User.add` also updates this function's
245
    :func:`memcache.memoize` cache.
246

247
    Args:
248
      copy_id (str)
249

250
    Returns:
251
      google.cloud.ndb.Key or None
252
    """
253
    assert copy_id
1✔
254

255
    for proto in PROTOCOLS.values():
1✔
256
        if proto and proto.LABEL != 'ui' and not proto.owns_id(copy_id):
1✔
257
            if orig := proto.query(proto.copies.uri == copy_id).get(keys_only=True):
1✔
258
                return orig
1✔
259

260

261
class AddRemoveMixin:
1✔
262
    """Mixin class that defines the :meth:`add` and :meth:`remove` methods.
263

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

269
    lock = None
1✔
270
    """Synchronizes :meth:`add`, :meth:`remove`, etc."""
1✔
271

272
    def __init__(self, *args, **kwargs):
1✔
273
        super().__init__(*args, **kwargs)
1✔
274
        self.lock = Lock()
1✔
275

276
    def add(self, prop, val):
1✔
277
        """Adds a value to a multiply-valued property.
278

279
        Args:
280
          prop (str)
281
          val
282

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

289
        if prop == 'copies' and added:
1✔
290
            if fn := getattr(self, 'GET_ORIGINAL_FN'):
1✔
291
                memcache.pickle_memcache.set(memcache.memoize_key(fn, val.uri),
1✔
292
                                             self.key)
293

294
        return added
1✔
295

296
    def remove(self, prop, val):
1✔
297
        """Removes a value from a multiply-valued property.
298

299
        Args:
300
          prop (str)
301
          val
302
        """
303
        with self.lock:
1✔
304
            existing = getattr(self, prop)
1✔
305
            if val in existing:
1✔
306
                existing.remove(val)
1✔
307

308
        if prop == 'copies':
1✔
309
            self.clear_get_original_cache(val.uri)
1✔
310

311
    def remove_copies_on(self, proto):
1✔
312
        """Removes all copies on a given protocol.
313

314
        ``proto.HAS_COPIES`` must be True.
315

316
        Args:
317
          proto (protocol.Protocol subclass)
318
        """
319
        assert proto.HAS_COPIES
1✔
320

321
        for copy in self.copies:
1✔
322
            if copy.protocol in (proto.ABBREV, proto.LABEL):
1✔
323
                self.remove('copies', copy)
1✔
324

325
    @classmethod
1✔
326
    def clear_get_original_cache(cls, uri):
1✔
327
        if fn := getattr(cls, 'GET_ORIGINAL_FN'):
1✔
328
            memcache.pickle_memcache.delete(memcache.memoize_key(fn, uri))
1✔
329

330

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

337
    Stores some protocols' keypairs. Currently:
338

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

350
    obj_key = ndb.KeyProperty(kind='Object')  # user profile
1✔
351
    ''
1✔
352
    use_instead = ndb.KeyProperty()
1✔
353
    ''
1✔
354

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

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

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

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

378
    Protocols that don't require explicit opt in are omitted here.
379
    """
380

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

384
    send_notifs = ndb.StringProperty(default='all', choices=('all', 'none'))
1✔
385
    """Which notifications we should send this user."""
1✔
386

387
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
388
    ''
1✔
389
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
390
    ''
1✔
391

392
    # `existing` attr is set by get_or_create
393

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

401
    def __init__(self, **kwargs):
1✔
402
        """Constructor.
403

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

411
        if obj:
1✔
412
            self.obj = obj
1✔
413

414
    @classmethod
1✔
415
    def new(cls, **kwargs):
1✔
416
        """Try to prevent instantiation. Use subclasses instead."""
417
        raise NotImplementedError()
×
418

419
    def _post_put_hook(self, future):
1✔
420
        logger.debug(f'Wrote {self.key}')
1✔
421

422
    @classmethod
1✔
423
    def get_by_id(cls, id, allow_opt_out=False, **kwargs):
1✔
424
        """Override to follow ``use_instead`` property and ``status``.
425

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

433
        if not user:
1✔
434
            return None
1✔
435

436
        if user.status and not allow_opt_out:
1✔
437
            logger.info(f'{user.key} is {user.status}')
1✔
438
            return None
1✔
439

440
        return user
1✔
441

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

447
        Not transactional because transactions don't read or write memcache. :/
448
        Fortunately we don't really depend on atomicity for much, last writer wins
449
        is usually fine.
450

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

459
        Returns:
460
          User: existing or new user, or None if the user is opted out
461
        """
462
        assert cls != User
1✔
463

464
        user = cls.get_by_id(id, allow_opt_out=True)
1✔
465
        if user:  # existing
1✔
466
            if reload:
1✔
467
                user.reload_profile(gateway=True, raise_=False)
1✔
468

469
            if user.status and not allow_opt_out:
1✔
470
                return None
1✔
471
            user.existing = True
1✔
472

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

482
            if enabled_protocols := kwargs.get('enabled_protocols'):
1✔
483
                user.enabled_protocols = (set(user.enabled_protocols)
1✔
484
                                          | set(enabled_protocols))
485
                changed = True
1✔
486

487
            if not propagate:
1✔
488
                if changed:
1✔
489
                    user.put()
1✔
490
                return user
1✔
491

492
        else:  # new, not existing
493
            if orig_key := get_original_user_key(id):
1✔
494
                orig = orig_key.get()
1✔
495
                if orig.status and not allow_opt_out:
1✔
496
                    return None
×
497
                orig.existing = False
1✔
498
                return orig
1✔
499

500
            user = cls(id=id, **kwargs)
1✔
501
            user.existing = False
1✔
502
            user.reload_profile(gateway=True, raise_=False)
1✔
503
            if user.status and not allow_opt_out:
1✔
504
                return None
1✔
505

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

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

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

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

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

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

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

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

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

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

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

583
        To be implemented by subclasses.
584
        """
585
        raise NotImplementedError()
×
586

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

662
        Args:
663
          to_proto (Protocol subclass)
664
          explicit (bool)
665

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

672
        if self.__class__ == to_proto:
1✔
673
            return True
1✔
674

675
        from_label = self.LABEL
1✔
676
        to_label = to_proto.LABEL
1✔
677

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

681
        elif self.manual_opt_out:
1✔
682
            return False
1✔
683

684
        elif to_label in self.enabled_protocols:
1✔
685
            return True
1✔
686

687
        elif self.status:
1✔
688
            return False
1✔
689

690
        elif to_label in self.DEFAULT_ENABLED_PROTOCOLS and not explicit:
1✔
691
            return True
1✔
692

693
        return False
1✔
694

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

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

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

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

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

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

728
        if to_proto.LABEL not in self.enabled_protocols:
1✔
729
            self.enabled_protocols.append(to_proto.LABEL)
1✔
730
            dms.maybe_send(from_=to_proto, to_user=self, type='welcome', text=f"""Welcome to Bridgy Fed! Your account will soon be bridged to {to_proto.PHRASE} at {self.user_link(proto=to_proto, name=False)}. <a href="https://fed.brid.gy/docs">See the docs</a> and <a href="https://{common.PRIMARY_DOMAIN}{self.user_page_path()}">your user page</a> for more information. To disable this and delete your bridged profile, block this account.""")
1✔
731
            self.put()
1✔
732

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

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

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

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

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

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

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

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

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

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

781
        Args:
782
          to_proto (str or Protocol)
783

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

873
        return self.handle_or_id()
1✔
874

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

878
        To be implemented by subclasses.
879

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

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

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

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

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

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

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

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

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

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

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

926
        Examples:
927

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

932
        Defaults to this user's key id.
933

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

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

942
        Populates the reloaded profile :class:`Object` in ``self.obj``.
943

944
        Args:
945
          kwargs: passed through to :meth:`Protocol.load`
946
        """
947
        obj = self.load(self.profile_id(), remote=True, **kwargs)
1✔
948
        if obj:
1✔
949
            self.obj = obj
1✔
950

951
        # write the user so that we re-populate any computed properties
952
        self.put()
1✔
953

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

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

964
        if rest:
1✔
965
            if not (rest.startswith('?') or rest.startswith('/')):
1✔
966
                path += '/'
1✔
967
            path += rest
1✔
968

969
        return path
1✔
970

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

974
        ...or None if no such copy exists. If ``proto`` is this user, returns
975
        this user's key id.
976

977
        Args:
978
          proto: :class:`Protocol` subclass
979

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

987
        for copy in self.copies:
1✔
988
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
989
                return copy.uri
1✔
990

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

995
        Can optionally include display name, handle, profile
996
        picture, and/or link to a different protocol that they've enabled.
997

998
        TODO: unify with :meth:`Object.actor_link`?
999

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

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

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

1030
        if handle:
1✔
1031
            full_handle = self.handle_as(proto) or ''
1✔
1032
            handle_str = self.handle_as(proto, short=(handle == 'short')) or ''
1✔
1033

1034
        if name and self.name() != full_handle:
1✔
1035
            name_str = self.name() or ''
1✔
1036

1037
        if handle_str and name_str:
1✔
1038
            dot = ' &middot; '
1✔
1039

1040
        if url:
1✔
1041
            a_open = f'<a class="h-card u-author mention" rel="me" href="{url}" title="{name_str}{dot}{full_handle}">'
1✔
1042
            a_close = '</a>'
1✔
1043

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

1047
    def profile_picture(self):
1✔
1048
        """Returns the user's profile picture image URL, if available, or None."""
1049
        if self.obj and self.obj.as1:
1✔
1050
            return util.get_url(self.obj.as1, 'image')
1✔
1051

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

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

1070
        num_followers = Follower.query(Follower.to == self.key,
1✔
1071
                                       Follower.status == 'active')\
1072
                                .count_async()
1073
        num_following = Follower.query(Follower.from_ == self.key,
1✔
1074
                                       Follower.status == 'active')\
1075
                                .count_async()
1076
        return num_followers.get_result(), num_following.get_result()
1✔
1077

1078

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

1085
    Key name is the id, generally a URI. We synthesize ids if necessary.
1086
    """
1087
    GET_ORIGINAL_FN = get_original_object_key
1✔
1088
    'used by AddRemoveMixin'
1✔
1089

1090
    users = ndb.KeyProperty(repeated=True)
1✔
1091
    'User(s) who created or otherwise own this object.'
1✔
1092

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

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

1104
    TODO: nail down whether this is :attr:`ABBREV`` or :attr:`LABEL`
1105
    """
1106

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

1122
    extra_as1 = JsonProperty()
1✔
1123
    "Additional individual fields to merge into this object's AS1 representation"
1✔
1124

1125
    # TODO: remove and actually delete Objects instead!
1126
    deleted = ndb.BooleanProperty()
1✔
1127
    ''
1✔
1128

1129
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
1130
    """Copies of this object elsewhere, eg at:// URIs for ATProto records and
1✔
1131
    nevent etc bech32-encoded Nostr ids, where this object is the original.
1132
    Similar to u-syndication links in microformats2 and
1133
    upstream/downstreamDuplicates in AS1.
1134
    """
1135

1136
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1137
    ''
1✔
1138
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1139
    ''
1✔
1140

1141
    new = None
1✔
1142
    """True if this object is new, ie this is the first time we've seen it,
1✔
1143
    False otherwise, None if we don't know.
1144
    """
1145
    changed = None
1✔
1146
    """True if this object's contents have changed from our existing copy in the
1✔
1147
    datastore, False otherwise, None if we don't know. :class:`Object` is
1148
    new/changed. See :meth:`activity_changed()` for more details.
1149
    """
1150

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

1160
    # DEPRECATED; these were for delivery tracking, but they were too expensive,
1161
    # so we stopped: https://github.com/snarfed/bridgy-fed/issues/1501
1162
    #
1163
    # STATUSES = ('new', 'in progress', 'complete', 'failed', 'ignored')
1164
    # status = ndb.StringProperty(choices=STATUSES)
1165
    # delivered = ndb.StructuredProperty(Target, repeated=True)
1166
    # undelivered = ndb.StructuredProperty(Target, repeated=True)
1167
    # failed = ndb.StructuredProperty(Target, repeated=True)
1168

1169
    # DEPRECATED but still used read only to maintain backward compatibility
1170
    # with old Objects in the datastore that we haven't bothered migrating.
1171
    #
1172
    # domains = ndb.StringProperty(repeated=True)
1173

1174
    # DEPRECATED; replaced by :attr:`users`, :attr:`notify`, :attr:`feed`
1175
    #
1176
    # labels = ndb.StringProperty(repeated=True,
1177
    #                             choices=('activity', 'feed', 'notification', 'user'))
1178

1179
    @property
1✔
1180
    def as1(self):
1✔
1181
        def use_urls_as_ids(obj):
1✔
1182
            """If id field is missing or not a URL, use the url field."""
1183
            id = obj.get('id')
1✔
1184
            if not id or not (util.is_web(id) or re.match(DOMAIN_RE, id)):
1✔
1185
                if url := util.get_url(obj):
1✔
1186
                    obj['id'] = url
1✔
1187

1188
            for field in 'author', 'actor', 'object':
1✔
1189
                if inner := as1.get_object(obj, field):
1✔
1190
                    use_urls_as_ids(inner)
1✔
1191

1192
        if self.our_as1:
1✔
1193
            obj = self.our_as1
1✔
1194
            if self.source_protocol == 'web':
1✔
1195
                use_urls_as_ids(obj)
1✔
1196

1197
        elif self.as2:
1✔
1198
            obj = as2.to_as1(unwrap(self.as2))
1✔
1199

1200
        elif self.bsky:
1✔
1201
            owner, _, _ = parse_at_uri(self.key.id())
1✔
1202
            ATProto = PROTOCOLS['atproto']
1✔
1203
            handle = ATProto(id=owner).handle
1✔
1204
            try:
1✔
1205
                obj = bluesky.to_as1(self.bsky, repo_did=owner, repo_handle=handle,
1✔
1206
                                     uri=self.key.id(), pds=ATProto.pds_for(self))
1207
            except (ValueError, RequestException):
1✔
1208
                logger.info(f"Couldn't convert to ATProto", exc_info=True)
1✔
1209
                return None
1✔
1210

1211
        elif self.mf2:
1✔
1212
            obj = microformats2.json_to_object(self.mf2,
1✔
1213
                                               rel_urls=self.mf2.get('rel-urls'))
1214
            use_urls_as_ids(obj)
1✔
1215

1216
            # use fetched final URL as id, not u-url
1217
            # https://github.com/snarfed/bridgy-fed/issues/829
1218
            if url := self.mf2.get('url'):
1✔
1219
                obj['id'] = (self.key.id() if self.key and '#' in self.key.id()
1✔
1220
                             else url)
1221

1222
        elif self.nostr:
1✔
1223
            obj = granary.nostr.to_as1(self.nostr)
1✔
1224

1225
        else:
1226
            return None
1✔
1227

1228
        # populate id if necessary
1229
        if self.key:
1✔
1230
            obj.setdefault('id', self.key.id())
1✔
1231

1232
        if util.domain_or_parent_in(obj.get('id'), IMAGE_PROXY_DOMAINS):
1✔
1233
           as1.prefix_urls(obj, 'image', IMAGE_PROXY_URL_BASE)
1✔
1234

1235
        if self.extra_as1:
1✔
1236
            obj.update(self.extra_as1)
1✔
1237

1238
        return obj
1✔
1239

1240
    @ndb.ComputedProperty
1✔
1241
    def type(self):  # AS1 objectType, or verb if it's an activity
1✔
1242
        if self.as1:
1✔
1243
            return as1.object_type(self.as1)
1✔
1244

1245
    def _expire(self):
1✔
1246
        """Automatically delete most Objects after a while using a TTL policy.
1247

1248
        https://cloud.google.com/datastore/docs/ttl
1249

1250
        They recommend not indexing TTL properties:
1251
        https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
1252
        """
1253
        now = self.updated or util.now()
1✔
1254
        if self.deleted:
1✔
1255
            return now + timedelta(days=1)
1✔
1256
        elif self.type not in DONT_EXPIRE_OBJECT_TYPES:
1✔
1257
            return now + OBJECT_EXPIRE_AGE
1✔
1258

1259
    expire = ndb.ComputedProperty(_expire, indexed=False)
1✔
1260

1261
    def _pre_put_hook(self):
1✔
1262
        """
1263
        * Validate that at:// URIs have DID repos
1264
        * Set/remove the activity label
1265
        * Strip @context from as2 (we don't do LD) to save disk space
1266
        """
1267
        id = self.key.id()
1✔
1268

1269
        if self.source_protocol not in (None, 'ui'):
1✔
1270
            proto = PROTOCOLS[self.source_protocol]
1✔
1271
            assert proto.owns_id(id) is not False, \
1✔
1272
                f'Protocol {proto.LABEL} does not own id {id}'
1273

1274
        if self.source_protocol == 'nostr':
1✔
1275
            assert id.startswith('nostr:'), id
1✔
1276

1277
        if id.startswith('at://'):
1✔
1278
            repo, _, _ = parse_at_uri(id)
1✔
1279
            if not repo.startswith('did:'):
1✔
1280
                # TODO: if we hit this, that means the AppView gave us an AT URI
1281
                # with a handle repo/authority instead of DID. that's surprising!
1282
                # ...if so, and if we need to handle it, add a new
1283
                # arroba.did.canonicalize_at_uri() function, then use it here,
1284
                # or before.
1285
                raise ValueError(
1✔
1286
                    f'at:// URI ids must have DID repos; got {id}')
1287

1288
        if self.as2:
1✔
1289
           self.as2.pop('@context', None)
1✔
1290
           for field in 'actor', 'attributedTo', 'author', 'object':
1✔
1291
               for val in util.get_list(self.as2, field):
1✔
1292
                   if isinstance(val, dict):
1✔
1293
                       val.pop('@context', None)
1✔
1294

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

1299
    @classmethod
1✔
1300
    def get_by_id(cls, id, authed_as=None, **kwargs):
1✔
1301
        """Fetches the :class:`Object` with the given id, if it exists.
1302

1303
        Args:
1304
          id (str)
1305
          authed_as (str): optional; if provided, and a matching :class:`Object`
1306
            already exists, its ``author`` or ``actor`` must contain this actor
1307
            id. Implements basic authorization for updates and deletes.
1308

1309
        Returns:
1310
          Object:
1311

1312
        Raises:
1313
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1314
            the existing object
1315
        """
1316
        key_id = id
1✔
1317
        if len(key_id) > _MAX_KEYPART_BYTES:
1✔
1318
            # TODO: handle Unicode chars. naive approach is to UTF-8 encode,
1319
            # truncate, then decode, but that might cut mid character. easier to just
1320
            # hope/assume the URL is already URL-encoded.
1321
            key_id = key_id[:_MAX_KEYPART_BYTES]
1✔
1322
            logger.warning(f'Truncating id to {_MAX_KEYPART_BYTES} chars: {key_id}')
1✔
1323

1324
        obj = super().get_by_id(key_id, **kwargs)
1✔
1325

1326
        if obj and obj.as1 and authed_as:
1✔
1327
            # authorization: check that the authed user is allowed to modify
1328
            # this object
1329
            # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1330
            proto = PROTOCOLS.get(obj.source_protocol)
1✔
1331
            assert proto, obj.source_protocol
1✔
1332
            owners = [ids.normalize_user_id(id=owner, proto=proto)
1✔
1333
                      for owner in (as1.get_ids(obj.as1, 'author')
1334
                                    + as1.get_ids(obj.as1, 'actor'))
1335
                                    + [id]]
1336
            if (ids.normalize_user_id(id=authed_as, proto=proto) not in owners
1✔
1337
                    and ids.profile_id(id=authed_as, proto=proto) not in owners):
1338
                report_error("Auth: Object: authed_as doesn't match owner",
1✔
1339
                             user=f'{id} authed_as {authed_as} owners {owners}')
1340
                error(f"authed user {authed_as} isn't object owner {owners}",
1✔
1341
                      status=403)
1342

1343
        return obj
1✔
1344

1345
    @classmethod
1✔
1346
    def get_or_create(cls, id, authed_as=None, **props):
1✔
1347
        """Returns an :class:`Object` with the given property values.
1348

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

1353
        Not transactional because transactions don't read or write memcache. :/
1354
        Fortunately we don't really depend on atomicity for much, last writer wins
1355
        is usually fine.
1356

1357
        Args:
1358
          authed_as (str): optional; if provided, and a matching :class:`Object`
1359
            already exists, its ``author`` or ``actor`` must contain this actor
1360
            id. Implements basic authorization for updates and deletes.
1361

1362
        Returns:
1363
          Object:
1364

1365
        Raises:
1366
          :class:`werkzeug.exceptions.Forbidden` if ``authed_as`` doesn't match
1367
            the existing object
1368
        """
1369
        key_id = id
1✔
1370
        if len(key_id) > _MAX_KEYPART_BYTES:
1✔
1371
            # TODO: handle Unicode chars. naive approach is to UTF-8 encode,
1372
            # truncate, then decode, but that might cut mid character. easier to just
1373
            # hope/assume the URL is already URL-encoded.
1374
            key_id = key_id[:_MAX_KEYPART_BYTES]
1✔
1375
            logger.warning(f'Truncating id to {_MAX_KEYPART_BYTES} chars: {key_id}')
1✔
1376

1377
        obj = cls.get_by_id(key_id, authed_as=authed_as)
1✔
1378

1379
        if not obj:
1✔
1380
            obj = Object(id=key_id, **props)
1✔
1381
            obj.new = True
1✔
1382
            obj.changed = False
1✔
1383
            obj.put()
1✔
1384
            return obj
1✔
1385

1386
        if orig_as1 := obj.as1:
1✔
1387
            # get_by_id() checks authorization if authed_as is set. make sure
1388
            # it's always set for existing objects.
1389
            assert authed_as
1✔
1390

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

1405
        obj.new = False
1✔
1406
        obj.changed = obj.activity_changed(orig_as1)
1✔
1407
        if dirty:
1✔
1408
            obj.put()
1✔
1409
        return obj
1✔
1410

1411
    @staticmethod
1✔
1412
    def from_request():
1✔
1413
        """Creates and returns an :class:`Object` from form-encoded JSON parameters.
1414

1415
        Parameters:
1416
          obj_id (str): id of :class:`models.Object` to handle
1417
          *: If ``obj_id`` is unset, all other parameters are properties for a
1418
            new :class:`models.Object` to handle
1419
        """
1420
        if obj_id := request.form.get('obj_id'):
1✔
1421
            return Object.get_by_id(obj_id)
1✔
1422

1423
        props = {field: request.form.get(field)
1✔
1424
                 for field in ('id', 'source_protocol')}
1425

1426
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'nostr', 'raw':
1✔
1427
            if val := request.form.get(json_prop):
1✔
1428
                props[json_prop] = json_loads(val)
1✔
1429

1430
        obj = Object(**props)
1✔
1431
        if not obj.key and obj.as1:
1✔
1432
            if id := obj.as1.get('id'):
1✔
1433
                obj.key = ndb.Key(Object, id)
1✔
1434

1435
        return obj
1✔
1436

1437
    def to_request(self):
1✔
1438
        """Returns a query parameter dict representing this :class:`Object`."""
1439
        form = {}
1✔
1440

1441
        for json_prop in 'as2', 'bsky', 'mf2', 'our_as1', 'raw':
1✔
1442
            if val := getattr(self, json_prop, None):
1✔
1443
                form[json_prop] = json_dumps(val, sort_keys=True)
1✔
1444

1445
        for prop in ['source_protocol']:
1✔
1446
            if val := getattr(self, prop):
1✔
1447
                form[prop] = val
1✔
1448

1449
        if self.key:
1✔
1450
            form['id'] = self.key.id()
1✔
1451

1452
        return form
1✔
1453

1454
    def activity_changed(self, other_as1):
1✔
1455
        """Returns True if this activity is meaningfully changed from ``other_as1``.
1456

1457
        ...otherwise False.
1458

1459
        Used to populate :attr:`changed`.
1460

1461
        Args:
1462
          other_as1 (dict): AS1 object, or none
1463
        """
1464
        # ignore inReplyTo since we translate it between protocols
1465
        return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
1✔
1466
                if self.as1 and other_as1
1467
                else bool(self.as1) != bool(other_as1))
1468

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

1472
        TODO: unify with :meth:`User.user_link`?
1473

1474
        Args:
1475
          image (bool): whether to include an ``img`` tag with the actor's picture
1476
          sized (bool): whether to set an explicit (``width=32``) size on the
1477
            profile picture ``img`` tag
1478
          user (User): current user
1479

1480
        Returns:
1481
          str:
1482
        """
1483
        attrs = {'class': 'h-card u-author'}
1✔
1484

1485
        if user and user.key in self.users:
1✔
1486
            # outbound; show a nice link to the user
1487
            return user.user_link(handle=False, pictures=True)
1✔
1488

1489
        proto = PROTOCOLS.get(self.source_protocol)
1✔
1490

1491
        actor = None
1✔
1492
        if self.as1:
1✔
1493
            actor = (as1.get_object(self.as1, 'actor')
1✔
1494
                     or as1.get_object(self.as1, 'author'))
1495
            # hydrate from datastore if available
1496
            # TODO: optimize! this is called serially in loops, eg in home.html
1497
            if set(actor.keys()) == {'id'} and self.source_protocol:
1✔
1498
                actor_obj = proto.load(actor['id'], remote=False)
1✔
1499
                if actor_obj and actor_obj.as1:
1✔
1500
                    actor = actor_obj.as1
1✔
1501

1502
        if not actor:
1✔
1503
            return ''
1✔
1504
        elif set(actor.keys()) == {'id'}:
1✔
1505
            return common.pretty_link(actor['id'], attrs=attrs, user=user)
1✔
1506

1507
        url = as1.get_url(actor)
1✔
1508
        name = actor.get('displayName') or actor.get('username') or ''
1✔
1509
        img_url = util.get_url(actor, 'image')
1✔
1510
        if not image or not img_url:
1✔
1511
            return common.pretty_link(url, text=name, attrs=attrs, user=user)
1✔
1512

1513
        logo = ''
1✔
1514
        if proto:
1✔
1515
            logo = f'<span class="logo" title="{self.__class__.__name__}">{proto.LOGO_HTML or proto.LOGO_EMOJI}</span>'
×
1516

1517
        return f"""\
1✔
1518
        {logo}
1519
        <a class="h-card u-author" href="{url}" title="{name}">
1520
          <img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
1521
          <span style="unicode-bidi: isolate">{util.ellipsize(name, chars=40)}</span>
1522
        </a>"""
1523

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

1527
        ...or None if no such copy exists. If ``proto`` is ``source_protocol``,
1528
        returns this object's key id.
1529

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

1538
        Args:
1539
          proto: :class:`Protocol` subclass
1540

1541
        Returns:
1542
          str:
1543
        """
1544
        if self.source_protocol in (proto.LABEL, proto.ABBREV):
1✔
1545
            return self.key.id()
1✔
1546

1547
        for copy in self.copies:
1✔
1548
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
1549
                return copy.uri
1✔
1550

1551
    def resolve_ids(self):
1✔
1552
        """Replaces "copy" ids, subdomain ids, etc with their originals.
1553

1554
        The end result is that all ids are original "source" ids, ie in the
1555
        protocol that they first came from.
1556

1557
        Specifically, resolves:
1558

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

1568
        ...in these AS1 fields, in place:
1569

1570
        * ``id``
1571
        * ``actor``
1572
        * ``author``
1573
        * ``object``
1574
        * ``object.actor``
1575
        * ``object.author``
1576
        * ``object.id``
1577
        * ``object.inReplyTo``
1578
        * ``attachments.[objectType=note].id``
1579
        * ``tags.[objectType=mention].url``
1580

1581
        :meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
1582
        Much of the same logic is duplicated there!
1583

1584
        TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
1585
        """
1586
        if not self.as1:
1✔
1587
            return
1✔
1588

1589
        # extract ids, strip Bridgy Fed subdomain URLs
1590
        outer_obj = unwrap(self.as1)
1✔
1591
        if outer_obj != self.as1:
1✔
1592
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1593

1594
        self_proto = PROTOCOLS.get(self.source_protocol)
1✔
1595
        if not self_proto:
1✔
1596
            return
1✔
1597

1598
        logger.debug(f'Resolving ids for {self.key.id()}')
1✔
1599
        inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
1✔
1600
        replaced = False
1✔
1601

1602
        def replace(val, orig_fn):
1✔
1603
            id = val.get('id') if isinstance(val, dict) else val
1✔
1604
            if not id or not self_proto.HAS_COPIES:
1✔
1605
                return id
1✔
1606

1607
            orig = orig_fn(id)
1✔
1608
            if not orig:
1✔
1609
                return val
1✔
1610

1611
            nonlocal replaced
1612
            replaced = True
1✔
1613
            logger.debug(f'Resolved copy id {val} to original {orig.id()}')
1✔
1614

1615
            if isinstance(val, dict) and util.trim_nulls(val).keys() > {'id'}:
1✔
1616
                val['id'] = orig.id()
1✔
1617
                return val
1✔
1618
            else:
1619
                return orig.id()
1✔
1620

1621
        # actually replace ids
1622
        #
1623
        # object field could be either object (eg repost) or actor (eg follow)
1624
        outer_obj['object'] = replace(inner_obj, get_original_object_key)
1✔
1625
        if not replaced:
1✔
1626
            outer_obj['object'] = replace(inner_obj, get_original_user_key)
1✔
1627

1628
        for obj in outer_obj, inner_obj:
1✔
1629
            for tag in as1.get_objects(obj, 'tags'):
1✔
1630
                if tag.get('objectType') == 'mention':
1✔
1631
                    tag['url'] = replace(tag.get('url'), get_original_user_key)
1✔
1632
            for att in as1.get_objects(obj, 'attachments'):
1✔
1633
                if att.get('objectType') == 'note':
1✔
1634
                    att['id'] = replace(att.get('id'), get_original_object_key)
1✔
1635
            for field, fn in (
1✔
1636
                    ('actor', get_original_user_key),
1637
                    ('author', get_original_user_key),
1638
                    ('inReplyTo', get_original_object_key),
1639
                ):
1640
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1641
                if len(obj[field]) == 1:
1✔
1642
                    obj[field] = obj[field][0]
1✔
1643

1644
        if replaced:
1✔
1645
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1646

1647
    def normalize_ids(self):
1✔
1648
        """Normalizes ids to their protocol's canonical representation, if any.
1649

1650
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
1651
        for profiles, ``at://`` URIs for posts.
1652

1653
        Modifies this object in place.
1654

1655
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
1656
        """
1657
        from protocol import Protocol
1✔
1658

1659
        if not self.as1:
1✔
1660
            return
1✔
1661

1662
        logger.debug(f'Normalizing ids for {self.key.id()}')
1✔
1663
        outer_obj = copy.deepcopy(self.as1)
1✔
1664
        inner_objs = as1.get_objects(outer_obj)
1✔
1665
        replaced = False
1✔
1666

1667
        def replace(val, translate_fn):
1✔
1668
            nonlocal replaced
1669

1670
            orig = val.get('id') if isinstance(val, dict) else val
1✔
1671
            if not orig:
1✔
1672
                return val
1✔
1673

1674
            proto = Protocol.for_id(orig, remote=False)
1✔
1675
            if not proto:
1✔
1676
                return val
1✔
1677

1678
            translated = translate_fn(id=orig, from_=proto, to=proto)
1✔
1679
            if translated and translated != orig:
1✔
1680
                # logger.debug(f'Normalized {proto.LABEL} id {orig} to {translated}')
1681
                replaced = True
1✔
1682
                if isinstance(val, dict):
1✔
1683
                    val['id'] = translated
1✔
1684
                    return val
1✔
1685
                else:
1686
                    return translated
1✔
1687

1688
            return val
1✔
1689

1690
        # actually replace ids
1691
        for obj in [outer_obj] + inner_objs:
1✔
1692
            for tag in as1.get_objects(obj, 'tags'):
1✔
1693
                if tag.get('objectType') == 'mention':
1✔
1694
                    tag['url'] = replace(tag.get('url'), ids.translate_user_id)
1✔
1695
            for field in ['actor', 'author', 'inReplyTo']:
1✔
1696
                fn = (ids.translate_object_id if field == 'inReplyTo'
1✔
1697
                      else ids.translate_user_id)
1698
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1699
                if len(obj[field]) == 1:
1✔
1700
                    obj[field] = obj[field][0]
1✔
1701

1702
        outer_obj['object'] = []
1✔
1703
        for inner_obj in inner_objs:
1✔
1704
            translate_fn = ids.translate_object_id
1✔
1705
            if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
1✔
1706
                    or as1.object_type(outer_obj) in as1.VERBS_WITH_ACTOR_OBJECT):
1707
                translate_fn = ids.translate_user_id
1✔
1708

1709
            got = replace(inner_obj, translate_fn)
1✔
1710
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
1711
                got = got['id']
1✔
1712

1713
            outer_obj['object'].append(got)
1✔
1714

1715
        if len(outer_obj['object']) == 1:
1✔
1716
            outer_obj['object'] = outer_obj['object'][0]
1✔
1717

1718
        if replaced:
1✔
1719
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1720

1721

1722
class Follower(ndb.Model):
1✔
1723
    """A follower of a Bridgy Fed user."""
1724
    STATUSES = ('active', 'inactive')
1✔
1725

1726
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
1727
    """The follower."""
1✔
1728
    to = ndb.KeyProperty(required=True)
1✔
1729
    """The followee, ie the user being followed."""
1✔
1730

1731
    follow = ndb.KeyProperty(Object)
1✔
1732
    """The last follow activity."""
1✔
1733
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
1734
    """Whether this follow is active or not."""
1✔
1735

1736
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1737
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1738

1739
    # OLD. some stored entities still have these; do not reuse.
1740
    # src = ndb.StringProperty()
1741
    # dest = ndb.StringProperty()
1742
    # last_follow = JsonProperty()
1743

1744
    def _pre_put_hook(self):
1✔
1745
        # we're a bridge! stick with bridging.
1746
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
1747

1748
    def _post_put_hook(self, future):
1✔
1749
        logger.debug(f'Wrote {self.key}')
1✔
1750

1751
    @classmethod
1✔
1752
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
1753
        """Returns a Follower with the given ``from_`` and ``to`` users.
1754

1755
        Not transactional because transactions don't read or write memcache. :/
1756
        Fortunately we don't really depend on atomicity for much, last writer wins
1757
        is usually fine.
1758

1759
        If a matching :class:`Follower` doesn't exist in the datastore, creates
1760
        it first.
1761

1762
        Args:
1763
          from_ (User)
1764
          to (User)
1765

1766
        Returns:
1767
          Follower:
1768
        """
1769
        assert from_
1✔
1770
        assert to
1✔
1771

1772
        follower = Follower.query(Follower.from_ == from_.key,
1✔
1773
                                  Follower.to == to.key,
1774
                                  ).get()
1775
        if not follower:
1✔
1776
            follower = Follower(from_=from_.key, to=to.key, **kwargs)
1✔
1777
            follower.put()
1✔
1778
        elif kwargs:
1✔
1779
            # update existing entity with new property values, eg to make an
1780
            # inactive Follower active again
1781
            for prop, val in kwargs.items():
1✔
1782
                setattr(follower, prop, val)
1✔
1783
            follower.put()
1✔
1784

1785
        return follower
1✔
1786

1787
    @staticmethod
1✔
1788
    def fetch_page(collection, user):
1✔
1789
        r"""Fetches a page of :class:`Follower`\s for a given user.
1790

1791
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
1792
        parameters, if available in the request.
1793

1794
        Args:
1795
          collection (str): ``followers`` or ``following``
1796
          user (User)
1797

1798
        Returns:
1799
          (list of Follower, str, str) tuple: results, annotated with an extra
1800
          ``user`` attribute that holds the follower or following :class:`User`,
1801
          and new str query param values for ``before`` and ``after`` to fetch
1802
          the previous and next pages, respectively
1803
        """
1804
        assert collection in ('followers', 'following'), collection
1✔
1805

1806
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1807
        query = Follower.query(
1✔
1808
            Follower.status == 'active',
1809
            filter_prop == user.key,
1810
        )
1811

1812
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
1813
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
1814
                              for f in followers)
1815
        User.load_multi(u for u in users if u)
1✔
1816

1817
        for f, u in zip(followers, users):
1✔
1818
            f.user = u
1✔
1819

1820
        followers = [f for f in followers if f.user]
1✔
1821

1822
        # only show followers in protocols that this user is bridged into
1823
        if collection == 'followers':
1✔
1824
            followers = [f for f in followers if user.is_enabled(f.user)]
1✔
1825

1826
        return followers, before, after
1✔
1827

1828

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

1832
    Wraps :func:`fetch_page` and adds attributes to the returned
1833
    :class:`Object` entities for rendering in ``objects.html``.
1834

1835
    Args:
1836
      query (ndb.Query)
1837
      by (ndb.model.Property): either :attr:`Object.updated` or
1838
        :attr:`Object.created`
1839
      user (User): current user
1840

1841
    Returns:
1842
      (list of Object, str, str) tuple:
1843
      (results, new ``before`` query param, new ``after`` query param)
1844
      to fetch the previous and next pages, respectively
1845
    """
1846
    assert by is Object.updated or by is Object.created
1✔
1847
    objects, new_before, new_after = fetch_page(query, Object, by=by)
1✔
1848
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
1849

1850
    # synthesize human-friendly content for objects
1851
    for i, obj in enumerate(objects):
1✔
1852
        obj_as1 = obj.as1
1✔
1853
        type = as1.object_type(obj_as1)
1✔
1854

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

1879
        obj.phrase = phrases.get(type, '')
1✔
1880

1881
        content = (obj_as1.get('content')
1✔
1882
                   or obj_as1.get('displayName')
1883
                   or obj_as1.get('summary'))
1884
        if content:
1✔
1885
            content = util.parse_html(content).get_text()
1✔
1886

1887
        urls = as1.object_urls(obj_as1)
1✔
1888
        url = urls[0] if urls else None
1✔
1889
        if url and not content:
1✔
1890
            # heuristics for sniffing URLs and converting them to more friendly
1891
            # phrases and user handles.
1892
            # TODO: standardize this into granary.as2 somewhere?
1893
            from activitypub import FEDI_URL_RE
×
1894
            from atproto import COLLECTION_TO_TYPE, did_to_handle
×
1895

1896
            handle = suffix = ''
×
1897
            if match := FEDI_URL_RE.match(url):
×
1898
                handle = match.group(2)
×
1899
                if match.group(4):
×
1900
                    suffix = "'s post"
×
1901
            elif match := BSKY_APP_URL_RE.match(url):
×
1902
                handle = match.group('id')
×
1903
                if match.group('tid'):
×
1904
                    suffix = "'s post"
×
1905
            elif match := AT_URI_PATTERN.match(url):
×
1906
                handle = match.group('repo')
×
1907
                if coll := match.group('collection'):
×
1908
                    suffix = f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
×
1909
                url = bluesky.at_uri_to_web_url(url)
×
1910
            elif url.startswith('did:'):
×
1911
                handle = url
×
1912
                url = bluesky.Bluesky.user_url(handle)
×
1913

1914
            if handle:
×
1915
                if handle.startswith('did:'):
×
1916
                    handle = did_to_handle(handle) or handle
×
1917
                content = f'@{handle}{suffix}'
×
1918

1919
            if url:
×
1920
                content = common.pretty_link(url, text=content, user=user)
×
1921

1922
        obj.content = (obj_as1.get('content')
1✔
1923
                       or obj_as1.get('displayName')
1924
                       or obj_as1.get('summary'))
1925
        obj.url = as1.get_url(obj_as1)
1✔
1926

1927
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
1928
            inner_as1 = as1.get_object(obj_as1)
1✔
1929
            obj.inner_url = as1.get_url(inner_as1) or inner_as1.get('id')
1✔
1930
            if obj.url:
1✔
1931
                obj.phrase = common.pretty_link(
1✔
1932
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
1933
            if content:
1✔
1934
                obj.content = content
1✔
1935
                obj.url = url
1✔
1936
            elif obj.inner_url:
1✔
1937
                obj.content = common.pretty_link(obj.inner_url, max_length=50)
1✔
1938

1939
    return objects, new_before, new_after
1✔
1940

1941

1942
def hydrate(activity, fields=('author', 'actor', 'object')):
1✔
1943
    """Hydrates fields in an AS1 activity, in place.
1944

1945
    Args:
1946
      activity (dict): AS1 activity
1947
      fields (sequence of str): names of fields to hydrate. If they're string ids,
1948
        loads them from the datastore, if possible, and replaces them with their dict
1949
        AS1 objects.
1950

1951
    Returns:
1952
      sequence of :class:`google.cloud.ndb.tasklets.Future`: tasklets for hydrating
1953
        each field. Wait on these before using ``activity``.
1954
    """
1955
    def _hydrate(field):
1✔
1956
        def maybe_set(future):
1✔
1957
            if future.result() and future.result().as1:
1✔
1958
                activity[field] = future.result().as1
1✔
1959
        return maybe_set
1✔
1960

1961
    futures = []
1✔
1962

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

1975
            future = Object.get_by_id_async(id)
1✔
1976
            future.add_done_callback(_hydrate(field))
1✔
1977
            futures.append(future)
1✔
1978

1979
    return futures
1✔
1980

1981

1982
def fetch_page(query, model_class, by=None):
1✔
1983
    """Fetches a page of results from a datastore query.
1984

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

1988
    Populates a ``log_url_path`` property on each result entity that points to a
1989
    its most recent logged request.
1990

1991
    Args:
1992
      query (google.cloud.ndb.query.Query)
1993
      model_class (class)
1994
      by (ndb.model.Property): paging property, eg :attr:`Object.updated`
1995
        or :attr:`Object.created`
1996

1997
    Returns:
1998
      (list of Object or Follower, str, str) tuple: (results, new_before,
1999
      new_after), where new_before and new_after are query param values for
2000
      ``before`` and ``after`` to fetch the previous and next pages,
2001
      respectively
2002
    """
2003
    assert by
1✔
2004

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

2018
    before = get_paging_param('before')
1✔
2019
    after = get_paging_param('after')
1✔
2020
    if before and after:
1✔
2021
        error("can't handle both before and after")
×
2022
    elif after:
1✔
2023
        query = query.filter(by >= after).order(by)
1✔
2024
    elif before:
1✔
2025
        query = query.filter(by < before).order(-by)
1✔
2026
    else:
2027
        query = query.order(-by)
1✔
2028

2029
    query_iter = query.iter()
1✔
2030
    results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
1✔
2031
                     key=lambda r: r.updated, reverse=True)
2032

2033
    # calculate new paging param(s)
2034
    has_next = results and query_iter.probably_has_next()
1✔
2035
    new_after = (
1✔
2036
        before if before
2037
        else results[0].updated if has_next and after
2038
        else None)
2039
    if new_after:
1✔
2040
        new_after = new_after.isoformat()
1✔
2041

2042
    new_before = (
1✔
2043
        after if after else
2044
        results[-1].updated if has_next
2045
        else None)
2046
    if new_before:
1✔
2047
        new_before = new_before.isoformat()
1✔
2048

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

© 2026 Coveralls, Inc