Skip to content

Commit

Permalink
chore: refactor antispam API
Browse files Browse the repository at this point in the history
Allow future extension to services supporting checking multiple strings
at once.
  • Loading branch information
nijel committed Dec 4, 2024
1 parent 3ec13a8 commit 250a65b
Show file tree
Hide file tree
Showing 4 changed files with 46 additions and 37 deletions.
31 changes: 14 additions & 17 deletions weblate/trans/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import re
from datetime import datetime
from secrets import token_hex
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, ClassVar, Literal

from crispy_forms.bootstrap import InlineCheckboxes, InlineRadios, Tab, TabHolder
from crispy_forms.helper import FormHelper
Expand Down Expand Up @@ -1430,28 +1430,25 @@ def get_field_doc(self, field: forms.Field) -> tuple[str, str] | None:


class SpamCheckMixin(forms.Form):
def spam_check(self, value) -> None:
if is_spam(value, self.request):
raise ValidationError(gettext("This field has been identified as spam!"))
spam_fields: ClassVar[tuple[str, ...]]

def clean(self) -> None:
data = self.cleaned_data
check_values: list[str] = [
data[field] for field in self.spam_fields if field in data
]
if is_spam(self.request, check_values):
raise ValidationError(
gettext("This submission has been identified as spam!")
)


class ComponentAntispamMixin(SpamCheckMixin):
def clean_agreement(self):
value = self.cleaned_data["agreement"]
self.spam_check(value)
return value
spam_fields = ("agreement",)


class ProjectAntispamMixin(SpamCheckMixin):
def clean_web(self):
value = self.cleaned_data["web"]
self.spam_check(value)
return value

def clean_instructions(self):
value = self.cleaned_data["instructions"]
self.spam_check(value)
return value
spam_fields = ("web", "instructions")


class ComponentSettingsForm(
Expand Down
2 changes: 1 addition & 1 deletion weblate/trans/views/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ def perform_suggestion(unit, form, request: AuthenticatedHttpRequest):
return False
# Spam check for unauthenticated users
if not request.user.is_authenticated and is_spam(
"\n".join(form.cleaned_data["target"]), request
request, form.cleaned_data["target"]
):
messages.error(request, gettext("Your suggestion has been identified as spam!"))
return False
Expand Down
34 changes: 19 additions & 15 deletions weblate/utils/antispam.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,34 @@ def get_akismet():
)


def is_spam(text, request: AuthenticatedHttpRequest):
def is_spam(request: AuthenticatedHttpRequest, texts: str | list[str]):
"""Check whether text is considered spam."""
if not text:
if not texts or not any(texts):
return False
if isinstance(texts, str):
texts = [texts]
akismet = get_akismet()
if akismet is not None:
from akismet import AkismetServerError, SpamStatus

user_ip = get_ip_address(request)
user_agent = get_user_agent_raw(request)

try:
result = akismet.check(
user_ip=user_ip,
user_agent=user_agent,
comment_content=text,
comment_type="comment",
)
except (OSError, AkismetServerError):
report_error("Akismet error")
return True
if result:
report_error("Akismet reported spam", level="info", message=True)
return result == SpamStatus.DefiniteSpam
for text in texts:
try:
result = akismet.check(
user_ip=user_ip,
user_agent=user_agent,
comment_content=text,
comment_type="comment",
)
except (OSError, AkismetServerError):
report_error("Akismet error")
return True
if result:
report_error("Akismet reported spam", level="info", message=True)
if result == SpamStatus.DefiniteSpam:
return True
return False


Expand Down
16 changes: 12 additions & 4 deletions weblate/utils/tests/test_antispam.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
class SpamTest(TestCase):
@override_settings(AKISMET_API_KEY=None)
def test_disabled(self) -> None:
self.assertFalse(is_spam("text", HttpRequest()))
self.assertFalse(is_spam(HttpRequest(), "text"))

def mock_akismet(self, body, **kwargs) -> None:
responses.add(
Expand All @@ -40,26 +40,34 @@ def mock_akismet(self, body, **kwargs) -> None:
responses.POST, "https://rest.akismet.com/1.1/verify-key", body="valid"
)

@responses.activate
def test_akismet_spam_blank(self) -> None:
self.assertFalse(is_spam(HttpRequest(), ""))
self.assertFalse(is_spam(HttpRequest(), [""]))

@skipIf(not HAS_AKISMET, "akismet module not installed")
@responses.activate
@override_settings(AKISMET_API_KEY="key")
def test_akismet_spam(self) -> None:
self.mock_akismet("true")
self.assertFalse(is_spam("text", HttpRequest()))
self.assertFalse(is_spam(HttpRequest(), "text"))
self.assertFalse(is_spam(HttpRequest(), ["text"]))

@skipIf(not HAS_AKISMET, "akismet module not installed")
@responses.activate
@override_settings(AKISMET_API_KEY="key")
def test_akismet_definite_spam(self) -> None:
self.mock_akismet("true", headers={"X-Akismet-Pro-Tip": "discard"})
self.assertTrue(is_spam("text", HttpRequest()))
self.assertTrue(is_spam(HttpRequest(), "text"))
self.assertTrue(is_spam(HttpRequest(), ["text"]))

@skipIf(not HAS_AKISMET, "akismet module not installed")
@responses.activate
@override_settings(AKISMET_API_KEY="key")
def test_akismet_nospam(self) -> None:
self.mock_akismet("false")
self.assertFalse(is_spam("text", HttpRequest()))
self.assertFalse(is_spam(HttpRequest(), "text"))
self.assertFalse(is_spam(HttpRequest(), ["text"]))

@skipIf(not HAS_AKISMET, "akismet module not installed")
@responses.activate
Expand Down

0 comments on commit 250a65b

Please sign in to comment.