From 12567f6891ad591390cbd74c0e7b77a4a024a24e Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Fri, 16 Dec 2022 19:42:48 -0700 Subject: Identity admin/moderation --- ...ty_admin_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 +- 7 files changed, 162 insertions(+), 20 deletions(-) create mode 100644 users/migrations/0004_identity_admin_notes_identity_restriction_and_more.py create mode 100644 users/views/admin/identities.py (limited to 'users') 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") -- cgit v1.2.3