Identity admin/moderation

pull/184/head
Andrew Godwin 2022-12-16 19:42:48 -07:00
rodzic c588567c86
commit 12567f6891
20 zmienionych plików z 489 dodań i 35 usunięć

Wyświetl plik

@ -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")

Wyświetl plik

@ -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")

Wyświetl plik

@ -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)

Wyświetl plik

@ -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")

Wyświetl plik

@ -18,6 +18,7 @@ in alpha. For more information about Takahē, see
features
contributing
domains
moderation
stator
tuning
releases/index

Wyświetl plik

@ -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 <https://jointakahe.org/conduct/>`_, please let us know
at conduct@jointakahe.org and we will do our best to not give them any support.

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -106,9 +106,14 @@ urlpatterns = [
),
path(
"admin/identities/",
admin.Identities.as_view(),
admin.IdentitiesRoot.as_view(),
name="admin_identities",
),
path(
"admin/identities/<id>/",
admin.IdentityEdit.as_view(),
name="admin_identity_edit",
),
path(
"admin/invites/",
admin.Invites.as_view(),

Wyświetl plik

@ -3,7 +3,50 @@
{% block subtitle %}Identities{% endblock %}
{% block content %}
<p>
Please use the <a href="/djadmin/users/identity/">Django Admin</a> for now.
</p>
<form action="." class="search">
<input type="search" name="query" value="{{ query }}" placeholder="Search by name/username">
{% if local_only %}
<input type="hidden" name="local_only" value="true">
{% endif %}
<button><i class="fa-solid fa-search"></i></button>
</form>
<div class="view-options">
{% if local_only %}
<a href=".?{% if query %}query={{ query }}{% endif %}" class="selected"><i class="fa-solid fa-check"></i> Local Only</a>
{% else %}
<a href=".?local_only=true{% if query %}&amp;query={{ query }}{% endif %}"><i class="fa-solid fa-xmark"></i> Local Only</a>
{% endif %}
</div>
<section class="icon-menu">
{% for identity in page_obj %}
<a class="option" href="{{ identity.urls.admin_edit }}">
<img src="{{ identity.local_icon_url.relative }}" class="icon" alt="Avatar for {{ identity.name_or_handle }}">
<span class="handle">
{{ identity.html_name_or_handle }}
<small>
{{ identity.handle }}
</small>
</span>
{% if identity.banned %}
<span class="pill bad">Banned</span>
{% endif %}
</a>
{% empty %}
<p class="option empty">
{% if query %}
No identities match your query.
{% else %}
There are no identities yet.
{% endif %}
</p>
{% endfor %}
<div class="load-more">
{% if page_obj.has_previous %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}{% if local_only %}&amp;local_only=true{% endif %}{% if query %}&amp;query={{ query }}{% endif %}">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}{% if local_only %}&amp;local_only=true{% endif %}{% if query %}&amp;query={{ query }}{% endif %}">Next Page</a>
{% endif %}
</div>
</section>
{% endblock %}

Wyświetl plik

@ -0,0 +1,123 @@
{% extends "settings/base.html" %}
{% block subtitle %}{{ identity.name_or_handle }}{% endblock %}
{% block content %}
<h1>{{ identity.html_name_or_handle }} <small>{{ identity.handle }}</small></h1>
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Stats</legend>
<table class="metadata">
<tr>
<th>Status</td>
<td>
{% if identity.limited %}
Limited
{% elif identity.blocked %}
Blocked
{% else %}
Normal
{% endif %}
</td>
</tr>
{% if identity.local %}
<tr>
<th>Type</td>
<td>Local Identity</td>
</tr>
<tr>
<th>Followers</td>
<td>{{ identity.inbound_follows.count }}</td>
</tr>
<tr>
<th>Following</td>
<td>{{ identity.outbound_follows.count }}</td>
</tr>
{% else %}
<tr>
<th>Type</td>
<td>Remote Identity</td>
</tr>
<tr>
<th>Local Followers</td>
<td>{{ identity.inbound_follows.count }}</td>
</tr>
<tr>
<th>Following Locals</td>
<td>{{ identity.outbound_follows.count }}</td>
</tr>
{% endif %}
<tr>
<th>Posts</td>
<td>{{ identity.posts.count }}</td>
</tr>
<tr>
<th>First Seen</td>
<td>{{ identity.created|timesince }} ago</td>
</tr>
</table>
</fieldset>
{% if identity.local %}
<fieldset>
<legend>Users</legend>
<p>
{% for user in identity.users.all %}
<a href="{{ user.urls.admin_edit }}">{{ user.email }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</p>
</fieldset>
{% endif %}
<fieldset>
<legend>Technical</legend>
<table class="metadata">
{% if not identity.local %}
<tr>
<th>Last Fetched</td>
<td>{{ identity.fetched|timesince }} ago</td>
</tr>
{% if identity.state == "outdated" %}
<tr>
<th>Attempting Fetch Since</td>
<td>{{ identity.state_changed|timesince }} ago</td>
</tr>
{% endif %}
{% endif %}
<tr>
<th>Actor URI</td>
<td>{{ identity.actor_uri }}</td>
</tr>
{% if not identity.local %}
<tr>
<th>Inbox URI</td>
<td>{{ identity.inbox_uri }}</td>
</tr>
{% endif %}
</table>
</fieldset>
<fieldset>
<legend>Admin Notes</legend>
{% include "forms/_field.html" with field=form.notes %}
</fieldset>
<div class="buttons">
{% if not identity.local %}
<button class="left" name="fetch">Force Fetch</a>
{% endif %}
{% if identity.limited %}
<button class="left delete" name="unlimit">Unlimit</a>
{% else %}
<button class="left delete" name="limit">Limit</a>
{% endif %}
{% if identity.blocked %}
<button class="left delete" name="unblock">Unblock</a>
{% else %}
<button class="left delete" name="block">Block</a>
{% endif %}
</div>
<div class="buttons">
<a href="{{ identity.urls.admin }}" class="button secondary left">Back</a>
<a href="{{ identity.urls.view }}" class="button secondary">View Profile</a>
<button>Save Notes</button>
</div>
</form>
{% endblock %}

Wyświetl plik

@ -6,8 +6,20 @@
{% for model, stats in model_stats.items %}
<fieldset>
<legend>{{ model }}</legend>
<p><b>Pending:</b> {{ stats.most_recent_queued }}</p>
<p><b>Processed today:</b> {{ stats.most_recent_handled.1 }}</p>
<table class="metadata">
<tr>
<th>Pending</td>
<td>{{ stats.most_recent_queued }}</td>
</tr>
<tr>
<th>Processed today</td>
<td>{{ stats.most_recent_handled.1 }}</td>
</tr>
<tr>
<th>This month</td>
<td>{{ stats.most_recent_handled.2 }}</td>
</tr>
</table>
</fieldset>
{% endfor %}
{% endblock %}

Wyświetl plik

@ -1,6 +1,6 @@
{% extends "settings/base.html" %}
{% block subtitle %}{{ user.email }}{% endblock %}
{% block subtitle %}{{ editing_user.email }}{% endblock %}
{% block content %}
<h1>{{ editing_user.email }}</h1>

Wyświetl plik

@ -66,11 +66,11 @@
<table class="metadata">
{% for entry in identity.safe_metadata %}
<tr>
<td class="name">{{ entry.name }}</td>
<td class="value">{{ entry.value }}</td>
<th>{{ entry.name }}</td>
<td>{{ entry.value }}</td>
</tr>
{% endfor %}
</table>
</table>
{% endif %}
{% if identity.config_identity.visible_follows %}

Wyświetl plik

@ -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),
),
]

Wyświetl plik

@ -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):

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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):

Wyświetl plik

@ -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

Wyświetl plik

@ -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")