changeset 2983:7b0aadb97d01

new antispam stuff: textchas (text CAPTCHAs), cleanup AttachFile handler (port from 1.6)
author Thomas Waldmann <tw AT waldmann-edv DOT de>
date Sun, 06 Jan 2008 02:49:32 +0100
parents 24854dcf16ba
children 52f9d511f155
files MoinMoin/PageEditor.py MoinMoin/PageGraphicalEditor.py MoinMoin/action/AttachFile.py MoinMoin/action/edit.py MoinMoin/action/newaccount.py MoinMoin/config/multiconfig.py MoinMoin/security/textcha.py wiki/htdocs/classic/css/screen.css wiki/htdocs/modern/css/screen.css wiki/htdocs/rightsidebar/css/screen.css
diffstat 10 files changed, 293 insertions(+), 38 deletions(-) [+]
line wrap: on
line diff
--- a/MoinMoin/PageEditor.py	Sat Jan 05 23:41:20 2008 +0100
+++ b/MoinMoin/PageEditor.py	Sun Jan 06 02:49:32 2008 +0100
@@ -399,6 +399,9 @@
 <input type="hidden" name="editor" value="text">
 ''' % (button_spellcheck, cancel_button_text, ))
 
+        from MoinMoin.security.textcha import TextCha
+        request.write(TextCha(request).render())
+
         # Add textarea with page text
         self.sendconfirmleaving()
 
--- a/MoinMoin/PageGraphicalEditor.py	Sat Jan 05 23:41:20 2008 +0100
+++ b/MoinMoin/PageGraphicalEditor.py	Sun Jan 06 02:49:32 2008 +0100
@@ -288,6 +288,9 @@
 <input type="hidden" name="editor" value="gui">
 ''' % (button_spellcheck, cancel_button_text, ))
 
+        from MoinMoin.security.textcha import TextCha
+        request.write(TextCha(request).render())
+
         self.sendconfirmleaving() # TODO update state of flgChange to make this work, see PageEditor
 
         # Add textarea with page text
--- a/MoinMoin/action/AttachFile.py	Sat Jan 05 23:41:20 2008 +0100
+++ b/MoinMoin/action/AttachFile.py	Sun Jan 06 02:49:32 2008 +0100
@@ -31,6 +31,7 @@
 from MoinMoin import config, wikiutil, packages
 from MoinMoin.Page import Page
 from MoinMoin.util import filesys, timefuncs
+from MoinMoin.security.textcha import TextCha
 from MoinMoin.events import FileAttachedEvent, send_event
 import MoinMoin.events.notification as notification
 
@@ -514,6 +515,7 @@
 <dt>%(upload_label_overwrite)s</dt>
 <dd><input type="checkbox" name="overwrite" value="1" %(overwrite_checked)s></dd>
 </dl>
+%(textcha)s
 <p>
 <input type="hidden" name="action" value="%(action_name)s">
 <input type="hidden" name="do" value="upload">
@@ -530,6 +532,7 @@
     'upload_label_overwrite': _('Overwrite existing attachment of same name'),
     'overwrite_checked': ('', 'checked')[request.form.get('overwrite', ['0'])[0] == '1'],
     'upload_button': _('Upload'),
+    'textcha': TextCha(request).render(),
 })
 
 #<dt>%(upload_label_mime)s</dt>
@@ -555,14 +558,59 @@
     """
     _ = request.getText
 
-    msg = None
-    do = request.form.get('do')
-    if do is not None:
-        do = do[0]
     if action_name in request.cfg.actions_excluded:
         msg = _('File attachments are not allowed in this wiki!')
-    elif 'do' not in request.form:
+        error_msg(pagename, request, msg)
+        return
+
+    do = request.form.get('do')
+    if do is None:
         upload_form(pagename, request)
+        return
+
+    msg = None
+    do = do[0]
+
+    # First handle read-only access to attachments:
+    if do == 'get':
+        if request.user.may.read(pagename):
+            get_file(pagename, request)
+        else:
+            msg = _('You are not allowed to get attachments from this page.')
+    elif do == 'view':
+        if request.user.may.read(pagename):
+            view_file(pagename, request)
+        else:
+            msg = _('You are not allowed to view attachments of this page.')
+    elif do == 'move':
+        if request.user.may.delete(pagename):
+            send_moveform(pagename, request)
+        else:
+            msg = _('You are not allowed to move attachments from this page.')
+
+    # Second handle write access:
+    elif do == 'upload':
+        # Currently we only check TextCha for upload (this is what spammers ususally do),
+        # but it could be extended to more/all attachment write access
+        if not TextCha(request).check_answer_from_form():
+            msg = _('TextCha: Wrong answer! Go back and try again...', formatted=False)
+        else:
+            overwrite = 0
+            if 'overwrite' in request.form:
+                try:
+                    overwrite = int(request.form['overwrite'][0])
+                except:
+                    pass
+            if (not overwrite and request.user.may.write(pagename)) or \
+               (overwrite and request.user.may.write(pagename) and request.user.may.delete(pagename)):
+                if 'file' in request.form:
+                    do_upload(pagename, request, overwrite)
+                else:
+                    # This might happen when trying to upload file names
+                    # with non-ascii characters on Safari.
+                    msg = _("No file content. Delete non ASCII characters from the file name and try again.")
+            else:
+                msg = _('You are not allowed to attach a file to this page.')
     elif do == 'savedrawing':
         if request.user.may.write(pagename):
             save_drawing(pagename, request)
@@ -570,33 +618,11 @@
             request.write("OK")
         else:
             msg = _('You are not allowed to save a drawing on this page.')
-    elif do == 'upload':
-        overwrite = 0
-        if 'overwrite' in request.form:
-            try:
-                overwrite = int(request.form['overwrite'][0])
-            except:
-                pass
-        if (not overwrite and request.user.may.write(pagename)) or \
-           (overwrite and request.user.may.write(pagename) and request.user.may.delete(pagename)):
-            if 'file' in request.form:
-                do_upload(pagename, request, overwrite)
-            else:
-                # This might happen when trying to upload file names
-                # with non-ascii characters on Safari.
-                msg = _("No file content. Delete non ASCII characters from the file name and try again.")
-        else:
-            msg = _('You are not allowed to attach a file to this page.')
     elif do == 'del':
         if request.user.may.delete(pagename):
             del_file(pagename, request)
         else:
             msg = _('You are not allowed to delete attachments on this page.')
-    elif do == 'move':
-        if request.user.may.delete(pagename):
-            send_moveform(pagename, request)
-        else:
-            msg = _('You are not allowed to move attachments from this page.')
     elif do == 'attachment_move':
         if 'cancel' in request.form:
             msg = _('Move aborted!')
@@ -610,11 +636,6 @@
             attachment_move(pagename, request)
         else:
             msg = _('You are not allowed to move attachments from this page.')
-    elif do == 'get':
-        if request.user.may.read(pagename):
-            get_file(pagename, request)
-        else:
-            msg = _('You are not allowed to get attachments from this page.')
     elif do == 'unzip':
         if request.user.may.delete(pagename) and request.user.may.read(pagename) and request.user.may.write(pagename):
             unzip_file(pagename, request)
@@ -625,13 +646,8 @@
             install_package(pagename, request)
         else:
             msg = _('You are not allowed to install files.')
-    elif do == 'view':
-        if request.user.may.read(pagename):
-            view_file(pagename, request)
-        else:
-            msg = _('You are not allowed to view attachments of this page.')
     else:
-        msg = _('Unsupported upload action: %s') % (wikiutil.escape(do), )
+        msg = _('Unsupported AttachFile sub-action: %s') % (wikiutil.escape(do), )
 
     if msg:
         error_msg(pagename, request, msg)
--- a/MoinMoin/action/edit.py	Sat Jan 05 23:41:20 2008 +0100
+++ b/MoinMoin/action/edit.py	Sun Jan 06 02:49:32 2008 +0100
@@ -158,6 +158,9 @@
     # Save new text
     else:
         try:
+            from MoinMoin.security.textcha import TextCha
+            if not TextCha(request).check_answer_from_form():
+                raise pg.SaveError(_('TextCha: Wrong answer! Go back and try again...', formatted=False))
             savemsg = pg.saveText(savetext, rev, trivial=trivial, comment=comment)
         except pg.EditConflict, e:
             msg = e.message
--- a/MoinMoin/action/newaccount.py	Sat Jan 05 23:41:20 2008 +0100
+++ b/MoinMoin/action/newaccount.py	Sun Jan 06 02:49:32 2008 +0100
@@ -10,6 +10,7 @@
 from MoinMoin.Page import Page
 from MoinMoin.widget import html
 import MoinMoin.events as events
+from MoinMoin.security.textcha import TextCha
 
 
 _debug = False
@@ -20,6 +21,10 @@
 
     if request.request_method != 'POST':
         return _("Use UserPreferences to change your settings or create an account.")
+           
+    if not TextCha(request).check_answer_from_form():
+        return _('TextCha: Wrong answer! Go back and try again...', formatted=False)
+
     # Create user profile
     theuser = user.User(request, auth_method="new-user")
 
@@ -127,6 +132,16 @@
 
     row = html.TR()
     tbl.append(row)
+    row.append(html.TD().append(html.STRONG().append(
+                                  html.Text(_('TextCha (required)', formatted=False))))) 
+    td = html.TD()
+    textcha = TextCha(request).render()
+    if textcha:
+        td.append(textcha)
+    row.append(td)
+
+    row = html.TR()
+    tbl.append(row)
     row.append(html.TD())
     td = html.TD()
     row.append(td)
--- a/MoinMoin/config/multiconfig.py	Sat Jan 05 23:41:20 2008 +0100
+++ b/MoinMoin/config/multiconfig.py	Sun Jan 06 02:49:32 2008 +0100
@@ -569,6 +569,9 @@
     }
     surge_lockout_time = 3600 # secs you get locked out when you ignore warnings
 
+    textchas = None
+    textchas_disabled_group = None # e.g. u'NoTextChasGroup' if you are a member of this group, you don't get textchas
+
     theme_default = 'modern'
     theme_force = False
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/security/textcha.py	Sun Jan 06 02:49:32 2008 +0100
@@ -0,0 +1,170 @@
+# -*- coding: iso-8859-1 -*-
+"""
+    MoinMoin - Text CAPTCHAs
+    
+    This is just asking some (admin configured) questions and
+    checking if the answer is as expected. It is up to the wiki
+    admin to setup questions that a bot can not easily answer, but
+    humans can. It is recommended to setup SITE SPECIFIC questions
+    and not to share the questions with other sites (if everyone
+    asks the same questions / expects the same answers, spammers
+    could adapt to that).
+
+    TODO:
+    * roundtrip the question in some other way:
+     * use safe encoding / encryption for the q
+     * make sure a q/a pair in the POST is for the q in the GET before
+    * make some nice CSS
+    * make similar changes to GUI editor
+
+    @copyright: 2007 by MoinMoin:ThomasWaldmann
+    @license: GNU GPL, see COPYING for details.
+"""
+
+import re
+import random
+import logging
+
+from MoinMoin import wikiutil
+
+class TextCha(object):
+    """ Text CAPTCHA support """
+
+    def __init__(self, request, question=None):
+        """ Initialize the TextCha.
+
+            @param request: the request object
+            @param question: see _init_qa()
+        """
+        self.request = request
+        self.user_info = request.user.valid and request.user.name or request.remote_addr
+        self.textchas = self._get_textchas()
+        self._init_qa(question)
+
+    def _get_textchas(self):
+        """ get textchas from the wiki config for the user's language (or default_language or en) """
+        request = self.request
+        cfg = request.cfg
+        user = request.user
+        disabled_group = cfg.textchas_disabled_group
+        if disabled_group and user.name and request.dicts.has_member(disabled_group, user.name):
+            return None
+        textchas = cfg.textchas
+        if textchas:
+            lang = user.language or request.lang
+            #logging.debug(u"TextCha: user.language == '%s'." % lang)
+            if lang not in textchas:
+                lang = cfg.language_default
+                #logging.debug(u"TextCha: fallback to language_default == '%s'." % lang)
+                if lang not in textchas:
+                    logging.error(u"TextCha: The textchas do not have content for language_default == '%s'! Falling back to English." % lang)
+                    lang = 'en'
+                    if lang not in textchas:
+                        logging.error(u"TextCha: The textchas do not have content for 'en', auto-disabling textchas!")
+                        cfg.textchas = None
+                        lang = None
+        else:
+            lang = None
+        if lang is None:
+            return None
+        else:
+            #logging.debug(u"TextCha: using lang = '%s'" % lang)
+            return textchas[lang]
+
+    def _init_qa(self, question=None):
+        """ Initialize the question / answer.
+        
+         @param question: If given, the given question will be used.
+                          If None, a new question will be generated.
+        """
+        if self.is_enabled():
+            if question is None:
+                self.question = random.choice(self.textchas.keys())
+            else:
+                self.question = question
+            try:
+                self.answer_regex = self.textchas[self.question]
+                self.answer_re = re.compile(self.answer_regex, re.U|re.I)
+            except KeyError:
+                # this question does not exist, thus there is no answer
+                self.answer_regex = ur"[^.]*" # this shall never match!
+                self.answer_re = re.compile(self.answer_regex, re.U|re.I)
+                logging.warning(u"TextCha: Non-existing question '%s'. User '%s' trying to cheat?" % (
+                                self.question, self.user_info))
+            except re.error:
+                logging.error(u"TextCha: Invalid regex in answer for question '%s'" % self.question)
+                self._init_qa()
+
+    def is_enabled(self):
+        """ check if textchas are enabled.
+
+            They can be disabled for all languages if you use textchas = None or = {},
+            also they can be disabled for some specific language, like:
+            textchas = {
+                'en': {
+                    'some question': 'some answer',
+                    # ...
+                },
+                'de': {}, # having no questions for 'de' means disabling textchas for 'de'
+                # ...
+            }             
+        """
+        return not not self.textchas # we don't want to return the dict
+
+    def check_answer(self, given_answer):
+        """ check if the given answer to the question is correct """
+        if self.is_enabled():
+            success = self.answer_re.match(given_answer.strip()) is not None
+            success_status = success and u"success" or u"failure"
+            logging.info(u"TextCha: %s (u='%s', a='%s', re='%s', q='%s')" % (
+                             success_status,
+                             self.user_info,
+                             given_answer,
+                             self.answer_regex,
+                             self.question,
+                             ))
+            return success
+        else:
+            return True
+
+    def _make_form_values(self, question, given_answer):
+        question_form = wikiutil.escape(question, True)
+        given_answer_form = wikiutil.escape(given_answer, True)
+        return question_form, given_answer_form
+
+    def _extract_form_values(self, form=None):
+        if form is None:
+            form = self.request.form
+        question = form.get('textcha-question', [None])[0]
+        given_answer = form.get('textcha-answer', [u''])[0]
+        return question, given_answer
+
+    def render(self, form=None):
+        """ Checks if textchas are enabled and returns HTML for one,
+            or an empty string if they are not enabled.
+            
+            @return: unicode result html         
+        """
+        if self.is_enabled():
+            question, given_answer = self._extract_form_values(form)
+            if question is None:
+                question = self.question
+            question_form, given_answer_form = self._make_form_values(question, given_answer)
+            result = u"""
+<div id="textcha">
+<span id="textcha-question">%s</span>
+<input type="hidden" name="textcha-question" value="%s">
+<input id="textcha-answer" type="text" name="textcha-answer" value="%s" size="20" maxlength="80">
+</div>
+""" % (wikiutil.escape(question), question_form, given_answer_form)
+        else:
+            result = u''
+        return result
+
+    def check_answer_from_form(self, form=None):
+        if self.is_enabled():
+            question, given_answer = self._extract_form_values(form)
+            self._init_qa(question)
+            return self.check_answer(given_answer)
+        else:
+            return True
--- a/wiki/htdocs/classic/css/screen.css	Sat Jan 05 23:41:20 2008 +0100
+++ b/wiki/htdocs/classic/css/screen.css	Sun Jan 06 02:49:32 2008 +0100
@@ -361,6 +361,20 @@
 	background: url(../img/draft.png);
 }
 
+#textcha {
+    font-size: 100%;
+    margin-top: 0.5em;
+    border: 2px solid #FF8888;
+    color: black;
+    vertical-align: middle;
+    padding: 3px 2px;
+}
+
+#textcha-answer {
+    border: 2px solid #000000;
+    padding: 3px 2px;
+}
+
 .diff {
 	width:99%;
 }
--- a/wiki/htdocs/modern/css/screen.css	Sat Jan 05 23:41:20 2008 +0100
+++ b/wiki/htdocs/modern/css/screen.css	Sun Jan 06 02:49:32 2008 +0100
@@ -383,6 +383,20 @@
     margin-top: 0.5em;
 }
 
+#textcha {
+    font-size: 100%;
+    margin-top: 0.5em;
+    border: 2px solid #FF8888;
+    color: black;
+    vertical-align: middle;
+    padding: 3px 2px;
+}
+
+#textcha-answer {
+    border: 2px solid #000000;
+    padding: 3px 2px;
+}
+
 input.button {
     /*
     border: 1px solid #8cacbb;  
--- a/wiki/htdocs/rightsidebar/css/screen.css	Sat Jan 05 23:41:20 2008 +0100
+++ b/wiki/htdocs/rightsidebar/css/screen.css	Sun Jan 06 02:49:32 2008 +0100
@@ -289,6 +289,20 @@
 	background: url(../img/draft.png);
 }
 
+#textcha {
+    font-size: 100%;
+    margin-top: 0.5em;
+    border: 2px solid #FF8888;
+    color: black;
+    vertical-align: middle;
+    padding: 3px 2px;
+}
+
+#textcha-answer {
+    border: 2px solid #000000;
+    padding: 3px 2px;
+}
+
 #pagebottom {
 	clear: both;
 }