Merge pull request #1635 from hughrun/remote-follow

Remote follow
This commit is contained in:
Mouse Reeve 2021-12-06 14:36:21 -08:00 committed by GitHub
commit 857bc6adae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 487 additions and 5 deletions

View file

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

View file

@ -267,5 +267,6 @@
<script src="{% static "js/status_cache.js" %}?v={{ js_cache }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,70 @@
{% load i18n %}
{% block content %}
<div class="block">
{% if error == 'invalid_username' %}
<div class="notification is-warning has-text-centered" role="status">
<p>{% blocktrans %}<strong>{{ account }}</strong> is not a valid username{% endblocktrans %}.</p>
<p>{% trans 'Check you have the correct username before trying again' %}.</p>
</div>
{% elif error == 'user_not_found' %}
<div class="notification is-warning has-text-centered" role="status">
<p>{% blocktrans %}<strong>{{ account }}</strong> could not be found or <code>{{ remote_domain }}</code> does not support identity discovery{% endblocktrans %}.</p>
<p>{% trans 'Check you have the correct username before trying again' %}.</p>
</div>
{% elif error == 'not_supported' %}
<div class="notification is-warning has-text-centered" role="status">
<p>{% blocktrans %}<strong>{{ account }}</strong> was found but <code>{{ remote_domain }}</code> does not support 'remote follow'{% endblocktrans %}.</p>
<p>{% blocktrans %}Try searching for <strong>{{ user }}</strong> on <code>{{ remote_domain }}</code> instead{% endblocktrans %}.</p>
</div>
{% elif not request.user.is_authenticated %}
<div class="navbar-item">
<div class="columns">
<div class="column">
<form name="login" method="post" action="{% url 'login' %}?next={{ request.path }}?acct={{ user.remote_id }}">
{% csrf_token %}
<div class="columns is-variable is-1">
<div class="column">
<label class="is-sr-only" for="id_localname">{% trans "Username:" %}</label>
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="{% trans 'username' %}">
</div>
<div class="column">
<label class="is-sr-only" for="id_password">{% trans "Password:" %}</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
<p class="help"><a href="{% url 'password-reset' %}">{% trans "Forgot your password?" %}</a></p>
</div>
<div class="column is-narrow">
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
</div>
</div>
</form>
</div>
</div>
</div>
{% elif error == 'ostatus_subscribe' %}
<div class="notification is-warning has-text-centered" role="status">
<p>{% blocktrans %}Something went wrong trying to follow <strong>{{ account }}</strong>{% endblocktrans %}</p>
<p>{% trans 'Check you have the correct username before trying again.' %}</p>
</div>
{% elif error == 'is_blocked' %}
<div class="notification is-danger has-text-centered" role="status">
<p>{% blocktrans %}You have blocked <strong>{{ account }}</strong>{% endblocktrans %}</p>
</div>
{% elif error == 'has_blocked' %}
<div class="notification is-danger has-text-centered" role="status">
<p>{% blocktrans %}<strong>{{ account }}</strong> has blocked you{% endblocktrans %}</p>
</div>
{% elif error == 'already_following' %}
<div class="notification is-success has-text-centered" role="status">
<p>{% blocktrans %}You are already following <strong>{{ account }}</strong>{% endblocktrans %}</p>
</div>
{% elif error == 'already_requested' %}
<div class="notification is-success has-text-centered" role="status">
<p>{% blocktrans %}You have already requested to follow <strong>{{ account }}</strong>{% endblocktrans %}</p>
</div>
{% endif %}
</div>
<div class="block is-pulled-right">
<button type="button" class="button" onclick="closeWindow()">Close window</button>
</div>
{% endblock %}

View file

@ -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 %}
<div class="block card">
<div class="card-content">
<div class="media">
<a href="{{ user.local_path }}" class="media-left">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
<div class="media-content">
<a href="{{ user.local_path }}" class="is-block mb-2">
<span class="title is-4 is-block">
{{ user.display_name }}
{% if user.manually_approves_followers %}
<span class="icon icon-lock is-size-7" title="{% trans 'Locked account' %}">
<span class="is-sr-only">{% trans "Locked account" %}</span>
</span>
{% endif %}
</span>
<span class="subtitle is-7 is-block">@{{ user|username }}</span>
</a>
</div>
</div>
</div>
</div>
<div class="block">
<p>{% blocktrans with username=user.display_name %}Follow {{ username }} from another Fediverse account like BookWyrm, Mastodon, or Pleroma.{% endblocktrans %}</p>
</div>
<div class="card">
<section class="card-content content">
<form name="remote-follow" method="post" action="{% url 'remote-follow' %}">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.id }}">
<label class="label" for="remote_user">{% trans 'User handle to follow from:' %}</label>
<input class="input" type="text" name="remote_user" id="remote_user" placeholder="user@example.social" required>
<button class="button mt-1 is-primary" type="submit">{% trans 'Follow!' %}</button>
</form>
</section>
</div>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% load i18n %}
{% if request.user == user %}
{% else %}
<div class="field mb-0">
<div class="control">
<a class="button is-small is-link" href="{% url 'remote-follow-page' %}?user={{ user.username }}" target="_blank" rel="noopener noreferrer" onclick="BookWyrm.displayPopUp(`{% url 'remote-follow-page' %}?user={{ user.username }}`, `remoteFollow`); return false;" aria-describedby="remote_follow_warning">
{% blocktrans with username=user.localname %}Follow on Fediverse{% endblocktrans %}
</a>
</div>
<p id="remote_follow_warning" class="mt-1 is-size-7 has-text-weight-light">
{% trans 'This link opens in a pop-up window' %}
</p>
</div>
{% endif %}

View file

@ -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 %}
<div class="block card">
<div class="card-content">
<div class="media">
<a href="{{ user.local_path }}" class="media-left">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
<div class="media-content">
<a href="{{ user.local_path }}" class="is-block mb-2">
<span class="title is-4 is-block">
{{ user.display_name }}
{% if user.manually_approves_followers %}
<span class="icon icon-lock is-size-7" title="{% trans 'Locked account' %}">
<span class="is-sr-only">{% trans 'Locked account' %}</span>
</span>
{% endif %}
</span>
<span class="subtitle is-7 is-block">@{{ user|username }}</span>
</a>
<form name="follow" method="post" action="{% url 'follow' %}/?next={% url 'ostatus-success' %}?following={{ user.username }}">
{% csrf_token %}
<input name="user" value="{{ user.username }}" hidden>
<button class="button is-link" type="submit">{% blocktrans with username=user.display_name %}Follow {{ username }}{% endblocktrans %}</button>
</form>
</div>
</div>
<div>
{% if user.summary %}
{{ user.summary|to_markdown|safe|truncatechars_html:120 }}
{% else %}&nbsp;{% endif %}
</div>
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends 'ostatus/template.html' %}
{% load i18n %}
{% load utilities %}
{% block content %}
<div class="block card">
<div class="card-content">
<div class="media">
<a href="{{ user.local_path }}" class="media-left">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
<div class="media-content">
<a href="{{ user.local_path }}" class="is-block mb-2">
<span class="title is-4 is-block">
{{ user.display_name }}
{% if user.manually_approves_followers %}
<span class="icon icon-lock is-size-7" title="{% trans 'Locked account' %}">
<span class="is-sr-only">{% trans "Locked account" %}</span>
</span>
{% endif %}
</span>
<span class="subtitle is-7 is-block">@{{ user|username }}</span>
</a>
</div>
</div>
<p class="notification is-success">
<span class="icon icon-check m-0-mobile" aria-hidden="true"></span>
<span>{% blocktrans with display_name=user.display_name %}You are now following {{ display_name }}!{% endblocktrans %}</span>
</p>
</div>
</div>
<div class="block is-pulled-right">
<button type="button" class="button" onclick="closeWindow()">Close window</button>
</div>
{% endblock %}

View file

@ -0,0 +1,41 @@
{% load layout %}
{% load i18n %}
{% load static %}
{% load utilities %}
{% load markdown %}
<!DOCTYPE html>
<html lang="{% get_lang %}">
<head>
<title>{% block title %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{% static 'css/vendor/bulma.min.css' %}">
<link rel="stylesheet" href="{% static 'css/vendor/icons.css' %}">
<link rel="stylesheet" href="{% static 'css/bookwyrm.css' %}">
<script>
function closeWindow() {
window.close();
}
</script>
</head>
<body>
<nav class="navbar" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<img class="image logo navbar-item" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static 'images/logo-small.png' %}{% endif %}" alt="Home page">
<h2 class="navbar-item subtitle">{% block heading %}{% endblock %}</h2>
</div>
</div>
</nav>
<div class="section is-flex-grow-1 columns is-centered">
<div class="block column is-one-third">
{% block content%}{% endblock %}
</div>
</div>
<script>
var csrf_token = '{{ csrf_token }}';
</script>
<script src="{% static 'js/bookwyrm.js' %}?v={{ js_cache }}"></script>
</body>
</html>

View file

@ -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 %}
<div class="follow-requests">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}}",
},
],
}
)