X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;f=roundup%2Fcgi%2Fclient.py;h=ff4573abe175543943ffd07b6c4cf3ce6c6f8ffd;hb=1184281ead46ac2e354b515a19f91947372398d8;hp=097a48d3e1007552b572ae2abd75ef8d88183c82;hpb=d3c855f69d8add133f703aca8a1155fdcb3fd2e5;p=roundup.git diff --git a/roundup/cgi/client.py b/roundup/cgi/client.py index 097a48d..ff4573a 100644 --- a/roundup/cgi/client.py +++ b/roundup/cgi/client.py @@ -1,69 +1,39 @@ -# $Id: client.py,v 1.128 2003-08-10 13:38:43 jlgijsbers 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, string - -from roundup import roundupdb, date, hyperdb, password, token -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, openSMTPConnection - -class HTTPException(Exception): - pass -class Unauthorised(HTTPException): - pass -class NotFound(HTTPException): - pass -class Redirect(HTTPException): - pass -class NotModified(HTTPException): - pass - -# set to indicate to roundup not to actually _send_ email -# this var must contain a file to write the mail to -SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '') - -# used by a couple of routines -chars = string.letters+string.digits - -# XXX actually _use_ FormError -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 ''' +__docformat__ = 'restructuredtext' + +import base64, binascii, cgi, codecs, mimetypes, os +import quopri, random, re, rfc822, stat, sys, time +import socket, errno + +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, encode_quopri +from roundup.cgi import accept_language +from roundup import xmlrpc + +from roundup.anypy.cookie_ import CookieError, BaseCookie, SimpleCookie, \ + get_cookie_date +from roundup.anypy.io_ import StringIO +from roundup.anypy import http_ +from roundup.anypy import urllib_ 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) @@ -75,50 +45,206 @@ CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)' def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)): return mc.sub(clean_message_callback, message) def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}): - ''' Strip all non ,, and
tags from a string - ''' - if ok.has_key(match.group(3).lower()): + """ Strip all non
,, and
tags from a string + """ + if match.group(3).lower() in ok: 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 variables of the form : are used. The colon (":") part may 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 @@ -127,39 +253,37 @@ 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 self.path = env['PATH_INFO'] @@ -167,15 +291,20 @@ 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) + self.cookie_path = urllib_.urlparse(self.base)[2] + # 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: - self.form = cgi.FieldStorage(environ=env) + self.form = cgi.FieldStorage(fp=request.rfile, environ=env) else: self.form = form @@ -194,159 +323,396 @@ 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. - ''' + """ Wrap the real main in a try/finally so we always close off the db. + """ try: - self.inner_main() + if self.env.get('CONTENT_TYPE') == 'text/xml': + self.handle_xmlrpc() + else: + self.inner_main() finally: if hasattr(self, 'db'): self.db.close() + + def handle_xmlrpc(self): + + # Pull the raw XML out of the form. The "value" attribute + # will be the raw content of the POST request. + assert self.form.file + input = self.form.value + # So that the rest of Roundup can query the form in the + # usual way, we create an empty list of fields. + self.form.list = [] + + # Set the charset and language, since other parts of + # Roundup may depend upon that. + self.determine_charset() + self.determine_language() + # Open the database as the correct user. + self.determine_user() + self.check_anonymous_access() + + # Call the appropriate XML-RPC method. + handler = xmlrpc.RoundupDispatcher(self.db, + self.instance.actions, + self.translator, + allow_none=True) + output = handler.dispatch(input) + + self.setHeader("Content-Type", "text/xml") + self.setHeader("Content-Length", str(len(output))) + self.write(output) + 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() - - # make sure we're identified (even anonymously) - self.determine_user() - - # possibly handle a form submit action (may change self.classname - # and self.template, and may also append error/ok_messages) - self.handle_action() - - # now render the page - # we don't want clients caching our dynamic pages - self.additional_headers['Cache-Control'] = 'no-cache' -# 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 - - # render the content - self.write(self.renderContext()) + self.determine_charset() + self.determine_language() + + try: + # make sure we're identified (even anonymously) + self.determine_user() + + # figure out the context and desired content template + self.determine_context() + + # if we've made it this far the context is to a bit of + # Roundup's real web interface (not a file being served up) + # so do the Anonymous Web Acess check now + self.check_anonymous_access() + + # 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(html) + return + + # now render the page + # we don't want clients caching our dynamic pages + self.additional_headers['Cache-Control'] = 'no-cache' + # Pragma: no-cache makes Mozilla and its ilk + # double-load all pages!! + # self.additional_headers['Pragma'] = 'no-cache' + + # 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_html(self.renderContext()) + except SendFile, designator: + # The call to serve_file may result in an Unauthorised + # exception or a NotModified exception. Those + # exceptions will be handled by the outermost set of + # exception handlers. + self.serve_file(designator) + except SendStaticFile, file: + self.serve_static_file(str(file)) + 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)) - except SendFile, designator: - self.serve_file(designator) - 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.write_html('Redirecting to %s'%(url, url)) + except LoginError, message: + # The user tried to log in, but did not provide a valid + # username and password. If we support HTTP + # authorization, send back a response that will cause the + # browser to prompt the user again. + if self.instance.config.WEB_HTTP_AUTH: + self.response_code = http_.client.UNAUTHORIZED + realm = self.instance.config.TRACKER_NAME + self.setHeader("WWW-Authenticate", + "Basic realm=\"%s\"" % realm) + else: + self.response_code = http_.client.FORBIDDEN + self.renderFrontPage(message) except Unauthorised, message: - self.classname = None - self.template = '' - self.error_message.append(message) - self.write(self.renderContext()) - except NotFound: - # pass through - raise + # users may always see the front page + self.response_code = 403 + self.renderFrontPage(message) + except NotModified: + # send the 304 response + self.response_code = 304 + self.header() + 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(self._('Form Error: ') + str(e)) + self.write_html(self.renderContext()) except: - # everything else - self.write(cgitb.html()) + # Something has gone badly wrong. Therefore, we should + # make sure that the response code indicates failure. + if self.response_code == http_.client.OK: + self.response_code = http_.client.INTERNAL_SERVER_ERROR + # Help the administrator work out what went wrong. + html = ("

Traceback

" + + cgitb.html(i18n=self.translator) + + ("

Environment Variables

%s
" + % cgitb.niceDict("", self.env))) + if not self.instance.config.WEB_DEBUG: + exc_info = sys.exc_info() + subject = "Error: %s" % exc_info[1] + self.send_html_to_admin(subject, html) + self.write_html(self._(error_message)) + else: + self.write_html(html) 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 '@charset' in self.form: + charset = self.form['@charset'].value + if charset.lower() == "none": + charset = "" + charset_parameter = 1 + elif 'roundup_charset' in self.cookie: + 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: + 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 "@language" in self.form: + language = self.form["@language"].value + if language.lower() == "none": + language = "" + self.add_cookie("roundup_language", language) + elif "roundup_language" in self.cookie: + 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 'REMOTE_USER' in self.env: + # 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() + raise + 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): @@ -361,50 +727,111 @@ class Client: # 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 check_anonymous_access(self): + """Check that the Anonymous user is actually allowed to use the web + interface and short-circuit all further processing if they're not. + """ + # allow Anonymous to use the "login" and "register" actions (noting + # that "register" has its own "Register" permission check) + + if ':action' in self.form: + action = self.form[':action'] + elif '@action' in self.form: + action = self.form['@action'] + else: + action = '' + if isinstance(action, list): + raise SeriousError('broken form: multiple @action values submitted') + elif action != '': + action = action.value.lower() + if action in ('login', 'register'): + return + + # allow Anonymous to view the "user" "register" template if they're + # allowed to register + if (self.db.security.hasPermission('Register', self.userid, 'user') + and self.classname == 'user' and self.template == 'register'): + return + + # otherwise for everything else + if self.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")) + + 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) + # The old session API refers to the closed database; + # we can no longer use it. + self.session_api = Session(self) + + + 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 # see if a template or messages are specified template_override = ok_message = error_message = None - for key in self.form.keys(): + for key in self.form: if self.FV_TEMPLATE.match(key): template_override = self.form[key].value elif self.FV_OK_MESSAGE.match(key): @@ -414,6 +841,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,24 +855,25 @@ class Client: else: self.template = '' return - elif path[0] == '_file': - raise SendStaticFile, os.path.join(*path[1:]) + elif path[0] in ('_file', '@@file'): + raise SendStaticFile(os.path.join(*path[1:])) else: self.classname = path[0] if len(path) > 1: # 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') + raise SendFile(path[0]) # 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): - raise NotFound, '%s/%s'%(self.classname, 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' else: @@ -450,67 +884,161 @@ class Client: try: self.db.getclass(self.classname) except KeyError: - raise NotFound, self.classname + raise NotFound(self.classname) # see if we have a template override 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. - ''' + """ Serve the file from the content property of the designated item. + """ m = dre.match(str(designator)) if not m: - raise NotFound, str(designator) + raise NotFound(str(designator)) classname, nodeid = m.group(1), m.group(2) - if classname != 'file': - raise NotFound, designator - # we just want to serve up the file named - file = self.db.file - self.additional_headers['Content-Type'] = file.get(nodeid, 'type') - self.write(file.get(nodeid, 'content')) + try: + klass = self.db.getclass(classname) + except KeyError: + # The classname was not valid. + raise NotFound(str(designator)) + + # perform the Anonymous user access check + self.check_anonymous_access() + + # make sure we have the appropriate properties + props = klass.getprops() + if 'type' not in props: + raise NotFound(designator) + if 'content' not in props: + 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') + # Can happen for msg class: + if not mime_type: + mime_type = 'text/plain' + + # if the mime_type is HTML-ish then make sure we're allowed to serve up + # HTML-ish content + if mime_type in ('text/html', 'text/x-html'): + if not self.instance.config['WEB_ALLOW_HTML_FILE']: + # do NOT serve the content up as HTML + mime_type = 'application/octet-stream' + + # If this object is a file (i.e., an instance of FileClass), + # see if we can find it in the filesystem. If so, we may be + # able to use the more-efficient request.sendfile method of + # sending the file. If not, just get the "content" property + # in the usual way, and use that. + content = None + filename = None + if isinstance(klass, hyperdb.FileClass): + try: + filename = self.db.filename(classname, nodeid) + except AttributeError: + # The database doesn't store files in the filesystem + # and therefore doesn't provide the "filename" method. + pass + except IOError: + # The file does not exist. + pass + if not filename: + content = klass.get(nodeid, 'content') + + lmt = klass.get(nodeid, 'activity').timestamp() + + self._serve_file(lmt, mime_type, content, filename) 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' + + self._serve_file(lmt, mime_type, '', filename) + + def _serve_file(self, lmt, mime_type, content=None, filename=None): + """ guts of serve_file() and serve_static_file() + """ + + # spit out headers + self.additional_headers['Content-Type'] = mime_type + 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 'HTTP_IF_MODIFIED_SINCE' in self.env: # 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()) + if filename: + self.write_file(filename) + else: + self.additional_headers['Content-Length'] = str(len(content)) + self.write(content) + + def send_html_to_admin(self, subject, content): + + to = [self.mailer.config.ADMIN_EMAIL] + message = self.mailer.get_standard_message(to, subject) + # delete existing content-type headers + del message['Content-type'] + message['Content-type'] = 'text/html; charset=utf-8' + message.set_payload(content) + encode_quopri(message) + self.mailer.smtp_send(to, message.as_string()) + + def renderFrontPage(self, message): + """Return the front page of the tracker.""" + + self.classname = self.nodeid = None + self.template = '' + self.error_message.append(message) + self.write_html(self.renderContext()) def renderContext(self): - ''' Return a PageTemplate for the named page - ''' + """ 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 = { @@ -518,1483 +1046,490 @@ 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: - return '%s'%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'%cgi.escape(str(message)) + except templating.Unauthorised, message: + raise Unauthorised(cgi.escape(str(message))) except: # everything else - return cgitb.pt_html() + if self.instance.config.WEB_DEBUG: + return cgitb.pt_html(i18n=self.translator) + exc_info = sys.exc_info() + try: + # If possible, send the HTML page template traceback + # to the administrator. + subject = "Templating Error: %s" % exc_info[1] + self.send_html_to_admin(subject, cgitb.pt_html()) + # Now report the error to the user. + return self._(error_message) + except: + # Reraise the original exception. The user will + # receive an error message, and the adminstrator will + # receive a traceback, albeit with less information + # than the one we tried to generate above. + raise exc_info[0](exc_info[1]).with_traceback(exc_info[2]) # 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. + """ Determine whether there should be an Action called. 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. - ''' - if self.form.has_key(':action'): - action = self.form[':action'].value.lower() - elif self.form.has_key('@action'): - action = self.form['@action'].value.lower() - else: - return None - try: - # get the action, validate it - for name, method in self.actions: - if name == action: - break - else: - raise ValueError, 'No such action "%s"'%action - # call the mapped action - getattr(self, method)() - except Redirect: - raise - except Unauthorised: - raise - - def write(self, content): - if not self.headers_done: - self.header() - self.request.wfile.write(content) - - def header(self, headers=None, response=None): - '''Put up the appropriate header. - ''' - if headers is None: - headers = {'Content-Type':'text/html'} - 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() - - # 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()) - - # and commit immediately - self.db.sessions.commit() - - # expire us in a long, long time - expire = Cookie._getdate(86400*365) - - # 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) + Actions may return a page (by default HTML) to return to the + user, bypassing the usual template rendering. - def make_user_anonymous(self): - ''' Make us anonymous - - This method used to handle non-existence of the 'anonymous' - user, but that user is mandatory now. - ''' - 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 + We explicitly catch Reject and ValueError exceptions and + present their messages to the user. + """ + if ':action' in self.form: + action = self.form[':action'] + elif '@action' in self.form: + action = self.form['@action'] + else: + return None - # get the login info - self.user = self.form['__login_name'].value - if self.form.has_key('__login_password'): - password = self.form['__login_password'].value + if isinstance(action, list): + raise SeriousError('broken form: multiple @action values submitted') else: - password = '' + action = action.value.lower() - # 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 + action_klass = self.get_action_class(action) - # 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) + # 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() - # Let the user know what's going on - self.ok_message.append(_('You are logged out')) + except (ValueError, Reject), err: + self.error_message.append(str(err)) - def registerAction(self): - '''Attempt to create a new user based on the contents of the form - and then set the cookie. + def get_action_class(self, action_name): + if (hasattr(self.instance, 'cgi_actions') and + action_name in self.instance.cgi_actions): + # 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 - return 1 on successful login - ''' - # parse the props from the form - try: - props = self.parsePropsFromForm()[0][('user', None)] - except (ValueError, KeyError), message: - self.error_message.append(_('Error: ') + str(message)) - return + def _socket_op(self, call, *args, **kwargs): + """Execute socket-related operation, catch common network errors - # make sure we're allowed to register - if not self.registerPermission(props): - raise Unauthorised, _("You do not have permission to register") + Parameters: + call: a callable to execute + args, kwargs: call arguments + """ 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: + 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 + except IOError: + # Apache's mod_python will raise IOError -- without an + # accompanying errno -- when a write to the client fails. + # A common case is that the client has closed the + # connection. There's no way to be certain that this is + # the situation that has occurred here, but that is the + # most likely case. 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 - subject = 'Complete your registration to %s'%tracker_name - body = ''' -To complete your registration of the user "%(name)s" with %(tracker)s, -please visit the following URL: - - %(url)s?@action=confrego&otk=%(otk)s -'''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base, - 'otk': otk} - if not self.sendEmail(props['address'], subject, body): - 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 sendEmail(self, to, subject, content): - # send email to the user's email address - message = StringIO.StringIO() - writer = MimeWriter.MimeWriter(message) - tracker_name = self.db.config.TRACKER_NAME - writer.addheader('Subject', encode_header(subject)) - writer.addheader('To', to) - writer.addheader('From', roundupdb.straddr((tracker_name, - self.db.config.ADMIN_EMAIL))) - writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000", - time.gmtime())) - # add a uniquely Roundup header to help filtering - writer.addheader('X-Roundup-Name', tracker_name) - # avoid email loops - writer.addheader('X-Roundup-Loop', 'hello') - writer.addheader('Content-Transfer-Encoding', 'quoted-printable') - body = writer.startbody('text/plain; charset=utf-8') - - # message body, encoded quoted-printable - content = StringIO.StringIO(content) - quopri.encode(content, body, 0) - - if SENDMAILDEBUG: - # don't send - just write to a file - open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%( - self.db.config.ADMIN_EMAIL, - ', '.join(to),message.getvalue())) - else: - # now try to send the message - try: - # send the message as admin so bounces are sent there - # instead of to roundup - smtp = openSMTPConnection(self.db.config) - smtp.sendmail(self.db.config.ADMIN_EMAIL, [to], - message.getvalue()) - except socket.error, value: - self.error_message.append("Error: couldn't send email: " - "mailhost %s"%value) - return 0 - except smtplib.SMTPException, msg: - self.error_message.append("Error: couldn't send email: %s"%msg) - return 0 - return 1 + def write(self, content): + if not self.headers_done: + self.header() + if self.env['REQUEST_METHOD'] != 'HEAD': + self._socket_op(self.request.wfile.write, content) - def registerPermission(self, props): - ''' Determine whether the user has permission to register + def write_html(self, content): + if not self.headers_done: + # at this point, we are sure about Content-Type + if 'Content-Type' not in self.additional_headers: + self.additional_headers['Content-Type'] = \ + 'text/html; charset=%s' % self.charset + self.header() - 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 - ''' - # pull the rego information out of the otk database - otk = self.form['otk'].value - props = self.db.otks.getall(otk) - 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] = date.Date(value) - elif isinstance(proptype, hyperdb.Interval): - props[propname] = date.Interval(value) - elif isinstance(proptype, hyperdb.Password): - props[propname] = password.Password() - props[propname].unpack(value) - - # re-open the database as "admin" - if self.user != 'admin': - self.opendb('admin') - - # create the new user - cl = self.db.user -# XXX we need to make the "default" page be able to display errors! - try: - props['roles'] = self.instance.config.NEW_WEB_USER_ROLES - del props['__time'] - self.userid = cl.create(**props) - # 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)) + if self.env['REQUEST_METHOD'] == 'HEAD': + # client doesn't care about content return - # log the new user in - self.user = cl.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!') - return + if self.charset != self.STORAGE_CHARSET: + # recode output + content = content.decode(self.STORAGE_CHARSET, 'replace') + content = content.encode(self.charset, 'xmlcharrefreplace') - # re-open the database as "admin" - if self.user != 'admin': - self.opendb('admin') + # and write + self._socket_op(self.request.wfile.write, content) - # change the password - newpw = password.generatePassword() + def http_strip(self, content): + """Remove HTTP Linear White Space from 'content'. - 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 + 'content' -- A string. - # user info - address = self.db.user.get(uid, 'address') - name = self.db.user.get(uid, 'username') + returns -- 'content', with all leading and trailing LWS + removed.""" - # 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". + # RFC 2616 2.2: Basic Rules + # + # LWS = [CRLF] 1*( SP | HT ) + return content.strip(" \r\n\t") -Your password is now: %(password)s -'''%{'name': name, 'password': newpw} - if not self.sendEmail(address, subject, body): - return + def http_split(self, content): + """Split an HTTP list. - self.ok_message.append('Password reset and email sent to %s'%address) - return + 'content' -- A string, giving a list of items. - # 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 + returns -- A sequence of strings, containing the elements of + the list.""" - # 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()) + # RFC 2616 2.1: Augmented BNF + # + # Grammar productions of the form "#rule" indicate a + # comma-separated list of elements matching "rule". LWS + # is then removed from each element, and empty elements + # removed. - # 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.sendEmail(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 - ''' - # parse the props from the form - try: - props, links = self.parsePropsFromForm() - except (ValueError, KeyError), message: - self.error_message.append(_('Error: ') + str(message)) - return - - # handle the props + # Split at commas. + elements = content.split(",") + # Remove linear whitespace at either end of the string. + elements = [self.http_strip(e) for e in elements] + # Remove any now-empty elements. + return [e for e in elements if e] + + def handle_range_header(self, length, etag): + """Handle the 'Range' and 'If-Range' headers. + + 'length' -- the length of the content available for the + resource. + + 'etag' -- the entity tag for this resources. + + returns -- If the request headers (including 'Range' and + 'If-Range') indicate that only a portion of the entity should + be returned, then the return value is a pair '(offfset, + length)' indicating the first byte and number of bytes of the + content that should be returned to the client. In addition, + this method will set 'self.response_code' to indicate Partial + Content. In all other cases, the return value is 'None'. If + appropriate, 'self.response_code' will be + set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'. In that + case, the caller should not send any data to the client.""" + + # RFC 2616 14.35: Range + # + # See if the Range header is present. + ranges_specifier = self.env.get("HTTP_RANGE") + if ranges_specifier is None: + return None + # RFC 2616 14.27: If-Range + # + # Check to see if there is an If-Range header. + # Because the specification says: + # + # The If-Range header ... MUST be ignored if the request + # does not include a Range header, we check for If-Range + # after checking for Range. + if_range = self.env.get("HTTP_IF_RANGE") + if if_range: + # The grammar for the If-Range header is: + # + # If-Range = "If-Range" ":" ( entity-tag | HTTP-date ) + # entity-tag = [ weak ] opaque-tag + # weak = "W/" + # opaque-tag = quoted-string + # + # We only support strong entity tags. + if_range = self.http_strip(if_range) + if (not if_range.startswith('"') + or not if_range.endswith('"')): + return None + # If the condition doesn't match the entity tag, then we + # must send the client the entire file. + if if_range != etag: + return + # The grammar for the Range header value is: + # + # ranges-specifier = byte-ranges-specifier + # byte-ranges-specifier = bytes-unit "=" byte-range-set + # byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec ) + # byte-range-spec = first-byte-pos "-" [last-byte-pos] + # first-byte-pos = 1*DIGIT + # last-byte-pos = 1*DIGIT + # suffix-byte-range-spec = "-" suffix-length + # suffix-length = 1*DIGIT + # + # Look for the "=" separating the units from the range set. + specs = ranges_specifier.split("=", 1) + if len(specs) != 2: + return None + # Check that the bytes-unit is in fact "bytes". If it is not, + # we do not know how to process this range. + bytes_unit = self.http_strip(specs[0]) + if bytes_unit != "bytes": + return None + # Seperate the range-set into range-specs. + byte_range_set = self.http_strip(specs[1]) + byte_range_specs = self.http_split(byte_range_set) + # We only handle exactly one range at this time. + if len(byte_range_specs) != 1: + return None + # Parse the spec. + byte_range_spec = byte_range_specs[0] + pos = byte_range_spec.split("-", 1) + if len(pos) != 2: + return None + # Get the first and last bytes. + first = self.http_strip(pos[0]) + last = self.http_strip(pos[1]) + # We do not handle suffix ranges. + if not first: + return None + # Convert the first and last positions to integers. try: - message = self._editnodes(props, links) - except (ValueError, KeyError, IndexError), message: - self.error_message.append(_('Error: ') + str(message)) + first = int(first) + if last: + last = int(last) + else: + last = length - 1 + except: + # The positions could not be parsed as integers. + return None + # Check that the range makes sense. + if (first < 0 or last < 0 or last < first): + return None + if last >= length: + # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable + # + # If there is an If-Range header, RFC 2616 says that we + # should just ignore the invalid Range header. + if if_range: + return None + # Return code 416 with a Content-Range header giving the + # allowable range. + self.response_code = http_.client.REQUESTED_RANGE_NOT_SATISFIABLE + self.setHeader("Content-Range", "bytes */%d" % length) + return None + # RFC 2616 10.2.7: 206 Partial Content + # + # Tell the client that we are honoring the Range request by + # indicating that we are providing partial content. + self.response_code = http_.client.PARTIAL_CONTENT + # RFC 2616 14.16: Content-Range + # + # Tell the client what data we are providing. + # + # content-range-spec = byte-content-range-spec + # byte-content-range-spec = bytes-unit SP + # byte-range-resp-spec "/" + # ( instance-length | "*" ) + # byte-range-resp-spec = (first-byte-pos "-" last-byte-pos) + # | "*" + # instance-length = 1 * DIGIT + self.setHeader("Content-Range", + "bytes %d-%d/%d" % (first, last, length)) + return (first, last - first + 1) + + def write_file(self, filename): + """Send the contents of 'filename' to the user.""" + + # Determine the length of the file. + stat_info = os.stat(filename) + length = stat_info[stat.ST_SIZE] + # Assume we will return the entire file. + offset = 0 + # If the headers have not already been finalized, + if not self.headers_done: + # RFC 2616 14.19: ETag + # + # Compute the entity tag, in a format similar to that + # used by Apache. + etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO], + length, + stat_info[stat.ST_MTIME]) + self.setHeader("ETag", etag) + # RFC 2616 14.5: Accept-Ranges + # + # Let the client know that we will accept range requests. + self.setHeader("Accept-Ranges", "bytes") + # RFC 2616 14.35: Range + # + # If there is a Range header, we may be able to avoid + # sending the entire file. + content_range = self.handle_range_header(length, etag) + if content_range: + offset, length = content_range + # RFC 2616 14.13: Content-Length + # + # Tell the client how much data we are providing. + self.setHeader("Content-Length", str(length)) + # Send the HTTP header. + self.header() + # If the client doesn't actually want the body, or if we are + # indicating an invalid range. + if (self.env['REQUEST_METHOD'] == 'HEAD' + or self.response_code == http_.client.REQUESTED_RANGE_NOT_SATISFIABLE): 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)) - - 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: - return 1 - if self.db.security.hasPermission('Edit', self.userid, self.classname): - return 1 - return 0 - - def newItemAction(self): - ''' Add a new item to the database. - - This follows the same form as the editItemAction, with the same - special form values. - ''' - # parse the props from the form - try: - props, links = self.parsePropsFromForm() - except (ValueError, KeyError), message: - self.error_message.append(_('Error: ') + str(message)) + # Use the optimized "sendfile" operation, if possible. + if hasattr(self.request, "sendfile"): + self._socket_op(self.request.sendfile, filename, offset, length) return - - # handle the props - edit or create + # Fallback to the "write" operation. + f = open(filename, 'rb') try: - # when it hits the None element, it'll set self.nodeid - messages = self._editnodes(props, links) - - except (ValueError, KeyError, IndexError), message: - # these errors might just be indicative of user dumbness - self.error_message.append(_('Error: ') + str(message)) - return - - # commit now that all the tricky stuff is done - self.db.commit() - - # redirect to the new item's page - raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base, - self.classname, self.nodeid, urllib.quote(messages), - urllib.quote(self.template)) - - 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 - try: - import csv - except ImportError: - self.error_message.append(_( - 'Sorry, you need the csv module to use this function.
\n' - 'Get it from: http://www.object-craft.com.au/projects/csv/')) - return - - cl = self.db.classes[self.classname] - idlessprops = cl.getprops(protected=0).keys() - idlessprops.sort() - props = ['id'] + idlessprops - - # do the edit - rows = self.form['rows'].value.splitlines() - p = csv.parser() - found = {} - line = 0 - for row in rows[1:]: - line += 1 - values = p.parse(row) - # not a complete row, keep going - if not values: 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 cl.hasnode(nodeid): - exists = 1 - else: - exists = 0 - - # 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(':') - 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')) + if offset: + f.seek(offset) + content = f.read(length) + finally: + f.close() + self.write(content) - def editCSVPermission(self): - ''' Determine whether the user has permission to edit this class. + def setHeader(self, header, value): + """Override a header to be returned to the user's browser. + """ + self.additional_headers[header] = value - 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 + def header(self, headers=None, response=None): + """Put up the appropriate header. + """ + if headers is None: + headers = {'Content-Type':'text/html; charset=utf-8'} + if response is None: + response = self.response_code - 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) + # update with additional info + headers.update(self.additional_headers) - # 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) + if headers.get('Content-Type', 'text/html') == 'text/html': + headers['Content-Type'] = 'text/html; charset=utf-8' - # commit the query change to the database - self.db.commit() + headers = list(headers.items()) - def searchPermission(self): - ''' Determine whether the user has permission to search this class. + for ((path, name), (value, expire)) in self._cookies.iteritems(): + cookie = "%s=%s; Path=%s;"%(name, value, path) + if expire is not None: + cookie += " expires=%s;"%get_cookie_date(expire) + headers.append(('Set-Cookie', cookie)) - 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 + self._socket_op(self.request.start_response, headers, response) + self.headers_done = 1 + if self.debug: + self.headers_sent = headers - 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 + 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) - # 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 + def make_user_anonymous(self): + """ Make us anonymous - # do the retire - self.db.getclass(self.classname).retire(nodeid) - self.db.commit() + This method used to handle non-existence of the 'anonymous' + user, but that user is mandatory now. + """ + self.userid = self.db.user.lookup('anonymous') + self.user = 'anonymous' - self.ok_message.append( - _('%(classname)s %(itemid)s has been retired')%{ - 'classname': self.classname.capitalize(), 'itemid': nodeid}) + def standard_message(self, to, subject, body, author=None): + """Send a standard email message from Roundup. - def retirePermission(self): - ''' Determine whether the user has permission to retire this class. + "to" - recipients list + "subject" - Subject + "body" - Message + "author" - (name, address) tuple or None for admin email - Base behaviour is to check the user can edit this class. - ''' - if not self.db.security.hasPermission('Edit', self.userid, - self.classname): + Arguments are passed to the Mailer.standard_message code. + """ + try: + self.mailer.standard_message(to, subject, body, author) + except MessageSendError, e: + self.error_message.append(str(e)) 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 ValueError, \ - '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 ValueError, '%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 ValueError, '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 ValueError, '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 ValueError, 'Password and confirmation text do '\ - 'not match' - if isinstance(confirm, type([])): - raise ValueError, 'You have submitted more than one value'\ - ' for the %s property'%propname - if value != confirm.value: - raise ValueError, 'Password and confirmation text do '\ - 'not match' - value = password.Password(value) - - elif isinstance(proptype, hyperdb.Link): - # see if it's the "no selection" choice - if value == '-1' or not value: - # if we're creating, just don't include this property - if not nodeid or nodeid.startswith('-'): - continue - value = None - else: - # handle key values - link = proptype.classname - if not num_re.match(value): - try: - value = db.classes[link].lookup(value) - except KeyError: - raise ValueError, _('property "%(propname)s": ' - '%(value)s not a %(classname)s')%{ - 'propname': propname, 'value': value, - 'classname': link} - except TypeError, message: - raise ValueError, _('you may only enter ID values ' - 'for property "%(propname)s": %(message)s')%{ - 'propname': propname, 'message': message} - elif isinstance(proptype, hyperdb.Multilink): - # perform link class key value lookup if necessary - link = proptype.classname - link_cl = db.classes[link] - l = [] - for entry in value: - if not entry: continue - if not num_re.match(entry): - try: - entry = link_cl.lookup(entry) - except KeyError: - raise ValueError, _('property "%(propname)s": ' - '"%(value)s" not an entry of %(classname)s')%{ - 'propname': propname, 'value': entry, - 'classname': link} - except TypeError, message: - raise ValueError, _('you may only enter ID values ' - 'for property "%(propname)s": %(message)s')%{ - 'propname': propname, 'message': message} - l.append(entry) - l.sort() - - # 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 ValueError, _('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 == '': - # if we're creating, just don't include this property - if not nodeid or nodeid.startswith('-'): - continue - # other types should be None'd if there's no value - value = None - else: - # handle ValueErrors for all these in a similar fashion - 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 - value = value.value - else: - # normal String fix the CRLF/CR -> LF stuff - value = fixNewlines(value) - - elif isinstance(proptype, hyperdb.Date): - value = date.Date(value, offset=timezone) - elif isinstance(proptype, hyperdb.Interval): - value = date.Interval(value) - elif isinstance(proptype, hyperdb.Boolean): - value = value.lower() in ('yes', 'true', 'on', '1') - elif isinstance(proptype, hyperdb.Number): - value = float(value) - except ValueError, msg: - raise ValueError, _('Error with %s property: %s')%( - propname, 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 - - # 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 ValueError, '\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 ValueError, _('File is empty') - return all_props, all_links - -def fixNewlines(text): - ''' Homogenise line endings. - - Different web clients send different line ending values, but - other systems (eg. email) don't necessarily handle those line - endings. Our solution is to convert all line endings to LF. - ''' - text = text.replace('\r\n', '\n') - return text.replace('\r', '\n') - -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 :