Merge pull request #1968 from bookwyrm-social/automod

Automod
This commit is contained in:
Mouse Reeve 2022-02-24 17:51:40 -08:00 committed by GitHub
commit f1f7b21d43
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 602 additions and 20 deletions

View file

@ -495,7 +495,7 @@ class GroupForm(CustomForm):
class ReportForm(CustomForm): class ReportForm(CustomForm):
class Meta: class Meta:
model = models.Report model = models.Report
fields = ["user", "reporter", "statuses", "links", "note"] fields = ["user", "reporter", "status", "links", "note"]
class EmailBlocklistForm(CustomForm): class EmailBlocklistForm(CustomForm):
@ -550,3 +550,9 @@ class ReadThroughForm(CustomForm):
class Meta: class Meta:
model = models.ReadThrough model = models.ReadThrough
fields = ["user", "book", "start_date", "finish_date"] fields = ["user", "book", "start_date", "finish_date"]
class AutoModRuleForm(CustomForm):
class Meta:
model = models.AutoMod
fields = ["string_match", "flag_users", "flag_statuses", "created_by"]

View file

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

View file

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

View file

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

View file

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

View file

@ -29,7 +29,7 @@ from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite from .site import SiteSettings, SiteInvite
from .site import PasswordReset, InviteRequest from .site import PasswordReset, InviteRequest
from .announcement import Announcement from .announcement import Announcement
from .antispam import EmailBlocklist, IPBlocklist from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task
from .notification import Notification from .notification import Notification

View file

@ -1,6 +1,13 @@
""" Lets try NOT to sell viagra """ """ 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 from .user import User
@ -33,3 +40,107 @@ class IPBlocklist(models.Model):
"""default sorting""" """default sorting"""
ordering = ("-created_date",) 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
]
)

View file

@ -12,7 +12,12 @@ class Report(BookWyrmModel):
) )
note = models.TextField(null=True, blank=True) note = models.TextField(null=True, blank=True)
user = models.ForeignKey("User", on_delete=models.PROTECT) 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) links = models.ManyToManyField("Link", blank=True)
resolved = models.BooleanField(default=False) resolved = models.BooleanField(default=False)

View file

@ -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 %}
<div class="notification content">
<p>
{% 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 <em>not</em> being generated automatically, and you must manually trigger a scan." %}
</p>
<form name="run-scan" method="POST" action="{% url 'settings-automod-run' %}">
{% csrf_token %}
<button class="button is-warning">{% trans "Run scan" %}</button>
</form>
</div>
{% if success %}
<div class="notification is-success is-light">
<span class="icon icon-check" aria-hidden="true"></span>
<span>
{% trans "Successfully added rule" %}
</span>
</div>
{% endif %}
<div class="block content">
<h2 class="title is-4">{% trans "Add Rule" %}</h2>
<div class="box">
<form action="{% url 'settings-automod' %}" method="POST">
{% csrf_token %}
<input type="hidden" value="{{ request.user.id }}" name="created_by">
<div class="columns">
<div class="column">
<div class="field">
<label class="label" for="id_string_match">{% trans "String match" %}</label>
{{ form.string_match }}
{% include 'snippets/form_errors.html' with errors_list=form.string_match.errors id="desc_string_match" %}
</div>
</div>
<div class="column">
<div class="field">
<label class="label" for="id_flag_users">
{{ form.flag_users }}
{% trans "Flag users" %}
</label>
</div>
<div class="field">
<label class="label" for="id_flag_statuses">
{{ form.flag_statuses }}
{% trans "Flag statuses" %}
</label>
</div>
</div>
</div>
<div class="field">
<button type="submit" class="button is-primary">{% trans "Add rule" %}</button>
</div>
</form>
</div>
</div>
<div class="block content">
<h2 class="title is-4">{% trans "Current Rules" %}</h2>
<details class="details-panel">
<summary>
<span class="title is-5" role="heading" aria-level="3">
{% trans "Show rules" %} ({{ rules.count }})
</span>
<span class="details-close icon icon-x" aria-hidden="true"></span>
</summary>
<div class="table-container">
<table class="table is-striped">
<tr>
<th>
<label for="id_string_match">{% trans "String match" %}</label>
</th>
<th>
<label for="id_flag_users">{% trans "Flag users" %}</label>
</th>
<th>
<label for="id_flag_statuses">{% trans "Flag statuses" %}</label>
</th>
<th>
</th>
</tr>
{% for rule in rules %}
<tr>
<td>
<code>{{ rule.string_match }}</code>
</td>
<td>
{{ rule.flag_users|yesno }}
</td>
<td>
{{ rule.flag_statuses|yesno }}
</td>
<td>
<form action="{% url 'settings-automod-delete' rule.id %}" method="POST">
{% csrf_token %}
<div class="control">
<button type="submit" class="button is-danger is-light is-small">
<span class="icon icon-x m-0-mobile" aria-hidden="true"></span>
<span class="is-sr-only-mobile">{% trans "Remove rule" %}</span>
</button>
</div>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
</details>
</div>
{% endblock %}

View file

@ -56,6 +56,10 @@
{% url 'settings-reports' as url %} {% url 'settings-reports' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Reports" %}</a> <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Reports" %}</a>
</li> </li>
<li>
{% url 'settings-automod' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Auto-moderation rules" %}</a>
</li>
<li> <li>
{% url 'settings-email-blocks' as url %} {% url 'settings-email-blocks' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Email Blocklist" %}</a> <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Email Blocklist" %}</a>

View file

@ -1,6 +1,7 @@
{% extends 'settings/layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% load humanize %} {% load humanize %}
{% load feed_page_tags %}
{% block title %} {% block title %}
{% include "settings/reports/report_header.html" with report=report %} {% include "settings/reports/report_header.html" with report=report %}
@ -30,20 +31,14 @@
</details> </details>
</div> </div>
{% if report.statuses.exists %} {% if report.status %}
<div class="block"> <div class="block">
<h3 class="title is-4">{% trans "Reported statuses" %}</h3> <h3 class="title is-4">{% trans "Reported status" %}</h3>
<ul> {% if report.status.deleted %}
{% for status in report.statuses.select_subclasses.all %}
<li>
{% if status.deleted %}
<em>{% trans "Status has been deleted" %}</em> <em>{% trans "Status has been deleted" %}</em>
{% else %} {% else %}
{% include 'snippets/status/status.html' with status=status moderation_mode=True %} {% include 'snippets/status/status.html' with status=report.status|load_subclass moderation_mode=True %}
{% endif %} {% endif %}
</li>
{% endfor %}
</ul>
</div> </div>
{% endif %} {% endif %}

View file

@ -1,7 +1,7 @@
{% load i18n %} {% load i18n %}
{% load utilities %} {% load utilities %}
{% if report.statuses.exists %} {% if report.status %}
{% blocktrans trimmed with report_id=report.id username=report.user|username %} {% blocktrans trimmed with report_id=report.id username=report.user|username %}
Report #{{ report_id }}: Status posted by @{{ username }} Report #{{ report_id }}: Status posted by @{{ username }}

View file

@ -12,6 +12,6 @@
> >
{% trans "Report" %} {% trans "Report" %}
</button> </button>
{% 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 %} {% endwith %}

View file

@ -23,7 +23,7 @@
<input type="hidden" name="reporter" value="{{ request.user.id }}"> <input type="hidden" name="reporter" value="{{ request.user.id }}">
<input type="hidden" name="user" value="{{ user.id }}"> <input type="hidden" name="user" value="{{ user.id }}">
{% if status_id %} {% if status_id %}
<input type="hidden" name="statuses" value="{{ status_id }}"> <input type="hidden" name="status" value="{{ status_id }}">
{% endif %} {% endif %}
{% if link %} {% if link %}
<input type="hidden" name="links" value="{{ link.id }}"> <input type="hidden" name="links" value="{{ link.id }}">

View file

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

View file

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

View file

@ -214,6 +214,14 @@ urlpatterns = [
views.IPBlocklist.as_view(), views.IPBlocklist.as_view(),
name="settings-ip-blocks-delete", 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<rule_id>\d+)/delete?$",
views.automod_delete,
name="settings-automod-delete",
),
re_path(r"^settings/automod/run?$", views.run_automod, name="settings-automod-run"),
# moderation # moderation
re_path( re_path(
r"^settings/reports/?$", views.ReportsAdmin.as_view(), name="settings-reports" r"^settings/reports/?$", views.ReportsAdmin.as_view(), name="settings-reports"

View file

@ -2,6 +2,7 @@
# site admin # site admin
from .admin.announcements import Announcements, Announcement from .admin.announcements import Announcements, Announcement
from .admin.announcements import EditAnnouncement, delete_announcement from .admin.announcements import EditAnnouncement, delete_announcement
from .admin.automod import AutoMod, automod_delete, run_automod
from .admin.dashboard import Dashboard from .admin.dashboard import Dashboard
from .admin.federation import Federation, FederatedServer from .admin.federation import Federation, FederatedServer
from .admin.federation import AddFederatedServer, ImportServerBlocklist from .admin.federation import AddFederatedServer, ImportServerBlocklist

View file

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