From 12567f6891ad591390cbd74c0e7b77a4a024a24e Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Fri, 16 Dec 2022 19:42:48 -0700 Subject: [PATCH] Identity admin/moderation --- activities/views/posts.py | 6 +- activities/views/timelines.py | 4 + api/views/accounts.py | 8 +- api/views/timelines.py | 3 + docs/index.rst | 1 + docs/moderation.rst | 99 ++++++++++++++ static/css/style.css | 18 ++- takahe/urls.py | 7 +- templates/admin/identities.html | 49 ++++++- templates/admin/identity_edit.html | 123 ++++++++++++++++++ templates/admin/stator.html | 16 ++- templates/admin/user_edit.html | 2 +- templates/identity/view.html | 6 +- ...min_notes_identity_restriction_and_more.py | 30 +++++ users/models/identity.py | 33 ++++- users/shortcuts.py | 2 + users/views/activitypub.py | 9 +- users/views/admin/__init__.py | 16 +-- users/views/admin/identities.py | 90 +++++++++++++ users/views/admin/users.py | 2 +- 20 files changed, 489 insertions(+), 35 deletions(-) create mode 100644 docs/moderation.rst create mode 100644 templates/admin/identity_edit.html create mode 100644 users/migrations/0004_identity_admin_notes_identity_restriction_and_more.py create mode 100644 users/views/admin/identities.py diff --git a/activities/views/posts.py b/activities/views/posts.py index 1b8676d..a3810e0 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -1,6 +1,6 @@ from django.core.exceptions import PermissionDenied from django.db import models -from django.http import JsonResponse +from django.http import Http404, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.decorators import method_decorator from django.views.decorators.vary import vary_on_headers @@ -10,6 +10,7 @@ from activities.models import Post, PostInteraction, PostStates from core.decorators import cache_page_by_ap_json from core.ld import canonicalise from users.decorators import identity_required +from users.models import Identity from users.shortcuts import by_handle_or_404 @@ -23,6 +24,8 @@ class Individual(TemplateView): def get(self, request, handle, post_id): self.identity = by_handle_or_404(self.request, handle, local=False) + if self.identity.blocked: + raise Http404("Blocked user") self.post_obj = get_object_or_404(self.identity.posts, pk=post_id) # If they're coming in looking for JSON, they want the actor if request.ap_json: @@ -66,6 +69,7 @@ class Individual(TemplateView): ), in_reply_to=self.post_obj.object_uri, ) + .exclude(author__restriction=Identity.Restriction.blocked) .distinct() .select_related("author__domain") .prefetch_related("emojis") diff --git a/activities/views/timelines.py b/activities/views/timelines.py index 84e490f..9e4bcfb 100644 --- a/activities/views/timelines.py +++ b/activities/views/timelines.py @@ -7,6 +7,7 @@ from django.views.generic import FormView, ListView from activities.models import Hashtag, Post, PostInteraction, TimelineEvent from core.decorators import cache_page from users.decorators import identity_required +from users.models import Identity from .compose import Compose @@ -75,6 +76,7 @@ class Tag(ListView): def get_queryset(self): return ( Post.objects.public() + .filter(author__restriction=Identity.Restriction.none) .tagged_with(self.hashtag) .select_related("author") .prefetch_related("attachments", "mentions") @@ -105,6 +107,7 @@ class Local(ListView): def get_queryset(self): return ( Post.objects.local_public() + .filter(author__restriction=Identity.Restriction.none) .select_related("author", "author__domain") .prefetch_related("attachments", "mentions", "emojis") .order_by("-created") @@ -133,6 +136,7 @@ class Federated(ListView): Post.objects.filter( visibility=Post.Visibilities.public, in_reply_to__isnull=True ) + .filter(author__restriction=Identity.Restriction.none) .select_related("author", "author__domain") .prefetch_related("attachments", "mentions", "emojis") .order_by("-created") diff --git a/api/views/accounts.py b/api/views/accounts.py index 4f1903b..d0aeb08 100644 --- a/api/views/accounts.py +++ b/api/views/accounts.py @@ -48,7 +48,9 @@ def account_relationships(request): @api_router.get("/v1/accounts/{id}", response=schemas.Account) @identity_required def account(request, id: str): - identity = get_object_or_404(Identity, pk=id) + identity = get_object_or_404( + Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id + ) return identity.to_mastodon_json() @@ -67,7 +69,9 @@ def account_statuses( min_id: str | None = None, limit: int = 20, ): - identity = get_object_or_404(Identity, pk=id) + identity = get_object_or_404( + Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id + ) queryset = ( identity.posts.not_hidden() .unlisted(include_replies=not exclude_replies) diff --git a/api/views/timelines.py b/api/views/timelines.py index 8f4ac78..b14586f 100644 --- a/api/views/timelines.py +++ b/api/views/timelines.py @@ -3,6 +3,7 @@ from api import schemas from api.decorators import identity_required from api.pagination import MastodonPaginator from api.views.base import api_router +from users.models import Identity @api_router.get("/v1/timelines/home", response=list[schemas.Status]) @@ -52,6 +53,7 @@ def public( ): queryset = ( Post.objects.public() + .filter(author__restriction=Identity.Restriction.none) .select_related("author") .prefetch_related("attachments") .order_by("-created") @@ -90,6 +92,7 @@ def hashtag( limit = 40 queryset = ( Post.objects.public() + .filter(author__restriction=Identity.Restriction.none) .tagged_with(hashtag) .select_related("author") .prefetch_related("attachments") diff --git a/docs/index.rst b/docs/index.rst index 2c1ff48..aeb41de 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,6 +18,7 @@ in alpha. For more information about Takahē, see features contributing domains + moderation stator tuning releases/index diff --git a/docs/moderation.rst b/docs/moderation.rst new file mode 100644 index 0000000..70479a8 --- /dev/null +++ b/docs/moderation.rst @@ -0,0 +1,99 @@ +Moderation +========== + +As a server admin, you have both identity-level and server-level moderation +options at your disposal. + + +Identities +---------- + +Identities, known as Accounts in Mastodon, have their own handle +(like ``@takahe@jointakahe.org``), and are generally what people think of as +"users". + +Takahē distinguishes between the two - for us, a User is a set of login +credentials, while an Identity is the public-facing identity people use to +post. A user can have multiple identities, and an identity can be shared +across multiple users (for example, a brand account that five people can +post from). + +You can moderate both local and remote identities, but bear in mind that any +moderation actions on *remote identities* are local to your server only; +they will not propagate over to other servers. + +Identity moderation actions are available in the "Identities" admin area. + + +Limiting +~~~~~~~~ + +Limiting an identity prevents its posts from appearing in the Public and +Federated timelines; they will, however, still appear in the timelines of +people who follow them, be able to notify other people via mentions, and their +replies will appear in conversation threads. + +You can limit both local and remote identities. Limiting is reversible, +and encouraged as a way to remove some visibility if you don't want a full block. + + +Blocking +~~~~~~~~ + +Blocking an identity erases its existence from your server. Its posts will +not appear anywhere, no mentions from it will come through, and Takahē will +actively discard all incoming information from it as soon as it is received. + +If you block a local identity, you are freezing the account and erasing it +from the Fediverse. Takahē will still accept inbound notifications for it, +but if any servers ask if it exists, it will deny its existence. Users trying +to log into that identity will be denied access. + +If you block a remote identity, you are almost erasing it from existence +from your server's users. Users will not be able to follow it or see posts +from it; they will, however, be able to mention it in outgoing posts. + +Blocking is reversible; however, you will lose data intended for the account +for the duration it is blocked for. If you leave a local account blocked for +too long, other servers will decide it has totally vanished and stop their +users following it. + + +Servers +------- + +If your problem is not with an individual identity/account but with an entire +server - be it very poorly run or actively malicious - you can instead +choose to block the entire server ("defederate"). + +This is accomplished via the "Federation" admin area. Search and select the +domain you want, and then set it to blocked. + +While a domain is blocked, Takahē will actively drop all inbound messages +from it. Blocking is reversible, but you will lose all inbound data from the +server during the blocking period. + + +Defederating from Takahē +------------------------ + +Takahē is unusual in the Fediverse in that it's possible to have it claim to be +multiple different domains at once; this extends to the way it speaks to +other servers, and means you cannot easily block an entire Takahē installation at once. + +If you wish to block a Takahē server, either from Takahē or any other Fediverse +server that supports defederation, you may choose to either block a single +domain as normal, or you may want to block the entire server. + +Takahē sends all actor messages from identities based on the domain they are +part of, but uses a single System Actor for all GET requests to retrieve +identity and post information. To properly defederate a Takahē server, you +need to: + +* Block all domains you know it has identities on +* Block the domain of the System Actor (visible at the ``/actor/`` URL) + +If you are having trouble blocking a Takahē server due to this, we apologise; +this is the nature of the underlying protocol. If you find a server that breaks +our `Code of Conduct `_, please let us know +at conduct@jointakahe.org and we will do our best to not give them any support. diff --git a/static/css/style.css b/static/css/style.css index abfa61c..200a74b 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -307,6 +307,14 @@ nav a i { margin: 0 0 10px 0; } +.left-column h1 small { + font-size: 60%; + color: var(--color-text-dull); + display: block; + margin: -10px 0 0 0; + padding: 0; +} + .left-column h2 { margin: 10px 0 10px 0; } @@ -642,10 +650,15 @@ form .uploaded-image .buttons { } form .buttons { + clear: both; text-align: right; margin: -20px 0 15px 0; } +form .buttons:nth-of-type(2) { + padding-top: 15px; +} + form p+.buttons, form fieldset .buttons { margin-top: 0; @@ -794,14 +807,15 @@ h1.identity small { table.metadata { margin: -10px 0 0 0; + text-align: left; } table.metadata td { padding: 0; } -table.metadata td.name { - padding-right: 10px; +table.metadata th { + padding: 0 10px 0 0; font-weight: bold; } diff --git a/takahe/urls.py b/takahe/urls.py index 07ccc50..6ae0c88 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -106,9 +106,14 @@ urlpatterns = [ ), path( "admin/identities/", - admin.Identities.as_view(), + admin.IdentitiesRoot.as_view(), name="admin_identities", ), + path( + "admin/identities//", + admin.IdentityEdit.as_view(), + name="admin_identity_edit", + ), path( "admin/invites/", admin.Invites.as_view(), diff --git a/templates/admin/identities.html b/templates/admin/identities.html index 556e915..9e30e39 100644 --- a/templates/admin/identities.html +++ b/templates/admin/identities.html @@ -3,7 +3,50 @@ {% block subtitle %}Identities{% endblock %} {% block content %} -

- Please use the Django Admin for now. -

+ +
+ {% if local_only %} + Local Only + {% else %} + Local Only + {% endif %} +
+
+ {% for identity in page_obj %} + + Avatar for {{ identity.name_or_handle }} + + {{ identity.html_name_or_handle }} + + {{ identity.handle }} + + + {% if identity.banned %} + Banned + {% endif %} + + {% empty %} +

+ {% if query %} + No identities match your query. + {% else %} + There are no identities yet. + {% endif %} +

+ {% endfor %} +
+ {% if page_obj.has_previous %} + Previous Page + {% endif %} + {% if page_obj.has_next %} + Next Page + {% endif %} +
+
{% endblock %} diff --git a/templates/admin/identity_edit.html b/templates/admin/identity_edit.html new file mode 100644 index 0000000..c093b7a --- /dev/null +++ b/templates/admin/identity_edit.html @@ -0,0 +1,123 @@ +{% extends "settings/base.html" %} + +{% block subtitle %}{{ identity.name_or_handle }}{% endblock %} + +{% block content %} +

{{ identity.html_name_or_handle }} {{ identity.handle }}

+
+ {% csrf_token %} +
+ Stats + + + + + {% if identity.local %} + + + + + + + + + + {% else %} + + + + + + + + + + {% endif %} + + + + + + + +
+ {% if identity.local %} +
+ Users +

+ {% for user in identity.users.all %} + {{ user.email }}{% if not forloop.last %}, {% endif %} + {% endfor %} +

+
+ {% endif %} +
+ Technical + + {% if not identity.local %} + + + + {% if identity.state == "outdated" %} + + + + {% endif %} + {% endif %} + + + + {% if not identity.local %} + + + + {% endif %} + +
+
+ Admin Notes + {% include "forms/_field.html" with field=form.notes %} +
+
+ {% if not identity.local %} +
+
+ Back + View Profile + +
+
+{% endblock %} diff --git a/templates/admin/stator.html b/templates/admin/stator.html index 64bd432..1f64eda 100644 --- a/templates/admin/stator.html +++ b/templates/admin/stator.html @@ -6,8 +6,20 @@ {% for model, stats in model_stats.items %}
{{ model }} -

Pending: {{ stats.most_recent_queued }}

-

Processed today: {{ stats.most_recent_handled.1 }}

+ + + + + + + + + + +
{% endfor %} {% endblock %} diff --git a/templates/admin/user_edit.html b/templates/admin/user_edit.html index b795848..45be8d9 100644 --- a/templates/admin/user_edit.html +++ b/templates/admin/user_edit.html @@ -1,6 +1,6 @@ {% extends "settings/base.html" %} -{% block subtitle %}{{ user.email }}{% endblock %} +{% block subtitle %}{{ editing_user.email }}{% endblock %} {% block content %}

{{ editing_user.email }}

diff --git a/templates/identity/view.html b/templates/identity/view.html index 145a0ef..e68ebc1 100644 --- a/templates/identity/view.html +++ b/templates/identity/view.html @@ -66,11 +66,11 @@ {% for entry in identity.safe_metadata %} - - + {% endfor %} - + {% endif %} {% if identity.config_identity.visible_follows %} diff --git a/users/migrations/0004_identity_admin_notes_identity_restriction_and_more.py b/users/migrations/0004_identity_admin_notes_identity_restriction_and_more.py new file mode 100644 index 0000000..6b7aa0a --- /dev/null +++ b/users/migrations/0004_identity_admin_notes_identity_restriction_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.1.4 on 2022-12-17 01:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0003_identity_followers_etc"), + ] + + operations = [ + migrations.AddField( + model_name="identity", + name="admin_notes", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="identity", + name="restriction", + field=models.IntegerField( + choices=[(0, "None"), (1, "Limited"), (2, "Blocked")], default=0 + ), + ), + migrations.AddField( + model_name="identity", + name="sensitive", + field=models.BooleanField(default=False), + ), + ] diff --git a/users/models/identity.py b/users/models/identity.py index 574e54e..d6c35d2 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -55,6 +55,11 @@ class Identity(StatorModel): Represents both local and remote Fediverse identities (actors) """ + class Restriction(models.IntegerChoices): + none = 0 + limited = 1 + blocked = 2 + # The Actor URI is essentially also a PK - we keep the default numeric # one around as well for making nice URLs etc. actor_uri = models.CharField(max_length=500, unique=True) @@ -105,6 +110,13 @@ 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) + # Admin-only moderation fields + sensitive = models.BooleanField(default=False) + restriction = models.IntegerField( + choices=Restriction.choices, default=Restriction.none + ) + admin_notes = models.TextField(null=True, blank=True) + private_key = models.TextField(null=True, blank=True) public_key = models.TextField(null=True, blank=True) public_key_id = models.TextField(null=True, blank=True) @@ -124,6 +136,8 @@ class Identity(StatorModel): view = "/@{self.username}@{self.domain_id}/" action = "{view}action/" activate = "{view}activate/" + admin = "/admin/identities/" + admin_edit = "{admin}{self.pk}/" def get_scheme(self, url): return "https" @@ -197,9 +211,16 @@ class Identity(StatorModel): domain = domain.lower() try: if local: - return cls.objects.get(username=username, domain_id=domain, local=True) + return cls.objects.get( + username=username, + domain_id=domain, + local=True, + ) else: - return cls.objects.get(username=username, domain_id=domain) + return cls.objects.get( + username=username, + domain_id=domain, + ) except cls.DoesNotExist: if fetch and not local: actor_uri, handle = async_to_sync(cls.fetch_webfinger)( @@ -277,6 +298,14 @@ class Identity(StatorModel): # TODO: Setting return self.data_age > 60 * 24 * 24 + @property + def blocked(self) -> bool: + return self.restriction == self.Restriction.blocked + + @property + def limited(self) -> bool: + return self.restriction == self.Restriction.limited + ### ActivityPub (outbound) ### def to_ap(self): diff --git a/users/shortcuts.py b/users/shortcuts.py index 8377a7f..9aadb66 100644 --- a/users/shortcuts.py +++ b/users/shortcuts.py @@ -31,4 +31,6 @@ def by_handle_or_404(request, handle, local=True, fetch=False) -> Identity: ) if identity is None: raise Http404(f"No identity for handle {handle}") + if identity.blocked: + raise Http404("Blocked user") return identity diff --git a/users/views/activitypub.py b/users/views/activitypub.py index b44edfb..d80a1c8 100644 --- a/users/views/activitypub.py +++ b/users/views/activitypub.py @@ -165,11 +165,12 @@ class Inbox(View): f"Inbox error: cannot fetch actor {document['actor']}" ) return HttpResponseBadRequest("Cannot retrieve actor") - # See if it's from a blocked domain - if identity.domain.blocked: + + # See if it's from a blocked user or domain + if identity.blocked or identity.domain.blocked: # I love to lie! Throw it away! exceptions.capture_message( - f"Inbox: Discarded message from {identity.domain}" + f"Inbox: Discarded message from {identity.actor_uri}" ) return HttpResponse(status=202) @@ -185,6 +186,7 @@ class Inbox(View): except VerificationError: exceptions.capture_message("Inbox error: Bad LD signature") return HttpResponseUnauthorized("Bad signature") + # Otherwise, verify against the header (assuming it's the same actor) else: try: @@ -200,6 +202,7 @@ class Inbox(View): except VerificationError: exceptions.capture_message("Inbox error: Bad HTTP signature") return HttpResponseUnauthorized("Bad signature") + # Hand off the item to be processed by the queue InboxMessage.objects.create(message=document) return HttpResponse(status=202) diff --git a/users/views/admin/__init__.py b/users/views/admin/__init__.py index 5ace04d..d923f80 100644 --- a/users/views/admin/__init__.py +++ b/users/views/admin/__init__.py @@ -1,9 +1,8 @@ from django import forms from django.utils.decorators import method_decorator -from django.views.generic import FormView, RedirectView, TemplateView +from django.views.generic import FormView, RedirectView from users.decorators import admin_required -from users.models import Identity from users.views.admin.domains import ( # noqa DomainCreate, DomainDelete, @@ -17,6 +16,7 @@ from users.views.admin.hashtags import ( # noqa HashtagEdit, Hashtags, ) +from users.views.admin.identities import IdentitiesRoot, IdentityEdit # noqa from users.views.admin.settings import ( # noqa BasicSettings, PoliciesSettings, @@ -31,18 +31,6 @@ class AdminRoot(RedirectView): pattern_name = "admin_basic" -@method_decorator(admin_required, name="dispatch") -class Identities(TemplateView): - - template_name = "admin/identities.html" - - def get_context_data(self): - return { - "identities": Identity.objects.order_by("username"), - "section": "identities", - } - - @method_decorator(admin_required, name="dispatch") class Invites(FormView): diff --git a/users/views/admin/identities.py b/users/views/admin/identities.py new file mode 100644 index 0000000..d094978 --- /dev/null +++ b/users/views/admin/identities.py @@ -0,0 +1,90 @@ +from django import forms +from django.db import models +from django.shortcuts import get_object_or_404, redirect +from django.utils.decorators import method_decorator +from django.views.generic import FormView, ListView + +from users.decorators import admin_required +from users.models import Identity, IdentityStates + + +@method_decorator(admin_required, name="dispatch") +class IdentitiesRoot(ListView): + + template_name = "admin/identities.html" + paginate_by = 30 + + def get(self, request, *args, **kwargs): + self.query = request.GET.get("query") + self.local_only = request.GET.get("local_only") + self.extra_context = { + "section": "identities", + "query": self.query or "", + "local_only": self.local_only, + } + return super().get(request, *args, **kwargs) + + def get_queryset(self): + identities = Identity.objects.annotate( + num_users=models.Count("users") + ).order_by("created") + if self.local_only: + identities = identities.filter(local=True) + if self.query: + query = self.query.lower().strip().lstrip("@") + if "@" in query: + username, domain = query.split("@", 1) + identities = identities.filter( + username=username, + domain__domain__istartswith=domain, + ) + else: + identities = identities.filter( + models.Q(username__icontains=self.query) + | models.Q(name__icontains=self.query) + ) + return identities + + +@method_decorator(admin_required, name="dispatch") +class IdentityEdit(FormView): + + template_name = "admin/identity_edit.html" + extra_context = { + "section": "identities", + } + + class form_class(forms.Form): + notes = forms.CharField(widget=forms.Textarea, required=False) + + def dispatch(self, request, id, *args, **kwargs): + self.identity = get_object_or_404(Identity, id=id) + return super().dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + if "fetch" in request.POST: + self.identity.transition_perform(IdentityStates.outdated) + self.identity = Identity.objects.get(pk=self.identity.pk) + if "limit" in request.POST: + self.identity.restriction = Identity.Restriction.limited + self.identity.save() + if "block" in request.POST: + self.identity.restriction = Identity.Restriction.blocked + self.identity.save() + if "unlimit" in request.POST or "unblock" in request.POST: + self.identity.restriction = Identity.Restriction.none + self.identity.save() + return super().post(request, *args, **kwargs) + + def get_initial(self): + return {"notes": self.identity.admin_notes} + + def form_valid(self, form): + self.identity.admin_notes = form.cleaned_data["notes"] + self.identity.save() + return redirect(".") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["identity"] = self.identity + return context diff --git a/users/views/admin/users.py b/users/views/admin/users.py index fab4616..5921de2 100644 --- a/users/views/admin/users.py +++ b/users/views/admin/users.py @@ -12,7 +12,7 @@ from users.models import User class UsersRoot(ListView): template_name = "admin/users.html" - paginate_by = 50 + paginate_by = 30 def get(self, request, *args, **kwargs): self.query = request.GET.get("query")