Code

* Refactor XMLRPC interface.
[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
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, self.userid, self.translator,
386                                            allow_none=True)
387         output = handler.dispatch(input)
388         self.db.commit()
390         self.setHeader("Content-Type", "text/xml")
391         self.setHeader("Content-Length", str(len(output)))
392         self.write(output)
393         
394     def inner_main(self):
395         """Process a request.
397         The most common requests are handled like so:
399         1. look for charset and language preferences, set up user locale
400            see determine_charset, determine_language
401         2. figure out who we are, defaulting to the "anonymous" user
402            see determine_user
403         3. figure out what the request is for - the context
404            see determine_context
405         4. handle any requested action (item edit, search, ...)
406            see handle_action
407         5. render a template, resulting in HTML output
409         In some situations, exceptions occur:
411         - HTTP Redirect  (generally raised by an action)
412         - SendFile       (generally raised by determine_context)
413           serve up a FileClass "content" property
414         - SendStaticFile (generally raised by determine_context)
415           serve up a file from the tracker "html" directory
416         - Unauthorised   (generally raised by an action)
417           the action is cancelled, the request is rendered and an error
418           message is displayed indicating that permission was not
419           granted for the action to take place
420         - templating.Unauthorised   (templating action not permitted)
421           raised by an attempted rendering of a template when the user
422           doesn't have permission
423         - NotFound       (raised wherever it needs to be)
424           percolates up to the CGI interface that called the client
425         """
426         self.ok_message = []
427         self.error_message = []
428         try:
429             self.determine_charset()
430             self.determine_language()
432             # make sure we're identified (even anonymously)
433             self.determine_user()
435             # figure out the context and desired content template
436             self.determine_context()
438             # possibly handle a form submit action (may change self.classname
439             # and self.template, and may also append error/ok_messages)
440             html = self.handle_action()
442             if html:
443                 self.write_html(html)
444                 return
446             # now render the page
447             # we don't want clients caching our dynamic pages
448             self.additional_headers['Cache-Control'] = 'no-cache'
449 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
450 #            self.additional_headers['Pragma'] = 'no-cache'
452             # pages with messages added expire right now
453             # simple views may be cached for a small amount of time
454             # TODO? make page expire time configurable
455             # <rj> always expire pages, as IE just doesn't seem to do the
456             # right thing here :(
457             date = time.time() - 1
458             #if self.error_message or self.ok_message:
459             #    date = time.time() - 1
460             #else:
461             #    date = time.time() + 5
462             self.additional_headers['Expires'] = rfc822.formatdate(date)
464             # render the content
465             try:
466                 self.write_html(self.renderContext())
467             except IOError:
468                 # IOErrors here are due to the client disconnecting before
469                 # recieving the reply.
470                 pass
472         except SeriousError, message:
473             self.write_html(str(message))
474         except Redirect, url:
475             # let's redirect - if the url isn't None, then we need to do
476             # the headers, otherwise the headers have been set before the
477             # exception was raised
478             if url:
479                 self.additional_headers['Location'] = str(url)
480                 self.response_code = 302
481             self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
482         except SendFile, designator:
483             try:
484                 self.serve_file(designator)
485             except NotModified:
486                 # send the 304 response
487                 self.response_code = 304
488                 self.header()
489         except SendStaticFile, file:
490             try:
491                 self.serve_static_file(str(file))
492             except NotModified:
493                 # send the 304 response
494                 self.response_code = 304
495                 self.header()
496         except Unauthorised, message:
497             # users may always see the front page
498             self.response_code = 403
499             self.classname = self.nodeid = None
500             self.template = ''
501             self.error_message.append(message)
502             self.write_html(self.renderContext())
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             if self.instance.config.WEB_DEBUG:
519                 self.write_html(cgitb.html(i18n=self.translator))
520             else:
521                 self.mailer.exception_message()
522                 return self.write_html(self._(error_message))
524     def clean_sessions(self):
525         """Deprecated
526            XXX remove
527         """
528         self.clean_up()
530     def clean_up(self):
531         """Remove expired sessions and One Time Keys.
533            Do it only once an hour.
534         """
535         hour = 60*60
536         now = time.time()
538         # XXX: hack - use OTK table to store last_clean time information
539         #      'last_clean' string is used instead of otk key
540         last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
541         if now - last_clean < hour:
542             return
544         self.session_api.clean_up()
545         self.db.getOTKManager().clean()
546         self.db.getOTKManager().set('last_clean', last_use=now)
547         self.db.commit(fail_ok=True)
549     def determine_charset(self):
550         """Look for client charset in the form parameters or browser cookie.
552         If no charset requested by client, use storage charset (utf-8).
554         If the charset is found, and differs from the storage charset,
555         recode all form fields of type 'text/plain'
556         """
557         # look for client charset
558         charset_parameter = 0
559         if self.form.has_key('@charset'):
560             charset = self.form['@charset'].value
561             if charset.lower() == "none":
562                 charset = ""
563             charset_parameter = 1
564         elif self.cookie.has_key('roundup_charset'):
565             charset = self.cookie['roundup_charset'].value
566         else:
567             charset = None
568         if charset:
569             # make sure the charset is recognized
570             try:
571                 codecs.lookup(charset)
572             except LookupError:
573                 self.error_message.append(self._('Unrecognized charset: %r')
574                     % charset)
575                 charset_parameter = 0
576             else:
577                 self.charset = charset.lower()
578         # If we've got a character set in request parameters,
579         # set the browser cookie to keep the preference.
580         # This is done after codecs.lookup to make sure
581         # that we aren't keeping a wrong value.
582         if charset_parameter:
583             self.add_cookie('roundup_charset', charset)
585         # if client charset is different from the storage charset,
586         # recode form fields
587         # XXX this requires FieldStorage from Python library.
588         #   mod_python FieldStorage is not supported!
589         if self.charset != self.STORAGE_CHARSET:
590             decoder = codecs.getdecoder(self.charset)
591             encoder = codecs.getencoder(self.STORAGE_CHARSET)
592             re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
593             def _decode_charref(matchobj):
594                 num = matchobj.group(1)
595                 if num[0].lower() == 'x':
596                     uc = int(num[1:], 16)
597                 else:
598                     uc = int(num)
599                 return unichr(uc)
601             for field_name in self.form.keys():
602                 field = self.form[field_name]
603                 if (field.type == 'text/plain') and not field.filename:
604                     try:
605                         value = decoder(field.value)[0]
606                     except UnicodeError:
607                         continue
608                     value = re_charref.sub(_decode_charref, value)
609                     field.value = encoder(value)[0]
611     def determine_language(self):
612         """Determine the language"""
613         # look for language parameter
614         # then for language cookie
615         # last for the Accept-Language header
616         if self.form.has_key("@language"):
617             language = self.form["@language"].value
618             if language.lower() == "none":
619                 language = ""
620             self.add_cookie("roundup_language", language)
621         elif self.cookie.has_key("roundup_language"):
622             language = self.cookie["roundup_language"].value
623         elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
624             hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
625             language = accept_language.parse(hal)
626         else:
627             language = ""
629         self.language = language
630         if language:
631             self.setTranslator(TranslationService.get_translation(
632                     language,
633                     tracker_home=self.instance.config["TRACKER_HOME"]))
635     def determine_user(self):
636         """Determine who the user is"""
637         self.opendb('admin')
639         # get session data from db
640         # XXX: rename
641         self.session_api = Session(self)
643         # take the opportunity to cleanup expired sessions and otks
644         self.clean_up()
646         user = None
647         # first up, try http authorization if enabled
648         if self.instance.config['WEB_HTTP_AUTH']:
649             if self.env.has_key('REMOTE_USER'):
650                 # we have external auth (e.g. by Apache)
651                 user = self.env['REMOTE_USER']
652             elif self.env.get('HTTP_AUTHORIZATION', ''):
653                 # try handling Basic Auth ourselves
654                 auth = self.env['HTTP_AUTHORIZATION']
655                 scheme, challenge = auth.split(' ', 1)
656                 if scheme.lower() == 'basic':
657                     try:
658                         decoded = base64.decodestring(challenge)
659                     except TypeError:
660                         # invalid challenge
661                         pass
662                     username, password = decoded.split(':')
663                     try:
664                         login = self.get_action_class('login')(self)
665                         login.verifyLogin(username, password)
666                     except LoginError, err:
667                         self.make_user_anonymous()
668                         raise Unauthorised, err
669                     user = username
671         # if user was not set by http authorization, try session lookup
672         if not user:
673             user = self.session_api.get('user')
674             if user:
675                 # update session lifetime datestamp
676                 self.session_api.update()
678         # if no user name set by http authorization or session lookup
679         # the user is anonymous
680         if not user:
681             user = 'anonymous'
683         # sanity check on the user still being valid,
684         # getting the userid at the same time
685         try:
686             self.userid = self.db.user.lookup(user)
687         except (KeyError, TypeError):
688             user = 'anonymous'
690         # make sure the anonymous user is valid if we're using it
691         if user == 'anonymous':
692             self.make_user_anonymous()
693             if not self.db.security.hasPermission('Web Access', self.userid):
694                 raise Unauthorised, self._("Anonymous users are not "
695                     "allowed to use the web interface")
696         else:
697             self.user = user
699         # reopen the database as the correct user
700         self.opendb(self.user)
702     def opendb(self, username):
703         """Open the database and set the current user.
705         Opens a database once. On subsequent calls only the user is set on
706         the database object the instance.optimize is set. If we are in
707         "Development Mode" (cf. roundup_server) then the database is always
708         re-opened.
709         """
710         # don't do anything if the db is open and the user has not changed
711         if hasattr(self, 'db') and self.db.isCurrentUser(username):
712             return
714         # open the database or only set the user
715         if not hasattr(self, 'db'):
716             self.db = self.instance.open(username)
717         else:
718             if self.instance.optimize:
719                 self.db.setCurrentUser(username)
720             else:
721                 self.db.close()
722                 self.db = self.instance.open(username)
724     def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
725         """Determine the context of this page from the URL:
727         The URL path after the instance identifier is examined. The path
728         is generally only one entry long.
730         - if there is no path, then we are in the "home" context.
731         - if the path is "_file", then the additional path entry
732           specifies the filename of a static file we're to serve up
733           from the instance "html" directory. Raises a SendStaticFile
734           exception.(*)
735         - if there is something in the path (eg "issue"), it identifies
736           the tracker class we're to display.
737         - if the path is an item designator (eg "issue123"), then we're
738           to display a specific item.
739         - if the path starts with an item designator and is longer than
740           one entry, then we're assumed to be handling an item of a
741           FileClass, and the extra path information gives the filename
742           that the client is going to label the download with (ie
743           "file123/image.png" is nicer to download than "file123"). This
744           raises a SendFile exception.(*)
746         Both of the "*" types of contexts stop before we bother to
747         determine the template we're going to use. That's because they
748         don't actually use templates.
750         The template used is specified by the :template CGI variable,
751         which defaults to:
753         - only classname suplied:          "index"
754         - full item designator supplied:   "item"
756         We set:
758              self.classname  - the class to display, can be None
760              self.template   - the template to render the current context with
762              self.nodeid     - the nodeid of the class we're displaying
763         """
764         # default the optional variables
765         self.classname = None
766         self.nodeid = None
768         # see if a template or messages are specified
769         template_override = ok_message = error_message = None
770         for key in self.form.keys():
771             if self.FV_TEMPLATE.match(key):
772                 template_override = self.form[key].value
773             elif self.FV_OK_MESSAGE.match(key):
774                 ok_message = self.form[key].value
775                 ok_message = clean_message(ok_message)
776             elif self.FV_ERROR_MESSAGE.match(key):
777                 error_message = self.form[key].value
778                 error_message = clean_message(error_message)
780         # see if we were passed in a message
781         if ok_message:
782             self.ok_message.append(ok_message)
783         if error_message:
784             self.error_message.append(error_message)
786         # determine the classname and possibly nodeid
787         path = self.path.split('/')
788         if not path or path[0] in ('', 'home', 'index'):
789             if template_override is not None:
790                 self.template = template_override
791             else:
792                 self.template = ''
793             return
794         elif path[0] in ('_file', '@@file'):
795             raise SendStaticFile, os.path.join(*path[1:])
796         else:
797             self.classname = path[0]
798             if len(path) > 1:
799                 # send the file identified by the designator in path[0]
800                 raise SendFile, path[0]
802         # see if we got a designator
803         m = dre.match(self.classname)
804         if m:
805             self.classname = m.group(1)
806             self.nodeid = m.group(2)
807             try:
808                 klass = self.db.getclass(self.classname)
809             except KeyError:
810                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
811             if not klass.hasnode(self.nodeid):
812                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
813             # with a designator, we default to item view
814             self.template = 'item'
815         else:
816             # with only a class, we default to index view
817             self.template = 'index'
819         # make sure the classname is valid
820         try:
821             self.db.getclass(self.classname)
822         except KeyError:
823             raise NotFound, self.classname
825         # see if we have a template override
826         if template_override is not None:
827             self.template = template_override
829     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
830         """ Serve the file from the content property of the designated item.
831         """
832         m = dre.match(str(designator))
833         if not m:
834             raise NotFound, str(designator)
835         classname, nodeid = m.group(1), m.group(2)
837         klass = self.db.getclass(classname)
839         # make sure we have the appropriate properties
840         props = klass.getprops()
841         if not props.has_key('type'):
842             raise NotFound, designator
843         if not props.has_key('content'):
844             raise NotFound, designator
846         # make sure we have permission
847         if not self.db.security.hasPermission('View', self.userid,
848                 classname, 'content', nodeid):
849             raise Unauthorised, self._("You are not allowed to view "
850                 "this file.")
852         mime_type = klass.get(nodeid, 'type')
854         # If this object is a file (i.e., an instance of FileClass),
855         # see if we can find it in the filesystem.  If so, we may be
856         # able to use the more-efficient request.sendfile method of
857         # sending the file.  If not, just get the "content" property
858         # in the usual way, and use that.
859         content = None
860         filename = None
861         if isinstance(klass, hyperdb.FileClass):
862             try:
863                 filename = self.db.filename(classname, nodeid)
864             except AttributeError:
865                 # The database doesn't store files in the filesystem
866                 # and therefore doesn't provide the "filename" method.
867                 pass
868             except IOError:
869                 # The file does not exist.
870                 pass
871         if not filename:
872             content = klass.get(nodeid, 'content')
873         
874         lmt = klass.get(nodeid, 'activity').timestamp()
876         self._serve_file(lmt, mime_type, content, filename)
878     def serve_static_file(self, file):
879         """ Serve up the file named from the templates dir
880         """
881         # figure the filename - try STATIC_FILES, then TEMPLATES dir
882         for dir_option in ('STATIC_FILES', 'TEMPLATES'):
883             prefix = self.instance.config[dir_option]
884             if not prefix:
885                 continue
886             # ensure the load doesn't try to poke outside
887             # of the static files directory
888             prefix = os.path.normpath(prefix)
889             filename = os.path.normpath(os.path.join(prefix, file))
890             if os.path.isfile(filename) and filename.startswith(prefix):
891                 break
892         else:
893             raise NotFound, file
895         # last-modified time
896         lmt = os.stat(filename)[stat.ST_MTIME]
898         # detemine meta-type
899         file = str(file)
900         mime_type = mimetypes.guess_type(file)[0]
901         if not mime_type:
902             if file.endswith('.css'):
903                 mime_type = 'text/css'
904             else:
905                 mime_type = 'text/plain'
907         self._serve_file(lmt, mime_type, '', filename)
909     def _serve_file(self, lmt, mime_type, content=None, filename=None):
910         """ guts of serve_file() and serve_static_file()
911         """
913         # spit out headers
914         self.additional_headers['Content-Type'] = mime_type
915         self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
917         ims = None
918         # see if there's an if-modified-since...
919         # XXX see which interfaces set this
920         #if hasattr(self.request, 'headers'):
921             #ims = self.request.headers.getheader('if-modified-since')
922         if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
923             # cgi will put the header in the env var
924             ims = self.env['HTTP_IF_MODIFIED_SINCE']
925         if ims:
926             ims = rfc822.parsedate(ims)[:6]
927             lmtt = time.gmtime(lmt)[:6]
928             if lmtt <= ims:
929                 raise NotModified
931         if filename:
932             self.write_file(filename)
933         else:
934             self.additional_headers['Content-Length'] = str(len(content))
935             self.write(content)
937     def renderContext(self):
938         """ Return a PageTemplate for the named page
939         """
940         name = self.classname
941         extension = self.template
943         # catch errors so we can handle PT rendering errors more nicely
944         args = {
945             'ok_message': self.ok_message,
946             'error_message': self.error_message
947         }
948         try:
949             pt = self.instance.templates.get(name, extension)
950             # let the template render figure stuff out
951             result = pt.render(self, None, None, **args)
952             self.additional_headers['Content-Type'] = pt.content_type
953             if self.env.get('CGI_SHOW_TIMING', ''):
954                 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
955                     timings = {'starttag': '<!-- ', 'endtag': ' -->'}
956                 else:
957                     timings = {'starttag': '<p>', 'endtag': '</p>'}
958                 timings['seconds'] = time.time()-self.start
959                 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
960                     ) % timings
961                 if hasattr(self.db, 'stats'):
962                     timings.update(self.db.stats)
963                     s += self._("%(starttag)sCache hits: %(cache_hits)d,"
964                         " misses %(cache_misses)d."
965                         " Loading items: %(get_items)f secs."
966                         " Filtering: %(filtering)f secs."
967                         "%(endtag)s\n") % timings
968                 s += '</body>'
969                 result = result.replace('</body>', s)
970             return result
971         except templating.NoTemplate, message:
972             return '<strong>%s</strong>'%message
973         except templating.Unauthorised, message:
974             raise Unauthorised, str(message)
975         except:
976             # everything else
977             if self.instance.config.WEB_DEBUG:
978                 return cgitb.pt_html(i18n=self.translator)
979             exc_info = sys.exc_info()
980             try:
981                 # If possible, send the HTML page template traceback
982                 # to the administrator.
983                 to = [self.mailer.config.ADMIN_EMAIL]
984                 subject = "Templating Error: %s" % exc_info[1]
985                 content = cgitb.pt_html()
986                 message, writer = self.mailer.get_standard_message(
987                     to, subject)
988                 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
989                 body = writer.startbody('text/html; charset=utf-8')
990                 content = StringIO(content)
991                 quopri.encode(content, body, 0)
992                 self.mailer.smtp_send(to, message)
993                 # Now report the error to the user.
994                 return self._(error_message)
995             except:
996                 # Reraise the original exception.  The user will
997                 # receive an error message, and the adminstrator will
998                 # receive a traceback, albeit with less information
999                 # than the one we tried to generate above.
1000                 raise exc_info[0], exc_info[1], exc_info[2]
1002     # these are the actions that are available
1003     actions = (
1004         ('edit',        EditItemAction),
1005         ('editcsv',     EditCSVAction),
1006         ('new',         NewItemAction),
1007         ('register',    RegisterAction),
1008         ('confrego',    ConfRegoAction),
1009         ('passrst',     PassResetAction),
1010         ('login',       LoginAction),
1011         ('logout',      LogoutAction),
1012         ('search',      SearchAction),
1013         ('retire',      RetireAction),
1014         ('show',        ShowAction),
1015         ('export_csv',  ExportCSVAction),
1016     )
1017     def handle_action(self):
1018         """ Determine whether there should be an Action called.
1020             The action is defined by the form variable :action which
1021             identifies the method on this object to call. The actions
1022             are defined in the "actions" sequence on this class.
1024             Actions may return a page (by default HTML) to return to the
1025             user, bypassing the usual template rendering.
1027             We explicitly catch Reject and ValueError exceptions and
1028             present their messages to the user.
1029         """
1030         if self.form.has_key(':action'):
1031             action = self.form[':action'].value.lower()
1032         elif self.form.has_key('@action'):
1033             action = self.form['@action'].value.lower()
1034         else:
1035             return None
1037         try:
1038             action_klass = self.get_action_class(action)
1040             # call the mapped action
1041             if isinstance(action_klass, type('')):
1042                 # old way of specifying actions
1043                 return getattr(self, action_klass)()
1044             else:
1045                 return action_klass(self).execute()
1047         except (ValueError, Reject), err:
1048             self.error_message.append(str(err))
1050     def get_action_class(self, action_name):
1051         if (hasattr(self.instance, 'cgi_actions') and
1052                 self.instance.cgi_actions.has_key(action_name)):
1053             # tracker-defined action
1054             action_klass = self.instance.cgi_actions[action_name]
1055         else:
1056             # go with a default
1057             for name, action_klass in self.actions:
1058                 if name == action_name:
1059                     break
1060             else:
1061                 raise ValueError, 'No such action "%s"'%action_name
1062         return action_klass
1064     def _socket_op(self, call, *args, **kwargs):
1065         """Execute socket-related operation, catch common network errors
1067         Parameters:
1068             call: a callable to execute
1069             args, kwargs: call arguments
1071         """
1072         try:
1073             call(*args, **kwargs)
1074         except socket.error, err:
1075             err_errno = getattr (err, 'errno', None)
1076             if err_errno is None:
1077                 try:
1078                     err_errno = err[0]
1079                 except TypeError:
1080                     pass
1081             if err_errno not in self.IGNORE_NET_ERRORS:
1082                 raise
1083         except IOError:
1084             # Apache's mod_python will raise IOError -- without an
1085             # accompanying errno -- when a write to the client fails.
1086             # A common case is that the client has closed the
1087             # connection.  There's no way to be certain that this is
1088             # the situation that has occurred here, but that is the
1089             # most likely case.
1090             pass
1092     def write(self, content):
1093         if not self.headers_done:
1094             self.header()
1095         if self.env['REQUEST_METHOD'] != 'HEAD':
1096             self._socket_op(self.request.wfile.write, content)
1098     def write_html(self, content):
1099         if not self.headers_done:
1100             # at this point, we are sure about Content-Type
1101             if not self.additional_headers.has_key('Content-Type'):
1102                 self.additional_headers['Content-Type'] = \
1103                     'text/html; charset=%s' % self.charset
1104             self.header()
1106         if self.env['REQUEST_METHOD'] == 'HEAD':
1107             # client doesn't care about content
1108             return
1110         if self.charset != self.STORAGE_CHARSET:
1111             # recode output
1112             content = content.decode(self.STORAGE_CHARSET, 'replace')
1113             content = content.encode(self.charset, 'xmlcharrefreplace')
1115         # and write
1116         self._socket_op(self.request.wfile.write, content)
1118     def http_strip(self, content):
1119         """Remove HTTP Linear White Space from 'content'.
1121         'content' -- A string.
1123         returns -- 'content', with all leading and trailing LWS
1124         removed."""
1126         # RFC 2616 2.2: Basic Rules
1127         #
1128         # LWS = [CRLF] 1*( SP | HT )
1129         return content.strip(" \r\n\t")
1131     def http_split(self, content):
1132         """Split an HTTP list.
1134         'content' -- A string, giving a list of items.
1136         returns -- A sequence of strings, containing the elements of
1137         the list."""
1139         # RFC 2616 2.1: Augmented BNF
1140         #
1141         # Grammar productions of the form "#rule" indicate a
1142         # comma-separated list of elements matching "rule".  LWS
1143         # is then removed from each element, and empty elements
1144         # removed.
1146         # Split at commas.
1147         elements = content.split(",")
1148         # Remove linear whitespace at either end of the string.
1149         elements = [self.http_strip(e) for e in elements]
1150         # Remove any now-empty elements.
1151         return [e for e in elements if e]
1152         
1153     def handle_range_header(self, length, etag):
1154         """Handle the 'Range' and 'If-Range' headers.
1156         'length' -- the length of the content available for the
1157         resource.
1159         'etag' -- the entity tag for this resources.
1161         returns -- If the request headers (including 'Range' and
1162         'If-Range') indicate that only a portion of the entity should
1163         be returned, then the return value is a pair '(offfset,
1164         length)' indicating the first byte and number of bytes of the
1165         content that should be returned to the client.  In addition,
1166         this method will set 'self.response_code' to indicate Partial
1167         Content.  In all other cases, the return value is 'None'.  If
1168         appropriate, 'self.response_code' will be
1169         set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'.  In that
1170         case, the caller should not send any data to the client."""
1172         # RFC 2616 14.35: Range
1173         #
1174         # See if the Range header is present.
1175         ranges_specifier = self.env.get("HTTP_RANGE")
1176         if ranges_specifier is None:
1177             return None
1178         # RFC 2616 14.27: If-Range
1179         #
1180         # Check to see if there is an If-Range header.
1181         # Because the specification says:
1182         #
1183         #  The If-Range header ... MUST be ignored if the request
1184         #  does not include a Range header, we check for If-Range
1185         #  after checking for Range.
1186         if_range = self.env.get("HTTP_IF_RANGE")
1187         if if_range:
1188             # The grammar for the If-Range header is:
1189             # 
1190             #   If-Range = "If-Range" ":" ( entity-tag | HTTP-date )
1191             #   entity-tag = [ weak ] opaque-tag
1192             #   weak = "W/"
1193             #   opaque-tag = quoted-string
1194             #
1195             # We only support strong entity tags.
1196             if_range = self.http_strip(if_range)
1197             if (not if_range.startswith('"')
1198                 or not if_range.endswith('"')):
1199                 return None
1200             # If the condition doesn't match the entity tag, then we
1201             # must send the client the entire file.
1202             if if_range != etag:
1203                 return
1204         # The grammar for the Range header value is:
1205         #
1206         #   ranges-specifier = byte-ranges-specifier
1207         #   byte-ranges-specifier = bytes-unit "=" byte-range-set
1208         #   byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
1209         #   byte-range-spec = first-byte-pos "-" [last-byte-pos]
1210         #   first-byte-pos = 1*DIGIT
1211         #   last-byte-pos = 1*DIGIT
1212         #   suffix-byte-range-spec = "-" suffix-length
1213         #   suffix-length = 1*DIGIT
1214         #
1215         # Look for the "=" separating the units from the range set.
1216         specs = ranges_specifier.split("=", 1)
1217         if len(specs) != 2:
1218             return None
1219         # Check that the bytes-unit is in fact "bytes".  If it is not,
1220         # we do not know how to process this range.
1221         bytes_unit = self.http_strip(specs[0])
1222         if bytes_unit != "bytes":
1223             return None
1224         # Seperate the range-set into range-specs.
1225         byte_range_set = self.http_strip(specs[1])
1226         byte_range_specs = self.http_split(byte_range_set)
1227         # We only handle exactly one range at this time.
1228         if len(byte_range_specs) != 1:
1229             return None
1230         # Parse the spec.
1231         byte_range_spec = byte_range_specs[0]
1232         pos = byte_range_spec.split("-", 1)
1233         if len(pos) != 2:
1234             return None
1235         # Get the first and last bytes.
1236         first = self.http_strip(pos[0])
1237         last = self.http_strip(pos[1])
1238         # We do not handle suffix ranges.
1239         if not first:
1240             return None
1241        # Convert the first and last positions to integers.
1242         try:
1243             first = int(first)
1244             if last:
1245                 last = int(last)
1246             else:
1247                 last = length - 1
1248         except:
1249             # The positions could not be parsed as integers.
1250             return None
1251         # Check that the range makes sense.
1252         if (first < 0 or last < 0 or last < first):
1253             return None
1254         if last >= length:
1255             # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable
1256             #
1257             # If there is an If-Range header, RFC 2616 says that we
1258             # should just ignore the invalid Range header.
1259             if if_range:
1260                 return None
1261             # Return code 416 with a Content-Range header giving the
1262             # allowable range.
1263             self.response_code = httplib.REQUESTED_RANGE_NOT_SATISFIABLE
1264             self.setHeader("Content-Range", "bytes */%d" % length)
1265             return None
1266         # RFC 2616 10.2.7: 206 Partial Content
1267         #
1268         # Tell the client that we are honoring the Range request by
1269         # indicating that we are providing partial content.
1270         self.response_code = httplib.PARTIAL_CONTENT
1271         # RFC 2616 14.16: Content-Range
1272         #
1273         # Tell the client what data we are providing.
1274         #
1275         #   content-range-spec = byte-content-range-spec
1276         #   byte-content-range-spec = bytes-unit SP
1277         #                             byte-range-resp-spec "/"
1278         #                             ( instance-length | "*" )
1279         #   byte-range-resp-spec = (first-byte-pos "-" last-byte-pos)
1280         #                          | "*"
1281         #   instance-length      = 1 * DIGIT
1282         self.setHeader("Content-Range",
1283                        "bytes %d-%d/%d" % (first, last, length))
1284         return (first, last - first + 1)
1286     def write_file(self, filename):
1287         """Send the contents of 'filename' to the user."""
1289         # Determine the length of the file.
1290         stat_info = os.stat(filename)
1291         length = stat_info[stat.ST_SIZE]
1292         # Assume we will return the entire file.
1293         offset = 0
1294         # If the headers have not already been finalized, 
1295         if not self.headers_done:
1296             # RFC 2616 14.19: ETag
1297             #
1298             # Compute the entity tag, in a format similar to that
1299             # used by Apache.
1300             etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
1301                                    length,
1302                                    stat_info[stat.ST_MTIME])
1303             self.setHeader("ETag", etag)
1304             # RFC 2616 14.5: Accept-Ranges
1305             #
1306             # Let the client know that we will accept range requests.
1307             self.setHeader("Accept-Ranges", "bytes")
1308             # RFC 2616 14.35: Range
1309             #
1310             # If there is a Range header, we may be able to avoid
1311             # sending the entire file.
1312             content_range = self.handle_range_header(length, etag)
1313             if content_range:
1314                 offset, length = content_range
1315             # RFC 2616 14.13: Content-Length
1316             #
1317             # Tell the client how much data we are providing.
1318             self.setHeader("Content-Length", length)
1319             # Send the HTTP header.
1320             self.header()
1321         # If the client doesn't actually want the body, or if we are
1322         # indicating an invalid range.
1323         if (self.env['REQUEST_METHOD'] == 'HEAD'
1324             or self.response_code == httplib.REQUESTED_RANGE_NOT_SATISFIABLE):
1325             return
1326         # Use the optimized "sendfile" operation, if possible.
1327         if hasattr(self.request, "sendfile"):
1328             self._socket_op(self.request.sendfile, filename, offset, length)
1329             return
1330         # Fallback to the "write" operation.
1331         f = open(filename, 'rb')
1332         try:
1333             if offset:
1334                 f.seek(offset)
1335             content = f.read(length)
1336         finally:
1337             f.close()
1338         self.write(content)
1340     def setHeader(self, header, value):
1341         """Override a header to be returned to the user's browser.
1342         """
1343         self.additional_headers[header] = value
1345     def header(self, headers=None, response=None):
1346         """Put up the appropriate header.
1347         """
1348         if headers is None:
1349             headers = {'Content-Type':'text/html; charset=utf-8'}
1350         if response is None:
1351             response = self.response_code
1353         # update with additional info
1354         headers.update(self.additional_headers)
1356         if headers.get('Content-Type', 'text/html') == 'text/html':
1357             headers['Content-Type'] = 'text/html; charset=utf-8'
1359         headers = headers.items()
1361         for ((path, name), (value, expire)) in self._cookies.items():
1362             cookie = "%s=%s; Path=%s;"%(name, value, path)
1363             if expire is not None:
1364                 cookie += " expires=%s;"%Cookie._getdate(expire)
1365             headers.append(('Set-Cookie', cookie))
1367         self._socket_op(self.request.start_response, headers, response)
1369         self.headers_done = 1
1370         if self.debug:
1371             self.headers_sent = headers
1373     def add_cookie(self, name, value, expire=86400*365, path=None):
1374         """Set a cookie value to be sent in HTTP headers
1376         Parameters:
1377             name:
1378                 cookie name
1379             value:
1380                 cookie value
1381             expire:
1382                 cookie expiration time (seconds).
1383                 If value is empty (meaning "delete cookie"),
1384                 expiration time is forced in the past
1385                 and this argument is ignored.
1386                 If None, the cookie will expire at end-of-session.
1387                 If omitted, the cookie will be kept for a year.
1388             path:
1389                 cookie path (optional)
1391         """
1392         if path is None:
1393             path = self.cookie_path
1394         if not value:
1395             expire = -1
1396         self._cookies[(path, name)] = (value, expire)
1398     def set_cookie(self, user, expire=None):
1399         """Deprecated. Use session_api calls directly
1401         XXX remove
1402         """
1404         # insert the session in the session db
1405         self.session_api.set(user=user)
1406         # refresh session cookie
1407         self.session_api.update(set_cookie=True, expire=expire)
1409     def make_user_anonymous(self):
1410         """ Make us anonymous
1412             This method used to handle non-existence of the 'anonymous'
1413             user, but that user is mandatory now.
1414         """
1415         self.userid = self.db.user.lookup('anonymous')
1416         self.user = 'anonymous'
1418     def standard_message(self, to, subject, body, author=None):
1419         """Send a standard email message from Roundup.
1421         "to"      - recipients list
1422         "subject" - Subject
1423         "body"    - Message
1424         "author"  - (name, address) tuple or None for admin email
1426         Arguments are passed to the Mailer.standard_message code.
1427         """
1428         try:
1429             self.mailer.standard_message(to, subject, body, author)
1430         except MessageSendError, e:
1431             self.error_message.append(str(e))
1432             return 0
1433         return 1
1435     def parsePropsFromForm(self, create=0):
1436         return FormParser(self).parse(create=create)
1438 # vim: set et sts=4 sw=4 :