"""
Calypso Server module.
-This module offers 3 useful classes:
+This module offers 4 useful classes:
- ``HTTPServer`` is a simple HTTP server;
- ``HTTPSServer`` is a HTTPS server, wrapping the HTTP server in a socket
managing SSL connections;
- ``CollectionHTTPHandler`` is a WebDAV request handler for HTTP(S) servers.
+- ``CalypsoApp`` is a WSGI-style HTTP request handler
To use this module, you should take a look at the file ``calypso.py`` that
should have been included in this package.
"""
+from cStringIO import StringIO
import os
import os.path
import base64
VERSION = "1.5"
-def _check(request, function):
+def _check(request, function, environ, start_response):
"""Check if user has sufficient rights for performing ``request``."""
# ``_check`` decorator can access ``request`` protected functions
# pylint: disable=W0212
owner = user = password = None
negotiate_success = False
- entity = request._resource or request._collection or None
+ resource = identify_resource(environ['PATH_INFO'])
+ collection = collection_singleton(environ['PATH_INFO'])
+ entity = resource or collection or None
- authorization = request.headers.get("Authorization", None)
+ if "REMOTE_USER" in environ:
+ user = environ["REMOTE_USER"]
+
+ authorization = environ.get("HTTP_AUTHORIZATION", None)
if authorization:
if authorization.startswith("Basic"):
challenge = authorization.lstrip("Basic").strip().encode("ascii")
- plain = request._decode(base64.b64decode(challenge))
+ plain = request._decode(environ.get('CONTENT_TYPE', ''), base64.b64decode(challenge))
user, password = plain.split(":")
elif negotiate.enabled():
user, negotiate_success = negotiate.try_aaa(authorization, request, entity)
client_info = dict([
- (name, request.headers.get(name)) for name in
- ("user-agent", "x-client", "origin")
- if name in request.headers])
+ (name, environ.get(name, None)) for name in
+ ("HTTP_USER_AGENT", "HTTP_X_CLIENT", "HTTP_ORIGIN")
+ if name in environ])
# bound privilege checker that can be used by principals etc in discovery too
- has_right = functools.partial(request.server.acl.has_right, user=user, password=password)
+ has_right = functools.partial(request.acl.has_right, user=user, password=password)
# Also send UNAUTHORIZED if there's no collection. Otherwise one
# could probe the server for (non-)existing collections.
if has_right(entity) or negotiate_success:
- function(request, context={
+ return function(request, context={
"user": user,
"client_info": client_info,
- "has_right": has_right})
+ "has_right": has_right},
+ environ=environ, start_response=start_response,
+ )
else:
- request.send_calypso_response(client.UNAUTHORIZED, 0)
if negotiate.enabled():
- request.send_header("WWW-Authenticate", "Negotiate")
- request.send_header(
- "WWW-Authenticate",
- 'Basic realm="Calypso CalDAV/CardDAV server - password required"')
- request.end_headers()
+ start_response('401 Unauthorized', [
+ ('WWW-Authenticate', 'Negotiate')])
+ else:
+ start_response('401 Unauthorized', [
+ ("WWW-Authenticate",
+ 'Basic realm="Calypso CalDAV/CardDAV server - password required"')])
# pylint: enable=W0212
def __init__(self, address, handler):
"""Create server."""
server.HTTPServer.__init__(self, address, handler)
- self.acl = acl.load()
# pylint: enable=W0231
else:
return None
-class CollectionHTTPHandler(server.BaseHTTPRequestHandler):
- """HTTP requests handler for WebDAV collections."""
- _encoding = config.get("encoding", "request")
-
- # Decorator checking rights before performing request
- check_rights = lambda function: lambda request: _check(request, function)
-
- # We do set Content-Length on all replies, so we can use HTTP/1.1
- # with multiple requests (as desired by the android CalDAV sync program
-
- protocol_version = 'HTTP/1.1'
-
- timeout = 90
-
- server_version = "Calypso/%s" % VERSION
- queued_headers = {}
-
- def queue_header(self, keyword, value):
- self.queued_headers[keyword] = value
-
- def end_headers(self):
- """
- Send out all queued headers and invoke or super classes
- end_header.
- """
- if self.queued_headers:
- for keyword, val in self.queued_headers.items():
- self.send_header(keyword, val)
- self.queued_headers = {}
- return server.BaseHTTPRequestHandler.end_headers(self)
-
- def address_string(self):
- return str(self.client_address[0])
-
- def send_connection_header(self):
- conntype = "Close"
- if self.close_connection == 0:
- conntype = "Keep-Alive"
- self.send_header("Connection", conntype)
-
- def send_calypso_response(self, response, length):
- self.send_response(response)
- self.send_connection_header()
- self.send_header("Content-Length", length)
- for header, value in config.items('headers'):
- self.send_header(header, value)
-
-
- def handle_one_request(self):
- """Handle a single HTTP request.
+def collection_singleton(p):
+ path = paths.collection_from_path(p)
+ if not path:
+ return None
+ if not path in CalypsoApp.collections:
+ CalypsoApp.collections[path] = webdav.Collection(path)
+ return CalypsoApp.collections[path]
- You normally don't need to override this method; see the class
- __doc__ string for information on how to handle specific HTTP
- commands such as GET and POST.
- """
- try:
- self.wfile.flush()
- self.close_connection = 1
+class CalypsoApp(object):
- self.connection.settimeout(5)
-
- self.raw_requestline = self.rfile.readline(65537)
+ _encoding = config.get("encoding", "request")
- self.connection.settimeout(90)
+ # Decorator checking rights before performing request
+ check_rights = lambda function: lambda request, environ, start_response: _check(request, function, environ, start_response)
- if len(self.raw_requestline) > 65536:
- log.error("Read request too long")
- self.requestline = ''
- self.request_version = ''
- self.command = ''
- self.send_error(414)
- return
- if not self.raw_requestline:
- log.error("Connection closed")
- return
- log.debug("First line '%s'", self.raw_requestline)
- if not self.parse_request():
- # An error code has been sent, just exit
- self.close_connection = 1
- return
- # parse_request clears close_connection on all http/1.1 links
- # it should only do this if a keep-alive header is seen
- self.close_connection = 1
- conntype = self.headers.get('Connection', "")
- if (conntype.lower() == 'keep-alive'
- and self.protocol_version >= "HTTP/1.1"):
- log.debug("keep-alive")
- self.close_connection = 0
- reqlen = self.headers.get('Content-Length',"0")
- log.debug("reqlen %s", reqlen)
- self.xml_request = self.rfile.read(int(reqlen))
- mname = 'do_' + self.command
- if not hasattr(self, mname):
- log.error("Unsupported method (%r)", self.command)
- self.send_error(501, "Unsupported method (%r)" % self.command)
- return
- method = getattr(self, mname)
- method()
- self.wfile.flush() #actually send the response if not already done.
- except socket.timeout as e:
- #a read or a write timed out. Discard this connection
- log.error("Request timed out: %r", e)
- self.close_connection = 1
- return
- except ssl.SSLError, x:
- #an io error. Discard this connection
- log.error("SSL request error: %r", x.args[0])
- self.close_connection = 1
- return
+ def __init__(self):
+ self.acl = acl.load()
+ def __call__(self, environ, start_response):
+ mname = 'do_' + environ['REQUEST_METHOD']
+ if not hasattr(self, mname):
+ log.error("Unsupported method (%r)", self.command)
+ start_response('501 Unknown method', [])
+ return ["Unsupported method (%r)" % self.command]
+ method = getattr(self, mname)
+ return method(environ, start_response)
collections = {}
- @property
- def _collection(self):
- """The ``webdav.Collection`` object corresponding to the given path."""
- return collection_singleton(self.path)
-
- @property
- def _resource(self):
- return identify_resource(self.path)
-
- def _decode(self, text):
+ def _decode(self, content_type, text):
"""Try to decode text according to various parameters."""
# List of charsets to try
charsets = []
# First append content charset given in the request
- content_type = self.headers.get("Content-Type", None)
if content_type and "charset=" in content_type:
charsets.append(content_type.split("charset=")[1].strip())
# Then append default Calypso charset
# pylint: disable=C0103
@check_rights
- def do_GET(self, context):
+ def do_GET(self, context, environ, start_response):
"""Manage GET request."""
- self.do_get_head(context, True)
+ return self.do_get_head(context, True, environ, start_response)
@check_rights
- def do_HEAD(self, context):
+ def do_HEAD(self, context, environ, start_response):
"""Manage HEAD request."""
- self.do_get_head(context, False)
+ return self.do_get_head(context, False, environ, start_response)
- def do_get_head(self, context, is_get):
+ def do_get_head(self, context, is_get, environ, start_response):
"""Manage either GET or HEAD request."""
- self._answer = ''
+ path = environ['PATH_INFO']
answer_text = ''
try:
- item_name = paths.resource_from_path(self.path)
- if item_name and self._collection:
+ item_name = paths.resource_from_path(path)
+ collection = collection_singleton(path)
+ resource = identify_resource(path)
+ if item_name and collection:
# Get collection item
- item = self._collection.get_item(item_name)
+ item = collection.get_item(item_name)
if item:
if is_get:
answer_text = item.text
etag = item.etag
else:
- self.send_response(client.GONE)
- self.send_header("Content-Length", 0)
- self.end_headers()
- return
- elif self._collection:
+ start_response('410 Gone', [('Content-Length', '0')])
+ return []
+ elif collection:
# Get whole collection
if is_get:
- answer_text = self._collection.text
- etag = self._collection.etag
- elif self._resource:
- self._resource.do_get_head(self, context, is_get)
- return
+ answer_text = collection.text
+ etag = collection.etag
+ elif resource:
+ return resource.do_get_head(context, is_get, environ, start_response)
else:
- self.send_calypso_response(client.NOT_FOUND, 0)
- self.end_headers()
- return
-
+ start_response('404 Not Found', [])
+ return []
+
if is_get:
try:
- self._answer = answer_text.encode(self._encoding,"xmlcharrefreplace")
+ answer = answer_text.encode(self._encoding, "xmlcharrefreplace")
except UnicodeDecodeError:
answer_text = answer_text.decode(errors="ignore")
- self._answer = answer_text.encode(self._encoding,"ignore")
+ answer = answer_text.encode(self._encoding,"ignore")
+ else:
+ answer = ''
- self.send_calypso_response(client.OK, len(self._answer))
- self.send_header("Content-Type", "text/calendar")
- self.send_header("Last-Modified", email.utils.formatdate(time.mktime(self._collection.last_modified)))
- self.send_header("ETag", etag)
- self.end_headers()
+ start_response('200 OK', [
+ ('Content-Length', str(len(answer))),
+ ("Content-Type", "text/calendar"),
+ ("Last-Modified", email.utils.formatdate(time.mktime(collection.last_modified))),
+ ("ETag", etag)])
if is_get:
- self.wfile.write(self._answer)
+ return [answer]
+ return []
except Exception:
- log.exception("Failed HEAD for %s", self.path)
- self.send_calypso_response(client.BAD_REQUEST, 0)
- self.end_headers()
+ log.exception("Failed HEAD for %s", path)
+ start_response('400 Bad Request', [])
+ return []
- def if_match(self, item):
- header = self.headers.get("If-Match", item.etag)
+ def if_match(self, environ, item):
+ header = environ.get("HTTP_IF_MATCH", item.etag)
header = rfc822.unquote(header)
if header == item.etag:
return True
return False
@check_rights
- def do_DELETE(self, context):
+ def do_DELETE(self, context, environ, start_response):
"""Manage DELETE request."""
try:
- item_name = paths.resource_from_path(self.path)
- item = self._collection.get_item(item_name)
+ path = environ['PATH_INFO']
+ item_name = paths.resource_from_path(path)
+ collection = collection_singleton(path)
+ item = collection.get_item(item_name)
- if item and self.if_match(item):
+ if item and self.if_match(environ, item):
# No ETag precondition or precondition verified, delete item
- self._answer = xmlutils.delete(self.path, self._collection, context=context)
-
- self.send_calypso_response(client.NO_CONTENT, len(self._answer))
- self.send_header("Content-Type", "text/xml")
- self.end_headers()
- self.wfile.write(self._answer)
+ answer = xmlutils.delete(path, collection, context=context)
+
+ start_response('200 OK', [
+ ('Content-Length', str(len(answer))),
+ ('Content-Type', 'text/xml')])
+ return [answer]
elif not item:
# Item does not exist
- self.send_calypso_response(client.NOT_FOUND, 0)
- self.end_headers()
+ start_response('404 Not Found', [])
+ return []
else:
# No item or ETag precondition not verified, do not delete item
- self.send_calypso_response(client.PRECONDITION_FAILED, 0)
- self.end_headers()
+ start_response('412 Precondition Failed', [])
+ return []
except Exception:
- log.exception("Failed DELETE for %s", self.path)
- self.send_calypso_response(client.BAD_REQUEST, 0)
- self.end_headers()
+ log.exception("Failed DELETE for %s", path)
+ start_response('400 Bad Request', [])
+ return []
@check_rights
- def do_MKCALENDAR(self, context):
+ def do_MKCALENDAR(self, context, environ, start_response):
"""Manage MKCALENDAR request."""
- self.send_calypso_response(client.CREATED, 0)
- self.end_headers()
+ start_response('201 Created', [])
+ return []
- def do_OPTIONS(self):
+ def do_OPTIONS(self, environ, start_response):
"""Manage OPTIONS request."""
- self.send_calypso_response(client.OK, 0)
- self.send_header(
- "Allow", "DELETE, HEAD, GET, MKCALENDAR, "
- "OPTIONS, PROPFIND, PUT, REPORT")
- self.send_header("DAV", "1, access-control, calendar-access, addressbook")
- self.end_headers()
+ start_response('204 No Content', [
+ ("Allow", "DELETE, HEAD, GET, MKCALENDAR, "
+ "OPTIONS, PROPFIND, PUT, REPORT"),
+ ("DAV", "1, access-control, calendar-access, addressbook")])
+ return []
@check_rights
- def do_PROPFIND(self, context):
+ def do_PROPFIND(self, context, environ, start_response):
"""Manage PROPFIND request."""
try:
- xml_request = self.xml_request
+ path = environ['PATH_INFO']
+ xml_request = environ['wsgi.input'].read(int(environ.get('CONTENT_LENGTH', '0')))
log.debug("PROPFIND %s", xml_request)
- self._answer = xmlutils.propfind(
- self.path, xml_request, self._collection, self._resource,
- self.headers.get("depth", "infinity"),
+ answer = xmlutils.propfind(
+ path, xml_request, collection_singleton(path), identify_resource(path),
+ environ.get("HTTP_DEPTH", "infinity"),
context)
- log.debug("PROPFIND ANSWER %s", self._answer)
+ log.debug("PROPFIND ANSWER %s", answer)
- self.send_calypso_response(client.MULTI_STATUS, len(self._answer))
- self.send_header("DAV", "1, calendar-access")
- self.send_header("Content-Type", "text/xml")
- self.end_headers()
- self.wfile.write(self._answer)
+ start_response('207 Multi-Status', [
+ ('Content-Length', str(len(answer))),
+ ("DAV", "1, calendar-access"),
+ ("Content-Type", "text/xml")])
+
+ return [answer]
except Exception:
- log.exception("Failed PROPFIND for %s", self.path)
- self.send_calypso_response(client.BAD_REQUEST, 0)
- self.end_headers()
+ log.exception("Failed PROPFIND for %s", path)
+ start_response('400 Bad Request', [])
+ return []
@check_rights
- def do_SEARCH(self, context):
+ def do_SEARCH(self, context, environ, start_response):
"""Manage SEARCH request."""
try:
- self.send_calypso_response(client.NO_CONTENT, 0)
- self.end_headers()
+ path = environ['PATH_INFO']
+ start_response('204 No Content', [])
+ return []
except Exception:
- log.exception("Failed SEARCH for %s", self.path)
- self.send_calypso_response(client.BAD_REQUEST, 0)
- self.end_headers()
-
+ log.exception("Failed SEARCH for %s", path)
+ start_response('400 Bad Request', [])
+ return []
+
@check_rights
- def do_PUT(self, context):
+ def do_PUT(self, context, environ, start_response):
"""Manage PUT request."""
try:
- item_name = paths.resource_from_path(self.path)
- item = self._collection.get_item(item_name)
- if not item or self.if_match(item):
+ path = environ['PATH_INFO']
+ item_name = paths.resource_from_path(path)
+ collection = collection_singleton(path)
+ item = collection.get_item(item_name)
+ if not item or self.if_match(environ, item):
# PUT allowed in 3 cases
# Case 1: No item and no ETag precondition: Add new item
# Case 2: Item and ETag precondition verified: Modify item
# Case 3: Item and no Etag precondition: Force modifying item
- webdav_request = self._decode(self.xml_request)
- new_item = xmlutils.put(self.path, webdav_request, self._collection, context=context)
-
+ content_type = environ.get("CONTENT_TYPE", None)
+ webdav_request = self._decode(content_type, self.xml_request)
+ new_item = xmlutils.put(path, webdav_request, collection, context=context)
+
log.debug("item_name %s new_name %s", item_name, new_item.name)
etag = new_item.etag
#log.debug("replacement etag %s", etag)
- self.send_calypso_response(client.CREATED, 0)
- self.send_header("ETag", etag)
- self.end_headers()
+ start_response('201 Created', [
+ ("ETag", etag),
+ ])
+ return []
else:
#log.debug("Precondition failed")
# PUT rejected in all other cases
- self.send_calypso_response(client.PRECONDITION_FAILED, 0)
- self.end_headers()
+ start_response('412 Precondition Failed', [])
+ return []
except Exception:
- log.exception('Failed PUT for %s', self.path)
- self.send_calypso_response(client.BAD_REQUEST, 0)
- self.end_headers()
-
+ log.exception('Failed PUT for %s', path)
+ start_response('400 Bad Request', [])
+ return []
@check_rights
- def do_REPORT(self, context):
+ def do_REPORT(self, context, environ, start_response):
"""Manage REPORT request."""
try:
- xml_request = self.xml_request
- log.debug("REPORT %s %s", self.path, xml_request)
- self._answer = xmlutils.report(self.path, xml_request, self._collection)
- log.debug("REPORT ANSWER %s", self._answer)
- self.send_calypso_response(client.MULTI_STATUS, len(self._answer))
- self.send_header("Content-Type", "text/xml")
- self.end_headers()
- self.wfile.write(self._answer)
+ path = environ['PATH_INFO']
+ xml_request = environ['wsgi.input'].read(int(environ['CONTENT_LENGTH']))
+ log.debug("REPORT %s %s", path, xml_request)
+ collection = collection_singleton(path)
+ answer = xmlutils.report(path, xml_request, collection)
+ log.debug("REPORT ANSWER %s", answer)
+ start_response('207 Multi-Status', [
+ ('Content-Length', str(len(answer))),
+ ("Content-Type", "text/xml"),
+ ])
+ return [answer]
except Exception:
- log.exception("Failed REPORT for %s", self.path)
- self.send_calypso_response(client.BAD_REQUEST, 0)
- self.end_headers()
+ log.exception("Failed REPORT for %s", path)
+ start_response('400 Bad Request', [])
+ return []
# pylint: enable=C0103
+
+
+class CollectionHTTPHandler(server.BaseHTTPRequestHandler):
+ """HTTP requests handler for WebDAV collections."""
+
+ # We do set Content-Length on all replies, so we can use HTTP/1.1
+ # with multiple requests (as desired by the android CalDAV sync program
+
+ app = CalypsoApp()
+
+ protocol_version = 'HTTP/1.1'
+
+ timeout = 90
+
+ server_version = "Calypso/%s" % VERSION
+
+ def address_string(self):
+ return str(self.client_address[0])
+
+ def send_connection_header(self):
+ conntype = "Close"
+ if self.close_connection == 0:
+ conntype = "Keep-Alive"
+ self.send_header("Connection", conntype)
+
+ def send_calypso_response(self, response, length):
+ self.send_response(response)
+ self.send_connection_header()
+ self.send_header("Content-Length", str(length))
+ for header, value in config.items('headers'):
+ self.send_header(header, value)
+
+ def handle_one_request(self):
+ """Handle a single HTTP request.
+
+ You normally don't need to override this method; see the class
+ __doc__ string for information on how to handle specific HTTP
+ commands such as GET and POST.
+
+ """
+ try:
+ self.close_connection = 1
+ self.wfile.flush()
+
+ self.connection.settimeout(5)
+
+ self.raw_requestline = self.rfile.readline(65537)
+
+ self.connection.settimeout(90)
+
+ if len(self.raw_requestline) > 65536:
+ log.error("Read request too long")
+ self.requestline = ''
+ self.request_version = ''
+ self.command = ''
+ self.send_error(414)
+ return
+ if not self.raw_requestline:
+ log.error("Connection closed")
+ return
+ log.debug("First line '%s'", self.raw_requestline)
+ if not self.parse_request():
+ # An error code has been sent, just exit
+ self.close_connection = 1
+ return
+ # parse_request clears close_connection on all http/1.1 links
+ # it should only do this if a keep-alive header is seen
+ self.close_connection = 1
+ conntype = self.headers.get('Connection', "")
+ if (conntype.lower() == 'keep-alive'
+ and self.protocol_version >= "HTTP/1.1"):
+ log.debug("keep-alive")
+ self.close_connection = 0
+ reqlen = self.headers.get('Content-Length', "0")
+ log.debug("reqlen %s", reqlen)
+ self.xml_request = self.rfile.read(int(reqlen))
+ environ = {
+ 'REQUEST_METHOD': self.command,
+ 'CONTENT_LENGTH': reqlen,
+ 'PATH_INFO': self.path,
+ 'wsgi.input': StringIO(self.xml_request)
+ }
+ for name, value in self.headers.items():
+ environ['HTTP_' + name.replace('-', '_').upper()] = value
+ def start_response(status, headers):
+ (code, message) = status.split(' ', 1)
+ self.send_response(int(code), message)
+ self.send_connection_header()
+ for header, value in headers:
+ self.send_header(header, value)
+ self.end_headers()
+ lines = self.app(environ, start_response)
+ if lines is not None:
+ self.wfile.writelines(lines)
+ self.wfile.flush() #actually send the response if not already done.
+ except socket.timeout as e:
+ #a read or a write timed out. Discard this connection
+ log.error("Request timed out: %r", e)
+ self.close_connection = 1
+ return
+ except ssl.SSLError, x:
+ #an io error. Discard this connection
+ log.error("SSL request error: %r", x.args[0])
+ self.close_connection = 1
+ return