import json import os import random import subprocess import tempfile from io import BytesIO from PIL import Image, ImageDraw, ImageFont from ranged_response import RangedFileResponse from django.core.exceptions import ImproperlyConfigured from django.http import Http404, HttpResponse from captcha.conf import settings from captcha.helpers import captcha_audio_url, captcha_image_url from captcha.models import CaptchaStore # Distance of the drawn text from the top of the captcha image DISTANCE_FROM_TOP = 4 def getsize(font, text): if hasattr(font, "getbbox"): _top, _left, _right, _bottom = font.getbbox(text) return _right - _left, _bottom - _top elif hasattr(font, "getoffset"): return tuple([x + y for x, y in zip(font.getsize(text), font.getoffset(text))]) else: return font.getsize(text) def makeimg(size): if settings.CAPTCHA_BACKGROUND_COLOR == "transparent": image = Image.new("RGBA", size) else: image = Image.new("RGB", size, settings.CAPTCHA_BACKGROUND_COLOR) return image def captcha_image(request, key, scale=1): if scale == 2 and not settings.CAPTCHA_2X_IMAGE: raise Http404 try: store = 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 if isinstance(settings.CAPTCHA_FONT_PATH, str): fontpath = settings.CAPTCHA_FONT_PATH elif isinstance(settings.CAPTCHA_FONT_PATH, (list, tuple)): fontpath = 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" ) if fontpath.lower().strip().endswith("ttf"): font = ImageFont.truetype(fontpath, settings.CAPTCHA_FONT_SIZE * scale) else: font = ImageFont.load(fontpath) if settings.CAPTCHA_IMAGE_SIZE: size = settings.CAPTCHA_IMAGE_SIZE else: size = getsize(font, text) size = (size[0] * 2, int(size[1] * 1.4)) image = makeimg(size) xpos = 2 charlist = [] 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) chardraw.text((0, 0), " %s " % char, font=font, fill="#ffffff") if settings.CAPTCHA_LETTER_ROTATION: charimage = charimage.rotate( random.randrange(*settings.CAPTCHA_LETTER_ROTATION), expand=0, resample=Image.BICUBIC, ) charimage = charimage.crop(charimage.getbbox()) maskimage = Image.new("L", size) maskimage.paste( charimage, ( xpos, DISTANCE_FROM_TOP, xpos + charimage.size[0], DISTANCE_FROM_TOP + charimage.size[1], ), ) size = maskimage.size image = Image.composite(fgimage, image, maskimage) xpos = xpos + 2 + charimage.size[0] if settings.CAPTCHA_IMAGE_SIZE: # centering captcha on the image tmpimg = makeimg(size) tmpimg.paste( image, ( int((size[0] - xpos) / 2), int((size[1] - charimage.size[1]) / 2 - DISTANCE_FROM_TOP), ), ) image = tmpimg.crop((0, 0, size[0], size[1])) else: image = image.crop((0, 0, xpos + 1, size[1])) draw = ImageDraw.Draw(image) for f in settings.noise_functions(): draw = f(draw, image) for f in settings.filter_functions(): image = f(image) out = BytesIO() image.save(out, "PNG") out.seek(0) response = HttpResponse(content_type="image/png") response.write(out.read()) response["Content-length"] = out.tell() # At line :50 above we fixed the random seed so that we always generate the # same image, see: https://github.com/mbi/django-simple-captcha/pull/194 # This is a problem though, because knowledge of the seed will let an attacker # predict the next random (globally). We therefore reset the random here. # Reported in https://github.com/mbi/django-simple-captcha/pull/221 random.seed() return response def captcha_audio(request, key): if settings.CAPTCHA_FLITE_PATH: try: store = 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 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)) subprocess.call([settings.CAPTCHA_FLITE_PATH, "-t", text, "-o", path]) # Add arbitrary noise if sox is installed if settings.CAPTCHA_SOX_PATH: arbnoisepath = str( os.path.join(tempfile.gettempdir(), "%s_arbitrary.wav") % key ) mergedpath = str(os.path.join(tempfile.gettempdir(), "%s_merged.wav") % key) subprocess.call( [ settings.CAPTCHA_SOX_PATH, "-r", "8000", "-n", arbnoisepath, "synth", "2", "brownnoise", "gain", "-15", ] ) subprocess.call( [ settings.CAPTCHA_SOX_PATH, "-m", arbnoisepath, path, "-t", "wavpcm", "-b", "16", mergedpath, ] ) os.remove(arbnoisepath) os.remove(path) os.rename(mergedpath, path) if os.path.isfile(path): response = RangedFileResponse( request, open(path, "rb"), content_type="audio/wav" ) response["Content-Disposition"] = 'attachment; filename="{}.wav"'.format( key ) return response raise Http404 def captcha_refresh(request): """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 = { "key": new_key, "image_url": captcha_image_url(new_key), "audio_url": captcha_audio_url(new_key) if settings.CAPTCHA_FLITE_PATH else None, } return HttpResponse(json.dumps(to_json_response), content_type="application/json")