diff --git a/roundup/cgi/client.py b/roundup/cgi/client.py
index 28d1ceeef04f14d8281df4bd444848da79f5e259..11a22172e9c1f69437c139afa66525d78d6986a1 100644 (file)
--- a/roundup/cgi/client.py
+++ b/roundup/cgi/client.py
__docformat__ = 'restructuredtext'
import base64, binascii, cgi, codecs, 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 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
from roundup.exceptions import *
from roundup.cgi.exceptions import *
from roundup.cgi.form_parser import FormParser
-from roundup.mailer import Mailer, MessageSendError
+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
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 <a>,<i>,<b> and <br> tags from a string
- '''
- if ok.has_key(match.group(3).lower()):
+ """ Strip all non <a>,<i>,<b> and <br> tags from a string
+ """
+ if match.group(3).lower() in ok:
return match.group(1)
return '<%s>'%match.group(2)
-error_message = ""'''<html><head><title>An error has occurred</title></head>
+error_message = ''"""<html><head><title>An error has occurred</title></head>
<body><h1>An error has occurred</h1>
<p>A problem was encountered processing your request.
The tracker maintainers have been notified of the problem.</p>
-</body></html>'''
+</body></html>"""
class LiberalCookie(SimpleCookie):
- ''' Python's SimpleCookie throws an exception if the cookie uses invalid
+ """ 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:
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 = []
class Session:
- '''
+ """
Needs DB to be already opened by client
Session attributes at instantiation:
# refresh session expiration time, setting persistent
# cookie if needed to last for 'expire' seconds
- '''
+ """
def __init__(self, client):
self._data = {}
self._data = self.session_db.getall(self._sid)
def _gen_sid(self):
- ''' generate a unique session key '''
+ """ generate a unique session key """
while 1:
s = '%s%s'%(time.time(), random.random())
s = binascii.b2a_base64(s).strip()
return s
def clean_up(self):
- '''Remove expired sessions'''
+ """Remove expired sessions"""
self.session_db.clean()
def destroy(self):
self.client.db.commit()
def update(self, set_cookie=False, expire=None):
- ''' update timestamp in db to avoid expiration
+ """ 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()
class Client:
- '''Instantiate to handle one CGI request.
+ """Instantiate to handle one CGI request.
See inner_main for request processing.
Note that in various places throughout this code, special form
variables of the form :<name> 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!
# 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
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.
+ """Process a request.
The most common requests are handled like so:
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:
self.determine_charset()
self.determine_language()
- # make sure we're identified (even anonymously)
- self.determine_user()
-
- # figure out the context and desired content template
- self.determine_context()
-
- # possibly handle a form submit action (may change self.classname
- # and self.template, and may also append error/ok_messages)
- html = self.handle_action()
-
- if html:
- self.write_html(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
- # <rj> 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
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
+ # <rj> 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.
self.additional_headers['Location'] = str(url)
self.response_code = 302
self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
- except SendFile, designator:
- try:
- self.serve_file(designator)
- except NotModified:
- # send the 304 response
- self.response_code = 304
- self.header()
- except SendStaticFile, file:
- try:
- self.serve_static_file(str(file))
- except NotModified:
- # send the 304 response
- self.response_code = 304
- self.header()
+ 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.classname = self.nodeid = None
- self.template = ''
- self.error_message.append(message)
- self.write_html(self.renderContext())
+ 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'
# 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
"""
# 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
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:
# 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')
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', ''):
login.verifyLogin(username, password)
except LoginError, err:
self.make_user_anonymous()
- self.response_code = 403
- raise Unauthorised, err
-
+ raise
user = username
# if user was not set by http authorization, try session lookup
# 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.
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:
# 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):
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)
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:
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
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)
- 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 Unauthorised, self._("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
+ 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
self._serve_file(lmt, mime_type, content, filename)
def serve_static_file(self, file):
- ''' Serve up the file named from the templates dir
- '''
+ """ 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 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]
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()
- '''
-
- if not content:
- length = os.stat(filename)[stat.ST_SIZE]
- else:
- length = len(content)
+ """ guts of serve_file() and serve_static_file()
+ """
# spit out headers
self.additional_headers['Content-Type'] = mime_type
- self.additional_headers['Content-Length'] = str(length)
self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
ims = None
# 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:
if lmtt <= ims:
raise NotModified
- if not self.headers_done:
- self.header()
-
- if self.env['REQUEST_METHOD'] == 'HEAD':
- return
-
- # If we have a file, and the 'sendfile' method is available,
- # we can bypass reading and writing the content into application
- # memory entirely.
if filename:
- if hasattr(self.request, 'sendfile'):
- self._socket_op(self.request.sendfile, filename)
- return
- f = open(filename, 'rb')
- try:
- content = f.read()
- finally:
- f.close()
-
- self._socket_op(self.request.wfile.write, content)
-
+ self.write_file(filename)
+ else:
+ 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
- '''
+ """ Return a PageTemplate for the named page
+ """
name = self.classname
extension = self.template
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:
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, writer = self.mailer.get_standard_message(
- to, subject)
- writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
- body = writer.startbody('text/html; charset=utf-8')
- content = StringIO(content)
- quopri.encode(content, body, 0)
- self.mailer.smtp_send(to, message)
+ self.send_error_to_admin(subject, cgitb.pt_html(), format_exc())
# Now report the error to the user.
return self._(error_message)
except:
# 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 = (
('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
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)
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:
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):
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
def write(self, content):
if not self.headers_done:
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()
# and write
self._socket_op(self.request.wfile.write, content)
+ def http_strip(self, content):
+ """Remove HTTP Linear White Space from 'content'.
+
+ 'content' -- A string.
+
+ returns -- 'content', with all leading and trailing LWS
+ removed."""
+
+ # RFC 2616 2.2: Basic Rules
+ #
+ # LWS = [CRLF] 1*( SP | HT )
+ return content.strip(" \r\n\t")
+
+ def http_split(self, content):
+ """Split an HTTP list.
+
+ 'content' -- A string, giving a list of items.
+
+ returns -- A sequence of strings, containing the elements of
+ the list."""
+
+ # 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.
+
+ # 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:
+ 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
+ # Use the optimized "sendfile" operation, if possible.
+ if hasattr(self.request, "sendfile"):
+ self._socket_op(self.request.sendfile, filename, offset, length)
+ return
+ # Fallback to the "write" operation.
+ f = open(filename, 'rb')
+ try:
+ if offset:
+ f.seek(offset)
+ content = f.read(length)
+ finally:
+ f.close()
+ self.write(content)
def setHeader(self, header, value):
- '''Override a header to be returned to the user's browser.
- '''
+ """Override a header to be returned to the user's browser.
+ """
self.additional_headers[header] = value
def header(self, headers=None, response=None):
- '''Put up the appropriate header.
- '''
+ """Put up the appropriate header.
+ """
if headers is None:
headers = {'Content-Type':'text/html; charset=utf-8'}
if response is None:
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)
self.session_api.update(set_cookie=True, expire=expire)
def make_user_anonymous(self):
- ''' Make us anonymous
+ """ 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 standard_message(self, to, subject, body, author=None):
- '''Send a standard email message from Roundup.
+ """Send a standard email message from Roundup.
"to" - recipients list
"subject" - Subject
"author" - (name, address) tuple or None for admin email
Arguments are passed to the Mailer.standard_message code.
- '''
+ """
try:
self.mailer.standard_message(to, subject, body, author)
except MessageSendError, e: