diff --git a/FEDERATION.md b/FEDERATION.md index dd0c917e2..d80e98bd3 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -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 - `Update`: updates a user's profile and settings - `Delete`: deactivates a user -- `Undo`: reverses a `Follow` or `Block` +- `Undo`: reverses a `Block` or `Follow` ### Activities - `Create/Status`: saves a new status in the database. - `Delete/Status`: Removes a status - `Like/Status`: Creates a favorite on the status - `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 User's books and lists are represented by [`OrderedCollection`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection) diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index 2697620f0..41decd68a 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -23,6 +23,7 @@ from .verbs import Create, Delete, Undo, Update from .verbs import Follow, Accept, Reject, Block from .verbs import Add, Remove from .verbs import Announce, Like +from .verbs import Move # 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 diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py index 61c15a579..85cf44409 100644 --- a/bookwyrm/activitypub/person.py +++ b/bookwyrm/activitypub/person.py @@ -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" diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index 4b7514b5a..00c9524fe 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -231,3 +231,30 @@ class Announce(Verb): def action(self, allow_external_connections=True): """boost""" 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 diff --git a/bookwyrm/forms/edit_user.py b/bookwyrm/forms/edit_user.py index ce7bb6d07..9024972c3 100644 --- a/bookwyrm/forms/edit_user.py +++ b/bookwyrm/forms/edit_user.py @@ -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) diff --git a/bookwyrm/migrations/0182_auto_20231027_1122.py b/bookwyrm/migrations/0182_auto_20231027_1122.py new file mode 100644 index 000000000..ab57907a9 --- /dev/null +++ b/bookwyrm/migrations/0182_auto_20231027_1122.py @@ -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",), + ), + ] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index 7b779190b..c455c751f 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -27,6 +27,8 @@ from .group import Group, GroupMember, GroupMemberInvitation from .import_job import ImportJob, ImportItem +from .move import MoveUser + from .site import SiteSettings, Theme, SiteInvite from .site import PasswordReset, InviteRequest from .announcement import Announcement diff --git a/bookwyrm/models/move.py b/bookwyrm/models/move.py new file mode 100644 index 000000000..a5bf9d76d --- /dev/null +++ b/bookwyrm/models/move.py @@ -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() diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py index 522038f9a..093c25c65 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -40,11 +40,14 @@ class Notification(BookWyrmModel): GROUP_NAME = "GROUP_NAME" GROUP_DESCRIPTION = "GROUP_DESCRIPTION" + # Migrations + MOVE = "MOVE" + # pylint: disable=line-too-long NotificationType = models.TextChoices( # there has got be a better way to do this "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) diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 6e0912aec..c152cf445 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -140,6 +140,19 @@ 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", 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 show_goal = models.BooleanField(default=True) show_suggested_users = models.BooleanField(default=True) @@ -314,6 +327,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 diff --git a/bookwyrm/templates/feed/layout.html b/bookwyrm/templates/feed/layout.html index b70ed99ea..1a2fb33c6 100644 --- a/bookwyrm/templates/feed/layout.html +++ b/bookwyrm/templates/feed/layout.html @@ -1,5 +1,6 @@ {% extends 'layout.html' %} {% load i18n %} +{% load utilities %} {% block title %}{% trans "Updates" %}{% endblock %} diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index b8459856c..a700ed9e9 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -27,6 +27,7 @@ @@ -173,11 +180,15 @@
- {# almost every view needs to know the user shelves #} - {% with request.user.shelf_set.all as user_shelves %} - {% block content %} - {% endblock %} - {% endwith %} + {% if request.user.moved_to %} + {% include "moved.html" %} + {% else %} + {# almost every view needs to know the user shelves #} + {% with request.user.shelf_set.all as user_shelves %} + {% block content %} + {% endblock %} + {% endwith %} + {% endif %}
diff --git a/bookwyrm/templates/moved.html b/bookwyrm/templates/moved.html new file mode 100644 index 000000000..1ad837f18 --- /dev/null +++ b/bookwyrm/templates/moved.html @@ -0,0 +1,52 @@ +{% load i18n %} +{% load static %} +{% load utilities %} + +
+
+
+
+
+
+ {{ request.user.alt_text }} +
+
+
+

{{ request.user.display_name }}

+

{{request.user.username}}

+
+
+ +
+

+ {% id_to_username request.user.moved_to as username %} + {% blocktrans %} + You have moved your account to {{ username }} + {% endblocktrans %} +

+ {% blocktrans %} +

You can undo the move to restore full functionality, but some followers may have already unfollowed this account.

+ {% endblocktrans %} +
+
+
+
+
+ + {% csrf_token %} + + + +
+ {% csrf_token %} + +
+
+
+
+
+
\ No newline at end of file diff --git a/bookwyrm/templates/notifications/item.html b/bookwyrm/templates/notifications/item.html index b53abe3d1..bac7dc61f 100644 --- a/bookwyrm/templates/notifications/item.html +++ b/bookwyrm/templates/notifications/item.html @@ -35,4 +35,6 @@ {% include 'notifications/items/update.html' %} {% elif notification.notification_type == 'GROUP_DESCRIPTION' %} {% include 'notifications/items/update.html' %} +{% elif notification.notification_type == 'MOVE' %} + {% include 'notifications/items/move_user.html' %} {% endif %} diff --git a/bookwyrm/templates/notifications/items/layout.html b/bookwyrm/templates/notifications/items/layout.html index 8acbb9fec..41353abcf 100644 --- a/bookwyrm/templates/notifications/items/layout.html +++ b/bookwyrm/templates/notifications/items/layout.html @@ -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 %} {% if related_status %} diff --git a/bookwyrm/templates/notifications/items/move_user.html b/bookwyrm/templates/notifications/items/move_user.html new file mode 100644 index 000000000..c3587057e --- /dev/null +++ b/bookwyrm/templates/notifications/items/move_user.html @@ -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 %} + +{% endblock %} + +{% block description %} + {% if related_user_moved_to %} + {% id_to_username request.user.moved_to as username %} + {% blocktrans %} + {{ related_user }} has moved to {{ username }} + {% endblocktrans %} +
+ {% include 'snippets/move_user_buttons.html' with group=notification.related_group %} +
+ {% else %} + {% blocktrans %} + {{ related_user }} has undone their move + {% endblocktrans %} + {% endif %} +{% endblock %} diff --git a/bookwyrm/templates/preferences/alias_user.html b/bookwyrm/templates/preferences/alias_user.html new file mode 100644 index 000000000..e1e468208 --- /dev/null +++ b/bookwyrm/templates/preferences/alias_user.html @@ -0,0 +1,59 @@ +{% extends 'preferences/layout.html' %} +{% load i18n %} + +{% block title %}{% trans "Move Account" %}{% endblock %} + +{% block header %} +{% trans "Create Alias" %} +{% endblock %} + +{% block panel %} +
+

{% trans "Add another account as an alias" %}

+
+
+

+ {% trans "Marking another account as an alias is required if you want to move that account to this one." %} +

+

+ {% trans "This is a reversable action and will not change the functionality of this account." %} +

+
+
+ {% csrf_token %} +
+ + + {% include 'snippets/form_errors.html' with errors_list=form.username.errors id="desc_username" %} +
+
+ + + {% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %} +
+ +
+
+ {% if user.also_known_as.all.0 %} +
+

{% trans "Aliases" %}

+
+ + {% for alias in user.also_known_as.all %} + + + + + {% endfor %} +
{{ alias.username }} +
+ {% csrf_token %} + + +
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/bookwyrm/templates/preferences/layout.html b/bookwyrm/templates/preferences/layout.html index ca63ec93d..fb0b6fba6 100644 --- a/bookwyrm/templates/preferences/layout.html +++ b/bookwyrm/templates/preferences/layout.html @@ -23,6 +23,14 @@ {% url 'prefs-2fa' as url %} {% trans "Two Factor Authentication" %} +
  • + {% url 'prefs-alias' as url %} + {% trans "Aliases" %} +
  • +
  • + {% url 'prefs-move' as url %} + {% trans "Move Account" %} +
  • {% url 'prefs-delete' as url %} {% trans "Delete Account" %} diff --git a/bookwyrm/templates/preferences/move_user.html b/bookwyrm/templates/preferences/move_user.html new file mode 100644 index 000000000..47b370e82 --- /dev/null +++ b/bookwyrm/templates/preferences/move_user.html @@ -0,0 +1,43 @@ +{% extends 'preferences/layout.html' %} +{% load i18n %} + +{% block title %}{% trans "Move Account" %}{% endblock %} + +{% block header %} +{% trans "Move Account" %} +{% endblock %} + +{% block panel %} +
    +

    {% trans "Migrate account to another server" %}

    +
    +
    +

    + {% trans "Moving your account will notify all your followers and direct them to follow the new account." %} +

    +

    + {% blocktrans %} + {{ user }} will be marked as moved and will not be discoverable or usable unless you undo the move. + {% endblocktrans %} +

    +
    +
    +

    {% trans "Remember to add this user as an alias of the target account before you try to move." %}

    +
    +
    + {% csrf_token %} +
    + + + {% include 'snippets/form_errors.html' with errors_list=form.target.errors id="desc_target" %} +
    +
    + + + {% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %} +
    + +
    +
    +
    +{% endblock %} diff --git a/bookwyrm/templates/settings/users/user_admin.html b/bookwyrm/templates/settings/users/user_admin.html index 9bc5805b1..a1d93ddd0 100644 --- a/bookwyrm/templates/settings/users/user_admin.html +++ b/bookwyrm/templates/settings/users/user_admin.html @@ -75,10 +75,17 @@ {{ user.last_active_date }} {% if user.is_active %} - - {% trans "Active" %} + {% if user.moved_to %} + + {% trans "Moved" %} + {% else %} + + {% trans "Active" %} + {% endif %} {% elif user.deactivation_reason == "moderator_deletion" or user.deactivation_reason == "self_deletion" %}