Code

Improve error reporting.
[roundup.git] / roundup / cgi / client.py
1 """WWW request handler (also used in the stand-alone server).
2 """
3 __docformat__ = 'restructuredtext'
5 import base64, binascii, cgi, codecs, httplib, mimetypes, os
6 import quopri, random, re, rfc822, stat, sys, time, urllib, urlparse
7 import Cookie, socket, errno
8 from Cookie import CookieError, BaseCookie, SimpleCookie
9 from cStringIO import StringIO
11 from roundup import roundupdb, date, hyperdb, password
12 from roundup.cgi import templating, cgitb, TranslationService
13 from roundup.cgi.actions import *
14 from roundup.exceptions import *
15 from roundup.cgi.exceptions import *
16 from roundup.cgi.form_parser import FormParser
17 from roundup.mailer import Mailer, MessageSendError, encode_quopri
18 from roundup.cgi import accept_language
19 from roundup import xmlrpc
21 def initialiseSecurity(security):
22     '''Create some Permissions and Roles on the security object
24     This function is directly invoked by security.Security.__init__()
25     as a part of the Security object instantiation.
26     '''
27     p = security.addPermission(name="Web Access",
28         description="User may access the web interface")
29     security.addPermissionToRole('Admin', p)
31     # doing Role stuff through the web - make sure Admin can
32     # TODO: deprecate this and use a property-based control
33     p = security.addPermission(name="Web Roles",
34         description="User may manipulate user Roles through the web")
35     security.addPermissionToRole('Admin', p)
37 # used to clean messages passed through CGI variables - HTML-escape any tag
38 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
39 # that people can't pass through nasties like <script>, <iframe>, ...
40 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
41 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
42     return mc.sub(clean_message_callback, message)
43 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
44     """ Strip all non <a>,<i>,<b> and <br> tags from a string
45     """
46     if ok.has_key(match.group(3).lower()):
47         return match.group(1)
48     return '&lt;%s&gt;'%match.group(2)
51 error_message = ''"""<html><head><title>An error has occurred</title></head>
52 <body><h1>An error has occurred</h1>
53 <p>A problem was encountered processing your request.
54 The tracker maintainers have been notified of the problem.</p>
55 </body></html>"""
58 class LiberalCookie(SimpleCookie):
59     """ Python's SimpleCookie throws an exception if the cookie uses invalid
60         syntax.  Other applications on the same server may have done precisely
61         this, preventing roundup from working through no fault of roundup.
62         Numerous other python apps have run into the same problem:
64         trac: http://trac.edgewall.org/ticket/2256
65         mailman: http://bugs.python.org/issue472646
67         This particular implementation comes from trac's solution to the
68         problem. Unfortunately it requires some hackery in SimpleCookie's
69         internals to provide a more liberal __set method.
70     """
71     def load(self, rawdata, ignore_parse_errors=True):
72         if ignore_parse_errors:
73             self.bad_cookies = []
74             self._BaseCookie__set = self._loose_set
75         SimpleCookie.load(self, rawdata)
76         if ignore_parse_errors:
77             self._BaseCookie__set = self._strict_set
78             for key in self.bad_cookies:
79                 del self[key]
81     _strict_set = BaseCookie._BaseCookie__set
83     def _loose_set(self, key, real_value, coded_value):
84         try:
85             self._strict_set(key, real_value, coded_value)
86         except CookieError:
87             self.bad_cookies.append(key)
88             dict.__setitem__(self, key, None)
91 class Session:
92     """
93     Needs DB to be already opened by client
95     Session attributes at instantiation:
97     - "client" - reference to client for add_cookie function
98     - "session_db" - session DB manager
99     - "cookie_name" - name of the cookie with session id
100     - "_sid" - session id for current user
101     - "_data" - session data cache
103     session = Session(client)
104     session.set(name=value)
105     value = session.get(name)
107     session.destroy()  # delete current session
108     session.clean_up() # clean up session table
110     session.update(set_cookie=True, expire=3600*24*365)
111                        # refresh session expiration time, setting persistent
112                        # cookie if needed to last for 'expire' seconds
114     """
116     def __init__(self, client):
117         self._data = {}
118         self._sid  = None
120         self.client = client
121         self.session_db = client.db.getSessionManager()
123         # parse cookies for session id
124         self.cookie_name = 'roundup_session_%s' % \
125             re.sub('[^a-zA-Z]', '', client.instance.config.TRACKER_NAME)
126         cookies = LiberalCookie(client.env.get('HTTP_COOKIE', ''))
127         if self.cookie_name in cookies:
128             if not self.session_db.exists(cookies[self.cookie_name].value):
129                 self._sid = None
130                 # remove old cookie
131                 self.client.add_cookie(self.cookie_name, None)
132             else:
133                 self._sid = cookies[self.cookie_name].value
134                 self._data = self.session_db.getall(self._sid)
136     def _gen_sid(self):
137         """ generate a unique session key """
138         while 1:
139             s = '%s%s'%(time.time(), random.random())
140             s = binascii.b2a_base64(s).strip()
141             if not self.session_db.exists(s):
142                 break
144         # clean up the base64
145         if s[-1] == '=':
146             if s[-2] == '=':
147                 s = s[:-2]
148             else:
149                 s = s[:-1]
150         return s
152     def clean_up(self):
153         """Remove expired sessions"""
154         self.session_db.clean()
156     def destroy(self):
157         self.client.add_cookie(self.cookie_name, None)
158         self._data = {}
159         self.session_db.destroy(self._sid)
160         self.client.db.commit()
162     def get(self, name, default=None):
163         return self._data.get(name, default)
165     def set(self, **kwargs):
166         self._data.update(kwargs)
167         if not self._sid:
168             self._sid = self._gen_sid()
169             self.session_db.set(self._sid, **self._data)
170             # add session cookie
171             self.update(set_cookie=True)
173             # XXX added when patching 1.4.4 for backward compatibility
174             # XXX remove
175             self.client.session = self._sid
176         else:
177             self.session_db.set(self._sid, **self._data)
178             self.client.db.commit()
180     def update(self, set_cookie=False, expire=None):
181         """ update timestamp in db to avoid expiration
183             if 'set_cookie' is True, set cookie with 'expire' seconds lifetime
184             if 'expire' is None - session will be closed with the browser
185              
186             XXX the session can be purged within a week even if a cookie
187                 lifetime is longer
188         """
189         self.session_db.updateTimestamp(self._sid)
190         self.client.db.commit()
192         if set_cookie:
193             self.client.add_cookie(self.cookie_name, self._sid, expire=expire)
197 class Client:
198     """Instantiate to handle one CGI request.
200     See inner_main for request processing.
202     Client attributes at instantiation:
204     - "path" is the PATH_INFO inside the instance (with no leading '/')
205     - "base" is the base URL for the instance
206     - "form" is the cgi form, an instance of FieldStorage from the standard
207       cgi module
208     - "additional_headers" is a dictionary of additional HTTP headers that
209       should be sent to the client
210     - "response_code" is the HTTP response code to send to the client
211     - "translator" is TranslationService instance
213     During the processing of a request, the following attributes are used:
215     - "db" 
216     - "error_message" holds a list of error messages
217     - "ok_message" holds a list of OK messages
218     - "session" is deprecated in favor of session_api (XXX remove)
219     - "session_api" is the interface to store data in session
220     - "user" is the current user's name
221     - "userid" is the current user's id
222     - "template" is the current :template context
223     - "classname" is the current class context name
224     - "nodeid" is the current context item id
226     User Identification:
227      Users that are absent in session data are anonymous and are logged
228      in as that user. This typically gives them all Permissions assigned to the
229      Anonymous Role.
231      Every user is assigned a session. "session_api" is the interface to work
232      with session data.
234     Special form variables:
235      Note that in various places throughout this code, special form
236      variables of the form :<name> are used. The colon (":") part may
237      actually be one of either ":" or "@".
238     """
240     # charset used for data storage and form templates
241     # Note: must be in lower case for comparisons!
242     # XXX take this from instance.config?
243     STORAGE_CHARSET = 'utf-8'
245     #
246     # special form variables
247     #
248     FV_TEMPLATE = re.compile(r'[@:]template')
249     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
250     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
252     # Note: index page stuff doesn't appear here:
253     # columns, sort, sortdir, filter, group, groupdir, search_text,
254     # pagesize, startwith
256     # list of network error codes that shouldn't be reported to tracker admin
257     # (error descriptions from FreeBSD intro(2))
258     IGNORE_NET_ERRORS = (
259         # A write on a pipe, socket or FIFO for which there is
260         # no process to read the data.
261         errno.EPIPE,
262         # A connection was forcibly closed by a peer.
263         # This normally results from a loss of the connection
264         # on the remote socket due to a timeout or a reboot.
265         errno.ECONNRESET,
266         # Software caused connection abort.  A connection abort
267         # was caused internal to your host machine.
268         errno.ECONNABORTED,
269         # A connect or send request failed because the connected party
270         # did not properly respond after a period of time.
271         errno.ETIMEDOUT,
272     )
274     def __init__(self, instance, request, env, form=None, translator=None):
275         # re-seed the random number generator
276         random.seed()
277         self.start = time.time()
278         self.instance = instance
279         self.request = request
280         self.env = env
281         self.setTranslator(translator)
282         self.mailer = Mailer(instance.config)
284         # save off the path
285         self.path = env['PATH_INFO']
287         # this is the base URL for this tracker
288         self.base = self.instance.config.TRACKER_WEB
290         # check the tracker_we setting
291         if not self.base.endswith('/'):
292             self.base = self.base + '/'
294         # this is the "cookie path" for this tracker (ie. the path part of
295         # the "base" url)
296         self.cookie_path = urlparse.urlparse(self.base)[2]
297         # cookies to set in http responce
298         # {(path, name): (value, expire)}
299         self._cookies = {}
301         # see if we need to re-parse the environment for the form (eg Zope)
302         if form is None:
303             self.form = cgi.FieldStorage(environ=env)
304         else:
305             self.form = form
307         # turn debugging on/off
308         try:
309             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
310         except ValueError:
311             # someone gave us a non-int debug level, turn it off
312             self.debug = 0
314         # flag to indicate that the HTTP headers have been sent
315         self.headers_done = 0
317         # additional headers to send with the request - must be registered
318         # before the first write
319         self.additional_headers = {}
320         self.response_code = 200
322         # default character set
323         self.charset = self.STORAGE_CHARSET
325         # parse cookies (used for charset lookups)
326         # use our own LiberalCookie to handle bad apps on the same
327         # server that have set cookies that are out of spec
328         self.cookie = LiberalCookie(self.env.get('HTTP_COOKIE', ''))
330         self.user = None
331         self.userid = None
332         self.nodeid = None
333         self.classname = None
334         self.template = None
336     def setTranslator(self, translator=None):
337         """Replace the translation engine
339         'translator'
340            is TranslationService instance.
341            It must define methods 'translate' (TAL-compatible i18n),
342            'gettext' and 'ngettext' (gettext-compatible i18n).
344            If omitted, create default TranslationService.
345         """
346         if translator is None:
347             translator = TranslationService.get_translation(
348                 language=self.instance.config["TRACKER_LANGUAGE"],
349                 tracker_home=self.instance.config["TRACKER_HOME"])
350         self.translator = translator
351         self._ = self.gettext = translator.gettext
352         self.ngettext = translator.ngettext
354     def main(self):
355         """ Wrap the real main in a try/finally so we always close off the db.
356         """
357         try:
358             if self.env.get('CONTENT_TYPE') == 'text/xml':
359                 self.handle_xmlrpc()
360             else:
361                 self.inner_main()
362         finally:
363             if hasattr(self, 'db'):
364                 self.db.close()
367     def handle_xmlrpc(self):
369         # Pull the raw XML out of the form.  The "value" attribute
370         # will be the raw content of the POST request.
371         assert self.form.file
372         input = self.form.value
373         # So that the rest of Roundup can query the form in the
374         # usual way, we create an empty list of fields.
375         self.form.list = []
377         # Set the charset and language, since other parts of
378         # Roundup may depend upon that.
379         self.determine_charset()
380         self.determine_language()
381         # Open the database as the correct user.
382         self.determine_user()
384         # Call the appropriate XML-RPC method.
385         handler = xmlrpc.RoundupDispatcher(self.db,
386                                            self.instance.actions,
387                                            self.translator,
388                                            allow_none=True)
389         output = handler.dispatch(input)
391         self.setHeader("Content-Type", "text/xml")
392         self.setHeader("Content-Length", str(len(output)))
393         self.write(output)
394         
395     def inner_main(self):
396         """Process a request.
398         The most common requests are handled like so:
400         1. look for charset and language preferences, set up user locale
401            see determine_charset, determine_language
402         2. figure out who we are, defaulting to the "anonymous" user
403            see determine_user
404         3. figure out what the request is for - the context
405            see determine_context
406         4. handle any requested action (item edit, search, ...)
407            see handle_action
408         5. render a template, resulting in HTML output
410         In some situations, exceptions occur:
412         - HTTP Redirect  (generally raised by an action)
413         - SendFile       (generally raised by determine_context)
414           serve up a FileClass "content" property
415         - SendStaticFile (generally raised by determine_context)
416           serve up a file from the tracker "html" directory
417         - Unauthorised   (generally raised by an action)
418           the action is cancelled, the request is rendered and an error
419           message is displayed indicating that permission was not
420           granted for the action to take place
421         - templating.Unauthorised   (templating action not permitted)
422           raised by an attempted rendering of a template when the user
423           doesn't have permission
424         - NotFound       (raised wherever it needs to be)
425           percolates up to the CGI interface that called the client
426         """
427         self.ok_message = []
428         self.error_message = []
429         try:
430             self.determine_charset()
431             self.determine_language()
433             try:
434                 # make sure we're identified (even anonymously)
435                 self.determine_user()
437                 # figure out the context and desired content template
438                 self.determine_context()
440                 # possibly handle a form submit action (may change self.classname
441                 # and self.template, and may also append error/ok_messages)
442                 html = self.handle_action()
444                 if html:
445                     self.write_html(html)
446                     return
448                 # now render the page
449                 # we don't want clients caching our dynamic pages
450                 self.additional_headers['Cache-Control'] = 'no-cache'
451                 # Pragma: no-cache makes Mozilla and its ilk
452                 # double-load all pages!!
453                 #            self.additional_headers['Pragma'] = 'no-cache'
455                 # pages with messages added expire right now
456                 # simple views may be cached for a small amount of time
457                 # TODO? make page expire time configurable
458                 # <rj> always expire pages, as IE just doesn't seem to do the
459                 # right thing here :(
460                 date = time.time() - 1
461                 #if self.error_message or self.ok_message:
462                 #    date = time.time() - 1
463                 #else:
464                 #    date = time.time() + 5
465                 self.additional_headers['Expires'] = rfc822.formatdate(date)
467                 # render the content
468                 self.write_html(self.renderContext())
469             except SendFile, designator:
470                 # The call to serve_file may result in an Unauthorised
471                 # exception or a NotModified exception.  Those
472                 # exceptions will be handled by the outermost set of
473                 # exception handlers.
474                 self.serve_file(designator)
475             except SendStaticFile, file:
476                 self.serve_static_file(str(file))
477             except IOError:
478                 # IOErrors here are due to the client disconnecting before
479                 # recieving the reply.
480                 pass
482         except SeriousError, message:
483             self.write_html(str(message))
484         except Redirect, url:
485             # let's redirect - if the url isn't None, then we need to do
486             # the headers, otherwise the headers have been set before the
487             # exception was raised
488             if url:
489                 self.additional_headers['Location'] = str(url)
490                 self.response_code = 302
491             self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
492         except Unauthorised, message:
493             # users may always see the front page
494             self.response_code = 403
495             self.classname = self.nodeid = None
496             self.template = ''
497             self.error_message.append(message)
498             self.write_html(self.renderContext())
499         except NotModified:
500             # send the 304 response
501             self.response_code = 304
502             self.header()
503         except NotFound, e:
504             self.response_code = 404
505             self.template = '404'
506             try:
507                 cl = self.db.getclass(self.classname)
508                 self.write_html(self.renderContext())
509             except KeyError:
510                 # we can't map the URL to a class we know about
511                 # reraise the NotFound and let roundup_server
512                 # handle it
513                 raise NotFound, e
514         except FormError, e:
515             self.error_message.append(self._('Form Error: ') + str(e))
516             self.write_html(self.renderContext())
517         except:
518             # Something has gone badly wrong.  Therefore, we should
519             # make sure that the response code indicates failure.
520             if self.response_code == httplib.OK:
521                 self.response_code = httplib.INTERNAL_SERVER_ERROR
522             # Help the administrator work out what went wrong.
523             html = ("<h1>Traceback</h1>"
524                     + cgitb.html(i18n=self.translator)
525                     + ("<h1>Environment Variables</h1><table>%s</table>"
526                        % cgitb.niceDict("", self.env)))
527             if not self.instance.config.WEB_DEBUG:
528                 exc_info = sys.exc_info()
529                 subject = "Error: %s" % exc_info[1]
530                 self.send_html_to_admin(subject, html)
531                 self.write_html(self._(error_message))
532             else:
533                 self.write_html(html)
535     def clean_sessions(self):
536         """Deprecated
537            XXX remove
538         """
539         self.clean_up()
541     def clean_up(self):
542         """Remove expired sessions and One Time Keys.
544            Do it only once an hour.
545         """
546         hour = 60*60
547         now = time.time()
549         # XXX: hack - use OTK table to store last_clean time information
550         #      'last_clean' string is used instead of otk key
551         last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
552         if now - last_clean < hour:
553             return
555         self.session_api.clean_up()
556         self.db.getOTKManager().clean()
557         self.db.getOTKManager().set('last_clean', last_use=now)
558         self.db.commit(fail_ok=True)
560     def determine_charset(self):
561         """Look for client charset in the form parameters or browser cookie.
563         If no charset requested by client, use storage charset (utf-8).
565         If the charset is found, and differs from the storage charset,
566         recode all form fields of type 'text/plain'
567         """
568         # look for client charset
569         charset_parameter = 0
570         if self.form.has_key('@charset'):
571             charset = self.form['@charset'].value
572             if charset.lower() == "none":
573                 charset = ""
574             charset_parameter = 1
575         elif self.cookie.has_key('roundup_charset'):
576             charset = self.cookie['roundup_charset'].value
577         else:
578             charset = None
579         if charset:
580             # make sure the charset is recognized
581             try:
582                 codecs.lookup(charset)
583             except LookupError:
584                 self.error_message.append(self._('Unrecognized charset: %r')
585                     % charset)
586                 charset_parameter = 0
587             else:
588                 self.charset = charset.lower()
589         # If we've got a character set in request parameters,
590         # set the browser cookie to keep the preference.
591         # This is done after codecs.lookup to make sure
592         # that we aren't keeping a wrong value.
593         if charset_parameter:
594             self.add_cookie('roundup_charset', charset)
596         # if client charset is different from the storage charset,
597         # recode form fields
598         # XXX this requires FieldStorage from Python library.
599         #   mod_python FieldStorage is not supported!
600         if self.charset != self.STORAGE_CHARSET:
601             decoder = codecs.getdecoder(self.charset)
602             encoder = codecs.getencoder(self.STORAGE_CHARSET)
603             re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
604             def _decode_charref(matchobj):
605                 num = matchobj.group(1)
606                 if num[0].lower() == 'x':
607                     uc = int(num[1:], 16)
608                 else:
609                     uc = int(num)
610                 return unichr(uc)
612             for field_name in self.form.keys():
613                 field = self.form[field_name]
614                 if (field.type == 'text/plain') and not field.filename:
615                     try:
616                         value = decoder(field.value)[0]
617                     except UnicodeError:
618                         continue
619                     value = re_charref.sub(_decode_charref, value)
620                     field.value = encoder(value)[0]
622     def determine_language(self):
623         """Determine the language"""
624         # look for language parameter
625         # then for language cookie
626         # last for the Accept-Language header
627         if self.form.has_key("@language"):
628             language = self.form["@language"].value
629             if language.lower() == "none":
630                 language = ""
631             self.add_cookie("roundup_language", language)
632         elif self.cookie.has_key("roundup_language"):
633             language = self.cookie["roundup_language"].value
634         elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
635             hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
636             language = accept_language.parse(hal)
637         else:
638             language = ""
640         self.language = language
641         if language:
642             self.setTranslator(TranslationService.get_translation(
643                     language,
644                     tracker_home=self.instance.config["TRACKER_HOME"]))
646     def determine_user(self):
647         """Determine who the user is"""
648         self.opendb('admin')
650         # get session data from db
651         # XXX: rename
652         self.session_api = Session(self)
654         # take the opportunity to cleanup expired sessions and otks
655         self.clean_up()
657         user = None
658         # first up, try http authorization if enabled
659         if self.instance.config['WEB_HTTP_AUTH']:
660             if self.env.has_key('REMOTE_USER'):
661                 # we have external auth (e.g. by Apache)
662                 user = self.env['REMOTE_USER']
663             elif self.env.get('HTTP_AUTHORIZATION', ''):
664                 # try handling Basic Auth ourselves
665                 auth = self.env['HTTP_AUTHORIZATION']
666                 scheme, challenge = auth.split(' ', 1)
667                 if scheme.lower() == 'basic':
668                     try:
669                         decoded = base64.decodestring(challenge)
670                     except TypeError:
671                         # invalid challenge
672                         pass
673                     username, password = decoded.split(':')
674                     try:
675                         login = self.get_action_class('login')(self)
676                         login.verifyLogin(username, password)
677                     except LoginError, err:
678                         self.make_user_anonymous()
679                         raise Unauthorised, err
680                     user = username
682         # if user was not set by http authorization, try session lookup
683         if not user:
684             user = self.session_api.get('user')
685             if user:
686                 # update session lifetime datestamp
687                 self.session_api.update()
689         # if no user name set by http authorization or session lookup
690         # the user is anonymous
691         if not user:
692             user = 'anonymous'
694         # sanity check on the user still being valid,
695         # getting the userid at the same time
696         try:
697             self.userid = self.db.user.lookup(user)
698         except (KeyError, TypeError):
699             user = 'anonymous'
701         # make sure the anonymous user is valid if we're using it
702         if user == 'anonymous':
703             self.make_user_anonymous()
704             if not self.db.security.hasPermission('Web Access', self.userid):
705                 raise Unauthorised, self._("Anonymous users are not "
706                     "allowed to use the web interface")
707         else:
708             self.user = user
710         # reopen the database as the correct user
711         self.opendb(self.user)
713     def opendb(self, username):
714         """Open the database and set the current user.
716         Opens a database once. On subsequent calls only the user is set on
717         the database object the instance.optimize is set. If we are in
718         "Development Mode" (cf. roundup_server) then the database is always
719         re-opened.
720         """
721         # don't do anything if the db is open and the user has not changed
722         if hasattr(self, 'db') and self.db.isCurrentUser(username):
723             return
725         # open the database or only set the user
726         if not hasattr(self, 'db'):
727             self.db = self.instance.open(username)
728         else:
729             if self.instance.optimize:
730                 self.db.setCurrentUser(username)
731             else:
732                 self.db.close()
733                 self.db = self.instance.open(username)
734                 # The old session API refers to the closed database;
735                 # we can no longer use it.
736                 self.session_api = Session(self)
737  
739     def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
740         """Determine the context of this page from the URL:
742         The URL path after the instance identifier is examined. The path
743         is generally only one entry long.
745         - if there is no path, then we are in the "home" context.
746         - if the path is "_file", then the additional path entry
747           specifies the filename of a static file we're to serve up
748           from the instance "html" directory. Raises a SendStaticFile
749           exception.(*)
750         - if there is something in the path (eg "issue"), it identifies
751           the tracker class we're to display.
752         - if the path is an item designator (eg "issue123"), then we're
753           to display a specific item.
754         - if the path starts with an item designator and is longer than
755           one entry, then we're assumed to be handling an item of a
756           FileClass, and the extra path information gives the filename
757           that the client is going to label the download with (ie
758           "file123/image.png" is nicer to download than "file123"). This
759           raises a SendFile exception.(*)
761         Both of the "*" types of contexts stop before we bother to
762         determine the template we're going to use. That's because they
763         don't actually use templates.
765         The template used is specified by the :template CGI variable,
766         which defaults to:
768         - only classname suplied:          "index"
769         - full item designator supplied:   "item"
771         We set:
773              self.classname  - the class to display, can be None
775              self.template   - the template to render the current context with
777              self.nodeid     - the nodeid of the class we're displaying
778         """
779         # default the optional variables
780         self.classname = None
781         self.nodeid = None
783         # see if a template or messages are specified
784         template_override = ok_message = error_message = None
785         for key in self.form.keys():
786             if self.FV_TEMPLATE.match(key):
787                 template_override = self.form[key].value
788             elif self.FV_OK_MESSAGE.match(key):
789                 ok_message = self.form[key].value
790                 ok_message = clean_message(ok_message)
791             elif self.FV_ERROR_MESSAGE.match(key):
792                 error_message = self.form[key].value
793                 error_message = clean_message(error_message)
795         # see if we were passed in a message
796         if ok_message:
797             self.ok_message.append(ok_message)
798         if error_message:
799             self.error_message.append(error_message)
801         # determine the classname and possibly nodeid
802         path = self.path.split('/')
803         if not path or path[0] in ('', 'home', 'index'):
804             if template_override is not None:
805                 self.template = template_override
806             else:
807                 self.template = ''
808             return
809         elif path[0] in ('_file', '@@file'):
810             raise SendStaticFile, os.path.join(*path[1:])
811         else:
812             self.classname = path[0]
813             if len(path) > 1:
814                 # send the file identified by the designator in path[0]
815                 raise SendFile, path[0]
817         # see if we got a designator
818         m = dre.match(self.classname)
819         if m:
820             self.classname = m.group(1)
821             self.nodeid = m.group(2)
822             try:
823                 klass = self.db.getclass(self.classname)
824             except KeyError:
825                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
826             if not klass.hasnode(self.nodeid):
827                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
828             # with a designator, we default to item view
829             self.template = 'item'
830         else:
831             # with only a class, we default to index view
832             self.template = 'index'
834         # make sure the classname is valid
835         try:
836             self.db.getclass(self.classname)
837         except KeyError:
838             raise NotFound, self.classname
840         # see if we have a template override
841         if template_override is not None:
842             self.template = template_override
844     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
845         """ Serve the file from the content property of the designated item.
846         """
847         m = dre.match(str(designator))
848         if not m:
849             raise NotFound, str(designator)
850         classname, nodeid = m.group(1), m.group(2)
852         try:
853             klass = self.db.getclass(classname)
854         except KeyError:
855             # The classname was not valid.
856             raise NotFound, str(designator)
857             
859         # make sure we have the appropriate properties
860         props = klass.getprops()
861         if not props.has_key('type'):
862             raise NotFound, designator
863         if not props.has_key('content'):
864             raise NotFound, designator
866         # make sure we have permission
867         if not self.db.security.hasPermission('View', self.userid,
868                 classname, 'content', nodeid):
869             raise Unauthorised, self._("You are not allowed to view "
870                 "this file.")
872         mime_type = klass.get(nodeid, 'type')
874         # if the mime_type is HTML-ish then make sure we're allowed to serve up
875         # HTML-ish content
876         if mime_type in ('text/html', 'text/x-html'):
877             if not self.instance.config['WEB_ALLOW_HTML_FILE']:
878                 # do NOT serve the content up as HTML
879                 mime_type = 'application/octet-stream'
881         # If this object is a file (i.e., an instance of FileClass),
882         # see if we can find it in the filesystem.  If so, we may be
883         # able to use the more-efficient request.sendfile method of
884         # sending the file.  If not, just get the "content" property
885         # in the usual way, and use that.
886         content = None
887         filename = None
888         if isinstance(klass, hyperdb.FileClass):
889             try:
890                 filename = self.db.filename(classname, nodeid)
891             except AttributeError:
892                 # The database doesn't store files in the filesystem
893                 # and therefore doesn't provide the "filename" method.
894                 pass
895             except IOError:
896                 # The file does not exist.
897                 pass
898         if not filename:
899             content = klass.get(nodeid, 'content')
900         
901         lmt = klass.get(nodeid, 'activity').timestamp()
903         self._serve_file(lmt, mime_type, content, filename)
905     def serve_static_file(self, file):
906         """ Serve up the file named from the templates dir
907         """
908         # figure the filename - try STATIC_FILES, then TEMPLATES dir
909         for dir_option in ('STATIC_FILES', 'TEMPLATES'):
910             prefix = self.instance.config[dir_option]
911             if not prefix:
912                 continue
913             # ensure the load doesn't try to poke outside
914             # of the static files directory
915             prefix = os.path.normpath(prefix)
916             filename = os.path.normpath(os.path.join(prefix, file))
917             if os.path.isfile(filename) and filename.startswith(prefix):
918                 break
919         else:
920             raise NotFound, file
922         # last-modified time
923         lmt = os.stat(filename)[stat.ST_MTIME]
925         # detemine meta-type
926         file = str(file)
927         mime_type = mimetypes.guess_type(file)[0]
928         if not mime_type:
929             if file.endswith('.css'):
930                 mime_type = 'text/css'
931             else:
932                 mime_type = 'text/plain'
934         self._serve_file(lmt, mime_type, '', filename)
936     def _serve_file(self, lmt, mime_type, content=None, filename=None):
937         """ guts of serve_file() and serve_static_file()
938         """
940         # spit out headers
941         self.additional_headers['Content-Type'] = mime_type
942         self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
944         ims = None
945         # see if there's an if-modified-since...
946         # XXX see which interfaces set this
947         #if hasattr(self.request, 'headers'):
948             #ims = self.request.headers.getheader('if-modified-since')
949         if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
950             # cgi will put the header in the env var
951             ims = self.env['HTTP_IF_MODIFIED_SINCE']
952         if ims:
953             ims = rfc822.parsedate(ims)[:6]
954             lmtt = time.gmtime(lmt)[:6]
955             if lmtt <= ims:
956                 raise NotModified
958         if filename:
959             self.write_file(filename)
960         else:
961             self.additional_headers['Content-Length'] = str(len(content))
962             self.write(content)
964     def send_html_to_admin(self, subject, content):
966         to = [self.mailer.config.ADMIN_EMAIL]
967         message = self.mailer.get_standard_message(to, subject)
968         # delete existing content-type headers
969         del message['Content-type']
970         message['Content-type'] = 'text/html; charset=utf-8'
971         message.set_payload(content)
972         encode_quopri(message)
973         self.mailer.smtp_send(to, str(message))
974     
975     def renderContext(self):
976         """ Return a PageTemplate for the named page
977         """
978         name = self.classname
979         extension = self.template
981         # catch errors so we can handle PT rendering errors more nicely
982         args = {
983             'ok_message': self.ok_message,
984             'error_message': self.error_message
985         }
986         try:
987             pt = self.instance.templates.get(name, extension)
988             # let the template render figure stuff out
989             result = pt.render(self, None, None, **args)
990             self.additional_headers['Content-Type'] = pt.content_type
991             if self.env.get('CGI_SHOW_TIMING', ''):
992                 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
993                     timings = {'starttag': '<!-- ', 'endtag': ' -->'}
994                 else:
995                     timings = {'starttag': '<p>', 'endtag': '</p>'}
996                 timings['seconds'] = time.time()-self.start
997                 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
998                     ) % timings
999                 if hasattr(self.db, 'stats'):
1000                     timings.update(self.db.stats)
1001                     s += self._("%(starttag)sCache hits: %(cache_hits)d,"
1002                         " misses %(cache_misses)d."
1003                         " Loading items: %(get_items)f secs."
1004                         " Filtering: %(filtering)f secs."
1005                         "%(endtag)s\n") % timings
1006                 s += '</body>'
1007                 result = result.replace('</body>', s)
1008             return result
1009         except templating.NoTemplate, message:
1010             return '<strong>%s</strong>'%message
1011         except templating.Unauthorised, message:
1012             raise Unauthorised, str(message)
1013         except:
1014             # everything else
1015             if self.instance.config.WEB_DEBUG:
1016                 return cgitb.pt_html(i18n=self.translator)
1017             exc_info = sys.exc_info()
1018             try:
1019                 # If possible, send the HTML page template traceback
1020                 # to the administrator.
1021                 subject = "Templating Error: %s" % exc_info[1]
1022                 self.send_html_to_admin(subject, cgitb.pt_html())
1023                 # Now report the error to the user.
1024                 return self._(error_message)
1025             except:
1026                 # Reraise the original exception.  The user will
1027                 # receive an error message, and the adminstrator will
1028                 # receive a traceback, albeit with less information
1029                 # than the one we tried to generate above.
1030                 raise exc_info[0], exc_info[1], exc_info[2]
1032     # these are the actions that are available
1033     actions = (
1034         ('edit',        EditItemAction),
1035         ('editcsv',     EditCSVAction),
1036         ('new',         NewItemAction),
1037         ('register',    RegisterAction),
1038         ('confrego',    ConfRegoAction),
1039         ('passrst',     PassResetAction),
1040         ('login',       LoginAction),
1041         ('logout',      LogoutAction),
1042         ('search',      SearchAction),
1043         ('retire',      RetireAction),
1044         ('show',        ShowAction),
1045         ('export_csv',  ExportCSVAction),
1046     )
1047     def handle_action(self):
1048         """ Determine whether there should be an Action called.
1050             The action is defined by the form variable :action which
1051             identifies the method on this object to call. The actions
1052             are defined in the "actions" sequence on this class.
1054             Actions may return a page (by default HTML) to return to the
1055             user, bypassing the usual template rendering.
1057             We explicitly catch Reject and ValueError exceptions and
1058             present their messages to the user.
1059         """
1060         if self.form.has_key(':action'):
1061             action = self.form[':action'].value.lower()
1062         elif self.form.has_key('@action'):
1063             action = self.form['@action'].value.lower()
1064         else:
1065             return None
1067         try:
1068             action_klass = self.get_action_class(action)
1070             # call the mapped action
1071             if isinstance(action_klass, type('')):
1072                 # old way of specifying actions
1073                 return getattr(self, action_klass)()
1074             else:
1075                 return action_klass(self).execute()
1077         except (ValueError, Reject), err:
1078             self.error_message.append(str(err))
1080     def get_action_class(self, action_name):
1081         if (hasattr(self.instance, 'cgi_actions') and
1082                 self.instance.cgi_actions.has_key(action_name)):
1083             # tracker-defined action
1084             action_klass = self.instance.cgi_actions[action_name]
1085         else:
1086             # go with a default
1087             for name, action_klass in self.actions:
1088                 if name == action_name:
1089                     break
1090             else:
1091                 raise ValueError, 'No such action "%s"'%action_name
1092         return action_klass
1094     def _socket_op(self, call, *args, **kwargs):
1095         """Execute socket-related operation, catch common network errors
1097         Parameters:
1098             call: a callable to execute
1099             args, kwargs: call arguments
1101         """
1102         try:
1103             call(*args, **kwargs)
1104         except socket.error, err:
1105             err_errno = getattr (err, 'errno', None)
1106             if err_errno is None:
1107                 try:
1108                     err_errno = err[0]
1109                 except TypeError:
1110                     pass
1111             if err_errno not in self.IGNORE_NET_ERRORS:
1112                 raise
1113         except IOError:
1114             # Apache's mod_python will raise IOError -- without an
1115             # accompanying errno -- when a write to the client fails.
1116             # A common case is that the client has closed the
1117             # connection.  There's no way to be certain that this is
1118             # the situation that has occurred here, but that is the
1119             # most likely case.
1120             pass
1122     def write(self, content):
1123         if not self.headers_done:
1124             self.header()
1125         if self.env['REQUEST_METHOD'] != 'HEAD':
1126             self._socket_op(self.request.wfile.write, content)
1128     def write_html(self, content):
1129         if not self.headers_done:
1130             # at this point, we are sure about Content-Type
1131             if not self.additional_headers.has_key('Content-Type'):
1132                 self.additional_headers['Content-Type'] = \
1133                     'text/html; charset=%s' % self.charset
1134             self.header()
1136         if self.env['REQUEST_METHOD'] == 'HEAD':
1137             # client doesn't care about content
1138             return
1140         if self.charset != self.STORAGE_CHARSET:
1141             # recode output
1142             content = content.decode(self.STORAGE_CHARSET, 'replace')
1143             content = content.encode(self.charset, 'xmlcharrefreplace')
1145         # and write
1146         self._socket_op(self.request.wfile.write, content)
1148     def http_strip(self, content):
1149         """Remove HTTP Linear White Space from 'content'.
1151         'content' -- A string.
1153         returns -- 'content', with all leading and trailing LWS
1154         removed."""
1156         # RFC 2616 2.2: Basic Rules
1157         #
1158         # LWS = [CRLF] 1*( SP | HT )
1159         return content.strip(" \r\n\t")
1161     def http_split(self, content):
1162         """Split an HTTP list.
1164         'content' -- A string, giving a list of items.
1166         returns -- A sequence of strings, containing the elements of
1167         the list."""
1169         # RFC 2616 2.1: Augmented BNF
1170         #
1171         # Grammar productions of the form "#rule" indicate a
1172         # comma-separated list of elements matching "rule".  LWS
1173         # is then removed from each element, and empty elements
1174         # removed.
1176         # Split at commas.
1177         elements = content.split(",")
1178         # Remove linear whitespace at either end of the string.
1179         elements = [self.http_strip(e) for e in elements]
1180         # Remove any now-empty elements.
1181         return [e for e in elements if e]
1182         
1183     def handle_range_header(self, length, etag):
1184         """Handle the 'Range' and 'If-Range' headers.
1186         'length' -- the length of the content available for the
1187         resource.
1189         'etag' -- the entity tag for this resources.
1191         returns -- If the request headers (including 'Range' and
1192         'If-Range') indicate that only a portion of the entity should
1193         be returned, then the return value is a pair '(offfset,
1194         length)' indicating the first byte and number of bytes of the
1195         content that should be returned to the client.  In addition,
1196         this method will set 'self.response_code' to indicate Partial
1197         Content.  In all other cases, the return value is 'None'.  If
1198         appropriate, 'self.response_code' will be
1199         set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'.  In that
1200         case, the caller should not send any data to the client."""
1202         # RFC 2616 14.35: Range
1203         #
1204         # See if the Range header is present.
1205         ranges_specifier = self.env.get("HTTP_RANGE")
1206         if ranges_specifier is None:
1207             return None
1208         # RFC 2616 14.27: If-Range
1209         #
1210         # Check to see if there is an If-Range header.
1211         # Because the specification says:
1212         #
1213         #  The If-Range header ... MUST be ignored if the request
1214         #  does not include a Range header, we check for If-Range
1215         #  after checking for Range.
1216         if_range = self.env.get("HTTP_IF_RANGE")
1217         if if_range:
1218             # The grammar for the If-Range header is:
1219             # 
1220             #   If-Range = "If-Range" ":" ( entity-tag | HTTP-date )
1221             #   entity-tag = [ weak ] opaque-tag
1222             #   weak = "W/"
1223             #   opaque-tag = quoted-string
1224             #
1225             # We only support strong entity tags.
1226             if_range = self.http_strip(if_range)
1227             if (not if_range.startswith('"')
1228                 or not if_range.endswith('"')):
1229                 return None
1230             # If the condition doesn't match the entity tag, then we
1231             # must send the client the entire file.
1232             if if_range != etag:
1233                 return
1234         # The grammar for the Range header value is:
1235         #
1236         #   ranges-specifier = byte-ranges-specifier
1237         #   byte-ranges-specifier = bytes-unit "=" byte-range-set
1238         #   byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
1239         #   byte-range-spec = first-byte-pos "-" [last-byte-pos]
1240         #   first-byte-pos = 1*DIGIT
1241         #   last-byte-pos = 1*DIGIT
1242         #   suffix-byte-range-spec = "-" suffix-length
1243         #   suffix-length = 1*DIGIT
1244         #
1245         # Look for the "=" separating the units from the range set.
1246         specs = ranges_specifier.split("=", 1)
1247         if len(specs) != 2:
1248             return None
1249         # Check that the bytes-unit is in fact "bytes".  If it is not,
1250         # we do not know how to process this range.
1251         bytes_unit = self.http_strip(specs[0])
1252         if bytes_unit != "bytes":
1253             return None
1254         # Seperate the range-set into range-specs.
1255         byte_range_set = self.http_strip(specs[1])
1256         byte_range_specs = self.http_split(byte_range_set)
1257         # We only handle exactly one range at this time.
1258         if len(byte_range_specs) != 1:
1259             return None
1260         # Parse the spec.
1261         byte_range_spec = byte_range_specs[0]
1262         pos = byte_range_spec.split("-", 1)
1263         if len(pos) != 2:
1264             return None
1265         # Get the first and last bytes.
1266         first = self.http_strip(pos[0])
1267         last = self.http_strip(pos[1])
1268         # We do not handle suffix ranges.
1269         if not first:
1270             return None
1271        # Convert the first and last positions to integers.
1272         try:
1273             first = int(first)
1274             if last:
1275                 last = int(last)
1276             else:
1277                 last = length - 1
1278         except:
1279             # The positions could not be parsed as integers.
1280             return None
1281         # Check that the range makes sense.
1282         if (first < 0 or last < 0 or last < first):
1283             return None
1284         if last >= length:
1285             # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable
1286             #
1287             # If there is an If-Range header, RFC 2616 says that we
1288             # should just ignore the invalid Range header.
1289             if if_range:
1290                 return None
1291             # Return code 416 with a Content-Range header giving the
1292             # allowable range.
1293             self.response_code = httplib.REQUESTED_RANGE_NOT_SATISFIABLE
1294             self.setHeader("Content-Range", "bytes */%d" % length)
1295             return None
1296         # RFC 2616 10.2.7: 206 Partial Content
1297         #
1298         # Tell the client that we are honoring the Range request by
1299         # indicating that we are providing partial content.
1300         self.response_code = httplib.PARTIAL_CONTENT
1301         # RFC 2616 14.16: Content-Range
1302         #
1303         # Tell the client what data we are providing.
1304         #
1305         #   content-range-spec = byte-content-range-spec
1306         #   byte-content-range-spec = bytes-unit SP
1307         #                             byte-range-resp-spec "/"
1308         #                             ( instance-length | "*" )
1309         #   byte-range-resp-spec = (first-byte-pos "-" last-byte-pos)
1310         #                          | "*"
1311         #   instance-length      = 1 * DIGIT
1312         self.setHeader("Content-Range",
1313                        "bytes %d-%d/%d" % (first, last, length))
1314         return (first, last - first + 1)
1316     def write_file(self, filename):
1317         """Send the contents of 'filename' to the user."""
1319         # Determine the length of the file.
1320         stat_info = os.stat(filename)
1321         length = stat_info[stat.ST_SIZE]
1322         # Assume we will return the entire file.
1323         offset = 0
1324         # If the headers have not already been finalized, 
1325         if not self.headers_done:
1326             # RFC 2616 14.19: ETag
1327             #
1328             # Compute the entity tag, in a format similar to that
1329             # used by Apache.
1330             etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
1331                                    length,
1332                                    stat_info[stat.ST_MTIME])
1333             self.setHeader("ETag", etag)
1334             # RFC 2616 14.5: Accept-Ranges
1335             #
1336             # Let the client know that we will accept range requests.
1337             self.setHeader("Accept-Ranges", "bytes")
1338             # RFC 2616 14.35: Range
1339             #
1340             # If there is a Range header, we may be able to avoid
1341             # sending the entire file.
1342             content_range = self.handle_range_header(length, etag)
1343             if content_range:
1344                 offset, length = content_range
1345             # RFC 2616 14.13: Content-Length
1346             #
1347             # Tell the client how much data we are providing.
1348             self.setHeader("Content-Length", str(length))
1349             # Send the HTTP header.
1350             self.header()
1351         # If the client doesn't actually want the body, or if we are
1352         # indicating an invalid range.
1353         if (self.env['REQUEST_METHOD'] == 'HEAD'
1354             or self.response_code == httplib.REQUESTED_RANGE_NOT_SATISFIABLE):
1355             return
1356         # Use the optimized "sendfile" operation, if possible.
1357         if hasattr(self.request, "sendfile"):
1358             self._socket_op(self.request.sendfile, filename, offset, length)
1359             return
1360         # Fallback to the "write" operation.
1361         f = open(filename, 'rb')
1362         try:
1363             if offset:
1364                 f.seek(offset)
1365             content = f.read(length)
1366         finally:
1367             f.close()
1368         self.write(content)
1370     def setHeader(self, header, value):
1371         """Override a header to be returned to the user's browser.
1372         """
1373         self.additional_headers[header] = value
1375     def header(self, headers=None, response=None):
1376         """Put up the appropriate header.
1377         """
1378         if headers is None:
1379             headers = {'Content-Type':'text/html; charset=utf-8'}
1380         if response is None:
1381             response = self.response_code
1383         # update with additional info
1384         headers.update(self.additional_headers)
1386         if headers.get('Content-Type', 'text/html') == 'text/html':
1387             headers['Content-Type'] = 'text/html; charset=utf-8'
1389         headers = headers.items()
1391         for ((path, name), (value, expire)) in self._cookies.items():
1392             cookie = "%s=%s; Path=%s;"%(name, value, path)
1393             if expire is not None:
1394                 cookie += " expires=%s;"%Cookie._getdate(expire)
1395             headers.append(('Set-Cookie', cookie))
1397         self._socket_op(self.request.start_response, headers, response)
1399         self.headers_done = 1
1400         if self.debug:
1401             self.headers_sent = headers
1403     def add_cookie(self, name, value, expire=86400*365, path=None):
1404         """Set a cookie value to be sent in HTTP headers
1406         Parameters:
1407             name:
1408                 cookie name
1409             value:
1410                 cookie value
1411             expire:
1412                 cookie expiration time (seconds).
1413                 If value is empty (meaning "delete cookie"),
1414                 expiration time is forced in the past
1415                 and this argument is ignored.
1416                 If None, the cookie will expire at end-of-session.
1417                 If omitted, the cookie will be kept for a year.
1418             path:
1419                 cookie path (optional)
1421         """
1422         if path is None:
1423             path = self.cookie_path
1424         if not value:
1425             expire = -1
1426         self._cookies[(path, name)] = (value, expire)
1428     def set_cookie(self, user, expire=None):
1429         """Deprecated. Use session_api calls directly
1431         XXX remove
1432         """
1434         # insert the session in the session db
1435         self.session_api.set(user=user)
1436         # refresh session cookie
1437         self.session_api.update(set_cookie=True, expire=expire)
1439     def make_user_anonymous(self):
1440         """ Make us anonymous
1442             This method used to handle non-existence of the 'anonymous'
1443             user, but that user is mandatory now.
1444         """
1445         self.userid = self.db.user.lookup('anonymous')
1446         self.user = 'anonymous'
1448     def standard_message(self, to, subject, body, author=None):
1449         """Send a standard email message from Roundup.
1451         "to"      - recipients list
1452         "subject" - Subject
1453         "body"    - Message
1454         "author"  - (name, address) tuple or None for admin email
1456         Arguments are passed to the Mailer.standard_message code.
1457         """
1458         try:
1459             self.mailer.standard_message(to, subject, body, author)
1460         except MessageSendError, e:
1461             self.error_message.append(str(e))
1462             return 0
1463         return 1
1465     def parsePropsFromForm(self, create=0):
1466         return FormParser(self).parse(create=create)
1468 # vim: set et sts=4 sw=4 :