From 505559f27b31da4c854a24910af65e562c978dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Gon=C3=A7alves=20da=20Silva?= Date: Thu, 21 Mar 2024 17:10:35 -0300 Subject: [PATCH 1/7] adds type hints for views.py --- captcha/views.py | 90 +++++++++++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/captcha/views.py b/captcha/views.py index d2d7b80..b56b441 100644 --- a/captcha/views.py +++ b/captcha/views.py @@ -3,13 +3,15 @@ import random import subprocess import tempfile -from io import BytesIO +from io import BufferedRandom, BytesIO from PIL import Image, ImageDraw, ImageFont +from PIL.Image import Image as ImageType +from PIL.ImageDraw import ImageDraw as ImageDrawType from ranged_response import RangedFileResponse from django.core.exceptions import ImproperlyConfigured -from django.http import Http404, HttpResponse +from django.http import Http404, HttpRequest, HttpResponse from captcha.conf import settings from captcha.helpers import captcha_audio_url, captcha_image_url @@ -20,7 +22,7 @@ DISTANCE_FROM_TOP = 4 -def getsize(font, text): +def getsize(font, text: str) -> tuple[int, int]: if hasattr(font, "getbbox"): _top, _left, _right, _bottom = font.getbbox(text) return _right - _left, _bottom - _top @@ -30,31 +32,31 @@ def getsize(font, text): return font.getsize(text) -def makeimg(size): +def makeimg(size: tuple[int, int]) -> ImageType: if settings.CAPTCHA_BACKGROUND_COLOR == "transparent": - image = Image.new("RGBA", size) + image: ImageType = Image.new("RGBA", size) else: - image = Image.new("RGB", size, settings.CAPTCHA_BACKGROUND_COLOR) + image: ImageType = Image.new("RGB", size, settings.CAPTCHA_BACKGROUND_COLOR) return image -def captcha_image(request, key, scale=1): +def captcha_image(request: HttpRequest, key, scale: int=1) -> HttpResponse: if scale == 2 and not settings.CAPTCHA_2X_IMAGE: raise Http404 try: - store = CaptchaStore.objects.get(hashkey=key) + store: CaptchaStore = CaptchaStore.objects.get(hashkey=key) except CaptchaStore.DoesNotExist: # HTTP 410 Gone status so that crawlers don't index these expired urls. return HttpResponse(status=410) random.seed(key) # Do not generate different images for the same key - text = store.challenge + text: str = store.challenge if isinstance(settings.CAPTCHA_FONT_PATH, str): - fontpath = settings.CAPTCHA_FONT_PATH + fontpath: str = settings.CAPTCHA_FONT_PATH elif isinstance(settings.CAPTCHA_FONT_PATH, (list, tuple)): - fontpath = random.choice(settings.CAPTCHA_FONT_PATH) + fontpath: str = random.choice(settings.CAPTCHA_FONT_PATH) else: raise ImproperlyConfigured( "settings.CAPTCHA_FONT_PATH needs to be a path to a font or list of paths to fonts" @@ -71,28 +73,32 @@ def captcha_image(request, key, scale=1): size = getsize(font, text) size = (size[0] * 2, int(size[1] * 1.4)) - image = makeimg(size) + image: ImageType = makeimg(size) xpos = 2 - charlist = [] + charlist: list[str] = [] + for char in text: if char in settings.CAPTCHA_PUNCTUATION and len(charlist) >= 1: charlist[-1] += char else: charlist.append(char) + for char in charlist: - fgimage = Image.new("RGB", size, settings.CAPTCHA_FOREGROUND_COLOR) - charimage = Image.new("L", getsize(font, " %s " % char), "#000000") - chardraw = ImageDraw.Draw(charimage) + fgimage: ImageType = Image.new("RGB", size, settings.CAPTCHA_FOREGROUND_COLOR) + charimage: ImageType = Image.new("L", getsize(font, " %s " % char), "#000000") + chardraw: ImageDrawType = ImageDraw.Draw(charimage) chardraw.text((0, 0), " %s " % char, font=font, fill="#ffffff") + if settings.CAPTCHA_LETTER_ROTATION: - charimage = charimage.rotate( + charimage: ImageType = charimage.rotate( random.randrange(*settings.CAPTCHA_LETTER_ROTATION), - expand=0, + expand=False, resample=Image.BICUBIC, ) - charimage = charimage.crop(charimage.getbbox()) - maskimage = Image.new("L", size) + + charimage: ImageType = charimage.crop(charimage.getbbox()) + maskimage: ImageType = Image.new("L", size) maskimage.paste( charimage, @@ -103,13 +109,14 @@ def captcha_image(request, key, scale=1): DISTANCE_FROM_TOP + charimage.size[1], ), ) - size = maskimage.size - image = Image.composite(fgimage, image, maskimage) - xpos = xpos + 2 + charimage.size[0] + + size: tuple[int, int] = maskimage.size + image: ImageType = Image.composite(fgimage, image, maskimage) + xpos: int = xpos + 2 + charimage.size[0] if settings.CAPTCHA_IMAGE_SIZE: # centering captcha on the image - tmpimg = makeimg(size) + tmpimg: ImageType = makeimg(size) tmpimg.paste( image, ( @@ -117,17 +124,18 @@ def captcha_image(request, key, scale=1): int((size[1] - charimage.size[1]) / 2 - DISTANCE_FROM_TOP), ), ) - image = tmpimg.crop((0, 0, size[0], size[1])) + image: ImageType = tmpimg.crop((0, 0, size[0], size[1])) else: - image = image.crop((0, 0, xpos + 1, size[1])) - draw = ImageDraw.Draw(image) + image: ImageType = image.crop((0, 0, xpos + 1, size[1])) + + draw: ImageDrawType = ImageDraw.Draw(image) for f in settings.noise_functions(): draw = f(draw, image) for f in settings.filter_functions(): image = f(image) - out = BytesIO() + out: BytesIO = BytesIO() image.save(out, "PNG") out.seek(0) @@ -145,28 +153,30 @@ def captcha_image(request, key, scale=1): return response -def captcha_audio(request, key): +def captcha_audio(request: HttpRequest, key) -> HttpResponse | RangedFileResponse: if settings.CAPTCHA_FLITE_PATH: try: - store = CaptchaStore.objects.get(hashkey=key) + store: CaptchaStore = CaptchaStore.objects.get(hashkey=key) except CaptchaStore.DoesNotExist: # HTTP 410 Gone status so that crawlers don't index these expired urls. return HttpResponse(status=410) - text = store.challenge + text: str = store.challenge + if "captcha.helpers.math_challenge" == settings.CAPTCHA_CHALLENGE_FUNCT: text = text.replace("*", "times").replace("-", "minus").replace("+", "plus") else: text = ", ".join(list(text)) - path = str(os.path.join(tempfile.gettempdir(), "%s.wav" % key)) + + path: str = str(os.path.join(tempfile.gettempdir(), "%s.wav" % key)) subprocess.call([settings.CAPTCHA_FLITE_PATH, "-t", text, "-o", path]) # Add arbitrary noise if sox is installed if settings.CAPTCHA_SOX_PATH: - arbnoisepath = str( + arbnoisepath: str = str( os.path.join(tempfile.gettempdir(), "%s_arbitrary.wav") % key ) - mergedpath = str(os.path.join(tempfile.gettempdir(), "%s_merged.wav") % key) + mergedpath: str = str(os.path.join(tempfile.gettempdir(), "%s_merged.wav") % key) subprocess.call( [ settings.CAPTCHA_SOX_PATH, @@ -200,13 +210,15 @@ def captcha_audio(request, key): if os.path.isfile(path): # Move the response file to a filelike that will be deleted on close - temporary_file = tempfile.TemporaryFile() + temporary_file: BufferedRandom = tempfile.TemporaryFile() + with open(path, "rb") as original_file: temporary_file.write(original_file.read()) + temporary_file.seek(0) os.remove(path) - response = RangedFileResponse( + response: RangedFileResponse = RangedFileResponse( request, temporary_file, content_type="audio/wav" ) response["Content-Disposition"] = 'attachment; filename="{}.wav"'.format( @@ -216,13 +228,13 @@ def captcha_audio(request, key): raise Http404 -def captcha_refresh(request): +def captcha_refresh(request: HttpRequest) -> HttpResponse: """Return json with new captcha for ajax refresh request""" if not request.headers.get("x-requested-with") == "XMLHttpRequest": raise Http404 - new_key = CaptchaStore.pick() - to_json_response = { + new_key: str = CaptchaStore.pick() + to_json_response: dict[str, str | None] = { "key": new_key, "image_url": captcha_image_url(new_key), "audio_url": captcha_audio_url(new_key) From bbe60047957e3cbe339efec282115cef60b4f103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Gon=C3=A7alves=20da=20Silva?= Date: Thu, 21 Mar 2024 17:10:53 -0300 Subject: [PATCH 2/7] adds type hints for urls.py --- captcha/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/captcha/urls.py b/captcha/urls.py index 73681e8..4c2a28f 100644 --- a/captcha/urls.py +++ b/captcha/urls.py @@ -1,9 +1,9 @@ -from django.urls import re_path +from django.urls import re_path, URLPattern from captcha import views -urlpatterns = [ +urlpatterns: list[URLPattern] = [ re_path( r"image/(?P\w+)/$", views.captcha_image, From 59a3cc062423d59acca5af391b138a21c0da91fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Gon=C3=A7alves=20da=20Silva?= Date: Thu, 21 Mar 2024 17:11:09 -0300 Subject: [PATCH 3/7] adds type hints for models.py --- captcha/models.py | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/captcha/models.py b/captcha/models.py index d8f9604..3f36c45 100644 --- a/captcha/models.py +++ b/captcha/models.py @@ -3,6 +3,7 @@ import logging import random import time +from typing import Final from django.db import models from django.utils import timezone @@ -13,13 +14,11 @@ # Heavily based on session key generation in Django # Use the system (hardware-based) random number generator if it exists. -if hasattr(random, "SystemRandom"): - randrange = random.SystemRandom().randrange -else: - randrange = random.randrange -MAX_RANDOM_KEY = 18446744073709551616 # 2 << 63 +randrange: function = random.SystemRandom().randrange if hasattr(random, "SystemRandom") else random.randrange -logger = logging.getLogger(__name__) +MAX_RANDOM_KEY: Final[int] = 18446744073709551616 # 2 << 63 + +logger: logging.Logger = logging.getLogger(__name__) class CaptchaStore(models.Model): @@ -29,40 +28,43 @@ class CaptchaStore(models.Model): hashkey = models.CharField(blank=False, max_length=40, unique=True) expiration = models.DateTimeField(blank=False) - def save(self, *args, **kwargs): - self.response = self.response.lower() + def save(self, *args, **kwargs) -> None: + self.response: models.CharField = self.response.lower() + if not self.expiration: self.expiration = timezone.now() + datetime.timedelta( minutes=int(captcha_settings.CAPTCHA_TIMEOUT) ) + if not self.hashkey: - key_ = ( + key_: bytes = ( smart_str(randrange(0, MAX_RANDOM_KEY)) + smart_str(time.time()) + smart_str(self.challenge, errors="ignore") + smart_str(self.response, errors="ignore") ).encode("utf8") - self.hashkey = hashlib.sha1(key_).hexdigest() + + self.hashkey: models.CharField = hashlib.sha1(key_).hexdigest() del key_ + super().save(*args, **kwargs) - def __str__(self): + def __str__(self) -> str: return self.challenge - def remove_expired(cls): + @classmethod + def remove_expired(cls) -> None: cls.objects.filter(expiration__lte=timezone.now()).delete() - remove_expired = classmethod(remove_expired) - @classmethod - def generate_key(cls, generator=None): + def generate_key(cls, generator=None) -> str: challenge, response = captcha_settings.get_challenge(generator)() store = cls.objects.create(challenge=challenge, response=response) return store.hashkey @classmethod - def pick(cls): + def pick(cls) -> str: if not captcha_settings.CAPTCHA_GET_FROM_POOL: return cls.generate_key() @@ -81,7 +83,7 @@ def fallback(): return (store and store.hashkey) or fallback() @classmethod - def create_pool(cls, count=1000): + def create_pool(cls, count: int=1000): assert count > 0 while count > 0: cls.generate_key() From d3c2f8e9400ea46bbecfade013c3c428f12d68b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Gon=C3=A7alves=20da=20Silva?= Date: Thu, 21 Mar 2024 17:11:48 -0300 Subject: [PATCH 4/7] adds type hints for helpers.py --- captcha/helpers.py | 58 +++++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/captcha/helpers.py b/captcha/helpers.py index c4338fa..ef3df66 100644 --- a/captcha/helpers.py +++ b/captcha/helpers.py @@ -1,57 +1,64 @@ import random +from typing import Literal from django.urls import reverse +from PIL.Image import Image as ImageType from captcha.conf import settings -def math_challenge(): +def math_challenge() -> tuple[str, str]: operators = ("+", "*", "-") - operands = (random.randint(1, 10), random.randint(1, 10)) - operator = random.choice(operators) + operands: tuple[int, int] = (random.randint(1, 10), random.randint(1, 10)) + operator: Literal["+", "*", "-"] = random.choice(operators) + if operands[0] < operands[1] and "-" == operator: operands = (operands[1], operands[0]) + challenge = "%d%s%d" % (operands[0], operator, operands[1]) + return ( "{}=".format(challenge.replace("*", settings.CAPTCHA_MATH_CHALLENGE_OPERATOR)), str(eval(challenge)), ) -def random_char_challenge(): +def random_char_challenge() -> tuple[str, str]: chars, ret = "abcdefghijklmnopqrstuvwxyz", "" - for i in range(settings.CAPTCHA_LENGTH): + for _ in range(settings.CAPTCHA_LENGTH): ret += random.choice(chars) return ret.upper(), ret -def unicode_challenge(): +def unicode_challenge() -> tuple[str, str]: chars, ret = "äàáëéèïíîöóòüúù", "" for i in range(settings.CAPTCHA_LENGTH): ret += random.choice(chars) return ret.upper(), ret -def word_challenge(): - fd = open(settings.CAPTCHA_WORDS_DICTIONARY, "r") - lines = fd.readlines() - fd.close() +def word_challenge() -> tuple[str, str]: + with open(settings.CAPTCHA_WORDS_DICTIONARY, "r") as fd: + lines = fd.readlines() + while True: - word = random.choice(lines).strip() + word: str = random.choice(lines).strip() if ( len(word) >= settings.CAPTCHA_DICTIONARY_MIN_LENGTH and len(word) <= settings.CAPTCHA_DICTIONARY_MAX_LENGTH ): break + return word.upper(), word.lower() -def huge_words_and_punctuation_challenge(): +def huge_words_and_punctuation_challenge() -> tuple[str, str]: "Yay, undocumneted. Mostly used to test Issue 39 - http://code.google.com/p/django-simple-captcha/issues/detail?id=39" - fd = open(settings.CAPTCHA_WORDS_DICTIONARY, "rb") - lines = fd.readlines() - fd.close() + with open(settings.CAPTCHA_WORDS_DICTIONARY, "rb") as fd: + lines = fd.readlines() + word = "" + while True: word1 = random.choice(lines).strip() word2 = random.choice(lines).strip() @@ -62,11 +69,12 @@ def huge_words_and_punctuation_challenge(): and len(word) <= settings.CAPTCHA_DICTIONARY_MAX_LENGTH ): break + return word.upper(), word.lower() -def noise_arcs(draw, image): - size = image.size +def noise_arcs(draw, image: ImageType): + size: tuple[int, int] = image.size draw.arc([-20, -20, size[0], 20], 0, 295, fill=settings.CAPTCHA_FOREGROUND_COLOR) draw.line( [-20, 20, size[0] + 20, size[1] - 20], fill=settings.CAPTCHA_FOREGROUND_COLOR @@ -75,31 +83,33 @@ def noise_arcs(draw, image): return draw -def noise_dots(draw, image): - size = image.size - for p in range(int(size[0] * size[1] * 0.1)): +def noise_dots(draw, image: ImageType): + size: tuple[int, int] = image.size + + for _ in range(int(size[0] * size[1] * 0.1)): draw.point( (random.randint(0, size[0]), random.randint(0, size[1])), fill=settings.CAPTCHA_FOREGROUND_COLOR, ) + return draw -def noise_null(draw, image): +def noise_null(draw, image: ImageType): return draw -def post_smooth(image): +def post_smooth(image: ImageType) -> ImageType: from PIL import ImageFilter return image.filter(ImageFilter.SMOOTH) -def captcha_image_url(key): +def captcha_image_url(key) -> str: """Return url to image. Need for ajax refresh and, etc""" return reverse("captcha-image", args=[key]) -def captcha_audio_url(key): +def captcha_audio_url(key) -> str: """Return url to image. Need for ajax refresh and, etc""" return reverse("captcha-audio", args=[key]) From e662d190ac7485eb7b25e134fecdfe6dd5ca19a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Gon=C3=A7alves=20da=20Silva?= Date: Thu, 21 Mar 2024 17:12:11 -0300 Subject: [PATCH 5/7] adds type hints for fields.py --- captcha/fields.py | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/captcha/fields.py b/captcha/fields.py index 443da65..0228dad 100644 --- a/captcha/fields.py +++ b/captcha/fields.py @@ -1,3 +1,5 @@ +from decimal import Decimal +from typing import Any, Mapping from django.core.exceptions import ImproperlyConfigured from django.forms import ValidationError from django.forms.fields import CharField, MultiValueField @@ -41,16 +43,16 @@ class BaseCaptchaTextInput(MultiWidget): Base class for Captcha widgets """ - def __init__(self, attrs=None): + def __init__(self, attrs=None) -> None: widgets = (CaptchaHiddenInput(attrs), CaptchaAnswerInput(attrs)) super().__init__(widgets, attrs) - def decompress(self, value): + def decompress(self, value) -> list[str | None]: if value: return value.split(",") return [None, None] - def fetch_captcha_store(self, name, value, attrs=None, generator=None): + def fetch_captcha_store(self, name, value, attrs=None, generator=None) -> None: """ Fetches a new CaptchaStore This has to be called inside render @@ -72,22 +74,22 @@ def fetch_captcha_store(self, name, value, attrs=None, generator=None): self._key = key self.id_ = self.build_attrs(attrs).get("id", None) - def id_for_label(self, id_): + def id_for_label(self, id_: str) -> str: if id_: return id_ + "_1" return id_ - def image_url(self): + def image_url(self) -> str: return reverse("captcha-image", kwargs={"key": self._key}) - def audio_url(self): + def audio_url(self) -> str | None: return ( reverse("captcha-audio", kwargs={"key": self._key}) if settings.CAPTCHA_FLITE_PATH else None ) - def refresh_url(self): + def refresh_url(self) -> str: return reverse("captcha-refresh") @@ -105,21 +107,23 @@ def __init__( self.generator = generator super().__init__(attrs) - def build_attrs(self, *args, **kwargs): - ret = super().build_attrs(*args, **kwargs) + def build_attrs(self, *args, **kwargs) -> dict[str, str | float | Decimal]: + ret: dict[str, str | float | Decimal] = super().build_attrs(*args, **kwargs) + if self.id_prefix and "id" in ret: ret["id"] = "%s_%s" % (self.id_prefix, ret["id"]) + return ret - def id_for_label(self, id_): - ret = super().id_for_label(id_) + def id_for_label(self, id_: str) -> str: + ret: str = super().id_for_label(id_) if self.id_prefix and "id" in ret: ret = "%s_%s" % (self.id_prefix, ret) return ret - def get_context(self, name, value, attrs): + def get_context(self, name, value, attrs) -> dict[str, Any]: """Add captcha specific variables to context.""" - context = super().get_context(name, value, attrs) + context: dict[str, Any] = super().get_context(name, value, attrs) context["image"] = self.image_url() context["audio"] = self.audio_url() return context @@ -134,7 +138,7 @@ def format_output(self, rendered_widgets): } return ret - def render(self, name, value, attrs=None, renderer=None): + def render(self, name, value, attrs=None, renderer=None) -> str: self.fetch_captcha_store(name, value, attrs, self.generator) extra_kwargs = {} @@ -173,8 +177,10 @@ def compress(self, data_list): def clean(self, value): super().clean(value) response, value[1] = (value[1] or "").strip().lower(), "" + if not settings.CAPTCHA_GET_FROM_POOL: CaptchaStore.remove_expired() + if settings.CAPTCHA_TEST_MODE and response.lower() == "passed": # automatically pass the test try: @@ -183,8 +189,10 @@ def clean(self, value): except CaptchaStore.DoesNotExist: # ignore errors pass + elif not self.required and not response: pass + else: try: CaptchaStore.objects.get( @@ -196,4 +204,5 @@ def clean(self, value): "invalid", gettext_lazy("Invalid CAPTCHA") ) ) + return value From 499168176f5f478db6ab277a1d99cf2db95a5526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Gon=C3=A7alves=20da=20Silva?= Date: Thu, 21 Mar 2024 17:13:01 -0300 Subject: [PATCH 6/7] edits .gitignore to ignore .vscode/ --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a71a3f5..e9236f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.vscode dist build *.pyc From f8c56e33cdf65feddff1de379c68af3041c1e063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Gon=C3=A7alves=20da=20Silva?= <90107526+LucasGoncSilva@users.noreply.github.com> Date: Thu, 21 Mar 2024 19:43:19 -0300 Subject: [PATCH 7/7] fixes models.py function to Callable --- captcha/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/captcha/models.py b/captcha/models.py index 3f36c45..857468a 100644 --- a/captcha/models.py +++ b/captcha/models.py @@ -3,7 +3,7 @@ import logging import random import time -from typing import Final +from typing import Callable, Final from django.db import models from django.utils import timezone @@ -14,7 +14,7 @@ # Heavily based on session key generation in Django # Use the system (hardware-based) random number generator if it exists. -randrange: function = random.SystemRandom().randrange if hasattr(random, "SystemRandom") else random.randrange +randrange: Callable = random.SystemRandom().randrange if hasattr(random, "SystemRandom") else random.randrange MAX_RANDOM_KEY: Final[int] = 18446744073709551616 # 2 << 63