Code

Refactored CGI file serving so that FileClass contents are a) read more
[roundup.git] / roundup / cgi / client.py
index ea1ad7359af8048f831407d3afced4486f1c06e5..fe8831371b8590243b153b644e93d7bd4bef0411 100644 (file)
@@ -1,4 +1,4 @@
-# $Id: client.py,v 1.136 2003-09-08 09:28:28 jlgijsbers Exp $
+# $Id: client.py,v 1.149 2003-12-05 03:28:38 richard Exp $
 
 __doc__ = """
 WWW request handler (also used in the stand-alone server).
@@ -6,7 +6,7 @@ WWW request handler (also used in the stand-alone server).
 
 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
 import binascii, Cookie, time, random, MimeWriter, smtplib, socket, quopri
-import stat, rfc822, string
+import stat, rfc822
 
 from roundup import roundupdb, date, hyperdb, password, token, rcsv
 from roundup.i18n import _
@@ -18,32 +18,27 @@ from roundup.mailgw import uidFromAddress
 from roundup.mailer import Mailer, MessageSendError
 
 class HTTPException(Exception):
-      pass
-class  Unauthorised(HTTPException):
-       pass
-class  NotFound(HTTPException):
-       pass
-class  Redirect(HTTPException):
-       pass
-class  NotModified(HTTPException):
-       pass
+    pass
+class Unauthorised(HTTPException):
+    pass
+class NotFound(HTTPException):
+    pass
+class Redirect(HTTPException):
+    pass
+class NotModified(HTTPException):
+    pass
 
 # used by a couple of routines
-if hasattr(string, 'ascii_letters'):
-    chars = string.ascii_letters+string.digits
-else:
-    # python2.1 doesn't have ascii_letters
-    chars = string.letters+string.digits
+chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
 
-# XXX actually _use_ FormError
 class FormError(ValueError):
-    ''' An "expected" exception occurred during form parsing.
+    """ An "expected" exception occurred during form parsing.
         - ie. something we know can go wrong, and don't want to alarm the
           user with
 
         We trap this at the user interface level and feed back a nice error
         to the user.
-    '''
+    """
     pass
 
 class SendFile(Exception):
@@ -284,18 +279,20 @@ 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.
+        """Age sessions, remove when they haven't been used for a week.
         
-            Do it only once an hour.
+        Do it only once an hour.
 
-            Note: also cleans One Time Keys, and other "session" based
-            stuff.
-        '''
+        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
 
@@ -317,7 +314,7 @@ class Client:
             sessions.set('last_clean', last_use=time.time())
 
     def determine_user(self):
-        ''' Determine who the user is
+        '''Determine who the user is.
         '''
         # open the database as admin
         self.opendb('admin')
@@ -364,7 +361,7 @@ class Client:
         self.opendb(self.user)
 
     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
-        ''' Determine the context of this page from the URL:
+        """ Determine the context of this page from the URL:
 
             The URL path after the instance identifier is examined. The path
             is generally only one entry long.
@@ -399,7 +396,7 @@ class Client:
              self.classname  - the class to display, can be None
              self.template   - the template to render the current context with
              self.nodeid     - the nodeid of the class we're displaying
-        '''
+        """
         # default the optional variables
         self.classname = None
         self.nodeid = None
@@ -424,7 +421,7 @@ class Client:
             else:
                 self.template = ''
             return
-        elif path[0] == '_file':
+        elif path[0] in ('_file', '@@file'):
             raise SendStaticFile, os.path.join(*path[1:])
         else:
             self.classname = path[0]
@@ -474,13 +471,44 @@ class Client:
         if classname != 'file':
             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'))
+
+        mime_type = file.get(nodeid, 'type')
+        content = file.get(nodeid, 'content')
+        lmt = file.get(nodeid, 'activity').timestamp()
+
+        self._serve_file(lmt, mime_type, content)
 
     def serve_static_file(self, file):
+        ''' Serve up the file named from the templates dir
+        '''
+        filename = os.path.join(self.instance.config.TEMPLATES, file)
+
+        # last-modified time
+        lmt = os.stat(filename)[stat.ST_MTIME]
+
+        # detemine meta-type
+        file = str(file)
+        mime_type = mimetypes.guess_type(file)[0]
+        if not mime_type:
+            if file.endswith('.css'):
+                mime_type = 'text/css'
+            else:
+                mime_type = 'text/plain'
+
+        # snarf the content
+        f = open(filename, 'rb')
+        try:
+            content = f.read()
+        finally:
+            f.close()
+
+        self._serve_file(lmt, mime_type, content)
+
+    def _serve_file(self, last_modified, mime_type, content):
+        ''' guts of serve_file() and serve_static_file()
+        '''
         ims = None
         # see if there's an if-modified-since...
         if hasattr(self.request, 'headers'):
@@ -488,25 +516,18 @@ class Client:
         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]
         if ims:
             ims = rfc822.parsedate(ims)[:6]
             lmtt = time.gmtime(lmt)[:6]
             if lmtt <= ims:
                 raise NotModified
 
-        # we just want to serve up the file named
-        file = str(file)
-        mt = mimetypes.guess_type(file)[0]
-        if not mt:
-            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())
+        # spit out headers
+        self.additional_headers['Content-Type'] = mime_type
+        self.additional_headers['Content-Length'] = len(content)
+        lmt = rfc822.formatdate(last_modified)
+        self.additional_headers['Last-Modifed'] = lmt
+        self.write(content)
 
     def renderContext(self):
         ''' Return a PageTemplate for the named page
@@ -597,9 +618,10 @@ class Client:
             self.headers_sent = headers
 
     def set_cookie(self, user):
-        ''' Set up a session cookie for the user and store away the user's
-            login info against the session.
-        '''
+        """Set up a session cookie for the user.
+
+        Also 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()
 
@@ -730,12 +752,7 @@ class Client:
 
         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):
@@ -766,15 +783,22 @@ class Client:
 
         # send the email
         tracker_name = self.db.config.TRACKER_NAME
-        subject = 'Complete your registration to %s'%tracker_name
-        body = '''
-To complete your registration of the user "%(name)s" with %(tracker)s,
-please visit the following URL:
+        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}
-        if not self.standard_message(props['address'], subject, body):
+""" % {'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
@@ -783,14 +807,13 @@ please visit the following URL:
         # redirect to the "you're almost there" page
         raise Redirect, '%suser?@template=rego_progress'%self.base
 
-    def standard_message(self, to, subject, body):
+    def standard_message(self, to, subject, body, author=None):
         try:
-            self.mailer.standard_message(to, subject, body)
+            self.mailer.standard_message(to, subject, body, author)
             return 1
-        except MessageSendException, e:
+        except MessageSendError, e:
             self.error_message.append(str(e))
             
-
     def registerPermission(self, props):
         ''' Determine whether the user has permission to register
 
@@ -845,7 +868,9 @@ please visit the following URL:
             otk = self.form['otk'].value
             uid = self.db.otks.get(otk, 'uid')
             if uid is None:
-                self.error_message.append('Invalid One Time Key!')
+                self.error_message.append("""Invalid One Time Key!
+(a Mozilla bug may cause this message to show up erroneously,
+ please check your email)""")
                 return
 
             # re-open the database as "admin"
@@ -879,10 +904,11 @@ 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):
+            if not self.standard_message([address], subject, body):
                 return
 
-            self.ok_message.append('Password reset and email sent to %s'%address)
+            self.ok_message.append('Password reset and email sent to %s' %
+                                   address)
             return
 
         # no OTK, so now figure the user
@@ -922,7 +948,7 @@ the link below:
 
 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):
+        if not self.standard_message([address], subject, body):
             return
 
         self.ok_message.append('Email sent to %s'%address)
@@ -932,12 +958,7 @@ You should then receive another email with the new password.
 
            See parsePropsFromForm and _editnodes for special variables
         '''
-        # parse the props from the form
-        try:
-            props, links = self.parsePropsFromForm()
-        except (ValueError, KeyError), message:
-            self.error_message.append(_('Parse Error: ') + str(message))
-            return
+        props, links = self.parsePropsFromForm()
 
         # handle the props
         try:
@@ -954,14 +975,16 @@ You should then receive another email with the new password.
             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.
+        """Determine whether the user has permission to edit this item.
 
-            Base behaviour is to check the user can edit this class. If we're
-            editing the "user" class, users are allowed to edit their own
-            details. Unless it's the "roles" property, which requires the
-            special Permission "Web Roles".
-        '''
+        Base behaviour is to check the user can edit this class. If we're
+        editing the"user" class, users are allowed to edit their own details.
+        Unless it's the "roles" property, which requires the special Permission
+        "Web Roles".
+        """
         # if this is a user node and the user is editing their own node, then
         # we're OK
         has = self.db.security.hasPermission
@@ -972,43 +995,13 @@ You should then receive another email with the new password.
                     'user'):
                 return 0
             # if the item being edited is the current user, we're ok
-            if self.nodeid == self.userid:
+            if (self.nodeid == self.userid
+                and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
                 return 1
         if self.db.security.hasPermission('Edit', self.userid, self.classname):
             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
-        try:
-            props, links = self.parsePropsFromForm()
-        except (ValueError, KeyError), message:
-            self.error_message.append(_('Error: ') + str(message))
-            return
-
-        # handle the props - edit or create
-        try:
-            # when it hits the None element, it'll set self.nodeid
-            messages = self._editnodes(props, links)
-
-        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&@template=%s'%(self.base,
-            self.classname, self.nodeid, urllib.quote(messages),
-            urllib.quote(self.template))
-
     def newItemPermission(self, props):
         ''' Determine whether the user has permission to create (edit) this
             item.
@@ -1148,16 +1141,17 @@ You should then receive another email with the new password.
     # More actions
     #
     def editCSVAction(self):
-        ''' Performs an edit of all of a class' items in one go.
+        """ Performs an edit of all of a class' items in one go.
 
             The "rows" CGI var defines the CSV-formatted entries for the
             class. New nodes are identified by the ID 'X' (or any other
             non-existent ID) and removed lines are retired.
-        '''
+        """
         # this is per-class only
         if not self.editCSVPermission():
             self.error_message.append(
-                _('You do not have permission to edit %s' %self.classname))
+                 _('You do not have permission to edit %s' %self.classname))
+            return
 
         # get the CSV module
         if rcsv.error:
@@ -1269,6 +1263,7 @@ You should then receive another email with the new password.
         if not self.searchPermission():
             self.error_message.append(
                 _('You do not have permission to search %s' %self.classname))
+            return
 
         # add a faked :filter form variable for each filtering prop
         props = self.db.classes[self.classname].getprops()
@@ -1396,7 +1391,7 @@ You should then receive another email with the new password.
         raise Redirect, url
 
     def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
-        ''' Item properties and their values are edited with html FORM
+        """ Item properties and their values are edited with html FORM
             variables and their values. You can:
 
             - Change the value of some property of the current item.
@@ -1503,7 +1498,7 @@ You should then receive another email with the new password.
                 This is equivalent to::
 
                     @link@messages=msg-1
-                    @msg-1@content=value
+                    msg-1@content=value
 
                 except that in addition, the "author" and "date"
                 properties of "msg-1" are set to the userid of the
@@ -1513,7 +1508,7 @@ You should then receive another email with the new password.
                 This is equivalent to::
 
                     @link@files=file-1
-                    @file-1@content=value
+                    file-1@content=value
 
                 The String content value is handled as described above for
                 file uploads.
@@ -1536,7 +1531,7 @@ You should then receive another email with the new password.
             doesn't result in any changes would return {('issue','123'): {}})
             The id may be None, which indicates that an item should be
             created.
-        '''
+        """
         # some very useful variables
         db = self.db
         form = self.form
@@ -1630,14 +1625,14 @@ You should then receive another email with the new password.
                 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))
@@ -1658,7 +1653,7 @@ You should then receive another email with the new password.
             # 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
@@ -1676,7 +1671,7 @@ You should then receive another email with the new password.
             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:
@@ -1699,59 +1694,26 @@ You should then receive another email with the new password.
                         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)
-
-            elif isinstance(proptype, hyperdb.Link):
-                # see if it's the "no selection" choice
-                if value == '-1' or not value:
-                    # if we're creating, just don't include this property
-                    if not nodeid or nodeid.startswith('-'):
-                        continue
-                    value = None
-                else:
-                    # handle key values
-                    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': propname, 'value': value,
-                                'classname': link}
-                        except TypeError, message:
-                            raise ValueError, _('you may only enter ID values '
-                                'for property "%(propname)s": %(message)s')%{
-                                'propname': propname, 'message': message}
+                try:
+                    value = password.Password(value)
+                except hyperdb.HyperdbValueError, msg:
+                    raise FormError, msg
+
             elif isinstance(proptype, hyperdb.Multilink):
-                # perform link class key value lookup if necessary
-                link = proptype.classname
-                link_cl = db.classes[link]
-                l = []
-                for entry in value:
-                    if not entry: continue
-                    if not num_re.match(entry):
-                        try:
-                            entry = link_cl.lookup(entry)
-                        except KeyError:
-                            raise ValueError, _('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 '
-                                'for property "%(propname)s": %(message)s')%{
-                                'propname': propname, 'message': message}
-                    l.append(entry)
-                l.sort()
+                # convert input to list of ids
+                try:
+                    l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
+                        propname, value)
+                except hyperdb.HyperdbValueError, msg:
+                    raise FormError, msg
 
                 # now use that list of ids to modify the multilink
                 if mlaction == 'set':
@@ -1773,7 +1735,7 @@ You should then receive another email with the new password.
                             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:
@@ -1785,13 +1747,10 @@ You should then receive another email with the new password.
                     value.sort()
 
             elif value == '':
-                # if we're creating, just don't include this property
-                if not nodeid or nodeid.startswith('-'):
-                    continue
                 # other types should be None'd if there's no value
                 value = None
             else:
-                # handle ValueErrors for all these in a similar fashion
+                # handle all other types
                 try:
                     if isinstance(proptype, hyperdb.String):
                         if (hasattr(value, 'filename') and
@@ -1809,23 +1768,17 @@ You should then receive another email with the new password.
                                 props['type'] = mimetypes.guess_type(fn)[0]
                                 if not props['type']:
                                     props['type'] = "application/octet-stream"
-                            # finally, read the content
+                            # finally, read the content RAW
                             value = value.value
                         else:
-                            # normal String fix the CRLF/CR -> LF stuff
-                            value = fixNewlines(value)
+                            value = hyperdb.rawToHyperdb(self.db, cl,
+                                nodeid, propname, 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 ValueError, _('Error with %s property: %s')%(
-                        propname, msg)
+                    else:
+                        value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
+                            propname, value)
+                except hyperdb.HyperdbValueError, msg:
+                    raise FormError, msg
 
             # register that we got this property
             if value:
@@ -1840,6 +1793,8 @@ You should then receive another email with the new password.
                     # 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):
@@ -1896,7 +1851,7 @@ You should then receive another email with the new password.
             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)
 
         # When creating a FileClass node, it should have a non-empty content
         # property to be created. When editing a FileClass node, it should
@@ -1905,22 +1860,12 @@ You should then receive another email with the new password.
         for (cn, id), props in all_props.items():
             if isinstance(self.db.classes[cn], hyperdb.FileClass):
                 if id == '-1':
-                      if not props.get('content', ''):
-                            del all_props[(cn, id)]
+                    if not props.get('content', ''):
+                        del all_props[(cn, id)]
                 elif props.has_key('content') and not props['content']:
-                      raise ValueError, _('File is empty')
+                    raise FormError, _('File is empty')
         return all_props, all_links
 
-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 extractFormList(value):
     ''' Extract a list of values from the form value.