Merge pull request #3076 from bookwyrm-social/move

Add Move activity for user migration (with small change)
This commit is contained in:
Mouse Reeve 2023-11-01 18:19:56 -07:00 committed by GitHub
commit 0502f6ba42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 791 additions and 84 deletions

View file

@ -13,14 +13,15 @@ User relationship interactions follow the standard ActivityPub spec.
- `Block`: prevent users from seeing one another's statuses, and prevents the blocked user from viewing the actor's profile - `Block`: prevent users from seeing one another's statuses, and prevents the blocked user from viewing the actor's profile
- `Update`: updates a user's profile and settings - `Update`: updates a user's profile and settings
- `Delete`: deactivates a user - `Delete`: deactivates a user
- `Undo`: reverses a `Follow` or `Block` - `Undo`: reverses a `Block` or `Follow`
### Activities ### Activities
- `Create/Status`: saves a new status in the database. - `Create/Status`: saves a new status in the database.
- `Delete/Status`: Removes a status - `Delete/Status`: Removes a status
- `Like/Status`: Creates a favorite on the status - `Like/Status`: Creates a favorite on the status
- `Announce/Status`: Boosts the status into the actor's timeline - `Announce/Status`: Boosts the status into the actor's timeline
- `Undo/*`,: Reverses a `Like` or `Announce` - `Undo/*`,: Reverses an `Announce`, `Like`, or `Move`
- `Move/User`: Moves a user from one ActivityPub id to another.
### Collections ### Collections
User's books and lists are represented by [`OrderedCollection`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection) User's books and lists are represented by [`OrderedCollection`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection)

View file

@ -23,6 +23,7 @@ from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject, Block from .verbs import Follow, Accept, Reject, Block
from .verbs import Add, Remove from .verbs import Add, Remove
from .verbs import Announce, Like from .verbs import Announce, Like
from .verbs import Move
# this creates a list of all the Activity types that we can serialize, # this creates a list of all the Activity types that we can serialize,
# so when an Activity comes in from outside, we can check if it's known # so when an Activity comes in from outside, we can check if it's known

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

@ -231,3 +231,30 @@ class Announce(Verb):
def action(self, allow_external_connections=True): def action(self, allow_external_connections=True):
"""boost""" """boost"""
self.to_model(allow_external_connections=allow_external_connections) self.to_model(allow_external_connections=allow_external_connections)
@dataclass(init=False)
class Move(Verb):
"""a user moving an object"""
object: str
type: str = "Move"
origin: str = None
target: str = None
def action(self, allow_external_connections=True):
"""move"""
object_is_user = resolve_remote_id(remote_id=self.object, model="User")
if object_is_user:
model = apps.get_model("bookwyrm.MoveUser")
self.to_model(
model=model,
save=True,
allow_external_connections=allow_external_connections,
)
else:
# we might do something with this to move other objects at some point
pass

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

@ -0,0 +1,130 @@
# Generated by Django 3.2.20 on 2023-10-27 11:22
import bookwyrm.models.activitypub_mixin
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0181_merge_20230806_2302"),
]
operations = [
migrations.AddField(
model_name="user",
name="also_known_as",
field=bookwyrm.models.fields.ManyToManyField(to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name="user",
name="moved_to",
field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
migrations.AlterField(
model_name="notification",
name="notification_type",
field=models.CharField(
choices=[
("FAVORITE", "Favorite"),
("REPLY", "Reply"),
("MENTION", "Mention"),
("TAG", "Tag"),
("FOLLOW", "Follow"),
("FOLLOW_REQUEST", "Follow Request"),
("BOOST", "Boost"),
("IMPORT", "Import"),
("ADD", "Add"),
("REPORT", "Report"),
("LINK_DOMAIN", "Link Domain"),
("INVITE", "Invite"),
("ACCEPT", "Accept"),
("JOIN", "Join"),
("LEAVE", "Leave"),
("REMOVE", "Remove"),
("GROUP_PRIVACY", "Group Privacy"),
("GROUP_NAME", "Group Name"),
("GROUP_DESCRIPTION", "Group Description"),
("MOVE", "Move"),
],
max_length=255,
),
),
migrations.CreateModel(
name="Move",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
(
"remote_id",
bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
("object", bookwyrm.models.fields.CharField(max_length=255)),
(
"origin",
bookwyrm.models.fields.CharField(
blank=True, default="", max_length=255, null=True
),
),
(
"user",
bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
bases=(bookwyrm.models.activitypub_mixin.ActivityMixin, models.Model),
),
migrations.CreateModel(
name="MoveUser",
fields=[
(
"move_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="bookwyrm.move",
),
),
(
"target",
bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="move_target",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
bases=("bookwyrm.move",),
),
]

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

72
bookwyrm/models/move.py Normal file
View file

@ -0,0 +1,72 @@
""" move an object including migrating a user account """
from django.core.exceptions import PermissionDenied
from django.db import models
from bookwyrm import activitypub
from .activitypub_mixin import ActivityMixin
from .base_model import BookWyrmModel
from . import fields
from .notification import Notification
class Move(ActivityMixin, BookWyrmModel):
"""migrating an activitypub user account"""
user = fields.ForeignKey(
"User", on_delete=models.PROTECT, activitypub_field="actor"
)
object = fields.CharField(
max_length=255,
blank=False,
null=False,
activitypub_field="object",
)
origin = fields.CharField(
max_length=255,
blank=True,
null=True,
default="",
activitypub_field="origin",
)
activity_serializer = activitypub.Move
class MoveUser(Move):
"""migrating an activitypub user account"""
target = fields.ForeignKey(
"User",
on_delete=models.PROTECT,
related_name="move_target",
activitypub_field="target",
)
def save(self, *args, **kwargs):
"""update user info and broadcast it"""
# only allow if the source is listed in the target's alsoKnownAs
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
super().save(*args, **kwargs)
for follower in self.user.followers.all():
if follower.local:
Notification.notify(
follower, self.user, notification_type=Notification.MOVE
)
else:
raise PermissionDenied()

View file

@ -40,11 +40,14 @@ class Notification(BookWyrmModel):
GROUP_NAME = "GROUP_NAME" GROUP_NAME = "GROUP_NAME"
GROUP_DESCRIPTION = "GROUP_DESCRIPTION" GROUP_DESCRIPTION = "GROUP_DESCRIPTION"
# Migrations
MOVE = "MOVE"
# pylint: disable=line-too-long # pylint: disable=line-too-long
NotificationType = models.TextChoices( NotificationType = models.TextChoices(
# there has got be a better way to do this # there has got be a better way to do this
"NotificationType", "NotificationType",
f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {LINK_DOMAIN} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}", f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {LINK_DOMAIN} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION} {MOVE}",
) )
user = models.ForeignKey("User", on_delete=models.CASCADE) user = models.ForeignKey("User", on_delete=models.CASCADE)

View file

@ -140,6 +140,19 @@ 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", deduplication_field=False
)
also_known_as = fields.ManyToManyField(
"self",
symmetrical=False,
unique=False,
activitypub_field="alsoKnownAs",
deduplication_field=False,
)
# 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 +327,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

@ -27,6 +27,7 @@
<nav class="navbar" aria-label="main navigation"> <nav class="navbar" aria-label="main navigation">
<div class="container"> <div class="container">
{% with notification_count=request.user.unread_notification_count has_unread_mentions=request.user.has_unread_mentions %} {% with notification_count=request.user.unread_notification_count has_unread_mentions=request.user.has_unread_mentions %}
{% if not request.user.moved_to %}
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item" href="/"> <a class="navbar-item" href="/">
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}" loading="lazy" decoding="async"> <img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}" loading="lazy" decoding="async">
@ -34,7 +35,7 @@
<form class="navbar-item column is-align-items-start pt-5" action="{% url 'search' %}"> <form class="navbar-item column is-align-items-start pt-5" action="{% url 'search' %}">
<div class="field has-addons"> <div class="field has-addons">
<div class="control"> <div class="control">
{% if user.is_authenticated %} {% if request.user.is_authenticated %}
{% trans "Search for a book, user, or list" as search_placeholder %} {% trans "Search for a book, user, or list" as search_placeholder %}
{% else %} {% else %}
{% trans "Search for a book" as search_placeholder %} {% trans "Search for a book" as search_placeholder %}
@ -80,7 +81,6 @@
</strong> </strong>
</button> </button>
</div> </div>
<div class="navbar-menu" id="main_nav"> <div class="navbar-menu" id="main_nav">
<div class="navbar-start" id="tour-navbar-start"> <div class="navbar-start" id="tour-navbar-start">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
@ -157,6 +157,13 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% else %}
<div class="navbar-brand">
<a class="navbar-item" href="/">
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}" loading="lazy" decoding="async">
</a>
</div>
{% endif %}
{% endwith %} {% endwith %}
</div> </div>
</nav> </nav>
@ -173,11 +180,15 @@
<main class="section is-flex-grow-1"> <main class="section is-flex-grow-1">
<div class="container"> <div class="container">
{% if request.user.moved_to %}
{% include "moved.html" %}
{% else %}
{# almost every view needs to know the user shelves #} {# almost every view needs to know the user shelves #}
{% with request.user.shelf_set.all as user_shelves %} {% with request.user.shelf_set.all as user_shelves %}
{% block content %} {% block content %}
{% endblock %} {% endblock %}
{% endwith %} {% endwith %}
{% endif %}
</div> </div>
</main> </main>

View file

@ -0,0 +1,52 @@
{% load i18n %}
{% load static %}
{% load utilities %}
<div class="container my-6">
<div class="card">
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img src="{% if request.user.avatar %}{% get_media_prefix %}{{ request.user.avatar }}{% else %}{% static "images/default_avi.jpg" %}{% endif %}"
{% if ariaHide %}aria-hidden="true"{% endif %}
alt="{{ request.user.alt_text }}"
loading="lazy"
decoding="async">
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{ request.user.display_name }}</p>
<p class="subtitle is-6"><s>{{request.user.username}}</s></p>
</div>
</div>
<div class="notification is-warning">
<p>
{% id_to_username request.user.moved_to as username %}
{% blocktrans trimmed with moved_to=user.moved_to %}
<strong>You have moved your account</strong> to <a href="{{ moved_to }}">{{ username }}</a>
{% endblocktrans %}
</p>
<p class="mt-2">
{% trans "You can undo the move to restore full functionality, but some followers may have already unfollowed this account." %}
</p>
</div>
</div>
<div class="columns is-justify-content-center">
<div class="column is-one-quarter">
<div class="level">
<form class="level-left" name="remove-alias" action="{% url 'prefs-unmove' %}" method="post">
{% csrf_token %}
<input type="hidden" name="remote_id" id="remote_id" value="{{user.moved_to}}">
<button type="submit" class="button is-medium is-danger">{% trans "Undo move" %}</button>
</form>
<form class="level-right" name="logout" action="{% url 'logout' %}" method="post">
{% csrf_token %}
<button type="submit" class="button is-medium is-primary">{% trans 'Log out' %}</button>
</form>
</div>
</div>
</div>
</div>
</div>

View file

@ -35,4 +35,6 @@
{% include 'notifications/items/update.html' %} {% include 'notifications/items/update.html' %}
{% 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' %}
{% 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

@ -0,0 +1,29 @@
{% 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 %}
{% if related_user_moved_to %}
{% id_to_username request.user.moved_to as username %}
{% blocktrans trimmed %}
{{ related_user }} has moved to <a href="{{ related_user_moved_to }}">{{ username }}</a>
{% endblocktrans %}
<div class="row shrink my-2">
{% include 'snippets/move_user_buttons.html' with group=notification.related_group %}
</div>
{% else %}
{% blocktrans trimmed %}
{{ related_user }} has undone their move
{% endblocktrans %}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,59 @@
{% 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." %}
</p>
<p>
{% trans "This is a reversable action and will not change the functionality of this account." %}
</p>
</div>
<form name="alias-user" action="{% url 'prefs-alias' %}" method="post">
{% csrf_token %}
<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>
<div class="field">
<label class="label" for="id_password">{% trans "Confirm your 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>
<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-alias' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Aliases" %}</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> <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,43 @@
{% 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 direct them to follow the new account." %}
</p>
<p>
{% blocktrans %}
<strong>{{ user }}</strong> will be marked as moved and will not be discoverable or usable unless you undo the move.
{% endblocktrans %}
</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_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>
<div class="field">
<label class="label" for="id_password">{% trans "Confirm your 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>
<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 %}
{% 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="tag is-success" aria-hidden="true">
<span class="icon icon-check"></span> <span class="icon icon-check"></span>
</span> </span>
{% trans "Active" %} {% 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 %}
{% if user.moved_to %}
<p class="notification is-info">
{% trans "Moved" %}
</p>
{% else %}
<p class="notification is-success"> <p class="notification is-success">
{% trans "Active" %} {% trans "Active" %}
</p> </p>
{% endif %}
{% else %} {% else %}
<p class="notification is-warning"> <p class="notification is-warning">
{% trans "Inactive" %} {% trans "Inactive" %}

View file

@ -18,7 +18,22 @@
{% include 'user/books_header.html' %} {% include 'user/books_header.html' %}
</h1> </h1>
</header> </header>
{% if user.moved_to %}
<div class="container my-6">
<div class="notification is-info has-text-centered">
<p>
{% trans "You have have moved to" %}
<a href="{{user.moved_to}}">{% id_to_username user.moved_to %}</a>
</p>
<p> {% trans "You can undo this move to restore full functionality, but some followers may have already unfollowed this account." %}</p>
<form name="remove-alias" action="{% url 'prefs-unmove' %}" method="post">
{% csrf_token %}
<input type="hidden" name="remote_id" id="remote_id" value="{{user.moved_to}}">
<button type="submit" class="button is-small">{% trans "Undo move" %}</button>
</form>
</div>
</div>
{% else %}
<nav class="breadcrumb subtitle" aria-label="breadcrumbs"> <nav class="breadcrumb subtitle" aria-label="breadcrumbs">
<ul> <ul>
<li><a href="{% url 'user-feed' user|username %}">{% trans "User profile" %}</a></li> <li><a href="{% url 'user-feed' user|username %}">{% trans "User profile" %}</a></li>
@ -215,6 +230,7 @@
<div> <div>
{% include 'snippets/pagination.html' with page=books path=request.path %} {% include 'snippets/pagination.html' with page=books path=request.path %}
</div> </div>
{% endif %}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}

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 at 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">
{% if user.moved_to %}
{% include 'user/moved.html' with user=user %}
{% else %}
{% include 'user/user_preview.html' with user=user %} {% include 'user/user_preview.html' with user=user %}
{% endif %}
</div> </div>
{% if user.summary %} {% if user.summary %}
@ -38,6 +43,15 @@
{% endspaceless %} {% endspaceless %}
{% endif %} {% endif %}
</div> </div>
<div>
{% 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>
</div>
{% else %}
{% if not is_self and request.user.is_authenticated %} {% if not is_self and request.user.is_authenticated %}
{% include 'snippets/follow_button.html' with user=user %} {% include 'snippets/follow_button.html' with user=user %}
{% endif %} {% endif %}
@ -58,11 +72,12 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
</div> {% endif %}
</div>
{% block tabs %} {% block tabs %}
{% with user|username as username %} {% if not user.moved_to %}
<nav class="tabs"> {% with user|username as username %}
<nav class="tabs">
<ul> <ul>
{% url 'user-feed' user|username as url %} {% url 'user-feed' user|username as url %}
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}> <li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
@ -98,10 +113,13 @@
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>
{% endwith %} {% endwith %}
{% endblock %} {% endif %}
{% endblock %}
{% block panel %}{% endblock %} </div>
{% if not user.moved_to %}
{% block panel %}{% endblock %}
{% endif %}
{% 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,16 @@ 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 remote id, return the username"""
if user_id:
url = urlparse(user_id)
domain = url.netloc
parts = url.path.split("/")
name = parts[-1]
value = f"{name}@{domain}"
return value

View file

@ -88,9 +88,11 @@ class User(TestCase):
"https://www.w3.org/ns/activitystreams", "https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1", "https://w3id.org/security/v1",
{ {
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue", "PropertyValue": "schema:PropertyValue",
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
"schema": "http://schema.org#",
"value": "schema:value", "value": "schema:value",
}, },
], ],

View file

@ -600,6 +600,12 @@ 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/unmove/?$", views.unmove, name="prefs-unmove"),
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, unmove
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,111 @@
""" move your account somewhere else """
from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, 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("user-feed", username=request.user.username)
except PermissionDenied:
form.errors["target"] = [
"Set this user as an alias on the user you are moving to first"
]
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")
@require_POST
@login_required
def unmove(request):
"""undo a user move"""
target = get_object_or_404(models.User, remote_id=request.POST["remote_id"])
move = get_object_or_404(models.MoveUser, target=target, user=request.user)
move.delete()
request.user.moved_to = None
request.user.save(update_fields=["moved_to"], broadcast=True)
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",