diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index d656ed18..b65d2083 100644 --- a/bookwyrm/static/js/bookwyrm.js +++ b/bookwyrm/static/js/bookwyrm.js @@ -411,6 +411,21 @@ let BookWyrm = new class { } } + /** + * Display pop up window. + * + * @param {string} url Url to open + * @param {string} windowName windowName + * @return {undefined} + */ + displayPopUp(url, windowName) { + window.open( + url, + windowName, + "left=100,top=100,width=430,height=600" + ); + } + duplicateInput (event ) { const trigger = event.currentTarget; const input_id = trigger.dataset['duplicate'] diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index f2d04f96..bc85e678 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -267,5 +267,6 @@ {% block scripts %}{% endblock %} + diff --git a/bookwyrm/templates/ostatus/error.html b/bookwyrm/templates/ostatus/error.html new file mode 100644 index 00000000..fc1dd18d --- /dev/null +++ b/bookwyrm/templates/ostatus/error.html @@ -0,0 +1,70 @@ +{% load i18n %} + +{% block content %} +
+ {% if error == 'invalid_username' %} +
+

{% blocktrans %}{{ account }} is not a valid username{% endblocktrans %}.

+

{% trans 'Check you have the correct username before trying again' %}.

+
+ {% elif error == 'user_not_found' %} +
+

{% blocktrans %}{{ account }} could not be found or {{ remote_domain }} does not support identity discovery{% endblocktrans %}.

+

{% trans 'Check you have the correct username before trying again' %}.

+
+ {% elif error == 'not_supported' %} +
+

{% blocktrans %}{{ account }} was found but {{ remote_domain }} does not support 'remote follow'{% endblocktrans %}.

+

{% blocktrans %}Try searching for {{ user }} on {{ remote_domain }} instead{% endblocktrans %}.

+
+ {% elif not request.user.is_authenticated %} + + {% elif error == 'ostatus_subscribe' %} +
+

{% blocktrans %}Something went wrong trying to follow {{ account }}{% endblocktrans %}

+

{% trans 'Check you have the correct username before trying again.' %}

+
+ {% elif error == 'is_blocked' %} +
+

{% blocktrans %}You have blocked {{ account }}{% endblocktrans %}

+
+ {% elif error == 'has_blocked' %} +
+

{% blocktrans %}{{ account }} has blocked you{% endblocktrans %}

+
+ {% elif error == 'already_following' %} +
+

{% blocktrans %}You are already following {{ account }}{% endblocktrans %}

+
+ {% elif error == 'already_requested' %} +
+

{% blocktrans %}You have already requested to follow {{ account }}{% endblocktrans %}

+
+ {% endif %} +
+
+ +
+{% endblock %} diff --git a/bookwyrm/templates/ostatus/remote_follow.html b/bookwyrm/templates/ostatus/remote_follow.html new file mode 100644 index 00000000..ca47c529 --- /dev/null +++ b/bookwyrm/templates/ostatus/remote_follow.html @@ -0,0 +1,46 @@ +{% extends 'ostatus/template.html' %} +{% load i18n %} +{% load utilities %} + +{% block heading %} +{% blocktrans with username=user.localname sitename=site.name %}Follow {{ username }} on the fediverse{% endblocktrans %} +{% endblock %} + +{% block content %} +
+
+ +
+
+
+

{% blocktrans with username=user.display_name %}Follow {{ username }} from another Fediverse account like BookWyrm, Mastodon, or Pleroma.{% endblocktrans %}

+
+
+
+
+ {% csrf_token %} + + + + +
+
+
+{% endblock %} diff --git a/bookwyrm/templates/ostatus/remote_follow_button.html b/bookwyrm/templates/ostatus/remote_follow_button.html new file mode 100644 index 00000000..fc869229 --- /dev/null +++ b/bookwyrm/templates/ostatus/remote_follow_button.html @@ -0,0 +1,15 @@ +{% load i18n %} +{% if request.user == user %} +{% else %} + +
+
+ + {% blocktrans with username=user.localname %}Follow on Fediverse{% endblocktrans %} + +
+

+ {% trans 'This link opens in a pop-up window' %} +

+
+{% endif %} diff --git a/bookwyrm/templates/ostatus/subscribe.html b/bookwyrm/templates/ostatus/subscribe.html new file mode 100644 index 00000000..1020279b --- /dev/null +++ b/bookwyrm/templates/ostatus/subscribe.html @@ -0,0 +1,63 @@ +{% extends 'ostatus/template.html' %} +{% load i18n %} +{% load utilities %} +{% load markdown %} + +{% block title %} +{% if not request.user.is_authenticated %} +{% blocktrans with sitename=site.name %}Log in to {{ sitename }}{% endblocktrans %} +{% elif error %} +{% blocktrans with sitename=site.name %}Error following from {{ sitename }}{% endblocktrans %} +{% else %} +{% blocktrans with sitename=site.name %}Follow from {{ sitename }}{% endblocktrans %} +{% endif %} +{% endblock %} + +{% block heading %} +{% if error %} +{% trans 'Uh oh...' %} +{% elif not request.user.is_authenticated %} +{% trans "Let's log in first..." %} +{% else %} +{% blocktrans with sitename=site.name %}Follow from {{ sitename }}{% endblocktrans %} +{% endif %} +{% endblock %} + +{% block content %} +{% if error or not request.user.is_authenticated %} + {% include 'ostatus/error.html' with error=error user=user account=account %} +{% else %} +
+
+ +
+ {% if user.summary %} + {{ user.summary|to_markdown|safe|truncatechars_html:120 }} + {% else %} {% endif %} +
+
+
+{% endif %} +{% endblock %} diff --git a/bookwyrm/templates/ostatus/success.html b/bookwyrm/templates/ostatus/success.html new file mode 100644 index 00000000..66577e83 --- /dev/null +++ b/bookwyrm/templates/ostatus/success.html @@ -0,0 +1,35 @@ +{% extends 'ostatus/template.html' %} +{% load i18n %} +{% load utilities %} + +{% block content %} +
+
+ +

+ + {% blocktrans with display_name=user.display_name %}You are now following {{ display_name }}!{% endblocktrans %} +

+
+
+
+ +
+{% endblock %} diff --git a/bookwyrm/templates/ostatus/template.html b/bookwyrm/templates/ostatus/template.html new file mode 100644 index 00000000..9776f28d --- /dev/null +++ b/bookwyrm/templates/ostatus/template.html @@ -0,0 +1,41 @@ +{% load layout %} +{% load i18n %} +{% load static %} +{% load utilities %} +{% load markdown %} + + + + + {% block title %}{% endblock %} + + + + + + + + +
+
+ {% block content%}{% endblock %} +
+
+ + + + + diff --git a/bookwyrm/templates/user/layout.html b/bookwyrm/templates/user/layout.html index d7557ae7..03e3dfce 100755 --- a/bookwyrm/templates/user/layout.html +++ b/bookwyrm/templates/user/layout.html @@ -39,6 +39,9 @@ {% if not is_self and request.user.is_authenticated %} {% include 'snippets/follow_button.html' with user=user %} {% endif %} + {% if not is_self %} + {% include 'ostatus/remote_follow_button.html' with user=user %} + {% endif %} {% if is_self and user.follower_requests.all %}
diff --git a/bookwyrm/tests/views/test_follow.py b/bookwyrm/tests/views/test_follow.py index 25b5a014..046fe3d9 100644 --- a/bookwyrm/tests/views/test_follow.py +++ b/bookwyrm/tests/views/test_follow.py @@ -4,10 +4,12 @@ from unittest.mock import patch from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType +from django.template.response import TemplateResponse from django.test import TestCase from django.test.client import RequestFactory from bookwyrm import models, views +from bookwyrm.tests.validate_html import validate_html @patch("bookwyrm.activitystreams.add_user_statuses_task.delay") @@ -16,6 +18,7 @@ class FollowViews(TestCase): def setUp(self): """we need basic test data and mocks""" + models.SiteSettings.objects.create() self.factory = RequestFactory() with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch( "bookwyrm.activitystreams.populate_stream_task.delay" @@ -174,3 +177,43 @@ class FollowViews(TestCase): self.assertEqual(models.UserFollowRequest.objects.filter(id=rel.id).count(), 0) # follow relationship should not exist self.assertEqual(models.UserFollows.objects.filter(id=rel.id).count(), 0) + + def test_ostatus_follow_request(self, _): + """check ostatus subscribe template loads""" + request = self.factory.get( + "", {"acct": "https%3A%2F%2Fexample.com%2Fusers%2Frat"} + ) + request.user = self.local_user + result = views.ostatus_follow_request(request) + self.assertIsInstance(result, TemplateResponse) + validate_html(result.render()) + self.assertEqual(result.status_code, 200) + + def test_remote_follow_page(self, _): + """check remote follow page loads""" + request = self.factory.get("", {"acct": "mouse@local.com"}) + request.user = self.remote_user + result = views.remote_follow_page(request) + self.assertIsInstance(result, TemplateResponse) + validate_html(result.render()) + self.assertEqual(result.status_code, 200) + + def test_ostatus_follow_success(self, _): + """check remote follow success page loads""" + request = self.factory.get("") + request.user = self.remote_user + request.following = "mouse@local.com" + result = views.ostatus_follow_success(request) + self.assertIsInstance(result, TemplateResponse) + validate_html(result.render()) + self.assertEqual(result.status_code, 200) + + def test_remote_follow(self, _): + """check follow from remote page loads""" + request = self.factory.post("", {"user": self.remote_user.id}) + request.user = self.remote_user + request.remote_user = "mouse@local.com" + result = views.remote_follow(request) + self.assertIsInstance(result, TemplateResponse) + validate_html(result.render()) + self.assertEqual(result.status_code, 200) diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 514bb7e6..45837ec1 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -44,6 +44,7 @@ urlpatterns = [ re_path(r"^api/v1/instance/?$", views.instance_info), re_path(r"^api/v1/instance/peers/?$", views.peers), re_path(r"^opensearch.xml$", views.opensearch, name="opensearch"), + re_path(r"^ostatus_subscribe/?$", views.ostatus_follow_request), # polling updates re_path("^api/updates/notifications/?$", views.get_notification_count), re_path("^api/updates/stream/(?P[a-z]+)/?$", views.get_unread_status_count), @@ -450,4 +451,9 @@ urlpatterns = [ re_path(r"^unfollow/?$", views.unfollow, name="unfollow"), re_path(r"^accept-follow-request/?$", views.accept_follow_request), re_path(r"^delete-follow-request/?$", views.delete_follow_request), + re_path(r"^ostatus_follow/?$", views.remote_follow, name="remote-follow"), + re_path(r"^remote_follow/?$", views.remote_follow_page, name="remote-follow-page"), + re_path( + r"^ostatus_success/?$", views.ostatus_follow_success, name="ostatus-success" + ), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index d79de424..4a92578c 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -58,7 +58,14 @@ from .author import Author, EditAuthor from .directory import Directory from .discover import Discover from .feed import DirectMessage, Feed, Replies, Status -from .follow import follow, unfollow +from .follow import ( + follow, + unfollow, + ostatus_follow_request, + ostatus_follow_success, + remote_follow, + remote_follow_page, +) from .follow import accept_follow_request, delete_follow_request from .get_started import GetStartedBooks, GetStartedProfile, GetStartedUsers from .goal import Goal, hide_goal diff --git a/bookwyrm/views/follow.py b/bookwyrm/views/follow.py index 7d91ce5b..6e58a2b2 100644 --- a/bookwyrm/views/follow.py +++ b/bookwyrm/views/follow.py @@ -1,11 +1,19 @@ """ views for actions you can take in the application """ +import urllib.parse +import re from django.contrib.auth.decorators import login_required from django.db import IntegrityError from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse from django.views.decorators.http import require_POST from bookwyrm import models -from .helpers import get_user_from_username +from .helpers import ( + get_user_from_username, + handle_remote_webfinger, + subscribe_remote_webfinger, + WebFingerError, +) @login_required @@ -23,6 +31,9 @@ def follow(request): except IntegrityError: pass + if request.GET.get("next"): + return redirect(request.GET.get("next", "/")) + return redirect(to_follow.local_path) @@ -84,3 +95,91 @@ def delete_follow_request(request): follow_request.delete() return redirect(f"/user/{request.user.localname}") + + +def ostatus_follow_request(request): + """prepare an outgoing remote follow request""" + uri = urllib.parse.unquote(request.GET.get("acct")) + username_parts = re.search( + r"(?:^http(?:s?):\/\/)([\w\-\.]*)(?:.)*(?:(?:\/)([\w]*))", uri + ) + account = f"{username_parts[2]}@{username_parts[1]}" + user = handle_remote_webfinger(account) + error = None + + if user is None or user == "": + error = "ostatus_subscribe" + + # don't do these checks for AnonymousUser before they sign in + if request.user.is_authenticated: + + # you have blocked them so you probably don't want to follow + if hasattr(request.user, "blocks") and user in request.user.blocks.all(): + error = "is_blocked" + # they have blocked you + if hasattr(user, "blocks") and request.user in user.blocks.all(): + error = "has_blocked" + # you're already following them + if hasattr(user, "followers") and request.user in user.followers.all(): + error = "already_following" + # you're not following yet but you already asked + if ( + hasattr(user, "follower_requests") + and request.user in user.follower_requests.all() + ): + error = "already_requested" + + data = {"account": account, "user": user, "error": error} + + return TemplateResponse(request, "ostatus/subscribe.html", data) + + +@login_required +def ostatus_follow_success(request): + """display success message for remote follow""" + user = get_user_from_username(request.user, request.GET.get("following")) + data = {"account": user.name, "user": user, "error": None} + return TemplateResponse(request, "ostatus/success.html", data) + + +def remote_follow_page(request): + """display remote follow page""" + user = get_user_from_username(request.user, request.GET.get("user")) + data = {"user": user} + return TemplateResponse(request, "ostatus/remote_follow.html", data) + + +@require_POST +def remote_follow(request): + """direct user to follow from remote account using ostatus subscribe protocol""" + remote_user = request.POST.get("remote_user") + try: + if remote_user[0] == "@": + remote_user = remote_user[1:] + remote_domain = remote_user.split("@")[1] + except (TypeError, IndexError): + remote_domain = None + + wf_response = subscribe_remote_webfinger(remote_user) + user = get_object_or_404(models.User, id=request.POST.get("user")) + + if wf_response is None: + data = { + "account": remote_user, + "user": user, + "error": "not_supported", + "remote_domain": remote_domain, + } + return TemplateResponse(request, "ostatus/subscribe.html", data) + + if isinstance(wf_response, WebFingerError): + data = { + "account": remote_user, + "user": user, + "error": str(wf_response), + "remote_domain": remote_domain, + } + return TemplateResponse(request, "ostatus/subscribe.html", data) + + url = wf_response.replace("{uri}", urllib.parse.quote(user.remote_id)) + return redirect(url) diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index 173cb85b..93aac9c4 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -16,6 +16,13 @@ from bookwyrm.status import create_generated_note from bookwyrm.utils import regex +# pylint: disable=unnecessary-pass +class WebFingerError(Exception): + """empty error class for problems finding user information with webfinger""" + + pass + + def get_user_from_username(viewer, username): """helper function to resolve a localname or a username to a user""" if viewer.is_authenticated and viewer.localname == username: @@ -57,10 +64,8 @@ def handle_remote_webfinger(query): # usernames could be @user@domain or user@domain if not query: return None - if query[0] == "@": query = query[1:] - try: domain = query.split("@")[1] except IndexError: @@ -86,6 +91,35 @@ def handle_remote_webfinger(query): return user +def subscribe_remote_webfinger(query): + """get subscribe template from other servers""" + template = None + # usernames could be @user@domain or user@domain + if not query: + return WebFingerError("invalid_username") + + if query[0] == "@": + query = query[1:] + + try: + domain = query.split("@")[1] + except IndexError: + return WebFingerError("invalid_username") + + url = f"https://{domain}/.well-known/webfinger?resource=acct:{query}" + + try: + data = get_data(url) + except (ConnectorException, HTTPError): + return WebFingerError("user_not_found") + + for link in data.get("links"): + if link.get("rel") == "http://ostatus.org/schema/1.0/subscribe": + template = link["template"] + + return template + + def get_edition(book_id): """look up a book in the db and return an edition""" book = models.Book.objects.select_subclasses().get(id=book_id) diff --git a/bookwyrm/views/wellknown.py b/bookwyrm/views/wellknown.py index 04aa88bf..03e619df 100644 --- a/bookwyrm/views/wellknown.py +++ b/bookwyrm/views/wellknown.py @@ -30,7 +30,11 @@ def webfinger(request): "rel": "self", "type": "application/activity+json", "href": user.remote_id, - } + }, + { + "rel": "http://ostatus.org/schema/1.0/subscribe", + "template": f"https://{DOMAIN}/ostatus_subscribe?acct={{uri}}", + }, ], } )