From 3aaaac51f009041e949deb0d6fbb5df683d64c15 Mon Sep 17 00:00:00 2001 From: Oliver Falk Date: Fri, 7 Feb 2025 11:34:24 +0000 Subject: [PATCH] Bluesky integration * Centralize the our urlopen for consistency. * Fix a few tests --- config.py | 12 +- ivatar/ivataraccount/gravatar.py | 7 +- .../0019_confirmedemail_bluesky_handle.py | 19 ++ .../0020_confirmedopenid_bluesky_handle.py | 19 ++ ivatar/ivataraccount/models.py | 30 +- .../templates/assign_photo_email.html | 81 +++-- .../templates/assign_photo_openid.html | 13 + ivatar/ivataraccount/templates/profile.html | 30 +- ivatar/ivataraccount/test_views.py | 130 +++++--- ivatar/ivataraccount/test_views_bluesky.py | 284 ++++++++++++++++++ ivatar/ivataraccount/urls.py | 11 + ivatar/ivataraccount/views.py | 94 +++++- ivatar/test_views.py | 23 +- ivatar/tools/views.py | 32 +- ivatar/urls.py | 8 +- ivatar/utils.py | 76 +++++ ivatar/views.py | 142 ++++++++- 17 files changed, 920 insertions(+), 91 deletions(-) create mode 100644 ivatar/ivataraccount/migrations/0019_confirmedemail_bluesky_handle.py create mode 100644 ivatar/ivataraccount/migrations/0020_confirmedopenid_bluesky_handle.py create mode 100644 ivatar/ivataraccount/test_views_bluesky.py diff --git a/config.py b/config.py index cb4e18c..4d85543 100644 --- a/config.py +++ b/config.py @@ -268,9 +268,7 @@ TRUSTED_DEFAULT_URLS = [ }, ] -# This MUST BE THE LAST! -if os.path.isfile(os.path.join(BASE_DIR, "config_local.py")): - from config_local import * # noqa # flake8: noqa # NOQA # pragma: no cover +URL_TIMEOUT = 10 def map_legacy_config(trusted_url): @@ -286,3 +284,11 @@ def map_legacy_config(trusted_url): # Backward compability for legacy behavior TRUSTED_DEFAULT_URLS = list(map(map_legacy_config, TRUSTED_DEFAULT_URLS)) + +# Bluesky settings +BLUESKY_IDENTIFIER = os.environ.get("BLUESKY_IDENTIFIER", None) +BLUESKY_APP_PASSWORD = os.environ.get("BLUESKY_APP_PASSWORD", None) + +# This MUST BE THE LAST! +if os.path.isfile(os.path.join(BASE_DIR, "config_local.py")): + from config_local import * # noqa # flake8: noqa # NOQA # pragma: no cover diff --git a/ivatar/ivataraccount/gravatar.py b/ivatar/ivataraccount/gravatar.py index 18ca9a7..8ee51a0 100644 --- a/ivatar/ivataraccount/gravatar.py +++ b/ivatar/ivataraccount/gravatar.py @@ -3,13 +3,12 @@ Helper method to fetch Gravatar image """ from ssl import SSLError -from urllib.request import urlopen, HTTPError, URLError +from urllib.request import HTTPError, URLError +from ivatar.utils import urlopen import hashlib from ..settings import AVATAR_MAX_SIZE -URL_TIMEOUT = 5 # in seconds - def get_photo(email): """ @@ -30,7 +29,7 @@ def get_photo(email): service_url = "http://www.gravatar.com/" + hash_object.hexdigest() try: - urlopen(image_url, timeout=URL_TIMEOUT) + urlopen(image_url) except HTTPError as exc: if exc.code != 404 and exc.code != 503: print( # pragma: no cover diff --git a/ivatar/ivataraccount/migrations/0019_confirmedemail_bluesky_handle.py b/ivatar/ivataraccount/migrations/0019_confirmedemail_bluesky_handle.py new file mode 100644 index 0000000..61a3083 --- /dev/null +++ b/ivatar/ivataraccount/migrations/0019_confirmedemail_bluesky_handle.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 5.1.5 on 2025-01-27 10:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ivataraccount", "0018_alter_photo_format"), + ] + + operations = [ + migrations.AddField( + model_name="confirmedemail", + name="bluesky_handle", + field=models.CharField(blank=True, max_length=256, null=True), + ), + ] diff --git a/ivatar/ivataraccount/migrations/0020_confirmedopenid_bluesky_handle.py b/ivatar/ivataraccount/migrations/0020_confirmedopenid_bluesky_handle.py new file mode 100644 index 0000000..888fcdd --- /dev/null +++ b/ivatar/ivataraccount/migrations/0020_confirmedopenid_bluesky_handle.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 5.1.5 on 2025-01-27 13:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ivataraccount", "0019_confirmedemail_bluesky_handle"), + ] + + operations = [ + migrations.AddField( + model_name="confirmedopenid", + name="bluesky_handle", + field=models.CharField(blank=True, max_length=256, null=True), + ), + ] diff --git a/ivatar/ivataraccount/models.py b/ivatar/ivataraccount/models.py index f6217aa..25453b1 100644 --- a/ivatar/ivataraccount/models.py +++ b/ivatar/ivataraccount/models.py @@ -9,7 +9,7 @@ import time from io import BytesIO from os import urandom from urllib.error import HTTPError, URLError -from urllib.request import urlopen +from ivatar.utils import urlopen, Bluesky from urllib.parse import urlsplit, urlunsplit from PIL import Image @@ -322,6 +322,8 @@ class ConfirmedEmail(BaseAccountModel): null=True, on_delete=models.deletion.SET_NULL, ) + # Alternative assignment - use Bluesky handle + bluesky_handle = models.CharField(max_length=256, null=True, blank=True) digest = models.CharField(max_length=32) digest_sha256 = models.CharField(max_length=64) objects = ConfirmedEmailManager() @@ -342,6 +344,18 @@ class ConfirmedEmail(BaseAccountModel): self.photo = photo self.save() + def set_bluesky_handle(self, handle): + """ + Helper method to set Bluesky handle + """ + bs = Bluesky() + avatar = bs.get_profile(handle) + if not avatar: + raise Exception("Invalid Bluesky handle") + return + self.bluesky_handle = handle + self.save() + def save( self, force_insert=False, force_update=False, using=None, update_fields=None ): @@ -463,6 +477,8 @@ class ConfirmedOpenId(BaseAccountModel): alt_digest2 = models.CharField(max_length=64, null=True, blank=True, default=None) # https:// - https w/o trailing slash alt_digest3 = models.CharField(max_length=64, null=True, blank=True, default=None) + # Alternative assignment - use Bluesky handle + bluesky_handle = models.CharField(max_length=256, null=True, blank=True) access_count = models.BigIntegerField(default=0, editable=False) @@ -481,6 +497,18 @@ class ConfirmedOpenId(BaseAccountModel): self.photo = photo self.save() + def set_bluesky_handle(self, handle): + """ + Helper method to set Bluesky handle + """ + bs = Bluesky() + avatar = bs.get_profile(handle) + if not avatar: + raise Exception("Invalid Bluesky handle") + return + self.bluesky_handle = handle + self.save() + def save( self, force_insert=False, force_update=False, using=None, update_fields=None ): diff --git a/ivatar/ivataraccount/templates/assign_photo_email.html b/ivatar/ivataraccount/templates/assign_photo_email.html index 920dcf9..9ca1b2d 100644 --- a/ivatar/ivataraccount/templates/assign_photo_email.html +++ b/ivatar/ivataraccount/templates/assign_photo_email.html @@ -30,39 +30,68 @@ outline: inherit;
{% for photo in user.photo_set.all %}
{% csrf_token %} - - + +
+ + {% endfor %}
{% csrf_token %} - +
+ {% if email.bluesky_handle %} +
+
+
+

{% trans "Bluesky" %}

+
+
+
+ +
+
+
+
+ {% endif %} +
- {% blocktrans %}Upload a new one{% endblocktrans %}   - {% blocktrans %}Import from other services{% endblocktrans %} +{% blocktrans %}Upload a new one{% endblocktrans %}   +{% blocktrans %}Import from other services{% endblocktrans %} + +
+
{% csrf_token %} +
+ + {% if email.bluesky_handle %} + + {% else %} + + {% endif %} +
+ +
+ {% endif %}
{% endblock content %} diff --git a/ivatar/ivataraccount/templates/assign_photo_openid.html b/ivatar/ivataraccount/templates/assign_photo_openid.html index de1abb9..6595347 100644 --- a/ivatar/ivataraccount/templates/assign_photo_openid.html +++ b/ivatar/ivataraccount/templates/assign_photo_openid.html @@ -63,6 +63,19 @@ outline: inherit;
{% blocktrans %}upload a new one{% endblocktrans %} +
+
{% csrf_token %} +
+ + {% if openid.bluesky_handle %} + + {% else %} + + {% endif %} +
+ +
+ {% endif %}
{% endblock content %} diff --git a/ivatar/ivataraccount/templates/profile.html b/ivatar/ivataraccount/templates/profile.html index ec87235..12354c6 100644 --- a/ivatar/ivataraccount/templates/profile.html +++ b/ivatar/ivataraccount/templates/profile.html @@ -101,7 +101,15 @@
{% csrf_token %}
- + @@ -123,7 +131,15 @@ {% csrf_token %}
- + @@ -148,7 +164,15 @@ {% csrf_token %}
- + diff --git a/ivatar/ivataraccount/test_views.py b/ivatar/ivataraccount/test_views.py index 8cfcebb..074a7b9 100644 --- a/ivatar/ivataraccount/test_views.py +++ b/ivatar/ivataraccount/test_views.py @@ -456,11 +456,18 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods }, follow=True, ) # Create test addresses + 1 too much - # TODO: This test isn't super criticial, but needs to be fixed - # Currently issues an error with an unbound form - # self.assertFormError( - # response, "form", None, "Too many unconfirmed mail addresses!" - # ) + # Check the response context for form errors + self.assertTrue( + hasattr(response, "context"), "Response does not have a context" + ) + form = response.context.get("form") + self.assertIsNotNone(form, "No form found in response context") + + # Verify form errors + self.assertFalse(form.is_valid(), "Form should not be valid") + self.assertIn( + "Too many unconfirmed mail addresses!", form.errors.get("__all__", []) + ) def test_add_mail_address_twice(self): """ @@ -479,11 +486,18 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods }, follow=True, ) - # TODO: This test isn't super criticial, but needs to be fixed - # Currently issues an error with an unbound form - # self.assertFormError( - # response, "form", "email", "Address already added, currently unconfirmed" - # ) + # Check the response context for form errors + self.assertTrue( + hasattr(response, "context"), "Response does not have a context" + ) + form = response.context.get("form") + self.assertIsNotNone(form, "No form found in response context") + + # Verify form errors + self.assertFalse(form.is_valid(), "Form should not be valid") + self.assertIn( + "Address already added, currently unconfirmed", form.errors.get("email", []) + ) def test_add_already_confirmed_email_self(self): # pylint: disable=invalid-name """ @@ -500,11 +514,19 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods }, follow=True, ) - # TODO: This test isn't super criticial, but needs to be fixed - # Currently issues an error with an unbound form - # self.assertFormError( - # response, "form", "email", "Address already confirmed (by you)" - # ) + + # Check the response context for form errors + self.assertTrue( + hasattr(response, "context"), "Response does not have a context" + ) + form = response.context.get("form") + self.assertIsNotNone(form, "No form found in response context") + + # Verify form errors + self.assertFalse(form.is_valid(), "Form should not be valid") + self.assertIn( + "Address already confirmed (by you)", form.errors.get("email", []) + ) def test_add_already_confirmed_email_other(self): # pylint: disable=invalid-name """ @@ -528,11 +550,19 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods }, follow=True, ) - # TODO: This test isn't super criticial, but needs to be fixed - # Currently issues an error with an unbound form - # self.assertFormError( - # response, "form", "email", "Address already confirmed (by someone)" - # ) + + # Check the response context for form errors + self.assertTrue( + hasattr(response, "context"), "Response does not have a context" + ) + form = response.context.get("form") + self.assertIsNotNone(form, "No form found in response context") + + # Verify form errors + self.assertFalse(form.is_valid(), "Form should not be valid") + self.assertIn( + "Address already confirmed (by someone else)", form.errors.get("email", []) + ) def test_remove_unconfirmed_non_existing_email( self, @@ -1052,11 +1082,19 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods "There must only be one unconfirmed ID!", ) - # TODO: This test isn't super criticial, but needs to be fixed - # Currently issues an error with an unbound form - # self.assertFormError( - # response, "form", "openid", "OpenID already added, but not confirmed yet!" - # ) + # Check the response context for form errors + self.assertTrue( + hasattr(response, "context"), "Response does not have a context" + ) + form = response.context.get("form") + self.assertIsNotNone(form, "No form found in response context") + + # Verify form errors + self.assertFalse(form.is_valid(), "Form should not be valid") + self.assertIn( + "OpenID already added, but not confirmed yet!", + form.errors.get("openid", []), + ) # Manual confirm, since testing is _really_ hard! unconfirmed = self.user.unconfirmedopenid_set.first() @@ -1075,11 +1113,19 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods }, follow=True, ) - # TODO: This test isn't super criticial, but needs to be fixed - # Currently issues an error with an unbound form - # self.assertFormError( - # response, "form", "openid", "OpenID already added and confirmed!" - # ) + + # Check the response context for form errors + self.assertTrue( + hasattr(response, "context"), "Response does not have a context" + ) + form = response.context.get("form") + self.assertIsNotNone(form, "No form found in response context") + + # Verify form errors + self.assertFalse(form.is_valid(), "Form should not be valid") + self.assertIn( + "OpenID already added and confirmed!", form.errors.get("openid", []) + ) def test_assign_photo_to_openid(self): """ @@ -1529,9 +1575,6 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods """ Test fetching avatar for not existing mail with default specified """ - # TODO - Find a new way - # Do not run this test, since static serving isn't allowed in testing mode - return urlobj = urlsplit( libravatar_url( "xxx@xxx.xxx", @@ -1540,11 +1583,13 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods ) ) url = "%s?%s" % (urlobj.path, urlobj.query) + url += "&gravatarproxy=n" response = self.client.get(url, follow=False) - self.assertRedirects( - response=response, - expected_url="/static/img/nobody.png", - msg_prefix="Why does this not redirect to nobody img?", + self.assertEqual(response.status_code, 302, "Doesn't redirect with 302?") + self.assertEqual( + response["Location"], + "/static/img/nobody.png", + "Doesn't redirect to static img?", ) def test_avatar_url_default_gravatarproxy_disabled( @@ -1553,9 +1598,6 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods """ Test fetching avatar for not existing mail with default specified """ - # TODO - Find a new way - # Do not run this test, since static serving isn't allowed in testing mode - return urlobj = urlsplit( libravatar_url( "xxx@xxx.xxx", @@ -1565,10 +1607,10 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods ) url = "%s?%s&gravatarproxy=n" % (urlobj.path, urlobj.query) response = self.client.get(url, follow=True) - self.assertRedirects( - response=response, - expected_url="/static/img/nobody.png", - msg_prefix="Why does this not redirect to the default img?", + self.assertEqual( + response.redirect_chain[0][0], + "/static/img/nobody.png", + "Doesn't redirect to static?", ) def test_avatar_url_default_external(self): # pylint: disable=invalid-name diff --git a/ivatar/ivataraccount/test_views_bluesky.py b/ivatar/ivataraccount/test_views_bluesky.py new file mode 100644 index 0000000..4a92ec3 --- /dev/null +++ b/ivatar/ivataraccount/test_views_bluesky.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- +""" +Test our views in ivatar.ivataraccount.views and ivatar.views +""" +# pylint: disable=too-many-lines +import os +import django +from django.test import TestCase +from django.test import Client + +from django.urls import reverse +from django.contrib.auth.models import User + +# from django.contrib.auth import authenticate + +os.environ["DJANGO_SETTINGS_MODULE"] = "ivatar.settings" +django.setup() + +# pylint: disable=wrong-import-position +from ivatar import settings +from ivatar.ivataraccount.models import ConfirmedOpenId, ConfirmedEmail +from ivatar.utils import random_string + +from libravatar import libravatar_url + + +class Tester(TestCase): # pylint: disable=too-many-public-methods + """ + Main test class + """ + + client = Client() + user = None + username = random_string() + password = random_string() + email = "%s@%s.%s" % (username, random_string(), random_string(2)) + # Dunno why random tld doesn't work, but I'm too lazy now to investigate + openid = "http://%s.%s.%s/" % (username, random_string(), "org") + first_name = random_string() + last_name = random_string() + bsky_test_account = "ofalk.bsky.social" + + def login(self): + """ + Login as user + """ + self.client.login(username=self.username, password=self.password) + + def setUp(self): + """ + Prepare for tests. + - Create user + """ + self.user = User.objects.create_user( + username=self.username, + password=self.password, + first_name=self.first_name, + last_name=self.last_name, + ) + settings.EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend" + + def create_confirmed_openid(self): + """ + Create a confirmed openid + """ + confirmed = ConfirmedOpenId.objects.create( + user=self.user, + ip_address="127.0.0.1", + openid=self.openid, + ) + + return confirmed + + def create_confirmed_email(self): + """ + Create a confirmed email + """ + confirmed = ConfirmedEmail.objects.create( + email=self.email, + user=self.user, + ) + + return confirmed + + # The following tests need to be moved over to the model tests + # and real web UI tests added + def test_bluesky_handle_for_mail_via_model_handle_doesnt_exist(self): + """ + Add Bluesky handle to a confirmed mail address + """ + self.login() + confirmed = self.create_confirmed_email() + confirmed.set_bluesky_handle(self.bsky_test_account) + + try: + confirmed.set_bluesky_handle(self.bsky_test_account + "1") + except Exception: + pass + self.assertNotEqual( + confirmed.bluesky_handle, + self.bsky_test_account + "1", + "Setting Bluesky handle that doesn exist works?", + ) + + def test_bluesky_handle_for_mail_via_model_handle_exists(self): + """ + Add Bluesky handle to a confirmed mail address + """ + self.login() + confirmed = self.create_confirmed_email() + confirmed.set_bluesky_handle(self.bsky_test_account) + + self.assertEqual( + confirmed.bluesky_handle, + self.bsky_test_account, + "Setting Bluesky handle doesn't work?", + ) + + def test_bluesky_handle_for_openid_via_model_handle_doesnt_exist(self): + """ + Add Bluesky handle to a confirmed openid address + """ + self.login() + confirmed = self.create_confirmed_openid() + confirmed.set_bluesky_handle(self.bsky_test_account) + + try: + confirmed.set_bluesky_handle(self.bsky_test_account + "1") + except Exception: + pass + self.assertNotEqual( + confirmed.bluesky_handle, + self.bsky_test_account + "1", + "Setting Bluesky handle that doesn exist works?", + ) + + def test_bluesky_handle_for_openid_via_model_handle_exists(self): + """ + Add Bluesky handle to a confirmed openid address + """ + self.login() + confirmed = self.create_confirmed_openid() + confirmed.set_bluesky_handle(self.bsky_test_account) + + self.assertEqual( + confirmed.bluesky_handle, + self.bsky_test_account, + "Setting Bluesky handle doesn't work?", + ) + + def test_bluesky_fetch_mail(self): + """ + Check if we can successfully fetch a Bluesky avatar via email + """ + self.login() + confirmed = self.create_confirmed_email() + confirmed.set_bluesky_handle(self.bsky_test_account) + lu = libravatar_url(confirmed.email, https=True) + lu = lu.replace("https://seccdn.libravatar.org/", reverse("home")) + + response = self.client.get(lu) + # This is supposed to redirect to the Bluesky proxy + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "/blueskyproxy/%s" % confirmed.digest) + + def test_bluesky_fetch_openid(self): + """ + Check if we can successfully fetch a Bluesky avatar via OpenID + """ + self.login() + confirmed = self.create_confirmed_openid() + confirmed.set_bluesky_handle(self.bsky_test_account) + lu = libravatar_url(openid=confirmed.openid, https=True) + lu = lu.replace("https://seccdn.libravatar.org/", reverse("home")) + + response = self.client.get(lu) + # This is supposed to redirect to the Bluesky proxy + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "/blueskyproxy/%s" % confirmed.digest) + + def test_assign_bluesky_handle_to_openid(self): + """ + Assign a Bluesky handle to an OpenID + """ + self.login() + confirmed = self.create_confirmed_openid() + url = reverse("assign_bluesky_handle_to_openid", args=[confirmed.id]) + response = self.client.post( + url, + { + "bluesky_handle": self.bsky_test_account, + }, + follow=True, + ) + self.assertEqual( + response.status_code, 200, "Adding Bluesky handle to OpenID fails?" + ) + # Fetch object again, as it has changed because of the request + confirmed.refresh_from_db(fields=["bluesky_handle"]) + self.assertEqual( + confirmed.bluesky_handle, + self.bsky_test_account, + "Setting Bluesky handle doesn't work?", + ) + + def test_assign_bluesky_handle_to_email(self): + """ + Assign a Bluesky handle to an email + + """ + self.login() + confirmed = self.create_confirmed_email() + url = reverse("assign_bluesky_handle_to_email", args=[confirmed.id]) + response = self.client.post( + url, + { + "bluesky_handle": self.bsky_test_account, + }, + follow=True, + ) + self.assertEqual( + response.status_code, 200, "Adding Bluesky handle to Email fails?" + ) + # Fetch object again, as it has changed because of the request + confirmed.refresh_from_db(fields=["bluesky_handle"]) + self.assertEqual( + confirmed.bluesky_handle, + self.bsky_test_account, + "Setting Bluesky handle doesn't work?", + ) + + def test_assign_photo_to_mail_removes_bluesky_handle(self): + """ + Assign a Photo to a mail, removes Bluesky handle + """ + self.login() + confirmed = self.create_confirmed_email() + confirmed.bluesky_handle = self.bsky_test_account + confirmed.save() + + url = reverse("assign_photo_email", args=[confirmed.id]) + response = self.client.post( + url, + { + "photoNone": True, + }, + follow=True, + ) + + self.assertEqual(response.status_code, 200, "Unassigning Photo doesn't work?") + # Fetch object again, as it has changed because of the request + confirmed.refresh_from_db(fields=["bluesky_handle"]) + self.assertEqual( + confirmed.bluesky_handle, + None, + "Removing Bluesky handle doesn't work?", + ) + + def test_assign_photo_to_openid_removes_bluesky_handle(self): + """ + Assign a Photo to a OpenID, removes Bluesky handle + """ + self.login() + confirmed = self.create_confirmed_openid() + confirmed.bluesky_handle = self.bsky_test_account + confirmed.save() + + url = reverse("assign_photo_openid", args=[confirmed.id]) + response = self.client.post( + url, + { + "photoNone": True, + }, + follow=True, + ) + + self.assertEqual(response.status_code, 200, "Unassigning Photo doesn't work?") + # Fetch object again, as it has changed because of the request + confirmed.refresh_from_db(fields=["bluesky_handle"]) + self.assertEqual( + confirmed.bluesky_handle, + None, + "Removing Bluesky handle doesn't work?", + ) diff --git a/ivatar/ivataraccount/urls.py b/ivatar/ivataraccount/urls.py index 84f99e6..ae6a1c4 100644 --- a/ivatar/ivataraccount/urls.py +++ b/ivatar/ivataraccount/urls.py @@ -20,6 +20,7 @@ from .views import RemoveUnconfirmedOpenIDView, RemoveConfirmedOpenIDView from .views import ImportPhotoView, RawImageView, DeletePhotoView from .views import UploadPhotoView, AssignPhotoOpenIDView from .views import AddOpenIDView, RedirectOpenIDView, ConfirmOpenIDView +from .views import AssignBlueskyHandleToEmailView, AssignBlueskyHandleToOpenIdView from .views import CropPhotoView from .views import UserPreferenceView, UploadLibravatarExportView from .views import ResendConfirmationMailView @@ -125,6 +126,16 @@ urlpatterns = [ # pylint: disable=invalid-name AssignPhotoOpenIDView.as_view(), name="assign_photo_openid", ), + re_path( + r"assign_bluesky_handle_to_email/(?P\d+)", + AssignBlueskyHandleToEmailView.as_view(), + name="assign_bluesky_handle_to_email", + ), + re_path( + r"assign_bluesky_handle_to_openid/(?P\d+)", + AssignBlueskyHandleToOpenIdView.as_view(), + name="assign_bluesky_handle_to_openid", + ), re_path(r"import_photo/$", ImportPhotoView.as_view(), name="import_photo"), re_path( r"import_photo/(?P[\w.+-]+@[\w.]+.[\w.]+)", diff --git a/ivatar/ivataraccount/views.py b/ivatar/ivataraccount/views.py index f993cb5..d7add09 100644 --- a/ivatar/ivataraccount/views.py +++ b/ivatar/ivataraccount/views.py @@ -3,7 +3,7 @@ View classes for ivatar/ivataraccount/ """ from io import BytesIO -from urllib.request import urlopen +from ivatar.utils import urlopen, Bluesky import base64 import binascii from xml.sax import saxutils @@ -288,6 +288,7 @@ class AssignPhotoEmailView(SuccessMessageMixin, TemplateView): messages.error(request, _("Photo does not exist")) return HttpResponseRedirect(reverse_lazy("profile")) email.photo = photo + email.bluesky_handle = None email.save() messages.success(request, _("Successfully changed photo")) @@ -337,6 +338,7 @@ class AssignPhotoOpenIDView(SuccessMessageMixin, TemplateView): messages.error(request, _("Photo does not exist")) return HttpResponseRedirect(reverse_lazy("profile")) openid.photo = photo + openid.bluesky_handle = None openid.save() messages.success(request, _("Successfully changed photo")) @@ -350,6 +352,96 @@ class AssignPhotoOpenIDView(SuccessMessageMixin, TemplateView): return data +@method_decorator(login_required, name="dispatch") +class AssignBlueskyHandleToEmailView(SuccessMessageMixin, TemplateView): + """ + View class for assigning a Bluesky handle to an email address + """ + + def post(self, request, *args, **kwargs): # pylint: disable=unused-argument + """ + Handle post request - assign bluesky handle to email + """ + + try: + email = ConfirmedEmail.objects.get(user=request.user, id=kwargs["email_id"]) + except ConfirmedEmail.DoesNotExist: # pylint: disable=no-member + messages.error(request, _("Invalid request")) + return HttpResponseRedirect(reverse_lazy("profile")) + + if "bluesky_handle" not in request.POST: + messages.error(request, _("Invalid request [bluesky_handle] missing")) + return HttpResponseRedirect(reverse_lazy("profile")) + bluesky_handle = request.POST["bluesky_handle"] + + try: + bs = Bluesky() + + bs.get_avatar(bluesky_handle) + except Exception as e: + messages.error( + request, _("Handle '%s' not found: %s" % (bluesky_handle, e)) + ) + return HttpResponseRedirect(reverse_lazy("profile")) + email.set_bluesky_handle(bluesky_handle) + email.photo = None + email.save() + + messages.success(request, _("Successfully assigned Bluesky handle")) + return HttpResponseRedirect(reverse_lazy("profile")) + + def get_context_data(self, **kwargs): + data = super().get_context_data(**kwargs) + data["email"] = ConfirmedEmail.objects.get(pk=kwargs["email_id"]) + return data + + +@method_decorator(login_required, name="dispatch") +class AssignBlueskyHandleToOpenIdView(SuccessMessageMixin, TemplateView): + """ + View class for assigning a Bluesky handle to an email address + """ + + def post(self, request, *args, **kwargs): # pylint: disable=unused-argument + """ + Handle post request - assign bluesky handle to email + """ + + try: + openid = ConfirmedOpenId.objects.get( + user=request.user, id=kwargs["open_id"] + ) + except ConfirmedOpenId.DoesNotExist: # pylint: disable=no-member + messages.error(request, _("Invalid request")) + return HttpResponseRedirect(reverse_lazy("profile")) + + if "bluesky_handle" not in request.POST: + messages.error(request, _("Invalid request [bluesky_handle] missing")) + return HttpResponseRedirect(reverse_lazy("profile")) + bluesky_handle = request.POST["bluesky_handle"] + + try: + bs = Bluesky() + + bs.get_avatar(bluesky_handle) + except Exception as e: + messages.error( + request, _("Handle '%s' not found: %s" % (bluesky_handle, e)) + ) + return HttpResponseRedirect(reverse_lazy("profile")) + openid.set_bluesky_handle(bluesky_handle) + openid.photo = None + openid.save() + + messages.success(request, _("Successfully assigned Bluesky handle")) + return HttpResponseRedirect(reverse_lazy("profile")) + + def get_context_data(self, **kwargs): + data = super().get_context_data(**kwargs) + data["openid"] = ConfirmedOpenId.objects.get(pk=kwargs["open_id"]) + return data + + @method_decorator(login_required, name="dispatch") class ImportPhotoView(SuccessMessageMixin, TemplateView): """ diff --git a/ivatar/test_views.py b/ivatar/test_views.py index 1015880..710d82e 100644 --- a/ivatar/test_views.py +++ b/ivatar/test_views.py @@ -10,8 +10,14 @@ from django.urls import reverse from django.test import TestCase from django.test import Client from django.contrib.auth.models import User +from ivatar.utils import random_string, Bluesky -from ivatar.utils import random_string +BLUESKY_APP_PASSWORD = None +BLUESKY_IDENTIFIER = None +try: + from settings import BLUESKY_APP_PASSWORD, BLUESKY_IDENTIFIER +except Exception: # pylint: disable=broad-except + pass os.environ["DJANGO_SETTINGS_MODULE"] = "ivatar.settings" django.setup() @@ -89,3 +95,18 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods ) response = self.client.post(reverse("logout"), follow=True) self.assertEqual(response.status_code, 200, "logout with post should logout") + + def test_Bluesky_client(self): + """ + Bluesky client needs credentials, so it's limited with testing here now + """ + if BLUESKY_APP_PASSWORD and BLUESKY_IDENTIFIER: + b = Bluesky() + profile = b.get_profile("ofalk.bsky.social") + self.assertEqual(profile["handle"], "ofalk.bsky.social") + # As long as I don't change my avatar, this should stay the same + self.assertEqual( + profile["avatar"], + "https://cdn.bsky.app/img/avatar/plain/did:plc:35jdu26cjgsc5vdbsaqiuw4a/bafkreidgtubihcdwcr72s5nag2ohcnwhhbg2zabw4jtxlhmtekrm6t5f4y@jpeg", + ) + self.assertEqual(True, True) diff --git a/ivatar/tools/views.py b/ivatar/tools/views.py index 8b6986a..b073cff 100644 --- a/ivatar/tools/views.py +++ b/ivatar/tools/views.py @@ -16,7 +16,7 @@ from libravatar import libravatar_url, parse_user_identity from libravatar import SECURE_BASE_URL as LIBRAVATAR_SECURE_BASE_URL from libravatar import BASE_URL as LIBRAVATAR_BASE_URL -from ivatar.settings import SECURE_BASE_URL, BASE_URL +from ivatar.settings import SECURE_BASE_URL, BASE_URL, SITE_NAME, DEBUG from .forms import ( CheckDomainForm, CheckForm, @@ -139,6 +139,36 @@ class CheckView(FormView): openid=form.cleaned_data["openid"], email=None )[0] + if "DEVELOPMENT" in SITE_NAME: + if DEBUG: + if mailurl: + mailurl = mailurl.replace( + "https://avatars.linux-kernel.at", + "http://" + self.request.get_host(), + ) + if mailurl_secure: + mailurl_secure = mailurl_secure.replace( + "https://avatars.linux-kernel.at", + "http://" + self.request.get_host(), + ) + if mailurl_secure_256: + mailurl_secure_256 = mailurl_secure_256.replace( + "https://avatars.linux-kernel.at", + "http://" + self.request.get_host(), + ) + + if openidurl: + openidurl = openidurl.replace( + "https://avatars.linux-kernel.at", + "http://" + self.request.get_host(), + ) + if openidurl_secure: + openidurl_secure = openidurl_secure.replace( + "https://avatars.linux-kernel.at", + "http://" + self.request.get_host(), + ) + print(mailurl, openidurl, mailurl_secure, mailurl_secure_256, openidurl_secure) + return render( self.request, self.template_name, diff --git a/ivatar/urls.py b/ivatar/urls.py index 3056ab4..a726e42 100644 --- a/ivatar/urls.py +++ b/ivatar/urls.py @@ -7,7 +7,8 @@ from django.urls import path, include, re_path from django.conf.urls.static import static from django.views.generic import TemplateView, RedirectView from ivatar import settings -from .views import AvatarImageView, GravatarProxyView, StatsView +from .views import AvatarImageView, StatsView +from .views import GravatarProxyView, BlueskyProxyView urlpatterns = [ # pylint: disable=invalid-name path("admin/", admin.site.urls), @@ -31,6 +32,11 @@ urlpatterns = [ # pylint: disable=invalid-name GravatarProxyView.as_view(), name="gravatarproxy", ), + re_path( + r"blueskyproxy/(?P\w*)", + BlueskyProxyView.as_view(), + name="blueskyproxy", + ), path( "description/", TemplateView.as_view(template_name="description.html"), diff --git a/ivatar/utils.py b/ivatar/utils.py index cf4a524..1e935be 100644 --- a/ivatar/utils.py +++ b/ivatar/utils.py @@ -7,6 +7,82 @@ import string from io import BytesIO from PIL import Image, ImageDraw, ImageSequence from urllib.parse import urlparse +import requests +from ivatar.settings import DEBUG, URL_TIMEOUT +from urllib.request import urlopen as urlopen_orig + +BLUESKY_IDENTIFIER = None +BLUESKY_APP_PASSWORD = None +try: + from ivatar.settings import BLUESKY_IDENTIFIER, BLUESKY_APP_PASSWORD +except Exception: # pylint: disable=broad-except + pass + + +def urlopen(url, timeout=URL_TIMEOUT): + ctx = None + if DEBUG: + import ssl + + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return urlopen_orig(url, timeout=timeout, context=ctx) + + +class Bluesky: + """ + Handle Bluesky client access + """ + + identifier = "" + app_password = "" + service = "https://bsky.social" + session = None + + def __init__( + self, + identifier: str = BLUESKY_IDENTIFIER, + app_password: str = BLUESKY_APP_PASSWORD, + service: str = "https://bsky.social", + ): + self.identifier = identifier + self.app_password = app_password + self.service = service + + def login(self): + """ + Login to Bluesky + """ + auth_response = requests.post( + f"{self.service}/xrpc/com.atproto.server.createSession", + json={"identifier": self.identifier, "password": self.app_password}, + ) + auth_response.raise_for_status() + self.session = auth_response.json() + + def get_profile(self, handle: str): + if not self.session: + self.login() + profile_response = None + try: + profile_response = requests.get( + f"{self.service}/xrpc/app.bsky.actor.getProfile", + headers={"Authorization": f'Bearer {self.session["accessJwt"]}'}, + params={"actor": handle}, + ) + profile_response.raise_for_status() + except Exception as exc: + print( + "Bluesky profile fetch failed with HTTP error: %s" % exc + ) # pragma: no cover + return None + + return profile_response.json() + + def get_avatar(self, handle: str): + profile = self.get_profile(handle) + return profile["avatar"] if profile else None def random_string(length=10): diff --git a/ivatar/views.py b/ivatar/views.py index bf4394d..066f3d1 100644 --- a/ivatar/views.py +++ b/ivatar/views.py @@ -5,7 +5,7 @@ views under / from io import BytesIO from os import path import hashlib -from urllib.request import urlopen +from ivatar.utils import urlopen, Bluesky from urllib.error import HTTPError, URLError from ssl import SSLError from django.views.generic.base import TemplateView, View @@ -36,8 +36,6 @@ from .ivataraccount.models import Photo from .ivataraccount.models import pil_format, file_format from .utils import is_trusted_url, mm_ng, resize_animated_gif -URL_TIMEOUT = 5 # in seconds - def get_size(request, size=DEFAULT_AVATAR_SIZE): """ @@ -123,7 +121,8 @@ class AvatarImageView(TemplateView): if CACHE_RESPONSE: centry = caches["filesystem"].get(uri) if centry: - # For DEBUG purpose only print('Cached entry for %s' % uri) + # For DEBUG purpose only + # print('Cached entry for %s' % uri) return HttpResponse( centry["content"], content_type=centry["content_type"], @@ -192,6 +191,12 @@ class AvatarImageView(TemplateView): except Exception: # pylint: disable=bare-except pass + # Handle the special case of Bluesky + if obj: + if obj.bluesky_handle: + return HttpResponseRedirect( + reverse_lazy("blueskyproxy", args=[kwargs["digest"]]) + ) # If that mail/openid doesn't exist, or has no photo linked to it if not obj or not obj.photo or forcedefault: gravatar_url = ( @@ -396,7 +401,7 @@ class GravatarProxyView(View): # print("Cached Gravatar response: Default.") return redir_default(default) try: - urlopen(gravatar_test_url, timeout=URL_TIMEOUT) + urlopen(gravatar_test_url) except HTTPError as exc: if exc.code == 404: cache.set(gravatar_test_url, "default", 60) @@ -415,7 +420,7 @@ class GravatarProxyView(View): print("Cached Gravatar fetch failed with URL error: %s" % gravatar_url) return redir_default(default) - gravatarimagedata = urlopen(gravatar_url, timeout=URL_TIMEOUT) + gravatarimagedata = urlopen(gravatar_url) except HTTPError as exc: if exc.code != 404 and exc.code != 503: print( @@ -450,6 +455,131 @@ class GravatarProxyView(View): return redir_default(default) +class BlueskyProxyView(View): + """ + Proxy request to Bluesky and return the image from there + """ + + def get( + self, request, *args, **kwargs + ): # pylint: disable=too-many-branches,too-many-statements,too-many-locals,no-self-use,unused-argument,too-many-return-statements + """ + Override get from parent class + """ + + def redir_default(default=None): + url = ( + reverse_lazy("avatar_view", args=[kwargs["digest"]]) + + "?s=%i" % size + + "&forcedefault=y" + ) + if default is not None: + url += "&default=%s" % default + return HttpResponseRedirect(url) + + size = get_size(request) + print(size) + blueskyimagedata = None + default = None + + try: + if str(request.GET["default"]) != "None": + default = request.GET["default"] + except Exception: # pylint: disable=bare-except + pass + + identity = None + + # First check for email, as this is the most common + try: + identity = ConfirmedEmail.objects.filter( + Q(digest=kwargs["digest"]) | Q(digest_sha256=kwargs["digest"]) + ).first() + except Exception as exc: + print(exc) + + # If no identity is found in the email table, try the openid table + if not identity: + try: + identity = ConfirmedOpenId.objects.filter( + Q(digest=kwargs["digest"]) + | Q(alt_digest1=kwargs["digest"]) + | Q(alt_digest2=kwargs["digest"]) + | Q(alt_digest3=kwargs["digest"]) + ).first() + except Exception as exc: + print(exc) + + # If still no identity is found, redirect to the default + if not identity: + return redir_default(default) + + bs = Bluesky() + bluesky_url = None + # Try with the cache first + try: + if cache.get(identity.bluesky_handle): + bluesky_url = cache.get(identity.bluesky_handle) + except Exception: # pylint: disable=bare-except + pass + + if not bluesky_url: + try: + bluesky_url = bs.get_avatar(identity.bluesky_handle) + cache.set(identity.bluesky_handle, bluesky_url) + except Exception: # pylint: disable=bare-except + return redir_default(default) + + try: + if cache.get(bluesky_url) == "err": + print("Cached Bluesky fetch failed with URL error: %s" % bluesky_url) + return redir_default(default) + + blueskyimagedata = urlopen(bluesky_url) + except HTTPError as exc: + if exc.code != 404 and exc.code != 503: + print( + "Bluesky fetch failed with an unexpected %s HTTP error: %s" + % (exc.code, bluesky_url) + ) + cache.set(bluesky_url, "err", 30) + return redir_default(default) + except URLError as exc: + print("Bluesky fetch failed with URL error: %s" % exc.reason) + cache.set(bluesky_url, "err", 30) + return redir_default(default) + except SSLError as exc: + print("Bluesky fetch failed with SSL error: %s" % exc.reason) + cache.set(bluesky_url, "err", 30) + return redir_default(default) + try: + data = BytesIO(blueskyimagedata.read()) + img = Image.open(data) + format = img.format + if max(img.size) > size: + aspect = img.size[0] / float(img.size[1]) + if aspect > 1: + new_size = (size, int(size / aspect)) + else: + new_size = (int(size * aspect), size) + img = img.resize(new_size) + data = BytesIO() + img.save(data, format=format) + + data.seek(0) + response = HttpResponse( + data.read(), content_type="image/%s" % file_format(format) + ) + response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE + return response + except ValueError as exc: + print("Value error: %s" % exc) + return redir_default(default) + + # We shouldn't reach this point... But make sure we do something + return redir_default(default) + + class StatsView(TemplateView, JsonResponse): """ Return stats