Code

issue2550715: IndexError when requesting non-existing file via http.
[roundup.git] / roundup / cgi / client.py
index 72edc6fa02c092c2d04e218b4cf1a3be2c3ac714..b4147d47923e46fb047baa225def472f13846fad 100644 (file)
@@ -1,65 +1,39 @@
-# $Id: client.py,v 1.143 2003-10-24 09:32:19 jlgijsbers Exp $
-
-__doc__ = """
-WWW request handler (also used in the stand-alone server).
+"""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
-
-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
-class  Unauthorised(HTTPException):
-       pass
-class  NotFound(HTTPException):
-       pass
-class  Redirect(HTTPException):
-       pass
-class  NotModified(HTTPException):
-       pass
-
-# 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
-          user with
-
-        We trap this at the user interface level and feed back a nice error
-        to the user.
-    '''
-    pass
-
-class SendFile(Exception):
-    ''' Send a file from the database '''
-
-class SendStaticFile(Exception):
-    ''' Send a static file from the instance html directory '''
+__docformat__ = 'restructuredtext'
+
+import base64, binascii, cgi, codecs, mimetypes, os
+import quopri, random, re, rfc822, stat, sys, time
+import socket, errno
+
+from roundup import roundupdb, date, hyperdb, password
+from roundup.cgi import templating, cgitb, TranslationService
+from roundup.cgi.actions import *
+from roundup.exceptions import *
+from roundup.cgi.exceptions import *
+from roundup.cgi.form_parser import FormParser
+from roundup.mailer import Mailer, MessageSendError, encode_quopri
+from roundup.cgi import accept_language
+from roundup import xmlrpc
+
+from roundup.anypy.cookie_ import CookieError, BaseCookie, SimpleCookie, \
+    get_cookie_date
+from roundup.anypy.io_ import StringIO
+from roundup.anypy import http_
+from roundup.anypy import urllib_
 
 def initialiseSecurity(security):
 
 def initialiseSecurity(security):
-    ''' Create some Permissions and Roles on the security object
+    '''Create some Permissions and Roles on the security object
 
 
-        This function is directly invoked by security.Security.__init__()
-        as a part of the Security object instantiation.
+    This function is directly invoked by security.Security.__init__()
+    as a part of the Security object instantiation.
     '''
     '''
-    security.addPermission(name="Web Registration",
-        description="User may register through the web")
     p = security.addPermission(name="Web Access",
         description="User may access the web interface")
     security.addPermissionToRole('Admin', p)
 
     # doing Role stuff through the web - make sure Admin can
     p = security.addPermission(name="Web Access",
         description="User may access the web interface")
     security.addPermissionToRole('Admin', p)
 
     # doing Role stuff through the web - make sure Admin can
+    # TODO: deprecate this and use a property-based control
     p = security.addPermission(name="Web Roles",
         description="User may manipulate user Roles through the web")
     security.addPermissionToRole('Admin', p)
     p = security.addPermission(name="Web Roles",
         description="User may manipulate user Roles through the web")
     security.addPermissionToRole('Admin', p)
@@ -71,50 +45,206 @@ 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}):
 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()):
+    """ Strip all non <a>,<i>,<b> and <br> tags from a string
+    """
+    if match.group(3).lower() in ok:
         return match.group(1)
     return '&lt;%s&gt;'%match.group(2)
 
         return match.group(1)
     return '&lt;%s&gt;'%match.group(2)
 
+
+error_message = ''"""<html><head><title>An error has occurred</title></head>
+<body><h1>An error has occurred</h1>
+<p>A problem was encountered processing your request.
+The tracker maintainers have been notified of the problem.</p>
+</body></html>"""
+
+
+class LiberalCookie(SimpleCookie):
+    """ Python's SimpleCookie throws an exception if the cookie uses invalid
+        syntax.  Other applications on the same server may have done precisely
+        this, preventing roundup from working through no fault of roundup.
+        Numerous other python apps have run into the same problem:
+
+        trac: http://trac.edgewall.org/ticket/2256
+        mailman: http://bugs.python.org/issue472646
+
+        This particular implementation comes from trac's solution to the
+        problem. Unfortunately it requires some hackery in SimpleCookie's
+        internals to provide a more liberal __set method.
+    """
+    def load(self, rawdata, ignore_parse_errors=True):
+        if ignore_parse_errors:
+            self.bad_cookies = []
+            self._BaseCookie__set = self._loose_set
+        SimpleCookie.load(self, rawdata)
+        if ignore_parse_errors:
+            self._BaseCookie__set = self._strict_set
+            for key in self.bad_cookies:
+                del self[key]
+
+    _strict_set = BaseCookie._BaseCookie__set
+
+    def _loose_set(self, key, real_value, coded_value):
+        try:
+            self._strict_set(key, real_value, coded_value)
+        except CookieError:
+            self.bad_cookies.append(key)
+            dict.__setitem__(self, key, None)
+
+
+class Session:
+    """
+    Needs DB to be already opened by client
+
+    Session attributes at instantiation:
+
+    - "client" - reference to client for add_cookie function
+    - "session_db" - session DB manager
+    - "cookie_name" - name of the cookie with session id
+    - "_sid" - session id for current user
+    - "_data" - session data cache
+
+    session = Session(client)
+    session.set(name=value)
+    value = session.get(name)
+
+    session.destroy()  # delete current session
+    session.clean_up() # clean up session table
+
+    session.update(set_cookie=True, expire=3600*24*365)
+                       # refresh session expiration time, setting persistent
+                       # cookie if needed to last for 'expire' seconds
+
+    """
+
+    def __init__(self, client):
+        self._data = {}
+        self._sid  = None
+
+        self.client = client
+        self.session_db = client.db.getSessionManager()
+
+        # parse cookies for session id
+        self.cookie_name = 'roundup_session_%s' % \
+            re.sub('[^a-zA-Z]', '', client.instance.config.TRACKER_NAME)
+        cookies = LiberalCookie(client.env.get('HTTP_COOKIE', ''))
+        if self.cookie_name in cookies:
+            if not self.session_db.exists(cookies[self.cookie_name].value):
+                self._sid = None
+                # remove old cookie
+                self.client.add_cookie(self.cookie_name, None)
+            else:
+                self._sid = cookies[self.cookie_name].value
+                self._data = self.session_db.getall(self._sid)
+
+    def _gen_sid(self):
+        """ generate a unique session key """
+        while 1:
+            s = '%s%s'%(time.time(), random.random())
+            s = binascii.b2a_base64(s).strip()
+            if not self.session_db.exists(s):
+                break
+
+        # clean up the base64
+        if s[-1] == '=':
+            if s[-2] == '=':
+                s = s[:-2]
+            else:
+                s = s[:-1]
+        return s
+
+    def clean_up(self):
+        """Remove expired sessions"""
+        self.session_db.clean()
+
+    def destroy(self):
+        self.client.add_cookie(self.cookie_name, None)
+        self._data = {}
+        self.session_db.destroy(self._sid)
+        self.client.db.commit()
+
+    def get(self, name, default=None):
+        return self._data.get(name, default)
+
+    def set(self, **kwargs):
+        self._data.update(kwargs)
+        if not self._sid:
+            self._sid = self._gen_sid()
+            self.session_db.set(self._sid, **self._data)
+            # add session cookie
+            self.update(set_cookie=True)
+
+            # XXX added when patching 1.4.4 for backward compatibility
+            # XXX remove
+            self.client.session = self._sid
+        else:
+            self.session_db.set(self._sid, **self._data)
+            self.client.db.commit()
+
+    def update(self, set_cookie=False, expire=None):
+        """ update timestamp in db to avoid expiration
+
+            if 'set_cookie' is True, set cookie with 'expire' seconds lifetime
+            if 'expire' is None - session will be closed with the browser
+             
+            XXX the session can be purged within a week even if a cookie
+                lifetime is longer
+        """
+        self.session_db.updateTimestamp(self._sid)
+        self.client.db.commit()
+
+        if set_cookie:
+            self.client.add_cookie(self.cookie_name, self._sid, expire=expire)
+
+
+
 class Client:
 class Client:
-    ''' Instantiate to handle one CGI request.
+    """Instantiate to handle one CGI request.
 
     See inner_main for request processing.
 
     Client attributes at instantiation:
 
     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
+
+    - "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
+    - "translator" is TranslationService instance
 
     During the processing of a request, the following attributes are used:
 
     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
+
+    - "db" 
+    - "error_message" holds a list of error messages
+    - "ok_message" holds a list of OK messages
+    - "session" is deprecated in favor of session_api (XXX remove)
+    - "session_api" is the interface to store data in session
+    - "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:
 
     User Identification:
-     If the user has no login cookie, then they are anonymous and are logged
+     Users that are absent in session data are anonymous and are logged
      in as that user. This typically gives them all Permissions assigned to the
      Anonymous Role.
 
      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.
-
+     Every user is assigned a session. "session_api" is the interface to work
+     with session data.
 
     Special form variables:
      Note that in various places throughout this code, special form
      variables of the form :<name> are used. The colon (":") part may
      actually be one of either ":" or "@".
 
     Special form variables:
      Note that in various places throughout this code, special form
      variables of the form :<name> are used. The colon (":") part may
      actually be one of either ":" or "@".
-    '''
+    """
+
+    # charset used for data storage and form templates
+    # Note: must be in lower case for comparisons!
+    # XXX take this from instance.config?
+    STORAGE_CHARSET = 'utf-8'
 
     #
     # special form variables
 
     #
     # special form variables
@@ -123,39 +253,36 @@ class Client:
     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
 
     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
 
-    FV_QUERYNAME = re.compile(r'[@:]queryname')
-
-    # edit form variable handling (see unit tests)
-    FV_LABELS = r'''
-       ^(
-         (?P<note>[@:]note)|
-         (?P<file>[@:]file)|
-         (
-          ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
-          ((?P<required>[@:]required$)|       # :required
-           (
-            (
-             (?P<add>[@:]add[@:])|            # :add:<prop>
-             (?P<remove>[@:]remove[@:])|      # :remove:<prop>
-             (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
-             (?P<link>[@:]link[@:])|          # :link:<prop>
-             ([@:])                           # just a separator
-            )?
-            (?P<propname>[^@:]+)             # <prop>
-           )
-          )
-         )
-        )$'''
-
     # Note: index page stuff doesn't appear here:
     # columns, sort, sortdir, filter, group, groupdir, search_text,
     # pagesize, startwith
 
     # Note: index page stuff doesn't appear here:
     # columns, sort, sortdir, filter, group, groupdir, search_text,
     # pagesize, startwith
 
-    def __init__(self, instance, request, env, form=None):
-        hyperdb.traceMark()
+    # list of network error codes that shouldn't be reported to tracker admin
+    # (error descriptions from FreeBSD intro(2))
+    IGNORE_NET_ERRORS = (
+        # A write on a pipe, socket or FIFO for which there is
+        # no process to read the data.
+        errno.EPIPE,
+        # A connection was forcibly closed by a peer.
+        # This normally results from a loss of the connection
+        # on the remote socket due to a timeout or a reboot.
+        errno.ECONNRESET,
+        # Software caused connection abort.  A connection abort
+        # was caused internal to your host machine.
+        errno.ECONNABORTED,
+        # A connect or send request failed because the connected party
+        # did not properly respond after a period of time.
+        errno.ETIMEDOUT,
+    )
+
+    def __init__(self, instance, request, env, form=None, translator=None):
+        # re-seed the random number generator
+        random.seed()
+        self.start = time.time()
         self.instance = instance
         self.request = request
         self.env = env
         self.instance = instance
         self.request = request
         self.env = env
+        self.setTranslator(translator)
         self.mailer = Mailer(instance.config)
 
         # save off the path
         self.mailer = Mailer(instance.config)
 
         # save off the path
@@ -164,15 +291,20 @@ class Client:
         # this is the base URL for this tracker
         self.base = self.instance.config.TRACKER_WEB
 
         # this is the base URL for this tracker
         self.base = self.instance.config.TRACKER_WEB
 
+        # check the tracker_we setting
+        if not self.base.endswith('/'):
+            self.base = self.base + '/'
+
         # this is the "cookie path" for this tracker (ie. the path part of
         # the "base" url)
         # this is the "cookie path" for this tracker (ie. the path part of
         # the "base" url)
-        self.cookie_path = urlparse.urlparse(self.base)[2]
-        self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
-            self.instance.config.TRACKER_NAME)
+        self.cookie_path = urllib_.urlparse(self.base)[2]
+        # cookies to set in http responce
+        # {(path, name): (value, expire)}
+        self._cookies = {}
 
         # see if we need to re-parse the environment for the form (eg Zope)
         if form is None:
 
         # see if we need to re-parse the environment for the form (eg Zope)
         if form is None:
-            self.form = cgi.FieldStorage(environ=env)
+            self.form = cgi.FieldStorage(fp=request.rfile, environ=env)
         else:
             self.form = form
 
         else:
             self.form = form
 
@@ -191,162 +323,396 @@ class Client:
         self.additional_headers = {}
         self.response_code = 200
 
         self.additional_headers = {}
         self.response_code = 200
 
+        # default character set
+        self.charset = self.STORAGE_CHARSET
+
+        # parse cookies (used for charset lookups)
+        # use our own LiberalCookie to handle bad apps on the same
+        # server that have set cookies that are out of spec
+        self.cookie = LiberalCookie(self.env.get('HTTP_COOKIE', ''))
+
+        self.user = None
+        self.userid = None
+        self.nodeid = None
+        self.classname = None
+        self.template = None
+
+    def setTranslator(self, translator=None):
+        """Replace the translation engine
+
+        'translator'
+           is TranslationService instance.
+           It must define methods 'translate' (TAL-compatible i18n),
+           'gettext' and 'ngettext' (gettext-compatible i18n).
+
+           If omitted, create default TranslationService.
+        """
+        if translator is None:
+            translator = TranslationService.get_translation(
+                language=self.instance.config["TRACKER_LANGUAGE"],
+                tracker_home=self.instance.config["TRACKER_HOME"])
+        self.translator = translator
+        self._ = self.gettext = translator.gettext
+        self.ngettext = translator.ngettext
 
     def main(self):
 
     def main(self):
-        ''' Wrap the real main in a try/finally so we always close off the db.
-        '''
+        """ Wrap the real main in a try/finally so we always close off the db.
+        """
         try:
         try:
-            self.inner_main()
+            if self.env.get('CONTENT_TYPE') == 'text/xml':
+                self.handle_xmlrpc()
+            else:
+                self.inner_main()
         finally:
             if hasattr(self, 'db'):
                 self.db.close()
 
         finally:
             if hasattr(self, 'db'):
                 self.db.close()
 
+
+    def handle_xmlrpc(self):
+
+        # Pull the raw XML out of the form.  The "value" attribute
+        # will be the raw content of the POST request.
+        assert self.form.file
+        input = self.form.value
+        # So that the rest of Roundup can query the form in the
+        # usual way, we create an empty list of fields.
+        self.form.list = []
+
+        # Set the charset and language, since other parts of
+        # Roundup may depend upon that.
+        self.determine_charset()
+        self.determine_language()
+        # Open the database as the correct user.
+        self.determine_user()
+        self.check_anonymous_access()
+
+        # Call the appropriate XML-RPC method.
+        handler = xmlrpc.RoundupDispatcher(self.db,
+                                           self.instance.actions,
+                                           self.translator,
+                                           allow_none=True)
+        output = handler.dispatch(input)
+
+        self.setHeader("Content-Type", "text/xml")
+        self.setHeader("Content-Length", str(len(output)))
+        self.write(output)
+        
     def inner_main(self):
     def inner_main(self):
-        ''' Process a request.
-
-            The most common requests are handled like so:
-            1. figure out who we are, defaulting to the "anonymous" user
-               see determine_user
-            2. figure out what the request is for - the context
-               see determine_context
-            3. handle any requested action (item edit, search, ...)
-               see handle_action
-            4. render a template, resulting in HTML output
-
-            In some situations, exceptions occur:
-            - HTTP Redirect  (generally raised by an action)
-            - SendFile       (generally raised by determine_context)
-              serve up a FileClass "content" property
-            - SendStaticFile (generally raised by determine_context)
-              serve up a file from the tracker "html" directory
-            - Unauthorised   (generally raised by an action)
-              the action is cancelled, the request is rendered and an error
-              message is displayed indicating that permission was not
-              granted for the action to take place
-            - NotFound       (raised wherever it needs to be)
-              percolates up to the CGI interface that called the client
-        '''
+        """Process a request.
+
+        The most common requests are handled like so:
+
+        1. look for charset and language preferences, set up user locale
+           see determine_charset, determine_language
+        2. figure out who we are, defaulting to the "anonymous" user
+           see determine_user
+        3. figure out what the request is for - the context
+           see determine_context
+        4. handle any requested action (item edit, search, ...)
+           see handle_action
+        5. render a template, resulting in HTML output
+
+        In some situations, exceptions occur:
+
+        - HTTP Redirect  (generally raised by an action)
+        - SendFile       (generally raised by determine_context)
+          serve up a FileClass "content" property
+        - SendStaticFile (generally raised by determine_context)
+          serve up a file from the tracker "html" directory
+        - Unauthorised   (generally raised by an action)
+          the action is cancelled, the request is rendered and an error
+          message is displayed indicating that permission was not
+          granted for the action to take place
+        - templating.Unauthorised   (templating action not permitted)
+          raised by an attempted rendering of a template when the user
+          doesn't have permission
+        - NotFound       (raised wherever it needs to be)
+          percolates up to the CGI interface that called the client
+        """
         self.ok_message = []
         self.error_message = []
         try:
         self.ok_message = []
         self.error_message = []
         try:
-            # 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
-            # we don't want clients caching our dynamic pages
-            self.additional_headers['Cache-Control'] = 'no-cache'
-# 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())
+            self.determine_charset()
+            self.determine_language()
+
+            try:
+                # make sure we're identified (even anonymously)
+                self.determine_user()
+
+                # figure out the context and desired content template
+                self.determine_context()
+
+                # if we've made it this far the context is to a bit of
+                # Roundup's real web interface (not a file being served up)
+                # so do the Anonymous Web Acess check now
+                self.check_anonymous_access()
+
+                # possibly handle a form submit action (may change self.classname
+                # and self.template, and may also append error/ok_messages)
+                html = self.handle_action()
+
+                if html:
+                    self.write_html(html)
+                    return
+
+                # now render the page
+                # we don't want clients caching our dynamic pages
+                self.additional_headers['Cache-Control'] = 'no-cache'
+                # Pragma: no-cache makes Mozilla and its ilk
+                # double-load all pages!!
+                #            self.additional_headers['Pragma'] = 'no-cache'
+
+                # pages with messages added expire right now
+                # simple views may be cached for a small amount of time
+                # TODO? make page expire time configurable
+                # <rj> always expire pages, as IE just doesn't seem to do the
+                # right thing here :(
+                date = time.time() - 1
+                #if self.error_message or self.ok_message:
+                #    date = time.time() - 1
+                #else:
+                #    date = time.time() + 5
+                self.additional_headers['Expires'] = rfc822.formatdate(date)
+
+                # render the content
+                self.write_html(self.renderContext())
+            except SendFile, designator:
+                # The call to serve_file may result in an Unauthorised
+                # exception or a NotModified exception.  Those
+                # exceptions will be handled by the outermost set of
+                # exception handlers.
+                self.serve_file(designator)
+            except SendStaticFile, file:
+                self.serve_static_file(str(file))
+            except IOError:
+                # IOErrors here are due to the client disconnecting before
+                # recieving the reply.
+                pass
+
+        except SeriousError, message:
+            self.write_html(str(message))
         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
             # exception was raised
             if url:
         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
             # exception was raised
             if url:
-                self.additional_headers['Location'] = url
+                self.additional_headers['Location'] = str(url)
                 self.response_code = 302
                 self.response_code = 302
-            self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
-        except SendFile, designator:
-            self.serve_file(designator)
-        except SendStaticFile, file:
-            try:
-                self.serve_static_file(str(file))
-            except NotModified:
-                # send the 304 response
-                self.request.send_response(304)
-                self.request.end_headers()
+            self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
+        except LoginError, message:
+            # The user tried to log in, but did not provide a valid
+            # username and password.  If we support HTTP
+            # authorization, send back a response that will cause the
+            # browser to prompt the user again.
+            if self.instance.config.WEB_HTTP_AUTH:
+                self.response_code = http_.client.UNAUTHORIZED
+                realm = self.instance.config.TRACKER_NAME
+                self.setHeader("WWW-Authenticate",
+                               "Basic realm=\"%s\"" % realm)
+            else:
+                self.response_code = http_.client.FORBIDDEN
+            self.renderFrontPage(message)
         except Unauthorised, message:
         except Unauthorised, message:
-            self.classname = None
-            self.template = ''
-            self.error_message.append(message)
-            self.write(self.renderContext())
-        except NotFound:
-            # pass through
-            raise
+            # users may always see the front page
+            self.response_code = 403
+            self.renderFrontPage(message)
+        except NotModified:
+            # send the 304 response
+            self.response_code = 304
+            self.header()
+        except NotFound, e:
+            self.response_code = 404
+            self.template = '404'
+            try:
+                cl = self.db.getclass(self.classname)
+                self.write_html(self.renderContext())
+            except KeyError:
+                # we can't map the URL to a class we know about
+                # reraise the NotFound and let roundup_server
+                # handle it
+                raise NotFound(e)
         except FormError, e:
         except FormError, e:
-            self.error_message.append(_('Form Error: ') + str(e))
-            self.write(self.renderContext())
+            self.error_message.append(self._('Form Error: ') + str(e))
+            self.write_html(self.renderContext())
         except:
         except:
-            # everything else
-            self.write(cgitb.html())
+            # Something has gone badly wrong.  Therefore, we should
+            # make sure that the response code indicates failure.
+            if self.response_code == http_.client.OK:
+                self.response_code = http_.client.INTERNAL_SERVER_ERROR
+            # Help the administrator work out what went wrong.
+            html = ("<h1>Traceback</h1>"
+                    + cgitb.html(i18n=self.translator)
+                    + ("<h1>Environment Variables</h1><table>%s</table>"
+                       % cgitb.niceDict("", self.env)))
+            if not self.instance.config.WEB_DEBUG:
+                exc_info = sys.exc_info()
+                subject = "Error: %s" % exc_info[1]
+                self.send_html_to_admin(subject, html)
+                self.write_html(self._(error_message))
+            else:
+                self.write_html(html)
 
     def clean_sessions(self):
 
     def clean_sessions(self):
-        ''' Age sessions, remove when they haven't been used for a week.
-        
-            Do it only once an hour.
+        """Deprecated
+           XXX remove
+        """
+        self.clean_up()
 
 
-            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
+    def clean_up(self):
+        """Remove expired sessions and One Time Keys.
 
 
-        week = 60*60*24*7
+           Do it only once an hour.
+        """
         hour = 60*60
         now = time.time()
         hour = 60*60
         now = time.time()
-        if now - last_clean > hour:
-            # 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
-        '''
-        # open the database as admin
-        self.opendb('admin')
-
-        # clean age sessions
-        self.clean_sessions()
+        # XXX: hack - use OTK table to store last_clean time information
+        #      'last_clean' string is used instead of otk key
+        last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
+        if now - last_clean < hour:
+            return
 
 
-        # make sure we have the session Class
-        sessions = self.db.sessions
+        self.session_api.clean_up()
+        self.db.getOTKManager().clean()
+        self.db.getOTKManager().set('last_clean', last_use=now)
+        self.db.commit(fail_ok=True)
+
+    def determine_charset(self):
+        """Look for client charset in the form parameters or browser cookie.
+
+        If no charset requested by client, use storage charset (utf-8).
+
+        If the charset is found, and differs from the storage charset,
+        recode all form fields of type 'text/plain'
+        """
+        # look for client charset
+        charset_parameter = 0
+        if '@charset' in self.form:
+            charset = self.form['@charset'].value
+            if charset.lower() == "none":
+                charset = ""
+            charset_parameter = 1
+        elif 'roundup_charset' in self.cookie:
+            charset = self.cookie['roundup_charset'].value
+        else:
+            charset = None
+        if charset:
+            # make sure the charset is recognized
+            try:
+                codecs.lookup(charset)
+            except LookupError:
+                self.error_message.append(self._('Unrecognized charset: %r')
+                    % charset)
+                charset_parameter = 0
+            else:
+                self.charset = charset.lower()
+        # If we've got a character set in request parameters,
+        # set the browser cookie to keep the preference.
+        # This is done after codecs.lookup to make sure
+        # that we aren't keeping a wrong value.
+        if charset_parameter:
+            self.add_cookie('roundup_charset', charset)
+
+        # if client charset is different from the storage charset,
+        # recode form fields
+        # XXX this requires FieldStorage from Python library.
+        #   mod_python FieldStorage is not supported!
+        if self.charset != self.STORAGE_CHARSET:
+            decoder = codecs.getdecoder(self.charset)
+            encoder = codecs.getencoder(self.STORAGE_CHARSET)
+            re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
+            def _decode_charref(matchobj):
+                num = matchobj.group(1)
+                if num[0].lower() == 'x':
+                    uc = int(num[1:], 16)
+                else:
+                    uc = int(num)
+                return unichr(uc)
+
+            for field_name in self.form:
+                field = self.form[field_name]
+                if (field.type == 'text/plain') and not field.filename:
+                    try:
+                        value = decoder(field.value)[0]
+                    except UnicodeError:
+                        continue
+                    value = re_charref.sub(_decode_charref, value)
+                    field.value = encoder(value)[0]
+
+    def determine_language(self):
+        """Determine the language"""
+        # look for language parameter
+        # then for language cookie
+        # last for the Accept-Language header
+        if "@language" in self.form:
+            language = self.form["@language"].value
+            if language.lower() == "none":
+                language = ""
+            self.add_cookie("roundup_language", language)
+        elif "roundup_language" in self.cookie:
+            language = self.cookie["roundup_language"].value
+        elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
+            hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
+            language = accept_language.parse(hal)
+        else:
+            language = ""
 
 
-        # look up the user session cookie
-        cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
-        user = 'anonymous'
+        self.language = language
+        if language:
+            self.setTranslator(TranslationService.get_translation(
+                    language,
+                    tracker_home=self.instance.config["TRACKER_HOME"]))
 
 
-        # bump the "revision" of the cookie since the format changed
-        if (cookie.has_key(self.cookie_name) and
-                cookie[self.cookie_name].value != 'deleted'):
+    def determine_user(self):
+        """Determine who the user is"""
+        self.opendb('admin')
 
 
-            # get the session key from the cookie
-            self.session = cookie[self.cookie_name].value
-            # get the user from the session
-            try:
-                # update the lifetime datestamp
-                sessions.set(self.session, last_use=time.time())
-                sessions.commit()
-                user = sessions.get(self.session, 'user')
-            except KeyError:
-                user = 'anonymous'
+        # get session data from db
+        # XXX: rename
+        self.session_api = Session(self)
+
+        # take the opportunity to cleanup expired sessions and otks
+        self.clean_up()
+
+        user = None
+        # first up, try http authorization if enabled
+        if self.instance.config['WEB_HTTP_AUTH']:
+            if 'REMOTE_USER' in self.env:
+                # we have external auth (e.g. by Apache)
+                user = self.env['REMOTE_USER']
+            elif self.env.get('HTTP_AUTHORIZATION', ''):
+                # try handling Basic Auth ourselves
+                auth = self.env['HTTP_AUTHORIZATION']
+                scheme, challenge = auth.split(' ', 1)
+                if scheme.lower() == 'basic':
+                    try:
+                        decoded = base64.decodestring(challenge)
+                    except TypeError:
+                        # invalid challenge
+                        pass
+                    username, password = decoded.split(':')
+                    try:
+                        login = self.get_action_class('login')(self)
+                        login.verifyLogin(username, password)
+                    except LoginError, err:
+                        self.make_user_anonymous()
+                        raise
+                    user = username
+
+        # if user was not set by http authorization, try session lookup
+        if not user:
+            user = self.session_api.get('user')
+            if user:
+                # update session lifetime datestamp
+                self.session_api.update()
+
+        # if no user name set by http authorization or session lookup
+        # the user is anonymous
+        if not user:
+            user = 'anonymous'
 
 
-        # sanity check on the user still being valid, getting the userid
-        # at the same time
+        # sanity check on the user still being valid,
+        # getting the userid at the same time
         try:
             self.userid = self.db.user.lookup(user)
         except (KeyError, TypeError):
         try:
             self.userid = self.db.user.lookup(user)
         except (KeyError, TypeError):
@@ -361,50 +727,111 @@ class Client:
         # reopen the database as the correct user
         self.opendb(self.user)
 
         # reopen the database as the correct user
         self.opendb(self.user)
 
-    def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
-        ''' 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.
-
-            - if there is no path, then we are in the "home" context.
-            * if the path is "_file", then the additional path entry
-              specifies the filename of a static file we're to serve up
-              from the instance "html" directory. Raises a SendStaticFile
-              exception.
-            - if there is something in the path (eg "issue"), it identifies
-              the tracker class we're to display.
-            - if the path is an item designator (eg "issue123"), then we're
-              to display a specific item.
-            * if the path starts with an item designator and is longer than
-              one entry, then we're assumed to be handling an item of a
-              FileClass, and the extra path information gives the filename
-              that the client is going to label the download with (ie
-              "file123/image.png" is nicer to download than "file123"). This
-              raises a SendFile exception.
-
-            Both of the "*" types of contexts stop before we bother to
-            determine the template we're going to use. That's because they
-            don't actually use templates.
-
-            The template used is specified by the :template CGI variable,
-            which defaults to:
-
-             only classname suplied:          "index"
-             full item designator supplied:   "item"
-
-            We set:
+    def check_anonymous_access(self):
+        """Check that the Anonymous user is actually allowed to use the web
+        interface and short-circuit all further processing if they're not.
+        """
+        # allow Anonymous to use the "login" and "register" actions (noting
+        # that "register" has its own "Register" permission check)
+
+        if ':action' in self.form:
+            action = self.form[':action']
+        elif '@action' in self.form:
+            action = self.form['@action']
+        else:
+            action = ''
+        if isinstance(action, list):
+            raise SeriousError('broken form: multiple @action values submitted')
+        elif action != '':
+            action = action.value.lower()
+        if action in ('login', 'register'):
+            return
+
+        # allow Anonymous to view the "user" "register" template if they're
+        # allowed to register
+        if (self.db.security.hasPermission('Register', self.userid, 'user')
+                and self.classname == 'user' and self.template == 'register'):
+            return
+
+        # otherwise for everything else
+        if self.user == 'anonymous':
+            if not self.db.security.hasPermission('Web Access', self.userid):
+                raise Unauthorised(self._("Anonymous users are not "
+                    "allowed to use the web interface"))
+
+    def opendb(self, username):
+        """Open the database and set the current user.
+
+        Opens a database once. On subsequent calls only the user is set on
+        the database object the instance.optimize is set. If we are in
+        "Development Mode" (cf. roundup_server) then the database is always
+        re-opened.
+        """
+        # don't do anything if the db is open and the user has not changed
+        if hasattr(self, 'db') and self.db.isCurrentUser(username):
+            return
+
+        # open the database or only set the user
+        if not hasattr(self, 'db'):
+            self.db = self.instance.open(username)
+        else:
+            if self.instance.optimize:
+                self.db.setCurrentUser(username)
+            else:
+                self.db.close()
+                self.db = self.instance.open(username)
+                # The old session API refers to the closed database;
+                # we can no longer use it.
+                self.session_api = Session(self)
+
+    def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
+        """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.
+
+        - if there is no path, then we are in the "home" context.
+        - if the path is "_file", then the additional path entry
+          specifies the filename of a static file we're to serve up
+          from the instance "html" directory. Raises a SendStaticFile
+          exception.(*)
+        - if there is something in the path (eg "issue"), it identifies
+          the tracker class we're to display.
+        - if the path is an item designator (eg "issue123"), then we're
+          to display a specific item.
+        - if the path starts with an item designator and is longer than
+          one entry, then we're assumed to be handling an item of a
+          FileClass, and the extra path information gives the filename
+          that the client is going to label the download with (ie
+          "file123/image.png" is nicer to download than "file123"). This
+          raises a SendFile exception.(*)
+
+        Both of the "*" types of contexts stop before we bother to
+        determine the template we're going to use. That's because they
+        don't actually use templates.
+
+        The template used is specified by the :template CGI variable,
+        which defaults to:
+
+        - only classname suplied:          "index"
+        - full item designator supplied:   "item"
+
+        We set:
+
              self.classname  - the class to display, can be None
              self.classname  - the class to display, can be None
+
              self.template   - the template to render the current context with
              self.template   - the template to render the current context with
+
              self.nodeid     - the nodeid of the class we're displaying
              self.nodeid     - the nodeid of the class we're displaying
-        '''
+        """
         # default the optional variables
         self.classname = None
         self.nodeid = None
 
         # see if a template or messages are specified
         template_override = ok_message = error_message = None
         # default the optional variables
         self.classname = None
         self.nodeid = None
 
         # see if a template or messages are specified
         template_override = ok_message = error_message = None
-        for key in self.form.keys():
+        for key in self.form:
             if self.FV_TEMPLATE.match(key):
                 template_override = self.form[key].value
             elif self.FV_OK_MESSAGE.match(key):
             if self.FV_TEMPLATE.match(key):
                 template_override = self.form[key].value
             elif self.FV_OK_MESSAGE.match(key):
@@ -414,6 +841,12 @@ class Client:
                 error_message = self.form[key].value
                 error_message = clean_message(error_message)
 
                 error_message = self.form[key].value
                 error_message = clean_message(error_message)
 
+        # see if we were passed in a message
+        if ok_message:
+            self.ok_message.append(ok_message)
+        if error_message:
+            self.error_message.append(error_message)
+
         # determine the classname and possibly nodeid
         path = self.path.split('/')
         if not path or path[0] in ('', 'home', 'index'):
         # determine the classname and possibly nodeid
         path = self.path.split('/')
         if not path or path[0] in ('', 'home', 'index'):
@@ -422,24 +855,25 @@ class Client:
             else:
                 self.template = ''
             return
             else:
                 self.template = ''
             return
-        elif path[0] == '_file':
-            raise SendStaticFile, os.path.join(*path[1:])
+        elif path[0] in ('_file', '@@file'):
+            raise SendStaticFile(os.path.join(*path[1:]))
         else:
             self.classname = path[0]
             if len(path) > 1:
                 # send the file identified by the designator in path[0]
         else:
             self.classname = path[0]
             if len(path) > 1:
                 # 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')
+                raise SendFile(path[0])
 
         # see if we got a designator
         m = dre.match(self.classname)
         if m:
             self.classname = m.group(1)
             self.nodeid = m.group(2)
 
         # see if we got a designator
         m = dre.match(self.classname)
         if m:
             self.classname = m.group(1)
             self.nodeid = m.group(2)
-            if not self.db.getclass(self.classname).hasnode(self.nodeid):
-                raise NotFound, '%s/%s'%(self.classname, self.nodeid)
+            try:
+                klass = self.db.getclass(self.classname)
+            except KeyError:
+                raise NotFound('%s/%s'%(self.classname, self.nodeid))
+            if not klass.hasnode(self.nodeid):
+                raise NotFound('%s/%s'%(self.classname, self.nodeid))
             # with a designator, we default to item view
             self.template = 'item'
         else:
             # with a designator, we default to item view
             self.template = 'item'
         else:
@@ -450,68 +884,164 @@ class Client:
         try:
             self.db.getclass(self.classname)
         except KeyError:
         try:
             self.db.getclass(self.classname)
         except KeyError:
-            raise NotFound, self.classname
+            raise NotFound(self.classname)
 
         # see if we have a template override
         if template_override is not None:
             self.template = template_override
 
 
         # see if we have a template override
         if template_override is not None:
             self.template = template_override
 
-        # see if we were passed in a message
-        if ok_message:
-            self.ok_message.append(ok_message)
-        if error_message:
-            self.error_message.append(error_message)
-
     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
-        ''' Serve the file from the content property of the designated item.
-        '''
+        """ Serve the file from the content property of the designated item.
+        """
         m = dre.match(str(designator))
         if not m:
         m = dre.match(str(designator))
         if not m:
-            raise NotFound, str(designator)
+            raise NotFound(str(designator))
         classname, nodeid = m.group(1), m.group(2)
         classname, nodeid = m.group(1), m.group(2)
-        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'))
+        try:
+            klass = self.db.getclass(classname)
+        except KeyError:
+            # The classname was not valid.
+            raise NotFound(str(designator))
+            
+        # perform the Anonymous user access check
+        self.check_anonymous_access()
+
+        # make sure we have the appropriate properties
+        props = klass.getprops()
+        if 'type' not in props:
+            raise NotFound(designator)
+        if 'content' not in props:
+            raise NotFound(designator)
+
+        # make sure we have permission
+        if not self.db.security.hasPermission('View', self.userid,
+                classname, 'content', nodeid):
+            raise Unauthorised(self._("You are not allowed to view "
+                "this file."))
+
+        try:
+            mime_type = klass.get(nodeid, 'type')
+        except IndexError, e:
+            raise NotFound(e)
+        # Can happen for msg class:
+        if not mime_type:
+            mime_type = 'text/plain'
+
+        # if the mime_type is HTML-ish then make sure we're allowed to serve up
+        # HTML-ish content
+        if mime_type in ('text/html', 'text/x-html'):
+            if not self.instance.config['WEB_ALLOW_HTML_FILE']:
+                # do NOT serve the content up as HTML
+                mime_type = 'application/octet-stream'
+
+        # If this object is a file (i.e., an instance of FileClass),
+        # see if we can find it in the filesystem.  If so, we may be
+        # able to use the more-efficient request.sendfile method of
+        # sending the file.  If not, just get the "content" property
+        # in the usual way, and use that.
+        content = None
+        filename = None
+        if isinstance(klass, hyperdb.FileClass):
+            try:
+                filename = self.db.filename(classname, nodeid)
+            except AttributeError:
+                # The database doesn't store files in the filesystem
+                # and therefore doesn't provide the "filename" method.
+                pass
+            except IOError:
+                # The file does not exist.
+                pass
+        if not filename:
+            content = klass.get(nodeid, 'content')
+        
+        lmt = klass.get(nodeid, 'activity').timestamp()
+
+        self._serve_file(lmt, mime_type, content, filename)
 
     def serve_static_file(self, file):
 
     def serve_static_file(self, file):
+        """ Serve up the file named from the templates dir
+        """
+        # figure the filename - try STATIC_FILES, then TEMPLATES dir
+        for dir_option in ('STATIC_FILES', 'TEMPLATES'):
+            prefix = self.instance.config[dir_option]
+            if not prefix:
+                continue
+            # ensure the load doesn't try to poke outside
+            # of the static files directory
+            prefix = os.path.normpath(prefix)
+            filename = os.path.normpath(os.path.join(prefix, file))
+            if os.path.isfile(filename) and filename.startswith(prefix):
+                break
+        else:
+            raise NotFound(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'
+
+        self._serve_file(lmt, mime_type, '', filename)
+
+    def _serve_file(self, lmt, mime_type, content=None, filename=None):
+        """ guts of serve_file() and serve_static_file()
+        """
+
+        # spit out headers
+        self.additional_headers['Content-Type'] = mime_type
+        self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
+
         ims = None
         # see if there's an if-modified-since...
         ims = None
         # see if there's an 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'):
+        # XXX see which interfaces set this
+        #if hasattr(self.request, 'headers'):
+            #ims = self.request.headers.getheader('if-modified-since')
+        if 'HTTP_IF_MODIFIED_SINCE' in self.env:
             # cgi will put the header in the env var
             ims = self.env['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
 
         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())
+        if filename:
+            self.write_file(filename)
+        else:
+            self.additional_headers['Content-Length'] = str(len(content))
+            self.write(content)
+
+    def send_html_to_admin(self, subject, content):
+
+        to = [self.mailer.config.ADMIN_EMAIL]
+        message = self.mailer.get_standard_message(to, subject)
+        # delete existing content-type headers
+        del message['Content-type']
+        message['Content-type'] = 'text/html; charset=utf-8'
+        message.set_payload(content)
+        encode_quopri(message)
+        self.mailer.smtp_send(to, message.as_string())
+    
+    def renderFrontPage(self, message):
+        """Return the front page of the tracker."""
+    
+        self.classname = self.nodeid = None
+        self.template = ''
+        self.error_message.append(message)
+        self.write_html(self.renderContext())
 
     def renderContext(self):
 
     def renderContext(self):
-        ''' Return a PageTemplate for the named page
-        '''
+        """ Return a PageTemplate for the named page
+        """
         name = self.classname
         extension = self.template
         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 = {
 
         # catch errors so we can handle PT rendering errors more nicely
         args = {
@@ -519,1397 +1049,490 @@ class Client:
             'error_message': self.error_message
         }
         try:
             'error_message': self.error_message
         }
         try:
+            pt = self.instance.templates.get(name, extension)
             # let the template render figure stuff out
             # let the template render figure stuff out
-            return pt.render(self, None, None, **args)
-        except NoTemplate, message:
-            return '<strong>%s</strong>'%message
+            result = pt.render(self, None, None, **args)
+            self.additional_headers['Content-Type'] = pt.content_type
+            if self.env.get('CGI_SHOW_TIMING', ''):
+                if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
+                    timings = {'starttag': '<!-- ', 'endtag': ' -->'}
+                else:
+                    timings = {'starttag': '<p>', 'endtag': '</p>'}
+                timings['seconds'] = time.time()-self.start
+                s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
+                    ) % timings
+                if hasattr(self.db, 'stats'):
+                    timings.update(self.db.stats)
+                    s += self._("%(starttag)sCache hits: %(cache_hits)d,"
+                        " misses %(cache_misses)d."
+                        " Loading items: %(get_items)f secs."
+                        " Filtering: %(filtering)f secs."
+                        "%(endtag)s\n") % timings
+                s += '</body>'
+                result = result.replace('</body>', s)
+            return result
+        except templating.NoTemplate, message:
+            return '<strong>%s</strong>'%cgi.escape(str(message))
+        except templating.Unauthorised, message:
+            raise Unauthorised(cgi.escape(str(message)))
         except:
             # everything else
         except:
             # everything else
-            return cgitb.pt_html()
+            if self.instance.config.WEB_DEBUG:
+                return cgitb.pt_html(i18n=self.translator)
+            exc_info = sys.exc_info()
+            try:
+                # If possible, send the HTML page template traceback
+                # to the administrator.
+                subject = "Templating Error: %s" % exc_info[1]
+                self.send_html_to_admin(subject, cgitb.pt_html())
+                # Now report the error to the user.
+                return self._(error_message)
+            except:
+                # Reraise the original exception.  The user will
+                # receive an error message, and the adminstrator will
+                # receive a traceback, albeit with less information
+                # than the one we tried to generate above.
+                raise exc_info[0](exc_info[1]).with_traceback(exc_info[2])
 
     # these are the actions that are available
     actions = (
 
     # these are the actions that are available
     actions = (
-        ('edit',     'editItemAction'),
-        ('editcsv',  'editCSVAction'),
-        ('new',      'newItemAction'),
-        ('register', 'registerAction'),
-        ('confrego', 'confRegoAction'),
-        ('passrst',  'passResetAction'),
-        ('login',    'loginAction'),
-        ('logout',   'logout_action'),
-        ('search',   'searchAction'),
-        ('retire',   'retireAction'),
-        ('show',     'showAction'),
+        ('edit',        EditItemAction),
+        ('editcsv',     EditCSVAction),
+        ('new',         NewItemAction),
+        ('register',    RegisterAction),
+        ('confrego',    ConfRegoAction),
+        ('passrst',     PassResetAction),
+        ('login',       LoginAction),
+        ('logout',      LogoutAction),
+        ('search',      SearchAction),
+        ('retire',      RetireAction),
+        ('show',        ShowAction),
+        ('export_csv',  ExportCSVAction),
     )
     def handle_action(self):
     )
     def handle_action(self):
-        ''' Determine whether there should be an Action called.
+        """ 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 actions
             are defined in the "actions" sequence on this class.
 
             The action is defined by the form variable :action which
             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()
-        elif self.form.has_key('@action'):
-            action = self.form['@action'].value.lower()
-        else:
-            return None
-        try:
-            # get the action, validate it
-            for name, method in self.actions:
-                if name == action:
-                    break
-            else:
-                raise ValueError, 'No such action "%s"'%action
-            # call the mapped action
-            getattr(self, method)()
-        except Redirect:
-            raise
-        except Unauthorised:
-            raise
-
-    def write(self, content):
-        if not self.headers_done:
-            self.header()
-        self.request.wfile.write(content)
-
-    def header(self, headers=None, response=None):
-        '''Put up the appropriate header.
-        '''
-        if headers is None:
-            headers = {'Content-Type':'text/html'}
-        if response is None:
-            response = self.response_code
-
-        # update with additional info
-        headers.update(self.additional_headers)
-
-        if not headers.has_key('Content-Type'):
-            headers['Content-Type'] = 'text/html'
-        self.request.send_response(response)
-        for entry in headers.items():
-            self.request.send_header(*entry)
-        self.request.end_headers()
-        self.headers_done = 1
-        if self.debug:
-            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.
-        '''
-        # TODO generate a much, much stronger session key ;)
-        self.session = binascii.b2a_base64(repr(random.random())).strip()
-
-        # clean up the base64
-        if self.session[-1] == '=':
-            if self.session[-2] == '=':
-                self.session = self.session[:-2]
-            else:
-                self.session = self.session[:-1]
-
-        # insert the session in the sessiondb
-        self.db.sessions.set(self.session, user=user, last_use=time.time())
-
-        # and commit immediately
-        self.db.sessions.commit()
-
-        # expire us in a long, long time
-        expire = Cookie._getdate(86400*365)
-
-        # generate the cookie path - make sure it has a trailing '/'
-        self.additional_headers['Set-Cookie'] = \
-          '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
-            expire, self.cookie_path)
-
-    def make_user_anonymous(self):
-        ''' Make us anonymous
-
-            This method used to handle non-existence of the 'anonymous'
-            user, but that user is mandatory now.
-        '''
-        self.userid = self.db.user.lookup('anonymous')
-        self.user = 'anonymous'
 
 
-    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)
+            Actions may return a page (by default HTML) to return to the
+            user, bypassing the usual template rendering.
 
 
-    #
-    # Actions
-    #
-    def loginAction(self):
-        ''' Attempt to log a user in.
-
-            Sets up a session for the user which contains the login
-            credentials.
-        '''
-        # we need the username at a minimum
-        if not self.form.has_key('__login_name'):
-            self.error_message.append(_('Username required'))
-            return
+            We explicitly catch Reject and ValueError exceptions and
+            present their messages to the user.
+        """
+        if ':action' in self.form:
+            action = self.form[':action']
+        elif '@action' in self.form:
+            action = self.form['@action']
+        else:
+            return None
 
 
-        # get the login info
-        self.user = self.form['__login_name'].value
-        if self.form.has_key('__login_password'):
-            password = self.form['__login_password'].value
+        if isinstance(action, list):
+            raise SeriousError('broken form: multiple @action values submitted')
         else:
         else:
-            password = ''
+            action = action.value.lower()
 
 
-        # make sure the user exists
         try:
         try:
-            self.userid = self.db.user.lookup(self.user)
-        except KeyError:
-            name = self.user
-            self.error_message.append(_('No such user "%(name)s"')%locals())
-            self.make_user_anonymous()
-            return
-
-        # verify the password
-        if not self.verifyPassword(self.userid, password):
-            self.make_user_anonymous()
-            self.error_message.append(_('Incorrect password'))
-            return
+            action_klass = self.get_action_class(action)
 
 
-        # make sure we're allowed to be here
-        if not self.loginPermission():
-            self.make_user_anonymous()
-            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)
-
-    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.
-
-            Base behaviour is to check the user has "Web Access".
-        ''' 
-        if not self.db.security.hasPermission('Web Access', self.userid):
-            return 0
-        return 1
-
-    def logout_action(self):
-        ''' Make us really anonymous - nuke the cookie too
-        '''
-        # log us out
-        self.make_user_anonymous()
-
-        # construct the logout cookie
-        now = Cookie._getdate()
-        self.additional_headers['Set-Cookie'] = \
-           '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
-            now, self.cookie_path)
+            # call the mapped action
+            if isinstance(action_klass, type('')):
+                # old way of specifying actions
+                return getattr(self, action_klass)()
+            else:
+                return action_klass(self).execute()
 
 
-        # Let the user know what's going on
-        self.ok_message.append(_('You are logged out'))
+        except (ValueError, Reject), err:
+            self.error_message.append(str(err))
 
 
-    def registerAction(self):
-        '''Attempt to create a new user based on the contents of the form
-        and then set the cookie.
+    def get_action_class(self, action_name):
+        if (hasattr(self.instance, 'cgi_actions') and
+                action_name in self.instance.cgi_actions):
+            # tracker-defined action
+            action_klass = self.instance.cgi_actions[action_name]
+        else:
+            # go with a default
+            for name, action_klass in self.actions:
+                if name == action_name:
+                    break
+            else:
+                raise ValueError('No such action "%s"'%action_name)
+        return action_klass
 
 
-        return 1 on successful login
-        '''
-        props = self.parsePropsFromForm()[0][('user', None)]
+    def _socket_op(self, call, *args, **kwargs):
+        """Execute socket-related operation, catch common network errors
 
 
-        # make sure we're allowed to register
-        if not self.registerPermission(props):
-            raise Unauthorised, _("You do not have permission to register")
+        Parameters:
+            call: a callable to execute
+            args, kwargs: call arguments
 
 
+        """
         try:
         try:
-            self.db.user.lookup(props['username'])
-            self.error_message.append('Error: A user with the username "%s" '
-                'already exists'%props['username'])
-            return
-        except KeyError:
+            call(*args, **kwargs)
+        except socket.error, err:
+            err_errno = getattr (err, 'errno', None)
+            if err_errno is None:
+                try:
+                    err_errno = err[0]
+                except TypeError:
+                    pass
+            if err_errno not in self.IGNORE_NET_ERRORS:
+                raise
+        except IOError:
+            # Apache's mod_python will raise IOError -- without an
+            # accompanying errno -- when a write to the client fails.
+            # A common case is that the client has closed the
+            # connection.  There's no way to be certain that this is
+            # the situation that has occurred here, but that is the
+            # most likely case.
             pass
 
             pass
 
-        # generate the one-time-key and store the props for later
-        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:
-                pass
-            elif isinstance(proptype, hyperdb.Date):
-                props[propname] = str(value)
-            elif isinstance(proptype, hyperdb.Interval):
-                props[propname] = str(value)
-            elif isinstance(proptype, hyperdb.Password):
-                props[propname] = str(value)
-        props['__time'] = time.time()
-        self.db.otks.set(otk, **props)
-
-        # send the email
-        tracker_name = self.db.config.TRACKER_NAME
-        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, '%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
+    def write(self, content):
+        if not self.headers_done:
+            self.header()
+        if self.env['REQUEST_METHOD'] != 'HEAD':
+            self._socket_op(self.request.wfile.write, content)
 
 
-            Base behaviour is to check the user has "Web Registration".
-        '''
-        # registration isn't allowed to supply roles
-        if props.has_key('roles'):
-            return 0
-        if self.db.security.hasPermission('Web Registration', self.userid):
-            return 1
-        return 0
+    def write_html(self, content):
+        if not self.headers_done:
+            # at this point, we are sure about Content-Type
+            if 'Content-Type' not in self.additional_headers:
+                self.additional_headers['Content-Type'] = \
+                    'text/html; charset=%s' % self.charset
+            self.header()
 
 
-    def confRegoAction(self):
-        ''' Grab the OTK, use it to load up the new user details
-        '''
-        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))
+        if self.env['REQUEST_METHOD'] == 'HEAD':
+            # client doesn't care about content
             return
             return
-        
-        # log the new user in
-        self.user = self.db.user.get(self.userid, 'username')
-        # re-open the database for real, using the user
-        self.opendb(self.user)
 
 
-        # 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 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!
-(a Mozilla bug may cause this message to show up erroneously,
- please check your email)""")
-                return
+        if self.charset != self.STORAGE_CHARSET:
+            # recode output
+            content = content.decode(self.STORAGE_CHARSET, 'replace')
+            content = content.encode(self.charset, 'xmlcharrefreplace')
 
 
-            # re-open the database as "admin"
-            if self.user != 'admin':
-                self.opendb('admin')
+        # and write
+        self._socket_op(self.request.wfile.write, content)
 
 
-            # change the password
-            newpw = password.generatePassword()
+    def http_strip(self, content):
+        """Remove HTTP Linear White Space from 'content'.
 
 
-            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
+        'content' -- A string.
 
 
-            # user info
-            address = self.db.user.get(uid, 'address')
-            name = self.db.user.get(uid, 'username')
+        returns -- 'content', with all leading and trailing LWS
+        removed."""
 
 
-            # 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".
+        # RFC 2616 2.2: Basic Rules
+        #
+        # LWS = [CRLF] 1*( SP | HT )
+        return content.strip(" \r\n\t")
 
 
-Your password is now: %(password)s
-'''%{'name': name, 'password': newpw}
-            if not self.standard_message([address], subject, body):
-                return
+    def http_split(self, content):
+        """Split an HTTP list.
 
 
-            self.ok_message.append('Password reset and email sent to %s'%address)
-            return
+        'content' -- A string, giving a list of items.
 
 
-        # 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())
+        returns -- A sequence of strings, containing the elements of
+        the list."""
 
 
-        # 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:
+        # RFC 2616 2.1: Augmented BNF
+        #
+        # Grammar productions of the form "#rule" indicate a
+        # comma-separated list of elements matching "rule".  LWS
+        # is then removed from each element, and empty elements
+        # removed.
 
 
-  %(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
-        '''
-        props, links = self.parsePropsFromForm()
-
-        # handle the props
+        # Split at commas.
+        elements = content.split(",")
+        # Remove linear whitespace at either end of the string.
+        elements = [self.http_strip(e) for e in elements]
+        # Remove any now-empty elements.
+        return [e for e in elements if e]
+        
+    def handle_range_header(self, length, etag):
+        """Handle the 'Range' and 'If-Range' headers.
+
+        'length' -- the length of the content available for the
+        resource.
+
+        'etag' -- the entity tag for this resources.
+
+        returns -- If the request headers (including 'Range' and
+        'If-Range') indicate that only a portion of the entity should
+        be returned, then the return value is a pair '(offfset,
+        length)' indicating the first byte and number of bytes of the
+        content that should be returned to the client.  In addition,
+        this method will set 'self.response_code' to indicate Partial
+        Content.  In all other cases, the return value is 'None'.  If
+        appropriate, 'self.response_code' will be
+        set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'.  In that
+        case, the caller should not send any data to the client."""
+
+        # RFC 2616 14.35: Range
+        #
+        # See if the Range header is present.
+        ranges_specifier = self.env.get("HTTP_RANGE")
+        if ranges_specifier is None:
+            return None
+        # RFC 2616 14.27: If-Range
+        #
+        # Check to see if there is an If-Range header.
+        # Because the specification says:
+        #
+        #  The If-Range header ... MUST be ignored if the request
+        #  does not include a Range header, we check for If-Range
+        #  after checking for Range.
+        if_range = self.env.get("HTTP_IF_RANGE")
+        if if_range:
+            # The grammar for the If-Range header is:
+            # 
+            #   If-Range = "If-Range" ":" ( entity-tag | HTTP-date )
+            #   entity-tag = [ weak ] opaque-tag
+            #   weak = "W/"
+            #   opaque-tag = quoted-string
+            #
+            # We only support strong entity tags.
+            if_range = self.http_strip(if_range)
+            if (not if_range.startswith('"')
+                or not if_range.endswith('"')):
+                return None
+            # If the condition doesn't match the entity tag, then we
+            # must send the client the entire file.
+            if if_range != etag:
+                return
+        # The grammar for the Range header value is:
+        #
+        #   ranges-specifier = byte-ranges-specifier
+        #   byte-ranges-specifier = bytes-unit "=" byte-range-set
+        #   byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
+        #   byte-range-spec = first-byte-pos "-" [last-byte-pos]
+        #   first-byte-pos = 1*DIGIT
+        #   last-byte-pos = 1*DIGIT
+        #   suffix-byte-range-spec = "-" suffix-length
+        #   suffix-length = 1*DIGIT
+        #
+        # Look for the "=" separating the units from the range set.
+        specs = ranges_specifier.split("=", 1)
+        if len(specs) != 2:
+            return None
+        # Check that the bytes-unit is in fact "bytes".  If it is not,
+        # we do not know how to process this range.
+        bytes_unit = self.http_strip(specs[0])
+        if bytes_unit != "bytes":
+            return None
+        # Seperate the range-set into range-specs.
+        byte_range_set = self.http_strip(specs[1])
+        byte_range_specs = self.http_split(byte_range_set)
+        # We only handle exactly one range at this time.
+        if len(byte_range_specs) != 1:
+            return None
+        # Parse the spec.
+        byte_range_spec = byte_range_specs[0]
+        pos = byte_range_spec.split("-", 1)
+        if len(pos) != 2:
+            return None
+        # Get the first and last bytes.
+        first = self.http_strip(pos[0])
+        last = self.http_strip(pos[1])
+        # We do not handle suffix ranges.
+        if not first:
+            return None
+       # Convert the first and last positions to integers.
         try:
         try:
-            message = self._editnodes(props, links)
-        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&@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.
-
-            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
-        if self.classname == 'user':
-            # reject if someone's trying to edit "roles" and doesn't have the
-            # right permission.
-            if props.has_key('roles') and not has('Web Roles', self.userid,
-                    'user'):
-                return 0
-            # if the item being edited is the current user, we're ok
-            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 newItemPermission(self, props):
-        ''' Determine whether the user has permission to create (edit) this
-            item.
-
-            Base behaviour is to check the user can edit this class. No
-            additional property checks are made. Additionally, new user items
-            may be created if the user has the "Web Registration" Permission.
-        '''
-        has = self.db.security.hasPermission
-        if self.classname == 'user' and has('Web Registration', self.userid,
-                'user'):
-            return 1
-        if has('Edit', self.userid, self.classname):
-            return 1
-        return 0
-
-
-    #
-    #  Utility methods for editing
-    #
-    def _editnodes(self, all_props, all_links, newids=None):
-        ''' Use the props in all_props to perform edit and creation, then
-            use the link specs in all_links to do linking.
-        '''
-        # figure dependencies and re-work links
-        deps = {}
-        links = {}
-        for cn, nodeid, propname, vlist in all_links:
-            if not all_props.has_key((cn, nodeid)):
-                # link item to link to doesn't (and won't) exist
-                continue
-            for value in vlist:
-                if not all_props.has_key(value):
-                    # link item to link to doesn't (and won't) exist
-                    continue
-                deps.setdefault((cn, nodeid), []).append(value)
-                links.setdefault(value, []).append((cn, nodeid, propname))
-
-        # figure chained dependencies ordering
-        order = []
-        done = {}
-        # loop detection
-        change = 0
-        while len(all_props) != len(done):
-            for needed in all_props.keys():
-                if done.has_key(needed):
-                    continue
-                tlist = deps.get(needed, [])
-                for target in tlist:
-                    if not done.has_key(target):
-                        break
-                else:
-                    done[needed] = 1
-                    order.append(needed)
-                    change = 1
-            if not change:
-                raise ValueError, 'linking must not loop!'
-
-        # now, edit / create
-        m = []
-        for needed in order:
-            props = all_props[needed]
-            if not props:
-                # nothing to do
-                continue
-            cn, nodeid = needed
-
-            if nodeid is not None and int(nodeid) > 0:
-                # make changes to the node
-                props = self._changenode(cn, nodeid, props)
-
-                # and some nice feedback for the user
-                if props:
-                    info = ', '.join(props.keys())
-                    m.append('%s %s %s edited ok'%(cn, nodeid, info))
-                else:
-                    m.append('%s %s - nothing changed'%(cn, nodeid))
+            first = int(first)
+            if last:
+                last = int(last)
             else:
             else:
-                assert props
-
-                # make a new node
-                newid = self._createnode(cn, props)
-                if nodeid is None:
-                    self.nodeid = newid
-                nodeid = newid
-
-                # and some nice feedback for the user
-                m.append('%s %s created'%(cn, newid))
-
-            # fill in new ids in links
-            if links.has_key(needed):
-                for linkcn, linkid, linkprop in links[needed]:
-                    props = all_props[(linkcn, linkid)]
-                    cl = self.db.classes[linkcn]
-                    propdef = cl.getprops()[linkprop]
-                    if not props.has_key(linkprop):
-                        if linkid is None or linkid.startswith('-'):
-                            # linking to a new item
-                            if isinstance(propdef, hyperdb.Multilink):
-                                props[linkprop] = [newid]
-                            else:
-                                props[linkprop] = newid
-                        else:
-                            # linking to an existing item
-                            if isinstance(propdef, hyperdb.Multilink):
-                                existing = cl.get(linkid, linkprop)[:]
-                                existing.append(nodeid)
-                                props[linkprop] = existing
-                            else:
-                                props[linkprop] = newid
-
-        return '<br>'.join(m)
-
-    def _changenode(self, cn, nodeid, props):
-        ''' change the node based on the contents of the form
-        '''
-        # check for permission
-        if not self.editItemPermission(props):
-            raise Unauthorised, 'You do not have permission to edit %s'%cn
-
-        # make the changes
-        cl = self.db.classes[cn]
-        return cl.set(nodeid, **props)
-
-    def _createnode(self, cn, props):
-        ''' create a node based on the contents of the form
-        '''
-        # check for permission
-        if not self.newItemPermission(props):
-            raise Unauthorised, 'You do not have permission to create %s'%cn
-
-        # create the node and return its id
-        cl = self.db.classes[cn]
-        return cl.create(**props)
-
-    # 
-    # More actions
-    #
-    def editCSVAction(self):
-        ''' 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))
-
-        # get the CSV module
-        if rcsv.error:
-            self.error_message.append(_(rcsv.error))
+                last = length - 1
+        except:
+            # The positions could not be parsed as integers.
+            return None
+        # Check that the range makes sense.
+        if (first < 0 or last < 0 or last < first):
+            return None
+        if last >= length:
+            # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable
+            #
+            # If there is an If-Range header, RFC 2616 says that we
+            # should just ignore the invalid Range header.
+            if if_range:
+                return None
+            # Return code 416 with a Content-Range header giving the
+            # allowable range.
+            self.response_code = http_.client.REQUESTED_RANGE_NOT_SATISFIABLE
+            self.setHeader("Content-Range", "bytes */%d" % length)
+            return None
+        # RFC 2616 10.2.7: 206 Partial Content
+        #
+        # Tell the client that we are honoring the Range request by
+        # indicating that we are providing partial content.
+        self.response_code = http_.client.PARTIAL_CONTENT
+        # RFC 2616 14.16: Content-Range
+        #
+        # Tell the client what data we are providing.
+        #
+        #   content-range-spec = byte-content-range-spec
+        #   byte-content-range-spec = bytes-unit SP
+        #                             byte-range-resp-spec "/"
+        #                             ( instance-length | "*" )
+        #   byte-range-resp-spec = (first-byte-pos "-" last-byte-pos)
+        #                          | "*"
+        #   instance-length      = 1 * DIGIT
+        self.setHeader("Content-Range",
+                       "bytes %d-%d/%d" % (first, last, length))
+        return (first, last - first + 1)
+
+    def write_file(self, filename):
+        """Send the contents of 'filename' to the user."""
+
+        # Determine the length of the file.
+        stat_info = os.stat(filename)
+        length = stat_info[stat.ST_SIZE]
+        # Assume we will return the entire file.
+        offset = 0
+        # If the headers have not already been finalized, 
+        if not self.headers_done:
+            # RFC 2616 14.19: ETag
+            #
+            # Compute the entity tag, in a format similar to that
+            # used by Apache.
+            etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
+                                   length,
+                                   stat_info[stat.ST_MTIME])
+            self.setHeader("ETag", etag)
+            # RFC 2616 14.5: Accept-Ranges
+            #
+            # Let the client know that we will accept range requests.
+            self.setHeader("Accept-Ranges", "bytes")
+            # RFC 2616 14.35: Range
+            #
+            # If there is a Range header, we may be able to avoid
+            # sending the entire file.
+            content_range = self.handle_range_header(length, etag)
+            if content_range:
+                offset, length = content_range
+            # RFC 2616 14.13: Content-Length
+            #
+            # Tell the client how much data we are providing.
+            self.setHeader("Content-Length", str(length))
+            # Send the HTTP header.
+            self.header()
+        # If the client doesn't actually want the body, or if we are
+        # indicating an invalid range.
+        if (self.env['REQUEST_METHOD'] == 'HEAD'
+            or self.response_code == http_.client.REQUESTED_RANGE_NOT_SATISFIABLE):
             return
             return
+        # Use the optimized "sendfile" operation, if possible.
+        if hasattr(self.request, "sendfile"):
+            self._socket_op(self.request.sendfile, filename, offset, length)
+            return
+        # Fallback to the "write" operation.
+        f = open(filename, 'rb')
+        try:
+            if offset:
+                f.seek(offset)
+            content = f.read(length)
+        finally:
+            f.close()
+        self.write(content)
 
 
-        cl = self.db.classes[self.classname]
-        idlessprops = cl.getprops(protected=0).keys()
-        idlessprops.sort()
-        props = ['id'] + idlessprops
-
-        # do the edit
-        rows = StringIO.StringIO(self.form['rows'].value)
-        reader = rcsv.reader(rows, rcsv.comma_separated)
-        found = {}
-        line = 0
-        for values in reader:
-            line += 1
-            if line == 1: continue
-            # skip property names header
-            if values == props:
-                continue
-
-            # extract the nodeid
-            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(
-                    _('Not enough values on line %(line)s')%{'line':line})
-                return
-
-            # 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(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 exists:
-                # edit existing
-                cl.set(nodeid, **d)
-            else:
-                # new node
-                found[cl.create(**d)] = 1
-
-        # retire the removed entries
-        for nodeid in cl.list():
-            if not found.has_key(nodeid):
-                cl.retire(nodeid)
-
-        # all OK
-        self.db.commit()
-
-        self.ok_message.append(_('Items edited OK'))
-
-    def editCSVPermission(self):
-        ''' Determine whether the user has permission to edit 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
-
-    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.
-
-            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():
-            self.error_message.append(
-                _('You do not have permission to search %s' %self.classname))
-
-        # add a faked :filter form variable for each filtering prop
-        props = self.db.classes[self.classname].getprops()
-        queryname = ''
-        for key in self.form.keys():
-            # special vars
-            if self.FV_QUERYNAME.match(key):
-                queryname = self.form[key].value.strip()
-                continue
+    def setHeader(self, header, value):
+        """Override a header to be returned to the user's browser.
+        """
+        self.additional_headers[header] = value
 
 
-            if not props.has_key(key):
-                continue
-            if isinstance(self.form[key], type([])):
-                # search for at least one entry which is not empty
-                for minifield in self.form[key]:
-                    if minifield.value:
-                        break
-                else:
-                    continue
-            else:
-                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)
-
-            # 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:
-                qid = self.db.query.lookup(queryname)
-                self.db.query.set(qid, klass=self.classname, url=url)
-            except KeyError:
-                # create a query
-                qid = self.db.query.create(name=queryname,
-                    klass=self.classname, url=url)
+    def header(self, headers=None, response=None):
+        """Put up the appropriate header.
+        """
+        if headers is None:
+            headers = {'Content-Type':'text/html; charset=utf-8'}
+        if response is None:
+            response = self.response_code
 
 
-                # and add it to the user's query multilink
-                queries = self.db.user.get(self.userid, 'queries')
-                queries.append(qid)
-                self.db.user.set(self.userid, queries=queries)
+        # update with additional info
+        headers.update(self.additional_headers)
 
 
-            # commit the query change to the database
-            self.db.commit()
+        if headers.get('Content-Type', 'text/html') == 'text/html':
+            headers['Content-Type'] = 'text/html; charset=utf-8'
 
 
-    def searchPermission(self):
-        ''' Determine whether the user has permission to search this class.
+        headers = list(headers.items())
 
 
-            Base behaviour is to check the user can view this class.
-        ''' 
-        if not self.db.security.hasPermission('View', self.userid,
-                self.classname):
-            return 0
-        return 1
+        for ((path, name), (value, expire)) in self._cookies.iteritems():
+            cookie = "%s=%s; Path=%s;"%(name, value, path)
+            if expire is not None:
+                cookie += " expires=%s;"%get_cookie_date(expire)
+            headers.append(('Set-Cookie', cookie))
 
 
+        self._socket_op(self.request.start_response, headers, response)
 
 
-    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
+        self.headers_done = 1
+        if self.debug:
+            self.headers_sent = headers
 
 
-        # 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
+    def add_cookie(self, name, value, expire=86400*365, path=None):
+        """Set a cookie value to be sent in HTTP headers
+
+        Parameters:
+            name:
+                cookie name
+            value:
+                cookie value
+            expire:
+                cookie expiration time (seconds).
+                If value is empty (meaning "delete cookie"),
+                expiration time is forced in the past
+                and this argument is ignored.
+                If None, the cookie will expire at end-of-session.
+                If omitted, the cookie will be kept for a year.
+            path:
+                cookie path (optional)
+
+        """
+        if path is None:
+            path = self.cookie_path
+        if not value:
+            expire = -1
+        self._cookies[(path, name)] = (value, expire)
+
+    def set_cookie(self, user, expire=None):
+        """Deprecated. Use session_api calls directly
+
+        XXX remove
+        """
+
+        # insert the session in the session db
+        self.session_api.set(user=user)
+        # refresh session cookie
+        self.session_api.update(set_cookie=True, expire=expire)
 
 
-        # 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
+    def make_user_anonymous(self):
+        """ Make us anonymous
 
 
-        # do the retire
-        self.db.getclass(self.classname).retire(nodeid)
-        self.db.commit()
+            This method used to handle non-existence of the 'anonymous'
+            user, but that user is mandatory now.
+        """
+        self.userid = self.db.user.lookup('anonymous')
+        self.user = 'anonymous'
 
 
-        self.ok_message.append(
-            _('%(classname)s %(itemid)s has been retired')%{
-                'classname': self.classname.capitalize(), 'itemid': nodeid})
+    def standard_message(self, to, subject, body, author=None):
+        """Send a standard email message from Roundup.
 
 
-    def retirePermission(self):
-        ''' Determine whether the user has permission to retire this class.
+        "to"      - recipients list
+        "subject" - Subject
+        "body"    - Message
+        "author"  - (name, address) tuple or None for admin email
 
 
-            Base behaviour is to check the user can edit this class.
-        ''' 
-        if not self.db.security.hasPermission('Edit', self.userid,
-                self.classname):
+        Arguments are passed to the Mailer.standard_message code.
+        """
+        try:
+            self.mailer.standard_message(to, subject, body, author)
+        except MessageSendError, e:
+            self.error_message.append(str(e))
             return 0
         return 1
 
             return 0
         return 1
 
+    def parsePropsFromForm(self, create=0):
+        return FormParser(self).parse(create=create)
 
 
-    def showAction(self, typere=re.compile('[@:]type'),
-            numre=re.compile('[@:]number')):
-        ''' Show a node of a particular class/id
-        '''
-        t = n = ''
-        for key in self.form.keys():
-            if typere.match(key):
-                t = self.form[key].value.strip()
-            elif numre.match(key):
-                n = self.form[key].value.strip()
-        if not t:
-            raise ValueError, 'Invalid %s number'%t
-        url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
-        raise Redirect, url
-
-    def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
-        ''' 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.
-            - 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.
-
-            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 (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.
-
-            @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.
-
-            @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.
-
-            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.
-
-                For a Link('klass') property, the form value is a
-                single key for 'klass', where the key field is
-                specified in dbinit.py.  
-
-                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.  
-
-                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,
-            even if it's empty (ie. a submission for an existing issue that
-            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
-
-        if not hasattr(self, 'FV_SPECIAL'):
-            # generate the regexp for handling special form values
-            classes = '|'.join(db.classes.keys())
-            # specials for parsePropsFromForm
-            # handle the various forms (see unit tests)
-            self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
-            self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
-
-        # these indicate the default class / item
-        default_cn = self.classname
-        default_cl = self.db.classes[default_cn]
-        default_nodeid = self.nodeid
-
-        # we'll store info about the individual class/item edit in these
-        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
-
-        # we should always return something, even empty, for the context
-        all_props[(default_cn, default_nodeid)] = {}
-
-        keys = form.keys()
-        timezone = db.getUserTimezone()
-
-        # sentinels for the :note and :file props
-        have_note = have_file = 0
-
-        # extract the usable form labels from the form
-        matches = []
-        for key in keys:
-            m = self.FV_SPECIAL.match(key)
-            if m:
-                matches.append((key, m.groupdict()))
-
-        # now handle the matches
-        for key, d in matches:
-            if d['classname']:
-                # we got a designator
-                cn = d['classname']
-                cl = self.db.classes[cn]
-                nodeid = d['id']
-                propname = d['propname']
-            elif d['note']:
-                # the special note field
-                cn = 'msg'
-                cl = self.db.classes[cn]
-                nodeid = '-1'
-                propname = 'content'
-                all_links.append((default_cn, default_nodeid, 'messages',
-                    [('msg', '-1')]))
-                have_note = 1
-            elif d['file']:
-                # the special file field
-                cn = 'file'
-                cl = self.db.classes[cn]
-                nodeid = '-1'
-                propname = 'content'
-                all_links.append((default_cn, default_nodeid, 'files',
-                    [('file', '-1')]))
-                have_file = 1
-            else:
-                # default
-                cn = default_cn
-                cl = default_cl
-                nodeid = default_nodeid
-                propname = d['propname']
-
-            # the thing this value relates to is...
-            this = (cn, nodeid)
-
-            # get more info about the class, and the current set of
-            # form props for it
-            if not all_propdef.has_key(cn):
-                all_propdef[cn] = cl.getprops()
-            propdef = all_propdef[cn]
-            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']:
-                value = []
-                for entry in extractFormList(form[key]):
-                    m = self.FV_DESIGNATOR.match(entry)
-                    if not m:
-                        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 FormError, '%s %s is not a link or '\
-                        'multilink property'%(cn, propname)
-
-                all_links.append((cn, nodeid, propname, value))
-                continue
-
-            # detect the special ":required" variable
-            if d['required']:
-                all_required[this] = extractFormList(form[key])
-                continue
-
-            # see if we're performing a special multilink action
-            mlaction = 'set'
-            if d['remove']:
-                mlaction = 'remove'
-            elif d['add']:
-                mlaction = 'add'
-
-            # does the property exist?
-            if not propdef.has_key(propname):
-                if mlaction != 'set':
-                    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
-                # about - ignore it
-                continue
-            proptype = propdef[propname]
-
-            # Get the form value. This value may be a MiniFieldStorage or a list
-            # of MiniFieldStorages.
-            value = form[key]
-
-            # handle unpacking of the MiniFieldStorage / list form value
-            if isinstance(proptype, hyperdb.Multilink):
-                value = extractFormList(value)
-            else:
-                # multiple values are not OK
-                if isinstance(value, type([])):
-                    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:
-                    # nope, pull out the value and strip it
-                    value = value.value.strip()
-
-            # now that we have the props field, we need a teensy little
-            # extra bit of help for the old :note field...
-            if d['note'] and value:
-                props['author'] = self.db.getuid()
-                props['date'] = date.Date()
-
-            # handle by type now
-            if isinstance(proptype, hyperdb.Password):
-                if not value:
-                    # ignore empty password values
-                    continue
-                for key, d in matches:
-                    if d['confirm'] and d['propname'] == propname:
-                        confirm = form[key]
-                        break
-                else:
-                    raise FormError, 'Password and confirmation text do '\
-                        'not match'
-                if isinstance(confirm, type([])):
-                    raise FormError, 'You have submitted more than one value'\
-                        ' for the %s property'%propname
-                if value != confirm.value:
-                    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 FormError, _('property "%(propname)s": '
-                                '%(value)s not a %(classname)s')%{
-                                'propname': propname, 'value': value,
-                                'classname': link}
-                        except TypeError, message:
-                            raise FormError, _('you may only enter ID values '
-                                'for property "%(propname)s": %(message)s')%{
-                                'propname': propname, 'message': message}
-            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 FormError, _('property "%(propname)s": '
-                                '"%(value)s" not an entry of %(classname)s')%{
-                                'propname': propname, 'value': entry,
-                                'classname': link}
-                        except TypeError, message:
-                            raise FormError, _('you may only enter ID values '
-                                'for property "%(propname)s": %(message)s')%{
-                                'propname': propname, 'message': message}
-                    l.append(entry)
-                l.sort()
-
-                # 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
-                    if props.has_key(propname):
-                        existing = props[propname]
-                    elif nodeid and not nodeid.startswith('-'):
-                        existing = cl.get(nodeid, propname, [])
-                    else:
-                        existing = []
-
-                    # now either remove or add
-                    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 FormError, _('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 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
-                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('-'):
-                try:
-                    existing = cl.get(nodeid, propname)
-                except KeyError:
-                    # this might be a new property for which there is
-                    # 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):
-                    existing.sort()
-
-                # "missing" existing values may not be None
-                if not existing:
-                    if isinstance(proptype, hyperdb.String) and not existing:
-                        # some backends store "missing" Strings as empty strings
-                        existing = None
-                    elif isinstance(proptype, hyperdb.Number) and not existing:
-                        # some backends store "missing" Numbers as 0 :(
-                        existing = 0
-                    elif isinstance(proptype, hyperdb.Boolean) and not existing:
-                        # likewise Booleans
-                        existing = 0
-
-                # if changed, set it
-                if value != existing:
-                    props[propname] = value
-            else:
-                # don't bother setting empty/unset values
-                if value is None:
-                    continue
-                elif isinstance(proptype, hyperdb.Multilink) and value == []:
-                    continue
-                elif isinstance(proptype, hyperdb.String) and value == '':
-                    continue
-
-                props[propname] = value
-
-        # 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')]))
-
-        # 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:
-                p = 'property'
-            s.append('Required %s %s %s not supplied'%(thing[0], p,
-                ', '.join(required)))
-        if 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
-        # 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():
-            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):
-    ''' 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.
-
-        It may be one of:
-         [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
-         MiniFieldStorage('value,value,...')
-         MiniFieldStorage('value')
-    '''
-    # multiple values are OK
-    if isinstance(value, type([])):
-        # 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
-        values = value.value
-
-    value = [i.strip() for i in values.split(',')]
-
-    # filter out the empty bits
-    return filter(None, value)
-
+# vim: set et sts=4 sw=4 :