Code

Support the use of sendfile() for file transfer, if available.
[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, 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
20 def initialiseSecurity(security):
21     '''Create some Permissions and Roles on the security object
23     This function is directly invoked by security.Security.__init__()
24     as a part of the Security object instantiation.
25     '''
26     p = security.addPermission(name="Web Access",
27         description="User may access the web interface")
28     security.addPermissionToRole('Admin', p)
30     # doing Role stuff through the web - make sure Admin can
31     # TODO: deprecate this and use a property-based control
32     p = security.addPermission(name="Web Roles",
33         description="User may manipulate user Roles through the web")
34     security.addPermissionToRole('Admin', p)
36 # used to clean messages passed through CGI variables - HTML-escape any tag
37 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
38 # that people can't pass through nasties like <script>, <iframe>, ...
39 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
40 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
41     return mc.sub(clean_message_callback, message)
42 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
43     ''' Strip all non <a>,<i>,<b> and <br> tags from a string
44     '''
45     if ok.has_key(match.group(3).lower()):
46         return match.group(1)
47     return '&lt;%s&gt;'%match.group(2)
50 error_message = ""'''<html><head><title>An error has occurred</title></head>
51 <body><h1>An error has occurred</h1>
52 <p>A problem was encountered processing your request.
53 The tracker maintainers have been notified of the problem.</p>
54 </body></html>'''
57 class LiberalCookie(SimpleCookie):
58     ''' Python's SimpleCookie throws an exception if the cookie uses invalid
59         syntax.  Other applications on the same server may have done precisely
60         this, preventing roundup from working through no fault of roundup.
61         Numerous other python apps have run into the same problem:
63         trac: http://trac.edgewall.org/ticket/2256
64         mailman: http://bugs.python.org/issue472646
66         This particular implementation comes from trac's solution to the
67         problem. Unfortunately it requires some hackery in SimpleCookie's
68         internals to provide a more liberal __set method.
69     '''
70     def load(self, rawdata, ignore_parse_errors=True):
71         if ignore_parse_errors:
72             self.bad_cookies = []
73             self._BaseCookie__set = self._loose_set
74         SimpleCookie.load(self, rawdata)
75         if ignore_parse_errors:
76             self._BaseCookie__set = self._strict_set
77             for key in self.bad_cookies:
78                 del self[key]
80     _strict_set = BaseCookie._BaseCookie__set
82     def _loose_set(self, key, real_value, coded_value):
83         try:
84             self._strict_set(key, real_value, coded_value)
85         except CookieError:
86             self.bad_cookies.append(key)
87             dict.__setitem__(self, key, None)
90 class Session:
91     '''
92     Needs DB to be already opened by client
94     Session attributes at instantiation:
96     - "client" - reference to client for add_cookie function
97     - "session_db" - session DB manager
98     - "cookie_name" - name of the cookie with session id
99     - "_sid" - session id for current user
100     - "_data" - session data cache
102     session = Session(client)
103     session.set(name=value)
104     value = session.get(name)
106     session.destroy()  # delete current session
107     session.clean_up() # clean up session table
109     session.update(set_cookie=True, expire=3600*24*365)
110                        # refresh session expiration time, setting persistent
111                        # cookie if needed to last for 'expire' seconds
113     '''
115     def __init__(self, client):
116         self._data = {}
117         self._sid  = None
119         self.client = client
120         self.session_db = client.db.getSessionManager()
122         # parse cookies for session id
123         self.cookie_name = 'roundup_session_%s' % \
124             re.sub('[^a-zA-Z]', '', client.instance.config.TRACKER_NAME)
125         cookies = LiberalCookie(client.env.get('HTTP_COOKIE', ''))
126         if self.cookie_name in cookies:
127             if not self.session_db.exists(cookies[self.cookie_name].value):
128                 self._sid = None
129                 # remove old cookie
130                 self.client.add_cookie(self.cookie_name, None)
131             else:
132                 self._sid = cookies[self.cookie_name].value
133                 self._data = self.session_db.getall(self._sid)
135     def _gen_sid(self):
136         ''' generate a unique session key '''
137         while 1:
138             s = '%s%s'%(time.time(), random.random())
139             s = binascii.b2a_base64(s).strip()
140             if not self.session_db.exists(s):
141                 break
143         # clean up the base64
144         if s[-1] == '=':
145             if s[-2] == '=':
146                 s = s[:-2]
147             else:
148                 s = s[:-1]
149         return s
151     def clean_up(self):
152         '''Remove expired sessions'''
153         self.session_db.clean()
155     def destroy(self):
156         self.client.add_cookie(self.cookie_name, None)
157         self._data = {}
158         self.session_db.destroy(self._sid)
159         self.client.db.commit()
161     def get(self, name, default=None):
162         return self._data.get(name, default)
164     def set(self, **kwargs):
165         self._data.update(kwargs)
166         if not self._sid:
167             self._sid = self._gen_sid()
168             self.session_db.set(self._sid, **self._data)
169             # add session cookie
170             self.update(set_cookie=True)
172             # XXX added when patching 1.4.4 for backward compatibility
173             # XXX remove
174             self.client.session = self._sid
175         else:
176             self.session_db.set(self._sid, **self._data)
177             self.client.db.commit()
179     def update(self, set_cookie=False, expire=None):
180         ''' update timestamp in db to avoid expiration
182             if 'set_cookie' is True, set cookie with 'expire' seconds lifetime
183             if 'expire' is None - session will be closed with the browser
184              
185             XXX the session can be purged within a week even if a cookie
186                 lifetime is longer
187         '''
188         self.session_db.updateTimestamp(self._sid)
189         self.client.db.commit()
191         if set_cookie:
192             self.client.add_cookie(self.cookie_name, self._sid, expire=expire)
196 class Client:
197     '''Instantiate to handle one CGI request.
199     See inner_main for request processing.
201     Client attributes at instantiation:
203     - "path" is the PATH_INFO inside the instance (with no leading '/')
204     - "base" is the base URL for the instance
205     - "form" is the cgi form, an instance of FieldStorage from the standard
206       cgi module
207     - "additional_headers" is a dictionary of additional HTTP headers that
208       should be sent to the client
209     - "response_code" is the HTTP response code to send to the client
210     - "translator" is TranslationService instance
212     During the processing of a request, the following attributes are used:
214     - "db" 
215     - "error_message" holds a list of error messages
216     - "ok_message" holds a list of OK messages
217     - "session" is deprecated in favor of session_api (XXX remove)
218     - "session_api" is the interface to store data in session
219     - "user" is the current user's name
220     - "userid" is the current user's id
221     - "template" is the current :template context
222     - "classname" is the current class context name
223     - "nodeid" is the current context item id
225     User Identification:
226      Users that are absent in session data are anonymous and are logged
227      in as that user. This typically gives them all Permissions assigned to the
228      Anonymous Role.
230      Every user is assigned a session. "session_api" is the interface to work
231      with session data.
233     Special form variables:
234      Note that in various places throughout this code, special form
235      variables of the form :<name> are used. The colon (":") part may
236      actually be one of either ":" or "@".
237     '''
239     # charset used for data storage and form templates
240     # Note: must be in lower case for comparisons!
241     # XXX take this from instance.config?
242     STORAGE_CHARSET = 'utf-8'
244     #
245     # special form variables
246     #
247     FV_TEMPLATE = re.compile(r'[@:]template')
248     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
249     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
251     # Note: index page stuff doesn't appear here:
252     # columns, sort, sortdir, filter, group, groupdir, search_text,
253     # pagesize, startwith
255     # list of network error codes that shouldn't be reported to tracker admin
256     # (error descriptions from FreeBSD intro(2))
257     IGNORE_NET_ERRORS = (
258         # A write on a pipe, socket or FIFO for which there is
259         # no process to read the data.
260         errno.EPIPE,
261         # A connection was forcibly closed by a peer.
262         # This normally results from a loss of the connection
263         # on the remote socket due to a timeout or a reboot.
264         errno.ECONNRESET,
265         # Software caused connection abort.  A connection abort
266         # was caused internal to your host machine.
267         errno.ECONNABORTED,
268         # A connect or send request failed because the connected party
269         # did not properly respond after a period of time.
270         errno.ETIMEDOUT,
271     )
273     def __init__(self, instance, request, env, form=None, translator=None):
274         # re-seed the random number generator
275         random.seed()
276         self.start = time.time()
277         self.instance = instance
278         self.request = request
279         self.env = env
280         self.setTranslator(translator)
281         self.mailer = Mailer(instance.config)
283         # save off the path
284         self.path = env['PATH_INFO']
286         # this is the base URL for this tracker
287         self.base = self.instance.config.TRACKER_WEB
289         # check the tracker_we setting
290         if not self.base.endswith('/'):
291             self.base = self.base + '/'
293         # this is the "cookie path" for this tracker (ie. the path part of
294         # the "base" url)
295         self.cookie_path = urlparse.urlparse(self.base)[2]
296         # cookies to set in http responce
297         # {(path, name): (value, expire)}
298         self._cookies = {}
300         # see if we need to re-parse the environment for the form (eg Zope)
301         if form is None:
302             self.form = cgi.FieldStorage(environ=env)
303         else:
304             self.form = form
306         # turn debugging on/off
307         try:
308             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
309         except ValueError:
310             # someone gave us a non-int debug level, turn it off
311             self.debug = 0
313         # flag to indicate that the HTTP headers have been sent
314         self.headers_done = 0
316         # additional headers to send with the request - must be registered
317         # before the first write
318         self.additional_headers = {}
319         self.response_code = 200
321         # default character set
322         self.charset = self.STORAGE_CHARSET
324         # parse cookies (used for charset lookups)
325         # use our own LiberalCookie to handle bad apps on the same
326         # server that have set cookies that are out of spec
327         self.cookie = LiberalCookie(self.env.get('HTTP_COOKIE', ''))
329         self.user = None
330         self.userid = None
331         self.nodeid = None
332         self.classname = None
333         self.template = None
335     def setTranslator(self, translator=None):
336         """Replace the translation engine
338         'translator'
339            is TranslationService instance.
340            It must define methods 'translate' (TAL-compatible i18n),
341            'gettext' and 'ngettext' (gettext-compatible i18n).
343            If omitted, create default TranslationService.
344         """
345         if translator is None:
346             translator = TranslationService.get_translation(
347                 language=self.instance.config["TRACKER_LANGUAGE"],
348                 tracker_home=self.instance.config["TRACKER_HOME"])
349         self.translator = translator
350         self._ = self.gettext = translator.gettext
351         self.ngettext = translator.ngettext
353     def main(self):
354         ''' Wrap the real main in a try/finally so we always close off the db.
355         '''
356         try:
357             self.inner_main()
358         finally:
359             if hasattr(self, 'db'):
360                 self.db.close()
362     def inner_main(self):
363         '''Process a request.
365         The most common requests are handled like so:
367         1. look for charset and language preferences, set up user locale
368            see determine_charset, determine_language
369         2. figure out who we are, defaulting to the "anonymous" user
370            see determine_user
371         3. figure out what the request is for - the context
372            see determine_context
373         4. handle any requested action (item edit, search, ...)
374            see handle_action
375         5. render a template, resulting in HTML output
377         In some situations, exceptions occur:
379         - HTTP Redirect  (generally raised by an action)
380         - SendFile       (generally raised by determine_context)
381           serve up a FileClass "content" property
382         - SendStaticFile (generally raised by determine_context)
383           serve up a file from the tracker "html" directory
384         - Unauthorised   (generally raised by an action)
385           the action is cancelled, the request is rendered and an error
386           message is displayed indicating that permission was not
387           granted for the action to take place
388         - templating.Unauthorised   (templating action not permitted)
389           raised by an attempted rendering of a template when the user
390           doesn't have permission
391         - NotFound       (raised wherever it needs to be)
392           percolates up to the CGI interface that called the client
393         '''
394         self.ok_message = []
395         self.error_message = []
396         try:
397             self.determine_charset()
398             self.determine_language()
400             # make sure we're identified (even anonymously)
401             self.determine_user()
403             # figure out the context and desired content template
404             self.determine_context()
406             # possibly handle a form submit action (may change self.classname
407             # and self.template, and may also append error/ok_messages)
408             html = self.handle_action()
410             if html:
411                 self.write_html(html)
412                 return
414             # now render the page
415             # we don't want clients caching our dynamic pages
416             self.additional_headers['Cache-Control'] = 'no-cache'
417 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
418 #            self.additional_headers['Pragma'] = 'no-cache'
420             # pages with messages added expire right now
421             # simple views may be cached for a small amount of time
422             # TODO? make page expire time configurable
423             # <rj> always expire pages, as IE just doesn't seem to do the
424             # right thing here :(
425             date = time.time() - 1
426             #if self.error_message or self.ok_message:
427             #    date = time.time() - 1
428             #else:
429             #    date = time.time() + 5
430             self.additional_headers['Expires'] = rfc822.formatdate(date)
432             # render the content
433             try:
434                 self.write_html(self.renderContext())
435             except IOError:
436                 # IOErrors here are due to the client disconnecting before
437                 # recieving the reply.
438                 pass
440         except SeriousError, message:
441             self.write_html(str(message))
442         except Redirect, url:
443             # let's redirect - if the url isn't None, then we need to do
444             # the headers, otherwise the headers have been set before the
445             # exception was raised
446             if url:
447                 self.additional_headers['Location'] = str(url)
448                 self.response_code = 302
449             self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
450         except SendFile, designator:
451             try:
452                 self.serve_file(designator)
453             except NotModified:
454                 # send the 304 response
455                 self.response_code = 304
456                 self.header()
457         except SendStaticFile, file:
458             try:
459                 self.serve_static_file(str(file))
460             except NotModified:
461                 # send the 304 response
462                 self.response_code = 304
463                 self.header()
464         except Unauthorised, message:
465             # users may always see the front page
466             self.classname = self.nodeid = None
467             self.template = ''
468             self.error_message.append(message)
469             self.write_html(self.renderContext())
470         except NotFound, e:
471             self.response_code = 404
472             self.template = '404'
473             try:
474                 cl = self.db.getclass(self.classname)
475                 self.write_html(self.renderContext())
476             except KeyError:
477                 # we can't map the URL to a class we know about
478                 # reraise the NotFound and let roundup_server
479                 # handle it
480                 raise NotFound, e
481         except FormError, e:
482             self.error_message.append(self._('Form Error: ') + str(e))
483             self.write_html(self.renderContext())
484         except:
485             if self.instance.config.WEB_DEBUG:
486                 self.write_html(cgitb.html(i18n=self.translator))
487             else:
488                 self.mailer.exception_message()
489                 return self.write_html(self._(error_message))
491     def clean_sessions(self):
492         """Deprecated
493            XXX remove
494         """
495         self.clean_up()
497     def clean_up(self):
498         """Remove expired sessions and One Time Keys.
500            Do it only once an hour.
501         """
502         hour = 60*60
503         now = time.time()
505         # XXX: hack - use OTK table to store last_clean time information
506         #      'last_clean' string is used instead of otk key
507         last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
508         if now - last_clean < hour:
509             return
511         self.session_api.clean_up()
512         self.db.getOTKManager().clean()
513         self.db.getOTKManager().set('last_clean', last_use=now)
514         self.db.commit(fail_ok=True)
516     def determine_charset(self):
517         """Look for client charset in the form parameters or browser cookie.
519         If no charset requested by client, use storage charset (utf-8).
521         If the charset is found, and differs from the storage charset,
522         recode all form fields of type 'text/plain'
523         """
524         # look for client charset
525         charset_parameter = 0
526         if self.form.has_key('@charset'):
527             charset = self.form['@charset'].value
528             if charset.lower() == "none":
529                 charset = ""
530             charset_parameter = 1
531         elif self.cookie.has_key('roundup_charset'):
532             charset = self.cookie['roundup_charset'].value
533         else:
534             charset = None
535         if charset:
536             # make sure the charset is recognized
537             try:
538                 codecs.lookup(charset)
539             except LookupError:
540                 self.error_message.append(self._('Unrecognized charset: %r')
541                     % charset)
542                 charset_parameter = 0
543             else:
544                 self.charset = charset.lower()
545         # If we've got a character set in request parameters,
546         # set the browser cookie to keep the preference.
547         # This is done after codecs.lookup to make sure
548         # that we aren't keeping a wrong value.
549         if charset_parameter:
550             self.add_cookie('roundup_charset', charset)
552         # if client charset is different from the storage charset,
553         # recode form fields
554         # XXX this requires FieldStorage from Python library.
555         #   mod_python FieldStorage is not supported!
556         if self.charset != self.STORAGE_CHARSET:
557             decoder = codecs.getdecoder(self.charset)
558             encoder = codecs.getencoder(self.STORAGE_CHARSET)
559             re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
560             def _decode_charref(matchobj):
561                 num = matchobj.group(1)
562                 if num[0].lower() == 'x':
563                     uc = int(num[1:], 16)
564                 else:
565                     uc = int(num)
566                 return unichr(uc)
568             for field_name in self.form.keys():
569                 field = self.form[field_name]
570                 if (field.type == 'text/plain') and not field.filename:
571                     try:
572                         value = decoder(field.value)[0]
573                     except UnicodeError:
574                         continue
575                     value = re_charref.sub(_decode_charref, value)
576                     field.value = encoder(value)[0]
578     def determine_language(self):
579         """Determine the language"""
580         # look for language parameter
581         # then for language cookie
582         # last for the Accept-Language header
583         if self.form.has_key("@language"):
584             language = self.form["@language"].value
585             if language.lower() == "none":
586                 language = ""
587             self.add_cookie("roundup_language", language)
588         elif self.cookie.has_key("roundup_language"):
589             language = self.cookie["roundup_language"].value
590         elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
591             hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
592             language = accept_language.parse(hal)
593         else:
594             language = ""
596         self.language = language
597         if language:
598             self.setTranslator(TranslationService.get_translation(
599                     language,
600                     tracker_home=self.instance.config["TRACKER_HOME"]))
602     def determine_user(self):
603         """Determine who the user is"""
604         self.opendb('admin')
606         # get session data from db
607         # XXX: rename
608         self.session_api = Session(self)
610         # take the opportunity to cleanup expired sessions and otks
611         self.clean_up()
613         user = None
614         # first up, try http authorization if enabled
615         if self.instance.config['WEB_HTTP_AUTH']:
616             if self.env.has_key('REMOTE_USER'):
617                 # we have external auth (e.g. by Apache)
618                 user = self.env['REMOTE_USER']
619             elif self.env.get('HTTP_AUTHORIZATION', ''):
620                 # try handling Basic Auth ourselves
621                 auth = self.env['HTTP_AUTHORIZATION']
622                 scheme, challenge = auth.split(' ', 1)
623                 if scheme.lower() == 'basic':
624                     try:
625                         decoded = base64.decodestring(challenge)
626                     except TypeError:
627                         # invalid challenge
628                         pass
629                     username, password = decoded.split(':')
630                     try:
631                         login = self.get_action_class('login')(self)
632                         login.verifyLogin(username, password)
633                     except LoginError, err:
634                         self.make_user_anonymous()
635                         self.response_code = 403
636                         raise Unauthorised, err
638                     user = username
640         # if user was not set by http authorization, try session lookup
641         if not user:
642             user = self.session_api.get('user')
643             if user:
644                 # update session lifetime datestamp
645                 self.session_api.update()
647         # if no user name set by http authorization or session lookup
648         # the user is anonymous
649         if not user:
650             user = 'anonymous'
652         # sanity check on the user still being valid,
653         # getting the userid at the same time
654         try:
655             self.userid = self.db.user.lookup(user)
656         except (KeyError, TypeError):
657             user = 'anonymous'
659         # make sure the anonymous user is valid if we're using it
660         if user == 'anonymous':
661             self.make_user_anonymous()
662             if not self.db.security.hasPermission('Web Access', self.userid):
663                 raise Unauthorised, self._("Anonymous users are not "
664                     "allowed to use the web interface")
665         else:
666             self.user = user
668         # reopen the database as the correct user
669         self.opendb(self.user)
671     def opendb(self, username):
672         """Open the database and set the current user.
674         Opens a database once. On subsequent calls only the user is set on
675         the database object the instance.optimize is set. If we are in
676         "Development Mode" (cf. roundup_server) then the database is always
677         re-opened.
678         """
679         # don't do anything if the db is open and the user has not changed
680         if hasattr(self, 'db') and self.db.isCurrentUser(username):
681             return
683         # open the database or only set the user
684         if not hasattr(self, 'db'):
685             self.db = self.instance.open(username)
686         else:
687             if self.instance.optimize:
688                 self.db.setCurrentUser(username)
689             else:
690                 self.db.close()
691                 self.db = self.instance.open(username)
693     def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
694         """Determine the context of this page from the URL:
696         The URL path after the instance identifier is examined. The path
697         is generally only one entry long.
699         - if there is no path, then we are in the "home" context.
700         - if the path is "_file", then the additional path entry
701           specifies the filename of a static file we're to serve up
702           from the instance "html" directory. Raises a SendStaticFile
703           exception.(*)
704         - if there is something in the path (eg "issue"), it identifies
705           the tracker class we're to display.
706         - if the path is an item designator (eg "issue123"), then we're
707           to display a specific item.
708         - if the path starts with an item designator and is longer than
709           one entry, then we're assumed to be handling an item of a
710           FileClass, and the extra path information gives the filename
711           that the client is going to label the download with (ie
712           "file123/image.png" is nicer to download than "file123"). This
713           raises a SendFile exception.(*)
715         Both of the "*" types of contexts stop before we bother to
716         determine the template we're going to use. That's because they
717         don't actually use templates.
719         The template used is specified by the :template CGI variable,
720         which defaults to:
722         - only classname suplied:          "index"
723         - full item designator supplied:   "item"
725         We set:
727              self.classname  - the class to display, can be None
729              self.template   - the template to render the current context with
731              self.nodeid     - the nodeid of the class we're displaying
732         """
733         # default the optional variables
734         self.classname = None
735         self.nodeid = None
737         # see if a template or messages are specified
738         template_override = ok_message = error_message = None
739         for key in self.form.keys():
740             if self.FV_TEMPLATE.match(key):
741                 template_override = self.form[key].value
742             elif self.FV_OK_MESSAGE.match(key):
743                 ok_message = self.form[key].value
744                 ok_message = clean_message(ok_message)
745             elif self.FV_ERROR_MESSAGE.match(key):
746                 error_message = self.form[key].value
747                 error_message = clean_message(error_message)
749         # see if we were passed in a message
750         if ok_message:
751             self.ok_message.append(ok_message)
752         if error_message:
753             self.error_message.append(error_message)
755         # determine the classname and possibly nodeid
756         path = self.path.split('/')
757         if not path or path[0] in ('', 'home', 'index'):
758             if template_override is not None:
759                 self.template = template_override
760             else:
761                 self.template = ''
762             return
763         elif path[0] in ('_file', '@@file'):
764             raise SendStaticFile, os.path.join(*path[1:])
765         else:
766             self.classname = path[0]
767             if len(path) > 1:
768                 # send the file identified by the designator in path[0]
769                 raise SendFile, path[0]
771         # see if we got a designator
772         m = dre.match(self.classname)
773         if m:
774             self.classname = m.group(1)
775             self.nodeid = m.group(2)
776             try:
777                 klass = self.db.getclass(self.classname)
778             except KeyError:
779                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
780             if not klass.hasnode(self.nodeid):
781                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
782             # with a designator, we default to item view
783             self.template = 'item'
784         else:
785             # with only a class, we default to index view
786             self.template = 'index'
788         # make sure the classname is valid
789         try:
790             self.db.getclass(self.classname)
791         except KeyError:
792             raise NotFound, self.classname
794         # see if we have a template override
795         if template_override is not None:
796             self.template = template_override
798     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
799         ''' Serve the file from the content property of the designated item.
800         '''
801         m = dre.match(str(designator))
802         if not m:
803             raise NotFound, str(designator)
804         classname, nodeid = m.group(1), m.group(2)
806         klass = self.db.getclass(classname)
808         # make sure we have the appropriate properties
809         props = klass.getprops()
810         if not props.has_key('type'):
811             raise NotFound, designator
812         if not props.has_key('content'):
813             raise NotFound, designator
815         # make sure we have permission
816         if not self.db.security.hasPermission('View', self.userid,
817                 classname, 'content', nodeid):
818             raise Unauthorised, self._("You are not allowed to view "
819                 "this file.")
821         mime_type = klass.get(nodeid, 'type')
823         # If this object is a file (i.e., an instance of FileClass),
824         # see if we can find it in the filesystem.  If so, we may be
825         # able to use the more-efficient request.sendfile method of
826         # sending the file.  If not, just get the "content" property
827         # in the usual way, and use that.
828         content = None
829         filename = None
830         if isinstance(klass, hyperdb.FileClass):
831             try:
832                 filename = self.db.filename(classname, nodeid)
833             except AttributeError:
834                 # The database doesn't store files in the filesystem
835                 # and therefore doesn't provide the "filename" method.
836                 pass
837             except IOError:
838                 # The file does not exist.
839                 pass
840         if not filename:
841             content = klass.get(nodeid, 'content')
842         
843         lmt = klass.get(nodeid, 'activity').timestamp()
845         self._serve_file(lmt, mime_type, content, filename)
847     def serve_static_file(self, file):
848         ''' Serve up the file named from the templates dir
849         '''
850         # figure the filename - try STATIC_FILES, then TEMPLATES dir
851         for dir_option in ('STATIC_FILES', 'TEMPLATES'):
852             prefix = self.instance.config[dir_option]
853             if not prefix:
854                 continue
855             # ensure the load doesn't try to poke outside
856             # of the static files directory
857             prefix = os.path.normpath(prefix)
858             filename = os.path.normpath(os.path.join(prefix, file))
859             if os.path.isfile(filename) and filename.startswith(prefix):
860                 break
861         else:
862             raise NotFound, file
864         # last-modified time
865         lmt = os.stat(filename)[stat.ST_MTIME]
867         # detemine meta-type
868         file = str(file)
869         mime_type = mimetypes.guess_type(file)[0]
870         if not mime_type:
871             if file.endswith('.css'):
872                 mime_type = 'text/css'
873             else:
874                 mime_type = 'text/plain'
876         self._serve_file(lmt, mime_type, '', filename)
878     def _serve_file(self, lmt, mime_type, content=None, filename=None):
879         ''' guts of serve_file() and serve_static_file()
880         '''
882         if not content:
883             length = os.stat(filename)[stat.ST_SIZE]
884         else:
885             length = len(content)
887         # spit out headers
888         self.additional_headers['Content-Type'] = mime_type
889         self.additional_headers['Content-Length'] = str(length)
890         self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
892         ims = None
893         # see if there's an if-modified-since...
894         # XXX see which interfaces set this
895         #if hasattr(self.request, 'headers'):
896             #ims = self.request.headers.getheader('if-modified-since')
897         if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
898             # cgi will put the header in the env var
899             ims = self.env['HTTP_IF_MODIFIED_SINCE']
900         if ims:
901             ims = rfc822.parsedate(ims)[:6]
902             lmtt = time.gmtime(lmt)[:6]
903             if lmtt <= ims:
904                 raise NotModified
906         if not self.headers_done:
907             self.header()
909         if self.env['REQUEST_METHOD'] == 'HEAD':
910             return
912         # If we have a file, and the 'sendfile' method is available,
913         # we can bypass reading and writing the content into application
914         # memory entirely.
915         if filename:
916             if hasattr(self.request, 'sendfile'):
917                 self._socket_op(self.request.sendfile, filename)
918                 return
919             f = open(filename, 'rb')
920             try:
921                 content = f.read()
922             finally:
923                 f.close()
925         self._socket_op(self.request.wfile.write, content)
928     def renderContext(self):
929         ''' Return a PageTemplate for the named page
930         '''
931         name = self.classname
932         extension = self.template
934         # catch errors so we can handle PT rendering errors more nicely
935         args = {
936             'ok_message': self.ok_message,
937             'error_message': self.error_message
938         }
939         try:
940             pt = self.instance.templates.get(name, extension)
941             # let the template render figure stuff out
942             result = pt.render(self, None, None, **args)
943             self.additional_headers['Content-Type'] = pt.content_type
944             if self.env.get('CGI_SHOW_TIMING', ''):
945                 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
946                     timings = {'starttag': '<!-- ', 'endtag': ' -->'}
947                 else:
948                     timings = {'starttag': '<p>', 'endtag': '</p>'}
949                 timings['seconds'] = time.time()-self.start
950                 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
951                     ) % timings
952                 if hasattr(self.db, 'stats'):
953                     timings.update(self.db.stats)
954                     s += self._("%(starttag)sCache hits: %(cache_hits)d,"
955                         " misses %(cache_misses)d."
956                         " Loading items: %(get_items)f secs."
957                         " Filtering: %(filtering)f secs."
958                         "%(endtag)s\n") % timings
959                 s += '</body>'
960                 result = result.replace('</body>', s)
961             return result
962         except templating.NoTemplate, message:
963             return '<strong>%s</strong>'%message
964         except templating.Unauthorised, message:
965             raise Unauthorised, str(message)
966         except:
967             # everything else
968             if self.instance.config.WEB_DEBUG:
969                 return cgitb.pt_html(i18n=self.translator)
970             exc_info = sys.exc_info()
971             try:
972                 # If possible, send the HTML page template traceback
973                 # to the administrator.
974                 to = [self.mailer.config.ADMIN_EMAIL]
975                 subject = "Templating Error: %s" % exc_info[1]
976                 content = cgitb.pt_html()
977                 message, writer = self.mailer.get_standard_message(
978                     to, subject)
979                 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
980                 body = writer.startbody('text/html; charset=utf-8')
981                 content = StringIO(content)
982                 quopri.encode(content, body, 0)
983                 self.mailer.smtp_send(to, message)
984                 # Now report the error to the user.
985                 return self._(error_message)
986             except:
987                 # Reraise the original exception.  The user will
988                 # receive an error message, and the adminstrator will
989                 # receive a traceback, albeit with less information
990                 # than the one we tried to generate above.
991                 raise exc_info[0], exc_info[1], exc_info[2]
993     # these are the actions that are available
994     actions = (
995         ('edit',        EditItemAction),
996         ('editcsv',     EditCSVAction),
997         ('new',         NewItemAction),
998         ('register',    RegisterAction),
999         ('confrego',    ConfRegoAction),
1000         ('passrst',     PassResetAction),
1001         ('login',       LoginAction),
1002         ('logout',      LogoutAction),
1003         ('search',      SearchAction),
1004         ('retire',      RetireAction),
1005         ('show',        ShowAction),
1006         ('export_csv',  ExportCSVAction),
1007     )
1008     def handle_action(self):
1009         ''' Determine whether there should be an Action called.
1011             The action is defined by the form variable :action which
1012             identifies the method on this object to call. The actions
1013             are defined in the "actions" sequence on this class.
1015             Actions may return a page (by default HTML) to return to the
1016             user, bypassing the usual template rendering.
1018             We explicitly catch Reject and ValueError exceptions and
1019             present their messages to the user.
1020         '''
1021         if self.form.has_key(':action'):
1022             action = self.form[':action'].value.lower()
1023         elif self.form.has_key('@action'):
1024             action = self.form['@action'].value.lower()
1025         else:
1026             return None
1028         try:
1029             action_klass = self.get_action_class(action)
1031             # call the mapped action
1032             if isinstance(action_klass, type('')):
1033                 # old way of specifying actions
1034                 return getattr(self, action_klass)()
1035             else:
1036                 return action_klass(self).execute()
1038         except (ValueError, Reject), err:
1039             self.error_message.append(str(err))
1041     def get_action_class(self, action_name):
1042         if (hasattr(self.instance, 'cgi_actions') and
1043                 self.instance.cgi_actions.has_key(action_name)):
1044             # tracker-defined action
1045             action_klass = self.instance.cgi_actions[action_name]
1046         else:
1047             # go with a default
1048             for name, action_klass in self.actions:
1049                 if name == action_name:
1050                     break
1051             else:
1052                 raise ValueError, 'No such action "%s"'%action_name
1053         return action_klass
1055     def _socket_op(self, call, *args, **kwargs):
1056         """Execute socket-related operation, catch common network errors
1058         Parameters:
1059             call: a callable to execute
1060             args, kwargs: call arguments
1062         """
1063         try:
1064             call(*args, **kwargs)
1065         except socket.error, err:
1066             err_errno = getattr (err, 'errno', None)
1067             if err_errno is None:
1068                 try:
1069                     err_errno = err[0]
1070                 except TypeError:
1071                     pass
1072             if err_errno not in self.IGNORE_NET_ERRORS:
1073                 raise
1075     def write(self, content):
1076         if not self.headers_done:
1077             self.header()
1078         if self.env['REQUEST_METHOD'] != 'HEAD':
1079             self._socket_op(self.request.wfile.write, content)
1081     def write_html(self, content):
1082         if not self.headers_done:
1083             # at this point, we are sure about Content-Type
1084             if not self.additional_headers.has_key('Content-Type'):
1085                 self.additional_headers['Content-Type'] = \
1086                     'text/html; charset=%s' % self.charset
1087             self.header()
1089         if self.env['REQUEST_METHOD'] == 'HEAD':
1090             # client doesn't care about content
1091             return
1093         if self.charset != self.STORAGE_CHARSET:
1094             # recode output
1095             content = content.decode(self.STORAGE_CHARSET, 'replace')
1096             content = content.encode(self.charset, 'xmlcharrefreplace')
1098         # and write
1099         self._socket_op(self.request.wfile.write, content)
1102     def setHeader(self, header, value):
1103         '''Override a header to be returned to the user's browser.
1104         '''
1105         self.additional_headers[header] = value
1107     def header(self, headers=None, response=None):
1108         '''Put up the appropriate header.
1109         '''
1110         if headers is None:
1111             headers = {'Content-Type':'text/html; charset=utf-8'}
1112         if response is None:
1113             response = self.response_code
1115         # update with additional info
1116         headers.update(self.additional_headers)
1118         if headers.get('Content-Type', 'text/html') == 'text/html':
1119             headers['Content-Type'] = 'text/html; charset=utf-8'
1121         headers = headers.items()
1123         for ((path, name), (value, expire)) in self._cookies.items():
1124             cookie = "%s=%s; Path=%s;"%(name, value, path)
1125             if expire is not None:
1126                 cookie += " expires=%s;"%Cookie._getdate(expire)
1127             headers.append(('Set-Cookie', cookie))
1129         self._socket_op(self.request.start_response, headers, response)
1131         self.headers_done = 1
1132         if self.debug:
1133             self.headers_sent = headers
1135     def add_cookie(self, name, value, expire=86400*365, path=None):
1136         """Set a cookie value to be sent in HTTP headers
1138         Parameters:
1139             name:
1140                 cookie name
1141             value:
1142                 cookie value
1143             expire:
1144                 cookie expiration time (seconds).
1145                 If value is empty (meaning "delete cookie"),
1146                 expiration time is forced in the past
1147                 and this argument is ignored.
1148                 If None, the cookie will expire at end-of-session.
1149                 If omitted, the cookie will be kept for a year.
1150             path:
1151                 cookie path (optional)
1153         """
1154         if path is None:
1155             path = self.cookie_path
1156         if not value:
1157             expire = -1
1158         self._cookies[(path, name)] = (value, expire)
1160     def set_cookie(self, user, expire=None):
1161         """Deprecated. Use session_api calls directly
1163         XXX remove
1164         """
1166         # insert the session in the session db
1167         self.session_api.set(user=user)
1168         # refresh session cookie
1169         self.session_api.update(set_cookie=True, expire=expire)
1171     def make_user_anonymous(self):
1172         ''' Make us anonymous
1174             This method used to handle non-existence of the 'anonymous'
1175             user, but that user is mandatory now.
1176         '''
1177         self.userid = self.db.user.lookup('anonymous')
1178         self.user = 'anonymous'
1180     def standard_message(self, to, subject, body, author=None):
1181         '''Send a standard email message from Roundup.
1183         "to"      - recipients list
1184         "subject" - Subject
1185         "body"    - Message
1186         "author"  - (name, address) tuple or None for admin email
1188         Arguments are passed to the Mailer.standard_message code.
1189         '''
1190         try:
1191             self.mailer.standard_message(to, subject, body, author)
1192         except MessageSendError, e:
1193             self.error_message.append(str(e))
1194             return 0
1195         return 1
1197     def parsePropsFromForm(self, create=0):
1198         return FormParser(self).parse(create=create)
1200 # vim: set et sts=4 sw=4 :