# Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij , et al. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 3 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Abstract WebDAV server implementation.. This module contains an abstract WebDAV server. All caldav/carddav specific functionality should live in xandikos.caldav/xandikos.carddav respectively. """ # TODO(jelmer): Add authorization support import collections import fnmatch import functools import logging import posixpath import urllib.parse from wsgiref.util import request_uri from defusedxml.ElementTree import fromstring as xmlparse # Hmm, defusedxml doesn't have XML generation functions? :( from xml.etree import ElementTree as ET DEFAULT_ENCODING = 'utf-8' COLLECTION_RESOURCE_TYPE = '{DAV:}collection' PRINCIPAL_RESOURCE_TYPE = '{DAV:}principal' PropStatus = collections.namedtuple( 'PropStatus', ['statuscode', 'responsedescription', 'prop']) class BadRequestError(Exception): """Base class for bad request errors.""" def __init__(self, message): super(Exception, self).__init__(message) self.message = message class NotAcceptableError(Exception): """Base class for not acceptable errors.""" def __init__(self, available_content_types, acceptable_content_types): super(Exception, self).__init__( "Unable to convert from content types %r to one of %r" % ( available_content_types, acceptable_content_types)) self.available_content_types = available_content_types self.acceptable_content_types = acceptable_content_types class UnsupportedMediaType(Exception): """Base class for unsupported media type errors.""" def __init__(self, content_type): super(Exception, self).__init__( "Unsupported media type: %r" % (content_type, )) self.content_type = content_type class UnauthorizedError(Exception): """Base class for unauthorized errors.""" def __init__(self): super(Exception, self).__init__( "Request unauthorized") def pick_content_types(accepted_content_types, available_content_types): """Pick best content types for a client. :param accepted_content_types: Accept variable (as name, params tuples) :raise NotAcceptableError: If there are no overlapping content types """ available_content_types = set(available_content_types) acceptable_by_q = {} for ct, params in accepted_content_types: acceptable_by_q.setdefault(float(params.get('q', '1')), []).append(ct) if 0 in acceptable_by_q: # Items with q=0 are not acceptable for pat in acceptable_by_q[0]: available_content_types -= set(fnmatch.filter( available_content_types, pat)) del acceptable_by_q[0] for q, pats in sorted(acceptable_by_q.items(), reverse=True): ret = [] for pat in pats: ret.extend(fnmatch.filter(available_content_types, pat)) if ret: return ret raise NotAcceptableError( available_content_types, accepted_content_types) def parse_type(content_type): """Parse a content-type style header. :param content_type: type to parse :return: Tuple with base name and dict with params """ params = {} try: (ct, rest) = content_type.split(';', 1) except ValueError: ct = content_type else: for param in rest.split(';'): (key, val) = param.split('=') params[key.strip()] = val.strip() return (ct, params) def parse_accept_header(accept): """Parse a HTTP Accept or Accept-Language header. :param accept: Accept header contents :return: List of (content_type, params) tuples """ ret = [] for part in accept.split(','): part = part.strip() if not part: continue ret.append(parse_type(part)) return ret class PreconditionFailure(Exception): """A precondition failed.""" def __init__(self, precondition, description): self.precondition = precondition self.description = description def etag_matches(condition, actual_etag): """Check if an etag matches an If-Matches condition. :param condition: Condition (e.g. '*', '"foo"' or '"foo", "bar"' :param actual_etag: ETag to compare to. None nonexistant :return: bool indicating whether condition matches """ if actual_etag is None and condition: return False for etag in condition.split(','): if etag.strip(' ') == '*': return True if etag.strip(' ') == actual_etag: return True else: return False class NeedsMultiStatus(Exception): """Raised when a response needs multi-status (e.g. for propstat).""" def propstat_by_status(propstat): """Sort a list of propstatus objects by HTTP status. :param propstat: List of PropStatus objects: :return: dictionary mapping HTTP status code to list of PropStatus objects """ bystatus = {} for propstat in propstat: (bystatus .setdefault((propstat.statuscode, propstat.responsedescription), []) .append(propstat.prop)) return bystatus def propstat_as_xml(propstat): """Format a list of propstats as XML elements. :param propstat: List of PropStatus objects :return: Iterator over {DAV:}propstat elements """ bystatus = propstat_by_status(propstat) for (status, rd), props in sorted(bystatus.items()): propstat = ET.Element('{DAV:}propstat') ET.SubElement(propstat, '{DAV:}status').text = 'HTTP/1.1 ' + status if rd: ET.SubElement(propstat, '{DAV:}responsedescription').text = rd propresp = ET.SubElement(propstat, '{DAV:}prop') for prop in props: propresp.append(prop) yield propstat def path_from_environ(environ, name): """Return a path from an environ dict. Will re-decode using a different encoding as necessary. """ # Re-decode using DEFAULT_ENCODING. PEP-3333 says that # everything will be decoded using iso-8859-1. # See also https://bugs.python.org/issue16679 path = environ[name].encode('iso-8859-1').decode(DEFAULT_ENCODING) return posixpath.normpath(path) class Status(object): """A DAV response that can be used in multi-status.""" def __init__(self, href, status=None, error=None, responsedescription=None, propstat=None): self.href = href self.status = status self.error = error self.propstat = propstat self.responsedescription = responsedescription def __repr__(self): return "<%s(%r, %r, %r)>" % ( type(self).__name__, self.href, self.status, self.responsedescription ) def get_single_body(self, encoding): if self.propstat and len(propstat_by_status(self.propstat)) > 1: raise NeedsMultiStatus() if self.error is not None: raise NeedsMultiStatus() if self.propstat: [ret] = list(propstat_as_xml(self.propstat)) body = ET.tostringlist(ret, encoding) return body, ('text/xml; encoding="%s"' % encoding) else: body = ( [self.responsedescription.encode(encoding)] if self.responsedescription else []) return body, ('text/plain; encoding="%s"' % encoding) def aselement(self): ret = ET.Element('{DAV:}response') ret.append(create_href(self.href)) if self.propstat: for ps in propstat_as_xml(self.propstat): ret.append(ps) elif self.status: ET.SubElement(ret, '{DAV:}status').text = 'HTTP/1.1 ' + self.status # Note the check for "is not None" here. Elements without children # evaluate to False. if self.error is not None: ET.SubElement(ret, '{DAV:}error').append(self.error) if self.responsedescription: ET.SubElement(ret, '{DAV:}responsedescription').text = ( self.responsedescription) return ret def multistatus(req_fn): def wrapper(self, environ, start_response, *args, **kwargs): responses = req_fn(self, environ, *args, **kwargs) return _send_dav_responses(start_response, responses, DEFAULT_ENCODING) return wrapper class Property(object): """Handler for listing, retrieving and updating DAV Properties.""" # Property name (e.g. '{DAV:}resourcetype') name = None # Whether to include this property in 'allprop' PROPFIND requests. # https://tools.ietf.org/html/rfc4918, section 14.2 in_allprops = True # Resource type this property belongs to. If None, get_value() # will always be called. resource_type = None # Whether this property is live (i.e set by the server) live = None def supported_on(self, resource): return (self.resource_type is None or self.resource_type in resource.resource_types) def is_set(self, href, resource): """Check if this property is set on a resource.""" if not self.supported_on(resource): return False try: self.get_value('/', resource, ET.Element(self.name)) except KeyError: return False else: return True def get_value(self, href, resource, el): """Get property with specified name. :param href: Resource href :param resource: Resource for which to retrieve the property :param el: Element to populate :raise KeyError: if this property is not present """ raise KeyError(self.name) def set_value(self, href, resource, el): """Set property. :param href: Resource href :param resource: Resource to modify :param el: Element to get new value from (None to remove property) :raise NotImplementedError: to indicate this property can not be set (i.e. is protected) """ raise NotImplementedError(self.set_value) class ResourceTypeProperty(Property): """Provides {DAV:}resourcetype.""" name = '{DAV:}resourcetype' resource_type = None live = True def get_value(self, href, resource, el): for rt in resource.resource_types: ET.SubElement(el, rt) def set_value(self, href, resource, el): resource.set_resource_types([e.tag for e in el]) class DisplayNameProperty(Property): """Provides {DAV:}displayname. https://tools.ietf.org/html/rfc4918, section 5.2 """ name = '{DAV:}displayname' resource_type = None def get_value(self, href, resource, el): el.text = resource.get_displayname() def set_value(self, href, resource, el): resource.set_displayname(el.text) class GetETagProperty(Property): """Provides {DAV:}getetag. https://tools.ietf.org/html/rfc4918, section 15.6 """ name = '{DAV:}getetag' resource_type = None live = True def get_value(self, href, resource, el): el.text = resource.get_etag() class AddMemberProperty(Property): """Provides {DAV:}add-member. https://tools.ietf.org/html/rfc5995, section 3.2.1 """ name = '{DAV:}add-member' resource_type = COLLECTION_RESOURCE_TYPE live = True def get_value(self, href, resource, el): # Support POST against collection URL el.append(create_href('.', href)) class GetLastModifiedProperty(Property): """Provides {DAV:}getlastmodified. https://tools.ietf.org/html/rfc4918, section 15.7 """ name = '{DAV:}getlastmodified' resource_type = None live = True in_allprops = True def get_value(self, href, resource, el): # Use rfc1123 date (section 3.3.1 of RFC2616) el.text = resource.get_last_modified().strftime( '%a, %d %b %Y %H:%M:%S GMT') def format_datetime(dt): s = "%04d%02d%02dT%02d%02d%02dZ" % ( dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second ) return s.encode('utf-8') class CreationDateProperty(Property): """Provides {DAV:}creationdate. https://tools.ietf.org/html/rfc4918, section 23.2 """ name = '{DAV:}creationdate' resource_type = None live = True def get_value(self, href, resource, el): el.text = format_datetime(resource.get_creationdate()) class GetContentLanguageProperty(Property): """Provides {DAV:}getcontentlanguage. https://tools.ietf.org/html/rfc4918, section 15.3 """ name = '{DAV:}getcontentlanguage' resource_type = None def get_value(self, href, resource, el): el.text = ', '.join(resource.get_content_language()) class GetContentLengthProperty(Property): """Provides {DAV:}getcontentlength. https://tools.ietf.org/html/rfc4918, section 15.4 """ name = '{DAV:}getcontentlength' resource_type = None def get_value(self, href, resource, el): el.text = str(resource.get_content_length()) class GetContentTypeProperty(Property): """Provides {DAV:}getcontenttype. https://tools.ietf.org/html/rfc4918, section 13.5 """ name = '{DAV:}getcontenttype' resource_type = None def get_value(self, href, resource, el): el.text = resource.get_content_type() class CurrentUserPrincipalProperty(Property): """Provides {DAV:}current-user-principal. See https://tools.ietf.org/html/rfc5397 """ name = '{DAV:}current-user-principal' resource_type = None in_allprops = False live = True def __init__(self, current_user_principal): super(CurrentUserPrincipalProperty, self).__init__() self.current_user_principal = ensure_trailing_slash( current_user_principal) def get_value(self, href, resource, el): """Get property with specified name. :param name: A property name. """ if self.current_user_principal is None: ET.SubElement(el, '{DAV:}unauthenticated') else: el.append(create_href(self.current_user_principal, href)) class PrincipalURLProperty(Property): name = '{DAV:}principal-URL' resource_type = '{DAV:}principal' in_allprops = True live = True def get_value(self, href, resource, el): """Get property with specified name. :param name: A property name. """ el.append(create_href( ensure_trailing_slash(resource.get_principal_url()), href)) class SupportedReportSetProperty(Property): name = '{DAV:}supported-report-set' resource_type = '{DAV:}collection' in_allprops = False live = True def __init__(self, reporters): self._reporters = reporters def get_value(self, href, resource, el): for name, reporter in self._reporters.items(): if reporter.supported_on(resource): bel = ET.SubElement(el, '{DAV:}supported-report') ET.SubElement(bel, name) class GetCTagProperty(Property): """getctag property """ name = None resource_type = COLLECTION_RESOURCE_TYPE in_allprops = False live = True def get_value(self, href, resource, el): el.text = resource.get_ctag() class DAVGetCTagProperty(GetCTagProperty): """getctag property """ name = '{DAV:}getctag' class AppleGetCTagProperty(GetCTagProperty): """getctag property """ name = '{http://calendarserver.org/ns/}getctag' LOCK_SCOPE_EXCLUSIVE = '{DAV:}exclusive' LOCK_SCOPE_SHARED = '{DAV:}shared' LOCK_TYPE_WRITE = '{DAV:}write' ActiveLock = collections.namedtuple( 'ActiveLock', ['lockscope', 'locktype', 'depth', 'owner', 'timeout', 'locktoken', 'lockroot']) class Resource(object): """A WebDAV resource.""" # A list of resource type names (e.g. '{DAV:}collection') resource_types = [] # TODO(jelmer): Be consistent in using get/set functions vs properties. def set_resource_types(self, resource_types): """Set the resource types.""" raise NotImplementedError(self.set_resource_types) def get_displayname(self): """Get the resource display name.""" raise KeyError def set_displayname(self, displayname): """Set the resource display name.""" raise NotImplementedError(self.set_displayname) def get_creationdate(self): """Get the resource creation date. :return: A datetime object """ raise NotImplementedError(self.get_creationdate) def get_supported_locks(self): """Get the list of supported locks. This should return a list of (lockscope, locktype) tuples. Known lockscopes are LOCK_SCOPE_EXCLUSIVE, LOCK_SCOPE_SHARED Known locktypes are LOCK_TYPE_WRITE """ raise NotImplementedError(self.get_supported_locks) def get_active_locks(self): """Return the list of active locks. :return: A list of ActiveLock tuples """ raise NotImplementedError(self.get_active_locks) def get_content_type(self): """Get the content type for the resource. This is a mime type like text/plain """ raise NotImplementedError(self.get_content_type) def get_owner(self): """Get an href identifying the owner of the resource. Can be None if owner information is not known. """ raise NotImplementedError(self.get_owner) def get_etag(self): """Get the etag for this resource. Contains the ETag header value (from Section 14.19 of [RFC2616]) as it would be returned by a GET without accept headers. """ raise NotImplementedError(self.get_etag) def get_body(self): """Get resource contents. :return: Iterable over bytestrings.""" raise NotImplementedError(self.get_body) def render(self, accepted_content_types, accepted_languages): """'Render' this resource in the specified content type. The default implementation just checks that the resource' content type is acceptable and if so returns (get_body(), get_content_type(), get_content_language()). :param accepted_content_types: List of accepted content types :param accepted_languages: List of accepted languages :raise NotAcceptableError: if there is no acceptable content type :return: Tuple with (content_body, content_length, etag, content_type, content_language) """ # TODO(jelmer): Check content_language content_types = pick_content_types( accepted_content_types, [self.get_content_type()]) assert content_types == [self.get_content_type()] body = self.get_body() try: content_language = self.get_content_language() except KeyError: content_language = None return (body, sum(map(len, body)), self.get_etag(), self.get_content_type(), content_language) def get_content_length(self): """Get content length. :return: Length of this objects content. """ return sum(map(len, self.get_body())) def get_content_language(self): """Get content language. :return: Language, as used in HTTP Accept-Language """ raise NotImplementedError(self.get_content_language) def set_body(self, body, replace_etag=None): """Set resource contents. :param body: Iterable over bytestrings :return: New ETag """ raise NotImplementedError(self.set_body) def set_comment(self, comment): """Set resource comment. :param comment: New comment """ raise NotImplementedError(self.set_comment) def get_comment(self, comment): """Get resource comment. :return: comment """ raise NotImplementedError(self.get_comment) def get_last_modified(self): """Get last modified time. :return: Last modified time """ raise NotImplementedError(self.get_last_modified) def get_is_executable(self): """Get executable bit. :return: Boolean indicating executability """ raise NotImplementedError(self.get_is_executable) def set_is_executable(self, executable): """Set executable bit. :param executable: Boolean indicating executability """ raise NotImplementedError(self.set_is_executable) def get_quota_used_bytes(self): """Return bytes consumed by this resource. If unknown, this can raise KeyError. :return: an integer """ raise NotImplementedError(self.get_quota_used_bytes) def get_quota_available_bytes(self): """Return quota available as bytes. This can raise KeyError if there is infinite quota available. """ raise NotImplementedError(self.get_quota_available_bytes) class Collection(Resource): """Resource for a WebDAV Collection.""" resource_types = Resource.resource_types + [COLLECTION_RESOURCE_TYPE] def members(self): """List all members. :return: List of (name, Resource) tuples """ raise NotImplementedError(self.members) def get_member(self, name): """Retrieve a member by name. :param name: Name of member to retrieve :return: A Resource """ raise NotImplementedError(self.get_member) def delete_member(self, name, etag=None): """Delete a member with a specific name. :param name: Member name :param etag: Optional required etag :raise KeyError: when the item doesn't exist """ raise NotImplementedError(self.delete_member) def create_member(self, name, contents, content_type): """Create a new member with specified name and contents. :param name: Member name (can be None) :param contents: Chunked contents :param etag: Optional required etag :return: (name, etag) for the new member """ raise NotImplementedError(self.create_member) def get_sync_token(self): """Get sync-token for the current state of this collection. """ raise NotImplementedError(self.get_sync_token) def iter_differences_since(self, old_token, new_token): """Iterate over differences in this collection. Should return an iterator over (name, old resource, new resource) tuples. If one of the two didn't exist previously or now, they should be None. If old_token is None, this should return full contents of the collection. May raise NotImplementedError if iterating differences is not supported. """ raise NotImplementedError(self.iter_differences_since) def get_ctag(self): raise NotImplementedError(self.getctag) def get_headervalue(self): raise NotImplementedError(self.get_headervalue) def destroy(self): """Destroy this collection itself. """ raise NotImplementedError(self.destroy) class Principal(Resource): """Resource for a DAV Principal.""" resource_Types = Resource.resource_types + [PRINCIPAL_RESOURCE_TYPE] def get_principal_url(self): """Return the principal URL for this principal. :return: A URL identifying this principal. """ raise NotImplementedError(self.get_principal_url) def get_infit_settings(self): """Return inf-it settings string. """ raise NotImplementedError(self.get_infit_settings) def set_infit_settings(self, settings): """Set inf-it settings string.""" raise NotImplementedError(self.get_infit_settings) def get_group_membership(self): """Get group membership URLs.""" raise NotImplementedError(self.get_group_membership) def get_calendar_proxy_read_for(self): """List principals for which this one is a read proxy. :return: List of principal hrefs """ raise NotImplementedError(self.get_calendar_proxy_read_for) def get_calendar_proxy_write_for(self): """List principals for which this one is a write proxy. :return: List of principal hrefs """ raise NotImplementedError(self.get_calendar_proxy_write_for) def get_property_from_name(href, resource, properties, name): """Get a single property on a resource. :param href: Resource href :param resource: Resource object :param properties: Dictionary of properties :param name: name of property to resolve :return: PropStatus items """ return get_property_from_element( href, resource, properties, ET.Element(name)) def get_property_from_element(href, resource, properties, requested): """Get a single property on a resource. :param href: Resource href :param resource: Resource object :param properties: Dictionary of properties :param requested: Requested element :return: PropStatus items """ responsedescription = None ret = ET.Element(requested.tag) try: prop = properties[requested.tag] except KeyError: statuscode = '404 Not Found' logging.warning( 'Client requested unknown property %s', requested.tag) else: try: if not prop.supported_on(resource): raise KeyError try: get_value_ext = prop.get_value_ext except AttributeError: prop.get_value(href, resource, ret) else: get_value_ext(href, resource, ret, requested) except KeyError: statuscode = '404 Not Found' else: statuscode = '200 OK' return PropStatus(statuscode, responsedescription, ret) def get_properties(href, resource, properties, requested): """Get a set of properties. :param href: Resource Href :param resource: Resource object :param properties: Dictionary of properties :param requested: XML {DAV:}prop element with properties to look up :return: Iterator over PropStatus items """ for propreq in list(requested): yield get_property_from_element(href, resource, properties, propreq) def get_property_names(href, resource, properties, requested): """Get a set of property names. :param href: Resource Href :param resource: Resource object :param properties: Dictionary of properties :param requested: XML {DAV:}prop element with properties to look up :return: Iterator over PropStatus items """ for name, prop in properties.items(): if prop.is_set(href, resource): yield PropStatus('200 OK', None, ET.Element(name)) def get_all_properties(href, resource, properties): """Get all properties. :param href: Resource Href :param resource: Resource object :param properties: Dictionary of properties :param requested: XML {DAV:}prop element with properties to look up :return: Iterator over PropStatus items """ for name in properties: ps = get_property_from_name(href, resource, properties, name) if ps.statuscode == '200 OK': yield ps def ensure_trailing_slash(href): """Ensure that a href has a trailing slash. Useful for collection hrefs, e.g. when used with urljoin. :param href: href to possibly add slash to :return: href with trailing slash """ if href.endswith('/'): return href return href + '/' def traverse_resource(base_resource, base_href, depth): """Traverse a resource. :param base_resource: Resource to traverse from :param base_href: href for base resource :param depth: Depth ("0", "1", "infinity") :return: Iterator over (URL, Resource) tuples """ todo = collections.deque([(base_href, base_resource, depth)]) while todo: (href, resource, depth) = todo.popleft() if COLLECTION_RESOURCE_TYPE in resource.resource_types: # caldavzap/carddavmate require this href = ensure_trailing_slash(href) yield (href, resource) if depth == "0": continue elif depth == "1": nextdepth = "0" elif depth == "infinity": nextdepth = "infinity" else: raise AssertionError("invalid depth %r" % depth) if COLLECTION_RESOURCE_TYPE in resource.resource_types: for (child_name, child_resource) in resource.members(): child_href = urllib.parse.urljoin(href, child_name) todo.append((child_href, child_resource, nextdepth)) class Reporter(object): """Implementation for DAV REPORT requests.""" name = None resource_type = None def supported_on(self, resource): """Check if this reporter is available for the specified resource. :param resource: Resource to check for :return: boolean indicating whether this reporter is available """ return (self.resource_type is None or self.resource_type in resource.resource_types) def report(self, environ, start_response, request_body, resources_by_hrefs, properties, href, resource, depth): """Send a report. :param environ: wsgi environ :param start_response: WSGI start_response function :param request_body: XML Element for request body :param resources_by_hrefs: Function for retrieving resource by HREF :param properties: Dictionary mapping names to DAVProperty instances :param href: Base resource href :param resource: Resource to start from :param depth: Depth ("0", "1", ...) :return: chunked body """ raise NotImplementedError(self.report) def create_href(href, base_href=None): if '//' in href: logging.warning('invalidly formatted href: %s' % href) et = ET.Element('{DAV:}href') if base_href is not None: href = urllib.parse.urljoin(ensure_trailing_slash(base_href), href) et.text = urllib.parse.quote(href) return et def read_href_element(et): return urllib.parse.unquote(et.text) class ExpandPropertyReporter(Reporter): """A expand-property reporter. See https://tools.ietf.org/html/rfc3253, section 3.8 """ name = '{DAV:}expand-property' def _populate(self, prop_list, resources_by_hrefs, properties, href, resource): """Expand properties for a resource. :param prop_list: DAV:property elements to retrieve and expand :param resources_by_hrefs: Resolve resource by HREF :param properties: Available properties :param href: href for current resource :param resource: current resource :return: Status object """ ret = [] for prop in prop_list: prop_name = prop.get('name') # FIXME: Resolve prop_name on resource propstat = get_property_from_name( href, resource, properties, prop_name) new_prop = ET.Element(propstat.prop.tag) child_hrefs = [ read_href_element(prop_child) for prop_child in propstat.prop if prop_child.tag == '{DAV:}href'] child_resources = resources_by_hrefs(child_hrefs) for prop_child in propstat.prop: if prop_child.tag != '{DAV:}href': new_prop.append(prop_child) else: child_href = read_href_element(prop_child) child_resource = child_resources[child_href] if child_resource is None: # FIXME: What to do if the referenced href is invalid? # For now, let's just keep the unresolved href around new_prop.append(prop_child) else: response = self._populate( prop, properties, child_href, child_resource) new_prop.append(response.aselement()) propstat = PropStatus(propstat.statuscode, propstat.responsedescription, prop=new_prop) ret.append(propstat) return Status(href, '200 OK', propstat=ret) @multistatus def report(self, environ, request_body, resources_by_hrefs, properties, href, resource, depth): return self._populate(request_body, resources_by_hrefs, properties, href, resource) class SupportedLockProperty(Property): """supportedlock property. See rfc4918, section 15.10. """ name = '{DAV:}supportedlock' resource_type = None live = True def get_value(self, href, resource, el): for (lockscope, locktype) in resource.get_supported_locks(): entry = ET.SubElement(el, '{DAV:}lockentry') scope_el = ET.SubElement(entry, '{DAV:}lockscope') ET.SubElement(scope_el, lockscope) type_el = ET.SubElement(entry, '{DAV:}locktype') ET.SubElement(type_el, locktype) class LockDiscoveryProperty(Property): """lockdiscovery property. See rfc4918, section 15.8 """ name = '{DAV:}lockdiscovery' resource_type = None live = True def get_value(self, href, resource, el): for activelock in resource.get_active_locks(): entry = ET.SubElement(el, '{DAV:}activelock') type_el = ET.SubElement(entry, '{DAV:}locktype') ET.SubElement(type_el, activelock.locktype) scope_el = ET.SubElement(entry, '{DAV:}lockscope') ET.SubElement(scope_el, activelock.lockscope) ET.SubElement(entry, '{DAV:}depth').text = str(activelock.depth) if activelock.owner: ET.SubElement(entry, '{DAV:}owner').text = activelock.owner if activelock.timeout: ET.SubElement(entry, '{DAV:}timeout').text = activelock.timeout if activelock.locktoken: locktoken_el = ET.SubElement(entry, '{DAV:}locktoken') locktoken_el.append(create_href(activelock.locktoken)) if activelock.lockroot: lockroot_el = ET.SubElement(entry, '{DAV:}lockroot') lockroot_el.append(create_href(activelock.lockroot)) class CommentProperty(Property): """comment property. See RFC3253, section 3.1.1 """ name = '{DAV:}comment' live = False in_allprops = False def get_value(self, href, resource, el): el.text = resource.get_comment() def set_value(self, href, resource, el): resource.set_comment(el.text) class Backend(object): """WebDAV backend.""" def create_collection(self, relpath): """Create a collection with the specified relpath. :param relpath: Collection path """ raise NotImplementedError(self.create_collection) def get_resoure(self, relpath): raise NotImplementedError(self.get_resource) def _get_resources_by_hrefs(backend, environ, hrefs): """Retrieve multiple resources by href. :param backend: backend from which to retrieve resources :param environ: Environment dictionary :param hrefs: List of hrefs to resolve :return: iterator over (href, resource) tuples """ script_name = environ['SCRIPT_NAME'] # TODO(jelmer): Bulk query hrefs in a more efficient manner for href in hrefs: if not href.startswith(script_name): resource = None else: resource = backend.get_resource(href[len(script_name):]) yield (href, resource) def _send_xml_response(start_response, status, et, out_encoding): body_type = 'text/xml; charset="%s"' % out_encoding body = ET.tostringlist(et, encoding=out_encoding) start_response(status, [ ('Content-Type', body_type), ('Content-Length', str(sum(map(len, body))))]) return body def _send_dav_responses(start_response, responses, out_encoding): if isinstance(responses, Status): try: (body, body_type) = responses.get_single_body( out_encoding) except NeedsMultiStatus: responses = [responses] else: start_response(responses.status, [ ('Content-Type', body_type), ('Content-Length', str(sum(map(len, body))))]) return body ret = ET.Element('{DAV:}multistatus') for response in responses: ret.append(response.aselement()) return _send_xml_response(start_response, '207 Multi-Status', ret, out_encoding) def _send_simple_dav_error(environ, start_response, statuscode, error, description): status = Status(request_uri(environ), statuscode, error=error, responsedescription=description) return _send_dav_responses(start_response, status, DEFAULT_ENCODING) def _send_not_found(environ, start_response): path = request_uri(environ) start_response('404 Not Found', []) return [b'Path ' + path.encode(DEFAULT_ENCODING) + b' not found.'] def _send_method_not_allowed(environ, start_response, allowed_methods): start_response('405 Method Not Allowed', [ ('Allow', ', '.join(allowed_methods))]) return [] def apply_modify_prop(el, href, resource, properties): """Apply property set/remove operations. :param el: set element to apply. :param href: Resource href :param resource: Resource to apply property modifications on :param properties: Known properties :yield: PropStatus objects """ if el.tag not in ('{DAV:}set', '{DAV:}remove'): # callers should check tag raise AssertionError try: [requested] = el except IndexError: raise BadRequestError( 'Received more than one element in {DAV:}set element.') if requested.tag != '{DAV:}prop': raise BadRequestError('Expected prop tag, got ' + requested.tag) for propel in requested: try: handler = properties[propel.tag] except KeyError: logging.warning( 'client attempted to modify unknown property %r on %r', propel.tag, href) yield PropStatus('404 Not Found', None, ET.Element(propel.tag)) else: if el.tag == '{DAV:}remove': newval = None elif el.tag == '{DAV:}set': newval = propel else: raise AssertionError if not handler.supported_on(resource): statuscode = '404 Not Found' else: try: handler.set_value(href, resource, newval) except NotImplementedError: # TODO(jelmer): Signal # {DAV:}cannot-modify-protected-property error statuscode = '409 Conflict' else: statuscode = '200 OK' yield PropStatus(statuscode, None, ET.Element(propel.tag)) def _readBody(environ): try: request_body_size = int(environ['CONTENT_LENGTH']) except KeyError: return [environ['wsgi.input'].read()] else: return [environ['wsgi.input'].read(request_body_size)] def _readXmlBody(environ, expected_tag=None): try: content_type = environ['CONTENT_TYPE'] except KeyError: pass # Just assume it's okay? else: base_content_type, params = parse_type(content_type) if base_content_type not in ('text/xml', 'application/xml'): raise UnsupportedMediaType(content_type) body = b''.join(_readBody(environ)) try: et = xmlparse(body) except ET.ParseError: raise BadRequestError('Unable to parse body.') if expected_tag is not None and et.tag != expected_tag: raise BadRequestError('Expected %s tag, got %s' % (expected_tag, et.tag)) return et class Method(object): @property def name(self): return type(self).__name__.upper()[:-6] def handle(self, environ, start_response, app): raise NotImplementedError(self.handle) def allow(self, environ): """Is this method allowed considering the specified environ?""" return True class DeleteMethod(Method): def handle(self, environ, start_response, app): unused_href, path, r = app._get_resource_from_environ(environ) if r is None: return _send_not_found(environ, start_response) container_path, item_name = posixpath.split(path) pr = app.backend.get_resource(container_path) if pr is None: return _send_not_found(environ, start_response) current_etag = r.get_etag() if_match = environ.get('HTTP_IF_MATCH', None) if if_match is not None and not etag_matches(if_match, current_etag): start_response('412 Precondition Failed', []) return [] pr.delete_member(item_name, current_etag) start_response('204 No Content', []) return [] class PostMethod(Method): def handle(self, environ, start_response, app): # see RFC5995 new_contents = _readBody(environ) unused_href, path, r = app._get_resource_from_environ(environ) if r is None: return _send_not_found(environ, start_response) if COLLECTION_RESOURCE_TYPE not in r.resource_types: return _send_method_not_allowed( environ, start_response, app._get_allowed_methods(environ)) content_type = environ['CONTENT_TYPE'].split(';')[0] try: (name, etag) = r.create_member(None, new_contents, content_type) except PreconditionFailure as e: return _send_simple_dav_error( environ, start_response, '412 Precondition Failed', error=ET.Element(e.precondition), description=e.description) href = ( environ['SCRIPT_NAME'] + urllib.parse.urljoin(ensure_trailing_slash(path), name) ) start_response('200 OK', [('Location', href)]) return [] class PutMethod(Method): def handle(self, environ, start_response, app): new_contents = _readBody(environ) unused_href, path, r = app._get_resource_from_environ(environ) if r is not None: current_etag = r.get_etag() else: current_etag = None if_match = environ.get('HTTP_IF_MATCH', None) if if_match is not None and not etag_matches(if_match, current_etag): start_response('412 Precondition Failed', []) return [] if_none_match = environ.get('HTTP_IF_NONE_MATCH', None) if if_none_match and etag_matches(if_none_match, current_etag): start_response('412 Precondition Failed', []) return [] if r is not None: # Item already exists; update it try: new_etag = r.set_body(new_contents, current_etag) except PreconditionFailure as e: return _send_simple_dav_error( environ, start_response, '412 Precondition Failed', error=ET.Element(e.precondition), description=e.description) except NotImplementedError: return _send_method_not_allowed( environ, start_response, app._get_allowed_methods(environ)) else: start_response('204 No Content', [ ('ETag', new_etag)]) return [] content_type = environ.get('CONTENT_TYPE') container_path, name = posixpath.split(path) r = app.backend.get_resource(container_path) if r is None: return _send_not_found(environ, start_response) if COLLECTION_RESOURCE_TYPE not in r.resource_types: return _send_method_not_allowed( environ, start_response, app._get_allowed_methods(environ)) try: (new_name, new_etag) = r.create_member( name, new_contents, content_type) except PreconditionFailure as e: return _send_simple_dav_error( environ, start_response, '412 Precondition Failed', error=ET.Element(e.precondition), description=e.description) start_response('201 Created', [ ('ETag', new_etag)]) return [] class ReportMethod(Method): def handle(self, environ, start_response, app): # See https://tools.ietf.org/html/rfc3253, section 3.6 base_href, unused_path, r = app._get_resource_from_environ(environ) if r is None: return _send_not_found(environ, start_response) depth = environ.get("HTTP_DEPTH", "0") et = _readXmlBody(environ, None) try: reporter = app.reporters[et.tag] except KeyError: logging.warning('Client requested unknown REPORT %s', et.tag) return _send_simple_dav_error( environ, start_response, '403 Forbidden', error=ET.Element('{DAV:}supported-report'), description=('Unknown report %s.' % et.tag) ) if not reporter.supported_on(r): return _send_simple_dav_error( environ, start_response, '403 Forbidden', error=ET.Element('{DAV:}supported-report'), description=('Report %s not supported on resource.' % et.tag) ) return reporter.report( environ, start_response, et, functools.partial( _get_resources_by_hrefs, app.backend, environ), app.properties, base_href, r, depth) class PropfindMethod(Method): @multistatus def handle(self, environ, app): base_href, unused_path, base_resource = ( app._get_resource_from_environ(environ)) if base_resource is None: return Status(request_uri(environ), '404 Not Found') # Default depth is infinity, per RFC2518 depth = environ.get("HTTP_DEPTH", "infinity") if ( 'CONTENT_TYPE' not in environ and environ.get('CONTENT_LENGTH') == '0' ): requested = None else: et = _readXmlBody(environ, '{DAV:}propfind') try: [requested] = et except ValueError: raise BadRequestError( 'Received more than one element in propfind.') ret = [] for href, resource in traverse_resource( base_resource, base_href, depth): propstat = [] if requested is None or requested.tag == '{DAV:}allprop': propstat = get_all_properties( href, resource, app.properties) elif requested.tag == '{DAV:}prop': propstat = get_properties( href, resource, app.properties, requested) elif requested.tag == '{DAV:}propname': propstat = get_property_names( href, resource, app.properties, requested) else: raise BadRequestError( 'Expected prop/allprop/propname tag, got ' + requested.tag) ret.append(Status(href, '200 OK', propstat=list(propstat))) # By my reading of the WebDAV RFC, it should be legal to return # '200 OK' here if Depth=0, but the RFC is not super clear and # some clients don't seem to like it . return ret class ProppatchMethod(Method): @multistatus def handle(self, environ, app): href, unused_path, resource = app._get_resource_from_environ(environ) if resource is None: return Status(request_uri(environ), '404 Not Found') et = _readXmlBody(environ, '{DAV:}propertyupdate') propstat = [] for el in et: if el.tag not in ('{DAV:}set', '{DAV:}remove'): raise BadRequestError('Unknown tag %s in propertyupdate' % el.tag) propstat.extend(apply_modify_prop(el, href, resource, app.properties)) return [Status(request_uri(environ), propstat=propstat)] class MkcolMethod(Method): def handle(self, environ, start_response, app): try: content_type = environ['CONTENT_TYPE'] except KeyError: base_content_type = None else: base_content_type, params = parse_type(content_type) if base_content_type not in ( 'text/plain', 'text/xml', 'application/xml', None ): raise UnsupportedMediaType(base_content_type) href, path, resource = app._get_resource_from_environ(environ) if resource is not None: return _send_method_not_allowed( environ, start_response, app._get_allowed_methods(environ)) try: resource = app.backend.create_collection(path) except FileNotFoundError: start_response('409 Conflict', []) return [] if base_content_type in ('text/xml', 'application/xml'): # Extended MKCOL (RFC5689) et = _readXmlBody(environ, '{DAV:}mkcol') propstat = [] for el in et: if el.tag != '{DAV:}set': raise BadRequestError('Unknown tag %s in mkcol' % el.tag) propstat.extend(apply_modify_prop(el, href, resource, app.properties)) ret = ET.Element('{DAV:}mkcol-response') for propstat_el in propstat_as_xml(propstat): ret.append(propstat_el) return _send_xml_response(start_response, '201 Created', ret, DEFAULT_ENCODING) else: start_response('201 Created', []) return [] class OptionsMethod(Method): def handle(self, environ, start_response, app): headers = [] if environ['PATH_INFO'] != '*': unused_href, unused_path, r = ( app._get_resource_from_environ(environ)) if r is None: return _send_not_found(environ, start_response) dav_features = app._get_dav_features(r) headers.append(('DAV', ', '.join(dav_features))) allowed_methods = app._get_allowed_methods(environ) headers.append(('Allow', ', '.join(allowed_methods))) # RFC7231 requires that if there is no response body, # Content-Length: 0 must be sent. This implies that there is # content (albeit empty), and thus a 204 is not a valid reply. # Thunderbird also fails if a 204 is sent rather than a 200. start_response('200 OK', headers + [ ('Content-Length', '0')]) return [] class HeadMethod(Method): def handle(self, environ, start_response, app): return _do_get(environ, start_response, app, send_body=False) class GetMethod(Method): def handle(self, environ, start_response, app): return _do_get(environ, start_response, app, send_body=True) def _do_get(environ, start_response, app, send_body): unused_href, unused_path, r = app._get_resource_from_environ(environ) if r is None: return _send_not_found(environ, start_response) accept_content_types = parse_accept_header( environ.get('HTTP_ACCEPT', '*/*')) accept_content_languages = parse_accept_header( environ.get('HTTP_ACCEPT_LANGUAGES', '*')) ( body, content_length, current_etag, content_type, content_languages ) = r.render(accept_content_types, accept_content_languages) if_none_match = environ.get('HTTP_IF_NONE_MATCH', None) if ( if_none_match and current_etag is not None and etag_matches(if_none_match, current_etag) ): start_response('304 Not Modified', []) return [] headers = [ ('Content-Length', str(content_length)), ] if current_etag is not None: headers.append(('ETag', current_etag)) if content_type is not None: headers.append(('Content-Type', content_type)) try: last_modified = r.get_last_modified() except KeyError: pass else: headers.append(('Last-Modified', last_modified)) if content_languages is not None: headers.append(('Content-Language', ', '.join(content_languages))) start_response('200 OK', headers) if send_body: return body else: return [] class WebDAVApp(object): """A wsgi App that provides a WebDAV server. A concrete implementation should provide an implementation of the lookup_resource function that can map a path to a Resource object (returning None for nonexistant objects). """ def __init__(self, backend): self.backend = backend self.properties = {} self.reporters = {} self.methods = {} self.register_methods([ DeleteMethod(), PostMethod(), PutMethod(), ReportMethod(), PropfindMethod(), ProppatchMethod(), MkcolMethod(), OptionsMethod(), GetMethod(), HeadMethod(), ]) def _get_resource_from_environ(self, environ): path = path_from_environ(environ, 'PATH_INFO') href = (environ['SCRIPT_NAME'] + path) r = self.backend.get_resource(path) return (href, path, r) def register_properties(self, properties): for p in properties: self.properties[p.name] = p def register_reporters(self, reporters): for r in reporters: self.reporters[r.name] = r def register_methods(self, methods): for m in methods: self.methods[m.name] = m def _get_dav_features(self, resource): # TODO(jelmer): Support access-control return ['1', '2', '3', 'calendar-access', 'addressbook', 'extended-mkcol'] def _get_allowed_methods(self, environ): """List of supported methods on this endpoint.""" ret = [] for name in sorted(self.methods.keys()): if self.methods[name].allow(environ): ret.append(name) return ret def __call__(self, environ, start_response): if environ.get('HTTP_EXPECT', '') != '': start_response('417 Expectation Failed', []) return [] method = environ['REQUEST_METHOD'] try: do = self.methods[method] except KeyError as e: return _send_method_not_allowed(environ, start_response, self._get_allowed_methods(environ)) try: return do.handle(environ, start_response, self) except BadRequestError as e: start_response('400 Bad Request', []) return [e.message.encode(DEFAULT_ENCODING)] except NotAcceptableError as e: start_response('406 Not Acceptable', []) return [e.message.encode(DEFAULT_ENCODING)] except UnsupportedMediaType as e: start_response('415 Unsupported Media Type', []) return [('Unsupported media type %r' % e.content_type) .encode(DEFAULT_ENCODING)] except UnauthorizedError as e: start_response('401 Unauthorized', []) return [('Please login.'.encode(DEFAULT_ENCODING))]