Skip to content

Commit

Permalink
Add flood control (#1832)
Browse files Browse the repository at this point in the history
  • Loading branch information
rafalp authored Dec 27, 2024
1 parent 0e538c1 commit d9c95ba
Show file tree
Hide file tree
Showing 32 changed files with 463 additions and 78 deletions.
11 changes: 11 additions & 0 deletions misago/admin/groups/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@ class EditGroupForm(forms.ModelForm):
min_value=0,
)

exempt_from_flood_control = YesNoSwitch(
label=pgettext_lazy(
"admin group permissions form", "Exempt from flood control"
),
help_text=pgettext_lazy(
"admin group permissions form",
"Enable this option to disable the flood control for members of this group.",
),
)

can_use_private_threads = YesNoSwitch(
label=pgettext_lazy("admin group permissions form", "Can use private threads"),
)
Expand Down Expand Up @@ -212,6 +222,7 @@ class Meta:
"own_threads_edit_time_limit",
"can_edit_own_posts",
"own_posts_edit_time_limit",
"exempt_from_flood_control",
"can_use_private_threads",
"can_start_private_threads",
"private_thread_users_limit",
Expand Down
10 changes: 2 additions & 8 deletions misago/admin/templates/misago/admin/groups/edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,13 @@
</div>
<div class="form-fieldset">
<fieldset>
<legend>{% trans "Threads" context "admin group permissions form" %}</legend>
<legend>{% trans "Posting" context "admin group permissions form" %}</legend>

{% form_row form.group.can_edit_own_threads %}
{% form_row form.group.own_threads_edit_time_limit %}

</fieldset>
</div>
<div class="form-fieldset">
<fieldset>
<legend>{% trans "Posts" context "admin group permissions form" %}</legend>

{% form_row form.group.can_edit_own_posts %}
{% form_row form.group.own_posts_edit_time_limit %}
{% form_row form.group.exempt_from_flood_control %}

</fieldset>
</div>
Expand Down
30 changes: 12 additions & 18 deletions misago/conf/admin/forms/threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ class ThreadsSettingsForm(SettingsForm):
settings = [
"attachment_403_image",
"attachment_404_image",
"daily_post_limit",
"hourly_post_limit",
"merge_recent_posts",
"flood_control",
"merge_concurrent_posts",
"post_attachments_limit",
"post_length_max",
"post_length_min",
Expand All @@ -29,24 +28,18 @@ class ThreadsSettingsForm(SettingsForm):
"events_per_page",
]

daily_post_limit = forms.IntegerField(
label=pgettext_lazy("admin threads settings form", "Daily post limit per user"),
help_text=pgettext_lazy(
"admin threads settings form",
"Daily limit of posts that may be posted by single user. Fail-safe for situations when forum is flooded by spam bots. Change to 0 to remove the limit.",
),
min_value=0,
)
hourly_post_limit = forms.IntegerField(
flood_control = forms.IntegerField(
label=pgettext_lazy(
"admin threads settings form", "Hourly post limit per user"
"admin threads settings form",
"Flood control",
),
help_text=pgettext_lazy(
"admin threads settings form",
"Hourly limit of posts that may be posted by single user. Fail-safe for situations when forum is flooded by spam bots. Change to 0 to remove the limit.",
"Number of seconds that must pass after a user posts before they can post again. Edits and concurrent posts are excluded from this limit. Enter zero to disable this feature.",
),
min_value=0,
)

post_attachments_limit = forms.IntegerField(
label=pgettext_lazy(
"admin threads settings form", "Maximum number of attachments per post"
Expand Down Expand Up @@ -85,18 +78,19 @@ class ThreadsSettingsForm(SettingsForm):
),
help_text=pgettext_lazy(
"admin threads settings form",
"Period of time (in hours) after which user-uploaded files that weren't attached to any post are deleted from disk.",
"Time (in hours) after which user-uploaded files that weren't attached to any post are deleted from disk.",
),
min_value=1,
)
merge_recent_posts = forms.IntegerField(

merge_concurrent_posts = forms.IntegerField(
label=pgettext_lazy(
"admin threads settings form",
"Automatically merge recent posts made within specified time",
"Automatically merge concurrent posts made within specified time",
),
help_text=pgettext_lazy(
"admin threads settings form",
"Period of time (in minutes) during which user's newly posted reply to a thread will be appended to their last post. The last post must be editable by the user. Enter zero to disable this feature.",
"Time (in minutes) during which user's newly posted reply to a thread will be appended to their last post. The last post must be editable by the user. Enter zero to disable this feature.",
),
min_value=0,
)
Expand Down
20 changes: 20 additions & 0 deletions misago/conf/migrations/0015_add_merge_concurrent_posts_setting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 4.2.10 on 2024-12-15 12:07

from django.db import migrations

from ..operations import CreateSetting


class Migration(migrations.Migration):

dependencies = [
("misago_conf", "0014_add_threads_lists_settings"),
]

operations = [
CreateSetting(
setting="merge_concurrent_posts",
python_type="int",
dry_value=20,
),
]
35 changes: 0 additions & 35 deletions misago/conf/migrations/0015_add_merge_repeaded_postings_setting.py

This file was deleted.

20 changes: 20 additions & 0 deletions misago/conf/migrations/0016_add_flood_control_setting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 4.2.10 on 2024-12-15 12:07

from django.db import migrations

from ..operations import CreateSetting


class Migration(migrations.Migration):

dependencies = [
("misago_conf", "0015_add_merge_concurrent_posts_setting"),
]

operations = [
CreateSetting(
setting="flood_control",
python_type="int",
dry_value=20,
),
]
38 changes: 38 additions & 0 deletions misago/conf/operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from django.db.migrations import RunPython


class CreateSetting(RunPython):
def __init__(
self,
*,
setting: str,
python_type: str = "string",
dry_value: str | int | None = None,
is_public: bool = False,
):
code = create_setting(setting, python_type, dry_value, is_public)
reverse_code = delete_setting(setting)
super().__init__(code, reverse_code, atomic=False)


def create_setting(
setting: str, python_type: str, dry_value: str | int | None, is_public: bool
):
def migration_operation(apps, _):
Setting = apps.get_model("misago_conf", "Setting")
Setting.objects.create(
setting=setting,
python_type=python_type,
dry_value=dry_value,
is_public=is_public,
)

return migration_operation


def delete_setting(setting: str):
def migration_operation(apps, _):
Setting = apps.get_model("misago_conf", "Setting")
Setting.objects.filter(setting=setting).delete()

return migration_operation
1 change: 1 addition & 0 deletions misago/permissions/copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def _copy_category_permissions_action(
"own_threads_edit_time_limit",
"can_edit_own_posts",
"own_posts_edit_time_limit",
"exempt_from_flood_control",
"can_change_username",
"username_changes_limit",
"username_changes_expire",
Expand Down
6 changes: 6 additions & 0 deletions misago/permissions/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def _build_user_permissions_action(groups: list[Group]) -> dict:
"own_threads_edit_time_limit": 0,
"can_edit_own_posts": False,
"own_posts_edit_time_limit": 0,
"exempt_from_flood_control": False,
"can_change_username": False,
"username_changes_limit": 0,
"username_changes_expire": 0,
Expand Down Expand Up @@ -120,6 +121,11 @@ def _build_user_permissions_action(groups: list[Group]) -> dict:
"own_posts_edit_time_limit",
group.own_posts_edit_time_limit,
)
if_true(
permissions,
"exempt_from_flood_control",
group.exempt_from_flood_control,
)
if_true(
permissions,
"can_change_username",
Expand Down
26 changes: 26 additions & 0 deletions misago/posting/floodcontrol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from datetime import timedelta

from django.http import HttpRequest
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.utils.translation import pgettext_lazy


def flood_control(request: HttpRequest) -> None:
if not request.settings.flood_control:
return

if request.user_permissions.exempt_from_flood_control:
return

flood_posts = timezone.now() - timedelta(seconds=request.settings.flood_control)
posts_queryset = request.user.post_set.filter(posted_on__gt=flood_posts)

if posts_queryset.exists():
raise ValidationError(
message=pgettext_lazy(
"flood control",
"You can't post a new message so soon after the previous one.",
),
code="flood_control",
)
1 change: 1 addition & 0 deletions misago/posting/state/reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def save_category(self):
def save_user(self):
if not self.is_merged:
self.user.posts = models.F("posts") + 1
self.user.last_posted_on = self.timestamp

self.update_object(self.user)

Expand Down
1 change: 1 addition & 0 deletions misago/posting/state/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def save_category(self):
def save_user(self):
self.user.threads = models.F("threads") + 1
self.user.posts = models.F("posts") + 1
self.user.last_posted_on = self.timestamp

self.update_object(self.user)

Expand Down
44 changes: 44 additions & 0 deletions misago/posting/tests/test_flood_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from datetime import timedelta

import pytest
from django.core.exceptions import ValidationError

from ...conf.test import override_dynamic_settings
from ..floodcontrol import flood_control


def test_flood_control_passes_user_without_posts(user_request):
flood_control(user_request)


@override_dynamic_settings(flood_control=0)
def test_flood_control_passes_user_if_flood_control_is_disabled(
user_request, user_reply
):
flood_control(user_request)


def test_flood_control_fails_user_if_they_have_recent_post(user_request, user_reply):
with pytest.raises(ValidationError) as exc_info:
flood_control(user_request)

assert exc_info.value.message == (
"You can't post a new message so soon after the previous one."
)
assert exc_info.value.code == "flood_control"


def test_flood_control_passes_user_if_their_last_post_is_old(user_request, user_reply):
user_reply.posted_on -= timedelta(hours=1)
user_reply.save()

flood_control(user_request)


def test_flood_control_passes_user_if_they_are_exempt_from_flood_control(
user_request, user_reply, members_group
):
members_group.exempt_from_flood_control = True
members_group.save()

flood_control(user_request)
1 change: 1 addition & 0 deletions misago/posting/tests/test_reply_thread_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def test_reply_thread_state_updates_user(user_request, other_user_thread, user):
user.refresh_from_db()
assert user.threads == 0
assert user.posts == 1
assert user.last_posted_on == state.timestamp


def test_reply_thread_state_updates_existing_post(user, user_request, user_thread):
Expand Down
1 change: 1 addition & 0 deletions misago/posting/tests/test_start_thread_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,4 @@ def test_start_thread_state_updates_user(user_request, default_category, user):
user.refresh_from_db()
assert user.threads == 1
assert user.posts == 1
assert user.last_posted_on == state.timestamp
30 changes: 30 additions & 0 deletions misago/posting/tests/test_validate_flood_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from django.core.exceptions import ValidationError

from ..formsets import get_start_thread_formset
from ..state import StartThreadState
from ..validators import validate_flood_control


def test_validate_flood_control_passes_user_without_posts(
user_request, default_category
):
formset = get_start_thread_formset(user_request, default_category)
state = StartThreadState(user_request, default_category)

assert validate_flood_control(formset, state)


def test_validate_flood_control_fails_user_on_flood_control(
user_request, default_category, user_reply
):
formset = get_start_thread_formset(user_request, default_category)
state = StartThreadState(user_request, default_category)

assert not validate_flood_control(formset, state)

error = formset.errors[0]
assert isinstance(error, ValidationError)
assert error.message == (
"You can't post a new message so soon after the previous one."
)
assert error.code == "flood_control"
Loading

0 comments on commit d9c95ba

Please sign in to comment.