* 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 manuallyApprovesFollowers: str = False
discoverable: str = False discoverable: str = False
hideFollows: str = False hideFollows: str = False
movedTo: str = None
alsoKnownAs: dict[str] = None
type: str = "Person" type: str = "Person"

View file

@ -75,6 +75,9 @@ class Update(Verb):
"""update a model instance from the dataclass""" """update a model instance from the dataclass"""
if not self.object: if not self.object:
return 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( self.object.to_model(
allow_create=False, allow_external_connections=allow_external_connections allow_create=False, allow_external_connections=allow_external_connections
) )
@ -232,27 +235,23 @@ class Announce(Verb):
"""boost""" """boost"""
self.to_model(allow_external_connections=allow_external_connections) self.to_model(allow_external_connections=allow_external_connections)
@dataclass(init=False) @dataclass(init=False)
class Move(Verb): class Move(Verb):
"""a user moving an object""" """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 object: str
type: str = "Move" type: str = "Move"
target: str origin: str = None
origin: str target: str = None
def action(self, allow_external_connections=True): def action(self, allow_external_connections=True):
"""move""" """move"""
# we need to work out whether the object is a user or something else. object_is_user = resolve_remote_id(remote_id=self.object, model="User")
object_is_user = True # TODO!
if object_is_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: 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"] 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): class ChangePasswordForm(CustomForm):
current_password = forms.CharField(widget=forms.PasswordInput) current_password = forms.CharField(widget=forms.PasswordInput)
confirm_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 .import_job import ImportJob, ImportItem
from .move import MoveUser, MoveUserNotification
from .site import SiteSettings, Theme, SiteInvite from .site import SiteSettings, Theme, SiteInvite
from .site import PasswordReset, InviteRequest from .site import PasswordReset, InviteRequest
from .announcement import Announcement from .announcement import Announcement

View file

@ -1,4 +1,5 @@
""" move an object including migrating a user account """ """ move an object including migrating a user account """
from django.core.exceptions import PermissionDenied
from django.db import models from django.db import models
from bookwyrm import activitypub from bookwyrm import activitypub
@ -6,6 +7,7 @@ from .activitypub_mixin import ActivityMixin
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
from . import fields from . import fields
from .status import Status from .status import Status
from bookwyrm.models import User
class Move(ActivityMixin, BookWyrmModel): class Move(ActivityMixin, BookWyrmModel):
@ -15,20 +17,21 @@ class Move(ActivityMixin, BookWyrmModel):
"User", on_delete=models.PROTECT, activitypub_field="actor" "User", on_delete=models.PROTECT, activitypub_field="actor"
) )
# TODO: can we just use the abstract class here? object = fields.CharField(
activitypub_object = fields.ForeignKey( max_length=255,
"BookWyrmModel", on_delete=models.PROTECT, blank=False,
null=False,
deduplication_field=True,
activitypub_field="object", activitypub_field="object",
blank=True,
null=True
)
target = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True
) )
origin = fields.CharField( 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 activity_serializer = activitypub.Move
@ -37,14 +40,66 @@ class Move(ActivityMixin, BookWyrmModel):
@classmethod @classmethod
def ignore_activity(cls, activity, allow_external_connections=True): def ignore_activity(cls, activity, allow_external_connections=True):
"""don't bother with incoming moves of unknown objects""" """don't bother with incoming moves of unknown objects"""
# TODO how do we check this for any conceivable object? # TODO
pass 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 class MoveUser(Move):
# What about when a book is merged (i.e. moved from one id into another)? We could use that to send out a message """migrating an activitypub user account"""
# to other Bookwyrm instances to update their remote_id for the book, but ...how do we trigger any action?
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.db import models, transaction
from django.dispatch import receiver from django.dispatch import receiver
from .base_model import BookWyrmModel 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 from . import ListItem, Report, Status, User, UserFollowRequest
@ -330,13 +337,11 @@ def notify_user_on_follow(sender, instance, created, *args, **kwargs):
read=False, read=False,
) )
@receiver(models.signals.post_save, sender=Move)
@receiver(models.signals.post_save, sender=MoveUserNotification)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def notify_on_move(sender, instance, *args, **kwargs): def notify_on_move(sender, instance, *args, **kwargs):
"""someone moved something""" """someone migrated their account"""
Notification.notify( Notification.notify(
instance.status.user, instance.user, instance.target, notification_type=Notification.MOVE
instance.user,
related_object=instance.object,
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) theme = models.ForeignKey("Theme", null=True, blank=True, on_delete=models.SET_NULL)
hide_follows = fields.BooleanField(default=False) 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 # options to turn features on and off
show_goal = models.BooleanField(default=True) show_goal = models.BooleanField(default=True)
show_suggested_users = models.BooleanField(default=True) show_suggested_users = models.BooleanField(default=True)
@ -314,6 +325,8 @@ class User(OrderedCollectionPageMixin, AbstractUser):
"schema": "http://schema.org#", "schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue", "PropertyValue": "schema:PropertyValue",
"value": "schema:value", "value": "schema:value",
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
}, },
] ]
return activity_object return activity_object

View file

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

View file

@ -39,6 +39,8 @@
{% with related_user=related_users.0.display_name %} {% with related_user=related_users.0.display_name %}
{% with related_user_link=related_users.0.local_path %} {% 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=related_users.1.display_name %}
{% with second_user_link=related_users.1.local_path %} {% with second_user_link=related_users.1.local_path %}
{% with other_user_count=related_user_count|add:"-1" %} {% with other_user_count=related_user_count|add:"-1" %}
@ -50,6 +52,8 @@
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}
{% endwith %}
{% endwith %}
</div> </div>
{% if related_status %} {% 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 %} {% 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> <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Two Factor Authentication" %}</a>
</li> </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> <li>
{% url 'prefs-delete' as url %} {% url 'prefs-delete' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a> <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>{{ user.last_active_date }}</td>
<td> <td>
{% if user.is_active %} {% if user.is_active %}
<span class="tag is-success" aria-hidden="true"> {% if user.moved_to %}
<span class="icon icon-check"></span> <span class="tag is-info" aria-hidden="true">
</span> <span class="icon icon-x"></span>
{% trans "Active" %} </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" %} {% elif user.deactivation_reason == "moderator_deletion" or user.deactivation_reason == "self_deletion" %}
<span class="tag is-danger" aria-hidden="true"> <span class="tag is-danger" aria-hidden="true">
<span class="icon icon-x"></span> <span class="icon icon-x"></span>

View file

@ -24,9 +24,15 @@
<h4 class="title is-4">{% trans "Status" %}</h4> <h4 class="title is-4">{% trans "Status" %}</h4>
<div class="box is-flex-grow-1 has-text-weight-bold"> <div class="box is-flex-grow-1 has-text-weight-bold">
{% if user.is_active %} {% if user.is_active %}
<p class="notification is-success"> {% if user.moved_to %}
{% trans "Active" %} <p class="notification is-info">
</p> {% trans "Moved" %}
</p>
{% else %}
<p class="notification is-success">
{% trans "Active" %}
</p>
{% endif %}
{% else %} {% else %}
<p class="notification is-warning"> <p class="notification is-warning">
{% trans "Inactive" %} {% 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 markdown %}
{% load layout %} {% load layout %}
{% load group_tags %} {% load group_tags %}
{% load user_page_tags %}
{% block title %}{{ user.display_name }}{% endblock %} {% block title %}{{ user.display_name }}{% endblock %}
@ -27,7 +28,11 @@
<div class="block"> <div class="block">
<div class="columns"> <div class="columns">
<div class="column is-two-fifths"> <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> </div>
{% if user.summary %} {% if user.summary %}
@ -38,70 +43,78 @@
{% endspaceless %} {% endspaceless %}
{% endif %} {% endif %}
</div> </div>
{% if not is_self and request.user.is_authenticated %} {% if user.moved_to %}
{% include 'snippets/follow_button.html' with user=user %} <div class="container my-6">
{% endif %} <div class="notification is-info has-text-centered">
{% if not is_self %} <p><em>{{ user.localname }}</em> {% trans "has moved to" %} <a href="{{user.moved_to}}">{% id_to_username user.moved_to %}</a></p>
{% 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> </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> </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 %} {% 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 %} {% 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 os
import re import re
from uuid import uuid4 from uuid import uuid4
from urllib.parse import urlparse
from django import template from django import template
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.templatetags.static import static from django.templatetags.static import static
from bookwyrm.models import User
register = template.Library() register = template.Library()
@ -29,6 +31,13 @@ def get_user_identifier(user):
return user.localname if user.localname else user.username 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") @register.filter(name="book_title")
def get_title(book, too_short=5): def get_title(book, too_short=5):
"""display the subtitle if the title is short""" """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>' f'<input type="text" name="isni-for-{author.id}" value="{isni}" hidden>'
) )
return "" 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", name="prompt-2fa",
), ),
re_path(r"^preferences/export/?$", views.Export.as_view(), name="prefs-export"), 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/delete/?$", views.DeleteUser.as_view(), name="prefs-delete"),
re_path( re_path(
r"^preferences/deactivate/?$", 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.change_password import ChangePassword
from .preferences.edit_user import EditUser from .preferences.edit_user import EditUser
from .preferences.export import Export from .preferences.export import Export
from .preferences.move_user import MoveUser, AliasUser, remove_alias
from .preferences.delete_user import DeleteUser, DeactivateUser, ReactivateUser from .preferences.delete_user import DeleteUser, DeactivateUser, ReactivateUser
from .preferences.block import Block, unblock from .preferences.block import Block, unblock
from .preferences.two_factor_auth import ( 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:", "") username = resource.replace("acct:", "")
user = get_object_or_404(models.User, username__iexact=username) user = get_object_or_404(models.User, username__iexact=username)
href = user.moved_to if user.moved_to else user.remote_id
return JsonResponse( return JsonResponse(
{ {
@ -29,7 +30,7 @@ def webfinger(request):
{ {
"rel": "self", "rel": "self",
"type": "application/activity+json", "type": "application/activity+json",
"href": user.remote_id, "href": href,
}, },
{ {
"rel": "http://ostatus.org/schema/1.0/subscribe", "rel": "http://ostatus.org/schema/1.0/subscribe",