# 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. """CardDAV support. https://tools.ietf.org/html/rfc6352 """ from . import collation as _mod_collation from . import davcommon, webdav ET = webdav.ET WELLKNOWN_CARDDAV_PATH = "/.well-known/carddav" NAMESPACE = "urn:ietf:params:xml:ns:carddav" ADDRESSBOOK_RESOURCE_TYPE = "{%s}addressbook" % NAMESPACE # Feature to advertise presence of CardDAV support FEATURE = "addressbook" class AddressbookHomeSetProperty(webdav.Property): """addressbook-home-set property. See https://tools.ietf.org/html/rfc6352, section 7.1.1 """ name = "{%s}addressbook-home-set" % NAMESPACE resource_type = "{DAV:}principal" in_allprops = False live = True async def get_value(self, base_href, resource, el, environ): for href in resource.get_addressbook_home_set(): href = webdav.ensure_trailing_slash(href) el.append(webdav.create_href(href, base_href)) class AddressDataProperty(davcommon.SubbedProperty): """address-data property. See https://tools.ietf.org/html/rfc6352, section 10.4 Note that this is not technically a DAV property, and it is thus not registered in the regular webdav server. """ name = "{%s}address-data" % NAMESPACE def supported_on(self, resource): return resource.get_content_type() == "text/vcard" async def get_value_ext(self, href, resource, el, environ, requested): # TODO(jelmer): Support subproperties # TODO(jelmer): Don't hardcode encoding el.text = b"".join(await resource.get_body()).decode("utf-8") class AddressbookDescriptionProperty(webdav.Property): """Provides calendar-description property. https://tools.ietf.org/html/rfc6352, section 6.2.1 """ name = "{%s}addressbook-description" % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE async def get_value(self, href, resource, el, environ): el.text = resource.get_addressbook_description() async def set_value(self, href, resource, el): resource.set_addressbook_description(el.text) class AddressbookMultiGetReporter(davcommon.MultiGetReporter): name = "{%s}addressbook-multiget" % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE data_property = AddressDataProperty() class Addressbook(webdav.Collection): resource_types = webdav.Collection.resource_types + [ ADDRESSBOOK_RESOURCE_TYPE] def get_addressbook_description(self) -> str: raise NotImplementedError(self.get_addressbook_description) def set_addressbook_description(self, description: str) -> None: raise NotImplementedError(self.set_addressbook_description) def get_addressbook_color(self) -> str: raise NotImplementedError(self.get_addressbook_color) def set_addressbook_color(self, color: str) -> None: raise NotImplementedError(self.set_addressbook_color) def get_supported_address_data_types(self): """Get list of supported data types. Returns: List of tuples with content type and version """ raise NotImplementedError(self.get_supported_address_data_types) def get_max_resource_size(self) -> int: """Get maximum object size this address book will store (in bytes). Absence indicates no maximum. """ raise NotImplementedError(self.get_max_resource_size) def get_max_image_size(self) -> int: """Get maximum image size this address book will store (in bytes). Absence indicates no maximum. """ raise NotImplementedError(self.get_max_image_size) class PrincipalExtensions: """Extensions to webdav.Principal.""" def get_addressbook_home_set(self) -> set[str]: """Return set of addressbook home URLs. Returns: set of URLs """ raise NotImplementedError(self.get_addressbook_home_set) def get_principal_address(self) -> str: """Return URL to principal address vCard.""" raise NotImplementedError(self.get_principal_address) class PrincipalAddressProperty(webdav.Property): """Provides the principal-address property. https://tools.ietf.org/html/rfc6352, section 7.1.2 """ name = "{%s}principal-address" % NAMESPACE resource_type = "{DAV:}principal" in_allprops = False async def get_value(self, href, resource, el, environ): el.append(webdav.create_href(resource.get_principal_address(), href)) class SupportedAddressDataProperty(webdav.Property): """Provides the supported-address-data property. https://tools.ietf.org/html/rfc6352, section 6.2.2 """ name = "{%s}supported-address-data" % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE in_allprops = False live = True async def get_value(self, href, resource, el, environ): for ( content_type, version, ) in resource.get_supported_address_data_types(): subel = ET.SubElement(el, "{%s}content-type" % NAMESPACE) subel.set("content-type", content_type) subel.set("version", version) class MaxResourceSizeProperty(webdav.Property): """Provides the max-resource-size property. See https://tools.ietf.org/html/rfc6352, section 6.2.3. """ name = "{%s}max-resource-size" % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_resource_size()) class MaxImageSizeProperty(webdav.Property): """Provides the max-image-size property. This seems to be a carddav extension used by iOS and caldavzap. """ name = "{%s}max-image-size" % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE in_allprops = False live = True async def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_image_size()) async def addressbook_from_resource(resource): try: if resource.get_content_type() != "text/vcard": return None except KeyError: return None file = await resource.get_file() return file.addressbook.contents def apply_text_match(el: ET.Element, value: str) -> bool: collation = el.get("collation", "i;ascii-casemap") negate_condition = el.get("negate-condition", "no") match_type = el.get("match-type", "contains") matches = _mod_collation.collations[collation]( value, el.text or '', match_type) if negate_condition == "yes": return not matches else: return matches def apply_param_filter(el, prop): name = el.get("name") if (len(el) == 1 and el[0].tag == "{urn:ietf:params:xml:ns:carddav}is-not-defined"): return name not in prop.params try: value = prop.params[name] except KeyError: return False for subel in el: if subel.tag == "{urn:ietf:params:xml:ns:carddav}text-match": if not apply_text_match(subel, value): return False else: raise AssertionError("unknown tag %r in param-filter", subel.tag) return True def apply_prop_filter(el, ab): name = el.get("name").lower() # From https://tools.ietf.org/html/rfc6352 # A CARDDAV:prop-filter is said to match if: # The CARDDAV:prop-filter XML element contains a CARDDAV:is-not-defined XML # element and no property of the type specified by the "name" attribute # exists in the enclosing calendar component; if (len(el) == 1 and el[0].tag == "{urn:ietf:params:xml:ns:carddav}is-not-defined"): return name not in ab try: prop = ab[name] except KeyError: return False for prop_el in prop: matched = True for subel in el: if subel.tag == "{urn:ietf:params:xml:ns:carddav}text-match": if not apply_text_match(subel, str(prop_el)): matched = False break elif subel.tag == "{urn:ietf:params:xml:ns:carddav}param-filter": if not apply_param_filter(subel, prop_el): matched = False break if matched: return True return False async def apply_filter(el, resource): """Compile a filter element into a Python function.""" if el is None or not list(el): # Empty filter, let's not bother parsing return lambda x: True ab = await addressbook_from_resource(resource) if ab is None: return False test_name = el.get("test", "anyof") test = {"allof": all, "anyof": any}[test_name] return test(apply_prop_filter(subel, ab) for subel in el) class AddressbookQueryReporter(webdav.Reporter): name = "{%s}addressbook-query" % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE data_property = AddressDataProperty() @webdav.multistatus async def report( self, environ, body, resources_by_hrefs, properties, base_href, base_resource, depth, strict ): requested = None filter_el = None limit = None for el in body: if el.tag in ("{DAV:}prop", "{DAV:}allprop", "{DAV:}propname"): requested = el elif el.tag == ("{%s}filter" % NAMESPACE): filter_el = el elif el.tag == ("{%s}limit" % NAMESPACE): limit = el else: webdav.nonfatal_bad_request( f"Unknown tag {el.tag} in report {self.name}", strict) if requested is None: # The CardDAV RFC says that behaviour mimicks that of PROPFIND, # and the WebDAV RFC says that no body implies {DAV}allprop # This isn't exactly an empty body, but close enough. requested = ET.Element('{DAV:}allprop') if limit is not None: try: [nresults_el] = list(limit) except ValueError: webdav.nonfatal_bad_request( "Invalid number of subelements in limit", strict) nresults = None else: try: nresults = int(nresults_el.text) except ValueError: webdav.nonfatal_bad_request( "nresults not a number", strict) nresults = None else: nresults = None i = 0 async for (href, resource) in webdav.traverse_resource( base_resource, base_href, depth ): if not await apply_filter(filter_el, resource): continue if nresults is not None and i >= nresults: break propstat = davcommon.get_properties_with_data( self.data_property, href, resource, properties, environ, requested, ) yield webdav.Status( href, "200 OK", propstat=[s async for s in propstat]) i += 1