views.py 7.2 KB

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