Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 22d7a26

Browse files
committed
feat:support animated captchas
1 parent 706bdb0 commit 22d7a26

9 files changed

Lines changed: 161 additions & 31 deletions

File tree

CHANGES

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Version History
55
Version 0.6.3 (unreleased)
66
--------------------------
77
* Test against Django 6.0a1
8+
* Support animated CAPTCHAs (AVIF or GIF)
89

910
Version 0.6.2
1011
-------------

captcha/conf/settings.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import os
22

3+
from PIL.features import check as check_pil_feature
4+
35
from django.conf import settings
46
from django.utils.module_loading import import_string
57

@@ -43,10 +45,10 @@
4345
)
4446
CAPTCHA_GET_FROM_POOL = getattr(settings, "CAPTCHA_GET_FROM_POOL", False)
4547
CAPTCHA_GET_FROM_POOL_TIMEOUT = getattr(settings, "CAPTCHA_GET_FROM_POOL_TIMEOUT", 5)
46-
4748
CAPTCHA_TEST_MODE = getattr(settings, "CAPTCHA_TEST_MODE", False)
48-
4949
CAPTCHA_2X_IMAGE = getattr(settings, "CAPTCHA_2X_IMAGE", True)
50+
CAPTCHA_ANIMATED = getattr(settings, "CAPTCHA_ANIMATED", False)
51+
CAPTCHA_ANIMATED_USE_AVIF = CAPTCHA_ANIMATED and check_pil_feature("avif")
5052

5153
# Failsafe
5254
if CAPTCHA_DICTIONARY_MIN_LENGTH > CAPTCHA_DICTIONARY_MAX_LENGTH:

captcha/helpers.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,14 @@ def noise_null(draw, image):
9191

9292
def random_letter_color_challenge(idx, plaintext_captcha):
9393
# Generate colorful but balanced RGB values
94-
red = random.randint(64, 200)
95-
green = random.randint(64, 200)
96-
blue = random.randint(64, 200)
94+
red = random.randint(64, 150)
95+
green = random.randint(64, 150)
96+
blue = random.randint(64, 150)
9797

9898
# Ensure at least one channel is higher to make it colorful
9999
channels = [red, green, blue]
100-
random.shuffle(channels)
101100
channels[0] = random.randint(150, 255)
101+
random.shuffle(channels)
102102

103103
# Format the color as a hex string
104104
return f"#{channels[0]:02X}{channels[1]:02X}{channels[2]:02X}"

captcha/tests/tests.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,29 @@ def test_audio(self):
8888
self.assertTrue(response.has_header("content-type"))
8989
self.assertEqual(response["content-type"], "audio/wav")
9090

91+
def test_animation(self):
92+
_setting = settings.CAPTCHA_ANIMATED
93+
settings.CAPTCHA_ANIMATED = True
94+
for key in [store.hashkey for store in self.stores.values()]:
95+
response = self.client.get(reverse("captcha-image", kwargs=dict(key=key)))
96+
self.assertEqual(response.status_code, 200)
97+
self.assertTrue(response.has_header("content-type"))
98+
self.assertEqual(
99+
response["content-type"],
100+
"image/avif" if settings.CAPTCHA_ANIMATED_USE_AVIF else "image/gif",
101+
)
102+
settings.CAPTCHA_ANIMATED = _setting
103+
104+
def test_background_color(self):
105+
_setting = settings.CAPTCHA_BACKGROUND_COLOR
106+
settings.CAPTCHA_BACKGROUND_COLOR = "#f00"
107+
for key in [store.hashkey for store in self.stores.values()]:
108+
response = self.client.get(reverse("captcha-image", kwargs=dict(key=key)))
109+
self.assertEqual(response.status_code, 200)
110+
self.assertTrue(response.has_header("content-type"))
111+
self.assertEqual(response["content-type"], "image/png")
112+
settings.CAPTCHA_BACKGROUND_COLOR = _setting
113+
91114
def test_form_submit(self):
92115
r = self.client.get(reverse("captcha-test"))
93116
self.assertEqual(r.status_code, 200)

captcha/views.py

Lines changed: 74 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ def makeimg(size):
3939
return image
4040

4141

42+
def add_noise(image):
43+
draw = ImageDraw.Draw(image)
44+
45+
for f in settings.noise_functions():
46+
draw = f(draw, image)
47+
for f in settings.filter_functions():
48+
image = f(image)
49+
50+
return image
51+
52+
4253
def captcha_image(request, key, scale=1):
4354
if scale == 2 and not settings.CAPTCHA_2X_IMAGE:
4455
raise Http404
@@ -82,7 +93,15 @@ def captcha_image(request, key, scale=1):
8293
else:
8394
charlist.append(char)
8495

96+
if settings.CAPTCHA_ANIMATED:
97+
frames = []
98+
8599
for index, char in enumerate(charlist):
100+
# If we're rendering an animated captcha, render
101+
# each char onto a fresh image.
102+
if settings.CAPTCHA_ANIMATED:
103+
image = makeimg(size)
104+
86105
fgimage = Image.new(
87106
"RGB", size, settings.get_letter_color(index, "".join(charlist))
88107
)
@@ -111,31 +130,66 @@ def captcha_image(request, key, scale=1):
111130
image = Image.composite(fgimage, image, maskimage)
112131
xpos = xpos + 2 + charimage.size[0]
113132

114-
if settings.CAPTCHA_IMAGE_SIZE:
115-
# centering captcha on the image
116-
tmpimg = makeimg(size)
117-
tmpimg.paste(
118-
image,
119-
(
120-
int((size[0] - xpos) / 2),
121-
int((size[1] - charimage.size[1]) / 2 - DISTANCE_FROM_TOP),
122-
),
133+
# Animated captcha: apply individual noise on each frame
134+
if settings.CAPTCHA_ANIMATED:
135+
image = add_noise(image)
136+
frames.append(image)
137+
138+
if settings.CAPTCHA_ANIMATED:
139+
for i, frame in enumerate(frames):
140+
if settings.CAPTCHA_IMAGE_SIZE:
141+
# centering captcha on the image
142+
tmpimg = makeimg(size)
143+
tmpimg.paste(
144+
frame,
145+
(
146+
int((size[0] - xpos) / 2),
147+
int((size[1] - charimage.size[1]) / 2 - DISTANCE_FROM_TOP),
148+
),
149+
)
150+
frames[i] = tmpimg.crop((0, 0, size[0], size[1]))
151+
else:
152+
frames[i] = frame.crop((0, 0, xpos + 1, size[1]))
153+
154+
else:
155+
if settings.CAPTCHA_IMAGE_SIZE:
156+
# centering captcha on the image
157+
tmpimg = makeimg(size)
158+
tmpimg.paste(
159+
image,
160+
(
161+
int((size[0] - xpos) / 2),
162+
int((size[1] - charimage.size[1]) / 2 - DISTANCE_FROM_TOP),
163+
),
164+
)
165+
image = tmpimg.crop((0, 0, size[0], size[1]))
166+
else:
167+
image = image.crop((0, 0, xpos + 1, size[1]))
168+
169+
out = BytesIO()
170+
if settings.CAPTCHA_ANIMATED:
171+
frames[0].save(
172+
out,
173+
"AVIF" if settings.CAPTCHA_ANIMATED_USE_AVIF else "GIF",
174+
save_all=True,
175+
append_images=frames[1:],
176+
optimise=False,
177+
duration=500,
178+
loop=0,
179+
disposal=2,
123180
)
124-
image = tmpimg.crop((0, 0, size[0], size[1]))
181+
content_type = (
182+
"image/avif" if settings.CAPTCHA_ANIMATED_USE_AVIF else "image/gif"
183+
)
184+
125185
else:
126-
image = image.crop((0, 0, xpos + 1, size[1]))
127-
draw = ImageDraw.Draw(image)
186+
image = add_noise(image)
128187

129-
for f in settings.noise_functions():
130-
draw = f(draw, image)
131-
for f in settings.filter_functions():
132-
image = f(image)
188+
image.save(out, "PNG")
189+
content_type = "image/png"
133190

134-
out = BytesIO()
135-
image.save(out, "PNG")
136191
out.seek(0)
137-
138-
response = HttpResponse(content_type="image/png")
192+
response = HttpResponse(content_type=content_type)
139193
response.write(out.read())
140194
response["Content-length"] = out.tell()
141195

docs/advanced.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,13 @@ When set to True, a double resolution version of the captcha image is made serve
193193
Defaults to: True
194194

195195

196+
CAPTCHA_ANIMATED
197+
----------------
198+
199+
Renders an animated CAPTCHA, each frame containing an individual character from the challenge. By default returns an AVIF animation if the installed Pillow version supports it, otherwise falls back to animated GIFs.
200+
201+
Defaults to: False
202+
196203
Rendering
197204
+++++++++
198205

testproject/.coveragerc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ omit =
99

1010
[report]
1111
precision = 2
12-
fail_under = 84.0
12+
fail_under = 80.0

testproject/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,4 @@
8181
CAPTCHA_SOX_PATH = os.environ.get("CAPTCHA_SOX_PATH", None)
8282
CAPTCHA_BACKGROUND_COLOR = "transparent"
8383
CAPTCHA_LETTER_COLOR_FUNCT = "captcha.helpers.random_letter_color_challenge"
84-
CAPTCHA_BACKGROUND_COLOR = "#ffffff"
84+
# CAPTCHA_ANIMATED = True

testproject/templates/home.html

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,55 @@
1-
<body style="background-color: #aba">
1+
<html lang="en">
2+
<head>
3+
<title>Captcha test</title>
4+
<style>
5+
body {
6+
background-color: #eee;
7+
padding: 2rem;
8+
font: 400 1rem/1.5 sans-serif;
9+
}
10+
11+
input[type="text"] {
12+
display: inline-block;
13+
height: 32px;
14+
width: 4.5rem;
15+
vertical-align: top;
16+
margin: 0 0.25rem;
17+
border: 1px solid #ccc;
18+
text-transform: uppercase;
19+
}
20+
input[type="submit"] {
21+
display: inline-block;
22+
height: 32px;
23+
width: auto;
24+
border: 1px solid #ccc;
25+
background-color: black;
26+
color: white;
27+
padding: 0.25rem 0.5rem;
28+
margin: 1rem 0;
29+
}
30+
p.ok {
31+
color: forestgreen;
32+
}
33+
ul.errorlist {
34+
list-style-type: none;
35+
color: red;
36+
padding: 0;
37+
}
38+
</style>
39+
</head>
40+
<body>
241
<form action="." method="post">
342
{% csrf_token %}
443

5-
{{form.captcha.errors}}
6-
{{form.captcha}}
44+
{% if 'ok' in request.GET %}
45+
<p class="ok">Correct answer</p>
46+
{% endif %}
47+
{{ form.captcha.errors }}
48+
{{ form.captcha }}
749

850
<div>
951
<input type="submit" value="Submit" />
1052
</div>
1153
</form>
1254
</body>
55+
</html>

0 commit comments

Comments
 (0)