Code

Fix mailer (sf bug #817470) and add docstrings to prevent this from happening again.
[roundup.git] / roundup / cgi / client.py
index e109bafacc7d7a46f296b1aa3a40c5f96f360e7f..d4499bb334839aef8aa2abe05e2a15eebff01ca7 100644 (file)
@@ -1,4 +1,4 @@
-# $Id: client.py,v 1.100 2003-02-26 04:57:49 richard Exp $
+# $Id: client.py,v 1.141 2003-10-04 11:21:47 jlgijsbers Exp $
 
 __doc__ = """
 WWW request handler (also used in the stand-alone server).
@@ -8,12 +8,14 @@ import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
 import binascii, Cookie, time, random, MimeWriter, smtplib, socket, quopri
 import stat, rfc822
 
-from roundup import roundupdb, date, hyperdb, password
+from roundup import roundupdb, date, hyperdb, password, token, rcsv
 from roundup.i18n import _
 from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
 from roundup.cgi import cgitb
 from roundup.cgi.PageTemplates import PageTemplate
 from roundup.rfc2822 import encode_header
+from roundup.mailgw import uidFromAddress
+from roundup.mailer import Mailer, MessageSendError
 
 class HTTPException(Exception):
       pass
@@ -26,7 +28,9 @@ class  Redirect(HTTPException):
 class  NotModified(HTTPException):
        pass
 
-# XXX actually _use_ FormError
+# used by a couple of routines
+chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+
 class FormError(ValueError):
     ''' An "expected" exception occurred during form parsing.
         - ie. something we know can go wrong, and don't want to alarm the
@@ -60,6 +64,19 @@ def initialiseSecurity(security):
         description="User may manipulate user Roles through the web")
     security.addPermissionToRole('Admin', p)
 
+# used to clean messages passed through CGI variables - HTML-escape any tag
+# that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
+# that people can't pass through nasties like <script>, <iframe>, ...
+CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
+def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
+    return mc.sub(clean_message_callback, message)
+def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
+    ''' Strip all non <a>,<i>,<b> and <br> tags from a string
+    '''
+    if ok.has_key(match.group(3).lower()):
+        return match.group(1)
+    return '&lt;%s&gt;'%match.group(2)
+
 class Client:
     ''' Instantiate to handle one CGI request.
 
@@ -139,6 +156,7 @@ class Client:
         self.instance = instance
         self.request = request
         self.env = env
+        self.mailer = Mailer(instance.config)
 
         # save off the path
         self.path = env['PATH_INFO']
@@ -211,19 +229,28 @@ class Client:
         self.ok_message = []
         self.error_message = []
         try:
-            # make sure we're identified (even anonymously)
-            self.determine_user()
             # figure out the context and desired content template
+            # do this first so we don't authenticate for static files
+            # Note: this method opens the database as "admin" in order to
+            # perform context checks
             self.determine_context()
+
+            # make sure we're identified (even anonymously)
+            self.determine_user()
+
             # possibly handle a form submit action (may change self.classname
             # and self.template, and may also append error/ok_messages)
             self.handle_action()
-            # now render the page
 
+            # now render the page
             # we don't want clients caching our dynamic pages
             self.additional_headers['Cache-Control'] = 'no-cache'
-            self.additional_headers['Pragma'] = 'no-cache'
-            self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
+# Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
+#            self.additional_headers['Pragma'] = 'no-cache'
+
+            # expire this page 5 seconds from now
+            date = rfc822.formatdate(time.time() + 5)
+            self.additional_headers['Expires'] = date
 
             # render the content
             self.write(self.renderContext())
@@ -252,13 +279,21 @@ class Client:
         except NotFound:
             # pass through
             raise
+        except FormError, e:
+            self.error_message.append(_('Form Error: ') + str(e))
+            self.write(self.renderContext())
         except:
             # everything else
             self.write(cgitb.html())
 
     def clean_sessions(self):
-        '''age sessions, remove when they haven't been used for a week.
-        Do it only once an hour'''
+        ''' Age sessions, remove when they haven't been used for a week.
+        
+            Do it only once an hour.
+
+            Note: also cleans One Time Keys, and other "session" based
+            stuff.
+        '''
         sessions = self.db.sessions
         last_clean = sessions.get('last_clean', 'last_use') or 0
 
@@ -266,20 +301,28 @@ class Client:
         hour = 60*60
         now = time.time()
         if now - last_clean > hour:
-            # remove age sessions
+            # remove aged sessions
             for sessid in sessions.list():
                 interval = now - sessions.get(sessid, 'last_use')
                 if interval > week:
                     sessions.destroy(sessid)
+            # remove aged otks
+            otks = self.db.otks
+            for sessid in otks.list():
+                interval = now - otks.get(sessid, '__time')
+                if interval > week:
+                    otks.destroy(sessid)
             sessions.set('last_clean', last_use=time.time())
 
     def determine_user(self):
         ''' Determine who the user is
         '''
-        # determine the uid to use
+        # open the database as admin
         self.opendb('admin')
+
         # clean age sessions
         self.clean_sessions()
+
         # make sure we have the session Class
         sessions = self.db.sessions
 
@@ -366,8 +409,10 @@ class Client:
                 template_override = self.form[key].value
             elif self.FV_OK_MESSAGE.match(key):
                 ok_message = self.form[key].value
+                ok_message = clean_message(ok_message)
             elif self.FV_ERROR_MESSAGE.match(key):
                 error_message = self.form[key].value
+                error_message = clean_message(error_message)
 
         # determine the classname and possibly nodeid
         path = self.path.split('/')
@@ -385,6 +430,9 @@ class Client:
                 # send the file identified by the designator in path[0]
                 raise SendFile, path[0]
 
+        # we need the db for further context stuff - open it as admin
+        self.opendb('admin')
+
         # see if we got a designator
         m = dre.match(self.classname)
         if m:
@@ -425,15 +473,18 @@ class Client:
             raise NotFound, designator
 
         # we just want to serve up the file named
+        self.opendb('admin')
         file = self.db.file
         self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
         self.write(file.get(nodeid, 'content'))
 
     def serve_static_file(self, file):
+        ims = None
         # see if there's an if-modified-since...
-        ims = self.request.headers.getheader('if-modified-since')
-        # cgi will put the header in the env var
-        if not ims and self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
+        if hasattr(self.request, 'headers'):
+            ims = self.request.headers.getheader('if-modified-since')
+        elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
+            # cgi will put the header in the env var
             ims = self.env['HTTP_IF_MODIFIED_SINCE']
         filename = os.path.join(self.instance.config.TEMPLATES, file)
         lmt = os.stat(filename)[stat.ST_MTIME]
@@ -444,9 +495,13 @@ class Client:
                 raise NotModified
 
         # we just want to serve up the file named
-        mt = mimetypes.guess_type(str(file))[0]
+        file = str(file)
+        mt = mimetypes.guess_type(file)[0]
         if not mt:
-            mt = 'text/plain'
+            if file.endswith('.css'):
+                mt = 'text/css'
+            else:
+                mt = 'text/plain'
         self.additional_headers['Content-Type'] = mt
         self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
         self.write(open(filename, 'rb').read())
@@ -479,6 +534,7 @@ class Client:
         ('new',      'newItemAction'),
         ('register', 'registerAction'),
         ('confrego', 'confRegoAction'),
+        ('passrst',  'passResetAction'),
         ('login',    'loginAction'),
         ('logout',   'logout_action'),
         ('search',   'searchAction'),
@@ -489,17 +545,8 @@ class Client:
         ''' Determine whether there should be an Action called.
 
             The action is defined by the form variable :action which
-            identifies the method on this object to call. The four basic
-            actions are defined in the "actions" sequence on this class:
-             "edit"      -> self.editItemAction
-             "editcsv"   -> self.editCSVAction
-             "new"       -> self.newItemAction
-             "register"  -> self.registerAction
-             "confrego"  -> self.confRegoAction
-             "login"     -> self.loginAction
-             "logout"    -> self.logout_action
-             "search"    -> self.searchAction
-             "retire"    -> self.retireAction
+            identifies the method on this object to call. The actions
+            are defined in the "actions" sequence on this class.
         '''
         if self.form.has_key(':action'):
             action = self.form[':action'].value.lower()
@@ -675,19 +722,13 @@ class Client:
         # Let the user know what's going on
         self.ok_message.append(_('You are logged out'))
 
-    chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
     def registerAction(self):
         '''Attempt to create a new user based on the contents of the form
         and then set the cookie.
 
         return 1 on successful login
         '''
-        # parse the props from the form
-        try:
-            props = self.parsePropsFromForm()[0][('user', None)]
-        except (ValueError, KeyError), message:
-            self.error_message.append(_('Error: ') + str(message))
-            return
+        props = self.parsePropsFromForm()[0][('user', None)]
 
         # make sure we're allowed to register
         if not self.registerPermission(props):
@@ -702,7 +743,7 @@ class Client:
             pass
 
         # generate the one-time-key and store the props for later
-        otk = ''.join([random.choice(self.chars) for x in range(32)])
+        otk = ''.join([random.choice(chars) for x in range(32)])
         for propname, proptype in self.db.user.getprops().items():
             value = props.get(propname, None)
             if value is None:
@@ -713,58 +754,42 @@ class Client:
                 props[propname] = str(value)
             elif isinstance(proptype, hyperdb.Password):
                 props[propname] = str(value)
+        props['__time'] = time.time()
         self.db.otks.set(otk, **props)
 
-        # send email to the user's email address
-        message = StringIO.StringIO()
-        writer = MimeWriter.MimeWriter(message)
+        # send the email
         tracker_name = self.db.config.TRACKER_NAME
-        s = 'Complete your registration to %s'%tracker_name
-        writer.addheader('Subject', encode_header(s))
-        writer.addheader('To', props['address'])
-        writer.addheader('From', roundupdb.straddr((tracker_name,
-            self.db.config.ADMIN_EMAIL)))
-        writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
-            time.gmtime()))
-        # add a uniquely Roundup header to help filtering
-        writer.addheader('X-Roundup-Name', tracker_name)
-        # avoid email loops
-        writer.addheader('X-Roundup-Loop', 'hello')
-        writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
-        body = writer.startbody('text/plain; charset=utf-8')
-
-        # message body, encoded quoted-printable
-        content = StringIO.StringIO('''
-To complete your registration of the user "%(name)s" with %(tracker)s,
-please visit the following URL:
-
-   http://localhost:8001/test/?@action=confrego&otk=%(otk)s
-'''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
-        'otk': otk})
-        quopri.encode(content, body, 0)
-
-        # now try to send the message
-        try:
-            # send the message as admin so bounces are sent there
-            # instead of to roundup
-            smtp = smtplib.SMTP(self.db.config.MAILHOST)
-            smtp.sendmail(self.db.config.ADMIN_EMAIL, [props['address']],
-                message.getvalue())
-        except socket.error, value:
-            self.error_message.append("Error: couldn't send "
-                "confirmation email: mailhost %s"%value)
-            return
-        except smtplib.SMTPException, value:
-            self.error_message.append("Error: couldn't send "
-                "confirmation email: %s"%value)
+        tracker_email = self.db.config.TRACKER_EMAIL
+        subject = 'Complete your registration to %s -- key %s' % (tracker_name,
+                                                                  otk)
+        body = """To complete your registration of the user "%(name)s" with
+%(tracker)s, please do one of the following:
+
+- send a reply to %(tracker_email)s and maintain the subject line as is (the
+reply's additional "Re:" is ok),
+
+- or visit the following URL:
+
+   %(url)s?@action=confrego&otk=%(otk)s
+""" % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
+       'otk': otk, 'tracker_email': tracker_email}
+        if not self.standard_message([props['address']], subject, body,
+                                     tracker_email):
             return
 
         # commit changes to the database
         self.db.commit()
 
         # redirect to the "you're almost there" page
-        raise Redirect, '%s?:template=rego_step1_done'%self.base
+        raise Redirect, '%suser?@template=rego_progress'%self.base
 
+    def standard_message(self, to, subject, body, author=None):
+        try:
+            self.mailer.standard_message(to, subject, body, author)
+            return 1
+        except MessageSendError, e:
+            self.error_message.append(str(e))
+            
     def registerPermission(self, props):
         ''' Determine whether the user has permission to register
 
@@ -780,41 +805,16 @@ please visit the following URL:
     def confRegoAction(self):
         ''' Grab the OTK, use it to load up the new user details
         '''
-        # pull the rego information out of the otk database
-        otk = self.form['otk'].value
-        props = self.db.otks.getall(otk)
-        for propname, proptype in self.db.user.getprops().items():
-            value = props.get(propname, None)
-            if value is None:
-                pass
-            elif isinstance(proptype, hyperdb.Date):
-                props[propname] = date.Date(value)
-            elif isinstance(proptype, hyperdb.Interval):
-                props[propname] = date.Interval(value)
-            elif isinstance(proptype, hyperdb.Password):
-                props[propname] = password.Password()
-                props[propname].unpack(value)
-
-        # re-open the database as "admin"
-        if self.user != 'admin':
-            self.opendb('admin')
-
-        # create the new user
-        cl = self.db.user
-# XXX we need to make the "default" page be able to display errors!
-#        try:
-        if 1:
-            props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
-            self.userid = cl.create(**props)
-            # clear the props from the otk database
-            self.db.otks.destroy(otk)
-            self.db.commit()
-#        except (ValueError, KeyError), message:
-#            self.error_message.append(str(message))
-#            return
-
+        try:
+            # pull the rego information out of the otk database
+            self.userid = self.db.confirm_registration(self.form['otk'].value)
+        except (ValueError, KeyError), message:
+            # XXX: we need to make the "default" page be able to display errors!
+            self.error_message.append(str(message))
+            return
+        
         # log the new user in
-        self.user = cl.get(self.userid, 'username')
+        self.user = self.db.user.get(self.userid, 'username')
         # re-open the database for real, using the user
         self.opendb(self.user)
 
@@ -829,39 +829,126 @@ please visit the following URL:
         # nice message
         message = _('You are now registered, welcome!')
 
-        # redirect to the item's edit page
-        raise Redirect, '%suser%s?@ok_message=%s'%(
-            self.base, self.userid,  urllib.quote(message))
+        # redirect to the user's page
+        raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
+            self.userid, urllib.quote(message))
+
+    def passResetAction(self):
+        ''' Handle password reset requests.
+
+            Presence of either "name" or "address" generate email.
+            Presense of "otk" performs the reset.
+        '''
+        if self.form.has_key('otk'):
+            # pull the rego information out of the otk database
+            otk = self.form['otk'].value
+            uid = self.db.otks.get(otk, 'uid')
+            if uid is None:
+                self.error_message.append('Invalid One Time Key!')
+                return
+
+            # re-open the database as "admin"
+            if self.user != 'admin':
+                self.opendb('admin')
+
+            # change the password
+            newpw = password.generatePassword()
+
+            cl = self.db.user
+# XXX we need to make the "default" page be able to display errors!
+            try:
+                # set the password
+                cl.set(uid, password=password.Password(newpw))
+                # clear the props from the otk database
+                self.db.otks.destroy(otk)
+                self.db.commit()
+            except (ValueError, KeyError), message:
+                self.error_message.append(str(message))
+                return
+
+            # user info
+            address = self.db.user.get(uid, 'address')
+            name = self.db.user.get(uid, 'username')
+
+            # send the email
+            tracker_name = self.db.config.TRACKER_NAME
+            subject = 'Password reset for %s'%tracker_name
+            body = '''
+The password has been reset for username "%(name)s".
+
+Your password is now: %(password)s
+'''%{'name': name, 'password': newpw}
+            if not self.standard_message([address], subject, body):
+                return
+
+            self.ok_message.append('Password reset and email sent to %s'%address)
+            return
+
+        # no OTK, so now figure the user
+        if self.form.has_key('username'):
+            name = self.form['username'].value
+            try:
+                uid = self.db.user.lookup(name)
+            except KeyError:
+                self.error_message.append('Unknown username')
+                return
+            address = self.db.user.get(uid, 'address')
+        elif self.form.has_key('address'):
+            address = self.form['address'].value
+            uid = uidFromAddress(self.db, ('', address), create=0)
+            if not uid:
+                self.error_message.append('Unknown email address')
+                return
+            name = self.db.user.get(uid, 'username')
+        else:
+            self.error_message.append('You need to specify a username '
+                'or address')
+            return
+
+        # generate the one-time-key and store the props for later
+        otk = ''.join([random.choice(chars) for x in range(32)])
+        self.db.otks.set(otk, uid=uid, __time=time.time())
+
+        # send the email
+        tracker_name = self.db.config.TRACKER_NAME
+        subject = 'Confirm reset of password for %s'%tracker_name
+        body = '''
+Someone, perhaps you, has requested that the password be changed for your
+username, "%(name)s". If you wish to proceed with the change, please follow
+the link below:
+
+  %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
+
+You should then receive another email with the new password.
+'''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
+        if not self.standard_message([address], subject, body):
+            return
+
+        self.ok_message.append('Email sent to %s'%address)
 
     def editItemAction(self):
         ''' Perform an edit of an item in the database.
 
            See parsePropsFromForm and _editnodes for special variables
         '''
-        # parse the props from the form
-# XXX reinstate exception handling
-#        try:
-        if 1:
-            props, links = self.parsePropsFromForm()
-#        except (ValueError, KeyError), message:
-#            self.error_message.append(_('Error: ') + str(message))
-#            return
+        props, links = self.parsePropsFromForm()
 
         # handle the props
-# XXX reinstate exception handling
-#        try:
-        if 1:
+        try:
             message = self._editnodes(props, links)
-#        except (ValueError, KeyError, IndexError), message:
-#            self.error_message.append(_('Error: ') + str(message))
-#            return
+        except (ValueError, KeyError, IndexError), message:
+            self.error_message.append(_('Apply Error: ') + str(message))
+            return
 
         # commit now that all the tricky stuff is done
         self.db.commit()
 
         # redirect to the item's edit page
-        raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
-            self.nodeid,  urllib.quote(message))
+        raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
+            self.classname, self.nodeid, urllib.quote(message),
+            urllib.quote(self.template))
+
+    newItemAction = editItemAction
 
     def editItemPermission(self, props):
         ''' Determine whether the user has permission to edit this item.
@@ -887,45 +974,6 @@ please visit the following URL:
             return 1
         return 0
 
-    def newItemAction(self):
-        ''' Add a new item to the database.
-
-            This follows the same form as the editItemAction, with the same
-            special form values.
-        '''
-        # parse the props from the form
-# XXX reinstate exception handling
-#        try:
-        if 1:
-            props, links = self.parsePropsFromForm()
-#        except (ValueError, KeyError), message:
-#            self.error_message.append(_('Error: ') + str(message))
-#            return
-
-        # handle the props - edit or create
-# XXX reinstate exception handling
-#        try:
-        if 1:
-            # create the context here
-#            cn = self.classname
-#            nid = self._createnode(cn, props[(cn, None)])
-#            del props[(cn, None)]
-
-            # when it hits the None element, it'll set self.nodeid
-            messages = self._editnodes(props, links) #, {(cn, None): nid})
-
-#        except (ValueError, KeyError, IndexError), message:
-#            # these errors might just be indicative of user dumbness
-#            self.error_message.append(_('Error: ') + str(message))
-#            return
-
-        # commit now that all the tricky stuff is done
-        self.db.commit()
-
-        # redirect to the new item's page
-        raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
-            self.nodeid, urllib.quote(messages))
-
     def newItemPermission(self, props):
         ''' Determine whether the user has permission to create (edit) this
             item.
@@ -1044,7 +1092,7 @@ please visit the following URL:
         '''
         # check for permission
         if not self.editItemPermission(props):
-            raise PermissionError, 'You do not have permission to edit %s'%cn
+            raise Unauthorised, 'You do not have permission to edit %s'%cn
 
         # make the changes
         cl = self.db.classes[cn]
@@ -1055,7 +1103,7 @@ please visit the following URL:
         '''
         # check for permission
         if not self.newItemPermission(props):
-            raise PermissionError, 'You do not have permission to create %s'%cn
+            raise Unauthorised, 'You do not have permission to create %s'%cn
 
         # create the node and return its id
         cl = self.db.classes[cn]
@@ -1077,12 +1125,8 @@ please visit the following URL:
                 _('You do not have permission to edit %s' %self.classname))
 
         # get the CSV module
-        try:
-            import csv
-        except ImportError:
-            self.error_message.append(_(
-                'Sorry, you need the csv module to use this function.<br>\n'
-                'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
+        if rcsv.error:
+            self.error_message.append(_(rcsv.error))
             return
 
         cl = self.db.classes[self.classname]
@@ -1091,16 +1135,13 @@ please visit the following URL:
         props = ['id'] + idlessprops
 
         # do the edit
-        rows = self.form['rows'].value.splitlines()
-        p = csv.parser()
+        rows = StringIO.StringIO(self.form['rows'].value)
+        reader = rcsv.reader(rows, rcsv.comma_separated)
         found = {}
         line = 0
-        for row in rows[1:]:
+        for values in reader:
             line += 1
-            values = p.parse(row)
-            # not a complete row, keep going
-            if not values: continue
-
+            if line == 1: continue
             # skip property names header
             if values == props:
                 continue
@@ -1109,6 +1150,12 @@ please visit the following URL:
             nodeid, values = values[0], values[1:]
             found[nodeid] = 1
 
+            # see if the node exists
+            if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
+                exists = 0
+            else:
+                exists = 1
+
             # confirm correct weight
             if len(idlessprops) != len(values):
                 self.error_message.append(
@@ -1118,16 +1165,33 @@ please visit the following URL:
             # extract the new values
             d = {}
             for name, value in zip(idlessprops, values):
+                prop = cl.properties[name]
                 value = value.strip()
                 # only add the property if it has a value
                 if value:
                     # if it's a multilink, split it
-                    if isinstance(cl.properties[name], hyperdb.Multilink):
+                    if isinstance(prop, hyperdb.Multilink):
                         value = value.split(':')
+                    elif isinstance(prop, hyperdb.Password):
+                        value = password.Password(value)
+                    elif isinstance(prop, hyperdb.Interval):
+                        value = date.Interval(value)
+                    elif isinstance(prop, hyperdb.Date):
+                        value = date.Date(value)
+                    elif isinstance(prop, hyperdb.Boolean):
+                        value = value.lower() in ('yes', 'true', 'on', '1')
+                    elif isinstance(prop, hyperdb.Number):
+                        value = float(value)
                     d[name] = value
+                elif exists:
+                    # nuke the existing value
+                    if isinstance(prop, hyperdb.Multilink):
+                        d[name] = []
+                    else:
+                        d[name] = None
 
             # perform the edit
-            if cl.hasnode(nodeid):
+            if exists:
                 # edit existing
                 cl.set(nodeid, **d)
             else:
@@ -1154,15 +1218,17 @@ please visit the following URL:
             return 0
         return 1
 
-    def searchAction(self):
+    def searchAction(self, wcre=re.compile(r'[\s,]+')):
         ''' Mangle some of the form variables.
 
             Set the form ":filter" variable based on the values of the
             filter variables - if they're set to anything other than
             "dontcare" then add them to :filter.
 
-            Also handle the ":queryname" variable and save off the query to
+            Handle the ":queryname" variable and save off the query to
             the user's query list.
+
+            Split any String query values on whitespace and comma.
         '''
         # generic edit is per-class only
         if not self.searchPermission():
@@ -1188,14 +1254,27 @@ please visit the following URL:
                 else:
                     continue
             else:
-                if not self.form[key].value: continue
+                if not self.form[key].value:
+                    continue
+                if isinstance(props[key], hyperdb.String):
+                    v = self.form[key].value
+                    l = token.token_split(v)
+                    if len(l) > 1 or l[0] != v:
+                        self.form.value.remove(self.form[key])
+                        # replace the single value with the split list
+                        for v in l:
+                            self.form.value.append(cgi.MiniFieldStorage(key, v))
+
             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
 
         # handle saving the query params
         if queryname:
             # parse the environment and figure what the query _is_
             req = HTMLRequest(self)
-            url = req.indexargs_href('', {})
+
+            # The [1:] strips off the '?' character, it isn't part of the
+            # query string.
+            url = req.indexargs_href('', {})[1:]
 
             # handle editing an existing query
             try:
@@ -1282,48 +1361,139 @@ please visit the following URL:
         raise Redirect, url
 
     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
-        ''' Pull properties out of the form.
+        ''' Item properties and their values are edited with html FORM
+            variables and their values. You can:
 
-            In the following, <bracketed> values are variable, ":" may be
-            one of ":" or "@", and other text "required" is fixed.
+            - Change the value of some property of the current item.
+            - Create a new item of any class, and edit the new item's
+              properties,
+            - Attach newly created items to a multilink property of the
+              current item.
+            - Remove items from a multilink property of the current item.
+            - Specify that some properties are required for the edit
+              operation to be successful.
 
-            Properties are specified as form variables:
+            In the following, <bracketed> values are variable, "@" may be
+            either ":" or "@", and other text "required" is fixed.
+
+            Most properties are specified as form variables:
 
              <propname>
               - property on the current context item
 
-             <designator>:<propname>
-              - property on the indicated item
+             <designator>"@"<propname>
+              - property on the indicated item (for editing related
+                information)
+
+            Designators name a specific item of a class.
+
+            <classname><N>
+
+                Name an existing item of class <classname>.
+
+            <classname>"-"<N>
+
+                Name the <N>th new item of class <classname>. If the form
+                submission is successful, a new item of <classname> is
+                created. Within the submitted form, a particular
+                designator of this form always refers to the same new
+                item.
+
+            Once we have determined the "propname", we look at it to see
+            if it's special:
+
+            @required
+                The associated form value is a comma-separated list of
+                property names that must be specified when the form is
+                submitted for the edit operation to succeed.  
+
+                When the <designator> is missing, the properties are
+                for the current context item.  When <designator> is
+                present, they are for the item specified by
+                <designator>.
+
+                The "@required" specifier must come before any of the
+                properties it refers to are assigned in the form.
 
-             <classname>-<N>:<propname>
-              - property on the Nth new item of classname
+            @remove@<propname>=id(s) or @add@<propname>=id(s)
+                The "@add@" and "@remove@" edit actions apply only to
+                Multilink properties.  The form value must be a
+                comma-separate list of keys for the class specified by
+                the simple form variable.  The listed items are added
+                to (respectively, removed from) the specified
+                property.
 
-            Once we have determined the "propname", we check to see if it
-            is one of the special form values:
+            @link@<propname>=<designator>
+                If the edit action is "@link@", the simple form
+                variable must specify a Link or Multilink property.
+                The form value is a comma-separated list of
+                designators.  The item corresponding to each
+                designator is linked to the property given by simple
+                form variable.  These are collected up and returned in
+                all_links.
 
-             :required
-              The named property values must be supplied or a ValueError
-              will be raised.
+            None of the above (ie. just a simple form value)
+                The value of the form variable is converted
+                appropriately, depending on the type of the property.
 
-             :remove:<propname>=id(s)
-              The ids will be removed from the multilink property.
+                For a Link('klass') property, the form value is a
+                single key for 'klass', where the key field is
+                specified in dbinit.py.  
 
-             :add:<propname>=id(s)
-              The ids will be added to the multilink property.
+                For a Multilink('klass') property, the form value is a
+                comma-separated list of keys for 'klass', where the
+                key field is specified in dbinit.py.  
 
-             :link:<propname>=<designator>
-              Used to add a link to new items created during edit.
-              These are collected up and returned in all_links. This will
-              result in an additional linking operation (either Link set or
-              Multilink append) after the edit/create is done using
-              all_props in _editnodes. The <propname> on the current item
-              will be set/appended the id of the newly created item of
-              class <designator> (where <designator> must be
-              <classname>-<N>).
+                Note that for simple-form-variables specifiying Link
+                and Multilink properties, the linked-to class must
+                have a key field.
+
+                For a String() property specifying a filename, the
+                file named by the form value is uploaded. This means we
+                try to set additional properties "filename" and "type" (if
+                they are valid for the class).  Otherwise, the property
+                is set to the form value.
+
+                For Date(), Interval(), Boolean(), and Number()
+                properties, the form value is converted to the
+                appropriate
 
             Any of the form variables may be prefixed with a classname or
             designator.
 
+            Two special form values are supported for backwards
+            compatibility:
+
+            @note
+                This is equivalent to::
+
+                    @link@messages=msg-1
+                    @msg-1@content=value
+
+                except that in addition, the "author" and "date"
+                properties of "msg-1" are set to the userid of the
+                submitter, and the current time, respectively.
+
+            @file
+                This is equivalent to::
+
+                    @link@files=file-1
+                    @file-1@content=value
+
+                The String content value is handled as described above for
+                file uploads.
+
+            If both the "@note" and "@file" form variables are
+            specified, the action::
+
+                    @link@msg-1@files=file-1
+
+            is also performed.
+
+            We also check that FileClass items have a "content" property with
+            actual content, otherwise we remove them from all_props before
+            returning.
+
             The return from this method is a dict of 
                 (classname, id): properties
             ... this dict _always_ has an entry for the current context,
@@ -1331,22 +1501,6 @@ please visit the following URL:
             doesn't result in any changes would return {('issue','123'): {}})
             The id may be None, which indicates that an item should be
             created.
-
-            If a String property's form value is a file upload, then we
-            try to set additional properties "filename" and "type" (if
-            they are valid for the class).
-
-            Two special form values are supported for backwards
-            compatibility:
-             :note - create a message (with content, author and date), link
-                     to the context item. This is ALWAYS desginated "msg-1".
-             :file - create a file, attach to the current item and any
-                     message created by :note. This is ALWAYS designated
-                     "file-1".
-
-            We also check that FileClass items have a "content" property with
-            actual content, otherwise we remove them from all_props before
-            returning.
         '''
         # some very useful variables
         db = self.db
@@ -1366,8 +1520,9 @@ please visit the following URL:
         default_nodeid = self.nodeid
 
         # we'll store info about the individual class/item edit in these
-        all_required = {}       # one entry per class/item
-        all_props = {}          # one entry per class/item
+        all_required = {}       # required props per class/item
+        all_props = {}          # props to set per class/item
+        got_props = {}          # props received per class/item
         all_propdef = {}        # note - only one entry per class
         all_links = []          # as many as are required
 
@@ -1431,6 +1586,8 @@ please visit the following URL:
             if not all_props.has_key(this):
                 all_props[this] = {}
             props = all_props[this]
+            if not got_props.has_key(this):
+                got_props[this] = {}
 
             # is this a link command?
             if d['link']:
@@ -1438,14 +1595,14 @@ please visit the following URL:
                 for entry in extractFormList(form[key]):
                     m = self.FV_DESIGNATOR.match(entry)
                     if not m:
-                        raise ValueError, \
+                        raise FormError, \
                             'link "%s" value "%s" not a designator'%(key, entry)
                     value.append((m.group(1), m.group(2)))
 
                 # make sure the link property is valid
                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
                         not isinstance(propdef[propname], hyperdb.Link)):
-                    raise ValueError, '%s %s is not a link or '\
+                    raise FormError, '%s %s is not a link or '\
                         'multilink property'%(cn, propname)
 
                 all_links.append((cn, nodeid, propname, value))
@@ -1456,11 +1613,6 @@ please visit the following URL:
                 all_required[this] = extractFormList(form[key])
                 continue
 
-            # get the required values list
-            if not all_required.has_key(this):
-                all_required[this] = []
-            required = all_required[this]
-
             # see if we're performing a special multilink action
             mlaction = 'set'
             if d['remove']:
@@ -1471,7 +1623,7 @@ please visit the following URL:
             # does the property exist?
             if not propdef.has_key(propname):
                 if mlaction != 'set':
-                    raise ValueError, 'You have submitted a %s action for'\
+                    raise FormError, 'You have submitted a %s action for'\
                         ' the property "%s" which doesn\'t exist'%(mlaction,
                         propname)
                 # the form element is probably just something we don't care
@@ -1489,7 +1641,7 @@ please visit the following URL:
             else:
                 # multiple values are not OK
                 if isinstance(value, type([])):
-                    raise ValueError, 'You have submitted more than one value'\
+                    raise FormError, 'You have submitted more than one value'\
                         ' for the %s property'%propname
                 # value might be a file upload...
                 if not hasattr(value, 'filename') or value.filename is None:
@@ -1512,13 +1664,13 @@ please visit the following URL:
                         confirm = form[key]
                         break
                 else:
-                    raise ValueError, 'Password and confirmation text do '\
+                    raise FormError, 'Password and confirmation text do '\
                         'not match'
                 if isinstance(confirm, type([])):
-                    raise ValueError, 'You have submitted more than one value'\
+                    raise FormError, 'You have submitted more than one value'\
                         ' for the %s property'%propname
                 if value != confirm.value:
-                    raise ValueError, 'Password and confirmation text do '\
+                    raise FormError, 'Password and confirmation text do '\
                         'not match'
                 value = password.Password(value)
 
@@ -1536,12 +1688,12 @@ please visit the following URL:
                         try:
                             value = db.classes[link].lookup(value)
                         except KeyError:
-                            raise ValueError, _('property "%(propname)s": '
+                            raise FormError, _('property "%(propname)s": '
                                 '%(value)s not a %(classname)s')%{
                                 'propname': propname, 'value': value,
                                 'classname': link}
                         except TypeError, message:
-                            raise ValueError, _('you may only enter ID values '
+                            raise FormError, _('you may only enter ID values '
                                 'for property "%(propname)s": %(message)s')%{
                                 'propname': propname, 'message': message}
             elif isinstance(proptype, hyperdb.Multilink):
@@ -1555,12 +1707,12 @@ please visit the following URL:
                         try:
                             entry = link_cl.lookup(entry)
                         except KeyError:
-                            raise ValueError, _('property "%(propname)s": '
+                            raise FormError, _('property "%(propname)s": '
                                 '"%(value)s" not an entry of %(classname)s')%{
                                 'propname': propname, 'value': entry,
                                 'classname': link}
                         except TypeError, message:
-                            raise ValueError, _('you may only enter ID values '
+                            raise FormError, _('you may only enter ID values '
                                 'for property "%(propname)s": %(message)s')%{
                                 'propname': propname, 'message': message}
                     l.append(entry)
@@ -1586,7 +1738,7 @@ please visit the following URL:
                             try:
                                 existing.remove(entry)
                             except ValueError:
-                                raise ValueError, _('property "%(propname)s": '
+                                raise FormError, _('property "%(propname)s": '
                                     '"%(value)s" not currently in list')%{
                                     'propname': propname, 'value': entry}
                     else:
@@ -1604,36 +1756,45 @@ please visit the following URL:
                 # other types should be None'd if there's no value
                 value = None
             else:
-                if isinstance(proptype, hyperdb.String):
-                    if (hasattr(value, 'filename') and
-                            value.filename is not None):
-                        # skip if the upload is empty
-                        if not value.filename:
-                            continue
-                        # this String is actually a _file_
-                        # try to determine the file content-type
-                        filename = value.filename.split('\\')[-1]
-                        if propdef.has_key('name'):
-                            props['name'] = filename
-                        # use this info as the type/filename properties
-                        if propdef.has_key('type'):
-                            props['type'] = mimetypes.guess_type(filename)[0]
-                            if not props['type']:
-                                props['type'] = "application/octet-stream"
-                        # finally, read the content
-                        value = value.value
-                    else:
-                        # normal String fix the CRLF/CR -> LF stuff
-                        value = fixNewlines(value)
-
-                elif isinstance(proptype, hyperdb.Date):
-                    value = date.Date(value, offset=timezone)
-                elif isinstance(proptype, hyperdb.Interval):
-                    value = date.Interval(value)
-                elif isinstance(proptype, hyperdb.Boolean):
-                    value = value.lower() in ('yes', 'true', 'on', '1')
-                elif isinstance(proptype, hyperdb.Number):
-                    value = float(value)
+                # handle ValueErrors for all these in a similar fashion
+                try:
+                    if isinstance(proptype, hyperdb.String):
+                        if (hasattr(value, 'filename') and
+                                value.filename is not None):
+                            # skip if the upload is empty
+                            if not value.filename:
+                                continue
+                            # this String is actually a _file_
+                            # try to determine the file content-type
+                            fn = value.filename.split('\\')[-1]
+                            if propdef.has_key('name'):
+                                props['name'] = fn
+                            # use this info as the type/filename properties
+                            if propdef.has_key('type'):
+                                props['type'] = mimetypes.guess_type(fn)[0]
+                                if not props['type']:
+                                    props['type'] = "application/octet-stream"
+                            # finally, read the content
+                            value = value.value
+                        else:
+                            # normal String fix the CRLF/CR -> LF stuff
+                            value = fixNewlines(value)
+
+                    elif isinstance(proptype, hyperdb.Date):
+                        value = date.Date(value, offset=timezone)
+                    elif isinstance(proptype, hyperdb.Interval):
+                        value = date.Interval(value)
+                    elif isinstance(proptype, hyperdb.Boolean):
+                        value = value.lower() in ('yes', 'true', 'on', '1')
+                    elif isinstance(proptype, hyperdb.Number):
+                        value = float(value)
+                except ValueError, msg:
+                    raise FormError, _('Error with %s property: %s')%(
+                        propname, msg)
+
+            # register that we got this property
+            if value:
+                got_props[this][propname] = 1
 
             # get the old value
             if nodeid and not nodeid.startswith('-'):
@@ -1644,6 +1805,8 @@ please visit the following URL:
                     # no existing value
                     if not propdef.has_key(propname):
                         raise
+                except IndexError, message:
+                    raise FormError(str(message))
 
                 # make sure the existing multilink is sorted
                 if isinstance(proptype, hyperdb.Multilink):
@@ -1675,10 +1838,6 @@ please visit the following URL:
 
                 props[propname] = value
 
-            # register this as received if required?
-            if propname in required and value is not None:
-                required.remove(propname)
-
         # check to see if we need to specially link a file to the note
         if have_note and have_file:
             all_links.append(('msg', '-1', 'files', [('file', '-1')]))
@@ -1686,8 +1845,17 @@ please visit the following URL:
         # see if all the required properties have been supplied
         s = []
         for thing, required in all_required.items():
+            # register the values we got
+            got = got_props.get(thing, {})
+            for entry in required[:]:
+                if got.has_key(entry):
+                    required.remove(entry)
+
+            # any required values not present?
             if not required:
                 continue
+
+            # tell the user to entry the values required
             if len(required) > 1:
                 p = 'properties'
             else:
@@ -1695,17 +1863,19 @@ please visit the following URL:
             s.append('Required %s %s %s not supplied'%(thing[0], p,
                 ', '.join(required)))
         if s:
-            raise ValueError, '\n'.join(s)
+            raise FormError, '\n'.join(s)
 
-        # check that FileClass entries have a "content" property with
-        # content, otherwise remove them
+        # When creating a FileClass node, it should have a non-empty content
+        # property to be created. When editing a FileClass node, it should
+        # either have a non-empty content property or no property at all. In
+        # the latter case, nothing will change.
         for (cn, id), props in all_props.items():
-            cl = self.db.classes[cn]
-            if not isinstance(cl, hyperdb.FileClass):
-                continue
-            # we also don't want to create FileClass items with no content
-            if not props.get('content', ''):
-                del all_props[(cn, id)]
+            if isinstance(self.db.classes[cn], hyperdb.FileClass):
+                if id == '-1':
+                      if not props.get('content', ''):
+                            del all_props[(cn, id)]
+                elif props.has_key('content') and not props['content']:
+                      raise FormError, _('File is empty')
         return all_props, all_links
 
 def fixNewlines(text):
@@ -1722,18 +1892,20 @@ def extractFormList(value):
     ''' Extract a list of values from the form value.
 
         It may be one of:
-         [MiniFieldStorage, MiniFieldStorage, ...]
+         [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
          MiniFieldStorage('value,value,...')
          MiniFieldStorage('value')
     '''
     # multiple values are OK
     if isinstance(value, type([])):
-        # it's a list of MiniFieldStorages
-        value = [i.value.strip() for i in value]
+        # it's a list of MiniFieldStorages - join then into
+        values = ','.join([i.value.strip() for i in value])
     else:
         # it's a MiniFieldStorage, but may be a comma-separated list
         # of values
-        value = [i.strip() for i in value.value.split(',')]
+        values = value.value
+
+    value = [i.strip() for i in values.split(',')]
 
     # filter out the empty bits
     return filter(None, value)