Code

Modified roundup-mailgw so it can read e-mails from a local mail spool
[roundup.git] / roundup / cgi_client.py
index 88fc686db371986c55a9d9854782ec6d4ddb9fa7..43b648e85cf70746552d98ee706e83ca8715d370 100644 (file)
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: cgi_client.py,v 1.38 2001-10-22 03:25:01 richard Exp $
+# $Id: cgi_client.py,v 1.55 2001-11-07 02:34:06 jhermann Exp $
 
 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
-import base64, Cookie, time
+import binascii, Cookie, time
 
 import roundupdb, htmltemplate, date, hyperdb, password
 
@@ -52,31 +52,39 @@ class Client:
     ANONYMOUS_ACCESS = 'deny'        # one of 'deny', 'allow'
     ANONYMOUS_REGISTER = 'deny'      # one of 'deny', 'allow'
 
-    def __init__(self, instance, out, env):
+    def __init__(self, instance, request, env):
         self.instance = instance
-        self.out = out
+        self.request = request
         self.env = env
         self.path = env['PATH_INFO']
         self.split_path = self.path.split('/')
 
-        self.headers_done = 0
         self.form = cgi.FieldStorage(environ=env)
         self.headers_done = 0
-        self.debug = 0
+        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
 
     def getuid(self):
         return self.db.user.lookup(self.user)
 
     def header(self, headers={'Content-Type':'text/html'}):
+        '''Put up the appropriate header.
+        '''
         if not headers.has_key('Content-Type'):
             headers['Content-Type'] = 'text/html'
+        self.request.send_response(200)
         for entry in headers.items():
-            self.out.write('%s: %s\n'%entry)
-        self.out.write('\n')
+            self.request.send_header(*entry)
+        self.request.end_headers()
         self.headers_done = 1
+        if self.debug:
+            self.headers_sent = headers
 
     def pagehead(self, title, message=None):
-        url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
+        url = self.env['SCRIPT_NAME'] + '/'
         machine = self.env['SERVER_NAME']
         port = self.env['SERVER_PORT']
         if port != '80': machine = machine + ':' + port
@@ -86,11 +94,27 @@ class Client:
         else:
             message = ''
         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
-        if self.user is not None:
+        user_name = self.user or ''
+        if self.user == 'admin':
+            admin_links = ' | <a href="list_classes">Class List</a>'
+        else:
+            admin_links = ''
+        if self.user not in (None, 'anonymous'):
             userid = self.db.user.lookup(self.user)
-            user_info = '(login: <a href="user%s">%s</a>)'%(userid, self.user)
+            user_info = '''
+<a href="issue?assignedto=%s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
+<a href="user%s">My Details</a> | <a href="logout">Logout</a>
+'''%(userid, userid)
+        else:
+            user_info = '<a href="login">Login</a>'
+        if self.user is not None:
+            add_links = '''
+| Add
+<a href="newissue">Issue</a>,
+<a href="newuser">User</a>
+'''
         else:
-            user_info = ''
+            add_links = ''
         self.write('''<html><head>
 <title>%s</title>
 <style type="text/css">%s</style>
@@ -98,9 +122,19 @@ class Client:
 <body bgcolor=#ffffff>
 %s
 <table width=100%% border=0 cellspacing=0 cellpadding=2>
-<tr class="location-bar"><td><big><strong>%s</strong></big> %s</td></tr>
+<tr class="location-bar"><td><big><strong>%s</strong></big></td>
+<td align=right valign=bottom>%s</td></tr>
+<tr class="location-bar">
+<td align=left>All
+<a href="issue?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>
+| Unassigned
+<a href="issue?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>
+%s
+%s</td>
+<td align=right>%s</td>
 </table>
-'''%(title, style, message, title, user_info))
+'''%(title, style, message, title, user_name, add_links, admin_links,
+    user_info))
 
     def pagefoot(self):
         if self.debug:
@@ -112,21 +146,30 @@ class Client:
             if keys:
                 self.write('<dt><b>Form entries</b></dt>')
                 for k in self.form.keys():
-                    v = str(self.form[k].value)
-                    self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
+                    v = self.form.getvalue(k, "<empty>")
+                    if type(v) is type([]):
+                        # Multiple username fields specified
+                        v = "|".join(v)
+                    self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
+            keys = self.headers_sent.keys()
+            keys.sort()
+            self.write('<dt><b>Sent these HTTP headers</b></dt>')
+            for k in keys:
+                v = self.headers_sent[k]
+                self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
             keys = self.env.keys()
             keys.sort()
             self.write('<dt><b>CGI environment</b></dt>')
             for k in keys:
                 v = self.env[k]
-                self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
+                self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
             self.write('</dl></small>')
         self.write('</body></html>')
 
     def write(self, content):
         if not self.headers_done:
             self.header()
-        self.out.write(content)
+        self.request.wfile.write(content)
 
     def index_arg(self, arg):
         ''' handle the args to index - they might be a list from the form
@@ -240,8 +283,8 @@ class Client:
         if show_customization is None:
             show_customization = self.customization_widget()
 
-        htmltemplate.index(self, self.TEMPLATES, self.db, cn, filterspec,
-            filter, columns, sort, group,
+        index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
+        index.render(filterspec, filter, columns, sort, group,
             show_customization=show_customization)
         self.pagefoot()
 
@@ -276,19 +319,66 @@ class Client:
         nodeid = self.nodeid
 
         # use the template to display the item
-        htmltemplate.item(self, self.TEMPLATES, self.db, self.classname, nodeid)
+        item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
+        item.render(nodeid)
+
         self.pagefoot()
     showissue = shownode
     showmsg = shownode
 
     def showuser(self, message=None):
-        ''' display an item
+        '''Display a user page for editing. Make sure the user is allowed
+            to edit this node, and also check for password changes.
         '''
-        if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
-            self.shownode(message)
-        else:
+        if self.user == 'anonymous':
+            raise Unauthorised
+
+        user = self.db.user
+
+        # get the username of the node being edited
+        node_user = user.get(self.nodeid, 'username')
+
+        if self.user not in ('admin', node_user):
             raise Unauthorised
 
+        #
+        # perform any editing
+        #
+        keys = self.form.keys()
+        num_re = re.compile('^\d+$')
+        if keys:
+            try:
+                props, changed = parsePropsFromForm(self.db, user, self.form,
+                    self.nodeid)
+                if self.nodeid == self.getuid() and 'password' in changed:
+                    set_cookie = self.form['password'].value.strip()
+                else:
+                    set_cookie = 0
+                user.set(self.nodeid, **props)
+                self._post_editnode(self.nodeid, changed)
+                # and some feedback for the user
+                message = '%s edited ok'%', '.join(changed)
+            except:
+                s = StringIO.StringIO()
+                traceback.print_exc(None, s)
+                message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
+        else:
+            set_cookie = 0
+
+        # fix the cookie if the password has changed
+        if set_cookie:
+            self.set_cookie(self.user, set_cookie)
+
+        #
+        # now the display
+        #
+        self.pagehead('User: %s'%node_user, message)
+
+        # use the template to display the item
+        item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
+        item.render(self.nodeid)
+        self.pagefoot()
+
     def showfile(self):
         ''' display a file
         '''
@@ -433,8 +523,12 @@ class Client:
                 traceback.print_exc(None, s)
                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
         self.pagehead('New %s'%self.classname.capitalize(), message)
-        htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
-            self.form)
+
+        # call the template
+        newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
+            self.classname)
+        newitem.render(self.form)
+
         self.pagefoot()
     newissue = newnode
     newuser = newnode
@@ -466,8 +560,9 @@ class Client:
                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
 
         self.pagehead('New %s'%self.classname.capitalize(), message)
-        htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
-            self.form)
+        newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
+            self.classname)
+        newitem.render(self.form)
         self.pagefoot()
 
     def classes(self, message=None):
@@ -491,7 +586,7 @@ class Client:
         else:
             raise Unauthorised
 
-    def login(self, message=None):
+    def login(self, message=None, newuser_form=None):
         self.pagehead('Login to roundup', message)
         self.write('''
 <table>
@@ -505,33 +600,40 @@ class Client:
     <td><input type="submit" value="Log In"></td></tr>
 </form>
 ''')
-        if self.user is None and not self.ANONYMOUS_REGISTER == 'deny':
-            self.write('</table')
+        if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
+            self.write('</table>')
+            self.pagefoot()
             return
+        values = {'realname': '', 'organisation': '', 'address': '',
+            'phone': '', 'username': '', 'password': '', 'confirm': ''}
+        if newuser_form is not None:
+            for key in newuser_form.keys():
+                values[key] = newuser_form[key].value
         self.write('''
 <p>
 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
 <form action="newuser_action" method=POST>
 <tr><td align=right><em>Name: </em></td>
-    <td><input name="__newuser_realname"></td></tr>
+    <td><input name="realname" value="%(realname)s"></td></tr>
 <tr><td align=right><em>Organisation: </em></td>
-    <td><input name="__newuser_organisation"></td></tr>
+    <td><input name="organisation" value="%(organisation)s"></td></tr>
 <tr><td align=right>E-Mail Address: </td>
-    <td><input name="__newuser_address"></td></tr>
+    <td><input name="address" value="%(address)s"></td></tr>
 <tr><td align=right><em>Phone: </em></td>
-    <td><input name="__newuser_phone"></td></tr>
+    <td><input name="phone" value="%(phone)s"></td></tr>
 <tr><td align=right>Preferred Login name: </td>
-    <td><input name="__newuser_username"></td></tr>
+    <td><input name="username" value="%(username)s"></td></tr>
 <tr><td align=right>Password: </td>
-    <td><input type="password" name="__newuser_password"></td></tr>
+    <td><input type="password" name="password" value="%(password)s"></td></tr>
 <tr><td align=right>Password Again: </td>
-    <td><input type="password" name="__newuser_confirm"></td></tr>
+    <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
 <tr><td></td>
     <td><input type="submit" value="Register"></td></tr>
 </form>
 </table>
-''')
+'''%values)
+        self.pagefoot()
 
     def login_action(self, message=None):
         if not self.form.has_key('__login_name'):
@@ -555,14 +657,22 @@ class Client:
             self.make_user_anonymous()
             return self.login(message='Incorrect password')
 
-        # construct the cookie
-        uid = self.db.user.lookup(self.user)
-        user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
-        path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
-            ''))
-        self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)})
+        self.set_cookie(self.user, password)
         return self.index()
 
+    def set_cookie(self, user, password):
+        # construct the cookie
+        user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
+        if user[-1] == '=':
+          if user[-2] == '=':
+            user = user[:-2]
+          else:
+            user = user[:-1]
+        expire = Cookie._getdate(86400*365)
+        path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
+        self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
+            user, expire, path)})
+
     def make_user_anonymous(self):
         # make us anonymous if we can
         try:
@@ -574,29 +684,31 @@ class Client:
     def logout(self, message=None):
         self.make_user_anonymous()
         # construct the logout cookie
-        path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
-            ''))
         now = Cookie._getdate()
+        path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
         self.header({'Set-Cookie':
-            'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
-        return self.index()
+            'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
+            path)})
+        return self.login()
 
     def newuser_action(self, message=None):
         ''' create a new user based on the contents of the form and then
         set the cookie
         '''
+        # re-open the database as "admin"
+        self.db.close()
+        self.db = self.instance.open('admin')
+
         # TODO: pre-check the required fields and username key property
-        cl = self.db.classes['user']
-        props, dummy = parsePropsFromForm(self.db, cl, self.form)
-        uid = cl.create(**props)
-        self.user = self.db.user.get(uid, 'username')
-        password = self.db.user.get(uid, 'password')
-        # construct the cookie
-        uid = self.db.user.lookup(self.user)
-        user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
-        path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
-            ''))
-        self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)})
+        cl = self.db.user
+        try:
+            props, dummy = parsePropsFromForm(self.db, cl, self.form)
+            uid = cl.create(**props)
+        except ValueError, message:
+            return self.login(message, newuser_form=self.form)
+        self.user = cl.get(uid, 'username')
+        password = cl.get(uid, 'password')
+        self.set_cookie(self.user, self.form['password'].value)
         return self.index()
 
     def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
@@ -609,7 +721,14 @@ class Client:
         if (cookie.has_key('roundup_user') and
                 cookie['roundup_user'].value != 'deleted'):
             cookie = cookie['roundup_user'].value
-            user, password = base64.decodestring(cookie).split(':')
+            if len(cookie)%4:
+              cookie = cookie + '='*(4-len(cookie)%4)
+            try:
+                user, password = binascii.a2b_base64(cookie).split(':')
+            except (TypeError, binascii.Error, binascii.Incomplete):
+                # damaged cookie!
+                user, password = 'anonymous', ''
+
             # make sure the user exists
             try:
                 uid = self.db.user.lookup(user)
@@ -626,42 +745,46 @@ class Client:
             self.user = user
         self.db.close()
 
-        # make sure totally anonymous access is OK
-        if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
-            return self.login()
-
         # re-open the database for real, using the user
         self.db = self.instance.open(self.user)
 
         # now figure which function to call
         path = self.split_path
         if not path or path[0] in ('', 'index'):
-            self.index()
-        elif not path:
-            raise 'ValueError', 'Path not understood'
+            action = 'index'
+        else:
+            action = path[0]
 
-        #
         # Everthing ignores path[1:]
-        #
-        # The file download link generator actually relies on this - it
-        # appends the name of the file to the URL so the download file name
-        # is correct, but doesn't actually use it.
-        action = path[0]
-        if action == 'list_classes':
-            self.classes()
-            return
-        if action == 'login':
-            self.login()
-            return
+        #  - The file download link generator actually relies on this - it
+        #    appends the name of the file to the URL so the download file name
+        #    is correct, but doesn't actually use it.
+
+        # everyone is allowed to try to log in
         if action == 'login_action':
-            self.login_action()
-            return
+            return self.login_action()
+
+        # allow anonymous people to register
         if action == 'newuser_action':
-            self.newuser_action()
-            return
+            # if we don't have a login and anonymous people aren't allowed to
+            # register, then spit up the login form
+            if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
+                return self.login()
+            return self.newuser_action()
+
+        # make sure totally anonymous access is OK
+        if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
+            return self.login()
+
+        # here be the "normal" functionality
+        if action == 'index':
+            return self.index()
+        if action == 'list_classes':
+            return self.classes()
+        if action == 'login':
+            return self.login()
         if action == 'logout':
-            self.logout()
-            return
+            return self.logout()
         m = dre.match(action)
         if m:
             self.classname = m.group(1)
@@ -678,8 +801,7 @@ class Client:
                 func = getattr(self, 'show%s'%self.classname)
             except AttributeError:
                 raise NotFound
-            func()
-            return
+            return func()
         m = nre.match(action)
         if m:
             self.classname = m.group(1)
@@ -687,8 +809,7 @@ class Client:
                 func = getattr(self, 'new%s'%self.classname)
             except AttributeError:
                 raise NotFound
-            func()
-            return
+            return func()
         self.classname = action
         try:
             self.db.getclass(self.classname)
@@ -834,6 +955,77 @@ def parsePropsFromForm(db, cl, form, nodeid=0):
 
 #
 # $Log: not supported by cvs2svn $
+# Revision 1.54  2001/11/07 01:16:12  richard
+# Remove the '=' padding from cookie value so quoting isn't an issue.
+#
+# Revision 1.53  2001/11/06 23:22:05  jhermann
+# More IE fixes: it does not like quotes around cookie values; in the
+# hope this does not break anything for other browser; if it does, we
+# need to check HTTP_USER_AGENT
+#
+# Revision 1.52  2001/11/06 23:11:22  jhermann
+# Fixed debug output in page footer; added expiry date to the login cookie
+# (expires 1 year in the future) to prevent probs with certain versions
+# of IE
+#
+# Revision 1.51  2001/11/06 22:00:34  jhermann
+# Get debug level from ROUNDUP_DEBUG env var
+#
+# Revision 1.50  2001/11/05 23:45:40  richard
+# Fixed newuser_action so it sets the cookie with the unencrypted password.
+# Also made it present nicer error messages (not tracebacks).
+#
+# Revision 1.49  2001/11/04 03:07:12  richard
+# Fixed various cookie-related bugs:
+#  . bug #477685 ] base64.decodestring breaks
+#  . bug #477837 ] lynx does not like the cookie
+#  . bug #477892 ] Password edit doesn't fix login cookie
+# Also closed a security hole - a logged-in user could edit another user's
+# details.
+#
+# Revision 1.48  2001/11/03 01:30:18  richard
+# Oops. uses pagefoot now.
+#
+# Revision 1.47  2001/11/03 01:29:28  richard
+# Login page didn't have all close tags.
+#
+# Revision 1.46  2001/11/03 01:26:55  richard
+# possibly fix truncated base64'ed user:pass
+#
+# Revision 1.45  2001/11/01 22:04:37  richard
+# Started work on supporting a pop3-fetching server
+# Fixed bugs:
+#  . bug #477104 ] HTML tag error in roundup-server
+#  . bug #477107 ] HTTP header problem
+#
+# Revision 1.44  2001/10/28 23:03:08  richard
+# Added more useful header to the classic schema.
+#
+# Revision 1.43  2001/10/24 00:01:42  richard
+# More fixes to lockout logic.
+#
+# Revision 1.42  2001/10/23 23:56:03  richard
+# HTML typo
+#
+# Revision 1.41  2001/10/23 23:52:35  richard
+# Fixed lock-out logic, thanks Roch'e for pointing out the problems.
+#
+# Revision 1.40  2001/10/23 23:06:39  richard
+# Some cleanup.
+#
+# Revision 1.39  2001/10/23 01:00:18  richard
+# Re-enabled login and registration access after lopping them off via
+# disabling access for anonymous users.
+# Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
+# a couple of bugs while I was there. Probably introduced a couple, but
+# things seem to work OK at the moment.
+#
+# Revision 1.38  2001/10/22 03:25:01  richard
+# Added configuration for:
+#  . anonymous user access and registration (deny/allow)
+#  . filter "widget" location on index page (top, bottom, both)
+# Updated some documentation.
+#
 # Revision 1.37  2001/10/21 07:26:35  richard
 # feature #473127: Filenames. I modified the file.index and htmltemplate
 #  source so that the filename is used in the link and the creation