diff --git a/journal/models/common.py b/journal/models/common.py index 71c65237..42823a85 100644 --- a/journal/models/common.py +++ b/journal/models/common.py @@ -114,13 +114,7 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): def delete(self, *args, **kwargs): if self.local: Takahe.delete_posts(self.all_post_ids) - toot_id = ( - (self.metadata or {}).get("mastodon_id") - if hasattr(self, "metadata") - else None - ) - if toot_id and self.owner.user.mastodon: - self.owner.user.mastodon.delete_post_later(toot_id) + self.delete_crossposts() return super().delete(*args, **kwargs) @property @@ -277,6 +271,22 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): # subclass may have to add additional code to update type_data in local post return p + @classmethod + def _delete_crossposts(cls, user_pk, metadata: dict): + user = User.objects.get(pk=user_pk) + toot_id = metadata.get("mastodon_id") + if toot_id and user.mastodon: + user.mastodon.delete_post(toot_id) + post_id = metadata.get("bluesky_id") + if toot_id and user.bluesky: + user.bluesky.delete_post(post_id) + + def delete_crossposts(self): + if hasattr(self, "metadata") and self.metadata: + django_rq.get_queue("mastodon").enqueue( + self._delete_crossposts, self.owner.user_id, self.metadata + ) + def get_crosspost_params(self): d = { "visibility": self.visibility, @@ -305,7 +315,13 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): activate_language_for_user(self.owner.user) metadata = self.metadata.copy() - # TODO migrate + + # backward compatible with previous way of storing mastodon id + legacy_mastodon_url = self.metadata.pop("shared_link", None) + if legacy_mastodon_url and not self.metadata.get("mastodon_id"): + self.metadata["mastodon_id"] = legacy_mastodon_url.split("/")[-1] + self.metadata["mastodon_url"] = legacy_mastodon_url + params = self.get_crosspost_params() self.sync_to_mastodon(params_for_platform(params, "mastodon"), update_mode) self.sync_to_threads(params_for_platform(params, "threads"), update_mode) diff --git a/mastodon/models/bluesky.py b/mastodon/models/bluesky.py index 41a67b28..cb139a89 100644 --- a/mastodon/models/bluesky.py +++ b/mastodon/models/bluesky.py @@ -1,8 +1,12 @@ +import re from functools import cached_property from operator import pos from atproto import Client, SessionEvent, client_utils from atproto_client import models +from atproto_identity.did.resolver import DidResolver +from atproto_identity.handle.resolver import HandleResolver +from django.db.models import base from django.utils import timezone from loguru import logger @@ -12,28 +16,60 @@ from .common import SocialAccount class Bluesky: - BASE_DOMAIN = "bsky.app" # TODO support alternative servers + _DOMAIN = "-" + _RE_HANDLE = re.compile( + r"/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/" + ) + # for BlueskyAccount + # uid is did and the only unique identifier + # domain is not useful and will always be _DOMAIN + # handle and base_url may change in BlueskyAccount.refresh() @staticmethod - def authenticate(username: str, password: str) -> "BlueskyAccount | None": + def authenticate(handle: str, password: str) -> "BlueskyAccount | None": + if not Bluesky._RE_HANDLE.match(handle) or len(handle) > 500: + logger.warning(f"ATProto login failed: handle {handle} is invalid") + return None try: - client = Client() - profile = client.login(username, password) + handle_r = HandleResolver(timeout=5) + did = handle_r.resolve(handle) + if not did: + logger.warning( + f"ATProto login failed: handle {handle} -> " + ) + return + did_r = DidResolver() + did_doc = did_r.resolve(did) + if not did_doc: + logger.warning( + f"ATProto login failed: handle {handle} -> did {did} -> " + ) + return + resolved_handle = did_doc.get_handle() + if resolved_handle != handle: + logger.warning( + f"ATProto login failed: handle {handle} -> did {did} -> handle {resolved_handle}" + ) + return + base_url = did_doc.get_pds_endpoint() + client = Client(base_url) + profile = client.login(handle, password) session_string = client.export_session_string() except Exception as e: - logger.debug(f"Bluesky login {username} exception {e}") - return None - existing_account = BlueskyAccount.objects.filter( - uid=profile.did, domain=Bluesky.BASE_DOMAIN + logger.debug(f"Bluesky login {handle} exception {e}") + return + account = BlueskyAccount.objects.filter( + uid=profile.did, domain=Bluesky._DOMAIN ).first() - if existing_account: - existing_account.session_string = session_string - existing_account.save(update_fields=["access_data"]) - existing_account.refresh(save=True, profile=profile) - return existing_account - account = BlueskyAccount(uid=profile.did, domain=Bluesky.BASE_DOMAIN) + if not account: + account = BlueskyAccount(uid=profile.did, domain=Bluesky._DOMAIN) + account._client = client account.session_string = session_string - account.refresh(save=False, profile=profile) + account.base_url = base_url + if account.pk: + account.refresh(save=True, did_refresh=False) + else: + account.refresh(save=False, did_refresh=False) return account @@ -42,9 +78,13 @@ class BlueskyAccount(SocialAccount): # app_password = jsondata.EncryptedTextField( # json_field_name="access_data", default="" # ) + base_url = jsondata.CharField(json_field_name="access_data", default=None) session_string = jsondata.EncryptedTextField( json_field_name="access_data", default="" ) + display_name = jsondata.CharField(json_field_name="account_data", default="") + description = jsondata.CharField(json_field_name="account_data", default="") + avatar = jsondata.CharField(json_field_name="account_data", default="") def on_session_change(self, event, session) -> None: if event in (SessionEvent.CREATE, SessionEvent.REFRESH): @@ -63,13 +103,46 @@ class BlueskyAccount(SocialAccount): @property def url(self): - return f"https://bsky.app/profile/{self.handle}" + return f"{self.base_url}/profile/{self.handle}" - def refresh(self, save=True, profile=None): + def refresh(self, save=True, did_refresh=True): + if did_refresh: + did = self.uid + did_r = DidResolver() + handle_r = HandleResolver(timeout=5) + did_doc = did_r.resolve(did) + if not did_doc: + logger.warning(f"ATProto refresh failed: did {did} -> ") + return False + resolved_handle = did_doc.get_handle() + if not resolved_handle: + logger.warning(f"ATProto refresh failed: did {did} -> ") + return False + resolved_did = handle_r.resolve(resolved_handle) + resolved_pds = did_doc.get_pds_endpoint() + if did != resolved_did: + logger.warning( + f"ATProto refresh failed: did {did} -> handle {resolved_handle} -> did {resolved_did}" + ) + return False + if resolved_handle != self.handle: + logger.debug( + f"ATProto refresh: handle changed for did {did}: handle {self.handle} -> {resolved_handle}" + ) + self.handle = resolved_handle + if resolved_pds != self.base_url: + logger.debug( + f"ATProto refresh: pds changed for did {did}: handle {self.base_url} -> {resolved_pds}" + ) + self.base_url = resolved_pds + profile = self._client.me if not profile: - _ = self._client - profile = self._profile - self.handle = profile.handle + logger.warning("Bluesky: client not logged in.") # this should not happen + return None + if self.handle != profile.handle: + logger.warning( + "ATProto refresh: handle mismatch {self.handle} from did doc -> {profile.handle} from PDS" + ) self.account_data = { k: v for k, v in profile.__dict__.items() if isinstance(v, (int, str)) } @@ -78,6 +151,7 @@ class BlueskyAccount(SocialAccount): if save: self.save( update_fields=[ + "access_data", "account_data", "handle", "last_refresh", diff --git a/users/models/apidentity.py b/users/models/apidentity.py index 3eba76ff..ad5a317c 100644 --- a/users/models/apidentity.py +++ b/users/models/apidentity.py @@ -19,6 +19,7 @@ class APIdentity(models.Model): """ user: User + user_id: int user = models.OneToOneField( User, models.SET_NULL, related_name="identity", null=True ) # type:ignore diff --git a/users/models/user.py b/users/models/user.py index 00d99fe8..708c2b24 100644 --- a/users/models/user.py +++ b/users/models/user.py @@ -295,12 +295,12 @@ class User(AbstractUser): return mastodon = self.mastodon threads = self.threads - # bluesky = self.bluesky + bluesky = self.bluesky changed = False name = ( (mastodon.display_name if mastodon else "") or (threads.username if threads else "") - # or (bluesky.display_name if bluesky else "") + or (bluesky.display_name if bluesky else "") or identity.name or identity.username ) @@ -310,7 +310,7 @@ class User(AbstractUser): summary = ( (mastodon.note if mastodon else "") or (threads.threads_biography if threads else "") - # or (bluesky.note if bluesky else "") + or (bluesky.description if bluesky else "") or identity.summary ) if identity.summary != summary: @@ -326,7 +326,8 @@ class User(AbstractUser): url = mastodon.avatar elif threads and threads.threads_profile_picture_url: url = threads.threads_profile_picture_url - # elif bluesky and bluesky. + elif bluesky and bluesky.avatar: + url = bluesky.avatar if url: try: r = httpx.get(url) diff --git a/users/templates/users/login.html b/users/templates/users/login.html index f1d72a22..86d8a1b7 100644 --- a/users/templates/users/login.html +++ b/users/templates/users/login.html @@ -145,14 +145,15 @@ {% csrf_token %} + {% trans "Please input your ATProto handle (e.g. neodb.bsky.social, without the leading @), do not use email." %}