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/0179_populate_sort_title.py b/bookwyrm/migrations/0179_populate_sort_title.py
index e238bca1d..a149a68a7 100644
--- a/bookwyrm/migrations/0179_populate_sort_title.py
+++ b/bookwyrm/migrations/0179_populate_sort_title.py
@@ -45,5 +45,7 @@ class Migration(migrations.Migration):
]
operations = [
- migrations.RunPython(populate_sort_title),
+ migrations.RunPython(
+ populate_sort_title, reverse_code=migrations.RunPython.noop
+ ),
]
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 c2e5308cc..7062fe390 100644
--- a/bookwyrm/models/__init__.py
+++ b/bookwyrm/models/__init__.py
@@ -28,6 +28,8 @@ from .group import Group, GroupMember, GroupMemberInvitation
from .import_job import ImportJob, ImportItem
from .bookwyrm_import_job import BookwyrmImportJob
+from .move import MoveUser
+
from .site import SiteSettings, Theme, SiteInvite
from .site import PasswordReset, InviteRequest
from .announcement import Announcement
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
index 28effaf9b..1e458c815 100644
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -483,10 +483,12 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
image_slug = value
# when it's an inline image (User avatar/icon, Book cover), it's a json
# blob, but when it's an attached image, it's just a url
- if hasattr(image_slug, "url"):
- url = image_slug.url
- elif isinstance(image_slug, str):
+ if isinstance(image_slug, str):
url = image_slug
+ elif isinstance(image_slug, dict):
+ url = image_slug.get("url")
+ elif hasattr(image_slug, "url"): # Serialized to Image/Document object?
+ url = image_slug.url
else:
return None
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 d62043845..e0aefea0a 100644
--- a/bookwyrm/models/notification.py
+++ b/bookwyrm/models/notification.py
@@ -50,11 +50,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} {USER_IMPORT} {USER_EXPORT} {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} {USER_IMPORT} {USER_EXPORT} {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/settings.py b/bookwyrm/settings.py
index 16241f9df..4cecc4df6 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -366,9 +366,9 @@ if USE_S3:
AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY")
AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME")
- AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN")
+ AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN", None)
AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME", "")
- AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL")
+ AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", None)
AWS_DEFAULT_ACL = "public-read"
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
# S3 Static settings
diff --git a/bookwyrm/templates/book/cover_add_modal.html b/bookwyrm/templates/book/cover_add_modal.html
index 8ca5bf2a8..89d870cd0 100644
--- a/bookwyrm/templates/book/cover_add_modal.html
+++ b/bookwyrm/templates/book/cover_add_modal.html
@@ -20,7 +20,7 @@
diff --git a/bookwyrm/templates/book/edit/edit_book_form.html b/bookwyrm/templates/book/edit/edit_book_form.html
index 4cc3965e7..30fb00049 100644
--- a/bookwyrm/templates/book/edit/edit_book_form.html
+++ b/bookwyrm/templates/book/edit/edit_book_form.html
@@ -247,7 +247,7 @@
diff --git a/bookwyrm/templates/import/import.html b/bookwyrm/templates/import/import.html
index ad857fb2e..2c3be9e07 100644
--- a/bookwyrm/templates/import/import.html
+++ b/bookwyrm/templates/import/import.html
@@ -21,7 +21,7 @@
{% blocktrans trimmed count days=import_limit_reset with display_size=import_size_limit|intcomma %}
Currently, you are allowed to import {{ display_size }} books every {{ import_limit_reset }} day.
{% plural %}
- Currently, you are allowed to import {{ import_size_limit }} books every {{ import_limit_reset }} days.
+ Currently, you are allowed to import {{ display_size }} books every {{ import_limit_reset }} days.
{% endblocktrans %}
{% blocktrans with display_left=allowed_imports|intcomma %}You have {{ display_left }} left.{% endblocktrans %}
diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html
index b8459856c..6283e61c4 100644
--- a/bookwyrm/templates/layout.html
+++ b/bookwyrm/templates/layout.html
@@ -27,6 +27,7 @@