From 2550432a24423f5f9b38085a6a9c256cfeebfd19 Mon Sep 17 00:00:00 2001 From: richard Date: Fri, 5 Oct 2001 02:23:24 +0000 Subject: [PATCH] . roundup-admin create now prompts for property info if none is supplied on the command-line. . hyperdb Class getprops() method may now return only the mutable properties. . Login now uses cookies, which makes it a whole lot more flexible. We can now support anonymous user access (read-only, unless there's an "anonymous" user, in which case write access is permitted). Login handling has been moved into cgi_client.Client.main() . The "extended" schema is now the default in roundup init. . The schemas have had their page headings modified to cope with the new login handling. Existing installations should copy the interfaces.py file from the roundup lib directory to their instance home. . Incorrectly had a Bizar Software copyright on the cgitb.py module from Ping - has been removed. . Fixed a whole bunch of places in the CGI interface where we should have been returning Not Found instead of throwing an exception. . Fixed a deviation from the spec: trying to modify the 'id' property of an item now throws an exception. git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@272 57a73879-2fb5-44c3-a270-3262357dd7e2 --- CHANGES.txt | 25 ++- cgi-bin/roundup.cgi | 94 ++++------ roundup-admin | 14 +- roundup-server | 54 ++---- roundup/cgi_client.py | 211 +++++++++++++++++++++-- roundup/hyperdb.py | 12 +- roundup/mailgw.py | 7 +- roundup/templates/extended/htmlbase.py | 2 +- roundup/templates/extended/interfaces.py | 42 +++-- 9 files changed, 332 insertions(+), 129 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 49159f5..29abac0 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -2,11 +2,34 @@ This file contains the changes to the Roundup system over time. The entries are given with the most recent entry first. 2001-??-?? - 0.2.9 +Feature: + . roundup-admin create now prompts for property info if none is supplied + on the command-line. + . hyperdb Class getprops() method may now return only the mutable + properties. + . CGI interfaces now generate a top-level index of their known instances. + +Changed: + . Login now uses cookies, which makes it a whole lot more flexible. We can + now support anonymous user access (read-only, unless there's an + "anonymous" user, in which case write access is permitted). Login + handling has been moved into cgi_client.Client.main() + . The "extended" schema is now the default in roundup init. + . The schemas have had their page headings modified to cope with the new + login handling. Existing installations should copy the interfaces.py + file from the roundup lib directory to their instance home. + Fixed: + . Incorrectly had a Bizar Software copyright on the cgitb.py module from + Ping - has been removed. . Pretty time interval wasn't handling > 1 month properly. . Generation of links to Link/Multilink in indexes. (thanks Hubert Hoegl) . AssignedTo wasn't in the "classic" schema's item page. - + . Fixed a whole bunch of places in the CGI interface where we should have + been returning Not Found instead of throwing an exception. + . Fixed a deviation from the spec: trying to modify the 'id' property of + an item now throws an exception. + . The plain() template function now html-escapes the content. 2001-08-30 - 0.2.8 Fixed: diff --git a/cgi-bin/roundup.cgi b/cgi-bin/roundup.cgi index 7095825..5a5f728 100755 --- a/cgi-bin/roundup.cgi +++ b/cgi-bin/roundup.cgi @@ -16,7 +16,7 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: roundup.cgi,v 1.12 2001-10-01 05:55:41 richard Exp $ +# $Id: roundup.cgi,v 1.13 2001-10-05 02:23:24 richard Exp $ # python version check import sys @@ -59,73 +59,46 @@ except: traceback.print_exc(None, s) print cgi.escape(s.getvalue()), "" -def main(instance, out): - from roundup import cgi_client - db = instance.open('admin') - auth = os.environ.get("HTTP_CGI_AUTHORIZATION", None) - message = 'Unauthorised' - if auth: - import binascii - l = binascii.a2b_base64(auth.split(' ')[1]).split(':') - user = l[0] - password = None - if len(l) > 1: - password = l[1] - try: - uid = db.user.lookup(user) - except KeyError: - auth = None - message = 'Username not recognised' - else: - if password != db.user.get(uid, 'password'): - message = 'Incorrect password' - auth = None - if not auth: - out.write('Content-Type: text/html\n') - out.write('Status: 401\n') - out.write('WWW-Authenticate: basic realm="Roundup"\n\n') - keys = os.environ.keys() - keys.sort() - out.write(message) - return - client = instance.Client(out, db, os.environ, user) - try: - client.main() - except cgi_client.Unauthorised: - out.write('Content-Type: text/html\n') - out.write('Status: 403\n\n') - out.write('Unauthorised') - -def index(out): - ''' Print up an index of the available instances - ''' - import urllib - w = out.write - w("Content-Type: text/html\n\n") - w('Roundup instances index\n') - w('

Roundup instances index

    \n') - for instance in ROUNDUP_INSTANCE_HOMES.keys(): - w('
  1. %s\n'%(urllib.quote(instance), - instance)) - w('
') - -# -# Now do the actual CGI handling -# -out, err = sys.stdout, sys.stderr -try: - sys.stdout = sys.stderr = LOG +def main(out, err): import os, string import roundup.instance path = string.split(os.environ['PATH_INFO'], '/') instance = path[1] + os.environ['INSTANCE_NAME'] = instance os.environ['PATH_INFO'] = string.join(path[2:], '/') if ROUNDUP_INSTANCE_HOMES.has_key(instance): instance_home = ROUNDUP_INSTANCE_HOMES[instance] instance = roundup.instance.open(instance_home) - main(instance, out) + from roundup import cgi_client + client = instance.Client(instance, out, os.environ) + try: + client.main() + except cgi_client.Unauthorised: + out.write('Content-Type: text/html\n') + out.write('Status: 403\n\n') + out.write('Unauthorised') + except cgi_client.NotFound: + out.write('Content-Type: text/html\n') + out.write('Status: 404\n\n') + out.write('Not found: %s'%client.path) else: - index(out) + import urllib + w = out.write + w("Content-Type: text/html\n\n") + w('Roundup instances index\n') + w('

Roundup instances index

    \n') + for instance in ROUNDUP_INSTANCE_HOMES.keys(): + w('
  1. %s\n'%(urllib.quote(instance), + instance)) + w('
') + +# +# Now do the actual CGI handling +# +out, err = sys.stdout, sys.stderr +try: + sys.stdout = sys.stderr = LOG + main(out, err) except: sys.stdout, sys.stderr = out, err out.write('Content-Type: text/html\n\n') @@ -135,6 +108,9 @@ sys.stdout, sys.stderr = out, err # # $Log: not supported by cvs2svn $ +# Revision 1.12 2001/10/01 05:55:41 richard +# Fixes to the top-level index +# # Revision 1.11 2001/09/29 13:27:00 richard # CGI interfaces now spit up a top-level index of all the instances they can # serve. diff --git a/roundup-admin b/roundup-admin index e8082a4..86bc4ae 100755 --- a/roundup-admin +++ b/roundup-admin @@ -16,7 +16,7 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: roundup-admin,v 1.20 2001-10-04 02:12:42 richard Exp $ +# $Id: roundup-admin,v 1.21 2001-10-05 02:23:24 richard Exp $ import sys if int(sys.version[0]) < 2: @@ -119,9 +119,9 @@ def do_init(instance_home, args): if template not in templates: print 'Templates:', ', '.join(templates) while template not in templates: - template = raw_input('Select template [classic]: ').strip() + template = raw_input('Select template [extended]: ').strip() if not template: - template = 'classic' + template = 'extended' import roundup.backends backends = roundup.backends.__all__ @@ -449,6 +449,14 @@ if __name__ == '__main__': # # $Log: not supported by cvs2svn $ +# Revision 1.20 2001/10/04 02:12:42 richard +# Added nicer command-line item adding: passing no arguments will enter an +# interactive more which asks for each property in turn. While I was at it, I +# fixed an implementation problem WRT the spec - I wasn't raising a +# ValueError if the key property was missing from a create(). Also added a +# protected=boolean argument to getprops() so we can list only the mutable +# properties (defaults to yes, which lists the immutables). +# # Revision 1.19 2001/10/01 06:40:43 richard # made do_get have the args in the correct order # diff --git a/roundup-server b/roundup-server index fb905d1..3c6c9e5 100755 --- a/roundup-server +++ b/roundup-server @@ -20,7 +20,7 @@ Based on CGIHTTPServer in the Python library. -$Id: roundup-server,v 1.12 2001-09-29 13:27:00 richard Exp $ +$Id: roundup-server,v 1.13 2001-10-05 02:23:24 richard Exp $ """ import sys @@ -75,10 +75,12 @@ class RoundupRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): sys.stdin = self.rfile try: self.inner_run_cgi() + except cgi_client.NotFound: + self.send_error(404, self.path) except cgi_client.Unauthorised: self.wfile.write('Content-Type: text/html\n') - self.wfile.write('Status: 403\n') - self.wfile.write('Unauthorised') + self.wfile.write('Status: 403\n\n') + self.wfile.write('You are not authorised to access this URL.') except: try: reload(cgitb) @@ -121,12 +123,12 @@ class RoundupRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): if rest == '/': return self.index() l_path = string.split(rest, '/') - instance = urllib.unquote(l_path[1]) - if self.ROUNDUP_INSTANCE_HOMES.has_key(instance): - instance_home = self.ROUNDUP_INSTANCE_HOMES[instance] + instance_name = urllib.unquote(l_path[1]) + if self.ROUNDUP_INSTANCE_HOMES.has_key(instance_name): + instance_home = self.ROUNDUP_INSTANCE_HOMES[instance_name] instance = roundup.instance.open(instance_home) else: - return self.index() + raise cgi_client.NotFound # figure out what the rest of the path is if len(l_path) > 2: @@ -136,6 +138,7 @@ class RoundupRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): # Set up the CGI environment env = {} + env['INSTANCE_NAME'] = instance_name env['REQUEST_METHOD'] = self.command env['PATH_INFO'] = urllib.unquote(rest) if query: @@ -176,41 +179,12 @@ class RoundupRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): #finally: # del sys.path[0] - # initialise the roundupdb, check for auth - db = instance.open('admin') - message = 'Unauthorised' - auth = self.headers.getheader('authorization') - if auth: - l = binascii.a2b_base64(auth.split(' ')[1]).split(':') - user = l[0] - password = None - if len(l) > 1: - password = l[1] - try: - uid = db.user.lookup(user) - except KeyError: - auth = None - message = 'Username not recognised' - else: - if password != db.user.get(uid, 'password'): - message = 'Incorrect password' - auth = None - db.close() - del db - if not auth: - self.send_response(401) - self.send_header('Content-Type', 'text/html') - self.send_header('WWW-Authenticate', 'basic realm="Roundup"') - self.end_headers() - self.wfile.write(message) - return - self.send_response(200, "Script output follows") # do the roundup thang - db = instance.open(user) - client = instance.Client(self.wfile, db, env, user) + client = instance.Client(instance, self.wfile, env) client.main() + do_POST = run_cgi nobody = None @@ -282,6 +256,10 @@ if __name__ == '__main__': # # $Log: not supported by cvs2svn $ +# Revision 1.12 2001/09/29 13:27:00 richard +# CGI interfaces now spit up a top-level index of all the instances they can +# serve. +# # Revision 1.11 2001/08/07 00:24:42 richard # stupid typo # diff --git a/roundup/cgi_client.py b/roundup/cgi_client.py index 1cb3113..33d2381 100644 --- a/roundup/cgi_client.py +++ b/roundup/cgi_client.py @@ -15,21 +15,37 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: cgi_client.py,v 1.26 2001-09-12 08:31:42 richard Exp $ +# $Id: cgi_client.py,v 1.27 2001-10-05 02:23:24 richard Exp $ import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes +import base64, Cookie, time import roundupdb, htmltemplate, date, hyperdb class Unauthorised(ValueError): pass +class NotFound(ValueError): + pass + class Client: - def __init__(self, out, db, env, user): + ''' + + A note about login + ------------------ + + If the user has no login cookie, then they are anonymous. There + are two levels of anonymous use. If there is no 'anonymous' user, there + is no login at all and the database is opened in read-only mode. If the + 'anonymous' user exists, the user is logged in using that user (though + there is no cookie). This allows them to modify the database, and all + modifications are attributed to the 'anonymous' user. + ''' + + def __init__(self, instance, out, env): + self.instance = instance self.out = out - self.db = db self.env = env - self.user = user self.path = env['PATH_INFO'] self.split_path = self.path.split('/') @@ -60,7 +76,11 @@ class Client: else: message = '' style = open(os.path.join(self.TEMPLATES, 'style.css')).read() - userid = self.db.user.lookup(self.user) + if self.user is not None: + userid = self.db.user.lookup(self.user) + user_info = '(login: %s)'%(userid, self.user) + else: + user_info = '' self.write(''' %s @@ -68,10 +88,9 @@ class Client: %s - +
%s -(login: %s)
%s %s
-'''%(title, style, message, title, userid, self.user)) +'''%(title, style, message, title, user_info)) def pagefoot(self): if self.debug: @@ -122,6 +141,7 @@ class Client: filterspec = {} for key in self.form.keys(): if key[0] == ':': continue + if not props.has_key(key): continue prop = props[key] value = self.form[key] if (isinstance(prop, hyperdb.Link) or @@ -433,7 +453,143 @@ class Client: else: raise Unauthorised - def main(self, dre=re.compile(r'([^\d]+)(\d+)'), nre=re.compile(r'new(\w+)')): + def login(self, message=None): + self.pagehead('Login to roundup', message) + self.write(''' + + + + + + + + + + + +

+

+ + + + + + + + + + + + + + + + + + + +
Existing User Login
Login name:
Password:
New User Registration
marked items are optional...
Name:
Organisation:
E-Mail Address:
Phone:
Preferred Login name:
Password:
Password Again:
+''') + + def login_action(self, message=None): + self.user = self.form['__login_name'].value + password = self.form['__login_password'].value + # make sure the user exists + try: + uid = self.db.user.lookup(self.user) + except KeyError: + name = self.user + self.make_user_anonymous() + return self.login(message='No such user "%s"'%name) + + # and that the password is correct + if password != self.db.user.get(uid, 'password'): + return self.login(message='Incorrect password') + + # construct the cookie + uid = self.db.user.lookup(self.user) + user = base64.encodestring('%s:%s'%(self.user, password))[:-1] + path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'], + '')) + cookie = Cookie.SmartCookie() + cookie['roundup_user'] = user + cookie['roundup_user']['path'] = path + self.header({'Set-Cookie': str(cookie)}) + return self.index() + + def make_user_anonymous(self): + # make us anonymous if we can + try: + self.db.user.lookup('anonymous') + self.user = 'anonymous' + except KeyError: + self.user = None + + def logout(self, message=None): + self.make_user_anonymous() + # construct the logout cookie + path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'], + '')) + cookie = Cookie.SmartCookie() + cookie['roundup_user'] = 'deleted' + cookie['roundup_user']['path'] = path + cookie['roundup_user']['expires'] = 0 + cookie['roundup_user']['max-age'] = 0 + self.header({'Set-Cookie': str(cookie)}) + return self.index() + + def newuser_action(self, message=None): + ''' create a new user based on the contents of the form and then + set the cookie + ''' + # TODO: pre-check the required fields and username key property + cl = self.db.classes['user'] + props, dummy = parsePropsFromForm(cl, self.form) + uid = cl.create(**props) + self.user = self.db.user.get(uid, 'username') + password = self.db.user.get(uid, 'password') + # construct the cookie + uid = self.db.user.lookup(self.user) + user = base64.encodestring('%s:%s'%(self.user, password))[:-1] + path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'], + '')) + cookie = Cookie.SmartCookie() + cookie['roundup_user'] = user + cookie['roundup_user']['path'] = path + self.header({'Set-Cookie': str(cookie)}) + return self.index() + + def main(self, dre=re.compile(r'([^\d]+)(\d+)'), + nre=re.compile(r'new(\w+)')): + + # determine the uid to use + self.db = self.instance.open('admin') + cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', '')) + user = 'anonymous' + if (cookie.has_key('roundup_user') and + cookie['roundup_user'].value != 'deleted'): + cookie = cookie['roundup_user'].value + user, password = base64.decodestring(cookie).split(':') + # make sure the user exists + try: + uid = self.db.user.lookup(user) + # now validate the password + if password != self.db.user.get(uid, 'password'): + user = 'anonymous' + except KeyError: + user = 'anonymous' + + # make sure the anonymous user is valid if we're using it + if user == 'anonymous': + self.make_user_anonymous() + else: + self.user = user + self.db.close() + + # re-open the database for real, using the user + self.db = self.instance.open(self.user) + + # now figure which function to call path = self.split_path if not path or path[0] in ('', 'index'): self.index() @@ -441,18 +597,48 @@ class Client: if path[0] == 'list_classes': self.classes() return + if path[0] == 'login': + self.login() + return + if path[0] == 'login_action': + self.login_action() + return + if path[0] == 'newuser_action': + self.newuser_action() + return + if path[0] == 'logout': + self.logout() + return m = dre.match(path[0]) if m: self.classname = m.group(1) self.nodeid = m.group(2) - getattr(self, 'show%s'%self.classname)() + try: + cl = self.db.classes[self.classname] + except KeyError: + raise NotFound + try: + cl.get(self.nodeid, 'id') + except IndexError: + raise NotFound + try: + getattr(self, 'show%s'%self.classname)() + except AttributeError: + raise NotFound return m = nre.match(path[0]) if m: self.classname = m.group(1) - getattr(self, 'new%s'%self.classname)() + try: + getattr(self, 'new%s'%self.classname)() + except AttributeError: + raise NotFound return self.classname = path[0] + try: + self.db.getclass(self.classname) + except KeyError: + raise NotFound self.list() else: raise 'ValueError', 'Path not understood' @@ -515,6 +701,9 @@ def parsePropsFromForm(cl, form, nodeid=0): # # $Log: not supported by cvs2svn $ +# Revision 1.26 2001/09/12 08:31:42 richard +# handle cases where mime type is not guessable +# # Revision 1.25 2001/08/29 05:30:49 richard # change messages weren't being saved when there was no-one on the nosy list. # diff --git a/roundup/hyperdb.py b/roundup/hyperdb.py index 367abac..32729f1 100644 --- a/roundup/hyperdb.py +++ b/roundup/hyperdb.py @@ -15,7 +15,7 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: hyperdb.py,v 1.20 2001-10-04 02:12:42 richard Exp $ +# $Id: hyperdb.py,v 1.21 2001-10-05 02:23:24 richard Exp $ # standard python modules import cPickle, re, string @@ -219,9 +219,9 @@ class Class: IndexError is raised. 'propname' must be the name of a property of this class or a KeyError is raised. """ + d = self.db.getnode(self.classname, nodeid) if propname == 'id': return nodeid - d = self.db.getnode(self.classname, nodeid) if not d.has_key(propname) and default is not _marker: return default return d[propname] @@ -800,6 +800,14 @@ def Choice(name, *options): # # $Log: not supported by cvs2svn $ +# Revision 1.20 2001/10/04 02:12:42 richard +# Added nicer command-line item adding: passing no arguments will enter an +# interactive more which asks for each property in turn. While I was at it, I +# fixed an implementation problem WRT the spec - I wasn't raising a +# ValueError if the key property was missing from a create(). Also added a +# protected=boolean argument to getprops() so we can list only the mutable +# properties (defaults to yes, which lists the immutables). +# # Revision 1.19 2001/08/29 04:47:18 richard # Fixed CGI client change messages so they actually include the properties # changed (again). diff --git a/roundup/mailgw.py b/roundup/mailgw.py index 19151b2..9ce0593 100644 --- a/roundup/mailgw.py +++ b/roundup/mailgw.py @@ -72,7 +72,7 @@ are calling the create() method to create a new node). If an auditor raises an exception, the original message is bounced back to the sender with the explanatory message given in the exception. -$Id: mailgw.py,v 1.15 2001-08-30 06:01:17 richard Exp $ +$Id: mailgw.py,v 1.16 2001-10-05 02:23:24 richard Exp $ ''' @@ -230,7 +230,9 @@ Subject was: "%s" elif isinstance(type, hyperdb.Multilink): props[key] = value.split(',') + # # handle the users + # author = self.db.uidFromAddress(message.getaddrlist('from')[0]) recipients = [] for recipient in message.getaddrlist('to') + message.getaddrlist('cc'): @@ -398,6 +400,9 @@ def parseContent(content, blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'), # # $Log: not supported by cvs2svn $ +# Revision 1.15 2001/08/30 06:01:17 richard +# Fixed missing import in mailgw :( +# # Revision 1.14 2001/08/13 23:02:54 richard # Make the mail parser a little more robust. # diff --git a/roundup/templates/extended/htmlbase.py b/roundup/templates/extended/htmlbase.py index bee173e..f32f5b2 100644 --- a/roundup/templates/extended/htmlbase.py +++ b/roundup/templates/extended/htmlbase.py @@ -182,7 +182,7 @@ issueDOTitem = """ +msgDOTindex = """ diff --git a/roundup/templates/extended/interfaces.py b/roundup/templates/extended/interfaces.py index b3db3c7..77fd54f 100644 --- a/roundup/templates/extended/interfaces.py +++ b/roundup/templates/extended/interfaces.py @@ -15,7 +15,7 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: interfaces.py,v 1.9 2001-08-07 00:24:43 richard Exp $ +# $Id: interfaces.py,v 1.10 2001-10-05 02:23:24 richard Exp $ import instance_config, urlparse, os from roundup import cgi_client, mailgw @@ -47,11 +47,29 @@ class Client(cgi_client.Client): else: message = '' style = open(os.path.join(self.TEMPLATES, 'style.css')).read() - userid = self.db.user.lookup(self.user) + user_name = self.user or '' if self.user == 'admin': - extras = ' | Class List' + admin_links = ' | Class List' else: - extras = '' + admin_links = '' + if self.user not in (None, 'anonymous'): + userid = self.db.user.lookup(self.user) + user_info = ''' +My Issues | +My Support | +My Details | Logout +'''%(userid, userid, userid) + else: + user_info = 'Login' + if self.user is not None: + add_links = ''' +| Add +Issue, +Support, +User +''' + else: + add_links = '' self.write(''' %s @@ -68,17 +86,12 @@ class Client(cgi_client.Client): | Unassigned Issues, Support -| Add -Issue, -Support, -User +%s %s - -My Issues | -My Support | -My Details +%s -'''%(title, style, message, title, self.user, extras, userid, userid, userid)) +'''%(title, style, message, title, user_name, add_links, admin_links, + user_info)) class MailGW(mailgw.MailGW): ''' derives basic mail gateway implementation from the standard module, @@ -90,6 +103,9 @@ class MailGW(mailgw.MailGW): # # $Log: not supported by cvs2svn $ +# Revision 1.9 2001/08/07 00:24:43 richard +# stupid typo +# # Revision 1.8 2001/08/07 00:15:51 richard # Added the copyright/license notice to (nearly) all files at request of # Bizar Software. -- 2.30.2