* update User model to allow for moved_to and also_known_as values
* allow users to add aliases (also_known_as) in UI
* allow users to move account to another one (moved_to)
* redirect webfinger to the new account after a move
* present notification to followers inviting to follow at new account

Note: unlike Mastodon we're not running any unfollow/autofollow action here: users can decide for themselves
This makes undoing moves easier.

TODO

There is still a bug with incoming Moves, at least from Mastodon.
This seems to be something to do with Update activities (rather than Move, strictly).
This commit is contained in:
Hugh Rundle 2023-09-18 21:21:04 +10:00
parent e7ba6a3141
commit 5b051631ec
No known key found for this signature in database
GPG key ID: A7E35779918253F9
24 changed files with 521 additions and 138 deletions

View file

@ -40,4 +40,6 @@ class Person(ActivityObject):
manuallyApprovesFollowers: str = False
discoverable: str = False
hideFollows: str = False
movedTo: str = None
alsoKnownAs: dict[str] = None
type: str = "Person"

View file

@ -75,6 +75,9 @@ class Update(Verb):
"""update a model instance from the dataclass"""
if not self.object:
return
# BUG: THIS IS THROWING A DUPLIATE USERNAME ERROR WHEN WE GET AN "UPDATE" AFTER/BEFORE A "MOVE"
# FROM MASTODON - BUT ONLY SINCE WE ADDED MOVEUSER
# is it something to do with the updated User model?
self.object.to_model(
allow_create=False, allow_external_connections=allow_external_connections
)
@ -232,27 +235,23 @@ class Announce(Verb):
"""boost"""
self.to_model(allow_external_connections=allow_external_connections)
@dataclass(init=False)
class Move(Verb):
"""a user moving an object"""
# note the spec example for target and origin is an object but
# Mastodon uses a URI string and TBH this makes more sense
# Is there a way we can account for either format?
object: str
type: str = "Move"
target: str
origin: str
origin: str = None
target: str = None
def action(self, allow_external_connections=True):
"""move"""
# we need to work out whether the object is a user or something else.
object_is_user = True # TODO!
object_is_user = resolve_remote_id(remote_id=self.object, model="User")
if object_is_user:
self.to_model(object_is_user=True allow_external_connections=allow_external_connections)
model = apps.get_model("bookwyrm.MoveUser")
self.to_model(model=model)
else:
self.to_model(object_is_user=False allow_external_connections=allow_external_connections)
return

View file

@ -70,6 +70,22 @@ class DeleteUserForm(CustomForm):
fields = ["password"]
class MoveUserForm(CustomForm):
target = forms.CharField(widget=forms.TextInput)
class Meta:
model = models.User
fields = ["password"]
class AliasUserForm(CustomForm):
username = forms.CharField(widget=forms.TextInput)
class Meta:
model = models.User
fields = ["password"]
class ChangePasswordForm(CustomForm):
current_password = forms.CharField(widget=forms.PasswordInput)
confirm_password = forms.CharField(widget=forms.PasswordInput)

View file

@ -27,6 +27,8 @@ from .group import Group, GroupMember, GroupMemberInvitation
from .import_job import ImportJob, ImportItem
from .move import MoveUser, MoveUserNotification
from .site import SiteSettings, Theme, SiteInvite
from .site import PasswordReset, InviteRequest
from .announcement import Announcement

View file

@ -1,4 +1,5 @@
""" move an object including migrating a user account """
from django.core.exceptions import PermissionDenied
from django.db import models
from bookwyrm import activitypub
@ -6,6 +7,7 @@ from .activitypub_mixin import ActivityMixin
from .base_model import BookWyrmModel
from . import fields
from .status import Status
from bookwyrm.models import User
class Move(ActivityMixin, BookWyrmModel):
@ -15,20 +17,21 @@ class Move(ActivityMixin, BookWyrmModel):
"User", on_delete=models.PROTECT, activitypub_field="actor"
)
# TODO: can we just use the abstract class here?
activitypub_object = fields.ForeignKey(
"BookWyrmModel", on_delete=models.PROTECT,
object = fields.CharField(
max_length=255,
blank=False,
null=False,
deduplication_field=True,
activitypub_field="object",
blank=True,
null=True
)
target = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True
)
origin = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True
max_length=255,
blank=True,
null=True,
deduplication_field=True,
default="",
activitypub_field="origin",
)
activity_serializer = activitypub.Move
@ -37,14 +40,66 @@ class Move(ActivityMixin, BookWyrmModel):
@classmethod
def ignore_activity(cls, activity, allow_external_connections=True):
"""don't bother with incoming moves of unknown objects"""
# TODO how do we check this for any conceivable object?
# TODO
pass
def save(self, *args, **kwargs):
"""update user active time"""
self.user.update_active_date()
super().save(*args, **kwargs)
# Ok what else? We can trigger a notification for followers of a user who sends a `Move` for themselves
# What about when a book is merged (i.e. moved from one id into another)? We could use that to send out a message
# to other Bookwyrm instances to update their remote_id for the book, but ...how do we trigger any action?
class MoveUser(Move):
"""migrating an activitypub user account"""
target = fields.ForeignKey(
"User",
on_delete=models.PROTECT,
related_name="move_target",
activitypub_field="target",
)
# pylint: disable=unused-argument
@classmethod
def ignore_activity(cls, activity, allow_external_connections=True):
"""don't bother with incoming moves of unknown users"""
return not User.objects.filter(remote_id=activity.origin).exists()
def save(self, *args, **kwargs):
"""update user info and broadcast it"""
notify_followers = False
if self.user in self.target.also_known_as.all():
self.user.also_known_as.add(self.target.id)
self.user.update_active_date()
self.user.moved_to = self.target.remote_id
self.user.save(update_fields=["moved_to"])
if self.user.local:
kwargs[
"broadcast"
] = True # Only broadcast if we are initiating the Move
notify_followers = True
super().save(*args, **kwargs)
if notify_followers:
for follower in self.user.followers.all():
MoveUserNotification.objects.create(user=follower, target=self.user)
else:
raise PermissionDenied()
class MoveUserNotification(models.Model):
"""notify followers that the user has moved"""
created_date = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(
"User", on_delete=models.CASCADE, related_name="moved_user_notifications"
) # user we are notifying
target = models.ForeignKey(
"User", on_delete=models.CASCADE, related_name="moved_user_notification_target"
) # new account of user who moved
def save(self, *args, **kwargs):
"""send notification"""
super().save(*args, **kwargs)

View file

@ -2,7 +2,14 @@
from django.db import models, transaction
from django.dispatch import receiver
from .base_model import BookWyrmModel
from . import Boost, Favorite, GroupMemberInvitation, ImportJob, LinkDomain
from . import (
Boost,
Favorite,
GroupMemberInvitation,
ImportJob,
LinkDomain,
MoveUserNotification,
)
from . import ListItem, Report, Status, User, UserFollowRequest
@ -330,13 +337,11 @@ def notify_user_on_follow(sender, instance, created, *args, **kwargs):
read=False,
)
@receiver(models.signals.post_save, sender=Move)
@receiver(models.signals.post_save, sender=MoveUserNotification)
# pylint: disable=unused-argument
def notify_on_move(sender, instance, *args, **kwargs):
"""someone moved something"""
"""someone migrated their account"""
Notification.notify(
instance.status.user,
instance.user,
related_object=instance.object,
notification_type=Notification.MOVE,
)
instance.user, instance.target, notification_type=Notification.MOVE
)

View file

@ -140,6 +140,17 @@ class User(OrderedCollectionPageMixin, AbstractUser):
theme = models.ForeignKey("Theme", null=True, blank=True, on_delete=models.SET_NULL)
hide_follows = fields.BooleanField(default=False)
# migration fields
moved_to = fields.RemoteIdField(
null=True, unique=False, activitypub_field="movedTo"
)
also_known_as = fields.ManyToManyField(
"self",
symmetrical=True,
activitypub_field="alsoKnownAs",
)
# options to turn features on and off
show_goal = models.BooleanField(default=True)
show_suggested_users = models.BooleanField(default=True)
@ -314,6 +325,8 @@ class User(OrderedCollectionPageMixin, AbstractUser):
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
},
]
return activity_object

View file

@ -36,5 +36,5 @@
{% elif notification.notification_type == 'GROUP_DESCRIPTION' %}
{% include 'notifications/items/update.html' %}
{% elif notification.notification_type == 'MOVE' %}
{% include 'notifications/items/move.html' %}
{% include 'notifications/items/move_user.html' %}
{% endif %}

View file

@ -39,6 +39,8 @@
{% with related_user=related_users.0.display_name %}
{% with related_user_link=related_users.0.local_path %}
{% with related_user_moved_to=related_users.0.moved_to %}
{% with related_user_username=related_users.0.username %}
{% with second_user=related_users.1.display_name %}
{% with second_user_link=related_users.1.local_path %}
{% with other_user_count=related_user_count|add:"-1" %}
@ -50,6 +52,8 @@
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
</div>
{% if related_status %}

View file

@ -1,28 +0,0 @@
{% extends 'notifications/items/layout.html' %}
{% load i18n %}
{% load utilities %}
{% block primary_link %}{% spaceless %}
{{ notification.related_object.local_path }}
{% endspaceless %}{% endblock %}
{% block icon %}
<span class="icon icon-local"></span>
{% endblock %}
{% block description %}
<!--
TODO: a user has a 'name' but not everything does, notably a book.
On the other hand, maybe we don't need to notify anyone if a book
is moved, just update the remote_id?
-->
{% blocktrans trimmed with object_name=notification.related_object.name object_path=notification.related_object.local_path %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
moved {{ object_name }}
"<a href="{{ object_path }}">{{ object_name }}</a>"
{% endblocktrans %}
<!-- TODO maybe put a brief context message here for migrated user accounts? -->
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends 'notifications/items/layout.html' %}
{% load i18n %}
{% load utilities %}
{% load user_page_tags %}
{% block primary_link %}{% spaceless %}
{{ notification.related_object.local_path }}
{% endspaceless %}{% endblock %}
{% block icon %}
<span class="icon icon-local"></span>
{% endblock %}
{% block description %}
{{ related_user }} {% trans "has moved to" %} <a href="{{ related_user_moved_to }}">{% id_to_username related_user_moved_to %}</a>
<div class="row shrink my-2">
{% include 'snippets/move_user_buttons.html' with group=notification.related_group %}
</div>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends 'preferences/layout.html' %}
{% load i18n %}
{% block title %}{% trans "Move Account" %}{% endblock %}
{% block header %}
{% trans "Create Alias" %}
{% endblock %}
{% block panel %}
<div class="block">
<h2 class="title is-4">{% trans "Add another account as an alias" %}</h2>
<div class="box">
<div class="notification is-info is-light">
<p>
{% trans "Marking another account as an alias is required if you want to move that account to this one. This is a reversable action and will not change this account." %}
</p>
</div>
<form name="alias-user" action="{% url 'prefs-alias' %}" method="post">
{% csrf_token %}
<div class="field">
<label class="label" for="id_password">{% trans "Confirm password:" %}</label>
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
</div>
<div class="field">
<label class="label" for="id_target">{% trans "Enter the username for the account you want to add as an alias e.g. <em>user@example.com </em>:" %}</label>
<input class="input {% if form.username.errors %}is-danger{% endif %}" type="text" name="username" id="id_username" required aria-describedby="desc_username">
{% include 'snippets/form_errors.html' with errors_list=form.username.errors id="desc_username" %}
</div>
<button type="submit" class="button is-success">{% trans "Create Alias" %}</button>
</form>
</div>
{% if user.also_known_as.all.0 %}
<div class="box">
<h2 class="title is-4">{% trans "Aliases" %}</h2>
<div class="table-container block">
<table class="table is-striped is-fullwidth">
{% for alias in user.also_known_as.all %}
<tr>
<td>{{ alias.username }}</td>
<td>
<form name="remove-alias" action="{% url 'prefs-remove-alias' %}" method="post">
{% csrf_token %}
<input type="hidden" name="alias" id="alias" value="{{ alias.id }}">
<button type="submit" class="button is-info">{% trans "Remove alias" %}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -23,6 +23,14 @@
{% url 'prefs-2fa' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Two Factor Authentication" %}</a>
</li>
<li>
{% url 'prefs-move' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Move Account" %}</a>
</li>
<li>
{% url 'prefs-alias' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Add alias" %}</a>
</li>
<li>
{% url 'prefs-delete' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>

View file

@ -0,0 +1,41 @@
{% extends 'preferences/layout.html' %}
{% load i18n %}
{% block title %}{% trans "Move Account" %}{% endblock %}
{% block header %}
{% trans "Move Account" %}
{% endblock %}
{% block panel %}
<div class="block">
<h2 class="title is-4">{% trans "Migrate account to another server" %}</h2>
<div class="box">
<div class="notification is-danger is-light">
<p>
{% trans "Moving your account will notify all your followers and redirect them to the new account." %}
</p>
<p>
<strong>{{ user.username }}</strong> {% trans "will be marked as moved and will not be discoverable." %}
</p>
</div>
<div class="notification is-info is-light">
<p>{% trans "Remember to add this user as an alias of the target account before you try to move." %}</p>
</div>
<form name="move-user" action="{% url 'prefs-move' %}" method="post">
{% csrf_token %}
<div class="field">
<label class="label" for="id_password">{% trans "Confirm password:" %}</label>
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
</div>
<div class="field">
<label class="label" for="id_target">{% trans "Enter the username for the account you want to move to e.g. <em>user@example.com </em>:" %}</label>
<input class="input {% if form.target.errors %}is-danger{% endif %}" type="text" name="target" id="id_target" required aria-describedby="desc_target">
{% include 'snippets/form_errors.html' with errors_list=form.target.errors id="desc_target" %}
</div>
<button type="submit" class="button is-danger">{% trans "Move Account" %}</button>
</form>
</div>
</div>
{% endblock %}

View file

@ -75,10 +75,17 @@
<td>{{ user.last_active_date }}</td>
<td>
{% if user.is_active %}
<span class="tag is-success" aria-hidden="true">
<span class="icon icon-check"></span>
</span>
{% trans "Active" %}
{% if user.moved_to %}
<span class="tag is-info" aria-hidden="true">
<span class="icon icon-x"></span>
</span>
{% trans "Moved" %}
{% else %}
<span class="tag is-success" aria-hidden="true">
<span class="icon icon-check"></span>
</span>
{% trans "Active" %}
{% endif %}
{% elif user.deactivation_reason == "moderator_deletion" or user.deactivation_reason == "self_deletion" %}
<span class="tag is-danger" aria-hidden="true">
<span class="icon icon-x"></span>

View file

@ -24,9 +24,15 @@
<h4 class="title is-4">{% trans "Status" %}</h4>
<div class="box is-flex-grow-1 has-text-weight-bold">
{% if user.is_active %}
<p class="notification is-success">
{% trans "Active" %}
</p>
{% if user.moved_to %}
<p class="notification is-info">
{% trans "Moved" %}
</p>
{% else %}
<p class="notification is-success">
{% trans "Active" %}
</p>
{% endif %}
{% else %}
<p class="notification is-warning">
{% trans "Inactive" %}

View file

@ -0,0 +1,13 @@
{% load i18n %}
{% load utilities %}
{% if related_user_moved_to|user_from_remote_id not in request.user.following.all %}
<div class="field is-grouped">
<form action="{% url 'follow' %}" method="POST" class="interaction follow_{{ related_users.0.id }}" data-id="follow_{{ related_users.0.id }}">
{% csrf_token %}
<input type="hidden" name="user" value="{% id_to_username related_user_moved_to %}">
<button class="button is-link is-small" type="submit">{% trans "Follow new account" %}</button>
</form>
</div>
{% endif %}

View file

@ -5,6 +5,7 @@
{% load markdown %}
{% load layout %}
{% load group_tags %}
{% load user_page_tags %}
{% block title %}{{ user.display_name }}{% endblock %}
@ -27,7 +28,11 @@
<div class="block">
<div class="columns">
<div class="column is-two-fifths">
{% include 'user/user_preview.html' with user=user %}
{% if user.moved_to %}
{% include 'user/moved.html' with user=user %}
{% else %}
{% include 'user/user_preview.html' with user=user %}
{% endif %}
</div>
{% if user.summary %}
@ -38,70 +43,78 @@
{% endspaceless %}
{% endif %}
</div>
{% 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.active_follower_requests.all %}
<div class="follow-requests">
<h2>{% trans "Follow Requests" %}</h2>
{% for requester in user.follower_requests.all %}
<div class="row shrink">
<p>
<a href="{{ requester.local_path }}">{{ requester.display_name }}</a> ({{ requester.username }})
</p>
{% include 'snippets/follow_request_buttons.html' with user=requester %}
{% if user.moved_to %}
<div class="container my-6">
<div class="notification is-info has-text-centered">
<p><em>{{ user.localname }}</em> {% trans "has moved to" %} <a href="{{user.moved_to}}">{% id_to_username user.moved_to %}</a></p>
</div>
{% endfor %}
</div>
{% else %}
{% 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.active_follower_requests.all %}
<div class="follow-requests">
<h2>{% trans "Follow Requests" %}</h2>
{% for requester in user.follower_requests.all %}
<div class="row shrink">
<p>
<a href="{{ requester.local_path }}">{{ requester.display_name }}</a> ({{ requester.username }})
</p>
{% include 'snippets/follow_request_buttons.html' with user=requester %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% block tabs %}
{% with user|username as username %}
<nav class="tabs">
<ul>
{% url 'user-feed' user|username as url %}
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
<a href="{{ url }}">{% trans "Activity" %}</a>
</li>
{% url 'user-reviews-comments' user|username as url %}
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
<a href="{{ url }}">{% trans "Reviews and Comments" %}</a>
</li>
{% if is_self or user.goal.exists %}
{% now 'Y' as year %}
{% url 'user-goal' user|username year as url %}
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-reading-goal">
<a href="{{ url }}">{% trans "Reading Goal" %}</a>
</li>
{% endif %}
{% if is_self or user|has_groups %}
{% url 'user-groups' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-groups-tab">
<a href="{{ url }}">{% trans "Groups" %}</a>
</li>
{% endif %}
{% if is_self or user.list_set.exists %}
{% url 'user-lists' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-lists-tab">
<a href="{{ url }}">{% trans "Lists" %}</a>
</li>
{% endif %}
{% if user.shelf_set.exists %}
{% url 'user-shelves' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-shelves-tab">
<a href="{{ url }}">{% trans "Books" %}</a>
</li>
{% endif %}
</ul>
</nav>
{% endwith %}
{% endblock %}
{% block panel %}{% endblock %}
{% endif %}
</div>
{% block tabs %}
{% with user|username as username %}
<nav class="tabs">
<ul>
{% url 'user-feed' user|username as url %}
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
<a href="{{ url }}">{% trans "Activity" %}</a>
</li>
{% url 'user-reviews-comments' user|username as url %}
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
<a href="{{ url }}">{% trans "Reviews and Comments" %}</a>
</li>
{% if is_self or user.goal.exists %}
{% now 'Y' as year %}
{% url 'user-goal' user|username year as url %}
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-reading-goal">
<a href="{{ url }}">{% trans "Reading Goal" %}</a>
</li>
{% endif %}
{% if is_self or user|has_groups %}
{% url 'user-groups' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-groups-tab">
<a href="{{ url }}">{% trans "Groups" %}</a>
</li>
{% endif %}
{% if is_self or user.list_set.exists %}
{% url 'user-lists' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-lists-tab">
<a href="{{ url }}">{% trans "Lists" %}</a>
</li>
{% endif %}
{% if user.shelf_set.exists %}
{% url 'user-shelves' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-shelves-tab">
<a href="{{ url }}">{% trans "Books" %}</a>
</li>
{% endif %}
</ul>
</nav>
{% endwith %}
{% endblock %}
{% block panel %}{% endblock %}
{% endblock %}

View file

@ -0,0 +1,27 @@
{% load i18n %}
{% load humanize %}
{% load utilities %}
{% load markdown %}
{% load layout %}
{% load group_tags %}
<div class="media block">
<div class="media-left">
<a href="{{ user.local_path }}">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
</div>
<div class="media-content">
<p>
{% if user.name %}{{ user.name }}{% else %}{{ user.localname }}{% endif %}
{% 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 %}
</p>
<p>{{ user.username }}</p>
<p>{% blocktrans with date=user.created_date|naturaltime %}Joined {{ date }}{% endblocktrans %}</p>
</div>
</div>

View file

@ -2,11 +2,13 @@
import os
import re
from uuid import uuid4
from urllib.parse import urlparse
from django import template
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.templatetags.static import static
from bookwyrm.models import User
register = template.Library()
@ -29,6 +31,13 @@ def get_user_identifier(user):
return user.localname if user.localname else user.username
@register.filter(name="user_from_remote_id")
def get_user_identifier_from_remote_id(remote_id):
"""get the local user id from their remote id"""
user = User.objects.get(remote_id=remote_id)
return user if user else None
@register.filter(name="book_title")
def get_title(book, too_short=5):
"""display the subtitle if the title is short"""
@ -103,3 +112,13 @@ def get_isni(existing, author, autoescape=True):
f'<input type="text" name="isni-for-{author.id}" value="{isni}" hidden>'
)
return ""
@register.simple_tag(takes_context=False)
def id_to_username(user_id):
"""given an arbitrary user id, return the username"""
url = urlparse(user_id)
domain = url.netloc
parts = url.path.split("/")
name = parts[-1]
return f"{name}@{domain}"

View file

@ -593,6 +593,11 @@ urlpatterns = [
name="prompt-2fa",
),
re_path(r"^preferences/export/?$", views.Export.as_view(), name="prefs-export"),
re_path(r"^preferences/move/?$", views.MoveUser.as_view(), name="prefs-move"),
re_path(r"^preferences/alias/?$", views.AliasUser.as_view(), name="prefs-alias"),
re_path(
r"^preferences/remove-alias/?$", views.remove_alias, name="prefs-remove-alias"
),
re_path(r"^preferences/delete/?$", views.DeleteUser.as_view(), name="prefs-delete"),
re_path(
r"^preferences/deactivate/?$",

View file

@ -37,6 +37,7 @@ from .admin.user_admin import UserAdmin, UserAdminList, ActivateUserAdmin
from .preferences.change_password import ChangePassword
from .preferences.edit_user import EditUser
from .preferences.export import Export
from .preferences.move_user import MoveUser, AliasUser, remove_alias
from .preferences.delete_user import DeleteUser, DeactivateUser, ReactivateUser
from .preferences.block import Block, unblock
from .preferences.two_factor_auth import (

View file

@ -0,0 +1,98 @@
""" move your account somewhere else """
from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.views.helpers import handle_remote_webfinger
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
class MoveUser(View):
"""move user view"""
def get(self, request):
"""move page for a user"""
data = {
"form": forms.MoveUserForm(),
"user": request.user,
}
return TemplateResponse(request, "preferences/move_user.html", data)
def post(self, request):
"""Packing your stuff and moving house"""
form = forms.MoveUserForm(request.POST, instance=request.user)
user = models.User.objects.get(id=request.user.id)
if form.is_valid() and user.check_password(form.cleaned_data["password"]):
username = form.cleaned_data["target"]
target = handle_remote_webfinger(username)
try:
models.MoveUser.objects.create(
user=request.user, object=request.user.remote_id, target=target
)
return redirect("/")
except (PermissionDenied):
form.errors["target"] = [
"You must set this server's user as an alias on the user you wish to move to before moving"
]
data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/move_user.html", data)
form.errors["password"] = ["Invalid password"]
data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/move_user.html", data)
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
class AliasUser(View):
"""alias user view"""
def get(self, request):
"""move page for a user"""
data = {
"form": forms.AliasUserForm(),
"user": request.user,
}
return TemplateResponse(request, "preferences/alias_user.html", data)
def post(self, request):
"""Creating a nom de plume"""
form = forms.AliasUserForm(request.POST, instance=request.user)
user = models.User.objects.get(id=request.user.id)
if form.is_valid() and user.check_password(form.cleaned_data["password"]):
username = form.cleaned_data["username"]
remote_user = handle_remote_webfinger(username)
if remote_user is None:
form.errors["username"] = ["Username does not exist"]
data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/alias_user.html", data)
user.also_known_as.add(remote_user.id)
return redirect("prefs-alias")
form.errors["password"] = ["Invalid password"]
data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/alias_user.html", data)
@login_required
@require_POST
def remove_alias(request):
"""remove an alias from the user profile"""
request.user.also_known_as.remove(request.POST["alias"])
return redirect("prefs-alias")

View file

@ -21,6 +21,7 @@ def webfinger(request):
username = resource.replace("acct:", "")
user = get_object_or_404(models.User, username__iexact=username)
href = user.moved_to if user.moved_to else user.remote_id
return JsonResponse(
{
@ -29,7 +30,7 @@ def webfinger(request):
{
"rel": "self",
"type": "application/activity+json",
"href": user.remote_id,
"href": href,
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",