Merge pull request #1389 from bookwyrm-social/email-blocking

Email blocking
This commit is contained in:
Mouse Reeve 2021-09-08 17:54:39 -07:00 committed by GitHub
commit 9a53808eeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 361 additions and 26 deletions

View file

@ -299,6 +299,12 @@ class ReportForm(CustomForm):
fields = ["user", "reporter", "statuses", "note"] fields = ["user", "reporter", "statuses", "note"]
class EmailBlocklistForm(CustomForm):
class Meta:
model = models.EmailBlocklist
fields = ["domain"]
class ServerForm(CustomForm): class ServerForm(CustomForm):
class Meta: class Meta:
model = models.FederatedServer model = models.FederatedServer

View file

@ -0,0 +1,32 @@
# Generated by Django 3.2.4 on 2021-09-08 22:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0089_user_show_suggested_users"),
]
operations = [
migrations.CreateModel(
name="EmailBlocklist",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("domain", models.CharField(max_length=255, unique=True)),
],
options={
"ordering": ("-created_date",),
},
),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.4 on 2021-09-09 00:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0090_auto_20210908_2346"),
("bookwyrm", "0090_emailblocklist"),
]
operations = []

View file

@ -24,7 +24,9 @@ from .federated_server import FederatedServer
from .import_job import ImportJob, ImportItem from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest from .site import SiteSettings, SiteInvite
from .site import PasswordReset, InviteRequest
from .site import EmailBlocklist
from .announcement import Announcement from .announcement import Announcement
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)

View file

@ -123,6 +123,23 @@ class PasswordReset(models.Model):
return "https://{}/password-reset/{}".format(DOMAIN, self.code) return "https://{}/password-reset/{}".format(DOMAIN, self.code)
class EmailBlocklist(models.Model):
"""blocked email addresses"""
created_date = models.DateTimeField(auto_now_add=True)
domain = models.CharField(max_length=255, unique=True)
class Meta:
"""default sorting"""
ordering = ("-created_date",)
@property
def users(self):
"""find the users associated with this address"""
return User.objects.filter(email__endswith=f"@{self.domain}")
# pylint: disable=unused-argument # pylint: disable=unused-argument
@receiver(models.signals.post_save, sender=SiteSettings) @receiver(models.signals.post_save, sender=SiteSettings)
def preview_image(instance, *args, **kwargs): def preview_image(instance, *args, **kwargs):

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% load humanize %} {% load humanize %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% block title %} {% block title %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %}{% load humanize %} {% load i18n %}{% load humanize %}
{% block title %}{% trans "Announcement" %} - {{ announcement.preview }}{% endblock %} {% block title %}{% trans "Announcement" %} - {{ announcement.preview }}{% endblock %}

View file

@ -13,21 +13,21 @@
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}"> <input type="hidden" name="user" value="{{ request.user.id }}">
<p> <p>
<label class="label" for="id_preview">Preview:</label> <label class="label" for="id_preview">{% trans "Preview:" %}</label>
{{ form.preview }} {{ form.preview }}
{% for error in form.preview.errors %} {% for error in form.preview.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
</p> </p>
<p> <p>
<label class="label" for="id_content">Content:</label> <label class="label" for="id_content">{% trans "Content:" %}</label>
{{ form.content }} {{ form.content }}
{% for error in form.content.errors %} {% for error in form.content.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
</p> </p>
<p> <p>
<label class="label" for="id_event_date">Event date:</label> <label class="label" for="id_event_date">{% trans "Event date:" %}</label>
<input type="date" name="event_date" value="{{ form.event_date.value|date:'Y-m-d' }}" class="input" id="id_event_date"> <input type="date" name="event_date" value="{{ form.event_date.value|date:'Y-m-d' }}" class="input" id="id_event_date">
{% for error in form.event_date.errors %} {% for error in form.event_date.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
@ -37,7 +37,7 @@
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<p> <p>
<label class="label" for="id_start_date">Start date:</label> <label class="label" for="id_start_date">{% trans "Start date:" %}</label>
<input type="date" name="start_date" class="input" value="{{ form.start_date.value|date:'Y-m-d' }}" id="id_start_date"> <input type="date" name="start_date" class="input" value="{{ form.start_date.value|date:'Y-m-d' }}" id="id_start_date">
{% for error in form.start_date.errors %} {% for error in form.start_date.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
@ -46,7 +46,7 @@
</div> </div>
<div class="column"> <div class="column">
<p> <p>
<label class="label" for="id_end_date">End date:</label> <label class="label" for="id_end_date">{% trans "End date:" %}</label>
<input type="date" name="end_date" class="input" id="id_end_date" value="{{ form.end_date.value|date:'Y-m-d' }}"> <input type="date" name="end_date" class="input" id="id_end_date" value="{{ form.end_date.value|date:'Y-m-d' }}">
{% for error in form.end_date.errors %} {% for error in form.end_date.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
@ -55,7 +55,7 @@
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
<p> <p>
<label class="label" for="id_active">Active:</label> <label class="label" for="id_active">{% trans "Active:" %}</label>
{{ form.active }} {{ form.active }}
{% for error in form.active.errors %} {% for error in form.active.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %}{% load humanize %} {% load i18n %}{% load humanize %}
{% block title %}{% trans "Announcements" %}{% endblock %} {% block title %}{% trans "Announcements" %}{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends 'components/inline_form.html' %}
{% load i18n %}
{% block header %}
{% trans "Add domain" %}
{% endblock %}
{% block form %}
<form name="add-domain" method="post" action="{% url 'settings-email-blocks' %}">
{% csrf_token %}
<label class="label" for="id_event_date">{% trans "Domain:" %}</label>
<div class="field has-addons">
<div class="control">
<div class="button is-disabled">@</div>
</div>
<div class="control">
{{ form.domain }}
</div>
</div>
{% for error in form.domain.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<div class="control">
<button type="submit" class="button is-primary">{% trans "Add" %}</button>
</div>
</div>
</form>
{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Add instance" %}{% endblock %} {% block title %}{% trans "Add instance" %}{% endblock %}

View file

@ -0,0 +1,62 @@
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load humanize %}
{% block title %}{% trans "Email Blocklist" %}{% endblock %}
{% block header %}{% trans "Email Blocklist" %}{% endblock %}
{% block edit-button %}
{% trans "Add domain" as button_text %}
{% include 'snippets/toggle/open_button.html' with controls_text="add_domain" icon_with_text="plus" text=button_text focus="add_domain_header" %}
{% endblock %}
{% block panel %}
{% include 'settings/domain_form.html' with controls_text="add_domain" class="block" %}
<p class="notification block">
{% trans "When someone tries to register with an email from this domain, no account will be created. The registration process will appear to have worked." %}
</p>
<table class="table is-striped is-fullwidth">
<tr>
{% url 'settings-federation' as url %}
<th>
{% trans "Domain" as text %}
{% include 'snippets/table-sort-header.html' with field="server_name" sort=sort text=text %}
</th>
<th></th>
<th>
{% trans "Options" %}
</th>
</tr>
{% for domain in domains %}
<tr>
<td>{{ domain.domain }}</td>
<td>
<a href="{% url 'settings-users' %}?email=@{{ domain.domain }}">
{% with user_count=domain.users.count %}
{% blocktrans trimmed count conter=user_count with display_count=user_count|intcomma %}
{{ display_count }} user
{% plural %}
{{ display_count }} users
{% endblocktrans %}
{% endwith %}
</a>
</td>
<td>
<form name="remove-{{ domain.id }}" action="{% url 'settings-email-blocks-delete' domain.id %}" method="post">
{% csrf_token %}
{% trans "Delete" as button_text %}
<button class="button" type="submit">
<span class="icon icon-x" title="{{ button_text }}" aria-hidden="true"></span>
<span class="is-hidden-mobile">{{ button_text }}</span>
</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% load markdown %} {% load markdown %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Federated Instances" %}{% endblock %} {% block title %}{% trans "Federated Instances" %}{% endblock %}

View file

@ -32,12 +32,6 @@
{% url 'settings-invites' as alt_url %} {% url 'settings-invites' as alt_url %}
<a href="{{ url }}"{% if url in request.path or request.path in alt_url %} class="is-active" aria-selected="true"{% endif %}>{% trans "Invites" %}</a> <a href="{{ url }}"{% if url in request.path or request.path in alt_url %} class="is-active" aria-selected="true"{% endif %}>{% trans "Invites" %}</a>
</li> </li>
{% if perms.bookwyrm.moderate_user %}
<li>
{% url 'settings-reports' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Reports" %}</a>
</li>
{% endif %}
{% if perms.bookwyrm.control_federation %} {% if perms.bookwyrm.control_federation %}
<li> <li>
{% url 'settings-federation' as url %} {% url 'settings-federation' as url %}
@ -46,6 +40,19 @@
{% endif %} {% endif %}
</ul> </ul>
{% endif %} {% endif %}
{% if perms.bookwyrm.moderate_user %}
<h2 class="menu-label">{% trans "Moderation" %}</h2>
<ul class="menu-list">
<li>
{% url 'settings-reports' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Reports" %}</a>
</li>
<li>
{% 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>
</li>
</ul>
{% endif %}
{% if perms.bookwyrm.edit_instance_settings %} {% if perms.bookwyrm.edit_instance_settings %}
<h2 class="menu-label">{% trans "Instance Settings" %}</h2> <h2 class="menu-label">{% trans "Instance Settings" %}</h2>
<ul class="menu-list"> <ul class="menu-list">

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% load humanize %} {% load humanize %}
{% block header %}{% trans "Invite Requests" %}{% endblock %} {% block header %}{% trans "Invite Requests" %}{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% block header %}{% trans "Invites" %}{% endblock %} {% block header %}{% trans "Invites" %}{% endblock %}
{% load humanize %} {% load humanize %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Add instance" %}{% endblock %} {% block title %}{% trans "Add instance" %}{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Site Settings" %}{% endblock %} {% block title %}{% trans "Site Settings" %}{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% block title %}{{ user.username }}{% endblock %} {% block title %}{{ user.username }}{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Users" %}{% endblock %} {% block title %}{% trans "Users" %}{% endblock %}

View file

@ -0,0 +1,73 @@
""" 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 models, views
class EmailBlocklistViews(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"
):
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_blocklist_page_get(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.EmailBlocklist.as_view()
request = self.factory.get("")
request.user = self.local_user
request.user.is_superuser = True
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
def test_blocklist_page_post(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.EmailBlocklist.as_view()
request = self.factory.post("", {"domain": "gmail.com"})
request.user = self.local_user
request.user.is_superuser = True
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
self.assertTrue(
models.EmailBlocklist.objects.filter(domain="gmail.com").exists()
)
def test_blocklist_page_delete(self):
"""there are so many views, this just makes sure it LOADS"""
domain = models.EmailBlocklist.objects.create(domain="gmail.com")
view = views.EmailBlocklist.as_view()
request = self.factory.post("")
request.user = self.local_user
request.user.is_superuser = True
result = view(request, domain_id=domain.id)
self.assertEqual(result.status_code, 302)
self.assertFalse(
models.EmailBlocklist.objects.filter(domain="gmail.com").exists()
)

View file

@ -153,6 +153,30 @@ class RegisterViews(TestCase):
with self.assertRaises(PermissionDenied): with self.assertRaises(PermissionDenied):
view(request) view(request)
def test_register_blocked_domain(self, *_):
"""you can't register with a blocked domain"""
view = views.Register.as_view()
models.EmailBlocklist.objects.create(domain="gmail.com")
# one that fails
request = self.factory.post(
"register/",
{"localname": "nutria ", "password": "mouseword", "email": "aa@gmail.com"},
)
result = view(request)
self.assertEqual(result.status_code, 302)
self.assertFalse(models.User.objects.filter(email="aa@gmail.com").exists())
# one that succeeds
request = self.factory.post(
"register/",
{"localname": "nutria ", "password": "mouseword", "email": "aa@bleep.com"},
)
with patch("bookwyrm.views.register.login"):
result = view(request)
self.assertEqual(result.status_code, 302)
self.assertTrue(models.User.objects.filter(email="aa@bleep.com").exists())
def test_register_invite(self, *_): def test_register_invite(self, *_):
"""you can't just register""" """you can't just register"""
view = views.Register.as_view() view = views.Register.as_view()

View file

@ -141,6 +141,16 @@ urlpatterns = [
r"^invite-request/?$", views.InviteRequest.as_view(), name="invite-request" r"^invite-request/?$", views.InviteRequest.as_view(), name="invite-request"
), ),
re_path(r"^invite/(?P<code>[A-Za-z0-9]+)/?$", views.Invite.as_view()), re_path(r"^invite/(?P<code>[A-Za-z0-9]+)/?$", views.Invite.as_view()),
re_path(
r"^settings/email-blocklist/?$",
views.EmailBlocklist.as_view(),
name="settings-email-blocks",
),
re_path(
r"^settings/email-blocks/(?P<domain_id>\d+)/delete/?$",
views.EmailBlocklist.as_view(),
name="settings-email-blocks-delete",
),
# moderation # moderation
re_path(r"^settings/reports/?$", views.Reports.as_view(), name="settings-reports"), re_path(r"^settings/reports/?$", views.Reports.as_view(), name="settings-reports"),
re_path( re_path(

View file

@ -8,6 +8,7 @@ from .directory import Directory
from .discover import Discover from .discover import Discover
from .edit_user import EditUser, DeleteUser from .edit_user import EditUser, DeleteUser
from .editions import Editions, switch_edition from .editions import Editions, switch_edition
from .email_blocklist import EmailBlocklist
from .federation import Federation, FederatedServer from .federation import Federation, FederatedServer
from .federation import AddFederatedServer, ImportServerBlocklist from .federation import AddFederatedServer, ImportServerBlocklist
from .federation import block_server, unblock_server from .federation import block_server, unblock_server

View file

@ -0,0 +1,49 @@
""" 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 bookwyrm import forms, models
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.moderate_user", raise_exception=True),
name="dispatch",
)
class EmailBlocklist(View):
"""Block users by email address"""
def get(self, request):
"""view and compose blocks"""
data = {
"domains": models.EmailBlocklist.objects.order_by("-created_date").all(),
"form": forms.EmailBlocklistForm(),
}
return TemplateResponse(request, "settings/email_blocklist.html", data)
def post(self, request, domain_id=None):
"""create a new domain block"""
if domain_id:
return self.delete(request, domain_id)
form = forms.EmailBlocklistForm(request.POST)
data = {
"domains": models.EmailBlocklist.objects.order_by("-created_date").all(),
"form": form,
}
if not form.is_valid():
return TemplateResponse(request, "settings/email_blocklist.html", data)
form.save()
data["form"] = forms.EmailBlocklistForm()
return TemplateResponse(request, "settings/email_blocklist.html", data)
# pylint: disable=unused-argument
def delete(self, request, domain_id):
"""remove a domain block"""
domain = get_object_or_404(models.EmailBlocklist, id=domain_id)
domain.delete()
return redirect("settings-email-blocks")

View file

@ -42,6 +42,12 @@ class Register(View):
email = form.data["email"] email = form.data["email"]
password = form.data["password"] password = form.data["password"]
# make sure the email isn't blocked as spam
email_domain = email.split("@")[-1]
if models.EmailBlocklist.objects.filter(domain=email_domain).exists():
# treat this like a successful registration, but don't do anything
return redirect("confirm-email")
# check localname and email uniqueness # check localname and email uniqueness
if models.User.objects.filter(localname=localname).first(): if models.User.objects.filter(localname=localname).first():
form.errors["localname"] = ["User with this username already exists"] form.errors["localname"] = ["User with this username already exists"]

View file

@ -33,6 +33,9 @@ class UserAdminList(View):
scope = request.GET.get("scope") scope = request.GET.get("scope")
if scope and scope == "local": if scope and scope == "local":
filters["local"] = True filters["local"] = True
email = request.GET.get("email")
if email:
filters["email__endswith"] = email
users = models.User.objects.filter(**filters) users = models.User.objects.filter(**filters)