2021-07-28 04:58:05 +00:00
|
|
|
"""Datastore model classes."""
|
2023-10-17 21:37:36 +00:00
|
|
|
import copy
|
2023-04-18 16:08:45 +00:00
|
|
|
from datetime import timedelta, timezone
|
2023-03-08 21:10:41 +00:00
|
|
|
import itertools
|
2023-04-28 19:02:26 +00:00
|
|
|
import json
|
2017-10-26 14:31:15 +00:00
|
|
|
import logging
|
2023-04-25 21:04:29 +00:00
|
|
|
import random
|
2023-10-19 23:25:04 +00:00
|
|
|
import re
|
2023-10-07 20:51:59 +00:00
|
|
|
from threading import Lock
|
2023-07-24 21:31:07 +00:00
|
|
|
from urllib.parse import quote, urlparse
|
2017-10-11 05:42:19 +00:00
|
|
|
|
2023-10-04 19:44:14 +00:00
|
|
|
from arroba.util import parse_at_uri
|
2023-11-08 20:03:56 +00:00
|
|
|
import cachetools
|
2023-08-31 20:49:45 +00:00
|
|
|
from Crypto.PublicKey import RSA
|
2023-03-20 21:28:14 +00:00
|
|
|
from flask import g, request
|
2019-12-26 06:20:57 +00:00
|
|
|
from google.cloud import ndb
|
2023-12-02 22:30:14 +00:00
|
|
|
from granary import as1, as2, atom, bluesky, microformats2
|
2023-10-22 03:51:54 +00:00
|
|
|
from granary.source import html_to_text
|
2023-05-24 13:49:54 +00:00
|
|
|
from oauth_dropins.webutil import util
|
2023-02-06 06:22:22 +00:00
|
|
|
from oauth_dropins.webutil.appengine_info import DEBUG
|
2023-05-24 13:49:54 +00:00
|
|
|
from oauth_dropins.webutil.flask_util import error
|
2023-07-02 05:40:42 +00:00
|
|
|
from oauth_dropins.webutil.models import (
|
|
|
|
ComputedJsonProperty,
|
|
|
|
JsonProperty,
|
|
|
|
StringIdModel,
|
|
|
|
)
|
2022-11-20 17:38:46 +00:00
|
|
|
from oauth_dropins.webutil.util import json_dumps, json_loads
|
2017-08-19 15:36:55 +00:00
|
|
|
|
2022-11-19 02:46:27 +00:00
|
|
|
import common
|
2023-10-24 17:46:57 +00:00
|
|
|
from common import add, base64_to_long, long_to_base64, unwrap
|
2023-09-25 17:27:08 +00:00
|
|
|
import ids
|
2022-11-19 02:46:27 +00:00
|
|
|
|
2023-05-26 23:07:36 +00:00
|
|
|
# maps string label to Protocol subclass. populated by ProtocolUserMeta.
|
|
|
|
# seed with old and upcoming protocols that don't have their own classes (yet).
|
2023-09-18 18:19:55 +00:00
|
|
|
PROTOCOLS = {'ostatus': None}
|
2023-05-26 23:07:36 +00:00
|
|
|
|
2023-02-06 06:22:22 +00:00
|
|
|
# 2048 bits makes tests slow, so use 1024 for them
|
|
|
|
KEY_BITS = 1024 if DEBUG else 2048
|
2023-03-08 21:10:41 +00:00
|
|
|
PAGE_SIZE = 20
|
2023-01-05 03:22:11 +00:00
|
|
|
|
2023-04-18 16:08:45 +00:00
|
|
|
# auto delete old objects of these types via the Object.expire property
|
|
|
|
# https://cloud.google.com/datastore/docs/ttl
|
|
|
|
OBJECT_EXPIRE_TYPES = (
|
|
|
|
'post',
|
|
|
|
'update',
|
|
|
|
'delete',
|
|
|
|
'accept',
|
|
|
|
'reject',
|
|
|
|
'undo',
|
2023-09-18 18:19:55 +00:00
|
|
|
None,
|
2023-04-18 16:08:45 +00:00
|
|
|
)
|
|
|
|
OBJECT_EXPIRE_AGE = timedelta(days=90)
|
|
|
|
|
2023-10-13 19:36:31 +00:00
|
|
|
OPT_OUT_TAGS = frozenset(('#nobot', '#nobridge'))
|
|
|
|
|
2022-02-12 06:38:56 +00:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2017-08-19 15:36:55 +00:00
|
|
|
|
2023-09-19 23:07:11 +00:00
|
|
|
class Target(ndb.Model):
|
2023-10-06 06:32:31 +00:00
|
|
|
""":class:`protocol.Protocol` + URI pairs for identifying objects.
|
2023-09-19 23:07:11 +00:00
|
|
|
|
|
|
|
These are currently used for:
|
2023-10-06 06:32:31 +00:00
|
|
|
|
2023-09-19 23:07:11 +00:00
|
|
|
* delivery destinations, eg ActivityPub inboxes, webmention targets, etc.
|
2023-10-06 06:32:31 +00:00
|
|
|
* 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.
|
2023-09-19 23:07:11 +00:00
|
|
|
|
2023-10-06 06:32:31 +00:00
|
|
|
Used in :class:`google.cloud.ndb.model.StructuredProperty`\s inside
|
2023-10-24 17:46:57 +00:00
|
|
|
:class:`Object` and :class:`User`; not stored as top-level entities in the
|
|
|
|
datastore.
|
2023-09-19 23:07:11 +00:00
|
|
|
|
|
|
|
ndb implements this by hoisting each property here into a corresponding
|
|
|
|
property on the parent entity, prefixed by the StructuredProperty name
|
2023-10-06 06:32:31 +00:00
|
|
|
below, eg ``delivered.uri``, ``delivered.protocol``, etc.
|
2023-09-19 23:07:11 +00:00
|
|
|
|
|
|
|
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)
|
|
|
|
# choices is populated in app via reset_protocol_properties, after all User
|
|
|
|
# subclasses are created, so that PROTOCOLS is fully populated
|
|
|
|
protocol = ndb.StringProperty(choices=[], 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))
|
|
|
|
|
|
|
|
|
2023-05-26 23:07:36 +00:00
|
|
|
class ProtocolUserMeta(type(ndb.Model)):
|
2023-10-06 06:32:31 +00:00
|
|
|
""":class:`User` metaclass. Registers all subclasses in the ``PROTOCOLS`` global."""
|
2023-05-26 23:07:36 +00:00
|
|
|
def __new__(meta, name, bases, class_dict):
|
|
|
|
cls = super().__new__(meta, name, bases, class_dict)
|
2023-09-18 18:19:55 +00:00
|
|
|
|
2023-06-11 15:14:17 +00:00
|
|
|
if hasattr(cls, 'LABEL') and cls.LABEL not in ('protocol', 'user'):
|
|
|
|
for label in (cls.LABEL, cls.ABBREV) + cls.OTHER_LABELS:
|
2023-06-13 20:17:11 +00:00
|
|
|
if label:
|
|
|
|
PROTOCOLS[label] = cls
|
2023-09-18 18:19:55 +00:00
|
|
|
|
2023-05-26 23:07:36 +00:00
|
|
|
return cls
|
|
|
|
|
|
|
|
|
|
|
|
def reset_protocol_properties():
|
2023-10-06 06:32:31 +00:00
|
|
|
"""Recreates various protocol properties to include choices from ``PROTOCOLS``."""
|
2023-05-26 23:36:45 +00:00
|
|
|
Target.protocol = ndb.StringProperty(
|
|
|
|
'protocol', choices=list(PROTOCOLS.keys()), required=True)
|
|
|
|
Object.source_protocol = ndb.StringProperty(
|
|
|
|
'source_protocol', choices=list(PROTOCOLS.keys()))
|
2023-05-26 23:07:36 +00:00
|
|
|
|
2023-10-24 17:46:57 +00:00
|
|
|
abbrevs = f'({"|".join(PROTOCOLS.keys())}|fed)'
|
|
|
|
common.SUBDOMAIN_BASE_URL_RE = re.compile(
|
|
|
|
rf'^https?://({abbrevs}\.brid\.gy|localhost(:8080)?)/(convert/|r/)?({abbrevs}/)?')
|
|
|
|
|
2023-05-26 23:07:36 +00:00
|
|
|
|
|
|
|
class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|
|
|
"""Abstract base class for a Bridgy Fed user.
|
2017-08-19 16:15:29 +00:00
|
|
|
|
2023-09-09 04:52:46 +00:00
|
|
|
Stores some protocols' keypairs. Currently:
|
2023-04-25 21:04:29 +00:00
|
|
|
|
|
|
|
* RSA keypair for ActivityPub HTTP Signatures
|
2023-10-06 06:32:31 +00:00
|
|
|
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:
|
2023-04-25 21:04:29 +00:00
|
|
|
https://tools.ietf.org/html/draft-cavage-http-signatures-12
|
2023-09-09 04:52:46 +00:00
|
|
|
* *Not* K-256 signing or rotation keys for AT Protocol, those are stored in
|
|
|
|
:class:`arroba.datastore_storage.AtpRepo` entities
|
2017-08-19 16:15:29 +00:00
|
|
|
"""
|
2023-06-16 04:22:20 +00:00
|
|
|
obj_key = ndb.KeyProperty(kind='Object') # user profile
|
2023-05-31 20:17:17 +00:00
|
|
|
mod = ndb.StringProperty()
|
2022-12-02 18:48:16 +00:00
|
|
|
use_instead = ndb.KeyProperty()
|
2017-08-19 16:15:29 +00:00
|
|
|
|
2023-09-20 02:59:28 +00:00
|
|
|
# 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.
|
2023-11-02 19:45:25 +00:00
|
|
|
# TODO: switch to using Object.copies on the user profile object?
|
2023-09-20 02:59:28 +00:00
|
|
|
copies = ndb.StructuredProperty(Target, repeated=True)
|
|
|
|
|
2023-05-30 02:37:35 +00:00
|
|
|
# whether this user signed up or otherwise explicitly, deliberately
|
|
|
|
# interacted with Bridgy Fed. For example, if fediverse user @a@b.com looks
|
|
|
|
# up @foo.com@fed.brid.gy via WebFinger, we'll create Users for both,
|
|
|
|
# @a@b.com will be direct, foo.com will not.
|
|
|
|
direct = ndb.BooleanProperty(default=False)
|
|
|
|
|
2023-09-22 18:41:30 +00:00
|
|
|
# these are for ActivityPub HTTP Signatures
|
|
|
|
public_exponent = ndb.StringProperty()
|
|
|
|
private_exponent = ndb.StringProperty()
|
|
|
|
|
2022-12-02 22:46:09 +00:00
|
|
|
created = ndb.DateTimeProperty(auto_now_add=True)
|
|
|
|
updated = ndb.DateTimeProperty(auto_now=True)
|
|
|
|
|
2024-01-06 23:19:43 +00:00
|
|
|
# `existing` attr is set by get_or_create
|
|
|
|
|
2023-06-16 04:22:20 +00:00
|
|
|
# OLD. some stored entities still have this; do not reuse.
|
2023-06-16 20:27:04 +00:00
|
|
|
# actor_as2 = JsonProperty()
|
2023-06-16 04:22:20 +00:00
|
|
|
|
|
|
|
def __init__(self, **kwargs):
|
|
|
|
"""Constructor.
|
|
|
|
|
2023-10-06 06:32:31 +00:00
|
|
|
Sets :attr:`obj` explicitly because however
|
|
|
|
:class:`google.cloud.ndb.model.Model` sets it doesn't work with
|
|
|
|
``@property`` and ``@obj.setter`` below.
|
2023-06-04 04:48:15 +00:00
|
|
|
"""
|
2023-06-16 04:22:20 +00:00
|
|
|
obj = kwargs.pop('obj', None)
|
|
|
|
super().__init__(**kwargs)
|
2023-06-04 04:48:15 +00:00
|
|
|
|
2023-06-16 04:22:20 +00:00
|
|
|
if obj:
|
|
|
|
self.obj = obj
|
2023-06-04 04:48:15 +00:00
|
|
|
|
2023-05-26 23:07:36 +00:00
|
|
|
@classmethod
|
|
|
|
def new(cls, **kwargs):
|
|
|
|
"""Try to prevent instantiation. Use subclasses instead."""
|
|
|
|
raise NotImplementedError()
|
|
|
|
|
2023-02-14 23:38:42 +00:00
|
|
|
def _post_put_hook(self, future):
|
2023-05-26 23:07:36 +00:00
|
|
|
logger.info(f'Wrote {self.key}')
|
2023-02-14 23:38:42 +00:00
|
|
|
|
2023-02-05 03:11:06 +00:00
|
|
|
@classmethod
|
2023-11-27 19:26:44 +00:00
|
|
|
def get_by_id(cls, id, allow_opt_out=False):
|
|
|
|
"""Override to follow ``use_instead`` property and ``opt-out` status.
|
|
|
|
|
|
|
|
Returns None if the user is opted out.
|
2023-10-06 06:32:31 +00:00
|
|
|
"""
|
2023-02-05 03:11:06 +00:00
|
|
|
user = cls._get_by_id(id)
|
2023-11-27 19:26:44 +00:00
|
|
|
if not user:
|
|
|
|
return None
|
|
|
|
elif user.use_instead:
|
2023-07-24 18:32:07 +00:00
|
|
|
logger.info(f'{user.key} use_instead => {user.use_instead}')
|
2023-02-05 03:11:06 +00:00
|
|
|
return user.use_instead.get()
|
2023-11-27 19:26:44 +00:00
|
|
|
elif user.status == 'opt-out' and not allow_opt_out:
|
|
|
|
logger.info(f'{user.key} is opted out')
|
|
|
|
return None
|
2023-02-05 03:11:06 +00:00
|
|
|
|
|
|
|
return user
|
|
|
|
|
2023-05-26 23:07:36 +00:00
|
|
|
@classmethod
|
2019-12-26 06:20:57 +00:00
|
|
|
@ndb.transactional()
|
2023-09-28 20:42:16 +00:00
|
|
|
def get_or_create(cls, id, propagate=False, **kwargs):
|
2023-10-19 22:01:19 +00:00
|
|
|
"""Loads and returns a :class:`User`. Creates it if necessary.
|
2023-09-28 20:42:16 +00:00
|
|
|
|
|
|
|
Args:
|
|
|
|
propagate (bool): whether to create copies of this user in push-based
|
|
|
|
protocols, eg ATProto and Nostr.
|
2023-11-27 14:50:09 +00:00
|
|
|
|
|
|
|
Returns:
|
|
|
|
User: existing or new user, or None if the user is opted out
|
2023-09-28 20:42:16 +00:00
|
|
|
"""
|
2023-05-26 23:07:36 +00:00
|
|
|
assert cls != User
|
2023-11-27 19:26:44 +00:00
|
|
|
user = cls.get_by_id(id, allow_opt_out=True)
|
2023-04-25 21:04:29 +00:00
|
|
|
if user:
|
2023-11-27 14:50:09 +00:00
|
|
|
if user.status == 'opt-out':
|
|
|
|
return None
|
2024-01-06 23:19:43 +00:00
|
|
|
user.existing = True
|
2023-06-03 14:28:01 +00:00
|
|
|
# override direct from False => True if set
|
2023-09-29 17:48:59 +00:00
|
|
|
# TODO: propagate more props into user?
|
2023-05-30 02:37:35 +00:00
|
|
|
direct = kwargs.get('direct')
|
2023-06-02 19:55:07 +00:00
|
|
|
if direct and not user.direct:
|
2023-05-31 20:17:17 +00:00
|
|
|
logger.info(f'Setting {user.key} direct={direct}')
|
2023-05-30 02:37:35 +00:00
|
|
|
user.direct = direct
|
|
|
|
user.put()
|
2023-09-28 20:42:16 +00:00
|
|
|
if not propagate:
|
|
|
|
return user
|
|
|
|
else:
|
|
|
|
user = cls(id=id, **kwargs)
|
2024-01-06 23:19:43 +00:00
|
|
|
user.existing = False
|
2023-09-28 20:42:16 +00:00
|
|
|
|
2023-10-20 18:23:43 +00:00
|
|
|
# load and propagate user and profile object
|
|
|
|
if not user.obj_key:
|
|
|
|
user.obj = cls.load(user.profile_id())
|
2023-10-07 18:49:04 +00:00
|
|
|
|
2023-11-16 03:08:06 +00:00
|
|
|
ATProto = PROTOCOLS['atproto']
|
|
|
|
if propagate and cls.LABEL != 'atproto' and not user.get_copy(ATProto):
|
|
|
|
ATProto.create_for(user)
|
2023-09-28 20:42:16 +00:00
|
|
|
|
2023-05-26 23:07:36 +00:00
|
|
|
# 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 cls.LABEL != 'activitypub':
|
|
|
|
key = RSA.generate(KEY_BITS, randfunc=random.randbytes if DEBUG else None)
|
2023-09-28 20:42:16 +00:00
|
|
|
user.mod = long_to_base64(key.n)
|
|
|
|
user.public_exponent = long_to_base64(key.e)
|
|
|
|
user.private_exponent = long_to_base64(key.d)
|
2023-05-26 23:07:36 +00:00
|
|
|
|
2023-06-23 19:22:37 +00:00
|
|
|
try:
|
|
|
|
user.put()
|
|
|
|
except AssertionError as e:
|
|
|
|
error(f'Bad {cls.__name__} id {id} : {e}')
|
|
|
|
|
2023-05-31 20:17:17 +00:00
|
|
|
logger.info(f'Created new {user}')
|
2022-11-16 06:00:28 +00:00
|
|
|
return user
|
2017-08-19 16:15:29 +00:00
|
|
|
|
2023-06-16 04:22:20 +00:00
|
|
|
@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
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def load_multi(cls, users):
|
|
|
|
"""Loads :attr:`obj` for multiple users in parallel.
|
|
|
|
|
|
|
|
Args:
|
2023-10-06 06:32:31 +00:00
|
|
|
users (sequence of User)
|
2023-06-16 04:22:20 +00:00
|
|
|
"""
|
|
|
|
objs = ndb.get_multi(u.obj_key for u in users if u.obj_key)
|
2023-06-29 20:45:55 +00:00
|
|
|
keys_to_objs = {o.key: o for o in objs if o}
|
2023-06-16 04:22:20 +00:00
|
|
|
|
|
|
|
for u in users:
|
|
|
|
u._obj = keys_to_objs.get(u.obj_key)
|
|
|
|
|
|
|
|
@ndb.ComputedProperty
|
2023-09-25 17:27:08 +00:00
|
|
|
def handle(self):
|
2023-09-25 22:08:14 +00:00
|
|
|
"""This user's unique, human-chosen handle, eg ``@me@snarfed.org``.
|
2023-09-25 17:27:08 +00:00
|
|
|
|
|
|
|
To be implemented by subclasses.
|
|
|
|
"""
|
|
|
|
raise NotImplementedError()
|
|
|
|
|
2023-09-25 22:08:14 +00:00
|
|
|
@ndb.ComputedProperty
|
|
|
|
def readable_id(self):
|
|
|
|
"""DEPRECATED: replaced by handle. Kept for backward compatibility."""
|
|
|
|
return None
|
|
|
|
|
2023-10-13 19:36:31 +00:00
|
|
|
@ndb.ComputedProperty
|
|
|
|
def status(self):
|
|
|
|
"""Whether this user has explicitly opted out of Bridgy Fed.
|
|
|
|
|
|
|
|
Optional. Current possible values:
|
|
|
|
* ``opt-out``
|
|
|
|
|
|
|
|
Currently just looks for ``#nobridge`` or ``#nobot`` in the profile
|
|
|
|
description/bio.
|
|
|
|
|
2023-11-12 15:49:13 +00:00
|
|
|
Duplicates ``util.is_opt_out`` in Bridgy!
|
|
|
|
|
2023-10-13 19:36:31 +00:00
|
|
|
https://github.com/snarfed/bridgy-fed/issues/666
|
|
|
|
"""
|
2023-11-29 18:25:45 +00:00
|
|
|
if self.key.id() in common.OPT_OUT_IDS:
|
|
|
|
return 'opt-out'
|
|
|
|
|
2023-10-13 19:36:31 +00:00
|
|
|
if not self.obj or not self.obj.as1:
|
|
|
|
return None
|
|
|
|
|
|
|
|
for field in 'summary', 'displayName':
|
2023-10-22 03:51:54 +00:00
|
|
|
text = html_to_text(self.obj.as1.get(field, ''))
|
2023-10-13 19:36:31 +00:00
|
|
|
for tag in OPT_OUT_TAGS:
|
2023-10-22 03:51:54 +00:00
|
|
|
if tag in text:
|
2023-10-13 19:36:31 +00:00
|
|
|
return 'opt-out'
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
2023-09-25 17:57:16 +00:00
|
|
|
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]
|
|
|
|
|
2023-11-23 03:00:02 +00:00
|
|
|
# override web users to always use domain instead of custom username
|
2023-11-30 05:06:55 +00:00
|
|
|
# TODO: fall back to id if handle is unset?
|
2023-11-23 03:00:02 +00:00
|
|
|
handle = self.key.id() if self.LABEL == 'web' else self.handle
|
2023-11-24 05:20:12 +00:00
|
|
|
if not handle:
|
|
|
|
return None
|
2023-11-23 03:00:02 +00:00
|
|
|
|
|
|
|
return ids.translate_handle(handle=handle, from_proto=self.__class__,
|
2023-11-30 00:31:33 +00:00
|
|
|
to_proto=to_proto, enhanced=False)
|
2023-09-25 17:57:16 +00:00
|
|
|
|
|
|
|
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]
|
|
|
|
|
2023-10-26 20:49:42 +00:00
|
|
|
return ids.translate_user_id(id=self.key.id(), from_proto=self.__class__,
|
|
|
|
to_proto=to_proto)
|
2023-09-25 17:57:16 +00:00
|
|
|
|
2023-09-25 19:33:24 +00:00
|
|
|
def handle_or_id(self):
|
|
|
|
"""Returns handle if we know it, otherwise id."""
|
2023-09-25 22:08:14 +00:00
|
|
|
return self.handle or self.key.id()
|
2023-06-16 04:22:20 +00:00
|
|
|
|
2017-09-19 14:13:35 +00:00
|
|
|
def public_pem(self):
|
2023-10-06 06:32:31 +00:00
|
|
|
"""
|
|
|
|
Returns:
|
|
|
|
bytes:
|
|
|
|
"""
|
2023-01-05 03:22:11 +00:00
|
|
|
rsa = RSA.construct((base64_to_long(str(self.mod)),
|
|
|
|
base64_to_long(str(self.public_exponent))))
|
2017-09-19 14:13:35 +00:00
|
|
|
return rsa.exportKey(format='PEM')
|
|
|
|
|
|
|
|
def private_pem(self):
|
2023-10-06 06:32:31 +00:00
|
|
|
"""
|
|
|
|
Returns:
|
|
|
|
bytes:
|
|
|
|
"""
|
2023-08-09 18:26:36 +00:00
|
|
|
assert self.mod and self.public_exponent and self.private_exponent, str(self)
|
2023-01-05 03:22:11 +00:00
|
|
|
rsa = RSA.construct((base64_to_long(str(self.mod)),
|
|
|
|
base64_to_long(str(self.public_exponent)),
|
|
|
|
base64_to_long(str(self.private_exponent))))
|
2017-09-19 14:13:35 +00:00
|
|
|
return rsa.exportKey(format='PEM')
|
2017-10-10 00:29:50 +00:00
|
|
|
|
2023-06-02 04:37:58 +00:00
|
|
|
def name(self):
|
2023-10-06 06:32:31 +00:00
|
|
|
"""Returns this user's human-readable name, eg ``Ryan Barrett``."""
|
2023-06-16 04:22:20 +00:00
|
|
|
if self.obj and self.obj.as1:
|
|
|
|
name = self.obj.as1.get('displayName')
|
2023-06-02 04:37:58 +00:00
|
|
|
if name:
|
|
|
|
return name
|
|
|
|
|
2023-09-25 19:33:24 +00:00
|
|
|
return self.handle_or_id()
|
2023-06-02 04:37:58 +00:00
|
|
|
|
2023-06-01 01:34:33 +00:00
|
|
|
def web_url(self):
|
2023-10-06 06:32:31 +00:00
|
|
|
"""Returns this user's web URL (homepage), eg ``https://foo.com/``.
|
2023-06-01 01:34:33 +00:00
|
|
|
|
|
|
|
To be implemented by subclasses.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
str
|
|
|
|
"""
|
|
|
|
raise NotImplementedError()
|
|
|
|
|
2023-10-19 23:25:04 +00:00
|
|
|
def is_web_url(self, url, ignore_www=False):
|
2023-06-06 21:50:20 +00:00
|
|
|
"""Returns True if the given URL is this user's web URL (homepage).
|
2023-06-01 01:34:33 +00:00
|
|
|
|
|
|
|
Args:
|
2023-10-06 06:32:31 +00:00
|
|
|
url (str)
|
2023-10-19 23:25:04 +00:00
|
|
|
ignore_www (bool): if True, ignores ``www.`` subdomains
|
2023-06-01 01:34:33 +00:00
|
|
|
|
|
|
|
Returns:
|
2023-10-06 06:32:31 +00:00
|
|
|
bool:
|
2023-06-01 01:34:33 +00:00
|
|
|
"""
|
|
|
|
if not url:
|
|
|
|
return False
|
|
|
|
|
|
|
|
url = url.strip().rstrip('/')
|
2023-10-19 23:25:04 +00:00
|
|
|
url = re.sub(r'^(https?://)www\.', r'\1', url)
|
2023-07-24 21:31:07 +00:00
|
|
|
parsed_url = urlparse(url)
|
2023-06-01 01:34:33 +00:00
|
|
|
if parsed_url.scheme not in ('http', 'https', ''):
|
|
|
|
return False
|
|
|
|
|
|
|
|
this = self.web_url().rstrip('/')
|
2023-10-19 23:25:04 +00:00
|
|
|
this = re.sub(r'^(https?://)www\.', r'\1', this)
|
2023-07-24 21:31:07 +00:00
|
|
|
parsed_this = urlparse(this)
|
2023-06-01 01:34:33 +00:00
|
|
|
|
|
|
|
return (url == this or url == parsed_this.netloc or
|
|
|
|
parsed_url[1:] == parsed_this[1:]) # ignore http vs https
|
|
|
|
|
2023-09-22 22:40:22 +00:00
|
|
|
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:
|
2023-10-06 06:32:31 +00:00
|
|
|
str:
|
2023-09-22 22:40:22 +00:00
|
|
|
"""
|
|
|
|
return self.key.id()
|
|
|
|
|
2023-05-30 21:08:13 +00:00
|
|
|
def user_page_path(self, rest=None):
|
|
|
|
"""Returns the user's Bridgy Fed user page path."""
|
2023-09-25 19:33:24 +00:00
|
|
|
path = f'/{self.ABBREV}/{self.handle_or_id()}'
|
2023-06-03 15:03:38 +00:00
|
|
|
|
2023-05-30 21:08:13 +00:00
|
|
|
if rest:
|
2023-06-03 15:03:38 +00:00
|
|
|
if not rest.startswith('?'):
|
|
|
|
path += '/'
|
|
|
|
path += rest
|
|
|
|
|
2023-05-30 21:08:13 +00:00
|
|
|
return path
|
|
|
|
|
2023-11-02 19:28:46 +00:00
|
|
|
def get_copy(self, proto):
|
|
|
|
"""Returns the id for the copy of this user in a given protocol.
|
|
|
|
|
2023-11-02 19:45:25 +00:00
|
|
|
...or None if no such copy exists. If ``proto`` is this user, returns
|
|
|
|
this user's key id.
|
2023-11-02 19:28:46 +00:00
|
|
|
|
|
|
|
Args:
|
|
|
|
proto: :class:`Protocol` subclass
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
str:
|
|
|
|
"""
|
2023-11-02 19:45:25 +00:00
|
|
|
if isinstance(self, proto):
|
|
|
|
return self.key.id()
|
|
|
|
|
2023-11-02 19:28:46 +00:00
|
|
|
for copy in self.copies:
|
|
|
|
if copy.protocol in (proto.LABEL, proto.ABBREV):
|
|
|
|
return copy.uri
|
|
|
|
|
2023-10-10 18:14:42 +00:00
|
|
|
def user_link(self):
|
|
|
|
"""Returns a pretty link to the external user with name and profile picture."""
|
2023-06-16 04:22:20 +00:00
|
|
|
actor = self.obj.as1 if self.obj and self.obj.as1 else {}
|
|
|
|
img = util.get_url(actor, 'image') or ''
|
2023-10-10 18:14:42 +00:00
|
|
|
return f"""\
|
|
|
|
<a class="h-card u-author" href="{self.web_url()}">
|
|
|
|
<img src="{img}" class="profile">
|
2023-10-11 04:19:26 +00:00
|
|
|
{self.name()}</a>"""
|
2022-11-26 06:21:50 +00:00
|
|
|
|
2023-11-08 20:03:56 +00:00
|
|
|
@cachetools.cached(cachetools.TTLCache(50000, 60 * 60 * 2), # 2h expiration
|
|
|
|
key=lambda user: user.key.id(), lock=Lock())
|
2023-11-08 19:56:01 +00:00
|
|
|
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()
|
|
|
|
num_following = Follower.query(Follower.from_ == self.key,
|
|
|
|
Follower.status == 'active')\
|
|
|
|
.count()
|
|
|
|
return num_followers, num_following
|
|
|
|
|
2017-10-10 00:29:50 +00:00
|
|
|
|
2023-01-28 15:48:50 +00:00
|
|
|
class Object(StringIdModel):
|
|
|
|
"""An activity or other object, eg actor.
|
2017-10-10 00:29:50 +00:00
|
|
|
|
2023-01-28 15:48:50 +00:00
|
|
|
Key name is the id. We synthesize ids if necessary.
|
2017-10-10 00:29:50 +00:00
|
|
|
"""
|
2023-01-29 04:49:20 +00:00
|
|
|
STATUSES = ('new', 'in progress', 'complete', 'failed', 'ignored')
|
2023-07-16 21:06:03 +00:00
|
|
|
LABELS = ('activity',
|
|
|
|
# DEPRECATED, replaced by users, notify, feed
|
|
|
|
'feed', 'notification', 'user')
|
|
|
|
|
|
|
|
# Keys for user(s) who created or otherwise own this activity.
|
|
|
|
#
|
|
|
|
# DEPRECATED: this used to include all users related the activity, including
|
|
|
|
# followers, but we've now moved those to the notify and feed properties.
|
2023-06-09 19:56:45 +00:00
|
|
|
users = ndb.KeyProperty(repeated=True)
|
2023-07-16 21:06:03 +00:00
|
|
|
# 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)
|
|
|
|
|
2023-06-16 04:22:20 +00:00
|
|
|
# DEPRECATED but still used read only to maintain backward compatibility
|
|
|
|
# with old Objects in the datastore that we haven't bothered migrating.
|
2023-01-28 15:48:50 +00:00
|
|
|
domains = ndb.StringProperty(repeated=True)
|
2023-06-09 19:56:45 +00:00
|
|
|
|
2023-02-03 03:51:55 +00:00
|
|
|
status = ndb.StringProperty(choices=STATUSES)
|
2023-09-14 16:42:11 +00:00
|
|
|
# choices is populated in app, after all User subclasses are created,
|
2023-05-26 23:07:36 +00:00
|
|
|
# so that PROTOCOLS is fully populated
|
2023-11-15 22:23:08 +00:00
|
|
|
# TODO: nail down whether this is ABBREV or LABEL
|
2023-05-26 23:07:36 +00:00
|
|
|
source_protocol = ndb.StringProperty(choices=[])
|
2023-01-28 15:48:50 +00:00
|
|
|
labels = ndb.StringProperty(repeated=True, choices=LABELS)
|
|
|
|
|
2023-02-24 13:25:29 +00:00
|
|
|
# TODO: switch back to ndb.JsonProperty if/when they fix it for the web console
|
|
|
|
# https://github.com/googleapis/python-ndb/issues/874
|
2023-03-21 16:10:14 +00:00
|
|
|
as2 = JsonProperty() # only one of the rest will be populated...
|
|
|
|
bsky = JsonProperty() # Bluesky / AT Protocol
|
2023-03-22 19:27:40 +00:00
|
|
|
mf2 = JsonProperty() # HTML microformats2 item (ie _not_ the top level
|
|
|
|
# parse object with items inside an 'items' field)
|
2023-03-21 16:10:14 +00:00
|
|
|
our_as1 = JsonProperty() # AS1 for activities that we generate or modify ourselves
|
2023-08-31 03:59:37 +00:00
|
|
|
raw = JsonProperty() # other standalone data format, eg DID document
|
2023-01-28 15:48:50 +00:00
|
|
|
|
2024-01-01 20:22:23 +00:00
|
|
|
# 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
|
|
|
|
|
2023-09-14 17:20:04 +00:00
|
|
|
deleted = ndb.BooleanProperty()
|
|
|
|
|
|
|
|
delivered = ndb.StructuredProperty(Target, repeated=True)
|
|
|
|
undelivered = ndb.StructuredProperty(Target, repeated=True)
|
|
|
|
failed = ndb.StructuredProperty(Target, repeated=True)
|
|
|
|
|
2023-09-19 23:07:11 +00:00
|
|
|
# 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.
|
2023-09-14 17:20:04 +00:00
|
|
|
copies = ndb.StructuredProperty(Target, repeated=True)
|
|
|
|
|
|
|
|
created = ndb.DateTimeProperty(auto_now_add=True)
|
|
|
|
updated = ndb.DateTimeProperty(auto_now=True)
|
|
|
|
|
2023-04-02 02:13:51 +00:00
|
|
|
new = None
|
|
|
|
changed = None
|
2023-10-06 06:32:31 +00:00
|
|
|
"""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.
|
2023-07-01 21:24:18 +00:00
|
|
|
|
2023-10-07 20:51:59 +00:00
|
|
|
:attr:`changed` is populated by :meth:`activity_changed()`.
|
2023-06-29 05:16:44 +00:00
|
|
|
"""
|
2023-04-02 02:13:51 +00:00
|
|
|
|
2023-10-07 20:51:59 +00:00
|
|
|
lock = None
|
|
|
|
"""Initialized in __init__, synchronizes property access, :meth:`put`s, etc."""
|
|
|
|
|
2023-02-24 14:25:18 +00:00
|
|
|
@ComputedJsonProperty
|
2023-02-24 03:17:26 +00:00
|
|
|
def as1(self):
|
2023-12-05 20:39:40 +00:00
|
|
|
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):
|
|
|
|
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)
|
|
|
|
|
2023-06-27 03:22:06 +00:00
|
|
|
if self.our_as1:
|
2023-10-24 17:46:57 +00:00
|
|
|
obj = self.our_as1
|
2024-01-01 20:22:23 +00:00
|
|
|
if self.atom or self.rss:
|
|
|
|
use_urls_as_ids(obj)
|
2023-09-18 18:19:55 +00:00
|
|
|
|
2023-06-27 03:22:06 +00:00
|
|
|
elif self.as2:
|
2023-12-02 22:25:38 +00:00
|
|
|
obj = as2.to_as1(self.as2)
|
2023-09-18 18:19:55 +00:00
|
|
|
|
2023-06-27 03:22:06 +00:00
|
|
|
elif self.bsky:
|
2023-09-28 20:56:22 +00:00
|
|
|
owner, _, _ = parse_at_uri(self.key.id())
|
2023-11-15 22:23:08 +00:00
|
|
|
ATProto = PROTOCOLS['atproto']
|
2023-09-25 22:08:14 +00:00
|
|
|
handle = ATProto(id=owner).handle
|
2023-09-25 03:02:09 +00:00
|
|
|
obj = bluesky.to_as1(self.bsky, repo_did=owner, repo_handle=handle,
|
2023-11-10 04:50:37 +00:00
|
|
|
uri=self.key.id(), pds=ATProto.target_for(self))
|
2023-09-18 18:19:55 +00:00
|
|
|
|
2023-06-27 03:22:06 +00:00
|
|
|
elif self.mf2:
|
2023-07-09 14:53:33 +00:00
|
|
|
obj = microformats2.json_to_object(self.mf2,
|
|
|
|
rel_urls=self.mf2.get('rel-urls'))
|
2023-12-05 20:39:40 +00:00
|
|
|
use_urls_as_ids(obj)
|
2023-09-18 18:19:55 +00:00
|
|
|
|
2023-07-09 14:53:33 +00:00
|
|
|
else:
|
|
|
|
return None
|
|
|
|
|
2023-09-20 02:59:28 +00:00
|
|
|
# populate id if necessary
|
2023-07-09 14:53:33 +00:00
|
|
|
if self.key:
|
|
|
|
obj.setdefault('id', self.key.id())
|
2023-09-20 02:59:28 +00:00
|
|
|
|
2023-07-09 14:53:33 +00:00
|
|
|
return obj
|
2023-02-24 03:17:26 +00:00
|
|
|
|
2023-02-14 05:43:49 +00:00
|
|
|
@ndb.ComputedProperty
|
|
|
|
def type(self): # AS1 objectType, or verb if it's an activity
|
2023-02-25 03:59:12 +00:00
|
|
|
if self.as1:
|
|
|
|
return as1.object_type(self.as1)
|
2023-02-14 05:43:49 +00:00
|
|
|
|
2023-10-07 20:51:59 +00:00
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.lock = Lock()
|
|
|
|
|
2023-02-14 05:43:49 +00:00
|
|
|
def _object_ids(self): # id(s) of inner objects
|
2023-02-25 03:59:12 +00:00
|
|
|
if self.as1:
|
2023-10-24 17:46:57 +00:00
|
|
|
return unwrap(as1.get_ids(self.as1, 'object'))
|
2023-01-28 15:48:50 +00:00
|
|
|
|
2023-09-14 17:20:04 +00:00
|
|
|
object_ids = ndb.ComputedProperty(_object_ids, repeated=True)
|
2017-10-11 05:42:19 +00:00
|
|
|
|
2023-04-18 16:08:45 +00:00
|
|
|
def _expire(self):
|
2023-06-29 05:16:44 +00:00
|
|
|
"""Maybe automatically delete this Object after 90d using a TTL policy.
|
|
|
|
|
2023-09-18 18:19:55 +00:00
|
|
|
https://cloud.google.com/datastore/docs/ttl
|
2023-06-29 05:16:44 +00:00
|
|
|
|
|
|
|
They recommend not indexing TTL properties:
|
|
|
|
https://cloud.google.com/datastore/docs/ttl#ttl_properties_and_indexes
|
|
|
|
"""
|
2023-04-18 16:08:45 +00:00
|
|
|
if self.type in OBJECT_EXPIRE_TYPES:
|
|
|
|
return (self.updated or util.now()) + OBJECT_EXPIRE_AGE
|
2023-09-14 17:20:04 +00:00
|
|
|
|
2023-04-18 16:08:45 +00:00
|
|
|
expire = ndb.ComputedProperty(_expire, indexed=False)
|
|
|
|
|
2023-03-28 04:51:18 +00:00
|
|
|
def _pre_put_hook(self):
|
2023-10-11 23:35:05 +00:00
|
|
|
"""
|
|
|
|
* 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
|
|
|
|
"""
|
2023-04-05 23:23:49 +00:00
|
|
|
assert '^^' not in self.key.id()
|
|
|
|
|
2023-09-13 19:51:34 +00:00
|
|
|
if self.key.id().startswith('at://'):
|
2023-09-28 20:56:22 +00:00
|
|
|
repo, _, _ = parse_at_uri(self.key.id())
|
2023-09-13 19:51:34 +00:00
|
|
|
if not repo.startswith('did:'):
|
2023-09-14 23:52:22 +00:00
|
|
|
# TODO: if we hit this, that means the AppView gave us an AT URI
|
|
|
|
# with a handle repo/authority instead of DID. that's surprising!
|
|
|
|
# ...if so, and if we need to handle it, add a new
|
|
|
|
# arroba.did.canonicalize_at_uri() function, then use it here,
|
|
|
|
# or before.
|
2023-09-13 19:51:34 +00:00
|
|
|
raise ValueError(
|
|
|
|
f'at:// URI ids must have DID repos; got {self.key.id()}')
|
|
|
|
|
2023-03-28 04:51:18 +00:00
|
|
|
if self.as1 and self.as1.get('objectType') == 'activity':
|
2023-10-07 20:51:59 +00:00
|
|
|
# can't self.add because we're inside self.put, which has the lock
|
2023-06-30 05:15:07 +00:00
|
|
|
add(self.labels, 'activity')
|
|
|
|
elif 'activity' in self.labels:
|
2023-10-07 20:51:59 +00:00
|
|
|
# ditto
|
2023-06-30 05:15:07 +00:00
|
|
|
self.labels.remove('activity')
|
2023-03-28 04:51:18 +00:00
|
|
|
|
2023-10-11 23:35:05 +00:00
|
|
|
if self.as2:
|
|
|
|
self.as2.pop('@context', None)
|
|
|
|
for field in 'actor', 'attributedTo', 'author', 'object':
|
|
|
|
for val in util.get_list(self.as2, field):
|
|
|
|
if isinstance(val, dict):
|
|
|
|
val.pop('@context', None)
|
|
|
|
|
2023-02-14 22:30:00 +00:00
|
|
|
def _post_put_hook(self, future):
|
2023-03-29 20:13:32 +00:00
|
|
|
"""Update :meth:`Protocol.load` cache."""
|
2023-02-14 22:56:27 +00:00
|
|
|
# TODO: assert that as1 id is same as key id? in pre put hook?
|
2023-06-23 18:05:12 +00:00
|
|
|
|
|
|
|
# log, pruning data fields
|
2023-07-09 02:19:57 +00:00
|
|
|
props = util.trim_nulls({
|
|
|
|
**self.to_dict(),
|
|
|
|
'new': self.new,
|
|
|
|
'changed': self.changed,
|
|
|
|
})
|
2024-01-01 20:22:23 +00:00
|
|
|
for prop in 'as2', 'atom', 'bsky', 'mf2', 'our_as1', 'raw', 'rss':
|
2023-06-23 18:05:12 +00:00
|
|
|
if props.get(prop):
|
|
|
|
props[prop] = "..."
|
2023-06-29 05:16:44 +00:00
|
|
|
for prop in 'created', 'updated', 'as1', 'expire':
|
|
|
|
props.pop(prop, None)
|
|
|
|
|
2023-06-23 18:05:12 +00:00
|
|
|
logger.info(f'Wrote {self.key} {props}')
|
|
|
|
|
2023-03-28 04:51:18 +00:00
|
|
|
if '#' not in self.key.id():
|
2023-04-19 00:17:48 +00:00
|
|
|
import protocol # TODO: actually fix this circular import
|
2023-06-23 18:05:12 +00:00
|
|
|
# make a copy so that if we later modify this object in memory,
|
|
|
|
# those modifications don't affect the cache.
|
|
|
|
# NOTE: keep in sync with Protocol.load!
|
|
|
|
protocol.objects_cache[self.key.id()] = Object(
|
|
|
|
id=self.key.id(),
|
|
|
|
# exclude computed properties
|
|
|
|
**self.to_dict(exclude=['as1', 'expire', 'object_ids', 'type']))
|
2023-03-08 21:10:41 +00:00
|
|
|
|
2023-04-05 23:23:49 +00:00
|
|
|
@classmethod
|
|
|
|
def get_by_id(cls, id):
|
2023-10-06 06:32:31 +00:00
|
|
|
"""Override :meth:`google.cloud.ndb.model.Model.get_by_id` to un-escape
|
|
|
|
``^^`` to ``#``.
|
2023-04-05 23:23:49 +00:00
|
|
|
|
2023-07-24 21:31:07 +00:00
|
|
|
Only needed for compatibility with historical URL paths, we're now back
|
2023-10-06 15:22:50 +00:00
|
|
|
to URL-encoding ``#``\s instead.
|
2023-04-05 23:23:49 +00:00
|
|
|
https://github.com/snarfed/bridgy-fed/issues/469
|
|
|
|
"""
|
|
|
|
return super().get_by_id(id.replace('^^', '#'))
|
|
|
|
|
2023-07-01 17:02:01 +00:00
|
|
|
@classmethod
|
|
|
|
@ndb.transactional()
|
2023-10-16 13:37:52 +00:00
|
|
|
def get_or_create(cls, id, actor=None, **props):
|
2023-10-06 06:32:31 +00:00
|
|
|
"""Returns an :class:`Object` with the given property values.
|
2023-07-01 17:02:01 +00:00
|
|
|
|
2023-10-06 06:32:31 +00:00
|
|
|
If a matching :class:`Object` doesn't exist in the datastore, creates it
|
|
|
|
first. Only populates non-False/empty property values in props into the
|
|
|
|
object. Also populates the :attr:`new` and :attr:`changed` properties.
|
2023-07-01 17:02:01 +00:00
|
|
|
|
2023-10-16 13:37:52 +00:00
|
|
|
Args:
|
|
|
|
actor (str): if a matching :class:`Object` already exists, its
|
|
|
|
`author` or `actor` must contain this actor id. Implements basic
|
|
|
|
authorization for updates and deletes.
|
|
|
|
|
2023-07-01 17:02:01 +00:00
|
|
|
Returns:
|
2023-10-06 06:32:31 +00:00
|
|
|
Object:
|
2023-07-01 17:02:01 +00:00
|
|
|
"""
|
2023-07-02 01:45:18 +00:00
|
|
|
obj = cls.get_by_id(id)
|
|
|
|
if obj:
|
|
|
|
obj.new = False
|
|
|
|
orig_as1 = obj.as1
|
2023-10-16 13:37:52 +00:00
|
|
|
if orig_as1:
|
2023-10-16 19:25:29 +00:00
|
|
|
authorized = (as1.get_ids(orig_as1, 'author') +
|
|
|
|
as1.get_ids(orig_as1, 'actor'))
|
2023-10-16 13:37:52 +00:00
|
|
|
if not actor:
|
2023-10-19 22:01:19 +00:00
|
|
|
logger.warning(f'would cowardly refuse to overwrite {id} without checking actor')
|
2023-10-16 19:25:29 +00:00
|
|
|
elif actor not in authorized + [id]:
|
|
|
|
logger.warning(f"actor {actor} isn't {id}'s author or actor {authorized}")
|
2023-07-02 01:45:18 +00:00
|
|
|
else:
|
|
|
|
obj = Object(id=id)
|
|
|
|
obj.new = True
|
|
|
|
|
2023-07-02 05:40:42 +00:00
|
|
|
obj.populate(**{
|
|
|
|
k: v for k, v in props.items()
|
|
|
|
if v and not isinstance(getattr(Object, k), ndb.ComputedProperty)
|
|
|
|
})
|
2023-07-02 01:45:18 +00:00
|
|
|
if not obj.new:
|
|
|
|
obj.changed = obj.activity_changed(orig_as1)
|
|
|
|
|
2023-07-01 17:02:01 +00:00
|
|
|
obj.put()
|
|
|
|
return obj
|
|
|
|
|
2023-10-07 20:51:59 +00:00
|
|
|
def put(self, **kwargs):
|
|
|
|
"""Stores this object. Uses ``self.lock``.
|
|
|
|
"""
|
|
|
|
with self.lock:
|
|
|
|
return super().put(**kwargs)
|
|
|
|
|
|
|
|
def add(self, prop, val):
|
|
|
|
"""Adds a value to a multiply-valued property. Uses ``self.lock``.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
prop (str)
|
|
|
|
val
|
|
|
|
"""
|
|
|
|
with self.lock:
|
|
|
|
add(getattr(self, prop), val)
|
|
|
|
|
|
|
|
def remove(self, prop, val):
|
|
|
|
"""Removes a value from a multiply-valued property. Uses ``self.lock``.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
prop (str)
|
|
|
|
val
|
|
|
|
"""
|
|
|
|
with self.lock:
|
|
|
|
getattr(self, prop).remove(val)
|
|
|
|
|
2023-07-01 21:24:18 +00:00
|
|
|
def activity_changed(self, other_as1):
|
2023-10-06 06:32:31 +00:00
|
|
|
"""Returns True if this activity is meaningfully changed from ``other_as1``.
|
2023-07-01 21:24:18 +00:00
|
|
|
|
|
|
|
...otherwise False.
|
|
|
|
|
|
|
|
Used to populate :attr:`changed`.
|
|
|
|
|
|
|
|
Args:
|
2023-10-06 06:32:31 +00:00
|
|
|
other_as1 (dict): AS1 object, or none
|
2023-07-01 21:24:18 +00:00
|
|
|
"""
|
2023-11-14 14:49:57 +00:00
|
|
|
# ignore inReplyTo since we translate it between protocols
|
|
|
|
return (as1.activity_changed(self.as1, other_as1, inReplyTo=False)
|
2023-07-01 21:24:18 +00:00
|
|
|
if self.as1 and other_as1
|
|
|
|
else bool(self.as1) != bool(other_as1))
|
|
|
|
|
2023-11-26 04:38:28 +00:00
|
|
|
def actor_link(self, image=True, sized=False, user=None):
|
2023-10-12 17:19:59 +00:00
|
|
|
"""Returns a pretty HTML link with the actor's name and picture.
|
|
|
|
|
|
|
|
Args:
|
2023-10-12 17:37:22 +00:00
|
|
|
image (bool): whether to include an ``img`` tag with the actor's picture
|
|
|
|
sized (bool): whether to set an explicit (``width=32``) size on the
|
2023-10-12 17:19:59 +00:00
|
|
|
profile picture ``img` tag
|
2023-11-26 04:38:28 +00:00
|
|
|
user (User): current user
|
2023-10-12 17:19:59 +00:00
|
|
|
|
|
|
|
Returns:
|
|
|
|
str:
|
|
|
|
"""
|
2023-03-14 13:54:16 +00:00
|
|
|
attrs = {'class': 'h-card u-author'}
|
|
|
|
|
2023-11-26 04:38:28 +00:00
|
|
|
if (self.source_protocol in ('web', 'webmention', 'ui')
|
|
|
|
and (user.key in self.users or user.key.id() in self.domains)):
|
2023-02-07 04:30:08 +00:00
|
|
|
# outbound; show a nice link to the user
|
2023-11-26 04:38:28 +00:00
|
|
|
return user.user_link()
|
2022-11-26 06:21:50 +00:00
|
|
|
|
2023-10-11 18:51:46 +00:00
|
|
|
actor = None
|
2023-06-27 03:22:06 +00:00
|
|
|
if self.as1:
|
2023-10-11 18:51:46 +00:00
|
|
|
actor = (as1.get_object(self.as1, 'actor')
|
|
|
|
or as1.get_object(self.as1, 'author'))
|
2023-10-11 19:22:34 +00:00
|
|
|
# hydrate from datastore if available
|
|
|
|
# TODO: optimize! this is called serially in loops, eg in home.html
|
|
|
|
if set(actor.keys()) == {'id'} and self.source_protocol:
|
|
|
|
proto = PROTOCOLS[self.source_protocol]
|
|
|
|
actor_obj = proto.load(actor['id'], remote=False)
|
|
|
|
if actor_obj and actor_obj.as1:
|
|
|
|
actor = actor_obj.as1
|
2023-10-11 18:51:46 +00:00
|
|
|
|
|
|
|
if not actor:
|
|
|
|
return ''
|
|
|
|
elif set(actor.keys()) == {'id'}:
|
2023-11-26 04:38:28 +00:00
|
|
|
return common.pretty_link(actor['id'], attrs=attrs, user=user)
|
2022-11-26 06:21:50 +00:00
|
|
|
|
2023-11-03 13:52:07 +00:00
|
|
|
url = as1.get_url(actor)
|
2023-02-07 16:24:24 +00:00
|
|
|
name = actor.get('displayName') or actor.get('username') or ''
|
2023-10-12 17:37:22 +00:00
|
|
|
img_url = util.get_url(actor, 'image')
|
|
|
|
if not image or not img_url:
|
2023-11-26 04:38:28 +00:00
|
|
|
return common.pretty_link(url, text=name, attrs=attrs, user=user)
|
2022-11-26 06:21:50 +00:00
|
|
|
|
|
|
|
return f"""\
|
2023-03-14 13:54:16 +00:00
|
|
|
<a class="h-card u-author" href="{url}" title="{name}">
|
2023-10-12 17:37:22 +00:00
|
|
|
<img class="profile" src="{img_url}" {'width="32"' if sized else ''}/>
|
2022-11-26 06:21:50 +00:00
|
|
|
{util.ellipsize(name, chars=40)}
|
|
|
|
</a>"""
|
|
|
|
|
2023-11-02 19:28:46 +00:00
|
|
|
def get_copy(self, proto):
|
|
|
|
"""Returns the id for the copy of this object in a given protocol.
|
|
|
|
|
2023-11-02 19:45:25 +00:00
|
|
|
...or None if no such copy exists. If ``proto`` is ``source_protocol``,
|
|
|
|
returns this object's key id.
|
2023-11-02 19:28:46 +00:00
|
|
|
|
|
|
|
Args:
|
|
|
|
proto: :class:`Protocol` subclass
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
str:
|
|
|
|
"""
|
2023-11-02 19:45:25 +00:00
|
|
|
if self.source_protocol in (proto.LABEL, proto.ABBREV):
|
|
|
|
return self.key.id()
|
|
|
|
|
2023-11-02 19:28:46 +00:00
|
|
|
for copy in self.copies:
|
|
|
|
if copy.protocol in (proto.LABEL, proto.ABBREV):
|
|
|
|
return copy.uri
|
|
|
|
|
2023-10-24 17:46:57 +00:00
|
|
|
def resolve_ids(self):
|
|
|
|
"""Resolves "copy" ids, subdomain ids, etc with their originals.
|
|
|
|
|
2023-11-02 19:45:25 +00:00
|
|
|
The end result is that all ids are original "source" ids, ie in the
|
|
|
|
protocol that they first came from.
|
|
|
|
|
2023-10-24 17:46:57 +00:00
|
|
|
Specifically, resolves:
|
|
|
|
|
|
|
|
* ids in :class:`User.copies` and :class:`Object.copies`, eg ATProto
|
|
|
|
records and Nostr events that we bridged, to the ids of their
|
|
|
|
original objects in their source protocol, eg
|
|
|
|
``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
|
|
|
|
* Bridgy Fed subdomain URLs to the ids embedded inside them, eg
|
|
|
|
``https://atproto.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
|
2023-10-17 21:37:36 +00:00
|
|
|
|
2023-10-24 17:46:57 +00:00
|
|
|
...in these AS1 fields, in place:
|
2023-10-17 21:37:36 +00:00
|
|
|
|
2023-10-26 19:04:04 +00:00
|
|
|
* ``id``
|
2023-10-17 21:37:36 +00:00
|
|
|
* ``actor``
|
|
|
|
* ``author``
|
|
|
|
* ``object``
|
|
|
|
* ``object.actor``
|
|
|
|
* ``object.author``
|
|
|
|
* ``object.id``
|
|
|
|
* ``object.inReplyTo``
|
|
|
|
* ``tags.[objectType=mention].url``
|
2023-10-26 19:04:04 +00:00
|
|
|
|
2023-11-02 19:45:25 +00:00
|
|
|
:meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
|
|
|
|
Much of the same logic is duplicated there!
|
2023-10-17 21:37:36 +00:00
|
|
|
"""
|
2023-10-25 20:23:11 +00:00
|
|
|
if not self.as1:
|
2023-10-17 21:37:36 +00:00
|
|
|
return
|
|
|
|
|
2023-10-24 17:46:57 +00:00
|
|
|
# extract ids, strip Bridgy Fed subdomain URLs
|
|
|
|
outer_obj = unwrap(self.as1)
|
|
|
|
if outer_obj != self.as1:
|
|
|
|
self.our_as1 = util.trim_nulls(outer_obj)
|
|
|
|
|
2023-11-12 18:33:08 +00:00
|
|
|
self_proto = PROTOCOLS.get(self.source_protocol)
|
|
|
|
if not self_proto:
|
2023-10-25 20:23:11 +00:00
|
|
|
return
|
|
|
|
|
2023-10-17 21:37:36 +00:00
|
|
|
inner_obj = outer_obj['object'] = as1.get_object(outer_obj)
|
2023-10-24 17:46:57 +00:00
|
|
|
fields = ['actor', 'author', 'inReplyTo']
|
2023-10-17 21:37:36 +00:00
|
|
|
|
2023-11-12 21:15:08 +00:00
|
|
|
# collect relevant ids
|
|
|
|
ids = [inner_obj.get('id')]
|
|
|
|
for obj in outer_obj, inner_obj:
|
|
|
|
for tag in as1.get_objects(obj, 'tags'):
|
|
|
|
if tag.get('objectType') == 'mention':
|
|
|
|
ids.append(tag.get('url'))
|
|
|
|
for field in fields:
|
|
|
|
for val in as1.get_objects(obj, field):
|
|
|
|
ids.append(val.get('id'))
|
|
|
|
|
|
|
|
ids = util.trim_nulls(ids)
|
2023-10-18 18:18:20 +00:00
|
|
|
if not ids:
|
|
|
|
return
|
|
|
|
|
2023-11-12 21:15:08 +00:00
|
|
|
# batch lookup matching users
|
|
|
|
origs = {} # maps str copy URI to str original URI
|
|
|
|
for obj in get_originals(ids):
|
|
|
|
for copy in obj.copies:
|
|
|
|
if copy.protocol in (self_proto.LABEL, self_proto.ABBREV):
|
|
|
|
origs[copy.uri] = obj.key.id()
|
2023-10-24 17:46:57 +00:00
|
|
|
|
2023-11-12 21:15:08 +00:00
|
|
|
logger.debug(f'Replacing copies with originals: {origs}')
|
|
|
|
replaced = False
|
2023-10-17 21:37:36 +00:00
|
|
|
|
2023-11-12 21:15:08 +00:00
|
|
|
def replace(val):
|
|
|
|
id = val.get('id') if isinstance(val, dict) else val
|
|
|
|
orig = origs.get(id)
|
|
|
|
if not orig:
|
|
|
|
return val
|
|
|
|
|
|
|
|
nonlocal replaced
|
|
|
|
replaced = True
|
|
|
|
if isinstance(val, dict) and val.keys() > {'id'}:
|
|
|
|
val['id'] = orig
|
|
|
|
return val
|
|
|
|
else:
|
|
|
|
return orig
|
|
|
|
|
|
|
|
# actually replace ids
|
2023-10-17 21:37:36 +00:00
|
|
|
for obj in outer_obj, inner_obj:
|
2023-11-12 21:15:08 +00:00
|
|
|
for tag in as1.get_objects(obj, 'tags'):
|
|
|
|
if tag.get('objectType') == 'mention':
|
|
|
|
tag['url'] = replace(tag.get('url'))
|
2023-10-24 17:46:57 +00:00
|
|
|
for field in fields:
|
2023-11-12 21:15:08 +00:00
|
|
|
obj[field] = [replace(val) for val in util.get_list(obj, field)]
|
|
|
|
if len(obj[field]) == 1:
|
|
|
|
obj[field] = obj[field][0]
|
2023-10-17 21:37:36 +00:00
|
|
|
|
2023-11-12 21:15:08 +00:00
|
|
|
outer_obj['object'] = replace(inner_obj)
|
|
|
|
if util.trim_nulls(outer_obj['object']).keys() == {'id'}:
|
|
|
|
outer_obj['object'] = outer_obj['object']['id']
|
2023-10-17 21:37:36 +00:00
|
|
|
|
|
|
|
if replaced:
|
2023-10-24 17:46:57 +00:00
|
|
|
self.our_as1 = util.trim_nulls(outer_obj)
|
2023-10-17 21:37:36 +00:00
|
|
|
|
2023-06-20 18:22:54 +00:00
|
|
|
|
2023-06-06 21:50:20 +00:00
|
|
|
class Follower(ndb.Model):
|
|
|
|
"""A follower of a Bridgy Fed user."""
|
2019-08-01 14:32:45 +00:00
|
|
|
STATUSES = ('active', 'inactive')
|
|
|
|
|
2023-06-06 21:50:20 +00:00
|
|
|
# these are both subclasses of User
|
2023-06-14 03:58:28 +00:00
|
|
|
from_ = ndb.KeyProperty(name='from', required=True)
|
|
|
|
to = ndb.KeyProperty(required=True)
|
2023-06-06 21:50:20 +00:00
|
|
|
|
|
|
|
follow = ndb.KeyProperty(Object) # last follow activity
|
2019-08-01 14:32:45 +00:00
|
|
|
status = ndb.StringProperty(choices=STATUSES, default='active')
|
2018-10-22 00:37:33 +00:00
|
|
|
|
|
|
|
created = ndb.DateTimeProperty(auto_now_add=True)
|
|
|
|
updated = ndb.DateTimeProperty(auto_now=True)
|
|
|
|
|
2023-06-08 23:04:37 +00:00
|
|
|
# OLD. some stored entities still have these; do not reuse.
|
|
|
|
# src = ndb.StringProperty()
|
|
|
|
# dest = ndb.StringProperty()
|
|
|
|
# last_follow = JsonProperty()
|
2023-06-06 21:50:20 +00:00
|
|
|
|
2023-06-14 03:58:28 +00:00
|
|
|
def _pre_put_hook(self):
|
|
|
|
if self.from_.kind() == 'Fake' and self.to.kind() == 'Fake':
|
|
|
|
return
|
|
|
|
|
|
|
|
# we're a bridge! stick with bridging.
|
|
|
|
assert self.from_.kind() != self.to.kind(), f'from {self.from_} to {self.to}'
|
|
|
|
|
2023-02-14 23:38:42 +00:00
|
|
|
def _post_put_hook(self, future):
|
2023-06-29 05:16:44 +00:00
|
|
|
# log, pruning data fields
|
|
|
|
props = util.trim_nulls(self.to_dict())
|
|
|
|
if props.get('follow'):
|
|
|
|
props['follow'] = "..."
|
|
|
|
for prop in 'created', 'updated':
|
|
|
|
props.pop(prop, None)
|
|
|
|
|
|
|
|
logger.info(f'Wrote {self.key} {props}')
|
2023-02-14 23:38:42 +00:00
|
|
|
|
2018-10-22 00:37:33 +00:00
|
|
|
@classmethod
|
2023-06-06 21:50:20 +00:00
|
|
|
@ndb.transactional()
|
|
|
|
def get_or_create(cls, *, from_, to, **kwargs):
|
2023-10-06 06:32:31 +00:00
|
|
|
"""Returns a Follower with the given ``from_`` and ``to`` users.
|
2018-10-22 00:37:33 +00:00
|
|
|
|
2023-10-06 06:32:31 +00:00
|
|
|
If a matching :class:`Follower` doesn't exist in the datastore, creates
|
|
|
|
it first.
|
2023-01-13 19:40:52 +00:00
|
|
|
|
2023-06-06 21:50:20 +00:00
|
|
|
Args:
|
2023-10-06 06:32:31 +00:00
|
|
|
from_ (User)
|
|
|
|
to (User)
|
2023-01-19 14:49:39 +00:00
|
|
|
|
2023-06-06 21:50:20 +00:00
|
|
|
Returns:
|
2023-10-06 06:32:31 +00:00
|
|
|
Follower:
|
2023-06-06 21:50:20 +00:00
|
|
|
"""
|
|
|
|
assert from_
|
|
|
|
assert to
|
|
|
|
|
|
|
|
follower = Follower.query(Follower.from_ == from_.key,
|
|
|
|
Follower.to == to.key,
|
|
|
|
).get()
|
|
|
|
if not follower:
|
|
|
|
follower = Follower(from_=from_.key, to=to.key, **kwargs)
|
|
|
|
follower.put()
|
|
|
|
elif kwargs:
|
|
|
|
# update existing entity with new property values, eg to make an
|
|
|
|
# inactive Follower active again
|
|
|
|
for prop, val in kwargs.items():
|
|
|
|
setattr(follower, prop, val)
|
|
|
|
follower.put()
|
|
|
|
|
|
|
|
return follower
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
@staticmethod
|
2023-11-20 04:39:05 +00:00
|
|
|
def fetch_page(collection, user):
|
|
|
|
"""Fetches a page of :class:`Follower`s for a given user.
|
2023-03-08 21:10:41 +00:00
|
|
|
|
2023-10-06 06:32:31 +00:00
|
|
|
Wraps :func:`fetch_page`. Paging uses the ``before`` and ``after`` query
|
2023-03-08 21:10:41 +00:00
|
|
|
parameters, if available in the request.
|
|
|
|
|
|
|
|
Args:
|
2023-10-06 06:32:31 +00:00
|
|
|
collection (str): ``followers`` or ``following``
|
2023-11-20 04:39:05 +00:00
|
|
|
user (User)
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
Returns:
|
2023-10-06 15:22:50 +00:00
|
|
|
(list of Follower, str, str) tuple: results, annotated with an extra
|
|
|
|
``user`` attribute that holds the follower or following :class:`User`,
|
|
|
|
and new str query param values for ``before`` and ``after`` to fetch
|
|
|
|
the previous and next pages, respectively
|
2023-03-08 21:10:41 +00:00
|
|
|
"""
|
|
|
|
assert collection in ('followers', 'following'), collection
|
|
|
|
|
2023-06-07 21:24:00 +00:00
|
|
|
filter_prop = Follower.to if collection == 'followers' else Follower.from_
|
2023-03-08 21:10:41 +00:00
|
|
|
query = Follower.query(
|
|
|
|
Follower.status == 'active',
|
2023-11-20 04:39:05 +00:00
|
|
|
filter_prop == user.key,
|
2023-03-08 21:10:41 +00:00
|
|
|
).order(-Follower.updated)
|
2023-06-07 21:24:00 +00:00
|
|
|
|
2023-10-10 21:55:27 +00:00
|
|
|
followers, before, after = fetch_page(query, Follower, by=Follower.updated)
|
2023-06-07 21:24:00 +00:00
|
|
|
users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
|
|
|
|
for f in followers)
|
2023-06-16 04:22:20 +00:00
|
|
|
User.load_multi(u for u in users if u)
|
|
|
|
|
2023-06-07 21:24:00 +00:00
|
|
|
for f, u in zip(followers, users):
|
|
|
|
f.user = u
|
2023-10-13 19:36:31 +00:00
|
|
|
followers = [f for f in followers if f.user.status != 'opt-out']
|
2023-06-07 21:24:00 +00:00
|
|
|
|
|
|
|
return followers, before, after
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
|
2023-11-23 04:39:21 +00:00
|
|
|
def fetch_objects(query, by=None, user=None):
|
|
|
|
"""Fetches a page of :class:`Object` entities from a datastore query.
|
|
|
|
|
|
|
|
Wraps :func:`fetch_page` and adds attributes to the returned
|
|
|
|
:class:`Object` entities for rendering in ``objects.html``.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
query (ndb.Query)
|
|
|
|
by (ndb.model.Property): either :attr:`Object.updated` or
|
|
|
|
:attr:`Object.created`
|
|
|
|
user (User): current user
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
(list of Object, str, str) tuple:
|
|
|
|
(results, new ``before`` query param, new ``after`` query param)
|
|
|
|
to fetch the previous and next pages, respectively
|
|
|
|
"""
|
|
|
|
assert by is Object.updated or by is Object.created
|
|
|
|
objects, new_before, new_after = fetch_page(query, Object, by=by)
|
|
|
|
objects = [o for o in objects if not o.deleted]
|
|
|
|
|
|
|
|
# synthesize human-friendly content for objects
|
|
|
|
for i, obj in enumerate(objects):
|
|
|
|
if obj.deleted:
|
|
|
|
continue
|
|
|
|
|
|
|
|
obj_as1 = obj.as1
|
|
|
|
inner_obj = as1.get_object(obj_as1)
|
|
|
|
|
|
|
|
# synthesize text snippet
|
|
|
|
type = as1.object_type(obj_as1)
|
|
|
|
if type == 'post':
|
|
|
|
inner_type = inner_obj.get('objectType')
|
|
|
|
if inner_type:
|
|
|
|
type = inner_type
|
|
|
|
|
|
|
|
phrases = {
|
|
|
|
'article': 'posted',
|
|
|
|
'comment': 'replied',
|
|
|
|
'delete': 'deleted',
|
|
|
|
'follow': 'followed',
|
|
|
|
'invite': 'is invited to',
|
|
|
|
'issue': 'filed issue',
|
|
|
|
'like': 'liked',
|
|
|
|
'note': 'posted',
|
|
|
|
'post': 'posted',
|
|
|
|
'repost': 'reposted',
|
|
|
|
'rsvp-interested': 'is interested in',
|
|
|
|
'rsvp-maybe': 'might attend',
|
|
|
|
'rsvp-no': 'is not attending',
|
|
|
|
'rsvp-yes': 'is attending',
|
|
|
|
'share': 'reposted',
|
|
|
|
'stop-following': 'unfollowed',
|
2023-12-23 15:54:17 +00:00
|
|
|
'undo': 'undid',
|
2023-11-23 04:39:21 +00:00
|
|
|
'update': 'updated',
|
|
|
|
}
|
|
|
|
obj.phrase = phrases.get(type)
|
|
|
|
|
|
|
|
content = (inner_obj.get('content')
|
|
|
|
or inner_obj.get('displayName')
|
|
|
|
or inner_obj.get('summary'))
|
|
|
|
if content:
|
|
|
|
content = util.parse_html(content).get_text()
|
|
|
|
|
|
|
|
urls = as1.object_urls(inner_obj)
|
2023-12-02 22:25:38 +00:00
|
|
|
id = unwrap(inner_obj.get('id', ''))
|
2023-11-23 04:39:21 +00:00
|
|
|
url = urls[0] if urls else id
|
|
|
|
if (type == 'update' and
|
|
|
|
(obj.users and (user.is_web_url(id)
|
|
|
|
or id.strip('/') == obj.users[0].id())
|
|
|
|
or obj.domains and id.strip('/') == f'https://{obj.domains[0]}')):
|
|
|
|
obj.phrase = 'updated'
|
|
|
|
obj_as1.update({
|
|
|
|
'content': 'their profile',
|
|
|
|
'url': id,
|
|
|
|
})
|
|
|
|
elif url:
|
|
|
|
# heuristics for sniffing Mastodon and similar fediverse URLs and
|
|
|
|
# converting them to more friendly @-names
|
|
|
|
# TODO: standardize this into granary.as2 somewhere?
|
|
|
|
if not content:
|
|
|
|
fedi_url = re.match(
|
|
|
|
r'https://[^/]+/(@|users/)([^/@]+)(@[^/@]+)?(/(?:statuses/)?[0-9]+)?', url)
|
|
|
|
if fedi_url:
|
|
|
|
content = '@' + fedi_url.group(2)
|
|
|
|
if fedi_url.group(4):
|
|
|
|
content += "'s post"
|
2023-11-26 04:38:28 +00:00
|
|
|
content = common.pretty_link(url, text=content, user=user)
|
2023-11-23 04:39:21 +00:00
|
|
|
|
|
|
|
obj.content = (obj_as1.get('content')
|
|
|
|
or obj_as1.get('displayName')
|
|
|
|
or obj_as1.get('summary'))
|
|
|
|
obj.url = util.get_first(obj_as1, 'url')
|
|
|
|
|
|
|
|
if type in ('like', 'follow', 'repost', 'share') or not obj.content:
|
|
|
|
if obj.url:
|
2023-11-26 04:38:28 +00:00
|
|
|
obj.phrase = common.pretty_link(
|
|
|
|
obj.url, text=obj.phrase, attrs={'class': 'u-url'}, user=user)
|
2023-11-23 04:39:21 +00:00
|
|
|
if content:
|
|
|
|
obj.content = content
|
|
|
|
obj.url = url
|
|
|
|
|
|
|
|
return objects, new_before, new_after
|
|
|
|
|
|
|
|
|
2023-10-10 21:55:27 +00:00
|
|
|
def fetch_page(query, model_class, by=None):
|
2023-03-08 21:10:41 +00:00
|
|
|
"""Fetches a page of results from a datastore query.
|
|
|
|
|
2023-10-06 06:32:31 +00:00
|
|
|
Uses the ``before`` and ``after`` query params (if provided; should be
|
2023-10-10 21:55:27 +00:00
|
|
|
ISO8601 timestamps) and the ``by`` property to identify the page to fetch.
|
2023-03-08 21:10:41 +00:00
|
|
|
|
2023-10-06 06:32:31 +00:00
|
|
|
Populates a ``log_url_path`` property on each result entity that points to a
|
2023-03-08 21:10:41 +00:00
|
|
|
its most recent logged request.
|
|
|
|
|
|
|
|
Args:
|
2023-10-06 15:22:50 +00:00
|
|
|
query (google.cloud.ndb.query.Query)
|
2023-10-06 06:32:31 +00:00
|
|
|
model_class (class)
|
2023-11-23 04:39:21 +00:00
|
|
|
by (ndb.model.Property): paging property, eg :attr:`Object.updated`
|
|
|
|
or :attr:`Object.created`
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
Returns:
|
2023-10-06 15:22:50 +00:00
|
|
|
(list of Object or Follower, str, str) tuple: (results, new_before,
|
|
|
|
new_after), where new_before and new_after are query param values for
|
|
|
|
``before`` and ``after`` to fetch the previous and next pages,
|
|
|
|
respectively
|
2023-03-08 21:10:41 +00:00
|
|
|
"""
|
2023-10-10 21:55:27 +00:00
|
|
|
assert by
|
|
|
|
|
2023-03-08 21:10:41 +00:00
|
|
|
# if there's a paging param ('before' or 'after'), update query with it
|
|
|
|
# TODO: unify this with Bridgy's user page
|
|
|
|
def get_paging_param(param):
|
|
|
|
val = request.values.get(param)
|
|
|
|
if val:
|
|
|
|
try:
|
|
|
|
dt = util.parse_iso8601(val.replace(' ', '+'))
|
|
|
|
except BaseException as e:
|
|
|
|
error(f"Couldn't parse {param}, {val!r} as ISO8601: {e}")
|
|
|
|
if dt.tzinfo:
|
|
|
|
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
|
|
|
return dt
|
|
|
|
|
|
|
|
before = get_paging_param('before')
|
|
|
|
after = get_paging_param('after')
|
|
|
|
if before and after:
|
|
|
|
error("can't handle both before and after")
|
|
|
|
elif after:
|
2023-10-10 21:55:27 +00:00
|
|
|
query = query.filter(by >= after).order(by)
|
2023-03-08 21:10:41 +00:00
|
|
|
elif before:
|
2023-10-10 21:55:27 +00:00
|
|
|
query = query.filter(by < before).order(-by)
|
2023-03-08 21:10:41 +00:00
|
|
|
else:
|
2023-10-10 21:55:27 +00:00
|
|
|
query = query.order(-by)
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
query_iter = query.iter()
|
|
|
|
results = sorted(itertools.islice(query_iter, 0, PAGE_SIZE),
|
|
|
|
key=lambda r: r.updated, reverse=True)
|
|
|
|
|
|
|
|
# calculate new paging param(s)
|
|
|
|
has_next = results and query_iter.probably_has_next()
|
|
|
|
new_after = (
|
|
|
|
before if before
|
|
|
|
else results[0].updated if has_next and after
|
|
|
|
else None)
|
|
|
|
if new_after:
|
|
|
|
new_after = new_after.isoformat()
|
|
|
|
|
|
|
|
new_before = (
|
|
|
|
after if after else
|
|
|
|
results[-1].updated if has_next
|
|
|
|
else None)
|
|
|
|
if new_before:
|
|
|
|
new_before = new_before.isoformat()
|
|
|
|
|
|
|
|
return results, new_before, new_after
|
2023-10-26 23:00:03 +00:00
|
|
|
|
|
|
|
|
2023-11-02 19:18:08 +00:00
|
|
|
def get_original(copy_id, keys_only=None):
|
2023-10-26 23:00:03 +00:00
|
|
|
"""Fetches a user or object with a given id in copies.
|
|
|
|
|
|
|
|
Thin wrapper around :func:`get_copies` that returns the first
|
|
|
|
matching result.
|
|
|
|
|
2023-11-02 19:28:46 +00:00
|
|
|
Also see :Object:`get_copy` and :User:`get_copy`.
|
|
|
|
|
2023-10-26 23:00:03 +00:00
|
|
|
Args:
|
|
|
|
copy_id (str)
|
|
|
|
keys_only (bool): passed through to :class:`google.cloud.ndb.Query`
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
User or Object:
|
|
|
|
"""
|
2023-11-02 19:18:08 +00:00
|
|
|
got = get_originals([copy_id], keys_only=keys_only)
|
2023-10-26 23:00:03 +00:00
|
|
|
if got:
|
|
|
|
return got[0]
|
|
|
|
|
|
|
|
|
2023-11-02 19:18:08 +00:00
|
|
|
def get_originals(copy_ids, keys_only=None):
|
2023-10-26 23:00:03 +00:00
|
|
|
"""Fetches users (across all protocols) for a given set of copies.
|
|
|
|
|
2023-11-02 19:28:46 +00:00
|
|
|
Also see :Object:`get_copy` and :User:`get_copy`.
|
|
|
|
|
2023-10-26 23:00:03 +00:00
|
|
|
Args:
|
|
|
|
copy_ids (sequence of str)
|
|
|
|
keys_only (bool): passed through to :class:`google.cloud.ndb.Query`
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
sequence of User and/or Object
|
|
|
|
"""
|
|
|
|
assert copy_ids
|
|
|
|
|
|
|
|
classes = set(cls for cls in PROTOCOLS.values() if cls)
|
|
|
|
classes.add(Object)
|
|
|
|
|
|
|
|
return list(itertools.chain(*(
|
|
|
|
cls.query(cls.copies.uri.IN(copy_ids)).iter(keys_only=keys_only)
|
|
|
|
for cls in classes)))
|
|
|
|
|
|
|
|
# TODO: default to looking up copy_ids as key ids, across protocols? is
|
|
|
|
# that useful anywhere?
|