X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;f=roundup%2Fcgi%2Fclient.py;h=d1775daa530d119904a9f50d015272ae3a299959;hb=0e52c41f35764f37f1e00d30203d3e6e6122a0da;hp=5e581b08757c40e28ea987c74384e9522f49df62;hpb=28c868f2f2370ccd0edac1f1212ee1cc141ea892;p=roundup.git diff --git a/roundup/cgi/client.py b/roundup/cgi/client.py index 5e581b0..d1775da 100644 --- a/roundup/cgi/client.py +++ b/roundup/cgi/client.py @@ -1,65 +1,35 @@ -# $Id: client.py,v 1.144 2003-11-11 00:35:14 richard Exp $ +# $Id: client.py,v 1.239 2008-08-18 05:04:02 richard Exp $ -__doc__ = """ -WWW request handler (also used in the stand-alone server). +"""WWW request handler (also used in the stand-alone server). """ - -import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib -import binascii, Cookie, time, random, MimeWriter, smtplib, socket, quopri -import stat, rfc822 - -from roundup import roundupdb, date, hyperdb, password, token, rcsv -from roundup.i18n import _ -from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate -from roundup.cgi import cgitb -from roundup.cgi.PageTemplates import PageTemplate -from roundup.rfc2822 import encode_header -from roundup.mailgw import uidFromAddress +__docformat__ = 'restructuredtext' + +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.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 - -class HTTPException(Exception): - pass -class Unauthorised(HTTPException): - pass -class NotFound(HTTPException): - pass -class Redirect(HTTPException): - pass -class NotModified(HTTPException): - pass - -# used by a couple of routines -chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' - -class FormError(ValueError): - ''' An "expected" exception occurred during form parsing. - - ie. something we know can go wrong, and don't want to alarm the - user with - - We trap this at the user interface level and feed back a nice error - to the user. - ''' - pass - -class SendFile(Exception): - ''' Send a file from the database ''' - -class SendStaticFile(Exception): - ''' Send a static file from the instance html directory ''' +from roundup.cgi import accept_language def initialiseSecurity(security): - ''' Create some Permissions and Roles on the security object + '''Create some Permissions and Roles on the security object - This function is directly invoked by security.Security.__init__() - as a part of the Security object instantiation. + 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) @@ -77,38 +47,189 @@ 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. + '''Instantiate to handle one CGI request. See inner_main for request processing. Client attributes at instantiation: - "path" is the PATH_INFO inside the instance (with no leading '/') - "base" is the base URL for the instance - "form" is the cgi form, an instance of FieldStorage from the standard - cgi module - "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 + + - "path" is the PATH_INFO inside the instance (with no leading '/') + - "base" is the base URL for the instance + - "form" is the cgi form, an instance of FieldStorage from the standard + cgi module + - "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: - "error_message" holds a list of error messages - "ok_message" holds a list of OK messages - "session" is the current user session id - "user" is the current user's name - "userid" is the current user's id - "template" is the current :template context - "classname" is the current class context name - "nodeid" is the current context item id + + - "db" + - "error_message" holds a list of error messages + - "ok_message" holds a list of OK messages + - "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 + - "classname" is the current class context name + - "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 @@ -116,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 # @@ -123,39 +249,36 @@ class Client: FV_OK_MESSAGE = re.compile(r'[@:]ok_message') FV_ERROR_MESSAGE = re.compile(r'[@:]error_message') - FV_QUERYNAME = re.compile(r'[@:]queryname') - - # edit form variable handling (see unit tests) - FV_LABELS = r''' - ^( - (?P[@:]note)| - (?P[@:]file)| - ( - ((?P%s)(?P[-\d]+))? # optional leading designator - ((?P[@:]required$)| # :required - ( - ( - (?P[@:]add[@:])| # :add: - (?P[@:]remove[@:])| # :remove: - (?P[@:]confirm[@:])| # :confirm: - (?P[@:]link[@:])| # :link: - ([@:]) # just a separator - )? - (?P[^@:]+) # - ) - ) - ) - )$''' - # Note: index page stuff doesn't appear here: # 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 @@ -164,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: @@ -191,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. @@ -202,45 +361,56 @@ class Client: self.db.close() def inner_main(self): - ''' Process a request. - - The most common requests are handled like so: - 1. figure out who we are, defaulting to the "anonymous" user - see determine_user - 2. figure out what the request is for - the context - see determine_context - 3. handle any requested action (item edit, search, ...) - see handle_action - 4. render a template, resulting in HTML output - - In some situations, exceptions occur: - - HTTP Redirect (generally raised by an action) - - SendFile (generally raised by determine_context) - serve up a FileClass "content" property - - SendStaticFile (generally raised by determine_context) - serve up a file from the tracker "html" directory - - Unauthorised (generally raised by an action) - the action is cancelled, the request is rendered and an error - message is displayed indicating that permission was not - granted for the action to take place - - NotFound (raised wherever it needs to be) - percolates up to the CGI interface that called the client + '''Process a request. + + The most common requests are handled like so: + + 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 + 3. figure out what the request is for - the context + see determine_context + 4. handle any requested action (item edit, search, ...) + see handle_action + 5. render a template, resulting in HTML output + + In some situations, exceptions occur: + + - HTTP Redirect (generally raised by an action) + - SendFile (generally raised by determine_context) + serve up a FileClass "content" property + - SendStaticFile (generally raised by determine_context) + serve up a file from the tracker "html" directory + - Unauthorised (generally raised by an action) + the action is cancelled, the request is rendered and an error + message is displayed indicating that permission was not + granted for the action to take place + - templating.Unauthorised (templating action not permitted) + raised by an attempted rendering of a template when the user + doesn't have permission + - NotFound (raised wherever it needs to be) + percolates up to the CGI interface that called the 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) - self.handle_action() + html = self.handle_action() + + if html: + self.write_html(html) + return # now render the page # we don't want clients caching our dynamic pages @@ -248,105 +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: - self.classname = None + # 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. - - Do it only once an hour. + """Deprecated + XXX remove + """ + self.clean_up() - Note: also cleans One Time Keys, and other "session" based - stuff. - ''' - sessions = self.db.sessions - last_clean = sessions.get('last_clean', 'last_use') or 0 + def clean_up(self): + """Remove expired sessions and One Time Keys. - week = 60*60*24*7 + Do it only once an hour. + """ 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 - ''' - # open the database as admin - self.opendb('admin') - - # clean age sessions - self.clean_sessions() + # 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 - 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) + + def determine_charset(self): + """Look for client charset in the form parameters or browser cookie. + + If no charset requested by client, use storage charset (utf-8). + + 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: + 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 = "" - # look up the user session cookie - cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', '')) - user = 'anonymous' + self.language = language + if language: + self.setTranslator(TranslationService.get_translation( + language, + tracker_home=self.instance.config["TRACKER_HOME"])) - # bump the "revision" of the cookie since the format changed - if (cookie.has_key(self.cookie_name) and - cookie[self.cookie_name].value != 'deleted'): + def determine_user(self): + """Determine who the user is""" + self.opendb('admin') - # get the session key from the cookie - self.session = cookie[self.cookie_name].value - # get the user from the session - try: - # update the lifetime datestamp - sessions.set(self.session, last_use=time.time()) - sessions.commit() - user = sessions.get(self.session, 'user') - except KeyError: - user = 'anonymous' + # 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 + # 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): @@ -355,49 +660,77 @@ 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+)')): - ''' Determine the context of this page from the URL: - - The URL path after the instance identifier is examined. The path - is generally only one entry long. - - - if there is no path, then we are in the "home" context. - * if the path is "_file", then the additional path entry - specifies the filename of a static file we're to serve up - from the instance "html" directory. Raises a SendStaticFile - exception. - - if there is something in the path (eg "issue"), it identifies - the tracker class we're to display. - - if the path is an item designator (eg "issue123"), then we're - to display a specific item. - * if the path starts with an item designator and is longer than - one entry, then we're assumed to be handling an item of a - FileClass, and the extra path information gives the filename - that the client is going to label the download with (ie - "file123/image.png" is nicer to download than "file123"). This - raises a SendFile exception. - - Both of the "*" types of contexts stop before we bother to - determine the template we're going to use. That's because they - don't actually use templates. - - The template used is specified by the :template CGI variable, - which defaults to: - - only classname suplied: "index" - full item designator supplied: "item" - - We set: + 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 + is generally only one entry long. + + - if there is no path, then we are in the "home" context. + - if the path is "_file", then the additional path entry + specifies the filename of a static file we're to serve up + from the instance "html" directory. Raises a SendStaticFile + exception.(*) + - if there is something in the path (eg "issue"), it identifies + the tracker class we're to display. + - if the path is an item designator (eg "issue123"), then we're + to display a specific item. + - if the path starts with an item designator and is longer than + one entry, then we're assumed to be handling an item of a + FileClass, and the extra path information gives the filename + that the client is going to label the download with (ie + "file123/image.png" is nicer to download than "file123"). This + raises a SendFile exception.(*) + + Both of the "*" types of contexts stop before we bother to + determine the template we're going to use. That's because they + don't actually use templates. + + The template used is specified by the :template CGI variable, + which defaults to: + + - only classname suplied: "index" + - full item designator supplied: "item" + + We set: + self.classname - the class to display, can be None + self.template - the template to render the current context with + self.nodeid - the nodeid of the class we're displaying - ''' + """ # default the optional variables self.classname = None self.nodeid = None @@ -414,6 +747,12 @@ class Client: error_message = self.form[key].value error_message = clean_message(error_message) + # see if we were passed in a message + if ok_message: + self.ok_message.append(ok_message) + if error_message: + self.error_message.append(error_message) + # determine the classname and possibly nodeid path = self.path.split('/') if not path or path[0] in ('', 'home', 'index'): @@ -422,7 +761,7 @@ class Client: else: self.template = '' return - elif path[0] == '_file': + elif path[0] in ('_file', '@@file'): raise SendStaticFile, os.path.join(*path[1:]) else: self.classname = path[0] @@ -430,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' @@ -456,12 +796,6 @@ class Client: if template_override is not None: self.template = template_override - # see if we were passed in a message - if ok_message: - self.ok_message.append(ok_message) - if error_message: - self.error_message.append(error_message) - def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')): ''' Serve the file from the content property of the designated item. ''' @@ -469,49 +803,95 @@ class Client: if not m: raise NotFound, str(designator) classname, nodeid = m.group(1), m.group(2) - if classname != 'file': + + klass = self.db.getclass(classname) + + # make sure we have the appropriate properties + props = klass.getprops() + if not props.has_key('type'): + raise NotFound, designator + if not props.has_key('content'): raise NotFound, designator - # we just want to serve up the file named - self.opendb('admin') - file = self.db.file - self.additional_headers['Content-Type'] = file.get(nodeid, 'type') - self.write(file.get(nodeid, 'content')) + # 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() + + self._serve_file(lmt, mime_type, content) def serve_static_file(self, file): + ''' Serve up the file named from the templates dir + ''' + # 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] + + # detemine meta-type + file = str(file) + mime_type = mimetypes.guess_type(file)[0] + if not mime_type: + if file.endswith('.css'): + mime_type = 'text/css' + else: + mime_type = 'text/plain' + + # snarf the content + f = open(filename, 'rb') + try: + content = f.read() + finally: + f.close() + + self._serve_file(lmt, 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'] - filename = os.path.join(self.instance.config.TEMPLATES, file) - lmt = os.stat(filename)[stat.ST_MTIME] if ims: ims = rfc822.parsedate(ims)[:6] lmtt = time.gmtime(lmt)[:6] if lmtt <= ims: raise NotModified - # we just want to serve up the file named - file = str(file) - mt = mimetypes.guess_type(file)[0] - if not mt: - if file.endswith('.css'): - mt = 'text/css' - else: - mt = 'text/plain' - self.additional_headers['Content-Type'] = mt - self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt) - self.write(open(filename, 'rb').read()) + self.write(content) def renderContext(self): ''' Return a PageTemplate for the named page ''' name = self.classname extension = self.template - pt = Templates(self.instance.config.TEMPLATES).get(name, extension) # catch errors so we can handle PT rendering errors more nicely args = { @@ -519,27 +899,50 @@ class Client: 'error_message': self.error_message } try: + pt = self.instance.templates.get(name, extension) # let the template render figure stuff out - return pt.render(self, None, None, **args) - except NoTemplate, message: + 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 + except templating.Unauthorised, message: + 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', 'logout_action'), - ('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. @@ -547,6 +950,12 @@ class Client: The action is defined by the form variable :action which identifies the method on this object to call. The actions are defined in the "actions" sequence on this class. + + 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() @@ -554,73 +963,148 @@ class Client: action = self.form['@action'].value.lower() else: return None + try: - # get the action, validate it - for name, method in self.actions: - if name == action: + action_klass = self.get_action_class(action) + + # call the mapped action + if isinstance(action_klass, type('')): + # old way of specifying actions + return getattr(self, action_klass)() + else: + return action_klass(self).execute() + + 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 - # call the mapped action - getattr(self, method)() - except Redirect: - raise - except Unauthorised: - raise + 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() - self.headers_done = 1 - if self.debug: - self.headers_sent = headers - - def set_cookie(self, user): - ''' Set up a session cookie for the user and 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() + if headers.get('Content-Type', 'text/html') == 'text/html': + headers['Content-Type'] = 'text/html; charset=utf-8' - # clean up the base64 - if self.session[-1] == '=': - if self.session[-2] == '=': - self.session = self.session[:-2] - else: - self.session = self.session[:-1] + headers = headers.items() - # insert the session in the sessiondb - self.db.sessions.set(self.session, user=user, last_use=time.time()) + 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)) - # and commit immediately - self.db.sessions.commit() + self._socket_op(self.request.start_response, headers, response) - # expire us in a long, long time - expire = Cookie._getdate(86400*365) + self.headers_done = 1 + if self.debug: + self.headers_sent = headers - # 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) + 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) + + """ + if path is None: + path = self.cookie_path + if not value: + expire = -1 + self._cookies[(path, name)] = (value, expire) + + def set_cookie(self, user, expire=None): + """Deprecated. Use session_api calls directly + + XXX remove + """ + + # 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 @@ -631,1233 +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) - - # - # Actions - # - def loginAction(self): - ''' Attempt to log a user in. - - Sets up a session for the user which contains the login - credentials. - ''' - # we need the username at a minimum - if not self.form.has_key('__login_name'): - self.error_message.append(_('Username required')) - return - - # get the login info - self.user = self.form['__login_name'].value - if self.form.has_key('__login_password'): - password = self.form['__login_password'].value - else: - password = '' - - # make sure the user exists - try: - self.userid = self.db.user.lookup(self.user) - except KeyError: - name = self.user - self.error_message.append(_('No such user "%(name)s"')%locals()) - self.make_user_anonymous() - return - - # verify the password - if not self.verifyPassword(self.userid, password): - self.make_user_anonymous() - self.error_message.append(_('Incorrect password')) - return - - # make sure we're allowed to be here - if not self.loginPermission(): - self.make_user_anonymous() - self.error_message.append(_("You do not have permission to login")) - return - - # now we're OK, re-open the database for real, using the user - self.opendb(self.user) - - # set the session cookie - self.set_cookie(self.user) - - def verifyPassword(self, userid, password): - ''' Verify the password that the user has supplied - ''' - stored = self.db.user.get(self.userid, 'password') - if password == stored: - return 1 - if not password and not stored: - return 1 - return 0 - - def loginPermission(self): - ''' Determine whether the user has permission to log in. - - Base behaviour is to check the user has "Web Access". - ''' - if not self.db.security.hasPermission('Web Access', self.userid): - return 0 - return 1 - - def logout_action(self): - ''' Make us really anonymous - nuke the cookie too - ''' - # log us out - self.make_user_anonymous() - - # construct the logout cookie - now = Cookie._getdate() - self.additional_headers['Set-Cookie'] = \ - '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name, - now, self.cookie_path) - - # Let the user know what's going on - self.ok_message.append(_('You are logged out')) + def standard_message(self, to, subject, body, author=None): + '''Send a standard email message from Roundup. - def registerAction(self): - '''Attempt to create a new user based on the contents of the form - and then set the cookie. + "to" - recipients list + "subject" - Subject + "body" - Message + "author" - (name, address) tuple or None for admin email - return 1 on successful login + Arguments are passed to the Mailer.standard_message code. ''' - props = self.parsePropsFromForm()[0][('user', None)] - - # make sure we're allowed to register - if not self.registerPermission(props): - raise Unauthorised, _("You do not have permission to register") - - try: - self.db.user.lookup(props['username']) - self.error_message.append('Error: A user with the username "%s" ' - 'already exists'%props['username']) - return - except KeyError: - pass - - # generate the one-time-key and store the props for later - otk = ''.join([random.choice(chars) for x in range(32)]) - for propname, proptype in self.db.user.getprops().items(): - value = props.get(propname, None) - if value is None: - pass - elif isinstance(proptype, hyperdb.Date): - props[propname] = str(value) - elif isinstance(proptype, hyperdb.Interval): - props[propname] = str(value) - elif isinstance(proptype, hyperdb.Password): - props[propname] = str(value) - props['__time'] = time.time() - self.db.otks.set(otk, **props) - - # send the email - tracker_name = self.db.config.TRACKER_NAME - tracker_email = self.db.config.TRACKER_EMAIL - subject = 'Complete your registration to %s -- key %s' % (tracker_name, - otk) - body = """To complete your registration of the user "%(name)s" with -%(tracker)s, please do one of the following: - -- send a reply to %(tracker_email)s and maintain the subject line as is (the -reply's additional "Re:" is ok), - -- or visit the following URL: - - %(url)s?@action=confrego&otk=%(otk)s -""" % {'name': props['username'], 'tracker': tracker_name, 'url': self.base, - 'otk': otk, 'tracker_email': tracker_email} - if not self.standard_message([props['address']], subject, body, - tracker_email): - return - - # commit changes to the database - self.db.commit() - - # redirect to the "you're almost there" page - raise Redirect, '%suser?@template=rego_progress'%self.base - - def standard_message(self, to, subject, body, author=None): try: self.mailer.standard_message(to, subject, body, author) - return 1 except MessageSendError, e: self.error_message.append(str(e)) - - def registerPermission(self, props): - ''' Determine whether the user has permission to register - - Base behaviour is to check the user has "Web Registration". - ''' - # registration isn't allowed to supply roles - if props.has_key('roles'): - return 0 - if self.db.security.hasPermission('Web Registration', self.userid): - return 1 - return 0 - - def confRegoAction(self): - ''' Grab the OTK, use it to load up the new user details - ''' - try: - # pull the rego information out of the otk database - self.userid = self.db.confirm_registration(self.form['otk'].value) - except (ValueError, KeyError), message: - # XXX: we need to make the "default" page be able to display errors! - self.error_message.append(str(message)) - return - - # log the new user in - self.user = self.db.user.get(self.userid, 'username') - # re-open the database for real, using the user - self.opendb(self.user) - - # if we have a session, update it - if hasattr(self, 'session'): - self.db.sessions.set(self.session, user=self.user, - last_use=time.time()) - else: - # new session cookie - self.set_cookie(self.user) - - # nice message - message = _('You are now registered, welcome!') - - # redirect to the user's page - raise Redirect, '%suser%s?@ok_message=%s'%(self.base, - self.userid, urllib.quote(message)) - - def passResetAction(self): - ''' Handle password reset requests. - - Presence of either "name" or "address" generate email. - Presense of "otk" performs the reset. - ''' - if self.form.has_key('otk'): - # pull the rego information out of the otk database - otk = self.form['otk'].value - uid = self.db.otks.get(otk, 'uid') - if uid is None: - self.error_message.append("""Invalid One Time Key! -(a Mozilla bug may cause this message to show up erroneously, - please check your email)""") - return - - # re-open the database as "admin" - if self.user != 'admin': - self.opendb('admin') - - # change the password - newpw = password.generatePassword() - - cl = self.db.user -# XXX we need to make the "default" page be able to display errors! - try: - # set the password - cl.set(uid, password=password.Password(newpw)) - # clear the props from the otk database - self.db.otks.destroy(otk) - self.db.commit() - except (ValueError, KeyError), message: - self.error_message.append(str(message)) - return - - # user info - address = self.db.user.get(uid, 'address') - name = self.db.user.get(uid, 'username') - - # send the email - tracker_name = self.db.config.TRACKER_NAME - subject = 'Password reset for %s'%tracker_name - body = ''' -The password has been reset for username "%(name)s". - -Your password is now: %(password)s -'''%{'name': name, 'password': newpw} - if not self.standard_message([address], subject, body): - return - - self.ok_message.append('Password reset and email sent to %s'%address) - return - - # no OTK, so now figure the user - if self.form.has_key('username'): - name = self.form['username'].value - try: - uid = self.db.user.lookup(name) - except KeyError: - self.error_message.append('Unknown username') - return - address = self.db.user.get(uid, 'address') - elif self.form.has_key('address'): - address = self.form['address'].value - uid = uidFromAddress(self.db, ('', address), create=0) - if not uid: - self.error_message.append('Unknown email address') - return - name = self.db.user.get(uid, 'username') - else: - self.error_message.append('You need to specify a username ' - 'or address') - return - - # generate the one-time-key and store the props for later - otk = ''.join([random.choice(chars) for x in range(32)]) - self.db.otks.set(otk, uid=uid, __time=time.time()) - - # send the email - tracker_name = self.db.config.TRACKER_NAME - subject = 'Confirm reset of password for %s'%tracker_name - body = ''' -Someone, perhaps you, has requested that the password be changed for your -username, "%(name)s". If you wish to proceed with the change, please follow -the link below: - - %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s - -You should then receive another email with the new password. -'''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk} - if not self.standard_message([address], subject, body): - return - - self.ok_message.append('Email sent to %s'%address) - - def editItemAction(self): - ''' Perform an edit of an item in the database. - - See parsePropsFromForm and _editnodes for special variables - ''' - props, links = self.parsePropsFromForm() - - # handle the props - try: - message = self._editnodes(props, links) - except (ValueError, KeyError, IndexError), message: - self.error_message.append(_('Apply Error: ') + str(message)) - return - - # commit now that all the tricky stuff is done - self.db.commit() - - # redirect to the item's edit page - raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base, - self.classname, self.nodeid, urllib.quote(message), - urllib.quote(self.template)) - - newItemAction = editItemAction - - def editItemPermission(self, props): - ''' Determine whether the user has permission to edit this item. - - Base behaviour is to check the user can edit this class. If we're - editing the "user" class, users are allowed to edit their own - details. Unless it's the "roles" property, which requires the - special Permission "Web Roles". - ''' - # if this is a user node and the user is editing their own node, then - # we're OK - has = self.db.security.hasPermission - if self.classname == 'user': - # reject if someone's trying to edit "roles" and doesn't have the - # right permission. - if props.has_key('roles') and not has('Web Roles', self.userid, - 'user'): - return 0 - # if the item being edited is the current user, we're ok - if (self.nodeid == self.userid - and self.db.user.get(self.nodeid, 'username') != 'anonymous'): - return 1 - if self.db.security.hasPermission('Edit', self.userid, self.classname): - return 1 - return 0 - - def newItemPermission(self, props): - ''' Determine whether the user has permission to create (edit) this - item. - - Base behaviour is to check the user can edit this class. No - additional property checks are made. Additionally, new user items - may be created if the user has the "Web Registration" Permission. - ''' - has = self.db.security.hasPermission - if self.classname == 'user' and has('Web Registration', self.userid, - 'user'): - return 1 - if has('Edit', self.userid, self.classname): - return 1 - return 0 - - - # - # Utility methods for editing - # - def _editnodes(self, all_props, all_links, newids=None): - ''' Use the props in all_props to perform edit and creation, then - use the link specs in all_links to do linking. - ''' - # figure dependencies and re-work links - deps = {} - links = {} - for cn, nodeid, propname, vlist in all_links: - if not all_props.has_key((cn, nodeid)): - # link item to link to doesn't (and won't) exist - continue - for value in vlist: - if not all_props.has_key(value): - # link item to link to doesn't (and won't) exist - continue - deps.setdefault((cn, nodeid), []).append(value) - links.setdefault(value, []).append((cn, nodeid, propname)) - - # figure chained dependencies ordering - order = [] - done = {} - # loop detection - change = 0 - while len(all_props) != len(done): - for needed in all_props.keys(): - if done.has_key(needed): - continue - tlist = deps.get(needed, []) - for target in tlist: - if not done.has_key(target): - break - else: - done[needed] = 1 - order.append(needed) - change = 1 - if not change: - raise ValueError, 'linking must not loop!' - - # now, edit / create - m = [] - for needed in order: - props = all_props[needed] - if not props: - # nothing to do - continue - cn, nodeid = needed - - if nodeid is not None and int(nodeid) > 0: - # make changes to the node - props = self._changenode(cn, nodeid, props) - - # and some nice feedback for the user - if props: - info = ', '.join(props.keys()) - m.append('%s %s %s edited ok'%(cn, nodeid, info)) - else: - m.append('%s %s - nothing changed'%(cn, nodeid)) - else: - assert props - - # make a new node - newid = self._createnode(cn, props) - if nodeid is None: - self.nodeid = newid - nodeid = newid - - # and some nice feedback for the user - m.append('%s %s created'%(cn, newid)) - - # fill in new ids in links - if links.has_key(needed): - for linkcn, linkid, linkprop in links[needed]: - props = all_props[(linkcn, linkid)] - cl = self.db.classes[linkcn] - propdef = cl.getprops()[linkprop] - if not props.has_key(linkprop): - if linkid is None or linkid.startswith('-'): - # linking to a new item - if isinstance(propdef, hyperdb.Multilink): - props[linkprop] = [newid] - else: - props[linkprop] = newid - else: - # linking to an existing item - if isinstance(propdef, hyperdb.Multilink): - existing = cl.get(linkid, linkprop)[:] - existing.append(nodeid) - props[linkprop] = existing - else: - props[linkprop] = newid - - return '
'.join(m) - - def _changenode(self, cn, nodeid, props): - ''' change the node based on the contents of the form - ''' - # check for permission - if not self.editItemPermission(props): - raise Unauthorised, 'You do not have permission to edit %s'%cn - - # make the changes - cl = self.db.classes[cn] - return cl.set(nodeid, **props) - - def _createnode(self, cn, props): - ''' create a node based on the contents of the form - ''' - # check for permission - if not self.newItemPermission(props): - raise Unauthorised, 'You do not have permission to create %s'%cn - - # create the node and return its id - cl = self.db.classes[cn] - return cl.create(**props) - - # - # More actions - # - def editCSVAction(self): - ''' Performs an edit of all of a class' items in one go. - - The "rows" CGI var defines the CSV-formatted entries for the - class. New nodes are identified by the ID 'X' (or any other - non-existent ID) and removed lines are retired. - ''' - # this is per-class only - if not self.editCSVPermission(): - self.error_message.append( - _('You do not have permission to edit %s' %self.classname)) - - # get the CSV module - if rcsv.error: - self.error_message.append(_(rcsv.error)) - return - - cl = self.db.classes[self.classname] - idlessprops = cl.getprops(protected=0).keys() - idlessprops.sort() - props = ['id'] + idlessprops - - # do the edit - rows = StringIO.StringIO(self.form['rows'].value) - reader = rcsv.reader(rows, rcsv.comma_separated) - found = {} - line = 0 - for values in reader: - line += 1 - if line == 1: continue - # skip property names header - if values == props: - continue - - # extract the nodeid - nodeid, values = values[0], values[1:] - found[nodeid] = 1 - - # see if the node exists - if nodeid in ('x', 'X') or not cl.hasnode(nodeid): - exists = 0 - else: - exists = 1 - - # confirm correct weight - if len(idlessprops) != len(values): - self.error_message.append( - _('Not enough values on line %(line)s')%{'line':line}) - return - - # extract the new values - d = {} - for name, value in zip(idlessprops, values): - prop = cl.properties[name] - value = value.strip() - # only add the property if it has a value - if value: - # if it's a multilink, split it - if isinstance(prop, hyperdb.Multilink): - value = value.split(':') - elif isinstance(prop, hyperdb.Password): - value = password.Password(value) - elif isinstance(prop, hyperdb.Interval): - value = date.Interval(value) - elif isinstance(prop, hyperdb.Date): - value = date.Date(value) - elif isinstance(prop, hyperdb.Boolean): - value = value.lower() in ('yes', 'true', 'on', '1') - elif isinstance(prop, hyperdb.Number): - value = float(value) - d[name] = value - elif exists: - # nuke the existing value - if isinstance(prop, hyperdb.Multilink): - d[name] = [] - else: - d[name] = None - - # perform the edit - if exists: - # edit existing - cl.set(nodeid, **d) - else: - # new node - found[cl.create(**d)] = 1 - - # retire the removed entries - for nodeid in cl.list(): - if not found.has_key(nodeid): - cl.retire(nodeid) - - # all OK - self.db.commit() - - self.ok_message.append(_('Items edited OK')) - - def editCSVPermission(self): - ''' Determine whether the user has permission to edit this class. - - Base behaviour is to check the user can edit this class. - ''' - if not self.db.security.hasPermission('Edit', self.userid, - self.classname): - return 0 - return 1 - - def searchAction(self, wcre=re.compile(r'[\s,]+')): - ''' Mangle some of the form variables. - - Set the form ":filter" variable based on the values of the - filter variables - if they're set to anything other than - "dontcare" then add them to :filter. - - Handle the ":queryname" variable and save off the query to - the user's query list. - - Split any String query values on whitespace and comma. - ''' - # generic edit is per-class only - if not self.searchPermission(): - self.error_message.append( - _('You do not have permission to search %s' %self.classname)) - - # add a faked :filter form variable for each filtering prop - props = self.db.classes[self.classname].getprops() - queryname = '' - for key in self.form.keys(): - # special vars - if self.FV_QUERYNAME.match(key): - queryname = self.form[key].value.strip() - continue - - if not props.has_key(key): - continue - if isinstance(self.form[key], type([])): - # search for at least one entry which is not empty - for minifield in self.form[key]: - if minifield.value: - break - else: - continue - else: - if not self.form[key].value: - continue - if isinstance(props[key], hyperdb.String): - v = self.form[key].value - l = token.token_split(v) - if len(l) > 1 or l[0] != v: - self.form.value.remove(self.form[key]) - # replace the single value with the split list - for v in l: - self.form.value.append(cgi.MiniFieldStorage(key, v)) - - self.form.value.append(cgi.MiniFieldStorage('@filter', key)) - - # handle saving the query params - if queryname: - # parse the environment and figure what the query _is_ - req = HTMLRequest(self) - - # The [1:] strips off the '?' character, it isn't part of the - # query string. - url = req.indexargs_href('', {})[1:] - - # handle editing an existing query - try: - qid = self.db.query.lookup(queryname) - self.db.query.set(qid, klass=self.classname, url=url) - except KeyError: - # create a query - qid = self.db.query.create(name=queryname, - klass=self.classname, url=url) - - # and add it to the user's query multilink - queries = self.db.user.get(self.userid, 'queries') - queries.append(qid) - self.db.user.set(self.userid, queries=queries) - - # commit the query change to the database - self.db.commit() - - def searchPermission(self): - ''' Determine whether the user has permission to search this class. - - Base behaviour is to check the user can view this class. - ''' - if not self.db.security.hasPermission('View', self.userid, - self.classname): - return 0 - return 1 - - - def retireAction(self): - ''' Retire the context item. - ''' - # if we want to view the index template now, then unset the nodeid - # context info (a special-case for retire actions on the index page) - nodeid = self.nodeid - if self.template == 'index': - self.nodeid = None - - # generic edit is per-class only - if not self.retirePermission(): - self.error_message.append( - _('You do not have permission to retire %s' %self.classname)) - return - - # make sure we don't try to retire admin or anonymous - if self.classname == 'user' and \ - self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'): - self.error_message.append( - _('You may not retire the admin or anonymous user')) - return - - # do the retire - self.db.getclass(self.classname).retire(nodeid) - self.db.commit() - - self.ok_message.append( - _('%(classname)s %(itemid)s has been retired')%{ - 'classname': self.classname.capitalize(), 'itemid': nodeid}) - - def retirePermission(self): - ''' Determine whether the user has permission to retire this class. - - Base behaviour is to check the user can edit this class. - ''' - if not self.db.security.hasPermission('Edit', self.userid, - self.classname): return 0 return 1 + def parsePropsFromForm(self, create=0): + return FormParser(self).parse(create=create) - def showAction(self, typere=re.compile('[@:]type'), - numre=re.compile('[@:]number')): - ''' Show a node of a particular class/id - ''' - t = n = '' - for key in self.form.keys(): - if typere.match(key): - t = self.form[key].value.strip() - elif numre.match(key): - n = self.form[key].value.strip() - if not t: - raise ValueError, 'Invalid %s number'%t - url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n) - raise Redirect, url - - def parsePropsFromForm(self, num_re=re.compile('^\d+$')): - ''' Item properties and their values are edited with html FORM - variables and their values. You can: - - - Change the value of some property of the current item. - - Create a new item of any class, and edit the new item's - properties, - - Attach newly created items to a multilink property of the - current item. - - Remove items from a multilink property of the current item. - - Specify that some properties are required for the edit - operation to be successful. - - In the following, values are variable, "@" may be - either ":" or "@", and other text "required" is fixed. - - Most properties are specified as form variables: - - - - property on the current context item - - "@" - - property on the indicated item (for editing related - information) - - Designators name a specific item of a class. - - - - Name an existing item of class . - - "-" - - Name the th new item of class . If the form - submission is successful, a new item of is - created. Within the submitted form, a particular - designator of this form always refers to the same new - item. - - Once we have determined the "propname", we look at it to see - if it's special: - - @required - The associated form value is a comma-separated list of - property names that must be specified when the form is - submitted for the edit operation to succeed. - - When the is missing, the properties are - for the current context item. When is - present, they are for the item specified by - . - - The "@required" specifier must come before any of the - properties it refers to are assigned in the form. - - @remove@=id(s) or @add@=id(s) - The "@add@" and "@remove@" edit actions apply only to - Multilink properties. The form value must be a - comma-separate list of keys for the class specified by - the simple form variable. The listed items are added - to (respectively, removed from) the specified - property. - - @link@= - If the edit action is "@link@", the simple form - variable must specify a Link or Multilink property. - The form value is a comma-separated list of - designators. The item corresponding to each - designator is linked to the property given by simple - form variable. These are collected up and returned in - all_links. - - None of the above (ie. just a simple form value) - The value of the form variable is converted - appropriately, depending on the type of the property. - - For a Link('klass') property, the form value is a - single key for 'klass', where the key field is - specified in dbinit.py. - - For a Multilink('klass') property, the form value is a - comma-separated list of keys for 'klass', where the - key field is specified in dbinit.py. - - Note that for simple-form-variables specifiying Link - and Multilink properties, the linked-to class must - have a key field. - - For a String() property specifying a filename, the - file named by the form value is uploaded. This means we - try to set additional properties "filename" and "type" (if - they are valid for the class). Otherwise, the property - is set to the form value. - - For Date(), Interval(), Boolean(), and Number() - properties, the form value is converted to the - appropriate - - Any of the form variables may be prefixed with a classname or - designator. - - Two special form values are supported for backwards - compatibility: - - @note - This is equivalent to:: - - @link@messages=msg-1 - @msg-1@content=value - - except that in addition, the "author" and "date" - properties of "msg-1" are set to the userid of the - submitter, and the current time, respectively. - - @file - This is equivalent to:: - - @link@files=file-1 - @file-1@content=value - - The String content value is handled as described above for - file uploads. - - If both the "@note" and "@file" form variables are - specified, the action:: - - @link@msg-1@files=file-1 - - is also performed. - - We also check that FileClass items have a "content" property with - actual content, otherwise we remove them from all_props before - returning. - - The return from this method is a dict of - (classname, id): properties - ... this dict _always_ has an entry for the current context, - even if it's empty (ie. a submission for an existing issue that - doesn't result in any changes would return {('issue','123'): {}}) - The id may be None, which indicates that an item should be - created. - ''' - # some very useful variables - db = self.db - form = self.form - - if not hasattr(self, 'FV_SPECIAL'): - # generate the regexp for handling special form values - classes = '|'.join(db.classes.keys()) - # specials for parsePropsFromForm - # handle the various forms (see unit tests) - self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE) - self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes) - - # these indicate the default class / item - default_cn = self.classname - default_cl = self.db.classes[default_cn] - default_nodeid = self.nodeid - - # we'll store info about the individual class/item edit in these - all_required = {} # required props per class/item - all_props = {} # props to set per class/item - got_props = {} # props received per class/item - all_propdef = {} # note - only one entry per class - all_links = [] # as many as are required - - # we should always return something, even empty, for the context - all_props[(default_cn, default_nodeid)] = {} - - keys = form.keys() - timezone = db.getUserTimezone() - - # sentinels for the :note and :file props - have_note = have_file = 0 - - # extract the usable form labels from the form - matches = [] - for key in keys: - m = self.FV_SPECIAL.match(key) - if m: - matches.append((key, m.groupdict())) - - # now handle the matches - for key, d in matches: - if d['classname']: - # we got a designator - cn = d['classname'] - cl = self.db.classes[cn] - nodeid = d['id'] - propname = d['propname'] - elif d['note']: - # the special note field - cn = 'msg' - cl = self.db.classes[cn] - nodeid = '-1' - propname = 'content' - all_links.append((default_cn, default_nodeid, 'messages', - [('msg', '-1')])) - have_note = 1 - elif d['file']: - # the special file field - cn = 'file' - cl = self.db.classes[cn] - nodeid = '-1' - propname = 'content' - all_links.append((default_cn, default_nodeid, 'files', - [('file', '-1')])) - have_file = 1 - else: - # default - cn = default_cn - cl = default_cl - nodeid = default_nodeid - propname = d['propname'] - - # the thing this value relates to is... - this = (cn, nodeid) - - # get more info about the class, and the current set of - # form props for it - if not all_propdef.has_key(cn): - all_propdef[cn] = cl.getprops() - propdef = all_propdef[cn] - if not all_props.has_key(this): - all_props[this] = {} - props = all_props[this] - if not got_props.has_key(this): - got_props[this] = {} - - # is this a link command? - if d['link']: - value = [] - for entry in extractFormList(form[key]): - m = self.FV_DESIGNATOR.match(entry) - if not m: - raise FormError, \ - 'link "%s" value "%s" not a designator'%(key, entry) - value.append((m.group(1), m.group(2))) - - # make sure the link property is valid - if (not isinstance(propdef[propname], hyperdb.Multilink) and - not isinstance(propdef[propname], hyperdb.Link)): - raise FormError, '%s %s is not a link or '\ - 'multilink property'%(cn, propname) - - all_links.append((cn, nodeid, propname, value)) - continue - - # detect the special ":required" variable - if d['required']: - all_required[this] = extractFormList(form[key]) - continue - - # see if we're performing a special multilink action - mlaction = 'set' - if d['remove']: - mlaction = 'remove' - elif d['add']: - mlaction = 'add' - - # does the property exist? - if not propdef.has_key(propname): - if mlaction != 'set': - raise FormError, 'You have submitted a %s action for'\ - ' the property "%s" which doesn\'t exist'%(mlaction, - propname) - # the form element is probably just something we don't care - # about - ignore it - continue - proptype = propdef[propname] - - # Get the form value. This value may be a MiniFieldStorage or a list - # of MiniFieldStorages. - value = form[key] - - # handle unpacking of the MiniFieldStorage / list form value - if isinstance(proptype, hyperdb.Multilink): - value = extractFormList(value) - else: - # multiple values are not OK - if isinstance(value, type([])): - raise FormError, 'You have submitted more than one value'\ - ' for the %s property'%propname - # value might be a file upload... - if not hasattr(value, 'filename') or value.filename is None: - # nope, pull out the value and strip it - value = value.value.strip() - - # now that we have the props field, we need a teensy little - # extra bit of help for the old :note field... - if d['note'] and value: - props['author'] = self.db.getuid() - props['date'] = date.Date() - - # handle by type now - if isinstance(proptype, hyperdb.Password): - if not value: - # ignore empty password values - continue - for key, d in matches: - if d['confirm'] and d['propname'] == propname: - confirm = form[key] - break - else: - raise FormError, 'Password and confirmation text do '\ - 'not match' - if isinstance(confirm, type([])): - raise FormError, 'You have submitted more than one value'\ - ' for the %s property'%propname - if value != confirm.value: - raise FormError, 'Password and confirmation text do '\ - 'not match' - try: - value = password.Password(value) - except hyperdb.HyperdbValueError, msg: - raise FormError, msg - - elif isinstance(proptype, hyperdb.Multilink): - # convert input to list of ids - try: - l = hyperdb.rawToHyperdb(self.db, cl, nodeid, - propname, value) - except hyperdb.HyperdbValueError, msg: - raise FormError, msg - - # now use that list of ids to modify the multilink - if mlaction == 'set': - value = l - else: - # we're modifying the list - get the current list of ids - if props.has_key(propname): - existing = props[propname] - elif nodeid and not nodeid.startswith('-'): - existing = cl.get(nodeid, propname, []) - else: - existing = [] - - # now either remove or add - if mlaction == 'remove': - # remove - handle situation where the id isn't in - # the list - for entry in l: - try: - existing.remove(entry) - except ValueError: - raise FormError, _('property "%(propname)s": ' - '"%(value)s" not currently in list')%{ - 'propname': propname, 'value': entry} - else: - # add - easy, just don't dupe - for entry in l: - if entry not in existing: - existing.append(entry) - value = existing - value.sort() - - elif value == '': - # other types should be None'd if there's no value - value = None - else: - # handle all other types - try: - if isinstance(proptype, hyperdb.String): - if (hasattr(value, 'filename') and - value.filename is not None): - # skip if the upload is empty - if not value.filename: - continue - # this String is actually a _file_ - # try to determine the file content-type - fn = value.filename.split('\\')[-1] - if propdef.has_key('name'): - props['name'] = fn - # use this info as the type/filename properties - if propdef.has_key('type'): - props['type'] = mimetypes.guess_type(fn)[0] - if not props['type']: - props['type'] = "application/octet-stream" - # finally, read the content RAW - value = value.value - else: - value = hyperdb.rawToHyperdb(self.db, cl, - nodeid, propname, value) - - else: - value = hyperdb.rawToHyperdb(self.db, cl, nodeid, - propname, value) - except hyperdb.HyperdbValueError, msg: - raise FormError, msg - - # register that we got this property - if value: - got_props[this][propname] = 1 - - # get the old value - if nodeid and not nodeid.startswith('-'): - try: - existing = cl.get(nodeid, propname) - except KeyError: - # this might be a new property for which there is - # no existing value - if not propdef.has_key(propname): - raise - except IndexError, message: - raise FormError(str(message)) - - # make sure the existing multilink is sorted - if isinstance(proptype, hyperdb.Multilink): - existing.sort() - - # "missing" existing values may not be None - if not existing: - if isinstance(proptype, hyperdb.String) and not existing: - # some backends store "missing" Strings as empty strings - existing = None - elif isinstance(proptype, hyperdb.Number) and not existing: - # some backends store "missing" Numbers as 0 :( - existing = 0 - elif isinstance(proptype, hyperdb.Boolean) and not existing: - # likewise Booleans - existing = 0 - - # if changed, set it - if value != existing: - props[propname] = value - else: - # don't bother setting empty/unset values - if value is None: - continue - elif isinstance(proptype, hyperdb.Multilink) and value == []: - continue - elif isinstance(proptype, hyperdb.String) and value == '': - continue - - props[propname] = value - - # check to see if we need to specially link a file to the note - if have_note and have_file: - all_links.append(('msg', '-1', 'files', [('file', '-1')])) - - # see if all the required properties have been supplied - s = [] - for thing, required in all_required.items(): - # register the values we got - got = got_props.get(thing, {}) - for entry in required[:]: - if got.has_key(entry): - required.remove(entry) - - # any required values not present? - if not required: - continue - - # tell the user to entry the values required - if len(required) > 1: - p = 'properties' - else: - p = 'property' - s.append('Required %s %s %s not supplied'%(thing[0], p, - ', '.join(required))) - if s: - raise FormError, '\n'.join(s) - - # When creating a FileClass node, it should have a non-empty content - # property to be created. When editing a FileClass node, it should - # either have a non-empty content property or no property at all. In - # the latter case, nothing will change. - for (cn, id), props in all_props.items(): - if isinstance(self.db.classes[cn], hyperdb.FileClass): - if id == '-1': - if not props.get('content', ''): - del all_props[(cn, id)] - elif props.has_key('content') and not props['content']: - raise FormError, _('File is empty') - return all_props, all_links - -def extractFormList(value): - ''' Extract a list of values from the form value. - - It may be one of: - [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...] - MiniFieldStorage('value,value,...') - MiniFieldStorage('value') - ''' - # multiple values are OK - if isinstance(value, type([])): - # it's a list of MiniFieldStorages - join then into - values = ','.join([i.value.strip() for i in value]) - else: - # it's a MiniFieldStorage, but may be a comma-separated list - # of values - values = value.value - - value = [i.strip() for i in values.split(',')] - - # filter out the empty bits - return filter(None, value) - +# vim: set et sts=4 sw=4 :