Code

Fix traceback on .../msgN/ url, this requests the file content and for
[roundup.git] / roundup / cgi / client.py
1 """WWW request handler (also used in the stand-alone server).
2 """
3 __docformat__ = 'restructuredtext'
5 import base64, binascii, cgi, codecs, httplib, mimetypes, os
6 import quopri, random, re, rfc822, stat, sys, time, urllib, urlparse
7 import Cookie, socket, errno
8 from Cookie import CookieError, BaseCookie, SimpleCookie
9 from cStringIO import StringIO
11 from roundup import roundupdb, date, hyperdb, password
12 from roundup.cgi import templating, cgitb, TranslationService
13 from roundup.cgi.actions import *
14 from roundup.exceptions import *
15 from roundup.cgi.exceptions import *
16 from roundup.cgi.form_parser import FormParser
17 from roundup.mailer import Mailer, MessageSendError, encode_quopri
18 from roundup.cgi import accept_language
19 from roundup import xmlrpc
21 def initialiseSecurity(security):
22     '''Create some Permissions and Roles on the security object
24     This function is directly invoked by security.Security.__init__()
25     as a part of the Security object instantiation.
26     '''
27     p = security.addPermission(name="Web Access",
28         description="User may access the web interface")
29     security.addPermissionToRole('Admin', p)
31     # doing Role stuff through the web - make sure Admin can
32     # TODO: deprecate this and use a property-based control
33     p = security.addPermission(name="Web Roles",
34         description="User may manipulate user Roles through the web")
35     security.addPermissionToRole('Admin', p)
37 # used to clean messages passed through CGI variables - HTML-escape any tag
38 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
39 # that people can't pass through nasties like <script>, <iframe>, ...
40 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
41 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
42     return mc.sub(clean_message_callback, message)
43 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
44     """ Strip all non <a>,<i>,<b> and <br> tags from a string
45     """
46     if ok.has_key(match.group(3).lower()):
47         return match.group(1)
48     return '&lt;%s&gt;'%match.group(2)
51 error_message = ''"""<html><head><title>An error has occurred</title></head>
52 <body><h1>An error has occurred</h1>
53 <p>A problem was encountered processing your request.
54 The tracker maintainers have been notified of the problem.</p>
55 </body></html>"""
58 class LiberalCookie(SimpleCookie):
59     """ Python's SimpleCookie throws an exception if the cookie uses invalid
60         syntax.  Other applications on the same server may have done precisely
61         this, preventing roundup from working through no fault of roundup.
62         Numerous other python apps have run into the same problem:
64         trac: http://trac.edgewall.org/ticket/2256
65         mailman: http://bugs.python.org/issue472646
67         This particular implementation comes from trac's solution to the
68         problem. Unfortunately it requires some hackery in SimpleCookie's
69         internals to provide a more liberal __set method.
70     """
71     def load(self, rawdata, ignore_parse_errors=True):
72         if ignore_parse_errors:
73             self.bad_cookies = []
74             self._BaseCookie__set = self._loose_set
75         SimpleCookie.load(self, rawdata)
76         if ignore_parse_errors:
77             self._BaseCookie__set = self._strict_set
78             for key in self.bad_cookies:
79                 del self[key]
81     _strict_set = BaseCookie._BaseCookie__set
83     def _loose_set(self, key, real_value, coded_value):
84         try:
85             self._strict_set(key, real_value, coded_value)
86         except CookieError:
87             self.bad_cookies.append(key)
88             dict.__setitem__(self, key, None)
91 class Session:
92     """
93     Needs DB to be already opened by client
95     Session attributes at instantiation:
97     - "client" - reference to client for add_cookie function
98     - "session_db" - session DB manager
99     - "cookie_name" - name of the cookie with session id
100     - "_sid" - session id for current user
101     - "_data" - session data cache
103     session = Session(client)
104     session.set(name=value)
105     value = session.get(name)
107     session.destroy()  # delete current session
108     session.clean_up() # clean up session table
110     session.update(set_cookie=True, expire=3600*24*365)
111                        # refresh session expiration time, setting persistent
112                        # cookie if needed to last for 'expire' seconds
114     """
116     def __init__(self, client):
117         self._data = {}
118         self._sid  = None
120         self.client = client
121         self.session_db = client.db.getSessionManager()
123         # parse cookies for session id
124         self.cookie_name = 'roundup_session_%s' % \
125             re.sub('[^a-zA-Z]', '', client.instance.config.TRACKER_NAME)
126         cookies = LiberalCookie(client.env.get('HTTP_COOKIE', ''))
127         if self.cookie_name in cookies:
128             if not self.session_db.exists(cookies[self.cookie_name].value):
129                 self._sid = None
130                 # remove old cookie
131                 self.client.add_cookie(self.cookie_name, None)
132             else:
133                 self._sid = cookies[self.cookie_name].value
134                 self._data = self.session_db.getall(self._sid)
136     def _gen_sid(self):
137         """ generate a unique session key """
138         while 1:
139             s = '%s%s'%(time.time(), random.random())
140             s = binascii.b2a_base64(s).strip()
141             if not self.session_db.exists(s):
142                 break
144         # clean up the base64
145         if s[-1] == '=':
146             if s[-2] == '=':
147                 s = s[:-2]
148             else:
149                 s = s[:-1]
150         return s
152     def clean_up(self):
153         """Remove expired sessions"""
154         self.session_db.clean()
156     def destroy(self):
157         self.client.add_cookie(self.cookie_name, None)
158         self._data = {}
159         self.session_db.destroy(self._sid)
160         self.client.db.commit()
162     def get(self, name, default=None):
163         return self._data.get(name, default)
165     def set(self, **kwargs):
166         self._data.update(kwargs)
167         if not self._sid:
168             self._sid = self._gen_sid()
169             self.session_db.set(self._sid, **self._data)
170             # add session cookie
171             self.update(set_cookie=True)
173             # XXX added when patching 1.4.4 for backward compatibility
174             # XXX remove
175             self.client.session = self._sid
176         else:
177             self.session_db.set(self._sid, **self._data)
178             self.client.db.commit()
180     def update(self, set_cookie=False, expire=None):
181         """ update timestamp in db to avoid expiration
183             if 'set_cookie' is True, set cookie with 'expire' seconds lifetime
184             if 'expire' is None - session will be closed with the browser
185              
186             XXX the session can be purged within a week even if a cookie
187                 lifetime is longer
188         """
189         self.session_db.updateTimestamp(self._sid)
190         self.client.db.commit()
192         if set_cookie:
193             self.client.add_cookie(self.cookie_name, self._sid, expire=expire)
197 class Client:
198     """Instantiate to handle one CGI request.
200     See inner_main for request processing.
202     Client attributes at instantiation:
204     - "path" is the PATH_INFO inside the instance (with no leading '/')
205     - "base" is the base URL for the instance
206     - "form" is the cgi form, an instance of FieldStorage from the standard
207       cgi module
208     - "additional_headers" is a dictionary of additional HTTP headers that
209       should be sent to the client
210     - "response_code" is the HTTP response code to send to the client
211     - "translator" is TranslationService instance
213     During the processing of a request, the following attributes are used:
215     - "db" 
216     - "error_message" holds a list of error messages
217     - "ok_message" holds a list of OK messages
218     - "session" is deprecated in favor of session_api (XXX remove)
219     - "session_api" is the interface to store data in session
220     - "user" is the current user's name
221     - "userid" is the current user's id
222     - "template" is the current :template context
223     - "classname" is the current class context name
224     - "nodeid" is the current context item id
226     User Identification:
227      Users that are absent in session data are anonymous and are logged
228      in as that user. This typically gives them all Permissions assigned to the
229      Anonymous Role.
231      Every user is assigned a session. "session_api" is the interface to work
232      with session data.
234     Special form variables:
235      Note that in various places throughout this code, special form
236      variables of the form :<name> are used. The colon (":") part may
237      actually be one of either ":" or "@".
238     """
240     # charset used for data storage and form templates
241     # Note: must be in lower case for comparisons!
242     # XXX take this from instance.config?
243     STORAGE_CHARSET = 'utf-8'
245     #
246     # special form variables
247     #
248     FV_TEMPLATE = re.compile(r'[@:]template')
249     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
250     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
252     # Note: index page stuff doesn't appear here:
253     # columns, sort, sortdir, filter, group, groupdir, search_text,
254     # pagesize, startwith
256     # list of network error codes that shouldn't be reported to tracker admin
257     # (error descriptions from FreeBSD intro(2))
258     IGNORE_NET_ERRORS = (
259         # A write on a pipe, socket or FIFO for which there is
260         # no process to read the data.
261         errno.EPIPE,
262         # A connection was forcibly closed by a peer.
263         # This normally results from a loss of the connection
264         # on the remote socket due to a timeout or a reboot.
265         errno.ECONNRESET,
266         # Software caused connection abort.  A connection abort
267         # was caused internal to your host machine.
268         errno.ECONNABORTED,
269         # A connect or send request failed because the connected party
270         # did not properly respond after a period of time.
271         errno.ETIMEDOUT,
272     )
274     def __init__(self, instance, request, env, form=None, translator=None):
275         # re-seed the random number generator
276         random.seed()
277         self.start = time.time()
278         self.instance = instance
279         self.request = request
280         self.env = env
281         self.setTranslator(translator)
282         self.mailer = Mailer(instance.config)
284         # save off the path
285         self.path = env['PATH_INFO']
287         # this is the base URL for this tracker
288         self.base = self.instance.config.TRACKER_WEB
290         # check the tracker_we setting
291         if not self.base.endswith('/'):
292             self.base = self.base + '/'
294         # this is the "cookie path" for this tracker (ie. the path part of
295         # the "base" url)
296         self.cookie_path = urlparse.urlparse(self.base)[2]
297         # cookies to set in http responce
298         # {(path, name): (value, expire)}
299         self._cookies = {}
301         # see if we need to re-parse the environment for the form (eg Zope)
302         if form is None:
303             self.form = cgi.FieldStorage(environ=env)
304         else:
305             self.form = form
307         # turn debugging on/off
308         try:
309             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
310         except ValueError:
311             # someone gave us a non-int debug level, turn it off
312             self.debug = 0
314         # flag to indicate that the HTTP headers have been sent
315         self.headers_done = 0
317         # additional headers to send with the request - must be registered
318         # before the first write
319         self.additional_headers = {}
320         self.response_code = 200
322         # default character set
323         self.charset = self.STORAGE_CHARSET
325         # parse cookies (used for charset lookups)
326         # use our own LiberalCookie to handle bad apps on the same
327         # server that have set cookies that are out of spec
328         self.cookie = LiberalCookie(self.env.get('HTTP_COOKIE', ''))
330         self.user = None
331         self.userid = None
332         self.nodeid = None
333         self.classname = None
334         self.template = None
336     def setTranslator(self, translator=None):
337         """Replace the translation engine
339         'translator'
340            is TranslationService instance.
341            It must define methods 'translate' (TAL-compatible i18n),
342            'gettext' and 'ngettext' (gettext-compatible i18n).
344            If omitted, create default TranslationService.
345         """
346         if translator is None:
347             translator = TranslationService.get_translation(
348                 language=self.instance.config["TRACKER_LANGUAGE"],
349                 tracker_home=self.instance.config["TRACKER_HOME"])
350         self.translator = translator
351         self._ = self.gettext = translator.gettext
352         self.ngettext = translator.ngettext
354     def main(self):
355         """ Wrap the real main in a try/finally so we always close off the db.
356         """
357         try:
358             if self.env.get('CONTENT_TYPE') == 'text/xml':
359                 self.handle_xmlrpc()
360             else:
361                 self.inner_main()
362         finally:
363             if hasattr(self, 'db'):
364                 self.db.close()
367     def handle_xmlrpc(self):
369         # Pull the raw XML out of the form.  The "value" attribute
370         # will be the raw content of the POST request.
371         assert self.form.file
372         input = self.form.value
373         # So that the rest of Roundup can query the form in the
374         # usual way, we create an empty list of fields.
375         self.form.list = []
377         # Set the charset and language, since other parts of
378         # Roundup may depend upon that.
379         self.determine_charset()
380         self.determine_language()
381         # Open the database as the correct user.
382         self.determine_user()
384         # Call the appropriate XML-RPC method.
385         handler = xmlrpc.RoundupDispatcher(self.db,
386                                            self.instance.actions,
387                                            self.translator,
388                                            allow_none=True)
389         output = handler.dispatch(input)
391         self.setHeader("Content-Type", "text/xml")
392         self.setHeader("Content-Length", str(len(output)))
393         self.write(output)
394         
395     def inner_main(self):
396         """Process a request.
398         The most common requests are handled like so:
400         1. look for charset and language preferences, set up user locale
401            see determine_charset, determine_language
402         2. figure out who we are, defaulting to the "anonymous" user
403            see determine_user
404         3. figure out what the request is for - the context
405            see determine_context
406         4. handle any requested action (item edit, search, ...)
407            see handle_action
408         5. render a template, resulting in HTML output
410         In some situations, exceptions occur:
412         - HTTP Redirect  (generally raised by an action)
413         - SendFile       (generally raised by determine_context)
414           serve up a FileClass "content" property
415         - SendStaticFile (generally raised by determine_context)
416           serve up a file from the tracker "html" directory
417         - Unauthorised   (generally raised by an action)
418           the action is cancelled, the request is rendered and an error
419           message is displayed indicating that permission was not
420           granted for the action to take place
421         - templating.Unauthorised   (templating action not permitted)
422           raised by an attempted rendering of a template when the user
423           doesn't have permission
424         - NotFound       (raised wherever it needs to be)
425           percolates up to the CGI interface that called the client
426         """
427         self.ok_message = []
428         self.error_message = []
429         try:
430             self.determine_charset()
431             self.determine_language()
433             try:
434                 # make sure we're identified (even anonymously)
435                 self.determine_user()
437                 # figure out the context and desired content template
438                 self.determine_context()
440                 # possibly handle a form submit action (may change self.classname
441                 # and self.template, and may also append error/ok_messages)
442                 html = self.handle_action()
444                 if html:
445                     self.write_html(html)
446                     return
448                 # now render the page
449                 # we don't want clients caching our dynamic pages
450                 self.additional_headers['Cache-Control'] = 'no-cache'
451                 # Pragma: no-cache makes Mozilla and its ilk
452                 # double-load all pages!!
453                 #            self.additional_headers['Pragma'] = 'no-cache'
455                 # pages with messages added expire right now
456                 # simple views may be cached for a small amount of time
457                 # TODO? make page expire time configurable
458                 # <rj> always expire pages, as IE just doesn't seem to do the
459                 # right thing here :(
460                 date = time.time() - 1
461                 #if self.error_message or self.ok_message:
462                 #    date = time.time() - 1
463                 #else:
464                 #    date = time.time() + 5
465                 self.additional_headers['Expires'] = rfc822.formatdate(date)
467                 # render the content
468                 self.write_html(self.renderContext())
469             except SendFile, designator:
470                 # The call to serve_file may result in an Unauthorised
471                 # exception or a NotModified exception.  Those
472                 # exceptions will be handled by the outermost set of
473                 # exception handlers.
474                 self.serve_file(designator)
475             except SendStaticFile, file:
476                 self.serve_static_file(str(file))
477             except IOError:
478                 # IOErrors here are due to the client disconnecting before
479                 # recieving the reply.
480                 pass
482         except SeriousError, message:
483             self.write_html(str(message))
484         except Redirect, url:
485             # let's redirect - if the url isn't None, then we need to do
486             # the headers, otherwise the headers have been set before the
487             # exception was raised
488             if url:
489                 self.additional_headers['Location'] = str(url)
490                 self.response_code = 302
491             self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
492         except LoginError, message:
493             # The user tried to log in, but did not provide a valid
494             # username and password.  If we support HTTP
495             # authorization, send back a response that will cause the
496             # browser to prompt the user again.
497             if self.instance.config.WEB_HTTP_AUTH:
498                 self.response_code = httplib.UNAUTHORIZED
499                 realm = self.instance.config.TRACKER_NAME
500                 self.setHeader("WWW-Authenticate",
501                                "Basic realm=\"%s\"" % realm)
502             else:
503                 self.response_code = httplib.FORBIDDEN
504             self.renderFrontPage(message)
505         except Unauthorised, message:
506             # users may always see the front page
507             self.response_code = 403
508             self.renderFrontPage(message)
509         except NotModified:
510             # send the 304 response
511             self.response_code = 304
512             self.header()
513         except NotFound, e:
514             self.response_code = 404
515             self.template = '404'
516             try:
517                 cl = self.db.getclass(self.classname)
518                 self.write_html(self.renderContext())
519             except KeyError:
520                 # we can't map the URL to a class we know about
521                 # reraise the NotFound and let roundup_server
522                 # handle it
523                 raise NotFound, e
524         except FormError, e:
525             self.error_message.append(self._('Form Error: ') + str(e))
526             self.write_html(self.renderContext())
527         except:
528             # Something has gone badly wrong.  Therefore, we should
529             # make sure that the response code indicates failure.
530             if self.response_code == httplib.OK:
531                 self.response_code = httplib.INTERNAL_SERVER_ERROR
532             # Help the administrator work out what went wrong.
533             html = ("<h1>Traceback</h1>"
534                     + cgitb.html(i18n=self.translator)
535                     + ("<h1>Environment Variables</h1><table>%s</table>"
536                        % cgitb.niceDict("", self.env)))
537             if not self.instance.config.WEB_DEBUG:
538                 exc_info = sys.exc_info()
539                 subject = "Error: %s" % exc_info[1]
540                 self.send_html_to_admin(subject, html)
541                 self.write_html(self._(error_message))
542             else:
543                 self.write_html(html)
545     def clean_sessions(self):
546         """Deprecated
547            XXX remove
548         """
549         self.clean_up()
551     def clean_up(self):
552         """Remove expired sessions and One Time Keys.
554            Do it only once an hour.
555         """
556         hour = 60*60
557         now = time.time()
559         # XXX: hack - use OTK table to store last_clean time information
560         #      'last_clean' string is used instead of otk key
561         last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
562         if now - last_clean < hour:
563             return
565         self.session_api.clean_up()
566         self.db.getOTKManager().clean()
567         self.db.getOTKManager().set('last_clean', last_use=now)
568         self.db.commit(fail_ok=True)
570     def determine_charset(self):
571         """Look for client charset in the form parameters or browser cookie.
573         If no charset requested by client, use storage charset (utf-8).
575         If the charset is found, and differs from the storage charset,
576         recode all form fields of type 'text/plain'
577         """
578         # look for client charset
579         charset_parameter = 0
580         if self.form.has_key('@charset'):
581             charset = self.form['@charset'].value
582             if charset.lower() == "none":
583                 charset = ""
584             charset_parameter = 1
585         elif self.cookie.has_key('roundup_charset'):
586             charset = self.cookie['roundup_charset'].value
587         else:
588             charset = None
589         if charset:
590             # make sure the charset is recognized
591             try:
592                 codecs.lookup(charset)
593             except LookupError:
594                 self.error_message.append(self._('Unrecognized charset: %r')
595                     % charset)
596                 charset_parameter = 0
597             else:
598                 self.charset = charset.lower()
599         # If we've got a character set in request parameters,
600         # set the browser cookie to keep the preference.
601         # This is done after codecs.lookup to make sure
602         # that we aren't keeping a wrong value.
603         if charset_parameter:
604             self.add_cookie('roundup_charset', charset)
606         # if client charset is different from the storage charset,
607         # recode form fields
608         # XXX this requires FieldStorage from Python library.
609         #   mod_python FieldStorage is not supported!
610         if self.charset != self.STORAGE_CHARSET:
611             decoder = codecs.getdecoder(self.charset)
612             encoder = codecs.getencoder(self.STORAGE_CHARSET)
613             re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
614             def _decode_charref(matchobj):
615                 num = matchobj.group(1)
616                 if num[0].lower() == 'x':
617                     uc = int(num[1:], 16)
618                 else:
619                     uc = int(num)
620                 return unichr(uc)
622             for field_name in self.form.keys():
623                 field = self.form[field_name]
624                 if (field.type == 'text/plain') and not field.filename:
625                     try:
626                         value = decoder(field.value)[0]
627                     except UnicodeError:
628                         continue
629                     value = re_charref.sub(_decode_charref, value)
630                     field.value = encoder(value)[0]
632     def determine_language(self):
633         """Determine the language"""
634         # look for language parameter
635         # then for language cookie
636         # last for the Accept-Language header
637         if self.form.has_key("@language"):
638             language = self.form["@language"].value
639             if language.lower() == "none":
640                 language = ""
641             self.add_cookie("roundup_language", language)
642         elif self.cookie.has_key("roundup_language"):
643             language = self.cookie["roundup_language"].value
644         elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
645             hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
646             language = accept_language.parse(hal)
647         else:
648             language = ""
650         self.language = language
651         if language:
652             self.setTranslator(TranslationService.get_translation(
653                     language,
654                     tracker_home=self.instance.config["TRACKER_HOME"]))
656     def determine_user(self):
657         """Determine who the user is"""
658         self.opendb('admin')
660         # get session data from db
661         # XXX: rename
662         self.session_api = Session(self)
664         # take the opportunity to cleanup expired sessions and otks
665         self.clean_up()
667         user = None
668         # first up, try http authorization if enabled
669         if self.instance.config['WEB_HTTP_AUTH']:
670             if self.env.has_key('REMOTE_USER'):
671                 # we have external auth (e.g. by Apache)
672                 user = self.env['REMOTE_USER']
673             elif self.env.get('HTTP_AUTHORIZATION', ''):
674                 # try handling Basic Auth ourselves
675                 auth = self.env['HTTP_AUTHORIZATION']
676                 scheme, challenge = auth.split(' ', 1)
677                 if scheme.lower() == 'basic':
678                     try:
679                         decoded = base64.decodestring(challenge)
680                     except TypeError:
681                         # invalid challenge
682                         pass
683                     username, password = decoded.split(':')
684                     try:
685                         login = self.get_action_class('login')(self)
686                         login.verifyLogin(username, password)
687                     except LoginError, err:
688                         self.make_user_anonymous()
689                         raise
690                     user = username
692         # if user was not set by http authorization, try session lookup
693         if not user:
694             user = self.session_api.get('user')
695             if user:
696                 # update session lifetime datestamp
697                 self.session_api.update()
699         # if no user name set by http authorization or session lookup
700         # the user is anonymous
701         if not user:
702             user = 'anonymous'
704         # sanity check on the user still being valid,
705         # getting the userid at the same time
706         try:
707             self.userid = self.db.user.lookup(user)
708         except (KeyError, TypeError):
709             user = 'anonymous'
711         # make sure the anonymous user is valid if we're using it
712         if user == 'anonymous':
713             self.make_user_anonymous()
714             if not self.db.security.hasPermission('Web Access', self.userid):
715                 raise Unauthorised, self._("Anonymous users are not "
716                     "allowed to use the web interface")
717         else:
718             self.user = user
720         # reopen the database as the correct user
721         self.opendb(self.user)
723     def opendb(self, username):
724         """Open the database and set the current user.
726         Opens a database once. On subsequent calls only the user is set on
727         the database object the instance.optimize is set. If we are in
728         "Development Mode" (cf. roundup_server) then the database is always
729         re-opened.
730         """
731         # don't do anything if the db is open and the user has not changed
732         if hasattr(self, 'db') and self.db.isCurrentUser(username):
733             return
735         # open the database or only set the user
736         if not hasattr(self, 'db'):
737             self.db = self.instance.open(username)
738         else:
739             if self.instance.optimize:
740                 self.db.setCurrentUser(username)
741             else:
742                 self.db.close()
743                 self.db = self.instance.open(username)
744                 # The old session API refers to the closed database;
745                 # we can no longer use it.
746                 self.session_api = Session(self)
747  
749     def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
750         """Determine the context of this page from the URL:
752         The URL path after the instance identifier is examined. The path
753         is generally only one entry long.
755         - if there is no path, then we are in the "home" context.
756         - if the path is "_file", then the additional path entry
757           specifies the filename of a static file we're to serve up
758           from the instance "html" directory. Raises a SendStaticFile
759           exception.(*)
760         - if there is something in the path (eg "issue"), it identifies
761           the tracker class we're to display.
762         - if the path is an item designator (eg "issue123"), then we're
763           to display a specific item.
764         - if the path starts with an item designator and is longer than
765           one entry, then we're assumed to be handling an item of a
766           FileClass, and the extra path information gives the filename
767           that the client is going to label the download with (ie
768           "file123/image.png" is nicer to download than "file123"). This
769           raises a SendFile exception.(*)
771         Both of the "*" types of contexts stop before we bother to
772         determine the template we're going to use. That's because they
773         don't actually use templates.
775         The template used is specified by the :template CGI variable,
776         which defaults to:
778         - only classname suplied:          "index"
779         - full item designator supplied:   "item"
781         We set:
783              self.classname  - the class to display, can be None
785              self.template   - the template to render the current context with
787              self.nodeid     - the nodeid of the class we're displaying
788         """
789         # default the optional variables
790         self.classname = None
791         self.nodeid = None
793         # see if a template or messages are specified
794         template_override = ok_message = error_message = None
795         for key in self.form.keys():
796             if self.FV_TEMPLATE.match(key):
797                 template_override = self.form[key].value
798             elif self.FV_OK_MESSAGE.match(key):
799                 ok_message = self.form[key].value
800                 ok_message = clean_message(ok_message)
801             elif self.FV_ERROR_MESSAGE.match(key):
802                 error_message = self.form[key].value
803                 error_message = clean_message(error_message)
805         # see if we were passed in a message
806         if ok_message:
807             self.ok_message.append(ok_message)
808         if error_message:
809             self.error_message.append(error_message)
811         # determine the classname and possibly nodeid
812         path = self.path.split('/')
813         if not path or path[0] in ('', 'home', 'index'):
814             if template_override is not None:
815                 self.template = template_override
816             else:
817                 self.template = ''
818             return
819         elif path[0] in ('_file', '@@file'):
820             raise SendStaticFile, os.path.join(*path[1:])
821         else:
822             self.classname = path[0]
823             if len(path) > 1:
824                 # send the file identified by the designator in path[0]
825                 raise SendFile, path[0]
827         # see if we got a designator
828         m = dre.match(self.classname)
829         if m:
830             self.classname = m.group(1)
831             self.nodeid = m.group(2)
832             try:
833                 klass = self.db.getclass(self.classname)
834             except KeyError:
835                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
836             if not klass.hasnode(self.nodeid):
837                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
838             # with a designator, we default to item view
839             self.template = 'item'
840         else:
841             # with only a class, we default to index view
842             self.template = 'index'
844         # make sure the classname is valid
845         try:
846             self.db.getclass(self.classname)
847         except KeyError:
848             raise NotFound, self.classname
850         # see if we have a template override
851         if template_override is not None:
852             self.template = template_override
854     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
855         """ Serve the file from the content property of the designated item.
856         """
857         m = dre.match(str(designator))
858         if not m:
859             raise NotFound, str(designator)
860         classname, nodeid = m.group(1), m.group(2)
862         try:
863             klass = self.db.getclass(classname)
864         except KeyError:
865             # The classname was not valid.
866             raise NotFound, str(designator)
867             
869         # make sure we have the appropriate properties
870         props = klass.getprops()
871         if not props.has_key('type'):
872             raise NotFound, designator
873         if not props.has_key('content'):
874             raise NotFound, designator
876         # make sure we have permission
877         if not self.db.security.hasPermission('View', self.userid,
878                 classname, 'content', nodeid):
879             raise Unauthorised, self._("You are not allowed to view "
880                 "this file.")
882         mime_type = klass.get(nodeid, 'type')
883         # Can happen for msg class:
884         if not mime_type:
885             mime_type = 'text/plain'
887         # if the mime_type is HTML-ish then make sure we're allowed to serve up
888         # HTML-ish content
889         if mime_type in ('text/html', 'text/x-html'):
890             if not self.instance.config['WEB_ALLOW_HTML_FILE']:
891                 # do NOT serve the content up as HTML
892                 mime_type = 'application/octet-stream'
894         # If this object is a file (i.e., an instance of FileClass),
895         # see if we can find it in the filesystem.  If so, we may be
896         # able to use the more-efficient request.sendfile method of
897         # sending the file.  If not, just get the "content" property
898         # in the usual way, and use that.
899         content = None
900         filename = None
901         if isinstance(klass, hyperdb.FileClass):
902             try:
903                 filename = self.db.filename(classname, nodeid)
904             except AttributeError:
905                 # The database doesn't store files in the filesystem
906                 # and therefore doesn't provide the "filename" method.
907                 pass
908             except IOError:
909                 # The file does not exist.
910                 pass
911         if not filename:
912             content = klass.get(nodeid, 'content')
913         
914         lmt = klass.get(nodeid, 'activity').timestamp()
916         self._serve_file(lmt, mime_type, content, filename)
918     def serve_static_file(self, file):
919         """ Serve up the file named from the templates dir
920         """
921         # figure the filename - try STATIC_FILES, then TEMPLATES dir
922         for dir_option in ('STATIC_FILES', 'TEMPLATES'):
923             prefix = self.instance.config[dir_option]
924             if not prefix:
925                 continue
926             # ensure the load doesn't try to poke outside
927             # of the static files directory
928             prefix = os.path.normpath(prefix)
929             filename = os.path.normpath(os.path.join(prefix, file))
930             if os.path.isfile(filename) and filename.startswith(prefix):
931                 break
932         else:
933             raise NotFound, file
935         # last-modified time
936         lmt = os.stat(filename)[stat.ST_MTIME]
938         # detemine meta-type
939         file = str(file)
940         mime_type = mimetypes.guess_type(file)[0]
941         if not mime_type:
942             if file.endswith('.css'):
943                 mime_type = 'text/css'
944             else:
945                 mime_type = 'text/plain'
947         self._serve_file(lmt, mime_type, '', filename)
949     def _serve_file(self, lmt, mime_type, content=None, filename=None):
950         """ guts of serve_file() and serve_static_file()
951         """
953         # spit out headers
954         self.additional_headers['Content-Type'] = mime_type
955         self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
957         ims = None
958         # see if there's an if-modified-since...
959         # XXX see which interfaces set this
960         #if hasattr(self.request, 'headers'):
961             #ims = self.request.headers.getheader('if-modified-since')
962         if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
963             # cgi will put the header in the env var
964             ims = self.env['HTTP_IF_MODIFIED_SINCE']
965         if ims:
966             ims = rfc822.parsedate(ims)[:6]
967             lmtt = time.gmtime(lmt)[:6]
968             if lmtt <= ims:
969                 raise NotModified
971         if filename:
972             self.write_file(filename)
973         else:
974             self.additional_headers['Content-Length'] = str(len(content))
975             self.write(content)
977     def send_html_to_admin(self, subject, content):
979         to = [self.mailer.config.ADMIN_EMAIL]
980         message = self.mailer.get_standard_message(to, subject)
981         # delete existing content-type headers
982         del message['Content-type']
983         message['Content-type'] = 'text/html; charset=utf-8'
984         message.set_payload(content)
985         encode_quopri(message)
986         self.mailer.smtp_send(to, str(message))
987     
988     def renderFrontPage(self, message):
989         """Return the front page of the tracker."""
990     
991         self.classname = self.nodeid = None
992         self.template = ''
993         self.error_message.append(message)
994         self.write_html(self.renderContext())
996     def renderContext(self):
997         """ Return a PageTemplate for the named page
998         """
999         name = self.classname
1000         extension = self.template
1002         # catch errors so we can handle PT rendering errors more nicely
1003         args = {
1004             'ok_message': self.ok_message,
1005             'error_message': self.error_message
1006         }
1007         try:
1008             pt = self.instance.templates.get(name, extension)
1009             # let the template render figure stuff out
1010             result = pt.render(self, None, None, **args)
1011             self.additional_headers['Content-Type'] = pt.content_type
1012             if self.env.get('CGI_SHOW_TIMING', ''):
1013                 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
1014                     timings = {'starttag': '<!-- ', 'endtag': ' -->'}
1015                 else:
1016                     timings = {'starttag': '<p>', 'endtag': '</p>'}
1017                 timings['seconds'] = time.time()-self.start
1018                 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
1019                     ) % timings
1020                 if hasattr(self.db, 'stats'):
1021                     timings.update(self.db.stats)
1022                     s += self._("%(starttag)sCache hits: %(cache_hits)d,"
1023                         " misses %(cache_misses)d."
1024                         " Loading items: %(get_items)f secs."
1025                         " Filtering: %(filtering)f secs."
1026                         "%(endtag)s\n") % timings
1027                 s += '</body>'
1028                 result = result.replace('</body>', s)
1029             return result
1030         except templating.NoTemplate, message:
1031             return '<strong>%s</strong>'%message
1032         except templating.Unauthorised, message:
1033             raise Unauthorised, str(message)
1034         except:
1035             # everything else
1036             if self.instance.config.WEB_DEBUG:
1037                 return cgitb.pt_html(i18n=self.translator)
1038             exc_info = sys.exc_info()
1039             try:
1040                 # If possible, send the HTML page template traceback
1041                 # to the administrator.
1042                 subject = "Templating Error: %s" % exc_info[1]
1043                 self.send_html_to_admin(subject, cgitb.pt_html())
1044                 # Now report the error to the user.
1045                 return self._(error_message)
1046             except:
1047                 # Reraise the original exception.  The user will
1048                 # receive an error message, and the adminstrator will
1049                 # receive a traceback, albeit with less information
1050                 # than the one we tried to generate above.
1051                 raise exc_info[0], exc_info[1], exc_info[2]
1053     # these are the actions that are available
1054     actions = (
1055         ('edit',        EditItemAction),
1056         ('editcsv',     EditCSVAction),
1057         ('new',         NewItemAction),
1058         ('register',    RegisterAction),
1059         ('confrego',    ConfRegoAction),
1060         ('passrst',     PassResetAction),
1061         ('login',       LoginAction),
1062         ('logout',      LogoutAction),
1063         ('search',      SearchAction),
1064         ('retire',      RetireAction),
1065         ('show',        ShowAction),
1066         ('export_csv',  ExportCSVAction),
1067     )
1068     def handle_action(self):
1069         """ Determine whether there should be an Action called.
1071             The action is defined by the form variable :action which
1072             identifies the method on this object to call. The actions
1073             are defined in the "actions" sequence on this class.
1075             Actions may return a page (by default HTML) to return to the
1076             user, bypassing the usual template rendering.
1078             We explicitly catch Reject and ValueError exceptions and
1079             present their messages to the user.
1080         """
1081         if self.form.has_key(':action'):
1082             action = self.form[':action'].value.lower()
1083         elif self.form.has_key('@action'):
1084             action = self.form['@action'].value.lower()
1085         else:
1086             return None
1088         try:
1089             action_klass = self.get_action_class(action)
1091             # call the mapped action
1092             if isinstance(action_klass, type('')):
1093                 # old way of specifying actions
1094                 return getattr(self, action_klass)()
1095             else:
1096                 return action_klass(self).execute()
1098         except (ValueError, Reject), err:
1099             self.error_message.append(str(err))
1101     def get_action_class(self, action_name):
1102         if (hasattr(self.instance, 'cgi_actions') and
1103                 self.instance.cgi_actions.has_key(action_name)):
1104             # tracker-defined action
1105             action_klass = self.instance.cgi_actions[action_name]
1106         else:
1107             # go with a default
1108             for name, action_klass in self.actions:
1109                 if name == action_name:
1110                     break
1111             else:
1112                 raise ValueError, 'No such action "%s"'%action_name
1113         return action_klass
1115     def _socket_op(self, call, *args, **kwargs):
1116         """Execute socket-related operation, catch common network errors
1118         Parameters:
1119             call: a callable to execute
1120             args, kwargs: call arguments
1122         """
1123         try:
1124             call(*args, **kwargs)
1125         except socket.error, err:
1126             err_errno = getattr (err, 'errno', None)
1127             if err_errno is None:
1128                 try:
1129                     err_errno = err[0]
1130                 except TypeError:
1131                     pass
1132             if err_errno not in self.IGNORE_NET_ERRORS:
1133                 raise
1134         except IOError:
1135             # Apache's mod_python will raise IOError -- without an
1136             # accompanying errno -- when a write to the client fails.
1137             # A common case is that the client has closed the
1138             # connection.  There's no way to be certain that this is
1139             # the situation that has occurred here, but that is the
1140             # most likely case.
1141             pass
1143     def write(self, content):
1144         if not self.headers_done:
1145             self.header()
1146         if self.env['REQUEST_METHOD'] != 'HEAD':
1147             self._socket_op(self.request.wfile.write, content)
1149     def write_html(self, content):
1150         if not self.headers_done:
1151             # at this point, we are sure about Content-Type
1152             if not self.additional_headers.has_key('Content-Type'):
1153                 self.additional_headers['Content-Type'] = \
1154                     'text/html; charset=%s' % self.charset
1155             self.header()
1157         if self.env['REQUEST_METHOD'] == 'HEAD':
1158             # client doesn't care about content
1159             return
1161         if self.charset != self.STORAGE_CHARSET:
1162             # recode output
1163             content = content.decode(self.STORAGE_CHARSET, 'replace')
1164             content = content.encode(self.charset, 'xmlcharrefreplace')
1166         # and write
1167         self._socket_op(self.request.wfile.write, content)
1169     def http_strip(self, content):
1170         """Remove HTTP Linear White Space from 'content'.
1172         'content' -- A string.
1174         returns -- 'content', with all leading and trailing LWS
1175         removed."""
1177         # RFC 2616 2.2: Basic Rules
1178         #
1179         # LWS = [CRLF] 1*( SP | HT )
1180         return content.strip(" \r\n\t")
1182     def http_split(self, content):
1183         """Split an HTTP list.
1185         'content' -- A string, giving a list of items.
1187         returns -- A sequence of strings, containing the elements of
1188         the list."""
1190         # RFC 2616 2.1: Augmented BNF
1191         #
1192         # Grammar productions of the form "#rule" indicate a
1193         # comma-separated list of elements matching "rule".  LWS
1194         # is then removed from each element, and empty elements
1195         # removed.
1197         # Split at commas.
1198         elements = content.split(",")
1199         # Remove linear whitespace at either end of the string.
1200         elements = [self.http_strip(e) for e in elements]
1201         # Remove any now-empty elements.
1202         return [e for e in elements if e]
1203         
1204     def handle_range_header(self, length, etag):
1205         """Handle the 'Range' and 'If-Range' headers.
1207         'length' -- the length of the content available for the
1208         resource.
1210         'etag' -- the entity tag for this resources.
1212         returns -- If the request headers (including 'Range' and
1213         'If-Range') indicate that only a portion of the entity should
1214         be returned, then the return value is a pair '(offfset,
1215         length)' indicating the first byte and number of bytes of the
1216         content that should be returned to the client.  In addition,
1217         this method will set 'self.response_code' to indicate Partial
1218         Content.  In all other cases, the return value is 'None'.  If
1219         appropriate, 'self.response_code' will be
1220         set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'.  In that
1221         case, the caller should not send any data to the client."""
1223         # RFC 2616 14.35: Range
1224         #
1225         # See if the Range header is present.
1226         ranges_specifier = self.env.get("HTTP_RANGE")
1227         if ranges_specifier is None:
1228             return None
1229         # RFC 2616 14.27: If-Range
1230         #
1231         # Check to see if there is an If-Range header.
1232         # Because the specification says:
1233         #
1234         #  The If-Range header ... MUST be ignored if the request
1235         #  does not include a Range header, we check for If-Range
1236         #  after checking for Range.
1237         if_range = self.env.get("HTTP_IF_RANGE")
1238         if if_range:
1239             # The grammar for the If-Range header is:
1240             # 
1241             #   If-Range = "If-Range" ":" ( entity-tag | HTTP-date )
1242             #   entity-tag = [ weak ] opaque-tag
1243             #   weak = "W/"
1244             #   opaque-tag = quoted-string
1245             #
1246             # We only support strong entity tags.
1247             if_range = self.http_strip(if_range)
1248             if (not if_range.startswith('"')
1249                 or not if_range.endswith('"')):
1250                 return None
1251             # If the condition doesn't match the entity tag, then we
1252             # must send the client the entire file.
1253             if if_range != etag:
1254                 return
1255         # The grammar for the Range header value is:
1256         #
1257         #   ranges-specifier = byte-ranges-specifier
1258         #   byte-ranges-specifier = bytes-unit "=" byte-range-set
1259         #   byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
1260         #   byte-range-spec = first-byte-pos "-" [last-byte-pos]
1261         #   first-byte-pos = 1*DIGIT
1262         #   last-byte-pos = 1*DIGIT
1263         #   suffix-byte-range-spec = "-" suffix-length
1264         #   suffix-length = 1*DIGIT
1265         #
1266         # Look for the "=" separating the units from the range set.
1267         specs = ranges_specifier.split("=", 1)
1268         if len(specs) != 2:
1269             return None
1270         # Check that the bytes-unit is in fact "bytes".  If it is not,
1271         # we do not know how to process this range.
1272         bytes_unit = self.http_strip(specs[0])
1273         if bytes_unit != "bytes":
1274             return None
1275         # Seperate the range-set into range-specs.
1276         byte_range_set = self.http_strip(specs[1])
1277         byte_range_specs = self.http_split(byte_range_set)
1278         # We only handle exactly one range at this time.
1279         if len(byte_range_specs) != 1:
1280             return None
1281         # Parse the spec.
1282         byte_range_spec = byte_range_specs[0]
1283         pos = byte_range_spec.split("-", 1)
1284         if len(pos) != 2:
1285             return None
1286         # Get the first and last bytes.
1287         first = self.http_strip(pos[0])
1288         last = self.http_strip(pos[1])
1289         # We do not handle suffix ranges.
1290         if not first:
1291             return None
1292        # Convert the first and last positions to integers.
1293         try:
1294             first = int(first)
1295             if last:
1296                 last = int(last)
1297             else:
1298                 last = length - 1
1299         except:
1300             # The positions could not be parsed as integers.
1301             return None
1302         # Check that the range makes sense.
1303         if (first < 0 or last < 0 or last < first):
1304             return None
1305         if last >= length:
1306             # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable
1307             #
1308             # If there is an If-Range header, RFC 2616 says that we
1309             # should just ignore the invalid Range header.
1310             if if_range:
1311                 return None
1312             # Return code 416 with a Content-Range header giving the
1313             # allowable range.
1314             self.response_code = httplib.REQUESTED_RANGE_NOT_SATISFIABLE
1315             self.setHeader("Content-Range", "bytes */%d" % length)
1316             return None
1317         # RFC 2616 10.2.7: 206 Partial Content
1318         #
1319         # Tell the client that we are honoring the Range request by
1320         # indicating that we are providing partial content.
1321         self.response_code = httplib.PARTIAL_CONTENT
1322         # RFC 2616 14.16: Content-Range
1323         #
1324         # Tell the client what data we are providing.
1325         #
1326         #   content-range-spec = byte-content-range-spec
1327         #   byte-content-range-spec = bytes-unit SP
1328         #                             byte-range-resp-spec "/"
1329         #                             ( instance-length | "*" )
1330         #   byte-range-resp-spec = (first-byte-pos "-" last-byte-pos)
1331         #                          | "*"
1332         #   instance-length      = 1 * DIGIT
1333         self.setHeader("Content-Range",
1334                        "bytes %d-%d/%d" % (first, last, length))
1335         return (first, last - first + 1)
1337     def write_file(self, filename):
1338         """Send the contents of 'filename' to the user."""
1340         # Determine the length of the file.
1341         stat_info = os.stat(filename)
1342         length = stat_info[stat.ST_SIZE]
1343         # Assume we will return the entire file.
1344         offset = 0
1345         # If the headers have not already been finalized, 
1346         if not self.headers_done:
1347             # RFC 2616 14.19: ETag
1348             #
1349             # Compute the entity tag, in a format similar to that
1350             # used by Apache.
1351             etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
1352                                    length,
1353                                    stat_info[stat.ST_MTIME])
1354             self.setHeader("ETag", etag)
1355             # RFC 2616 14.5: Accept-Ranges
1356             #
1357             # Let the client know that we will accept range requests.
1358             self.setHeader("Accept-Ranges", "bytes")
1359             # RFC 2616 14.35: Range
1360             #
1361             # If there is a Range header, we may be able to avoid
1362             # sending the entire file.
1363             content_range = self.handle_range_header(length, etag)
1364             if content_range:
1365                 offset, length = content_range
1366             # RFC 2616 14.13: Content-Length
1367             #
1368             # Tell the client how much data we are providing.
1369             self.setHeader("Content-Length", str(length))
1370             # Send the HTTP header.
1371             self.header()
1372         # If the client doesn't actually want the body, or if we are
1373         # indicating an invalid range.
1374         if (self.env['REQUEST_METHOD'] == 'HEAD'
1375             or self.response_code == httplib.REQUESTED_RANGE_NOT_SATISFIABLE):
1376             return
1377         # Use the optimized "sendfile" operation, if possible.
1378         if hasattr(self.request, "sendfile"):
1379             self._socket_op(self.request.sendfile, filename, offset, length)
1380             return
1381         # Fallback to the "write" operation.
1382         f = open(filename, 'rb')
1383         try:
1384             if offset:
1385                 f.seek(offset)
1386             content = f.read(length)
1387         finally:
1388             f.close()
1389         self.write(content)
1391     def setHeader(self, header, value):
1392         """Override a header to be returned to the user's browser.
1393         """
1394         self.additional_headers[header] = value
1396     def header(self, headers=None, response=None):
1397         """Put up the appropriate header.
1398         """
1399         if headers is None:
1400             headers = {'Content-Type':'text/html; charset=utf-8'}
1401         if response is None:
1402             response = self.response_code
1404         # update with additional info
1405         headers.update(self.additional_headers)
1407         if headers.get('Content-Type', 'text/html') == 'text/html':
1408             headers['Content-Type'] = 'text/html; charset=utf-8'
1410         headers = headers.items()
1412         for ((path, name), (value, expire)) in self._cookies.items():
1413             cookie = "%s=%s; Path=%s;"%(name, value, path)
1414             if expire is not None:
1415                 cookie += " expires=%s;"%Cookie._getdate(expire)
1416             headers.append(('Set-Cookie', cookie))
1418         self._socket_op(self.request.start_response, headers, response)
1420         self.headers_done = 1
1421         if self.debug:
1422             self.headers_sent = headers
1424     def add_cookie(self, name, value, expire=86400*365, path=None):
1425         """Set a cookie value to be sent in HTTP headers
1427         Parameters:
1428             name:
1429                 cookie name
1430             value:
1431                 cookie value
1432             expire:
1433                 cookie expiration time (seconds).
1434                 If value is empty (meaning "delete cookie"),
1435                 expiration time is forced in the past
1436                 and this argument is ignored.
1437                 If None, the cookie will expire at end-of-session.
1438                 If omitted, the cookie will be kept for a year.
1439             path:
1440                 cookie path (optional)
1442         """
1443         if path is None:
1444             path = self.cookie_path
1445         if not value:
1446             expire = -1
1447         self._cookies[(path, name)] = (value, expire)
1449     def set_cookie(self, user, expire=None):
1450         """Deprecated. Use session_api calls directly
1452         XXX remove
1453         """
1455         # insert the session in the session db
1456         self.session_api.set(user=user)
1457         # refresh session cookie
1458         self.session_api.update(set_cookie=True, expire=expire)
1460     def make_user_anonymous(self):
1461         """ Make us anonymous
1463             This method used to handle non-existence of the 'anonymous'
1464             user, but that user is mandatory now.
1465         """
1466         self.userid = self.db.user.lookup('anonymous')
1467         self.user = 'anonymous'
1469     def standard_message(self, to, subject, body, author=None):
1470         """Send a standard email message from Roundup.
1472         "to"      - recipients list
1473         "subject" - Subject
1474         "body"    - Message
1475         "author"  - (name, address) tuple or None for admin email
1477         Arguments are passed to the Mailer.standard_message code.
1478         """
1479         try:
1480             self.mailer.standard_message(to, subject, body, author)
1481         except MessageSendError, e:
1482             self.error_message.append(str(e))
1483             return 0
1484         return 1
1486     def parsePropsFromForm(self, create=0):
1487         return FormParser(self).parse(create=create)
1489 # vim: set et sts=4 sw=4 :