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

1import json 

2import os 

3import random 

4import subprocess 

5import tempfile 

6from io import BytesIO 

7 

8from PIL import Image, ImageDraw, ImageFont 

9from ranged_response import RangedFileResponse 

10 

11from django.core.exceptions import ImproperlyConfigured 

12from django.http import Http404, HttpResponse 

13 

14from captcha.conf import settings 

15from captcha.helpers import captcha_audio_url, captcha_image_url 

16from captcha.models import CaptchaStore 

17 

18 

19# Distance of the drawn text from the top of the captcha image 

20DISTANCE_FROM_TOP = 4 

21 

22 

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) 

31 

32 

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 

39 

40 

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) 

49 

50 random.seed(key) # Do not generate different images for the same key 

51 

52 text = store.challenge 

53 

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 ) 

62 

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) 

67 

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)) 

73 

74 image = makeimg(size) 

75 xpos = 2 

76 

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) 

83 

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) 

99 

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] 

112 

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) 

127 

128 for f in settings.noise_functions(): 

129 draw = f(draw, image) 

130 for f in settings.filter_functions(): 

131 image = f(image) 

132 

133 out = BytesIO() 

134 image.save(out, "PNG") 

135 out.seek(0) 

136 

137 response = HttpResponse(content_type="image/png") 

138 response.write(out.read()) 

139 response["Content-length"] = out.tell() 

140 

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() 

147 

148 return response 

149 

150 

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) 

158 

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]) 

166 

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) 

203 

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) 

211 

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 

220 

221 

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 

226 

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")