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 LoginError, message:
493 # The user tried to log in, but did not provide a valid
494 # username and password. If we support HTTP
495 # authorization, send back a response that will cause the
496 # browser to prompt the user again.
497 if self.instance.config.WEB_HTTP_AUTH:
498 self.response_code = httplib.UNAUTHORIZED
499 realm = self.instance.config.TRACKER_NAME
500 self.setHeader("WWW-Authenticate",
501 "Basic realm=\"%s\"" % realm)
502 else:
503 self.response_code = httplib.FORBIDDEN
504 self.renderFrontPage(message)
505 except Unauthorised, message:
506 # users may always see the front page
507 self.response_code = 403
508 self.renderFrontPage(message)
509 except NotModified:
510 # send the 304 response
511 self.response_code = 304
512 self.header()
513 except NotFound, e:
514 self.response_code = 404
515 self.template = '404'
516 try:
517 cl = self.db.getclass(self.classname)
518 self.write_html(self.renderContext())
519 except KeyError:
520 # we can't map the URL to a class we know about
521 # reraise the NotFound and let roundup_server
522 # handle it
523 raise NotFound, e
524 except FormError, e:
525 self.error_message.append(self._('Form Error: ') + str(e))
526 self.write_html(self.renderContext())
527 except:
528 # Something has gone badly wrong. Therefore, we should
529 # make sure that the response code indicates failure.
530 if self.response_code == httplib.OK:
531 self.response_code = httplib.INTERNAL_SERVER_ERROR
532 # Help the administrator work out what went wrong.
533 html = ("<h1>Traceback</h1>"
534 + cgitb.html(i18n=self.translator)
535 + ("<h1>Environment Variables</h1><table>%s</table>"
536 % cgitb.niceDict("", self.env)))
537 if not self.instance.config.WEB_DEBUG:
538 exc_info = sys.exc_info()
539 subject = "Error: %s" % exc_info[1]
540 self.send_html_to_admin(subject, html)
541 self.write_html(self._(error_message))
542 else:
543 self.write_html(html)
545 def clean_sessions(self):
546 """Deprecated
547 XXX remove
548 """
549 self.clean_up()
551 def clean_up(self):
552 """Remove expired sessions and One Time Keys.
554 Do it only once an hour.
555 """
556 hour = 60*60
557 now = time.time()
559 # XXX: hack - use OTK table to store last_clean time information
560 # 'last_clean' string is used instead of otk key
561 last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
562 if now - last_clean < hour:
563 return
565 self.session_api.clean_up()
566 self.db.getOTKManager().clean()
567 self.db.getOTKManager().set('last_clean', last_use=now)
568 self.db.commit(fail_ok=True)
570 def determine_charset(self):
571 """Look for client charset in the form parameters or browser cookie.
573 If no charset requested by client, use storage charset (utf-8).
575 If the charset is found, and differs from the storage charset,
576 recode all form fields of type 'text/plain'
577 """
578 # look for client charset
579 charset_parameter = 0
580 if self.form.has_key('@charset'):
581 charset = self.form['@charset'].value
582 if charset.lower() == "none":
583 charset = ""
584 charset_parameter = 1
585 elif self.cookie.has_key('roundup_charset'):
586 charset = self.cookie['roundup_charset'].value
587 else:
588 charset = None
589 if charset:
590 # make sure the charset is recognized
591 try:
592 codecs.lookup(charset)
593 except LookupError:
594 self.error_message.append(self._('Unrecognized charset: %r')
595 % charset)
596 charset_parameter = 0
597 else:
598 self.charset = charset.lower()
599 # If we've got a character set in request parameters,
600 # set the browser cookie to keep the preference.
601 # This is done after codecs.lookup to make sure
602 # that we aren't keeping a wrong value.
603 if charset_parameter:
604 self.add_cookie('roundup_charset', charset)
606 # if client charset is different from the storage charset,
607 # recode form fields
608 # XXX this requires FieldStorage from Python library.
609 # mod_python FieldStorage is not supported!
610 if self.charset != self.STORAGE_CHARSET:
611 decoder = codecs.getdecoder(self.charset)
612 encoder = codecs.getencoder(self.STORAGE_CHARSET)
613 re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
614 def _decode_charref(matchobj):
615 num = matchobj.group(1)
616 if num[0].lower() == 'x':
617 uc = int(num[1:], 16)
618 else:
619 uc = int(num)
620 return unichr(uc)
622 for field_name in self.form.keys():
623 field = self.form[field_name]
624 if (field.type == 'text/plain') and not field.filename:
625 try:
626 value = decoder(field.value)[0]
627 except UnicodeError:
628 continue
629 value = re_charref.sub(_decode_charref, value)
630 field.value = encoder(value)[0]
632 def determine_language(self):
633 """Determine the language"""
634 # look for language parameter
635 # then for language cookie
636 # last for the Accept-Language header
637 if self.form.has_key("@language"):
638 language = self.form["@language"].value
639 if language.lower() == "none":
640 language = ""
641 self.add_cookie("roundup_language", language)
642 elif self.cookie.has_key("roundup_language"):
643 language = self.cookie["roundup_language"].value
644 elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
645 hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
646 language = accept_language.parse(hal)
647 else:
648 language = ""
650 self.language = language
651 if language:
652 self.setTranslator(TranslationService.get_translation(
653 language,
654 tracker_home=self.instance.config["TRACKER_HOME"]))
656 def determine_user(self):
657 """Determine who the user is"""
658 self.opendb('admin')
660 # get session data from db
661 # XXX: rename
662 self.session_api = Session(self)
664 # take the opportunity to cleanup expired sessions and otks
665 self.clean_up()
667 user = None
668 # first up, try http authorization if enabled
669 if self.instance.config['WEB_HTTP_AUTH']:
670 if self.env.has_key('REMOTE_USER'):
671 # we have external auth (e.g. by Apache)
672 user = self.env['REMOTE_USER']
673 elif self.env.get('HTTP_AUTHORIZATION', ''):
674 # try handling Basic Auth ourselves
675 auth = self.env['HTTP_AUTHORIZATION']
676 scheme, challenge = auth.split(' ', 1)
677 if scheme.lower() == 'basic':
678 try:
679 decoded = base64.decodestring(challenge)
680 except TypeError:
681 # invalid challenge
682 pass
683 username, password = decoded.split(':')
684 try:
685 login = self.get_action_class('login')(self)
686 login.verifyLogin(username, password)
687 except LoginError, err:
688 self.make_user_anonymous()
689 raise
690 user = username
692 # if user was not set by http authorization, try session lookup
693 if not user:
694 user = self.session_api.get('user')
695 if user:
696 # update session lifetime datestamp
697 self.session_api.update()
699 # if no user name set by http authorization or session lookup
700 # the user is anonymous
701 if not user:
702 user = 'anonymous'
704 # sanity check on the user still being valid,
705 # getting the userid at the same time
706 try:
707 self.userid = self.db.user.lookup(user)
708 except (KeyError, TypeError):
709 user = 'anonymous'
711 # make sure the anonymous user is valid if we're using it
712 if user == 'anonymous':
713 self.make_user_anonymous()
714 if not self.db.security.hasPermission('Web Access', self.userid):
715 raise Unauthorised, self._("Anonymous users are not "
716 "allowed to use the web interface")
717 else:
718 self.user = user
720 # reopen the database as the correct user
721 self.opendb(self.user)
723 def opendb(self, username):
724 """Open the database and set the current user.
726 Opens a database once. On subsequent calls only the user is set on
727 the database object the instance.optimize is set. If we are in
728 "Development Mode" (cf. roundup_server) then the database is always
729 re-opened.
730 """
731 # don't do anything if the db is open and the user has not changed
732 if hasattr(self, 'db') and self.db.isCurrentUser(username):
733 return
735 # open the database or only set the user
736 if not hasattr(self, 'db'):
737 self.db = self.instance.open(username)
738 else:
739 if self.instance.optimize:
740 self.db.setCurrentUser(username)
741 else:
742 self.db.close()
743 self.db = self.instance.open(username)
744 # The old session API refers to the closed database;
745 # we can no longer use it.
746 self.session_api = Session(self)
749 def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
750 """Determine the context of this page from the URL:
752 The URL path after the instance identifier is examined. The path
753 is generally only one entry long.
755 - if there is no path, then we are in the "home" context.
756 - if the path is "_file", then the additional path entry
757 specifies the filename of a static file we're to serve up
758 from the instance "html" directory. Raises a SendStaticFile
759 exception.(*)
760 - if there is something in the path (eg "issue"), it identifies
761 the tracker class we're to display.
762 - if the path is an item designator (eg "issue123"), then we're
763 to display a specific item.
764 - if the path starts with an item designator and is longer than
765 one entry, then we're assumed to be handling an item of a
766 FileClass, and the extra path information gives the filename
767 that the client is going to label the download with (ie
768 "file123/image.png" is nicer to download than "file123"). This
769 raises a SendFile exception.(*)
771 Both of the "*" types of contexts stop before we bother to
772 determine the template we're going to use. That's because they
773 don't actually use templates.
775 The template used is specified by the :template CGI variable,
776 which defaults to:
778 - only classname suplied: "index"
779 - full item designator supplied: "item"
781 We set:
783 self.classname - the class to display, can be None
785 self.template - the template to render the current context with
787 self.nodeid - the nodeid of the class we're displaying
788 """
789 # default the optional variables
790 self.classname = None
791 self.nodeid = None
793 # see if a template or messages are specified
794 template_override = ok_message = error_message = None
795 for key in self.form.keys():
796 if self.FV_TEMPLATE.match(key):
797 template_override = self.form[key].value
798 elif self.FV_OK_MESSAGE.match(key):
799 ok_message = self.form[key].value
800 ok_message = clean_message(ok_message)
801 elif self.FV_ERROR_MESSAGE.match(key):
802 error_message = self.form[key].value
803 error_message = clean_message(error_message)
805 # see if we were passed in a message
806 if ok_message:
807 self.ok_message.append(ok_message)
808 if error_message:
809 self.error_message.append(error_message)
811 # determine the classname and possibly nodeid
812 path = self.path.split('/')
813 if not path or path[0] in ('', 'home', 'index'):
814 if template_override is not None:
815 self.template = template_override
816 else:
817 self.template = ''
818 return
819 elif path[0] in ('_file', '@@file'):
820 raise SendStaticFile, os.path.join(*path[1:])
821 else:
822 self.classname = path[0]
823 if len(path) > 1:
824 # send the file identified by the designator in path[0]
825 raise SendFile, path[0]
827 # see if we got a designator
828 m = dre.match(self.classname)
829 if m:
830 self.classname = m.group(1)
831 self.nodeid = m.group(2)
832 try:
833 klass = self.db.getclass(self.classname)
834 except KeyError:
835 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
836 if not klass.hasnode(self.nodeid):
837 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
838 # with a designator, we default to item view
839 self.template = 'item'
840 else:
841 # with only a class, we default to index view
842 self.template = 'index'
844 # make sure the classname is valid
845 try:
846 self.db.getclass(self.classname)
847 except KeyError:
848 raise NotFound, self.classname
850 # see if we have a template override
851 if template_override is not None:
852 self.template = template_override
854 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
855 """ Serve the file from the content property of the designated item.
856 """
857 m = dre.match(str(designator))
858 if not m:
859 raise NotFound, str(designator)
860 classname, nodeid = m.group(1), m.group(2)
862 try:
863 klass = self.db.getclass(classname)
864 except KeyError:
865 # The classname was not valid.
866 raise NotFound, str(designator)
869 # make sure we have the appropriate properties
870 props = klass.getprops()
871 if not props.has_key('type'):
872 raise NotFound, designator
873 if not props.has_key('content'):
874 raise NotFound, designator
876 # make sure we have permission
877 if not self.db.security.hasPermission('View', self.userid,
878 classname, 'content', nodeid):
879 raise Unauthorised, self._("You are not allowed to view "
880 "this file.")
882 mime_type = klass.get(nodeid, 'type')
883 # Can happen for msg class:
884 if not mime_type:
885 mime_type = 'text/plain'
887 # if the mime_type is HTML-ish then make sure we're allowed to serve up
888 # HTML-ish content
889 if mime_type in ('text/html', 'text/x-html'):
890 if not self.instance.config['WEB_ALLOW_HTML_FILE']:
891 # do NOT serve the content up as HTML
892 mime_type = 'application/octet-stream'
894 # If this object is a file (i.e., an instance of FileClass),
895 # see if we can find it in the filesystem. If so, we may be
896 # able to use the more-efficient request.sendfile method of
897 # sending the file. If not, just get the "content" property
898 # in the usual way, and use that.
899 content = None
900 filename = None
901 if isinstance(klass, hyperdb.FileClass):
902 try:
903 filename = self.db.filename(classname, nodeid)
904 except AttributeError:
905 # The database doesn't store files in the filesystem
906 # and therefore doesn't provide the "filename" method.
907 pass
908 except IOError:
909 # The file does not exist.
910 pass
911 if not filename:
912 content = klass.get(nodeid, 'content')
914 lmt = klass.get(nodeid, 'activity').timestamp()
916 self._serve_file(lmt, mime_type, content, filename)
918 def serve_static_file(self, file):
919 """ Serve up the file named from the templates dir
920 """
921 # figure the filename - try STATIC_FILES, then TEMPLATES dir
922 for dir_option in ('STATIC_FILES', 'TEMPLATES'):
923 prefix = self.instance.config[dir_option]
924 if not prefix:
925 continue
926 # ensure the load doesn't try to poke outside
927 # of the static files directory
928 prefix = os.path.normpath(prefix)
929 filename = os.path.normpath(os.path.join(prefix, file))
930 if os.path.isfile(filename) and filename.startswith(prefix):
931 break
932 else:
933 raise NotFound, file
935 # last-modified time
936 lmt = os.stat(filename)[stat.ST_MTIME]
938 # detemine meta-type
939 file = str(file)
940 mime_type = mimetypes.guess_type(file)[0]
941 if not mime_type:
942 if file.endswith('.css'):
943 mime_type = 'text/css'
944 else:
945 mime_type = 'text/plain'
947 self._serve_file(lmt, mime_type, '', filename)
949 def _serve_file(self, lmt, mime_type, content=None, filename=None):
950 """ guts of serve_file() and serve_static_file()
951 """
953 # spit out headers
954 self.additional_headers['Content-Type'] = mime_type
955 self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
957 ims = None
958 # see if there's an if-modified-since...
959 # XXX see which interfaces set this
960 #if hasattr(self.request, 'headers'):
961 #ims = self.request.headers.getheader('if-modified-since')
962 if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
963 # cgi will put the header in the env var
964 ims = self.env['HTTP_IF_MODIFIED_SINCE']
965 if ims:
966 ims = rfc822.parsedate(ims)[:6]
967 lmtt = time.gmtime(lmt)[:6]
968 if lmtt <= ims:
969 raise NotModified
971 if filename:
972 self.write_file(filename)
973 else:
974 self.additional_headers['Content-Length'] = str(len(content))
975 self.write(content)
977 def send_html_to_admin(self, subject, content):
979 to = [self.mailer.config.ADMIN_EMAIL]
980 message = self.mailer.get_standard_message(to, subject)
981 # delete existing content-type headers
982 del message['Content-type']
983 message['Content-type'] = 'text/html; charset=utf-8'
984 message.set_payload(content)
985 encode_quopri(message)
986 self.mailer.smtp_send(to, str(message))
988 def renderFrontPage(self, message):
989 """Return the front page of the tracker."""
991 self.classname = self.nodeid = None
992 self.template = ''
993 self.error_message.append(message)
994 self.write_html(self.renderContext())
996 def renderContext(self):
997 """ Return a PageTemplate for the named page
998 """
999 name = self.classname
1000 extension = self.template
1002 # catch errors so we can handle PT rendering errors more nicely
1003 args = {
1004 'ok_message': self.ok_message,
1005 'error_message': self.error_message
1006 }
1007 try:
1008 pt = self.instance.templates.get(name, extension)
1009 # let the template render figure stuff out
1010 result = pt.render(self, None, None, **args)
1011 self.additional_headers['Content-Type'] = pt.content_type
1012 if self.env.get('CGI_SHOW_TIMING', ''):
1013 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
1014 timings = {'starttag': '<!-- ', 'endtag': ' -->'}
1015 else:
1016 timings = {'starttag': '<p>', 'endtag': '</p>'}
1017 timings['seconds'] = time.time()-self.start
1018 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
1019 ) % timings
1020 if hasattr(self.db, 'stats'):
1021 timings.update(self.db.stats)
1022 s += self._("%(starttag)sCache hits: %(cache_hits)d,"
1023 " misses %(cache_misses)d."
1024 " Loading items: %(get_items)f secs."
1025 " Filtering: %(filtering)f secs."
1026 "%(endtag)s\n") % timings
1027 s += '</body>'
1028 result = result.replace('</body>', s)
1029 return result
1030 except templating.NoTemplate, message:
1031 return '<strong>%s</strong>'%message
1032 except templating.Unauthorised, message:
1033 raise Unauthorised, str(message)
1034 except:
1035 # everything else
1036 if self.instance.config.WEB_DEBUG:
1037 return cgitb.pt_html(i18n=self.translator)
1038 exc_info = sys.exc_info()
1039 try:
1040 # If possible, send the HTML page template traceback
1041 # to the administrator.
1042 subject = "Templating Error: %s" % exc_info[1]
1043 self.send_html_to_admin(subject, cgitb.pt_html())
1044 # Now report the error to the user.
1045 return self._(error_message)
1046 except:
1047 # Reraise the original exception. The user will
1048 # receive an error message, and the adminstrator will
1049 # receive a traceback, albeit with less information
1050 # than the one we tried to generate above.
1051 raise exc_info[0], exc_info[1], exc_info[2]
1053 # these are the actions that are available
1054 actions = (
1055 ('edit', EditItemAction),
1056 ('editcsv', EditCSVAction),
1057 ('new', NewItemAction),
1058 ('register', RegisterAction),
1059 ('confrego', ConfRegoAction),
1060 ('passrst', PassResetAction),
1061 ('login', LoginAction),
1062 ('logout', LogoutAction),
1063 ('search', SearchAction),
1064 ('retire', RetireAction),
1065 ('show', ShowAction),
1066 ('export_csv', ExportCSVAction),
1067 )
1068 def handle_action(self):
1069 """ Determine whether there should be an Action called.
1071 The action is defined by the form variable :action which
1072 identifies the method on this object to call. The actions
1073 are defined in the "actions" sequence on this class.
1075 Actions may return a page (by default HTML) to return to the
1076 user, bypassing the usual template rendering.
1078 We explicitly catch Reject and ValueError exceptions and
1079 present their messages to the user.
1080 """
1081 if self.form.has_key(':action'):
1082 action = self.form[':action'].value.lower()
1083 elif self.form.has_key('@action'):
1084 action = self.form['@action'].value.lower()
1085 else:
1086 return None
1088 try:
1089 action_klass = self.get_action_class(action)
1091 # call the mapped action
1092 if isinstance(action_klass, type('')):
1093 # old way of specifying actions
1094 return getattr(self, action_klass)()
1095 else:
1096 return action_klass(self).execute()
1098 except (ValueError, Reject), err:
1099 self.error_message.append(str(err))
1101 def get_action_class(self, action_name):
1102 if (hasattr(self.instance, 'cgi_actions') and
1103 self.instance.cgi_actions.has_key(action_name)):
1104 # tracker-defined action
1105 action_klass = self.instance.cgi_actions[action_name]
1106 else:
1107 # go with a default
1108 for name, action_klass in self.actions:
1109 if name == action_name:
1110 break
1111 else:
1112 raise ValueError, 'No such action "%s"'%action_name
1113 return action_klass
1115 def _socket_op(self, call, *args, **kwargs):
1116 """Execute socket-related operation, catch common network errors
1118 Parameters:
1119 call: a callable to execute
1120 args, kwargs: call arguments
1122 """
1123 try:
1124 call(*args, **kwargs)
1125 except socket.error, err:
1126 err_errno = getattr (err, 'errno', None)
1127 if err_errno is None:
1128 try:
1129 err_errno = err[0]
1130 except TypeError:
1131 pass
1132 if err_errno not in self.IGNORE_NET_ERRORS:
1133 raise
1134 except IOError:
1135 # Apache's mod_python will raise IOError -- without an
1136 # accompanying errno -- when a write to the client fails.
1137 # A common case is that the client has closed the
1138 # connection. There's no way to be certain that this is
1139 # the situation that has occurred here, but that is the
1140 # most likely case.
1141 pass
1143 def write(self, content):
1144 if not self.headers_done:
1145 self.header()
1146 if self.env['REQUEST_METHOD'] != 'HEAD':
1147 self._socket_op(self.request.wfile.write, content)
1149 def write_html(self, content):
1150 if not self.headers_done:
1151 # at this point, we are sure about Content-Type
1152 if not self.additional_headers.has_key('Content-Type'):
1153 self.additional_headers['Content-Type'] = \
1154 'text/html; charset=%s' % self.charset
1155 self.header()
1157 if self.env['REQUEST_METHOD'] == 'HEAD':
1158 # client doesn't care about content
1159 return
1161 if self.charset != self.STORAGE_CHARSET:
1162 # recode output
1163 content = content.decode(self.STORAGE_CHARSET, 'replace')
1164 content = content.encode(self.charset, 'xmlcharrefreplace')
1166 # and write
1167 self._socket_op(self.request.wfile.write, content)
1169 def http_strip(self, content):
1170 """Remove HTTP Linear White Space from 'content'.
1172 'content' -- A string.
1174 returns -- 'content', with all leading and trailing LWS
1175 removed."""
1177 # RFC 2616 2.2: Basic Rules
1178 #
1179 # LWS = [CRLF] 1*( SP | HT )
1180 return content.strip(" \r\n\t")
1182 def http_split(self, content):
1183 """Split an HTTP list.
1185 'content' -- A string, giving a list of items.
1187 returns -- A sequence of strings, containing the elements of
1188 the list."""
1190 # RFC 2616 2.1: Augmented BNF
1191 #
1192 # Grammar productions of the form "#rule" indicate a
1193 # comma-separated list of elements matching "rule". LWS
1194 # is then removed from each element, and empty elements
1195 # removed.
1197 # Split at commas.
1198 elements = content.split(",")
1199 # Remove linear whitespace at either end of the string.
1200 elements = [self.http_strip(e) for e in elements]
1201 # Remove any now-empty elements.
1202 return [e for e in elements if e]
1204 def handle_range_header(self, length, etag):
1205 """Handle the 'Range' and 'If-Range' headers.
1207 'length' -- the length of the content available for the
1208 resource.
1210 'etag' -- the entity tag for this resources.
1212 returns -- If the request headers (including 'Range' and
1213 'If-Range') indicate that only a portion of the entity should
1214 be returned, then the return value is a pair '(offfset,
1215 length)' indicating the first byte and number of bytes of the
1216 content that should be returned to the client. In addition,
1217 this method will set 'self.response_code' to indicate Partial
1218 Content. In all other cases, the return value is 'None'. If
1219 appropriate, 'self.response_code' will be
1220 set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'. In that
1221 case, the caller should not send any data to the client."""
1223 # RFC 2616 14.35: Range
1224 #
1225 # See if the Range header is present.
1226 ranges_specifier = self.env.get("HTTP_RANGE")
1227 if ranges_specifier is None:
1228 return None
1229 # RFC 2616 14.27: If-Range
1230 #
1231 # Check to see if there is an If-Range header.
1232 # Because the specification says:
1233 #
1234 # The If-Range header ... MUST be ignored if the request
1235 # does not include a Range header, we check for If-Range
1236 # after checking for Range.
1237 if_range = self.env.get("HTTP_IF_RANGE")
1238 if if_range:
1239 # The grammar for the If-Range header is:
1240 #
1241 # If-Range = "If-Range" ":" ( entity-tag | HTTP-date )
1242 # entity-tag = [ weak ] opaque-tag
1243 # weak = "W/"
1244 # opaque-tag = quoted-string
1245 #
1246 # We only support strong entity tags.
1247 if_range = self.http_strip(if_range)
1248 if (not if_range.startswith('"')
1249 or not if_range.endswith('"')):
1250 return None
1251 # If the condition doesn't match the entity tag, then we
1252 # must send the client the entire file.
1253 if if_range != etag:
1254 return
1255 # The grammar for the Range header value is:
1256 #
1257 # ranges-specifier = byte-ranges-specifier
1258 # byte-ranges-specifier = bytes-unit "=" byte-range-set
1259 # byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
1260 # byte-range-spec = first-byte-pos "-" [last-byte-pos]
1261 # first-byte-pos = 1*DIGIT
1262 # last-byte-pos = 1*DIGIT
1263 # suffix-byte-range-spec = "-" suffix-length
1264 # suffix-length = 1*DIGIT
1265 #
1266 # Look for the "=" separating the units from the range set.
1267 specs = ranges_specifier.split("=", 1)
1268 if len(specs) != 2:
1269 return None
1270 # Check that the bytes-unit is in fact "bytes". If it is not,
1271 # we do not know how to process this range.
1272 bytes_unit = self.http_strip(specs[0])
1273 if bytes_unit != "bytes":
1274 return None
1275 # Seperate the range-set into range-specs.
1276 byte_range_set = self.http_strip(specs[1])
1277 byte_range_specs = self.http_split(byte_range_set)
1278 # We only handle exactly one range at this time.
1279 if len(byte_range_specs) != 1:
1280 return None
1281 # Parse the spec.
1282 byte_range_spec = byte_range_specs[0]
1283 pos = byte_range_spec.split("-", 1)
1284 if len(pos) != 2:
1285 return None
1286 # Get the first and last bytes.
1287 first = self.http_strip(pos[0])
1288 last = self.http_strip(pos[1])
1289 # We do not handle suffix ranges.
1290 if not first:
1291 return None
1292 # Convert the first and last positions to integers.
1293 try:
1294 first = int(first)
1295 if last:
1296 last = int(last)
1297 else:
1298 last = length - 1
1299 except:
1300 # The positions could not be parsed as integers.
1301 return None
1302 # Check that the range makes sense.
1303 if (first < 0 or last < 0 or last < first):
1304 return None
1305 if last >= length:
1306 # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable
1307 #
1308 # If there is an If-Range header, RFC 2616 says that we
1309 # should just ignore the invalid Range header.
1310 if if_range:
1311 return None
1312 # Return code 416 with a Content-Range header giving the
1313 # allowable range.
1314 self.response_code = httplib.REQUESTED_RANGE_NOT_SATISFIABLE
1315 self.setHeader("Content-Range", "bytes */%d" % length)
1316 return None
1317 # RFC 2616 10.2.7: 206 Partial Content
1318 #
1319 # Tell the client that we are honoring the Range request by
1320 # indicating that we are providing partial content.
1321 self.response_code = httplib.PARTIAL_CONTENT
1322 # RFC 2616 14.16: Content-Range
1323 #
1324 # Tell the client what data we are providing.
1325 #
1326 # content-range-spec = byte-content-range-spec
1327 # byte-content-range-spec = bytes-unit SP
1328 # byte-range-resp-spec "/"
1329 # ( instance-length | "*" )
1330 # byte-range-resp-spec = (first-byte-pos "-" last-byte-pos)
1331 # | "*"
1332 # instance-length = 1 * DIGIT
1333 self.setHeader("Content-Range",
1334 "bytes %d-%d/%d" % (first, last, length))
1335 return (first, last - first + 1)
1337 def write_file(self, filename):
1338 """Send the contents of 'filename' to the user."""
1340 # Determine the length of the file.
1341 stat_info = os.stat(filename)
1342 length = stat_info[stat.ST_SIZE]
1343 # Assume we will return the entire file.
1344 offset = 0
1345 # If the headers have not already been finalized,
1346 if not self.headers_done:
1347 # RFC 2616 14.19: ETag
1348 #
1349 # Compute the entity tag, in a format similar to that
1350 # used by Apache.
1351 etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
1352 length,
1353 stat_info[stat.ST_MTIME])
1354 self.setHeader("ETag", etag)
1355 # RFC 2616 14.5: Accept-Ranges
1356 #
1357 # Let the client know that we will accept range requests.
1358 self.setHeader("Accept-Ranges", "bytes")
1359 # RFC 2616 14.35: Range
1360 #
1361 # If there is a Range header, we may be able to avoid
1362 # sending the entire file.
1363 content_range = self.handle_range_header(length, etag)
1364 if content_range:
1365 offset, length = content_range
1366 # RFC 2616 14.13: Content-Length
1367 #
1368 # Tell the client how much data we are providing.
1369 self.setHeader("Content-Length", str(length))
1370 # Send the HTTP header.
1371 self.header()
1372 # If the client doesn't actually want the body, or if we are
1373 # indicating an invalid range.
1374 if (self.env['REQUEST_METHOD'] == 'HEAD'
1375 or self.response_code == httplib.REQUESTED_RANGE_NOT_SATISFIABLE):
1376 return
1377 # Use the optimized "sendfile" operation, if possible.
1378 if hasattr(self.request, "sendfile"):
1379 self._socket_op(self.request.sendfile, filename, offset, length)
1380 return
1381 # Fallback to the "write" operation.
1382 f = open(filename, 'rb')
1383 try:
1384 if offset:
1385 f.seek(offset)
1386 content = f.read(length)
1387 finally:
1388 f.close()
1389 self.write(content)
1391 def setHeader(self, header, value):
1392 """Override a header to be returned to the user's browser.
1393 """
1394 self.additional_headers[header] = value
1396 def header(self, headers=None, response=None):
1397 """Put up the appropriate header.
1398 """
1399 if headers is None:
1400 headers = {'Content-Type':'text/html; charset=utf-8'}
1401 if response is None:
1402 response = self.response_code
1404 # update with additional info
1405 headers.update(self.additional_headers)
1407 if headers.get('Content-Type', 'text/html') == 'text/html':
1408 headers['Content-Type'] = 'text/html; charset=utf-8'
1410 headers = headers.items()
1412 for ((path, name), (value, expire)) in self._cookies.items():
1413 cookie = "%s=%s; Path=%s;"%(name, value, path)
1414 if expire is not None:
1415 cookie += " expires=%s;"%Cookie._getdate(expire)
1416 headers.append(('Set-Cookie', cookie))
1418 self._socket_op(self.request.start_response, headers, response)
1420 self.headers_done = 1
1421 if self.debug:
1422 self.headers_sent = headers
1424 def add_cookie(self, name, value, expire=86400*365, path=None):
1425 """Set a cookie value to be sent in HTTP headers
1427 Parameters:
1428 name:
1429 cookie name
1430 value:
1431 cookie value
1432 expire:
1433 cookie expiration time (seconds).
1434 If value is empty (meaning "delete cookie"),
1435 expiration time is forced in the past
1436 and this argument is ignored.
1437 If None, the cookie will expire at end-of-session.
1438 If omitted, the cookie will be kept for a year.
1439 path:
1440 cookie path (optional)
1442 """
1443 if path is None:
1444 path = self.cookie_path
1445 if not value:
1446 expire = -1
1447 self._cookies[(path, name)] = (value, expire)
1449 def set_cookie(self, user, expire=None):
1450 """Deprecated. Use session_api calls directly
1452 XXX remove
1453 """
1455 # insert the session in the session db
1456 self.session_api.set(user=user)
1457 # refresh session cookie
1458 self.session_api.update(set_cookie=True, expire=expire)
1460 def make_user_anonymous(self):
1461 """ Make us anonymous
1463 This method used to handle non-existence of the 'anonymous'
1464 user, but that user is mandatory now.
1465 """
1466 self.userid = self.db.user.lookup('anonymous')
1467 self.user = 'anonymous'
1469 def standard_message(self, to, subject, body, author=None):
1470 """Send a standard email message from Roundup.
1472 "to" - recipients list
1473 "subject" - Subject
1474 "body" - Message
1475 "author" - (name, address) tuple or None for admin email
1477 Arguments are passed to the Mailer.standard_message code.
1478 """
1479 try:
1480 self.mailer.standard_message(to, subject, body, author)
1481 except MessageSendError, e:
1482 self.error_message.append(str(e))
1483 return 0
1484 return 1
1486 def parsePropsFromForm(self, create=0):
1487 return FormParser(self).parse(create=create)
1489 # vim: set et sts=4 sw=4 :