Identity admin/moderation
This commit is contained in:
parent
c588567c86
commit
12567f6891
@ -1,6 +1,6 @@
|
|||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import models
|
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.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.vary import vary_on_headers
|
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.decorators import cache_page_by_ap_json
|
||||||
from core.ld import canonicalise
|
from core.ld import canonicalise
|
||||||
from users.decorators import identity_required
|
from users.decorators import identity_required
|
||||||
|
from users.models import Identity
|
||||||
from users.shortcuts import by_handle_or_404
|
from users.shortcuts import by_handle_or_404
|
||||||
|
|
||||||
|
|
||||||
@ -23,6 +24,8 @@ class Individual(TemplateView):
|
|||||||
|
|
||||||
def get(self, request, handle, post_id):
|
def get(self, request, handle, post_id):
|
||||||
self.identity = by_handle_or_404(self.request, handle, local=False)
|
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)
|
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 they're coming in looking for JSON, they want the actor
|
||||||
if request.ap_json:
|
if request.ap_json:
|
||||||
@ -66,6 +69,7 @@ class Individual(TemplateView):
|
|||||||
),
|
),
|
||||||
in_reply_to=self.post_obj.object_uri,
|
in_reply_to=self.post_obj.object_uri,
|
||||||
)
|
)
|
||||||
|
.exclude(author__restriction=Identity.Restriction.blocked)
|
||||||
.distinct()
|
.distinct()
|
||||||
.select_related("author__domain")
|
.select_related("author__domain")
|
||||||
.prefetch_related("emojis")
|
.prefetch_related("emojis")
|
||||||
|
@ -7,6 +7,7 @@ from django.views.generic import FormView, ListView
|
|||||||
from activities.models import Hashtag, Post, PostInteraction, TimelineEvent
|
from activities.models import Hashtag, Post, PostInteraction, TimelineEvent
|
||||||
from core.decorators import cache_page
|
from core.decorators import cache_page
|
||||||
from users.decorators import identity_required
|
from users.decorators import identity_required
|
||||||
|
from users.models import Identity
|
||||||
|
|
||||||
from .compose import Compose
|
from .compose import Compose
|
||||||
|
|
||||||
@ -75,6 +76,7 @@ class Tag(ListView):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
Post.objects.public()
|
Post.objects.public()
|
||||||
|
.filter(author__restriction=Identity.Restriction.none)
|
||||||
.tagged_with(self.hashtag)
|
.tagged_with(self.hashtag)
|
||||||
.select_related("author")
|
.select_related("author")
|
||||||
.prefetch_related("attachments", "mentions")
|
.prefetch_related("attachments", "mentions")
|
||||||
@ -105,6 +107,7 @@ class Local(ListView):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
Post.objects.local_public()
|
Post.objects.local_public()
|
||||||
|
.filter(author__restriction=Identity.Restriction.none)
|
||||||
.select_related("author", "author__domain")
|
.select_related("author", "author__domain")
|
||||||
.prefetch_related("attachments", "mentions", "emojis")
|
.prefetch_related("attachments", "mentions", "emojis")
|
||||||
.order_by("-created")
|
.order_by("-created")
|
||||||
@ -133,6 +136,7 @@ class Federated(ListView):
|
|||||||
Post.objects.filter(
|
Post.objects.filter(
|
||||||
visibility=Post.Visibilities.public, in_reply_to__isnull=True
|
visibility=Post.Visibilities.public, in_reply_to__isnull=True
|
||||||
)
|
)
|
||||||
|
.filter(author__restriction=Identity.Restriction.none)
|
||||||
.select_related("author", "author__domain")
|
.select_related("author", "author__domain")
|
||||||
.prefetch_related("attachments", "mentions", "emojis")
|
.prefetch_related("attachments", "mentions", "emojis")
|
||||||
.order_by("-created")
|
.order_by("-created")
|
||||||
|
@ -48,7 +48,9 @@ def account_relationships(request):
|
|||||||
@api_router.get("/v1/accounts/{id}", response=schemas.Account)
|
@api_router.get("/v1/accounts/{id}", response=schemas.Account)
|
||||||
@identity_required
|
@identity_required
|
||||||
def account(request, id: str):
|
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()
|
return identity.to_mastodon_json()
|
||||||
|
|
||||||
|
|
||||||
@ -67,7 +69,9 @@ def account_statuses(
|
|||||||
min_id: str | None = None,
|
min_id: str | None = None,
|
||||||
limit: int = 20,
|
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 = (
|
queryset = (
|
||||||
identity.posts.not_hidden()
|
identity.posts.not_hidden()
|
||||||
.unlisted(include_replies=not exclude_replies)
|
.unlisted(include_replies=not exclude_replies)
|
||||||
|
@ -3,6 +3,7 @@ from api import schemas
|
|||||||
from api.decorators import identity_required
|
from api.decorators import identity_required
|
||||||
from api.pagination import MastodonPaginator
|
from api.pagination import MastodonPaginator
|
||||||
from api.views.base import api_router
|
from api.views.base import api_router
|
||||||
|
from users.models import Identity
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/v1/timelines/home", response=list[schemas.Status])
|
@api_router.get("/v1/timelines/home", response=list[schemas.Status])
|
||||||
@ -52,6 +53,7 @@ def public(
|
|||||||
):
|
):
|
||||||
queryset = (
|
queryset = (
|
||||||
Post.objects.public()
|
Post.objects.public()
|
||||||
|
.filter(author__restriction=Identity.Restriction.none)
|
||||||
.select_related("author")
|
.select_related("author")
|
||||||
.prefetch_related("attachments")
|
.prefetch_related("attachments")
|
||||||
.order_by("-created")
|
.order_by("-created")
|
||||||
@ -90,6 +92,7 @@ def hashtag(
|
|||||||
limit = 40
|
limit = 40
|
||||||
queryset = (
|
queryset = (
|
||||||
Post.objects.public()
|
Post.objects.public()
|
||||||
|
.filter(author__restriction=Identity.Restriction.none)
|
||||||
.tagged_with(hashtag)
|
.tagged_with(hashtag)
|
||||||
.select_related("author")
|
.select_related("author")
|
||||||
.prefetch_related("attachments")
|
.prefetch_related("attachments")
|
||||||
|
@ -18,6 +18,7 @@ in alpha. For more information about Takahē, see
|
|||||||
features
|
features
|
||||||
contributing
|
contributing
|
||||||
domains
|
domains
|
||||||
|
moderation
|
||||||
stator
|
stator
|
||||||
tuning
|
tuning
|
||||||
releases/index
|
releases/index
|
||||||
|
99
docs/moderation.rst
Normal file
99
docs/moderation.rst
Normal file
@ -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.
|
@ -307,6 +307,14 @@ nav a i {
|
|||||||
margin: 0 0 10px 0;
|
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 {
|
.left-column h2 {
|
||||||
margin: 10px 0 10px 0;
|
margin: 10px 0 10px 0;
|
||||||
}
|
}
|
||||||
@ -642,10 +650,15 @@ form .uploaded-image .buttons {
|
|||||||
}
|
}
|
||||||
|
|
||||||
form .buttons {
|
form .buttons {
|
||||||
|
clear: both;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
margin: -20px 0 15px 0;
|
margin: -20px 0 15px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form .buttons:nth-of-type(2) {
|
||||||
|
padding-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
form p+.buttons,
|
form p+.buttons,
|
||||||
form fieldset .buttons {
|
form fieldset .buttons {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@ -794,14 +807,15 @@ h1.identity small {
|
|||||||
|
|
||||||
table.metadata {
|
table.metadata {
|
||||||
margin: -10px 0 0 0;
|
margin: -10px 0 0 0;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.metadata td {
|
table.metadata td {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.metadata td.name {
|
table.metadata th {
|
||||||
padding-right: 10px;
|
padding: 0 10px 0 0;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,9 +106,14 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"admin/identities/",
|
"admin/identities/",
|
||||||
admin.Identities.as_view(),
|
admin.IdentitiesRoot.as_view(),
|
||||||
name="admin_identities",
|
name="admin_identities",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"admin/identities/<id>/",
|
||||||
|
admin.IdentityEdit.as_view(),
|
||||||
|
name="admin_identity_edit",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"admin/invites/",
|
"admin/invites/",
|
||||||
admin.Invites.as_view(),
|
admin.Invites.as_view(),
|
||||||
|
@ -3,7 +3,50 @@
|
|||||||
{% block subtitle %}Identities{% endblock %}
|
{% block subtitle %}Identities{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p>
|
<form action="." class="search">
|
||||||
Please use the <a href="/djadmin/users/identity/">Django Admin</a> for now.
|
<input type="search" name="query" value="{{ query }}" placeholder="Search by name/username">
|
||||||
</p>
|
{% 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 %}&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 %}&local_only=true{% endif %}{% if query %}&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 %}&local_only=true{% endif %}{% if query %}&query={{ query }}{% endif %}">Next Page</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
123
templates/admin/identity_edit.html
Normal file
123
templates/admin/identity_edit.html
Normal file
@ -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 %}
|
@ -6,8 +6,20 @@
|
|||||||
{% for model, stats in model_stats.items %}
|
{% for model, stats in model_stats.items %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{{ model }}</legend>
|
<legend>{{ model }}</legend>
|
||||||
<p><b>Pending:</b> {{ stats.most_recent_queued }}</p>
|
<table class="metadata">
|
||||||
<p><b>Processed today:</b> {{ stats.most_recent_handled.1 }}</p>
|
<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>
|
</fieldset>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{% extends "settings/base.html" %}
|
{% extends "settings/base.html" %}
|
||||||
|
|
||||||
{% block subtitle %}{{ user.email }}{% endblock %}
|
{% block subtitle %}{{ editing_user.email }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{{ editing_user.email }}</h1>
|
<h1>{{ editing_user.email }}</h1>
|
||||||
|
@ -66,11 +66,11 @@
|
|||||||
<table class="metadata">
|
<table class="metadata">
|
||||||
{% for entry in identity.safe_metadata %}
|
{% for entry in identity.safe_metadata %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="name">{{ entry.name }}</td>
|
<th>{{ entry.name }}</td>
|
||||||
<td class="value">{{ entry.value }}</td>
|
<td>{{ entry.value }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if identity.config_identity.visible_follows %}
|
{% if identity.config_identity.visible_follows %}
|
||||||
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
@ -55,6 +55,11 @@ class Identity(StatorModel):
|
|||||||
Represents both local and remote Fediverse identities (actors)
|
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
|
# The Actor URI is essentially also a PK - we keep the default numeric
|
||||||
# one around as well for making nice URLs etc.
|
# one around as well for making nice URLs etc.
|
||||||
actor_uri = models.CharField(max_length=500, unique=True)
|
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)
|
# Should be a list of object URIs (we don't want a full M2M here)
|
||||||
pinned = models.JSONField(blank=True, null=True)
|
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)
|
private_key = models.TextField(null=True, blank=True)
|
||||||
public_key = models.TextField(null=True, blank=True)
|
public_key = models.TextField(null=True, blank=True)
|
||||||
public_key_id = 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}/"
|
view = "/@{self.username}@{self.domain_id}/"
|
||||||
action = "{view}action/"
|
action = "{view}action/"
|
||||||
activate = "{view}activate/"
|
activate = "{view}activate/"
|
||||||
|
admin = "/admin/identities/"
|
||||||
|
admin_edit = "{admin}{self.pk}/"
|
||||||
|
|
||||||
def get_scheme(self, url):
|
def get_scheme(self, url):
|
||||||
return "https"
|
return "https"
|
||||||
@ -197,9 +211,16 @@ class Identity(StatorModel):
|
|||||||
domain = domain.lower()
|
domain = domain.lower()
|
||||||
try:
|
try:
|
||||||
if local:
|
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:
|
else:
|
||||||
return cls.objects.get(username=username, domain_id=domain)
|
return cls.objects.get(
|
||||||
|
username=username,
|
||||||
|
domain_id=domain,
|
||||||
|
)
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
if fetch and not local:
|
if fetch and not local:
|
||||||
actor_uri, handle = async_to_sync(cls.fetch_webfinger)(
|
actor_uri, handle = async_to_sync(cls.fetch_webfinger)(
|
||||||
@ -277,6 +298,14 @@ class Identity(StatorModel):
|
|||||||
# TODO: Setting
|
# TODO: Setting
|
||||||
return self.data_age > 60 * 24 * 24
|
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) ###
|
### ActivityPub (outbound) ###
|
||||||
|
|
||||||
def to_ap(self):
|
def to_ap(self):
|
||||||
|
@ -31,4 +31,6 @@ def by_handle_or_404(request, handle, local=True, fetch=False) -> Identity:
|
|||||||
)
|
)
|
||||||
if identity is None:
|
if identity is None:
|
||||||
raise Http404(f"No identity for handle {handle}")
|
raise Http404(f"No identity for handle {handle}")
|
||||||
|
if identity.blocked:
|
||||||
|
raise Http404("Blocked user")
|
||||||
return identity
|
return identity
|
||||||
|
@ -165,11 +165,12 @@ class Inbox(View):
|
|||||||
f"Inbox error: cannot fetch actor {document['actor']}"
|
f"Inbox error: cannot fetch actor {document['actor']}"
|
||||||
)
|
)
|
||||||
return HttpResponseBadRequest("Cannot retrieve 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!
|
# I love to lie! Throw it away!
|
||||||
exceptions.capture_message(
|
exceptions.capture_message(
|
||||||
f"Inbox: Discarded message from {identity.domain}"
|
f"Inbox: Discarded message from {identity.actor_uri}"
|
||||||
)
|
)
|
||||||
return HttpResponse(status=202)
|
return HttpResponse(status=202)
|
||||||
|
|
||||||
@ -185,6 +186,7 @@ class Inbox(View):
|
|||||||
except VerificationError:
|
except VerificationError:
|
||||||
exceptions.capture_message("Inbox error: Bad LD signature")
|
exceptions.capture_message("Inbox error: Bad LD signature")
|
||||||
return HttpResponseUnauthorized("Bad signature")
|
return HttpResponseUnauthorized("Bad signature")
|
||||||
|
|
||||||
# Otherwise, verify against the header (assuming it's the same actor)
|
# Otherwise, verify against the header (assuming it's the same actor)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@ -200,6 +202,7 @@ class Inbox(View):
|
|||||||
except VerificationError:
|
except VerificationError:
|
||||||
exceptions.capture_message("Inbox error: Bad HTTP signature")
|
exceptions.capture_message("Inbox error: Bad HTTP signature")
|
||||||
return HttpResponseUnauthorized("Bad signature")
|
return HttpResponseUnauthorized("Bad signature")
|
||||||
|
|
||||||
# Hand off the item to be processed by the queue
|
# Hand off the item to be processed by the queue
|
||||||
InboxMessage.objects.create(message=document)
|
InboxMessage.objects.create(message=document)
|
||||||
return HttpResponse(status=202)
|
return HttpResponse(status=202)
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.decorators import method_decorator
|
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.decorators import admin_required
|
||||||
from users.models import Identity
|
|
||||||
from users.views.admin.domains import ( # noqa
|
from users.views.admin.domains import ( # noqa
|
||||||
DomainCreate,
|
DomainCreate,
|
||||||
DomainDelete,
|
DomainDelete,
|
||||||
@ -17,6 +16,7 @@ from users.views.admin.hashtags import ( # noqa
|
|||||||
HashtagEdit,
|
HashtagEdit,
|
||||||
Hashtags,
|
Hashtags,
|
||||||
)
|
)
|
||||||
|
from users.views.admin.identities import IdentitiesRoot, IdentityEdit # noqa
|
||||||
from users.views.admin.settings import ( # noqa
|
from users.views.admin.settings import ( # noqa
|
||||||
BasicSettings,
|
BasicSettings,
|
||||||
PoliciesSettings,
|
PoliciesSettings,
|
||||||
@ -31,18 +31,6 @@ class AdminRoot(RedirectView):
|
|||||||
pattern_name = "admin_basic"
|
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")
|
@method_decorator(admin_required, name="dispatch")
|
||||||
class Invites(FormView):
|
class Invites(FormView):
|
||||||
|
|
||||||
|
90
users/views/admin/identities.py
Normal file
90
users/views/admin/identities.py
Normal file
@ -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
|
@ -12,7 +12,7 @@ from users.models import User
|
|||||||
class UsersRoot(ListView):
|
class UsersRoot(ListView):
|
||||||
|
|
||||||
template_name = "admin/users.html"
|
template_name = "admin/users.html"
|
||||||
paginate_by = 50
|
paginate_by = 30
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
self.query = request.GET.get("query")
|
self.query = request.GET.get("query")
|
||||||
|
Reference in New Issue
Block a user