Code

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