X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;f=roundup%2Fcgi%2Fclient.py;h=d1775daa530d119904a9f50d015272ae3a299959;hb=0e52c41f35764f37f1e00d30203d3e6e6122a0da;hp=57fdd110c9ca6a4bd0a4bdb93b33257899b2c5f1;hpb=53ea64cdb21d7beee1f69c3b7057d97ec4e84a74;p=roundup.git diff --git a/roundup/cgi/client.py b/roundup/cgi/client.py index 57fdd11..d1775da 100644 --- a/roundup/cgi/client.py +++ b/roundup/cgi/client.py @@ -1,19 +1,22 @@ -# $Id: client.py,v 1.163 2004-02-25 03:24:43 richard Exp $ +# $Id: client.py,v 1.239 2008-08-18 05:04:02 richard Exp $ """WWW request handler (also used in the stand-alone server). """ __docformat__ = 'restructuredtext' -import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib -import binascii, Cookie, time, random, stat, rfc822 +import base64, binascii, cgi, codecs, mimetypes, os +import random, re, rfc822, stat, time, urllib, urlparse +import Cookie, socket, errno +from Cookie import CookieError, BaseCookie, SimpleCookie from roundup import roundupdb, date, hyperdb, password -from roundup.i18n import _ -from roundup.cgi import templating, cgitb +from roundup.cgi import templating, cgitb, TranslationService from roundup.cgi.actions import * +from roundup.exceptions import * from roundup.cgi.exceptions import * from roundup.cgi.form_parser import FormParser from roundup.mailer import Mailer, MessageSendError +from roundup.cgi import accept_language def initialiseSecurity(security): '''Create some Permissions and Roles on the security object @@ -21,13 +24,12 @@ def initialiseSecurity(security): This function is directly invoked by security.Security.__init__() as a part of the Security object instantiation. ''' - security.addPermission(name="Web Registration", - description="User may register through the web") p = security.addPermission(name="Web Access", description="User may access the web interface") security.addPermissionToRole('Admin', p) # doing Role stuff through the web - make sure Admin can + # TODO: deprecate this and use a property-based control p = security.addPermission(name="Web Roles", description="User may manipulate user Roles through the web") security.addPermissionToRole('Admin', p) @@ -45,6 +47,153 @@ def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}): return match.group(1) return '<%s>'%match.group(2) + +error_message = ""'''An error has occurred +

An error has occurred

+

A problem was encountered processing your request. +The tracker maintainers have been notified of the problem.

+''' + + +class LiberalCookie(SimpleCookie): + ''' Python's SimpleCookie throws an exception if the cookie uses invalid + syntax. Other applications on the same server may have done precisely + this, preventing roundup from working through no fault of roundup. + Numerous other python apps have run into the same problem: + + trac: http://trac.edgewall.org/ticket/2256 + mailman: http://bugs.python.org/issue472646 + + This particular implementation comes from trac's solution to the + problem. Unfortunately it requires some hackery in SimpleCookie's + internals to provide a more liberal __set method. + ''' + def load(self, rawdata, ignore_parse_errors=True): + if ignore_parse_errors: + self.bad_cookies = [] + self._BaseCookie__set = self._loose_set + SimpleCookie.load(self, rawdata) + if ignore_parse_errors: + self._BaseCookie__set = self._strict_set + for key in self.bad_cookies: + del self[key] + + _strict_set = BaseCookie._BaseCookie__set + + def _loose_set(self, key, real_value, coded_value): + try: + self._strict_set(key, real_value, coded_value) + except CookieError: + self.bad_cookies.append(key) + dict.__setitem__(self, key, None) + + +class Session: + ''' + Needs DB to be already opened by client + + Session attributes at instantiation: + + - "client" - reference to client for add_cookie function + - "session_db" - session DB manager + - "cookie_name" - name of the cookie with session id + - "_sid" - session id for current user + - "_data" - session data cache + + session = Session(client) + session.set(name=value) + value = session.get(name) + + session.destroy() # delete current session + session.clean_up() # clean up session table + + session.update(set_cookie=True, expire=3600*24*365) + # refresh session expiration time, setting persistent + # cookie if needed to last for 'expire' seconds + + ''' + + def __init__(self, client): + self._data = {} + self._sid = None + + self.client = client + self.session_db = client.db.getSessionManager() + + # parse cookies for session id + self.cookie_name = 'roundup_session_%s' % \ + re.sub('[^a-zA-Z]', '', client.instance.config.TRACKER_NAME) + cookies = LiberalCookie(client.env.get('HTTP_COOKIE', '')) + if self.cookie_name in cookies: + if not self.session_db.exists(cookies[self.cookie_name].value): + self._sid = None + # remove old cookie + self.client.add_cookie(self.cookie_name, None) + else: + self._sid = cookies[self.cookie_name].value + self._data = self.session_db.getall(self._sid) + + def _gen_sid(self): + ''' generate a unique session key ''' + while 1: + s = '%s%s'%(time.time(), random.random()) + s = binascii.b2a_base64(s).strip() + if not self.session_db.exists(s): + break + + # clean up the base64 + if s[-1] == '=': + if s[-2] == '=': + s = s[:-2] + else: + s = s[:-1] + return s + + def clean_up(self): + '''Remove expired sessions''' + self.session_db.clean() + + def destroy(self): + self.client.add_cookie(self.cookie_name, None) + self._data = {} + self.session_db.destroy(self._sid) + self.client.db.commit() + + def get(self, name, default=None): + return self._data.get(name, default) + + def set(self, **kwargs): + self._data.update(kwargs) + if not self._sid: + self._sid = self._gen_sid() + self.session_db.set(self._sid, **self._data) + # add session cookie + self.update(set_cookie=True) + + # XXX added when patching 1.4.4 for backward compatibility + # XXX remove + self.client.session = self._sid + else: + self.session_db.set(self._sid, **self._data) + self.client.db.commit() + + def update(self, set_cookie=False, expire=None): + ''' update timestamp in db to avoid expiration + + if 'set_cookie' is True, set cookie with 'expire' seconds lifetime + if 'expire' is None - session will be closed with the browser + + XXX the session can be purged within a week even if a cookie + lifetime is longer + ''' + self.session_db.updateTimestamp(self._sid) + self.client.db.commit() + + if set_cookie: + self.client.add_cookie(self.cookie_name, self._sid, expire=expire) + + + class Client: '''Instantiate to handle one CGI request. @@ -59,12 +208,15 @@ class Client: - "additional_headers" is a dictionary of additional HTTP headers that should be sent to the client - "response_code" is the HTTP response code to send to the client + - "translator" is TranslationService instance During the processing of a request, the following attributes are used: + - "db" - "error_message" holds a list of error messages - "ok_message" holds a list of OK messages - - "session" is the current user session id + - "session" is deprecated in favor of session_api (XXX remove) + - "session_api" is the interface to store data in session - "user" is the current user's name - "userid" is the current user's id - "template" is the current :template context @@ -72,12 +224,12 @@ class Client: - "nodeid" is the current context item id User Identification: - If the user has no login cookie, then they are anonymous and are logged + Users that are absent in session data are anonymous and are logged in as that user. This typically gives them all Permissions assigned to the Anonymous Role. - Once a user logs in, they are assigned a session. The Client instance - keeps the nodeid of the session as the "session" attribute. + Every user is assigned a session. "session_api" is the interface to work + with session data. Special form variables: Note that in various places throughout this code, special form @@ -85,6 +237,11 @@ class Client: actually be one of either ":" or "@". ''' + # charset used for data storage and form templates + # Note: must be in lower case for comparisons! + # XXX take this from instance.config? + STORAGE_CHARSET = 'utf-8' + # # special form variables # @@ -96,11 +253,32 @@ class Client: # columns, sort, sortdir, filter, group, groupdir, search_text, # pagesize, startwith - def __init__(self, instance, request, env, form=None): - hyperdb.traceMark() + # list of network error codes that shouldn't be reported to tracker admin + # (error descriptions from FreeBSD intro(2)) + IGNORE_NET_ERRORS = ( + # A write on a pipe, socket or FIFO for which there is + # no process to read the data. + errno.EPIPE, + # A connection was forcibly closed by a peer. + # This normally results from a loss of the connection + # on the remote socket due to a timeout or a reboot. + errno.ECONNRESET, + # Software caused connection abort. A connection abort + # was caused internal to your host machine. + errno.ECONNABORTED, + # A connect or send request failed because the connected party + # did not properly respond after a period of time. + errno.ETIMEDOUT, + ) + + def __init__(self, instance, request, env, form=None, translator=None): + # re-seed the random number generator + random.seed() + self.start = time.time() self.instance = instance self.request = request self.env = env + self.setTranslator(translator) self.mailer = Mailer(instance.config) # save off the path @@ -109,11 +287,16 @@ class Client: # this is the base URL for this tracker self.base = self.instance.config.TRACKER_WEB + # check the tracker_we setting + if not self.base.endswith('/'): + self.base = self.base + '/' + # this is the "cookie path" for this tracker (ie. the path part of # the "base" url) self.cookie_path = urlparse.urlparse(self.base)[2] - self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '', - self.instance.config.TRACKER_NAME) + # cookies to set in http responce + # {(path, name): (value, expire)} + self._cookies = {} # see if we need to re-parse the environment for the form (eg Zope) if form is None: @@ -136,6 +319,37 @@ class Client: self.additional_headers = {} self.response_code = 200 + # default character set + self.charset = self.STORAGE_CHARSET + + # parse cookies (used for charset lookups) + # use our own LiberalCookie to handle bad apps on the same + # server that have set cookies that are out of spec + self.cookie = LiberalCookie(self.env.get('HTTP_COOKIE', '')) + + self.user = None + self.userid = None + self.nodeid = None + self.classname = None + self.template = None + + def setTranslator(self, translator=None): + """Replace the translation engine + + 'translator' + is TranslationService instance. + It must define methods 'translate' (TAL-compatible i18n), + 'gettext' and 'ngettext' (gettext-compatible i18n). + + If omitted, create default TranslationService. + """ + if translator is None: + translator = TranslationService.get_translation( + language=self.instance.config["TRACKER_LANGUAGE"], + tracker_home=self.instance.config["TRACKER_HOME"]) + self.translator = translator + self._ = self.gettext = translator.gettext + self.ngettext = translator.ngettext def main(self): ''' Wrap the real main in a try/finally so we always close off the db. @@ -151,13 +365,15 @@ class Client: The most common requests are handled like so: - 1. figure out who we are, defaulting to the "anonymous" user + 1. look for charset and language preferences, set up user locale + see determine_charset, determine_language + 2. figure out who we are, defaulting to the "anonymous" user see determine_user - 2. figure out what the request is for - the context + 3. figure out what the request is for - the context see determine_context - 3. handle any requested action (item edit, search, ...) + 4. handle any requested action (item edit, search, ...) see handle_action - 4. render a template, resulting in HTML output + 5. render a template, resulting in HTML output In some situations, exceptions occur: @@ -179,21 +395,21 @@ class Client: self.ok_message = [] self.error_message = [] try: - # figure out the context and desired content template - # do this first so we don't authenticate for static files - # Note: this method opens the database as "admin" in order to - # perform context checks - self.determine_context() + self.determine_charset() + self.determine_language() # make sure we're identified (even anonymously) self.determine_user() + # figure out the context and desired content template + self.determine_context() + # possibly handle a form submit action (may change self.classname # and self.template, and may also append error/ok_messages) html = self.handle_action() if html: - self.write(html) + self.write_html(html) return # now render the page @@ -202,109 +418,240 @@ class Client: # Pragma: no-cache makes Mozilla and its ilk double-load all pages!! # self.additional_headers['Pragma'] = 'no-cache' - # expire this page 5 seconds from now - date = rfc822.formatdate(time.time() + 5) - self.additional_headers['Expires'] = date + # pages with messages added expire right now + # simple views may be cached for a small amount of time + # TODO? make page expire time configurable + # always expire pages, as IE just doesn't seem to do the + # right thing here :( + date = time.time() - 1 + #if self.error_message or self.ok_message: + # date = time.time() - 1 + #else: + # date = time.time() + 5 + self.additional_headers['Expires'] = rfc822.formatdate(date) # render the content - self.write(self.renderContext()) + try: + self.write_html(self.renderContext()) + except IOError: + # IOErrors here are due to the client disconnecting before + # recieving the reply. + pass + + except SeriousError, message: + self.write_html(str(message)) except Redirect, url: # let's redirect - if the url isn't None, then we need to do # the headers, otherwise the headers have been set before the # exception was raised if url: - self.additional_headers['Location'] = url + self.additional_headers['Location'] = str(url) self.response_code = 302 - self.write('Redirecting to %s'%(url, url)) + self.write_html('Redirecting to %s'%(url, url)) except SendFile, designator: - self.serve_file(designator) + try: + self.serve_file(designator) + except NotModified: + # send the 304 response + self.response_code = 304 + self.header() except SendStaticFile, file: try: self.serve_static_file(str(file)) except NotModified: # send the 304 response - self.request.send_response(304) - self.request.end_headers() + self.response_code = 304 + self.header() except Unauthorised, message: # users may always see the front page self.classname = self.nodeid = None self.template = '' self.error_message.append(message) - self.write(self.renderContext()) - except NotFound: - # pass through - raise + self.write_html(self.renderContext()) + except NotFound, e: + self.response_code = 404 + self.template = '404' + try: + cl = self.db.getclass(self.classname) + self.write_html(self.renderContext()) + except KeyError: + # we can't map the URL to a class we know about + # reraise the NotFound and let roundup_server + # handle it + raise NotFound, e except FormError, e: - self.error_message.append(_('Form Error: ') + str(e)) - self.write(self.renderContext()) + self.error_message.append(self._('Form Error: ') + str(e)) + self.write_html(self.renderContext()) except: - # everything else - self.write(cgitb.html()) + if self.instance.config.WEB_DEBUG: + self.write_html(cgitb.html(i18n=self.translator)) + else: + self.mailer.exception_message() + return self.write_html(self._(error_message)) def clean_sessions(self): - """Age sessions, remove when they haven't been used for a week. + """Deprecated + XXX remove + """ + self.clean_up() - Do it only once an hour. + def clean_up(self): + """Remove expired sessions and One Time Keys. - Note: also cleans One Time Keys, and other "session" based stuff. + Do it only once an hour. """ - sessions = self.db.sessions - last_clean = sessions.get('last_clean', 'last_use') or 0 - - week = 60*60*24*7 hour = 60*60 now = time.time() - if now - last_clean > hour: - # remove aged sessions - for sessid in sessions.list(): - interval = now - sessions.get(sessid, 'last_use') - if interval > week: - sessions.destroy(sessid) - # remove aged otks - otks = self.db.otks - for sessid in otks.list(): - interval = now - otks.get(sessid, '__time') - if interval > week: - otks.destroy(sessid) - sessions.set('last_clean', last_use=time.time()) - def determine_user(self): - ''' Determine who the user is - ''' - # determine the uid to use - self.opendb('admin') + # XXX: hack - use OTK table to store last_clean time information + # 'last_clean' string is used instead of otk key + last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0) + if now - last_clean < hour: + return - # make sure we have the session Class - self.clean_sessions() - sessions = self.db.sessions + self.session_api.clean_up() + self.db.getOTKManager().clean() + self.db.getOTKManager().set('last_clean', last_use=now) + self.db.commit(fail_ok=True) - # first up, try the REMOTE_USER var (from HTTP Basic Auth handled - # by a front-end HTTP server) - try: - user = os.getenv('REMOTE_USER') - except KeyError: - pass + def determine_charset(self): + """Look for client charset in the form parameters or browser cookie. - # look up the user session cookie (may override the REMOTE_USER) - cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', '')) - user = 'anonymous' - if (cookie.has_key(self.cookie_name) and - cookie[self.cookie_name].value != 'deleted'): + If no charset requested by client, use storage charset (utf-8). - # get the session key from the cookie - self.session = cookie[self.cookie_name].value - # get the user from the session + If the charset is found, and differs from the storage charset, + recode all form fields of type 'text/plain' + """ + # look for client charset + charset_parameter = 0 + if self.form.has_key('@charset'): + charset = self.form['@charset'].value + if charset.lower() == "none": + charset = "" + charset_parameter = 1 + elif self.cookie.has_key('roundup_charset'): + charset = self.cookie['roundup_charset'].value + else: + charset = None + if charset: + # make sure the charset is recognized try: - # update the lifetime datestamp - sessions.set(self.session, last_use=time.time()) - sessions.commit() - user = sessions.get(self.session, 'user') - except KeyError: - # not valid, ignore id - pass + codecs.lookup(charset) + except LookupError: + self.error_message.append(self._('Unrecognized charset: %r') + % charset) + charset_parameter = 0 + else: + self.charset = charset.lower() + # If we've got a character set in request parameters, + # set the browser cookie to keep the preference. + # This is done after codecs.lookup to make sure + # that we aren't keeping a wrong value. + if charset_parameter: + self.add_cookie('roundup_charset', charset) + + # if client charset is different from the storage charset, + # recode form fields + # XXX this requires FieldStorage from Python library. + # mod_python FieldStorage is not supported! + if self.charset != self.STORAGE_CHARSET: + decoder = codecs.getdecoder(self.charset) + encoder = codecs.getencoder(self.STORAGE_CHARSET) + re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE) + def _decode_charref(matchobj): + num = matchobj.group(1) + if num[0].lower() == 'x': + uc = int(num[1:], 16) + else: + uc = int(num) + return unichr(uc) + + for field_name in self.form.keys(): + field = self.form[field_name] + if (field.type == 'text/plain') and not field.filename: + try: + value = decoder(field.value)[0] + except UnicodeError: + continue + value = re_charref.sub(_decode_charref, value) + field.value = encoder(value)[0] + + def determine_language(self): + """Determine the language""" + # look for language parameter + # then for language cookie + # last for the Accept-Language header + if self.form.has_key("@language"): + language = self.form["@language"].value + if language.lower() == "none": + language = "" + self.add_cookie("roundup_language", language) + elif self.cookie.has_key("roundup_language"): + language = self.cookie["roundup_language"].value + elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]: + hal = self.env.get('HTTP_ACCEPT_LANGUAGE') + language = accept_language.parse(hal) + else: + language = "" + + self.language = language + if language: + self.setTranslator(TranslationService.get_translation( + language, + tracker_home=self.instance.config["TRACKER_HOME"])) - # sanity check on the user still being valid, getting the userid - # at the same time + def determine_user(self): + """Determine who the user is""" + self.opendb('admin') + + # get session data from db + # XXX: rename + self.session_api = Session(self) + + # take the opportunity to cleanup expired sessions and otks + self.clean_up() + + user = None + # first up, try http authorization if enabled + if self.instance.config['WEB_HTTP_AUTH']: + if self.env.has_key('REMOTE_USER'): + # we have external auth (e.g. by Apache) + user = self.env['REMOTE_USER'] + elif self.env.get('HTTP_AUTHORIZATION', ''): + # try handling Basic Auth ourselves + auth = self.env['HTTP_AUTHORIZATION'] + scheme, challenge = auth.split(' ', 1) + if scheme.lower() == 'basic': + try: + decoded = base64.decodestring(challenge) + except TypeError: + # invalid challenge + pass + username, password = decoded.split(':') + try: + login = self.get_action_class('login')(self) + login.verifyLogin(username, password) + except LoginError, err: + self.make_user_anonymous() + self.response_code = 403 + raise Unauthorised, err + + user = username + + # if user was not set by http authorization, try session lookup + if not user: + user = self.session_api.get('user') + if user: + # update session lifetime datestamp + self.session_api.update() + + # if no user name set by http authorization or session lookup + # the user is anonymous + if not user: + user = 'anonymous' + + # sanity check on the user still being valid, + # getting the userid at the same time try: self.userid = self.db.user.lookup(user) except (KeyError, TypeError): @@ -313,13 +660,38 @@ class Client: # make sure the anonymous user is valid if we're using it if user == 'anonymous': self.make_user_anonymous() + if not self.db.security.hasPermission('Web Access', self.userid): + raise Unauthorised, self._("Anonymous users are not " + "allowed to use the web interface") else: self.user = user # reopen the database as the correct user self.opendb(self.user) - def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')): + def opendb(self, username): + """Open the database and set the current user. + + Opens a database once. On subsequent calls only the user is set on + the database object the instance.optimize is set. If we are in + "Development Mode" (cf. roundup_server) then the database is always + re-opened. + """ + # don't do anything if the db is open and the user has not changed + if hasattr(self, 'db') and self.db.isCurrentUser(username): + return + + # open the database or only set the user + if not hasattr(self, 'db'): + self.db = self.instance.open(username) + else: + if self.instance.optimize: + self.db.setCurrentUser(username) + else: + self.db.close() + self.db = self.instance.open(username) + + def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')): """Determine the context of this page from the URL: The URL path after the instance identifier is examined. The path @@ -397,15 +769,16 @@ class Client: # send the file identified by the designator in path[0] raise SendFile, path[0] - # we need the db for further context stuff - open it as admin - self.opendb('admin') - # see if we got a designator m = dre.match(self.classname) if m: self.classname = m.group(1) self.nodeid = m.group(2) - if not self.db.getclass(self.classname).hasnode(self.nodeid): + try: + klass = self.db.getclass(self.classname) + except KeyError: + raise NotFound, '%s/%s'%(self.classname, self.nodeid) + if not klass.hasnode(self.nodeid): raise NotFound, '%s/%s'%(self.classname, self.nodeid) # with a designator, we default to item view self.template = 'item' @@ -431,7 +804,6 @@ class Client: raise NotFound, str(designator) classname, nodeid = m.group(1), m.group(2) - self.opendb('admin') klass = self.db.getclass(classname) # make sure we have the appropriate properties @@ -441,6 +813,12 @@ class Client: if not props.has_key('content'): raise NotFound, designator + # make sure we have permission + if not self.db.security.hasPermission('View', self.userid, + classname, 'content', nodeid): + raise Unauthorised, self._("You are not allowed to view " + "this file.") + mime_type = klass.get(nodeid, 'type') content = klass.get(nodeid, 'content') lmt = klass.get(nodeid, 'activity').timestamp() @@ -450,7 +828,19 @@ class Client: def serve_static_file(self, file): ''' Serve up the file named from the templates dir ''' - filename = os.path.join(self.instance.config.TEMPLATES, file) + # figure the filename - try STATIC_FILES, then TEMPLATES dir + for dir_option in ('STATIC_FILES', 'TEMPLATES'): + prefix = self.instance.config[dir_option] + if not prefix: + continue + # ensure the load doesn't try to poke outside + # of the static files directory + prefix = os.path.normpath(prefix) + filename = os.path.normpath(os.path.join(prefix, file)) + if os.path.isfile(filename) and filename.startswith(prefix): + break + else: + raise NotFound, file # last-modified time lmt = os.stat(filename)[stat.ST_MTIME] @@ -473,14 +863,20 @@ class Client: self._serve_file(lmt, mime_type, content) - def _serve_file(self, last_modified, mime_type, content): + def _serve_file(self, lmt, mime_type, content): ''' guts of serve_file() and serve_static_file() ''' + # spit out headers + self.additional_headers['Content-Type'] = mime_type + self.additional_headers['Content-Length'] = str(len(content)) + self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt) + ims = None # see if there's an if-modified-since... - if hasattr(self.request, 'headers'): - ims = self.request.headers.getheader('if-modified-since') - elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'): + # XXX see which interfaces set this + #if hasattr(self.request, 'headers'): + #ims = self.request.headers.getheader('if-modified-since') + if self.env.has_key('HTTP_IF_MODIFIED_SINCE'): # cgi will put the header in the env var ims = self.env['HTTP_IF_MODIFIED_SINCE'] if ims: @@ -489,11 +885,6 @@ class Client: if lmtt <= ims: raise NotModified - # spit out headers - self.additional_headers['Content-Type'] = mime_type - self.additional_headers['Content-Length'] = len(content) - lmt = rfc822.formatdate(last_modified) - self.additional_headers['Last-Modifed'] = lmt self.write(content) def renderContext(self): @@ -501,8 +892,6 @@ class Client: ''' name = self.classname extension = self.template - pt = templating.Templates(self.instance.config.TEMPLATES).get(name, - extension) # catch errors so we can handle PT rendering errors more nicely args = { @@ -510,9 +899,27 @@ class Client: 'error_message': self.error_message } try: + pt = self.instance.templates.get(name, extension) # let the template render figure stuff out result = pt.render(self, None, None, **args) self.additional_headers['Content-Type'] = pt.content_type + if self.env.get('CGI_SHOW_TIMING', ''): + if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT': + timings = {'starttag': ''} + else: + timings = {'starttag': '

', 'endtag': '

'} + timings['seconds'] = time.time()-self.start + s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n' + ) % timings + if hasattr(self.db, 'stats'): + timings.update(self.db.stats) + s += self._("%(starttag)sCache hits: %(cache_hits)d," + " misses %(cache_misses)d." + " Loading items: %(get_items)f secs." + " Filtering: %(filtering)f secs." + "%(endtag)s\n") % timings + s += '' + result = result.replace('', s) return result except templating.NoTemplate, message: return '%s'%message @@ -520,21 +927,22 @@ class Client: raise Unauthorised, str(message) except: # everything else - return cgitb.pt_html() + return cgitb.pt_html(i18n=self.translator) # these are the actions that are available actions = ( - ('edit', EditItemAction), - ('editcsv', EditCSVAction), - ('new', NewItemAction), - ('register', RegisterAction), - ('confrego', ConfRegoAction), - ('passrst', PassResetAction), - ('login', LoginAction), - ('logout', LogoutAction), - ('search', SearchAction), - ('retire', RetireAction), - ('show', ShowAction), + ('edit', EditItemAction), + ('editcsv', EditCSVAction), + ('new', NewItemAction), + ('register', RegisterAction), + ('confrego', ConfRegoAction), + ('passrst', PassResetAction), + ('login', LoginAction), + ('logout', LogoutAction), + ('search', SearchAction), + ('retire', RetireAction), + ('show', ShowAction), + ('export_csv', ExportCSVAction), ) def handle_action(self): ''' Determine whether there should be an Action called. @@ -545,6 +953,9 @@ class Client: Actions may return a page (by default HTML) to return to the user, bypassing the usual template rendering. + + We explicitly catch Reject and ValueError exceptions and + present their messages to the user. ''' if self.form.has_key(':action'): action = self.form[':action'].value.lower() @@ -552,13 +963,9 @@ class Client: action = self.form['@action'].value.lower() else: return None + try: - # get the action, validate it - for name, action_klass in self.actions: - if name == action: - break - else: - raise ValueError, 'No such action "%s"'%action + action_klass = self.get_action_class(action) # call the mapped action if isinstance(action_klass, type('')): @@ -567,63 +974,137 @@ class Client: else: return action_klass(self).execute() - except ValueError, err: + except (ValueError, Reject), err: self.error_message.append(str(err)) + def get_action_class(self, action_name): + if (hasattr(self.instance, 'cgi_actions') and + self.instance.cgi_actions.has_key(action_name)): + # tracker-defined action + action_klass = self.instance.cgi_actions[action_name] + else: + # go with a default + for name, action_klass in self.actions: + if name == action_name: + break + else: + raise ValueError, 'No such action "%s"'%action_name + return action_klass + + def _socket_op(self, call, *args, **kwargs): + """Execute socket-related operation, catch common network errors + + Parameters: + call: a callable to execute + args, kwargs: call arguments + + """ + try: + call(*args, **kwargs) + except socket.error, err: + err_errno = getattr (err, 'errno', None) + if err_errno is None: + try: + err_errno = err[0] + except TypeError: + pass + if err_errno not in self.IGNORE_NET_ERRORS: + raise + def write(self, content): if not self.headers_done: self.header() - self.request.wfile.write(content) + if self.env['REQUEST_METHOD'] != 'HEAD': + self._socket_op(self.request.wfile.write, content) + + def write_html(self, content): + if not self.headers_done: + # at this point, we are sure about Content-Type + if not self.additional_headers.has_key('Content-Type'): + self.additional_headers['Content-Type'] = \ + 'text/html; charset=%s' % self.charset + self.header() + + if self.env['REQUEST_METHOD'] == 'HEAD': + # client doesn't care about content + return + + if self.charset != self.STORAGE_CHARSET: + # recode output + content = content.decode(self.STORAGE_CHARSET, 'replace') + content = content.encode(self.charset, 'xmlcharrefreplace') + + # and write + self._socket_op(self.request.wfile.write, content) + + def setHeader(self, header, value): + '''Override a header to be returned to the user's browser. + ''' + self.additional_headers[header] = value def header(self, headers=None, response=None): '''Put up the appropriate header. ''' if headers is None: - headers = {'Content-Type':'text/html'} + headers = {'Content-Type':'text/html; charset=utf-8'} if response is None: response = self.response_code # update with additional info headers.update(self.additional_headers) - if not headers.has_key('Content-Type'): - headers['Content-Type'] = 'text/html' - self.request.send_response(response) - for entry in headers.items(): - self.request.send_header(*entry) - self.request.end_headers() + if headers.get('Content-Type', 'text/html') == 'text/html': + headers['Content-Type'] = 'text/html; charset=utf-8' + + headers = headers.items() + + for ((path, name), (value, expire)) in self._cookies.items(): + cookie = "%s=%s; Path=%s;"%(name, value, path) + if expire is not None: + cookie += " expires=%s;"%Cookie._getdate(expire) + headers.append(('Set-Cookie', cookie)) + + self._socket_op(self.request.start_response, headers, response) + self.headers_done = 1 if self.debug: self.headers_sent = headers - def set_cookie(self, user): - """Set up a session cookie for the user. + def add_cookie(self, name, value, expire=86400*365, path=None): + """Set a cookie value to be sent in HTTP headers + + Parameters: + name: + cookie name + value: + cookie value + expire: + cookie expiration time (seconds). + If value is empty (meaning "delete cookie"), + expiration time is forced in the past + and this argument is ignored. + If None, the cookie will expire at end-of-session. + If omitted, the cookie will be kept for a year. + path: + cookie path (optional) - Also store away the user's login info against the session. """ - # TODO generate a much, much stronger session key ;) - self.session = binascii.b2a_base64(repr(random.random())).strip() - - # clean up the base64 - if self.session[-1] == '=': - if self.session[-2] == '=': - self.session = self.session[:-2] - else: - self.session = self.session[:-1] - - # insert the session in the sessiondb - self.db.sessions.set(self.session, user=user, last_use=time.time()) + if path is None: + path = self.cookie_path + if not value: + expire = -1 + self._cookies[(path, name)] = (value, expire) - # and commit immediately - self.db.sessions.commit() + def set_cookie(self, user, expire=None): + """Deprecated. Use session_api calls directly - # expire us in a long, long time - expire = Cookie._getdate(86400*365) + XXX remove + """ - # generate the cookie path - make sure it has a trailing '/' - self.additional_headers['Set-Cookie'] = \ - '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session, - expire, self.cookie_path) + # insert the session in the session db + self.session_api.set(user=user) + # refresh session cookie + self.session_api.update(set_cookie=True, expire=expire) def make_user_anonymous(self): ''' Make us anonymous @@ -634,22 +1115,24 @@ class Client: self.userid = self.db.user.lookup('anonymous') self.user = 'anonymous' - def opendb(self, user): - ''' Open the database. - ''' - # open the db if the user has changed - if not hasattr(self, 'db') or user != self.db.journaltag: - if hasattr(self, 'db'): - self.db.close() - self.db = self.instance.open(user) - def standard_message(self, to, subject, body, author=None): + '''Send a standard email message from Roundup. + + "to" - recipients list + "subject" - Subject + "body" - Message + "author" - (name, address) tuple or None for admin email + + Arguments are passed to the Mailer.standard_message code. + ''' try: self.mailer.standard_message(to, subject, body, author) - return 1 except MessageSendError, e: self.error_message.append(str(e)) + return 0 + return 1 - def parsePropsFromForm(self, create=False): + def parsePropsFromForm(self, create=0): return FormParser(self).parse(create=create) +# vim: set et sts=4 sw=4 :