xandikos-0.0.11/ 0000755 0001750 0001750 00000000000 13372667507 014207 5 ustar jelmer jelmer 0000000 0000000 xandikos-0.0.11/.coveragerc 0000644 0001750 0001750 00000000114 13260416120 016277 0 ustar jelmer jelmer 0000000 0000000 [run]
branch = True
[report]
exclude_lines =
raise NotImplementedError
xandikos-0.0.11/MANIFEST.in 0000644 0001750 0001750 00000000363 13343040556 015732 0 ustar jelmer jelmer 0000000 0000000 include *.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/AUTHORS 0000644 0001750 0001750 00000000313 13274414536 015245 0 ustar jelmer jelmer 0000000 0000000 Jelmer Vernooij
Geert Stappers
Hugo Osvaldo Barrera
Markus Unterwaditzer
Daniel M. Capella
xandikos-0.0.11/tox.ini 0000644 0001750 0001750 00000000231 13371556730 015510 0 ustar jelmer jelmer 0000000 0000000 [tox]
downloadcache = {toxworkdir}/cache/
envlist = py33, py34, py35, py36
[testenv]
commands = make check
recreate = True
whitelist_externals = make
xandikos-0.0.11/examples/ 0000755 0001750 0001750 00000000000 13372667507 016025 5 ustar jelmer jelmer 0000000 0000000 xandikos-0.0.11/examples/uwsgi.ini 0000644 0001750 0001750 00000001045 13343040757 017652 0 ustar jelmer jelmer 0000000 0000000 [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.ini 0000644 0001750 0001750 00000000460 13343040757 021145 0 ustar jelmer jelmer 0000000 0000000 [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.ini 0000644 0001750 0001750 00000001073 13343040757 022001 0 ustar jelmer jelmer 0000000 0000000 [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.example 0000644 0001750 0001750 00000000155 13343040757 021211 0 ustar jelmer jelmer 0000000 0000000 # This an example .xandikos file.
# The color for this collection is red
color = FF0000
inbox-url = inbox/
xandikos-0.0.11/setup.py 0000755 0001750 0001750 00000004256 13371556011 015715 0 ustar jelmer jelmer 0000000 0000000 #!/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.cfg 0000644 0001750 0001750 00000000242 13372667507 016026 0 ustar jelmer jelmer 0000000 0000000 [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/ 0000755 0001750 0001750 00000000000 13372667507 015472 5 ustar jelmer jelmer 0000000 0000000 xandikos-0.0.11/compat/xandikos-litmus.sh 0000755 0001750 0001750 00000000415 13366211554 021152 0 ustar jelmer jelmer 0000000 0000000 #!/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.sh 0000755 0001750 0001750 00000000435 13274414536 020156 0 ustar jelmer jelmer 0000000 0000000 #!/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.sh 0000755 0001750 0001750 00000001312 13343040757 017331 0 ustar jelmer jelmer 0000000 0000000 #!/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.sha256sum 0000644 0001750 0001750 00000000125 13274174415 022237 0 ustar jelmer jelmer 0000000 0000000 09d615958121706444db67e09c40df5f753ccf1fa14846fdeb439298aa9ac3ff litmus-0.13.tar.gz
xandikos-0.0.11/compat/xandikos-caldavtester.sh 0000755 0001750 0001750 00000003045 13366211554 022320 0 ustar jelmer jelmer 0000000 0000000 #!/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.sh 0000755 0001750 0001750 00000002140 13366211554 022022 0 ustar jelmer jelmer 0000000 0000000 #!/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.xml 0000644 0001750 0001750 00000050032 13343037646 020367 0 ustar jelmer jelmer 0000000 0000000
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.rst 0000644 0001750 0001750 00000000471 13274414536 017154 0 ustar jelmer jelmer 0000000 0000000 This 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.sh 0000644 0001750 0001750 00000001220 13366211554 017277 0 ustar jelmer jelmer 0000000 0000000 #!/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/.gitignore 0000644 0001750 0001750 00000000266 13333723552 016171 0 ustar jelmer jelmer 0000000 0000000 *.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/TODO 0000644 0001750 0001750 00000000560 13274414536 014671 0 ustar jelmer jelmer 0000000 0000000 webdav 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/Dockerfile 0000644 0001750 0001750 00000003656 13366211554 016201 0 ustar jelmer jelmer 0000000 0000000 # 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.rst 0000644 0001750 0001750 00000000423 13274414536 016640 0 ustar jelmer jelmer 0000000 0000000 Xandikos 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/ 0000755 0001750 0001750 00000000000 13372667507 014757 5 ustar jelmer jelmer 0000000 0000000 xandikos-0.0.11/bin/xandikos 0000755 0001750 0001750 00000002043 13366211554 016512 0 ustar jelmer jelmer 0000000 0000000 #!/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.1 0000644 0001750 0001750 00000002735 13343040561 016077 0 ustar jelmer jelmer 0000000 0000000 .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/.mailmap 0000644 0001750 0001750 00000000214 13274414536 015616 0 ustar jelmer jelmer 0000000 0000000 Jelmer Vernooij Jelmer Vernooij
Jelmer Vernooij Jelmer Vernooij
xandikos-0.0.11/notes/ 0000755 0001750 0001750 00000000000 13372667507 015337 5 ustar jelmer jelmer 0000000 0000000 xandikos-0.0.11/notes/api-stability.rst 0000644 0001750 0001750 00000000717 13343040561 020630 0 ustar jelmer jelmer 0000000 0000000 API 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.rst 0000644 0001750 0001750 00000001162 13343040757 021134 0 ustar jelmer jelmer 0000000 0000000 CalDAV 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.rst 0000644 0001750 0001750 00000002424 13274414536 017334 0 ustar jelmer jelmer 0000000 0000000 WebDAV 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.rst 0000644 0001750 0001750 00000002235 13343040757 017356 0 ustar jelmer jelmer 0000000 0000000 Running 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.rst 0000644 0001750 0001750 00000003323 13366211554 021456 0 ustar jelmer jelmer 0000000 0000000 Per-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.rst 0000644 0001750 0001750 00000001767 13274414536 020302 0 ustar jelmer jelmer 0000000 0000000 File 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.rst 0000644 0001750 0001750 00000001316 13274414536 020123 0 ustar jelmer jelmer 0000000 0000000 Xandikos 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.rst 0000644 0001750 0001750 00000000765 13274414536 017033 0 ustar jelmer jelmer 0000000 0000000 Authentication
==============
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.rst 0000644 0001750 0001750 00000000341 13274414536 021154 0 ustar jelmer jelmer 0000000 0000000 Release 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.rst 0000644 0001750 0001750 00000016740 13343040757 020751 0 ustar jelmer jelmer 0000000 0000000 DAV 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.rst 0000644 0001750 0001750 00000001341 13274414536 017545 0 ustar jelmer jelmer 0000000 0000000 Contexts
========
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.txt 0000644 0001750 0001750 00000000057 13260416120 017461 0 ustar jelmer jelmer 0000000 0000000 DAV in class names is spelled in all capitals.
xandikos-0.0.11/notes/store.rst 0000644 0001750 0001750 00000001100 13274414536 017206 0 ustar jelmer jelmer 0000000 0000000 Dulwich 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.rst 0000644 0001750 0001750 00000000440 13274414536 020245 0 ustar jelmer jelmer 0000000 0000000 Monitoring
==========
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.rst 0000644 0001750 0001750 00000000254 13274414536 017170 0 ustar jelmer jelmer 0000000 0000000 Goals
=====
- 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.rst 0000644 0001750 0001750 00000002737 13366211554 020176 0 ustar jelmer jelmer 0000000 0000000 Multi-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/Makefile 0000644 0001750 0001750 00000003015 13371556457 015646 0 ustar jelmer jelmer 0000000 0000000 export 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/ 0000755 0001750 0001750 00000000000 13372667507 016027 5 ustar jelmer jelmer 0000000 0000000 xandikos-0.0.11/xandikos/__init__.py 0000644 0001750 0001750 00000001753 13371556011 020130 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000003222 13343040561 020374 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000004416 13343040561 017626 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000103264 13366212322 017144 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000016252 13366212322 020514 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000002343 13366211554 017470 0 ustar jelmer jelmer 0000000 0000000 # 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/ 0000755 0001750 0001750 00000000000 13372667507 017163 5 ustar jelmer jelmer 0000000 0000000 xandikos-0.0.11/xandikos/store/__init__.py 0000644 0001750 0001750 00000023207 13366211554 021266 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000026171 13366211554 020476 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000003011 13343040757 020763 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000051061 13371556011 020305 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000003007 13343040561 017601 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000164620 13371556011 017644 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000002757 13343040757 017533 0 ustar jelmer jelmer 0000000 0000000 # 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__.py 0000644 0001750 0001750 00000001707 13366211554 020114 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000010707 13343040757 017350 0 ustar jelmer jelmer 0000000 0000000 # 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/ 0000755 0001750 0001750 00000000000 13372667507 020025 5 ustar jelmer jelmer 0000000 0000000 xandikos-0.0.11/xandikos/templates/principal.html 0000644 0001750 0001750 00000001447 13366211554 022670 0 ustar jelmer jelmer 0000000 0000000
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.html 0000644 0001750 0001750 00000001277 13366211554 023043 0 ustar jelmer jelmer 0000000 0000000
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.html 0000644 0001750 0001750 00000001101 13366211554 021655 0 ustar jelmer jelmer 0000000 0000000
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.py 0000644 0001750 0001750 00000004355 13343040561 017500 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000007141 13366212322 020347 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000025513 13366212322 017773 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000023635 13371556011 020316 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000100546 13371556011 017623 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000004366 13343040757 020721 0 ustar jelmer jelmer 0000000 0000000 # 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/ 0000755 0001750 0001750 00000000000 13372667507 017171 5 ustar jelmer jelmer 0000000 0000000 xandikos-0.0.11/xandikos/tests/__init__.py 0000644 0001750 0001750 00000002167 13343040757 021276 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000003647 13343040757 024123 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000001437 13343037646 021355 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000007347 13343040757 022035 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000002751 13343040561 021337 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000040311 13343040757 022037 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000031752 13371556011 021730 0 ustar jelmer jelmer 0000000 0000000 # 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.py 0000644 0001750 0001750 00000005622 13343040757 022517 0 ustar jelmer jelmer 0000000 0000000 # 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 something
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.py 0000644 0001750 0001750 00000003443 13343037646 017347 0 ustar jelmer jelmer 0000000 0000000 # 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.yml 0000644 0001750 0001750 00000002044 13371556011 016302 0 ustar jelmer jelmer 0000000 0000000 language: 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/COPYING 0000644 0001750 0001750 00000104513 13260416120 015221 0 ustar jelmer jelmer 0000000 0000000 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-INFO 0000644 0001750 0001750 00000020527 13372667507 015312 0 ustar jelmer jelmer 0000000 0000000 Metadata-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.rst 0000644 0001750 0001750 00000000434 13371555750 015602 0 ustar jelmer jelmer 0000000 0000000 The 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/ 0000755 0001750 0001750 00000000000 13372667507 017521 5 ustar jelmer jelmer 0000000 0000000 xandikos-0.0.11/xandikos.egg-info/requires.txt 0000644 0001750 0001750 00000000054 13372667507 022120 0 ustar jelmer jelmer 0000000 0000000 icalendar
dulwich>=0.19.1
defusedxml
jinja2
xandikos-0.0.11/xandikos.egg-info/SOURCES.txt 0000644 0001750 0001750 00000003424 13372667507 021410 0 ustar jelmer jelmer 0000000 0000000 .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.py xandikos-0.0.11/xandikos.egg-info/PKG-INFO 0000644 0001750 0001750 00000020527 13372667507 020624 0 ustar jelmer jelmer 0000000 0000000 Metadata-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.txt 0000644 0001750 0001750 00000000011 13372667507 022243 0 ustar jelmer jelmer 0000000 0000000 xandikos
xandikos-0.0.11/xandikos.egg-info/dependency_links.txt 0000644 0001750 0001750 00000000001 13372667507 023567 0 ustar jelmer jelmer 0000000 0000000
xandikos-0.0.11/.testr.conf 0000644 0001750 0001750 00000000243 13260416120 016247 0 ustar jelmer jelmer 0000000 0000000 [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.yml 0000644 0001750 0001750 00000004354 13371556011 016567 0 ustar jelmer jelmer 0000000 0000000 environment:
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.rst 0000644 0001750 0001750 00000014454 13371556011 015670 0 ustar jelmer jelmer 0000000 0000000 .. 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.