diff --git a/.gitreview b/.gitreview new file mode 100644 index 0000000..aaa93db --- /dev/null +++ b/.gitreview @@ -0,0 +1,4 @@ +[gerrit] +host=review.openstack.org +port=29418 +project=openstack-ci/lodgeit.git diff --git a/lodgeit/lib/antispam.py b/lodgeit/lib/antispam.py new file mode 100644 index 0000000..26bf9da --- /dev/null +++ b/lodgeit/lib/antispam.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +""" + lodgeit.lib.antispam + ~~~~~~~~~~~~~~~~~~~~ + + Fight stupid spammers. + + :copyright: 2007-2008 by Armin Ronacher, Christopher Grebs. + :license: BSD +""" +from __future__ import division +import re +from operator import sub +from itertools import starmap + + +_url_pattern = ( + r'(?:(?:https?|ftps?|file|ssh|mms|irc|rsync|smb)://|' + r'(?:mailto|telnet|s?news|sips?|skype):)' +) + +_link_re = re.compile(r'''(?xi) + (%(url)s[^\s\'"]+)| + (.*?) +''' % dict(url=_url_pattern)) + + +# maximum number of links in percent +MAX_LINK_PERCENTAGE = 30 + +# maximum number of links in the text (hard limit) +MAX_LINK_NUMBER = 15 + + +def check_for_link_spam(code): + """It's spam if more than 30% of the text are links.""" + spans = [x.span() for x in _link_re.finditer(code)] + if len(spans) > MAX_LINK_PERCENTAGE: + return True + return (sum(starmap(sub, spans)) * -100) / (len(code) or 1) \ + > MAX_LINK_PERCENTAGE + + +def is_spam(code): + """Check if the code provided contains spam.""" + return check_for_link_spam(code) diff --git a/lodgeit/lib/captcha.py b/lodgeit/lib/captcha.py new file mode 100644 index 0000000..7ec2111 --- /dev/null +++ b/lodgeit/lib/captcha.py @@ -0,0 +1,412 @@ +# -*- coding: utf-8 -*- +""" + lodgeit.captcha + ~~~~~~~~~~~~~~~ + + A module that produces image and audio captchas. Uses some code of + PyCAPTCHA by Micah Dowty and was originally used in inyoka. + + + :copyright: Copyright 2007-2008 by Armin Ronacher, Micah Dowty. + :license: BSD +""" +import random +import colorsys +import math +from os import listdir +from os.path import abspath, join, dirname, pardir +from PIL import ImageFont, ImageDraw, Image, ImageChops, ImageColor +from werkzeug import Response +from lodgeit import local + +try: + from hashlib import sha1 +except ImportError: + from sha import new as sha1 + +resource_path = abspath(join(dirname(__file__), pardir, 'res')) + + +def check_hashed_solution(solution, hashed_solution=None, secret_key=None): + """Check a solution against the hashed solution from the first + request by using the secret key. + """ + if hashed_solution is None: + hashed_solution = local.request.session.get('captcha_id') + if hashed_solution is None: + return False + return hashed_solution == calculate_hash(solution, secret_key) + + +def calculate_hash(solution, secret_key=None): + """Calculate the hash.""" + if secret_key is None: + secret_key = local.application.secret_key + return sha1('%s|%s' % ( + secret_key, + solution.encode('utf-8') + )).hexdigest() + + +def generate_word(): + """This function returns a pronounceable word.""" + consonants = 'bcdfghjklmnprstvwz' + vowels = 'aeiou' + both = consonants + vowels + length = random.randrange(8, 12) + return ''.join( + random.choice(consonants) + + random.choice(vowels) + + random.choice(both) for x in xrange(length // 3) + )[:length] + + +def get_random_resource(type, prefix=None): + """Return a random resource of a given type.""" + path = join(resource_path, type) + choices = (x for x in listdir(path) if not x.startswith('.')) + if prefix is not None: + choices = (x for x in choices if x.startswith(prefix)) + return join(path, random.choice(tuple(choices))) + + +def random_color(saturation=0.5, lumination=None): + """Return a random number with the given saturation.""" + hue = random.random() + if lumination is None: + lumination = random.random() + r, g, b = colorsys.hls_to_rgb(hue, lumination, saturation) + return '#%02x%02x%02x' % ( + int(r * 255) & 0xff, + int(g * 255) & 0xff, + int(b * 255) & 0xff + ) + + +class Captcha(object): + """Represents a captcha.""" + default_size = (300, 100) + + def __init__(self, solution=None): + if solution is None: + solution = generate_word() + self.solution = solution + self.layers = [ + RandomBackground(), + RandomDistortion() + ] + text_layer = TextLayer(self.solution, bg=self.layers[0].bg) + self.layers.extend((text_layer, SineWarp())) + + def hash_solution(self, secret_key=None): + """Return the solution as hashed value.""" + return calculate_hash(self.solution, secret_key) + + def render_image(self, size=None): + if size is None: + size = self.default_size + image = Image.new('RGBA', size) + for layer in self.layers: + image = layer.render(image) + return image + + def get_response(self, size=None, set_cookie=False): + response = Response(mimetype='image/png') + self.render_image(size=None).save(response.stream, 'PNG') + if set_cookie: + local.request.session['captcha_id'] = self.hash_solution() + return response + + +class Layer(object): + """Baseclass for a captcha layer.""" + bg = 'dark' + + def render(self, image): + return image + + +class TextLayer(Layer): + """Add text to the captcha.""" + bg = 'transparent' + + def __init__(self, text, min_size=32, max_size=48, bg='dark'): + self.text = text + self.alignment = (random.random(), random.random()) + if bg == 'dark': + color = random_color(saturation=0.3, lumination=0.8) + else: + color = random_color(saturation=0.1, lumination=0.1) + self.text_color = color + self.transparency = random.randint(20, 60) + self.font = ImageFont.truetype(get_random_resource('fonts'), + random.randrange(min_size, max_size)) + + def render(self, image): + text_layer = Image.new('RGB', image.size, (0, 0, 0)) + alpha = Image.new('L', image.size, 0) + + # draw grayscale image white on black + text_image = Image.new('L', image.size, 0) + draw = ImageDraw.Draw(text_image) + text_size = self.font.getsize(self.text) + x = int((image.size[0] - text_size[0]) * self.alignment[0] + 0.5) + y = int((image.size[1] - text_size[1]) * self.alignment[1] + 0.5) + draw.text((x, y), self.text, font=self.font, + fill=255 - self.transparency) + + # colorize the text and calculate the alpha channel + alpha = ImageChops.lighter(alpha, text_image) + color_layer = Image.new('RGBA', image.size, self.text_color) + mask = Image.eval(text_image, lambda x: 255 * (x != 0)) + text_layer = Image.composite(color_layer, text_layer, mask) + + # paste the text on the image with the correct alphachannel + image.paste(text_layer, alpha) + return image + + +class CombinedLayer(Layer): + """Combines multiple layers.""" + + def __init__(self, layers): + self.layers = layers + if layers: + self.bg = layers[0].bg + + def render(self, image): + for layer in self.layers: + image = layer.render(image) + return image + + +class RandomBackground(CombinedLayer): + """Selects a random background.""" + + def __init__(self): + layers = [random.choice([SolidColor, DarkBackground, + LightBackground])()] + for x in xrange(random.randrange(1, 4)): + layers.append(random.choice([ + NoiseBackground, + GridBackground + ])()) + CombinedLayer.__init__(self, layers) + self.bg = layers[0].bg + + +class RandomDistortion(CombinedLayer): + """Selects a random distortion.""" + background = 'transparent' + + def __init__(self): + layers = [] + for x in xrange(random.randrange(1, 3)): + layers.append(random.choice(( + WigglyBlocks, + SineWarp + ))()) + CombinedLayer.__init__(self, layers) + + +class Picture(Layer): + """Add a background to the captcha.""" + + def __init__(self, picture): + self.image = Image.open(picture) + self.offset = (random.random(), random.random()) + + def render(self, image): + tile = self.image + for j in xrange(-1, int(image.size[1] / tile.size[1]) + 1): + for i in xrange(-1, int(image.size[0] / tile.size[0]) + 1): + dest = (int((self.offset[0] + i) * tile.size[0]), + int((self.offset[1] + j) * tile.size[1])) + image.paste(tile, dest) + return image + + +class LightBackground(Picture): + bg = 'light' + + def __init__(self): + Picture.__init__(self, get_random_resource('backgrounds/light')) + + +class DarkBackground(Picture): + + def __init__(self): + Picture.__init__(self, get_random_resource('backgrounds/dark')) + + +class NoiseBackground(Layer): + """Add some noise as background. You can combine this with another + background layer. + """ + bg = 'transparent' + + def __init__(self, saturation=0.1, num_dots=None): + self.saturation = saturation + self.num_dots = random.randrange(300, 500) + self.seed = random.random() + + def render(self, image): + r = random.Random(self.seed) + for i in xrange(self.num_dots): + dot_size = random.randrange(1, 5) + bx = int(r.uniform(0, image.size[0] - dot_size)) + by = int(r.uniform(0, image.size[1] - dot_size)) + image.paste(random_color(self.saturation, 0.4), + (bx, by, bx + dot_size - 1, + by + dot_size - 1)) + return image + + +class GridBackground(Layer): + """Add a grid as background. You can combine this with another + background layer. + """ + bg = 'transparent' + + def __init__(self, size=None, color=None): + if size is None: + size = random.randrange(10, 50) + if color is None: + color = random_color(0, 0.4) + self.size = size + self.color = color + self.offset = (random.uniform(0, self.size), + random.uniform(0, self.size)) + + def render(self, image): + draw = ImageDraw.Draw(image) + for i in xrange(image.size[0] / self.size + 1): + draw.line((i * self.size + self.offset[0], 0, + i * self.size + self.offset[0], image.size[1]), + fill=self.color) + for i in xrange(image.size[0] / self.size + 1): + draw.line((0, i * self.size + self.offset[1], + image.size[0], i * self.size+self.offset[1]), + fill=self.color) + return image + + +class SolidColor(Layer): + """A solid color background. Very weak on its own, but good + to combine with other backgrounds. + """ + + def __init__(self, color=None): + if color is None: + color = random_color(0.2, random.random() > 0.5 and 0.3 or 0.7) + self.color = ImageColor.getrgb(color) + if colorsys.rgb_to_hls(*[x / 255.0 for x in self.color])[1] > 0.5: + self.bg = 'light' + + def render(self, image): + image.paste(self.color) + return image + + +class WigglyBlocks(Layer): + """Randomly select and shift blocks of the image""" + bg = 'transparent' + + def __init__(self, block_size=None, sigma=0.01, iterations=None): + if block_size is None: + block_size = random.randrange(15, 25) + if iterations is None: + iterations = random.randrange(250, 350) + self.block_size = block_size + self.sigma = sigma + self.iterations = iterations + self.seed = random.random() + + def render(self, image): + r = random.Random(self.seed) + for i in xrange(self.iterations): + # Select a block + bx = int(r.uniform(0, image.size[0] - self.block_size)) + by = int(r.uniform(0, image.size[1] - self.block_size)) + block = image.crop((bx, by, bx + self.block_size - 1, + by + self.block_size - 1)) + + # Figure out how much to move it. + # The call to floor() is important so we always round toward + # 0 rather than to -inf. Just int() would bias the block motion. + mx = int(math.floor(r.normalvariate(0, self.sigma))) + my = int(math.floor(r.normalvariate(0, self.sigma))) + + # Now actually move the block + image.paste(block, (bx+mx, by+my)) + return image + + +class WarpBase(Layer): + """Abstract base class for image warping. Subclasses define a function + that maps points in the output image to points in the input image. This + warping engine runs a grid of points through this transform and uses PIL's + mesh transform to warp the image. + """ + bg = 'transparent' + filtering = Image.BILINEAR + resolution = 10 + + def get_transform(self, image): + """Return a transformation function, subclasses should override this""" + return lambda x, y: (x, y) + + def render(self, image): + r = self.resolution + x_points = image.size[0] / r + 2 + y_points = image.size[1] / r + 2 + f = self.get_transform(image) + + # Create a list of arrays with transformed points + x_rows = [] + y_rows = [] + for j in xrange(y_points): + x_row = [] + y_row = [] + for i in xrange(x_points): + x, y = f(i * r, j * r) + + # Clamp the edges so we don't get black undefined areas + x = max(0, min(image.size[0] - 1, x)) + y = max(0, min(image.size[1] - 1, y)) + + x_row.append(x) + y_row.append(y) + x_rows.append(x_row) + y_rows.append(y_row) + + # Create the mesh list, with a transformation for + # each square between points on the grid + mesh = [] + for j in xrange(y_points - 1): + for i in xrange(x_points-1): + mesh.append(( + # Destination rectangle + (i * r, j * r, (i + 1) * r, (j + 1) * r), + # Source quadrilateral + (x_rows[j][i], y_rows[j][i], + x_rows[j + 1][i], y_rows[j+1][i], + x_rows[j + 1][i + 1], y_rows[j + 1][i + 1], + x_rows[j][i+1], y_rows[j][i + 1]), + )) + return image.transform(image.size, Image.MESH, mesh, self.filtering) + + +class SineWarp(WarpBase): + """Warp the image using a random composition of sine waves""" + + def __init__(self, amplitude_range=(3, 6.5), period_range=(0.04, 0.1)): + self.amplitude = random.uniform(*amplitude_range) + self.period = random.uniform(*period_range) + self.offset = (random.uniform(0, math.pi * 2 / self.period), + random.uniform(0, math.pi * 2 / self.period)) + + def get_transform(self, image): + return (lambda x, y, a=self.amplitude, p=self.period, + o=self.offset: (math.sin((y + o[0]) * p) * a + x, + math.sin((x + o[1]) * p) * a + y))