Code

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