xandikos-0.0.6/0000755000175000017500000000000013131770501014111 5ustar jelmerjelmer00000000000000xandikos-0.0.6/setup.cfg0000644000175000017500000000004613131770501015732 0ustar jelmerjelmer00000000000000[egg_info] tag_build = tag_date = 0 xandikos-0.0.6/.travis.yml0000644000175000017500000000120313131264017016216 0ustar jelmerjelmer00000000000000language: python cache: pip sudo: true python: - 3.3 - 3.4 - 3.5 - 3.6 - pypy3.3-5.2-alpha1 env: global: PYTHONHASHSEED=random install: - pip install pip --upgrade - pip install coverage codecov flake8 pycalendar - sudo apt-get install -qq libneon27-dev curl python2.7 - python setup.py develop script: - make style - make coverage - mv .coverage .coverage.unit - make coverage-litmus - mv .coverage .coverage.litmus - make coverage-vdirsyncer - mv .coverage .coverage.vdirsyncer - make coverage-caldavtester - mv .coverage .coverage.caldavtester after_success: - python -m coverage combine - codecov xandikos-0.0.6/.gitignore0000644000175000017500000000026613077476573016132 0ustar jelmerjelmer00000000000000*.pyc *~ build/ .testrepository/ MANIFEST .tox/ .*.sw? .coverage htmlcov/ dist .pybuild compat/litmus-*.tar.gz compat/vdirsyncer/ compat/ccs-caldavtester/ *.egg* child.log debug.log xandikos-0.0.6/Makefile0000644000175000017500000000327013126171271015556 0ustar jelmerjelmer00000000000000export PYTHON ?= python3 COVERAGE ?= $(PYTHON) -m coverage COVERAGE_RUN_OPTIONS ?= COVERAGE_RUN ?= $(COVERAGE) run $(COVERAGE_RUN_OPTIONS) TESTSUITE = xandikos.tests.test_suite LITMUS_TESTS ?= basic http CALDAVTESTER_TESTS ?= CalDAV/delete.xml \ CalDAV/schedulenomore.xml \ CalDAV/options.xml \ CalDAV/vtodos.xml XANDIKOS_COVERAGE ?= $(COVERAGE_RUN) -a --rcfile=$(shell pwd)/.coveragerc --source=xandikos -m xandikos.web check: $(PYTHON) -m unittest $(TESTSUITE) style: flake8 --exclude=compat/vdirsyncer/,.tox,compat/ccs-caldavtester web: $(PYTHON) -m xandikos.web check-litmus-all: ./compat/xandikos-litmus.sh "basic copymove http props locks" check-litmus: ./compat/xandikos-litmus.sh "${LITMUS_TESTS}" coverage-litmus: XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-litmus.sh "${LITMUS_TESTS}" check-vdirsyncer: ./compat/xandikos-vdirsyncer.sh coverage-vdirsyncer: PYTEST_ARGS="--cov-config $(shell pwd)/.coveragerc --cov-append --cov $(shell pwd)/xandikos" ./compat/xandikos-vdirsyncer.sh $(COVERAGE) combine -a compat/vdirsyncer/.coverage check-caldavtester: TESTS="$(CALDAVTESTER_TESTS)" ./compat/xandikos-caldavtester.sh coverage-caldavtester: TESTS="$(CALDAVTESTER_TESTS)" XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-caldavtester.sh check-caldavtester-all: ./compat/xandikos-caldavtester.sh coverage-caldavtester-all: XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-caldavtester.sh check-all: check check-vdirsyncer check-litmus check-caldavtester style coverage-all: coverage coverage-litmus coverage-vdirsyncer coverage-caldavtester coverage: $(COVERAGE_RUN) --source=xandikos -m unittest $(TESTSUITE) coverage-html: coverage $(COVERAGE) html xandikos-0.0.6/notes/0000755000175000017500000000000013131770501015241 5ustar jelmerjelmer00000000000000xandikos-0.0.6/notes/structure.rst0000644000175000017500000000131613077476573020061 0ustar jelmerjelmer00000000000000Xandikos has a fairly clear distinction between different components. Modules ======= The core WebDAV implementation lives in xandikos.webdav. This just implements the WebDAV protocol, and provides abstract classes for WebDAV resources that can be implemented by other code. Several WebDAV extensions (access, CardDAV, CalDAV) live in their own Python file. They build on top of the WebDAV module, and provide extra reporter and property implementations as defined in those specifications. Store is a simple object-store implementation on top of a Git repository, which has several properties that make it useful as a WebDAV backend. The business logic lives in xandikos.web; it ties together the other modules, xandikos-0.0.6/notes/hacking.txt0000644000175000017500000000005713043751200017405 0ustar jelmerjelmer00000000000000DAV in class names is spelled in all capitals. xandikos-0.0.6/notes/file-format.rst0000644000175000017500000000176713077476573020240 0ustar jelmerjelmer00000000000000File structure ============== Collections are represented as Git repositories on disk. A specific version is represented as a commit id. The 'ctag' for a calendar is taken from the tree id of the calendar root tree. The `entity tag`_ for an event is taken from the blob id of the Blob representing that EVENT. These kinds of entity tags are strong, since blobs are equivalent by octet equality. .. _entity tag: https://tools.ietf.org/html/rfc2616#section-3.11 The file name of calendar events shall be .ics / .vcf. Because of this, every file MUST only contain one UID and thus MUST contain exactly one VEVENT, VTODO, VJOURNAL or VFREEBUSY. All items in a collection *must* be well formed, so that they do not have to be validated when served. When new items are added, the collection should verify no existing items have the same UID. Open questions: - How to handle subtrees? Are they just subcollections? - Where should collection metadata (e.g. colors, description) be stored? .git/config? xandikos-0.0.6/notes/config.rst0000644000175000017500000000062613131264017017244 0ustar jelmerjelmer00000000000000Principal ========= Need per principal config: - calendar home sets - addressbook home sets - user address set - infit settings Calendar ======== Need per calendar config: - color - description - inbox URL - outbox URL - max instances - max attendees per instance - calendar timezone Addresssbook ============ Need per addressbook config: - max image size - max resource size - color - description xandikos-0.0.6/notes/context.rst0000644000175000017500000000134113077476573017503 0ustar jelmerjelmer00000000000000Contexts ======== Currently, property get_value/set_value receive three pieces of context: - HREF for the resource - resource object - Element object to update However, some properties need WebDAV server metadata: - supported-live-property-set needs list of properties - supported-report-set needs list of reports - supported-method-set needs list of methods Some operations need access to current user information: - current-user-principal - current-user-privilege-set - calendar-user-address-set PUT/DELETE/MKCOL need access to username (for author) and possibly things like user agent (for better commit message) .. code:: python class Context(object): def get_current_user(self): return (name, principal) xandikos-0.0.6/notes/store.rst0000644000175000017500000000110013123462661017125 0ustar jelmerjelmer00000000000000Dulwich Store ============= The main building blocks are vCard (.vcf) and iCalendar (.ics) files. Storage happens in Git repositories. Most items are identified by a UID and a filename, both of which are unique for the store. Items can have multiple versions, which are identified by an ETag. Each store maps to a single Git repository, and can not contain directories. In the future, a store could map to a subtree in a Git repository. Stores are responsible for making sure that: - their contents are validly formed calendars/contacts - UIDs are unique (where relevant) xandikos-0.0.6/notes/monitoring.rst0000644000175000017500000000044013077476573020203 0ustar jelmerjelmer00000000000000Monitoring ========== Things to monitor: - number of uploaded items - number of accessed store items - number of lru cache hits - number of HTTP requests - number of reports - number of properties requested - number of unknown properties requested - number of unknown reports requested xandikos-0.0.6/notes/auth.rst0000644000175000017500000000076513077476573016771 0ustar jelmerjelmer00000000000000Authentication ============== Ideally, Xandikos would stay out of the business of authenticating users. The trouble with this is that there are many flavours that need to be supported and configured. However, it is still necessary for Xandikos to handle authorization. An external system authenticates the user, and then sets the REMOTE_USER environment variable. Per http://wsgi.readthedocs.io/en/latest/specifications/simple_authentication.html, Xandikos should distinguish between 401 and 403. xandikos-0.0.6/notes/webdav.rst0000644000175000017500000000242413077476573017272 0ustar jelmerjelmer00000000000000WebDAV implementation ===================== .. code:: python class DAVPropertyProvider(object): NAME property matchresource() # One or multiple properties? def proplist(self, resource, all=False): def getprop(self, resource, property): def propupdate(self, resource, updates): class DAVBackend(object): def get_resource(self, path): def create_collection(self, path): class DAVReporter(object): class DAVResource(object): def get_resource_types(self): def get_body(self): """Returns the body of the resource. :return: bytes representing contents """ def set_body(self, body): """Set the body of the resource. :param body: body (as bytes) """ def proplist(self): """Return list of properties. :return: List of property names """ def propupdate(self, updates): """Update properties. :param updates: Dictionary mapping names to new values """ def lock(self): def unlock(self): def members(self): """List members. :return: List tuples of (name, DAVResource) """ # TODO(jelmer): COPY # TODO(jelmer): MOVE # TODO(jelmer): MKCOL # TODO(jelmer): LOCK/UNLOCK # TODO(jelmer): REPORT xandikos-0.0.6/notes/dav-compliance.rst0000644000175000017500000001610213131264017020655 0ustar jelmerjelmer00000000000000DAV Compliance ============== This document aims to document the compliance with various RFCs. rfc4918.txt (Core WebDAV) (obsoletes rfc2518) --------------------------------------------- Mostly supported. HTTP Methods ^^^^^^^^^^^^ - PROPFIND [supported] - PROPPATCH [supported] - MKCOL [supported] - DELETE [supported] - PUT [supported] - COPY [not implemented] - MOVE [not implemented] - LOCK [not implemented] - UNLOCK [not implemented] HTTP Headers ^^^^^^^^^^^^ - (9.1) Dav [supported] - (9.2) Depth ['0, '1' and 'infinity' are supported] - (9.3) Destination [only used with COPY/MOVE, which are not supported] - (9.4) If [not supported] - (9.5) Lock-Token [not supported] - (9.6) Overwrite [only used with COPY/MOVE, which are not supported] - (9.7) Status-URI [not supported] - (9.8) Timeout [not supported, only used for locks] DAV Properties ^^^^^^^^^^^^^^ - (15.1) creationdate [supported] - (15.2) displayname [supported] - (15.3) getcontentlanguage [supported] - (15.4) getcontentlength [supported] - (15.5) getcontenttype [supported] - (15.6) getetag [supported] - (15.7) getlastmodified [supported] - (15.8) lockdiscovery [supported] - (15.9) resourcetype [supported] - (15.10) supportedlock [supported] - (RFC2518 ONLY - 13.10) source [not supported] rfc3253.txt (Versioning Extensions) ----------------------------------- Broadly speaking, only features related to the REPORT method are supported. HTTP Methods ^^^^^^^^^^^^ - REPORT [supported] - CHECKOUT [not supported] - CHECKIN [not supported] - UNCHECKOUT [not supported] - MKWORKSPACE [not supported] - UPDATE [not supported] - LABEL [not supported] - MERGE [not supported] - VERSION-CONTROL [not supported] - BASELINE-CONTROL [not supported] - MKACTIVITY [not supported] DAV Properties ^^^^^^^^^^^^^^ - DAV:comment [supported] - DAV:creator-displayname [not supported] - DAV:supported-method-set [not supported] - DAV:supported-live-property-set [not supported] - DAV:supported-report-set [supported] - DAV:predecessor-set [not supported] - DAV:successor-set [not supported] - DAV:checkout-set [not supported] - DAV:version-name [not supported] - DAV:checked-out [not supported] - DAV:chcked-in [not supported] - DAV:auto-version [not supported] DAV Reports ^^^^^^^^^^^ - DAV:expand-property [supported] - DAV:version-tree [not supported] rfc5323.txt (WebDAV "SEARCH") ----------------------------- Not supported HTTP Methods ^^^^^^^^^^^^ - SEARCH [not supported] DAV Properties ^^^^^^^^^^^^^^ - DAV:datatype [not supported] - DAV:searchable [not supported] - DAV:selectable [not supported] - DAV:sortable [not supported] - DAV:caseless [not supported] - DAV:operators [not supported] rfc3744.txt (WebDAV access control) ----------------------------------- Not really supported DAV Properties ^^^^^^^^^^^^^^ - DAV:alternate-uri-set [not supported] - DAV:principal-URL [supported] - DAV:group-member-set [not supported] - DAV:group-membership [supported] - DAV:owner [supported] - DAV:group [not supported] - DAV:current-user-privilege-set [supported] - DAV:supported-privilege-set [not supported] - DAV:acl [not supported] - DAV:acl-restrictions [not supported] - DAV:inherited-acl-set [not supported] - DAV:principal-collection-set [not supported] DAV Reports ^^^^^^^^^^^ - DAV:acl-principal-prop-set [not supported] - DAV:principal-match [not supported] - DAV:principal-property-search [not supported] - DAV:principal-search-property-set [not supported] rfc4791.txt (CalDAV) -------------------- Fully supported. DAV Properties ^^^^^^^^^^^^^^ - CALDAV:calendar-description [supported] - CALDAV:calendar-home-set [supported] - CALDAV:calendar-timezone [supported] - CALDAV:supported-calendar-component-set [supported] - CALDAV:supported-calendar-data [supported] - CALDAV:max-resource-size [supported] - CALDAV:min-date-time [supported] - CALDAV:max-date-time [supported] - CALDAV:max-instances [supported] - CALDAV:max-attendees-per-instance [supported] HTTP Methods ^^^^^^^^^^^^ - MKCALENDAR [not supported] DAV Reports ^^^^^^^^^^^ - CALDAV:calendar-query [supported] - CALDAV:calendar-multiget [supported] - CALDAV:free-busy-query [supported] rfc6352.txt (CardDAV) --------------------- Fully supported. DAV Properties ^^^^^^^^^^^^^^ - CARDDAV:addressbook-description [supported] - CARDDAV:supported-address-data [supported] - CARDDAV:max-resource-size [supported] - CARDDAV:addressbook-home-set [supported] - CARDDAV:princial-address [supported] DAV Reports ^^^^^^^^^^^ - CARDDAV:addressbook-query [supported] - CARDDAV:addressbook-multiget [supported] rfc6638.txt (CardDAV scheduling extensions) ------------------------------------------- DAV Properties ^^^^^^^^^^^^^^ - CALDAV:schedule-outbox-URL [supported] - CALDAV:schedule-inbox-URL [supported] - CALDAV:calendar-user-address-set [supported] - CALDAV:calendar-user-type [supported] rfc6764.txt (Locating groupware services) ----------------------------------------- Most of this is outside of the scope of xandikos, but it does support DAV:current-user-principal rfc7809.txt (CalDAV Time Zone Extensions) ----------------------------------------- Not supported DAV Properties ^^^^^^^^^^^^^^ - CALDAV:timezone-service-set [supported] - CALDAV:calendar-timezone-id [not supported] rfc5397.txt (WebDAV Current Principal Extension) ------------------------------------------------ DAV Properties ^^^^^^^^^^^^^^ - CALDAV:current-user-principal [supported] Proprietary extensions ---------------------- Custom properties used by various clients ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - CARDDAV:max-image-size [supported] https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-ctag.txt - DAV:getctag [supported] https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-proxy.txt - DAV:calendar-proxy-read-for [supported] - DAV:calendar-proxy-write-for [supported] Apple-specific Properties ^^^^^^^^^^^^^^^^^^^^^^^^^ - calendar-color [supported] - getctag [supported] inf-it properties ^^^^^^^^^^^^^^^^^ - headervalue [supported] - settings [supported] - addressbook-color [supported] rfc5995.txt (POST to create members) ------------------------------------ Fully supported. DAV Properties ^^^^^^^^^^^^^^ - DAV:add-member [supported] HTTP Methods ^^^^^^^^^^^^ - POST [supported] rfc5689 (Extended MKCOL) ------------------------ Fully supported HTTP Methods ^^^^^^^^^^^^ - MKCOL [supported] rfc7529.txt (WebDAV Quota) -------------------------- DAV properties ^^^^^^^^^^^^^^ - {DAV:}quote-available-bytes [supported] - {DAV:}quote-used-bytes [supported] rfc4709 (WebDAV Mount) ---------------------- This RFC documents a mechanism that allows clients to find the WebDAV mount associated with a specific page. It's unclear to the writer what the value of this is - an alternate resource in the HTML page would also do. As far as I can tell, there is only a single server side implementation and a single client side implementation of this RFC. I don't have access to the client implementation (Xythos Drive) and the server side implementation is in SabreDAV. Experimental support for WebDAV Mount is available in the 'mount' branch, but won't be merged without a good use case. xandikos-0.0.6/notes/goals.rst0000644000175000017500000000025413077476573017126 0ustar jelmerjelmer00000000000000Goals ===== - standards compliant - standards complete - backed by Git - easily hackable/editable with standard tools (e.g. Git/Vim) - version tracked - unit tested xandikos-0.0.6/notes/release-process.rst0000644000175000017500000000034113123462661021073 0ustar jelmerjelmer00000000000000Release Process =============== 1. Update version in setup.py 2. Update version in xandikos/__init__.py 3. git commit -a -m "Release $VERSION" 4. git tag -as -m "Release $VERSION" v$VERSION 5. ./setup.py sdist upload --sign xandikos-0.0.6/.testr.conf0000644000175000017500000000024313106420570016176 0ustar jelmerjelmer00000000000000[DEFAULT] test_command=PYTHONPATH=. python3 -m subunit.run $IDOPTION $LISTOPT xandikos.tests.test_suite test_id_option=--load-list $IDFILE test_list_option=--list xandikos-0.0.6/MANIFEST.in0000644000175000017500000000036313123462661015657 0ustar jelmerjelmer00000000000000include *.rst include AUTHORS include COPYING include Makefile include TODO include compat/*.sh include compat/*.rst include compat/*.xml include compat/*.sha256sum include examples/*.ini include notes/*.rst include tox.ini include xandikos.1 xandikos-0.0.6/tox.ini0000644000175000017500000000037513077476573015456 0ustar jelmerjelmer00000000000000[tox] downloadcache = {toxworkdir}/cache/ envlist = py33, py34, py35, py36 [testenv] commands = make check recreate = True whitelist_externals = make [flake8] # E731: Use a def instead of lambda expr ignore = E731 application-package-names = xandikos xandikos-0.0.6/xandikos/0000755000175000017500000000000013131770501015731 5ustar jelmerjelmer00000000000000xandikos-0.0.6/xandikos/quota.py0000644000175000017500000000271113126171271017440 0ustar jelmerjelmer00000000000000# 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. """Quota and Size properties. See https://tools.ietf.org/html/rfc4331 """ from xandikos import webdav class QuotaAvailableBytesProperty(webdav.Property): """quota-available-bytes """ name = '{DAV:}quota-available-bytes' resource_type = None in_allprops = False live = True def get_value(self, href, resource, el): el.text = resource.get_quota_available_bytes() class QuotaUsedBytesProperty(webdav.Property): """quota-used-bytes """ name = '{DAV:}quota-used-bytes' resource_type = None in_allprops = False live = True def get_value(self, href, resource, el): el.text = resource.get_quota_used_bytes() xandikos-0.0.6/xandikos/webdav.py0000644000175000017500000015716213131264017017567 0ustar jelmerjelmer00000000000000# 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: # TODO(jelmer): Ideally this should receive # SCRIPT_NAME and prefix the returned URL with that. el.append(create_href(self.current_user_principal)) 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))] xandikos-0.0.6/xandikos/scheduling.py0000644000175000017500000000717613131264017020443 0ustar jelmerjelmer00000000000000# 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. """Scheduling. See https://tools.ietf.org/html/rfc6638 """ from xandikos import caldav, webdav SCHEDULE_INBOX_RESOURCE_TYPE = '{%s}schedule-inbox' % caldav.NAMESPACE # Feature to advertise to indicate scheduling support. FEATURE = 'calendar-auto-schedule' CALENDAR_USER_TYPE_INDIVIDUAL = "INDIVIDUAL" # An individual CALENDAR_USER_TYPE_GROUP = "GROUP" # A group of individuals CALENDAR_USER_TYPE_RESOURCE = "RESOURCE" # A physical resource CALENDAR_USER_TYPE_ROOM = "ROOM" # A room resource CALENDAR_USER_TYPE_UNKNOWN = "UNKNOWN" # Otherwise not known CALENDAR_USER_TYPES = ( CALENDAR_USER_TYPE_INDIVIDUAL, CALENDAR_USER_TYPE_GROUP, CALENDAR_USER_TYPE_RESOURCE, CALENDAR_USER_TYPE_ROOM, CALENDAR_USER_TYPE_UNKNOWN) class ScheduleInbox(caldav.Calendar): resource_types = (caldav.Calendar.resource_types + [SCHEDULE_INBOX_RESOURCE_TYPE]) def get_schedule_inbox_url(self): raise NotImplementedError(self.get_schedule_inbox_url) def get_schedule_outbox_url(self): raise NotImplementedError(self.get_schedule_outbox_url) def get_calendar_user_type(self): # Default, per section 2.4.2 return CALENDAR_USER_TYPE_INDIVIDUAL class ScheduleInboxURLProperty(webdav.Property): """Schedule-inbox-URL property. See https://tools.ietf.org/html/rfc6638, section 2.2 """ name = '{%s}schedule-inbox-URL' % caldav.NAMESPACE resource_type = caldav.CALENDAR_RESOURCE_TYPE in_allprops = True def get_value(self, href, resource, el): el.append(webdav.create_href(resource.get_schedule_inbox_url(), href)) class ScheduleOutboxURLProperty(webdav.Property): """Schedule-outbox-URL property. See https://tools.ietf.org/html/rfc6638, section 2.1 """ name = '{%s}schedule-outbox-URL' % caldav.NAMESPACE resource_type = caldav.CALENDAR_RESOURCE_TYPE in_allprops = True def get_value(self, href, resource, el): el.append(webdav.create_href(resource.get_schedule_outbox_url(), href)) class CalendarUserAddressSetProperty(webdav.Property): """calendar-user-address-set property See https://tools.ietf.org/html/rfc6638, section 2.4.1 """ name = '{%s}calendar-user-address-set' % caldav.NAMESPACE resource_type = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = False def get_value(self, base_href, resource, el): for href in resource.get_calendar_user_address_set(): el.append(webdav.create_href(href, base_href)) class CalendarUserTypeProperty(webdav.Property): """calendar-user-type property See https://tools.ietf.org/html/rfc6638, section 2.4.2 """ name = '{urn:ietf:params:xml:ns:caldav}calendar-user-type' resource_type = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = False def get_value(self, href, resource, el): el.text = resource.get_calendar_user_type() xandikos-0.0.6/xandikos/icalendar.py0000644000175000017500000001601113126171271020227 0ustar jelmerjelmer00000000000000# Xandikos # Copyright (C) 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. """ICalendar file handling. """ import logging from icalendar.cal import Calendar, component_factory from xandikos.store import File, InvalidFileContents def calendar_component_delta(old_cal, new_cal): """Find the differences between components in two calendars. :param old_cal: Old calendar (can be None) :param new_cal: New calendar (can be None) :yield: (old_component, new_component) tuples (either can be None) """ by_uid = {} by_content = {} by_idx = {} idx = 0 for component in getattr(old_cal, "subcomponents", []): try: by_uid[component["UID"]] = component except KeyError: by_content[component.to_ical()] = True by_idx[idx] = component idx += 1 idx = 0 for component in new_cal.subcomponents: try: old_component = by_uid.pop(component["UID"]) except KeyError: if not by_content.pop(component.to_ical(), None): # Not previously present yield (by_idx.get(idx, component_factory[component.name]()), component) by_idx.pop(idx, None) else: yield (old_component, component) for old_component in by_idx.values(): yield (old_component, component_factory[old_component.name]()) def calendar_prop_delta(old_component, new_component): fields = set([field for field in old_component or []] + [field for field in new_component or []]) for field in fields: old_value = old_component.get(field) new_value = new_component.get(field) if ( getattr(old_value, 'to_ical', None) is None or getattr(new_value, 'to_ical', None) is None or old_value.to_ical() != new_value.to_ical() ): yield (field, old_value, new_value) def describe_component(component): if component.name == "VTODO": try: return "task '%s'" % component["SUMMARY"] except KeyError: return "task" else: try: return component["SUMMARY"] except KeyError: return "calendar item" DELTA_IGNORE_FIELDS = set(["LAST-MODIFIED", "SEQUENCE", "DTSTAMP", "PRODID", "CREATED", "COMPLETED", "X-MOZ-GENERATION", "X-LIC-ERROR", "UID"]) def describe_calendar_delta(old_cal, new_cal): """Describe the differences between two calendars. :param old_cal: Old calendar (can be None) :param new_cal: New calendar (can be None) :yield: Lines describing changes """ # TODO(jelmer): Extend for old_component, new_component in calendar_component_delta(old_cal, new_cal): if not new_component: yield "Deleted %s" % describe_component(old_component) continue description = describe_component(new_component) if not old_component: yield "Added %s" % describe_component(new_component) continue for field, old_value, new_value in calendar_prop_delta(old_component, new_component): if field.upper() in DELTA_IGNORE_FIELDS: continue if ( old_component.name.upper() == "VTODO" and field.upper() == "STATUS" ): yield "%s marked as %s" % (description, new_value) elif field.upper() == 'DESCRIPTION': yield "changed description of %s" % description elif field.upper() == 'SUMMARY': yield "changed summary of %s" % description elif field.upper() == 'LOCATION': yield "changed location of %s to %s" % (description, new_value) elif (old_component.name.upper() == "VTODO" and field.upper() == "PERCENT-COMPLETE"): yield "%s marked as %d%% completed." % ( description, new_value) elif field.upper() == 'DUE': yield "changed due date for %s from %s to %s" % ( description, old_value.dt if old_value else 'none', new_value.dt if new_value else 'none') else: yield "modified field %s in %s" % (field, description) logging.debug("Changed %s/%s or %s/%s from %s to %s.", old_component.name, field, new_component.name, field, old_value, new_value) class ICalendarFile(File): """Handle for ICalendar files.""" content_type = 'text/calendar' def __init__(self, content, content_type): super(ICalendarFile, self).__init__(content, content_type) self._calendar = None def validate(self): """Verify that file contents are valid.""" self.calendar @property def calendar(self): if self._calendar is None: try: self._calendar = Calendar.from_ical(b''.join(self.content)) except ValueError: raise InvalidFileContents(self.content_type, self.content) return self._calendar def describe_delta(self, name, previous): try: lines = list(describe_calendar_delta( previous.calendar if previous else None, self.calendar)) except NotImplementedError: lines = [] if not lines: lines = super(ICalendarFile, self).describe_delta(name, previous) return lines def describe(self, name): try: subcomponents = self.calendar.subcomponents except InvalidFileContents: pass else: for component in subcomponents: try: return describe_component(component) except KeyError: pass return super(ICalendarFile, self).describe(name) def get_uid(self): """Extract the UID from a VCalendar file. :param cal: Calendar, possibly serialized. :return: UID """ for component in self.calendar.subcomponents: try: return component["UID"] except KeyError: pass raise KeyError xandikos-0.0.6/xandikos/timezones.py0000644000175000017500000000321113126171271020320 0ustar jelmerjelmer00000000000000# 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. """Timezone handling. See http://www.webdav.org/specs/rfc7809.html """ from xandikos import webdav class TimezoneServiceSetProperty(webdav.Property): """timezone-service-set property See http://www.webdav.org/specs/rfc7809.html, section 5.1 """ name = '{DAV:}timezone-service-set' # Should be set on CalDAV calendar home collection resources, # but Xandikos doesn't have a separate resource type for those. resource_type = webdav.COLLECTION_RESOURCE_TYPE in_allprops = False live = True def __init__(self, timezone_services): super(TimezoneServiceSetProperty, self).__init__() self._timezone_services = timezone_services def get_value(self, base_href, resource, el): for timezone_service_href in self._timezone_services: el.append(webdav.create_href(timezone_service_href, base_href)) xandikos-0.0.6/xandikos/davcommon.py0000644000175000017500000000601613126171271020274 0ustar jelmerjelmer00000000000000# 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. """Common functions for DAV implementations.""" from xandikos import webdav ET = webdav.ET class SubbedProperty(webdav.Property): """Property with sub-components that can be queried.""" def get_value_ext(self, href, resource, el, requested): """Get the value of a data property. :param href: Resource href :param resource: Resource to get value for :param el: Element to fill in :param requested: Requested property (including subelements) """ raise NotImplementedError(self.get_value_ext) def get_properties_with_data(data_property, href, resource, properties, requested): properties = dict(properties) properties[data_property.name] = data_property return webdav.get_properties(href, resource, properties, requested) class MultiGetReporter(webdav.Reporter): """Abstract base class for multi-get reporters.""" name = None # A SubbedProperty subclass data_property = None @webdav.multistatus def report(self, environ, body, resources_by_hrefs, properties, base_href, resource, depth): # TODO(jelmer): Verify that depth == "0" # TODO(jelmer): Verify that resource is an addressbook requested = None hrefs = [] for el in body: if el.tag in ('{DAV:}prop', '{DAV:}allprop', '{DAV:}propname'): requested = el elif el.tag == '{DAV:}href': hrefs.append(webdav.read_href_element(el)) else: raise webdav.BadRequestError( 'Unknown tag %s in report %s' % (el.tag, self.name)) for (href, resource) in resources_by_hrefs(hrefs): if resource is None: yield webdav.Status(href, '404 Not Found', propstat=[]) else: propstat = get_properties_with_data( self.data_property, href, resource, properties, requested) yield webdav.Status(href, '200 OK', propstat=list(propstat)) # see https://tools.ietf.org/html/rfc4790 collations = { 'i;ascii-casemap': lambda a, b: (a.decode('ascii').upper() == b.decode('ascii').upper()), 'i;octet': lambda a, b: a == b, } xandikos-0.0.6/xandikos/web.py0000644000175000017500000007417713131264017017100 0ustar jelmerjelmer00000000000000# 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. """Web server implementation.. This is the concrete web server implementation. It provides the high level application logic that combines the WebDAV server, the carddav support, the caldav support and the DAV store. """ from email.utils import parseaddr import functools import hashlib import jinja2 import logging import os import posixpath import shutil from xandikos import __version__ as xandikos_version from xandikos import (access, apache, caldav, carddav, quota, sync, webdav, infit, scheduling, timezones) from xandikos.icalendar import ICalendarFile from xandikos.store import ( DuplicateUidError, TreeGitStore, GitStore, InvalidFileContents, NoSuchItem, NotStoreError, STORE_TYPE_ADDRESSBOOK, STORE_TYPE_CALENDAR, STORE_TYPE_PRINCIPAL, STORE_TYPE_OTHER, ) from xandikos.vcard import VCardFile WELLKNOWN_DAV_PATHS = {caldav.WELLKNOWN_CALDAV_PATH, carddav.WELLKNOWN_CARDDAV_PATH} STORE_CACHE_SIZE = 128 # TODO(jelmer): Make these configurable/dynamic CALENDAR_HOME_SET = ['calendars'] ADDRESSBOOK_HOME_SET = ['contacts'] TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), 'templates') jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader(TEMPLATES_DIR)) def render_jinja_page(name, accepted_content_languages, **kwargs): """Render a HTML page from jinja template. :param name: Name of the page :param accepted_content_languages: List of accepted content languages :return: TUple of (body, content_length, etag, content_type, languages) """ # TODO(jelmer): Support rendering other languages encoding = 'utf-8' template = jinja_env.get_template(name) body = template.render(version=xandikos_version, **kwargs).encode(encoding) return ([body], len(body), None, 'text/html; encoding=%s' % encoding, ['en-UK']) def create_strong_etag(etag): """Create strong etags. :param etag: basic etag :return: A strong etag """ return '"' + etag + '"' def extract_strong_etag(etag): """Extract a strong etag from a string.""" if etag is None: return etag return etag.strip('"') class ObjectResource(webdav.Resource): """Object resource.""" def __init__(self, store, name, content_type, etag): self.store = store self.name = name self.etag = etag self.content_type = content_type self._file = None def __repr__(self): return "%s(%r, %r, %r, %r)" % ( type(self).__name__, self.store, self.name, self.etag, self.get_content_type() ) @property def file(self): if self._file is None: self._file = self.store.get_file(self.name, self.content_type, self.etag) return self._file def get_body(self): return self.file.content def set_body(self, data, replace_etag=None): try: (name, etag) = self.store.import_one( self.name, self.content_type, data, replace_etag=extract_strong_etag(replace_etag)) except InvalidFileContents: # TODO(jelmer): Not every invalid file is a calendar file.. raise webdav.PreconditionFailure( '{%s}valid-calendar-data' % caldav.NAMESPACE, 'Not a valid calendar file.') except DuplicateUidError: raise webdav.PreconditionFailure( '{%s}no-uid-conflict' % caldav.NAMESPACE, 'UID already in use.') return create_strong_etag(etag) def get_content_language(self): raise KeyError def get_content_type(self): return self.content_type def get_content_length(self): return sum(map(len, self.get_body())) def get_etag(self): return create_strong_etag(self.etag) def get_supported_locks(self): return [] def get_active_locks(self): return [] def get_owner(self): return None def get_comment(self): raise KeyError def set_comment(self, comment): raise NotImplementedError(self.set_comment) def get_creationdate(self): # TODO(jelmer): Find creation date using store function raise KeyError def get_last_modified(self): # TODO(jelmer): Find last modified time using store function raise KeyError def get_is_executable(self): # TODO(jelmer): Retrieve POSIX mode and check for executability. return False def get_quota_used_bytes(self): # TODO(jelmer): Ask the store? raise KeyError def get_quota_available_bytes(self): # TODO(jelmer): Ask the store? raise KeyError class StoreBasedCollection(object): def __init__(self, backend, relpath, store): self.backend = backend self.relpath = relpath self.store = store def __repr__(self): return "%s(%r)" % (type(self).__name__, self.store) def set_resource_types(self, resource_types): # TODO(jelmer): Allow more than just this set; allow combining # addressbook/calendar. resource_types = set(resource_types) if resource_types == {caldav.CALENDAR_RESOURCE_TYPE, webdav.COLLECTION_RESOURCE_TYPE}: self.store.set_type(STORE_TYPE_CALENDAR) elif resource_types == {carddav.ADDRESSBOOK_RESOURCE_TYPE, webdav.COLLECTION_RESOURCE_TYPE}: self.store.set_type(STORE_TYPE_ADDRESSBOOK) elif resource_types == {webdav.PRINCIPAL_RESOURCE_TYPE}: self.store.set_type(STORE_TYPE_PRINCIPAL) elif resource_types == {webdav.COLLECTION_RESOURCE_TYPE}: self.store.set_type(STORE_TYPE_OTHER) else: raise NotImplementedError(self.set_resource_types) def _get_resource(self, name, content_type, etag): return ObjectResource(self.store, name, content_type, etag) def _get_subcollection(self, name): return self.backend.get_resource(posixpath.join(self.relpath, name)) def get_displayname(self): displayname = self.store.get_displayname() if displayname is None: return os.path.basename(self.store.repo.path) return displayname def set_displayname(self, displayname): self.store.set_displayname(displayname) def get_sync_token(self): return self.store.get_ctag() def get_ctag(self): return self.store.get_ctag() def get_etag(self): return create_strong_etag(self.store.get_ctag()) def members(self): ret = [] for (name, content_type, etag) in self.store.iter_with_etag(): resource = self._get_resource(name, content_type, etag) ret.append((name, resource)) for name in self.store.subdirectories(): ret.append((name, self._get_subcollection(name))) return ret def get_member(self, name): assert name != '' for (fname, content_type, fetag) in self.store.iter_with_etag(): if name == fname: return self._get_resource(name, content_type, fetag) else: if name in self.store.subdirectories(): return self._get_subcollection(name) raise KeyError(name) def delete_member(self, name, etag=None): assert name != '' try: self.store.delete_one(name, etag=extract_strong_etag(etag)) except NoSuchItem: # TODO: Properly allow removing subcollections # self.get_subcollection(name).destroy() shutil.rmtree(os.path.join(self.store.path, name)) def create_member(self, name, contents, content_type): try: (name, etag) = self.store.import_one(name, content_type, contents) except InvalidFileContents: # TODO(jelmer): Not every invalid file is a calendar file.. raise webdav.PreconditionFailure( '{%s}valid-calendar-data' % caldav.NAMESPACE, 'Not a valid calendar file.') except DuplicateUidError: raise webdav.PreconditionFailure( '{%s}no-uid-conflict' % caldav.NAMESPACE, 'UID already in use.') return (name, create_strong_etag(etag)) def iter_differences_since(self, old_token, new_token): for (name, content_type, old_etag, new_etag) in self.store.iter_changes( old_token, new_token): if old_etag is not None: old_resource = self._get_resource(name, content_type, old_etag) else: old_resource = None if new_etag is not None: new_resource = self._get_resource(name, content_type, new_etag) else: new_resource = None yield (name, old_resource, new_resource) def get_owner(self): return None def get_supported_locks(self): return [] def get_active_locks(self): return [] def get_headervalue(self): raise KeyError def get_comment(self): return self.store.get_comment() def set_comment(self, comment): self.store.set_comment(comment) def get_creationdate(self): # TODO(jelmer): Find creation date using store function raise KeyError def get_last_modified(self): # TODO(jelmer): Find last modified time using store function raise KeyError def get_content_type(self): return 'httpd/unix-directory' def get_content_language(self): raise KeyError def get_content_length(self): raise KeyError def destroy(self): # RFC2518, section 8.6.2 says this should recursively delete. self.store.destroy() def get_body(self): raise NotImplementedError(self.get_body) def render(self, accepted_content_types, accepted_content_languages): content_types = webdav.pick_content_types( accepted_content_types, ['text/html']) assert content_types == ['text/html'] return render_jinja_page( 'collection.html', accepted_content_languages, collection=self) def get_is_executable(self): return False def get_quota_used_bytes(self): # TODO(jelmer): Ask the store? raise KeyError def get_quota_available_bytes(self): # TODO(jelmer): Ask the store? raise KeyError class Collection(StoreBasedCollection, webdav.Collection): """A generic WebDAV collection.""" class CalendarCollection(StoreBasedCollection, caldav.Calendar): def get_calendar_description(self): return self.store.get_description() def get_calendar_color(self): color = self.store.get_color() if not color: raise KeyError if color and color[0] != '#': color = '#' + color return color def set_calendar_color(self, color): self.store.set_color(color) def get_calendar_timezone(self): # TODO(jelmer): Read a magic file from the store? raise KeyError def set_calendar_timezone(self, content): raise NotImplementedError(self.set_calendar_timezone) def get_supported_calendar_components(self): return ["VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY"] def get_supported_calendar_data_types(self): return [('text/calendar', '1.0'), ('text/calendar', '2.0')] def get_max_date_time(self): return "99991231T235959Z" def get_min_date_time(self): return "00010101T000000Z" def get_max_instances(self): raise KeyError def get_max_attendees_per_instance(self): raise KeyError def get_schedule_outbox_url(self): raise KeyError def get_schedule_inbox_url(self): raise KeyError class AddressbookCollection(StoreBasedCollection, carddav.Addressbook): def get_addressbook_description(self): return self.store.get_description() def set_addressbook_description(self, description): self.store.set_description(description) def get_supported_address_data_types(self): return [('text/vcard', '3.0')] def get_max_resource_size(self): # No resource limit raise KeyError def get_max_image_size(self): # No resource limit raise KeyError def set_addressbook_color(self, color): self.store.set_color(color) def get_addressbook_color(self): color = self.store.get_color() if not color: raise KeyError if color and color[0] != '#': color = '#' + color return color class CollectionSetResource(webdav.Collection): """Resource for calendar sets.""" def __init__(self, backend, relpath): self.backend = backend self.relpath = relpath @classmethod def create(cls, backend, relpath): path = backend._map_to_file_path(relpath) if not os.path.isdir(path): os.makedirs(path) logging.info('Creating %s', path) return cls(backend, relpath) def get_displayname(self): return posixpath.basename(self.relpath) def get_sync_token(self): raise KeyError def get_etag(self): raise KeyError def get_ctag(self): raise KeyError def get_supported_locks(self): return [] def get_active_locks(self): return [] def members(self): ret = [] p = self.backend._map_to_file_path(self.relpath) for name in os.listdir(p): if name.startswith('.'): continue resource = self.get_member(name) ret.append((name, resource)) return ret def get_member(self, name): assert name != '' relpath = posixpath.join(self.relpath, name) p = self.backend._map_to_file_path(relpath) if not os.path.isdir(p): raise KeyError(name) return self.backend.get_resource(relpath) def get_headervalue(self): raise KeyError def get_comment(self): raise KeyError def set_comment(self, comment): raise NotImplementedError(self.set_comment) def get_content_type(self): return 'httpd/unix-directory' def get_content_language(self): raise KeyError def get_content_length(self): raise KeyError def get_last_modified(self): # TODO(jelmer): Find last modified time using store function raise KeyError def delete_member(self, name, etag=None): # This doesn't have any non-collection members. self.get_member(name).destroy() def destroy(self): p = self.backend._map_to_file_path(self.relpath) # RFC2518, section 8.6.2 says this should recursively delete. shutil.rmtree(p) def render(self, accepted_content_types, accepted_content_languages): content_types = webdav.pick_content_types( accepted_content_types, ['text/html']) assert content_types == ['text/html'] return render_jinja_page('root.html', accepted_content_languages) def get_is_executable(self): return False def get_quota_used_bytes(self): # TODO(jelmer): Ask the store? raise KeyError def get_quota_available_bytes(self): # TODO(jelmer): Ask the store? raise KeyError class RootPage(webdav.Resource): """A non-DAV resource.""" resource_types = [] def __init__(self, backend): self.backend = backend def render(self, accepted_content_types, accepted_content_languages): content_types = webdav.pick_content_types( accepted_content_types, ['text/html']) assert content_types == ['text/html'] return render_jinja_page('root.html', accepted_content_languages) def get_body(self): raise KeyError def get_content_length(self): raise KeyError def get_content_type(self): return 'text/html' def get_supported_locks(self): return [] def get_active_locks(self): return [] def get_etag(self): h = hashlib.md5() for c in self.get_body(): h.update(c) return h.hexdigest() def get_last_modified(self): raise KeyError def get_content_language(self): return ['en-UK'] def get_member(self, name): return self.backend.get_resource(name) def delete_member(self, name, etag=None): # This doesn't have any non-collection members. self.get_member(name).destroy() def get_is_executable(self): return False def get_quota_used_bytes(self): # TODO(jelmer): Ask the store? raise KeyError def get_quota_available_bytes(self): # TODO(jelmer): Ask the store? raise KeyError class Principal(webdav.Principal): def get_principal_url(self): return '.' def get_calendar_home_set(self): return CALENDAR_HOME_SET def get_addressbook_home_set(self): return ADDRESSBOOK_HOME_SET def get_calendar_user_address_set(self): # TODO(jelmer): Make this configurable ret = [] try: (fullname, email) = parseaddr(os.environ['EMAIL']) except KeyError: pass else: ret.append('mailto:' + email) return ret def set_infit_settings(self, settings): relpath = posixpath.join(self.relpath, '.infit') p = self.backend._map_to_file_path(relpath) with open(p, 'w') as f: f.write(settings) def get_infit_settings(self): relpath = posixpath.join(self.relpath, '.infit') p = self.backend._map_to_file_path(relpath) if not os.path.exists(p): raise KeyError with open(p, 'r') as f: return f.read() def get_group_membership(self): """Get group membership URLs.""" return [] def get_calendar_user_type(self): # TODO(jelmer) return scheduling.CALENDAR_USER_TYPE_INDIVIDUAL def get_calendar_proxy_read_for(self): # TODO(jelmer) return [] def get_calendar_proxy_write_for(self): # TODO(jelmer) return [] class PrincipalBare(CollectionSetResource, Principal): """Principal user resource.""" resource_types = [webdav.PRINCIPAL_RESOURCE_TYPE] @classmethod def create(cls, backend, relpath): p = super(PrincipalBare, cls).create(backend, relpath) to_create = set() to_create.update(p.get_addressbook_home_set()) to_create.update(p.get_calendar_home_set()) for n in to_create: try: backend.create_collection(posixpath.join(relpath, n)) except FileExistsError: pass return p class PrincipalCollection(Collection, Principal): """Principal user resource.""" resource_types = (webdav.Collection.resource_types + [webdav.PRINCIPAL_RESOURCE_TYPE]) @classmethod def create(cls, backend, relpath): p = super(PrincipalCollection, cls).create(backend, relpath) p.store.set_type(STORE_TYPE_PRINCIPAL) to_create = set() to_create.update(p.get_addressbook_home_set()) to_create.update(p.get_calendar_home_set()) for n in to_create: try: backend.create_collection(posixpath.join(relpath, n)) except FileExistsError: pass return p @functools.lru_cache(maxsize=STORE_CACHE_SIZE) def open_store_from_path(path): store = GitStore.open_from_path(path) store.load_extra_file_handler(ICalendarFile) store.load_extra_file_handler(VCardFile) return store class XandikosBackend(webdav.Backend): def __init__(self, path): self.path = path self._user_principals = set() def _map_to_file_path(self, relpath): return os.path.join(self.path, relpath.lstrip('/')) def _mark_as_principal(self, path): self._user_principals.add(posixpath.normpath(path)) def create_collection(self, relpath): p = self._map_to_file_path(relpath) return Collection(self, relpath, TreeGitStore.create(p)) def create_principal(self, relpath, create_defaults=False): principal = PrincipalBare.create(self, relpath) self._mark_as_principal(relpath) if create_defaults: create_principal_defaults(self, principal) def get_resource(self, relpath): relpath = posixpath.normpath(relpath) if relpath == '/': return RootPage(self) p = self._map_to_file_path(relpath) if p is None: return None if os.path.isdir(p): try: store = open_store_from_path(p) except NotStoreError: if relpath in self._user_principals: return PrincipalBare(self, relpath) return CollectionSetResource(self, relpath) else: return { STORE_TYPE_CALENDAR: CalendarCollection, STORE_TYPE_ADDRESSBOOK: AddressbookCollection, STORE_TYPE_PRINCIPAL: PrincipalCollection, STORE_TYPE_OTHER: Collection }[store.get_type()](self, relpath, store) else: (basepath, name) = os.path.split(relpath) assert name != '', 'path is %r' % relpath store = self.get_resource(basepath) if store is None: return None if webdav.COLLECTION_RESOURCE_TYPE not in store.resource_types: return None try: return store.get_member(name) except KeyError: return None class XandikosApp(webdav.WebDAVApp): """A wsgi App that provides a Xandikos web server. """ def __init__(self, backend, current_user_principal_href): super(XandikosApp, self).__init__(backend) self.register_properties([ webdav.ResourceTypeProperty(), webdav.CurrentUserPrincipalProperty( current_user_principal_href), webdav.PrincipalURLProperty(), webdav.DisplayNameProperty(), webdav.GetETagProperty(), webdav.GetContentTypeProperty(), webdav.GetContentLengthProperty(), webdav.GetContentLanguageProperty(), caldav.CalendarHomeSetProperty(), carddav.AddressbookHomeSetProperty(), caldav.CalendarDescriptionProperty(), caldav.CalendarColorProperty(), caldav.SupportedCalendarComponentSetProperty(), carddav.AddressbookDescriptionProperty(), carddav.PrincipalAddressProperty(), webdav.AppleGetCTagProperty(), webdav.DAVGetCTagProperty(), carddav.SupportedAddressDataProperty(), webdav.SupportedReportSetProperty(self.reporters), sync.SyncTokenProperty(), caldav.SupportedCalendarDataProperty(), caldav.CalendarTimezoneProperty(), caldav.MinDateTimeProperty(), caldav.MaxDateTimeProperty(), carddav.MaxResourceSizeProperty(), carddav.MaxImageSizeProperty(), access.CurrentUserPrivilegeSetProperty(), access.OwnerProperty(), webdav.CreationDateProperty(), webdav.SupportedLockProperty(), webdav.LockDiscoveryProperty(), infit.AddressbookColorProperty(), infit.SettingsProperty(), infit.HeaderValueProperty(), webdav.CommentProperty(), scheduling.CalendarUserAddressSetProperty(), scheduling.ScheduleInboxURLProperty(), scheduling.ScheduleOutboxURLProperty(), scheduling.CalendarUserTypeProperty(), webdav.GetLastModifiedProperty(), timezones.TimezoneServiceSetProperty([]), webdav.AddMemberProperty(), caldav.MaxInstancesProperty(), caldav.MaxAttendeesPerInstanceProperty(), access.GroupMembershipProperty(), apache.ExecutableProperty(), caldav.CalendarProxyReadForProperty(), caldav.CalendarProxyWriteForProperty(), quota.QuotaAvailableBytesProperty(), quota.QuotaUsedBytesProperty(), ]) self.register_reporters([ caldav.CalendarMultiGetReporter(), caldav.CalendarQueryReporter(), carddav.AddressbookMultiGetReporter(), carddav.AddressbookQueryReporter(), webdav.ExpandPropertyReporter(), sync.SyncCollectionReporter(), caldav.FreeBusyQueryReporter(), ]) self.register_methods([ caldav.MkcalendarMethod(), ]) class WellknownRedirector(object): """Redirect paths under .well-known/ to the appropriate paths.""" def __init__(self, inner_app, dav_root): self._inner_app = inner_app self._dav_root = dav_root def __call__(self, environ, start_response): # See https://tools.ietf.org/html/rfc6764 path = posixpath.normpath( environ['SCRIPT_NAME'] + environ['PATH_INFO']) if path in WELLKNOWN_DAV_PATHS: start_response('302 Found', [ ('Location', self._dav_root)]) return [] return self._inner_app(environ, start_response) def create_principal_defaults(backend, principal): """Create default calendar and addressbook for a principal. :param backend: Backend in which the principal exists. :param principal: Principal object """ calendar_path = posixpath.join(principal.relpath, principal.get_calendar_home_set()[0], 'calendar') try: resource = backend.create_collection(calendar_path) except FileExistsError: pass else: resource.store.set_type(STORE_TYPE_CALENDAR) logging.info('Create calendar in %s.', resource.store.path) addressbook_path = posixpath.join(principal.relpath, principal.get_addressbook_home_set()[0], 'addressbook') try: resource = backend.create_collection(addressbook_path) except FileExistsError: pass else: resource.store.set_type(STORE_TYPE_ADDRESSBOOK) logging.info('Create addressbook in %s.', resource.store.path) def main(argv): import argparse import sys from xandikos import __version__ parser = argparse.ArgumentParser( usage="%(prog)s -d ROOT-DIR [OPTIONS]", prog=argv[0]) parser.add_argument( '--version', action='version', version='%(prog)s ' + '.'.join(map(str, __version__))) access_group = parser.add_argument_group(title="Access Options") access_group.add_argument( "-l", "--listen_address", dest="listen_address", default="localhost", help="Binding IP address. [%(default)s]") access_group.add_argument( "-p", "--port", dest="port", type=int, default=8080, help="Port to listen on. [%(default)s]") access_group.add_argument( "--route-prefix", default="/", help=( "Path to Xandikos. " "(useful when Xandikos is behind a reverse proxy) " "[%(default)s]")) parser.add_argument( "-d", "--directory", dest="directory", default=None, help="Directory to serve from.") parser.add_argument( "--current-user-principal", default="/user/", help="Path to current user principal. [%(default)s]") parser.add_argument( "--autocreate", action="store_true", dest="autocreate", help="Automatically create necessary directories.") parser.add_argument( "--defaults", action="store_true", dest="defaults", help=("Create initial calendar and address book. " "Implies --autocreate.")) options = parser.parse_args(argv[1:]) if options.directory is None: parser.print_usage() sys.exit(1) logging.basicConfig(level=logging.INFO) backend = XandikosBackend(options.directory) backend._mark_as_principal(options.current_user_principal) if options.autocreate or options.defaults: if not os.path.isdir(options.directory): os.makedirs(options.directory) backend.create_principal( options.current_user_principal, create_defaults=options.defaults) if not os.path.isdir(options.directory): logging.warning( '%r does not exist. Run xandikos with --autocreate?', options.directory) if not backend.get_resource(options.current_user_principal): logging.warning( 'default user principal %s does not exist. ' 'Run xandikos with --autocreate?', options.current_user_principal) current_user_principal_href = posixpath.join( options.route_prefix, options.current_user_principal.lstrip('/')) app = XandikosApp( backend, current_user_principal_href=current_user_principal_href) from wsgiref.simple_server import make_server app = WellknownRedirector(app, options.route_prefix) server = make_server(options.listen_address, options.port, app) logging.info('Listening on %s:%s', options.listen_address, options.port) import signal def handle_sigterm(sig, action): sys.exit(0) signal.signal(signal.SIGTERM, handle_sigterm) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.shutdown() if __name__ == '__main__': import sys main(sys.argv) xandikos-0.0.6/xandikos/access.py0000644000175000017500000000436313126171271017555 0ustar jelmerjelmer00000000000000# 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. """Access control. See http://www.webdav.org/specs/rfc3744.html """ from xandikos import webdav ET = webdav.ET # Feature to advertise access control support. FEATURE = 'access-control' class CurrentUserPrivilegeSetProperty(webdav.Property): """current-user-privilege-set property See http://www.webdav.org/specs/rfc3744.html, section 3.7 """ name = '{DAV:}current-user-privilege-set' in_allprops = False live = True def get_value(self, href, resource, el): privilege = ET.SubElement(el, '{DAV:}privilege') # TODO(jelmer): Use something other than all ET.SubElement(privilege, '{DAV:}all') class OwnerProperty(webdav.Property): """owner property. See http://www.webdav.org/specs/rfc3744.html, section 5.1 """ name = '{DAV:}owner' in_allprops = False live = True def get_value(self, base_href, resource, el): owner_href = resource.get_owner() if owner_href is not None: el.append(webdav.create_href(owner_href, base_href=base_href)) class GroupMembershipProperty(webdav.Property): """Group membership. See https://www.ietf.org/rfc/rfc3744.txt, section 4.4 """ name = '{DAV:}group-membership' in_allprops = False live = True resource_type = webdav.PRINCIPAL_RESOURCE_TYPE def get_value(self, base_href, resource, el): for href in resource.get_group_membership(): el.append(webdav.create_href(href, base_href=href)) xandikos-0.0.6/xandikos/tests/0000755000175000017500000000000013131770501017073 5ustar jelmerjelmer00000000000000xandikos-0.0.6/xandikos/tests/test_store.py0000644000175000017500000002650313126171271021651 0ustar jelmerjelmer00000000000000# 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. import logging import os import tempfile import shutil import stat import unittest from dulwich.objects import Blob, Commit, Tree from dulwich.repo import Repo from xandikos.icalendar import ICalendarFile from xandikos.store import ( GitStore, BareGitStore, TreeGitStore, DuplicateUidError, File, InvalidETag, NoSuchItem) EXAMPLE_VCALENDAR1 = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED:20150314T223512Z DTSTAMP:20150527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff END:VTODO END:VCALENDAR """ EXAMPLE_VCALENDAR2 = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED:20120314T223512Z DTSTAMP:20130527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something else UID:bdc22764-b9e1-42c9-89c2-a85405d8fbff END:VTODO END:VCALENDAR """ EXAMPLE_VCALENDAR_NO_UID = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED:20120314T223512Z DTSTAMP:20130527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something without uid END:VTODO END:VCALENDAR """ class BaseStoreTest(object): def test_import_one(self): gc = self.create_store() (name, etag) = gc.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1]) self.assertIsInstance(etag, str) self.assertEqual([('foo.ics', 'text/calendar', etag)], list(gc.iter_with_etag())) def test_import_one_duplicate_uid(self): gc = self.create_store() (name, etag) = gc.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1]) self.assertRaises( DuplicateUidError, gc.import_one, 'bar.ics', 'text/calendar', [EXAMPLE_VCALENDAR1]) def test_import_one_duplicate_name(self): gc = self.create_store() (name, etag) = gc.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1]) (name, etag) = gc.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR2], replace_etag=etag) (name, etag) = gc.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1]) self.assertRaises(InvalidETag, gc.import_one, 'foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR2], replace_etag='invalidetag') def test_get_raw(self): gc = self.create_store() (name1, etag1) = gc.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1]) (name2, etag2) = gc.import_one('bar.ics', 'text/calendar', [EXAMPLE_VCALENDAR2]) self.assertEqual( EXAMPLE_VCALENDAR1, b''.join(gc._get_raw('foo.ics', etag1))) self.assertEqual( EXAMPLE_VCALENDAR2, b''.join(gc._get_raw('bar.ics', etag2))) self.assertRaises( KeyError, gc._get_raw, 'missing.ics', '01' * 20) def test_get_file(self): gc = self.create_store() (name1, etag1) = gc.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1]) (name1, etag2) = gc.import_one('bar.ics', 'text/calendar', [EXAMPLE_VCALENDAR2]) f1 = gc.get_file('foo.ics', 'text/calendar', etag1) self.assertEqual(EXAMPLE_VCALENDAR1, b''.join(f1.content)) self.assertEqual('text/calendar', f1.content_type) f2 = gc.get_file('bar.ics', 'text/calendar', etag2) self.assertEqual(EXAMPLE_VCALENDAR2, b''.join(f2.content)) self.assertEqual('text/calendar', f2.content_type) self.assertRaises( KeyError, gc._get_raw, 'missing.ics', '01' * 20) def test_delete_one(self): gc = self.create_store() self.assertEqual([], list(gc.iter_with_etag())) (name1, etag1) = gc.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1]) self.assertEqual( [('foo.ics', 'text/calendar', etag1)], list(gc.iter_with_etag())) gc.delete_one('foo.ics') self.assertEqual([], list(gc.iter_with_etag())) def test_delete_one_with_etag(self): gc = self.create_store() self.assertEqual([], list(gc.iter_with_etag())) (name1, etag1) = gc.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1]) self.assertEqual( [('foo.ics', 'text/calendar', etag1)], list(gc.iter_with_etag())) gc.delete_one('foo.ics', etag=etag1) self.assertEqual([], list(gc.iter_with_etag())) def test_delete_one_nonexistant(self): gc = self.create_store() self.assertRaises(NoSuchItem, gc.delete_one, 'foo.ics') def test_delete_one_invalid_etag(self): gc = self.create_store() self.assertEqual([], list(gc.iter_with_etag())) (name1, etag1) = gc.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1]) (name2, etag2) = gc.import_one('bar.ics', 'text/calendar', [EXAMPLE_VCALENDAR2]) self.assertEqual( set([('foo.ics', 'text/calendar', etag1), ('bar.ics', 'text/calendar', etag2)]), set(gc.iter_with_etag())) self.assertRaises(InvalidETag, gc.delete_one, 'foo.ics', etag=etag2) self.assertEqual( set([('foo.ics', 'text/calendar', etag1), ('bar.ics', 'text/calendar', etag2)]), set(gc.iter_with_etag())) class BaseGitStoreTest(BaseStoreTest): kls = None def create_store(self): raise NotImplementedError(self.create_store) def add_blob(self, gc, name, contents): raise NotImplementedError(self.add_blob) def test_create(self): d = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, d) gc = self.kls.create(os.path.join(d, 'store')) self.assertIsInstance(gc, GitStore) self.assertEqual(gc.repo.path, os.path.join(d, 'store')) def test_iter_with_etag_missing_uid(self): logging.getLogger('').setLevel(logging.ERROR) gc = self.create_store() bid = self.add_blob(gc, 'foo.ics', EXAMPLE_VCALENDAR_NO_UID) self.assertEqual( [('foo.ics', 'text/calendar', bid)], list(gc.iter_with_etag())) gc._scan_uids() logging.getLogger('').setLevel(logging.NOTSET) def test_iter_with_etag(self): gc = self.create_store() bid = self.add_blob(gc, 'foo.ics', EXAMPLE_VCALENDAR1) self.assertEqual( [('foo.ics', 'text/calendar', bid)], list(gc.iter_with_etag())) def test_get_description(self): gc = self.create_store() try: gc.repo.set_description(b'a repo description') except NotImplementedError: self.skipTest('old dulwich version without ' 'MemoryRepo.set_description') self.assertEqual(gc.get_description(), 'a repo description') def test_displayname(self): gc = self.create_store() self.assertIs(None, gc.get_color()) c = gc.repo.get_config() c.set(b'xandikos', b'displayname', b'a name') if getattr(c, 'path', None): c.write_to_path() self.assertEqual('a name', gc.get_displayname()) def test_get_color(self): gc = self.create_store() self.assertIs(None, gc.get_color()) c = gc.repo.get_config() c.set(b'xandikos', b'color', b'334433') if getattr(c, 'path', None): c.write_to_path() self.assertEqual('334433', gc.get_color()) def test_default_no_subdirectories(self): gc = self.create_store() self.assertEqual([], gc.subdirectories()) class GitStoreTest(unittest.TestCase): def test_open_from_path_bare(self): d = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, d) Repo.init_bare(d) gc = GitStore.open_from_path(d) self.assertIsInstance(gc, BareGitStore) self.assertEqual(gc.repo.path, d) def test_open_from_path_tree(self): d = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, d) Repo.init(d) gc = GitStore.open_from_path(d) self.assertIsInstance(gc, TreeGitStore) self.assertEqual(gc.repo.path, d) class BareGitStoreTest(BaseGitStoreTest, unittest.TestCase): kls = BareGitStore def create_store(self): store = BareGitStore.create_memory() store.load_extra_file_handler(ICalendarFile) return store def test_create_memory(self): gc = BareGitStore.create_memory() self.assertIsInstance(gc, GitStore) def add_blob(self, gc, name, contents): b = Blob.from_string(contents) t = Tree() t.add(name.encode('utf-8'), 0o644 | stat.S_IFREG, b.id) c = Commit() c.tree = t.id c.committer = c.author = b'Somebody ' c.commit_time = c.author_time = 800000 c.commit_timezone = c.author_timezone = 0 c.message = b'do something' gc.repo.object_store.add_objects([(b, None), (t, None), (c, None)]) gc.repo[gc.ref] = c.id return b.id.decode('ascii') def test_get_ctag(self): gc = self.create_store() self.assertEqual(Tree().id.decode('ascii'), gc.get_ctag()) self.add_blob(gc, 'foo.ics', EXAMPLE_VCALENDAR1) self.assertEqual( gc._get_current_tree().id.decode('ascii'), gc.get_ctag()) class TreeGitStoreTest(BaseGitStoreTest, unittest.TestCase): kls = TreeGitStore def create_store(self): d = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, d) store = self.kls.create(os.path.join(d, 'store')) store.load_extra_file_handler(ICalendarFile) return store def add_blob(self, gc, name, contents): with open(os.path.join(gc.repo.path, name), 'wb') as f: f.write(contents) gc.repo.stage(name.encode('utf-8')) return Blob.from_string(contents).id.decode('ascii') class ExtractRegularUIDTests(unittest.TestCase): def test_extract_no_uid(self): fi = File([EXAMPLE_VCALENDAR_NO_UID], 'text/bla') self.assertRaises(NotImplementedError, fi.get_uid) xandikos-0.0.6/xandikos/tests/test_icalendar.py0000644000175000017500000000360713126171271022437 0ustar jelmerjelmer00000000000000# 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. """Tests for xandikos.icalendar.""" import unittest from xandikos.icalendar import ICalendarFile EXAMPLE_VCALENDAR1 = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED:20150314T223512Z DTSTAMP:20150527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff END:VTODO END:VCALENDAR """ EXAMPLE_VCALENDAR_NO_UID = b"""\ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN BEGIN:VTODO CREATED:20120314T223512Z DTSTAMP:20130527T221952Z LAST-MODIFIED:20150314T223512Z STATUS:NEEDS-ACTION SUMMARY:do something without uid END:VTODO END:VCALENDAR """ class ExtractCalendarUIDTests(unittest.TestCase): def test_extract_str(self): self.assertEqual( 'bdc22720-b9e1-42c9-89c2-a85405d8fbff', ICalendarFile([EXAMPLE_VCALENDAR1], 'text/calendar').get_uid()) def test_extract_no_uid(self): fi = ICalendarFile([EXAMPLE_VCALENDAR_NO_UID], 'text/calendar') self.assertRaises(KeyError, fi.get_uid) xandikos-0.0.6/xandikos/tests/test_webdav.py0000644000175000017500000004056013126171555021771 0ustar jelmerjelmer00000000000000# 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. from io import BytesIO import logging import unittest from wsgiref.util import setup_testing_defaults from xandikos import webdav from xandikos.webdav import ( Collection, ET, Property, Resource, WebDAVApp ) class WebTestCase(unittest.TestCase): def setUp(self): super(WebTestCase, self).setUp() logging.disable(logging.WARNING) self.addCleanup(logging.disable, logging.NOTSET) def makeApp(self, resources, properties): class Backend(object): get_resource = resources.get app = WebDAVApp(Backend()) app.register_properties(properties) return app class WebTests(WebTestCase): def _method(self, app, method, path): environ = {'PATH_INFO': path, 'REQUEST_METHOD': method, 'SCRIPT_NAME': ''} setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) contents = b''.join(app(environ, start_response)) return _code[0], _headers, contents def lock(self, app, path): return self._method(app, 'LOCK', path) def mkcol(self, app, path): environ = {'PATH_INFO': path, 'REQUEST_METHOD': 'MKCOL', 'SCRIPT_NAME': ''} setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) contents = b''.join(app(environ, start_response)) return _code[0], _headers, contents def delete(self, app, path): environ = {'PATH_INFO': path, 'REQUEST_METHOD': 'DELETE', 'SCRIPT_NAME': ''} setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) contents = b''.join(app(environ, start_response)) return _code[0], _headers, contents def get(self, app, path): environ = {'PATH_INFO': path, 'REQUEST_METHOD': 'GET', 'SCRIPT_NAME': ''} setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) contents = b''.join(app(environ, start_response)) return _code[0], _headers, contents def put(self, app, path, contents): environ = { 'PATH_INFO': path, 'REQUEST_METHOD': 'PUT', 'wsgi.input': BytesIO(contents), 'SCRIPT_NAME': '' } setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) list(app(environ, start_response)) return _code[0], _headers def propfind(self, app, path, body): environ = { 'PATH_INFO': path, 'REQUEST_METHOD': 'PROPFIND', 'wsgi.input': BytesIO(body), 'SCRIPT_NAME': '' } setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) contents = b''.join(app(environ, start_response)) return _code[0], _headers, contents def test_not_found(self): app = self.makeApp({}, []) code, headers, contents = self.get(app, '/.well-known/carddav') self.assertEqual('404 Not Found', code) def test_get_body(self): class TestResource(Resource): def get_body(self): return [b'this is content'] def get_last_modified(self): raise KeyError def get_content_language(self): raise KeyError def get_etag(self): return "myetag" def get_content_type(self): return 'text/plain' app = self.makeApp({'/.well-known/carddav': TestResource()}, []) code, headers, contents = self.get(app, '/.well-known/carddav') self.assertEqual('200 OK', code) self.assertEqual(b'this is content', contents) def test_set_body(self): new_body = [] class TestResource(Resource): def set_body(self, body, replace_etag=None): new_body.extend(body) def get_etag(self): return '"blala"' app = self.makeApp({'/.well-known/carddav': TestResource()}, []) code, headers = self.put( app, '/.well-known/carddav', b'New contents') self.assertEqual('204 No Content', code) self.assertEqual([b'New contents'], new_body) def test_lock_not_allowed(self): app = self.makeApp({}, []) code, headers, contents = self.lock(app, '/resource') self.assertEqual('405 Method Not Allowed', code) self.assertIn( ('Allow', ('DELETE, GET, HEAD, MKCOL, OPTIONS, ' 'POST, PROPFIND, PROPPATCH, PUT, REPORT')), headers) self.assertEqual(b'', contents) def test_mkcol_ok(self): class Backend(object): def create_collection(self, relpath): pass def get_resource(self, relpath): return None app = WebDAVApp(Backend()) code, headers, contents = self.mkcol(app, '/resource/bla') self.assertEqual('201 Created', code) self.assertEqual(b'', contents) def test_mkcol_exists(self): app = self.makeApp({ '/resource': Resource(), '/resource/bla': Resource()}, []) code, headers, contents = self.mkcol(app, '/resource/bla') self.assertEqual('405 Method Not Allowed', code) self.assertEqual(b'', contents) def test_delete(self): class TestResource(Collection): def get_etag(self): return '"foo"' def delete_member(unused_self, name, etag=None): self.assertEqual(name, 'resource') app = self.makeApp({'/': TestResource(), '/resource': TestResource()}, []) code, headers, contents = self.delete(app, '/resource') self.assertEqual('204 No Content', code) self.assertEqual(b'', contents) def test_delete_not_found(self): class TestResource(Collection): pass app = self.makeApp({'/resource': TestResource()}, []) code, headers, contents = self.delete(app, '/resource') self.assertEqual('404 Not Found', code) self.assertTrue(contents.endswith(b'/resource not found.')) def test_propfind_prop_does_not_exist(self): app = self.makeApp({'/resource': Resource()}, []) code, headers, contents = self.propfind(app, '/resource', b"""\ """) self.assertMultiLineEqual( contents.decode('utf-8'), '' '/resource' 'HTTP/1.1 404 Not Found' '' '') self.assertEqual(code, '207 Multi-Status') def test_propfind_prop_not_present(self): class TestProperty(Property): name = '{DAV:}current-user-principal' def get_value(self, href, resource, ret): raise KeyError app = self.makeApp({'/resource': Resource()}, [TestProperty()]) code, headers, contents = self.propfind(app, '/resource', b"""\ """) self.assertMultiLineEqual( contents.decode('utf-8'), '' '/resource' 'HTTP/1.1 404 Not Found' '' '') self.assertEqual(code, '207 Multi-Status') def test_propfind_found(self): class TestProperty(Property): name = '{DAV:}current-user-principal' def get_value(self, href, resource, ret): ET.SubElement(ret, '{DAV:}href').text = '/user/' app = self.makeApp({'/resource': Resource()}, [TestProperty()]) code, headers, contents = self.propfind(app, '/resource', b"""\ \ """) self.assertMultiLineEqual( contents.decode('utf-8'), '' '/resource' 'HTTP/1.1 200 OK' '/user/' '' '') self.assertEqual(code, '207 Multi-Status') def test_propfind_found_multi(self): class TestProperty1(Property): name = '{DAV:}current-user-principal' def get_value(self, href, resource, el): ET.SubElement(el, '{DAV:}href').text = '/user/' class TestProperty2(Property): name = '{DAV:}somethingelse' def get_value(self, href, resource, el): pass app = self.makeApp( {'/resource': Resource()}, [TestProperty1(), TestProperty2()] ) code, headers, contents = self.propfind(app, '/resource', b"""\ \ """) self.maxDiff = None self.assertMultiLineEqual( contents.decode('utf-8'), '' '/resource' 'HTTP/1.1 200 OK' '/user/' '' '') self.assertEqual(code, '207 Multi-Status') def test_propfind_found_multi_status(self): class TestProperty(Property): name = '{DAV:}current-user-principal' def get_value(self, href, resource, ret): ET.SubElement(ret, '{DAV:}href').text = '/user/' app = self.makeApp({'/resource': Resource()}, [TestProperty()]) code, headers, contents = self.propfind(app, '/resource', b"""\ \ """) self.maxDiff = None self.assertEqual(code, '207 Multi-Status') self.assertMultiLineEqual( contents.decode('utf-8'), """\ /resource\ HTTP/1.1 200 OK\ /user/\ \ HTTP/1.1 404 Not Found\ \ \ """) class PickContentTypesTests(unittest.TestCase): def test_not_acceptable(self): self.assertRaises( webdav.NotAcceptableError, webdav.pick_content_types, [('text/plain', {})], ['text/html']) self.assertRaises( webdav.NotAcceptableError, webdav.pick_content_types, [('text/plain', {}), ('text/html', {'q': '0'})], ['text/html']) def test_highest_q(self): self.assertEqual( ['text/plain'], webdav.pick_content_types( [('text/html', {'q': '0.3'}), ('text/plain', {'q': '0.4'})], ['text/plain', 'text/html'])) self.assertEqual( ['text/html', 'text/plain'], webdav.pick_content_types( [('text/html', {}), ('text/plain', {'q': '1'})], ['text/plain', 'text/html'])) def test_no_q(self): self.assertEqual( ['text/html', 'text/plain'], webdav.pick_content_types( [('text/html', {}), ('text/plain', {})], ['text/plain', 'text/html'])) def test_wildcard(self): self.assertEqual( ['text/plain'], webdav.pick_content_types( [('text/*', {'q': '0.3'}), ('text/plain', {'q': '0.4'})], ['text/plain', 'text/html'])) self.assertEqual( set(['text/plain', 'text/html']), set(webdav.pick_content_types( [('text/*', {'q': '0.4'}), ('text/plain', {'q': '0.3'})], ['text/plain', 'text/html']))) self.assertEqual( ['application/html'], webdav.pick_content_types( [('application/*', {'q': '0.4'}), ('text/plain', {'q': '0.3'})], ['text/plain', 'application/html'])) class ParseAcceptHeaderTests(unittest.TestCase): def test_parse(self): self.assertEqual([], webdav.parse_accept_header('')) self.assertEqual([('text/plain', {'q': '0.1'})], webdav.parse_accept_header('text/plain; q=0.1')) self.assertEqual([('text/plain', {'q': '0.1'}), ('text/plain', {})], webdav.parse_accept_header( 'text/plain; q=0.1, text/plain')) class ETagMatchesTests(unittest.TestCase): def test_matches(self): self.assertTrue(webdav.etag_matches('etag1, etag2', 'etag1')) self.assertFalse(webdav.etag_matches('etag3, etag2', 'etag1')) self.assertFalse(webdav.etag_matches('etag1 etag2', 'etag1')) self.assertFalse(webdav.etag_matches('etag1, etag2', None)) self.assertTrue(webdav.etag_matches('*, etag2', 'etag1')) self.assertTrue(webdav.etag_matches('*', 'etag1')) self.assertFalse(webdav.etag_matches('*', None)) class PropstatByStatusTests(unittest.TestCase): def test_none(self): self.assertEqual({}, webdav.propstat_by_status([])) def test_one(self): self.assertEqual({ ('200 OK', None): ['foo']}, webdav.propstat_by_status([ webdav.PropStatus('200 OK', None, 'foo')])) def test_multiple(self): self.assertEqual({ ('200 OK', None): ['foo'], ('404 Not Found', 'Cannot find'): ['bar']}, webdav.propstat_by_status([ webdav.PropStatus('200 OK', None, 'foo'), webdav.PropStatus('404 Not Found', 'Cannot find', 'bar')])) class PropstatAsXmlTests(unittest.TestCase): def test_none(self): self.assertEqual([], list(webdav.propstat_as_xml([]))) def test_one(self): self.assertEqual([ b'HTTP/1.1 200 ' b'OK'], [ET.tostring(x) for x in webdav.propstat_as_xml([ webdav.PropStatus('200 OK', None, ET.Element('foo'))])]) class PathFromEnvironTests(unittest.TestCase): def test_ascii(self): self.assertEqual( '/bla', webdav.path_from_environ({'PATH_INFO': '/bla'}, 'PATH_INFO')) def test_recode(self): self.assertEqual( '/blü', webdav.path_from_environ( {'PATH_INFO': '/bl\xc3\xbc'}, 'PATH_INFO')) xandikos-0.0.6/xandikos/tests/__init__.py0000644000175000017500000000211413126171271021205 0ustar jelmerjelmer00000000000000# 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. import unittest def test_suite(): names = [ 'caldav', 'icalendar', 'store', 'webdav', 'web', ] module_names = ['xandikos.tests.test_' + name for name in names] loader = unittest.TestLoader() return loader.loadTestsFromNames(module_names) xandikos-0.0.6/xandikos/tests/test_caldav.py0000644000175000017500000000471613126171271021751 0ustar jelmerjelmer00000000000000# 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. from wsgiref.util import setup_testing_defaults from xandikos import caldav from xandikos.webdav import Property, WebDAVApp, ET from xandikos.tests import test_webdav class WebTests(test_webdav.WebTestCase): def makeApp(self, backend): app = WebDAVApp(backend) app.register_methods([caldav.MkcalendarMethod()]) return app def mkcalendar(self, app, path): environ = {'PATH_INFO': path, 'REQUEST_METHOD': 'MKCALENDAR', 'SCRIPT_NAME': ''} setup_testing_defaults(environ) _code = [] _headers = [] def start_response(code, headers): _code.append(code) _headers.extend(headers) contents = b''.join(app(environ, start_response)) return _code[0], _headers, contents def test_mkcalendar_ok(self): class Backend(object): def create_collection(self, relpath): pass def get_resource(self, relpath): return None class ResourceTypeProperty(Property): name = '{DAV:}resourcetype' def get_value(unused_self, href, resource, ret): ET.SubElement(ret, '{DAV:}collection') def set_value(unused_self, href, resource, ret): self.assertEqual( ['{DAV:}collection', '{urn:ietf:params:xml:ns:caldav}calendar'], [x.tag for x in ret]) app = self.makeApp(Backend()) app.register_properties([ResourceTypeProperty()]) code, headers, contents = self.mkcalendar(app, '/resource/bla') self.assertEqual('201 Created', code) self.assertEqual(b'', contents) xandikos-0.0.6/xandikos/tests/test_web.py0000644000175000017500000000143713126171271021271 0ustar jelmerjelmer00000000000000# 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. xandikos-0.0.6/xandikos/sync.py0000644000175000017500000001061613126171271017266 0ustar jelmerjelmer00000000000000# 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. """Calendar synchronisation. See https://tools.ietf.org/html/rfc6578 """ import itertools import urllib.parse from xandikos import webdav ET = webdav.ET class SyncToken(object): """A sync token wrapper.""" def __init__(self, token): self.token = token def aselement(self): ret = ET.Element('{DAV:}sync-token') ret.text = self.token return ret class SyncCollectionReporter(webdav.Reporter): """sync-collection reporter implementation. See https://tools.ietf.org/html/rfc6578, section 3.2. """ name = '{DAV:}sync-collection' @webdav.multistatus def report(self, environ, request_body, resources_by_hrefs, properties, href, resource, depth): old_token = None sync_level = None limit = None requested = None for el in request_body: if el.tag == '{DAV:}sync-token': old_token = el.text elif el.tag == '{DAV:}sync-level': sync_level = el.text elif el.tag == '{DAV:}limit': limit = el.text elif el.tag == '{DAV:}prop': requested = list(el) else: raise webdav.BadRequestError('unknown tag %s' % el.tag) # TODO(jelmer): Implement sync_level infinite if sync_level not in ("1", ): raise webdav.BadRequestError( "sync level %r unsupported" % sync_level) new_token = resource.get_sync_token() try: diff_iter = resource.iter_differences_since(old_token, new_token) except NotImplementedError: yield webdav.Status( href, '403 Forbidden', error=ET.Element('{DAV:}sync-traversal-supported')) return if limit is not None: try: [nresults_el] = list(limit) except ValueError: raise webdav.BadRequestError( 'Invalid number of subelements in limit') try: nresults = int(nresults_el.text) except ValueError: raise webdav.BadRequestError( 'nresults not a number') diff_iter = itertools.islice(diff_iter, nresults) for (name, old_resource, new_resource) in diff_iter: propstat = [] if new_resource is None: for prop in requested: propstat.append( webdav.PropStatus('404 Not Found', None, ET.Element(prop.tag))) else: for prop in requested: if old_resource is not None: old_propstat = webdav.get_property_from_element( href, old_resource, properties, prop) else: old_propstat = None new_propstat = webdav.get_property_from_element( href, new_resource, properties, prop) if old_propstat != new_propstat: propstat.append(new_propstat) yield webdav.Status( urllib.parse.urljoin(webdav.ensure_trailing_slash(href), name), propstat=propstat) yield SyncToken(new_token) class SyncTokenProperty(webdav.Property): """sync-token property. See https://tools.ietf.org/html/rfc6578, section 4 """ name = '{DAV:}sync-token' resource_type = webdav.COLLECTION_RESOURCE_TYPE in_allprops = False live = True def get_value(self, href, resource, el): el.text = resource.get_sync_token() xandikos-0.0.6/xandikos/templates/0000755000175000017500000000000013131770501017727 5ustar jelmerjelmer00000000000000xandikos-0.0.6/xandikos/templates/root.html0000644000175000017500000000055213131264017021602 0ustar jelmerjelmer00000000000000 Xandikos WebDAV server

This is a Xandikos WebDAV server.

For more information about Xandikos, see https://www.xandikos.org/ or https://github.com/jelmer/xandikos.

xandikos-0.0.6/xandikos/templates/collection.html0000644000175000017500000000124413131264017022751 0ustar jelmerjelmer00000000000000 WebDAV Collection - {{ collection.get_displayname() }}

{{ collection.get_displayname() }}

This is a collection.

Subcollections

    {% for name, resource in collection.members() %} {% if '{DAV:}collection' in resource.resource_types %}
  • {{ name }}
  • {% endif %} {% endfor %}

For more information about Xandikos, see https://www.xandikos.org/ or https://github.com/jelmer/xandikos.

xandikos-0.0.6/xandikos/apache.py0000644000175000017500000000277613126171271017543 0ustar jelmerjelmer00000000000000# 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. """Apache.org mod_dav custom properties. See http://www.webdav.org/mod_dav/ """ from xandikos import webdav class ExecutableProperty(webdav.Property): """executable property Equivalent of the 'x' bit on POSIX. """ name = '{http://apache.org/dav/props/}executable' resource_type = None live = False def get_value(self, href, resource, el): el.text = ('T' if resource.get_is_executable() else 'F') def set_value(self, href, resource, el): if el.text == 'T': resource.set_is_executable(True) elif el.text == 'F': resource.set_is_executable(False) else: raise ValueError( 'invalid executable setting %r' % el.text) xandikos-0.0.6/xandikos/carddav.py0000644000175000017500000002536313126171271017723 0ustar jelmerjelmer00000000000000# 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 xandikos 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 def get_value(self, base_href, resource, el): 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') def get_value_ext(self, href, resource, el, requested): # TODO(jelmer): Support subproperties # TODO(jelmer): Don't hardcode encoding el.text = b''.join(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 def get_value(self, href, resource, el): el.text = resource.get_addressbook_description() 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): raise NotImplementedError(self.get_addressbook_description) def set_addressbook_description(self, description): raise NotImplementedError(self.set_addressbook_description) def get_addressbook_color(self): raise NotImplementedError(self.get_addressbook_color) def set_addressbook_color(self, color): raise NotImplementedError(self.set_addressbook_color) def get_supported_address_data_types(self): """Get list of supported data types. :return: List of tuples with content type and version """ raise NotImplementedError(self.get_supported_address_data_types) def get_max_resource_size(self): """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): """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): """Return set of addressbook home URLs. :return: set of URLs """ raise NotImplementedError(self.get_addressbook_home_set) def get_principal_address(self): """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 def get_value(self, href, resource, el): 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 def get_value(self, href, resource, el): 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 def get_value(self, href, resource, el): 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 def get_value(self, href, resource, el): el.text = str(resource.get_max_image_size()) def addressbook_from_resource(resource): try: if resource.get_content_type() != 'text/vcard': return None except KeyError: return None return resource.file.addressbook def apply_text_match(el, value): collation = el.get('collation', 'i;ascii-casemap') negate_condition = el.get('negate-condition', 'no') # TODO(jelmer): Handle match-type: 'contains', 'equals', 'starts-with', # 'ends-with' match_type = el.get('match-type', 'contains') if match_type != 'contains': raise NotImplementedError('match_type != contains: %r' % match_type) matches = davcommon.collations[collation](el.text, value) 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') # 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 subel in el: if subel.tag == '{urn:ietf:params:xml:ns:carddav}text-match': if not apply_text_match(subel, prop): return False elif subel.tag == '{urn:ietf:params:xml:ns:carddav}param-filter': if not apply_param_filter(subel, prop): return False return True 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 = 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 def report(self, environ, body, resources_by_hrefs, properties, base_href, base_resource, depth): 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: raise webdav.BadRequestError( 'Unknown tag %s in report %s' % (el.tag, self.name)) if limit is not None: try: [nresults_el] = list(limit) except ValueError: raise webdav.BadRequestError( 'Invalid number of subelements in limit') try: nresults = int(nresults_el.text) except ValueError: raise webdav.BadRequestError( 'nresults not a number') else: nresults = None i = 0 for (href, resource) in webdav.traverse_resource( base_resource, base_href, depth): if not 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, requested) yield webdav.Status(href, '200 OK', propstat=list(propstat)) i += 1 xandikos-0.0.6/xandikos/__init__.py0000644000175000017500000000167113131770333020052 0ustar jelmerjelmer00000000000000# encoding: utf-8 # # 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. """CalDAV/CardDAV server.""" __version__ = (0, 0, 6) import defusedxml.ElementTree # noqa: This does some monkey-patching on-load xandikos-0.0.6/xandikos/vcard.py0000644000175000017500000000223613126171271017410 0ustar jelmerjelmer00000000000000# Xandikos # Copyright (C) 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. """VCard file handling. """ from .store import File, InvalidFileContents class VCardFile(File): content_type = 'text/vcard' def validate(self): c = b''.join(self.content).strip() if not c.startswith((b'BEGIN:VCARD\r\n', b'BEGIN:VCARD\n')) or \ not c.endswith(b'\nEND:VCARD'): raise InvalidFileContents(self.content_type, self.content) xandikos-0.0.6/xandikos/infit.py0000644000175000017500000000432213126171271017420 0ustar jelmerjelmer00000000000000# 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. """Inf-It properties. """ from xandikos import webdav, carddav class SettingsProperty(webdav.Property): """settings propety. JSON settings. """ name = '{http://inf-it.com/ns/dav/}settings' resource_type = webdav.PRINCIPAL_RESOURCE_TYPE live = False def get_value(self, href, resource, el): el.text = resource.get_infit_settings() def set_value(self, href, resource, el): resource.set_infit_settings(el.text) class AddressbookColorProperty(webdav.Property): """Provides the addressbook-color property. Contains a RRGGBB code, similar to calendar-color. """ name = '{http://inf-it.com/ns/ab/}addressbook-color' resource_type = carddav.ADDRESSBOOK_RESOURCE_TYPE in_allprops = False def get_value(self, href, resource, el): el.text = resource.get_addressbook_color() def set_value(self, href, resource, el): resource.set_addressbook_color(el.text) class HeaderValueProperty(webdav.Property): """Provides the header-value property. This behaves similar to the hrefLabel setting in caldavzap/carddavmate. """ name = '{http://inf-it.com/ns/dav/}headervalue' resource_type = webdav.COLLECTION_RESOURCE_TYPE in_allprops = False live = False def get_value(self, href, resource, el): el.text = resource.get_headervalue() def set_value(self, href, resource, el): # TODO raise NotImplementedError xandikos-0.0.6/xandikos/wsgi.py0000644000175000017500000000367513131264017017267 0ustar jelmerjelmer00000000000000# 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. """WSGI wrapper for xandikos. """ import logging import os import urllib.parse from xandikos.web import XandikosBackend, XandikosApp backend = XandikosBackend(path=os.environ['XANDIKOSPATH']) if not os.path.isdir(backend.path): if os.getenv('AUTOCREATE'): os.makedirs(os.environ['XANDIKOSPATH']) else: logging.warning('%r does not exist.', backend.path) current_user_principal = os.environ.get('CURRENT_USER_PRINCIPAL', '/user/') if not backend.get_resource(current_user_principal): if os.getenv('AUTOCREATE'): backend.create_principal( current_user_principal, create_defaults=os.environ['AUTOCREATE'] == 'defaults') else: logging.warning( 'default user principal \'%s\' does not exist. Create directory %s' ' or set AUTOCREATE variable?', current_user_principal, backend._map_to_file_path( current_user_principal)) backend._mark_as_principal(current_user_principal) current_user_principal_href = urllib.parse.urljoin( os.environ.get('ROUTE_PREFIX', '/'), current_user_principal.lstrip('/')) app = XandikosApp(backend, current_user_principal_href) xandikos-0.0.6/xandikos/caldav.py0000644000175000017500000006645313126171271017556 0ustar jelmerjelmer00000000000000# 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. """Simple CalDAV server. https://tools.ietf.org/html/rfc4791 """ import datetime import logging import pytz from icalendar.cal import component_factory, Calendar as ICalendar, FreeBusy from icalendar.prop import vDDDTypes, vPeriod, LocalTimezone from xandikos import davcommon, webdav ET = webdav.ET PRODID = '-//Jelmer Vernooij//Xandikos//EN' WELLKNOWN_CALDAV_PATH = "/.well-known/caldav" EXTENDED_MKCOL_FEATURE = 'extended-mkcol' # https://tools.ietf.org/html/rfc4791, section 4.2 CALENDAR_RESOURCE_TYPE = '{urn:ietf:params:xml:ns:caldav}calendar' NAMESPACE = 'urn:ietf:params:xml:ns:caldav' # Feature to advertise to indicate CalDAV support. FEATURE = 'calendar-access' class Calendar(webdav.Collection): resource_types = (webdav.Collection.resource_types + [CALENDAR_RESOURCE_TYPE]) def get_calendar_description(self): """Return the calendar description.""" raise NotImplementedError(self.get_calendar_description) def get_calendar_color(self): """Return the calendar color.""" raise NotImplementedError(self.get_calendar_color) def set_calendar_color(self, color): """Set the calendar color.""" raise NotImplementedError(self.set_calendar_color) def get_calendar_timezone(self): """Return calendar timezone. This should be an iCalendar object with exactly one VTIMEZONE component. """ raise NotImplementedError(self.get_calendar_timezone) def set_calendar_timezone(self): """Set calendar timezone. This should be an iCalendar object with exactly one VTIMEZONE component. """ raise NotImplementedError(self.set_calendar_timezone) def get_supported_calendar_components(self): """Return set of supported calendar components in this calendar. :return: iterable over component names """ raise NotImplementedError(self.get_supported_calendar_components) def get_supported_calendar_data_types(self): """Return supported calendar data types. :return: iterable over (content_type, version) tuples """ raise NotImplementedError(self.get_supported_calendar_data_types) def get_min_date_time(self): """Return minimum datetime property. """ raise NotImplementedError(self.get_min_date_time) def get_max_date_time(self): """Return maximum datetime property. """ raise NotImplementedError(self.get_min_date_time) def get_max_instances(self): """Return maximum number of instances. """ raise NotImplementedError(self.get_max_instances) def get_max_attendees_per_instance(self): """Return maximum number of attendees per instance. """ raise NotImplementedError(self.get_max_attendees_per_instance) class PrincipalExtensions: """CalDAV-specific extensions to DAVPrincipal.""" def get_calendar_home_set(self): """Get the calendar home set. :return: a set of URLs """ raise NotImplementedError(self.get_calendar_home_set) def get_calendar_user_address_set(self): """Get the calendar user address set. :return: a set of URLs (usually mailto:...) """ raise NotImplementedError(self.get_calendar_user_address_set) class CalendarHomeSetProperty(webdav.Property): """calendar-home-set property See https://www.ietf.org/rfc/rfc4791.txt, section 6.2.1. """ name = '{urn:ietf:params:xml:ns:caldav}calendar-home-set' resource_type = '{DAV:}principal' in_allprops = False live = True def get_value(self, base_href, resource, el): for href in resource.get_calendar_home_set(): href = webdav.ensure_trailing_slash(href) el.append(webdav.create_href(href, base_href)) class CalendarDescriptionProperty(webdav.Property): """Provides calendar-description property. https://tools.ietf.org/html/rfc4791, section 5.2.1 """ name = '{urn:ietf:params:xml:ns:caldav}calendar-description' resource_type = CALENDAR_RESOURCE_TYPE def get_value(self, base_href, resource, el): el.text = resource.get_calendar_description() # TODO(jelmer): allow modification of this property def set_value(self, href, resource, el): raise NotImplementedError def extract_from_calendar(incal, outcal, requested): """Extract requested components/properties from calendar. :param incal: Calendar to filter :param outcal: Calendar to write to :param requested: element with requested components/properties :return: A Calendar """ for tag in requested: if tag.tag == ('{%s}comp' % NAMESPACE): for insub in incal.subcomponents: if insub.name == tag.get('name'): outsub = component_factory[insub.name] outcal.add_component(outsub) extract_from_calendar(insub, outsub, tag) elif tag.tag == ('{%s}prop' % NAMESPACE): outcal[tag.get('name')] = incal[tag.get('name')] else: raise AssertionError('invalid element %r' % tag) class CalendarDataProperty(davcommon.SubbedProperty): """calendar-data property See https://tools.ietf.org/html/rfc4791, section 5.2.4 Note that this is not technically a DAV property, and it is thus not registered in the regular webdav server. """ name = '{%s}calendar-data' % NAMESPACE def supported_on(self, resource): return (resource.get_content_type() == 'text/calendar') def get_value_ext(self, base_href, resource, el, requested): if len(requested) == 0: serialized_cal = b''.join(resource.get_body()) else: c = ICalendar() calendar = calendar_from_resource(resource) if calendar is None: raise KeyError extract_from_calendar(calendar, c, requested) serialized_cal = c.to_ical() # TODO(jelmer): Don't hardcode encoding el.text = serialized_cal.decode('utf-8') class CalendarMultiGetReporter(davcommon.MultiGetReporter): name = '{urn:ietf:params:xml:ns:caldav}calendar-multiget' resource_type = CALENDAR_RESOURCE_TYPE data_property = CalendarDataProperty() def apply_prop_filter(el, comp, tzify): name = el.get('name') # From https://tools.ietf.org/html/rfc4791, 9.7.2: # A CALDAV:comp-filter is said to match if: # The CALDAV:prop-filter XML element contains a CALDAV: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:caldav}is-not-defined' ): return name not in comp try: prop = comp[name] except KeyError: return False for subel in el: if subel.tag == '{urn:ietf:params:xml:ns:caldav}time-range': if not apply_time_range_prop(subel, prop, tzify): return False elif subel.tag == '{urn:ietf:params:xml:ns:caldav}text-match': if not apply_text_match(subel, prop): return False elif subel.tag == '{urn:ietf:params:xml:ns:caldav}param-filter': if not apply_param_filter(subel, prop): return False return True def apply_text_match(el, value): collation = el.get('collation', 'i;ascii-casemap') negate_condition = el.get('negate-condition', 'no') matches = davcommon.collations[collation](el.text, value) 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:caldav}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:caldav}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 _parse_time_range(el): start = el.get('start') end = el.get('end') # Either start OR end OR both need to be specified. # https://tools.ietf.org/html/rfc4791, section 9.9 assert start is not None or end is not None if start is None: start = "00010101T000000Z" if end is None: end = "99991231T235959Z" start = vDDDTypes.from_ical(start) end = vDDDTypes.from_ical(end) assert end > start assert end.tzinfo assert start.tzinfo return (start, end) def as_tz_aware_ts(dt, default_timezone): if not getattr(dt, 'time', None): dt = datetime.datetime.combine(dt, datetime.time()) if dt.tzinfo is None: dt = dt.replace(tzinfo=default_timezone) assert dt.tzinfo return dt def apply_time_range_vevent(start, end, comp, tzify): if not (end > tzify(comp['DTSTART'].dt)): return False if 'DTEND' in comp: if tzify(comp['DTEND'].dt) < tzify(comp['DTSTART'].dt): logging.debug('Invalid DTEND < DTSTART') return (start < tzify(comp['DTEND'].dt)) if 'DURATION' in comp: return (start < tzify(comp['DTSTART'].dt) + comp['DURATION'].dt) if getattr(comp['DTSTART'].dt, 'time', None) is not None: return (start <= tzify(comp['DTSTART'].dt)) else: return (start < (tzify(comp['DTSTART'].dt) + datetime.timedelta(1))) def apply_time_range_vjournal(start, end, comp, tzify): if 'DTSTART' not in comp: return False if not (end > tzify(comp['DTSTART'].dt)): return False if getattr(comp['DTSTART'].dt, 'time', None) is not None: return (start <= tzify(comp['DTSTART'].dt)) else: return (start < (tzify(comp['DTSTART'].dt) + datetime.timedelta(1))) def apply_time_range_vtodo(start, end, comp, tzify): if 'DTSTART' in comp: if 'DURATION' in comp and 'DUE' not in comp: return ( start <= tzify(comp['DTSTART'].dt) + comp['DURATION'].dt and (end > tzify(comp['DTSTART'].dt) or end >= tzify(comp['DTSTART'].dt) + comp['DURATION'].dt) ) elif 'DUE' in comp and 'DURATION' not in comp: return ( (start <= tzify(comp['DTSTART'].dt) or start < tzify(comp['DUE'].dt)) and (end > tzify(comp['DTSTART'].dt) or end < tzify(comp['DUE'].dt)) ) else: return (start <= tzify(comp['DTSTART'].dt) and end > tzify(comp['DTSTART'].dt)) elif 'DUE' in comp: return start < tzify(comp['DUE'].dt) and end >= tzify(comp['DUE'].dt) elif 'COMPLETED' in comp: if 'CREATED' in comp: return ( (start <= tzify(comp['CREATED'].dt) or start <= tzify(comp['COMPLETED'].dt)) and (end >= tzify(comp['CREATED'].dt) or end >= tzify(comp['COMPLETED'].dt)) ) else: return ( start <= tzify(comp['COMPLETED'].dt) and end >= tzify(comp['COMPLETED'].dt) ) elif 'CREATED' in comp: return end >= tzify(comp['CREATED'].dt) else: return True def apply_time_range_vfreebusy(start, end, comp, tzify): if 'DTSTART' in comp and 'DTEND' in comp: return ( start <= tzify(comp['DTEND'].dt) and end > tzify(comp['DTEND'].dt) ) for period in comp.get('FREEBUSY', []): if start < period.end and end > period.start: return True return False def apply_time_range_valarm(start, end, comp, tzify): raise NotImplementedError(apply_time_range_valarm) def apply_time_range_comp(el, comp, tzify): # According to https://tools.ietf.org/html/rfc4791, section 9.9 these are # the properties to check. (start, end) = _parse_time_range(el) component_handlers = { 'VEVENT': apply_time_range_vevent, 'VTODO': apply_time_range_vtodo, 'VJOURNAL': apply_time_range_vjournal, 'VFREEBUSY': apply_time_range_vfreebusy, 'VALARM': apply_time_range_valarm} try: component_handler = component_handlers[comp.name] except KeyError: logging.warning('unknown component %r in time-range filter', comp.name) return False return component_handler(start, end, comp, tzify) def apply_time_range_prop(el, val, tzify): (start, end) = _parse_time_range(el) raise NotImplementedError(apply_time_range_prop) def apply_comp_filter(el, comp, tzify): """Compile a comp-filter element into a Python function. """ name = el.get('name') # From https://tools.ietf.org/html/rfc4791, 9.7.1: # A CALDAV:comp-filter is said to match if: # 2. The CALDAV:comp-filter XML element contains a CALDAV:is-not-defined # XML element and the calendar object or calendar component type specified # by the "name" attribute does not exist in the current scope; if ( len(el) == 1 and el[0].tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined' ): return comp.name != name # 1: The CALDAV:comp-filter XML element is empty and the calendar object or # calendar component type specified by the "name" attribute exists in the # current scope; if comp.name != name: return False # 3. The CALDAV:comp-filter XML element contains a CALDAV:time-range XML # element and at least one recurrence instance in the targeted calendar # component is scheduled to overlap the specified time range, and all # specified CALDAV:prop-filter and CALDAV:comp-filter child XML elements # also match the targeted calendar component; for subel in el: if subel.tag == '{urn:ietf:params:xml:ns:caldav}comp-filter': if not any(apply_comp_filter(subel, c, tzify) for c in comp.subcomponents): return False elif subel.tag == '{urn:ietf:params:xml:ns:caldav}prop-filter': if not apply_prop_filter(subel, comp, tzify): return False elif subel.tag == '{urn:ietf:params:xml:ns:caldav}time-range': if not apply_time_range_comp(subel, comp, tzify): return False else: raise AssertionError('unknown filter tag %r' % subel.tag) return True def calendar_from_resource(resource): try: if resource.get_content_type() != 'text/calendar': return None except KeyError: return None return resource.file.calendar def apply_filter(el, resource, tzify): """Compile a filter element into a Python function. """ if el is None: # Empty filter, let's not bother parsing return lambda x: True c = calendar_from_resource(resource) if c is None: return False return apply_comp_filter(list(el)[0], c, tzify) def extract_tzid(cal): return cal.subcomponents[0]['TZID'] def get_pytz_from_text(tztext): tzid = extract_tzid(ICalendar.from_ical(tztext)) return pytz.timezone(tzid) def get_calendar_timezone(resource): try: tztext = resource.get_calendar_timezone() except KeyError: return LocalTimezone() else: return get_pytz_from_text(tztext) class CalendarQueryReporter(webdav.Reporter): name = '{urn:ietf:params:xml:ns:caldav}calendar-query' resource_type = CALENDAR_RESOURCE_TYPE data_property = CalendarDataProperty() @webdav.multistatus def report(self, environ, body, resources_by_hrefs, properties, base_href, base_resource, depth): # TODO(jelmer): Verify that resource is an addressbook requested = None filter_el = None tztext = None for el in body: if el.tag in ('{DAV:}prop', '{DAV:}propname', '{DAV:}allprop'): requested = el elif el.tag == '{urn:ietf:params:xml:ns:caldav}filter': filter_el = el elif el.tag == '{urn:ietf:params:xml:ns:caldav}timezone': tztext = el.text else: raise webdav.BadRequestError( 'Unknown tag %s in report %s' % (el.tag, self.name)) if tztext is not None: tz = get_pytz_from_text(tztext) else: tz = get_calendar_timezone(base_resource) tzify = lambda dt: as_tz_aware_ts(dt, tz) for (href, resource) in webdav.traverse_resource( base_resource, base_href, depth): if not apply_filter(filter_el, resource, tzify): continue propstat = davcommon.get_properties_with_data( self.data_property, href, resource, properties, requested) yield webdav.Status(href, '200 OK', propstat=list(propstat)) class CalendarColorProperty(webdav.Property): """calendar-color property This contains a HTML #RRGGBB color code, as CDATA. """ name = '{http://apple.com/ns/ical/}calendar-color' resource_type = CALENDAR_RESOURCE_TYPE def get_value(self, href, resource, el): el.text = resource.get_calendar_color() def set_value(self, href, resource, el): resource.set_calendar_color(el.text) class SupportedCalendarComponentSetProperty(webdav.Property): """supported-calendar-component-set property Set of supported calendar components by this calendar. See https://www.ietf.org/rfc/rfc4791.txt, section 5.2.3 """ name = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set' resource_type = CALENDAR_RESOURCE_TYPE in_allprops = False live = True def get_value(self, href, resource, el): for component in resource.get_supported_calendar_components(): subel = ET.SubElement(el, '{urn:ietf:params:xml:ns:caldav}comp') subel.set('name', component) class SupportedCalendarDataProperty(webdav.Property): """supported-calendar-data property. See https://tools.ietf.org/html/rfc4791, section 5.2.4 """ name = '{urn:ietf:params:xml:ns:caldav}supported-calendar-data' resource_type = CALENDAR_RESOURCE_TYPE in_allprops = False def get_value(self, href, resource, el): for (content_type, version) in ( resource.get_supported_calendar_data_types()): subel = ET.SubElement( el, '{urn:ietf:params:xml:ns:caldav}calendar-data') subel.set('content-type', content_type) subel.set('version', version) class CalendarTimezoneProperty(webdav.Property): """calendar-timezone property. See https://tools.ietf.org/html/rfc4791, section 5.2.2 """ name = '{urn:ietf:params:xml:ns:caldav}calendar-timezone' resource_type = CALENDAR_RESOURCE_TYPE in_allprops = False def get_value(self, href, resource, el): el.text = resource.get_calendar_timezone() def set_value(self, href, resource, el): if el is not None: resource.set_calendar_timezone(el.text) else: resource.set_calendar_timezone(None) class MinDateTimeProperty(webdav.Property): """min-date-time property. See https://tools.ietf.org/html/rfc4791, section 5.2.6 """ name = '{urn:ietf:params:xml:ns:caldav}min-date-time' resource_type = CALENDAR_RESOURCE_TYPE in_allprops = False live = True def get_value(self, href, resource, el): el.text = resource.get_min_date_time() class MaxDateTimeProperty(webdav.Property): """max-date-time property. See https://tools.ietf.org/html/rfc4791, section 5.2.7 """ name = '{urn:ietf:params:xml:ns:caldav}max-date-time' resource_type = CALENDAR_RESOURCE_TYPE in_allprops = False live = True def get_value(self, href, resource, el): el.text = resource.get_max_date_time() class MaxInstancesProperty(webdav.Property): """max-instances property. See https://tools.ietf.org/html/rfc4791, section 5.2.8 """ name = '{urn:ietf:params:xml:ns:caldav}max-instances' resource_type = CALENDAR_RESOURCE_TYPE in_allprops = False live = True def get_value(self, href, resource, el): el.text = str(resource.get_max_instances()) class MaxAttendeesPerInstanceProperty(webdav.Property): """max-instances property. See https://tools.ietf.org/html/rfc4791, section 5.2.9 """ name = '{urn:ietf:params:xml:ns:caldav}max-attendees-per-instance' resource_type = CALENDAR_RESOURCE_TYPE in_allprops = False live = True def get_value(self, href, resource, el): el.text = str(resource.get_max_attendees_per_instance()) class CalendarProxyReadForProperty(webdav.Property): """calendar-proxy-read-for property. See https://github.com/apple/ccs-calendarserver/blob/master/\ doc/Extensions/caldav-proxy.txt, section 5.3.1. """ name = '{http://calendarserver.org/ns/}calendar-proxy-read-for' resource_type = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = False live = True def get_value(self, base_href, resource, el): for href in resource.get_calendar_proxy_read_for(): el.append(webdav.create_href(href, base_href)) class CalendarProxyWriteForProperty(webdav.Property): """calendar-proxy-write-for property. See https://github.com/apple/ccs-calendarserver/blob/master/\ doc/Extensions/caldav-proxy.txt, section 5.3.2. """ name = '{http://calendarserver.org/ns/}calendar-proxy-write-for' resource_type = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = False live = True def get_value(self, base_href, resource, el): for href in resource.get_calendar_proxy_write_for(): el.append(webdav.create_href(href, base_href)) def map_freebusy(comp): transp = comp.get('TRANSP', 'OPAQUE') if transp == 'TRANSPARENT': return 'FREE' assert transp == 'OPAQUE', 'unknown transp %r' % transp status = comp.get('STATUS', 'CONFIRMED') if status == 'CONFIRMED': return 'BUSY' elif status == 'CANCELLED': return 'FREE' elif status == 'TENTATIVE': return 'BUSY-TENTATIVE' elif status.startswith('X-'): return status else: raise AssertionError('unknown status %r' % status) def extract_freebusy(comp, tzify): kind = map_freebusy(comp) if kind == 'FREE': return None if 'DTEND' in comp: ret = vPeriod((tzify(comp['DTSTART'].dt), tzify(comp['DTEND'].dt))) if 'DURATION' in comp: ret = vPeriod((tzify(comp['DTSTART'].dt), comp['DURATION'].dt)) if kind != 'BUSY': ret.params['FBTYPE'] = kind return ret def iter_freebusy(resources, start, end, tzify): for (href, resource) in resources: c = calendar_from_resource(resource) if c is None: continue if c.name != 'VCALENDAR': continue for comp in c.subcomponents: if comp.name == 'VEVENT': if apply_time_range_vevent(start, end, comp, tzify): vp = extract_freebusy(comp, tzify) if vp is not None: yield vp class FreeBusyQueryReporter(webdav.Reporter): """free-busy-query reporter. See https://tools.ietf.org/html/rfc4791, section 7.10 """ name = '{urn:ietf:params:xml:ns:caldav}free-busy-query' resource_type = CALENDAR_RESOURCE_TYPE def report(self, environ, start_response, body, resources_by_hrefs, properties, base_href, base_resource, depth): requested = None for el in body: if el.tag == '{urn:ietf:params:xml:ns:caldav}time-range': requested = el else: raise AssertionError("unexpected XML element") tz = get_calendar_timezone(base_resource) tzify = lambda dt: as_tz_aware_ts(dt, tz).astimezone(pytz.utc) (start, end) = _parse_time_range(requested) assert start.tzinfo assert end.tzinfo ret = ICalendar() ret['VERSION'] = '2.0' ret['PRODID'] = PRODID fb = FreeBusy() fb['DTSTAMP'] = vDDDTypes(tzify(datetime.datetime.now())) fb['DTSTART'] = vDDDTypes(start) fb['DTEND'] = vDDDTypes(end) fb['FREEBUSY'] = list(iter_freebusy( webdav.traverse_resource(base_resource, base_href, depth), start, end, tzify)) ret.add_component(fb) start_response('200 OK', []) return [ret.to_ical()] class MkcalendarMethod(webdav.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 = webdav.parse_type(content_type) if base_content_type not in ( 'text/xml', 'application/xml', None, 'text/plain' ): raise webdav.UnsupportedMediaType(content_type) href, path, resource = app._get_resource_from_environ(environ) if resource is not None: return webdav._send_simple_dav_error( environ, start_response, '403 Forbidden', error=ET.Element('{DAV:}resource-must-be-null'), description=('Something already exists at %r' % path)) try: resource = app.backend.create_collection(path) except FileNotFoundError: start_response('409 Conflict', []) return [] el = ET.Element('{DAV:}resourcetype') app.properties['{DAV:}resourcetype'].get_value(href, resource, el) ET.SubElement(el, '{urn:ietf:params:xml:ns:caldav}calendar') app.properties['{DAV:}resourcetype'].set_value(href, resource, el) if base_content_type in ('text/xml', 'application/xml'): et = webdav._readXmlBody( environ, '{urn:ietf:params:xml:ns:caldav}mkcalendar') propstat = [] for el in et: if el.tag != '{DAV:}set': raise webdav.BadRequestError( 'Unknown tag %s in mkcalendar' % el.tag) propstat.extend(webdav.apply_modify_prop( el, href, resource, app.properties)) ret = ET.Element( '{urn:ietf:params:xml:ns:carldav:}mkcalendar-response') for propstat_el in webdav.propstat_as_xml(propstat): ret.append(propstat_el) return webdav._send_xml_response( start_response, '201 Created', ret, webdav.DEFAULT_ENCODING) else: start_response('201 Created', []) return [] xandikos-0.0.6/xandikos/store.py0000644000175000017500000006651213126171271017454 0ustar jelmerjelmer00000000000000# 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. """Stores and store sets. ETags (https://en.wikipedia.org/wiki/HTTP_ETag) used in this file are always strong, and should be returned without wrapping quotes. """ import logging import mimetypes import os import shutil import stat import uuid from dulwich.objects import Blob, Tree import dulwich.repo _DEFAULT_COMMITTER_IDENTITY = b'Xandikos ' STORE_TYPE_ADDRESSBOOK = 'addressbook' STORE_TYPE_CALENDAR = 'calendar' STORE_TYPE_PRINCIPAL = 'principal' STORE_TYPE_OTHER = 'other' VALID_STORE_TYPES = ( STORE_TYPE_ADDRESSBOOK, STORE_TYPE_CALENDAR, STORE_TYPE_PRINCIPAL, STORE_TYPE_OTHER) MIMETYPES = mimetypes.MimeTypes() MIMETYPES.add_type('text/calendar', '.ics') MIMETYPES.add_type('text/vcard', '.vcf') DEFAULT_MIME_TYPE = 'application/octet-stream' DEFAULT_ENCODING = 'utf-8' logger = logging.getLogger(__name__) class File(object): """A file type handler.""" def __init__(self, content, content_type): self.content = content self.content_type = content_type def validate(self): """Verify that file contents are valid. :raise InvalidFileContents: Raised if a file is not valid """ pass def describe(self, name): """Describe the contents of this file. Used in e.g. commit messages. """ return name def get_uid(self): """Return UID. :raise NotImplementedError: If UIDs aren't supported for this format :raise KeyError: If there is no UID set on this file :raise InvalidFileContents: If the file is misformatted :return: UID """ raise NotImplementedError(self.get_uid) def describe_delta(self, name, previous): """Describe the important difference between this and previous one. :param name: File name :param previous: Previous file to compare to. :raise InvalidFileContents: If the file is misformatted :return: List of strings describing change """ assert name is not None item_description = self.describe(name) assert item_description is not None if previous is None: yield "Added " + item_description else: yield "Modified " + item_description def open_by_content_type(content, content_type, extra_file_handlers): """Open a file based on content type. :param content: list of bytestrings with content :param content_type: MIME type :return: File instance """ return extra_file_handlers.get(content_type.split(';')[0], File)( content, content_type) def open_by_extension(content, name, extra_file_handlers): """Open a file based on the filename extension. :param content: list of bytestrings with content :param name: Name of file to open :return: File instance """ (mime_type, _) = MIMETYPES.guess_type(name) if mime_type is None: mime_type = DEFAULT_MIME_TYPE return open_by_content_type(content, mime_type, extra_file_handlers=extra_file_handlers) class DuplicateUidError(Exception): """UID already exists in store.""" def __init__(self, uid, existing_name, new_name): self.uid = uid self.existing_name = existing_name self.new_name = new_name class NoSuchItem(Exception): """No such item.""" def __init__(self, name): self.name = name class InvalidETag(Exception): """Unexpected value for etag.""" def __init__(self, name, expected_etag, got_etag): self.name = name self.expected_etag = expected_etag self.got_etag = got_etag class NotStoreError(Exception): """Not a store.""" def __init__(self, path): self.path = path class InvalidFileContents(Exception): """Invalid file contents.""" def __init__(self, content_type, data): self.content_type = content_type self.data = data class Store(object): """A object store.""" def __init__(self): self.extra_file_handlers = {} def load_extra_file_handler(self, file_handler): self.extra_file_handlers[file_handler.content_type] = file_handler def iter_with_etag(self): """Iterate over all items in the store with etag. :yield: (name, content_type, etag) tuples """ raise NotImplementedError(self.iter_with_etag) def get_file(self, name, content_type=None, etag=None): """Get the contents of an object. :return: A File object """ if content_type is None: return open_by_extension( self._get_raw(name, etag), name, extra_file_handlers=self.extra_file_handlers) else: return open_by_content_type( self._get_raw(name, etag), content_type, extra_file_handlers=self.extra_file_handlers) def _get_raw(self, name, etag): """Get the raw contents of an object. :return: raw contents """ raise NotImplementedError(self._get_raw) def get_ctag(self): """Return the ctag for this store.""" raise NotImplementedError(self.get_ctag) def import_one(self, name, data, message=None, author=None, replace_etag=None): """Import a single object. :param name: Name of the object :param data: serialized object as list of bytes :param message: Commit message :param author: Optional author :param replace_etag: Etag to replace :raise NameExists: when the name already exists :raise DuplicateUidError: when the uid already exists :return: (name, etag) """ raise NotImplementedError(self.import_one) def delete_one(self, name, message=None, author=None, etag=None): """Delete an item. :param name: Filename to delete :param author: Optional author :param message: Commit message :param etag: Optional mandatory etag of object to remove :raise NoSuchItem: when the item doesn't exist :raise InvalidETag: If the specified ETag doesn't match the current """ raise NotImplementedError(self.delete_one) def set_type(self, store_type): """Set store type. :param store_type: New store type (one of VALID_STORE_TYPES) """ raise NotImplementedError(self.set_type) def get_type(self): """Get type of this store. :return: one of VALID_STORE_TYPES """ ret = STORE_TYPE_OTHER for (name, content_type, etag) in self.iter_with_etag(): if content_type == 'text/calendar': ret = STORE_TYPE_CALENDAR elif content_type == 'text/vcard': ret = STORE_TYPE_ADDRESSBOOK return ret def set_description(self, description): """Set the extended description of this store. :param description: String with description """ raise NotImplementedError(self.set_description) def get_description(self): """Get the extended description of this store. """ raise NotImplementedError(self.get_description) def get_displayname(self): """Get the display name of this store. """ raise NotImplementedError(self.get_displayname) def set_displayname(self): """Set the display name of this store. """ raise NotImplementedError(self.set_displayname) def get_color(self): """Get the color code for this store.""" raise NotImplementedError(self.get_color) def set_color(self, color): """Set the color code for this store.""" raise NotImplementedError(self.set_color) def iter_changes(self, old_ctag, new_ctag): """Get changes between two versions of this store. :param old_ctag: Old ctag (None for empty Store) :param new_ctag: New ctag :return: Iterator over (name, content_type, old_etag, new_etag) """ raise NotImplementedError(self.iter_changes) def get_comment(self): """Retrieve store comment. :return: Comment """ raise NotImplementedError(self.get_comment) def set_comment(self, comment): """Set comment. :param comment: New comment to set """ raise NotImplementedError(self.set_comment) def destroy(self): """Destroy this store.""" raise NotImplementedError(self.destroy) def subdirectories(self): """Returns subdirectories to probe for other stores. :return: List of names """ raise NotImplementedError(self.subdirectories) class GitStore(Store): """A Store backed by a Git Repository. """ def __init__(self, repo, ref=b'refs/heads/master', check_for_duplicate_uids=True): super(GitStore, self).__init__() self.ref = ref self.repo = repo # Maps uids to (sha, fname) self._uid_to_fname = {} self._check_for_duplicate_uids = check_for_duplicate_uids # Set of blob ids that have already been scanned self._fname_to_uid = {} def __repr__(self): return "%s(%r, ref=%r)" % (type(self).__name__, self.repo, self.ref) @property def path(self): return self.repo.path def _check_duplicate(self, uid, name, replace_etag): if uid is not None and self._check_for_duplicate_uids: self._scan_uids() try: (existing_name, _) = self._uid_to_fname[uid] except KeyError: pass else: if existing_name != name: raise DuplicateUidError(uid, existing_name, name) try: etag = self._get_etag(name) except KeyError: etag = None if replace_etag is not None and etag != replace_etag: raise InvalidETag(name, etag, replace_etag) return etag def import_one(self, name, content_type, data, message=None, author=None, replace_etag=None): """Import a single object. :param name: name of the object :param content_type: Content type :param data: serialized object as list of bytes :param message: Commit message :param author: Optional author :param replace_etag: optional etag of object to replace :raise InvalidETag: when the name already exists but with different etag :raise DuplicateUidError: when the uid already exists :return: etag """ fi = open_by_content_type(data, content_type, self.extra_file_handlers) if name is None: name = str(uuid.uuid4()) extension = MIMETYPES.guess_extension(content_type) if extension is not None: name += extension fi.validate() try: uid = fi.get_uid() except (KeyError, NotImplementedError): uid = None self._check_duplicate(uid, name, replace_etag) if message is None: try: old_fi = self.get_file(name, content_type, replace_etag) except KeyError: old_fi = None message = '\n'.join(fi.describe_delta(name, old_fi)) etag = self._import_one(name, data, message, author=author) return (name, etag.decode('ascii')) def _get_raw(self, name, etag=None): """Get the raw contents of an object. :param name: Name of the item :param etag: Optional etag :return: raw contents as chunks """ if etag is None: etag = self._get_etag(name) blob = self.repo.object_store[etag.encode('ascii')] return blob.chunked def _scan_uids(self): removed = set(self._fname_to_uid.keys()) for (name, mode, sha) in self._iterblobs(): etag = sha.decode('ascii') if name in removed: removed.remove(name) if (name in self._fname_to_uid and self._fname_to_uid[name][0] == etag): continue blob = self.repo.object_store[sha] fi = open_by_extension(blob.chunked, name, self.extra_file_handlers) try: uid = fi.get_uid() except KeyError: logger.warning('No UID found in file %s', name) uid = None except InvalidFileContents: logging.warning('Unable to parse file %s', name) uid = None except NotImplementedError: # This file type doesn't support UIDs uid = None self._fname_to_uid[name] = (etag, uid) if uid is not None: self._uid_to_fname[uid] = (name, etag) for name in removed: (unused_etag, uid) = self._fname_to_uid[name] if uid is not None: del self._uid_to_fname[uid] del self._fname_to_uid[name] def _iterblobs(self, ctag=None): raise NotImplementedError(self._iterblobs) def iter_with_etag(self, ctag=None): """Iterate over all items in the store with etag. :param ctag: Ctag to iterate for :yield: (name, content_type, etag) tuples """ for (name, mode, sha) in self._iterblobs(ctag): (mime_type, _) = MIMETYPES.guess_type(name) if mime_type is None: mime_type = DEFAULT_MIME_TYPE yield (name, mime_type, sha.decode('ascii')) @classmethod def create(cls, path): """Create a new store backed by a Git repository on disk. :return: A `GitStore` """ raise NotImplementedError(cls.create) @classmethod def open_from_path(cls, path): """Open a GitStore from a path. :param path: Path :return: A `GitStore` """ try: return cls.open(dulwich.repo.Repo(path)) except dulwich.repo.NotGitRepository: raise NotStoreError(path) @classmethod def open(cls, repo): """Open a GitStore given a Repo object. :param repo: A Dulwich `Repo` :return: A `GitStore` """ if repo.has_index(): return TreeGitStore(repo) else: return BareGitStore(repo) def get_description(self): """Get extended description. :return: repository description as string """ desc = self.repo.get_description() if desc is not None: desc = desc.decode(DEFAULT_ENCODING) return desc def set_description(self, description): """Set extended description. :param description: repository description as string """ return self.repo.set_description(description.encode(DEFAULT_ENCODING)) def set_comment(self, comment): """Set comment. :param comment: Comment """ config = self.repo.get_config() config.set(b'xandikos', b'comment', comment.encode(DEFAULT_ENCODING)) config.write_to_path() def get_comment(self): """Get comment. :return: Comment """ config = self.repo.get_config() try: comment = config.get(b'xandikos', b'comment') except KeyError: return None else: return comment.decode(DEFAULT_ENCODING) def get_color(self): """Get color. :return: A Color code, or None """ config = self.repo.get_config() try: color = config.get(b'xandikos', b'color') except KeyError: return None else: return color.decode(DEFAULT_ENCODING) def set_color(self, color): """Set the color code for this store.""" config = self.repo.get_config() # Strip leading # to work around # https://github.com/jelmer/dulwich/issues/511 # TODO(jelmer): Drop when that bug gets fixed. config.set( b'xandikos', b'color', color.lstrip('#').encode(DEFAULT_ENCODING) if color else b'') config.write_to_path() def get_displayname(self): """Get display name. :return: The display name, or None if not set """ config = self.repo.get_config() try: displayname = config.get(b'xandikos', b'displayname') except KeyError: return None else: return displayname.decode(DEFAULT_ENCODING) def set_displayname(self, displayname): """Set the display name. :param displayname: New display name """ config = self.repo.get_config() config.set(b'xandikos', b'displayname', displayname.encode(DEFAULT_ENCODING)) config.write_to_path() def set_type(self, store_type): """Set store type. :param store_type: New store type (one of VALID_STORE_TYPES) """ config = self.repo.get_config() config.set(b'xandikos', b'type', store_type.encode(DEFAULT_ENCODING)) config.write_to_path() def get_type(self): """Get store type. This looks in git config first, then falls back to guessing. """ config = self.repo.get_config() try: store_type = config.get(b'xandikos', b'type') except KeyError: return super(GitStore, self).get_type() else: store_type = store_type.decode(DEFAULT_ENCODING) if store_type not in VALID_STORE_TYPES: logging.warning( 'Invalid store type %s set for %r.', store_type, self.repo) return store_type def iter_changes(self, old_ctag, new_ctag): """Get changes between two versions of this store. :param old_ctag: Old ctag (None for empty Store) :param new_ctag: New ctag :return: Iterator over (name, content_type, old_etag, new_etag) """ if old_ctag is None: t = Tree() self.repo.object_store.add_object(t) old_ctag = t.id.decode('ascii') previous = { name: (content_type, etag) for (name, content_type, etag) in self.iter_with_etag(old_ctag) } for (name, new_content_type, new_etag) in ( self.iter_with_etag(new_ctag)): try: (old_content_type, old_etag) = previous[name] except KeyError: old_etag = None else: assert old_content_type == new_content_type if old_etag != new_etag: yield (name, new_content_type, old_etag, new_etag) if old_etag is not None: del previous[name] for (name, (old_content_type, old_etag)) in previous.items(): yield (name, old_content_type, old_etag, None) def destroy(self): """Destroy this store.""" shutil.rmtree(self.path) class BareGitStore(GitStore): """A Store backed by a bare git repository.""" def _get_current_tree(self): try: ref_object = self.repo[self.ref] except KeyError: return Tree() if isinstance(ref_object, Tree): return ref_object else: return self.repo.object_store[ref_object.tree] def _get_etag(self, name): tree = self._get_current_tree() name = name.encode(DEFAULT_ENCODING) return tree[name][1].decode('ascii') def get_ctag(self): """Return the ctag for this store.""" return self._get_current_tree().id.decode('ascii') def _iterblobs(self, ctag=None): if ctag is None: tree = self._get_current_tree() else: tree = self.repo.object_store[ctag.encode('ascii')] for (name, mode, sha) in tree.iteritems(): name = name.decode(DEFAULT_ENCODING) yield (name, mode, sha) @classmethod def create_memory(cls): """Create a new store backed by a memory repository. :return: A `GitStore` """ return cls(dulwich.repo.MemoryRepo()) def _commit_tree(self, tree_id, message, author=None): try: committer = self.repo._get_user_identity() except KeyError: committer = _DEFAULT_COMMITTER_IDENTITY return self.repo.do_commit(message=message, tree=tree_id, ref=self.ref, committer=committer, author=author) def _import_one(self, name, data, message, author=None): """Import a single object. :param name: Optional name of the object :param data: serialized object as bytes :param message: optional commit message :param author: optional author :return: etag """ b = Blob() b.chunked = data tree = self._get_current_tree() name_enc = name.encode(DEFAULT_ENCODING) tree[name_enc] = (0o644 | stat.S_IFREG, b.id) self.repo.object_store.add_objects([(tree, ''), (b, name_enc)]) self._commit_tree(tree.id, message.encode(DEFAULT_ENCODING), author=author) return b.id def delete_one(self, name, message=None, author=None, etag=None): """Delete an item. :param name: Filename to delete :param message; Commit message :param author: Optional author to store :param etag: Optional mandatory etag of object to remove :raise NoSuchItem: when the item doesn't exist :raise InvalidETag: If the specified ETag doesn't match the curren """ tree = self._get_current_tree() name_enc = name.encode(DEFAULT_ENCODING) try: current_sha = tree[name_enc][1] except KeyError: raise NoSuchItem(name) if etag is not None and current_sha != etag.encode('ascii'): raise InvalidETag(name, etag, current_sha.decode('ascii')) del tree[name_enc] self.repo.object_store.add_objects([(tree, '')]) if message is None: fi = open_by_extension( self.repo.object_store[current_sha].chunked, name, self.extra_file_handlers) message = "Delete " + fi.describe(name) self._commit_tree(tree.id, message.encode(DEFAULT_ENCODING), author=author) @classmethod def create(cls, path): """Create a new store backed by a Git repository on disk. :return: A `GitStore` """ os.mkdir(path) return cls(dulwich.repo.Repo.init_bare(path)) def subdirectories(self): """Returns subdirectories to probe for other stores. :return: List of names """ # Or perhaps just return all subdirectories but filter out # Git-owned ones? return [] class TreeGitStore(GitStore): """A Store that backs onto a treefull Git repository.""" @classmethod def create(cls, path, bare=True): """Create a new store backed by a Git repository on disk. :return: A `GitStore` """ os.mkdir(path) return cls(dulwich.repo.Repo.init(path)) def _get_etag(self, name): index = self.repo.open_index() name = name.encode(DEFAULT_ENCODING) return index[name].sha.decode('ascii') def _commit_tree(self, message, author=None): try: committer = self.repo._get_user_identity() except KeyError: committer = _DEFAULT_COMMITTER_IDENTITY return self.repo.do_commit(message=message, committer=committer, author=author) def _import_one(self, name, data, message, author=None): """Import a single object. :param name: name of the object :param data: serialized object as list of bytes :param message: Commit message :param author: Optional author :return: etag """ p = os.path.join(self.repo.path, name) with open(p, 'wb') as f: f.writelines(data) self.repo.stage(name) etag = self.repo.open_index()[name.encode(DEFAULT_ENCODING)].sha self._commit_tree(message.encode(DEFAULT_ENCODING), author=author) return etag def delete_one(self, name, message=None, author=None, etag=None): """Delete an item. :param name: Filename to delete :param message: Commit message :param author: Optional author :param etag: Optional mandatory etag of object to remove :raise NoSuchItem: when the item doesn't exist :raise InvalidETag: If the specified ETag doesn't match the curren """ p = os.path.join(self.repo.path, name) try: with open(p, 'rb') as f: current_blob = Blob.from_string(f.read()) except IOError: raise NoSuchItem(name) if message is None: fi = open_by_extension(current_blob.chunked, name, self.extra_file_handlers) message = 'Delete ' + fi.describe(name) if etag is not None: with open(p, 'rb') as f: current_etag = current_blob.id if etag.encode('ascii') != current_etag: raise InvalidETag(name, etag, current_etag.decode('ascii')) os.unlink(p) self.repo.stage(name) self._commit_tree(message.encode(DEFAULT_ENCODING), author=author) def get_ctag(self): """Return the ctag for this store.""" index = self.repo.open_index() return index.commit(self.repo.object_store).decode('ascii') def _iterblobs(self, ctag=None): """Iterate over all items in the store with etag. :yield: (name, etag) tuples """ if ctag is not None: tree = self.repo.object_store[ctag.encode('ascii')] for (name, mode, sha) in tree.iteritems(): name = name.decode(DEFAULT_ENCODING) yield (name, mode, sha) else: index = self.repo.open_index() for (name, sha, mode) in index.iterblobs(): name = name.decode(DEFAULT_ENCODING) yield (name, mode, sha) def subdirectories(self): """Returns subdirectories to probe for other stores. :return: List of names """ ret = [] for name in os.listdir(self.path): if name == dulwich.repo.CONTROLDIR: continue p = os.path.join(self.path, name) if os.path.isdir(p): ret.append(name) return ret def open_store(location): """Open store from a location string. :param location: Location string to open :return: A `Store` """ # For now, just support opening git stores return GitStore.open_from_path(location) xandikos-0.0.6/COPYING0000644000175000017500000010451313043751030015146 0ustar jelmerjelmer00000000000000 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) 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, either version 3 of the License, or (at your option) any later version. 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, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . xandikos-0.0.6/xandikos.egg-info/0000755000175000017500000000000013131770501017423 5ustar jelmerjelmer00000000000000xandikos-0.0.6/xandikos.egg-info/requires.txt0000644000175000017500000000004413131770500022020 0ustar jelmerjelmer00000000000000defusedxml dulwich icalendar jinja2 xandikos-0.0.6/xandikos.egg-info/dependency_links.txt0000644000175000017500000000000113131770500023470 0ustar jelmerjelmer00000000000000 xandikos-0.0.6/xandikos.egg-info/SOURCES.txt0000644000175000017500000000257513131770501021320 0ustar jelmerjelmer00000000000000.coveragerc .gitignore .mailmap .testr.conf .travis.yml AUTHORS CONTRIBUTING.rst COPYING MANIFEST.in Makefile README.rst TODO appveyor.yml setup.py tox.ini xandikos.1 bin/xandikos compat/README.rst compat/common.sh compat/litmus-0.13.tar.gz.sha256sum compat/litmus.sh compat/serverinfo.xml compat/testcaldav.sh compat/xandikos-caldavtester.sh compat/xandikos-litmus.sh compat/xandikos-vdirsyncer.sh examples/uwsgi-standalone.ini examples/uwsgi.ini notes/auth.rst notes/config.rst notes/context.rst notes/dav-compliance.rst notes/file-format.rst notes/goals.rst notes/hacking.txt notes/monitoring.rst notes/release-process.rst notes/store.rst notes/structure.rst notes/webdav.rst xandikos/__init__.py xandikos/access.py xandikos/apache.py xandikos/caldav.py xandikos/carddav.py xandikos/davcommon.py xandikos/icalendar.py xandikos/infit.py xandikos/quota.py xandikos/scheduling.py xandikos/store.py xandikos/sync.py xandikos/timezones.py xandikos/vcard.py xandikos/web.py xandikos/webdav.py xandikos/wsgi.py xandikos.egg-info/PKG-INFO xandikos.egg-info/SOURCES.txt xandikos.egg-info/dependency_links.txt xandikos.egg-info/requires.txt xandikos.egg-info/top_level.txt xandikos/templates/collection.html xandikos/templates/root.html xandikos/tests/__init__.py xandikos/tests/test_caldav.py xandikos/tests/test_icalendar.py xandikos/tests/test_store.py xandikos/tests/test_web.py xandikos/tests/test_webdav.pyxandikos-0.0.6/xandikos.egg-info/top_level.txt0000644000175000017500000000001113131770500022144 0ustar jelmerjelmer00000000000000xandikos xandikos-0.0.6/xandikos.egg-info/PKG-INFO0000644000175000017500000000137313131770500020523 0ustar jelmerjelmer00000000000000Metadata-Version: 1.1 Name: xandikos Version: 0.0.6 Summary: Lightweight CalDAV/CardDAV server Home-page: https://www.xandikos.org/ Author: Jelmer Vernooij Author-email: jelmer@jelmer.uk License: GNU GPLv3 or later Description: UNKNOWN Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) Classifier: Programming Language :: Python :: 3.3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Operating System :: POSIX xandikos-0.0.6/setup.py0000755000175000017500000000363313131770320015632 0ustar jelmerjelmer00000000000000#!/usr/bin/env python3 # encoding: utf-8 # # 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. from setuptools import setup version = "0.0.6" setup(name="xandikos", description="Lightweight CalDAV/CardDAV server", version=version, author="Jelmer Vernooij", author_email="jelmer@jelmer.uk", license="GNU GPLv3 or later", url="https://www.xandikos.org/", install_requires=['icalendar', 'dulwich', 'defusedxml', 'jinja2'], packages=['xandikos', 'xandikos.tests'], package_data={'xandikos': ['templates/*.html']}, scripts=['bin/xandikos'], test_suite='xandikos.tests.test_suite', classifiers=[ 'Development Status :: 4 - Beta', 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', # noqa 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Operating System :: POSIX', ]) xandikos-0.0.6/examples/0000755000175000017500000000000013131770501015727 5ustar jelmerjelmer00000000000000xandikos-0.0.6/examples/uwsgi.ini0000644000175000017500000000104513126171271017571 0ustar jelmerjelmer00000000000000[uwsgi] socket = 127.0.0.1:8001 uid = xandikos gid = xandikos master = true cheaper = 2 processes = 4 plugin = python3 module = xandikos.wsgi:app umask = 022 env = XANDIKOSPATH=/var/lib/xandikos/collections env = CURRENT_USER_PRINCIPAL=/user/ # Set AUTOCREATE to have Xandikos create default CalDAV/CardDAV # collections if they don't yet exist. Possible values: # - principal: just create the current user principal # - defaults: create the principal and default calendar and contacts # collections. (recommended) env = AUTOCREATE=defaults xandikos-0.0.6/examples/uwsgi-standalone.ini0000644000175000017500000000107313126171271021720 0ustar jelmerjelmer00000000000000[uwsgi] http-socket = 127.0.0.1:8080 umask = 022 master = true cheaper = 2 processes = 4 plugin = router_basicauth,python3 route = ^/ basicauth:myrealm,user1:password1 module = xandikos.wsgi:app env = XANDIKOSPATH=$HOME/dav env = CURRENT_USER_PRINCIPAL=/dav/user1/ # Set AUTOCREATE to have Xandikos create default CalDAV/CardDAV # collections if they don't yet exist. Possible values: # - principal: just create the current user principal # - defaults: create the principal and default calendar and contacts # collections. (recommended) env = AUTOCREATE=defaults xandikos-0.0.6/TODO0000644000175000017500000000056013077476573014627 0ustar jelmerjelmer00000000000000webdav server: - add support for authorization - implement COPY - implement MOVE - implement LOCK - run caldav tester - cross-check UIDs for vcard files - support returning components in addressbook-data - properties: - calendar-proxy-read-for - calendar-proxy-write-for - better author data in commits - improve calendar delta describer - improve performance xandikos-0.0.6/README.rst0000644000175000017500000001256213131264017015606 0ustar jelmerjelmer00000000000000.. image:: https://travis-ci.org/jelmer/xandikos.png?branch=master :target: https://travis-ci.org/jelmer/xandikos :alt: Build Status .. image:: https://ci.appveyor.com/api/projects/status/fjqtsk8agwmwavqk/branch/master?svg=true :target: https://ci.appveyor.com/project/jelmer/xandikos/branch/master :alt: Windows Build Status Xandikos is a lightweight yet complete CardDAV/CalDAV server that backs onto a Git repository. Xandikos (Ξανδικός or Ξανθικός) takes its name from the name of the March month in the ancient Macedonian calendar, used in Macedon in the first millennium BC. Implemented standards ===================== The following standards are implemented: - :RFC:`4918`/:RFC:`2518` (Core WebDAV) - *implemented, except for COPY/MOVE/LOCK operations* - :RFC:`4791` (CalDAV) - *fully implemented* - :RFC:`6352` (CardDAV) - *fully implemented* - :RFC:`5397` (Current Principal) - *fully implemented* - :RFC:`3253` (Versioning Extensions) - *partially implemented, only the REPORT method and {DAV:}expand-property property* - :RFC:`3744` (Access Control) - *partially implemented* - :RFC:`5995` (POST to create members) - *fully implemented* - :RFC:`5689` (Extended MKCOL) - *fully implemented* The following standards are not implemented: - :RFC:`6638` (CalDAV Scheduling Extensions) - *not implemented* - :RFC:`7809` (CalDAV Time Zone Extensions) - *not implemented* - :RFC:`7529` (WebDAV Quota) - *not implemented* - :RFC:`4709` (WebDAV Mount) - `intentionally `_ *not implemented* - :RFC:`5546` (iCal iTIP) - *not implemented* - :RFC:`4324` (iCAL CAP) - *not implemented* - :RFC:`7953` (iCal AVAILABILITY) - *not implemented* See `DAV compliance `_ for more detail on specification compliancy. Limitations ----------- - No multi-user support - No support for CalDAV scheduling extensions Supported clients ================= Xandikos has been tested and works with the following CalDAV/CardDAV clients: - `Vdirsyncer `_ - `caldavzap `_/`carddavmate `_ - `evolution `_ - `DAVdroid `_ - `sogo connector for Icedove/Thunderbird `_ - `aCALdav syncer for Android `_ - `pycardsyncer `_ - `akonadi `_ - `CalDAV-Sync `_ - `CardDAV-Sync `_ - `Calendarsync `_ Dependencies ============ At the moment, Xandikos supports Python 3.3 and higher as well as Pypy 3. It also uses `Dulwich `_, `Jinja2 `_, `icalendar `_, and `defusedxml `_. E.g. to install those dependencies on Debian: .. code:: shell sudo apt install python3-dulwich python3-defusedxml python3-icalendar python3-jinja2 Or to install them using pip: .. code:: shell python setup.py develop Running ======= Testing ------- To run a standalone (low-performance, no authentication) instance of Xandikos, with a pre-created calendar and addressbook (storing data in *$HOME/dav*): .. code:: shell ./bin/xandikos --defaults -d $HOME/dav A server should now be listening on `localhost:8080 `_. Note that Xandikos does not create any collections unless --defaults is specified. You can also either create collections from your CalDAV/CardDAV client, or by creating git repositories under the *contacts* or *calendars* directories it has created. Production ---------- The easiest way to run Xandikos in production is using `uWSGI `_. One option is to setup uWSGI with a server like `Apache `_, `Nginx `_ or another web server that can authenticate users and forward authorized requests to Xandikos in uWSGI. See `examples/uwsgi.ini `_ for an example uWSGI configuration. Alternatively, you can run uWSGI standalone and have it authenticate and directly serve HTTP traffic. An example configuration for this can be found in `examples/uwsgi-standalone.ini `_. This will start a server on `localhost:8080 `_ with username *user1* and password *password1*. .. code:: shell mkdir -p $HOME/dav uwsgi examples/uwsgi-standalone.ini Contributing ============ Contributions to Xandikos are very welcome. If you run into bugs or have feature requests, please file issues `on GitHub `_. If you're interested in contributing code or documentation, please read `CONTRIBUTING `_. Issues that are good for new contributors are tagged `new-contributor `_ on GitHub. Help ==== There is a *#xandikos* IRC channel on the `Freenode `_ IRC network, and a `Xandikos `_ mailing list. xandikos-0.0.6/CONTRIBUTING.rst0000644000175000017500000000042313077476573016576 0ustar jelmerjelmer00000000000000Xandikos uses the :PEP:`8` style guide. You can verify whether you've introduced any style violations by running "make style". There are some very minimal developer documentation/vague design docs in notes/. Please implement new RFCs as much as possible in their own file. xandikos-0.0.6/xandikos.10000644000175000017500000000272013077476573016041 0ustar jelmerjelmer00000000000000.TH XANDIKOS "1" "February 2017" "xandikos 0.0.1" "User Commands" .SH NAME xandikos \- CalDAV/CardDAV server .SH DESCRIPTION .PP Xandikos is a CalDAV/CardDAV application that stores its data in a Git repository. .PP The xandidos command-line tool starts a simple server instance, without authentication. .SH SYNOPSIS .B xandikos \fI\,-d ROOT-DIR \/\fR[\fI\,OPTIONS\/\fR] .SH OPTIONS .TP \fB\-\-version\fR show program's version number and exit .TP \fB\-h\fR, \fB\-\-help\fR show this help message and exit .TP \fB\-l\fR LISTEN_ADDRESS, \fB\-\-listen_address\fR=\fI\,LISTEN_ADDRESS\/\fR Binding IP address. .TP \fB\-d\fR DIRECTORY, \fB\-\-directory\fR=\fI\,DIRECTORY\/\fR Default path to serve from. .TP \fB\-p\fR PORT, \fB\-\-port\fR=\fI\,PORT\/\fR Port to listen on. .TP \fB\-\-current\-user\-principal\fR=\fI\,CURRENT_USER_PRINCIPAL\/\fR Path to current user principal. .TP \fB\-\-route\-prefix\fR=\fI\,ROUTE_PREFIX\/\fR Path to Xandikos. This is useful when Xandikos is behind a reverse proxy, and e.g. being served from a path. .PP E.g. if Xandikos is served from http://example.com/dav/, specify --route-prefix=/dav/. .TP \fB\-\-autocreate\fR Automatically create necessary directories for Xandikos to run. .TP \fB\-\-defaults\fR Automatically create a calendar and an addressbook; implies \-\-autocreate. .SH SEE ALSO .BR calypso(1), .BR vdirsyncer(1) .SH AUTHOR Xandikos was written by Jelmer Vernooij .SH LICENSE GNU General Public License, version 3 or later. xandikos-0.0.6/PKG-INFO0000644000175000017500000000137313131770501015212 0ustar jelmerjelmer00000000000000Metadata-Version: 1.1 Name: xandikos Version: 0.0.6 Summary: Lightweight CalDAV/CardDAV server Home-page: https://www.xandikos.org/ Author: Jelmer Vernooij Author-email: jelmer@jelmer.uk License: GNU GPLv3 or later Description: UNKNOWN Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) Classifier: Programming Language :: Python :: 3.3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Operating System :: POSIX xandikos-0.0.6/bin/0000755000175000017500000000000013131770501014661 5ustar jelmerjelmer00000000000000xandikos-0.0.6/bin/xandikos0000755000175000017500000000202413131013467016426 0ustar jelmerjelmer00000000000000#!/usr/bin/env python3 # Xandikos # Copyright (C) 2016-2017 Jelmer Vernooij # # 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 2 # 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. import os import sys # running from source dir? if os.path.join(os.path.dirname(__file__), '..', 'xandikos'): sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from xandikos.web import main main(sys.argv) xandikos-0.0.6/compat/0000755000175000017500000000000013131770501015374 5ustar jelmerjelmer00000000000000xandikos-0.0.6/compat/litmus.sh0000755000175000017500000000121313077476573017272 0ustar jelmerjelmer00000000000000#!/bin/bash -e URL="$1" if [ -z "$URL" ]; then echo "Usage: $0 URL" exit 1 fi if [ -n "$TESTS" ]; then TEST_ARG=TESTS="$TESTS" fi SRCPATH="$(dirname $(readlink -m $0))" VERSION=0.13 scratch=$(mktemp -d) function finish() { rm -rf "${scratch}" } trap finish EXIT pushd "${scratch}" if [ -f "${SRCPATH}/litmus-${VERSION}.tar.gz" ]; then cp "${SRCPATH}/litmus-${VERSION}.tar.gz" . else wget -O "litmus-${VERSION}.tar.gz" http://www.webdav.org/neon/litmus/litmus-${VERSION}.tar.gz fi sha256sum ${SRCPATH}/litmus-${VERSION}.tar.gz.sha256sum tar xvfz litmus-${VERSION}.tar.gz pushd litmus-${VERSION} ./configure make make URL="$URL" $TEST_ARG check xandikos-0.0.6/compat/xandikos-caldavtester.sh0000755000175000017500000000304013126171271022232 0ustar jelmerjelmer00000000000000#!/bin/bash # Run caldavtester tests against Xandikos. set -e . $(dirname $0)/common.sh CFGDIR=$(readlink -f $(dirname $0)) if which testcaldav >/dev/null; then TESTCALDAV=testcaldav else TESTCALDAV="$(dirname $0)/testcaldav.sh" fi function mkcol() { p="$1" t="$2" git init -q "${SERVEDIR}/$p" if [[ -n "$t" ]]; then echo "[xandikos]" >> "${SERVEDIR}/$p/.git/config" echo " type = $t" >> "${SERVEDIR}/$p/.git/config" fi } function mkcalendar() { p="$1" mkcol "$p" "calendar" } function mkaddressbook() { p="$1" mkcol "$p" "addressbook" } function mkprincipal() { p="$1" mkcol "$p" "principal" } mkcol addressbooks mkcol addressbooks/__uids__ for I in `seq 1 40`; do mkprincipal "addressbooks/__uids__/user$(printf %02d $I)" mkaddressbook addressbooks/__uids__/user$(printf %02d $I)/addressbook done mkcol calendars mkcol calendars/__uids__ mkcalendar calendars/users for I in `seq 1 40`; do mkprincipal "calendars/__uids__/user$(printf %02d $I)" mkcalendar calendars/__uids__/user$(printf %02d $I)/calendar mkcalendar calendars/__uids__/user$(printf %02d $I)/tasks mkcalendar calendars/__uids__/user$(printf %02d $I)/inbox mkcalendar calendars/__uids__/user$(printf %02d $I)/outbox done mkprincipal calendars/__uids__/i18nuser mkcalendar calendars/__uids__/i18nuser/calendar mkcol principals mkcol principals/__uids__ mkprincipal principals/__uids__/user01/ mkcol principals/users mkprincipal principals/users/user01 run_xandikos --defaults $TESTCALDAV --print-details-onfail -s ${CFGDIR}/serverinfo.xml ${TESTS} xandikos-0.0.6/compat/testcaldav.sh0000755000175000017500000000043513077476573020114 0ustar jelmerjelmer00000000000000#!/bin/bash -e BRANCH=master cd $(dirname $0) if [ ! -d ccs-caldavtester ]; then git clone https://github.com/apple/ccs-caldavtester.git else pushd ccs-caldavtester git pull --ff-only origin $BRANCH popd fi cd ccs-caldavtester exec env python2 ./testcaldav.py "$@" xandikos-0.0.6/compat/litmus-0.13.tar.gz.sha256sum0000644000175000017500000000012513077476573022176 0ustar jelmerjelmer0000000000000009d615958121706444db67e09c40df5f753ccf1fa14846fdeb439298aa9ac3ff litmus-0.13.tar.gz xandikos-0.0.6/compat/xandikos-vdirsyncer.sh0000755000175000017500000000120413077476573021763 0ustar jelmerjelmer00000000000000#!/bin/bash set -e readonly BRANCH=master [ -z "$PYTHON" ] && PYTHON=python3 cd "$(dirname $0)" REPO_DIR="$(readlink -f ..)" if [ ! -d vdirsyncer ]; then git clone -b $BRANCH https://github.com/pimutils/vdirsyncer else pushd vdirsyncer git pull --ff-only origin $BRANCH popd fi cd vdirsyncer if [ -z "${VIRTUAL_ENV}" ]; then virtualenv venv -p${PYTHON} source venv/bin/activate export PYTHONPATH=${REPO_DIR} pushd ${REPO_DIR} && ${PYTHON} setup.py develop && popd fi make \ COVERAGE=true \ PYTEST_ARGS="${PYTEST_ARGS} tests/storage/dav/" \ DAV_SERVER=xandikos \ install-dev install-test test xandikos-0.0.6/compat/README.rst0000644000175000017500000000047113077476573017112 0ustar jelmerjelmer00000000000000This directory contains scripts to run external CalDAV/CardDAV/WebDAV testsuites against the Xandikos web server. Currently supported: - `Vdirsyncer `_ - `litmus `_ - `caldavtester `_ xandikos-0.0.6/compat/xandikos-litmus.sh0000755000175000017500000000041013077476573021106 0ustar jelmerjelmer00000000000000#!/bin/bash -x # Run litmus against xandikos . $(dirname $0)/common.sh TESTS="$1" set -e run_xandikos --autocreate if which litmus >/dev/null; then LITMUS=litmus else LITMUS="$(dirname $0)/litmus.sh" fi TESTS="$TESTS" $LITMUS http://localhost:5233/ exit 0 xandikos-0.0.6/compat/serverinfo.xml0000644000175000017500000005003213126171271020303 0ustar jelmerjelmer00000000000000 localhost 5233 8443 basic 120 0.25 caldav no-duplicate-uids ctag $multistatus-response-prefix: /{DAV:}multistatus/{DAV:}response $multistatus-href-prefix: /{DAV:}multistatus/{DAV:}response/{DAV:}href $verify-response-prefix: {DAV:}response/{DAV:}propstat/{DAV:}prop $verify-property-prefix: /{DAV:}multistatus/{DAV:}response/{DAV:}propstat/{DAV:}prop $verify-bad-response: /{DAV:}multistatus/{DAV:}response/{DAV:}status $verify-error-response: /{DAV:}multistatus/{DAV:}response/{DAV:}error $CALDAV: urn:ietf:params:xml:ns:caldav $CARDDAV: urn:ietf:params:xml:ns:carddav $CS: http://calendarserver.org/ns/ $root: / $principalcollection: $root:principals/ $uidstype: __uids__ $userstype: users $groupstype: groups $locationstype: locations $resourcestype: resources $principals_uids: $principalcollection:$uidstype:/ $principals_users: $principalcollection:$userstype:/ $principals_groups: $principalcollection:$groupstype:/ $principals_resources: $principalcollection:$resourcestype:/ $principals_locations: $principalcollection:$locationstype:/ $calendars: $root:calendars/ $calendars_uids: $calendars:$uidstype:/ $calendars_users: $calendars:$userstype:/ $calendars_groups: $calendars:$groupstype:/ $calendars_resources: $calendars:$resourcestype:/ $calendars_locations: $calendars:$locationstype:/ $calendar: calendar $tasks: tasks $polls: polls $inbox: inbox $outbox: outbox $dropbox: dropbox $attachments: dropbox $notification: notification $freebusy: freebusy $servertoserver: $root:inbox $timezoneservice: $root:timezones $timezonestdservice: $root:stdtimezones $addressbooks: $root:addressbooks/ $addressbooks_uids: $addressbooks:$uidstype:/ $addressbooks_users: $addressbooks:$userstype:/ $addressbooks_groups: $addressbooks:$groupstype:/ $addressbook: addressbook $directory: $root:directory/ $add-member: ;add-member $useradmin: admin $useradminguid: admin $pswdadmin: admin $principal_admin: $principals_users:$useradmin:/ $principaluri_admin: $principals_uids:$useradminguid:/ $userapprentice: apprentice $userapprenticeguid: apprentice $pswdapprentice: apprentice $principal_apprentice: $principals_users:$userapprentice:/ $principaluri_apprentice: $principals_uids:$userapprenticeguid:/ $userproxy: superuser $pswdproxy: superuser $userid%d: user%02d $userguid%d: user%02d $username%d: User %02d $username-encoded%d: User%%20%02d $firstname%d: User $lastname%d: %02d $pswd%d: user%02d $principal%d: $principals_users:$userid%d:/ $principaluri%d: $principals_uids:$userguid%d:/ $principal%dnoslash: $principals_users:$userid%d: $calendarhome%d: $calendars_uids:$userguid%d: $calendarhomealt%d: $calendars_users:$userid%d: $calendarpath%d: $calendarhome%d:/$calendar: $calendarpathalt%d: $calendarhomealt%d:/$calendar: $taskspath%d: $calendarhome%d:/$tasks: $pollspath%d: $calendarhome%d:/$polls: $inboxpath%d: $calendarhome%d:/$inbox: $outboxpath%d: $calendarhome%d:/$outbox: $dropboxpath%d: $calendarhome%d:/$dropbox: $notificationpath%d: $calendarhome%d:/$notification: $freebusypath%d: $calendarhome%d:/$freebusy: $email%d: $userid%d:@example.com $cuaddr%d: mailto:$email%d: $cuaddralt%d: $principaluri%d: $cuaddraltnoslash%d: $principals_uids:$userguid%d: $cuaddrurn%d: urn:uuid:$userguid%d: $addressbookhome%d: $addressbooks_uids:$userguid%d: $addressbookpath%d: $addressbookhome%d:/$addressbook: $publicuserid%d: public%02d $publicuserguid%d: public%02d $publicusername%d: Public %02d $publicpswd%d: public%02d $publicprincipal%d: $principals_users:$publicuserid%d:/ $publicprincipaluri%d: $principals_uids:$publicuserguid%d:/ $publiccalendarhome%d: $calendars_uids:$publicuserguid%d: $publiccalendarpath%d: $calendars_uids:$publicuserguid%d:/$calendar: $publicemail%d: $publicuserid%d:@example.com $publiccuaddr%d: mailto:$publicemail%d: $publiccuaddralt%d: $publicprincipaluri%d: $publiccuaddrurn%d: urn:uuid:$publicuserguid%d: $resourceid%d: resource%02d $resourceguid%d: resource%02d $resourcename%d: Resource %02d $rcalendarhome%d: $calendars_uids:$resourceguid%d: $rcalendarpath%d: $calendars_uids:$resourceguid%d:/$calendar: $rinboxpath%d: $calendars_uids:$resourceguid%d:/$inbox: $routboxpath%d: $calendars_uids:$resourceguid%d:/$outbox: $rprincipal%d: $principals_resources:$resourceid%d:/ $rprincipaluri%d: $principals_uids:$resourceguid%d:/ $rcuaddralt%d: $rprincipaluri%d: $rcuaddrurn%d: urn:uuid:$resourceguid%d: $locationid%d: location%02d $locationguid%d: location%02d $locationname%d: Location %02d $lcalendarhome%d: $calendars_uids:$locationguid%d: $lcalendarpath%d: $calendars_uids:$locationguid%d:/$calendar: $linboxpath%d: $calendars_uids:$locationguid%d:/$inbox: $loutboxpath%d: $calendars_uids:$locationguid%d:/$outbox: $lprincipal%d: $principals_resources:$locationid%d:/ $lprincipaluri%d: $principals_uids:$locationguid%d:/ $lcuaddralt%d: $lprincipaluri%d: $lcuaddrurn%d: urn:uuid:$locationguid%d: $groupid%d: group%02d $groupguid%d: group%02d $groupname%d: Group %02d $gprincipal%d: $principals_resources:$groupid%d:/ $gprincipaluri%d: $principals_uids:$groupguid%d:/ $gcuaddralt%d: $gprincipaluri%d: $gcuaddrurn%d: urn:uuid:$groupguid%d: $i18nid: i18nuser $i18nguid: i18nuser $i18nname: まだ $i18npswd: i18nuser $i18ncalendarpath: $calendars_uids:$i18nguid:/$calendar: $i18nemail: $i18nid:@example.com $i18ncuaddr: mailto:$i18nemail: $i18ncuaddrurn: urn:uuid:$i18nguid: $principaldisabled: $principals_groups:disabledgroup/ $principaluridisabled: $principals_uids:disabledgroup/ $cuaddrdisabled: $principals_uids:disabledgroup/ $cuaddr2: MAILTO:$email2: xandikos-0.0.6/compat/common.sh0000644000175000017500000000116613077476573017251 0ustar jelmerjelmer00000000000000#!/bin/bash # Common functions for running xandikos in compat tests XANDIKOS_PID= DAEMON_LOG=$(mktemp) SERVEDIR=$(mktemp -d) if [ -z "${XANDIKOS}" ]; then XANDIKOS=$(dirname $0)/../bin/xandikos fi set -e xandikos_cleanup() { [ -z ${XANDIKOS_PID} ] || kill -TERM ${XANDIKOS_PID} rm --preserve-root -rf ${SERVEDIR} cat ${DAEMON_LOG} wait ${XANDIKOS_PID} || true } run_xandikos() { ${XANDIKOS} -p5233 -llocalhost -d ${SERVEDIR} "$@" 2>&1 >$DAEMON_LOG & XANDIKOS_PID=$! trap xandikos_cleanup 0 EXIT i=0 while [ $i -lt 10 ] do if curl http://localhost:5233/ >/dev/null; then break fi sleep 1 let i+=1 done } xandikos-0.0.6/.coveragerc0000644000175000017500000000011413077476573016253 0ustar jelmerjelmer00000000000000[run] branch = True [report] exclude_lines = raise NotImplementedError xandikos-0.0.6/AUTHORS0000644000175000017500000000031313077476573015203 0ustar jelmerjelmer00000000000000Jelmer Vernooij Geert Stappers Hugo Osvaldo Barrera Markus Unterwaditzer Daniel M. Capella xandikos-0.0.6/appveyor.yml0000644000175000017500000000476013126171271016513 0ustar jelmerjelmer00000000000000environment: matrix: - PYTHON: "C:\\Python33" PYTHON_VERSION: "3.3.x" PYTHON_ARCH: "32" - PYTHON: "C:\\Python33-x64" PYTHON_VERSION: "3.3.x" PYTHON_ARCH: "64" - PYTHON: "C:\\Python34" PYTHON_VERSION: "3.4.x" PYTHON_ARCH: "32" - PYTHON: "C:\\Python34-x64" PYTHON_VERSION: "3.4.x" PYTHON_ARCH: "64" - PYTHON: "C:\\Python35" PYTHON_VERSION: "3.5.x" PYTHON_ARCH: "32" - PYTHON: "C:\\Python35-x64" PYTHON_VERSION: "3.5.x" PYTHON_ARCH: "64" - PYTHON: "C:\\Python36" PYTHON_VERSION: "3.6.x" PYTHON_ARCH: "32" - PYTHON: "C:\\Python36-x64" PYTHON_VERSION: "3.6.x" PYTHON_ARCH: "64" install: # If there is a newer build queued for the same PR, cancel this one. # The AppVeyor 'rollout builds' option is supposed to serve the same # purpose but it is problematic because it tends to cancel builds pushed # directly to master instead of just PR builds (or the converse). # credits: JuliaLang developers. - ps: if ($env:APPVEYOR_PULL_REQUEST_NUMBER -and $env:APPVEYOR_BUILD_NUMBER -ne ((Invoke-RestMethod ` https://ci.appveyor.com/api/projects/$env:APPVEYOR_ACCOUNT_NAME/$env:APPVEYOR_PROJECT_SLUG/history?recordsNumber=50).builds | ` Where-Object pullRequestId -eq $env:APPVEYOR_PULL_REQUEST_NUMBER)[0].buildNumber) { ` throw "There are newer queued builds for this pull request, failing early." } - ECHO "Filesystem root:" - ps: "ls \"C:/\"" - ECHO "Installed SDKs:" - ps: "ls \"C:/Program Files/Microsoft SDKs/Windows\"" # Install Python (from the official .msi of http://python.org) and pip when # not already installed. - ps: if (-not(Test-Path($env:PYTHON))) { & appveyor\install.ps1 } # Prepend newly installed Python to the PATH of this build (this cannot be # done from inside the powershell script as it would require to restart # the parent CMD process). - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" # Check that we have the expected version and architecture for Python - "python --version" - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" # Install setuptools/wheel so that we can e.g. use bdist_wheel - "pip install setuptools wheel" - "python setup.py develop" build_script: - "python setup.py build" test_script: - "python setup.py test" after_test: - "python setup.py bdist_wheel" - "python setup.py bdist_wininst" - "python setup.py bdist_msi" - ps: "ls dist" artifacts: - path: dist\* xandikos-0.0.6/.mailmap0000644000175000017500000000021413123462661015535 0ustar jelmerjelmer00000000000000Jelmer Vernooij Jelmer Vernooij Jelmer Vernooij Jelmer Vernooij