Code

Fix issue2550517
[roundup.git] / roundup / cgi / client.py
1 """WWW request handler (also used in the stand-alone server).
2 """
3 __docformat__ = 'restructuredtext'
5 import base64, binascii, cgi, codecs, httplib, mimetypes, os
6 import quopri, random, re, rfc822, stat, sys, time, urllib, urlparse
7 import Cookie, socket, errno
8 from Cookie import CookieError, BaseCookie, SimpleCookie
9 from cStringIO import StringIO
11 from roundup import roundupdb, date, hyperdb, password
12 from roundup.cgi import templating, cgitb, TranslationService
13 from roundup.cgi.actions import *
14 from roundup.exceptions import *
15 from roundup.cgi.exceptions import *
16 from roundup.cgi.form_parser import FormParser
17 from roundup.mailer import Mailer, MessageSendError
18 from roundup.cgi import accept_language
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()
361         
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.response_code = 403
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                         raise Unauthorised, err
637                     user = username
639         # if user was not set by http authorization, try session lookup
640         if not user:
641             user = self.session_api.get('user')
642             if user:
643                 # update session lifetime datestamp
644                 self.session_api.update()
646         # if no user name set by http authorization or session lookup
647         # the user is anonymous
648         if not user:
649             user = 'anonymous'
651         # sanity check on the user still being valid,
652         # getting the userid at the same time
653         try:
654             self.userid = self.db.user.lookup(user)
655         except (KeyError, TypeError):
656             user = 'anonymous'
658         # make sure the anonymous user is valid if we're using it
659         if user == 'anonymous':
660             self.make_user_anonymous()
661             if not self.db.security.hasPermission('Web Access', self.userid):
662                 raise Unauthorised, self._("Anonymous users are not "
663                     "allowed to use the web interface")
664         else:
665             self.user = user
667         # reopen the database as the correct user
668         self.opendb(self.user)
670     def opendb(self, username):
671         """Open the database and set the current user.
673         Opens a database once. On subsequent calls only the user is set on
674         the database object the instance.optimize is set. If we are in
675         "Development Mode" (cf. roundup_server) then the database is always
676         re-opened.
677         """
678         # don't do anything if the db is open and the user has not changed
679         if hasattr(self, 'db') and self.db.isCurrentUser(username):
680             return
682         # open the database or only set the user
683         if not hasattr(self, 'db'):
684             self.db = self.instance.open(username)
685         else:
686             if self.instance.optimize:
687                 self.db.setCurrentUser(username)
688             else:
689                 self.db.close()
690                 self.db = self.instance.open(username)
692     def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
693         """Determine the context of this page from the URL:
695         The URL path after the instance identifier is examined. The path
696         is generally only one entry long.
698         - if there is no path, then we are in the "home" context.
699         - if the path is "_file", then the additional path entry
700           specifies the filename of a static file we're to serve up
701           from the instance "html" directory. Raises a SendStaticFile
702           exception.(*)
703         - if there is something in the path (eg "issue"), it identifies
704           the tracker class we're to display.
705         - if the path is an item designator (eg "issue123"), then we're
706           to display a specific item.
707         - if the path starts with an item designator and is longer than
708           one entry, then we're assumed to be handling an item of a
709           FileClass, and the extra path information gives the filename
710           that the client is going to label the download with (ie
711           "file123/image.png" is nicer to download than "file123"). This
712           raises a SendFile exception.(*)
714         Both of the "*" types of contexts stop before we bother to
715         determine the template we're going to use. That's because they
716         don't actually use templates.
718         The template used is specified by the :template CGI variable,
719         which defaults to:
721         - only classname suplied:          "index"
722         - full item designator supplied:   "item"
724         We set:
726              self.classname  - the class to display, can be None
728              self.template   - the template to render the current context with
730              self.nodeid     - the nodeid of the class we're displaying
731         """
732         # default the optional variables
733         self.classname = None
734         self.nodeid = None
736         # see if a template or messages are specified
737         template_override = ok_message = error_message = None
738         for key in self.form.keys():
739             if self.FV_TEMPLATE.match(key):
740                 template_override = self.form[key].value
741             elif self.FV_OK_MESSAGE.match(key):
742                 ok_message = self.form[key].value
743                 ok_message = clean_message(ok_message)
744             elif self.FV_ERROR_MESSAGE.match(key):
745                 error_message = self.form[key].value
746                 error_message = clean_message(error_message)
748         # see if we were passed in a message
749         if ok_message:
750             self.ok_message.append(ok_message)
751         if error_message:
752             self.error_message.append(error_message)
754         # determine the classname and possibly nodeid
755         path = self.path.split('/')
756         if not path or path[0] in ('', 'home', 'index'):
757             if template_override is not None:
758                 self.template = template_override
759             else:
760                 self.template = ''
761             return
762         elif path[0] in ('_file', '@@file'):
763             raise SendStaticFile, os.path.join(*path[1:])
764         else:
765             self.classname = path[0]
766             if len(path) > 1:
767                 # send the file identified by the designator in path[0]
768                 raise SendFile, path[0]
770         # see if we got a designator
771         m = dre.match(self.classname)
772         if m:
773             self.classname = m.group(1)
774             self.nodeid = m.group(2)
775             try:
776                 klass = self.db.getclass(self.classname)
777             except KeyError:
778                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
779             if not klass.hasnode(self.nodeid):
780                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
781             # with a designator, we default to item view
782             self.template = 'item'
783         else:
784             # with only a class, we default to index view
785             self.template = 'index'
787         # make sure the classname is valid
788         try:
789             self.db.getclass(self.classname)
790         except KeyError:
791             raise NotFound, self.classname
793         # see if we have a template override
794         if template_override is not None:
795             self.template = template_override
797     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
798         """ Serve the file from the content property of the designated item.
799         """
800         m = dre.match(str(designator))
801         if not m:
802             raise NotFound, str(designator)
803         classname, nodeid = m.group(1), m.group(2)
805         klass = self.db.getclass(classname)
807         # make sure we have the appropriate properties
808         props = klass.getprops()
809         if not props.has_key('type'):
810             raise NotFound, designator
811         if not props.has_key('content'):
812             raise NotFound, designator
814         # make sure we have permission
815         if not self.db.security.hasPermission('View', self.userid,
816                 classname, 'content', nodeid):
817             raise Unauthorised, self._("You are not allowed to view "
818                 "this file.")
820         mime_type = klass.get(nodeid, 'type')
822         # If this object is a file (i.e., an instance of FileClass),
823         # see if we can find it in the filesystem.  If so, we may be
824         # able to use the more-efficient request.sendfile method of
825         # sending the file.  If not, just get the "content" property
826         # in the usual way, and use that.
827         content = None
828         filename = None
829         if isinstance(klass, hyperdb.FileClass):
830             try:
831                 filename = self.db.filename(classname, nodeid)
832             except AttributeError:
833                 # The database doesn't store files in the filesystem
834                 # and therefore doesn't provide the "filename" method.
835                 pass
836             except IOError:
837                 # The file does not exist.
838                 pass
839         if not filename:
840             content = klass.get(nodeid, 'content')
841         
842         lmt = klass.get(nodeid, 'activity').timestamp()
844         self._serve_file(lmt, mime_type, content, filename)
846     def serve_static_file(self, file):
847         """ Serve up the file named from the templates dir
848         """
849         # figure the filename - try STATIC_FILES, then TEMPLATES dir
850         for dir_option in ('STATIC_FILES', 'TEMPLATES'):
851             prefix = self.instance.config[dir_option]
852             if not prefix:
853                 continue
854             # ensure the load doesn't try to poke outside
855             # of the static files directory
856             prefix = os.path.normpath(prefix)
857             filename = os.path.normpath(os.path.join(prefix, file))
858             if os.path.isfile(filename) and filename.startswith(prefix):
859                 break
860         else:
861             raise NotFound, file
863         # last-modified time
864         lmt = os.stat(filename)[stat.ST_MTIME]
866         # detemine meta-type
867         file = str(file)
868         mime_type = mimetypes.guess_type(file)[0]
869         if not mime_type:
870             if file.endswith('.css'):
871                 mime_type = 'text/css'
872             else:
873                 mime_type = 'text/plain'
875         self._serve_file(lmt, mime_type, '', filename)
877     def _serve_file(self, lmt, mime_type, content=None, filename=None):
878         """ guts of serve_file() and serve_static_file()
879         """
881         # spit out headers
882         self.additional_headers['Content-Type'] = mime_type
883         self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
885         ims = None
886         # see if there's an if-modified-since...
887         # XXX see which interfaces set this
888         #if hasattr(self.request, 'headers'):
889             #ims = self.request.headers.getheader('if-modified-since')
890         if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
891             # cgi will put the header in the env var
892             ims = self.env['HTTP_IF_MODIFIED_SINCE']
893         if ims:
894             ims = rfc822.parsedate(ims)[:6]
895             lmtt = time.gmtime(lmt)[:6]
896             if lmtt <= ims:
897                 raise NotModified
899         if filename:
900             self.write_file(filename)
901         else:
902             self.additional_headers['Content-Length'] = str(len(content))
903             self.write(content)
905     def renderContext(self):
906         """ Return a PageTemplate for the named page
907         """
908         name = self.classname
909         extension = self.template
911         # catch errors so we can handle PT rendering errors more nicely
912         args = {
913             'ok_message': self.ok_message,
914             'error_message': self.error_message
915         }
916         try:
917             pt = self.instance.templates.get(name, extension)
918             # let the template render figure stuff out
919             result = pt.render(self, None, None, **args)
920             self.additional_headers['Content-Type'] = pt.content_type
921             if self.env.get('CGI_SHOW_TIMING', ''):
922                 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
923                     timings = {'starttag': '<!-- ', 'endtag': ' -->'}
924                 else:
925                     timings = {'starttag': '<p>', 'endtag': '</p>'}
926                 timings['seconds'] = time.time()-self.start
927                 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
928                     ) % timings
929                 if hasattr(self.db, 'stats'):
930                     timings.update(self.db.stats)
931                     s += self._("%(starttag)sCache hits: %(cache_hits)d,"
932                         " misses %(cache_misses)d."
933                         " Loading items: %(get_items)f secs."
934                         " Filtering: %(filtering)f secs."
935                         "%(endtag)s\n") % timings
936                 s += '</body>'
937                 result = result.replace('</body>', s)
938             return result
939         except templating.NoTemplate, message:
940             return '<strong>%s</strong>'%message
941         except templating.Unauthorised, message:
942             raise Unauthorised, str(message)
943         except:
944             # everything else
945             if self.instance.config.WEB_DEBUG:
946                 return cgitb.pt_html(i18n=self.translator)
947             exc_info = sys.exc_info()
948             try:
949                 # If possible, send the HTML page template traceback
950                 # to the administrator.
951                 to = [self.mailer.config.ADMIN_EMAIL]
952                 subject = "Templating Error: %s" % exc_info[1]
953                 content = cgitb.pt_html()
954                 message, writer = self.mailer.get_standard_message(
955                     to, subject)
956                 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
957                 body = writer.startbody('text/html; charset=utf-8')
958                 content = StringIO(content)
959                 quopri.encode(content, body, 0)
960                 self.mailer.smtp_send(to, message)
961                 # Now report the error to the user.
962                 return self._(error_message)
963             except:
964                 # Reraise the original exception.  The user will
965                 # receive an error message, and the adminstrator will
966                 # receive a traceback, albeit with less information
967                 # than the one we tried to generate above.
968                 raise exc_info[0], exc_info[1], exc_info[2]
970     # these are the actions that are available
971     actions = (
972         ('edit',        EditItemAction),
973         ('editcsv',     EditCSVAction),
974         ('new',         NewItemAction),
975         ('register',    RegisterAction),
976         ('confrego',    ConfRegoAction),
977         ('passrst',     PassResetAction),
978         ('login',       LoginAction),
979         ('logout',      LogoutAction),
980         ('search',      SearchAction),
981         ('retire',      RetireAction),
982         ('show',        ShowAction),
983         ('export_csv',  ExportCSVAction),
984     )
985     def handle_action(self):
986         """ Determine whether there should be an Action called.
988             The action is defined by the form variable :action which
989             identifies the method on this object to call. The actions
990             are defined in the "actions" sequence on this class.
992             Actions may return a page (by default HTML) to return to the
993             user, bypassing the usual template rendering.
995             We explicitly catch Reject and ValueError exceptions and
996             present their messages to the user.
997         """
998         if self.form.has_key(':action'):
999             action = self.form[':action'].value.lower()
1000         elif self.form.has_key('@action'):
1001             action = self.form['@action'].value.lower()
1002         else:
1003             return None
1005         try:
1006             action_klass = self.get_action_class(action)
1008             # call the mapped action
1009             if isinstance(action_klass, type('')):
1010                 # old way of specifying actions
1011                 return getattr(self, action_klass)()
1012             else:
1013                 return action_klass(self).execute()
1015         except (ValueError, Reject), err:
1016             self.error_message.append(str(err))
1018     def get_action_class(self, action_name):
1019         if (hasattr(self.instance, 'cgi_actions') and
1020                 self.instance.cgi_actions.has_key(action_name)):
1021             # tracker-defined action
1022             action_klass = self.instance.cgi_actions[action_name]
1023         else:
1024             # go with a default
1025             for name, action_klass in self.actions:
1026                 if name == action_name:
1027                     break
1028             else:
1029                 raise ValueError, 'No such action "%s"'%action_name
1030         return action_klass
1032     def _socket_op(self, call, *args, **kwargs):
1033         """Execute socket-related operation, catch common network errors
1035         Parameters:
1036             call: a callable to execute
1037             args, kwargs: call arguments
1039         """
1040         try:
1041             call(*args, **kwargs)
1042         except socket.error, err:
1043             err_errno = getattr (err, 'errno', None)
1044             if err_errno is None:
1045                 try:
1046                     err_errno = err[0]
1047                 except TypeError:
1048                     pass
1049             if err_errno not in self.IGNORE_NET_ERRORS:
1050                 raise
1051         except IOError:
1052             # Apache's mod_python will raise IOError -- without an
1053             # accompanying errno -- when a write to the client fails.
1054             # A common case is that the client has closed the
1055             # connection.  There's no way to be certain that this is
1056             # the situation that has occurred here, but that is the
1057             # most likely case.
1058             pass
1060     def write(self, content):
1061         if not self.headers_done:
1062             self.header()
1063         if self.env['REQUEST_METHOD'] != 'HEAD':
1064             self._socket_op(self.request.wfile.write, content)
1066     def write_html(self, content):
1067         if not self.headers_done:
1068             # at this point, we are sure about Content-Type
1069             if not self.additional_headers.has_key('Content-Type'):
1070                 self.additional_headers['Content-Type'] = \
1071                     'text/html; charset=%s' % self.charset
1072             self.header()
1074         if self.env['REQUEST_METHOD'] == 'HEAD':
1075             # client doesn't care about content
1076             return
1078         if self.charset != self.STORAGE_CHARSET:
1079             # recode output
1080             content = content.decode(self.STORAGE_CHARSET, 'replace')
1081             content = content.encode(self.charset, 'xmlcharrefreplace')
1083         # and write
1084         self._socket_op(self.request.wfile.write, content)
1086     def http_strip(self, content):
1087         """Remove HTTP Linear White Space from 'content'.
1089         'content' -- A string.
1091         returns -- 'content', with all leading and trailing LWS
1092         removed."""
1094         # RFC 2616 2.2: Basic Rules
1095         #
1096         # LWS = [CRLF] 1*( SP | HT )
1097         return content.strip(" \r\n\t")
1099     def http_split(self, content):
1100         """Split an HTTP list.
1102         'content' -- A string, giving a list of items.
1104         returns -- A sequence of strings, containing the elements of
1105         the list."""
1107         # RFC 2616 2.1: Augmented BNF
1108         #
1109         # Grammar productions of the form "#rule" indicate a
1110         # comma-separated list of elements matching "rule".  LWS
1111         # is then removed from each element, and empty elements
1112         # removed.
1114         # Split at commas.
1115         elements = content.split(",")
1116         # Remove linear whitespace at either end of the string.
1117         elements = [self.http_strip(e) for e in elements]
1118         # Remove any now-empty elements.
1119         return [e for e in elements if e]
1120         
1121     def handle_range_header(self, length, etag):
1122         """Handle the 'Range' and 'If-Range' headers.
1124         'length' -- the length of the content available for the
1125         resource.
1127         'etag' -- the entity tag for this resources.
1129         returns -- If the request headers (including 'Range' and
1130         'If-Range') indicate that only a portion of the entity should
1131         be returned, then the return value is a pair '(offfset,
1132         length)' indicating the first byte and number of bytes of the
1133         content that should be returned to the client.  In addition,
1134         this method will set 'self.response_code' to indicate Partial
1135         Content.  In all other cases, the return value is 'None'.  If
1136         appropriate, 'self.response_code' will be
1137         set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'.  In that
1138         case, the caller should not send any data to the client."""
1140         # RFC 2616 14.35: Range
1141         #
1142         # See if the Range header is present.
1143         ranges_specifier = self.env.get("HTTP_RANGE")
1144         if ranges_specifier is None:
1145             return None
1146         # RFC 2616 14.27: If-Range
1147         #
1148         # Check to see if there is an If-Range header.
1149         # Because the specification says:
1150         #
1151         #  The If-Range header ... MUST be ignored if the request
1152         #  does not include a Range header, we check for If-Range
1153         #  after checking for Range.
1154         if_range = self.env.get("HTTP_IF_RANGE")
1155         if if_range:
1156             # The grammar for the If-Range header is:
1157             # 
1158             #   If-Range = "If-Range" ":" ( entity-tag | HTTP-date )
1159             #   entity-tag = [ weak ] opaque-tag
1160             #   weak = "W/"
1161             #   opaque-tag = quoted-string
1162             #
1163             # We only support strong entity tags.
1164             if_range = self.http_strip(if_range)
1165             if (not if_range.startswith('"')
1166                 or not if_range.endswith('"')):
1167                 return None
1168             # If the condition doesn't match the entity tag, then we
1169             # must send the client the entire file.
1170             if if_range != etag:
1171                 return
1172         # The grammar for the Range header value is:
1173         #
1174         #   ranges-specifier = byte-ranges-specifier
1175         #   byte-ranges-specifier = bytes-unit "=" byte-range-set
1176         #   byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
1177         #   byte-range-spec = first-byte-pos "-" [last-byte-pos]
1178         #   first-byte-pos = 1*DIGIT
1179         #   last-byte-pos = 1*DIGIT
1180         #   suffix-byte-range-spec = "-" suffix-length
1181         #   suffix-length = 1*DIGIT
1182         #
1183         # Look for the "=" separating the units from the range set.
1184         specs = ranges_specifier.split("=", 1)
1185         if len(specs) != 2:
1186             return None
1187         # Check that the bytes-unit is in fact "bytes".  If it is not,
1188         # we do not know how to process this range.
1189         bytes_unit = self.http_strip(specs[0])
1190         if bytes_unit != "bytes":
1191             return None
1192         # Seperate the range-set into range-specs.
1193         byte_range_set = self.http_strip(specs[1])
1194         byte_range_specs = self.http_split(byte_range_set)
1195         # We only handle exactly one range at this time.
1196         if len(byte_range_specs) != 1:
1197             return None
1198         # Parse the spec.
1199         byte_range_spec = byte_range_specs[0]
1200         pos = byte_range_spec.split("-", 1)
1201         if len(pos) != 2:
1202             return None
1203         # Get the first and last bytes.
1204         first = self.http_strip(pos[0])
1205         last = self.http_strip(pos[1])
1206         # We do not handle suffix ranges.
1207         if not first:
1208             return None
1209        # Convert the first and last positions to integers.
1210         try:
1211             first = int(first)
1212             if last:
1213                 last = int(last)
1214             else:
1215                 last = length - 1
1216         except:
1217             # The positions could not be parsed as integers.
1218             return None
1219         # Check that the range makes sense.
1220         if (first < 0 or last < 0 or last < first):
1221             return None
1222         if last >= length:
1223             # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable
1224             #
1225             # If there is an If-Range header, RFC 2616 says that we
1226             # should just ignore the invalid Range header.
1227             if if_range:
1228                 return None
1229             # Return code 416 with a Content-Range header giving the
1230             # allowable range.
1231             self.response_code = httplib.REQUESTED_RANGE_NOT_SATISFIABLE
1232             self.setHeader("Content-Range", "bytes */%d" % length)
1233             return None
1234         # RFC 2616 10.2.7: 206 Partial Content
1235         #
1236         # Tell the client that we are honoring the Range request by
1237         # indicating that we are providing partial content.
1238         self.response_code = httplib.PARTIAL_CONTENT
1239         # RFC 2616 14.16: Content-Range
1240         #
1241         # Tell the client what data we are providing.
1242         #
1243         #   content-range-spec = byte-content-range-spec
1244         #   byte-content-range-spec = bytes-unit SP
1245         #                             byte-range-resp-spec "/"
1246         #                             ( instance-length | "*" )
1247         #   byte-range-resp-spec = (first-byte-pos "-" last-byte-pos)
1248         #                          | "*"
1249         #   instance-length      = 1 * DIGIT
1250         self.setHeader("Content-Range",
1251                        "bytes %d-%d/%d" % (first, last, length))
1252         return (first, last - first + 1)
1254     def write_file(self, filename):
1255         """Send the contents of 'filename' to the user."""
1257         # Determine the length of the file.
1258         stat_info = os.stat(filename)
1259         length = stat_info[stat.ST_SIZE]
1260         # Assume we will return the entire file.
1261         offset = 0
1262         # If the headers have not already been finalized, 
1263         if not self.headers_done:
1264             # RFC 2616 14.19: ETag
1265             #
1266             # Compute the entity tag, in a format similar to that
1267             # used by Apache.
1268             etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
1269                                    length,
1270                                    stat_info[stat.ST_MTIME])
1271             self.setHeader("ETag", etag)
1272             # RFC 2616 14.5: Accept-Ranges
1273             #
1274             # Let the client know that we will accept range requests.
1275             self.setHeader("Accept-Ranges", "bytes")
1276             # RFC 2616 14.35: Range
1277             #
1278             # If there is a Range header, we may be able to avoid
1279             # sending the entire file.
1280             content_range = self.handle_range_header(length, etag)
1281             if content_range:
1282                 offset, length = content_range
1283             # RFC 2616 14.13: Content-Length
1284             #
1285             # Tell the client how much data we are providing.
1286             self.setHeader("Content-Length", length)
1287             # Send the HTTP header.
1288             self.header()
1289         # If the client doesn't actually want the body, or if we are
1290         # indicating an invalid range.
1291         if (self.env['REQUEST_METHOD'] == 'HEAD'
1292             or self.response_code == httplib.REQUESTED_RANGE_NOT_SATISFIABLE):
1293             return
1294         # Use the optimized "sendfile" operation, if possible.
1295         if hasattr(self.request, "sendfile"):
1296             self._socket_op(self.request.sendfile, filename, offset, length)
1297             return
1298         # Fallback to the "write" operation.
1299         f = open(filename, 'rb')
1300         try:
1301             if offset:
1302                 f.seek(offset)
1303             content = f.read(length)
1304         finally:
1305             f.close()
1306         self.write(content)
1308     def setHeader(self, header, value):
1309         """Override a header to be returned to the user's browser.
1310         """
1311         self.additional_headers[header] = value
1313     def header(self, headers=None, response=None):
1314         """Put up the appropriate header.
1315         """
1316         if headers is None:
1317             headers = {'Content-Type':'text/html; charset=utf-8'}
1318         if response is None:
1319             response = self.response_code
1321         # update with additional info
1322         headers.update(self.additional_headers)
1324         if headers.get('Content-Type', 'text/html') == 'text/html':
1325             headers['Content-Type'] = 'text/html; charset=utf-8'
1327         headers = headers.items()
1329         for ((path, name), (value, expire)) in self._cookies.items():
1330             cookie = "%s=%s; Path=%s;"%(name, value, path)
1331             if expire is not None:
1332                 cookie += " expires=%s;"%Cookie._getdate(expire)
1333             headers.append(('Set-Cookie', cookie))
1335         self._socket_op(self.request.start_response, headers, response)
1337         self.headers_done = 1
1338         if self.debug:
1339             self.headers_sent = headers
1341     def add_cookie(self, name, value, expire=86400*365, path=None):
1342         """Set a cookie value to be sent in HTTP headers
1344         Parameters:
1345             name:
1346                 cookie name
1347             value:
1348                 cookie value
1349             expire:
1350                 cookie expiration time (seconds).
1351                 If value is empty (meaning "delete cookie"),
1352                 expiration time is forced in the past
1353                 and this argument is ignored.
1354                 If None, the cookie will expire at end-of-session.
1355                 If omitted, the cookie will be kept for a year.
1356             path:
1357                 cookie path (optional)
1359         """
1360         if path is None:
1361             path = self.cookie_path
1362         if not value:
1363             expire = -1
1364         self._cookies[(path, name)] = (value, expire)
1366     def set_cookie(self, user, expire=None):
1367         """Deprecated. Use session_api calls directly
1369         XXX remove
1370         """
1372         # insert the session in the session db
1373         self.session_api.set(user=user)
1374         # refresh session cookie
1375         self.session_api.update(set_cookie=True, expire=expire)
1377     def make_user_anonymous(self):
1378         """ Make us anonymous
1380             This method used to handle non-existence of the 'anonymous'
1381             user, but that user is mandatory now.
1382         """
1383         self.userid = self.db.user.lookup('anonymous')
1384         self.user = 'anonymous'
1386     def standard_message(self, to, subject, body, author=None):
1387         """Send a standard email message from Roundup.
1389         "to"      - recipients list
1390         "subject" - Subject
1391         "body"    - Message
1392         "author"  - (name, address) tuple or None for admin email
1394         Arguments are passed to the Mailer.standard_message code.
1395         """
1396         try:
1397             self.mailer.standard_message(to, subject, body, author)
1398         except MessageSendError, e:
1399             self.error_message.append(str(e))
1400             return 0
1401         return 1
1403     def parsePropsFromForm(self, create=0):
1404         return FormParser(self).parse(create=create)
1406 # vim: set et sts=4 sw=4 :