diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index 865bc02f..7ae4e446 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -495,7 +495,7 @@ class GroupForm(CustomForm): class ReportForm(CustomForm): class Meta: model = models.Report - fields = ["user", "reporter", "statuses", "links", "note"] + fields = ["user", "reporter", "status", "links", "note"] class EmailBlocklistForm(CustomForm): @@ -550,3 +550,9 @@ class ReadThroughForm(CustomForm): class Meta: model = models.ReadThrough fields = ["user", "book", "start_date", "finish_date"] + + +class AutoModRuleForm(CustomForm): + class Meta: + model = models.AutoMod + fields = ["string_match", "flag_users", "flag_statuses", "created_by"] diff --git a/bookwyrm/migrations/0138_automod.py b/bookwyrm/migrations/0138_automod.py new file mode 100644 index 00000000..691ea94b --- /dev/null +++ b/bookwyrm/migrations/0138_automod.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.12 on 2022-02-24 18:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0137_alter_sitesettings_allow_registration"), + ] + + operations = [ + migrations.CreateModel( + name="AutoMod", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("string_match", models.CharField(max_length=200, unique=True)), + ("flag_users", models.BooleanField(default=True)), + ("flag_statuses", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/bookwyrm/migrations/0139_report_status.py b/bookwyrm/migrations/0139_report_status.py new file mode 100644 index 00000000..c85a43b8 --- /dev/null +++ b/bookwyrm/migrations/0139_report_status.py @@ -0,0 +1,45 @@ +# Generated by Django 3.2.12 on 2022-02-24 20:41 + +from django.db import migrations, models +import django.db.models.deletion + + +def set_report_statuses(apps, schema_editor): + """copy over status fields""" + db_alias = schema_editor.connection.alias + report_model = apps.get_model("bookwyrm", "Report") + reports = report_model.objects.using(db_alias).filter(statuses__isnull=False) + for report in reports: + report.status = report.statuses.first() + report.save() + + +def set_reverse(apps, schema_editor): + """copy over status fields""" + db_alias = schema_editor.connection.alias + report_model = apps.get_model("bookwyrm", "Report") + reports = report_model.objects.using(db_alias).filter(status__isnull=False) + for report in reports: + report.statuses.set(report.status) + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0138_automod"), + ] + + operations = [ + migrations.AddField( + model_name="report", + name="status", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="reports", + to="bookwyrm.status", + ), + ), + migrations.RunPython(set_report_statuses, reverse_code=set_reverse), + ] diff --git a/bookwyrm/migrations/0140_remove_report_statuses.py b/bookwyrm/migrations/0140_remove_report_statuses.py new file mode 100644 index 00000000..57651c3b --- /dev/null +++ b/bookwyrm/migrations/0140_remove_report_statuses.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.12 on 2022-02-24 20:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0139_report_status"), + ] + + operations = [ + migrations.RemoveField( + model_name="report", + name="statuses", + ), + ] diff --git a/bookwyrm/migrations/0141_alter_report_status.py b/bookwyrm/migrations/0141_alter_report_status.py new file mode 100644 index 00000000..58102dd5 --- /dev/null +++ b/bookwyrm/migrations/0141_alter_report_status.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.12 on 2022-02-24 20:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0140_remove_report_statuses"), + ] + + operations = [ + migrations.AlterField( + model_name="report", + name="status", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="bookwyrm.status", + ), + ), + ] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index 4c6305f9..440d18d9 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -29,7 +29,7 @@ from .import_job import ImportJob, ImportItem from .site import SiteSettings, SiteInvite from .site import PasswordReset, InviteRequest from .announcement import Announcement -from .antispam import EmailBlocklist, IPBlocklist +from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task from .notification import Notification diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py index 7a85bbcf..f506b6f1 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -1,6 +1,13 @@ """ Lets try NOT to sell viagra """ -from django.db import models +from functools import reduce +import operator +from django.apps import apps +from django.db import models +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + +from bookwyrm.tasks import app from .user import User @@ -33,3 +40,107 @@ class IPBlocklist(models.Model): """default sorting""" ordering = ("-created_date",) + + +class AutoMod(models.Model): + """rules to automatically flag suspicious activity""" + + string_match = models.CharField(max_length=200, unique=True) + flag_users = models.BooleanField(default=True) + flag_statuses = models.BooleanField(default=True) + created_by = models.ForeignKey("User", on_delete=models.PROTECT) + + +@app.task(queue="low_priority") +def automod_task(): + """Create reports""" + if not AutoMod.objects.exists(): + return + reporter = AutoMod.objects.first().created_by + reports = automod_users(reporter) + automod_statuses(reporter) + if reports: + admins = User.objects.filter( + models.Q(user_permissions__name__in=["moderate_user", "moderate_post"]) + | models.Q(is_superuser=True) + ).all() + notification_model = apps.get_model( + "bookwyrm", "Notification", require_ready=True + ) + for admin in admins: + notification_model.objects.bulk_create( + [ + notification_model( + user=admin, + related_report=r, + notification_type="REPORT", + ) + for r in reports + ] + ) + + +def automod_users(reporter): + """check users for moderation flags""" + user_rules = AutoMod.objects.filter(flag_users=True).values_list( + "string_match", flat=True + ) + if not user_rules: + return [] + + filters = [] + for field in ["username", "summary", "name"]: + filters += [{f"{field}__icontains": r} for r in user_rules] + users = User.objects.filter( + reduce(operator.or_, (Q(**f) for f in filters)), + is_active=True, + local=True, + report__isnull=True, # don't flag users that already have reports + ).distinct() + + report_model = apps.get_model("bookwyrm", "Report", require_ready=True) + + return report_model.objects.bulk_create( + [ + report_model( + reporter=reporter, + note=_("Automatically generated report"), + user=u, + ) + for u in users + ] + ) + + +def automod_statuses(reporter): + """check statues for moderation flags""" + status_rules = AutoMod.objects.filter(flag_statuses=True).values_list( + "string_match", flat=True + ) + + if not status_rules: + return [] + + filters = [] + for field in ["content", "content_warning", "quotation__quote", "review__name"]: + filters += [{f"{field}__icontains": r} for r in status_rules] + + status_model = apps.get_model("bookwyrm", "Status", require_ready=True) + statuses = status_model.objects.filter( + reduce(operator.or_, (Q(**f) for f in filters)), + deleted=False, + local=True, + report__isnull=True, # don't flag statuses that already have reports + ).distinct() + + report_model = apps.get_model("bookwyrm", "Report", require_ready=True) + return report_model.objects.bulk_create( + [ + report_model( + reporter=reporter, + note=_("Automatically generated report"), + user=s.user, + status=s, + ) + for s in statuses + ] + ) diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py index bd2a1ef0..bf3184f5 100644 --- a/bookwyrm/models/report.py +++ b/bookwyrm/models/report.py @@ -12,7 +12,12 @@ class Report(BookWyrmModel): ) note = models.TextField(null=True, blank=True) user = models.ForeignKey("User", on_delete=models.PROTECT) - statuses = models.ManyToManyField("Status", blank=True) + status = models.ForeignKey( + "Status", + null=True, + blank=True, + on_delete=models.PROTECT, + ) links = models.ManyToManyField("Link", blank=True) resolved = models.BooleanField(default=False) diff --git a/bookwyrm/templates/settings/automod/rules.html b/bookwyrm/templates/settings/automod/rules.html new file mode 100644 index 00000000..8205b3d7 --- /dev/null +++ b/bookwyrm/templates/settings/automod/rules.html @@ -0,0 +1,130 @@ +{% extends 'settings/layout.html' %} +{% load i18n %} +{% load utilities %} + +{% block title %} +{% trans "Auto-moderation rules" %} +{% endblock %} + +{% block header %} +{% trans "Auto-moderation rules" %} +{% endblock %} + +{% block panel %} + +
+

+ {% trans "Auto-moderation rules will create reports for any local user or status with fields matching the provided string." %} + {% trans "Users or statuses that have already been reported (regardless of whether the report was resolved) will not be flagged." %} + {% trans "At this time, reports are not being generated automatically, and you must manually trigger a scan." %} +

+
+ {% csrf_token %} + +
+
+ +{% if success %} +
+ + + {% trans "Successfully added rule" %} + +
+{% endif %} + +
+

{% trans "Add Rule" %}

+
+
+ {% csrf_token %} + + +
+
+
+ + {{ form.string_match }} + {% include 'snippets/form_errors.html' with errors_list=form.string_match.errors id="desc_string_match" %} +
+
+ +
+
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+

{% trans "Current Rules" %}

+
+ + + {% trans "Show rules" %} ({{ rules.count }}) + + + + +
+ + + + + + + + {% for rule in rules %} + + + + + + + {% endfor %} +
+ + + + + + +
+ {{ rule.string_match }} + + {{ rule.flag_users|yesno }} + + {{ rule.flag_statuses|yesno }} + +
+ {% csrf_token %} +
+ +
+
+
+
+
+
+{% endblock %} + diff --git a/bookwyrm/templates/settings/layout.html b/bookwyrm/templates/settings/layout.html index a74e111c..c5a3c5af 100644 --- a/bookwyrm/templates/settings/layout.html +++ b/bookwyrm/templates/settings/layout.html @@ -56,6 +56,10 @@ {% url 'settings-reports' as url %} {% trans "Reports" %} +
  • + {% url 'settings-automod' as url %} + {% trans "Auto-moderation rules" %} +
  • {% url 'settings-email-blocks' as url %} {% trans "Email Blocklist" %} diff --git a/bookwyrm/templates/settings/reports/report.html b/bookwyrm/templates/settings/reports/report.html index 3f83f016..20fdeca3 100644 --- a/bookwyrm/templates/settings/reports/report.html +++ b/bookwyrm/templates/settings/reports/report.html @@ -1,6 +1,7 @@ {% extends 'settings/layout.html' %} {% load i18n %} {% load humanize %} +{% load feed_page_tags %} {% block title %} {% include "settings/reports/report_header.html" with report=report %} @@ -30,20 +31,14 @@ -{% if report.statuses.exists %} +{% if report.status %}
    -

    {% trans "Reported statuses" %}

    - +

    {% trans "Reported status" %}

    + {% if report.status.deleted %} + {% trans "Status has been deleted" %} + {% else %} + {% include 'snippets/status/status.html' with status=report.status|load_subclass moderation_mode=True %} + {% endif %}
    {% endif %} diff --git a/bookwyrm/templates/settings/reports/report_header.html b/bookwyrm/templates/settings/reports/report_header.html index d76db104..878a825d 100644 --- a/bookwyrm/templates/settings/reports/report_header.html +++ b/bookwyrm/templates/settings/reports/report_header.html @@ -1,7 +1,7 @@ {% load i18n %} {% load utilities %} -{% if report.statuses.exists %} +{% if report.status %} {% blocktrans trimmed with report_id=report.id username=report.user|username %} Report #{{ report_id }}: Status posted by @{{ username }} diff --git a/bookwyrm/templates/snippets/report_button.html b/bookwyrm/templates/snippets/report_button.html index 9d94d2af..60b542f4 100644 --- a/bookwyrm/templates/snippets/report_button.html +++ b/bookwyrm/templates/snippets/report_button.html @@ -12,6 +12,6 @@ > {% trans "Report" %} -{% include 'snippets/report_modal.html' with user=user id=modal_id status=status.id %} +{% include 'snippets/report_modal.html' with user=user id=modal_id status_id=status.id %} {% endwith %} diff --git a/bookwyrm/templates/snippets/report_modal.html b/bookwyrm/templates/snippets/report_modal.html index f65cab59..64e0c298 100644 --- a/bookwyrm/templates/snippets/report_modal.html +++ b/bookwyrm/templates/snippets/report_modal.html @@ -23,7 +23,7 @@ {% if status_id %} - + {% endif %} {% if link %} diff --git a/bookwyrm/tests/models/test_automod.py b/bookwyrm/tests/models/test_automod.py new file mode 100644 index 00000000..abb9aa55 --- /dev/null +++ b/bookwyrm/tests/models/test_automod.py @@ -0,0 +1,75 @@ +""" test for app action functionality """ +from unittest.mock import patch + +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models +from bookwyrm.models.antispam import automod_task + + +@patch("bookwyrm.models.Status.broadcast") +@patch("bookwyrm.activitystreams.add_status_task.delay") +@patch("bookwyrm.activitystreams.remove_status_task.delay") +class AutomodModel(TestCase): + """every response to a get request, html or json""" + + def setUp(self): + """we need basic test data and mocks""" + self.factory = RequestFactory() + with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch( + "bookwyrm.activitystreams.populate_stream_task.delay" + ), patch("bookwyrm.lists_stream.populate_lists_task.delay"): + self.local_user = models.User.objects.create_user( + "mouse@local.com", + "mouse@mouse.mouse", + "password", + local=True, + localname="mouse", + ) + + def test_automod_task_no_rules(self, *_): + """nothing to see here""" + self.assertFalse(models.Report.objects.exists()) + automod_task() + self.assertFalse(models.Report.objects.exists()) + + def test_automod_task_user(self, *_): + """scan activity""" + self.assertFalse(models.Report.objects.exists()) + models.AutoMod.objects.create( + string_match="hi", + flag_users=True, + flag_statuses=True, + created_by=self.local_user, + ) + + self.local_user.name = "okay hi" + self.local_user.save(broadcast=False, update_fields=["name"]) + + automod_task() + + reports = models.Report.objects.all() + self.assertEqual(reports.count(), 1) + self.assertEqual(reports.first().user, self.local_user) + + def test_automod_status(self, *_): + """scan activity""" + self.assertFalse(models.Report.objects.exists()) + models.AutoMod.objects.create( + string_match="hi", + flag_users=True, + flag_statuses=True, + created_by=self.local_user, + ) + + status = models.Status.objects.create( + user=self.local_user, content="hello", content_warning="hi" + ) + + automod_task() + + reports = models.Report.objects.all() + self.assertEqual(reports.count(), 1) + self.assertEqual(reports.first().status, status) + self.assertEqual(reports.first().user, self.local_user) diff --git a/bookwyrm/tests/views/admin/test_automod.py b/bookwyrm/tests/views/admin/test_automod.py new file mode 100644 index 00000000..1ed36caf --- /dev/null +++ b/bookwyrm/tests/views/admin/test_automod.py @@ -0,0 +1,58 @@ +""" test for app action functionality """ +from unittest.mock import patch + +from django.template.response import TemplateResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import forms, models, views +from bookwyrm.tests.validate_html import validate_html + + +class AutomodViews(TestCase): + """every response to a get request, html or json""" + + def setUp(self): + """we need basic test data and mocks""" + self.factory = RequestFactory() + with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch( + "bookwyrm.activitystreams.populate_stream_task.delay" + ), patch("bookwyrm.lists_stream.populate_lists_task.delay"): + self.local_user = models.User.objects.create_user( + "mouse@local.com", + "mouse@mouse.mouse", + "password", + local=True, + localname="mouse", + ) + models.SiteSettings.objects.create() + + def test_automod_rules_get(self): + """there are so many views, this just makes sure it LOADS""" + view = views.AutoMod.as_view() + request = self.factory.get("") + request.user = self.local_user + request.user.is_superuser = True + + result = view(request) + self.assertIsInstance(result, TemplateResponse) + validate_html(result.render()) + self.assertEqual(result.status_code, 200) + + def test_automod_rules_post(self): + """there are so many views, this just makes sure it LOADS""" + form = forms.AutoModRuleForm() + form.data["string_match"] = "hello" + form.data["flag_users"] = True + form.data["flag_statuses"] = False + form.data["created_by"] = self.local_user + + view = views.AutoMod.as_view() + request = self.factory.post("", form.data) + request.user = self.local_user + request.user.is_superuser = True + + result = view(request) + self.assertIsInstance(result, TemplateResponse) + validate_html(result.render()) + self.assertEqual(result.status_code, 200) diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 7572fe63..d2caa76e 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -214,6 +214,14 @@ urlpatterns = [ views.IPBlocklist.as_view(), name="settings-ip-blocks-delete", ), + # auto-moderation rules + re_path(r"^settings/automod/?$", views.AutoMod.as_view(), name="settings-automod"), + re_path( + r"^settings/automod/(?P\d+)/delete?$", + views.automod_delete, + name="settings-automod-delete", + ), + re_path(r"^settings/automod/run?$", views.run_automod, name="settings-automod-run"), # moderation re_path( r"^settings/reports/?$", views.ReportsAdmin.as_view(), name="settings-reports" diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index 7474421b..76e9ff02 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -2,6 +2,7 @@ # site admin from .admin.announcements import Announcements, Announcement from .admin.announcements import EditAnnouncement, delete_announcement +from .admin.automod import AutoMod, automod_delete, run_automod from .admin.dashboard import Dashboard from .admin.federation import Federation, FederatedServer from .admin.federation import AddFederatedServer, ImportServerBlocklist diff --git a/bookwyrm/views/admin/automod.py b/bookwyrm/views/admin/automod.py new file mode 100644 index 00000000..d9901d01 --- /dev/null +++ b/bookwyrm/views/admin/automod.py @@ -0,0 +1,64 @@ +""" moderation via flagged posts and users """ +from django.contrib.auth.decorators import login_required, permission_required +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.http import require_POST + +from bookwyrm import forms, models + + +@method_decorator(login_required, name="dispatch") +@method_decorator( + permission_required("bookwyrm.moderate_user", raise_exception=True), + name="dispatch", +) +@method_decorator( + permission_required("bookwyrm.moderate_post", raise_exception=True), + name="dispatch", +) +# pylint: disable=no-self-use +class AutoMod(View): + """Manage automated flagging""" + + def get(self, request): + """view rules""" + data = {"rules": models.AutoMod.objects.all(), "form": forms.AutoModRuleForm()} + return TemplateResponse(request, "settings/automod/rules.html", data) + + def post(self, request): + """add rule""" + form = forms.AutoModRuleForm(request.POST) + success = form.is_valid() + if success: + form.save() + form = forms.AutoModRuleForm() + + data = { + "rules": models.AutoMod.objects.all(), + "form": form, + "success": success, + } + return TemplateResponse(request, "settings/automod/rules.html", data) + + +@require_POST +@permission_required("bookwyrm.moderate_user", raise_exception=True) +@permission_required("bookwyrm.moderate_post", raise_exception=True) +# pylint: disable=unused-argument +def automod_delete(request, rule_id): + """Remove a rule""" + rule = get_object_or_404(models.AutoMod, id=rule_id) + rule.delete() + return redirect("settings-automod") + + +@require_POST +@permission_required("bookwyrm.moderate_user", raise_exception=True) +@permission_required("bookwyrm.moderate_post", raise_exception=True) +# pylint: disable=unused-argument +def run_automod(request): + """run scan""" + models.automod_task.delay() + return redirect("settings-automod")