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"]
class EmailBlocklistForm(CustomForm):
class Meta:
model = models.EmailBlocklist
fields = ["domain"]
class ServerForm(CustomForm):
class Meta:
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 .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest
from .site import SiteSettings, SiteInvite
from .site import PasswordReset, InviteRequest
from .site import EmailBlocklist
from .announcement import Announcement
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)
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
@receiver(models.signals.post_save, sender=SiteSettings)
def preview_image(instance, *args, **kwargs):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -32,12 +32,6 @@
{% 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>
</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 %}
<li>
{% url 'settings-federation' as url %}
@ -46,6 +40,19 @@
{% endif %}
</ul>
{% 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 %}
<h2 class="menu-label">{% trans "Instance Settings" %}</h2>
<ul class="menu-list">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %}
{% extends 'settings/layout.html' %}
{% load i18n %}
{% 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):
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, *_):
"""you can't just register"""
view = views.Register.as_view()

View file

@ -141,6 +141,16 @@ urlpatterns = [
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"^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
re_path(r"^settings/reports/?$", views.Reports.as_view(), name="settings-reports"),
re_path(

View file

@ -8,6 +8,7 @@ from .directory import Directory
from .discover import Discover
from .edit_user import EditUser, DeleteUser
from .editions import Editions, switch_edition
from .email_blocklist import EmailBlocklist
from .federation import Federation, FederatedServer
from .federation import AddFederatedServer, ImportServerBlocklist
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"]
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
if models.User.objects.filter(localname=localname).first():
form.errors["localname"] = ["User with this username already exists"]

View file

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