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

snarfed / bridgy-fed / 0b7fc2f6-13ff-44aa-9bd7-3c8afe3e6b40

04 Dec 2024 10:39PM UTC coverage: 92.857% (+0.06%) from 92.801%
0b7fc2f6-13ff-44aa-9bd7-3c8afe3e6b40

push

circleci

snarfed
ATProto.create_for: recreate DNS when we reactivate an inactive account

for #1594

2 of 2 new or added lines in 1 file covered. (100.0%)

14 existing lines in 2 files now uncovered.

4368 of 4704 relevant lines covered (92.86%)

0.93 hits per line

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

95.83
/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 granary import as1, as2, atom, bluesky, microformats2
1✔
19
from granary.bluesky import AT_URI_PATTERN, BSKY_APP_URL_RE
1✔
20
from granary.source import html_to_text
1✔
21
from oauth_dropins.webutil import util
1✔
22
from oauth_dropins.webutil.appengine_info import DEBUG
1✔
23
from oauth_dropins.webutil.flask_util import error
1✔
24
from oauth_dropins.webutil.models import JsonProperty, StringIdModel
1✔
25
from oauth_dropins.webutil.util import ellipsize, json_dumps, json_loads
1✔
26
from requests import RequestException
1✔
27

28
import common
1✔
29
from common import (
1✔
30
    base64_to_long,
31
    DOMAIN_RE,
32
    long_to_base64,
33
    OLD_ACCOUNT_AGE,
34
    report_error,
35
    unwrap,
36
)
37
import ids
1✔
38

39
# maps string label to Protocol subclass. values are populated by ProtocolUserMeta.
40
# (we used to wait for ProtocolUserMeta to populate the keys as well, but that was
41
# awkward to use in datastore model properties with choices, below; it required
42
# overriding them in reset_model_properties, which was always flaky.)
43
PROTOCOLS = {label: None for label in (
1✔
44
    'activitypub',
45
    'ap',
46
    'atproto',
47
    'bsky',
48
    'ostatus',
49
    'web',
50
    'webmention',
51
    'ui',
52
)}
53
if DEBUG:
1✔
54
    PROTOCOLS.update({label: None for label in (
1✔
55
        'fa',
56
        'fake',
57
        'efake',
58
        'other',
59
    )})
60

61
# maps string kind (eg 'MagicKey') to Protocol subclass.
62
# populated in ProtocolUserMeta
63
PROTOCOLS_BY_KIND = {}
1✔
64

65

66
# 2048 bits makes tests slow, so use 1024 for them
67
KEY_BITS = 1024 if DEBUG else 2048
1✔
68
PAGE_SIZE = 20
1✔
69

70
# auto delete old objects of these types via the Object.expire property
71
# https://cloud.google.com/datastore/docs/ttl
72
OBJECT_EXPIRE_TYPES = (
1✔
73
    'accept',
74
    'block',
75
    'delete',
76
    'post',
77
    'reject',
78
    'undo',
79
    'update',
80
    None,
81
)
82
OBJECT_EXPIRE_AGE = timedelta(days=90)
1✔
83

84
logger = logging.getLogger(__name__)
1✔
85

86

87
class Target(ndb.Model):
1✔
88
    r""":class:`protocol.Protocol` + URI pairs for identifying objects.
89

90
    These are currently used for:
91

92
    * delivery destinations, eg ActivityPub inboxes, webmention targets, etc.
93
    * copies of :class:`Object`\s and :class:`User`\s elsewhere,
94
      eg ``at://`` URIs for ATProto records, nevent etc bech32-encoded Nostr ids,
95
      ATProto user DIDs, etc.
96

97
    Used in :class:`google.cloud.ndb.model.StructuredProperty`\s inside
98
    :class:`Object` and :class:`User`; not stored as top-level entities in the
99
    datastore.
100

101
    ndb implements this by hoisting each property here into a corresponding
102
    property on the parent entity, prefixed by the StructuredProperty name
103
    below, eg ``delivered.uri``, ``delivered.protocol``, etc.
104

105
    For repeated StructuredPropertys, the hoisted properties are all repeated on
106
    the parent entity, and reconstructed into StructuredPropertys based on their
107
    order.
108

109
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
110
    """
111
    uri = ndb.StringProperty(required=True)
1✔
112
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
113

114
    def __eq__(self, other):
1✔
115
        """Equality excludes Targets' :class:`Key`."""
116
        return self.uri == other.uri and self.protocol == other.protocol
1✔
117

118
    def __hash__(self):
1✔
119
        """Allow hashing so these can be dict keys."""
120
        return hash((self.protocol, self.uri))
1✔
121

122

123
class DM(ndb.Model):
1✔
124
    """:class:`protocol.Protocol` + type pairs for identifying sent DMs.
125

126
    Used in :attr:`User.sent_dms`.
127

128
    https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
129
    """
130
    TYPES = (
1✔
131
        'request_bridging',
132
        'replied_to_bridged_user',
133
        'welcome',
134
    )
135
    type = ndb.StringProperty(choices=TYPES, required=True)
1✔
136
    protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
1✔
137

138
    def __eq__(self, other):
1✔
139
        """Equality excludes Targets' :class:`Key`."""
140
        return self.type == other.type and self.protocol == other.protocol
1✔
141

142

143
class ProtocolUserMeta(type(ndb.Model)):
1✔
144
    """:class:`User` metaclass. Registers all subclasses in the ``PROTOCOLS`` global."""
145
    def __new__(meta, name, bases, class_dict):
1✔
146
        cls = super().__new__(meta, name, bases, class_dict)
1✔
147

148
        if hasattr(cls, 'LABEL') and cls.LABEL not in ('protocol', 'user'):
1✔
149
            for label in (cls.LABEL, cls.ABBREV) + cls.OTHER_LABELS:
1✔
150
                if label:
1✔
151
                    PROTOCOLS[label] = cls
1✔
152

153
        PROTOCOLS_BY_KIND[cls._get_kind()] = cls
1✔
154

155
        return cls
1✔
156

157

158
def reset_protocol_properties():
1✔
159
    """Recreates various protocol properties to include choices from ``PROTOCOLS``."""
160
    abbrevs = f'({"|".join(PROTOCOLS.keys())}|fed)'
1✔
161
    common.SUBDOMAIN_BASE_URL_RE = re.compile(
1✔
162
        rf'^https?://({abbrevs}\.brid\.gy|localhost(:8080)?)/(convert/|r/)?({abbrevs}/)?(?P<path>.+)')
163
    ids.COPIES_PROTOCOLS = tuple(label for label, proto in PROTOCOLS.items()
1✔
164
                                 if proto and proto.HAS_COPIES)
165

166

167
class User(StringIdModel, metaclass=ProtocolUserMeta):
1✔
168
    """Abstract base class for a Bridgy Fed user.
169

170
    Stores some protocols' keypairs. Currently:
171

172
    * RSA keypair for ActivityPub HTTP Signatures
173
      properties: ``mod``, ``public_exponent``, ``private_exponent``, all
174
      encoded as base64url (ie URL-safe base64) strings as described in RFC
175
      4648 and section 5.1 of the Magic Signatures spec:
176
      https://tools.ietf.org/html/draft-cavage-http-signatures-12
177
    * *Not* K-256 signing or rotation keys for AT Protocol, those are stored in
178
      :class:`arroba.datastore_storage.AtpRepo` entities
179
    """
180
    obj_key = ndb.KeyProperty(kind='Object')  # user profile
1✔
181
    mod = ndb.StringProperty()
1✔
182
    use_instead = ndb.KeyProperty()
1✔
183

184
    # Proxy copies of this user elsewhere, eg DIDs for ATProto records, bech32
185
    # npub Nostr ids, etc. Similar to rel-me links in microformats2, alsoKnownAs
186
    # in DID docs (and now AS2), etc.
187
    # TODO: switch to using Object.copies on the user profile object?
188
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
189

190
    # whether this user signed up or otherwise explicitly, deliberately
191
    # interacted with Bridgy Fed. For example, if fediverse user @a@b.com looks
192
    # up @foo.com@fed.brid.gy via WebFinger, we'll create Users for both,
193
    # @a@b.com will be direct, foo.com will not.
194
    direct = ndb.BooleanProperty(default=False)
1✔
195

196
    # these are for ActivityPub HTTP Signatures
197
    public_exponent = ndb.StringProperty()
1✔
198
    private_exponent = ndb.StringProperty()
1✔
199

200
    # set to True for users who asked me to be opted out instead of putting
201
    # #nobridge in their profile
202
    manual_opt_out = ndb.BooleanProperty()
1✔
203

204
    # protocols that this user has explicitly opted into. protocols that don't
205
    # require explicit opt in are omitted here. choices is populated in
206
    # reset_protocol_properties.
207
    enabled_protocols = ndb.StringProperty(repeated=True, choices=list(PROTOCOLS.keys()))
1✔
208

209
    # DMs that we've attempted to send to this user
210
    sent_dms = ndb.StructuredProperty(DM, repeated=True)
1✔
211

212
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
213
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
214

215
    # `existing` attr is set by get_or_create
216

217
    # OLD. some stored entities still have these; do not reuse.
218
    # actor_as2 = JsonProperty()
219
    # protocol-specific state
220
    # atproto_notifs_indexed_at = ndb.TextProperty()
221
    # atproto_feed_indexed_at = ndb.TextProperty()
222

223
    def __init__(self, **kwargs):
1✔
224
        """Constructor.
225

226
        Sets :attr:`obj` explicitly because however
227
        :class:`google.cloud.ndb.model.Model` sets it doesn't work with
228
        ``@property`` and ``@obj.setter`` below.
229
        """
230
        obj = kwargs.pop('obj', None)
1✔
231
        super().__init__(**kwargs)
1✔
232

233
        if obj:
1✔
234
            self.obj = obj
1✔
235

236
    @classmethod
1✔
237
    def new(cls, **kwargs):
1✔
238
        """Try to prevent instantiation. Use subclasses instead."""
239
        raise NotImplementedError()
×
240

241
    def _post_put_hook(self, future):
1✔
242
        logger.debug(f'Wrote {self.key}')
1✔
243

244
    @classmethod
1✔
245
    def get_by_id(cls, id, allow_opt_out=False, **kwargs):
1✔
246
        """Override to follow ``use_instead`` property and ``opt-out` status.
247

248
        Returns None if the user is opted out.
249
        """
250
        user = cls._get_by_id(id, **kwargs)
1✔
251
        if not user:
1✔
252
            return None
1✔
253
        elif user.use_instead:
1✔
254
            logger.info(f'{user.key} use_instead => {user.use_instead}')
1✔
255
            return user.use_instead.get()
1✔
256
        elif user.status and not allow_opt_out:
1✔
257
            logger.info(f'{user.key} is {user.status}')
1✔
258
            return None
1✔
259

260
        return user
1✔
261

262
    @classmethod
1✔
263
    def get_or_create(cls, id, propagate=False, allow_opt_out=False,
1✔
264
                      reload=False, **kwargs):
265
        """Loads and returns a :class:`User`. Creates it if necessary.
266

267
        Args:
268
          propagate (bool): whether to create copies of this user in push-based
269
            protocols, eg ATProto and Nostr.
270
          allow_opt_out (bool): whether to allow and create the user if they're
271
            currently opted out
272
          reload (bool): whether to reload profile always, vs only if necessary
273
          kwargs: passed through to ``cls`` constructor
274

275
        Returns:
276
          User: existing or new user, or None if the user is opted out
277
        """
278
        assert cls != User
1✔
279

280
        @ndb.transactional()
1✔
281
        def _run():
1✔
282
            user = cls.get_by_id(id, allow_opt_out=True)
1✔
283
            if user:
1✔
284
                if reload:
1✔
285
                    user.reload_profile(gateway=True, raise_=False)
1✔
286

287
                if user.status and not allow_opt_out:
1✔
288
                    return None
1✔
289
                user.existing = True
1✔
290

291
                # TODO: propagate more fields?
292
                changed = False
1✔
293
                for field in ['direct', 'obj', 'obj_key']:
1✔
294
                    old_val = getattr(user, field, None)
1✔
295
                    new_val = kwargs.get(field)
1✔
296
                    if ((old_val is None and new_val is not None)
1✔
297
                            or (field == 'direct' and not old_val and new_val)):
298
                        setattr(user, field, new_val)
1✔
299
                        changed = True
1✔
300

301
                if enabled_protocols := kwargs.get('enabled_protocols'):
1✔
302
                    user.enabled_protocols = (set(user.enabled_protocols)
1✔
303
                                              | set(enabled_protocols))
304
                    changed = True
1✔
305

306
                if not propagate:
1✔
307
                    if changed:
1✔
308
                        user.put()
1✔
309
                    return user
1✔
310

311
            else:
312
                if orig_key := get_original_user_key(id):
1✔
313
                    orig = orig_key.get()
1✔
314
                    if orig.status and not allow_opt_out:
1✔
315
                        return None
×
316
                    orig.existing = False
1✔
317
                    return orig
1✔
318

319
                user = cls(id=id, **kwargs)
1✔
320
                user.existing = False
1✔
321
                user.reload_profile(gateway=True, raise_=False)
1✔
322
                if user.status and not allow_opt_out:
1✔
323
                    return None
1✔
324

325
            if propagate and not user.status:
1✔
326
                for label in user.enabled_protocols + list(user.DEFAULT_ENABLED_PROTOCOLS):
1✔
327
                    proto = PROTOCOLS[label]
1✔
328
                    if proto == cls:
1✔
329
                        continue
×
330
                    elif proto.HAS_COPIES:
1✔
331
                        if not user.get_copy(proto) and user.is_enabled(proto):
1✔
332
                            try:
1✔
333
                                proto.create_for(user)
1✔
334
                            except (ValueError, AssertionError):
1✔
335
                                logger.info(f'failed creating {proto.LABEL} copy',
1✔
336
                                            exc_info=True)
337
                                util.remove(user.enabled_protocols, proto.LABEL)
1✔
338
                        else:
339
                            logger.debug(f'{proto.LABEL} not enabled or user copy already exists, skipping propagate')
1✔
340

341
            # generate keys for all protocols _except_ our own
342
            #
343
            # these can use urandom() and do nontrivial math, so they can take time
344
            # depending on the amount of randomness available and compute needed.
345
            if not user.existing and cls.LABEL != 'activitypub':
1✔
346
                key = RSA.generate(KEY_BITS,
1✔
347
                                   randfunc=random.randbytes if DEBUG else None)
348
                user.mod = long_to_base64(key.n)
1✔
349
                user.public_exponent = long_to_base64(key.e)
1✔
350
                user.private_exponent = long_to_base64(key.d)
1✔
351

352
            try:
1✔
353
                user.put()
1✔
354
            except AssertionError as e:
×
355
                error(f'Bad {cls.__name__} id {id} : {e}')
×
356

357
            return user
1✔
358

359
        user = _run()
1✔
360

361
        # load and propagate user and profile object
362
        if user:
1✔
363
            logger.debug(('Updated ' if user.existing else 'Created new ') + str(user))
1✔
364

365
        return user
1✔
366

367
    @property
1✔
368
    def obj(self):
1✔
369
        """Convenience accessor that loads :attr:`obj_key` from the datastore."""
370
        if self.obj_key:
1✔
371
            if not hasattr(self, '_obj'):
1✔
372
                self._obj = self.obj_key.get()
1✔
373
            return self._obj
1✔
374

375
    @obj.setter
1✔
376
    def obj(self, obj):
1✔
377
        if obj:
1✔
378
            assert isinstance(obj, Object)
1✔
379
            assert obj.key
1✔
380
            self._obj = obj
1✔
381
            self.obj_key = obj.key
1✔
382
        else:
383
            self._obj = self.obj_key = None
1✔
384

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

388
        Args:
389
          proto (Protocol): optional
390
        """
391
        now = util.now().isoformat()
1✔
392
        proto_label = proto.LABEL if proto else 'all'
1✔
393
        delete_id = f'{self.profile_id()}#delete-user-{proto_label}-{now}'
1✔
394
        delete = Object(id=delete_id, source_protocol=self.LABEL, our_as1={
1✔
395
            'id': delete_id,
396
            'objectType': 'activity',
397
            'verb': 'delete',
398
            'actor': self.key.id(),
399
            'object': self.key.id(),
400
        })
401
        delete.put()
1✔
402
        self.deliver(delete, from_user=self, to_proto=proto)
1✔
403

404
    @classmethod
1✔
405
    def load_multi(cls, users):
1✔
406
        """Loads :attr:`obj` for multiple users in parallel.
407

408
        Args:
409
          users (sequence of User)
410
        """
411
        objs = ndb.get_multi(u.obj_key for u in users if u.obj_key)
1✔
412
        keys_to_objs = {o.key: o for o in objs if o}
1✔
413

414
        for u in users:
1✔
415
            u._obj = keys_to_objs.get(u.obj_key)
1✔
416

417
    @ndb.ComputedProperty
1✔
418
    def handle(self):
1✔
419
        """This user's unique, human-chosen handle, eg ``@me@snarfed.org``.
420

421
        To be implemented by subclasses.
422
        """
423
        raise NotImplementedError()
×
424

425
    @ndb.ComputedProperty
1✔
426
    def readable_id(self):
1✔
427
        """DEPRECATED: replaced by handle. Kept for backward compatibility."""
428
        return None
1✔
429

430
    @ndb.ComputedProperty
1✔
431
    def status(self):
1✔
432
        """Whether this user is blocked or opted out.
433

434
        Optional. Current possible values:
435
          * ``opt-out``: if ``#nobridge`` or ``#nobot`` is in the profile
436
            description/bio, or if the user or domain has manually opted out.
437
            Some protocols also have protocol-specific opt out logic, eg Bluesky
438
            accounts that have disabled logged out view.
439
          * ``blocked``: if the user fails our validation checks, eg
440
            ``REQUIRES_NAME`` or ``REQUIRES_AVATAR`` if either of those are
441
            ``True` for this protocol.
442
          * `owns-webfinger`: a :class:`web.Web` user that looks like a
443
            fediverse server
444
          * `no-feed-or-webmention`: a :class:`web.Web` user that doesn't have
445
            an RSS or Atom feed or webmention endpoint and has never sent us a
446
            webmention
447

448
        Duplicates ``util.is_opt_out`` in Bridgy!
449

450
        https://github.com/snarfed/bridgy-fed/issues/666
451
        """
452
        if self.manual_opt_out:
1✔
453
            return 'opt-out'
1✔
454

455
        if not self.obj or not self.obj.as1:
1✔
456
            return None
1✔
457

458
        if self.REQUIRES_AVATAR and not self.obj.as1.get('image'):
1✔
459
            return 'blocked'
1✔
460

461
        name = self.obj.as1.get('displayName')
1✔
462
        if self.REQUIRES_NAME and (not name or name in (self.handle, self.key.id())):
1✔
463
            return 'blocked'
1✔
464

465
        if self.REQUIRES_OLD_ACCOUNT:
1✔
466
            if published := self.obj.as1.get('published'):
1✔
467
                if util.now() - util.parse_iso8601(published) < OLD_ACCOUNT_AGE:
1✔
468
                    return 'blocked'
1✔
469

470
        summary = html_to_text(self.obj.as1.get('summary', ''), ignore_links=True)
1✔
471
        name = self.obj.as1.get('displayName', '')
1✔
472

473
        # #nobridge overrides enabled_protocols
474
        if '#nobridge' in summary or '#nobridge' in name:
1✔
475
            return 'opt-out'
1✔
476

477
        # user has explicitly opted in. should go after spam filter (REQUIRES_*)
478
        # checks, but before is_public and #nobot
479
        if self.enabled_protocols:
1✔
480
            return None
1✔
481

482
        if not as1.is_public(self.obj.as1, unlisted=False):
1✔
483
            return 'opt-out'
1✔
484

485
        # enabled_protocols overrides #nobot
486
        if '#nobot' in summary or '#nobot' in name:
1✔
487
            return 'opt-out'
1✔
488

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

492
        Reasons this might return False:
493
        * We haven't turned on bridging these two protocols yet.
494
        * The user is opted out or blocked.
495
        * The user is on a domain that's opted out or blocked.
496
        * The from protocol requires opt in, and the user hasn't opted in.
497
        * ``explicit`` is True, and this protocol supports ``to_proto`` by
498
          default, but the user hasn't explicitly opted into it.
499

500
        Args:
501
          to_proto (Protocol subclass)
502
          explicit (bool)
503

504
        Returns:
505
          bool:
506
        """
507
        from protocol import Protocol
1✔
508
        assert issubclass(to_proto, Protocol)
1✔
509

510
        if self.__class__ == to_proto:
1✔
511
            return True
1✔
512

513
        from_label = self.LABEL
1✔
514
        to_label = to_proto.LABEL
1✔
515

516
        if bot_protocol := Protocol.for_bridgy_subdomain(self.key.id()):
1✔
517
            return to_proto != bot_protocol
1✔
518

519
        elif self.manual_opt_out:
1✔
520
            return False
1✔
521

522
        elif to_label in self.enabled_protocols:
1✔
523
            return True
1✔
524

525
        elif self.status:
1✔
526
            return False
1✔
527

528
        elif to_label in self.DEFAULT_ENABLED_PROTOCOLS and not explicit:
1✔
529
            return True
1✔
530

531
        return False
1✔
532

533
    def enable_protocol(self, to_proto):
1✔
534
        """Adds ``to_proto` to :attr:`enabled_protocols`.
535

536
        Also sends a welcome DM to the user (via a send task) if their protocol
537
        supports DMs.
538

539
        Args:
540
          to_proto (:class:`protocol.Protocol` subclass)
541
        """
542
        added = False
1✔
543

544
        if to_proto.LABEL in ids.COPIES_PROTOCOLS:
1✔
545
            # do this even if there's an existing copy since we might need to
546
            # reactivate it, which create_for should do
547
            to_proto.create_for(self)
1✔
548

549
        @ndb.transactional()
1✔
550
        def enable():
1✔
551
            user = self.key.get()
1✔
552
            if to_proto.LABEL not in user.enabled_protocols:
1✔
553
                user.enabled_protocols.append(to_proto.LABEL)
1✔
554
                util.add(user.sent_dms, DM(protocol=to_proto.LABEL, type='welcome'))
1✔
555
                user.put()
1✔
556
                nonlocal added
557
                added = True
1✔
558

559
            return user
1✔
560

561
        new_self = enable()
1✔
562
        # populate newly enabled protocol in this instance
563
        self.enabled_protocols = new_self.enabled_protocols
1✔
564
        self.copies = new_self.copies
1✔
565
        if self.obj:
1✔
566
            self.obj.copies = new_self.obj.copies
1✔
567

568
        if added:
1✔
569
            import dms
1✔
570
            dms.maybe_send(from_proto=to_proto, to_user=self, type='welcome',
1✔
571
                           text=f"""\
572
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.""")
573

574
        msg = f'Enabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
575
        logger.info(msg)
1✔
576

577
    def disable_protocol(self, to_proto):
1✔
578
        """Removes ``to_proto` from :attr:`enabled_protocols`.
579

580
        Args:
581
          to_proto (:class:`protocol.Protocol` subclass)
582
        """
583
        @ndb.transactional()
1✔
584
        def disable():
1✔
585
            user = self.key.get()
1✔
586
            util.remove(user.enabled_protocols, to_proto.LABEL)
1✔
587
            user.put()
1✔
588

589
        disable()
1✔
590
        util.remove(self.enabled_protocols, to_proto.LABEL)
1✔
591

592
        msg = f'Disabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
1✔
593
        logger.info(msg)
1✔
594

595
    def handle_as(self, to_proto):
1✔
596
        """Returns this user's handle in a different protocol.
597

598
        Args:
599
          to_proto (str or Protocol)
600

601
        Returns:
602
          str
603
        """
604
        if isinstance(to_proto, str):
1✔
605
            to_proto = PROTOCOLS[to_proto]
1✔
606

607
        # override to-ATProto to use custom domain handle in DID doc
608
        from atproto import ATProto, did_to_handle
1✔
609
        if to_proto == ATProto:
1✔
610
            if did := self.get_copy(ATProto):
1✔
611
                if handle := did_to_handle(did, remote=False):
1✔
612
                    return handle
1✔
613

614
        # override web users to always use domain instead of custom username
615
        # TODO: fall back to id if handle is unset?
616
        handle = self.key.id() if self.LABEL == 'web' else self.handle
1✔
617
        if not handle:
1✔
618
            return None
1✔
619

620
        return ids.translate_handle(handle=handle, from_=self.__class__,
1✔
621
                                    to=to_proto, enhanced=False)
622

623
    def id_as(self, to_proto):
1✔
624
        """Returns this user's id in a different protocol.
625

626
        Args:
627
          to_proto (str or Protocol)
628

629
        Returns:
630
          str
631
        """
632
        if isinstance(to_proto, str):
1✔
633
            to_proto = PROTOCOLS[to_proto]
1✔
634

635
        return ids.translate_user_id(id=self.key.id(), from_=self.__class__,
1✔
636
                                     to=to_proto)
637

638
    def handle_or_id(self):
1✔
639
        """Returns handle if we know it, otherwise id."""
640
        return self.handle or self.key.id()
1✔
641

642
    def public_pem(self):
1✔
643
        """
644
        Returns:
645
          bytes:
646
        """
647
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
648
                             base64_to_long(str(self.public_exponent))))
649
        return rsa.exportKey(format='PEM')
1✔
650

651
    def private_pem(self):
1✔
652
        """
653
        Returns:
654
          bytes:
655
        """
656
        assert self.mod and self.public_exponent and self.private_exponent, str(self)
1✔
657
        rsa = RSA.construct((base64_to_long(str(self.mod)),
1✔
658
                             base64_to_long(str(self.public_exponent)),
659
                             base64_to_long(str(self.private_exponent))))
660
        return rsa.exportKey(format='PEM')
1✔
661

662
    def name(self):
1✔
663
        """Returns this user's human-readable name, eg ``Ryan Barrett``."""
664
        if self.obj and self.obj.as1:
1✔
665
            name = self.obj.as1.get('displayName')
1✔
666
            if name:
1✔
667
                return name
1✔
668

669
        return self.handle_or_id()
1✔
670

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

674
        To be implemented by subclasses.
675

676
        Returns:
677
          str
678
        """
679
        raise NotImplementedError()
×
680

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

684
        Args:
685
          url (str)
686
          ignore_www (bool): if True, ignores ``www.`` subdomains
687

688
        Returns:
689
          bool:
690
        """
691
        if not url:
1✔
692
            return False
1✔
693

694
        url = url.strip().rstrip('/')
1✔
695
        url = re.sub(r'^(https?://)www\.', r'\1', url)
1✔
696
        parsed_url = urlparse(url)
1✔
697
        if parsed_url.scheme not in ('http', 'https', ''):
1✔
698
            return False
1✔
699

700
        this = self.web_url().rstrip('/')
1✔
701
        this = re.sub(r'^(https?://)www\.', r'\1', this)
1✔
702
        parsed_this = urlparse(this)
1✔
703

704
        return (url == this or url == parsed_this.netloc or
1✔
705
                parsed_url[1:] == parsed_this[1:])  # ignore http vs https
706

707
    def id_uri(self):
1✔
708
        """Returns the user id as a URI.
709

710
        Sometimes this is the user id itself, eg ActivityPub actor ids.
711
        Sometimes it's a bit different, eg at://did:plc:... for ATProto user,
712
        https://site.com for Web users.
713

714
        Returns:
715
          str
716
        """
717
        return self.key.id()
1✔
718

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

722
        Examples:
723

724
        * Web: home page URL, eg ``https://me.com/``
725
        * ActivityPub: actor URL, eg ``https://instance.com/users/me``
726
        * ATProto: profile AT URI, eg ``at://did:plc:123/app.bsky.actor.profile/self``
727

728
        Defaults to this user's key id.
729

730
        Returns:
731
          str or None:
732
        """
733
        return ids.profile_id(id=self.key.id(), proto=self)
1✔
734

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

738
        Populates the reloaded profile :class:`Object` in ``self.obj``.
739

740
        Args:
741
          kwargs: passed through to :meth:`Protocol.load`
742
        """
743
        obj = self.load(self.profile_id(), remote=True, **kwargs)
1✔
744
        if obj:
1✔
745
            self.obj = obj
1✔
746

747
    def user_page_path(self, rest=None):
1✔
748
        """Returns the user's Bridgy Fed user page path."""
749
        path = f'/{self.ABBREV}/{self.handle_or_id()}'
1✔
750

751
        if rest:
1✔
752
            if not rest.startswith('?'):
1✔
753
                path += '/'
1✔
754
            path += rest
1✔
755

756
        return path
1✔
757

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

761
        ...or None if no such copy exists. If ``proto`` is this user, returns
762
        this user's key id.
763

764
        Args:
765
          proto: :class:`Protocol` subclass
766

767
        Returns:
768
          str:
769
        """
770
        # don't use isinstance because the testutil Fake protocol has subclasses
771
        if self.LABEL == proto.LABEL:
1✔
772
            return self.key.id()
1✔
773

774
        for copy in self.copies:
1✔
775
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
776
                return copy.uri
1✔
777

778
    def user_link(self, name=True, handle=True, pictures=False, proto=None,
1✔
779
                  proto_fallback=False):
780
        """Returns a pretty HTML link to the user's profile.
781

782
        Can optionally include display name, handle, profile
783
        picture, and/or link to a different protocol that they've enabled.
784

785
        TODO: unify with :meth:`Object.actor_link`?
786

787
        Args:
788
          name (bool): include display name
789
          handle (bool): include handle
790
          pictures (bool): include profile picture and protocol logo
791
          proto (protocol.Protocol): link to this protocol instead of the user's
792
            native protocol
793
          proto_fallback (bool): if True, and ``proto`` is provided and has no
794
            no canonical profile URL for bridged users, uses the user's profile
795
            URL in their native protocol
796
        """
797
        img = name_str = handle_str = dot = logo = a_open = a_close = ''
1✔
798

799
        if proto:
1✔
800
            assert self.is_enabled(proto), f"{proto.LABEL} isn't enabled"
1✔
801
            url = proto.bridged_web_url_for(self, fallback=proto_fallback)
1✔
802
        else:
803
            proto = self.__class__
1✔
804
            url = self.web_url()
1✔
805

806
        if pictures:
1✔
807
            logo = f'<span class="logo" title="{proto.__name__}">{proto.LOGO_HTML}</span> '
1✔
808
            if pic := self.profile_picture():
1✔
809
                img = f'<img src="{pic}" class="profile"> '
1✔
810

811
        if handle:
1✔
812
            handle_str = self.handle_as(proto)
1✔
813

814
        if name and self.name() != handle_str:
1✔
815
            name_str = self.name()
1✔
816

817
        if handle_str and name_str:
1✔
818
            dot = ' &middot; '
1✔
819

820
        if url:
1✔
821
            a_open = f'<a class="h-card u-author" rel="me" href="{url}" title="{name_str}{dot}{handle_str}">'
1✔
822
            a_close = '</a>'
1✔
823

824
        return f'{logo}{a_open}{img}{ellipsize(name_str, chars=40)}{dot}{ellipsize(handle_str, chars=40)}{a_close}'
1✔
825

826
    def profile_picture(self):
1✔
827
        """Returns the user's profile picture image URL, if available, or None."""
828
        if self.obj and self.obj.as1:
1✔
829
            return util.get_url(self.obj.as1, 'image')
1✔
830

831
    # TODO: cache in memcache
832
    @cachetools.cached(cachetools.TTLCache(50000, 60 * 60 * 2),  # 2h expiration
1✔
833
                       key=lambda user: user.key.id(), lock=Lock())
834
    def count_followers(self):
1✔
835
        """Counts this user's followers and followings.
836

837
        Returns:
838
          (int, int) tuple: (number of followers, number following)
839
        """
840
        num_followers = Follower.query(Follower.to == self.key,
1✔
841
                                       Follower.status == 'active')\
842
                                .count_async()
843
        num_following = Follower.query(Follower.from_ == self.key,
1✔
844
                                       Follower.status == 'active')\
845
                                .count_async()
846
        return num_followers.get_result(), num_following.get_result()
1✔
847

848

849
class Object(StringIdModel):
1✔
850
    """An activity or other object, eg actor.
851

852
    Key name is the id. We synthesize ids if necessary.
853
    """
854
    STATUSES = ('new', 'in progress', 'complete', 'failed', 'ignored')
1✔
855
    LABELS = ('activity',
1✔
856
              # DEPRECATED, replaced by users, notify, feed
857
              'feed', 'notification', 'user')
858

859
    # Keys for user(s) who created or otherwise own this activity.
860
    users = ndb.KeyProperty(repeated=True)
1✔
861
    # User keys who should see this activity in their user page, eg in reply to,
862
    # reaction to, share of, etc.
863
    notify = ndb.KeyProperty(repeated=True)
1✔
864
    # User keys who should see this activity in their feeds, eg followers of its
865
    # creator
866
    feed = ndb.KeyProperty(repeated=True)
1✔
867

868
    # DEPRECATED but still used read only to maintain backward compatibility
869
    # with old Objects in the datastore that we haven't bothered migrating.
870
    domains = ndb.StringProperty(repeated=True)
1✔
871

872
    status = ndb.StringProperty(choices=STATUSES)
1✔
873
    # choices is populated in reset_protocol_properties, after all User
874
    # subclasses are created, so that PROTOCOLS is fully populated.
875
    # TODO: nail down whether this is ABBREV or LABEL
876
    source_protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()))
1✔
877
    labels = ndb.StringProperty(repeated=True, choices=LABELS)
1✔
878

879
    # TODO: switch back to ndb.JsonProperty if/when they fix it for the web console
880
    # https://github.com/googleapis/python-ndb/issues/874
881
    as2 = JsonProperty()      # only one of the rest will be populated...
1✔
882
    bsky = JsonProperty()     # Bluesky / AT Protocol
1✔
883
    mf2 = JsonProperty()      # HTML microformats2 item (ie _not_ the top level
1✔
884
                              # parse object with items inside an 'items' field)
885
    our_as1 = JsonProperty()  # AS1 for activities that we generate or modify ourselves
1✔
886
    raw = JsonProperty()      # other standalone data format, eg DID document
1✔
887

888
    # these are full feeds with multiple items, not just this one, so they're
889
    # stored as audit records only. they're not used in to_as1. for Atom/RSS
890
    # based Objects, our_as1 will be populated with an feed_index top-level
891
    # integer field that indexes into one of these.
892
    atom = ndb.TextProperty() # Atom XML
1✔
893
    rss = ndb.TextProperty()  # RSS XML
1✔
894

895
    deleted = ndb.BooleanProperty()
1✔
896

897
    delivered = ndb.StructuredProperty(Target, repeated=True)
1✔
898
    undelivered = ndb.StructuredProperty(Target, repeated=True)
1✔
899
    failed = ndb.StructuredProperty(Target, repeated=True)
1✔
900

901
    # Copies of this object elsewhere, eg at:// URIs for ATProto records and
902
    # nevent etc bech32-encoded Nostr ids, where this object is the original.
903
    # Similar to u-syndication links in microformats2 and
904
    # upstream/downstreamDuplicates in AS1.
905
    copies = ndb.StructuredProperty(Target, repeated=True)
1✔
906

907
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
908
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
909

910
    new = None
1✔
911
    changed = None
1✔
912
    """Protocol and subclasses set these in fetch if this :class:`Object` is
1✔
913
    new or if its contents have changed from what was originally loaded from the
914
    datastore. If either one is None, that means we don't know whether this
915
    :class:`Object` is new/changed.
916

917
    :attr:`changed` is populated by :meth:`activity_changed()`.
918
    """
919

920
    lock = None
1✔
921
    """Initialized in __init__, synchronizes :meth:`add` and :meth:`remove`."""
1✔
922

923
    @property
1✔
924
    def as1(self):
1✔
925
        def use_urls_as_ids(obj):
1✔
926
            """If id field is missing or not a URL, use the url field."""
927
            id = obj.get('id')
1✔
928
            if not id or not (util.is_web(id) or re.match(DOMAIN_RE, id)):
1✔
929
                if url := util.get_url(obj):
1✔
930
                    obj['id'] = url
1✔
931

932
            for field in 'author', 'actor', 'object':
1✔
933
                if inner := as1.get_object(obj, field):
1✔
934
                    use_urls_as_ids(inner)
1✔
935

936
        if self.our_as1:
1✔
937
            obj = self.our_as1
1✔
938
            if self.atom or self.rss:
1✔
939
                use_urls_as_ids(obj)
1✔
940

941
        elif self.as2:
1✔
942
            obj = as2.to_as1(unwrap(self.as2))
1✔
943

944
        elif self.bsky:
1✔
945
            owner, _, _ = parse_at_uri(self.key.id())
1✔
946
            ATProto = PROTOCOLS['atproto']
1✔
947
            handle = ATProto(id=owner).handle
1✔
948
            try:
1✔
949
                obj = bluesky.to_as1(self.bsky, repo_did=owner, repo_handle=handle,
1✔
950
                                     uri=self.key.id(), pds=ATProto.pds_for(self))
951
            except (ValueError, RequestException):
1✔
952
                logger.info(f"Couldn't convert to ATProto", exc_info=True)
1✔
953
                return None
1✔
954

955
        elif self.mf2:
1✔
956
            obj = microformats2.json_to_object(self.mf2,
1✔
957
                                               rel_urls=self.mf2.get('rel-urls'))
958
            use_urls_as_ids(obj)
1✔
959

960
            # use fetched final URL as id, not u-url
961
            # https://github.com/snarfed/bridgy-fed/issues/829
962
            if url := self.mf2.get('url'):
1✔
963
                obj['id'] = (self.key.id() if self.key and '#' in self.key.id()
1✔
964
                             else url)
965

966
        else:
967
            return None
1✔
968

969
        # populate id if necessary
970
        if self.key:
1✔
971
            obj.setdefault('id', self.key.id())
1✔
972

973
        return obj
1✔
974

975
    @ndb.ComputedProperty
1✔
976
    def type(self):  # AS1 objectType, or verb if it's an activity
1✔
977
        if self.as1:
1✔
978
            return as1.object_type(self.as1)
1✔
979

980
    def __init__(self, *args, **kwargs):
1✔
981
        super().__init__(*args, **kwargs)
1✔
982
        self.lock = Lock()
1✔
983

984
    def _expire(self):
1✔
985
        """Maybe automatically delete this Object after 90d using a TTL policy.
986

987
        https://cloud.google.com/datastore/docs/ttl
988

989
        They recommend not indexing TTL properties:
990
        https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
991
        """
992
        if self.type in OBJECT_EXPIRE_TYPES:
1✔
993
            return (self.updated or util.now()) + OBJECT_EXPIRE_AGE
1✔
994

995
    expire = ndb.ComputedProperty(_expire, indexed=False)
1✔
996

997
    def _pre_put_hook(self):
1✔
998
        """
999
        * Validate that at:// URIs have DID repos
1000
        * Set/remove the activity label
1001
        * Strip @context from as2 (we don't do LD) to save disk space
1002
        """
1003
        id = self.key.id()
1✔
1004

1005
        if self.source_protocol not in (None, 'ui'):
1✔
1006
            proto = PROTOCOLS[self.source_protocol]
1✔
1007
            assert proto.owns_id(id) is not False, \
1✔
1008
                f'Protocol {proto.LABEL} does not own id {id}'
1009

1010
        if id.startswith('at://'):
1✔
1011
            repo, _, _ = parse_at_uri(id)
1✔
1012
            if not repo.startswith('did:'):
1✔
1013
                # TODO: if we hit this, that means the AppView gave us an AT URI
1014
                # with a handle repo/authority instead of DID. that's surprising!
1015
                # ...if so, and if we need to handle it, add a new
1016
                # arroba.did.canonicalize_at_uri() function, then use it here,
1017
                # or before.
1018
                raise ValueError(
1✔
1019
                    f'at:// URI ids must have DID repos; got {id}')
1020

1021
        if self.as1 and self.as1.get('objectType') == 'activity':
1✔
1022
            self.add('labels', 'activity')
1✔
1023
        elif 'activity' in self.labels:
1✔
1024
            self.remove('labels', 'activity')
1✔
1025

1026
        if self.as2:
1✔
1027
           self.as2.pop('@context', None)
1✔
1028
           for field in 'actor', 'attributedTo', 'author', 'object':
1✔
1029
               for val in util.get_list(self.as2, field):
1✔
1030
                   if isinstance(val, dict):
1✔
1031
                       val.pop('@context', None)
1✔
1032

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

1037
    @classmethod
1✔
1038
    @ndb.transactional()
1✔
1039
    def get_or_create(cls, id, authed_as=None, **props):
1✔
1040
        """Returns an :class:`Object` with the given property values.
1041

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

1046
        Args:
1047
          authed_as (str): if a matching :class:`Object` already exists, its
1048
            `author` or `actor` must contain this actor id. Implements basic
1049
            authorization for updates and deletes.
1050

1051
        Returns:
1052
          Object:
1053
        """
1054
        obj = cls.get_by_id(id)
1✔
1055
        if obj:
1✔
1056
            obj.new = False
1✔
1057
            orig_as1 = obj.as1
1✔
1058
            if orig_as1:
1✔
1059
                # authorization: check that the authed user is allowed to modify
1060
                # this object
1061
                # https://www.w3.org/wiki/ActivityPub/Primer/Authentication_Authorization
1062
                assert authed_as
1✔
1063
                proto = PROTOCOLS.get(obj.source_protocol)
1✔
1064
                assert proto, obj.source_protocol
1✔
1065
                owners = [ids.normalize_user_id(id=owner, proto=proto)
1✔
1066
                          for owner in (as1.get_ids(orig_as1, 'author')
1067
                                        + as1.get_ids(orig_as1, 'actor'))
1068
                                        + [id]]
1069
                if (ids.normalize_user_id(id=authed_as, proto=proto) not in owners
1✔
1070
                        and ids.profile_id(id=authed_as, proto=proto) not in owners):
1071
                    report_error("Auth: Object: authed_as doesn't match owner",
1✔
1072
                                 user=f'{id} authed_as {authed_as} owners {owners}')
1073
                    error(f"authed user {authed_as} isn't object owner {owners}",
1✔
1074
                          status=403)
1075
        else:
1076
            obj = Object(id=id)
1✔
1077
            obj.new = True
1✔
1078

1079
        if set(props.keys()) & set(('as2', 'bsky', 'mf2', 'raw')):
1✔
1080
            obj.clear()
1✔
1081
        obj.populate(**{
1✔
1082
            k: v for k, v in props.items()
1083
            if v and not isinstance(getattr(Object, k), ndb.ComputedProperty)
1084
        })
1085
        if not obj.new:
1✔
1086
            obj.changed = obj.activity_changed(orig_as1)
1✔
1087

1088
        obj.put()
1✔
1089
        return obj
1✔
1090

1091
    def add(self, prop, val):
1✔
1092
        """Adds a value to a multiply-valued property. Uses ``self.lock``.
1093

1094
        Args:
1095
          prop (str)
1096
          val
1097
        """
1098
        with self.lock:
1✔
1099
            util.add(getattr(self, prop), val)
1✔
1100

1101
    def remove(self, prop, val):
1✔
1102
        """Removes a value from a multiply-valued property. Uses ``self.lock``.
1103

1104
        Args:
1105
          prop (str)
1106
          val
1107
        """
1108
        with self.lock:
1✔
1109
            getattr(self, prop).remove(val)
1✔
1110

1111
    def clear(self):
1✔
1112
        """Clears the :attr:`Object.our_as1` property."""
1113
        self.our_as1 = None
1✔
1114

1115
    def activity_changed(self, other_as1):
1✔
1116
        """Returns True if this activity is meaningfully changed from ``other_as1``.
1117

1118
        ...otherwise False.
1119

1120
        Used to populate :attr:`changed`.
1121

1122
        Args:
1123
          other_as1 (dict): AS1 object, or none
1124
        """
1125
        # ignore inReplyTo since we translate it between protocols
1126
        return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
1✔
1127
                if self.as1 and other_as1
1128
                else bool(self.as1) != bool(other_as1))
1129

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

1133
        TODO: unify with :meth:`User.user_link`?
1134

1135
        Args:
1136
          image (bool): whether to include an ``img`` tag with the actor's picture
1137
          sized (bool): whether to set an explicit (``width=32``) size on the
1138
            profile picture ``img` tag
1139
          user (User): current user
1140

1141
        Returns:
1142
          str:
1143
        """
1144
        attrs = {'class': 'h-card u-author'}
1✔
1145

1146
        if user and (user.key in self.users or user.key.id() in self.domains):
1✔
1147
            # outbound; show a nice link to the user
1148
            return user.user_link(handle=False, pictures=True)
1✔
1149

1150
        proto = PROTOCOLS.get(self.source_protocol)
1✔
1151

1152
        actor = None
1✔
1153
        if self.as1:
1✔
1154
            actor = (as1.get_object(self.as1, 'actor')
1✔
1155
                     or as1.get_object(self.as1, 'author'))
1156
            # hydrate from datastore if available
1157
            # TODO: optimize! this is called serially in loops, eg in home.html
1158
            if set(actor.keys()) == {'id'} and self.source_protocol:
1✔
1159
                actor_obj = proto.load(actor['id'], remote=False)
1✔
1160
                if actor_obj and actor_obj.as1:
1✔
1161
                    actor = actor_obj.as1
1✔
1162

1163
        if not actor:
1✔
1164
            return ''
1✔
1165
        elif set(actor.keys()) == {'id'}:
1✔
1166
            return common.pretty_link(actor['id'], attrs=attrs, user=user)
1✔
1167

1168
        url = as1.get_url(actor)
1✔
1169
        name = actor.get('displayName') or actor.get('username') or ''
1✔
1170
        img_url = util.get_url(actor, 'image')
1✔
1171
        if not image or not img_url:
1✔
1172
            return common.pretty_link(url, text=name, attrs=attrs, user=user)
1✔
1173

1174
        logo = ''
1✔
1175
        if proto:
1✔
UNCOV
1176
            logo = f'<span class="logo" title="{self.__class__.__name__}">{proto.LOGO_HTML}</span>'
×
1177

1178
        return f"""\
1✔
1179
        {logo}
1180
        <a class="h-card u-author" href="{url}" title="{name}">
1181
          <img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
1182
          {util.ellipsize(name, chars=40)}
1183
        </a>"""
1184

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

1188
        ...or None if no such copy exists. If ``proto`` is ``source_protocol``,
1189
        returns this object's key id.
1190

1191
        Args:
1192
          proto: :class:`Protocol` subclass
1193

1194
        Returns:
1195
          str:
1196
        """
1197
        if self.source_protocol in (proto.LABEL, proto.ABBREV):
1✔
1198
            return self.key.id()
1✔
1199

1200
        for copy in self.copies:
1✔
1201
            if copy.protocol in (proto.LABEL, proto.ABBREV):
1✔
1202
                return copy.uri
1✔
1203

1204
    def resolve_ids(self):
1✔
1205
        """Resolves "copy" ids, subdomain ids, etc with their originals.
1206

1207
        The end result is that all ids are original "source" ids, ie in the
1208
        protocol that they first came from.
1209

1210
        Specifically, resolves:
1211

1212
        * ids in :class:`User.copies` and :class:`Object.copies`, eg ATProto
1213
          records and Nostr events that we bridged, to the ids of their
1214
          original objects in their source protocol, eg
1215
          ``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
1216
        * Bridgy Fed subdomain URLs to the ids embedded inside them, eg
1217
          ``https://bsky.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
1218
        * ATProto bsky.app URLs to their DIDs or `at://` URIs, eg
1219
          ``https://bsky.app/profile/a.com`` => ``did:plc:123``
1220

1221
        ...in these AS1 fields, in place:
1222

1223
        * ``id``
1224
        * ``actor``
1225
        * ``author``
1226
        * ``object``
1227
        * ``object.actor``
1228
        * ``object.author``
1229
        * ``object.id``
1230
        * ``object.inReplyTo``
1231
        * ``tags.[objectType=mention].url``
1232

1233
        :meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
1234
        Much of the same logic is duplicated there!
1235

1236
        TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
1237
        """
1238
        if not self.as1:
1✔
1239
            return
1✔
1240

1241
        # extract ids, strip Bridgy Fed subdomain URLs
1242
        outer_obj = unwrap(self.as1)
1✔
1243
        if outer_obj != self.as1:
1✔
1244
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1245

1246
        self_proto = PROTOCOLS.get(self.source_protocol)
1✔
1247
        if not self_proto:
1✔
1248
            return
1✔
1249

1250
        inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
1✔
1251
        replaced = False
1✔
1252

1253
        def replace(val, orig_fn):
1✔
1254
            id = val.get('id') if isinstance(val, dict) else val
1✔
1255
            if not id:
1✔
1256
                return id
1✔
1257

1258
            orig = orig_fn(id)
1✔
1259
            if not orig:
1✔
1260
                return val
1✔
1261

1262
            nonlocal replaced
1263
            replaced = True
1✔
1264
            logger.debug(f'Resolved copy id {val} to original {orig.id()}')
1✔
1265

1266
            if isinstance(val, dict) and util.trim_nulls(val).keys() > {'id'}:
1✔
1267
                val['id'] = orig.id()
1✔
1268
                return val
1✔
1269
            else:
1270
                return orig.id()
1✔
1271

1272
        # actually replace ids
1273
        #
1274
        # object field could be either object (eg repost) or actor (eg follow)
1275
        outer_obj['object'] = replace(inner_obj, get_original_object_key)
1✔
1276
        if not replaced:
1✔
1277
            outer_obj['object'] = replace(inner_obj, get_original_user_key)
1✔
1278

1279
        for obj in outer_obj, inner_obj:
1✔
1280
            for tag in as1.get_objects(obj, 'tags'):
1✔
1281
                if tag.get('objectType') == 'mention':
1✔
1282
                    tag['url'] = replace(tag.get('url'), get_original_user_key)
1✔
1283
            for field, fn in (
1✔
1284
                    ('actor', get_original_user_key),
1285
                    ('author', get_original_user_key),
1286
                    ('inReplyTo', get_original_object_key),
1287
                ):
1288
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1289
                if len(obj[field]) == 1:
1✔
1290
                    obj[field] = obj[field][0]
1✔
1291

1292
        if replaced:
1✔
1293
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1294

1295
    def normalize_ids(self):
1✔
1296
        """Normalizes ids to their protocol's canonical representation, if any.
1297

1298
        For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
1299
        for profiles, ``at://`` URIs for posts.
1300

1301
        Modifies this object in place.
1302

1303
        TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
1304
        """
1305
        from protocol import Protocol
1✔
1306

1307
        if not self.as1:
1✔
1308
            return
1✔
1309

1310
        logger.debug(f'Normalizing ids')
1✔
1311
        outer_obj = copy.deepcopy(self.as1)
1✔
1312
        inner_objs = as1.get_objects(outer_obj)
1✔
1313
        replaced = False
1✔
1314

1315
        def replace(val, translate_fn):
1✔
1316
            nonlocal replaced
1317

1318
            orig = val.get('id') if isinstance(val, dict) else val
1✔
1319
            if not orig:
1✔
1320
                return val
1✔
1321

1322
            proto = Protocol.for_id(orig, remote=False)
1✔
1323
            if not proto:
1✔
1324
                return val
1✔
1325

1326
            translated = translate_fn(id=orig, from_=proto, to=proto)
1✔
1327
            if translated and translated != orig:
1✔
1328
                # logger.debug(f'Normalized {proto.LABEL} id {orig} to {translated}')
1329
                replaced = True
1✔
1330
                if isinstance(val, dict):
1✔
1331
                    val['id'] = translated
1✔
1332
                    return val
1✔
1333
                else:
1334
                    return translated
1✔
1335

1336
            return val
1✔
1337

1338
        # actually replace ids
1339
        for obj in [outer_obj] + inner_objs:
1✔
1340
            for tag in as1.get_objects(obj, 'tags'):
1✔
1341
                if tag.get('objectType') == 'mention':
1✔
1342
                    tag['url'] = replace(tag.get('url'), ids.translate_user_id)
1✔
1343
            for field in ['actor', 'author', 'inReplyTo']:
1✔
1344
                fn = (ids.translate_object_id if field == 'inReplyTo'
1✔
1345
                      else ids.translate_user_id)
1346
                obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
1✔
1347
                if len(obj[field]) == 1:
1✔
1348
                    obj[field] = obj[field][0]
1✔
1349

1350
        outer_obj['object'] = []
1✔
1351
        for inner_obj in inner_objs:
1✔
1352
            translate_fn = (ids.translate_user_id
1✔
1353
                            if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
1354
                                or as1.object_type(outer_obj) in
1355
                                ('follow', 'stop-following'))
1356
                            else ids.translate_object_id)
1357

1358
            got = replace(inner_obj, translate_fn)
1✔
1359
            if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
1✔
1360
                got = got['id']
1✔
1361

1362
            outer_obj['object'].append(got)
1✔
1363

1364
        if len(outer_obj['object']) == 1:
1✔
1365
            outer_obj['object'] = outer_obj['object'][0]
1✔
1366

1367
        if replaced:
1✔
1368
            self.our_as1 = util.trim_nulls(outer_obj)
1✔
1369

1370

1371
class Follower(ndb.Model):
1✔
1372
    """A follower of a Bridgy Fed user."""
1373
    STATUSES = ('active', 'inactive')
1✔
1374

1375
    # these are both subclasses of User
1376
    from_ = ndb.KeyProperty(name='from', required=True)
1✔
1377
    to = ndb.KeyProperty(required=True)
1✔
1378

1379
    follow = ndb.KeyProperty(Object)  # last follow activity
1✔
1380
    status = ndb.StringProperty(choices=STATUSES, default='active')
1✔
1381

1382
    created = ndb.DateTimeProperty(auto_now_add=True)
1✔
1383
    updated = ndb.DateTimeProperty(auto_now=True)
1✔
1384

1385
    # OLD. some stored entities still have these; do not reuse.
1386
    # src = ndb.StringProperty()
1387
    # dest = ndb.StringProperty()
1388
    # last_follow = JsonProperty()
1389

1390
    def _pre_put_hook(self):
1✔
1391
        # we're a bridge! stick with bridging.
1392
        assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
1✔
1393

1394
    def _post_put_hook(self, future):
1✔
1395
        logger.debug(f'Wrote {self.key}')
1✔
1396

1397
    @classmethod
1✔
1398
    @ndb.transactional()
1✔
1399
    def get_or_create(cls, *, from_, to, **kwargs):
1✔
1400
        """Returns a Follower with the given ``from_`` and ``to`` users.
1401

1402
        If a matching :class:`Follower` doesn't exist in the datastore, creates
1403
        it first.
1404

1405
        Args:
1406
          from_ (User)
1407
          to (User)
1408

1409
        Returns:
1410
          Follower:
1411
        """
1412
        assert from_
1✔
1413
        assert to
1✔
1414

1415
        follower = Follower.query(Follower.from_ == from_.key,
1✔
1416
                                  Follower.to == to.key,
1417
                                  ).get()
1418
        if not follower:
1✔
1419
            follower = Follower(from_=from_.key, to=to.key, **kwargs)
1✔
1420
            follower.put()
1✔
1421
        elif kwargs:
1✔
1422
            # update existing entity with new property values, eg to make an
1423
            # inactive Follower active again
1424
            for prop, val in kwargs.items():
1✔
1425
                setattr(follower, prop, val)
1✔
1426
            follower.put()
1✔
1427

1428
        return follower
1✔
1429

1430
    @staticmethod
1✔
1431
    def fetch_page(collection, user):
1✔
1432
        r"""Fetches a page of :class:`Follower`\s for a given user.
1433

1434
        Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
1435
        parameters, if available in the request.
1436

1437
        Args:
1438
          collection (str): ``followers`` or ``following``
1439
          user (User)
1440

1441
        Returns:
1442
          (list of Follower, str, str) tuple: results, annotated with an extra
1443
          ``user`` attribute that holds the follower or following :class:`User`,
1444
          and new str query param values for ``before`` and ``after`` to fetch
1445
          the previous and next pages, respectively
1446
        """
1447
        assert collection in ('followers', 'following'), collection
1✔
1448

1449
        filter_prop = Follower.to if collection == 'followers' else Follower.from_
1✔
1450
        query = Follower.query(
1✔
1451
            Follower.status == 'active',
1452
            filter_prop == user.key,
1453
        )
1454

1455
        followers, before, after = fetch_page(query, Follower, by=Follower.updated)
1✔
1456
        users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
1✔
1457
                              for f in followers)
1458
        User.load_multi(u for u in users if u)
1✔
1459

1460
        for f, u in zip(followers, users):
1✔
1461
            f.user = u
1✔
1462
        followers = [f for f in followers if not f.user.status]
1✔
1463

1464
        return followers, before, after
1✔
1465

1466

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

1470
    Wraps :func:`fetch_page` and adds attributes to the returned
1471
    :class:`Object` entities for rendering in ``objects.html``.
1472

1473
    Args:
1474
      query (ndb.Query)
1475
      by (ndb.model.Property): either :attr:`Object.updated` or
1476
        :attr:`Object.created`
1477
      user (User): current user
1478

1479
    Returns:
1480
      (list of Object, str, str) tuple:
1481
      (results, new ``before`` query param, new ``after`` query param)
1482
      to fetch the previous and next pages, respectively
1483
    """
1484
    assert by is Object.updated or by is Object.created
1✔
1485
    objects, new_before, new_after = fetch_page(query, Object, by=by)
1✔
1486
    objects = [o for o in objects if as1.is_public(o.as1) and not o.deleted]
1✔
1487

1488
    # synthesize human-friendly content for objects
1489
    for i, obj in enumerate(objects):
1✔
1490
        obj_as1 = obj.as1
1✔
1491
        inner_obj = as1.get_object(obj_as1)
1✔
1492

1493
        # synthesize text snippet
1494
        type = as1.object_type(obj_as1)
1✔
1495
        if type == 'post':
1✔
1496
            inner_type = inner_obj.get('objectType')
×
1497
            if inner_type:
×
UNCOV
1498
                type = inner_type
×
1499

1500
        # AS1 verb => human-readable phrase
1501
        phrases = {
1✔
1502
            'accept': 'accepted',
1503
            'article': 'posted',
1504
            'comment': 'replied',
1505
            'delete': 'deleted',
1506
            'follow': 'followed',
1507
            'invite': 'is invited to',
1508
            'issue': 'filed issue',
1509
            'like': 'liked',
1510
            'note': 'posted',
1511
            'post': 'posted',
1512
            'repost': 'reposted',
1513
            'rsvp-interested': 'is interested in',
1514
            'rsvp-maybe': 'might attend',
1515
            'rsvp-no': 'is not attending',
1516
            'rsvp-yes': 'is attending',
1517
            'share': 'reposted',
1518
            'stop-following': 'unfollowed',
1519
            'undo': 'undid',
1520
            'update': 'updated',
1521
        }
1522
        obj.phrase = phrases.get(type)
1✔
1523

1524
        content = (inner_obj.get('content')
1✔
1525
                   or inner_obj.get('displayName')
1526
                   or inner_obj.get('summary'))
1527
        if content:
1✔
UNCOV
1528
            content = util.parse_html(content).get_text()
×
1529

1530
        urls = as1.object_urls(inner_obj)
1✔
1531
        id = unwrap(inner_obj.get('id', ''))
1✔
1532
        url = urls[0] if urls else id
1✔
1533
        if (type == 'update' and
1✔
1534
            (obj.users and (user.is_web_url(id)
1535
                            or id.strip('/') == obj.users[0].id())
1536
             or obj.domains and id.strip('/') == f'https://{obj.domains[0]}')):
1537
            obj.phrase = 'updated'
×
UNCOV
1538
            obj_as1.update({
×
1539
                'content': 'their profile',
1540
                'url': id,
1541
            })
1542
        elif url and not content:
1✔
1543
            # heuristics for sniffing URLs and converting them to more friendly
1544
            # phrases and user handles.
1545
            # TODO: standardize this into granary.as2 somewhere?
1546
            from activitypub import FEDI_URL_RE
1✔
1547
            from atproto import COLLECTION_TO_TYPE, did_to_handle
1✔
1548

1549
            handle = suffix = ''
1✔
1550
            if match := FEDI_URL_RE.match(url):
1✔
1551
                handle = match.group(2)
×
1552
                if match.group(4):
×
UNCOV
1553
                    suffix = "'s post"
×
1554
            elif match := BSKY_APP_URL_RE.match(url):
1✔
1555
                handle = match.group('id')
×
1556
                if match.group('tid'):
×
UNCOV
1557
                    suffix = "'s post"
×
1558
            elif match := AT_URI_PATTERN.match(url):
1✔
1559
                handle = match.group('repo')
×
1560
                if coll := match.group('collection'):
×
1561
                    suffix = f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
×
UNCOV
1562
                url = bluesky.at_uri_to_web_url(url)
×
1563
            elif url.startswith('did:'):
1✔
1564
                handle = url
×
UNCOV
1565
                url = bluesky.Bluesky.user_url(handle)
×
1566

1567
            if handle:
1✔
1568
                if handle.startswith('did:'):
×
1569
                    handle = did_to_handle(handle) or handle
×
UNCOV
1570
                content = f'@{handle}{suffix}'
×
1571

1572
            if url:
1✔
1573
                content = common.pretty_link(url, text=content, user=user)
1✔
1574

1575
        obj.content = (obj_as1.get('content')
1✔
1576
                       or obj_as1.get('displayName')
1577
                       or obj_as1.get('summary'))
1578
        obj.url = util.get_first(obj_as1, 'url')
1✔
1579

1580
        if type in ('like', 'follow', 'repost', 'share') or not obj.content:
1✔
1581
            if obj.url:
1✔
UNCOV
1582
                obj.phrase = common.pretty_link(
×
1583
                    obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
1584
            if content:
1✔
1585
                obj.content = content
1✔
1586
                obj.url = url
1✔
1587

1588
    return objects, new_before, new_after
1✔
1589

1590

1591
def fetch_page(query, model_class, by=None):
1✔
1592
    """Fetches a page of results from a datastore query.
1593

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

1597
    Populates a ``log_url_path`` property on each result entity that points to a
1598
    its most recent logged request.
1599

1600
    Args:
1601
      query (google.cloud.ndb.query.Query)
1602
      model_class (class)
1603
      by (ndb.model.Property): paging property, eg :attr:`Object.updated`
1604
        or :attr:`Object.created`
1605

1606
    Returns:
1607
      (list of Object or Follower, str, str) tuple: (results, new_before,
1608
      new_after), where new_before and new_after are query param values for
1609
      ``before`` and ``after`` to fetch the previous and next pages,
1610
      respectively
1611
    """
1612
    assert by
1✔
1613

1614
    # if there's a paging param ('before' or 'after'), update query with it
1615
    # TODO: unify this with Bridgy's user page
1616
    def get_paging_param(param):
1✔
1617
        val = request.values.get(param)
1✔
1618
        if val:
1✔
1619
            try:
1✔
1620
                dt = util.parse_iso8601(val.replace(' ', '+'))
1✔
1621
            except BaseException as e:
1✔
1622
                error(f"Couldn't parse {param}, {val!r} as ISO8601: {e}")
1✔
1623
            if dt.tzinfo:
1✔
1624
                dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
1✔
1625
            return dt
1✔
1626

1627
    before = get_paging_param('before')
1✔
1628
    after = get_paging_param('after')
1✔
1629
    if before and after:
1✔
UNCOV
1630
        error("can't handle both before and after")
×
1631
    elif after:
1✔
1632
        query = query.filter(by >= after).order(by)
1✔
1633
    elif before:
1✔
1634
        query = query.filter(by < before).order(-by)
1✔
1635
    else:
1636
        query = query.order(-by)
1✔
1637

1638
    query_iter = query.iter()
1✔
1639
    results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
1✔
1640
                     key=lambda r: r.updated, reverse=True)
1641

1642
    # calculate new paging param(s)
1643
    has_next = results and query_iter.probably_has_next()
1✔
1644
    new_after = (
1✔
1645
        before if before
1646
        else results[0].updated if has_next and after
1647
        else None)
1648
    if new_after:
1✔
1649
        new_after = new_after.isoformat()
1✔
1650

1651
    new_before = (
1✔
1652
        after if after else
1653
        results[-1].updated if has_next
1654
        else None)
1655
    if new_before:
1✔
1656
        new_before = new_before.isoformat()
1✔
1657

1658
    return results, new_before, new_after
1✔
1659

1660

1661
@lru_cache(maxsize=100000)
1✔
1662
def get_original_object_key(copy_id):
1✔
1663
    """Finds the :class:`Object` with a given copy id, if any.
1664

1665
    Args:
1666
      copy_id (str)
1667

1668
    Returns:
1669
      google.cloud.ndb.Key or None
1670
    """
1671
    assert copy_id
1✔
1672

1673
    return Object.query(Object.copies.uri == copy_id).get(keys_only=True)
1✔
1674

1675

1676
@lru_cache(maxsize=100000)
1✔
1677
def get_original_user_key(copy_id):
1✔
1678
    """Finds the user with a given copy id, if any.
1679

1680
    Args:
1681
      copy_id (str)
1682
      not_proto (Protocol): optional, don't query this protocol
1683

1684
    Returns:
1685
      google.cloud.ndb.Key or None
1686
    """
1687
    assert copy_id
1✔
1688

1689
    for proto in PROTOCOLS.values():
1✔
1690
        if proto and proto.LABEL != 'ui' and not proto.owns_id(copy_id):
1✔
1691
            if orig := proto.query(proto.copies.uri == copy_id).get(keys_only=True):
1✔
1692
                return orig
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