From a6e9502145995d48ca0482a23a4b0d48027e4753 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Aur=C3=A9lien=20Calonne?= <aurel@nexedi.com>
Date: Wed, 27 Jan 2010 09:07:12 +0000
Subject: [PATCH] add Captcha field, work done by Pierre

git-svn-id: https://svn.erp5.org/repos/public/erp5/trunk@32007 20353a03-c40f-0410-a6d1-a30d3c3de9de
---
 product/ERP5Form/CaptchaField.py            | 325 ++++++++++++++++++++
 product/ERP5Form/CaptchasDotNet.py          | 137 +++++++++
 product/ERP5Form/__init__.py                |   5 +-
 product/ERP5Form/dtml/captchaFieldEdit.dtml |  98 ++++++
 4 files changed, 564 insertions(+), 1 deletion(-)
 create mode 100644 product/ERP5Form/CaptchaField.py
 create mode 100644 product/ERP5Form/CaptchasDotNet.py
 create mode 100644 product/ERP5Form/dtml/captchaFieldEdit.dtml

diff --git a/product/ERP5Form/CaptchaField.py b/product/ERP5Form/CaptchaField.py
new file mode 100644
index 0000000000..50c5ba3c4a
--- /dev/null
+++ b/product/ERP5Form/CaptchaField.py
@@ -0,0 +1,325 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Copyright (c) 2010 Nexedi SARL and Contributors. All Rights Reserved.
+#                    Pierre Ducroquet <pierre.ducroquet@nexedi.com>
+#
+# WARNING: This program as such is intended to be used by professional
+# programmers who take the whole responsability of assessing all potential
+# consequences resulting from its eventual inadequacies and bugs
+# End users who are looking for a ready-to-use solution with commercial
+# garantees and support are strongly adviced to contract a Free Software
+# Service Company
+#
+# This program is Free Software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
+#
+##############################################################################
+
+from Products.Formulator import Widget, Validator
+from Products.Formulator.Field import ZMIField
+from Products.Formulator.DummyField import fields
+from Products.Formulator.Errors import ValidationError
+from Products.PageTemplates.PageTemplateFile import PageTemplateFile
+from AccessControl import ClassSecurityInfo
+from Products.ERP5Type.Globals import DTMLFile
+
+import CaptchasDotNet
+
+import string
+import random
+import md5
+import time
+
+from zope.interface import Interface
+from zope.interface import implements
+
+_field_value_cache = {}
+def purgeFieldValueCache():
+  _field_value_cache.clear()
+  
+class ICaptchaProvider(Interface):
+  """The CaptchaProvider interface provides a captcha generator."""
+
+  def generate(self, field):
+    """Returns a tuple (key, valid_answer) for this captcha.
+    That key is never sent directly to the client, it is always hashed before."""
+
+  def getHTML(self, field, captcha_key):
+    """Returns the HTML code for the given captcha key"""
+
+  def getExtraPropertyList(self):
+    """Returns the list of additionnary properties that are configurable"""
+
+class CaptchasDotNetProvider(object):
+
+  implements(ICaptchaProvider)
+
+  def getImageGenerator (self, field):
+    captchas_client = field.get_value("captcha_dot_net_client") or "demo"
+    captchas_secret = field.get_value("captcha_dot_net_secret") or "secret"
+    return CaptchasDotNet.CaptchasDotNet(client = captchas_client, secret = captchas_secret)
+  
+  def generate(self, field):
+    image_generator = self.getImageGenerator(field)
+    captcha_key = image_generator.random_string()
+    return (captcha_key, image_generator.get_answer(captcha_key))
+  
+  def getHTML(self, field, captcha_key):
+    image_generator = self.getImageGenerator(field)
+    return image_generator.image(captcha_key, "__captcha_" + md5.new(captcha_key).hexdigest())
+
+  def getExtraPropertyList(self):
+    return [fields.StringField('captcha_dot_net_client',
+                               title='Captchas.net client login',
+                               description='Your login on captchas.net to get the pictures.',
+                               default="demo",
+                               size=32,
+                               required=0),
+            fields.PasswordField('captcha_dot_net_secret',
+                               title='Captchas.net client secret',
+                               description='Your secret on captchas.net to get the pictures.',
+                               default="secret",
+                               size=32,
+                               required=0)]
+
+class NumericCaptchaProvider(object):
+
+  implements(ICaptchaProvider)
+  
+  # No division because it would create decimal numbers
+  operator_set = {"+": "plus", "-": "minus", "*": "times"}
+  
+  def generate(self, field):
+    # First step : generate the calculus. It is really simple.
+    terms = [str(random.randint(1, 20)), random.choice(self.operator_set.keys())]
+    #XXX: Find a way to prevent too complex captchas (for instance 11*7*19...)
+    #terms += [str(random.randint(1, 20)), random.choice(operator_set.keys())]
+    terms.append(str(random.randint(1, 20)))
+
+    # Second step : generate a text for it, and compute it
+    calculus_text = " ".join(terms)
+    result = eval(calculus_text)
+    
+    return (calculus_text, result)
+  
+  def getHTML(self, field, captcha_key):
+    # Make the text harder to parse for a computer
+    calculus_text = captcha_key
+    for (operator, replacement) in self.operator_set.items():
+      calculus_text = calculus_text.replace(operator, replacement)
+    
+    return "<span class=\"%s\">%s</span>" % (field.get_value('css_class'), calculus_text)
+
+  def getExtraPropertyList(self):
+    return []
+
+class CaptchaProviderFactory(object):
+  @staticmethod
+  def getProvider(name):
+    if name == "numeric":
+      return NumericCaptchaProvider()
+    elif name == "text":
+      return CaptchasDotNetProvider()
+    return None
+
+  @staticmethod
+  def getProviderList():
+    return [('Mathematics', 'numeric'), ('Text recognition (using captchas.net)', 'text')]
+  
+  @staticmethod
+  def getDefaultProvider():
+    return "numeric"
+
+class CaptchaWidget(Widget.TextWidget):
+  """
+    A widget that displays a Captcha.
+  """
+  
+  def __init__(self):
+    # Associate a captcha key and (the right answer, the generation date)
+    self.__captcha_cache = {}
+
+  def add_captcha(self, key, value):
+    # First, cleanup the cache
+    cleanup_time = time.time() - 3600
+    for item in self.__captcha_cache.items():
+      if item[1][1] < cleanup_time:
+        del(self.__captcha_cache[item[0]])
+    # Then add the value if needed
+    if self.__captcha_cache.has_key(key):
+      return False
+    self.__captcha_cache[key] = (str(value), time.time())
+    return True
+  
+  def validate_answer(self, key, value):
+    if not(self.__captcha_cache.has_key(key)):
+      return False
+    result = (self.__captcha_cache[key][0] == value)
+    del(self.__captcha_cache[key]) # Forbid several use of the same captcha.
+    return result
+    
+  property_names = Widget.Widget.property_names + ['captcha_type']
+
+  captcha_type = fields.ListField('captcha_type',
+                                   title='Captcha type',
+                                   description=(
+        "The type of captcha you want to use."
+        ""),
+                                   default=CaptchaProviderFactory.getDefaultProvider(),
+                                   required=1,
+                                   size=1,
+                                   items=CaptchaProviderFactory.getProviderList())
+
+  def render(self, field, key, value, REQUEST, render_prefix=None):
+    """
+      Render editor
+    """
+    captcha_key = None
+    captcha_field = None
+    
+    captcha_type = field.get_value("captcha_type")
+    provider = CaptchaProviderFactory.getProvider(captcha_type)
+    (captcha_key, captcha_answer) = provider.generate(field)
+    while not(self.add_captcha(md5.new(captcha_key).hexdigest(), captcha_answer)):
+      (captcha_key, captcha_answer) = provider.generate(field)
+    captcha_field = provider.getHTML(field, captcha_key)
+    
+    key_field = Widget.render_element("input",
+                                      type="hidden",
+                                      name="__captcha_" + key + "__",
+                                      value=md5.new(captcha_key).hexdigest()
+                                      )
+    splitter = "<br />"
+    answer = Widget.render_element("input",
+                                   type="text",
+                                   name=key,
+                                   css_class=field.get_value('css_class'),
+                                   size=10)
+    return captcha_field + key_field + splitter + answer
+    
+  def render_view(self, field, value, REQUEST=None, render_prefix=None):
+    """ 
+      Render form in view only mode.
+    """
+    return None
+
+CaptchaWidgetInstance = CaptchaWidget()
+
+class CaptchaValidator(Validator.Validator):
+  message_names = Validator.Validator.message_names + ['wrong_captcha']
+
+  wrong_captcha = 'You did not enter the right answer.'
+  
+  def validate(self, field, key, REQUEST):
+    value = REQUEST.get(key, None)
+    cache_key = REQUEST.get("__captcha_" + key + "__")
+    
+    if not(CaptchaWidgetInstance.validate_answer(cache_key, value)):
+      self.raise_error('wrong_captcha', field)
+    return value
+
+CaptchaValidatorInstance = CaptchaValidator()
+
+class CaptchaField(ZMIField):
+  security = ClassSecurityInfo()
+  meta_type = "CaptchaField"
+
+  widget = CaptchaWidgetInstance
+  validator = CaptchaValidatorInstance
+  
+  # methods screen
+  security.declareProtected('View management screens',
+                            'manage_main')
+  manage_main = DTMLFile('dtml/captchaFieldEdit', globals())
+
+  security.declareProtected('Change Formulator Forms', 'manage_edit')
+  def manage_edit(self, REQUEST):
+    """
+    Surcharged values for the captcha provider custom fields.
+    """
+    captcha_provider = CaptchaProviderFactory.getProvider(self.get_value("captcha_type"))
+    result = {}
+    for field in captcha_provider.getExtraPropertyList():
+      try:
+        # validate the form and get results
+        result[field.get_real_field().id] = field.get_real_field().validate(REQUEST)
+      except ValidationError, err:
+        if REQUEST:
+          message = "Error: %s - %s" % (err.field.get_value('title'),
+                                        err.error_text)
+          return self.manage_main(self, REQUEST,
+                                  manage_tabs_message=message)
+        else:
+          raise
+    
+    # Edit standards attributes
+    # XXX It is not possible to call ZMIField.manage_edit because
+    # it returns at the end...
+    # we need to had a parameter to the method
+    try:
+      # validate the form and get results
+      result.update(self.form.validate(REQUEST))
+    except ValidationError, err:
+      if REQUEST:
+        message = "Error: %s - %s" % (err.field.get_value('title'),
+                                      err.error_text)
+        return self.manage_main(self,REQUEST,
+                                manage_tabs_message=message)
+      else:
+        raise
+    
+    self.values.update(result)
+    
+    self._edit(result)
+    
+    # finally notify field of all changed values if necessary
+    for key in result:
+      method_name = "on_value_%s_changed" % key
+      if hasattr(self, method_name):
+        getattr(self, method_name)(result[key])
+        
+    if REQUEST:
+      message="Content changed."
+      return self.manage_main(self, REQUEST,
+                              manage_tabs_message=message)
+                              
+  def _edit(self, result):
+    if result.has_key("captcha_type"):
+      # Now, find out the old fields and wipe them out !
+      new_provider = CaptchaProviderFactory.getProvider(result["captcha_type"])
+      old_propertiesIds = self.__extraPropertyList
+      new_properties = [x.get_real_field() for x in new_provider.getExtraPropertyList()]
+      deleted_properties = [x for x in new_properties if not x.id in old_propertiesIds]
+      for deleted_property in deleted_properties:
+        if deleted_property.values.has_key("default"):
+          result[deleted_property.id] = deleted_property.values["default"]
+        else:
+          result[deleted_property.id] = None
+      self.__extraPropertyList = new_properties
+    ZMIField._edit(self, result)
+  
+  security.declareProtected('Access contents information', 'get_value')
+  def get_value(self, id, **kw):
+    if self.values.has_key(id):
+      return self.values[id]
+    return ZMIField.get_value(self, id, **kw)
+
+  def getCaptchaCustomPropertyList(self):
+    captcha_type = self.get_value("captcha_type")
+    captcha_provider = CaptchaProviderFactory.getProvider(captcha_type)
+    extraPropertyList = captcha_provider.getExtraPropertyList()
+    self.__extraPropertyList = [x.id for x in extraPropertyList]
+    return extraPropertyList
+    
\ No newline at end of file
diff --git a/product/ERP5Form/CaptchasDotNet.py b/product/ERP5Form/CaptchasDotNet.py
new file mode 100644
index 0000000000..6e2c060be4
--- /dev/null
+++ b/product/ERP5Form/CaptchasDotNet.py
@@ -0,0 +1,137 @@
+# -*- coding: utf-8 -*-
+#---------------------------------------------------------------------       
+# Python module for easy utilization of http://captchas.net
+#
+# For documentation look at http://captchas.net/sample/python/
+# 
+# Written by Sebastian Wilhelmi <seppi@seppi.de> and
+#            Felix Holderied <felix@holderied.de>
+# This file is in the public domain.
+#
+# ChangeLog:
+#
+# 2010-01-15: Adapt to ERP5 : a lot of code had to be removed or changed.
+#            Most of the work must be done in another class.
+#
+# 2006-09-08: Add new optional parameters alphabet, letters 
+#             height an width. Add audio_url. 
+#      
+# 2006-03-01: Only delete the random string from the repository in
+#             case of a successful verification.
+#
+# 2006-02-14: Add new image() method returning an HTML/JavaScript
+#             snippet providing a fault tolerant service.
+#
+# 2005-06-02: Initial version.
+#
+#---------------------------------------------------------------------
+
+import md5
+import random
+
+class CaptchasDotNet:
+    def __init__ (self, client, secret,
+                  alphabet = 'abcdefghkmnopqrstuvwxyz',
+                  letters = 6,
+                  width = 240,
+                  height = 80
+                  ):
+        self.__client = client
+        self.__secret = secret
+        self.__alphabet = alphabet
+        self.__letters = letters
+        self.__width = width
+        self.__height = height
+
+    # Return a random string
+    def random_string (self):
+        # The random string shall consist of small letters, big letters
+        # and digits.
+        letters = "abcdefghijklmnopqrstuvwxyz"
+        letters += letters.upper () + "0123456789"
+
+        # The random starts out empty, then 40 random possible characters
+        # are appended.
+        random_string = ''
+        for i in range (40):
+            random_string += random.choice (letters)
+
+        # Return the random string.
+        return random_string
+
+    def image_url (self, random, base = 'http://image.captchas.net/'):
+        url = base
+        url += '?client=%s&amp;random=%s' % (self.__client, random)
+        if self.__alphabet != "abcdefghijklmnopqrstuvwxyz":
+            url += '&amp;alphabet=%s' % self.__alphabet
+        if self.__letters != 6:
+            url += '&amp;letters=%s' % self.__letters
+        if self.__width != 240:
+            url += '&amp;width=%s' % self.__width
+        if self.__height != 80:
+            url += '&amp;height=%s' % self.__height
+        return url
+
+    def audio_url (self, random, base = 'http://audio.captchas.net/'):
+        url = base
+        url += '?client=%s&amp;random=%s' % (self.__client, random)
+        if self.__alphabet != "abcdefghijklmnopqrstuvwxyz":
+            url += '&amp;alphabet=%s' % self.__alphabet
+        if self.__letters != 6:
+            url += '&amp;letters=%s' % self.__letters
+        return url
+
+    def image (self, random, id = 'captchas.net'):
+        return '''
+        <a href="http://captchas.net"><img
+            style="border: none; vertical-align: bottom"
+            id="%s" src="%s" width="%d" height="%d"
+            alt="The CAPTCHA image" /></a>
+        <script type="text/javascript">
+          <!--
+          function captchas_image_error (image) 
+          {
+            if (!image.timeout) return true;
+            image.src = image.src.replace (/^http:\/\/image\.captchas\.net/, 
+                                           'http://image.backup.captchas.net');
+            return captchas_image_loaded (image);
+          }
+
+          function captchas_image_loaded (image)
+          {
+            if (!image.timeout) return true;
+            window.clearTimeout (image.timeout);
+            image.timeout = false;
+            return true;
+          }
+
+          var image = document.getElementById ('%s');
+          image.onerror = function() {return captchas_image_error (image);};
+          image.onload = function() {return captchas_image_loaded (image);};
+          image.timeout 
+            = window.setTimeout(
+               "captchas_image_error (document.getElementById ('%s'))",
+               10000);
+          image.src = image.src;
+          //-->      
+        </script>''' % (id, self.image_url (random), self.__width, self.__height, id, id)
+        
+    def get_answer (self, random ):
+        # The format of the password.
+        password_alphabet = self.__alphabet
+        password_length = self.__letters
+
+        # Calculate the MD5 digest of the concatenation of secret key and
+        # random string.
+        encryption_base = self.__secret + random
+        if (password_alphabet != "abcdefghijklmnopqrstuvwxyz") or (password_length != 6):
+            encryption_base += ":" + password_alphabet + ":" + str(password_length)
+        digest = md5.new (encryption_base).digest ()
+
+        # Compute password
+        correct_password = ''
+        for pos in range (password_length):
+            letter_num = ord (digest[pos]) % len (password_alphabet)
+            correct_password += password_alphabet[letter_num]
+        
+        return correct_password
\ No newline at end of file
diff --git a/product/ERP5Form/__init__.py b/product/ERP5Form/__init__.py
index ee816c297e..45042c69b5 100644
--- a/product/ERP5Form/__init__.py
+++ b/product/ERP5Form/__init__.py
@@ -45,6 +45,7 @@ from Tool import SelectionTool
 import OOoChart, PDFTemplate, Report, PDFForm, ParallelListField
 import PlanningBox, POSBox, FormBox, EditorField, ProxyField, DurationField
 import RelationField, ImageField, MultiRelationField, MultiLinkField, InputButtonField
+import CaptchaField
 import PreferenceTool
 
 from Products.Formulator.FieldRegistry import FieldRegistry
@@ -143,7 +144,9 @@ def initialize( context ):
                                 'www/StringField.gif')
     FieldRegistry.registerField(OOoChart.OOoChart,
                                 'www/StringField.gif')
- 
+    FieldRegistry.registerField(CaptchaField.CaptchaField,
+                                'www/StringField.gif')
+
     # some helper fields
     FieldRegistry.registerField(HelperFields.ListTextAreaField)
     FieldRegistry.registerField(HelperFields.MethodField)
diff --git a/product/ERP5Form/dtml/captchaFieldEdit.dtml b/product/ERP5Form/dtml/captchaFieldEdit.dtml
new file mode 100644
index 0000000000..8bd012e761
--- /dev/null
+++ b/product/ERP5Form/dtml/captchaFieldEdit.dtml
@@ -0,0 +1,98 @@
+<dtml-var manage_page_header>
+<dtml-let help_product="'Formulator'" help_topic=meta_type>
+<dtml-var manage_tabs>
+</dtml-let>
+
+<p class="form-help">
+Surcharge <dtml-var meta_type> properties here.
+</p>
+
+<form action="manage_edit" method="POST">
+<table cellspacing="0" cellpadding="2" border="0">
+
+  <!-- First, display normal properties -->
+  <!-- see: Formulator/dtml/fieldEdit.dtml -->
+  <dtml-in "form.get_groups()">
+  <dtml-let group=sequence-item fields="form.get_fields_in_group(group)">
+
+  <dtml-if fields>
+  <tr>
+  <td colspan="3" class="form-title">
+    Captcha Widget properties
+  </td>
+  </tr>
+  <dtml-var fieldListHeader>
+  <dtml-let current_field="this()">
+  <dtml-in fields>
+  <dtml-let field=sequence-item field_id="field.id"
+            value="current_field.get_orig_value(field_id)"
+            override="current_field.get_override(field_id)"
+            tales="current_field.get_tales(field_id)">
+    <tr>
+      <td align="left" valign="top">
+      <div class="form-label">
+      <dtml-if "tales or override">[</dtml-if><dtml-var "field.title()"><dtml-if "field.has_value('required') and field.get_value('required')">*</dtml-if><dtml-if "tales or override">]</dtml-if>
+      </div>
+      </td>
+      <td align="left" valign="top">
+      <dtml-var "field.render(value)">
+      </td>
+      <td><div class="form-element">
+      <dtml-var "field.meta_type">
+      </div></td>
+    </tr>
+  </dtml-let>
+  </dtml-in>
+  </dtml-let>
+  </dtml-if>
+  </dtml-let>
+  </dtml-in>
+
+
+
+<!-- Then, display captcha-specific properties -->
+<dtml-let current_field="this()">
+<dtml-in "this().getCaptchaCustomPropertyList()" prefix="captcha">
+
+<dtml-var expr="captcha_item">
+
+  <dtml-let field="captcha_item.get_real_field()" field_id="field.id"
+            value="current_field.get_orig_value(field_id)"
+            override="current_field.get_override(field_id)"
+            tales="current_field.get_tales(field_id)">
+    <tr>
+      <td align="left" valign="top">
+      <div class="form-label">
+      <dtml-if "tales or override">[</dtml-if><dtml-var "field.title()"><dtml-if "field.has_value('required') and field.get_value('required')">*</dtml-if><dtml-if "tales or override">]</dtml-if>
+      </div>
+      </td>
+      <td align="left" valign="top">
+      <dtml-var "field.render(value)">
+      </td>
+      <td><div class="form-element">
+      <dtml-var "field.meta_type">
+      </div></td>
+    </tr>
+  </dtml-let>
+  
+</dtml-in>
+</dtml-let>
+
+
+    <tr>
+      <td align="left" valign="top">
+      <div class="form-element">
+      <input class="form-element" type="submit" name="submit" 
+       value="Save Changes" /> 
+      </div>
+      </td>
+    </tr>
+
+
+
+</table>
+</form>
+
+
+
+<dtml-var manage_page_footer>
-- 
2.30.9