bluesky login improvements

This commit is contained in:
Your Name 2024-07-05 10:53:43 -04:00 committed by Henri Dickson
parent 9ec737c8df
commit 4e97cb7083
5 changed files with 127 additions and 34 deletions

View file

@ -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)

View file

@ -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} -> <missing did>"
)
return
did_r = DidResolver()
did_doc = did_r.resolve(did)
if not did_doc:
logger.warning(
f"ATProto login failed: handle {handle} -> did {did} -> <missing doc>"
)
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} -> <missing doc>")
return False
resolved_handle = did_doc.get_handle()
if not resolved_handle:
logger.warning(f"ATProto refresh failed: did {did} -> <missing handle>")
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",

View file

@ -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

View file

@ -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)

View file

@ -145,14 +145,15 @@
{% csrf_token %}
<input name="method" value="bluesky" type="hidden" />
<input required
type="email"
name="username"
autofocus
placeholder="{% trans 'Bluesky Login ID' %}"
pattern="([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])?"
placeholder="{% trans 'Bluesky handle' %}"
autocorrect="off"
autocapitalize="off"
autocomplete="off"
spellcheck="false" />
<small>{% trans "Please input your ATProto handle (e.g. neodb.bsky.social, without the leading @), do not use email." %}</small>
<input required
type="password"
name="password"