diff --git a/activities/models/post.py b/activities/models/post.py index d6914c9..553118a 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -251,6 +251,7 @@ class Post(StatorModel): action_unboost = "{view}unboost/" action_delete = "{view}delete/" action_edit = "{view}edit/" + action_report = "{view}report/" action_reply = "/compose/?reply_to={self.id}" admin_edit = "/djadmin/activities/post/{self.id}/change/" diff --git a/static/css/style.css b/static/css/style.css index f525857..68efbdc 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -709,7 +709,9 @@ button, } button.delete, -.button.delete { +.button.delete, +button.danger, +.button.danger { background: var(--color-delete); } @@ -833,6 +835,20 @@ table.metadata th { font-weight: bold; } +table.buttons { + margin: -10px 0 10px 0; + text-align: left; +} + +table.buttons th { + padding: 5px 20px 5px 0; + text-align: center; +} + +table.buttons th button { + width: 100%; +} + .stats { margin: 0 0 20px 0; } diff --git a/takahe/urls.py b/takahe/urls.py index 6ae0c88..d3572a9 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -7,7 +7,7 @@ from api.views import api_router, oauth from core import views as core from mediaproxy import views as mediaproxy from stator import views as stator -from users.views import activitypub, admin, auth, identity, settings +from users.views import activitypub, admin, auth, identity, report, settings urlpatterns = [ path("", core.homepage), @@ -114,6 +114,16 @@ urlpatterns = [ admin.IdentityEdit.as_view(), name="admin_identity_edit", ), + path( + "admin/reports/", + admin.ReportsRoot.as_view(), + name="admin_reports", + ), + path( + "admin/reports//", + admin.ReportView.as_view(), + name="admin_report_view", + ), path( "admin/invites/", admin.Invites.as_view(), @@ -147,6 +157,7 @@ urlpatterns = [ path("@/inbox/", activitypub.Inbox.as_view()), path("@/action/", identity.ActionIdentity.as_view()), path("@/rss/", identity.IdentityFeed()), + path("@/report/", report.SubmitReport.as_view()), # Posts path("compose/", compose.Compose.as_view(), name="compose"), path( @@ -160,6 +171,7 @@ urlpatterns = [ path("@/posts//boost/", posts.Boost.as_view()), path("@/posts//unboost/", posts.Boost.as_view(undo=True)), path("@/posts//delete/", posts.Delete.as_view()), + path("@/posts//report/", report.SubmitReport.as_view()), path("@/posts//edit/", compose.Compose.as_view()), # Authentication path("auth/login/", auth.Login.as_view(), name="login"), diff --git a/templates/activities/_post.html b/templates/activities/_post.html index 5d75b78..990e457 100644 --- a/templates/activities/_post.html +++ b/templates/activities/_post.html @@ -37,6 +37,9 @@ View Post & Replies + + Report + {% if post.author == request.identity %} Edit diff --git a/templates/admin/report_view.html b/templates/admin/report_view.html new file mode 100644 index 0000000..c9819b4 --- /dev/null +++ b/templates/admin/report_view.html @@ -0,0 +1,84 @@ +{% extends "settings/base.html" %} + +{% block subtitle %}Report {{ report.pk }}{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+ Report + + {% if report.subject_post %} + {% include "activities/_mini_post.html" with post=report.subject_post %} + {% else %} + {% include "activities/_identity.html" with identity=report.subject_identity %} + {% endif %} + + {% if report.source_identity %} + {% include "activities/_identity.html" with identity=report.source_identity %} + {% else %} +

Remote server {{ report.source_domain.domain }}

+ {% endif %} + +

{{ report.complaint|linebreaks }}

+ {% if report.resolved %} + +

+ {{ report.resolved|timesince }} ago by + {{ report.moderator.name_or_handle }} +

+ {% endif %} +
+
+ Moderator Notes + {% include "forms/_field.html" with field=form.notes %} +
+
+ Resolution Options + + + {% if report.resolved and report.valid %} + + + {% else %} + + + {% endif %} + + + {% if report.resolved and not report.valid %} + + + {% else %} + + + {% endif %} + + + {% if report.subject_identity.limited %} + + + {% else %} + + + {% endif %} + + + {% if report.subject_identity.blocked %} + + + {% else %} + + + {% endif %} + +
Report is already resolved as validMark report against the identity but take no further action
Report is already resolved as invalidMark report as invalid and take no action
User is already limitedMake them less visible on this server
User is already blockedRemove their existence entirely from this server
+
+
+ Back + View Profile + Identity Admin + +
+
+{% endblock %} diff --git a/templates/admin/reports.html b/templates/admin/reports.html new file mode 100644 index 0000000..1634443 --- /dev/null +++ b/templates/admin/reports.html @@ -0,0 +1,43 @@ +{% extends "settings/base.html" %} +{% load activity_tags %} + +{% block subtitle %}Reports{% endblock %} + +{% block content %} +
+ {% if all %} + Show Resolved + {% else %} + Show Resolved + {% endif %} +
+
+ {% for report in page_obj %} + + Avatar for {{ report.subject_identity.name_or_handle }} + + {{ report.subject_identity.html_name_or_handle }} + {% if report.subject_post %} + (post {{ report.subject_post.pk }}) + {% endif %} + + {{ report.type|title }} + + + + + {% empty %} +

+ There are no {% if all %}reports yet{% else %}unresolved reports{% endif %}. +

+ {% endfor %} +
+ {% if page_obj.has_previous %} + Previous Page + {% endif %} + {% if page_obj.has_next %} + Next Page + {% endif %} +
+
+{% endblock %} diff --git a/templates/settings/_menu.html b/templates/settings/_menu.html index 0a6062d..45b7ee3 100644 --- a/templates/settings/_menu.html +++ b/templates/settings/_menu.html @@ -39,8 +39,8 @@ Hashtags - - Tuning + + Reports Stator diff --git a/templates/users/report.html b/templates/users/report.html new file mode 100644 index 0000000..557b35e --- /dev/null +++ b/templates/users/report.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block title %}Report{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+ Report + + {% if post %} + {% include "activities/_mini_post.html" %} + {% else %} + {% include "activities/_identity.html" %} + {% endif %} + {% include "forms/_field.html" with field=form.type %} + {% include "forms/_field.html" with field=form.complaint %} + {% if not identity.local %} + {% include "forms/_field.html" with field=form.forward %} + {% endif %} +
+ +
+ +
+
+{% endblock %} diff --git a/templates/users/report_sent.html b/templates/users/report_sent.html new file mode 100644 index 0000000..4e8c78c --- /dev/null +++ b/templates/users/report_sent.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Report Sent{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+ Report +

Your report has been sent.

+
+
+{% endblock %} diff --git a/users/admin.py b/users/admin.py index 6c89881..e780b16 100644 --- a/users/admin.py +++ b/users/admin.py @@ -9,6 +9,7 @@ from users.models import ( InboxMessage, Invite, PasswordReset, + Report, User, UserEvent, ) @@ -113,3 +114,8 @@ class InboxMessageAdmin(admin.ModelAdmin): @admin.register(Invite) class InviteAdmin(admin.ModelAdmin): list_display = ["id", "created", "token", "note"] + + +@admin.register(Report) +class ReportAdmin(admin.ModelAdmin): + list_display = ["id", "created", "resolved", "type", "subject_identity"] diff --git a/users/migrations/0005_report.py b/users/migrations/0005_report.py new file mode 100644 index 0000000..a4556b2 --- /dev/null +++ b/users/migrations/0005_report.py @@ -0,0 +1,119 @@ +# Generated by Django 4.1.4 on 2022-12-17 20:38 + +import django.db.models.deletion +from django.db import migrations, models + +import stator.models +import users.models.report + + +class Migration(migrations.Migration): + + dependencies = [ + ("activities", "0004_emoji_post_emojis"), + ("users", "0004_identity_admin_notes_identity_restriction_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Report", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state_ready", models.BooleanField(default=True)), + ("state_changed", models.DateTimeField(auto_now_add=True)), + ("state_attempted", models.DateTimeField(blank=True, null=True)), + ("state_locked_until", models.DateTimeField(blank=True, null=True)), + ( + "state", + stator.models.StateField( + choices=[("new", "new"), ("sent", "sent")], + default="new", + graph=users.models.report.ReportStates, + max_length=100, + ), + ), + ( + "type", + models.CharField( + choices=[ + ("spam", "Spam"), + ("hateful", "Hateful"), + ("illegal", "Illegal"), + ("remote", "Remote"), + ("other", "Other"), + ], + max_length=100, + ), + ), + ("complaint", models.TextField()), + ("forward", models.BooleanField(default=False)), + ("valid", models.BooleanField(null=True)), + ("seen", models.DateTimeField(blank=True, null=True)), + ("resolved", models.DateTimeField(blank=True, null=True)), + ("notes", models.TextField(blank=True, null=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "moderator", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="moderated_reports", + to="users.identity", + ), + ), + ( + "source_domain", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="filed_reports", + to="users.domain", + ), + ), + ( + "source_identity", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="filed_reports", + to="users.identity", + ), + ), + ( + "subject_identity", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="reports", + to="users.identity", + ), + ), + ( + "subject_post", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reports", + to="activities.post", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/users/models/__init__.py b/users/models/__init__.py index 1c5f519..4e271ba 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -5,6 +5,7 @@ from .identity import Identity, IdentityStates # noqa from .inbox_message import InboxMessage, InboxMessageStates # noqa from .invite import Invite # noqa from .password_reset import PasswordReset # noqa +from .report import Report # noqa from .system_actor import SystemActor # noqa from .user import User # noqa from .user_event import UserEvent # noqa diff --git a/users/models/report.py b/users/models/report.py new file mode 100644 index 0000000..7bafd0c --- /dev/null +++ b/users/models/report.py @@ -0,0 +1,129 @@ +import httpx +import urlman +from django.db import models + +from core.ld import canonicalise +from stator.models import State, StateField, StateGraph, StatorModel +from users.models import SystemActor + + +class ReportStates(StateGraph): + new = State(try_interval=600) + sent = State() + + new.transitions_to(sent) + + @classmethod + async def handle_new(cls, instance: "Report"): + """ + Sends the report to the remote server if we need to + """ + report = await instance.afetch_full() + if report.forward and not report.subject_identity.domain.local: + system_actor = SystemActor() + try: + await system_actor.signed_request( + method="post", + uri=report.subject_identity.inbox_uri, + body=canonicalise(report.to_ap()), + ) + except httpx.RequestError: + return + return cls.sent + + +class Report(StatorModel): + """ + A complaint about a user or post. + """ + + class Types(models.TextChoices): + spam = "spam" + hateful = "hateful" + illegal = "illegal" + remote = "remote" + other = "other" + + state = StateField(ReportStates) + + subject_identity = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="reports", + ) + subject_post = models.ForeignKey( + "activities.Post", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="reports", + ) + + source_identity = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="filed_reports", + ) + source_domain = models.ForeignKey( + "users.Domain", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="filed_reports", + ) + + moderator = models.ForeignKey( + "users.Identity", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="moderated_reports", + ) + + type = models.CharField(max_length=100, choices=Types.choices) + complaint = models.TextField() + forward = models.BooleanField(default=False) + valid = models.BooleanField(null=True) + + seen = models.DateTimeField(blank=True, null=True) + resolved = models.DateTimeField(blank=True, null=True) + notes = models.TextField(blank=True, null=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + class urls(urlman.Urls): + admin = "/admin/reports/" + admin_view = "{admin}{self.pk}/" + + ### ActivityPub ### + + async def afetch_full(self) -> "Report": + return await Report.objects.select_related( + "source_identity", + "source_domain", + "subject_identity__domain", + "subject_identity", + "subject_post", + ).aget(pk=self.pk) + + def to_ap(self): + system_actor = SystemActor() + if self.subject_post: + objects = [ + self.subject_post.object_uri, + self.subject_identity.actor_uri, + ] + else: + objects = self.subject_identity.actor_uri + return { + "id": f"https://{self.source_domain.uri_domain}/reports/{self.id}/", + "type": "Flag", + "actor": system_actor.actor_uri, + "object": objects, + "content": self.complaint, + } diff --git a/users/views/admin/__init__.py b/users/views/admin/__init__.py index d923f80..0e054d2 100644 --- a/users/views/admin/__init__.py +++ b/users/views/admin/__init__.py @@ -17,6 +17,7 @@ from users.views.admin.hashtags import ( # noqa Hashtags, ) from users.views.admin.identities import IdentitiesRoot, IdentityEdit # noqa +from users.views.admin.reports import ReportsRoot, ReportView # noqa from users.views.admin.settings import ( # noqa BasicSettings, PoliciesSettings, diff --git a/users/views/admin/reports.py b/users/views/admin/reports.py new file mode 100644 index 0000000..6187068 --- /dev/null +++ b/users/views/admin/reports.py @@ -0,0 +1,80 @@ +from django import forms +from django.shortcuts import get_object_or_404, redirect +from django.utils import timezone +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, Report + + +@method_decorator(admin_required, name="dispatch") +class ReportsRoot(ListView): + + template_name = "admin/reports.html" + paginate_by = 30 + + def get(self, request, *args, **kwargs): + self.query = request.GET.get("query") + self.all = request.GET.get("all") + self.extra_context = { + "section": "reports", + "all": self.all, + } + return super().get(request, *args, **kwargs) + + def get_queryset(self): + reports = Report.objects.select_related( + "subject_post", "subject_identity" + ).order_by("created") + if not self.all: + reports = reports.filter(resolved__isnull=True) + return reports + + +@method_decorator(admin_required, name="dispatch") +class ReportView(FormView): + + template_name = "admin/report_view.html" + extra_context = { + "section": "reports", + } + + class form_class(forms.Form): + notes = forms.CharField(widget=forms.Textarea, required=False) + + def dispatch(self, request, id, *args, **kwargs): + self.report = get_object_or_404(Report, id=id) + return super().dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + if "limit" in request.POST: + self.report.subject_identity.restriction = Identity.Restriction.limited + self.report.subject_identity.save() + if "block" in request.POST: + self.report.subject_identity.restriction = Identity.Restriction.blocked + self.report.subject_identity.save() + if "valid" in request.POST: + self.report.resolved = timezone.now() + self.report.valid = True + self.report.moderator = self.request.identity + self.report.save() + if "invalid" in request.POST: + self.report.resolved = timezone.now() + self.report.valid = False + self.report.moderator = self.request.identity + self.report.save() + return super().post(request, *args, **kwargs) + + def get_initial(self): + return {"notes": self.report.notes} + + def form_valid(self, form): + self.report.notes = form.cleaned_data["notes"] + self.report.save() + return redirect(".") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["report"] = self.report + return context diff --git a/users/views/report.py b/users/views/report.py new file mode 100644 index 0000000..f7e29ed --- /dev/null +++ b/users/views/report.py @@ -0,0 +1,76 @@ +from django import forms +from django.shortcuts import get_object_or_404, render +from django.utils.decorators import method_decorator +from django.views.generic import FormView + +from users.decorators import identity_required +from users.models import Report +from users.shortcuts import by_handle_or_404 + + +@method_decorator(identity_required, name="dispatch") +class SubmitReport(FormView): + """ + Submits a report on a user or a post + """ + + template_name = "users/report.html" + + class form_class(forms.Form): + type = forms.ChoiceField( + choices=[ + ("", "------"), + ("spam", "Spam or inappropriate advertising"), + ("hateful", "Hateful, abusive, or violent speech"), + ("other", "Something else"), + ], + label="Why are you reporting this?", + ) + + complaint = forms.CharField( + widget=forms.Textarea, + help_text="Please describe why you think this should be removed", + ) + + forward = forms.BooleanField( + widget=forms.Select( + choices=[ + (False, "Do not send to other server"), + (True, "Send to other server"), + ] + ), + help_text="Should we also send an anonymous copy of this to their server?", + required=False, + ) + + def dispatch(self, request, handle, post_id=None): + self.identity = by_handle_or_404(self.request, handle, local=False) + if post_id: + self.post_obj = get_object_or_404(self.identity.posts, pk=post_id) + else: + self.post_obj = None + return super().dispatch(request) + + def form_valid(self, form): + # Create the report + report = Report.objects.create( + type=form.cleaned_data["type"], + complaint=form.cleaned_data["complaint"], + subject_identity=self.identity, + subject_post=self.post_obj, + source_identity=self.request.identity, + source_domain=self.request.identity.domain, + forward=form.cleaned_data.get("forward", False), + ) + # Show a thanks page + return render( + self.request, + "users/report_sent.html", + {"report": report}, + ) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context["identity"] = self.identity + context["post"] = self.post_obj + return context