Added captcha to lodgeit.

This commit is contained in:
mitsuhiko 2008-06-23 12:47:33 +02:00
parent 3fec765590
commit cde440c798
59 changed files with 483 additions and 24 deletions

View File

@ -24,11 +24,12 @@ from lodgeit.controllers import get_controller
from lodgeit.lib.antispam import AntiSpam
class LodgeIt(object):
"""The WSGI Application"""
def __init__(self, dburi):
def __init__(self, dburi, secret_key):
#: the secret key used by the captcha
self.secret_key = secret_key
#: name of the error handler
self.not_found = ('static/not_found', {})
self.engine = sqlalchemy.create_engine(dburi)
@ -67,10 +68,10 @@ class LodgeIt(object):
[_local_manager.cleanup, session.remove])
def make_app(dburi, debug=False, shell=False):
def make_app(dburi, secret_key, debug=False, shell=False):
"""Apply the used middlewares and create the application."""
static_path = os.path.join(os.path.dirname(__file__), 'static')
app = LodgeIt(dburi)
app = LodgeIt(dburi, secret_key)
if debug:
app.engine.echo = True
if not shell:

View File

@ -16,6 +16,7 @@ from lodgeit.controllers import BaseController
from lodgeit.database import session, Paste
from lodgeit.lib.highlighting import LANGUAGES, STYLES, get_style
from lodgeit.lib.pagination import generate_pagination
from lodgeit.lib.captcha import check_hashed_solution, Captcha
MAX_LINE_LENGTH = 300
@ -29,6 +30,7 @@ class PasteController(BaseController):
code = error = ''
language = 'text'
pastes = session.query(Paste)
show_captcha = False
if ctx.request.method == 'POST':
code = ctx.request.form.get('code')
@ -41,7 +43,14 @@ class PasteController(BaseController):
spam = ctx.request.form.get('webpage') or \
ctx.application.antispam.is_spam(code)
if spam:
error = 'contains spam'
error = 'your paste contains spam'
captcha = ctx.request.form.get('captcha')
if captcha:
if check_hashed_solution(captcha):
error = None
else:
error += ' and the CAPTCHA solution was incorrect'
show_captcha = True
if code and language and not error:
paste = Paste(code, language, parent, ctx.request.user_hash)
session.save(paste)
@ -60,7 +69,8 @@ class PasteController(BaseController):
parent=parent,
code=code,
language=language,
error=error
error=error,
show_captcha=show_captcha
)
def show_paste(self, paste_id, raw=False):
@ -122,9 +132,7 @@ class PasteController(BaseController):
)
def compare_paste(self, new_id=None, old_id=None):
"""
Render a diff view for two pastes.
"""
"""Render a diff view for two pastes."""
# redirect for the compare form box
if old_id is new_id is None:
old_id = ctx.request.form.get('old', '-1').lstrip('#')
@ -142,9 +150,7 @@ class PasteController(BaseController):
)
def unidiff_paste(self, new_id=None, old_id=None):
"""
Render an udiff for the two pastes.
"""
"""Render an udiff for the two pastes."""
pastes = session.query(Paste)
old = pastes.filter(Paste.c.paste_id == old_id).first()
new = pastes.filter(Paste.c.paste_id == new_id).first()
@ -153,8 +159,7 @@ class PasteController(BaseController):
return Response(old.compare_to(new), mimetype='text/plain')
def set_colorscheme(self):
"""
Minimal view that updates the style session cookie. Redirects
"""Minimal view that updates the style session cookie. Redirects
back to the page the user is coming from.
"""
style_name = ctx.request.form.get('style')
@ -163,4 +168,8 @@ class PasteController(BaseController):
resp.set_cookie('style', style_name)
return resp
def show_captcha(self):
"""Show a captcha."""
return Captcha().get_response(set_cookie=True)
controller = PasteController

View File

@ -24,7 +24,7 @@ LINK_RE = re.compile(r'%s[^\s\'"]+\S' % _url_pattern)
def percentize(matched, length):
return matched * 100.0 / length
return matched * 100.0 / (length or 1)
class AntiSpam(object):
@ -36,7 +36,7 @@ class AntiSpam(object):
def check_for_link_spam(self, code):
lengths = (x.span() for x in LINK_RE.finditer(code))
return percentize(sum(i[1]-i[0] for i in lengths),
len(code) or 1) > 50
len(code)) > 50
def is_spam(self, code):
"""Check if one of the fields provides contains spam."""

415
lodgeit/lib/captcha.py Normal file
View File

@ -0,0 +1,415 @@
# -*- 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 by Armin Ronacher, Micah Dowty.
:license: GNU GPL.
"""
import random
import colorsys
import math
try:
from hashlib import sha1
except ImportError:
from sha import new as sha1
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.utils import ctx
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 = ctx.request.cookies.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 = ctx.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:
response.set_cookie('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)
f = get_random_resource('fonts')
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))

BIN
lodgeit/res/.DS_Store vendored Normal file

Binary file not shown.

4
lodgeit/res/LICENSE Normal file
View File

@ -0,0 +1,4 @@
Background Image Patterns
* 1-12 from PyCAPTCHA
* 12-15 from orgdot.com/3dstuff

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
lodgeit/res/fonts/Vera.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -149,6 +149,21 @@ div.related h3 a {
text-decoration: none;
}
div.notification div.captcha {
background-color: #222;
margin: 10px;
padding: 10px;
}
div.notification div.captcha p {
padding: 0;
}
div.notification div.captcha img {
display: block;
margin: 8px 0 8px 0;
}
div.related h3 a:hover {
background-color: #1d89a4;
color: white;

View File

@ -20,6 +20,9 @@ urlmap = Map([
Rule('/unidiff/<int:new_id>/<int:old_id>/', endpoint='pastes/unidiff_paste'),
Rule('/tree/<int:paste_id>/', endpoint='pastes/show_tree'),
# captcha for new paste
Rule('/_captcha.png', endpoint='pastes/show_captcha'),
# paste list
Rule('/all/', endpoint='pastes/show_all'),
Rule('/all/<int:page>/', endpoint='pastes/show_all'),

View File

@ -2,23 +2,31 @@
{% set page_title = 'New Paste' %}
{% set active_page = 'new' %}
{% block body %}
<form action="/" method="post" class="submitform">
{%- if error %}
<div class="notification">
<h3>Error While Pasting</h3>
<p>Could not submit your paste because {{ error|e }}.</p>
{%- if show_captcha %}
<div class="captcha">
<p>Please fill out the CAPTCHA to proceed:</p>
<img src="/_captcha.png" alt="a captcha you can't see. Sorry :(">
<input type="text" name="captcha" size="20">
</div>
{%- else %}
<p><a href="javascript:LodgeIt.hideNotification()">hide this message</a></p>
{%- endif %}
</div>
{%- endif %}
<form action="/" method="post" class="submitform">
{% if parent %}
<input type="hidden" name="parent" value="{{ parent.paste_id }}">
{% endif %}
<textarea name="code" rows="10" cols="80">{{ code|e }}</textarea>
<select name="language">
{% for key, caption in languages|dictsort(true, 'value') -%}
{%- for key, caption in languages|dictsort(true, 'value') -%}
<option value="{{ key }}"{% if language == key
%} selected="selected"{% endif %}>{{ caption|e }}</option>
{% endfor %}
{%- endfor %}
</select>
<input type="text" value="webpage" id="webpage">
<input type="submit" value="Paste!">

View File

@ -1,18 +1,22 @@
from lodgeit.application import make_app
from lodgeit.utils import ctx
from lodgeit.database import session
import os
from werkzeug import script
from werkzeug.serving import run_simple
from werkzeug.utils import create_environ, run_wsgi_app
from lodgeit.application import make_app
from lodgeit.utils import ctx
from lodgeit.database import session
dburi = 'sqlite:////tmp/lodgeit.db'
secret_key = os.urandom(50)
def run_app(app, path='/'):
env = create_environ(path)
env = create_environ(path, secret_key)
return run_wsgi_app(app, env)
action_runserver = script.make_runserver(
lambda: make_app(dburi),
lambda: make_app(dburi, secret_key),
use_reloader=True)
action_shell = script.make_shell(