Coverage for /home/marco/Code/django-simple-captcha/captcha/views.py: 67.70%
121 statements
« prev ^ index » next coverage.py v7.6.5, created at 2024-11-15 09:22 +0100
« prev ^ index » next coverage.py v7.6.5, created at 2024-11-15 09:22 +0100
1import json
2import os
3import random
4import subprocess
5import tempfile
6from io import BytesIO
8from PIL import Image, ImageDraw, ImageFont
9from ranged_response import RangedFileResponse
11from django.core.exceptions import ImproperlyConfigured
12from django.http import Http404, HttpResponse
14from captcha.conf import settings
15from captcha.helpers import captcha_audio_url, captcha_image_url
16from captcha.models import CaptchaStore
19# Distance of the drawn text from the top of the captcha image
20DISTANCE_FROM_TOP = 4
23def getsize(font, text):
24 if hasattr(font, "getbbox"): 24 ↛ 27line 24 didn't jump to line 27 because the condition on line 24 was always true
25 _top, _left, _right, _bottom = font.getbbox(text)
26 return _right - _left, _bottom - _top
27 elif hasattr(font, "getoffset"):
28 return tuple([x + y for x, y in zip(font.getsize(text), font.getoffset(text))])
29 else:
30 return font.getsize(text)
33def makeimg(size):
34 if settings.CAPTCHA_BACKGROUND_COLOR == "transparent": 34 ↛ 37line 34 didn't jump to line 37 because the condition on line 34 was always true
35 image = Image.new("RGBA", size)
36 else:
37 image = Image.new("RGB", size, settings.CAPTCHA_BACKGROUND_COLOR)
38 return image
41def captcha_image(request, key, scale=1):
42 if scale == 2 and not settings.CAPTCHA_2X_IMAGE: 42 ↛ 43line 42 didn't jump to line 43 because the condition on line 42 was never true
43 raise Http404
44 try:
45 store = CaptchaStore.objects.get(hashkey=key)
46 except CaptchaStore.DoesNotExist:
47 # HTTP 410 Gone status so that crawlers don't index these expired urls.
48 return HttpResponse(status=410)
50 random.seed(key) # Do not generate different images for the same key
52 text = store.challenge
54 if isinstance(settings.CAPTCHA_FONT_PATH, str):
55 fontpath = settings.CAPTCHA_FONT_PATH
56 elif isinstance(settings.CAPTCHA_FONT_PATH, (list, tuple)):
57 fontpath = random.choice(settings.CAPTCHA_FONT_PATH)
58 else:
59 raise ImproperlyConfigured(
60 "settings.CAPTCHA_FONT_PATH needs to be a path to a font or list of paths to fonts"
61 )
63 if fontpath.lower().strip().endswith("ttf"): 63 ↛ 66line 63 didn't jump to line 66 because the condition on line 63 was always true
64 font = ImageFont.truetype(fontpath, settings.CAPTCHA_FONT_SIZE * scale)
65 else:
66 font = ImageFont.load(fontpath)
68 if settings.CAPTCHA_IMAGE_SIZE:
69 size = settings.CAPTCHA_IMAGE_SIZE
70 else:
71 size = getsize(font, text)
72 size = (size[0] * 2, int(size[1] * 1.4))
74 image = makeimg(size)
75 xpos = 2
77 charlist = []
78 for char in text:
79 if char in settings.CAPTCHA_PUNCTUATION and len(charlist) >= 1:
80 charlist[-1] += char
81 else:
82 charlist.append(char)
84 for index, char in enumerate(charlist):
85 fgimage = Image.new(
86 "RGB", size, settings.get_letter_color(index, "".join(charlist))
87 )
88 charimage = Image.new("L", getsize(font, " %s " % char), "#000000")
89 chardraw = ImageDraw.Draw(charimage)
90 chardraw.text((0, 0), " %s " % char, font=font, fill="#ffffff")
91 if settings.CAPTCHA_LETTER_ROTATION: 91 ↛ 97line 91 didn't jump to line 97 because the condition on line 91 was always true
92 charimage = charimage.rotate(
93 random.randrange(*settings.CAPTCHA_LETTER_ROTATION),
94 expand=0,
95 resample=Image.BICUBIC,
96 )
97 charimage = charimage.crop(charimage.getbbox())
98 maskimage = Image.new("L", size)
100 maskimage.paste(
101 charimage,
102 (
103 xpos,
104 DISTANCE_FROM_TOP,
105 xpos + charimage.size[0],
106 DISTANCE_FROM_TOP + charimage.size[1],
107 ),
108 )
109 size = maskimage.size
110 image = Image.composite(fgimage, image, maskimage)
111 xpos = xpos + 2 + charimage.size[0]
113 if settings.CAPTCHA_IMAGE_SIZE:
114 # centering captcha on the image
115 tmpimg = makeimg(size)
116 tmpimg.paste(
117 image,
118 (
119 int((size[0] - xpos) / 2),
120 int((size[1] - charimage.size[1]) / 2 - DISTANCE_FROM_TOP),
121 ),
122 )
123 image = tmpimg.crop((0, 0, size[0], size[1]))
124 else:
125 image = image.crop((0, 0, xpos + 1, size[1]))
126 draw = ImageDraw.Draw(image)
128 for f in settings.noise_functions():
129 draw = f(draw, image)
130 for f in settings.filter_functions():
131 image = f(image)
133 out = BytesIO()
134 image.save(out, "PNG")
135 out.seek(0)
137 response = HttpResponse(content_type="image/png")
138 response.write(out.read())
139 response["Content-length"] = out.tell()
141 # At line :50 above we fixed the random seed so that we always generate the
142 # same image, see: https://github.com/mbi/django-simple-captcha/pull/194
143 # This is a problem though, because knowledge of the seed will let an attacker
144 # predict the next random (globally). We therefore reset the random here.
145 # Reported in https://github.com/mbi/django-simple-captcha/pull/221
146 random.seed()
148 return response
151def captcha_audio(request, key):
152 if settings.CAPTCHA_FLITE_PATH:
153 try:
154 store = CaptchaStore.objects.get(hashkey=key)
155 except CaptchaStore.DoesNotExist:
156 # HTTP 410 Gone status so that crawlers don't index these expired urls.
157 return HttpResponse(status=410)
159 text = store.challenge
160 if "captcha.helpers.math_challenge" == settings.CAPTCHA_CHALLENGE_FUNCT:
161 text = text.replace("*", "times").replace("-", "minus").replace("+", "plus")
162 else:
163 text = ", ".join(list(text))
164 path = str(os.path.join(tempfile.gettempdir(), "%s.wav" % key))
165 subprocess.call([settings.CAPTCHA_FLITE_PATH, "-t", text, "-o", path])
167 # Add arbitrary noise if sox is installed
168 if settings.CAPTCHA_SOX_PATH:
169 arbnoisepath = str(
170 os.path.join(tempfile.gettempdir(), "%s_arbitrary.wav") % key
171 )
172 mergedpath = str(os.path.join(tempfile.gettempdir(), "%s_merged.wav") % key)
173 subprocess.call(
174 [
175 settings.CAPTCHA_SOX_PATH,
176 "-r",
177 "8000",
178 "-n",
179 arbnoisepath,
180 "synth",
181 "2",
182 "brownnoise",
183 "gain",
184 "-15",
185 ]
186 )
187 subprocess.call(
188 [
189 settings.CAPTCHA_SOX_PATH,
190 "-m",
191 arbnoisepath,
192 path,
193 "-t",
194 "wavpcm",
195 "-b",
196 "16",
197 mergedpath,
198 ]
199 )
200 os.remove(arbnoisepath)
201 os.remove(path)
202 os.rename(mergedpath, path)
204 if os.path.isfile(path):
205 # Move the response file to a filelike that will be deleted on close
206 temporary_file = tempfile.TemporaryFile()
207 with open(path, "rb") as original_file:
208 temporary_file.write(original_file.read())
209 temporary_file.seek(0)
210 os.remove(path)
212 response = RangedFileResponse(
213 request, temporary_file, content_type="audio/wav"
214 )
215 response["Content-Disposition"] = 'attachment; filename="{}.wav"'.format(
216 key
217 )
218 return response
219 raise Http404
222def captcha_refresh(request):
223 """Return json with new captcha for ajax refresh request"""
224 if not request.headers.get("x-requested-with") == "XMLHttpRequest": 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true
225 raise Http404
227 new_key = CaptchaStore.pick()
228 to_json_response = {
229 "key": new_key,
230 "image_url": captcha_image_url(new_key),
231 "audio_url": captcha_audio_url(new_key)
232 if settings.CAPTCHA_FLITE_PATH
233 else None,
234 }
235 return HttpResponse(json.dumps(to_json_response), content_type="application/json")