diff --git a/static/css/style.css b/static/css/style.css index 09c4889..d9a841b 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -432,6 +432,17 @@ section h1.above { margin-bottom: -20px; } +section h2.above { + position: relative; + top: -35px; + left: -15px; + font-weight: bold; + font-size: 100%; + text-transform: uppercase; + color: var(--color-text-dull); + margin-bottom: -20px; +} + section p { margin: 5px 0 10px 0; } @@ -983,6 +994,7 @@ button, background-color: var(--color-highlight); color: var(--color-text-in-highlight); display: inline-block; + text-decoration: none; } button.delete, diff --git a/takahe/urls.py b/takahe/urls.py index 9a512c6..f40eb0c 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -65,6 +65,11 @@ urlpatterns = [ settings.CsvFollowers.as_view(), name="settings_export_followers_csv", ), + path( + "@/settings/migrate_in/", + settings.MigrateInPage.as_view(), + name="settings_migrate_in", + ), path( "@/settings/tokens/", settings.TokensRoot.as_view(), diff --git a/templates/settings/_menu.html b/templates/settings/_menu.html index ca77909..7e4635e 100644 --- a/templates/settings/_menu.html +++ b/templates/settings/_menu.html @@ -14,6 +14,10 @@ Import/Export + + + Migrate Inbound + Authorized Apps diff --git a/templates/settings/migrate_in.html b/templates/settings/migrate_in.html new file mode 100644 index 0000000..81c71dd --- /dev/null +++ b/templates/settings/migrate_in.html @@ -0,0 +1,36 @@ +{% extends "settings/base.html" %} + +{% block subtitle %}Migrate Here{% endblock %} + +{% block settings_content %} +
+ {% csrf_token %} + + +
+ Add New Alias +

+ To move another account to this one, first add it as an alias here, + and then go to the server where it is hosted and initiate the move. +

+ {% include "forms/_field.html" with field=form.alias %} +
+ +
+ +
+ +
+ +
+

Current Aliases

+ + {% for alias in aliases %} + + {% empty %} + + {% endfor %} +
{{ alias.handle }} Remove Alias
You have no aliases.
+
+ +{% endblock %} diff --git a/users/migrations/0021_identity_aliases.py b/users/migrations/0021_identity_aliases.py new file mode 100644 index 0000000..68d067a --- /dev/null +++ b/users/migrations/0021_identity_aliases.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.1 on 2023-07-22 17:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0020_alter_identity_local"), + ] + + operations = [ + migrations.AddField( + model_name="identity", + name="aliases", + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/users/models/identity.py b/users/models/identity.py index 32d30a5..5d35d80 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -1,6 +1,6 @@ import ssl from functools import cached_property, partial -from typing import Literal +from typing import Literal, Optional from urllib.parse import urlparse import httpx @@ -201,6 +201,10 @@ class Identity(StatorModel): # Should be a list of object URIs (we don't want a full M2M here) pinned = models.JSONField(blank=True, null=True) + # A list of other actor URIs - if this account was moved, should contain + # the one URI it was moved to. + aliases = models.JSONField(blank=True, null=True) + # Admin-only moderation fields sensitive = models.BooleanField(default=False) restriction = models.IntegerField( @@ -330,8 +334,21 @@ class Identity(StatorModel): self.following_uri = self.actor_uri + "following/" self.shared_inbox_uri = f"https://{self.domain.uri_domain}/inbox/" + def add_alias(self, actor_uri: str): + self.aliases = (self.aliases or []) + [actor_uri] + self.save() + + def remove_alias(self, actor_uri: str): + self.aliases = [x for x in (self.aliases or []) if x != actor_uri] + self.save() + ### Alternate constructors/fetchers ### + @classmethod + def by_handle(cls, handle, fetch: bool = False) -> Optional["Identity"]: + username, domain = handle.lstrip("@").split("@", 1) + return cls.by_username_and_domain(username=username, domain=domain, fetch=fetch) + @classmethod def by_username_and_domain( cls, @@ -339,7 +356,7 @@ class Identity(StatorModel): domain: str | Domain, fetch: bool = False, local: bool = False, - ): + ) -> Optional["Identity"]: """ Get an Identity by username and domain. @@ -543,6 +560,8 @@ class Identity(StatorModel): } for item in self.metadata ] + if self.aliases: + response["alsoKnownAs"] = self.aliases # Emoji emojis = Emoji.emojis_from_content( (self.name or "") + " " + (self.summary or ""), None diff --git a/users/views/settings/__init__.py b/users/views/settings/__init__.py index 70cd94e..0f824db 100644 --- a/users/views/settings/__init__.py +++ b/users/views/settings/__init__.py @@ -11,6 +11,7 @@ from users.views.settings.import_export import ( # noqa ImportExportPage, ) from users.views.settings.interface import InterfacePage # noqa +from users.views.settings.migration import MigrateInPage # noqa from users.views.settings.posting import PostingPage # noqa from users.views.settings.profile import ProfilePage # noqa from users.views.settings.security import SecurityPage # noqa diff --git a/users/views/settings/migration.py b/users/views/settings/migration.py new file mode 100644 index 0000000..59976c1 --- /dev/null +++ b/users/views/settings/migration.py @@ -0,0 +1,49 @@ +from django import forms +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect +from django.utils.decorators import method_decorator +from django.views.generic import FormView + +from users.models import Identity +from users.views.base import IdentityViewMixin + + +@method_decorator(login_required, name="dispatch") +class MigrateInPage(IdentityViewMixin, FormView): + """ + Lets the identity's profile be migrated in or out. + """ + + template_name = "settings/migrate_in.html" + extra_context = {"section": "migrate_in"} + + class form_class(forms.Form): + alias = forms.CharField( + help_text="The @account@example.com username you want to move here" + ) + + def clean_alias(self): + self.alias_identity = Identity.by_handle( + self.cleaned_data["alias"], fetch=True + ) + if self.alias_identity is None: + raise forms.ValidationError("Cannot find that account.") + return self.alias_identity.actor_uri + + def form_valid(self, form): + self.identity.add_alias(form.cleaned_data["alias"]) + messages.info(self.request, f"Alias to {form.alias_identity.handle} added") + return redirect(".") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # If they asked for an alias deletion, do it here + if "remove_alias" in self.request.GET: + self.identity.remove_alias(self.request.GET["remove_alias"]) + context["aliases"] = [] + if self.identity.aliases: + context["aliases"] = [ + Identity.by_actor_uri(uri) for uri in self.identity.aliases + ] + return context