-
Notifications
You must be signed in to change notification settings - Fork 39
/
models.py
1685 lines (1349 loc) · 58.7 KB
/
models.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Datastore model classes."""
import copy
from datetime import timedelta, timezone
from functools import lru_cache
import itertools
import json
import logging
import random
import re
from threading import Lock
from urllib.parse import quote, urlparse
from arroba.util import parse_at_uri
import cachetools
from Crypto.PublicKey import RSA
from flask import request
from google.cloud import ndb
from granary import as1, as2, atom, bluesky, microformats2
from granary.bluesky import AT_URI_PATTERN, BSKY_APP_URL_RE
from granary.source import html_to_text
from oauth_dropins.webutil import util
from oauth_dropins.webutil.appengine_info import DEBUG
from oauth_dropins.webutil.flask_util import error
from oauth_dropins.webutil.models import JsonProperty, StringIdModel
from oauth_dropins.webutil.util import ellipsize, json_dumps, json_loads
from requests import RequestException
import common
from common import (
base64_to_long,
DOMAIN_RE,
long_to_base64,
OLD_ACCOUNT_AGE,
report_error,
unwrap,
)
import ids
# maps string label to Protocol subclass. values are populated by ProtocolUserMeta.
# (we used to wait for ProtocolUserMeta to populate the keys as well, but that was
# awkward to use in datastore model properties with choices, below; it required
# overriding them in reset_model_properties, which was always flaky.)
PROTOCOLS = {label: None for label in (
'activitypub',
'ap',
'atproto',
'bsky',
'ostatus',
'web',
'webmention',
'ui',
)}
if DEBUG:
PROTOCOLS.update({label: None for label in (
'fa',
'fake',
'efake',
'other',
)})
# maps string kind (eg 'MagicKey') to Protocol subclass.
# populated in ProtocolUserMeta
PROTOCOLS_BY_KIND = {}
# 2048 bits makes tests slow, so use 1024 for them
KEY_BITS = 1024 if DEBUG else 2048
PAGE_SIZE = 20
# auto delete old objects of these types via the Object.expire property
# https://cloud.google.com/datastore/docs/ttl
OBJECT_EXPIRE_TYPES = (
'accept',
'block',
'delete',
'post',
'reject',
'undo',
'update',
None,
)
OBJECT_EXPIRE_AGE = timedelta(days=90)
logger = logging.getLogger(__name__)
class Target(ndb.Model):
r""":class:`protocol.Protocol` + URI pairs for identifying objects.
These are currently used for:
* delivery destinations, eg ActivityPub inboxes, webmention targets, etc.
* copies of :class:`Object`\s and :class:`User`\s elsewhere,
eg ``at://`` URIs for ATProto records, nevent etc bech32-encoded Nostr ids,
ATProto user DIDs, etc.
Used in :class:`google.cloud.ndb.model.StructuredProperty`\s inside
:class:`Object` and :class:`User`; not stored as top-level entities in the
datastore.
ndb implements this by hoisting each property here into a corresponding
property on the parent entity, prefixed by the StructuredProperty name
below, eg ``delivered.uri``, ``delivered.protocol``, etc.
For repeated StructuredPropertys, the hoisted properties are all repeated on
the parent entity, and reconstructed into StructuredPropertys based on their
order.
https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
"""
uri = ndb.StringProperty(required=True)
protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
def __eq__(self, other):
"""Equality excludes Targets' :class:`Key`."""
return self.uri == other.uri and self.protocol == other.protocol
def __hash__(self):
"""Allow hashing so these can be dict keys."""
return hash((self.protocol, self.uri))
class DM(ndb.Model):
""":class:`protocol.Protocol` + type pairs for identifying sent DMs.
Used in :attr:`User.sent_dms`.
https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
"""
TYPES = (
'request_bridging',
'replied_to_bridged_user',
'welcome',
)
type = ndb.StringProperty(choices=TYPES, required=True)
protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
def __eq__(self, other):
"""Equality excludes Targets' :class:`Key`."""
return self.type == other.type and self.protocol == other.protocol
class ProtocolUserMeta(type(ndb.Model)):
""":class:`User` metaclass. Registers all subclasses in the ``PROTOCOLS`` global."""
def __new__(meta, name, bases, class_dict):
cls = super().__new__(meta, name, bases, class_dict)
if hasattr(cls, 'LABEL') and cls.LABEL not in ('protocol', 'user'):
for label in (cls.LABEL, cls.ABBREV) + cls.OTHER_LABELS:
if label:
PROTOCOLS[label] = cls
PROTOCOLS_BY_KIND[cls._get_kind()] = cls
return cls
def reset_protocol_properties():
"""Recreates various protocol properties to include choices from ``PROTOCOLS``."""
abbrevs = f'({"|".join(PROTOCOLS.keys())}|fed)'
common.SUBDOMAIN_BASE_URL_RE = re.compile(
rf'^https?://({abbrevs}\.brid\.gy|localhost(:8080)?)/(convert/|r/)?({abbrevs}/)?(?P<path>.+)')
ids.COPIES_PROTOCOLS = tuple(label for label, proto in PROTOCOLS.items()
if proto and proto.HAS_COPIES)
class User(StringIdModel, metaclass=ProtocolUserMeta):
"""Abstract base class for a Bridgy Fed user.
Stores some protocols' keypairs. Currently:
* RSA keypair for ActivityPub HTTP Signatures
properties: ``mod``, ``public_exponent``, ``private_exponent``, all
encoded as base64url (ie URL-safe base64) strings as described in RFC
4648 and section 5.1 of the Magic Signatures spec:
https://tools.ietf.org/html/draft-cavage-http-signatures-12
* *Not* K-256 signing or rotation keys for AT Protocol, those are stored in
:class:`arroba.datastore_storage.AtpRepo` entities
"""
obj_key = ndb.KeyProperty(kind='Object') # user profile
mod = ndb.StringProperty()
use_instead = ndb.KeyProperty()
# Proxy copies of this user elsewhere, eg DIDs for ATProto records, bech32
# npub Nostr ids, etc. Similar to rel-me links in microformats2, alsoKnownAs
# in DID docs (and now AS2), etc.
# TODO: switch to using Object.copies on the user profile object?
copies = ndb.StructuredProperty(Target, repeated=True)
# whether this user signed up or otherwise explicitly, deliberately
# interacted with Bridgy Fed. For example, if fediverse user @[email protected] looks
# up @[email protected] via WebFinger, we'll create Users for both,
# @[email protected] will be direct, foo.com will not.
direct = ndb.BooleanProperty(default=False)
# these are for ActivityPub HTTP Signatures
public_exponent = ndb.StringProperty()
private_exponent = ndb.StringProperty()
# set to True for users who asked me to be opted out instead of putting
# #nobridge in their profile
manual_opt_out = ndb.BooleanProperty()
# protocols that this user has explicitly opted into. protocols that don't
# require explicit opt in are omitted here. choices is populated in
# reset_protocol_properties.
enabled_protocols = ndb.StringProperty(repeated=True, choices=list(PROTOCOLS.keys()))
# DMs that we've attempted to send to this user
sent_dms = ndb.StructuredProperty(DM, repeated=True)
created = ndb.DateTimeProperty(auto_now_add=True)
updated = ndb.DateTimeProperty(auto_now=True)
# `existing` attr is set by get_or_create
# OLD. some stored entities still have these; do not reuse.
# actor_as2 = JsonProperty()
# protocol-specific state
# atproto_notifs_indexed_at = ndb.TextProperty()
# atproto_feed_indexed_at = ndb.TextProperty()
def __init__(self, **kwargs):
"""Constructor.
Sets :attr:`obj` explicitly because however
:class:`google.cloud.ndb.model.Model` sets it doesn't work with
``@property`` and ``@obj.setter`` below.
"""
obj = kwargs.pop('obj', None)
super().__init__(**kwargs)
if obj:
self.obj = obj
@classmethod
def new(cls, **kwargs):
"""Try to prevent instantiation. Use subclasses instead."""
raise NotImplementedError()
def _post_put_hook(self, future):
logger.debug(f'Wrote {self.key}')
@classmethod
def get_by_id(cls, id, allow_opt_out=False, **kwargs):
"""Override to follow ``use_instead`` property and ``opt-out` status.
Returns None if the user is opted out.
"""
user = cls._get_by_id(id, **kwargs)
if not user:
return None
elif user.use_instead:
logger.info(f'{user.key} use_instead => {user.use_instead}')
return user.use_instead.get()
elif user.status and not allow_opt_out:
logger.info(f'{user.key} is {user.status}')
return None
return user
@classmethod
def get_or_create(cls, id, propagate=False, allow_opt_out=False, **kwargs):
"""Loads and returns a :class:`User`. Creates it if necessary.
Args:
propagate (bool): whether to create copies of this user in push-based
protocols, eg ATProto and Nostr.
allow_opt_out (bool): whether to allow and create the user if they're
currently opted out
kwargs: passed through to ``cls`` constructor
Returns:
User: existing or new user, or None if the user is opted out
"""
assert cls != User
@ndb.transactional()
def _run():
user = cls.get_by_id(id, allow_opt_out=True)
if user:
if user.status and not allow_opt_out:
return None
user.existing = True
# TODO: propagate more fields?
changed = False
for field in ['direct', 'obj', 'obj_key']:
old_val = getattr(user, field, None)
new_val = kwargs.get(field)
if ((old_val is None and new_val is not None)
or (field == 'direct' and not old_val and new_val)):
setattr(user, field, new_val)
changed = True
if enabled_protocols := kwargs.get('enabled_protocols'):
user.enabled_protocols = (set(user.enabled_protocols)
| set(enabled_protocols))
changed = True
if not propagate:
if changed:
user.put()
return user
else:
if orig_key := get_original_user_key(id):
orig = orig_key.get()
if orig.status and not allow_opt_out:
return None
orig.existing = False
return orig
user = cls(id=id, **kwargs)
user.existing = False
if user.status and not allow_opt_out:
return None
if propagate:
for label in user.enabled_protocols + list(user.DEFAULT_ENABLED_PROTOCOLS):
proto = PROTOCOLS[label]
if proto == cls:
continue
elif proto.HAS_COPIES:
if not user.get_copy(proto) and user.is_enabled(proto):
try:
proto.create_for(user)
except (ValueError, AssertionError):
logger.info(f'failed creating {proto.LABEL} copy',
exc_info=True)
util.remove(user.enabled_protocols, proto.LABEL)
else:
logger.debug(f'{proto.LABEL} not enabled or user copy already exists, skipping propagate')
# generate keys for all protocols _except_ our own
#
# these can use urandom() and do nontrivial math, so they can take time
# depending on the amount of randomness available and compute needed.
if not user.existing:
if cls.LABEL != 'activitypub':
key = RSA.generate(KEY_BITS,
randfunc=random.randbytes if DEBUG else None)
user.mod = long_to_base64(key.n)
user.public_exponent = long_to_base64(key.e)
user.private_exponent = long_to_base64(key.d)
try:
user.put()
except AssertionError as e:
error(f'Bad {cls.__name__} id {id} : {e}')
return user
user = _run()
# load and propagate user and profile object
if user:
logger.debug(('Updated ' if user.existing else 'Created new ') + str(user))
if not user.obj_key:
user.obj = cls.load(user.profile_id())
user.put()
return user
@property
def obj(self):
"""Convenience accessor that loads :attr:`obj_key` from the datastore."""
if self.obj_key:
if not hasattr(self, '_obj'):
self._obj = self.obj_key.get()
return self._obj
@obj.setter
def obj(self, obj):
if obj:
assert isinstance(obj, Object)
assert obj.key
self._obj = obj
self.obj_key = obj.key
else:
self._obj = self.obj_key = None
def delete(self, proto=None):
"""Deletes a user's bridged actors in all protocols or a specific one.
Args:
proto (Protocol): optional
"""
now = util.now().isoformat()
proto_label = proto.LABEL if proto else 'all'
delete_id = f'{self.profile_id()}#delete-user-{proto_label}-{now}'
delete = Object(id=delete_id, source_protocol=self.LABEL, our_as1={
'id': delete_id,
'objectType': 'activity',
'verb': 'delete',
'actor': self.key.id(),
'object': self.key.id(),
})
delete.put()
self.deliver(delete, from_user=self, to_proto=proto)
@classmethod
def load_multi(cls, users):
"""Loads :attr:`obj` for multiple users in parallel.
Args:
users (sequence of User)
"""
objs = ndb.get_multi(u.obj_key for u in users if u.obj_key)
keys_to_objs = {o.key: o for o in objs if o}
for u in users:
u._obj = keys_to_objs.get(u.obj_key)
@ndb.ComputedProperty
def handle(self):
"""This user's unique, human-chosen handle, eg ``@[email protected]``.
To be implemented by subclasses.
"""
raise NotImplementedError()
@ndb.ComputedProperty
def readable_id(self):
"""DEPRECATED: replaced by handle. Kept for backward compatibility."""
return None
@ndb.ComputedProperty
def status(self):
"""Whether this user is blocked or opted out.
Optional. Current possible values:
* ``opt-out``: if ``#nobridge`` or ``#nobot`` is in the profile
description/bio, or if the user or domain has manually opted out.
Some protocols also have protocol-specific opt out logic, eg Bluesky
accounts that have disabled logged out view.
* ``blocked``: if the user fails our validation checks, eg
``REQUIRES_NAME`` or ``REQUIRES_AVATAR`` if either of those are
``True` for this protocol.
Duplicates ``util.is_opt_out`` in Bridgy!
https://github.com/snarfed/bridgy-fed/issues/666
"""
if self.manual_opt_out:
return 'opt-out'
if not self.obj or not self.obj.as1:
return None
if self.REQUIRES_AVATAR and not self.obj.as1.get('image'):
return 'blocked'
name = self.obj.as1.get('displayName')
if self.REQUIRES_NAME and (not name or name in (self.handle, self.key.id())):
return 'blocked'
if self.REQUIRES_OLD_ACCOUNT:
if published := self.obj.as1.get('published'):
if util.now() - util.parse_iso8601(published) < OLD_ACCOUNT_AGE:
return 'blocked'
summary = html_to_text(self.obj.as1.get('summary', ''), ignore_links=True)
name = self.obj.as1.get('displayName', '')
# #nobridge overrides enabled_protocols
if '#nobridge' in summary or '#nobridge' in name:
return 'opt-out'
# user has explicitly opted in. should go after spam filter (REQUIRES_*)
# checks, but before is_public and #nobot
if self.enabled_protocols:
return None
if not as1.is_public(self.obj.as1, unlisted=False):
return 'opt-out'
# enabled_protocols overrides #nobot
if '#nobot' in summary or '#nobot' in name:
return 'opt-out'
def is_enabled(self, to_proto, explicit=False):
"""Returns True if this user can be bridged to a given protocol.
Reasons this might return False:
* We haven't turned on bridging these two protocols yet.
* The user is opted out or blocked.
* The user is on a domain that's opted out or blocked.
* The from protocol requires opt in, and the user hasn't opted in.
* ``explicit`` is True, and this protocol supports ``to_proto`` by
default, but the user hasn't explicitly opted into it.
Args:
to_proto (Protocol subclass)
explicit (bool)
Returns:
bool:
"""
from protocol import Protocol
assert issubclass(to_proto, Protocol)
if self.__class__ == to_proto:
return True
from_label = self.LABEL
to_label = to_proto.LABEL
if bot_protocol := Protocol.for_bridgy_subdomain(self.key.id()):
return to_proto != bot_protocol
elif self.manual_opt_out:
return False
elif to_label in self.enabled_protocols:
return True
elif self.status:
return False
elif to_label in self.DEFAULT_ENABLED_PROTOCOLS and not explicit:
return True
return False
def enable_protocol(self, to_proto):
"""Adds ``to_proto` to :attr:`enabled_protocols`.
Also sends a welcome DM to the user (via a send task) if their protocol
supports DMs.
Args:
to_proto (:class:`protocol.Protocol` subclass)
"""
added = False
if to_proto.LABEL in ids.COPIES_PROTOCOLS:
# do this even if there's an existing copy since we might need to
# reactivate it, which create_for should do
to_proto.create_for(self)
@ndb.transactional()
def enable():
user = self.key.get()
if to_proto.LABEL not in user.enabled_protocols:
user.enabled_protocols.append(to_proto.LABEL)
util.add(user.sent_dms, DM(protocol=to_proto.LABEL, type='welcome'))
user.put()
nonlocal added
added = True
return user
new_self = enable()
# populate newly enabled protocol in this instance
self.enabled_protocols = new_self.enabled_protocols
self.copies = new_self.copies
if self.obj:
self.obj.copies = new_self.obj.copies
if added:
import dms
dms.maybe_send(from_proto=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.""")
msg = f'Enabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
logger.info(msg)
def disable_protocol(self, to_proto):
"""Removes ``to_proto` from :attr:`enabled_protocols`.
Args:
to_proto (:class:`protocol.Protocol` subclass)
"""
@ndb.transactional()
def disable():
user = self.key.get()
util.remove(user.enabled_protocols, to_proto.LABEL)
user.put()
disable()
util.remove(self.enabled_protocols, to_proto.LABEL)
msg = f'Disabled {to_proto.LABEL} for {self.key.id()} : {self.user_page_path()}'
logger.info(msg)
def handle_as(self, to_proto):
"""Returns this user's handle in a different protocol.
Args:
to_proto (str or Protocol)
Returns:
str
"""
if isinstance(to_proto, str):
to_proto = PROTOCOLS[to_proto]
# override to-ATProto to use custom domain handle in DID doc
from atproto import ATProto, did_to_handle
if to_proto == ATProto:
if did := self.get_copy(ATProto):
if handle := did_to_handle(did, remote=False):
return handle
# override web users to always use domain instead of custom username
# TODO: fall back to id if handle is unset?
handle = self.key.id() if self.LABEL == 'web' else self.handle
if not handle:
return None
return ids.translate_handle(handle=handle, from_=self.__class__,
to=to_proto, enhanced=False)
def id_as(self, to_proto):
"""Returns this user's id in a different protocol.
Args:
to_proto (str or Protocol)
Returns:
str
"""
if isinstance(to_proto, str):
to_proto = PROTOCOLS[to_proto]
return ids.translate_user_id(id=self.key.id(), from_=self.__class__,
to=to_proto)
def handle_or_id(self):
"""Returns handle if we know it, otherwise id."""
return self.handle or self.key.id()
def public_pem(self):
"""
Returns:
bytes:
"""
rsa = RSA.construct((base64_to_long(str(self.mod)),
base64_to_long(str(self.public_exponent))))
return rsa.exportKey(format='PEM')
def private_pem(self):
"""
Returns:
bytes:
"""
assert self.mod and self.public_exponent and self.private_exponent, str(self)
rsa = RSA.construct((base64_to_long(str(self.mod)),
base64_to_long(str(self.public_exponent)),
base64_to_long(str(self.private_exponent))))
return rsa.exportKey(format='PEM')
def name(self):
"""Returns this user's human-readable name, eg ``Ryan Barrett``."""
if self.obj and self.obj.as1:
name = self.obj.as1.get('displayName')
if name:
return name
return self.handle_or_id()
def web_url(self):
"""Returns this user's web URL (homepage), eg ``https://foo.com/``.
To be implemented by subclasses.
Returns:
str
"""
raise NotImplementedError()
def is_web_url(self, url, ignore_www=False):
"""Returns True if the given URL is this user's web URL (homepage).
Args:
url (str)
ignore_www (bool): if True, ignores ``www.`` subdomains
Returns:
bool:
"""
if not url:
return False
url = url.strip().rstrip('/')
url = re.sub(r'^(https?://)www\.', r'\1', url)
parsed_url = urlparse(url)
if parsed_url.scheme not in ('http', 'https', ''):
return False
this = self.web_url().rstrip('/')
this = re.sub(r'^(https?://)www\.', r'\1', this)
parsed_this = urlparse(this)
return (url == this or url == parsed_this.netloc or
parsed_url[1:] == parsed_this[1:]) # ignore http vs https
def id_uri(self):
"""Returns the user id as a URI.
Sometimes this is the user id itself, eg ActivityPub actor ids.
Sometimes it's a bit different, eg at://did:plc:... for ATProto user,
https://site.com for Web users.
Returns:
str
"""
return self.key.id()
def profile_id(self):
"""Returns the id of this user's profile object in its native protocol.
Examples:
* Web: home page URL, eg ``https://me.com/``
* ActivityPub: actor URL, eg ``https://instance.com/users/me``
* ATProto: profile AT URI, eg ``at://did:plc:123/app.bsky.actor.profile/self``
Defaults to this user's key id.
Returns:
str or None:
"""
return ids.profile_id(id=self.key.id(), proto=self)
def reload_profile(self, **kwargs):
"""Reloads this user's identity and profile from their native protocol.
Populates the reloaded profile :class:`Object` in ``self.obj``.
Args:
kwargs: passed through to :meth:`Protocol.load`
"""
obj = self.load(self.profile_id(), remote=True, **kwargs)
if obj:
self.obj = obj
def user_page_path(self, rest=None):
"""Returns the user's Bridgy Fed user page path."""
path = f'/{self.ABBREV}/{self.handle_or_id()}'
if rest:
if not rest.startswith('?'):
path += '/'
path += rest
return path
def get_copy(self, proto):
"""Returns the id for the copy of this user in a given protocol.
...or None if no such copy exists. If ``proto`` is this user, returns
this user's key id.
Args:
proto: :class:`Protocol` subclass
Returns:
str:
"""
# don't use isinstance because the testutil Fake protocol has subclasses
if self.LABEL == proto.LABEL:
return self.key.id()
for copy in self.copies:
if copy.protocol in (proto.LABEL, proto.ABBREV):
return copy.uri
def user_link(self, name=True, handle=True, pictures=False, proto=None,
proto_fallback=False):
"""Returns a pretty HTML link to the user's profile.
Can optionally include display name, handle, profile
picture, and/or link to a different protocol that they've enabled.
TODO: unify with :meth:`Object.actor_link`?
Args:
name (bool): include display name
handle (bool): include handle
pictures (bool): include profile picture and protocol logo
proto (protocol.Protocol): link to this protocol instead of the user's
native protocol
proto_fallback (bool): if True, and ``proto`` is provided and has no
no canonical profile URL for bridged users, uses the user's profile
URL in their native protocol
"""
img = name_str = handle_str = dot = logo = a_open = a_close = ''
if proto:
assert self.is_enabled(proto), f"{proto.LABEL} isn't enabled"
url = proto.bridged_web_url_for(self, fallback=proto_fallback)
else:
proto = self.__class__
url = self.web_url()
if pictures:
logo = f'<span class="logo" title="{proto.__name__}">{proto.LOGO_HTML}</span> '
if pic := self.profile_picture():
img = f'<img src="{pic}" class="profile"> '
if handle:
handle_str = self.handle_as(proto)
if name and self.name() != handle_str:
name_str = self.name()
if handle_str and name_str:
dot = ' · '
if url:
a_open = f'<a class="h-card u-author" rel="me" href="{url}" title="{name_str}{dot}{handle_str}">'
a_close = '</a>'
return f'{logo}{a_open}{img}{ellipsize(name_str, chars=40)}{dot}{ellipsize(handle_str, chars=40)}{a_close}'
def profile_picture(self):
"""Returns the user's profile picture image URL, if available, or None."""
if self.obj and self.obj.as1:
return util.get_url(self.obj.as1, 'image')
@cachetools.cached(cachetools.TTLCache(50000, 60 * 60 * 2), # 2h expiration
key=lambda user: user.key.id(), lock=Lock())
def count_followers(self):
"""Counts this user's followers and followings.
Returns:
(int, int) tuple: (number of followers, number following)
"""
num_followers = Follower.query(Follower.to == self.key,
Follower.status == 'active')\
.count_async()
num_following = Follower.query(Follower.from_ == self.key,
Follower.status == 'active')\
.count_async()
return num_followers.get_result(), num_following.get_result()
class Object(StringIdModel):
"""An activity or other object, eg actor.
Key name is the id. We synthesize ids if necessary.
"""
STATUSES = ('new', 'in progress', 'complete', 'failed', 'ignored')
LABELS = ('activity',
# DEPRECATED, replaced by users, notify, feed
'feed', 'notification', 'user')
# Keys for user(s) who created or otherwise own this activity.
users = ndb.KeyProperty(repeated=True)
# User keys who should see this activity in their user page, eg in reply to,
# reaction to, share of, etc.
notify = ndb.KeyProperty(repeated=True)
# User keys who should see this activity in their feeds, eg followers of its
# creator
feed = ndb.KeyProperty(repeated=True)
# DEPRECATED but still used read only to maintain backward compatibility
# with old Objects in the datastore that we haven't bothered migrating.
domains = ndb.StringProperty(repeated=True)
status = ndb.StringProperty(choices=STATUSES)
# choices is populated in reset_protocol_properties, after all User
# subclasses are created, so that PROTOCOLS is fully populated.
# TODO: nail down whether this is ABBREV or LABEL
source_protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()))
labels = ndb.StringProperty(repeated=True, choices=LABELS)
# TODO: switch back to ndb.JsonProperty if/when they fix it for the web console
# https://github.com/googleapis/python-ndb/issues/874
as2 = JsonProperty() # only one of the rest will be populated...
bsky = JsonProperty() # Bluesky / AT Protocol
mf2 = JsonProperty() # HTML microformats2 item (ie _not_ the top level
# parse object with items inside an 'items' field)
our_as1 = JsonProperty() # AS1 for activities that we generate or modify ourselves
raw = JsonProperty() # other standalone data format, eg DID document
# these are full feeds with multiple items, not just this one, so they're
# stored as audit records only. they're not used in to_as1. for Atom/RSS
# based Objects, our_as1 will be populated with an feed_index top-level
# integer field that indexes into one of these.
atom = ndb.TextProperty() # Atom XML
rss = ndb.TextProperty() # RSS XML
deleted = ndb.BooleanProperty()
delivered = ndb.StructuredProperty(Target, repeated=True)
undelivered = ndb.StructuredProperty(Target, repeated=True)
failed = ndb.StructuredProperty(Target, repeated=True)
# Copies of this object elsewhere, eg at:// URIs for ATProto records and
# nevent etc bech32-encoded Nostr ids, where this object is the original.
# Similar to u-syndication links in microformats2 and
# upstream/downstreamDuplicates in AS1.
copies = ndb.StructuredProperty(Target, repeated=True)
created = ndb.DateTimeProperty(auto_now_add=True)
updated = ndb.DateTimeProperty(auto_now=True)
new = None
changed = None
"""Protocol and subclasses set these in fetch if this :class:`Object` is
new or if its contents have changed from what was originally loaded from the
datastore. If either one is None, that means we don't know whether this
:class:`Object` is new/changed.
:attr:`changed` is populated by :meth:`activity_changed()`.
"""
lock = None
"""Initialized in __init__, synchronizes :meth:`add` and :meth:`remove`."""
@property
def as1(self):
def use_urls_as_ids(obj):
"""If id field is missing or not a URL, use the url field."""
id = obj.get('id')
if not id or not (util.is_web(id) or re.match(DOMAIN_RE, id)):
if url := util.get_url(obj):
obj['id'] = url
for field in 'author', 'actor', 'object':
if inner := as1.get_object(obj, field):
use_urls_as_ids(inner)
if self.our_as1:
obj = self.our_as1
if self.atom or self.rss:
use_urls_as_ids(obj)
elif self.as2:
obj = as2.to_as1(unwrap(self.as2))
elif self.bsky:
owner, _, _ = parse_at_uri(self.key.id())
ATProto = PROTOCOLS['atproto']
handle = ATProto(id=owner).handle
try:
obj = bluesky.to_as1(self.bsky, repo_did=owner, repo_handle=handle,
uri=self.key.id(), pds=ATProto.pds_for(self))
except (ValueError, RequestException):
logger.info(f"Couldn't convert to ATProto", exc_info=True)
return None
elif self.mf2:
obj = microformats2.json_to_object(self.mf2,
rel_urls=self.mf2.get('rel-urls'))
use_urls_as_ids(obj)
# use fetched final URL as id, not u-url
# https://github.com/snarfed/bridgy-fed/issues/829
if url := self.mf2.get('url'):
obj['id'] = (self.key.id() if self.key and '#' in self.key.id()
else url)
else:
return None
# populate id if necessary
if self.key:
obj.setdefault('id', self.key.id())
return obj
@ndb.ComputedProperty
def type(self): # AS1 objectType, or verb if it's an activity
if self.as1:
return as1.object_type(self.as1)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.lock = Lock()
def _expire(self):
"""Maybe automatically delete this Object after 90d using a TTL policy.
https://cloud.google.com/datastore/docs/ttl
They recommend not indexing TTL properties:
https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
"""
if self.type in OBJECT_EXPIRE_TYPES:
return (self.updated or util.now()) + OBJECT_EXPIRE_AGE
expire = ndb.ComputedProperty(_expire, indexed=False)
def _pre_put_hook(self):
"""
* Validate that at:// URIs have DID repos
* Set/remove the activity label
* Strip @context from as2 (we don't do LD) to save disk space
"""
id = self.key.id()
if self.source_protocol not in (None, 'ui'):
proto = PROTOCOLS[self.source_protocol]
assert proto.owns_id(id) is not False, \