Code

d438e3e9adc7415efdab95985fd88694c2412da1
[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')
822         content = klass.get(nodeid, 'content')
823         lmt = klass.get(nodeid, 'activity').timestamp()
825         self._serve_file(lmt, mime_type, content)
827     def serve_static_file(self, file):
828         ''' Serve up the file named from the templates dir
829         '''
830         # figure the filename - try STATIC_FILES, then TEMPLATES dir
831         for dir_option in ('STATIC_FILES', 'TEMPLATES'):
832             prefix = self.instance.config[dir_option]
833             if not prefix:
834                 continue
835             # ensure the load doesn't try to poke outside
836             # of the static files directory
837             prefix = os.path.normpath(prefix)
838             filename = os.path.normpath(os.path.join(prefix, file))
839             if os.path.isfile(filename) and filename.startswith(prefix):
840                 break
841         else:
842             raise NotFound, file
844         # last-modified time
845         lmt = os.stat(filename)[stat.ST_MTIME]
847         # detemine meta-type
848         file = str(file)
849         mime_type = mimetypes.guess_type(file)[0]
850         if not mime_type:
851             if file.endswith('.css'):
852                 mime_type = 'text/css'
853             else:
854                 mime_type = 'text/plain'
856         # snarf the content
857         f = open(filename, 'rb')
858         try:
859             content = f.read()
860         finally:
861             f.close()
863         self._serve_file(lmt, mime_type, content)
865     def _serve_file(self, lmt, mime_type, content):
866         ''' guts of serve_file() and serve_static_file()
867         '''
868         # spit out headers
869         self.additional_headers['Content-Type'] = mime_type
870         self.additional_headers['Content-Length'] = str(len(content))
871         self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
873         ims = None
874         # see if there's an if-modified-since...
875         # XXX see which interfaces set this
876         #if hasattr(self.request, 'headers'):
877             #ims = self.request.headers.getheader('if-modified-since')
878         if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
879             # cgi will put the header in the env var
880             ims = self.env['HTTP_IF_MODIFIED_SINCE']
881         if ims:
882             ims = rfc822.parsedate(ims)[:6]
883             lmtt = time.gmtime(lmt)[:6]
884             if lmtt <= ims:
885                 raise NotModified
887         self.write(content)
889     def renderContext(self):
890         ''' Return a PageTemplate for the named page
891         '''
892         name = self.classname
893         extension = self.template
895         # catch errors so we can handle PT rendering errors more nicely
896         args = {
897             'ok_message': self.ok_message,
898             'error_message': self.error_message
899         }
900         try:
901             pt = self.instance.templates.get(name, extension)
902             # let the template render figure stuff out
903             result = pt.render(self, None, None, **args)
904             self.additional_headers['Content-Type'] = pt.content_type
905             if self.env.get('CGI_SHOW_TIMING', ''):
906                 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
907                     timings = {'starttag': '<!-- ', 'endtag': ' -->'}
908                 else:
909                     timings = {'starttag': '<p>', 'endtag': '</p>'}
910                 timings['seconds'] = time.time()-self.start
911                 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
912                     ) % timings
913                 if hasattr(self.db, 'stats'):
914                     timings.update(self.db.stats)
915                     s += self._("%(starttag)sCache hits: %(cache_hits)d,"
916                         " misses %(cache_misses)d."
917                         " Loading items: %(get_items)f secs."
918                         " Filtering: %(filtering)f secs."
919                         "%(endtag)s\n") % timings
920                 s += '</body>'
921                 result = result.replace('</body>', s)
922             return result
923         except templating.NoTemplate, message:
924             return '<strong>%s</strong>'%message
925         except templating.Unauthorised, message:
926             raise Unauthorised, str(message)
927         except:
928             # everything else
929             if self.instance.config.WEB_DEBUG:
930                 return cgitb.pt_html(i18n=self.translator)
931             exc_info = sys.exc_info()
932             try:
933                 # If possible, send the HTML page template traceback
934                 # to the administrator.
935                 to = [self.mailer.config.ADMIN_EMAIL]
936                 subject = "Templating Error: %s" % exc_info[1]
937                 content = cgitb.pt_html()
938                 message, writer = self.mailer.get_standard_message(
939                     to, subject)
940                 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
941                 body = writer.startbody('text/html; charset=utf-8')
942                 content = StringIO(content)
943                 quopri.encode(content, body, 0)
944                 self.mailer.smtp_send(to, message)
945                 # Now report the error to the user.
946                 return self._(error_message)
947             except:
948                 # Reraise the original exception.  The user will
949                 # receive an error message, and the adminstrator will
950                 # receive a traceback, albeit with less information
951                 # than the one we tried to generate above.
952                 raise exc_info[0], exc_info[1], exc_info[2]
954     # these are the actions that are available
955     actions = (
956         ('edit',        EditItemAction),
957         ('editcsv',     EditCSVAction),
958         ('new',         NewItemAction),
959         ('register',    RegisterAction),
960         ('confrego',    ConfRegoAction),
961         ('passrst',     PassResetAction),
962         ('login',       LoginAction),
963         ('logout',      LogoutAction),
964         ('search',      SearchAction),
965         ('retire',      RetireAction),
966         ('show',        ShowAction),
967         ('export_csv',  ExportCSVAction),
968     )
969     def handle_action(self):
970         ''' Determine whether there should be an Action called.
972             The action is defined by the form variable :action which
973             identifies the method on this object to call. The actions
974             are defined in the "actions" sequence on this class.
976             Actions may return a page (by default HTML) to return to the
977             user, bypassing the usual template rendering.
979             We explicitly catch Reject and ValueError exceptions and
980             present their messages to the user.
981         '''
982         if self.form.has_key(':action'):
983             action = self.form[':action'].value.lower()
984         elif self.form.has_key('@action'):
985             action = self.form['@action'].value.lower()
986         else:
987             return None
989         try:
990             action_klass = self.get_action_class(action)
992             # call the mapped action
993             if isinstance(action_klass, type('')):
994                 # old way of specifying actions
995                 return getattr(self, action_klass)()
996             else:
997                 return action_klass(self).execute()
999         except (ValueError, Reject), err:
1000             self.error_message.append(str(err))
1002     def get_action_class(self, action_name):
1003         if (hasattr(self.instance, 'cgi_actions') and
1004                 self.instance.cgi_actions.has_key(action_name)):
1005             # tracker-defined action
1006             action_klass = self.instance.cgi_actions[action_name]
1007         else:
1008             # go with a default
1009             for name, action_klass in self.actions:
1010                 if name == action_name:
1011                     break
1012             else:
1013                 raise ValueError, 'No such action "%s"'%action_name
1014         return action_klass
1016     def _socket_op(self, call, *args, **kwargs):
1017         """Execute socket-related operation, catch common network errors
1019         Parameters:
1020             call: a callable to execute
1021             args, kwargs: call arguments
1023         """
1024         try:
1025             call(*args, **kwargs)
1026         except socket.error, err:
1027             err_errno = getattr (err, 'errno', None)
1028             if err_errno is None:
1029                 try:
1030                     err_errno = err[0]
1031                 except TypeError:
1032                     pass
1033             if err_errno not in self.IGNORE_NET_ERRORS:
1034                 raise
1036     def write(self, content):
1037         if not self.headers_done:
1038             self.header()
1039         if self.env['REQUEST_METHOD'] != 'HEAD':
1040             self._socket_op(self.request.wfile.write, content)
1042     def write_html(self, content):
1043         if not self.headers_done:
1044             # at this point, we are sure about Content-Type
1045             if not self.additional_headers.has_key('Content-Type'):
1046                 self.additional_headers['Content-Type'] = \
1047                     'text/html; charset=%s' % self.charset
1048             self.header()
1050         if self.env['REQUEST_METHOD'] == 'HEAD':
1051             # client doesn't care about content
1052             return
1054         if self.charset != self.STORAGE_CHARSET:
1055             # recode output
1056             content = content.decode(self.STORAGE_CHARSET, 'replace')
1057             content = content.encode(self.charset, 'xmlcharrefreplace')
1059         # and write
1060         self._socket_op(self.request.wfile.write, content)
1062     def setHeader(self, header, value):
1063         '''Override a header to be returned to the user's browser.
1064         '''
1065         self.additional_headers[header] = value
1067     def header(self, headers=None, response=None):
1068         '''Put up the appropriate header.
1069         '''
1070         if headers is None:
1071             headers = {'Content-Type':'text/html; charset=utf-8'}
1072         if response is None:
1073             response = self.response_code
1075         # update with additional info
1076         headers.update(self.additional_headers)
1078         if headers.get('Content-Type', 'text/html') == 'text/html':
1079             headers['Content-Type'] = 'text/html; charset=utf-8'
1081         headers = headers.items()
1083         for ((path, name), (value, expire)) in self._cookies.items():
1084             cookie = "%s=%s; Path=%s;"%(name, value, path)
1085             if expire is not None:
1086                 cookie += " expires=%s;"%Cookie._getdate(expire)
1087             headers.append(('Set-Cookie', cookie))
1089         self._socket_op(self.request.start_response, headers, response)
1091         self.headers_done = 1
1092         if self.debug:
1093             self.headers_sent = headers
1095     def add_cookie(self, name, value, expire=86400*365, path=None):
1096         """Set a cookie value to be sent in HTTP headers
1098         Parameters:
1099             name:
1100                 cookie name
1101             value:
1102                 cookie value
1103             expire:
1104                 cookie expiration time (seconds).
1105                 If value is empty (meaning "delete cookie"),
1106                 expiration time is forced in the past
1107                 and this argument is ignored.
1108                 If None, the cookie will expire at end-of-session.
1109                 If omitted, the cookie will be kept for a year.
1110             path:
1111                 cookie path (optional)
1113         """
1114         if path is None:
1115             path = self.cookie_path
1116         if not value:
1117             expire = -1
1118         self._cookies[(path, name)] = (value, expire)
1120     def set_cookie(self, user, expire=None):
1121         """Deprecated. Use session_api calls directly
1123         XXX remove
1124         """
1126         # insert the session in the session db
1127         self.session_api.set(user=user)
1128         # refresh session cookie
1129         self.session_api.update(set_cookie=True, expire=expire)
1131     def make_user_anonymous(self):
1132         ''' Make us anonymous
1134             This method used to handle non-existence of the 'anonymous'
1135             user, but that user is mandatory now.
1136         '''
1137         self.userid = self.db.user.lookup('anonymous')
1138         self.user = 'anonymous'
1140     def standard_message(self, to, subject, body, author=None):
1141         '''Send a standard email message from Roundup.
1143         "to"      - recipients list
1144         "subject" - Subject
1145         "body"    - Message
1146         "author"  - (name, address) tuple or None for admin email
1148         Arguments are passed to the Mailer.standard_message code.
1149         '''
1150         try:
1151             self.mailer.standard_message(to, subject, body, author)
1152         except MessageSendError, e:
1153             self.error_message.append(str(e))
1154             return 0
1155         return 1
1157     def parsePropsFromForm(self, create=0):
1158         return FormParser(self).parse(create=create)
1160 # vim: set et sts=4 sw=4 :