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 '<%s>'%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
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)
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')
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]
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 :