xandikos-0.0.11/0000755000175000017500000000000013372667507014207 5ustar jelmerjelmer00000000000000xandikos-0.0.11/.coveragerc0000644000175000017500000000011413260416120016277 0ustar jelmerjelmer00000000000000[run] branch = True [report] exclude_lines = raise NotImplementedError xandikos-0.0.11/MANIFEST.in0000644000175000017500000000036313343040556015732 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.11/AUTHORS0000644000175000017500000000031313274414536015245 0ustar jelmerjelmer00000000000000Jelmer Vernooij Geert Stappers Hugo Osvaldo Barrera Markus Unterwaditzer Daniel M. Capella xandikos-0.0.11/tox.ini0000644000175000017500000000023113371556730015510 0ustar jelmerjelmer00000000000000[tox] downloadcache = {toxworkdir}/cache/ envlist = py33, py34, py35, py36 [testenv] commands = make check recreate = True whitelist_externals = make xandikos-0.0.11/examples/0000755000175000017500000000000013372667507016025 5ustar jelmerjelmer00000000000000xandikos-0.0.11/examples/uwsgi.ini0000644000175000017500000000104513343040757017652 0ustar jelmerjelmer00000000000000[uwsgi] socket = 127.0.0.1:8001 uid = xandikos gid = xandikos master = true cheaper = 0 processes = 1 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.11/examples/uwsgi-heroku.ini0000644000175000017500000000046013343040757021145 0ustar jelmerjelmer00000000000000[uwsgi] http-socket = :$(PORT) die-on-term = true umask = 022 master = true cheaper = 0 processes = 1 plugin = router_basicauth,python3 route = ^/ basicauth:myrealm,user1:password1 module = xandikos.wsgi:app env = XANDIKOSPATH=$HOME/dav env = CURRENT_USER_PRINCIPAL=/dav/user1/ env = AUTOCREATE=defaults xandikos-0.0.11/examples/uwsgi-standalone.ini0000644000175000017500000000107313343040757022001 0ustar jelmerjelmer00000000000000[uwsgi] http-socket = 127.0.0.1:8080 umask = 022 master = true cheaper = 0 processes = 1 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.11/examples/xandikos.example0000644000175000017500000000015513343040757021211 0ustar jelmerjelmer00000000000000# This an example .xandikos file. # The color for this collection is red color = FF0000 inbox-url = inbox/ xandikos-0.0.11/setup.py0000755000175000017500000000425613371556011015715 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 find_packages, setup import sys version = "0.0.11" if sys.platform != 'win32': # Win32 setup breaks on non-ascii characters author = "Jelmer Vernooij" else: author = "Jelmer Vernooij" with open('README.rst', encoding='utf-8') as f: long_description = f.read() setup(name="xandikos", description="Lightweight CalDAV/CardDAV server", long_description=long_description, version=version, author=author, author_email="jelmer@jelmer.uk", license="GNU GPLv3 or later", url="https://www.xandikos.org/", install_requires=[ 'icalendar', 'dulwich>=0.19.1', 'defusedxml', 'jinja2', ], packages=find_packages(), 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.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.11/setup.cfg0000644000175000017500000000024213372667507016026 0ustar jelmerjelmer00000000000000[flake8] ignore = W504 exclude = compat/vdirsyncer/,.tox,compat/ccs-caldavtester,.git application-package-names = xandikos [egg_info] tag_build = tag_date = 0 xandikos-0.0.11/compat/0000755000175000017500000000000013372667507015472 5ustar jelmerjelmer00000000000000xandikos-0.0.11/compat/xandikos-litmus.sh0000755000175000017500000000041513366211554021152 0ustar jelmerjelmer00000000000000#!/bin/bash -x # Run litmus against xandikos . $(dirname $0)/common.sh TESTS="$1" set -e run_xandikos 5233 --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.11/compat/testcaldav.sh0000755000175000017500000000043513274414536020156 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.11/compat/litmus.sh0000755000175000017500000000131213343040757017331 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=${LITMUS_VERSION:-0.13} LITMUS_URL="${LITMUS_URL:-http://www.webdav.org/neon/litmus/litmus-${VERSION}.tar.gz}" 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" "${LITMUS_URL}" 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.11/compat/litmus-0.13.tar.gz.sha256sum0000644000175000017500000000012513274174415022237 0ustar jelmerjelmer0000000000000009d615958121706444db67e09c40df5f753ccf1fa14846fdeb439298aa9ac3ff litmus-0.13.tar.gz xandikos-0.0.11/compat/xandikos-caldavtester.sh0000755000175000017500000000304513366211554022320 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 5233 --defaults $TESTCALDAV --print-details-onfail -s ${CFGDIR}/serverinfo.xml ${TESTS} xandikos-0.0.11/compat/xandikos-vdirsyncer.sh0000755000175000017500000000214013366211554022022 0ustar jelmerjelmer00000000000000#!/bin/bash . $(dirname $0)/common.sh set -e readonly BRANCH=master run_xandikos 5001 --autocreate [ -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 if [ -z "${CARGO_HOME}" ]; then export CARGO_HOME="$(readlink -f .)/cargo" export RUSTUP_HOME="$(readlink -f .)/cargo" fi curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly --no-modify-path . ${CARGO_HOME}/env rustup update nightly # Add --ignore=tests/system/utils/test_main.py since it fails in travis, # and isn't testing anything relevant to Xandikos. make \ PYTEST_ARGS="${PYTEST_ARGS} tests/storage/dav/ --ignore=tests/system/utils/test_main.py" \ DAV_SERVER=xandikos \ install-dev install-test test exit 0 xandikos-0.0.11/compat/serverinfo.xml0000644000175000017500000005003213343037646020367 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.11/compat/README.rst0000644000175000017500000000047113274414536017154 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.11/compat/common.sh0000644000175000017500000000122013366211554017277 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() { PORT="$1" shift 1 ${XANDIKOS} -p${PORT} -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:${PORT}/ >/dev/null; then break fi sleep 1 let i+=1 done } xandikos-0.0.11/.gitignore0000644000175000017500000000026613333723552016171 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.11/TODO0000644000175000017500000000056013274414536014671 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.11/Dockerfile0000644000175000017500000000365613366211554016201 0ustar jelmerjelmer00000000000000# Docker file for Xandikos. # This docker image starts a Xandikos server on port 8000. It supports two # environment variables: # # - autocreate: "principal" / "defaults" # If set to "yes", this will create the user principal, but not any # calendars or address books. # If set to "defaults", it will create a default calendar # (under $current_user_principal/calendars/calendar) and a default # addressbook (under $current_user_principal/contacts/addressbook) # # - current_user_principal: /path/to/user/principal # This specifies the path to the current users' principal, and effectively # the path under which Xandikos will be available. # It is recommended that you set it to "/YOURUSERNAME" # # E.g. If autocreate is set to "defaults" and current_user_principal is set to # "/dav/joe", Xandikos will provide two collections (one calendar, one # addressbook) at respecively: # # http://localhost:8000/dav/joe/calendars/calendar # http://localhost:8000/dav/joe/contacts/addressbook # # Note that this dockerfile starts Xandikos without any authentication; # for authenticated access we recommend you run it behind a reverse proxy. FROM debian:sid LABEL maintainer="jelmer@jelmer.uk" RUN apt-get update && \ apt-get -y install uwsgi uwsgi-plugin-python3 python3-icalendar python3-dulwich python3-jinja2 python3-defusedxml && \ apt-get clean ADD . /code WORKDIR /code VOLUME /data EXPOSE 8000 ENV autocreate="defaults" ENV current_user_principal="/user1" # TODO(jelmer): Add support for authentication # --plugin=router_basicauth,python3 --route="^/ basicauth:myrealm,user1:password1" CMD uwsgi --http-socket=:8000 \ --umask=022 \ --master \ --cheaper=2 \ --processes=4 \ --plugin=python3 \ --module=xandikos.wsgi:app \ --env=XANDIKOSPATH=/data \ --env=CURRENT_USER_PRINCIPAL=$current_user_principal \ --env=AUTOCREATE=$autocreate xandikos-0.0.11/CONTRIBUTING.rst0000644000175000017500000000042313274414536016640 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.11/bin/0000755000175000017500000000000013372667507014757 5ustar jelmerjelmer00000000000000xandikos-0.0.11/bin/xandikos0000755000175000017500000000204313366211554016512 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.__main__ import main sys.exit(main(sys.argv)) xandikos-0.0.11/xandikos.10000644000175000017500000000273513343040561016077 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 not being served from the root of the host. .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.11/.mailmap0000644000175000017500000000021413274414536015616 0ustar jelmerjelmer00000000000000Jelmer Vernooij Jelmer Vernooij Jelmer Vernooij Jelmer Vernooij xandikos-0.0.11/notes/0000755000175000017500000000000013372667507015337 5ustar jelmerjelmer00000000000000xandikos-0.0.11/notes/api-stability.rst0000644000175000017500000000071713343040561020630 0ustar jelmerjelmer00000000000000API Stability ============= There are currently no guarantees about Xandikos Python APIs staying the same across different versions, except the following APIs: xandikos.web.XandikosBackend(path) xandikos.web.XandikosBackend.create_principal(principal, create_defaults=False) xandikos.web.XandikosApp(backend, current_user_principal) xandikos.web.WellknownRedirector(app, path) If you care about stability of any other APIs, please file a bug against Xandikos. xandikos-0.0.11/notes/scheduling-plan.rst0000644000175000017500000000116213343040757021134 0ustar jelmerjelmer00000000000000CalDAV Scheduling ================= TODO: - When a new calendar object is uploaded to a calendar collection: * Check if the ATTENDEE property is present, and if so, process it - Support CALDAV:schedule-tag * When comparing with if-schedule-tag-match, simply retrieve the blob by schedule-tag and compare delta between newly uploaded and current * When determining schedule-tag, scroll back until last revision that didn't have attendee changes? + Perhaps include a hint in e.g. commit message? - Inbox "contains copies of incoming scheduling messages" - Outbox "at which busy time information requests are targeted." xandikos-0.0.11/notes/webdav.rst0000644000175000017500000000242413274414536017334 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.11/notes/heroku.rst0000644000175000017500000000223513343040757017356 0ustar jelmerjelmer00000000000000Running Xandikos on Heroku ========================== Heroku is an easy way to get a public instance of Xandikos running. A free heroku instance comes with 100Mb of local storage, which is enough for thousands of calendar items or contacts. Deployment ---------- All of these steps assume you already have a Heroku account and have installed the heroku command-line client. To run a Heroku instance with Xandikos: 1. Create a copy of Xandikos:: $ git clone git://jelmer.uk/xandikos xandikos $ cd xandikos 2. Make a copy of the example uwsgi configuration:: $ cp examples/uwsgi-heroku.ini uwsgi.ini 3. Edit *uwsgi.ini* as necessary, such as changing the credentials (the defaults are *user1*/*password1*). 4. Make heroku install and use uwsgi:: $ echo uwsgi > requirements.txt $ echo web: uwsgi uwsgi.ini > Procfile 5. Create the Heroku instance:: $ heroku create (this might ask you for your heroku credentials) 6. Deploy the app:: $ git push heroku master 7. Open the app with your browser:: $ heroku open (The URL opened is also the URL that you can provide to any CalDAV/CardDAV application that supports service discovery) xandikos-0.0.11/notes/collection-config.rst0000644000175000017500000000332313366211554021456 0ustar jelmerjelmer00000000000000Per-collection configuration ============================ Xandikos needs to store several piece of per-collection metadata. Goals ----- Find a place to store per-collection metadata. Some of these can be inferred from other sources. For starters, for each collection: - resource types: principal, calendar, addressbook At the moment, Xandikos is storing some of this information in git configuration. However, this means: * it is not versioned * there is a 1-1 relationship between collections and git repositories * some users object to mixing in this metadata in their git config Per resource type-specific properties ------------------------------------- Generic ~~~~~~~ - ACLs - owner? Principal ~~~~~~~~~ Per principal configuration settings: - calendar home sets - addressbook home sets - user address set - infit settings Calendar ~~~~~~~~ Need per calendar config: - color - description (can be inferred from .git/description) - inbox URL - outbox URL - max instances - max attendees per instance - calendar timezone - calendar schedule transparency Addressbook ~~~~~~~~~~~ Need per addressbook config: - max image size - max resource size - color - description (can be inferred from .git/description) Schedule Inbox ~~~~~~~~~~~~~~ - default-calendar-URL Proposed format --------------- Store a ini-style .xandikos file in the directory hosting the Collection (or Tree in case of a Git repository). All properties mentioned above are simple key/value pairs. For simplicity, it may make sense to use an ini-style format so that users can edit metadata using their editor. Example ------- # This is a standard Python configobj file, so it's mostly ini-style, and comments # can appear preceded by #. color = 030003 xandikos-0.0.11/notes/file-format.rst0000644000175000017500000000176713274414536020302 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.11/notes/structure.rst0000644000175000017500000000131613274414536020123 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.11/notes/auth.rst0000644000175000017500000000076513274414536017033 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.11/notes/release-process.rst0000644000175000017500000000034113274414536021154 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.11/notes/dav-compliance.rst0000644000175000017500000001674013343040757020751 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 (CalDAV 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] - CALDAV:schedule-calendar-transp [supported] - CALDAV:schedule-default-calendar-URL [supported] - CALDAV:schedule-tag [not 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] - refreshrate [supported] inf-it properties ^^^^^^^^^^^^^^^^^ - headervalue [supported] - settings [supported] - addressbook-color [supported] AgendaV properties ^^^^^^^^^^^^^^^^^^ https://tools.ietf.org/id/draft-ietf-calext-caldav-attachments-03.html - CALDAV:max-attachments-per-resource [supported] - CALDAV:max-attachment-size [supported] - CALDAV:managed-attachments-server-URL [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.11/notes/context.rst0000644000175000017500000000134113274414536017545 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.11/notes/hacking.txt0000644000175000017500000000005713260416120017461 0ustar jelmerjelmer00000000000000DAV in class names is spelled in all capitals. xandikos-0.0.11/notes/store.rst0000644000175000017500000000110013274414536017206 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.11/notes/monitoring.rst0000644000175000017500000000044013274414536020245 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.11/notes/goals.rst0000644000175000017500000000025413274414536017170 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.11/notes/multi-user.rst0000644000175000017500000000273713366211554020176 0ustar jelmerjelmer00000000000000Multi-User Support ================== Multi-user support could arguably also include sharing of calendars/collections/etc. This is beyond the scope of this document, which just focuses on allowing multiple users to use their own silo in a single instance of Xandikos. Siloed user support can be split up into three steps: * storage - mapping a user to a principal * authentication - letting a user log in * authorization - checking whether the user has access to a resource Authentication -------------- In the simplest form, a forwarding proxy provides the name of an authenticated user. E.g. Apache or uWSGI sets the REMOTE_USER environment variable. If REMOTE_USER is not present for an operation that requires authentication, a 401 error is returned. Authorization ------------- In the simplest form, users only have access to the resources under their own principal. Storage ------- By default, the principal for a user is simply "/%(username)s". Roadmap ======= * Optional: Allow marking collections as principals [DONE] * Expose username (or None, if not logged in) everywhere [DONE] * Add function get_username_principal() for mapping username to principal path [DONE] * Support automatic creation of principal on first login of user * Add simple function check_path_access() for checking access ("is this user allowed to access this path?") * Use access checking function everywhere * Have current-user-principal setting depend on $REMOTE_USER and get_username_principal() [DONE] xandikos-0.0.11/Makefile0000644000175000017500000000301513371556457015646 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 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: XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-vdirsyncer.sh 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.11/xandikos/0000755000175000017500000000000013372667507016027 5ustar jelmerjelmer00000000000000xandikos-0.0.11/xandikos/__init__.py0000644000175000017500000000175313371556011020130 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, 11) version_string = '.'.join(map(str, __version__)) import defusedxml.ElementTree # noqa: This does some monkey-patching on-load xandikos-0.0.11/xandikos/timezones.py0000644000175000017500000000322213343040561020374 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, environ): for timezone_service_href in self._timezone_services: el.append(webdav.create_href(timezone_service_href, base_href)) xandikos-0.0.11/xandikos/access.py0000644000175000017500000000441613343040561017626 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, environ): 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, environ): 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, environ): for href in resource.get_group_membership(): el.append(webdav.create_href(href, base_href=href)) xandikos-0.0.11/xandikos/web.py0000644000175000017500000010326413366212322017144 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 import urllib.parse 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, InvalidFileContents, NoSuchItem, NotStoreError, STORE_TYPE_ADDRESSBOOK, STORE_TYPE_CALENDAR, STORE_TYPE_PRINCIPAL, STORE_TYPE_SCHEDULE_INBOX, STORE_TYPE_SCHEDULE_OUTBOX, STORE_TYPE_OTHER, ) from xandikos.store.git import ( GitStore, TreeGitStore, ) 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, urljoin=urllib.parse.urljoin, **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 as e: # TODO(jelmer): Not every invalid file is a calendar file.. raise webdav.PreconditionFailure( '{%s}valid-calendar-data' % caldav.NAMESPACE, 'Not a valid calendar file: %s' % e.error) 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 == {caldav.SCHEDULE_INBOX_RESOURCE_TYPE, webdav.COLLECTION_RESOURCE_TYPE}: self.store.set_type(STORE_TYPE_SCHEDULE_INBOX) elif resource_types == {caldav.SCHEDULE_OUTBOX_RESOURCE_TYPE, webdav.COLLECTION_RESOURCE_TYPE}: self.store.set_type(STORE_TYPE_SCHEDULE_OUTBOX) 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 as e: # TODO(jelmer): Not every invalid file is a calendar file.. raise webdav.PreconditionFailure( '{%s}valid-calendar-data' % caldav.NAMESPACE, 'Not a valid calendar file: %s' % e.error) 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, self_url, 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, self_url=self_url) 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 def get_refreshrate(self): # TODO(jelmer): Support setting refreshrate raise KeyError def set_refreshrate(self, value): # TODO(jelmer): Store refreshrate raise NotImplementedError(self.set_refreshrate) class Collection(StoreBasedCollection, webdav.Collection): """A generic WebDAV collection.""" class ScheduleInbox(StoreBasedCollection, scheduling.ScheduleInbox): """A schedling inbox collection.""" class ScheduleOutbox(StoreBasedCollection, scheduling.ScheduleOutbox): """A schedling outbox 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_max_resource_size(self): # No resource limit raise KeyError def get_max_attachments_per_resource(self): # No resource limit raise KeyError def get_max_attachment_size(self): # No resource limit raise KeyError def get_schedule_calendar_transparency(self): # TODO(jelmer): Allow configuration in config return caldav.TRANSPARENCY_OPAQUE def get_managed_attachments_server_url(self): # TODO(jelmer) 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, self_url, 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, self_url=self_url) 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, self_url, 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, principals=self.backend.find_principals(), self_url=self_url) 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 [] def get_owner(self): return None def get_schedule_outbox_url(self): raise KeyError def get_schedule_inbox_url(self): # TODO(jelmer): make this configurable return 'inbox' 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 def render(self, self_url, 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( 'principal.html', accepted_content_languages, principal=self, self_url=self_url) 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 find_principals(self): """List all of the principals on this server.""" return self._user_principals 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_SCHEDULE_INBOX: ScheduleInbox, STORE_TYPE_SCHEDULE_OUTBOX: ScheduleOutbox, 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): super(XandikosApp, self).__init__(backend) def get_current_user_principal(env): try: return current_user_principal % env except KeyError: return None self.register_properties([ webdav.ResourceTypeProperty(), webdav.CurrentUserPrincipalProperty( get_current_user_principal), 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(), caldav.MaxResourceSizeProperty(), 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.ScheduleCalendarTransparencyProperty(), scheduling.ScheduleDefaultCalendarURLProperty(), caldav.MaxInstancesProperty(), caldav.MaxAttendeesPerInstanceProperty(), access.GroupMembershipProperty(), apache.ExecutableProperty(), caldav.CalendarProxyReadForProperty(), caldav.CalendarProxyWriteForProperty(), caldav.MaxAttachmentSizeProperty(), caldav.MaxAttachmentsPerResourceProperty(), caldav.ManagedAttachmentsServerURLProperty(), quota.QuotaAvailableBytesProperty(), quota.QuotaUsedBytesProperty(), webdav.RefreshRateProperty(), ]) 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) calendar_path = posixpath.join(principal.relpath, principal.get_schedule_inbox_url()) try: resource = backend.create_collection(calendar_path) except FileExistsError: pass else: resource.store.set_type(STORE_TYPE_SCHEDULE_INBOX) logging.info('Create inbox 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.")) parser.add_argument( "--dump-dav-xml", action="store_true", dest="dump_dav_xml", help="Print DAV XML request/responses.") options = parser.parse_args(argv[1:]) if options.directory is None: parser.print_usage() sys.exit(1) if options.dump_dav_xml: # TODO(jelmer): Find a way to propagate this without abusing # os.environ. os.environ["XANDIKOS_DUMP_DAV_XML"] = "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) app = XandikosApp( backend, current_user_principal=options.current_user_principal) 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.11/xandikos/scheduling.py0000644000175000017500000001625213366212322020514 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 SCHEDULE_OUTBOX_RESOURCE_TYPE = '{%s}schedule-outbox' % 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(webdav.Collection): resource_types = (webdav.Collection.resource_types + [SCHEDULE_INBOX_RESOURCE_TYPE]) def get_calendar_user_type(self): # Default, per section 2.4.2 return CALENDAR_USER_TYPE_INDIVIDUAL 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_max_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) def get_max_resource_size(self): """Return max resource size.""" raise NotImplementedError(self.get_max_resource_size) def get_schedule_default_calendar_url(self): """Return default calendar URL. None indicates there is no default URL. """ return None class ScheduleOutbox(webdav.Collection): resource_types = (webdav.Collection.resource_types + [SCHEDULE_OUTBOX_RESOURCE_TYPE]) 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_max_resource_size(self): """Return max resource size.""" raise NotImplementedError(self.get_max_resource_size) 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_max_date_time) def get_max_attendees_per_instance(self): """Return maximum number of attendees per instance. """ raise NotImplementedError(self.get_max_attendees_per_instance) 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 = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = True def get_value(self, href, resource, el, environ): 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 = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = True def get_value(self, href, resource, el, environ): 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, environ): 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 = '{%s}calendar-user-type' % caldav.NAMESPACE resource_type = webdav.PRINCIPAL_RESOURCE_TYPE in_allprops = False def get_value(self, href, resource, el, environ): el.text = resource.get_calendar_user_type() class ScheduleDefaultCalendarURLProperty(webdav.Property): """schedule-default-calendar-URL property. See https://tools.ietf.org/html/rfc6638, section-9.2 """ name = '{%s}schedule-default-calendar-URL' % caldav.NAMESPACE resource_type = SCHEDULE_INBOX_RESOURCE_TYPE in_allprops = True def get_value(self, href, resource, el, environ): url = resource.get_schedule_default_calendar_url() if url is not None: el.append(webdav.create_href(url, href)) xandikos-0.0.11/xandikos/vcard.py0000644000175000017500000000234313366211554017470 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, "Missing header and trailer lines") xandikos-0.0.11/xandikos/store/0000755000175000017500000000000013372667507017163 5ustar jelmerjelmer00000000000000xandikos-0.0.11/xandikos/store/__init__.py0000644000175000017500000002320713366211554021266 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 mimetypes STORE_TYPE_ADDRESSBOOK = 'addressbook' STORE_TYPE_CALENDAR = 'calendar' STORE_TYPE_PRINCIPAL = 'principal' STORE_TYPE_SCHEDULE_INBOX = 'schedule-inbox' STORE_TYPE_SCHEDULE_OUTBOX = 'schedule-outbox' STORE_TYPE_OTHER = 'other' VALID_STORE_TYPES = ( STORE_TYPE_ADDRESSBOOK, STORE_TYPE_CALENDAR, STORE_TYPE_PRINCIPAL, STORE_TYPE_SCHEDULE_INBOX, STORE_TYPE_SCHEDULE_OUTBOX, STORE_TYPE_OTHER) MIMETYPES = mimetypes.MimeTypes() MIMETYPES.add_type('text/calendar', '.ics') MIMETYPES.add_type('text/vcard', '.vcf') DEFAULT_MIME_TYPE = 'application/octet-stream' 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 normalized(self): """Return a normalized version of the file. """ return self.content 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, error): self.content_type = content_type self.data = data self.error = error 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) 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 from .git import GitStore return GitStore.open_from_path(location) xandikos-0.0.11/xandikos/store/vdir.py0000644000175000017500000002617113366211554020476 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. """vdir store. See https://github.com/pimutils/vdirsyncer/blob/master/docs/vdir.rst """ import errno import hashlib import logging import os import shutil import uuid from . import ( MIMETYPES, Store, DuplicateUidError, InvalidETag, InvalidFileContents, NoSuchItem, open_by_content_type, open_by_extension, ) from .config import CollectionConfig DEFAULT_ENCODING = 'utf-8' logger = logging.getLogger(__name__) class VdirStore(Store): """A Store backed by a Vdir directory. """ def __init__(self, path, check_for_duplicate_uids=True): super(VdirStore, self).__init__() self.path = path self._check_for_duplicate_uids = check_for_duplicate_uids # Set of blob ids that have already been scanned self._fname_to_uid = {} # Maps uids to (sha, fname) self._uid_to_fname = {} @property def config(self): return CollectionConfig() def __repr__(self): return "%s(%r)" % (type(self).__name__, self.path) def _get_etag(self, name): path = os.path.join(self.path, name) md5 = hashlib.md5() try: with open(path, 'rb') as f: for chunk in f: md5.update(chunk) except IOError as e: if e.errno == errno.ENOENT: raise KeyError raise return md5.hexdigest() 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) path = os.path.join(self.path, name) try: with open(path, 'rb') as f: return [f.read()] except IOError as e: if e.errno == errno.ENOENT: raise KeyError raise def _scan_uids(self): removed = set(self._fname_to_uid.keys()) for (name, content_type, etag) in self.iter_with_etag(): if name in removed: removed.remove(name) if (name in self._fname_to_uid and self._fname_to_uid[name][0] == etag): continue fi = open_by_extension(self._get_raw(name, etag), 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 _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 """ if content_type is None: fi = open_by_extension(data, name, self.extra_file_handlers) else: 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) # TODO(jelmer): Check that extensions match content type: # if this is a vCard, the extension should be .vcf # if this is a iCalendar, the extension should be .ics # TODO(jelmer): check that a UID is present and that all UIDs are the # same path = os.path.join(self.path, name) tmppath = os.path.join(self.path, name + '.tmp') with open(tmppath, 'wb') as f: for chunk in fi.normalized(): f.write(chunk) os.replace(tmppath, path) return (name, self._get_etag(name)) 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 in os.listdir(self.path): if name.endswith('.tmp'): continue if name.endswith('.ics'): content_type = 'text/calendar' elif name.endswith('.vcf'): content_type = 'text/vcard' else: continue yield (name, content_type, self._get_etag(name)) @classmethod def create(cls, path): """Create a new store backed by a Vdir on disk. :return: A `VdirStore` """ os.mkdir(path) return cls(path) @classmethod def open_from_path(cls, path): """Open a VdirStore from a path. :param path: Path :return: A `VdirStore` """ return cls(path) def get_description(self): """Get extended description. :return: repository description as string """ raise NotImplementedError(self.get_description) def set_description(self, description): """Set extended description. :param description: repository description as string """ raise NotImplementedError(self.set_description) def set_comment(self, comment): """Set comment. :param comment: Comment """ raise NotImplementedError(self.set_comment) def get_comment(self): """Get comment. :return: Comment """ raise NotImplementedError(self.get_comment) def _read_metadata(self, name): try: with open(os.path.join(self.path, name), 'r') as f: return f.read().strip() except EnvironmentError: return None def _write_metadata(self, name, data): path = os.path.join(self.path, name) if data is not None: with open(path, 'w') as f: f.write(data) else: os.unlink(path) def get_color(self): """Get color. :return: A Color code, or None """ color = self._read_metadata('color') assert color.startswith('#') return color def set_color(self, color): """Set the color code for this store.""" assert color.startswith('#') self._write_metadata('color', color) def get_displayname(self): """Get display name. :return: The display name, or None if not set """ return self._read_metadata('displayname') def set_displayname(self, displayname): """Set the display name. :param displayname: New display name """ self._write_metadata('displayname', displayname) 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 store type. """ raise NotImplementedError(self.get_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) """ raise NotImplementedError(self.iter_changes) def destroy(self): """Destroy this store.""" shutil.rmtree(self.path) 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 """ path = os.path.join(self.path, name) if etag is not None: try: current_etag = self._get_etag(name) except KeyError: raise NoSuchItem(name) if etag != current_etag: raise InvalidETag(name, etag, current_etag) try: os.unlink(path) except EnvironmentError as e: if e.errno == errno.ENOENT: raise NoSuchItem(path) raise def get_ctag(self): """Return the ctag for this store.""" raise NotImplementedError(self.get_ctag) def subdirectories(self): """Returns subdirectories to probe for other stores. :return: List of names """ ret = [] for name in os.listdir(self.path): p = os.path.join(self.path, name) if os.path.isdir(p): ret.append(name) return ret xandikos-0.0.11/xandikos/store/config.py0000644000175000017500000000301113343040757020763 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. """Collection configuration file. """ import configparser FILENAME = '.xandikos' class CollectionConfig(object): def __init__(self, cp=None): if cp is None: cp = configparser.ConfigParser() self._configparser = cp @classmethod def from_file(cls, f): cp = configparser.ConfigParser() cp.read_file(f) return CollectionConfig(cp) def get_color(self): return self._configparser['DEFAULT']['color'] def get_comment(self): return self._configparser['DEFAULT']['comment'] def get_displayname(self): return self._configparser['DEFAULT']['displayname'] def get_description(self): return self._configparser['DEFAULT']['description'] xandikos-0.0.11/xandikos/store/git.py0000644000175000017500000005106113371556011020305 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. """Git store. """ import logging import os import shutil import stat import uuid from . import ( DEFAULT_MIME_TYPE, MIMETYPES, Store, DuplicateUidError, InvalidETag, InvalidFileContents, NoSuchItem, NotStoreError, VALID_STORE_TYPES, open_by_content_type, open_by_extension, ) from .config import CollectionConfig from dulwich.file import GitFile from dulwich.index import ( Index, IndexEntry, index_entry_from_stat, write_index_dict, ) from dulwich.objects import Blob, Tree from dulwich.pack import SHA1Writer import dulwich.repo DEFAULT_ENCODING = 'utf-8' logger = logging.getLogger(__name__) class locked_index(object): def __init__(self, path): self._path = path def __enter__(self): self._file = GitFile(self._path, 'wb') self._index = Index(self._path) return self._index def __exit__(self, exc_type, exc_value, traceback): f = SHA1Writer(self._file) write_index_dict(f, self._index._byname) f.close() 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 = {} @property def config(self): return CollectionConfig() 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 """ if content_type is None: fi = open_by_extension(data, name, self.extra_file_handlers) else: 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, fi.normalized(), 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 """ try: return self.config.get_description() except KeyError: 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 """ try: return self.config.get_comment() except KeyError: 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 """ try: return self.config.get_color() except KeyError: 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 """ try: return self.config.get_displayname() except KeyError: 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): return self.repo.do_commit(message=message, tree=tree_id, ref=self.ref, 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() old_tree_id = tree.id 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)]) if tree.id != old_tree_id: 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, index, message, author=None): tree = index.commit(self.repo.object_store) return self.repo.do_commit(message=message, author=author, tree=tree) 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 """ with locked_index(self.repo.index_path()) as index: p = os.path.join(self.repo.path, name) with open(p, 'wb') as f: f.writelines(data) st = os.lstat(p) blob = Blob.from_string(b''.join(data)) encoded_name = name.encode(DEFAULT_ENCODING) if encoded_name not in index or blob.id != index[encoded_name].sha: self.repo.object_store.add_object(blob) index[encoded_name] = IndexEntry( *index_entry_from_stat(st, blob.id, 0)) self._commit_tree( index, message.encode(DEFAULT_ENCODING), author=author) return blob.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 :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')) with locked_index(self.repo.index_path()) as index: os.unlink(p) del index[name.encode(DEFAULT_ENCODING)] self._commit_tree(index, 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.iterobjects(): 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 xandikos-0.0.11/xandikos/apache.py0000644000175000017500000000300713343040561017601 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, environ): 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.11/xandikos/webdav.py0000644000175000017500000016462013371556011017644 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 os 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): if self.resource_type is None: return True if isinstance(self.resource_type, tuple): return any(rs in resource.resource_types for rs in self.resource_type) if self.resource_type in resource.resource_types: return True return False def is_set(self, href, resource, environ): """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), environ) except KeyError: return False else: return True def get_value(self, href, resource, el, environ): """Get property with specified name. :param href: Resource href :param resource: Resource for which to retrieve the property :param el: Element to populate :param environ: WSGI environment dict :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, environ): 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, environ): 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, environ): el.text = resource.get_etag() ADD_MEMBER_FEATURE = 'add-member' 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, environ): # 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, environ): # 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, environ): 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, environ): 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, environ): 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, environ): 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, get_current_user_principal): super(CurrentUserPrincipalProperty, self).__init__() self.get_current_user_principal = get_current_user_principal def get_value(self, href, resource, el, environ): """Get property with specified name. :param name: A property name. """ current_user_principal = self.get_current_user_principal(environ) if current_user_principal is None: ET.SubElement(el, '{DAV:}unauthenticated') else: current_user_principal = ensure_trailing_slash( current_user_principal.lstrip('/')) el.append(create_href( current_user_principal, environ['SCRIPT_NAME'])) class PrincipalURLProperty(Property): name = '{DAV:}principal-URL' resource_type = '{DAV:}principal' in_allprops = True live = True def get_value(self, href, resource, el, environ): """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, environ): 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, environ): el.text = resource.get_ctag() class DAVGetCTagProperty(GetCTagProperty): """getctag property """ name = '{DAV:}getctag' class AppleGetCTagProperty(GetCTagProperty): """getctag property """ name = '{http://calendarserver.org/ns/}getctag' class RefreshRateProperty(Property): """refreshrate property. (no public documentation, but contains an ical-style frequency indicator) """ name = '{http://calendarserver.org/ns/}refreshrate' resource_type = COLLECTION_RESOURCE_TYPE in_allprops = False def get_value(self, href, resource, el, environ): el.text = resource.get_refreshrate() def set_value(self, href, resource, el): resource.set_refreshrate(el.text) 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, self_url, 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) def set_refreshrate(self, value): """Set the recommended refresh rate for this collection. :param value: Refresh rate (None to remove) """ raise NotImplementedError(self.set_refreshrate) def get_refreshrate(self): """Get the recommended refresh rate. :return: Recommended refresh rate :raise KeyError: if there is no refresh rate set """ raise NotImplementedError(self.get_refreshrate) 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_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_property_from_name(href, resource, properties, name, environ): """Get a single property on a resource. :param href: Resource href :param resource: Resource object :param properties: Dictionary of properties :param environ: WSGI environ dict :param name: name of property to resolve :return: PropStatus items """ return get_property_from_element( href, resource, properties, environ, ET.Element(name)) def get_property_from_element(href, resource, properties, environ, requested): """Get a single property on a resource. :param href: Resource href :param resource: Resource object :param properties: Dictionary of properties :param environ: WSGI environ dict :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, environ) else: get_value_ext(href, resource, ret, environ, requested) except KeyError: statuscode = '404 Not Found' else: statuscode = '200 OK' return PropStatus(statuscode, responsedescription, ret) def get_properties(href, resource, properties, environ, 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 :param environ: WSGI environ dict :return: Iterator over PropStatus items """ for propreq in list(requested): yield get_property_from_element( href, resource, properties, environ, propreq) def get_property_names(href, resource, properties, environ, requested): """Get a set of property names. :param href: Resource Href :param resource: Resource object :param properties: Dictionary of properties :param environ: WSGI environ dict :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, environ): yield PropStatus('200 OK', None, ET.Element(name)) def get_all_properties(href, resource, properties, environ): """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 :param environ: WSGI environ dict :return: Iterator over PropStatus items """ for name in properties: ps = get_property_from_name(href, resource, properties, name, environ) 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 """ if self.resource_type is None: return True if isinstance(self.resource_type, tuple): return any(rs in resource.resource_types for rs in self.resource_type) return 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): parsed_url = urllib.parse.urlparse(href) if '//' in parsed_url.path: 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, environ): """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 :param environ: WSGI environ dict :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, environ) 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, environ) 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, environ) 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, environ): 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, environ): 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, environ): 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 if os.environ.get('XANDIKOS_DUMP_DAV_XML'): print("OUT: " + ET.tostring(et).decode('utf-8')) 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)) if os.environ.get('XANDIKOS_DUMP_DAV_XML'): print("IN: " + body.decode('utf-8')) 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, environ) elif requested.tag == '{DAV:}prop': propstat = get_properties( href, resource, app.properties, environ, requested) elif requested.tag == '{DAV:}propname': propstat = get_property_names( href, resource, app.properties, environ, 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(environ['SCRIPT_NAME'] + environ['PATH_INFO'], 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', 'calendar-auto-scheduling', 'addressbook', 'extended-mkcol', 'add-member', 'sync-collection', 'quota'] 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 [] if 'SCRIPT_NAME' not in environ: logging.debug('SCRIPT_NAME not set; assuming "".') environ['SCRIPT_NAME'] = '' method = environ['REQUEST_METHOD'] try: do = self.methods[method] except KeyError: 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: start_response('401 Unauthorized', []) return [('Please login.'.encode(DEFAULT_ENCODING))] xandikos-0.0.11/xandikos/quota.py0000644000175000017500000000275713343040757017533 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 FEATURE = 'quota' 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, environ): 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, environ): el.text = resource.get_quota_used_bytes() xandikos-0.0.11/xandikos/__main__.py0000644000175000017500000000170713366211554020114 0ustar jelmerjelmer00000000000000# Xandikos # Copyright (C) 2016-2018 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 command-line handling.""" def main(argv): from .web import main return main(argv) if __name__ == '__main__': import sys main(sys.argv) xandikos-0.0.11/xandikos/sync.py0000644000175000017500000001070713343040757017350 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 FEATURE = 'sync-collection' 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, environ, prop) else: old_propstat = None new_propstat = webdav.get_property_from_element( href, new_resource, properties, environ, 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, environ): el.text = resource.get_sync_token() xandikos-0.0.11/xandikos/templates/0000755000175000017500000000000013372667507020025 5ustar jelmerjelmer00000000000000xandikos-0.0.11/xandikos/templates/principal.html0000644000175000017500000000144713366211554022670 0ustar jelmerjelmer00000000000000 WebDAV Principal - {{ principal.get_displayname() }}

{{ principal.get_displayname() }}

This is a user principal. CalDAV/CardDAV clients that support autodiscovery can use the URL for this page for discovery.

Subcollections

    {% for name, resource in principal.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.11/xandikos/templates/collection.html0000644000175000017500000000127713366211554023043 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.11/xandikos/templates/root.html0000644000175000017500000000110113366211554021655 0ustar jelmerjelmer00000000000000 Xandikos WebDAV server

This is a Xandikos WebDAV server.

Principals on this server:

    {% for name in principals %}
  • {{ name }}
  • {% endfor %}

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

xandikos-0.0.11/xandikos/infit.py0000644000175000017500000000435513343040561017500 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, environ): 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, environ): 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, environ): el.text = resource.get_headervalue() def set_value(self, href, resource, el): # TODO raise NotImplementedError xandikos-0.0.11/xandikos/davcommon.py0000644000175000017500000000714113366212322020347 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, environ, 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 environ: WSGI environ dict :param requested: Requested property (including subelements) """ raise NotImplementedError(self.get_value_ext) def get_properties_with_data(data_property, href, resource, properties, environ, requested): properties = dict(properties) properties[data_property.name] = data_property return webdav.get_properties( href, resource, properties, environ, 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 the right resource type 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, environ, requested) yield webdav.Status(href, '200 OK', propstat=list(propstat)) # see https://tools.ietf.org/html/rfc4790 class UnknownCollation(Exception): def __init__(self, collation): super(UnknownCollation, self).__init__( "Collation %r is not supported" % collation) self.collation = collation collations = { 'i;ascii-casemap': lambda a, b: (a.decode('ascii').upper() == b.decode('ascii').upper()), 'i;octet': lambda a, b: a == b, } def get_collation(name): """Get a collation by name. :param name: Collation name :raises UnknownCollation: If the collation is not supported """ try: return collations[name] except KeyError: raise UnknownCollation(name) xandikos-0.0.11/xandikos/carddav.py0000644000175000017500000002551313366212322017773 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, environ): for href in resource.get_addressbook_home_set(): href = webdav.ensure_trailing_slash(href) el.append(webdav.create_href(href, base_href)) class AddressDataProperty(davcommon.SubbedProperty): """address-data property See https://tools.ietf.org/html/rfc6352, section 10.4 Note that this is not technically a DAV property, and it is thus not registered in the regular webdav server. """ name = '{%s}address-data' % NAMESPACE def supported_on(self, resource): return (resource.get_content_type() == 'text/vcard') def get_value_ext(self, href, resource, el, environ, 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, environ): 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, environ): el.append(webdav.create_href( resource.get_principal_address(), href)) class SupportedAddressDataProperty(webdav.Property): """Provides the supported-address-data property. https://tools.ietf.org/html/rfc6352, section 6.2.2 """ name = '{%s}supported-address-data' % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE in_allprops = False live = True def get_value(self, href, resource, el, environ): for (content_type, version) in resource.get_supported_address_data_types(): subel = ET.SubElement(el, '{%s}content-type' % NAMESPACE) subel.set('content-type', content_type) subel.set('version', version) class MaxResourceSizeProperty(webdav.Property): """Provides the max-resource-size property. See https://tools.ietf.org/html/rfc6352, section 6.2.3. """ name = '{%s}max-resource-size' % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE in_allprops = False live = True def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_resource_size()) class MaxImageSizeProperty(webdav.Property): """Provides the max-image-size property. This seems to be a carddav extension used by iOS and caldavzap. """ name = '{%s}max-image-size' % NAMESPACE resource_type = ADDRESSBOOK_RESOURCE_TYPE in_allprops = False live = True def get_value(self, href, resource, el, environ): 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, environ, requested) yield webdav.Status(href, '200 OK', propstat=list(propstat)) i += 1 xandikos-0.0.11/xandikos/icalendar.py0000644000175000017500000002363513371556011020316 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 icalendar.prop import vText from xandikos.store import File, InvalidFileContents # TODO(jelmer): Populate this further based on # https://tools.ietf.org/html/rfc5545#3.3.11 _INVALID_CONTROL_CHARACTERS = ['\x0c', '\x01'] def validate_calendar(cal, strict=False): """Validate a calendar object. :param cal: Calendar object :return: iterator over error messages """ for error in validate_component(cal, strict=strict): yield error def validate_component(comp, strict=False): """Validate a calendar component. :param comp: Calendar component """ # Check text fields for invalid characters for (name, value) in comp.items(): if isinstance(value, vText): for c in _INVALID_CONTROL_CHARACTERS: if c in value: yield "Invalid character %s in field %s" % ( c.encode('unicode_escape'), name) if strict: for required in comp.required: try: comp[required] except KeyError: yield "Missing required field %s" % required for subcomp in comp.subcomponents: for error in validate_component(subcomp, strict=strict): yield error 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" ): if new_value is None: yield "status of %s deleted" % description else: human_readable = { "NEEDS-ACTION": "needing action", "COMPLETED": "complete", "CANCELLED": "cancelled"} yield "%s marked as %s" % ( description, human_readable.get(new_value.upper(), 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" and new_value is not None): 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') elif field.upper() == 'DTSTART': yield "changed start date/time of %s from %s to %s" % ( description, old_value.dt if old_value else 'none', new_value.dt if new_value else 'none') elif field.upper() == 'DTEND': yield "changed end date/time of %s from %s to %s" % ( description, old_value.dt if old_value else 'none', new_value.dt if new_value else 'none') elif field.upper() == 'CLASS': yield "changed class of %s from %s to %s" % ( description, old_value.lower() if old_value else 'none', new_value.lower() 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.""" cal = self.calendar # TODO(jelmer): return the list of errors to the caller if cal.is_broken: raise InvalidFileContents( self.content_type, self.content, "Broken calendar file") errors = list(validate_calendar(cal, strict=False)) if errors: raise InvalidFileContents( self.content_type, self.content, ", ".join(errors)) def normalized(self): """Return a normalized version of the file.""" return [self.calendar.to_ical()] @property def calendar(self): if self._calendar is None: try: self._calendar = Calendar.from_ical(b''.join(self.content)) except ValueError as e: raise InvalidFileContents( self.content_type, self.content, str(e)) 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.11/xandikos/caldav.py0000644000175000017500000010054613371556011017623 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' NAMESPACE = 'urn:ietf:params:xml:ns:caldav' # https://tools.ietf.org/html/rfc4791, section 4.2 CALENDAR_RESOURCE_TYPE = '{%s}calendar' % NAMESPACE # TODO(jelmer): These resource types belong in scheduling.py SCHEDULE_INBOX_RESOURCE_TYPE = '{%s}schedule-inbox' % NAMESPACE SCHEDULE_OUTBOX_RESOURCE_TYPE = '{%s}schedule-outbox' % NAMESPACE # Feature to advertise to indicate CalDAV support. FEATURE = 'calendar-access' TRANSPARENCY_TRANSPARENT = 'transparent' TRANSPARENCY_OPAQUE = 'opaque' class MissingProperty(Exception): def __init__(self, property_name): super(MissingProperty, self).__init__( "Property %r missing" % property_name) self.property_name = property_name 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_max_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) def get_max_resource_size(self): """Return max resource size.""" raise NotImplementedError(self.get_max_resource_size) def get_max_attachments_per_resource(self): """Return max attachments per resource.""" raise NotImplementedError(self.get_max_attachments_per_resource) def get_max_attachment_size(self): """Return max attachment size.""" raise NotImplementedError(self.get_max_attachment_size) def get_managed_attachments_server_url(self): """Return the attachments server URL.""" raise NotImplementedError(self.get_managed_attachments_server_url) def get_schedule_calendar_transparency(self): """Get calendar transparency. Possible values are TRANSPARENCY_TRANSPARENT and TRANSPARENCY_OPAQUE """ return TRANSPARENCY_OPAQUE 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 = '{%s}calendar-home-set' % NAMESPACE resource_type = '{DAV:}principal' in_allprops = False live = True def get_value(self, base_href, resource, el, environ): 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 = '{%s}calendar-description' % NAMESPACE resource_type = CALENDAR_RESOURCE_TYPE def get_value(self, base_href, resource, el, environ): 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, environ, 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 # TODO(jelmer): Strip invalid characters or raise an exception el.text = serialized_cal.decode('utf-8') class CalendarMultiGetReporter(davcommon.MultiGetReporter): name = '{%s}calendar-multiget' % NAMESPACE resource_type = (CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_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.get_collation(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 comp['DTSTART'] is None: raise MissingProperty('DTSTART') 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 = '{%s}calendar-query' % NAMESPACE resource_type = (CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_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) def tzify(dt): return as_tz_aware_ts(dt, tz) for (href, resource) in webdav.traverse_resource( base_resource, base_href, depth): try: filter_result = apply_filter(filter_el, resource, tzify) except MissingProperty as e: logging.warning( 'calendar_query: Ignoring calendar object %s, due ' 'to missing property %s', href, e.property_name) continue if not filter_result: continue propstat = davcommon.get_properties_with_data( self.data_property, href, resource, properties, environ, 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, environ): 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 = '{%s}supported-calendar-component-set' % NAMESPACE resource_type = (CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE, SCHEDULE_OUTBOX_RESOURCE_TYPE) in_allprops = False live = True def get_value(self, href, resource, el, environ): 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, SCHEDULE_INBOX_RESOURCE_TYPE, SCHEDULE_OUTBOX_RESOURCE_TYPE) in_allprops = False def get_value(self, href, resource, el, environ): 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, SCHEDULE_INBOX_RESOURCE_TYPE) in_allprops = False def get_value(self, href, resource, el, environ): 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, SCHEDULE_INBOX_RESOURCE_TYPE, SCHEDULE_OUTBOX_RESOURCE_TYPE) in_allprops = False live = True def get_value(self, href, resource, el, environ): 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, SCHEDULE_INBOX_RESOURCE_TYPE, SCHEDULE_OUTBOX_RESOURCE_TYPE) in_allprops = False live = True def get_value(self, href, resource, el, environ): 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 = '{%s}max-instances' % NAMESPACE resource_type = (CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE) in_allprops = False live = True def get_value(self, href, resource, el, environ): 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 = '{%s}max-attendees-per-instance' % NAMESPACE resource_type = (CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE, SCHEDULE_OUTBOX_RESOURCE_TYPE) in_allprops = False live = True def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_attendees_per_instance()) class MaxResourceSizeProperty(webdav.Property): """max-resource-size property. See https://tools.ietf.org/html/rfc4791, section 5.2.5 """ name = '{%s}max-resource-size' % NAMESPACE resource_type = (CALENDAR_RESOURCE_TYPE, SCHEDULE_INBOX_RESOURCE_TYPE, SCHEDULE_OUTBOX_RESOURCE_TYPE) in_allprops = False live = True def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_resource_size()) class MaxAttachmentsPerResourceProperty(webdav.Property): """max-attachments-per-resource property. https://tools.ietf.org/id/draft-ietf-calext-caldav-attachments-03.html#rfc.section.6.3 """ name = '{%s}max-attachments-per-resource' % NAMESPACE resource_type = CALENDAR_RESOURCE_TYPE in_allprops = False live = True def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_attachments_per_resource()) class MaxAttachmentSizeProperty(webdav.Property): """max-attachment-size property. https://tools.ietf.org/id/draft-ietf-calext-caldav-attachments-03.html#rfc.section.6.2 """ name = '{%s}max-attachment-size' % NAMESPACE resource_type = CALENDAR_RESOURCE_TYPE in_allprops = False live = True def get_value(self, href, resource, el, environ): el.text = str(resource.get_max_attachment_size()) class ManagedAttachmentsServerURLProperty(webdav.Property): """managed-attachments-server-URL property. https://tools.ietf.org/id/draft-ietf-calext-caldav-attachments-03.html#rfc.section.6.1 """ name = '{%s}managed-attachments-server-URL' % NAMESPACE in_allprops = False def get_value(self, base_href, resource, el, environ): href = resource.get_managed_attachments_server_url() if href is not None: el.append(webdav.create_href(href, base_href)) 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, environ): 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, environ): for href in resource.get_calendar_proxy_write_for(): el.append(webdav.create_href(href, base_href)) class ScheduleCalendarTransparencyProperty(webdav.Property): """schedule-calendar-transp property. See https://tools.ietf.org/html/rfc6638#section-9.1 """ name = '{%s}schedule-calendar-transp' % NAMESPACE in_allprops = False live = False resource_type = CALENDAR_RESOURCE_TYPE def get_value(self, base_href, resource, el, environ): transp = resource.get_schedule_calendar_transparency() if transp == TRANSPARENCY_TRANSPARENT: ET.SubElement(el, '{%s}transparent' % NAMESPACE) elif transp == TRANSPARENCY_OPAQUE: ET.SubElement(el, '{%s}opaque' % NAMESPACE) else: raise ValueError('Invalid transparency %s' % transp) 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) def tzify(dt): return 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, environ) 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.11/xandikos/server_info.py0000644000175000017500000000436613343040757020721 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. """Server info. See https://www.ietf.org/archive/id/draft-douglass-server-info-03.txt """ import hashlib from xandikos import version_string from xandikos import webdav ET = webdav.ET # Feature to advertise server-info support. FEATURE = 'server-info' SERVER_INFO_MIME_TYPE = 'application/server-info+xml' class ServerInfo(object): """Server info.""" def __init__(self): self._token = None self._features = [] self._applications = [] def add_feature(self, feature): self._features.append(feature) self._token = None @property def token(self): if self._token is None: h = hashlib.sha1sum() h.update(version_string.encode('utf-8')) for z in (self._features + self._applications): h.update(z.encode('utf-8')) self._token = h.hexdigest() return self._token def get_body(self): el = ET.Element('{DAV:}server-info') el.set('token', self.token) server_el = ET.SubElement(el, 'server-instance-info') ET.SubElement(server_el, 'name').text = 'Xandikos' ET.SubElement(server_el, 'version').text = version_string features_el = ET.SubElement(el, 'features') for feature in self._features: features_el.append(feature) applications_el = ET.SubElement(el, 'applications') for application in self.applications: applications_el.append(application) return el xandikos-0.0.11/xandikos/tests/0000755000175000017500000000000013372667507017171 5ustar jelmerjelmer00000000000000xandikos-0.0.11/xandikos/tests/__init__.py0000644000175000017500000000216713343040757021276 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 = [ 'api', 'caldav', 'collectionconfig', '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.11/xandikos/tests/test_collectionconfig.py0000644000175000017500000000364713343040757024123 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 configparser import unittest from xandikos.store.config import CollectionConfig class CollectionConfigTest(unittest.TestCase): def test_get_color(self): cp = configparser.ConfigParser() c = CollectionConfig(cp) self.assertRaises(KeyError, c.get_color) cp['DEFAULT']['color'] = '040404' self.assertEqual('040404', c.get_color()) def test_get_comment(self): cp = configparser.ConfigParser() c = CollectionConfig(cp) self.assertRaises(KeyError, c.get_comment) cp['DEFAULT']['comment'] = 'foo' self.assertEqual('foo', c.get_comment()) def test_get_displayname(self): cp = configparser.ConfigParser() c = CollectionConfig(cp) self.assertRaises(KeyError, c.get_displayname) cp['DEFAULT']['displayname'] = 'foo' self.assertEqual('foo', c.get_displayname()) def test_get_description(self): cp = configparser.ConfigParser() c = CollectionConfig(cp) self.assertRaises(KeyError, c.get_description) cp['DEFAULT']['description'] = 'foo' self.assertEqual('foo', c.get_description()) xandikos-0.0.11/xandikos/tests/test_web.py0000644000175000017500000000143713343037646021355 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.11/xandikos/tests/test_caldav.py0000644000175000017500000000734713343040757022035 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 from wsgiref.util import setup_testing_defaults from xandikos import caldav, davcommon 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, environ): 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) class ApplyTextMatchTest(unittest.TestCase): def test_default_collation(self): el = ET.Element('someel') el.text = b"foobar" self.assertTrue(caldav.apply_text_match(el, b"FOOBAR")) self.assertTrue(caldav.apply_text_match(el, b"foobar")) self.assertFalse(caldav.apply_text_match(el, b"fobar")) def test_casecmp_collation(self): el = ET.Element('someel') el.set('collation', 'i;ascii-casemap') el.text = b"foobar" self.assertTrue(caldav.apply_text_match(el, b"FOOBAR")) self.assertTrue(caldav.apply_text_match(el, b"foobar")) self.assertFalse(caldav.apply_text_match(el, b"fobar")) def test_cmp_collation(self): el = ET.Element('someel') el.text = b"foobar" el.set('collation', 'i;octet') self.assertFalse(caldav.apply_text_match(el, b"FOOBAR")) self.assertTrue(caldav.apply_text_match(el, b"foobar")) self.assertFalse(caldav.apply_text_match(el, b"fobar")) def test_unknown_collation(self): el = ET.Element('someel') el.set('collation', 'i;blah') el.text = b"foobar" self.assertRaises(davcommon.UnknownCollation, caldav.apply_text_match, el, b"FOOBAR") xandikos-0.0.11/xandikos/tests/test_api.py0000644000175000017500000000275113343040561021337 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 shutil import tempfile import unittest from xandikos.web import ( XandikosApp, XandikosBackend, WellknownRedirector, ) class WebTests(unittest.TestCase): # When changing this API, please update notes/api-stability.rst and inform # vdirsyncer, who rely on this API. def test_backend(self): path = tempfile.mkdtemp() try: backend = XandikosBackend(path) backend.create_principal('foo', create_defaults=True) XandikosApp(backend, 'foo') finally: shutil.rmtree(path) def test_wellknownredirector(self): def app(environ, start_response): pass WellknownRedirector(app, '/path') xandikos-0.0.11/xandikos/tests/test_webdav.py0000644000175000017500000004031113343040757022037 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} 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'} 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'} 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'} 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), } 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), } 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, environ): 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, environ): 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, environ): ET.SubElement(el, '{DAV:}href').text = '/user/' class TestProperty2(Property): name = '{DAV:}somethingelse' def get_value(self, href, resource, el, environ): 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, environ): 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.11/xandikos/tests/test_store.py0000644000175000017500000003175213371556011021730 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 ( DuplicateUidError, File, InvalidETag, NoSuchItem) from xandikos.store.git import ( GitStore, BareGitStore, TreeGitStore) from xandikos.store.vdir import ( VdirStore) 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_VCALENDAR1_NORMALIZED = b"""\ BEGIN:VCALENDAR\r VERSION:2.0\r PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN\r BEGIN:VTODO\r CREATED:20150314T223512Z\r DTSTAMP:20150527T221952Z\r LAST-MODIFIED:20150314T223512Z\r STATUS:NEEDS-ACTION\r SUMMARY:do something\r UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff\r END:VTODO\r END:VCALENDAR\r """ 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_VCALENDAR2_NORMALIZED = b"""\ BEGIN:VCALENDAR\r VERSION:2.0\r PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN\r BEGIN:VTODO\r CREATED:20120314T223512Z\r DTSTAMP:20130527T221952Z\r LAST-MODIFIED:20150314T223512Z\r STATUS:NEEDS-ACTION\r SUMMARY:do something else\r UID:bdc22764-b9e1-42c9-89c2-a85405d8fbff\r END:VTODO\r END:VCALENDAR\r """ 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_NORMALIZED, b''.join(gc._get_raw('foo.ics', etag1))) self.assertEqual( EXAMPLE_VCALENDAR2_NORMALIZED, 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_NORMALIZED, b''.join(f1.content)) self.assertEqual('text/calendar', f1.content_type) f2 = gc.get_file('bar.ics', 'text/calendar', etag2) self.assertEqual(EXAMPLE_VCALENDAR2_NORMALIZED, 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 VdirStoreTest(BaseStoreTest, unittest.TestCase): kls = VdirStore 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 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()) def test_import_only_once(self): gc = self.create_store() (name1, etag1) = gc.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1]) (name2, etag2) = gc.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1]) self.assertEqual(name1, name2) self.assertEqual(etag1, etag2) walker = gc.repo.get_walker(include=[gc.repo.refs[gc.ref]]) self.assertEqual(1, len([w.commit for w in walker])) 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.11/xandikos/tests/test_icalendar.py0000644000175000017500000000562213343040757022517 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, validate_calendar from xandikos.store import InvalidFileContents 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 """ EXAMPLE_VCALENDAR_INVALID_CHAR = 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 somethi ng ID:bdc22720-b9e1-42c9-89c2-a85405d8fbff END:VTODO END:VCALENDAR """ class ExtractCalendarUIDTests(unittest.TestCase): def test_extract_str(self): fi = ICalendarFile([EXAMPLE_VCALENDAR1], 'text/calendar') self.assertEqual( 'bdc22720-b9e1-42c9-89c2-a85405d8fbff', fi.get_uid()) fi.validate() def test_extract_no_uid(self): fi = ICalendarFile([EXAMPLE_VCALENDAR_NO_UID], 'text/calendar') fi.validate() self.assertEqual(["Missing required field UID"], list(validate_calendar(fi.calendar, strict=True))) self.assertEqual([], list(validate_calendar(fi.calendar, strict=False))) self.assertRaises(KeyError, fi.get_uid) def test_invalid_character(self): fi = ICalendarFile([EXAMPLE_VCALENDAR_INVALID_CHAR], 'text/calendar') self.assertRaises(InvalidFileContents, fi.validate) self.assertEqual(["Invalid character b'\\\\x0c' in field SUMMARY"], list(validate_calendar(fi.calendar, strict=False))) xandikos-0.0.11/xandikos/wsgi.py0000644000175000017500000000344313343037646017347 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 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) app = XandikosApp(backend, current_user_principal) xandikos-0.0.11/.travis.yml0000644000175000017500000000204413371556011016302 0ustar jelmerjelmer00000000000000language: python cache: pip sudo: true addons: apt: update: true python: - 3.4 - 3.5 - 3.6 - pypy3.5 env: global: PYTHONHASHSEED=random matrix: include: - python: 3.7 dist: xenial # defusedxml appears to be broken on Python 3.8: #- python: 3.8-dev # dist: xenial install: - pip install pip --upgrade - pip install coverage codecov flake8 pycalendar - sudo apt-get install -qq libneon27-dev curl python2.7 - sudo apt-get install -qq cargo - python setup.py develop script: - make style - make coverage - mv .coverage .coverage.unit # Retrieve litmus from Xandikos server for now, since webdav.org is down. - make coverage-litmus LITMUS_URL=https://www.xandikos.org/litmus-0.13.tar.gz - mv .coverage .coverage.litmus - if [ "$TRAVIS_PYTHON_VERSION" = "3.6" ]; then make coverage-vdirsyncer; mv .coverage .coverage.vdirsyncer; fi - make coverage-caldavtester - mv .coverage .coverage.caldavtester after_success: - python -m coverage combine - codecov cache: pip: true xandikos-0.0.11/COPYING0000644000175000017500000010451313260416120015221 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.11/PKG-INFO0000644000175000017500000002052713372667507015312 0ustar jelmerjelmer00000000000000Metadata-Version: 1.1 Name: xandikos Version: 0.0.11 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: .. 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 `_ - `Tasks `_ - `AgendaV `_ - `CardBook `_ Dependencies ============ At the moment, Xandikos supports Python 3.4 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 Docker ------ A Dockerfile is also provided; see the comments on the top of the file for configuration instructions. 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 Client instructions =================== Some clients can automatically discover the calendars and addressbook URLs from a DAV server (if they support RFC:`5397`). For such clients you can simply provide the base URL to Xandikos during setup. Clients that lack such automated discovery (e.g. Thunderbird Lightning) require the direct URL to a calendar or addressbook. In this case you should provide the full URL to the calendar or addressbook; if you initialized Xandikos using the ``--defaults`` argument mentioned in the previous section, these URLs will look something like this:: http://dav.example.com/user/calendars/calendar http://dav.example.com/user/contacts/addressbook 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. Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) 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.11/GOALS.rst0000644000175000017500000000043413371555750015602 0ustar jelmerjelmer00000000000000The goal of Xandikos is to be a simple CalDAV/CardDAV server for personal use: * easy to set up * use of plain .ics/.vcf files for storage * history stored in Git * clear separation between protocol implementation and storage * well tested * standards complete * standards compliant xandikos-0.0.11/xandikos.egg-info/0000755000175000017500000000000013372667507017521 5ustar jelmerjelmer00000000000000xandikos-0.0.11/xandikos.egg-info/requires.txt0000644000175000017500000000005413372667507022120 0ustar jelmerjelmer00000000000000icalendar dulwich>=0.19.1 defusedxml jinja2 xandikos-0.0.11/xandikos.egg-info/SOURCES.txt0000644000175000017500000000342413372667507021410 0ustar jelmerjelmer00000000000000.coveragerc .gitignore .mailmap .testr.conf .travis.yml AUTHORS CONTRIBUTING.rst COPYING Dockerfile GOALS.rst MANIFEST.in Makefile README.rst TODO appveyor.yml setup.cfg 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-heroku.ini examples/uwsgi-standalone.ini examples/uwsgi.ini examples/xandikos.example notes/api-stability.rst notes/auth.rst notes/collection-config.rst notes/context.rst notes/dav-compliance.rst notes/file-format.rst notes/goals.rst notes/hacking.txt notes/heroku.rst notes/monitoring.rst notes/multi-user.rst notes/release-process.rst notes/scheduling-plan.rst notes/store.rst notes/structure.rst notes/webdav.rst xandikos/__init__.py xandikos/__main__.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/server_info.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/store/__init__.py xandikos/store/config.py xandikos/store/git.py xandikos/store/vdir.py xandikos/templates/collection.html xandikos/templates/principal.html xandikos/templates/root.html xandikos/tests/__init__.py xandikos/tests/test_api.py xandikos/tests/test_caldav.py xandikos/tests/test_collectionconfig.py xandikos/tests/test_icalendar.py xandikos/tests/test_store.py xandikos/tests/test_web.py xandikos/tests/test_webdav.pyxandikos-0.0.11/xandikos.egg-info/PKG-INFO0000644000175000017500000002052713372667507020624 0ustar jelmerjelmer00000000000000Metadata-Version: 1.1 Name: xandikos Version: 0.0.11 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: .. 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 `_ - `Tasks `_ - `AgendaV `_ - `CardBook `_ Dependencies ============ At the moment, Xandikos supports Python 3.4 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 Docker ------ A Dockerfile is also provided; see the comments on the top of the file for configuration instructions. 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 Client instructions =================== Some clients can automatically discover the calendars and addressbook URLs from a DAV server (if they support RFC:`5397`). For such clients you can simply provide the base URL to Xandikos during setup. Clients that lack such automated discovery (e.g. Thunderbird Lightning) require the direct URL to a calendar or addressbook. In this case you should provide the full URL to the calendar or addressbook; if you initialized Xandikos using the ``--defaults`` argument mentioned in the previous section, these URLs will look something like this:: http://dav.example.com/user/calendars/calendar http://dav.example.com/user/contacts/addressbook 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. Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) 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.11/xandikos.egg-info/top_level.txt0000644000175000017500000000001113372667507022243 0ustar jelmerjelmer00000000000000xandikos xandikos-0.0.11/xandikos.egg-info/dependency_links.txt0000644000175000017500000000000113372667507023567 0ustar jelmerjelmer00000000000000 xandikos-0.0.11/.testr.conf0000644000175000017500000000024313260416120016247 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.11/appveyor.yml0000644000175000017500000000435413371556011016567 0ustar jelmerjelmer00000000000000environment: matrix: - PYTHON: "C:\\Python34" PYTHON_VERSION: "3.4.x" PYTHON_ARCH: "32" - 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.11/README.rst0000644000175000017500000001445413371556011015670 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 `_ - `Tasks `_ - `AgendaV `_ - `CardBook `_ Dependencies ============ At the moment, Xandikos supports Python 3.4 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 Docker ------ A Dockerfile is also provided; see the comments on the top of the file for configuration instructions. 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 Client instructions =================== Some clients can automatically discover the calendars and addressbook URLs from a DAV server (if they support RFC:`5397`). For such clients you can simply provide the base URL to Xandikos during setup. Clients that lack such automated discovery (e.g. Thunderbird Lightning) require the direct URL to a calendar or addressbook. In this case you should provide the full URL to the calendar or addressbook; if you initialized Xandikos using the ``--defaults`` argument mentioned in the previous section, these URLs will look something like this:: http://dav.example.com/user/calendars/calendar http://dav.example.com/user/contacts/addressbook 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.