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