Code

* Add support for actions to XMLRPC interface.
[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
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)
390         self.db.commit()
392         self.setHeader("Content-Type", "text/xml")
393         self.setHeader("Content-Length", str(len(output)))
394         self.write(output)
395         
396     def inner_main(self):
397         """Process a request.
399         The most common requests are handled like so:
401         1. look for charset and language preferences, set up user locale
402            see determine_charset, determine_language
403         2. figure out who we are, defaulting to the "anonymous" user
404            see determine_user
405         3. figure out what the request is for - the context
406            see determine_context
407         4. handle any requested action (item edit, search, ...)
408            see handle_action
409         5. render a template, resulting in HTML output
411         In some situations, exceptions occur:
413         - HTTP Redirect  (generally raised by an action)
414         - SendFile       (generally raised by determine_context)
415           serve up a FileClass "content" property
416         - SendStaticFile (generally raised by determine_context)
417           serve up a file from the tracker "html" directory
418         - Unauthorised   (generally raised by an action)
419           the action is cancelled, the request is rendered and an error
420           message is displayed indicating that permission was not
421           granted for the action to take place
422         - templating.Unauthorised   (templating action not permitted)
423           raised by an attempted rendering of a template when the user
424           doesn't have permission
425         - NotFound       (raised wherever it needs to be)
426           percolates up to the CGI interface that called the client
427         """
428         self.ok_message = []
429         self.error_message = []
430         try:
431             self.determine_charset()
432             self.determine_language()
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 double-load all pages!!
452 #            self.additional_headers['Pragma'] = 'no-cache'
454             # pages with messages added expire right now
455             # simple views may be cached for a small amount of time
456             # TODO? make page expire time configurable
457             # <rj> always expire pages, as IE just doesn't seem to do the
458             # right thing here :(
459             date = time.time() - 1
460             #if self.error_message or self.ok_message:
461             #    date = time.time() - 1
462             #else:
463             #    date = time.time() + 5
464             self.additional_headers['Expires'] = rfc822.formatdate(date)
466             # render the content
467             try:
468                 self.write_html(self.renderContext())
469             except IOError:
470                 # IOErrors here are due to the client disconnecting before
471                 # recieving the reply.
472                 pass
474         except SeriousError, message:
475             self.write_html(str(message))
476         except Redirect, url:
477             # let's redirect - if the url isn't None, then we need to do
478             # the headers, otherwise the headers have been set before the
479             # exception was raised
480             if url:
481                 self.additional_headers['Location'] = str(url)
482                 self.response_code = 302
483             self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
484         except SendFile, designator:
485             try:
486                 self.serve_file(designator)
487             except NotModified:
488                 # send the 304 response
489                 self.response_code = 304
490                 self.header()
491         except SendStaticFile, file:
492             try:
493                 self.serve_static_file(str(file))
494             except NotModified:
495                 # send the 304 response
496                 self.response_code = 304
497                 self.header()
498         except Unauthorised, message:
499             # users may always see the front page
500             self.response_code = 403
501             self.classname = self.nodeid = None
502             self.template = ''
503             self.error_message.append(message)
504             self.write_html(self.renderContext())
505         except NotFound, e:
506             self.response_code = 404
507             self.template = '404'
508             try:
509                 cl = self.db.getclass(self.classname)
510                 self.write_html(self.renderContext())
511             except KeyError:
512                 # we can't map the URL to a class we know about
513                 # reraise the NotFound and let roundup_server
514                 # handle it
515                 raise NotFound, e
516         except FormError, e:
517             self.error_message.append(self._('Form Error: ') + str(e))
518             self.write_html(self.renderContext())
519         except:
520             if self.instance.config.WEB_DEBUG:
521                 self.write_html(cgitb.html(i18n=self.translator))
522             else:
523                 self.mailer.exception_message()
524                 return self.write_html(self._(error_message))
526     def clean_sessions(self):
527         """Deprecated
528            XXX remove
529         """
530         self.clean_up()
532     def clean_up(self):
533         """Remove expired sessions and One Time Keys.
535            Do it only once an hour.
536         """
537         hour = 60*60
538         now = time.time()
540         # XXX: hack - use OTK table to store last_clean time information
541         #      'last_clean' string is used instead of otk key
542         last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
543         if now - last_clean < hour:
544             return
546         self.session_api.clean_up()
547         self.db.getOTKManager().clean()
548         self.db.getOTKManager().set('last_clean', last_use=now)
549         self.db.commit(fail_ok=True)
551     def determine_charset(self):
552         """Look for client charset in the form parameters or browser cookie.
554         If no charset requested by client, use storage charset (utf-8).
556         If the charset is found, and differs from the storage charset,
557         recode all form fields of type 'text/plain'
558         """
559         # look for client charset
560         charset_parameter = 0
561         if self.form.has_key('@charset'):
562             charset = self.form['@charset'].value
563             if charset.lower() == "none":
564                 charset = ""
565             charset_parameter = 1
566         elif self.cookie.has_key('roundup_charset'):
567             charset = self.cookie['roundup_charset'].value
568         else:
569             charset = None
570         if charset:
571             # make sure the charset is recognized
572             try:
573                 codecs.lookup(charset)
574             except LookupError:
575                 self.error_message.append(self._('Unrecognized charset: %r')
576                     % charset)
577                 charset_parameter = 0
578             else:
579                 self.charset = charset.lower()
580         # If we've got a character set in request parameters,
581         # set the browser cookie to keep the preference.
582         # This is done after codecs.lookup to make sure
583         # that we aren't keeping a wrong value.
584         if charset_parameter:
585             self.add_cookie('roundup_charset', charset)
587         # if client charset is different from the storage charset,
588         # recode form fields
589         # XXX this requires FieldStorage from Python library.
590         #   mod_python FieldStorage is not supported!
591         if self.charset != self.STORAGE_CHARSET:
592             decoder = codecs.getdecoder(self.charset)
593             encoder = codecs.getencoder(self.STORAGE_CHARSET)
594             re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
595             def _decode_charref(matchobj):
596                 num = matchobj.group(1)
597                 if num[0].lower() == 'x':
598                     uc = int(num[1:], 16)
599                 else:
600                     uc = int(num)
601                 return unichr(uc)
603             for field_name in self.form.keys():
604                 field = self.form[field_name]
605                 if (field.type == 'text/plain') and not field.filename:
606                     try:
607                         value = decoder(field.value)[0]
608                     except UnicodeError:
609                         continue
610                     value = re_charref.sub(_decode_charref, value)
611                     field.value = encoder(value)[0]
613     def determine_language(self):
614         """Determine the language"""
615         # look for language parameter
616         # then for language cookie
617         # last for the Accept-Language header
618         if self.form.has_key("@language"):
619             language = self.form["@language"].value
620             if language.lower() == "none":
621                 language = ""
622             self.add_cookie("roundup_language", language)
623         elif self.cookie.has_key("roundup_language"):
624             language = self.cookie["roundup_language"].value
625         elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
626             hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
627             language = accept_language.parse(hal)
628         else:
629             language = ""
631         self.language = language
632         if language:
633             self.setTranslator(TranslationService.get_translation(
634                     language,
635                     tracker_home=self.instance.config["TRACKER_HOME"]))
637     def determine_user(self):
638         """Determine who the user is"""
639         self.opendb('admin')
641         # get session data from db
642         # XXX: rename
643         self.session_api = Session(self)
645         # take the opportunity to cleanup expired sessions and otks
646         self.clean_up()
648         user = None
649         # first up, try http authorization if enabled
650         if self.instance.config['WEB_HTTP_AUTH']:
651             if self.env.has_key('REMOTE_USER'):
652                 # we have external auth (e.g. by Apache)
653                 user = self.env['REMOTE_USER']
654             elif self.env.get('HTTP_AUTHORIZATION', ''):
655                 # try handling Basic Auth ourselves
656                 auth = self.env['HTTP_AUTHORIZATION']
657                 scheme, challenge = auth.split(' ', 1)
658                 if scheme.lower() == 'basic':
659                     try:
660                         decoded = base64.decodestring(challenge)
661                     except TypeError:
662                         # invalid challenge
663                         pass
664                     username, password = decoded.split(':')
665                     try:
666                         login = self.get_action_class('login')(self)
667                         login.verifyLogin(username, password)
668                     except LoginError, err:
669                         self.make_user_anonymous()
670                         raise Unauthorised, err
671                     user = username
673         # if user was not set by http authorization, try session lookup
674         if not user:
675             user = self.session_api.get('user')
676             if user:
677                 # update session lifetime datestamp
678                 self.session_api.update()
680         # if no user name set by http authorization or session lookup
681         # the user is anonymous
682         if not user:
683             user = 'anonymous'
685         # sanity check on the user still being valid,
686         # getting the userid at the same time
687         try:
688             self.userid = self.db.user.lookup(user)
689         except (KeyError, TypeError):
690             user = 'anonymous'
692         # make sure the anonymous user is valid if we're using it
693         if user == 'anonymous':
694             self.make_user_anonymous()
695             if not self.db.security.hasPermission('Web Access', self.userid):
696                 raise Unauthorised, self._("Anonymous users are not "
697                     "allowed to use the web interface")
698         else:
699             self.user = user
701         # reopen the database as the correct user
702         self.opendb(self.user)
704     def opendb(self, username):
705         """Open the database and set the current user.
707         Opens a database once. On subsequent calls only the user is set on
708         the database object the instance.optimize is set. If we are in
709         "Development Mode" (cf. roundup_server) then the database is always
710         re-opened.
711         """
712         # don't do anything if the db is open and the user has not changed
713         if hasattr(self, 'db') and self.db.isCurrentUser(username):
714             return
716         # open the database or only set the user
717         if not hasattr(self, 'db'):
718             self.db = self.instance.open(username)
719         else:
720             if self.instance.optimize:
721                 self.db.setCurrentUser(username)
722             else:
723                 self.db.close()
724                 self.db = self.instance.open(username)
726     def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
727         """Determine the context of this page from the URL:
729         The URL path after the instance identifier is examined. The path
730         is generally only one entry long.
732         - if there is no path, then we are in the "home" context.
733         - if the path is "_file", then the additional path entry
734           specifies the filename of a static file we're to serve up
735           from the instance "html" directory. Raises a SendStaticFile
736           exception.(*)
737         - if there is something in the path (eg "issue"), it identifies
738           the tracker class we're to display.
739         - if the path is an item designator (eg "issue123"), then we're
740           to display a specific item.
741         - if the path starts with an item designator and is longer than
742           one entry, then we're assumed to be handling an item of a
743           FileClass, and the extra path information gives the filename
744           that the client is going to label the download with (ie
745           "file123/image.png" is nicer to download than "file123"). This
746           raises a SendFile exception.(*)
748         Both of the "*" types of contexts stop before we bother to
749         determine the template we're going to use. That's because they
750         don't actually use templates.
752         The template used is specified by the :template CGI variable,
753         which defaults to:
755         - only classname suplied:          "index"
756         - full item designator supplied:   "item"
758         We set:
760              self.classname  - the class to display, can be None
762              self.template   - the template to render the current context with
764              self.nodeid     - the nodeid of the class we're displaying
765         """
766         # default the optional variables
767         self.classname = None
768         self.nodeid = None
770         # see if a template or messages are specified
771         template_override = ok_message = error_message = None
772         for key in self.form.keys():
773             if self.FV_TEMPLATE.match(key):
774                 template_override = self.form[key].value
775             elif self.FV_OK_MESSAGE.match(key):
776                 ok_message = self.form[key].value
777                 ok_message = clean_message(ok_message)
778             elif self.FV_ERROR_MESSAGE.match(key):
779                 error_message = self.form[key].value
780                 error_message = clean_message(error_message)
782         # see if we were passed in a message
783         if ok_message:
784             self.ok_message.append(ok_message)
785         if error_message:
786             self.error_message.append(error_message)
788         # determine the classname and possibly nodeid
789         path = self.path.split('/')
790         if not path or path[0] in ('', 'home', 'index'):
791             if template_override is not None:
792                 self.template = template_override
793             else:
794                 self.template = ''
795             return
796         elif path[0] in ('_file', '@@file'):
797             raise SendStaticFile, os.path.join(*path[1:])
798         else:
799             self.classname = path[0]
800             if len(path) > 1:
801                 # send the file identified by the designator in path[0]
802                 raise SendFile, path[0]
804         # see if we got a designator
805         m = dre.match(self.classname)
806         if m:
807             self.classname = m.group(1)
808             self.nodeid = m.group(2)
809             try:
810                 klass = self.db.getclass(self.classname)
811             except KeyError:
812                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
813             if not klass.hasnode(self.nodeid):
814                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
815             # with a designator, we default to item view
816             self.template = 'item'
817         else:
818             # with only a class, we default to index view
819             self.template = 'index'
821         # make sure the classname is valid
822         try:
823             self.db.getclass(self.classname)
824         except KeyError:
825             raise NotFound, self.classname
827         # see if we have a template override
828         if template_override is not None:
829             self.template = template_override
831     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
832         """ Serve the file from the content property of the designated item.
833         """
834         m = dre.match(str(designator))
835         if not m:
836             raise NotFound, str(designator)
837         classname, nodeid = m.group(1), m.group(2)
839         klass = self.db.getclass(classname)
841         # make sure we have the appropriate properties
842         props = klass.getprops()
843         if not props.has_key('type'):
844             raise NotFound, designator
845         if not props.has_key('content'):
846             raise NotFound, designator
848         # make sure we have permission
849         if not self.db.security.hasPermission('View', self.userid,
850                 classname, 'content', nodeid):
851             raise Unauthorised, self._("You are not allowed to view "
852                 "this file.")
854         mime_type = klass.get(nodeid, 'type')
856         # If this object is a file (i.e., an instance of FileClass),
857         # see if we can find it in the filesystem.  If so, we may be
858         # able to use the more-efficient request.sendfile method of
859         # sending the file.  If not, just get the "content" property
860         # in the usual way, and use that.
861         content = None
862         filename = None
863         if isinstance(klass, hyperdb.FileClass):
864             try:
865                 filename = self.db.filename(classname, nodeid)
866             except AttributeError:
867                 # The database doesn't store files in the filesystem
868                 # and therefore doesn't provide the "filename" method.
869                 pass
870             except IOError:
871                 # The file does not exist.
872                 pass
873         if not filename:
874             content = klass.get(nodeid, 'content')
875         
876         lmt = klass.get(nodeid, 'activity').timestamp()
878         self._serve_file(lmt, mime_type, content, filename)
880     def serve_static_file(self, file):
881         """ Serve up the file named from the templates dir
882         """
883         # figure the filename - try STATIC_FILES, then TEMPLATES dir
884         for dir_option in ('STATIC_FILES', 'TEMPLATES'):
885             prefix = self.instance.config[dir_option]
886             if not prefix:
887                 continue
888             # ensure the load doesn't try to poke outside
889             # of the static files directory
890             prefix = os.path.normpath(prefix)
891             filename = os.path.normpath(os.path.join(prefix, file))
892             if os.path.isfile(filename) and filename.startswith(prefix):
893                 break
894         else:
895             raise NotFound, file
897         # last-modified time
898         lmt = os.stat(filename)[stat.ST_MTIME]
900         # detemine meta-type
901         file = str(file)
902         mime_type = mimetypes.guess_type(file)[0]
903         if not mime_type:
904             if file.endswith('.css'):
905                 mime_type = 'text/css'
906             else:
907                 mime_type = 'text/plain'
909         self._serve_file(lmt, mime_type, '', filename)
911     def _serve_file(self, lmt, mime_type, content=None, filename=None):
912         """ guts of serve_file() and serve_static_file()
913         """
915         # spit out headers
916         self.additional_headers['Content-Type'] = mime_type
917         self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
919         ims = None
920         # see if there's an if-modified-since...
921         # XXX see which interfaces set this
922         #if hasattr(self.request, 'headers'):
923             #ims = self.request.headers.getheader('if-modified-since')
924         if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
925             # cgi will put the header in the env var
926             ims = self.env['HTTP_IF_MODIFIED_SINCE']
927         if ims:
928             ims = rfc822.parsedate(ims)[:6]
929             lmtt = time.gmtime(lmt)[:6]
930             if lmtt <= ims:
931                 raise NotModified
933         if filename:
934             self.write_file(filename)
935         else:
936             self.additional_headers['Content-Length'] = str(len(content))
937             self.write(content)
939     def renderContext(self):
940         """ Return a PageTemplate for the named page
941         """
942         name = self.classname
943         extension = self.template
945         # catch errors so we can handle PT rendering errors more nicely
946         args = {
947             'ok_message': self.ok_message,
948             'error_message': self.error_message
949         }
950         try:
951             pt = self.instance.templates.get(name, extension)
952             # let the template render figure stuff out
953             result = pt.render(self, None, None, **args)
954             self.additional_headers['Content-Type'] = pt.content_type
955             if self.env.get('CGI_SHOW_TIMING', ''):
956                 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
957                     timings = {'starttag': '<!-- ', 'endtag': ' -->'}
958                 else:
959                     timings = {'starttag': '<p>', 'endtag': '</p>'}
960                 timings['seconds'] = time.time()-self.start
961                 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
962                     ) % timings
963                 if hasattr(self.db, 'stats'):
964                     timings.update(self.db.stats)
965                     s += self._("%(starttag)sCache hits: %(cache_hits)d,"
966                         " misses %(cache_misses)d."
967                         " Loading items: %(get_items)f secs."
968                         " Filtering: %(filtering)f secs."
969                         "%(endtag)s\n") % timings
970                 s += '</body>'
971                 result = result.replace('</body>', s)
972             return result
973         except templating.NoTemplate, message:
974             return '<strong>%s</strong>'%message
975         except templating.Unauthorised, message:
976             raise Unauthorised, str(message)
977         except:
978             # everything else
979             if self.instance.config.WEB_DEBUG:
980                 return cgitb.pt_html(i18n=self.translator)
981             exc_info = sys.exc_info()
982             try:
983                 # If possible, send the HTML page template traceback
984                 # to the administrator.
985                 to = [self.mailer.config.ADMIN_EMAIL]
986                 subject = "Templating Error: %s" % exc_info[1]
987                 content = cgitb.pt_html()
988                 message, writer = self.mailer.get_standard_message(
989                     to, subject)
990                 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
991                 body = writer.startbody('text/html; charset=utf-8')
992                 content = StringIO(content)
993                 quopri.encode(content, body, 0)
994                 self.mailer.smtp_send(to, message)
995                 # Now report the error to the user.
996                 return self._(error_message)
997             except:
998                 # Reraise the original exception.  The user will
999                 # receive an error message, and the adminstrator will
1000                 # receive a traceback, albeit with less information
1001                 # than the one we tried to generate above.
1002                 raise exc_info[0], exc_info[1], exc_info[2]
1004     # these are the actions that are available
1005     actions = (
1006         ('edit',        EditItemAction),
1007         ('editcsv',     EditCSVAction),
1008         ('new',         NewItemAction),
1009         ('register',    RegisterAction),
1010         ('confrego',    ConfRegoAction),
1011         ('passrst',     PassResetAction),
1012         ('login',       LoginAction),
1013         ('logout',      LogoutAction),
1014         ('search',      SearchAction),
1015         ('retire',      RetireAction),
1016         ('show',        ShowAction),
1017         ('export_csv',  ExportCSVAction),
1018     )
1019     def handle_action(self):
1020         """ Determine whether there should be an Action called.
1022             The action is defined by the form variable :action which
1023             identifies the method on this object to call. The actions
1024             are defined in the "actions" sequence on this class.
1026             Actions may return a page (by default HTML) to return to the
1027             user, bypassing the usual template rendering.
1029             We explicitly catch Reject and ValueError exceptions and
1030             present their messages to the user.
1031         """
1032         if self.form.has_key(':action'):
1033             action = self.form[':action'].value.lower()
1034         elif self.form.has_key('@action'):
1035             action = self.form['@action'].value.lower()
1036         else:
1037             return None
1039         try:
1040             action_klass = self.get_action_class(action)
1042             # call the mapped action
1043             if isinstance(action_klass, type('')):
1044                 # old way of specifying actions
1045                 return getattr(self, action_klass)()
1046             else:
1047                 return action_klass(self).execute()
1049         except (ValueError, Reject), err:
1050             self.error_message.append(str(err))
1052     def get_action_class(self, action_name):
1053         if (hasattr(self.instance, 'cgi_actions') and
1054                 self.instance.cgi_actions.has_key(action_name)):
1055             # tracker-defined action
1056             action_klass = self.instance.cgi_actions[action_name]
1057         else:
1058             # go with a default
1059             for name, action_klass in self.actions:
1060                 if name == action_name:
1061                     break
1062             else:
1063                 raise ValueError, 'No such action "%s"'%action_name
1064         return action_klass
1066     def _socket_op(self, call, *args, **kwargs):
1067         """Execute socket-related operation, catch common network errors
1069         Parameters:
1070             call: a callable to execute
1071             args, kwargs: call arguments
1073         """
1074         try:
1075             call(*args, **kwargs)
1076         except socket.error, err:
1077             err_errno = getattr (err, 'errno', None)
1078             if err_errno is None:
1079                 try:
1080                     err_errno = err[0]
1081                 except TypeError:
1082                     pass
1083             if err_errno not in self.IGNORE_NET_ERRORS:
1084                 raise
1085         except IOError:
1086             # Apache's mod_python will raise IOError -- without an
1087             # accompanying errno -- when a write to the client fails.
1088             # A common case is that the client has closed the
1089             # connection.  There's no way to be certain that this is
1090             # the situation that has occurred here, but that is the
1091             # most likely case.
1092             pass
1094     def write(self, content):
1095         if not self.headers_done:
1096             self.header()
1097         if self.env['REQUEST_METHOD'] != 'HEAD':
1098             self._socket_op(self.request.wfile.write, content)
1100     def write_html(self, content):
1101         if not self.headers_done:
1102             # at this point, we are sure about Content-Type
1103             if not self.additional_headers.has_key('Content-Type'):
1104                 self.additional_headers['Content-Type'] = \
1105                     'text/html; charset=%s' % self.charset
1106             self.header()
1108         if self.env['REQUEST_METHOD'] == 'HEAD':
1109             # client doesn't care about content
1110             return
1112         if self.charset != self.STORAGE_CHARSET:
1113             # recode output
1114             content = content.decode(self.STORAGE_CHARSET, 'replace')
1115             content = content.encode(self.charset, 'xmlcharrefreplace')
1117         # and write
1118         self._socket_op(self.request.wfile.write, content)
1120     def http_strip(self, content):
1121         """Remove HTTP Linear White Space from 'content'.
1123         'content' -- A string.
1125         returns -- 'content', with all leading and trailing LWS
1126         removed."""
1128         # RFC 2616 2.2: Basic Rules
1129         #
1130         # LWS = [CRLF] 1*( SP | HT )
1131         return content.strip(" \r\n\t")
1133     def http_split(self, content):
1134         """Split an HTTP list.
1136         'content' -- A string, giving a list of items.
1138         returns -- A sequence of strings, containing the elements of
1139         the list."""
1141         # RFC 2616 2.1: Augmented BNF
1142         #
1143         # Grammar productions of the form "#rule" indicate a
1144         # comma-separated list of elements matching "rule".  LWS
1145         # is then removed from each element, and empty elements
1146         # removed.
1148         # Split at commas.
1149         elements = content.split(",")
1150         # Remove linear whitespace at either end of the string.
1151         elements = [self.http_strip(e) for e in elements]
1152         # Remove any now-empty elements.
1153         return [e for e in elements if e]
1154         
1155     def handle_range_header(self, length, etag):
1156         """Handle the 'Range' and 'If-Range' headers.
1158         'length' -- the length of the content available for the
1159         resource.
1161         'etag' -- the entity tag for this resources.
1163         returns -- If the request headers (including 'Range' and
1164         'If-Range') indicate that only a portion of the entity should
1165         be returned, then the return value is a pair '(offfset,
1166         length)' indicating the first byte and number of bytes of the
1167         content that should be returned to the client.  In addition,
1168         this method will set 'self.response_code' to indicate Partial
1169         Content.  In all other cases, the return value is 'None'.  If
1170         appropriate, 'self.response_code' will be
1171         set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'.  In that
1172         case, the caller should not send any data to the client."""
1174         # RFC 2616 14.35: Range
1175         #
1176         # See if the Range header is present.
1177         ranges_specifier = self.env.get("HTTP_RANGE")
1178         if ranges_specifier is None:
1179             return None
1180         # RFC 2616 14.27: If-Range
1181         #
1182         # Check to see if there is an If-Range header.
1183         # Because the specification says:
1184         #
1185         #  The If-Range header ... MUST be ignored if the request
1186         #  does not include a Range header, we check for If-Range
1187         #  after checking for Range.
1188         if_range = self.env.get("HTTP_IF_RANGE")
1189         if if_range:
1190             # The grammar for the If-Range header is:
1191             # 
1192             #   If-Range = "If-Range" ":" ( entity-tag | HTTP-date )
1193             #   entity-tag = [ weak ] opaque-tag
1194             #   weak = "W/"
1195             #   opaque-tag = quoted-string
1196             #
1197             # We only support strong entity tags.
1198             if_range = self.http_strip(if_range)
1199             if (not if_range.startswith('"')
1200                 or not if_range.endswith('"')):
1201                 return None
1202             # If the condition doesn't match the entity tag, then we
1203             # must send the client the entire file.
1204             if if_range != etag:
1205                 return
1206         # The grammar for the Range header value is:
1207         #
1208         #   ranges-specifier = byte-ranges-specifier
1209         #   byte-ranges-specifier = bytes-unit "=" byte-range-set
1210         #   byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
1211         #   byte-range-spec = first-byte-pos "-" [last-byte-pos]
1212         #   first-byte-pos = 1*DIGIT
1213         #   last-byte-pos = 1*DIGIT
1214         #   suffix-byte-range-spec = "-" suffix-length
1215         #   suffix-length = 1*DIGIT
1216         #
1217         # Look for the "=" separating the units from the range set.
1218         specs = ranges_specifier.split("=", 1)
1219         if len(specs) != 2:
1220             return None
1221         # Check that the bytes-unit is in fact "bytes".  If it is not,
1222         # we do not know how to process this range.
1223         bytes_unit = self.http_strip(specs[0])
1224         if bytes_unit != "bytes":
1225             return None
1226         # Seperate the range-set into range-specs.
1227         byte_range_set = self.http_strip(specs[1])
1228         byte_range_specs = self.http_split(byte_range_set)
1229         # We only handle exactly one range at this time.
1230         if len(byte_range_specs) != 1:
1231             return None
1232         # Parse the spec.
1233         byte_range_spec = byte_range_specs[0]
1234         pos = byte_range_spec.split("-", 1)
1235         if len(pos) != 2:
1236             return None
1237         # Get the first and last bytes.
1238         first = self.http_strip(pos[0])
1239         last = self.http_strip(pos[1])
1240         # We do not handle suffix ranges.
1241         if not first:
1242             return None
1243        # Convert the first and last positions to integers.
1244         try:
1245             first = int(first)
1246             if last:
1247                 last = int(last)
1248             else:
1249                 last = length - 1
1250         except:
1251             # The positions could not be parsed as integers.
1252             return None
1253         # Check that the range makes sense.
1254         if (first < 0 or last < 0 or last < first):
1255             return None
1256         if last >= length:
1257             # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable
1258             #
1259             # If there is an If-Range header, RFC 2616 says that we
1260             # should just ignore the invalid Range header.
1261             if if_range:
1262                 return None
1263             # Return code 416 with a Content-Range header giving the
1264             # allowable range.
1265             self.response_code = httplib.REQUESTED_RANGE_NOT_SATISFIABLE
1266             self.setHeader("Content-Range", "bytes */%d" % length)
1267             return None
1268         # RFC 2616 10.2.7: 206 Partial Content
1269         #
1270         # Tell the client that we are honoring the Range request by
1271         # indicating that we are providing partial content.
1272         self.response_code = httplib.PARTIAL_CONTENT
1273         # RFC 2616 14.16: Content-Range
1274         #
1275         # Tell the client what data we are providing.
1276         #
1277         #   content-range-spec = byte-content-range-spec
1278         #   byte-content-range-spec = bytes-unit SP
1279         #                             byte-range-resp-spec "/"
1280         #                             ( instance-length | "*" )
1281         #   byte-range-resp-spec = (first-byte-pos "-" last-byte-pos)
1282         #                          | "*"
1283         #   instance-length      = 1 * DIGIT
1284         self.setHeader("Content-Range",
1285                        "bytes %d-%d/%d" % (first, last, length))
1286         return (first, last - first + 1)
1288     def write_file(self, filename):
1289         """Send the contents of 'filename' to the user."""
1291         # Determine the length of the file.
1292         stat_info = os.stat(filename)
1293         length = stat_info[stat.ST_SIZE]
1294         # Assume we will return the entire file.
1295         offset = 0
1296         # If the headers have not already been finalized, 
1297         if not self.headers_done:
1298             # RFC 2616 14.19: ETag
1299             #
1300             # Compute the entity tag, in a format similar to that
1301             # used by Apache.
1302             etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
1303                                    length,
1304                                    stat_info[stat.ST_MTIME])
1305             self.setHeader("ETag", etag)
1306             # RFC 2616 14.5: Accept-Ranges
1307             #
1308             # Let the client know that we will accept range requests.
1309             self.setHeader("Accept-Ranges", "bytes")
1310             # RFC 2616 14.35: Range
1311             #
1312             # If there is a Range header, we may be able to avoid
1313             # sending the entire file.
1314             content_range = self.handle_range_header(length, etag)
1315             if content_range:
1316                 offset, length = content_range
1317             # RFC 2616 14.13: Content-Length
1318             #
1319             # Tell the client how much data we are providing.
1320             self.setHeader("Content-Length", length)
1321             # Send the HTTP header.
1322             self.header()
1323         # If the client doesn't actually want the body, or if we are
1324         # indicating an invalid range.
1325         if (self.env['REQUEST_METHOD'] == 'HEAD'
1326             or self.response_code == httplib.REQUESTED_RANGE_NOT_SATISFIABLE):
1327             return
1328         # Use the optimized "sendfile" operation, if possible.
1329         if hasattr(self.request, "sendfile"):
1330             self._socket_op(self.request.sendfile, filename, offset, length)
1331             return
1332         # Fallback to the "write" operation.
1333         f = open(filename, 'rb')
1334         try:
1335             if offset:
1336                 f.seek(offset)
1337             content = f.read(length)
1338         finally:
1339             f.close()
1340         self.write(content)
1342     def setHeader(self, header, value):
1343         """Override a header to be returned to the user's browser.
1344         """
1345         self.additional_headers[header] = value
1347     def header(self, headers=None, response=None):
1348         """Put up the appropriate header.
1349         """
1350         if headers is None:
1351             headers = {'Content-Type':'text/html; charset=utf-8'}
1352         if response is None:
1353             response = self.response_code
1355         # update with additional info
1356         headers.update(self.additional_headers)
1358         if headers.get('Content-Type', 'text/html') == 'text/html':
1359             headers['Content-Type'] = 'text/html; charset=utf-8'
1361         headers = headers.items()
1363         for ((path, name), (value, expire)) in self._cookies.items():
1364             cookie = "%s=%s; Path=%s;"%(name, value, path)
1365             if expire is not None:
1366                 cookie += " expires=%s;"%Cookie._getdate(expire)
1367             headers.append(('Set-Cookie', cookie))
1369         self._socket_op(self.request.start_response, headers, response)
1371         self.headers_done = 1
1372         if self.debug:
1373             self.headers_sent = headers
1375     def add_cookie(self, name, value, expire=86400*365, path=None):
1376         """Set a cookie value to be sent in HTTP headers
1378         Parameters:
1379             name:
1380                 cookie name
1381             value:
1382                 cookie value
1383             expire:
1384                 cookie expiration time (seconds).
1385                 If value is empty (meaning "delete cookie"),
1386                 expiration time is forced in the past
1387                 and this argument is ignored.
1388                 If None, the cookie will expire at end-of-session.
1389                 If omitted, the cookie will be kept for a year.
1390             path:
1391                 cookie path (optional)
1393         """
1394         if path is None:
1395             path = self.cookie_path
1396         if not value:
1397             expire = -1
1398         self._cookies[(path, name)] = (value, expire)
1400     def set_cookie(self, user, expire=None):
1401         """Deprecated. Use session_api calls directly
1403         XXX remove
1404         """
1406         # insert the session in the session db
1407         self.session_api.set(user=user)
1408         # refresh session cookie
1409         self.session_api.update(set_cookie=True, expire=expire)
1411     def make_user_anonymous(self):
1412         """ Make us anonymous
1414             This method used to handle non-existence of the 'anonymous'
1415             user, but that user is mandatory now.
1416         """
1417         self.userid = self.db.user.lookup('anonymous')
1418         self.user = 'anonymous'
1420     def standard_message(self, to, subject, body, author=None):
1421         """Send a standard email message from Roundup.
1423         "to"      - recipients list
1424         "subject" - Subject
1425         "body"    - Message
1426         "author"  - (name, address) tuple or None for admin email
1428         Arguments are passed to the Mailer.standard_message code.
1429         """
1430         try:
1431             self.mailer.standard_message(to, subject, body, author)
1432         except MessageSendError, e:
1433             self.error_message.append(str(e))
1434             return 0
1435         return 1
1437     def parsePropsFromForm(self, create=0):
1438         return FormParser(self).parse(create=create)
1440 # vim: set et sts=4 sw=4 :