Code

- fix handling of traceback mails to the roundup admin
[roundup.git] / roundup / cgi / client.py
index 0407abdbecc60fb98d09170305c4ad21606c50da..11a22172e9c1f69437c139afa66525d78d6986a1 100644 (file)
@@ -2,11 +2,10 @@
 """
 __docformat__ = 'restructuredtext'
 
-import base64, binascii, cgi, codecs, httplib, mimetypes, os
-import quopri, random, re, rfc822, stat, sys, time, urllib, urlparse
-import Cookie, socket, errno
-from Cookie import CookieError, BaseCookie, SimpleCookie
-from cStringIO import StringIO
+import base64, binascii, cgi, codecs, mimetypes, os
+import quopri, random, re, rfc822, stat, sys, time
+import socket, errno
+from traceback import format_exc
 
 from roundup import roundupdb, date, hyperdb, password
 from roundup.cgi import templating, cgitb, TranslationService
@@ -18,6 +17,16 @@ 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_
+
+from email.MIMEBase import MIMEBase
+from email.MIMEText import MIMEText
+from email.MIMEMultipart import MIMEMultipart
+
 def initialiseSecurity(security):
     '''Create some Permissions and Roles on the security object
 
@@ -43,7 +52,7 @@ def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
     """ Strip all non <a>,<i>,<b> and <br> tags from a string
     """
-    if ok.has_key(match.group(3).lower()):
+    if match.group(3).lower() in ok:
         return match.group(1)
     return '&lt;%s&gt;'%match.group(2)
 
@@ -293,14 +302,14 @@ class Client:
 
         # 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_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
 
@@ -380,6 +389,7 @@ class Client:
         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,
@@ -437,6 +447,11 @@ class Client:
                 # 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()
@@ -489,13 +504,23 @@ class Client:
                 self.additional_headers['Location'] = str(url)
                 self.response_code = 302
             self.write_html('Redirecting to <a href="%s">%s</a>'%(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:
             # users may always see the front page
             self.response_code = 403
-            self.classname = self.nodeid = None
-            self.template = ''
-            self.error_message.append(message)
-            self.write_html(self.renderContext())
+            self.renderFrontPage(message)
         except NotModified:
             # send the 304 response
             self.response_code = 304
@@ -510,16 +535,27 @@ class Client:
                 # we can't map the URL to a class we know about
                 # reraise the NotFound and let roundup_server
                 # handle it
-                raise NotFound, e
+                raise NotFound(e)
         except FormError, e:
             self.error_message.append(self._('Form Error: ') + str(e))
             self.write_html(self.renderContext())
         except:
-            if self.instance.config.WEB_DEBUG:
-                self.write_html(cgitb.html(i18n=self.translator))
+            # 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 = ("<h1>Traceback</h1>"
+                    + cgitb.html(i18n=self.translator)
+                    + ("<h1>Environment Variables</h1><table>%s</table>"
+                       % cgitb.niceDict("", self.env)))
+            if not self.instance.config.WEB_DEBUG:
+                exc_info = sys.exc_info()
+                subject = "Error: %s" % exc_info[1]
+                self.send_error_to_admin(subject, html, format_exc())
+                self.write_html(self._(error_message))
             else:
-                self.mailer.exception_message()
-                return self.write_html(self._(error_message))
+                self.write_html(html)
 
     def clean_sessions(self):
         """Deprecated
@@ -556,12 +592,12 @@ class Client:
         """
         # look for client charset
         charset_parameter = 0
-        if self.form.has_key('@charset'):
+        if '@charset' in self.form:
             charset = self.form['@charset'].value
             if charset.lower() == "none":
                 charset = ""
             charset_parameter = 1
-        elif self.cookie.has_key('roundup_charset'):
+        elif 'roundup_charset' in self.cookie:
             charset = self.cookie['roundup_charset'].value
         else:
             charset = None
@@ -598,7 +634,7 @@ class Client:
                     uc = int(num)
                 return unichr(uc)
 
-            for field_name in self.form.keys():
+            for field_name in self.form:
                 field = self.form[field_name]
                 if (field.type == 'text/plain') and not field.filename:
                     try:
@@ -613,12 +649,12 @@ class Client:
         # look for language parameter
         # then for language cookie
         # last for the Accept-Language header
-        if self.form.has_key("@language"):
+        if "@language" in self.form:
             language = self.form["@language"].value
             if language.lower() == "none":
                 language = ""
             self.add_cookie("roundup_language", language)
-        elif self.cookie.has_key("roundup_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')
@@ -646,7 +682,7 @@ class Client:
         user = None
         # first up, try http authorization if enabled
         if self.instance.config['WEB_HTTP_AUTH']:
-            if self.env.has_key('REMOTE_USER'):
+            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', ''):
@@ -665,7 +701,7 @@ class Client:
                         login.verifyLogin(username, password)
                     except LoginError, err:
                         self.make_user_anonymous()
-                        raise Unauthorised, err
+                        raise
                     user = username
 
         # if user was not set by http authorization, try session lookup
@@ -690,15 +726,44 @@ class Client:
         # make sure the anonymous user is valid if we're using it
         if user == 'anonymous':
             self.make_user_anonymous()
-            if not self.db.security.hasPermission('Web Access', self.userid):
-                raise Unauthorised, self._("Anonymous users are not "
-                    "allowed to use the web interface")
         else:
             self.user = user
 
         # reopen the database as the correct user
         self.opendb(self.user)
 
+    def 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.
 
@@ -771,7 +836,7 @@ class Client:
 
         # 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):
@@ -796,12 +861,12 @@ class Client:
                 self.template = ''
             return
         elif path[0] in ('_file', '@@file'):
-            raise SendStaticFile, os.path.join(*path[1:])
+            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]
+                raise SendFile(path[0])
 
         # see if we got a designator
         m = dre.match(self.classname)
@@ -811,9 +876,9 @@ class Client:
             try:
                 klass = self.db.getclass(self.classname)
             except KeyError:
-                raise NotFound, '%s/%s'%(self.classname, self.nodeid)
+                raise NotFound('%s/%s'%(self.classname, self.nodeid))
             if not klass.hasnode(self.nodeid):
-                raise NotFound, '%s/%s'%(self.classname, self.nodeid)
+                raise NotFound('%s/%s'%(self.classname, self.nodeid))
             # with a designator, we default to item view
             self.template = 'item'
         else:
@@ -824,7 +889,7 @@ 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:
@@ -835,25 +900,38 @@ class Client:
         """
         m = dre.match(str(designator))
         if not m:
-            raise NotFound, str(designator)
+            raise NotFound(str(designator))
         classname, nodeid = m.group(1), m.group(2)
 
-        klass = self.db.getclass(classname)
+        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 not props.has_key('type'):
-            raise NotFound, designator
-        if not props.has_key('content'):
-            raise NotFound, designator
+        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 Unauthorisedself._("You are not allowed to view "
-                "this file.")
+            raise Unauthorised(self._("You are not allowed to view "
+                "this file."))
 
-        mime_type = klass.get(nodeid, 'type')
+        try:
+            mime_type = klass.get(nodeid, 'type')
+        except IndexError, e:
+            raise NotFound(e)
+        # 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
@@ -901,7 +979,7 @@ class Client:
             if os.path.isfile(filename) and filename.startswith(prefix):
                 break
         else:
-            raise NotFound, file
+            raise NotFound(file)
 
         # last-modified time
         lmt = os.stat(filename)[stat.ST_MTIME]
@@ -930,7 +1008,7 @@ class Client:
         # XXX see which interfaces set this
         #if hasattr(self.request, 'headers'):
             #ims = self.request.headers.getheader('if-modified-since')
-        if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
+        if 'HTTP_IF_MODIFIED_SINCE' in self.env:
             # cgi will put the header in the env var
             ims = self.env['HTTP_IF_MODIFIED_SINCE']
         if ims:
@@ -945,6 +1023,33 @@ class Client:
             self.additional_headers['Content-Length'] = str(len(content))
             self.write(content)
 
+    def send_error_to_admin(self, subject, html, txt):
+        """Send traceback information to admin via email.
+           We send both, the formatted html (with more information) and
+           the text version of the traceback. We use
+           multipart/alternative so the receiver can chose which version
+           to display.
+        """
+        to = [self.mailer.config.ADMIN_EMAIL]
+        message = MIMEMultipart('alternative')
+        self.mailer.set_message_attributes(message, to, subject)
+        part = MIMEBase('text', 'html')
+        part.set_charset('utf-8')
+        part.set_payload(html)
+        encode_quopri(part)
+        message.attach(part)
+        part = MIMEText(txt)
+        message.attach(part)
+        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
         """
@@ -980,9 +1085,9 @@ class Client:
                 result = result.replace('</body>', s)
             return result
         except templating.NoTemplate, message:
-            return '<strong>%s</strong>'%message
+            return '<strong>%s</strong>'%cgi.escape(str(message))
         except templating.Unauthorised, message:
-            raise Unauthorised, str(message)
+            raise Unauthorised(cgi.escape(str(message)))
         except:
             # everything else
             if self.instance.config.WEB_DEBUG:
@@ -991,16 +1096,8 @@ class Client:
             try:
                 # If possible, send the HTML page template traceback
                 # to the administrator.
-                to = [self.mailer.config.ADMIN_EMAIL]
                 subject = "Templating Error: %s" % exc_info[1]
-                content = cgitb.pt_html()
-                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, str(message))
+                self.send_error_to_admin(subject, cgitb.pt_html(), format_exc())
                 # Now report the error to the user.
                 return self._(error_message)
             except:
@@ -1008,7 +1105,7 @@ class Client:
                 # 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], exc_info[2]
+                raise exc_info[0](exc_info[1]).with_traceback(exc_info[2])
 
     # these are the actions that are available
     actions = (
@@ -1038,13 +1135,18 @@ class Client:
             We explicitly catch Reject and ValueError exceptions and
             present their messages to the user.
         """
-        if self.form.has_key(':action'):
-            action = self.form[':action'].value.lower()
-        elif self.form.has_key('@action'):
-            action = self.form['@action'].value.lower()
+        if ':action' in self.form:
+            action = self.form[':action']
+        elif '@action' in self.form:
+            action = self.form['@action']
         else:
             return None
 
+        if isinstance(action, list):
+            raise SeriousError('broken form: multiple @action values submitted')
+        else:
+            action = action.value.lower()
+
         try:
             action_klass = self.get_action_class(action)
 
@@ -1060,7 +1162,7 @@ class Client:
 
     def get_action_class(self, action_name):
         if (hasattr(self.instance, 'cgi_actions') and
-                self.instance.cgi_actions.has_key(action_name)):
+                action_name in self.instance.cgi_actions):
             # tracker-defined action
             action_klass = self.instance.cgi_actions[action_name]
         else:
@@ -1069,7 +1171,7 @@ class Client:
                 if name == action_name:
                     break
             else:
-                raise ValueError, 'No such action "%s"'%action_name
+                raise ValueError('No such action "%s"'%action_name)
         return action_klass
 
     def _socket_op(self, call, *args, **kwargs):
@@ -1109,7 +1211,7 @@ class Client:
     def write_html(self, content):
         if not self.headers_done:
             # at this point, we are sure about Content-Type
-            if not self.additional_headers.has_key('Content-Type'):
+            if 'Content-Type' not in self.additional_headers:
                 self.additional_headers['Content-Type'] = \
                     'text/html; charset=%s' % self.charset
             self.header()
@@ -1271,14 +1373,14 @@ class Client:
                 return None
             # Return code 416 with a Content-Range header giving the
             # allowable range.
-            self.response_code = httplib.REQUESTED_RANGE_NOT_SATISFIABLE
+            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 = httplib.PARTIAL_CONTENT
+        self.response_code = http_.client.PARTIAL_CONTENT
         # RFC 2616 14.16: Content-Range
         #
         # Tell the client what data we are providing.
@@ -1332,7 +1434,7 @@ class Client:
         # 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 == httplib.REQUESTED_RANGE_NOT_SATISFIABLE):
+            or self.response_code == http_.client.REQUESTED_RANGE_NOT_SATISFIABLE):
             return
         # Use the optimized "sendfile" operation, if possible.
         if hasattr(self.request, "sendfile"):
@@ -1367,12 +1469,12 @@ class Client:
         if headers.get('Content-Type', 'text/html') == 'text/html':
             headers['Content-Type'] = 'text/html; charset=utf-8'
 
-        headers = headers.items()
+        headers = list(headers.items())
 
-        for ((path, name), (value, expire)) in self._cookies.items():
+        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;"%Cookie._getdate(expire)
+                cookie += " expires=%s;"%get_cookie_date(expire)
             headers.append(('Set-Cookie', cookie))
 
         self._socket_op(self.request.start_response, headers, response)