Code

Fix to handle bad form submissions, Links and the magic -1 form value.
[roundup.git] / roundup / cgi / client.py
index c7a2e79c7cba6a9190c21781542e5e7e24d232bd..c1c8021c5a79df735fbfea724c3d2f07e14cfed2 100644 (file)
@@ -1,4 +1,4 @@
-# $Id: client.py,v 1.36 2002-09-16 06:39:12 richard Exp $
+# $Id: client.py,v 1.60 2002-12-10 06:01:59 richard Exp $
 
 __doc__ = """
 WWW request handler (also used in the stand-alone server).
@@ -10,7 +10,7 @@ import binascii, Cookie, time, random
 from roundup import roundupdb, date, hyperdb, password
 from roundup.i18n import _
 
-from roundup.cgi.templating import getTemplate, HTMLRequest, NoTemplate
+from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
 from roundup.cgi import cgitb
 
 from roundup.cgi.PageTemplates import PageTemplate
@@ -48,24 +48,36 @@ def initialiseSecurity(security):
     security.addPermissionToRole('Admin', p)
 
 class Client:
-    '''
-    A note about login
-    ------------------
-
-    If the user has no login cookie, then they are anonymous. There
-    are two levels of anonymous use. If there is no 'anonymous' user, there
-    is no login at all and the database is opened in read-only mode. If the
-    'anonymous' user exists, the user is logged in using that user (though
-    there is no cookie). This allows them to modify the database, and all
-    modifications are attributed to the 'anonymous' user.
-
-    Once a user logs in, they are assigned a session. The Client instance
-    keeps the nodeid of the session as the "session" attribute.
-
-    Client attributes:
-        "url" is the current url path
-        "path" is the PATH_INFO inside the instance
+    ''' Instantiate to handle one CGI request.
+
+    See inner_main for request processing.
+
+    Client attributes at instantiation:
+        "path" is the PATH_INFO inside the instance (with no leading '/')
         "base" is the base URL for the instance
+        "form" is the cgi form, an instance of FieldStorage from the standard
+               cgi module
+        "additional_headers" is a dictionary of additional HTTP headers that
+               should be sent to the client
+        "response_code" is the HTTP response code to send to the client
+
+    During the processing of a request, the following attributes are used:
+        "error_message" holds a list of error messages
+        "ok_message" holds a list of OK messages
+        "session" is the current user session id
+        "user" is the current user's name
+        "userid" is the current user's id
+        "template" is the current :template context
+        "classname" is the current class context name
+        "nodeid" is the current context item id
+
+    User Identification:
+     If the user has no login cookie, then they are anonymous and are logged
+     in as that user. This typically gives them all Permissions assigned to the
+     Anonymous Role.
+
+     Once a user logs in, they are assigned a session. The Client instance
+     keeps the nodeid of the session as the "session" attribute.
     '''
 
     def __init__(self, instance, request, env, form=None):
@@ -74,31 +86,28 @@ class Client:
         self.request = request
         self.env = env
 
+        # save off the path
         self.path = env['PATH_INFO']
-        self.split_path = self.path.split('/')
-        self.instance_path_name = env['TRACKER_NAME']
 
         # this is the base URL for this instance
-        url = self.env['SCRIPT_NAME'] + '/' + self.instance_path_name
-        self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
-            None, None, None))
-
-        # request.path is the full request path
-        x, x, path, x, x, x = urlparse.urlparse(request.path)
-        self.url = urlparse.urlunparse(('http', env['HTTP_HOST'], path,
-            None, None, None))
+        self.base = self.instance.config.TRACKER_WEB
 
+        # see if we need to re-parse the environment for the form (eg Zope)
         if form is None:
             self.form = cgi.FieldStorage(environ=env)
         else:
             self.form = form
-        self.headers_done = 0
+
+        # turn debugging on/off
         try:
             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
         except ValueError:
             # someone gave us a non-int debug level, turn it off
             self.debug = 0
 
+        # flag to indicate that the HTTP headers have been sent
+        self.headers_done = 0
+
         # additional headers to send with the request - must be registered
         # before the first write
         self.additional_headers = {}
@@ -138,7 +147,6 @@ class Client:
             - NotFound       (raised wherever it needs to be)
               percolates up to the CGI interface that called the client
         '''
-        self.content_action = None
         self.ok_message = []
         self.error_message = []
         try:
@@ -156,14 +164,8 @@ class Client:
             self.additional_headers['Pragma'] = 'no-cache'
             self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
 
-            if self.form.has_key(':contentonly'):
-                # just the content
-                self.write(self.content())
-            else:
-                # render the content inside the page template
-                self.write(self.renderTemplate('page', '',
-                    ok_message=self.ok_message,
-                    error_message=self.error_message))
+            # render the content
+            self.write(self.renderContext())
         except Redirect, url:
             # let's redirect - if the url isn't None, then we need to do
             # the headers, otherwise the headers have been set before the
@@ -177,7 +179,13 @@ class Client:
         except SendStaticFile, file:
             self.serve_static_file(str(file))
         except Unauthorised, message:
-            self.write(self.renderTemplate('page', '', error_message=message))
+            self.classname=None
+            self.template=''
+            self.error_message.append(message)
+            self.write(self.renderContext())
+        except NotFound:
+            # pass through
+            raise
         except:
             # everything else
             self.write(cgitb.html())
@@ -277,7 +285,7 @@ class Client:
         self.nodeid = None
 
         # determine the classname and possibly nodeid
-        path = self.split_path
+        path = self.path.split('/')
         if not path or path[0] in ('', 'home', 'index'):
             if self.form.has_key(':template'):
                 self.template = self.form[':template'].value
@@ -305,6 +313,12 @@ class Client:
             # with only a class, we default to index view
             self.template = 'index'
 
+        # make sure the classname is valid
+        try:
+            self.db.getclass(self.classname)
+        except KeyError:
+            raise NotFound, self.classname
+
         # see if we have a template override
         if self.form.has_key(':template'):
             self.template = self.form[':template'].value
@@ -337,39 +351,27 @@ class Client:
         self.write(open(os.path.join(self.instance.config.TEMPLATES,
             file)).read())
 
-    def renderTemplate(self, name, extension, **kwargs):
+    def renderContext(self):
         ''' Return a PageTemplate for the named page
         '''
-        pt = getTemplate(self.instance.config.TEMPLATES, name, extension)
+        name = self.classname
+        extension = self.template
+        pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
+
         # catch errors so we can handle PT rendering errors more nicely
+        args = {
+            'ok_message': self.ok_message,
+            'error_message': self.error_message
+        }
         try:
             # let the template render figure stuff out
-            return pt.render(self, None, None, **kwargs)
-        except PageTemplate.PTRuntimeError, message:
-            return '<strong>%s</strong><ol><li>%s</ol>'%(message,
-                '<li>'.join([cgi.escape(x) for x in pt._v_errors]))
+            return pt.render(self, None, None, **args)
         except NoTemplate, message:
             return '<strong>%s</strong>'%message
         except:
             # everything else
             return cgitb.pt_html()
 
-    def content(self):
-        ''' Callback used by the page template to render the content of 
-            the page.
-
-            If we don't have a specific class to display, that is none was
-            determined in determine_context(), then we display a "home"
-            template.
-        '''
-        # now render the page content using the template we determined in
-        # determine_context
-        if self.classname is None:
-            name = 'home'
-        else:
-            name = self.classname
-        return self.renderTemplate(self.classname, self.template)
-
     # these are the actions that are available
     actions = (
         ('edit',     'editItemAction'),
@@ -379,6 +381,7 @@ class Client:
         ('login',    'loginAction'),
         ('logout',   'logout_action'),
         ('search',   'searchAction'),
+        ('retire',   'retireAction'),
     )
     def handle_action(self):
         ''' Determine whether there should be an _action called.
@@ -392,7 +395,7 @@ class Client:
              "login"     -> self.loginAction
              "logout"    -> self.logout_action
              "search"    -> self.searchAction
-
+             "retire"    -> self.retireAction
         '''
         if not self.form.has_key(':action'):
             return None
@@ -443,7 +446,10 @@ class Client:
         if self.debug:
             self.headers_sent = headers
 
-    def set_cookie(self, user, password):
+    def set_cookie(self, user):
+        ''' Set up a session cookie for the user and store away the user's
+            login info against the session.
+        '''
         # TODO generate a much, much stronger session key ;)
         self.session = binascii.b2a_base64(repr(random.random())).strip()
 
@@ -478,24 +484,13 @@ class Client:
         self.userid = self.db.user.lookup('anonymous')
         self.user = 'anonymous'
 
-    def logout(self):
-        ''' Make us really anonymous - nuke the cookie too
-        '''
-        self.make_user_anonymous()
-
-        # construct the logout cookie
-        now = Cookie._getdate()
-        path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
-            ''))
-        self.additional_headers['Set-Cookie'] = \
-           'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
-        self.login()
-
     def opendb(self, user):
         ''' Open the database.
         '''
         # open the db if the user has changed
         if not hasattr(self, 'db') or user != self.db.journaltag:
+            if hasattr(self, 'db'):
+                self.db.close()
             self.db = self.instance.open(user)
 
     #
@@ -512,25 +507,24 @@ class Client:
             self.error_message.append(_('Username required'))
             return
 
+        # get the login info
         self.user = self.form['__login_name'].value
-        # re-open the database for real, using the user
-        self.opendb(self.user)
         if self.form.has_key('__login_password'):
             password = self.form['__login_password'].value
         else:
             password = ''
+
         # make sure the user exists
         try:
             self.userid = self.db.user.lookup(self.user)
         except KeyError:
             name = self.user
-            self.make_user_anonymous()
             self.error_message.append(_('No such user "%(name)s"')%locals())
+            self.make_user_anonymous()
             return
 
-        # and that the password is correct
-        pw = self.db.user.get(self.userid, 'password')
-        if password != pw:
+        # verify the password
+        if not self.verifyPassword(self.userid, password):
             self.make_user_anonymous()
             self.error_message.append(_('Incorrect password'))
             return
@@ -538,10 +532,24 @@ class Client:
         # make sure we're allowed to be here
         if not self.loginPermission():
             self.make_user_anonymous()
-            raise Unauthorised, _("You do not have permission to login")
+            self.error_message.append(_("You do not have permission to login"))
+            return
+
+        # now we're OK, re-open the database for real, using the user
+        self.opendb(self.user)
 
         # set the session cookie
-        self.set_cookie(self.user, password)
+        self.set_cookie(self.user)
+
+    def verifyPassword(self, userid, password):
+        ''' Verify the password that the user has supplied
+        '''
+        stored = self.db.user.get(self.userid, 'password')
+        if password == stored:
+            return 1
+        if not password and not stored:
+            return 1
+        return 0
 
     def loginPermission(self):
         ''' Determine whether the user has permission to log in.
@@ -599,7 +607,7 @@ class Client:
             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
             self.userid = cl.create(**props)
             self.db.commit()
-        except ValueError, message:
+        except (ValueError, KeyError), message:
             self.error_message.append(message)
             return
 
@@ -607,14 +615,20 @@ class Client:
         self.user = cl.get(self.userid, 'username')
         # re-open the database for real, using the user
         self.opendb(self.user)
-        password = self.db.user.get(self.userid, 'password')
-        self.set_cookie(self.user, password)
+
+        # if we have a session, update it
+        if hasattr(self, 'session'):
+            self.db.sessions.set(self.session, user=self.user,
+                last_use=time.time())
+        else:
+            # new session cookie
+            self.set_cookie(self.user)
 
         # nice message
         message = _('You are now registered, welcome!')
 
         # redirect to the item's edit page
-        raise Redirect, '%s/%s%s?:ok_message=%s'%(
+        raise Redirect, '%s%s%s?:ok_message=%s'%(
             self.base, self.classname, self.userid,  urllib.quote(message))
 
     def registerPermission(self, props):
@@ -649,6 +663,11 @@ class Client:
             :required=property,property,...
              The named properties are required to be filled in the form.
 
+            :remove:<propname>=id(s)
+             The ids will be removed from the multilink property.
+            :add:<propname>=id(s)
+             The ids will be added to the multilink property.
+
         '''
         cl = self.db.classes[self.classname]
 
@@ -672,7 +691,7 @@ class Client:
             props = self._changenode(props)
             # handle linked nodes 
             self._post_editnode(self.nodeid)
-        except (ValueError, KeyError), message:
+        except (ValueError, KeyError, IndexError), message:
             self.error_message.append(_('Error: ') + str(message))
             return
 
@@ -691,7 +710,7 @@ class Client:
             message = _('nothing changed')
 
         # redirect to the item's edit page
-        raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
+        raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
             self.nodeid,  urllib.quote(message))
 
     def editItemPermission(self, props):
@@ -752,7 +771,19 @@ class Client:
         try:
             # do the create
             nid = self._createnode(props)
+        except (ValueError, KeyError, IndexError), message:
+            # these errors might just be indicative of user dumbness
+            self.error_message.append(_('Error: ') + str(message))
+            return
+        except:
+            # oops
+            self.db.rollback()
+            s = StringIO.StringIO()
+            traceback.print_exc(None, s)
+            self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
+            return
 
+        try:
             # handle linked nodes 
             self._post_editnode(nid)
 
@@ -764,9 +795,6 @@ class Client:
 
             # and some nice feedback for the user
             message = _('%(classname)s created ok')%self.__dict__ + xtra
-        except (ValueError, KeyError), message:
-            self.error_message.append(_('Error: ') + str(message))
-            return
         except:
             # oops
             self.db.rollback()
@@ -776,7 +804,7 @@ class Client:
             return
 
         # redirect to the new item's page
-        raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, self.classname,
+        raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
             nid,  urllib.quote(message))
 
     def newItemPermission(self, props):
@@ -942,33 +970,46 @@ class Client:
             return 0
         return 1
 
-    def remove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
-        # XXX I believe this could be handled by a regular edit action that
-        # just sets the multilink...
-        target = self.index_arg(':target')[0]
-        m = dre.match(target)
-        if m:
-            classname = m.group(1)
-            nodeid = m.group(2)
-            cl = self.db.getclass(classname)
-            cl.retire(nodeid)
-            # now take care of the reference
-            parentref =  self.index_arg(':multilink')[0]
-            parent, prop = parentref.split(':')
-            m = dre.match(parent)
-            if m:
-                self.classname = m.group(1)
-                self.nodeid = m.group(2)
-                cl = self.db.getclass(self.classname)
-                value = cl.get(self.nodeid, prop)
-                value.remove(nodeid)
-                cl.set(self.nodeid, **{prop:value})
-                func = getattr(self, 'show%s'%self.classname)
-                return func()
-            else:
-                raise NotFound, parent
-        else:
-            raise NotFound, target
+    def retireAction(self):
+        ''' Retire the context item.
+        '''
+        # if we want to view the index template now, then unset the nodeid
+        # context info (a special-case for retire actions on the index page)
+        nodeid = self.nodeid
+        if self.template == 'index':
+            self.nodeid = None
+
+        # generic edit is per-class only
+        if not self.retirePermission():
+            self.error_message.append(
+                _('You do not have permission to retire %s' %self.classname))
+            return
+
+        # make sure we don't try to retire admin or anonymous
+        if self.classname == 'user' and \
+                self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
+            self.error_message.append(
+                _('You may not retire the admin or anonymous user'))
+            return
+
+        # do the retire
+        self.db.getclass(self.classname).retire(nodeid)
+        self.db.commit()
+
+        self.ok_message.append(
+            _('%(classname)s %(itemid)s has been retired')%{
+                'classname': self.classname.capitalize(), 'itemid': nodeid})
+
+    def retirePermission(self):
+        ''' Determine whether the user has permission to retire this class.
+
+            Base behaviour is to check the user can edit this class.
+        ''' 
+        if not self.db.security.hasPermission('Edit', self.userid,
+                self.classname):
+            return 0
+        return 1
+
 
     #
     #  Utility methods for editing
@@ -1026,7 +1067,8 @@ class Client:
         # in a nutshell, don't do anything if there's no note or there's no
         # NOSY
         if self.form.has_key(':note'):
-            note = self.form[':note'].value.strip()
+            # fix the CRLF/CR -> LF stuff
+            note = fixNewlines(self.form[':note'].value.strip())
         if not note:
             return None, files
         if not props.has_key('messages'):
@@ -1098,12 +1140,27 @@ class Client:
                     link = self.db.classes[link]
                     link.set(nodeid, **{property: nid})
 
+def fixNewlines(text):
+    ''' Homogenise line endings.
+
+        Different web clients send different line ending values, but
+        other systems (eg. email) don't necessarily handle those line
+        endings. Our solution is to convert all line endings to LF.
+    '''
+    text = text.replace('\r\n', '\n')
+    return text.replace('\r', '\n')
 
 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
     ''' Pull properties for the given class out of the form.
 
         If a ":required" parameter is supplied, then the names property values
         must be supplied or a ValueError will be raised.
+
+        Other special form values:
+         :remove:<propname>=id(s)
+          The ids will be removed from the multilink property.
+         :add:<propname>=id(s)
+          The ids will be added to the multilink property.
     '''
     required = []
     if form.has_key(':required'):
@@ -1115,10 +1172,27 @@ def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
 
     props = {}
     keys = form.keys()
+    properties = cl.getprops()
     for key in keys:
-        if not cl.properties.has_key(key):
+        # see if we're performing a special multilink action
+        mlaction = 'set'
+        if key.startswith(':remove:'):
+            propname = key[8:]
+            mlaction = 'remove'
+        elif key.startswith(':add:'):
+            propname = key[5:]
+            mlaction = 'add'
+        else:
+            propname = key
+
+
+        # does the property exist?
+        if not properties.has_key(propname):
+            if mlaction != 'set':
+                raise ValueError, 'You have submitted a remove action for'\
+                    ' the property "%s" which doesn\'t exist'%propname
             continue
-        proptype = cl.properties[key]
+        proptype = properties[propname]
 
         # Get the form value. This value may be a MiniFieldStorage or a list
         # of MiniFieldStorages.
@@ -1128,7 +1202,7 @@ def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
         if not isinstance(proptype, hyperdb.Multilink):
             if isinstance(value, type([])):
                 raise ValueError, 'You have submitted more than one value'\
-                    ' for the %s property'%key
+                    ' for the %s property'%propname
             # we've got a MiniFieldStorage, so pull out the value and strip
             # surrounding whitespace
             value = value.value.strip()
@@ -1136,27 +1210,29 @@ def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
         if isinstance(proptype, hyperdb.String):
             if not value:
                 continue
+            # fix the CRLF/CR -> LF stuff
+            value = fixNewlines(value)
         elif isinstance(proptype, hyperdb.Password):
             if not value:
                 # ignore empty password values
                 continue
-            if not form.has_key('%s:confirm'%key):
+            if not form.has_key('%s:confirm'%propname):
                 raise ValueError, 'Password and confirmation text do not match'
-            confirm = form['%s:confirm'%key]
+            confirm = form['%s:confirm'%propname]
             if isinstance(confirm, type([])):
                 raise ValueError, 'You have submitted more than one value'\
-                    ' for the %s property'%key
+                    ' for the %s property'%propname
             if value != confirm.value:
                 raise ValueError, 'Password and confirmation text do not match'
             value = password.Password(value)
         elif isinstance(proptype, hyperdb.Date):
             if value:
-                value = date.Date(form[key].value.strip())
+                value = date.Date(value)
             else:
                 value = None
         elif isinstance(proptype, hyperdb.Interval):
             if value:
-                value = date.Interval(form[key].value.strip())
+                value = date.Interval(value)
             else:
                 value = None
         elif isinstance(proptype, hyperdb.Link):
@@ -1165,18 +1241,19 @@ def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
                 value = None
             else:
                 # handle key values
-                link = cl.properties[key].classname
+                link = proptype.classname
                 if not num_re.match(value):
                     try:
                         value = db.classes[link].lookup(value)
                     except KeyError:
                         raise ValueError, _('property "%(propname)s": '
-                            '%(value)s not a %(classname)s')%{'propname':key, 
-                            'value': value, 'classname': link}
+                            '%(value)s not a %(classname)s')%{
+                            'propname': propname, 'value': value,
+                            'classname': link}
                     except TypeError, message:
                         raise ValueError, _('you may only enter ID values '
                             'for property "%(propname)s": %(message)s')%{
-                            'propname':key, 'message': message}
+                            'propname': propname, 'message': message}
         elif isinstance(proptype, hyperdb.Multilink):
             if isinstance(value, type([])):
                 # it's a list of MiniFieldStorages
@@ -1185,7 +1262,7 @@ def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
                 # it's a MiniFieldStorage, but may be a comma-separated list
                 # of values
                 value = [i.strip() for i in value.value.split(',')]
-            link = cl.properties[key].classname
+            link = proptype.classname
             l = []
             for entry in map(str, value):
                 if entry == '': continue
@@ -1195,37 +1272,65 @@ def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
                     except KeyError:
                         raise ValueError, _('property "%(propname)s": '
                             '"%(value)s" not an entry of %(classname)s')%{
-                            'propname':key, 'value': entry, 'classname': link}
+                            'propname': propname, 'value': entry,
+                            'classname': link}
                     except TypeError, message:
                         raise ValueError, _('you may only enter ID values '
                             'for property "%(propname)s": %(message)s')%{
-                            'propname':key, 'message': message}
+                            'propname': propname, 'message': message}
                 l.append(entry)
             l.sort()
-            value = l
+
+            # now use that list of ids to modify the multilink
+            if mlaction == 'set':
+                value = l
+            else:
+                # we're modifying the list - get the current list of ids
+                try:
+                    existing = cl.get(nodeid, propname)
+                except KeyError:
+                    existing = []
+                if mlaction == 'remove':
+                    # remove - handle situation where the id isn't in the list
+                    for entry in l:
+                        try:
+                            existing.remove(entry)
+                        except ValueError:
+                            raise ValueError, _('property "%(propname)s": '
+                                '"%(value)s" not currently in list')%{
+                                'propname': propname, 'value': entry}
+                else:
+                    # add - easy, just don't dupe
+                    for entry in l:
+                        if entry not in existing:
+                            existing.append(entry)
+                value = existing
+                value.sort()
+
         elif isinstance(proptype, hyperdb.Boolean):
-            props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
+            value = value.lower() in ('yes', 'true', 'on', '1')
         elif isinstance(proptype, hyperdb.Number):
-            props[key] = value = int(value)
+            value = int(value)
 
-        # register this as received if required
-        if key in required:
-            required.remove(key)
+        # register this as received if required?
+        if propname in required and value is not None:
+            required.remove(propname)
 
         # get the old value
         if nodeid:
             try:
-                existing = cl.get(nodeid, key)
+                existing = cl.get(nodeid, propname)
             except KeyError:
                 # this might be a new property for which there is no existing
                 # value
-                if not cl.properties.has_key(key): raise
+                if not properties.has_key(propname):
+                    raise
 
             # if changed, set it
             if value != existing:
-                props[key] = value
+                props[propname] = value
         else:
-            props[key] = value
+            props[propname] = value
 
     # see if all the required properties have been supplied
     if required: