Code Repositories xandikos / upstream/0.0.10
Import upstream version 0.0.10, md5 dcf3502c964d4a070ed415db9e8f9bc0 Jelmer Vernooij 1 year, 10 months ago
31 changed file(s) with 1061 addition(s) and 92 deletion(s). Raw diff Collapse all Expand all
00 language: python
11 cache: pip
22 sudo: true
3 addons:
4 apt:
5 update: true
36 python:
47 - 3.3
58 - 3.4
69 - 3.5
710 - 3.6
8 - pypy3.3-5.2-alpha1
11 - pypy3.5
912 env:
1013 global: PYTHONHASHSEED=random
14 matrix:
15 include:
16 - python: 3.7
17 dist: xenial
18 # defusedxml appears to be broken on Python 3.8:
19 #- python: 3.8-dev
20 # dist: xenial
1121 install:
1222 - pip install pip --upgrade
1323 - pip install coverage codecov flake8 pycalendar
3141 - python -m coverage combine
3242 - codecov
3343 cache:
34 pip: true
44 pip: true
0 # Docker file for Xandikos.
1 # This docker image starts a Xandikos server on port 8000. It supports two
2 # environment variables:
3 #
4 # - autocreate: "principal" / "defaults"
5 # If set to "yes", this will create the user principal, but not any
6 # calendars or address books.
7 # If set to "defaults", it will create a default calendar
8 # (under $current_user_principal/calendars/calendar) and a default
9 # addressbook (under $current_user_principal/contacts/addressbook)
10 #
11 # - current_user_principal: /path/to/user/principal
12 # This specifies the path to the current users' principal, and effectively
13 # the path under which Xandikos will be available.
14 # It is recommended that you set it to "/YOURUSERNAME"
15 #
16 # E.g. If autocreate is set to "defaults" and current_user_principal is set to
17 # "/dav/joe", Xandikos will provide two collections (one calendar, one
18 # addressbook) at respecively:
19 #
20 # http://localhost:8000/dav/joe/calendars/calendar
21 # http://localhost:8000/dav/joe/contacts/addressbook
22 #
23 # Note that this dockerfile starts Xandikos without any authentication;
24 # for authenticated access we recommend you run it behind a reverse proxy.
25
026 FROM debian:sid
127 LABEL maintainer="jelmer@jelmer.uk"
228 RUN apt-get update && \
733 VOLUME /data
834 EXPOSE 8000
935 ENV autocreate="defaults"
10 ENV current_user_principal="/dav/user1"
36 ENV current_user_principal="/user1"
1137
1238 # TODO(jelmer): Add support for authentication
1339 # --plugin=router_basicauth,python3 --route="^/ basicauth:myrealm,user1:password1"
14 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
40 CMD uwsgi --http-socket=:8000 \
41 --umask=022 \
42 --master \
43 --cheaper=2 \
44 --processes=4 \
45 --plugin=python3 \
46 --module=xandikos.wsgi:app \
47 --env=XANDIKOSPATH=/data \
48 --env=CURRENT_USER_PRINCIPAL=$current_user_principal \
49 --env=AUTOCREATE=$autocreate
3131 ./compat/xandikos-vdirsyncer.sh
3232
3333 coverage-vdirsyncer:
34 PYTEST_ARGS="--cov-config $(shell pwd)/.coveragerc --cov-append --cov $(shell pwd)/xandikos" ./compat/xandikos-vdirsyncer.sh
35 $(COVERAGE) combine -a compat/vdirsyncer/.coverage
34 XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-vdirsyncer.sh
3635
3736 check-caldavtester:
3837 TESTS="$(CALDAVTESTER_TESTS)" ./compat/xandikos-caldavtester.sh
00 Metadata-Version: 1.1
11 Name: xandikos
2 Version: 0.0.9
2 Version: 0.0.10
33 Summary: Lightweight CalDAV/CardDAV server
44 Home-page: https://www.xandikos.org/
55 Author: Jelmer Vernooij
66 Author-email: jelmer@jelmer.uk
77 License: GNU GPLv3 or later
8 Description: UNKNOWN
8 Description: .. image:: https://travis-ci.org/jelmer/xandikos.png?branch=master
9 :target: https://travis-ci.org/jelmer/xandikos
10 :alt: Build Status
11
12 .. image:: https://ci.appveyor.com/api/projects/status/fjqtsk8agwmwavqk/branch/master?svg=true
13 :target: https://ci.appveyor.com/project/jelmer/xandikos/branch/master
14 :alt: Windows Build Status
15
16
17 Xandikos is a lightweight yet complete CardDAV/CalDAV server that backs onto a Git repository.
18
19 Xandikos (Ξανδικός or Ξανθικός) takes its name from the name of the March month
20 in the ancient Macedonian calendar, used in Macedon in the first millennium BC.
21
22 Implemented standards
23 =====================
24
25 The following standards are implemented:
26
27 - :RFC:`4918`/:RFC:`2518` (Core WebDAV) - *implemented, except for COPY/MOVE/LOCK operations*
28 - :RFC:`4791` (CalDAV) - *fully implemented*
29 - :RFC:`6352` (CardDAV) - *fully implemented*
30 - :RFC:`5397` (Current Principal) - *fully implemented*
31 - :RFC:`3253` (Versioning Extensions) - *partially implemented, only the REPORT method and {DAV:}expand-property property*
32 - :RFC:`3744` (Access Control) - *partially implemented*
33 - :RFC:`5995` (POST to create members) - *fully implemented*
34 - :RFC:`5689` (Extended MKCOL) - *fully implemented*
35
36 The following standards are not implemented:
37
38 - :RFC:`6638` (CalDAV Scheduling Extensions) - *not implemented*
39 - :RFC:`7809` (CalDAV Time Zone Extensions) - *not implemented*
40 - :RFC:`7529` (WebDAV Quota) - *not implemented*
41 - :RFC:`4709` (WebDAV Mount) - `intentionally <https://github.com/jelmer/xandikos/issues/48>`_ *not implemented*
42 - :RFC:`5546` (iCal iTIP) - *not implemented*
43 - :RFC:`4324` (iCAL CAP) - *not implemented*
44 - :RFC:`7953` (iCal AVAILABILITY) - *not implemented*
45
46 See `DAV compliance <notes/dav-compliance.rst>`_ for more detail on specification compliancy.
47
48 Limitations
49 -----------
50
51 - No multi-user support
52 - No support for CalDAV scheduling extensions
53
54 Supported clients
55 =================
56
57 Xandikos has been tested and works with the following CalDAV/CardDAV clients:
58
59 - `Vdirsyncer <https://github.com/pimutils/vdirsyncer>`_
60 - `caldavzap <https://www.inf-it.com/open-source/clients/caldavzap/>`_/`carddavmate <https://www.inf-it.com/open-source/clients/carddavmate/>`_
61 - `evolution <https://wiki.gnome.org/Apps/Evolution>`_
62 - `DAVdroid <https://davdroid.bitfire.at/>`_
63 - `sogo connector for Icedove/Thunderbird <http://v2.sogo.nu/english/downloads/frontends.html>`_
64 - `aCALdav syncer for Android <https://play.google.com/store/apps/details?id=de.we.acaldav&hl=en>`_
65 - `pycardsyncer <https://github.com/geier/pycarddav>`_
66 - `akonadi <https://community.kde.org/KDE_PIM/Akonadi>`_
67 - `CalDAV-Sync <https://dmfs.org/caldav/>`_
68 - `CardDAV-Sync <https://dmfs.org/carddav/>`_
69 - `Calendarsync <https://play.google.com/store/apps/details?id=com.icalparse>`_
70 - `Tasks <https://github.com/tasks/tasks/tree/caldav>`_
71 - `AgendaV <http://agendav.org/>`_
72 - `CardBook <https://gitlab.com/cardbook/cardbook/>`_
73
74 Dependencies
75 ============
76
77 At the moment, Xandikos supports Python 3.3 and higher as well as Pypy 3. It
78 also uses `Dulwich <https://github.com/jelmer/dulwich>`_,
79 `Jinja2 <http://jinja.pocoo.org/>`_,
80 `icalendar <https://github.com/collective/icalendar>`_, and
81 `defusedxml <https://github.com/tiran/defusedxml>`_.
82
83 E.g. to install those dependencies on Debian:
84
85 .. code:: shell
86
87 sudo apt install python3-dulwich python3-defusedxml python3-icalendar python3-jinja2
88
89 Or to install them using pip:
90
91 .. code:: shell
92
93 python setup.py develop
94
95 Docker
96 ------
97
98 A Dockerfile is also provided; see the comments on the top of the file for
99 configuration instructions.
100
101 Running
102 =======
103
104 Testing
105 -------
106
107 To run a standalone (low-performance, no authentication) instance of Xandikos,
108 with a pre-created calendar and addressbook (storing data in *$HOME/dav*):
109
110 .. code:: shell
111
112 ./bin/xandikos --defaults -d $HOME/dav
113
114 A server should now be listening on `localhost:8080 <http://localhost:8080/>`_.
115
116 Note that Xandikos does not create any collections unless --defaults is
117 specified. You can also either create collections from your CalDAV/CardDAV client,
118 or by creating git repositories under the *contacts* or *calendars* directories
119 it has created.
120
121 Production
122 ----------
123
124 The easiest way to run Xandikos in production is using
125 `uWSGI <https://uwsgi-docs.readthedocs.io/en/latest/>`_.
126
127 One option is to setup uWSGI with a server like
128 `Apache <http://uwsgi-docs.readthedocs.io/en/latest/Apache.html>`_,
129 `Nginx <http://uwsgi-docs.readthedocs.io/en/latest/Nginx.html>`_ or another web
130 server that can authenticate users and forward authorized requests to
131 Xandikos in uWSGI. See `examples/uwsgi.ini <examples/uwsgi.ini>`_ for an
132 example uWSGI configuration.
133
134 Alternatively, you can run uWSGI standalone and have it authenticate and
135 directly serve HTTP traffic. An example configuration for this can be found in
136 `examples/uwsgi-standalone.ini <examples/uwsgi-standalone.ini>`_.
137
138 This will start a server on `localhost:8080 <http://localhost:8080/>`_ with username *user1* and password
139 *password1*.
140
141 .. code:: shell
142
143 mkdir -p $HOME/dav
144 uwsgi examples/uwsgi-standalone.ini
145
146 Client instructions
147 ===================
148
149 Some clients can automatically discover the calendars and addressbook URLs from
150 a DAV server (if they support RFC:`5397`). For such clients you can simply
151 provide the base URL to Xandikos during setup.
152
153 Clients that lack such automated discovery (e.g. Thunderbird Lightning) require
154 the direct URL to a calendar or addressbook. In this case you
155 should provide the full URL to the calendar or addressbook; if you initialized
156 Xandikos using the ``--defaults`` argument mentioned in the previous section,
157 these URLs will look something like this::
158
159 http://dav.example.com/user/calendars/calendar
160
161 http://dav.example.com/user/contacts/addressbook
162
163
164 Contributing
165 ============
166
167 Contributions to Xandikos are very welcome. If you run into bugs or have
168 feature requests, please file issues `on GitHub
169 <https://github.com/jelmer/xandikos/issues/new>`_. If you're interested in
170 contributing code or documentation, please read `CONTRIBUTING
171 <CONTRIBUTING.rst>`_. Issues that are good for new contributors are tagged
172 `new-contributor <https://github.com/jelmer/xandikos/labels/new-contributor>`_
173 on GitHub.
174
175 Help
176 ====
177
178 There is a *#xandikos* IRC channel on the `Freenode <https://www.freenode.net/>`_
179 IRC network, and a `Xandikos <https://groups.google.com/forum/#!forum/xandikos>`_
180 mailing list.
181
9182 Platform: UNKNOWN
10183 Classifier: Development Status :: 4 - Beta
11184 Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
6161 - `Calendarsync <https://play.google.com/store/apps/details?id=com.icalparse>`_
6262 - `Tasks <https://github.com/tasks/tasks/tree/caldav>`_
6363 - `AgendaV <http://agendav.org/>`_
64
65 Client instructions
66 ===================
67
68 Some clients can automatically discover the calendar and addressbook URLs from
69 a DAV server. For such clients you can simply provide the URL to Xandikos directly.
70
71 Clients that lack such automated discovery require the direct URL to a calendar
72 or addressbook. One such client is Thunderbird lightning in which case you
73 should provide a URL similar to the following:
74
75 ::
76
77 http://dav.example.com/user/calendars/my_calendar
64 - `CardBook <https://gitlab.com/cardbook/cardbook/>`_
7865
7966 Dependencies
8067 ============
9683 .. code:: shell
9784
9885 python setup.py develop
86
87 Docker
88 ------
89
90 A Dockerfile is also provided; see the comments on the top of the file for
91 configuration instructions.
9992
10093 Running
10194 =======
142135 mkdir -p $HOME/dav
143136 uwsgi examples/uwsgi-standalone.ini
144137
138 Client instructions
139 ===================
140
141 Some clients can automatically discover the calendars and addressbook URLs from
142 a DAV server (if they support RFC:`5397`). For such clients you can simply
143 provide the base URL to Xandikos during setup.
144
145 Clients that lack such automated discovery (e.g. Thunderbird Lightning) require
146 the direct URL to a calendar or addressbook. In this case you
147 should provide the full URL to the calendar or addressbook; if you initialized
148 Xandikos using the ``--defaults`` argument mentioned in the previous section,
149 these URLs will look something like this::
150
151 http://dav.example.com/user/calendars/calendar
152
153 http://dav.example.com/user/contacts/addressbook
154
155
145156 Contributing
146157 ============
147158
55 PYTHON_VERSION: "3.3.x"
66 PYTHON_ARCH: "32"
77
8 - PYTHON: "C:\\Python33-x64"
9 PYTHON_VERSION: "3.3.x"
10 PYTHON_ARCH: "64"
11
128 - PYTHON: "C:\\Python34"
139 PYTHON_VERSION: "3.4.x"
1410 PYTHON_ARCH: "32"
15
16 - PYTHON: "C:\\Python34-x64"
17 PYTHON_VERSION: "3.4.x"
18 PYTHON_ARCH: "64"
1911
2012 - PYTHON: "C:\\Python35"
2113 PYTHON_VERSION: "3.5.x"
2525 sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
2626
2727
28 from xandikos.web import main
28 from xandikos.__main__ import main
2929
30 main(sys.argv)
30 sys.exit(main(sys.argv))
1818
1919 run_xandikos()
2020 {
21 ${XANDIKOS} -p5233 -llocalhost -d ${SERVEDIR} "$@" 2>&1 >$DAEMON_LOG &
21 PORT="$1"
22 shift 1
23 ${XANDIKOS} -p${PORT} -llocalhost -d ${SERVEDIR} "$@" 2>&1 >$DAEMON_LOG &
2224 XANDIKOS_PID=$!
2325 trap xandikos_cleanup 0 EXIT
2426 i=0
2527 while [ $i -lt 10 ]
2628 do
27 if curl http://localhost:5233/ >/dev/null; then
29 if curl http://localhost:${PORT}/ >/dev/null; then
2830 break
2931 fi
3032 sleep 1
6060 mkcol principals/users
6161 mkprincipal principals/users/user01
6262
63 run_xandikos --defaults
63 run_xandikos 5233 --defaults
6464
6565 $TESTCALDAV --print-details-onfail -s ${CFGDIR}/serverinfo.xml ${TESTS}
66
77 set -e
88
9 run_xandikos --autocreate
9 run_xandikos 5233 --autocreate
1010
1111 if which litmus >/dev/null; then
1212 LITMUS=litmus
00 #!/bin/bash
1
2 . $(dirname $0)/common.sh
3
14 set -e
25
36 readonly BRANCH=master
7
8 run_xandikos 5001 --autocreate
49
510 [ -z "$PYTHON" ] && PYTHON=python3
611
3338 # Add --ignore=tests/system/utils/test_main.py since it fails in travis,
3439 # and isn't testing anything relevant to Xandikos.
3540 make \
36 COVERAGE=true \
3741 PYTEST_ARGS="${PYTEST_ARGS} tests/storage/dav/ --ignore=tests/system/utils/test_main.py" \
3842 DAV_SERVER=xandikos \
3943 install-dev install-test test
44 exit 0
11 ============================
22
33 Xandikos needs to store several piece of per-collection metadata.
4
5 Goals
6 -----
7
8 Find a place to store per-collection metadata.
49
510 Some of these can be inferred from other sources.
611
813
914 - resource types: principal, calendar, addressbook
1015
16 At the moment, Xandikos is storing some of this information in git configuration. However, this means:
17
18 * it is not versioned
19 * there is a 1-1 relationship between collections and git repositories
20 * some users object to mixing in this metadata in their git config
21
1122 Per resource type-specific properties
1223 -------------------------------------
24
25 Generic
26 ~~~~~~~
27
28 - ACLs
29 - owner?
1330
1431 Principal
1532 ~~~~~~~~~
5572 Store a ini-style .xandikos file in the directory hosting the Collection (or
5673 Tree in case of a Git repository).
5774
75 All properties mentioned above are simple key/value pairs. For simplicity, it
76 may make sense to use an ini-style format so that users can edit metadata using their editor.
77
5878 Example
5979 -------
6080 # This is a standard Python configobj file, so it's mostly ini-style, and comments
6181 # can appear preceded by #.
6282
6383 color = 030003
64
65
3333 Roadmap
3434 =======
3535
36 * Allow marking collections as principals
37 * Expose username (or None, if not logged in) everywhere
38 * Add function get_username_principal() for mapping username to principal path
36 * Optional: Allow marking collections as principals [DONE]
37 * Expose username (or None, if not logged in) everywhere [DONE]
38 * Add function get_username_principal() for mapping username to principal path [DONE]
39 * Support automatic creation of principal on first login of user
3940 * Add simple function check_path_access() for checking access ("is this user allowed to access this path?")
4041 * Use access checking function everywhere
41 * Have current-user-principal setting depend on $REMOTE_USER and get_username_principal()
42 * Have current-user-principal setting depend on $REMOTE_USER and get_username_principal() [DONE]
2222 from setuptools import find_packages, setup
2323 import sys
2424
25 version = "0.0.9"
25 version = "0.0.10"
2626
2727 if sys.platform != 'win32':
2828 # Win32 setup breaks on non-ascii characters
3030 else:
3131 author = "Jelmer Vernooij"
3232
33 with open('README.rst') as f:
34 long_description = f.read()
35
3336 setup(name="xandikos",
3437 description="Lightweight CalDAV/CardDAV server",
38 long_description=long_description,
3539 version=version,
3640 author=author,
3741 author_email="jelmer@jelmer.uk",
2020
2121 """CalDAV/CardDAV server."""
2222
23 __version__ = (0, 0, 9)
23 __version__ = (0, 0, 10)
2424 version_string = '.'.join(map(str, __version__))
2525
2626 import defusedxml.ElementTree # noqa: This does some monkey-patching on-load
0 # Xandikos
1 # Copyright (C) 2016-2018 Jelmer Vernooij <jelmer@jelmer.uk>, et al.
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 3
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """Xandikos command-line handling."""
20
21
22 def main(argv):
23 from .web import main
24 return main(argv)
25
26
27 if __name__ == '__main__':
28 import sys
29 main(sys.argv)
3535 """Validate a calendar object.
3636
3737 :param cal: Calendar object
38 :return: iterator over error messages
3839 """
3940 for error in validate_component(cal, strict=strict):
4041 yield error
209210 cal = self.calendar
210211 # TODO(jelmer): return the list of errors to the caller
211212 if cal.is_broken:
212 raise InvalidFileContents(self.content_type, self.content)
213 if list(validate_calendar(cal, strict=False)):
214 raise InvalidFileContents(self.content_type, self.content)
213 raise InvalidFileContents(
214 self.content_type, self.content,
215 "Broken calendar file")
216 errors = list(validate_calendar(cal, strict=False))
217 if errors:
218 raise InvalidFileContents(
219 self.content_type, self.content,
220 ", ".join(errors))
221
222 def normalized(self):
223 """Return a normalized version of the file."""
224 return [self.calendar.to_ical()]
215225
216226 @property
217227 def calendar(self):
218228 if self._calendar is None:
219229 try:
220230 self._calendar = Calendar.from_ical(b''.join(self.content))
221 except ValueError:
222 raise InvalidFileContents(self.content_type, self.content)
231 except ValueError as e:
232 raise InvalidFileContents(
233 self.content_type, self.content, str(e))
223234 return self._calendar
224235
225236 def describe_delta(self, name, previous):
218218 """
219219
220220 name = '{%s}schedule-default-calendar-URL' % caldav.NAMESPACE
221 resource_types = SCHEDULE_INBOX_RESOURCE_TYPE
221 resource_type = SCHEDULE_INBOX_RESOURCE_TYPE
222222 in_allprops = True
223223
224224 def get_value(self, href, resource, el, environ):
5959 """
6060 pass
6161
62 def normalized(self):
63 """Return a normalized version of the file.
64 """
65 return self.content
66
6267 def describe(self, name):
6368 """Describe the contents of this file.
6469
153158 class InvalidFileContents(Exception):
154159 """Invalid file contents."""
155160
156 def __init__(self, content_type, data):
161 def __init__(self, content_type, data, error):
157162 self.content_type = content_type
158163 self.data = data
164 self.error = error
159165
160166
161167 class Store(object):
157157 except KeyError:
158158 old_fi = None
159159 message = '\n'.join(fi.describe_delta(name, old_fi))
160 etag = self._import_one(name, data, message, author=author)
160 etag = self._import_one(name, fi.normalized(), message, author=author)
161161 return (name, etag.decode('ascii'))
162162
163163 def _get_raw(self, name, etag=None):
470470 b = Blob()
471471 b.chunked = data
472472 tree = self._get_current_tree()
473 old_tree_id = tree.id
473474 name_enc = name.encode(DEFAULT_ENCODING)
474475 tree[name_enc] = (0o644 | stat.S_IFREG, b.id)
475476 self.repo.object_store.add_objects([(tree, ''), (b, name_enc)])
476 self._commit_tree(tree.id, message.encode(DEFAULT_ENCODING),
477 author=author)
477 if tree.id != old_tree_id:
478 self._commit_tree(tree.id, message.encode(DEFAULT_ENCODING),
479 author=author)
478480 return b.id
479481
480482 def delete_one(self, name, message=None, author=None, etag=None):
565567 f.writelines(data)
566568 st = os.lstat(p)
567569 blob = Blob.from_string(b''.join(data))
568 self.repo.object_store.add_object(blob)
569 index[name.encode(DEFAULT_ENCODING)] = IndexEntry(
570 *index_entry_from_stat(st, blob.id, 0))
571 self._commit_tree(
572 index, message.encode(DEFAULT_ENCODING),
573 author=author)
570 encoded_name = name.encode(DEFAULT_ENCODING)
571 if encoded_name not in index or blob.id != index[encoded_name].sha:
572 self.repo.object_store.add_object(blob)
573 index[encoded_name] = IndexEntry(
574 *index_entry_from_stat(st, blob.id, 0))
575 self._commit_tree(
576 index, message.encode(DEFAULT_ENCODING),
577 author=author)
574578 return blob.id
575579
576580 def delete_one(self, name, message=None, author=None, etag=None):
0 # Xandikos
1 # Copyright (C) 2016-2017 Jelmer Vernooij <jelmer@jelmer.uk>, et al.
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 3
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """vdir store.
20
21 See https://github.com/pimutils/vdirsyncer/blob/master/docs/vdir.rst
22 """
23
24 import errno
25 import hashlib
26 import logging
27 import os
28 import shutil
29 import uuid
30
31 from . import (
32 MIMETYPES,
33 Store,
34 DuplicateUidError,
35 InvalidETag,
36 InvalidFileContents,
37 NoSuchItem,
38 open_by_content_type,
39 open_by_extension,
40 )
41 from .config import CollectionConfig
42
43
44 DEFAULT_ENCODING = 'utf-8'
45
46
47 logger = logging.getLogger(__name__)
48
49
50 class VdirStore(Store):
51 """A Store backed by a Vdir directory.
52 """
53
54 def __init__(self, path, check_for_duplicate_uids=True):
55 super(VdirStore, self).__init__()
56 self.path = path
57 self._check_for_duplicate_uids = check_for_duplicate_uids
58 # Set of blob ids that have already been scanned
59 self._fname_to_uid = {}
60 # Maps uids to (sha, fname)
61 self._uid_to_fname = {}
62
63 @property
64 def config(self):
65 return CollectionConfig()
66
67 def __repr__(self):
68 return "%s(%r)" % (type(self).__name__, self.path)
69
70 def _get_etag(self, name):
71 path = os.path.join(self.path, name)
72 md5 = hashlib.md5()
73 try:
74 with open(path, 'rb') as f:
75 for chunk in f:
76 md5.update(chunk)
77 except IOError as e:
78 if e.errno == errno.ENOENT:
79 raise KeyError
80 raise
81 return md5.hexdigest()
82
83 def _get_raw(self, name, etag=None):
84 """Get the raw contents of an object.
85
86 :param name: Name of the item
87 :param etag: Optional etag
88 :return: raw contents as chunks
89 """
90 if etag is None:
91 etag = self._get_etag(name)
92 path = os.path.join(self.path, name)
93 try:
94 with open(path, 'rb') as f:
95 return [f.read()]
96 except IOError as e:
97 if e.errno == errno.ENOENT:
98 raise KeyError
99 raise
100
101 def _scan_uids(self):
102 removed = set(self._fname_to_uid.keys())
103 for (name, content_type, etag) in self.iter_with_etag():
104 if name in removed:
105 removed.remove(name)
106 if (name in self._fname_to_uid and
107 self._fname_to_uid[name][0] == etag):
108 continue
109 fi = open_by_extension(self._get_raw(name, etag), name,
110 self.extra_file_handlers)
111 try:
112 uid = fi.get_uid()
113 except KeyError:
114 logger.warning('No UID found in file %s', name)
115 uid = None
116 except InvalidFileContents:
117 logging.warning('Unable to parse file %s', name)
118 uid = None
119 except NotImplementedError:
120 # This file type doesn't support UIDs
121 uid = None
122 self._fname_to_uid[name] = (etag, uid)
123 if uid is not None:
124 self._uid_to_fname[uid] = (name, etag)
125 for name in removed:
126 (unused_etag, uid) = self._fname_to_uid[name]
127 if uid is not None:
128 del self._uid_to_fname[uid]
129 del self._fname_to_uid[name]
130
131 def _check_duplicate(self, uid, name, replace_etag):
132 if uid is not None and self._check_for_duplicate_uids:
133 self._scan_uids()
134 try:
135 (existing_name, _) = self._uid_to_fname[uid]
136 except KeyError:
137 pass
138 else:
139 if existing_name != name:
140 raise DuplicateUidError(uid, existing_name, name)
141
142 try:
143 etag = self._get_etag(name)
144 except KeyError:
145 etag = None
146 if replace_etag is not None and etag != replace_etag:
147 raise InvalidETag(name, etag, replace_etag)
148 return etag
149
150 def import_one(self, name, content_type, data, message=None, author=None,
151 replace_etag=None):
152 """Import a single object.
153
154 :param name: name of the object
155 :param content_type: Content type
156 :param data: serialized object as list of bytes
157 :param message: Commit message
158 :param author: Optional author
159 :param replace_etag: optional etag of object to replace
160 :raise InvalidETag: when the name already exists but with different
161 etag
162 :raise DuplicateUidError: when the uid already exists
163 :return: etag
164 """
165 if content_type is None:
166 fi = open_by_extension(data, name, self.extra_file_handlers)
167 else:
168 fi = open_by_content_type(
169 data, content_type, self.extra_file_handlers)
170 if name is None:
171 name = str(uuid.uuid4())
172 extension = MIMETYPES.guess_extension(content_type)
173 if extension is not None:
174 name += extension
175 fi.validate()
176 try:
177 uid = fi.get_uid()
178 except (KeyError, NotImplementedError):
179 uid = None
180 self._check_duplicate(uid, name, replace_etag)
181
182 # TODO(jelmer): Check that extensions match content type:
183 # if this is a vCard, the extension should be .vcf
184 # if this is a iCalendar, the extension should be .ics
185 # TODO(jelmer): check that a UID is present and that all UIDs are the
186 # same
187 path = os.path.join(self.path, name)
188 tmppath = os.path.join(self.path, name + '.tmp')
189 with open(tmppath, 'wb') as f:
190 for chunk in fi.normalized():
191 f.write(chunk)
192 os.replace(tmppath, path)
193 return (name, self._get_etag(name))
194
195 def iter_with_etag(self, ctag=None):
196 """Iterate over all items in the store with etag.
197
198 :param ctag: Ctag to iterate for
199 :yield: (name, content_type, etag) tuples
200 """
201 for name in os.listdir(self.path):
202 if name.endswith('.tmp'):
203 continue
204 if name.endswith('.ics'):
205 content_type = 'text/calendar'
206 elif name.endswith('.vcf'):
207 content_type = 'text/vcard'
208 else:
209 continue
210 yield (name, content_type, self._get_etag(name))
211
212 @classmethod
213 def create(cls, path):
214 """Create a new store backed by a Vdir on disk.
215
216 :return: A `VdirStore`
217 """
218 os.mkdir(path)
219 return cls(path)
220
221 @classmethod
222 def open_from_path(cls, path):
223 """Open a VdirStore from a path.
224
225 :param path: Path
226 :return: A `VdirStore`
227 """
228 return cls(path)
229
230 def get_description(self):
231 """Get extended description.
232
233 :return: repository description as string
234 """
235 raise NotImplementedError(self.get_description)
236
237 def set_description(self, description):
238 """Set extended description.
239
240 :param description: repository description as string
241 """
242 raise NotImplementedError(self.set_description)
243
244 def set_comment(self, comment):
245 """Set comment.
246
247 :param comment: Comment
248 """
249 raise NotImplementedError(self.set_comment)
250
251 def get_comment(self):
252 """Get comment.
253
254 :return: Comment
255 """
256 raise NotImplementedError(self.get_comment)
257
258 def _read_metadata(self, name):
259 try:
260 with open(os.path.join(self.path, name), 'r') as f:
261 return f.read().strip()
262 except EnvironmentError:
263 return None
264
265 def _write_metadata(self, name, data):
266 path = os.path.join(self.path, name)
267 if data is not None:
268 with open(path, 'w') as f:
269 f.write(data)
270 else:
271 os.unlink(path)
272
273 def get_color(self):
274 """Get color.
275
276 :return: A Color code, or None
277 """
278 color = self._read_metadata('color')
279 assert color.startswith('#')
280 return color
281
282 def set_color(self, color):
283 """Set the color code for this store."""
284 assert color.startswith('#')
285 self._write_metadata('color', color)
286
287 def get_displayname(self):
288 """Get display name.
289
290 :return: The display name, or None if not set
291 """
292 return self._read_metadata('displayname')
293
294 def set_displayname(self, displayname):
295 """Set the display name.
296
297 :param displayname: New display name
298 """
299 self._write_metadata('displayname', displayname)
300
301 def set_type(self, store_type):
302 """Set store type.
303
304 :param store_type: New store type (one of VALID_STORE_TYPES)
305 """
306 raise NotImplementedError(self.set_type)
307
308 def get_type(self):
309 """Get store type.
310 """
311 raise NotImplementedError(self.get_type)
312
313 def iter_changes(self, old_ctag, new_ctag):
314 """Get changes between two versions of this store.
315
316 :param old_ctag: Old ctag (None for empty Store)
317 :param new_ctag: New ctag
318 :return: Iterator over (name, content_type, old_etag, new_etag)
319 """
320 raise NotImplementedError(self.iter_changes)
321
322 def destroy(self):
323 """Destroy this store."""
324 shutil.rmtree(self.path)
325
326 def delete_one(self, name, message=None, author=None, etag=None):
327 """Delete an item.
328
329 :param name: Filename to delete
330 :param message: Commit message
331 :param author: Optional author
332 :param etag: Optional mandatory etag of object to remove
333 :raise NoSuchItem: when the item doesn't exist
334 :raise InvalidETag: If the specified ETag doesn't match the curren
335 """
336 path = os.path.join(self.path, name)
337 if etag is not None:
338 try:
339 current_etag = self._get_etag(name)
340 except KeyError:
341 raise NoSuchItem(name)
342 if etag != current_etag:
343 raise InvalidETag(name, etag, current_etag)
344 try:
345 os.unlink(path)
346 except EnvironmentError as e:
347 if e.errno == errno.ENOENT:
348 raise NoSuchItem(path)
349 raise
350
351 def get_ctag(self):
352 """Return the ctag for this store."""
353 raise NotImplementedError(self.get_ctag)
354
355 def subdirectories(self):
356 """Returns subdirectories to probe for other stores.
357
358 :return: List of names
359 """
360 ret = []
361 for name in os.listdir(self.path):
362 p = os.path.join(self.path, name)
363 if os.path.isdir(p):
364 ret.append(name)
365 return ret
1111 <ul>
1212 {% for name, resource in collection.members() %}
1313 {% if '{DAV:}collection' in resource.resource_types %}
14 <li><a href="{{ name }}">{{ name }}</a></li>
14 <li><a href="{{ urljoin(self_url+'/', name+'/') }}">{{ name }}</a></li>
1515 {% endif %}
1616 {% endfor %}
1717 </ul>
0 <html>
1 <head>
2 <title>WebDAV Principal - {{ principal.get_displayname() }}</title>
3 </head>
4 <body>
5 <h1>{{ principal.get_displayname() }} </h1>
6
7 <p>This is a user principal. CalDAV/CardDAV clients that support
8 autodiscovery can use the URL for this page for discovery.
9 </p>
10
11 <h2>Subcollections</h2>
12
13 <ul>
14 {% for name, resource in principal.members() %}
15 {% if '{DAV:}collection' in resource.resource_types %}
16 <li><a href="{{ urljoin(self_url+'/', name+'/') }}">{{ name }}</a></li>
17 {% endif %}
18 {% endfor %}
19 </ul>
20
21 <p>For more information about Xandikos, see <a
22 href="https://www.xandikos.org/">https://www.xandikos.org/</a>
23 or <a href="https://github.com/jelmer/xandikos">https://github.com/jelmer/xandikos</a>.
24 </p>
25 </body>
26 </html>
44 <body>
55 <p>This is a Xandikos WebDAV server.</p>
66
7 <p>Principals on this server:
8 <ul>
9 {% for name in principals %}
10 <li><a href="{{ urljoin(self_url+'/', name+'/') }}">{{ name }}</a></li>
11 {% endfor %}
12 </ul>
13 </p>
14
715 <p>For more information about Xandikos, see <a
816 href="https://www.xandikos.org/">https://www.xandikos.org/</a>
917 or <a href="https://github.com/jelmer/xandikos">https://github.com/jelmer/xandikos</a>.
3131 DuplicateUidError, File, InvalidETag, NoSuchItem)
3232 from xandikos.store.git import (
3333 GitStore, BareGitStore, TreeGitStore)
34 from xandikos.store.vdir import (
35 VdirStore)
3436
3537 EXAMPLE_VCALENDAR1 = b"""\
3638 BEGIN:VCALENDAR
4749 END:VCALENDAR
4850 """
4951
52 EXAMPLE_VCALENDAR1_NORMALIZED = b"""\
53 BEGIN:VCALENDAR\r
54 VERSION:2.0\r
55 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN\r
56 BEGIN:VTODO\r
57 CREATED:20150314T223512Z\r
58 DTSTAMP:20150527T221952Z\r
59 LAST-MODIFIED:20150314T223512Z\r
60 STATUS:NEEDS-ACTION\r
61 SUMMARY:do something\r
62 UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff\r
63 END:VTODO\r
64 END:VCALENDAR\r
65 """
66
5067 EXAMPLE_VCALENDAR2 = b"""\
5168 BEGIN:VCALENDAR
5269 VERSION:2.0
6279 END:VCALENDAR
6380 """
6481
82 EXAMPLE_VCALENDAR2_NORMALIZED = b"""\
83 BEGIN:VCALENDAR\r
84 VERSION:2.0\r
85 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN\r
86 BEGIN:VTODO\r
87 CREATED:20120314T223512Z\r
88 DTSTAMP:20130527T221952Z\r
89 LAST-MODIFIED:20150314T223512Z\r
90 STATUS:NEEDS-ACTION\r
91 SUMMARY:do something else\r
92 UID:bdc22764-b9e1-42c9-89c2-a85405d8fbff\r
93 END:VTODO\r
94 END:VCALENDAR\r
95 """
96
6597 EXAMPLE_VCALENDAR_NO_UID = b"""\
6698 BEGIN:VCALENDAR
6799 VERSION:2.0
114146 (name2, etag2) = gc.import_one('bar.ics', 'text/calendar',
115147 [EXAMPLE_VCALENDAR2])
116148 self.assertEqual(
117 EXAMPLE_VCALENDAR1,
149 EXAMPLE_VCALENDAR1_NORMALIZED,
118150 b''.join(gc._get_raw('foo.ics', etag1)))
119151 self.assertEqual(
120 EXAMPLE_VCALENDAR2,
152 EXAMPLE_VCALENDAR2_NORMALIZED,
121153 b''.join(gc._get_raw('bar.ics', etag2)))
122154 self.assertRaises(
123155 KeyError,
130162 (name1, etag2) = gc.import_one('bar.ics', 'text/calendar',
131163 [EXAMPLE_VCALENDAR2])
132164 f1 = gc.get_file('foo.ics', 'text/calendar', etag1)
133 self.assertEqual(EXAMPLE_VCALENDAR1, b''.join(f1.content))
165 self.assertEqual(EXAMPLE_VCALENDAR1_NORMALIZED, b''.join(f1.content))
134166 self.assertEqual('text/calendar', f1.content_type)
135167 f2 = gc.get_file('bar.ics', 'text/calendar', etag2)
136 self.assertEqual(EXAMPLE_VCALENDAR2, b''.join(f2.content))
168 self.assertEqual(EXAMPLE_VCALENDAR2_NORMALIZED, b''.join(f2.content))
137169 self.assertEqual('text/calendar', f2.content_type)
138170 self.assertRaises(
139171 KeyError,
181213 set([('foo.ics', 'text/calendar', etag1),
182214 ('bar.ics', 'text/calendar', etag2)]),
183215 set(gc.iter_with_etag()))
216
217
218 class VdirStoreTest(BaseStoreTest, unittest.TestCase):
219
220 kls = VdirStore
221
222 def create_store(self):
223 d = tempfile.mkdtemp()
224 self.addCleanup(shutil.rmtree, d)
225 store = self.kls.create(os.path.join(d, 'store'))
226 store.load_extra_file_handler(ICalendarFile)
227 return store
184228
185229
186230 class BaseGitStoreTest(BaseStoreTest):
247291 def test_default_no_subdirectories(self):
248292 gc = self.create_store()
249293 self.assertEqual([], gc.subdirectories())
294
295 def test_import_only_once(self):
296 gc = self.create_store()
297 (name1, etag1) = gc.import_one('foo.ics', 'text/calendar',
298 [EXAMPLE_VCALENDAR1])
299 (name2, etag2) = gc.import_one('foo.ics', 'text/calendar',
300 [EXAMPLE_VCALENDAR1])
301 self.assertEqual(name1, name2)
302 self.assertEqual(etag1, etag2)
303 walker = gc.repo.get_walker(include=[gc.repo.refs[gc.ref]])
304 self.assertEqual(1, len([w.commit for w in walker]))
250305
251306
252307 class GitStoreTest(unittest.TestCase):
3030 c = b''.join(self.content).strip()
3131 if not c.startswith((b'BEGIN:VCARD\r\n', b'BEGIN:VCARD\n')) or \
3232 not c.endswith(b'\nEND:VCARD'):
33 raise InvalidFileContents(self.content_type, self.content)
33 raise InvalidFileContents(
34 self.content_type, self.content,
35 "Missing header and trailer lines")
3131 import os
3232 import posixpath
3333 import shutil
34 import urllib.parse
3435
3536 from xandikos import __version__ as xandikos_version
3637 from xandikos import (access, apache, caldav, carddav, quota, sync, webdav,
7778 # TODO(jelmer): Support rendering other languages
7879 encoding = 'utf-8'
7980 template = jinja_env.get_template(name)
80 body = template.render(version=xandikos_version, **kwargs).encode(encoding)
81 body = template.render(
82 version=xandikos_version,
83 urljoin=urllib.parse.urljoin, **kwargs).encode(encoding)
8184 return ([body], len(body), None, 'text/html; encoding=%s' % encoding,
8285 ['en-UK'])
8386
129132 (name, etag) = self.store.import_one(
130133 self.name, self.content_type, data,
131134 replace_etag=extract_strong_etag(replace_etag))
132 except InvalidFileContents:
135 except InvalidFileContents as e:
133136 # TODO(jelmer): Not every invalid file is a calendar file..
134137 raise webdav.PreconditionFailure(
135138 '{%s}valid-calendar-data' % caldav.NAMESPACE,
136 'Not a valid calendar file.')
139 'Not a valid calendar file: %s' % e.error)
137140 except DuplicateUidError:
138141 raise webdav.PreconditionFailure(
139142 '{%s}no-uid-conflict' % caldav.NAMESPACE,
276279 def create_member(self, name, contents, content_type):
277280 try:
278281 (name, etag) = self.store.import_one(name, content_type, contents)
279 except InvalidFileContents:
282 except InvalidFileContents as e:
280283 # TODO(jelmer): Not every invalid file is a calendar file..
281284 raise webdav.PreconditionFailure(
282285 '{%s}valid-calendar-data' % caldav.NAMESPACE,
283 'Not a valid calendar file.')
286 'Not a valid calendar file: %s' % e.error)
284287 except DuplicateUidError:
285288 raise webdav.PreconditionFailure(
286289 '{%s}no-uid-conflict' % caldav.NAMESPACE,
343346 def get_body(self):
344347 raise NotImplementedError(self.get_body)
345348
346 def render(self, accepted_content_types, accepted_content_languages):
349 def render(self, self_url, accepted_content_types,
350 accepted_content_languages):
347351 content_types = webdav.pick_content_types(
348352 accepted_content_types, ['text/html'])
349353 assert content_types == ['text/html']
350354 return render_jinja_page(
351 'collection.html', accepted_content_languages, collection=self)
355 'collection.html', accepted_content_languages, collection=self,
356 self_url=self_url)
352357
353358 def get_is_executable(self):
354359 return False
558563 # RFC2518, section 8.6.2 says this should recursively delete.
559564 shutil.rmtree(p)
560565
561 def render(self, accepted_content_types, accepted_content_languages):
566 def render(self, self_url, accepted_content_types,
567 accepted_content_languages):
562568 content_types = webdav.pick_content_types(
563569 accepted_content_types, ['text/html'])
564570 assert content_types == ['text/html']
565 return render_jinja_page('root.html', accepted_content_languages)
571 return render_jinja_page(
572 'root.html', accepted_content_languages, self_url=self_url)
566573
567574 def get_is_executable(self):
568575 return False
584591 def __init__(self, backend):
585592 self.backend = backend
586593
587 def render(self, accepted_content_types, accepted_content_languages):
594 def render(self, self_url, accepted_content_types,
595 accepted_content_languages):
588596 content_types = webdav.pick_content_types(
589597 accepted_content_types, ['text/html'])
590598 assert content_types == ['text/html']
591 return render_jinja_page('root.html', accepted_content_languages)
599 return render_jinja_page(
600 'root.html', accepted_content_languages,
601 principals=self.backend.find_principals(),
602 self_url=self_url)
592603
593604 def get_body(self):
594605 raise KeyError
716727 except FileExistsError:
717728 pass
718729 return p
730
731 def render(self, self_url, accepted_content_types,
732 accepted_content_languages):
733 content_types = webdav.pick_content_types(
734 accepted_content_types, ['text/html'])
735 assert content_types == ['text/html']
736 return render_jinja_page(
737 'principal.html', accepted_content_languages, principal=self,
738 self_url=self_url)
719739
720740
721741 class PrincipalCollection(Collection, Principal):
768788 self._mark_as_principal(relpath)
769789 if create_defaults:
770790 create_principal_defaults(self, principal)
791
792 def find_principals(self):
793 """List all of the principals on this server."""
794 return self._user_principals
771795
772796 def get_resource(self, relpath):
773797 relpath = posixpath.normpath(relpath)
812836
813837 def __init__(self, backend, current_user_principal):
814838 super(XandikosApp, self).__init__(backend)
839
840 def get_current_user_principal(env):
841 try:
842 return current_user_principal % env
843 except KeyError:
844 return None
815845 self.register_properties([
816846 webdav.ResourceTypeProperty(),
817847 webdav.CurrentUserPrincipalProperty(
818 current_user_principal),
848 get_current_user_principal),
819849 webdav.PrincipalURLProperty(),
820850 webdav.DisplayNameProperty(),
821851 webdav.GetETagProperty(),
494494 in_allprops = False
495495 live = True
496496
497 def __init__(self, current_user_principal):
497 def __init__(self, get_current_user_principal):
498498 super(CurrentUserPrincipalProperty, self).__init__()
499 self.current_user_principal = ensure_trailing_slash(
500 current_user_principal.lstrip('/'))
499 self.get_current_user_principal = get_current_user_principal
501500
502501 def get_value(self, href, resource, el, environ):
503502 """Get property with specified name.
504503
505504 :param name: A property name.
506505 """
507 if self.current_user_principal is None:
506 current_user_principal = self.get_current_user_principal(environ)
507 if current_user_principal is None:
508508 ET.SubElement(el, '{DAV:}unauthenticated')
509509 else:
510 current_user_principal = ensure_trailing_slash(
511 current_user_principal.lstrip('/'))
510512 el.append(create_href(
511 self.current_user_principal, environ['SCRIPT_NAME']))
513 current_user_principal, environ['SCRIPT_NAME']))
512514
513515
514516 class PrincipalURLProperty(Property):
672674 :return: Iterable over bytestrings."""
673675 raise NotImplementedError(self.get_body)
674676
675 def render(self, accepted_content_types, accepted_languages):
677 def render(self, self_url, accepted_content_types, accepted_languages):
676678 """'Render' this resource in the specified content type.
677679
678680 The default implementation just checks that the
10791081
10801082
10811083 def create_href(href, base_href=None):
1082 if '//' in href:
1083 logging.warning('invalidly formatted href: %s' % href)
1084 parsed_url = urllib.parse.urlparse(href)
1085 if '//' in parsed_url.path:
1086 logging.warning('invalidly formatted href: %s', href)
10841087 et = ET.Element('{DAV:}href')
10851088 if base_href is not None:
10861089 href = urllib.parse.urljoin(ensure_trailing_slash(base_href), href)
16801683 current_etag,
16811684 content_type,
16821685 content_languages
1683 ) = r.render(accept_content_types, accept_content_languages)
1686 ) = r.render(environ['SCRIPT_NAME'] + environ['PATH_INFO'],
1687 accept_content_types, accept_content_languages)
16841688
16851689 if_none_match = environ.get('HTTP_IF_NONE_MATCH', None)
16861690 if (
00 Metadata-Version: 1.1
11 Name: xandikos
2 Version: 0.0.9
2 Version: 0.0.10
33 Summary: Lightweight CalDAV/CardDAV server
44 Home-page: https://www.xandikos.org/
55 Author: Jelmer Vernooij
66 Author-email: jelmer@jelmer.uk
77 License: GNU GPLv3 or later
8 Description: UNKNOWN
8 Description: .. image:: https://travis-ci.org/jelmer/xandikos.png?branch=master
9 :target: https://travis-ci.org/jelmer/xandikos
10 :alt: Build Status
11
12 .. image:: https://ci.appveyor.com/api/projects/status/fjqtsk8agwmwavqk/branch/master?svg=true
13 :target: https://ci.appveyor.com/project/jelmer/xandikos/branch/master
14 :alt: Windows Build Status
15
16
17 Xandikos is a lightweight yet complete CardDAV/CalDAV server that backs onto a Git repository.
18
19 Xandikos (Ξανδικός or Ξανθικός) takes its name from the name of the March month
20 in the ancient Macedonian calendar, used in Macedon in the first millennium BC.
21
22 Implemented standards
23 =====================
24
25 The following standards are implemented:
26
27 - :RFC:`4918`/:RFC:`2518` (Core WebDAV) - *implemented, except for COPY/MOVE/LOCK operations*
28 - :RFC:`4791` (CalDAV) - *fully implemented*
29 - :RFC:`6352` (CardDAV) - *fully implemented*
30 - :RFC:`5397` (Current Principal) - *fully implemented*
31 - :RFC:`3253` (Versioning Extensions) - *partially implemented, only the REPORT method and {DAV:}expand-property property*
32 - :RFC:`3744` (Access Control) - *partially implemented*
33 - :RFC:`5995` (POST to create members) - *fully implemented*
34 - :RFC:`5689` (Extended MKCOL) - *fully implemented*
35
36 The following standards are not implemented:
37
38 - :RFC:`6638` (CalDAV Scheduling Extensions) - *not implemented*
39 - :RFC:`7809` (CalDAV Time Zone Extensions) - *not implemented*
40 - :RFC:`7529` (WebDAV Quota) - *not implemented*
41 - :RFC:`4709` (WebDAV Mount) - `intentionally <https://github.com/jelmer/xandikos/issues/48>`_ *not implemented*
42 - :RFC:`5546` (iCal iTIP) - *not implemented*
43 - :RFC:`4324` (iCAL CAP) - *not implemented*
44 - :RFC:`7953` (iCal AVAILABILITY) - *not implemented*
45
46 See `DAV compliance <notes/dav-compliance.rst>`_ for more detail on specification compliancy.
47
48 Limitations
49 -----------
50
51 - No multi-user support
52 - No support for CalDAV scheduling extensions
53
54 Supported clients
55 =================
56
57 Xandikos has been tested and works with the following CalDAV/CardDAV clients:
58
59 - `Vdirsyncer <https://github.com/pimutils/vdirsyncer>`_
60 - `caldavzap <https://www.inf-it.com/open-source/clients/caldavzap/>`_/`carddavmate <https://www.inf-it.com/open-source/clients/carddavmate/>`_
61 - `evolution <https://wiki.gnome.org/Apps/Evolution>`_
62 - `DAVdroid <https://davdroid.bitfire.at/>`_
63 - `sogo connector for Icedove/Thunderbird <http://v2.sogo.nu/english/downloads/frontends.html>`_
64 - `aCALdav syncer for Android <https://play.google.com/store/apps/details?id=de.we.acaldav&hl=en>`_
65 - `pycardsyncer <https://github.com/geier/pycarddav>`_
66 - `akonadi <https://community.kde.org/KDE_PIM/Akonadi>`_
67 - `CalDAV-Sync <https://dmfs.org/caldav/>`_
68 - `CardDAV-Sync <https://dmfs.org/carddav/>`_
69 - `Calendarsync <https://play.google.com/store/apps/details?id=com.icalparse>`_
70 - `Tasks <https://github.com/tasks/tasks/tree/caldav>`_
71 - `AgendaV <http://agendav.org/>`_
72 - `CardBook <https://gitlab.com/cardbook/cardbook/>`_
73
74 Dependencies
75 ============
76
77 At the moment, Xandikos supports Python 3.3 and higher as well as Pypy 3. It
78 also uses `Dulwich <https://github.com/jelmer/dulwich>`_,
79 `Jinja2 <http://jinja.pocoo.org/>`_,
80 `icalendar <https://github.com/collective/icalendar>`_, and
81 `defusedxml <https://github.com/tiran/defusedxml>`_.
82
83 E.g. to install those dependencies on Debian:
84
85 .. code:: shell
86
87 sudo apt install python3-dulwich python3-defusedxml python3-icalendar python3-jinja2
88
89 Or to install them using pip:
90
91 .. code:: shell
92
93 python setup.py develop
94
95 Docker
96 ------
97
98 A Dockerfile is also provided; see the comments on the top of the file for
99 configuration instructions.
100
101 Running
102 =======
103
104 Testing
105 -------
106
107 To run a standalone (low-performance, no authentication) instance of Xandikos,
108 with a pre-created calendar and addressbook (storing data in *$HOME/dav*):
109
110 .. code:: shell
111
112 ./bin/xandikos --defaults -d $HOME/dav
113
114 A server should now be listening on `localhost:8080 <http://localhost:8080/>`_.
115
116 Note that Xandikos does not create any collections unless --defaults is
117 specified. You can also either create collections from your CalDAV/CardDAV client,
118 or by creating git repositories under the *contacts* or *calendars* directories
119 it has created.
120
121 Production
122 ----------
123
124 The easiest way to run Xandikos in production is using
125 `uWSGI <https://uwsgi-docs.readthedocs.io/en/latest/>`_.
126
127 One option is to setup uWSGI with a server like
128 `Apache <http://uwsgi-docs.readthedocs.io/en/latest/Apache.html>`_,
129 `Nginx <http://uwsgi-docs.readthedocs.io/en/latest/Nginx.html>`_ or another web
130 server that can authenticate users and forward authorized requests to
131 Xandikos in uWSGI. See `examples/uwsgi.ini <examples/uwsgi.ini>`_ for an
132 example uWSGI configuration.
133
134 Alternatively, you can run uWSGI standalone and have it authenticate and
135 directly serve HTTP traffic. An example configuration for this can be found in
136 `examples/uwsgi-standalone.ini <examples/uwsgi-standalone.ini>`_.
137
138 This will start a server on `localhost:8080 <http://localhost:8080/>`_ with username *user1* and password
139 *password1*.
140
141 .. code:: shell
142
143 mkdir -p $HOME/dav
144 uwsgi examples/uwsgi-standalone.ini
145
146 Client instructions
147 ===================
148
149 Some clients can automatically discover the calendars and addressbook URLs from
150 a DAV server (if they support RFC:`5397`). For such clients you can simply
151 provide the base URL to Xandikos during setup.
152
153 Clients that lack such automated discovery (e.g. Thunderbird Lightning) require
154 the direct URL to a calendar or addressbook. In this case you
155 should provide the full URL to the calendar or addressbook; if you initialized
156 Xandikos using the ``--defaults`` argument mentioned in the previous section,
157 these URLs will look something like this::
158
159 http://dav.example.com/user/calendars/calendar
160
161 http://dav.example.com/user/contacts/addressbook
162
163
164 Contributing
165 ============
166
167 Contributions to Xandikos are very welcome. If you run into bugs or have
168 feature requests, please file issues `on GitHub
169 <https://github.com/jelmer/xandikos/issues/new>`_. If you're interested in
170 contributing code or documentation, please read `CONTRIBUTING
171 <CONTRIBUTING.rst>`_. Issues that are good for new contributors are tagged
172 `new-contributor <https://github.com/jelmer/xandikos/labels/new-contributor>`_
173 on GitHub.
174
175 Help
176 ====
177
178 There is a *#xandikos* IRC channel on the `Freenode <https://www.freenode.net/>`_
179 IRC network, and a `Xandikos <https://groups.google.com/forum/#!forum/xandikos>`_
180 mailing list.
181
9182 Platform: UNKNOWN
10183 Classifier: Development Status :: 4 - Beta
11184 Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
4545 notes/structure.rst
4646 notes/webdav.rst
4747 xandikos/__init__.py
48 xandikos/__main__.py
4849 xandikos/access.py
4950 xandikos/apache.py
5051 xandikos/caldav.py
6970 xandikos/store/__init__.py
7071 xandikos/store/config.py
7172 xandikos/store/git.py
73 xandikos/store/vdir.py
7274 xandikos/templates/collection.html
75 xandikos/templates/principal.html
7376 xandikos/templates/root.html
7477 xandikos/tests/__init__.py
7578 xandikos/tests/test_api.py
0 icalendar
1 dulwich>=0.19.1
02 defusedxml
1 dulwich>=0.19.1
2 icalendar
33 jinja2