├── xandikos
├── py.typed
├── templates
│ ├── collection.html
│ ├── principal.html
│ └── root.html
├── __init__.py
├── wsgi_helpers.py
├── apache.py
├── quota.py
├── timezones.py
├── collation.py
├── wsgi.py
├── server_info.py
├── infit.py
├── access.py
├── xmpp.py
├── davcommon.py
├── store
│ ├── index.py
│ ├── config.py
│ └── memory.py
├── __main__.py
└── sync.py
├── .github
├── CODEOWNERS
├── FUNDING.yml
├── workflows
│ ├── disperse.yml
│ ├── auto-merge.yml
│ ├── litmus.yml
│ ├── caldav-server-tester.yml
│ ├── pycaldav.yml
│ ├── wheels.yaml
│ ├── pythonpackage.yml
│ ├── container.yml
│ └── docker-test.yml
└── dependabot.yml
├── .dockerignore
├── .stestr.conf
├── compat
├── .gitignore
├── litmus-0.13.tar.gz.sha256sum
├── caldav-server-tester-requirements.txt
├── xandikos-litmus.sh
├── README.rst
├── xandikos-pycaldav.sh
├── common.sh
├── litmus.sh
├── xandikos-vdirsyncer.sh
└── xandikos-caldav-server-tester.sh
├── logo.png
├── notes
├── hacking.txt
├── README.rst
├── prometheus.rst
├── goals.rst
├── release-process.rst
├── monitoring.rst
├── subcommands.rst
├── api-stability.rst
├── auth.rst
├── debugging.rst
├── store.rst
├── scheduling-plan.rst
├── structure.rst
├── context.rst
├── uwsgi.rst
├── file-format.rst
├── heroku.rst
├── webdav.rst
├── multi-user.rst
├── collection-config.rst
└── indexes.rst
├── logo-alt.png
├── logo-alt2.png
├── setup.py
├── .coveragerc
├── requirements.txt
├── .mailmap
├── examples
├── xandikos.example
├── xandikos.socket
├── xandikos-servicemonitor.k8s.yaml
├── uwsgi-heroku.ini
├── xandikos.service
├── xandikos.avahi.service
├── uwsgi.ini
├── uwsgi-standalone.ini
├── docker-compose.yml
├── gunicorn.conf.py
├── xandikos-ingress.k8s.yaml
├── xandikos.nginx.conf
└── xandikos.k8s.yaml
├── .testr.conf
├── SUPPORT.md
├── SECURITY.md
├── tox.ini
├── .gitignore
├── .readthedocs.yaml
├── GOALS.rst
├── MANIFEST.in
├── AUTHORS
├── docs
├── source
│ ├── index.rst
│ ├── getting-started.rst
│ ├── conf.py
│ ├── reverse-proxy.rst
│ ├── installation.rst
│ ├── configuration.rst
│ └── troubleshooting.rst
└── Makefile
├── disperse.toml
├── CONTRIBUTING.md
├── tests
├── test_wsgi.py
├── test_api.py
├── __init__.py
├── test_carddav.py
├── test_insufficient_index_handling.py
├── test_apache.py
├── test_wsgi_helpers.py
├── test_store_regression.py
└── test_config.py
├── bin
└── xandikos
├── man
└── xandikos.8
├── Makefile
├── Containerfile
├── grafana-dashboard.json
├── pyproject.toml
├── entrypoint.sh
├── CODE_OF_CONDUCT.md
├── NEWS
└── README.rst
/xandikos/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @jelmer
2 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git/
2 | compat/
3 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: jelmer
2 |
--------------------------------------------------------------------------------
/.stestr.conf:
--------------------------------------------------------------------------------
1 | [DEFAULT]
2 | test_path=tests
3 |
--------------------------------------------------------------------------------
/compat/.gitignore:
--------------------------------------------------------------------------------
1 | litmus-*.tar.gz
2 | vdirsyncer/
3 | pycaldav/
4 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jelmer/xandikos/HEAD/logo.png
--------------------------------------------------------------------------------
/notes/hacking.txt:
--------------------------------------------------------------------------------
1 | DAV in class names is spelled in all capitals.
2 |
--------------------------------------------------------------------------------
/logo-alt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jelmer/xandikos/HEAD/logo-alt.png
--------------------------------------------------------------------------------
/logo-alt2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jelmer/xandikos/HEAD/logo-alt2.png
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | from setuptools import setup
3 |
4 | setup()
5 |
--------------------------------------------------------------------------------
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = True
3 |
4 | [report]
5 | exclude_lines =
6 | raise NotImplementedError
7 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | icalendar
2 | dulwich
3 | defusedxml
4 | jinja2
5 | aiohttp
6 | prometheus_client
7 | vobject
8 |
--------------------------------------------------------------------------------
/compat/litmus-0.13.tar.gz.sha256sum:
--------------------------------------------------------------------------------
1 | 09d615958121706444db67e09c40df5f753ccf1fa14846fdeb439298aa9ac3ff litmus-0.13.tar.gz
2 |
--------------------------------------------------------------------------------
/notes/README.rst:
--------------------------------------------------------------------------------
1 | This directory contains rough design documentation for Xandikos.
2 |
3 | For user-targeted documentation, see docs/.
4 |
--------------------------------------------------------------------------------
/.mailmap:
--------------------------------------------------------------------------------
1 | Jelmer Vernooij Jelmer Vernooij
2 | Jelmer Vernooij Jelmer Vernooij
3 |
--------------------------------------------------------------------------------
/examples/xandikos.example:
--------------------------------------------------------------------------------
1 | # This an example .xandikos file.
2 |
3 | # The color for this collection is red
4 | color = FF0000
5 |
6 | inbox-url = inbox/
7 |
--------------------------------------------------------------------------------
/examples/xandikos.socket:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Xandikos socket
3 |
4 | [Socket]
5 | ListenStream=/run/xandikos.sock
6 |
7 | [Install]
8 | WantedBy=sockets.target
9 |
--------------------------------------------------------------------------------
/notes/prometheus.rst:
--------------------------------------------------------------------------------
1 | Prometheus
2 | ==========
3 |
4 | Proposed metrics:
5 |
6 | * number of HTTP queries
7 | * number of DAV queries by category
8 | * DAV versions used
9 |
--------------------------------------------------------------------------------
/.testr.conf:
--------------------------------------------------------------------------------
1 | [DEFAULT]
2 | test_command=PYTHONPATH=. python3 -m subunit.run $IDOPTION $LISTOPT tests.test_suite
3 | test_id_option=--load-list $IDFILE
4 | test_list_option=--list
5 |
--------------------------------------------------------------------------------
/SUPPORT.md:
--------------------------------------------------------------------------------
1 | There is a *#xandikos* IRC channel on the [OFTC](https://www.oftc.net/).
2 | IRC network, and a
3 | [Xandikos](https://groups.google.com/forum/#!forum/xandikos>) mailing list.
4 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Reporting a Vulnerability
4 |
5 | Please report security issues by e-mail to jelmer@jelmer.uk, ideally PGP encrypted to the key at https://jelmer.uk/D729A457.asc
6 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | downloadcache = {toxworkdir}/cache/
3 | envlist = py310, py311, py312, py313, py314
4 |
5 | [testenv]
6 | commands = make check
7 | recreate = True
8 | whitelist_externals = make
9 |
10 |
11 |
--------------------------------------------------------------------------------
/notes/goals.rst:
--------------------------------------------------------------------------------
1 | Goals
2 | =====
3 |
4 | - standards compliant
5 | - standards complete
6 | - backed by Git
7 |
8 | - easily hackable/editable with standard tools (e.g. Git/Vim)
9 | - version tracked
10 |
11 | - unit tested
12 |
--------------------------------------------------------------------------------
/notes/release-process.rst:
--------------------------------------------------------------------------------
1 | Release Process
2 | ===============
3 |
4 | 1. Update version in setup.py
5 | 2. Update version in xandikos/__init__.py
6 | 3. git commit -a -m "Release $VERSION"
7 | 4. git tag -as -m "Release $VERSION" v$VERSION
8 | 5. ./setup.py sdist upload --sign
9 |
--------------------------------------------------------------------------------
/.github/workflows/disperse.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Disperse configuration
3 |
4 | "on":
5 | - push
6 |
7 | jobs:
8 | disperse:
9 |
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v6
14 | - uses: jelmer/action-disperse-validate@v2
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *~
3 | build/
4 | .testrepository/
5 | MANIFEST
6 | .tox/
7 | .*.sw?
8 | .coverage
9 | htmlcov/
10 | dist
11 | .pybuild
12 | *.egg*
13 | child.log
14 | debug.log
15 | .mypy_cache
16 | .stestr
17 | target
18 | .claude/settings.local.json
19 | CLAUDE.local.md
20 |
--------------------------------------------------------------------------------
/examples/xandikos-servicemonitor.k8s.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: monitoring.coreos.com/v1
3 | kind: ServiceMonitor
4 | metadata:
5 | name: xandikos
6 | labels:
7 | app: xandikos
8 | spec:
9 | selector:
10 | matchLabels:
11 | app: xandikos
12 | endpoints:
13 | - port: web
14 |
--------------------------------------------------------------------------------
/compat/caldav-server-tester-requirements.txt:
--------------------------------------------------------------------------------
1 | # Requirements for caldav-server-tester compatibility tests
2 | # Pin to specific versions to avoid breaking changes
3 | # caldav 2.1.2 and caldav-server-tester 0.1.0 were released on the same day (Nov 8, 2025)
4 | caldav==2.1.2
5 | caldav-server-tester==0.1.0
6 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | # .readthedocs.yaml
2 | # Read the Docs configuration file
3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
4 |
5 | # Required
6 | version: 2
7 |
8 | # Build documentation in the docs/ directory with Sphinx
9 | sphinx:
10 | configuration: docs/conf.py
11 |
--------------------------------------------------------------------------------
/GOALS.rst:
--------------------------------------------------------------------------------
1 | The goal of Xandikos is to be a simple CalDAV/CardDAV server for personal use:
2 |
3 | * easy to set up
4 | * use of plain .ics/.vcf files for storage
5 | * history stored in Git
6 | * clear separation between protocol implementation and storage
7 | * well tested
8 | * standards complete
9 | * standards compliant
10 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include *.rst
2 | include AUTHORS
3 | include COPYING
4 | include README.rst
5 | include Makefile
6 | include compat/*.sh
7 | include compat/*.rst
8 | include compat/*.xml
9 | include compat/*.sha256sum
10 | include notes/*.rst
11 | include tox.ini
12 | graft examples
13 | graft man
14 | recursive-include tests *.py
15 |
--------------------------------------------------------------------------------
/AUTHORS:
--------------------------------------------------------------------------------
1 | Jelmer Vernooij
2 | Geert Stappers
3 | Hugo Osvaldo Barrera
4 | Markus Unterwaditzer
5 | Daniel M. Capella
6 | Ole-Christian S. Hagenes
7 | Denis Laxalde
8 | Félix Sipma
9 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | Xandikos
2 | ========
3 |
4 | .. toctree::
5 | :maxdepth: 2
6 | :caption: Contents:
7 |
8 | installation
9 | getting-started
10 | configuration
11 | reverse-proxy
12 | clients
13 | troubleshooting
14 |
15 | Indices and tables
16 | ==================
17 |
18 | * :ref:`genindex`
19 | * :ref:`search`
20 |
--------------------------------------------------------------------------------
/notes/monitoring.rst:
--------------------------------------------------------------------------------
1 | Monitoring
2 | ==========
3 |
4 | Things to monitor:
5 |
6 | - number of uploaded items
7 | - number of accessed store items
8 | - number of lru cache hits
9 | - number of HTTP requests
10 | - number of reports
11 | - number of properties requested
12 | - number of unknown properties requested
13 | - number of unknown reports requested
14 |
--------------------------------------------------------------------------------
/compat/xandikos-litmus.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -x
2 | # Run litmus against xandikos
3 |
4 | . $(dirname $0)/common.sh
5 |
6 | TESTS="$1"
7 |
8 | set -e
9 |
10 | run_xandikos 5233 5234 --autocreate
11 |
12 | if which litmus >/dev/null; then
13 | LITMUS=litmus
14 | else
15 | LITMUS="$(dirname $0)/litmus.sh"
16 | fi
17 |
18 | TESTS="$TESTS" $LITMUS http://localhost:5233/
19 | exit 0
20 |
--------------------------------------------------------------------------------
/examples/uwsgi-heroku.ini:
--------------------------------------------------------------------------------
1 | [uwsgi]
2 | http-socket = :$(PORT)
3 | die-on-term = true
4 | umask = 022
5 | master = true
6 | cheaper = 0
7 | processes = 1
8 | plugin = router_basicauth,python3
9 | route = ^/ basicauth:myrealm,user1:password1
10 | module = xandikos.wsgi:app
11 | env = XANDIKOSPATH=$HOME/dav
12 | env = CURRENT_USER_PRINCIPAL=/dav/user1/
13 | env = AUTOCREATE=defaults
14 |
--------------------------------------------------------------------------------
/examples/xandikos.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Xandikos CalDAV/CardDAV server
3 | After=network.target
4 |
5 | [Service]
6 | ExecStart=/usr/local/bin/xandikos \
7 | -d /var/lib/xandikos \
8 | --route-prefix=/dav \
9 | --current-user-principal=/jelmer \
10 | -l /run/sock
11 | User=xandikos
12 | Group=www-data
13 | Restart=on-failure
14 | Type=simple
15 | NotifyAccess=all
16 |
--------------------------------------------------------------------------------
/disperse.toml:
--------------------------------------------------------------------------------
1 | name = "xandikos"
2 | tag-name = "v$VERSION"
3 | news-file = "NEWS"
4 | verify-command = "make check"
5 | tarball-location = []
6 | release-timeout = 5
7 |
8 | [[update_version]]
9 | path = "xandikos/__init__.py"
10 | match = "^__version__ = ((.*))$"
11 | new-line = "__version__ = $TUPLED_VERSION"
12 |
13 |
14 | [github]
15 | url = "https://github.com/jelmer/xandikos"
16 |
--------------------------------------------------------------------------------
/compat/README.rst:
--------------------------------------------------------------------------------
1 | This directory contains scripts to run external CalDAV/CardDAV/WebDAV
2 | testsuites against the Xandikos web server.
3 |
4 | Currently supported:
5 |
6 | - `Vdirsyncer `_
7 | - `litmus `_
8 | - `python-caldav `_
9 | - `caldav-server-tester `_ (via python-caldav)
10 |
--------------------------------------------------------------------------------
/notes/subcommands.rst:
--------------------------------------------------------------------------------
1 | Subcommands
2 | ===========
3 |
4 | At the moment, the Xandikos command just supports running a
5 | (debug) webserver. In various situations it would also be useful
6 | to have subcommands for administrative operations.
7 |
8 | Propose subcommands:
9 |
10 | * ``xandikos init [--defaults] [--autocreate] [-d DIRECTORY]`` -
11 | create a Xandikos database
12 | * ``xandikos stats`` - dump stats, similar to those exposed by prometheus
13 | * ``xandikos web`` - run a debug web server
14 |
15 |
--------------------------------------------------------------------------------
/notes/api-stability.rst:
--------------------------------------------------------------------------------
1 | API Stability
2 | =============
3 |
4 | There are currently no guarantees about Xandikos Python APIs staying the same
5 | across different versions, except the following APIs:
6 |
7 | xandikos.web.XandikosBackend(path)
8 | xandikos.web.XandikosBackend.create_principal(principal, create_defaults=False)
9 | xandikos.web.XandikosApp(backend, current_user_principal)
10 | xandikos.web.WellknownRedirector(app, path)
11 |
12 | If you care about stability of any other APIs, please file a bug against Xandikos.
13 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | SPHINXOPTS ?=
2 | SPHINXBUILD ?= sphinx-build
3 | SOURCEDIR = source
4 | BUILDDIR = build
5 |
6 | # Put it first so that "make" without argument is like "make help".
7 | help:
8 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
9 |
10 | .PHONY: help Makefile
11 |
12 | # Catch-all target: route all unknown targets to Sphinx using the new
13 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
14 | %: Makefile
15 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
16 |
--------------------------------------------------------------------------------
/notes/auth.rst:
--------------------------------------------------------------------------------
1 | Authentication
2 | ==============
3 |
4 | Ideally, Xandikos would stay out of the business of authenticating users.
5 | The trouble with this is that there are many flavours that need to
6 | be supported and configured.
7 |
8 | However, it is still necessary for Xandikos to handle authorization.
9 |
10 | An external system authenticates the user, and then sets the REMOTE_USER
11 | environment variable.
12 |
13 | Per
14 | http://wsgi.readthedocs.io/en/latest/specifications/simple_authentication.html,
15 | Xandikos should distinguish between 401 and 403.
16 |
--------------------------------------------------------------------------------
/examples/xandikos.avahi.service:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 | Xandikos CalDAV/CardDAV server on %h
9 |
10 | _caldavs._tcp
11 | 443
12 |
13 |
14 | _carddavs._tcp
15 | 443
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # Please see the documentation for all configuration options:
2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
3 | ---
4 | version: 2
5 | updates:
6 | - package-ecosystem: "github-actions"
7 | directory: "/"
8 | schedule:
9 | interval: monthly
10 | - package-ecosystem: "pip"
11 | directory: "/"
12 | schedule:
13 | interval: monthly
14 | commit-message:
15 | prefix: "deps"
16 | include: "scope"
17 | groups:
18 | pip:
19 | patterns:
20 | - "*"
21 |
--------------------------------------------------------------------------------
/notes/debugging.rst:
--------------------------------------------------------------------------------
1 | Debugging Xandikos
2 | ==================
3 |
4 | When filing bugs, please include details on the Xandikos version you're running
5 | and the clients that you're using.
6 |
7 | It would be helpful if you can reproduce any issues with a clean Xandikos
8 | setup. That also makes it easier to e.g. share log files.
9 |
10 | 1. Verify the server side contents; you can do this by
11 | looking at the Git repository on the Xandikos side.
12 | 2. Run with ``xandikos --dump-dav-xml``; please note that these
13 | may contain personal information, so be careful before e.g. posting
14 | them on GitHub.
15 |
--------------------------------------------------------------------------------
/examples/uwsgi.ini:
--------------------------------------------------------------------------------
1 | [uwsgi]
2 | socket = 127.0.0.1:8001
3 | uid = xandikos
4 | gid = xandikos
5 | master = true
6 | cheaper = 0
7 | processes = 1
8 | plugin = python3
9 | module = xandikos.wsgi:app
10 | umask = 022
11 | env = XANDIKOSPATH=/var/lib/xandikos/collections
12 | env = CURRENT_USER_PRINCIPAL=/user/
13 | # Set AUTOCREATE to have Xandikos create default CalDAV/CardDAV
14 | # collections if they don't yet exist. Possible values:
15 | # - principal: just create the current user principal
16 | # - defaults: create the principal and default calendar and contacts
17 | # collections. (recommended)
18 | env = AUTOCREATE=defaults
19 |
--------------------------------------------------------------------------------
/notes/store.rst:
--------------------------------------------------------------------------------
1 | Dulwich Store
2 | =============
3 |
4 | The main building blocks are vCard (.vcf) and iCalendar (.ics) files. Storage
5 | happens in Git repositories.
6 |
7 | Most items are identified by a UID and a filename, both of which are unique for
8 | the store. Items can have multiple versions, which are identified by an ETag.
9 | Each store maps to a single Git repository, and can not contain directories. In
10 | the future, a store could map to a subtree in a Git repository.
11 |
12 | Stores are responsible for making sure that:
13 |
14 | - their contents are validly formed calendars/contacts
15 | - UIDs are unique (where relevant)
16 |
--------------------------------------------------------------------------------
/examples/uwsgi-standalone.ini:
--------------------------------------------------------------------------------
1 | [uwsgi]
2 | http-socket = 127.0.0.1:8080
3 | umask = 022
4 | master = true
5 | cheaper = 0
6 | processes = 1
7 | plugin = router_basicauth,python3
8 | route = ^/ basicauth:myrealm,user1:password1
9 | module = xandikos.wsgi:app
10 | env = XANDIKOSPATH=$HOME/dav
11 | env = CURRENT_USER_PRINCIPAL=/dav/user1/
12 | # Set AUTOCREATE to have Xandikos create default CalDAV/CardDAV
13 | # collections if they don't yet exist. Possible values:
14 | # - principal: just create the current user principal
15 | # - defaults: create the principal and default calendar and contacts
16 | # collections. (recommended)
17 | env = AUTOCREATE=defaults
18 |
--------------------------------------------------------------------------------
/notes/scheduling-plan.rst:
--------------------------------------------------------------------------------
1 | CalDAV Scheduling
2 | =================
3 |
4 | TODO:
5 |
6 | - When a new calendar object is uploaded to a calendar collection:
7 | * Check if the ATTENDEE property is present, and if so, process it
8 |
9 | - Support CALDAV:schedule-tag
10 | * When comparing with if-schedule-tag-match, simply retrieve the blob by schedule-tag and compare delta between newly uploaded and current
11 | * When determining schedule-tag, scroll back until last revision that didn't have attendee changes?
12 | + Perhaps include a hint in e.g. commit message?
13 |
14 | - Inbox "contains copies of incoming scheduling messages"
15 | - Outbox "at which busy time information requests are targeted."
16 |
--------------------------------------------------------------------------------
/.github/workflows/auto-merge.yml:
--------------------------------------------------------------------------------
1 | name: Dependabot auto-merge
2 | on: pull_request_target
3 |
4 | permissions:
5 | pull-requests: write
6 | contents: write
7 |
8 | jobs:
9 | dependabot:
10 | runs-on: ubuntu-latest
11 | if: ${{ github.actor == 'dependabot[bot]' }}
12 | steps:
13 | - name: Dependabot metadata
14 | id: metadata
15 | uses: dependabot/fetch-metadata@v2
16 | with:
17 | github-token: "${{ secrets.GITHUB_TOKEN }}"
18 | - name: Enable auto-merge for Dependabot PRs
19 | run: gh pr merge --auto --squash "$PR_URL"
20 | env:
21 | PR_URL: ${{github.event.pull_request.html_url}}
22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
23 |
--------------------------------------------------------------------------------
/xandikos/templates/collection.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | WebDAV Collection - {{ collection.get_displayname() }}
4 |
5 |
6 | {{ collection.get_displayname() }}
7 |
8 | This is a collection.
9 |
10 | Subcollections
11 |
12 |
13 | {% for name, resource in collection.subcollections() %}
14 | - {{ name }}
15 | {% endfor %}
16 |
17 |
18 | For more information about Xandikos, see https://www.xandikos.org/
20 | or https://github.com/jelmer/xandikos.
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/notes/structure.rst:
--------------------------------------------------------------------------------
1 | Xandikos has a fairly clear distinction between different components.
2 |
3 | Modules
4 | =======
5 |
6 | The core WebDAV implementation lives in xandikos.webdav. This just implements
7 | the WebDAV protocol, and provides abstract classes for WebDAV resources that can be
8 | implemented by other code.
9 |
10 | Several WebDAV extensions (access, CardDAV, CalDAV) live in their own
11 | Python file. They build on top of the WebDAV module, and provide extra
12 | reporter and property implementations as defined in those specifications.
13 |
14 | Store is a simple object-store implementation on top of a Git repository, which
15 | has several properties that make it useful as a WebDAV backend.
16 |
17 | The business logic lives in xandikos.web; it ties together the other modules,
18 |
19 |
--------------------------------------------------------------------------------
/xandikos/templates/principal.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | WebDAV Principal - {{ principal.get_displayname() }}
4 |
5 |
6 | {{ principal.get_displayname() }}
7 |
8 | This is a user principal. CalDAV/CardDAV clients that support
9 | autodiscovery can use the URL for this page for discovery.
10 |
11 |
12 | Subcollections
13 |
14 |
15 | {% for name, resource in principal.subcollections() %}
16 | - {{ name }}
17 | {% endfor %}
18 |
19 |
20 | For more information about Xandikos, see https://www.xandikos.org/
22 | or https://github.com/jelmer/xandikos.
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/notes/context.rst:
--------------------------------------------------------------------------------
1 | Contexts
2 | ========
3 |
4 | Currently, property get_value/set_value receive three pieces of context:
5 |
6 | - HREF for the resource
7 | - resource object
8 | - Element object to update
9 |
10 | However, some properties need WebDAV server metadata:
11 |
12 | - supported-live-property-set needs list of properties
13 | - supported-report-set needs list of reports
14 | - supported-method-set needs list of methods
15 |
16 | Some operations need access to current user information:
17 |
18 | - current-user-principal
19 | - current-user-privilege-set
20 | - calendar-user-address-set
21 |
22 | PUT/DELETE/MKCOL need access to username (for author) and possibly things like user agent
23 | (for better commit message)
24 |
25 | .. code:: python
26 |
27 | class Context(object):
28 |
29 | def get_current_user(self):
30 | return (name, principal)
31 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | Xandikos uses the PEP8 style guide.
2 |
3 | You can install mandatory development dependencies with:
4 |
5 | ```
6 | pip install .[dev]
7 | ```
8 |
9 | You can verify whether you've introduced any style violations by running
10 |
11 | ```
12 | ruff check; ruff format --check .
13 | ```
14 |
15 | To check for type errors, rnu:
16 |
17 | ```
18 | mypy xandikos
19 | ```
20 |
21 | To run the tests, run:
22 |
23 | ```
24 | python3 -m unittest tests.test_suite
25 | ```
26 |
27 | Convenience targets in the Makefile are also provided ("make check", "make style", "make typing").
28 |
29 | To run the compatibility tests, run one of:
30 |
31 | ```
32 | make check-litmus
33 | make check-pycaldav
34 | make check-caldav-server-tester
35 | make check-vdirsyncer
36 | ```
37 |
38 | There are some very minimal developer documentation/vague design docs in notes/.
39 |
40 | Please implement new RFCs as much as possible in their own file.
41 |
--------------------------------------------------------------------------------
/.github/workflows/litmus.yml:
--------------------------------------------------------------------------------
1 | name: Litmus DAV compliance tests
2 |
3 | on:
4 | - push
5 | - pull_request
6 |
7 | jobs:
8 | litmus:
9 |
10 | runs-on: ${{ matrix.os }}
11 | strategy:
12 | matrix:
13 | os: [ubuntu-latest]
14 | python-version: ["3.10", "3.11", "3.12", '3.14', '3.13']
15 | fail-fast: false
16 |
17 | steps:
18 | - uses: actions/checkout@v6
19 | - name: Set up Python ${{ matrix.python-version }}
20 | uses: actions/setup-python@v6
21 | with:
22 | python-version: ${{ matrix.python-version }}
23 | - name: Install dependencies
24 | run: |
25 | python -m pip install --upgrade pip setuptools
26 | pip install -U pip pycalendar vobject requests attrs aiohttp aiohttp-wsgi prometheus-client multidict pytest
27 | python setup.py develop
28 | - name: Run litmus tests
29 | run: |
30 | make check-litmus
31 | if: "matrix.os == 'ubuntu-latest'"
32 |
--------------------------------------------------------------------------------
/.github/workflows/caldav-server-tester.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: caldav-server-tester compliance tests
3 |
4 | "on":
5 | - push
6 | - pull_request
7 |
8 | jobs:
9 | caldav-server-tester:
10 |
11 | runs-on: ${{ matrix.os }}
12 | strategy:
13 | matrix:
14 | os: [ubuntu-latest]
15 | python-version: ['3.13']
16 | fail-fast: false
17 |
18 | steps:
19 | - uses: actions/checkout@v6
20 | - name: Set up Python ${{ matrix.python-version }}
21 | uses: actions/setup-python@v6
22 | with:
23 | python-version: ${{ matrix.python-version }}
24 | - name: Install dependencies
25 | run: |
26 | python -m pip install --upgrade pip
27 | pip install -U pip pycalendar vobject requests pytz attrs aiohttp aiohttp-wsgi prometheus-client multidict "recurring-ical-events>=1.1.0b" typing-extensions defusedxml
28 | python -m pip install -e .
29 | - name: Run caldav-server-tester tests
30 | run: |
31 | make check-caldav-server-tester
32 |
--------------------------------------------------------------------------------
/examples/docker-compose.yml:
--------------------------------------------------------------------------------
1 | ---
2 | version: "3.4"
3 |
4 | services:
5 | xandikos:
6 | image: ghcr.io/jelmer/xandikos
7 | ports:
8 | - 8000:8000
9 | - 8001:8001 # Metrics port
10 | volumes:
11 | - /path/to/xandikos/data:/data
12 | environment:
13 | # Core settings (all optional - defaults shown)
14 | # - PORT=8000
15 | # - METRICS_PORT=8001
16 | # - LISTEN_ADDRESS=0.0.0.0
17 | # - DATA_DIR=/data
18 | # - CURRENT_USER_PRINCIPAL=/user/
19 | # - ROUTE_PREFIX=/
20 |
21 | # Auto-create directories and default calendar/addressbook
22 | - AUTOCREATE=true
23 | - DEFAULTS=true
24 |
25 | # Debug options
26 | # - DEBUG=false
27 | # - DUMP_DAV_XML=false
28 |
29 | # Compatibility mode for buggy clients
30 | # - NO_STRICT=false
31 | restart: unless-stopped
32 | healthcheck:
33 | test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
34 | interval: 30s
35 | timeout: 3s
36 | start_period: 5s
37 | retries: 3
38 |
--------------------------------------------------------------------------------
/examples/gunicorn.conf.py:
--------------------------------------------------------------------------------
1 | # Gunicorn config file
2 | #
3 | # Usage
4 | # ----------------------------------------------------------
5 | #
6 | # Install: 1) copy this config to src directory for xandikos
7 | # 2) run 'pip install gunicorn'
8 | # 3) mkdir logs && mkdir data
9 | #
10 | # Execute: 'gunicorn'
11 | #
12 | wsgi_app = "xandikos.wsgi:app"
13 |
14 | # Server Mechanics
15 | # ========================================
16 | # daemon mode
17 | daemon = False
18 |
19 | # environment variables
20 | raw_env = [
21 | "XANDIKOSPATH=./data",
22 | "CURRENT_USER_PRINCIPAL=/user/",
23 | "AUTOCREATE=defaults",
24 | ]
25 |
26 | # Server Socket
27 | # ========================================
28 | bind = "0.0.0.0:8000"
29 |
30 | # Worker Processes
31 | # ========================================
32 | workers = 2
33 |
34 | # Logging
35 | # ========================================
36 | # access log
37 | accesslog = "./logs/access.log"
38 | access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
39 |
40 | # gunicorn log
41 | errorlog = "-"
42 | loglevel = "info"
43 |
--------------------------------------------------------------------------------
/xandikos/__init__.py:
--------------------------------------------------------------------------------
1 | #
2 | # Xandikos
3 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
4 | #
5 | # This program is free software; you can redistribute it and/or
6 | # modify it under the terms of the GNU General Public License
7 | # as published by the Free Software Foundation; version 3
8 | # of the License or (at your option) any later version of
9 | # the License.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program; if not, write to the Free Software
18 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
19 | # MA 02110-1301, USA.
20 |
21 | """CalDAV/CardDAV server."""
22 |
23 | import defusedxml.ElementTree # noqa: F401 This does some monkey-patching on-load
24 |
25 | __version__ = (0, 3, 0)
26 | version_string = ".".join(map(str, __version__))
27 |
--------------------------------------------------------------------------------
/.github/workflows/pycaldav.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: pycaldav cross-tests
3 |
4 | "on":
5 | - push
6 | - pull_request
7 |
8 | jobs:
9 | pycaldav:
10 |
11 | runs-on: ${{ matrix.os }}
12 | strategy:
13 | matrix:
14 | os: [ubuntu-latest]
15 | python-version: ["3.10", "3.11", "3.12", "3.13", '3.14']
16 | fail-fast: false
17 |
18 | steps:
19 | - uses: actions/checkout@v6
20 | - name: Set up Python ${{ matrix.python-version }}
21 | uses: actions/setup-python@v6
22 | with:
23 | python-version: ${{ matrix.python-version }}
24 | - name: Install dependencies
25 | run: |
26 | python -m pip install --upgrade pip
27 | pip install -U pip pycalendar vobject requests pytz attrs aiohttp aiohttp-wsgi prometheus-client multidict pytest "recurring-ical-events>=1.1.0b" typing-extensions defusedxml
28 | python -m pip install -e .
29 | - name: Run pycaldav tests
30 | run: |
31 | sudo apt install libxml2-dev libxslt1-dev
32 | pip install -U nose lxml
33 | make check-pycaldav
34 |
--------------------------------------------------------------------------------
/notes/uwsgi.rst:
--------------------------------------------------------------------------------
1 | Running Xandikos from uWSGI
2 | ===========================
3 |
4 | In addition to running as a standalone service, Xandikos can also be run by any
5 | service that supports the wsgi interface. An example of such a service is uWSGI.
6 |
7 | One option is to setup uWSGI with a server like
8 | `Apache `_,
9 | `Nginx `_ or another web
10 | server that can authenticate users and forward authorized requests to
11 | Xandikos in uWSGI. See `examples/uwsgi.ini `_ for an
12 | example uWSGI configuration.
13 |
14 | Alternatively, you can run uWSGI standalone and have it authenticate and
15 | directly serve HTTP traffic. An example configuration for this can be found in
16 | `examples/uwsgi-standalone.ini `_.
17 |
18 | This will start a server on `localhost:8080 `_ with username *user1* and password
19 | *password1*.
20 |
21 | .. code:: shell
22 |
23 | mkdir -p $HOME/dav
24 | uwsgi examples/uwsgi-standalone.ini
25 |
26 |
27 |
--------------------------------------------------------------------------------
/notes/file-format.rst:
--------------------------------------------------------------------------------
1 | File structure
2 | ==============
3 |
4 | Collections are represented as Git repositories on disk.
5 |
6 | A specific version is represented as a commit id. The 'ctag' for a calendar is taken from the
7 | tree id of the calendar root tree.
8 |
9 | The `entity tag`_ for an event is taken from the blob id of the Blob representing that EVENT. These kinds
10 | of entity tags are strong, since blobs are equivalent by octet equality.
11 |
12 | .. _entity tag: https://tools.ietf.org/html/rfc2616#section-3.11
13 |
14 | The file name of calendar events shall be .ics / .vcf. Because of
15 | this, every file MUST only contain one UID and thus MUST contain exactly one
16 | VEVENT, VTODO, VJOURNAL or VFREEBUSY.
17 |
18 | All items in a collection *must* be well formed, so that they do not have to be validated when served.
19 |
20 | When new items are added, the collection should verify no existing items have the same UID.
21 |
22 | Open questions:
23 |
24 | - How to handle subtrees? Are they just subcollections?
25 | - Where should collection metadata (e.g. colors, description) be stored? .git/config?
26 |
--------------------------------------------------------------------------------
/.github/workflows/wheels.yaml:
--------------------------------------------------------------------------------
1 | name: Upload Python Package
2 |
3 | on:
4 | release:
5 | types: [created]
6 |
7 | jobs:
8 | release-build:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v6
13 | - name: Set up Python
14 | uses: actions/setup-python@v6
15 | with:
16 | python-version: '3.x'
17 | - name: Install dependencies
18 | run: |
19 | python -m pip install --upgrade pip
20 | pip install build
21 | - name: Build
22 | run: |
23 | python -m build
24 |
25 | - name: Upload artifact
26 | uses: actions/upload-artifact@v5
27 | with:
28 | name: release-dists
29 | path: dist/
30 |
31 | pypi-publish:
32 | runs-on: ubuntu-latest
33 | needs:
34 | - release-build
35 | permissions:
36 | id-token: write
37 |
38 | steps:
39 | - name: Retrieve release distributions
40 | uses: actions/download-artifact@v6
41 | with:
42 | name: release-dists
43 | path: dist/
44 |
45 | - name: Publish release distributions to PyPI
46 | uses: pypa/gh-action-pypi-publish@release/v1
47 |
--------------------------------------------------------------------------------
/tests/test_wsgi.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | import unittest
21 |
22 | from xandikos.wsgi_helpers import WellknownRedirector
23 |
24 |
25 | class WebTests(unittest.TestCase):
26 | def test_wellknownredirector(self):
27 | def app(environ, start_response):
28 | pass
29 |
30 | WellknownRedirector(app, "/path")
31 |
--------------------------------------------------------------------------------
/bin/xandikos:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # Xandikos
3 | # Copyright (C) 2016-2017 Jelmer Vernooij
4 | #
5 | # This program is free software; you can redistribute it and/or
6 | # modify it under the terms of the GNU General Public License
7 | # as published by the Free Software Foundation; version 2
8 | # of the License or (at your option) any later version of
9 | # the License.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program; if not, write to the Free Software
18 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
19 | # MA 02110-1301, USA.
20 |
21 | import asyncio
22 | import os
23 | import sys
24 |
25 | # running from source dir?
26 | if os.path.join(os.path.dirname(__file__), "..", "xandikos"):
27 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
28 |
29 |
30 | from xandikos.__main__ import main
31 |
32 | sys.exit(asyncio.run(main(sys.argv[1:])))
33 |
--------------------------------------------------------------------------------
/examples/xandikos-ingress.k8s.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: networking.k8s.io/v1
2 | kind: Ingress
3 | metadata:
4 | name: xandikos
5 | annotations:
6 | nginx.ingress.kubernetes.io/auth-type: basic
7 | nginx.ingress.kubernetes.io/auth-secret: my-htpasswd
8 | nginx.ingress.kubernetes.io/auth-realm: 'Authentication Required - mysite'
9 | spec:
10 | ingressClassName: nginx
11 | rules:
12 | - host: example.com
13 | http:
14 | paths:
15 | - backend:
16 | service:
17 | name: xandikos
18 | port:
19 | name: web
20 | path: /dav(/|$)(.*)
21 | pathType: Prefix
22 | ---
23 | apiVersion: networking.k8s.io/v1
24 | kind: Ingress
25 | metadata:
26 | name: xandikos-wellknown
27 | spec:
28 | ingressClassName: nginx
29 | rules:
30 | - host: example.com
31 | http:
32 | paths:
33 | - backend:
34 | service:
35 | name: xandikos
36 | port:
37 | name: web
38 | path: /.well-known/carddav
39 | pathType: Exact
40 | - backend:
41 | service:
42 | name: xandikos
43 | port:
44 | name: web
45 | path: /.well-known/caldav
46 | pathType: Exact
47 |
--------------------------------------------------------------------------------
/xandikos/templates/root.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Xandikos WebDAV server
4 |
5 |
6 | This is a Xandikos WebDAV server.
7 |
8 | Principals on this server:
9 |
10 | {% for path in principals %}
11 | - {{ path }}
12 | {% endfor %}
13 |
14 |
15 |
16 | DAV Client Configuration
17 | Configure your CalDAV/CardDAV client with these URLs:
18 |
19 | - CalDAV:
{{ caldav_url }}
20 | - CardDAV:
{{ carddav_url }}
21 |
22 |
23 | {% if qr_code_data %}
24 | For DAVx⁵ on Android, scan this QR code:
25 |
26 | DAVx⁵ URL: {{ davx5_url }}
27 | {% else %}
28 |
29 | {% endif %}
30 |
31 | For more information about Xandikos, see https://www.xandikos.org/
33 | or https://github.com/jelmer/xandikos.
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/notes/heroku.rst:
--------------------------------------------------------------------------------
1 | Running Xandikos on Heroku
2 | ==========================
3 |
4 | Heroku is an easy way to get a public instance of Xandikos running. A free
5 | heroku instance comes with 100Mb of local storage, which is enough for
6 | thousands of calendar items or contacts.
7 |
8 | Deployment
9 | ----------
10 |
11 | All of these steps assume you already have a Heroku account and have installed
12 | the heroku command-line client.
13 |
14 | To run a Heroku instance with Xandikos:
15 |
16 | 1. Create a copy of Xandikos::
17 |
18 | $ git clone git://jelmer.uk/xandikos xandikos
19 | $ cd xandikos
20 |
21 | 2. Make a copy of the example uwsgi configuration::
22 |
23 | $ cp examples/uwsgi-heroku.ini uwsgi.ini
24 |
25 | 3. Edit *uwsgi.ini* as necessary, such as changing the credentials (the
26 | defaults are *user1*/*password1*).
27 |
28 | 4. Make heroku install and use uwsgi::
29 |
30 | $ echo uwsgi > requirements.txt
31 | $ echo web: uwsgi uwsgi.ini > Procfile
32 |
33 | 5. Create the Heroku instance::
34 |
35 | $ heroku create
36 |
37 | (this might ask you for your heroku credentials)
38 |
39 | 6. Deploy the app::
40 |
41 | $ git push heroku master
42 |
43 | 7. Open the app with your browser::
44 |
45 | $ heroku open
46 |
47 | (The URL opened is also the URL that you can provide to any CalDAV/CardDAV
48 | application that supports service discovery)
49 |
--------------------------------------------------------------------------------
/tests/test_api.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | import shutil
21 | import tempfile
22 | import unittest
23 |
24 | from xandikos.web import XandikosApp, XandikosBackend
25 |
26 |
27 | class WebTests(unittest.TestCase):
28 | # When changing this API, please update notes/api-stability.rst and inform
29 | # vdirsyncer, who rely on this API.
30 |
31 | def test_backend(self):
32 | path = tempfile.mkdtemp()
33 | try:
34 | backend = XandikosBackend(path)
35 | backend.create_principal("foo", create_defaults=True)
36 | XandikosApp(backend, "foo")
37 | finally:
38 | shutil.rmtree(path)
39 |
--------------------------------------------------------------------------------
/examples/xandikos.nginx.conf:
--------------------------------------------------------------------------------
1 | upstream xandikos {
2 | server 127.0.0.1:8080;
3 | # server unix:/run/xandikos.socket; # nginx will need write permissions here
4 | }
5 |
6 | server {
7 | server_name dav.example.com;
8 |
9 | # Service discovery, see RFC 6764
10 | location = /.well-known/caldav {
11 | return 307 $scheme://$host/user/calendars;
12 | }
13 |
14 | location = /.well-known/carddav {
15 | return 307 $scheme://$host/user/contacts;
16 | }
17 |
18 | location / {
19 | proxy_set_header Host $http_host;
20 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
21 | proxy_redirect off;
22 | proxy_buffering off;
23 | proxy_pass http://xandikos;
24 | auth_basic "Login required";
25 | auth_basic_user_file /etc/xandikos/htpasswd;
26 | }
27 |
28 | listen 443 ssl http2;
29 | listen [::]:443 ssl ipv6only=on http2;
30 |
31 | # use e.g. Certbot to have these modified:
32 | ssl_certificate /etc/letsencrypt/live/dav.example.com/fullchain.pem;
33 | ssl_certificate_key /etc/letsencrypt/live/dav.example.com/privkey.pem;
34 | include /etc/letsencrypt/options-ssl-nginx.conf;
35 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
36 | }
37 |
38 | server {
39 | if ($host = dav.example.com) {
40 | return 301 https://$host$request_uri;
41 | }
42 |
43 | listen 80 http2;
44 | listen [::]:80 http2;
45 | server_name dav.example.com;
46 | return 404;
47 | }
48 |
--------------------------------------------------------------------------------
/man/xandikos.8:
--------------------------------------------------------------------------------
1 | .TH XANDIKOS "8" "March 2021" "xandikos 0.2.5" "System Administration Utilities"
2 | .SH NAME
3 | xandikos \- git-backed CalDAV/CardDAV server
4 | .SH DESCRIPTION
5 | usage: ./bin/xandikos \fB\-d\fR ROOT\-DIR [OPTIONS]
6 | .SS "optional arguments:"
7 | .TP
8 | \fB\-h\fR, \fB\-\-help\fR
9 | show this help message and exit
10 | .TP
11 | \fB\-\-version\fR
12 | show program's version number and exit
13 | .TP
14 | \fB\-d\fR DIRECTORY, \fB\-\-directory\fR DIRECTORY
15 | Directory to serve from.
16 | .TP
17 | \fB\-\-current\-user\-principal\fR CURRENT_USER_PRINCIPAL
18 | Path to current user principal. [/user/]
19 | .TP
20 | \fB\-\-autocreate\fR
21 | Automatically create necessary directories.
22 | .TP
23 | \fB\-\-defaults\fR
24 | Create initial calendar and address book. Implies
25 | \fB\-\-autocreate\fR.
26 | .TP
27 | \fB\-\-dump\-dav\-xml\fR
28 | Print DAV XML request/responses.
29 | .TP
30 | \fB\-\-avahi\fR
31 | Announce services with avahi.
32 | .TP
33 | \fB\-\-no\-strict\fR
34 | Enable workarounds for buggy CalDAV/CardDAV client
35 | implementations.
36 | .SS "Access Options:"
37 | .TP
38 | \fB\-l\fR LISTEN_ADDRESS, \fB\-\-listen\-address\fR LISTEN_ADDRESS
39 | Bind to this address. Pass in path for unix domain
40 | socket. [localhost]
41 | .TP
42 | \fB\-p\fR PORT, \fB\-\-port\fR PORT
43 | Port to listen on. [8080]
44 | .TP
45 | \fB\-\-route\-prefix\fR ROUTE_PREFIX
46 | Path to Xandikos. (useful when Xandikos is behind a
47 | reverse proxy) [/]
48 | .SH AUTHORS
49 | Jelmer Vernooij
50 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | import unittest
21 |
22 |
23 | def test_suite():
24 | names = [
25 | "apache",
26 | "api",
27 | "caldav",
28 | "carddav",
29 | "config",
30 | "davcommon",
31 | "icalendar",
32 | "insufficient_index_handling",
33 | "main",
34 | "rrule_index_usage",
35 | "store",
36 | "store_regression",
37 | "sync",
38 | "vcard",
39 | "webdav",
40 | "web",
41 | "wsgi",
42 | "wsgi_helpers",
43 | ]
44 | module_names = ["tests.test_" + name for name in names]
45 | loader = unittest.TestLoader()
46 | return loader.loadTestsFromNames(module_names)
47 |
--------------------------------------------------------------------------------
/compat/xandikos-pycaldav.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Run python-caldav tests against Xandikos.
3 | set -e
4 |
5 | . $(dirname $0)/common.sh
6 |
7 | BRANCH=master
8 | PYCALDAV_REF=v1.2.1
9 | VENV_DIR=$(dirname $0)/pycaldav-venv
10 | [ -z "$PYTHON" ] && PYTHON=python3
11 |
12 | if [ ! -d $(dirname $0)/pycaldav ]; then
13 | git clone --branch $PYCALDAV_REF https://github.com/python-caldav/caldav $(dirname $0)/pycaldav
14 | else
15 | pushd $(dirname $0)/pycaldav
16 | git fetch origin
17 | git reset --hard $PYCALDAV_REF
18 | popd
19 | fi
20 |
21 | # Set up virtual environment
22 | if [ ! -d "${VENV_DIR}" ]; then
23 | echo "Creating virtual environment for pycaldav"
24 | ${PYTHON} -m venv "${VENV_DIR}"
25 | fi
26 |
27 | # Activate virtual environment
28 | source "${VENV_DIR}/bin/activate"
29 |
30 | # Install pycaldav and test dependencies in the virtual environment
31 | pushd $(dirname $0)/pycaldav
32 | pip install -e . pytest
33 | popd
34 |
35 | # Deactivate venv before running xandikos so it uses system Python
36 | deactivate
37 |
38 | cat <$(dirname $0)/pycaldav/tests/conf_private.py
39 | # Only run tests against my private caldav servers.
40 | only_private = True
41 |
42 | caldav_servers = [
43 | {'url': 'http://localhost:5233/',
44 | 'incompatibilities': ['no_scheduling', 'text_search_not_working'],
45 | }
46 | ]
47 | EOF
48 |
49 | run_xandikos 5233 5234 --defaults
50 |
51 | # Reactivate the virtual environment to run pycaldav tests
52 | source "${VENV_DIR}/bin/activate"
53 |
54 | pushd $(dirname $0)/pycaldav
55 | pytest tests "$@"
56 | popd
57 |
--------------------------------------------------------------------------------
/notes/webdav.rst:
--------------------------------------------------------------------------------
1 | WebDAV implementation
2 | =====================
3 |
4 | .. code:: python
5 |
6 | class DAVPropertyProvider(object):
7 |
8 | NAME property
9 |
10 | matchresource()
11 |
12 | # One or multiple properties?
13 |
14 | def proplist(self, resource, all=False):
15 |
16 | def getprop(self, resource, property):
17 |
18 | def propupdate(self, resource, updates):
19 |
20 |
21 | class DAVBackend(object):
22 |
23 | def get_resource(self, path):
24 |
25 | def create_collection(self, path):
26 |
27 |
28 | class DAVReporter(object):
29 |
30 | class DAVResource(object):
31 |
32 | def get_resource_types(self):
33 |
34 | def get_body(self):
35 | """Returns the body of the resource.
36 |
37 | Returns: bytes representing contents
38 | """
39 |
40 | def set_body(self, body):
41 | """Set the body of the resource.
42 |
43 | Args:
44 | body: body (as bytes)
45 | """
46 |
47 | def proplist(self):
48 | """Return list of properties.
49 |
50 | Returns: List of property names
51 | """
52 |
53 | def propupdate(self, updates):
54 | """Update properties.
55 |
56 | Args:
57 | updates: Dictionary mapping names to new values
58 | """
59 |
60 | def lock(self):
61 |
62 | def unlock(self):
63 |
64 | def members(self):
65 | """List members.
66 |
67 | Returns: List tuples of (name, DAVResource)
68 | """
69 |
70 | # TODO(jelmer): COPY
71 | # TODO(jelmer): MOVE
72 | # TODO(jelmer): MKCOL
73 | # TODO(jelmer): LOCK/UNLOCK
74 | # TODO(jelmer): REPORT
75 |
--------------------------------------------------------------------------------
/xandikos/wsgi_helpers.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2020 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """WSGI wrapper for xandikos."""
21 |
22 | import posixpath
23 |
24 | from .web import WELLKNOWN_DAV_PATHS
25 |
26 |
27 | class WellknownRedirector:
28 | """Redirect paths under .well-known/ to the appropriate paths."""
29 |
30 | def __init__(self, inner_app, dav_root) -> None:
31 | self._inner_app = inner_app
32 | self._dav_root = dav_root
33 |
34 | def __call__(self, environ, start_response):
35 | # See https://tools.ietf.org/html/rfc6764
36 | path = posixpath.normpath(environ["SCRIPT_NAME"] + environ["PATH_INFO"])
37 | if path in WELLKNOWN_DAV_PATHS:
38 | start_response("302 Found", [("Location", self._dav_root)])
39 | return []
40 | return self._inner_app(environ, start_response)
41 |
--------------------------------------------------------------------------------
/.github/workflows/pythonpackage.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Python package
3 |
4 | "on":
5 | push:
6 | branches: [master]
7 | pull_request:
8 |
9 | jobs:
10 | pythontests:
11 |
12 | runs-on: ${{ matrix.os }}
13 | strategy:
14 | matrix:
15 | os: [ubuntu-latest, macos-latest, windows-latest]
16 | python-version: ["3.10", "3.11", "3.12", '3.13', '3.14']
17 | fail-fast: false
18 |
19 | steps:
20 | - uses: actions/checkout@v6
21 | - name: Set up Python ${{ matrix.python-version }}
22 | uses: actions/setup-python@v6
23 | with:
24 | python-version: ${{ matrix.python-version }}
25 | - name: Install dependencies
26 | run: |
27 | python -m pip install --upgrade pip
28 | pip install ".[dev,prometheus]"
29 | - name: Install dependencies (linux)
30 | run: |
31 | sudo apt -y update
32 | sudo apt -y install libsystemd-dev
33 | python -m pip install -e ".[systemd,prometheus]"
34 | if: "matrix.os == 'ubuntu-latest'"
35 | - name: Install dependencies (non-linux)
36 | run: |
37 | python -m pip install -e ".[prometheus]"
38 | if: "matrix.os != 'ubuntu-latest'"
39 | - name: Lint checks
40 | run: |
41 | python -m ruff check .
42 | - name: Formatting checks
43 | run: |
44 | python -m ruff format --check .
45 | - name: Typing checks
46 | run: |
47 | pip install -U ".[typing]"
48 | python -m mypy xandikos
49 | - name: Test suite run
50 | run: |
51 | python -m unittest tests.test_suite
52 | env:
53 | PYTHONHASHSEED: random
54 |
--------------------------------------------------------------------------------
/xandikos/apache.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Apache.org mod_dav custom properties.
21 |
22 | See http://www.webdav.org/mod_dav/
23 | """
24 |
25 | from xandikos import webdav
26 |
27 |
28 | class ExecutableProperty(webdav.Property):
29 | """executable property.
30 |
31 | Equivalent of the 'x' bit on POSIX.
32 | """
33 |
34 | name = "{http://apache.org/dav/props/}executable"
35 | resource_type = None
36 | live = False
37 |
38 | async def get_value(self, href, resource, el, environ):
39 | el.text = "T" if resource.get_is_executable() else "F"
40 |
41 | async def set_value(self, href, resource, el):
42 | if el.text == "T":
43 | resource.set_is_executable(True)
44 | elif el.text == "F":
45 | resource.set_is_executable(False)
46 | else:
47 | raise ValueError(f"invalid executable setting {el.text!r}")
48 |
--------------------------------------------------------------------------------
/xandikos/quota.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Quota and Size properties.
21 |
22 | See https://tools.ietf.org/html/rfc4331
23 | """
24 |
25 | from xandikos import webdav
26 |
27 | FEATURE: str = "quota"
28 |
29 |
30 | class QuotaAvailableBytesProperty(webdav.Property):
31 | """quota-available-bytes."""
32 |
33 | name = "{DAV:}quota-available-bytes"
34 | resource_type = None
35 | in_allprops = False
36 | live = True
37 |
38 | async def get_value(self, href, resource, el, environ):
39 | el.text = resource.get_quota_available_bytes()
40 |
41 |
42 | class QuotaUsedBytesProperty(webdav.Property):
43 | """quota-used-bytes."""
44 |
45 | name = "{DAV:}quota-used-bytes"
46 | resource_type = None
47 | in_allprops = False
48 | live = True
49 |
50 | async def get_value(self, href, resource, el, environ):
51 | el.text = resource.get_quota_used_bytes()
52 |
--------------------------------------------------------------------------------
/compat/common.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Common functions for running xandikos in compat tests
3 |
4 | XANDIKOS_PID=
5 | DAEMON_LOG=$(mktemp)
6 | SERVEDIR=$(mktemp -d)
7 | if [ -z "${XANDIKOS}" ]; then
8 | XANDIKOS=$(dirname $0)/../bin/xandikos
9 | fi
10 |
11 | set -e
12 |
13 | xandikos_cleanup() {
14 | [ -z ${XANDIKOS_PID} ] || kill -INT ${XANDIKOS_PID} 2>/dev/null || true
15 | rm -rf ${SERVEDIR}
16 | mkdir -p ${SERVEDIR}
17 | cat ${DAEMON_LOG}
18 | wait ${XANDIKOS_PID} 2>/dev/null || true
19 | }
20 |
21 | run_xandikos()
22 | {
23 | PORT="$1"
24 | shift 1
25 |
26 | # Check if second argument is a port number
27 | if [[ $1 =~ ^[0-9]+$ ]]; then
28 | METRICS_PORT="$1"
29 | shift 1
30 | METRICS_ARGS="--metrics-port=${METRICS_PORT}"
31 | HEALTH_URL="http://localhost:${METRICS_PORT}/health"
32 | else
33 | METRICS_ARGS=""
34 | HEALTH_URL="http://localhost:${PORT}/"
35 | fi
36 |
37 | echo "Writing daemon log to $DAEMON_LOG"
38 | echo "Running: ${XANDIKOS} serve --no-detect-systemd --port=${PORT} ${METRICS_ARGS} -l localhost -d ${SERVEDIR} $@"
39 |
40 | ${XANDIKOS} serve --no-detect-systemd --port=${PORT} ${METRICS_ARGS} -l localhost -d ${SERVEDIR} "$@" >$DAEMON_LOG 2>&1 &
41 | XANDIKOS_PID=$!
42 | trap xandikos_cleanup 0 EXIT
43 | i=0
44 | while [ $i -lt 50 ]
45 | do
46 | if [ -n "${METRICS_PORT}" ]; then
47 | # Check metrics health endpoint
48 | if [ "$(curl -s http://localhost:${METRICS_PORT}/health)" = "ok" ]; then
49 | break
50 | fi
51 | else
52 | # Check if main port is responding
53 | if curl -s -f http://localhost:${PORT}/ >/dev/null 2>&1; then
54 | break
55 | fi
56 | fi
57 | sleep 1
58 | let i+=1
59 | done
60 |
61 | if [ $i -eq 50 ]; then
62 | echo "WARNING: xandikos may not have started properly. Check the daemon log."
63 | cat $DAEMON_LOG
64 | fi
65 | }
66 |
--------------------------------------------------------------------------------
/examples/xandikos.k8s.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: apps/v1
3 | kind: Deployment
4 | metadata:
5 | name: xandikos
6 | spec:
7 | strategy:
8 | rollingUpdate:
9 | maxSurge: 1
10 | maxUnavailable: 1
11 | type: RollingUpdate
12 | replicas: 1
13 | selector:
14 | matchLabels:
15 | app: xandikos
16 | template:
17 | metadata:
18 | labels:
19 | app: xandikos
20 | spec:
21 | containers:
22 | - name: xandikos
23 | image: ghcr.io/jelmer/xandikos
24 | imagePullPolicy: Always
25 | command:
26 | - "python3"
27 | - "-m"
28 | - "xandikos.web"
29 | - "--port=8080"
30 | - "-d/data"
31 | - "--defaults"
32 | - "--listen-address=0.0.0.0"
33 | - "--current-user-principal=/jelmer"
34 | - "--route-prefix=/dav"
35 | resources:
36 | limits:
37 | cpu: "2"
38 | memory: "2Gi"
39 | requests:
40 | cpu: "0.1"
41 | memory: "10M"
42 | livenessProbe:
43 | httpGet:
44 | path: /health
45 | port: 8081
46 | initialDelaySeconds: 30
47 | periodSeconds: 3
48 | timeoutSeconds: 90
49 | ports:
50 | - containerPort: 8081
51 | volumeMounts:
52 | - name: xandikos-volume
53 | mountPath: /data
54 | securityContext:
55 | fsGroup: 1000
56 | volumes:
57 | - name: xandikos-volume
58 | persistentVolumeClaim:
59 | claimName: xandikos
60 | ---
61 | apiVersion: v1
62 | kind: Service
63 | metadata:
64 | name: xandikos
65 | labels:
66 | app: xandikos
67 | spec:
68 | ports:
69 | - port: 8080
70 | name: web
71 | selector:
72 | app: xandikos
73 | type: ClusterIP
74 |
--------------------------------------------------------------------------------
/xandikos/timezones.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Timezone handling.
21 |
22 | See http://www.webdav.org/specs/rfc7809.html
23 | """
24 |
25 | from xandikos import webdav
26 |
27 |
28 | class TimezoneServiceSetProperty(webdav.Property):
29 | """timezone-service-set property.
30 |
31 | See http://www.webdav.org/specs/rfc7809.html, section 5.1
32 | """
33 |
34 | name = "{DAV:}timezone-service-set"
35 | # Should be set on CalDAV calendar home collection resources,
36 | # but Xandikos doesn't have a separate resource type for those.
37 | resource_type = webdav.COLLECTION_RESOURCE_TYPE
38 | in_allprops = False
39 | live = True
40 |
41 | def __init__(self, timezone_services) -> None:
42 | super().__init__()
43 | self._timezone_services = timezone_services
44 |
45 | async def get_value(self, base_href, resource, el, environ):
46 | for timezone_service_href in self._timezone_services:
47 | el.append(webdav.create_href(timezone_service_href, base_href))
48 |
--------------------------------------------------------------------------------
/compat/litmus.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -e
2 | URL="$1"
3 | if [ -z "$URL" ]; then
4 | echo "Usage: $0 URL"
5 | exit 1
6 | fi
7 | if [ -n "$TESTS" ]; then
8 | TEST_ARG=TESTS="$TESTS"
9 | fi
10 | # Use realpath if available, otherwise fall back to a compatible approach
11 | if which realpath >/dev/null 2>&1; then
12 | SRCPATH="$(dirname $(realpath $0))"
13 | else
14 | # macOS compatible way
15 | SRCPATH="$(cd $(dirname $0) && pwd)"
16 | fi
17 | VERSION=${LITMUS_VERSION:-0.13}
18 | LITMUS_URL="${LITMUS_URL:-http://www.webdav.org/neon/litmus/litmus-${VERSION}.tar.gz}"
19 |
20 | scratch=$(mktemp -d)
21 | function finish() {
22 | rm -rf "${scratch}"
23 | }
24 | trap finish EXIT
25 | pushd "${scratch}"
26 |
27 | if [ -f "${SRCPATH}/litmus-${VERSION}.tar.gz" ]; then
28 | cp "${SRCPATH}/litmus-${VERSION}.tar.gz" .
29 | else
30 | curl -L -o "litmus-${VERSION}.tar.gz" "${LITMUS_URL}"
31 | fi
32 | # Use shasum on macOS, sha256sum on Linux
33 | if which sha256sum >/dev/null 2>&1; then
34 | sha256sum -c ${SRCPATH}/litmus-${VERSION}.tar.gz.sha256sum
35 | else
36 | shasum -a 256 -c ${SRCPATH}/litmus-${VERSION}.tar.gz.sha256sum
37 | fi
38 | tar xvfz litmus-${VERSION}.tar.gz
39 | pushd litmus-${VERSION}
40 | # Configure with macOS-specific flags if needed
41 | if [ "$(uname)" = "Darwin" ]; then
42 | # Fix socket() test for macOS - configure uses socket() without parameters
43 | # Replace both instances of socket(); and the socket test code
44 | sed -i '' 's/socket();/int s = socket(AF_INET, SOCK_STREAM, 0);/g' configure
45 | sed -i '' 's/ne__code="socket();"/ne__code="int s = socket(AF_INET, SOCK_STREAM, 0);"/g' configure
46 | sed -i '' 's/__stdcall socket();/__stdcall socket(AF_INET, SOCK_STREAM, 0);/g' configure
47 | ./configure LDFLAGS="-framework CoreFoundation -framework Security" CFLAGS="-I/usr/include -I/usr/include/sys"
48 | else
49 | ./configure
50 | fi
51 | make
52 | make URL="$URL" $TEST_ARG check
53 |
--------------------------------------------------------------------------------
/notes/multi-user.rst:
--------------------------------------------------------------------------------
1 | Multi-User Support
2 | ==================
3 |
4 | Multi-user support could arguably also include sharing of
5 | calendars/collections/etc. This is beyond the scope of this document, which
6 | just focuses on allowing multiple users to use their own silo in a single
7 | instance of Xandikos.
8 |
9 | Siloed user support can be split up into three steps:
10 |
11 | * storage - mapping a user to a principal
12 | * authentication - letting a user log in
13 | * authorization - checking whether the user has access to a resource
14 |
15 | Authentication
16 | --------------
17 |
18 | In the simplest form, a forwarding proxy provides the name of an authenticated
19 | user. E.g. Apache or uWSGI sets the REMOTE_USER environment variable. If
20 | REMOTE_USER is not present for an operation that requires authentication, a 401
21 | error is returned.
22 |
23 | Authorization
24 | -------------
25 |
26 | In the simplest form, users only have access to the resources under their own
27 | principal.
28 |
29 | As a second step, we could let users configure ACLs; one way of doing this would be
30 | to allow adding authentication in the collection configuration. I.e. something like::
31 |
32 | [acl]
33 | read = jelmer, joe
34 | write = jelmer
35 |
36 | Storage
37 | -------
38 |
39 | By default, the principal for a user is simply "/%(username)s".
40 |
41 | Roadmap
42 | =======
43 |
44 | * Optional: Allow marking collections as principals [DONE]
45 | * Expose username (or None, if not logged in) everywhere [DONE]
46 | * Add function get_username_principal() for mapping username to principal path [DONE]
47 | * Support automatic creation of principal on first login of user
48 | * Add simple function check_path_access() for checking access ("is this user allowed to access this path?")
49 | * Use access checking function everywhere
50 | * Have current-user-principal setting depend on $REMOTE_USER and get_username_principal() [DONE]
51 |
--------------------------------------------------------------------------------
/docs/source/getting-started.rst:
--------------------------------------------------------------------------------
1 | .. _getting-started:
2 |
3 | Getting Started
4 | ===============
5 |
6 | Xandikos can either be run in a container (e.g. in docker or Kubernetes) or
7 | outside of a container.
8 |
9 | It is recommended that you run it behind a reverse proxy, since Xandikos by
10 | itself does not provide authentication support. See :ref:`reverse-proxy` for
11 | details.
12 |
13 | Running from systemd
14 | --------------------
15 |
16 | Xandikos supports socket activation through systemd. To use systemd, run something like:
17 |
18 | .. code-block:: shell
19 |
20 | cp examples/xandikos.{socket,service} /etc/systemd/system
21 | systemctl daemon-reload
22 | systemctl enable xandikos.socket
23 |
24 | Running from docker
25 | -------------------
26 |
27 | There is a docker image that gets regularly updated at
28 | ``ghcr.io/jelmer/xandikos``.
29 |
30 | If you use docker-compose, see the example configuration in
31 | ``examples/docker-compose.yml``.
32 |
33 | To run in docker interactively, try something like:
34 |
35 | .. code-block:: shell
36 |
37 | mkdir /tmp/xandikos
38 | docker run -it -v /tmp/xandikos:/data -p8000:8000 ghcr.io/jelmer/xandikos
39 |
40 | The following environment variables are supported by the docker image:
41 |
42 | * ``CURRENT_USER_PRINCIPAL``: path to current user principal; defaults to "/$USER"
43 | * ``AUTOCREATE``: whether to automatically create missing directories ("defaults", "empty")
44 | * ``ROUTE_PREFIX``: HTTP prefix under which Xandikos should run
45 |
46 | Running from kubernetes
47 | -----------------------
48 |
49 | Here is an example configuration for running Xandikos in kubernetes:
50 |
51 | .. literalinclude:: ../../examples/xandikos.k8s.yaml
52 | :language: yaml
53 |
54 | If you're using the prometheus operator, you may want also want to use this service monitor:
55 |
56 | .. literalinclude:: ../../examples/xandikos-servicemonitor.k8s.yaml
57 | :language: yaml
58 |
--------------------------------------------------------------------------------
/.github/workflows/container.yml:
--------------------------------------------------------------------------------
1 | name: Create and publish a Docker image
2 |
3 | on:
4 | push:
5 | branches:
6 | - '**'
7 | release:
8 | types: [created]
9 |
10 | env:
11 | REGISTRY: ghcr.io
12 | IMAGE_NAME: ${{ github.repository }}
13 |
14 | jobs:
15 | build-and-push-image:
16 | runs-on: ubuntu-latest
17 | permissions:
18 | contents: read
19 | packages: write
20 |
21 | steps:
22 | - name: Checkout repository
23 | uses: actions/checkout@v6
24 |
25 | - name: Set up QEMU
26 | uses: docker/setup-qemu-action@v3
27 | - name: Set up Docker Buildx
28 | uses: docker/setup-buildx-action@v3
29 |
30 | - name: Log in to the Container registry
31 | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef
32 | with:
33 | registry: ${{ env.REGISTRY }}
34 | username: ${{ github.actor }}
35 | password: ${{ secrets.GITHUB_TOKEN }}
36 |
37 | - name: Extract metadata (tags, labels) for Docker
38 | id: meta
39 | uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
40 | with:
41 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
42 | tags: |
43 | type=sha,prefix={{branch}}-
44 | type=ref,event=branch
45 | type=ref,event=tag
46 | type=semver,pattern={{version}}
47 | type=semver,pattern={{major}}.{{minor}}
48 | type=semver,pattern={{major}}
49 |
50 | - name: Build and push Docker image
51 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83
52 | with:
53 | context: .
54 | file: ./Containerfile
55 | platforms: linux/amd64,linux/arm64,linux/arm/v7
56 | push: ${{ github.repository == 'jelmer/xandikos' }}
57 | tags: ${{ steps.meta.outputs.tags }}
58 | labels: ${{ steps.meta.outputs.labels }}
59 |
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
6 |
7 | # -- Path setup --------------------------------------------------------------
8 |
9 | # If extensions (or modules to document with autodoc) are in another directory,
10 | # add these directories to sys.path here. If the directory is relative to the
11 | # documentation root, use os.path.abspath to make it absolute, like shown here.
12 | #
13 | # import os
14 | # import sys
15 | # sys.path.insert(0, os.path.abspath('.'))
16 |
17 |
18 | # -- Project information -----------------------------------------------------
19 |
20 | project = "Xandikos"
21 | copyright = "2022 Jelmer Vernooij et al"
22 | author = "Jelmer Vernooij"
23 |
24 |
25 | # -- General configuration ---------------------------------------------------
26 |
27 | # Add any Sphinx extension module names here, as strings. They can be
28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
29 | # ones.
30 | extensions: list[str] = []
31 |
32 | # Add any paths that contain templates here, relative to this directory.
33 | templates_path: list[str] = ["_templates"]
34 |
35 | # List of patterns, relative to source directory, that match files and
36 | # directories to ignore when looking for source files.
37 | # This pattern also affects html_static_path and html_extra_path.
38 | exclude_patterns: list[str] = []
39 |
40 |
41 | # -- Options for HTML output -------------------------------------------------
42 |
43 | # The theme to use for HTML and HTML Help pages. See the documentation for
44 | # a list of builtin themes.
45 | #
46 | html_theme = "furo"
47 |
48 | # Add any paths that contain custom static files (such as style sheets) here,
49 | # relative to this directory. They are copied after the builtin static files,
50 | # so a file named "default.css" will overwrite the builtin "default.css".
51 | html_static_path = ["_static"]
52 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | export PYTHON ?= python3
2 | COVERAGE ?= $(PYTHON) -m coverage
3 | COVERAGE_RUN_OPTIONS ?=
4 | COVERAGE_RUN ?= $(COVERAGE) run $(COVERAGE_RUN_OPTIONS)
5 | TESTSUITE = tests.test_suite
6 | LITMUS_TESTS ?= basic http copymove
7 | CALDAVTESTER_TESTS ?= CalDAV/delete.xml \
8 | CalDAV/options.xml \
9 | CalDAV/vtodos.xml
10 | XANDIKOS_COVERAGE ?= $(COVERAGE_RUN) -a --rcfile=$(shell pwd)/.coveragerc --source=xandikos -m xandikos.web
11 |
12 | check:
13 | $(PYTHON) -m unittest $(TESTSUITE)
14 |
15 | style:
16 | $(PYTHON) -m ruff check .
17 |
18 | typing:
19 | $(PYTHON) -m mypy xandikos
20 |
21 | web:
22 | $(PYTHON) -m xandikos.web
23 |
24 | check-litmus-all:
25 | ./compat/xandikos-litmus.sh "basic copymove http props locks"
26 |
27 | check-litmus:
28 | ./compat/xandikos-litmus.sh "${LITMUS_TESTS}"
29 |
30 | check-pycaldav:
31 | ./compat/xandikos-pycaldav.sh
32 |
33 | check-caldav-server-tester:
34 | ./compat/xandikos-caldav-server-tester.sh
35 |
36 | coverage-pycaldav:
37 | XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-pycaldav.sh
38 |
39 | coverage-caldav-server-tester:
40 | XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-caldav-server-tester.sh
41 |
42 | coverage-litmus:
43 | XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-litmus.sh "${LITMUS_TESTS}"
44 |
45 | check-vdirsyncer:
46 | ./compat/xandikos-vdirsyncer.sh
47 |
48 | coverage-vdirsyncer:
49 | XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-vdirsyncer.sh
50 |
51 | check-all: check check-vdirsyncer check-litmus check-pycaldav style
52 |
53 | coverage-all: coverage coverage-litmus coverage-vdirsyncer
54 |
55 | coverage:
56 | $(COVERAGE_RUN) --source=xandikos -m unittest $(TESTSUITE)
57 |
58 | coverage-html: coverage
59 | $(COVERAGE) html
60 |
61 | docs:
62 | $(MAKE) -C docs html
63 |
64 | .PHONY: docs
65 |
66 | docker: docker
67 | @echo "Please use 'make container' rather than 'make docker'"
68 |
69 | container:
70 | buildah build -t jvernooij/xandikos -t ghcr.io/jelmer/xandikos .
71 | buildah push jvernooij/xandikos
72 | buildah push ghcr.io/jelmer/xandikos
73 |
74 | reformat:
75 | ruff format .
76 |
--------------------------------------------------------------------------------
/Containerfile:
--------------------------------------------------------------------------------
1 | # Docker file for Xandikos.
2 | #
3 | # Note that this dockerfile starts Xandikos without any authentication;
4 | # for authenticated access we recommend you run it behind a reverse proxy.
5 | #
6 | # Environment variables:
7 | # PORT - Port to listen on (default: 8000)
8 | # METRICS_PORT - Port for metrics endpoint (default: 8001)
9 | # LISTEN_ADDRESS - Address to bind to (default: 0.0.0.0)
10 | # DATA_DIR - Data directory path (default: /data)
11 | # CURRENT_USER_PRINCIPAL - User principal path (default: /user/)
12 | # ROUTE_PREFIX - URL route prefix (default: /)
13 | # AUTOCREATE - Auto-create directories (true/false)
14 | # DEFAULTS - Create default calendar/addressbook (true/false)
15 | # DEBUG - Enable debug logging (true/false)
16 | # DUMP_DAV_XML - Print DAV XML requests/responses (true/false)
17 | # NO_STRICT - Enable client compatibility workarounds (true/false)
18 | #
19 | # Command line arguments passed to the container override environment variables.
20 |
21 | FROM debian:sid-slim
22 | LABEL maintainer="jelmer@jelmer.uk"
23 | RUN apt-get update && \
24 | apt-get -y install --no-install-recommends python3-icalendar python3-pip python3-jinja2 python3-defusedxml python3-aiohttp python3-vobject python3-aiohttp-openmetrics curl && \
25 | apt-get clean && \
26 | rm -rf /var/lib/apt/lists/ && \
27 | groupadd -g 1000 xandikos && \
28 | useradd -d /code -c Xandikos -g xandikos -M -s /bin/bash -u 1000 xandikos && \
29 | # Install dulwich from pip instead of Debian package to get a newer version
30 | # that fixes _GitFile import issues in index.py (0.24.6 vs 0.24.2)
31 | pip3 install --break-system-packages dulwich
32 | ADD . /code
33 | COPY entrypoint.sh /entrypoint.sh
34 | RUN chmod +x /entrypoint.sh && chown xandikos:xandikos /entrypoint.sh && \
35 | mkdir -p /data && chown xandikos:xandikos /data
36 | WORKDIR /code
37 | VOLUME /data
38 | EXPOSE 8000 8001
39 | USER xandikos
40 | HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
41 | CMD curl -f http://localhost:8001/health || exit 1
42 | ENTRYPOINT ["/entrypoint.sh"]
43 | CMD []
44 |
--------------------------------------------------------------------------------
/.github/workflows/docker-test.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Docker Build Test
3 |
4 | on:
5 | pull_request:
6 |
7 | jobs:
8 | docker-build-test:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - name: Checkout repository
13 | uses: actions/checkout@v6
14 |
15 | - name: Set up Docker Buildx
16 | uses: docker/setup-buildx-action@v3
17 |
18 | - name: Build Docker image
19 | uses: docker/build-push-action@v6
20 | with:
21 | context: .
22 | file: ./Containerfile
23 | push: false
24 | load: true
25 | tags: xandikos:test
26 | cache-from: type=gha
27 | cache-to: type=gha,mode=max
28 |
29 | - name: Run Docker container
30 | run: |
31 | docker run -d \
32 | --name xandikos-test \
33 | -p 8000:8000 \
34 | -p 8001:8001 \
35 | -v xandikos-test-data:/data \
36 | -e AUTOCREATE=true \
37 | -e DEFAULTS=true \
38 | xandikos:test
39 |
40 | - name: Wait for container to be healthy
41 | run: |
42 | timeout 30 sh -c 'until docker inspect --format="{{.State.Health.Status}}" xandikos-test 2>/dev/null | grep -q healthy; do sleep 1; done' || {
43 | echo "Container failed to become healthy"
44 | docker logs xandikos-test
45 | exit 1
46 | }
47 |
48 | - name: Test health endpoint
49 | run: |
50 | curl -f http://localhost:8001/health || {
51 | echo "Health check failed"
52 | docker logs xandikos-test
53 | exit 1
54 | }
55 |
56 | - name: Test main server endpoint
57 | run: |
58 | curl -f http://localhost:8000/ || {
59 | echo "Main server check failed"
60 | docker logs xandikos-test
61 | exit 1
62 | }
63 |
64 | - name: Show container logs
65 | if: always()
66 | run: docker logs xandikos-test
67 |
68 | - name: Stop container
69 | if: always()
70 | run: docker stop xandikos-test || true
71 |
--------------------------------------------------------------------------------
/notes/collection-config.rst:
--------------------------------------------------------------------------------
1 | Per-collection configuration
2 | ============================
3 |
4 | Xandikos needs to store several piece of per-collection metadata.
5 |
6 | Goals
7 | -----
8 |
9 | Find a place to store per-collection metadata.
10 |
11 | Some of these can be inferred from other sources.
12 |
13 | For starters, for each collection:
14 |
15 | - resource types: principal, calendar, addressbook
16 |
17 | At the moment, Xandikos is storing some of this information in git configuration. However, this means:
18 |
19 | * it is not versioned
20 | * there is a 1-1 relationship between collections and git repositories
21 | * some users object to mixing in this metadata in their git config
22 |
23 | Per resource type-specific properties
24 | -------------------------------------
25 |
26 | Generic
27 | ~~~~~~~
28 |
29 | - ACLs
30 | - owner?
31 |
32 | Principal
33 | ~~~~~~~~~
34 |
35 | Per principal configuration settings:
36 |
37 | - calendar home sets
38 | - addressbook home sets
39 | - user address set
40 | - infit settings
41 |
42 | Calendar
43 | ~~~~~~~~
44 |
45 | Need per calendar config:
46 |
47 | - color
48 | - description (can be inferred from .git/description)
49 | - inbox URL
50 | - outbox URL
51 | - max instances
52 | - max attendees per instance
53 | - calendar timezone
54 | - calendar schedule transparency
55 |
56 | Addressbook
57 | ~~~~~~~~~~~
58 |
59 | Need per addressbook config:
60 |
61 | - max image size
62 | - max resource size
63 | - color
64 | - description (can be inferred from .git/description)
65 |
66 | Schedule Inbox
67 | ~~~~~~~~~~~~~~
68 | - default-calendar-URL
69 |
70 | Proposed format
71 | ---------------
72 |
73 | Store a ini-style .xandikos file in the directory hosting the Collection (or
74 | Tree in case of a Git repository).
75 |
76 | All properties mentioned above are simple key/value pairs. For simplicity, it
77 | may make sense to use an ini-style format so that users can edit metadata using their editor.
78 |
79 | Example
80 | -------
81 | # This is a standard Python configobj file, so it's mostly ini-style, and comments
82 | # can appear preceded by #.
83 |
84 | color = 030003
85 |
--------------------------------------------------------------------------------
/xandikos/collation.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Collations."""
21 |
22 | from collections.abc import Callable
23 |
24 |
25 | class UnknownCollation(Exception):
26 | def __init__(self, collation: str) -> None:
27 | super().__init__(f"Collation {collation!r} is not supported")
28 | self.collation = collation
29 |
30 |
31 | def _match(a, b, k):
32 | if k == "equals":
33 | return a == b
34 | elif k == "contains":
35 | return b in a
36 | elif k == "starts-with":
37 | return a.startswith(b)
38 | elif k == "ends-with":
39 | return a.endswith(b)
40 | else:
41 | raise NotImplementedError
42 |
43 |
44 | collations: dict[str, Callable[[str, str, str], bool]] = {
45 | "i;ascii-casemap": lambda a, b, k: _match(
46 | a.encode("ascii").upper(), b.encode("ascii").upper(), k
47 | ),
48 | "i;octet": lambda a, b, k: _match(a, b, k),
49 | # TODO(jelmer): Follow all rules as specified in
50 | # https://datatracker.ietf.org/doc/html/rfc5051
51 | "i;unicode-casemap": lambda a, b, k: _match(
52 | a.encode("utf-8", "surrogateescape").upper(),
53 | b.encode("utf-8", "surrogateescape").upper(),
54 | k,
55 | ),
56 | }
57 |
58 |
59 | def get_collation(name: str) -> Callable[[str, str, str], bool]:
60 | """Get a collation by name.
61 |
62 | Args:
63 | name: Collation name
64 | Raises:
65 | UnknownCollation: If the collation is not supported
66 | """
67 | try:
68 | return collations[name]
69 | except KeyError as exc:
70 | raise UnknownCollation(name) from exc
71 |
--------------------------------------------------------------------------------
/xandikos/wsgi.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """WSGI wrapper for xandikos."""
21 |
22 | import logging
23 | import os
24 |
25 | from .web import XandikosApp, XandikosBackend
26 |
27 | create_defaults = False
28 |
29 | autocreate_str = os.getenv("AUTOCREATE")
30 | if autocreate_str == "defaults":
31 | logging.warning("Creating default collections.")
32 | create_defaults = True
33 | autocreate = True
34 | elif autocreate_str in ("empty", "yes"):
35 | autocreate = True
36 | elif autocreate_str in (None, "no"):
37 | autocreate = False
38 | else:
39 | logging.warning("Unknown value for AUTOCREATE: %r", autocreate_str)
40 | autocreate = False
41 |
42 | backend = XandikosBackend(path=os.environ["XANDIKOSPATH"])
43 | if not os.path.isdir(backend.path):
44 | if autocreate:
45 | os.makedirs(os.environ["XANDIKOSPATH"])
46 | else:
47 | logging.warning("%r does not exist.", backend.path)
48 |
49 | current_user_principal = os.environ.get("CURRENT_USER_PRINCIPAL", "/user/")
50 | if not backend.get_resource(current_user_principal):
51 | if autocreate:
52 | backend.create_principal(
53 | current_user_principal, create_defaults=create_defaults
54 | )
55 | else:
56 | logging.warning(
57 | "default user principal '%s' does not exist. "
58 | "Create directory %s or set AUTOCREATE variable?",
59 | current_user_principal,
60 | backend._map_to_file_path(current_user_principal),
61 | )
62 |
63 | backend._mark_as_principal(current_user_principal)
64 | app = XandikosApp(backend, current_user_principal)
65 |
--------------------------------------------------------------------------------
/notes/indexes.rst:
--------------------------------------------------------------------------------
1 | Filter Performance
2 | ==================
3 |
4 | There are several API calls that would be good to speed up. In particular,
5 | querying an entire calendar with filters is quite slow because it involves
6 | scanning all the items.
7 |
8 | Common Filters
9 | ~~~~~~~~~~~~~~
10 |
11 | There are a couple of common filters:
12 |
13 | Component filters that filter for only VTODO or VEVENT items
14 | Property filters that filter for a specific UID
15 | Property filters that filter for another property
16 | Property filters that do complex text searches, e.g. in DESCRIPTION
17 | Property filters that filter for some time range.
18 |
19 | But these are by no means the only possible filters, and there is no
20 | predicting what clients will scan for.
21 |
22 | Indexes are an implementation detail of the Store. This is necessary so that
23 | e.g. the Git stores can take advantage of the fact that they have a tree hash.
24 |
25 | One option would be to serialize the filter and then to keep a list of results
26 | per (tree_id, filter_hash). Unfortunately this by itself is not enough, since
27 | it doesn't help when we get repeated queries for different UIDs.
28 |
29 | Options considered:
30 |
31 | * Have some pre-set indexes. Perhaps components, and UID?
32 | * Cache but use the rightmost value as a key in a dict
33 | * Always just cache everything that was queried. This is probably actually fine.
34 | * Count how often a particular index is used
35 |
36 | Open Questions
37 | ~~~~~~~~~~~~~~
38 |
39 | * How are indexes identified?
40 |
41 | Proposed API
42 | ~~~~~~~~~~~~
43 |
44 | class Filter(object):
45 |
46 | def check_slow(self, name, resource):
47 | """Check whether this filter applies to a resources based on the actual
48 | resource.
49 |
50 | This is the naive, slow, fallback implementation.
51 |
52 | Args:
53 | resource: Resource to check
54 | """
55 | raise NotImplementedError(self.check_slow)
56 |
57 | def check_index(self, values):
58 | """Check whether this filter applies to a resources based on index values.
59 |
60 | Args:
61 | values: Dictionary mapping indexes to index values
62 | """
63 | raise NotImplementedError(self.check_index)
64 |
65 | def required_indexes(self):
66 | """Return a list of indexes that this Filter needs to function.
67 |
68 | Returns: List of ORed options, similar to a Depends line in Debian
69 | """
70 | raise NotImplementedError(self.required_indexes)
71 |
72 |
--------------------------------------------------------------------------------
/compat/xandikos-vdirsyncer.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | . $(dirname $0)/common.sh
4 |
5 | set -e
6 |
7 | readonly BRANCH=main
8 | VENV_DIR=$(dirname $0)/vdirsyncer-venv
9 |
10 | [ -z "$PYTHON" ] && PYTHON=python3
11 |
12 | cd "$(dirname $0)"
13 | REPO_DIR="$(readlink -f ..)"
14 |
15 | if [ ! -d vdirsyncer ]; then
16 | git clone -b $BRANCH https://github.com/pimutils/vdirsyncer
17 | else
18 | pushd vdirsyncer
19 | git pull --ff-only origin $BRANCH
20 | popd
21 | fi
22 |
23 | # Always use our own virtual environment for better isolation
24 | if [ ! -d "${VENV_DIR}" ]; then
25 | echo "Creating virtual environment for vdirsyncer"
26 | ${PYTHON} -m venv "${VENV_DIR}"
27 | fi
28 | source "${VENV_DIR}/bin/activate"
29 |
30 | # Install dependencies in virtual environment
31 | cd vdirsyncer
32 | pip install -e '.[test]'
33 | cd ..
34 |
35 | # Deactivate venv before running xandikos so it uses system Python
36 | deactivate
37 |
38 | # Now run xandikos with system Python on port 8000 (what tests expect)
39 | run_xandikos 8000 --autocreate
40 |
41 | # Reactivate virtual environment for running tests
42 | source "${VENV_DIR}/bin/activate"
43 |
44 | cd vdirsyncer
45 |
46 | if [ -z "${CARGO_HOME}" ]; then
47 | export CARGO_HOME="$(readlink -f .)/cargo"
48 | export RUSTUP_HOME="$(readlink -f .)/cargo"
49 | fi
50 | curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly --no-modify-path
51 | . ${CARGO_HOME}/env
52 | rustup update nightly
53 |
54 | # Export xandikos URL for tests
55 | export DAV_SERVER=xandikos
56 | export DAV_SERVER_URL=http://localhost:8000/
57 |
58 | # Patch the conftest.py to use our local xandikos instead of Docker
59 | cp tests/storage/conftest.py tests/storage/conftest.py.bak
60 |
61 | # Set up trap to restore conftest.py on exit
62 | restore_conftest() {
63 | if [ -f tests/storage/conftest.py.bak ]; then
64 | mv tests/storage/conftest.py.bak tests/storage/conftest.py
65 | fi
66 | }
67 | trap restore_conftest EXIT
68 |
69 | cat >> tests/storage/conftest.py << 'EOF'
70 |
71 | # Override the xandikos_server fixture to use our local instance
72 | import pytest
73 |
74 | @pytest.fixture(scope="session")
75 | def xandikos_server():
76 | """Use the locally running xandikos instead of Docker."""
77 | # Our xandikos is already running on port 5001
78 | # The tests expect it on port 8000, but we'll handle that in the server config
79 | yield
80 | EOF
81 |
82 | # Run the tests
83 | pytest tests/storage/dav/ --ignore=tests/system/utils/test_main.py --no-cov -v --tb=short
84 |
--------------------------------------------------------------------------------
/xandikos/server_info.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Server info.
21 |
22 | See https://www.ietf.org/archive/id/draft-douglass-server-info-03.txt
23 | """
24 |
25 | import hashlib
26 |
27 |
28 | from xandikos import version_string, webdav
29 |
30 | ET = webdav.ET
31 |
32 | # Feature to advertise server-info support.
33 | FEATURE = "server-info"
34 | SERVER_INFO_MIME_TYPE = "application/server-info+xml"
35 |
36 |
37 | class ServerInfo:
38 | """Server info."""
39 |
40 | def __init__(self) -> None:
41 | self._token = None
42 | self._features: list[str] = []
43 | self._applications: list[str] = []
44 |
45 | def add_feature(self, feature):
46 | self._features.append(feature)
47 | self._token = None
48 |
49 | @property
50 | def token(self):
51 | if self._token is None:
52 | h = hashlib.sha1()
53 | h.update(version_string.encode("utf-8"))
54 | for z in self._features + self._applications:
55 | h.update(z.encode("utf-8"))
56 | self._token = h.hexdigest()
57 | return self._token
58 |
59 | async def get_body(self):
60 | el = ET.Element("{DAV:}server-info")
61 | el.set("token", self.token)
62 | server_el = ET.SubElement(el, "server-instance-info")
63 | ET.SubElement(server_el, "name").text = "Xandikos"
64 | ET.SubElement(server_el, "version").text = version_string
65 | features_el = ET.SubElement(el, "features")
66 | for feature in self._features:
67 | features_el.append(feature)
68 | applications_el = ET.SubElement(el, "applications")
69 | for application in self.applications:
70 | applications_el.append(application)
71 | return el
72 |
--------------------------------------------------------------------------------
/xandikos/infit.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Inf-It properties."""
21 |
22 | from xandikos import carddav, webdav
23 |
24 |
25 | class SettingsProperty(webdav.Property):
26 | """settings property.
27 |
28 | JSON settings.
29 | """
30 |
31 | name = "{http://inf-it.com/ns/dav/}settings"
32 | resource_type = webdav.PRINCIPAL_RESOURCE_TYPE
33 | live = False
34 |
35 | async def get_value(self, href: str, resource, el, environ):
36 | el.text = resource.get_infit_settings()
37 |
38 | async def set_value(self, href: str, resource, el):
39 | resource.set_infit_settings(el.text)
40 |
41 |
42 | class AddressbookColorProperty(webdav.Property):
43 | """Provides the addressbook-color property.
44 |
45 | Contains a RRGGBB code, similar to calendar-color.
46 | """
47 |
48 | name = "{http://inf-it.com/ns/ab/}addressbook-color"
49 | resource_type = carddav.ADDRESSBOOK_RESOURCE_TYPE
50 | in_allprops = False
51 |
52 | async def get_value(self, href, resource, el, environ):
53 | el.text = resource.get_addressbook_color()
54 |
55 | async def set_value(self, href, resource, el):
56 | resource.set_addressbook_color(el.text)
57 |
58 |
59 | class HeaderValueProperty(webdav.Property):
60 | """Provides the header-value property.
61 |
62 | This behaves similar to the hrefLabel setting in caldavzap/carddavmate.
63 | """
64 |
65 | name = "{http://inf-it.com/ns/dav/}headervalue"
66 | resource_type = webdav.COLLECTION_RESOURCE_TYPE
67 | in_allprops = False
68 | live = False
69 |
70 | async def get_value(self, href, resource, el, environ):
71 | el.text = resource.get_headervalue()
72 |
73 | async def set_value(self, href, resource, el):
74 | # TODO
75 | raise NotImplementedError
76 |
--------------------------------------------------------------------------------
/xandikos/access.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Access control.
21 |
22 | See http://www.webdav.org/specs/rfc3744.html
23 | """
24 |
25 | from xandikos import webdav
26 |
27 | ET = webdav.ET
28 |
29 | # Feature to advertise access control support.
30 | FEATURE = "access-control"
31 |
32 |
33 | class CurrentUserPrivilegeSetProperty(webdav.Property):
34 | """current-user-privilege-set property.
35 |
36 | See http://www.webdav.org/specs/rfc3744.html, section 3.7
37 | """
38 |
39 | name = "{DAV:}current-user-privilege-set"
40 | in_allprops = False
41 | live = True
42 |
43 | async def get_value(self, href, resource, el, environ):
44 | privilege = ET.SubElement(el, "{DAV:}privilege")
45 | # TODO(jelmer): Use something other than all
46 | ET.SubElement(privilege, "{DAV:}all")
47 |
48 |
49 | class OwnerProperty(webdav.Property):
50 | """owner property.
51 |
52 | See http://www.webdav.org/specs/rfc3744.html, section 5.1
53 | """
54 |
55 | name = "{DAV:}owner"
56 | in_allprops = False
57 | live = True
58 |
59 | async def get_value(self, base_href, resource, el, environ):
60 | owner_href = resource.get_owner()
61 | if owner_href is not None:
62 | el.append(webdav.create_href(owner_href, base_href=base_href))
63 |
64 |
65 | class GroupMembershipProperty(webdav.Property):
66 | """Group membership.
67 |
68 | See https://www.ietf.org/rfc/rfc3744.txt, section 4.4
69 | """
70 |
71 | name = "{DAV:}group-membership"
72 | in_allprops = False
73 | live = True
74 | resource_type = webdav.PRINCIPAL_RESOURCE_TYPE
75 |
76 | async def get_value(self, base_href, resource, el, environ):
77 | for href in resource.get_group_membership():
78 | el.append(webdav.create_href(href, base_href=href))
79 |
--------------------------------------------------------------------------------
/grafana-dashboard.json:
--------------------------------------------------------------------------------
1 | {
2 | "__inputs": [
3 | {
4 | "name": "DS_PROMETHEUS",
5 | "label": "Prometheus",
6 | "description": "",
7 | "type": "datasource",
8 | "pluginId": "prometheus",
9 | "pluginName": "Prometheus"
10 | }
11 | ],
12 | "annotations": {
13 | "list": [
14 | {
15 | "builtIn": 1,
16 | "datasource": "-- Grafana --",
17 | "enable": true,
18 | "hide": true,
19 | "iconColor": "rgba(0, 211, 255, 1)",
20 | "name": "Annotations & Alerts",
21 | "type": "dashboard"
22 | }
23 | ]
24 | },
25 | "editable": true,
26 | "gnetId": null,
27 | "graphTooltip": 0,
28 | "id": 10286,
29 | "links": [],
30 | "panels": [
31 | {
32 | "datasource": "${DS_PROMETHEUS}",
33 | "fieldConfig": {
34 | "defaults": {
35 | "color": {
36 | "mode": "thresholds"
37 | },
38 | "mappings": [],
39 | "thresholds": {
40 | "mode": "absolute",
41 | "steps": [
42 | {
43 | "color": "green",
44 | "value": null
45 | },
46 | {
47 | "color": "red",
48 | "value": 80
49 | }
50 | ]
51 | }
52 | },
53 | "overrides": []
54 | },
55 | "gridPos": {
56 | "h": 9,
57 | "w": 12,
58 | "x": 0,
59 | "y": 0
60 | },
61 | "id": 2,
62 | "options": {
63 | "colorMode": "value",
64 | "graphMode": "area",
65 | "justifyMode": "auto",
66 | "orientation": "auto",
67 | "reduceOptions": {
68 | "calcs": [
69 | "lastNotNull"
70 | ],
71 | "fields": "",
72 | "values": false
73 | },
74 | "text": {},
75 | "textMode": "auto"
76 | },
77 | "pluginVersion": "7.5.11",
78 | "targets": [
79 | {
80 | "exemplar": true,
81 | "expr": "up{job=\"xandikos\"}",
82 | "interval": "",
83 | "legendFormat": "",
84 | "refId": "A"
85 | }
86 | ],
87 | "title": "Health",
88 | "type": "stat"
89 | }
90 | ],
91 | "schemaVersion": 27,
92 | "style": "dark",
93 | "tags": [],
94 | "templating": {
95 | "list": []
96 | },
97 | "time": {
98 | "from": "now-6h",
99 | "to": "now"
100 | },
101 | "timepicker": {},
102 | "timezone": "",
103 | "title": "Xandikos",
104 | "uid": "k7dunuVVk",
105 | "version": 2
106 | }
107 |
--------------------------------------------------------------------------------
/docs/source/reverse-proxy.rst:
--------------------------------------------------------------------------------
1 | .. _reverse-proxy:
2 |
3 | Running behind a reverse proxy
4 | ==============================
5 |
6 | By default, Xandikos does not provide any authentication support. Instead, it
7 | is recommended that it is run behind a reverse HTTP proxy that does.
8 |
9 | The author has used both nginx and Apache in front of Xandikos, but any
10 | reverse HTTP proxy should do.
11 |
12 | If you expose Xandikos at the root of a domain, no further configuration is
13 | necessary. When exposing it on a different path prefix, make sure to set the
14 | ``--route-prefix`` argument to Xandikos appropriately.
15 |
16 | .well-known
17 | -----------
18 |
19 | When serving Xandikos on a prefix, you may still want to provide
20 | the appropriate ``.well-known`` files at the root so that clients
21 | can find the DAV server without having to specify the subprefix.
22 |
23 | For this to work, reverse proxy the ``.well-known/carddav`` and
24 | ``.well-known/caldav`` files to Xandikos.
25 |
26 | Example: Kubernetes ingress
27 | ---------------------------
28 |
29 | Here is an example configuring Xandikos to listen on ``/dav`` using the
30 | Kubernetes nginx ingress controller. Note that this relies on the
31 | appropriate server being set up in kubernetes (see :ref:`getting-started`) and
32 | the ``my-htpasswd`` secret being present and having a htpasswd like file in it.
33 |
34 | .. literalinclude:: ../../examples/xandikos-ingress.k8s.yaml
35 | :language: yaml
36 |
37 | Example: nginx reverse proxy
38 | -----------------------------
39 |
40 | .. code-block:: nginx
41 |
42 | server {
43 | listen 443 ssl;
44 | server_name dav.example.com;
45 |
46 | ssl_certificate /path/to/cert.pem;
47 | ssl_certificate_key /path/to/key.pem;
48 |
49 | location / {
50 | auth_basic "CalDAV/CardDAV";
51 | auth_basic_user_file /etc/nginx/htpasswd;
52 |
53 | proxy_pass http://localhost:8080;
54 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
55 | proxy_set_header X-Forwarded-Proto $scheme;
56 | proxy_set_header Host $host;
57 | }
58 | }
59 |
60 | Example: Apache reverse proxy
61 | -----------------------------
62 |
63 | .. code-block:: apache
64 |
65 |
66 | ServerName dav.example.com
67 |
68 | SSLEngine on
69 | SSLCertificateFile /path/to/cert.pem
70 | SSLCertificateKeyFile /path/to/key.pem
71 |
72 |
73 | AuthType Digest
74 | AuthName "CalDAV/CardDAV"
75 | AuthUserFile /etc/apache2/htdigest
76 | Require valid-user
77 |
78 | ProxyPass http://localhost:8080/
79 | ProxyPassReverse http://localhost:8080/
80 |
81 |
82 |
--------------------------------------------------------------------------------
/xandikos/xmpp.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 |
21 | """XMPP support.
22 |
23 | https://github.com/evert/calendarserver-extensions/blob/master/caldav-pubsubdiscovery.txt
24 | """
25 |
26 | from . import webdav
27 | from .caldav import CALENDAR_RESOURCE_TYPE
28 |
29 | ET = webdav.ET
30 |
31 |
32 | class XmppUriProperty(webdav.Property):
33 | """xmpp-uri property."""
34 |
35 | name = "{http://calendarserver.org/ns/}xmpp-uri"
36 | resource_type = CALENDAR_RESOURCE_TYPE
37 | in_allprops = True
38 | live = False
39 |
40 | async def get_value(self, base_href, resource, el, environ):
41 | el.text = resource.get_xmpp_uri()
42 |
43 | async def set_value(self, href, resource, el):
44 | raise NotImplementedError(self.set_value)
45 |
46 |
47 | class XmppHeartbeatProperty(webdav.Property):
48 | """xmpp-heartbeat property."""
49 |
50 | name = "{http://calendarserver.org/ns/}xmpp-heartbeat"
51 | resource_type = CALENDAR_RESOURCE_TYPE
52 | in_allprops = True
53 | live = False
54 |
55 | async def get_value(self, base_href, resource, el, environ):
56 | (uri, minutes) = resource.get_xmpp_heartbeat()
57 | uri_el = ET.SubElement(el, "{http://calendarserver.org/ns/}xmpp-heartbeat-uri")
58 | uri_el.text = uri
59 | minutes_el = ET.SubElement(
60 | el, "{http://calendarserver.org/ns/}xmpp-heartbeat-minutes"
61 | )
62 | minutes_el.text = str(minutes)
63 |
64 | async def set_value(self, href, resource, el):
65 | raise NotImplementedError(self.set_value)
66 |
67 |
68 | class XmppServerProperty(webdav.Property):
69 | """xmpp-server property."""
70 |
71 | name = "{http://calendarserver.org/ns/}xmpp-server"
72 | resource_type = CALENDAR_RESOURCE_TYPE
73 | in_allprops = True
74 | live = False
75 |
76 | async def get_value(self, base_href, resource, el, environ):
77 | server = resource.get_xmpp_server()
78 | el.text = server
79 |
80 | async def set_value(self, href, resource, el):
81 | raise NotImplementedError(self.set_value)
82 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=61.2"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [project]
6 | name = "xandikos"
7 | description = "Lightweight CalDAV/CardDAV server"
8 | readme = "README.rst"
9 | authors = [{name = "Jelmer Vernooij", email = "jelmer@jelmer.uk"}]
10 | license = {text = "GNU GPLv3 or later"}
11 | classifiers = [
12 | "Development Status :: 4 - Beta",
13 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
14 | "Programming Language :: Python :: 3.10",
15 | "Programming Language :: Python :: 3.11",
16 | "Programming Language :: Python :: 3.12",
17 | "Programming Language :: Python :: 3.13",
18 | "Programming Language :: Python :: 3.14",
19 | "Programming Language :: Python :: Implementation :: CPython",
20 | "Programming Language :: Python :: Implementation :: PyPy",
21 | "Operating System :: POSIX",
22 | ]
23 | urls = {Homepage = "https://www.xandikos.org/"}
24 | requires-python = ">=3.10"
25 | dependencies = [
26 | "aiohttp",
27 | "icalendar>=5.0.4,<7.0",
28 | "dulwich>=0.21.6,<0.26.0",
29 | "defusedxml",
30 | "jinja2",
31 | "multidict",
32 | "vobject",
33 | ]
34 | dynamic = ["version"]
35 |
36 | [project.optional-dependencies]
37 | prometheus = ["aiohttp-openmetrics"]
38 | systemd = ["systemd_python"]
39 | qrcode = ["qrcode[pil]"]
40 | dev = [
41 | "ruff==0.14.7",
42 | "pytest",
43 | ]
44 | typing = [
45 | "mypy==1.19.0",
46 | "types-python-dateutil"
47 | ]
48 |
49 | [project.scripts]
50 | xandikos = "xandikos.__main__:main"
51 |
52 | [tool.setuptools]
53 | include-package-data = false
54 |
55 | [tool.setuptools.packages]
56 | find = {namespaces = false}
57 |
58 | [tool.setuptools.package-data]
59 | xandikos = [
60 | "templates/*.html",
61 | "py.typed",
62 | ]
63 |
64 | [tool.setuptools.dynamic]
65 | version = {attr = "xandikos.__version__"}
66 |
67 | [tool.mypy]
68 | ignore_missing_imports = true
69 |
70 | [tool.distutils.bdist_wheel]
71 | universal = 1
72 |
73 | [tool.ruff.lint]
74 | select = [
75 | "ANN",
76 | "D",
77 | "E",
78 | "F",
79 | "UP",
80 | ]
81 | ignore = [
82 | "ANN001",
83 | "ANN002",
84 | "ANN003",
85 | "ANN201",
86 | "ANN202",
87 | "ANN204",
88 | "ANN206",
89 | "D100",
90 | "D101",
91 | "D102",
92 | "D103",
93 | "D104",
94 | "D105",
95 | "D107",
96 | "D403",
97 | "D417",
98 | "E501",
99 | # f-strings aren't actually more readable for some expressions, especially things like:
100 | # "{%s}blah" % caldav.NAMESPACE
101 | # where escaping the braces is a pain
102 | "UP031",
103 | ]
104 |
105 | [tool.ruff.lint.pydocstyle]
106 | convention = "google"
107 |
108 | [tool.pytest.ini_options]
109 | asyncio_mode = "strict"
110 | asyncio_default_fixture_loop_scope = "function"
111 |
--------------------------------------------------------------------------------
/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # Default values
5 | DEFAULT_PORT="8000"
6 | DEFAULT_METRICS_PORT="8001"
7 | DEFAULT_LISTEN_ADDRESS="0.0.0.0"
8 | DEFAULT_DATA_DIR="/data"
9 | DEFAULT_CURRENT_USER_PRINCIPAL="/user/"
10 | DEFAULT_ROUTE_PREFIX="/"
11 |
12 | # Build command line arguments
13 | ARGS=()
14 |
15 | # Handle environment variables and build arguments
16 | if [ -n "$PORT" ]; then
17 | ARGS+=("--port=$PORT")
18 | else
19 | ARGS+=("--port=$DEFAULT_PORT")
20 | fi
21 |
22 | if [ -n "$METRICS_PORT" ]; then
23 | ARGS+=("--metrics-port=$METRICS_PORT")
24 | else
25 | ARGS+=("--metrics-port=$DEFAULT_METRICS_PORT")
26 | fi
27 |
28 | if [ -n "$LISTEN_ADDRESS" ]; then
29 | ARGS+=("--listen-address=$LISTEN_ADDRESS")
30 | else
31 | ARGS+=("--listen-address=$DEFAULT_LISTEN_ADDRESS")
32 | fi
33 |
34 | if [ -n "$DATA_DIR" ]; then
35 | ARGS+=("-d" "$DATA_DIR")
36 | else
37 | ARGS+=("-d" "$DEFAULT_DATA_DIR")
38 | fi
39 |
40 | if [ -n "$CURRENT_USER_PRINCIPAL" ]; then
41 | ARGS+=("--current-user-principal=$CURRENT_USER_PRINCIPAL")
42 | else
43 | ARGS+=("--current-user-principal=$DEFAULT_CURRENT_USER_PRINCIPAL")
44 | fi
45 |
46 | if [ -n "$ROUTE_PREFIX" ]; then
47 | ARGS+=("--route-prefix=$ROUTE_PREFIX")
48 | else
49 | ARGS+=("--route-prefix=$DEFAULT_ROUTE_PREFIX")
50 | fi
51 |
52 | # Boolean flags
53 | if [ "$AUTOCREATE" = "true" ] || [ "$AUTOCREATE" = "1" ]; then
54 | ARGS+=("--autocreate")
55 | fi
56 |
57 | if [ "$DEFAULTS" = "true" ] || [ "$DEFAULTS" = "1" ]; then
58 | ARGS+=("--defaults")
59 | fi
60 |
61 | if [ "$DUMP_DAV_XML" = "true" ] || [ "$DUMP_DAV_XML" = "1" ]; then
62 | ARGS+=("--dump-dav-xml")
63 | fi
64 |
65 | if [ "$AVAHI" = "true" ] || [ "$AVAHI" = "1" ]; then
66 | ARGS+=("--avahi")
67 | fi
68 |
69 | if [ "$NO_STRICT" = "true" ] || [ "$NO_STRICT" = "1" ]; then
70 | ARGS+=("--no-strict")
71 | fi
72 |
73 | if [ "$DEBUG" = "true" ] || [ "$DEBUG" = "1" ]; then
74 | ARGS+=("--debug")
75 | fi
76 |
77 | if [ "$PARANOID" = "true" ] || [ "$PARANOID" = "1" ]; then
78 | ARGS+=("--paranoid")
79 | fi
80 |
81 | if [ -n "$INDEX_THRESHOLD" ]; then
82 | ARGS+=("--index-threshold=$INDEX_THRESHOLD")
83 | fi
84 |
85 | if [ "$NO_DETECT_SYSTEMD" = "true" ] || [ "$NO_DETECT_SYSTEMD" = "1" ]; then
86 | ARGS+=("--no-detect-systemd")
87 | fi
88 |
89 | # Handle graceful shutdown
90 | shutdown_handler() {
91 | echo "Received SIGTERM, shutting down gracefully..."
92 | if [ -n "$XANDIKOS_PID" ]; then
93 | kill -TERM "$XANDIKOS_PID" 2>/dev/null || true
94 | wait "$XANDIKOS_PID" 2>/dev/null || true
95 | fi
96 | exit 0
97 | }
98 |
99 | # Set up signal handlers
100 | trap shutdown_handler SIGTERM SIGINT
101 |
102 | # If user provided arguments, pass them directly to xandikos
103 | if [ $# -gt 0 ]; then
104 | python3 -m xandikos.web "$@" &
105 | else
106 | # Use environment variable configuration
107 | python3 -m xandikos.web "${ARGS[@]}" &
108 | fi
109 |
110 | XANDIKOS_PID=$!
111 | wait $XANDIKOS_PID
112 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socioeconomic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project lead at jelmer@jelmer.uk. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/tests/test_carddav.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2022 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | import unittest
21 |
22 | from xandikos.carddav import NAMESPACE, AddressDataProperty
23 | from xandikos.vcard import VCardFile, CardDAVFilter, parse_filter
24 | from xandikos.webdav import ET
25 | from .test_vcard import EXAMPLE_VCARD1
26 |
27 |
28 | class TestApplyFilter(unittest.TestCase):
29 | def test_parse_filter(self):
30 | """Test parsing filter XML into CardDAVFilter object."""
31 | el = ET.Element("{%s}filter" % NAMESPACE)
32 | el.set("test", "anyof")
33 | pf = ET.SubElement(el, "{%s}prop-filter" % NAMESPACE)
34 | pf.set("name", "FN")
35 | tm = ET.SubElement(pf, "{%s}text-match" % NAMESPACE)
36 | tm.set("collation", "i;unicode-casemap")
37 | tm.set("match-type", "contains")
38 | tm.text = "Jeffrey"
39 |
40 | # Parse the filter
41 | filter_obj = parse_filter(el, CardDAVFilter())
42 |
43 | # Test that it was parsed correctly
44 | self.assertEqual(filter_obj.test, any)
45 | self.assertEqual(len(filter_obj.property_filters), 1)
46 | prop_filter = filter_obj.property_filters[0]
47 | self.assertEqual(prop_filter.name, "FN")
48 | self.assertEqual(len(prop_filter.text_matches), 1)
49 | text_match = prop_filter.text_matches[0]
50 | self.assertEqual(text_match.text, "Jeffrey")
51 | self.assertEqual(text_match.match_type, "contains")
52 |
53 | # Test that it actually filters correctly
54 | fi = VCardFile([EXAMPLE_VCARD1], "text/vcard")
55 | self.assertTrue(filter_obj.check("test.vcf", fi))
56 |
57 |
58 | class TestAddressDataProperty(unittest.TestCase):
59 | def test_supported_on_with_vcard(self):
60 | """Test that supported_on returns True for vcard resources."""
61 | prop = AddressDataProperty()
62 |
63 | class VCardResource:
64 | def get_content_type(self):
65 | return "text/vcard"
66 |
67 | self.assertTrue(prop.supported_on(VCardResource()))
68 |
69 | def test_supported_on_with_non_vcard(self):
70 | """Test that supported_on returns False for non-vcard resources."""
71 | prop = AddressDataProperty()
72 |
73 | class NonVCardResource:
74 | def get_content_type(self):
75 | return "text/plain"
76 |
77 | self.assertFalse(prop.supported_on(NonVCardResource()))
78 |
79 | def test_supported_on_with_missing_content_type(self):
80 | """Test that supported_on handles resources without content type gracefully."""
81 | prop = AddressDataProperty()
82 |
83 | class ResourceWithoutContentType:
84 | def get_content_type(self):
85 | raise KeyError("No content type")
86 |
87 | # This should not raise an exception, but return False
88 | self.assertFalse(prop.supported_on(ResourceWithoutContentType()))
89 |
--------------------------------------------------------------------------------
/xandikos/davcommon.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Common functions for DAV implementations."""
21 |
22 | from xandikos import webdav
23 |
24 | ET = webdav.ET
25 |
26 |
27 | class SubbedProperty(webdav.Property):
28 | """Property with sub-components that can be queried."""
29 |
30 | async def get_value_ext(self, href, resource, el, environ, requested):
31 | """Get the value of a data property.
32 |
33 | Args:
34 | href: Resource href
35 | resource: Resource to get value for
36 | el: Element to fill in
37 | environ: WSGI environ dict
38 | requested: Requested property (including subelements)
39 | """
40 | raise NotImplementedError(self.get_value_ext)
41 |
42 |
43 | async def get_properties_with_data(
44 | data_property, href, resource, properties, environ, requested
45 | ):
46 | properties = dict(properties)
47 | properties[data_property.name] = data_property
48 | async for ps in webdav.get_properties(
49 | href, resource, properties, environ, requested
50 | ):
51 | yield ps
52 |
53 |
54 | class MultiGetReporter(webdav.Reporter):
55 | """Abstract base class for multi-get reporters."""
56 |
57 | name: str
58 |
59 | # A SubbedProperty subclass
60 | data_property: SubbedProperty
61 |
62 | @webdav.multistatus
63 | async def report(
64 | self,
65 | environ,
66 | body,
67 | resources_by_hrefs,
68 | properties,
69 | base_href,
70 | resource,
71 | depth,
72 | strict,
73 | ):
74 | # TODO(jelmer): Verify that depth == "0"
75 | # TODO(jelmer): Verify that resource is an the right resource type
76 | requested = None
77 | hrefs = []
78 | for el in body:
79 | if el.tag in ("{DAV:}prop", "{DAV:}allprop", "{DAV:}propname"):
80 | requested = el
81 | elif el.tag == "{DAV:}href":
82 | hrefs.append(webdav.read_href_element(el))
83 | else:
84 | webdav.nonfatal_bad_request(
85 | f"Unknown tag {el.tag} in report {self.name}", strict
86 | )
87 | if requested is None:
88 | # The CalDAV RFC says that behaviour mimics that of PROPFIND,
89 | # and the WebDAV RFC says that no body implies {DAV}allprop
90 | # This isn't exactly an empty body, but close enough.
91 | requested = ET.Element("{DAV:}allprop")
92 | for href, resource in resources_by_hrefs(hrefs):
93 | if resource is None:
94 | yield webdav.Status(href, "404 Not Found", propstat=[])
95 | else:
96 | propstat = get_properties_with_data(
97 | self.data_property,
98 | href,
99 | resource,
100 | properties,
101 | environ,
102 | requested,
103 | )
104 | yield webdav.Status(
105 | href, "200 OK", propstat=[s async for s in propstat]
106 | )
107 |
108 |
109 | # see https://tools.ietf.org/html/rfc4790
110 |
--------------------------------------------------------------------------------
/xandikos/store/index.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2019 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Indexing."""
21 |
22 | import collections
23 | import logging
24 | from collections.abc import Iterable, Iterator
25 |
26 | IndexKey = str
27 | IndexValue = list[bytes | bool]
28 | IndexValueIterator = Iterator[bytes | bool]
29 | IndexDict = dict[IndexKey, IndexValue]
30 |
31 |
32 | DEFAULT_INDEXING_THRESHOLD = 5
33 |
34 |
35 | class Index:
36 | """Index management."""
37 |
38 | def available_keys(self) -> Iterable[IndexKey]:
39 | """Return list of available index keys."""
40 | raise NotImplementedError(self.available_keys)
41 |
42 | def get_values(self, name: str, etag: str, keys: list[IndexKey]):
43 | """Get the values for specified keys for a name."""
44 | raise NotImplementedError(self.get_values)
45 |
46 | def iter_etags(self) -> Iterator[str]:
47 | """Return all the etags covered by this index."""
48 | raise NotImplementedError(self.iter_etags)
49 |
50 |
51 | class MemoryIndex(Index):
52 | def __init__(self) -> None:
53 | self._indexes: dict[IndexKey, dict[str, IndexValue]] = {}
54 | self._in_index: set[str] = set()
55 |
56 | def available_keys(self):
57 | return self._indexes.keys()
58 |
59 | def get_values(self, name, etag, keys):
60 | if etag not in self._in_index:
61 | raise KeyError(etag)
62 | indexes = {}
63 | for k in keys:
64 | if k not in self._indexes:
65 | raise AssertionError
66 | try:
67 | indexes[k] = self._indexes[k][etag]
68 | except KeyError:
69 | indexes[k] = []
70 | return indexes
71 |
72 | def iter_etags(self):
73 | return iter(self._in_index)
74 |
75 | def add_values(self, name, etag, values):
76 | for k, v in values.items():
77 | if k not in self._indexes:
78 | raise AssertionError
79 | self._indexes[k][etag] = v
80 | self._in_index.add(etag)
81 |
82 | def reset(self, keys):
83 | self._in_index = set()
84 | self._indexes = {}
85 | for key in keys:
86 | self._indexes[key] = {}
87 |
88 |
89 | class AutoIndexManager:
90 | def __init__(self, index, threshold: int | None = None) -> None:
91 | self.index = index
92 | self.desired: dict[IndexKey, int] = collections.defaultdict(lambda: 0)
93 | if threshold is None:
94 | threshold = DEFAULT_INDEXING_THRESHOLD
95 | self.indexing_threshold = threshold
96 |
97 | def find_present_keys(
98 | self, necessary_keys: Iterable[Iterable[IndexKey]]
99 | ) -> Iterable[IndexKey] | None:
100 | available_keys = self.index.available_keys()
101 | needed_keys = []
102 | missing_keys: list[IndexKey] = []
103 | new_index_keys = set()
104 | for keys in necessary_keys:
105 | found = False
106 | for key in keys:
107 | if key in available_keys:
108 | needed_keys.append(key)
109 | found = True
110 | if not found:
111 | for key in keys:
112 | self.desired[key] += 1
113 | if self.desired[key] > self.indexing_threshold:
114 | new_index_keys.add(key)
115 | missing_keys.extend(keys)
116 | if not missing_keys:
117 | return needed_keys
118 |
119 | if new_index_keys:
120 | logging.debug("Adding new index keys: %r", new_index_keys)
121 | self.index.reset(set(self.index.available_keys()) | new_index_keys)
122 |
123 | # TODO(jelmer): Maybe best to check if missing_keys are satisfiable
124 | # now?
125 |
126 | return None
127 |
--------------------------------------------------------------------------------
/compat/xandikos-caldav-server-tester.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Run caldav-server-tester against Xandikos.
3 | set -e
4 |
5 | . $(dirname $0)/common.sh
6 |
7 | VENV_DIR=$(dirname $0)/caldav-server-tester-venv
8 | [ -z "$PYTHON" ] && PYTHON=python3
9 |
10 | # Set up virtual environment
11 | if [ ! -d "${VENV_DIR}" ]; then
12 | echo "Creating virtual environment for caldav-server-tester"
13 | ${PYTHON} -m venv "${VENV_DIR}"
14 | fi
15 |
16 | # Activate virtual environment
17 | source "${VENV_DIR}/bin/activate"
18 |
19 | # Install caldav and caldav-server-tester
20 | echo "Installing caldav and caldav-server-tester..."
21 | pip install -q --upgrade pip
22 | # Install pinned versions from requirements file
23 | if pip install -q -r "$(dirname $0)/caldav-server-tester-requirements.txt" 2>/dev/null; then
24 | echo "caldav-server-tester installed successfully"
25 | else
26 | echo "WARNING: caldav-server-tester not available on PyPI, skipping..."
27 | echo "The testCheckCompatibility test will be skipped"
28 | fi
29 |
30 | # Deactivate venv before running xandikos so it uses system Python
31 | deactivate
32 |
33 | # Create test configuration
34 | cat <$(dirname $0)/caldav-server-tester-venv/test_compatibility.py
35 | import unittest
36 | import caldav
37 | from caldav.compatibility_hints import FeatureSet
38 |
39 |
40 | class TestXandikosCompatibility(unittest.TestCase):
41 | """Test Xandikos server CalDAV compatibility."""
42 |
43 | @classmethod
44 | def setUpClass(cls):
45 | """Set up test class."""
46 | # Configure known Xandikos limitations/quirks
47 | #
48 | # Xandikos supports most core CalDAV features including:
49 | # - Basic calendar operations (create, delete, read, update)
50 | # - Event and todo management
51 | # - Time-range searches for events and todos
52 | # - Category searches (basic)
53 | # - Combined searches (logical AND of time-range and category)
54 | # - Recurring events (basic support)
55 | # - Server-side recurrence expansion for events (basic cases)
56 | #
57 | # Known limitations/unsupported features:
58 | xandikos_features = FeatureSet({
59 | # Category search with full string matching is not supported
60 | # (e.g., "hands,feet,head" won't match an event with those exact categories)
61 | "search.category.fullstring": "unsupported",
62 |
63 | # Component type filtering is required - searches must specify event=True or todo=True
64 | # (can't search without explicit component type specification)
65 | "search.comp-type-optional": "unsupported",
66 |
67 | # Recurring task queries with status filters fail when querying future recurrences
68 | # (searching for pending recurring tasks doesn't include implicit future occurrences)
69 | "search.recurrences.includes-implicit.todo.pending": "unsupported",
70 |
71 | # Server-side recurrence expansion is buggy for tasks and event exceptions
72 | # (expanded recurring todos and events with exceptions may not be handled correctly)
73 | "search.recurrences.expanded.todo": "unsupported",
74 | "search.recurrences.expanded.exception": "unsupported",
75 | })
76 |
77 | cls.caldav = caldav.DAVClient(
78 | url='http://localhost:5233/',
79 | username=None,
80 | password=None
81 | )
82 | cls.caldav.features = xandikos_features
83 |
84 | def test_check_compatibility(self):
85 | """Run server quirk checker against Xandikos."""
86 | from caldav_server_tester import ServerQuirkChecker
87 |
88 | checker = ServerQuirkChecker(self.caldav, debug_mode="assert")
89 | checker.check_all()
90 |
91 | # Report results
92 | observed = checker.features_checked.dotted_feature_set_list(compact=True)
93 |
94 | print("\n" + "="*60)
95 | print("Xandikos Server Compatibility Report")
96 | print("="*60)
97 | for feature, details in observed.items():
98 | support = details.get('support', 'unknown')
99 | print(f"{feature}: {support}")
100 | if 'behaviour' in details:
101 | print(f" Behaviour: {details['behaviour']}")
102 | print("="*60)
103 |
104 |
105 | if __name__ == '__main__':
106 | unittest.main(verbosity=2)
107 | EOF
108 |
109 | run_xandikos 5233 5234 --defaults
110 |
111 | # Reactivate the virtual environment to run tests
112 | source "${VENV_DIR}/bin/activate"
113 |
114 | # Run the compatibility test
115 | cd "${VENV_DIR}"
116 | python -m unittest test_compatibility "$@"
117 |
--------------------------------------------------------------------------------
/tests/test_insufficient_index_handling.py:
--------------------------------------------------------------------------------
1 | """Tests for InsufficientIndexDataError handling in store filtering."""
2 |
3 | import unittest
4 | from datetime import datetime
5 | from zoneinfo import ZoneInfo
6 |
7 | from xandikos.icalendar import CalendarFilter, ICalendarFile
8 | from xandikos.store import InsufficientIndexDataError
9 |
10 |
11 | class InsufficientIndexHandlingTest(unittest.TestCase):
12 | """Test that InsufficientIndexDataError is properly handled by the filtering system."""
13 |
14 | def setUp(self):
15 | # Create a test calendar with RRULE
16 | self.test_calendar = b"""\
17 | BEGIN:VCALENDAR
18 | VERSION:2.0
19 | PRODID:-//Test//Test//EN
20 | BEGIN:VEVENT
21 | DTSTART:20150527T100000Z
22 | DTEND:20150527T110000Z
23 | RRULE:FREQ=YEARLY;COUNT=3
24 | SUMMARY:Test recurring event
25 | UID:test-rrule@example.com
26 | END:VEVENT
27 | END:VCALENDAR
28 | """
29 | self.cal = ICalendarFile([self.test_calendar], "text/calendar")
30 |
31 | def tzify(dt):
32 | if hasattr(dt, "tzinfo") and dt.tzinfo is None:
33 | return dt.replace(tzinfo=ZoneInfo("UTC"))
34 | return dt
35 |
36 | self.tzify = tzify
37 |
38 | def test_insufficient_index_data_raises_exception(self):
39 | """Test that insufficient index data raises InsufficientIndexDataError."""
40 | filter = CalendarFilter(ZoneInfo("UTC"))
41 | filter.filter_subcomponent("VCALENDAR").filter_subcomponent(
42 | "VEVENT"
43 | ).filter_time_range(
44 | start=self.tzify(datetime(2016, 5, 26, 0, 0, 0)),
45 | end=self.tzify(datetime(2016, 5, 28, 0, 0, 0)),
46 | )
47 |
48 | # Empty indexes should raise exception
49 | empty_indexes = {
50 | "C=VCALENDAR/C=VEVENT/P=DTSTART": [],
51 | "C=VCALENDAR/C=VEVENT/P=DTEND": [],
52 | "C=VCALENDAR/C=VEVENT/P=DURATION": [],
53 | "C=VCALENDAR/C=VEVENT": True,
54 | }
55 |
56 | with self.assertRaises(InsufficientIndexDataError) as cm:
57 | filter.check_from_indexes("file", empty_indexes)
58 |
59 | self.assertIn("No valid index entries found", str(cm.exception))
60 |
61 | def test_missing_component_index_raises_exception(self):
62 | """Test that missing component index raises InsufficientIndexDataError."""
63 | filter = CalendarFilter(ZoneInfo("UTC"))
64 | filter.filter_subcomponent("VCALENDAR").filter_subcomponent("VEVENT")
65 |
66 | # Missing component marker
67 | incomplete_indexes = {
68 | "C=VCALENDAR/C=VEVENT/P=DTSTART": [b"20160527T100000Z"],
69 | # Missing "C=VCALENDAR/C=VEVENT": True
70 | }
71 |
72 | with self.assertRaises(InsufficientIndexDataError) as cm:
73 | filter.check_from_indexes("file", incomplete_indexes)
74 |
75 | self.assertIn("Missing component index", str(cm.exception))
76 |
77 | def test_sufficient_index_data_works_normally(self):
78 | """Test that sufficient index data works without exceptions."""
79 | filter = CalendarFilter(ZoneInfo("UTC"))
80 | filter.filter_subcomponent("VCALENDAR").filter_subcomponent(
81 | "VEVENT"
82 | ).filter_time_range(
83 | start=self.tzify(datetime(2016, 5, 26, 0, 0, 0)),
84 | end=self.tzify(datetime(2016, 5, 28, 0, 0, 0)),
85 | )
86 |
87 | # Complete indexes with RRULE should work normally
88 | complete_indexes = {
89 | "C=VCALENDAR/C=VEVENT/P=DTSTART": [b"20150527T100000Z"],
90 | "C=VCALENDAR/C=VEVENT/P=DTEND": [b"20150527T110000Z"],
91 | "C=VCALENDAR/C=VEVENT/P=RRULE": [b"FREQ=YEARLY;COUNT=3"],
92 | "C=VCALENDAR/C=VEVENT/P=DURATION": [],
93 | "C=VCALENDAR/C=VEVENT": True,
94 | }
95 |
96 | # Should return True (matches 2016 occurrence) without raising exception
97 | result = filter.check_from_indexes("file", complete_indexes)
98 | self.assertTrue(result)
99 |
100 | def test_full_file_check_fallback_works(self):
101 | """Test that full file check works correctly as fallback."""
102 | filter = CalendarFilter(ZoneInfo("UTC"))
103 | filter.filter_subcomponent("VCALENDAR").filter_subcomponent(
104 | "VEVENT"
105 | ).filter_time_range(
106 | start=self.tzify(datetime(2016, 5, 26, 0, 0, 0)),
107 | end=self.tzify(datetime(2016, 5, 28, 0, 0, 0)),
108 | )
109 |
110 | # Full file check should work even when index-based check fails
111 | result = filter.check("file", self.cal)
112 | self.assertTrue(result) # Should match the 2016 occurrence
113 |
114 |
115 | if __name__ == "__main__":
116 | unittest.main()
117 |
--------------------------------------------------------------------------------
/docs/source/installation.rst:
--------------------------------------------------------------------------------
1 | Installation
2 | ============
3 |
4 | This guide covers various methods for installing Xandikos on different platforms.
5 |
6 | System Requirements
7 | -------------------
8 |
9 | - Python 3 (check ``pyproject.toml`` for specific version requirements)
10 | - Optional: A reverse proxy (nginx, Apache) for production deployments
11 |
12 | Installation Methods
13 | --------------------
14 |
15 | Using pip
16 | ~~~~~~~~~
17 |
18 | The simplest way to install Xandikos is using pip:
19 |
20 | .. code-block:: bash
21 |
22 | pip install xandikos
23 |
24 | For development or to get the latest features:
25 |
26 | .. code-block:: bash
27 |
28 | git clone https://github.com/jelmer/xandikos.git
29 | cd xandikos
30 | pip install -e .
31 |
32 | Using Package Managers
33 | ~~~~~~~~~~~~~~~~~~~~~~
34 |
35 | **Debian/Ubuntu**
36 |
37 | Xandikos is available in Debian and Ubuntu repositories:
38 |
39 | .. code-block:: bash
40 |
41 | sudo apt update
42 | sudo apt install xandikos
43 |
44 | To install all optional dependencies:
45 |
46 | .. code-block:: bash
47 |
48 | sudo apt install python3-dulwich python3-defusedxml python3-icalendar python3-jinja2
49 |
50 | **NetBSD**
51 |
52 | Xandikos is available in pkgsrc:
53 |
54 | .. code-block:: bash
55 |
56 | pkgin install py-xandikos
57 |
58 | **Arch Linux (AUR)**
59 |
60 | Xandikos is available in the Arch User Repository:
61 |
62 | .. code-block:: bash
63 |
64 | yay -S xandikos
65 | # or using another AUR helper
66 |
67 | **macOS (using Homebrew)**
68 |
69 | First install Python and pip if not already available:
70 |
71 | .. code-block:: bash
72 |
73 | brew install python
74 | pip install xandikos
75 |
76 | **FreeBSD**
77 |
78 | Xandikos is available in the FreeBSD ports tree:
79 |
80 | .. code-block:: bash
81 |
82 | pkg install py311-xandikos
83 |
84 | Using Docker
85 | ~~~~~~~~~~~~
86 |
87 | Pull and run the official Docker image:
88 |
89 | .. code-block:: bash
90 |
91 | docker pull ghcr.io/jelmer/xandikos:latest
92 | docker run -p 8080:8080 -v /path/to/data:/data ghcr.io/jelmer/xandikos
93 |
94 | For production use with docker-compose:
95 |
96 | .. code-block:: yaml
97 |
98 | version: '3'
99 | services:
100 | xandikos:
101 | image: ghcr.io/jelmer/xandikos:latest
102 | ports:
103 | - "8080:8080"
104 | volumes:
105 | - ./data:/data
106 | environment:
107 | - AUTOCREATE=defaults
108 | - CURRENT_USER_PRINCIPAL=/alice
109 | restart: unless-stopped
110 |
111 | Using Kubernetes
112 | ~~~~~~~~~~~~~~~~
113 |
114 | Deploy using the example Kubernetes configuration:
115 |
116 | .. code-block:: bash
117 |
118 | kubectl apply -f examples/xandikos.k8s.yaml
119 |
120 | From Source
121 | ~~~~~~~~~~~
122 |
123 | To install from source with all dependencies:
124 |
125 | .. code-block:: bash
126 |
127 | git clone https://github.com/jelmer/xandikos.git
128 | cd xandikos
129 | python setup.py install
130 |
131 | Or for development:
132 |
133 | .. code-block:: bash
134 |
135 | git clone https://github.com/jelmer/xandikos.git
136 | cd xandikos
137 | pip install -r requirements.txt
138 | python setup.py develop
139 |
140 | Verifying Installation
141 | ----------------------
142 |
143 | After installation, verify that Xandikos is properly installed:
144 |
145 | .. code-block:: bash
146 |
147 | xandikos --version
148 |
149 | To test the installation with a temporary instance:
150 |
151 | .. code-block:: bash
152 |
153 | xandikos --defaults -d /tmp/test-dav
154 |
155 | Then navigate to http://localhost:8080 in your browser.
156 |
157 | Post-Installation Steps
158 | -----------------------
159 |
160 | 1. **Set up a reverse proxy** for authentication and SSL (see :ref:`reverse-proxy`)
161 | 2. **Configure storage location** for your calendar and contact data
162 | 3. **Set up automatic backups** of your data directory
163 | 4. **Configure systemd** or another init system for automatic startup
164 |
165 | Troubleshooting Installation
166 | ----------------------------
167 |
168 | **Missing Dependencies**
169 |
170 | If you encounter import errors, install the required Python packages.
171 | See the `pyproject.toml` file for a list of required and optional
172 | dependencies.
173 |
174 | **Permission Issues**
175 |
176 | Ensure the user running Xandikos has read/write access to the data directory:
177 |
178 | .. code-block:: bash
179 |
180 | mkdir -p /var/lib/xandikos
181 | chown -R xandikos:xandikos /var/lib/xandikos
182 |
183 | **Port Already in Use**
184 |
185 | If port 8080 is already in use, specify a different port:
186 |
187 | .. code-block:: bash
188 |
189 | xandikos --port 8090 -d /path/to/data
190 |
191 | Next Steps
192 | ----------
193 |
194 | - Configure your CalDAV/CardDAV clients (see :doc:`clients`)
195 | - Set up a reverse proxy for production use (see :ref:`reverse-proxy`)
196 |
--------------------------------------------------------------------------------
/tests/test_apache.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2025 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Tests for xandikos.apache."""
21 |
22 | import unittest
23 | from unittest.mock import Mock
24 | from xml.etree import ElementTree as ET
25 | import asyncio
26 |
27 | from xandikos import apache
28 |
29 |
30 | class ExecutablePropertyTests(unittest.TestCase):
31 | """Tests for ExecutableProperty."""
32 |
33 | def test_property_attributes(self):
34 | """Test ExecutableProperty attributes."""
35 | prop = apache.ExecutableProperty()
36 | self.assertEqual(prop.name, "{http://apache.org/dav/props/}executable")
37 | self.assertIsNone(prop.resource_type)
38 | self.assertFalse(prop.live)
39 |
40 | def test_get_value_true(self):
41 | """Test get_value when resource is executable."""
42 |
43 | async def run_test():
44 | prop = apache.ExecutableProperty()
45 | resource = Mock()
46 | resource.get_is_executable.return_value = True
47 |
48 | el = ET.Element("test")
49 | await prop.get_value("/test.sh", resource, el, {})
50 |
51 | self.assertEqual(el.text, "T")
52 | resource.get_is_executable.assert_called_once()
53 |
54 | asyncio.run(run_test())
55 |
56 | def test_get_value_false(self):
57 | """Test get_value when resource is not executable."""
58 |
59 | async def run_test():
60 | prop = apache.ExecutableProperty()
61 | resource = Mock()
62 | resource.get_is_executable.return_value = False
63 |
64 | el = ET.Element("test")
65 | await prop.get_value("/test.txt", resource, el, {})
66 |
67 | self.assertEqual(el.text, "F")
68 | resource.get_is_executable.assert_called_once()
69 |
70 | asyncio.run(run_test())
71 |
72 | def test_set_value_true(self):
73 | """Test set_value with 'T' (true)."""
74 |
75 | async def run_test():
76 | prop = apache.ExecutableProperty()
77 | resource = Mock()
78 |
79 | el = ET.Element("test")
80 | el.text = "T"
81 | await prop.set_value("/test.sh", resource, el)
82 |
83 | resource.set_is_executable.assert_called_once_with(True)
84 |
85 | asyncio.run(run_test())
86 |
87 | def test_set_value_false(self):
88 | """Test set_value with 'F' (false)."""
89 |
90 | async def run_test():
91 | prop = apache.ExecutableProperty()
92 | resource = Mock()
93 |
94 | el = ET.Element("test")
95 | el.text = "F"
96 | await prop.set_value("/test.txt", resource, el)
97 |
98 | resource.set_is_executable.assert_called_once_with(False)
99 |
100 | asyncio.run(run_test())
101 |
102 | def test_set_value_invalid(self):
103 | """Test set_value with invalid value."""
104 |
105 | async def run_test():
106 | prop = apache.ExecutableProperty()
107 | resource = Mock()
108 |
109 | el = ET.Element("test")
110 | el.text = "X" # Invalid value
111 |
112 | with self.assertRaises(ValueError) as cm:
113 | await prop.set_value("/test", resource, el)
114 |
115 | self.assertIn("invalid executable setting 'X'", str(cm.exception))
116 | resource.set_is_executable.assert_not_called()
117 |
118 | asyncio.run(run_test())
119 |
120 | def test_set_value_empty(self):
121 | """Test set_value with empty/None value."""
122 |
123 | async def run_test():
124 | prop = apache.ExecutableProperty()
125 | resource = Mock()
126 |
127 | el = ET.Element("test")
128 | el.text = None
129 |
130 | with self.assertRaises(ValueError) as cm:
131 | await prop.set_value("/test", resource, el)
132 |
133 | self.assertIn("invalid executable setting None", str(cm.exception))
134 | resource.set_is_executable.assert_not_called()
135 |
136 | asyncio.run(run_test())
137 |
138 |
139 | if __name__ == "__main__":
140 | unittest.main()
141 |
--------------------------------------------------------------------------------
/tests/test_wsgi_helpers.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2025 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Tests for xandikos.wsgi_helpers."""
21 |
22 | import unittest
23 | from unittest.mock import Mock
24 |
25 | from xandikos import wsgi_helpers
26 |
27 |
28 | class WellknownRedirectorTests(unittest.TestCase):
29 | """Tests for WellknownRedirector."""
30 |
31 | def setUp(self):
32 | self.inner_app = Mock()
33 | self.dav_root = "/dav/"
34 | self.redirector = wsgi_helpers.WellknownRedirector(
35 | self.inner_app, self.dav_root
36 | )
37 |
38 | def test_redirect_caldav(self):
39 | """Test redirection of /.well-known/caldav."""
40 | environ = {"SCRIPT_NAME": "", "PATH_INFO": "/.well-known/caldav"}
41 | start_response = Mock()
42 |
43 | result = self.redirector(environ, start_response)
44 |
45 | # Should redirect to dav root
46 | start_response.assert_called_once_with("302 Found", [("Location", "/dav/")])
47 | self.assertEqual(result, [])
48 | # Inner app should not be called
49 | self.inner_app.assert_not_called()
50 |
51 | def test_redirect_carddav(self):
52 | """Test redirection of /.well-known/carddav."""
53 | environ = {"SCRIPT_NAME": "", "PATH_INFO": "/.well-known/carddav"}
54 | start_response = Mock()
55 |
56 | result = self.redirector(environ, start_response)
57 |
58 | # Should redirect to dav root
59 | start_response.assert_called_once_with("302 Found", [("Location", "/dav/")])
60 | self.assertEqual(result, [])
61 | # Inner app should not be called
62 | self.inner_app.assert_not_called()
63 |
64 | def test_redirect_with_script_name(self):
65 | """Test redirection with SCRIPT_NAME set."""
66 | environ = {"SCRIPT_NAME": "/app", "PATH_INFO": "/.well-known/caldav"}
67 | start_response = Mock()
68 |
69 | # The code normalizes the path to include SCRIPT_NAME
70 | # But still checks against the wellknown paths
71 | self.redirector(environ, start_response)
72 |
73 | # Should pass through to inner app since normalized path is /app/.well-known/caldav
74 | self.inner_app.assert_called_once_with(environ, start_response)
75 | start_response.assert_not_called()
76 |
77 | def test_no_redirect_regular_path(self):
78 | """Test that regular paths are not redirected."""
79 | environ = {"SCRIPT_NAME": "", "PATH_INFO": "/calendar/events"}
80 | start_response = Mock()
81 |
82 | # Set up inner app to return something
83 | self.inner_app.return_value = ["response body"]
84 |
85 | result = self.redirector(environ, start_response)
86 |
87 | # Should pass through to inner app
88 | self.inner_app.assert_called_once_with(environ, start_response)
89 | self.assertEqual(result, ["response body"])
90 |
91 | def test_no_redirect_similar_path(self):
92 | """Test that similar but not exact paths are not redirected."""
93 | environ = {"SCRIPT_NAME": "", "PATH_INFO": "/.well-known/caldav/extra"}
94 | start_response = Mock()
95 |
96 | # Set up inner app to return something
97 | self.inner_app.return_value = ["response body"]
98 |
99 | result = self.redirector(environ, start_response)
100 |
101 | # Should pass through to inner app
102 | self.inner_app.assert_called_once_with(environ, start_response)
103 | self.assertEqual(result, ["response body"])
104 |
105 | def test_path_normalization(self):
106 | """Test that paths are normalized before checking."""
107 | environ = {
108 | "SCRIPT_NAME": "",
109 | "PATH_INFO": "/.well-known//caldav", # Double slash
110 | }
111 | start_response = Mock()
112 |
113 | result = self.redirector(environ, start_response)
114 |
115 | # After normalization, path becomes /.well-known/caldav
116 | start_response.assert_called_once_with("302 Found", [("Location", "/dav/")])
117 | self.assertEqual(result, [])
118 |
119 |
120 | if __name__ == "__main__":
121 | unittest.main()
122 |
--------------------------------------------------------------------------------
/xandikos/__main__.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2018 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Xandikos command-line handling."""
21 |
22 | import argparse
23 | import asyncio
24 | import logging
25 | import sys
26 | from . import __version__
27 | from .store import STORE_TYPE_CALENDAR, STORE_TYPE_ADDRESSBOOK
28 |
29 |
30 | # If no subparser is given, default to 'serve'
31 | def set_default_subparser(self, argv, name):
32 | subparser_found = False
33 | for arg in argv:
34 | if arg in ["-h", "--help", "--version"]:
35 | break
36 | else:
37 | for x in self._subparsers._actions:
38 | if not isinstance(x, argparse._SubParsersAction):
39 | continue
40 | for sp_name in x._name_parser_map.keys():
41 | if sp_name in argv:
42 | subparser_found = True
43 | if not subparser_found:
44 | print('No subcommand given, defaulting to "%s"' % name)
45 | argv.insert(0, name)
46 |
47 |
48 | def add_create_collection_parser(parser):
49 | """Add arguments for the create-collection subcommand."""
50 | parser.add_argument(
51 | "-d",
52 | "--directory",
53 | type=str,
54 | required=True,
55 | help="Root directory containing collections",
56 | )
57 | parser.add_argument(
58 | "--type",
59 | choices=["calendar", "addressbook"],
60 | required=True,
61 | help="Type of collection to create",
62 | )
63 | parser.add_argument(
64 | "--name",
65 | type=str,
66 | required=True,
67 | help="Name of the collection (used as path component)",
68 | )
69 | parser.add_argument(
70 | "--displayname", type=str, help="Display name for the collection"
71 | )
72 | parser.add_argument(
73 | "--description", type=str, help="Description for the collection"
74 | )
75 | parser.add_argument(
76 | "--color", type=str, help="Color for the collection (hex format, e.g., #FF0000)"
77 | )
78 |
79 |
80 | async def create_collection_main(args, parser):
81 | """Main function for the create-collection subcommand."""
82 | from .web import XandikosBackend
83 |
84 | logger = logging.getLogger(__name__)
85 |
86 | backend = XandikosBackend(args.directory)
87 | collection_path = args.name
88 | collection_type = (
89 | STORE_TYPE_CALENDAR if args.type == "calendar" else STORE_TYPE_ADDRESSBOOK
90 | )
91 |
92 | try:
93 | resource = backend.create_collection(collection_path)
94 | except FileExistsError:
95 | logger.error(f"Collection '{collection_path}' already exists")
96 | return 1
97 |
98 | resource.store.set_type(collection_type)
99 |
100 | if args.displayname:
101 | resource.store.set_displayname(args.displayname)
102 |
103 | if args.description:
104 | resource.store.set_description(args.description)
105 |
106 | if args.color:
107 | resource.store.set_color(args.color)
108 |
109 | logger.info(f"Successfully created {args.type} collection: {collection_path}")
110 | return 0
111 |
112 |
113 | async def main(argv):
114 | # For now, just invoke xandikos.web
115 | from . import web
116 |
117 | parser = argparse.ArgumentParser()
118 |
119 | parser.add_argument(
120 | "--version",
121 | action="version",
122 | version="%(prog)s " + ".".join(map(str, __version__)),
123 | )
124 |
125 | subparsers = parser.add_subparsers(help="Subcommands", dest="subcommand")
126 | web_parser = subparsers.add_parser(
127 | "serve", usage="%(prog)s -d ROOT-DIR [OPTIONS]", help="Run a Xandikos server"
128 | )
129 | web.add_parser(web_parser)
130 |
131 | create_parser = subparsers.add_parser(
132 | "create-collection", help="Create a calendar or address book collection"
133 | )
134 | add_create_collection_parser(create_parser)
135 |
136 | set_default_subparser(parser, argv, "serve")
137 | args = parser.parse_args(argv)
138 |
139 | if args.subcommand == "serve":
140 | return await web.main(args, parser)
141 | elif args.subcommand == "create-collection":
142 | # Configure logging for create-collection subcommand
143 | logging.basicConfig(level=logging.INFO, format="%(message)s")
144 | return await create_collection_main(args, parser)
145 | else:
146 | parser.print_help()
147 | return 1
148 |
149 |
150 | if __name__ == "__main__":
151 | sys.exit(asyncio.run(main(sys.argv[1:])))
152 |
--------------------------------------------------------------------------------
/xandikos/sync.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Calendar synchronisation.
21 |
22 | See https://tools.ietf.org/html/rfc6578
23 | """
24 |
25 | import itertools
26 | import urllib.parse
27 |
28 | from xandikos import webdav
29 |
30 | ET = webdav.ET
31 |
32 |
33 | FEATURE = "sync-collection"
34 |
35 |
36 | class SyncToken:
37 | """A sync token wrapper."""
38 |
39 | def __init__(self, token) -> None:
40 | self.token = token
41 |
42 | def aselement(self):
43 | ret = ET.Element("{DAV:}sync-token")
44 | ret.text = self.token
45 | return ret
46 |
47 |
48 | class InvalidToken(Exception):
49 | """Requested token is invalid."""
50 |
51 | def __init__(self, token) -> None:
52 | self.token = token
53 |
54 |
55 | class SyncCollectionReporter(webdav.Reporter):
56 | """sync-collection reporter implementation.
57 |
58 | See https://tools.ietf.org/html/rfc6578, section 3.2.
59 | """
60 |
61 | name = "{DAV:}sync-collection"
62 |
63 | @webdav.multistatus # noqa: C901
64 | async def report( # noqa: C901
65 | self,
66 | environ,
67 | request_body,
68 | resources_by_hrefs,
69 | properties,
70 | href,
71 | resource,
72 | depth,
73 | strict,
74 | ):
75 | old_token = None
76 | sync_level = None
77 | limit = None
78 | requested = None
79 | for el in request_body:
80 | if el.tag == "{DAV:}sync-token":
81 | old_token = el.text
82 | elif el.tag == "{DAV:}sync-level":
83 | sync_level = el.text
84 | elif el.tag == "{DAV:}limit":
85 | limit = el.text
86 | elif el.tag == "{DAV:}prop":
87 | requested = list(el)
88 | else:
89 | webdav.nonfatal_bad_request(f"unknown tag {el.tag}", strict)
90 | # TODO(jelmer): Implement sync_level infinite
91 | if sync_level not in ("1",):
92 | raise webdav.BadRequestError(f"sync level {sync_level!r} unsupported")
93 |
94 | new_token = resource.get_sync_token()
95 | try:
96 | try:
97 | diff_iter = resource.iter_differences_since(old_token, new_token)
98 | except NotImplementedError:
99 | yield webdav.Status(
100 | href,
101 | "403 Forbidden",
102 | error=ET.Element("{DAV:}sync-traversal-supported"),
103 | )
104 | return
105 |
106 | if limit is not None:
107 | try:
108 | [nresults_el] = list(limit)
109 | except ValueError:
110 | webdav.nonfatal_bad_request(
111 | "Invalid number of subelements in limit", strict
112 | )
113 | else:
114 | try:
115 | nresults = int(nresults_el.text)
116 | except ValueError:
117 | webdav.nonfatal_bad_request("nresults not a number", strict)
118 | else:
119 | diff_iter = itertools.islice(diff_iter, nresults)
120 |
121 | for name, old_resource, new_resource in diff_iter:
122 | subhref = urllib.parse.urljoin(webdav.ensure_trailing_slash(href), name)
123 | if new_resource is None:
124 | yield webdav.Status(subhref, status="404 Not Found")
125 | else:
126 | propstat = []
127 | for prop in requested:
128 | if old_resource is not None:
129 | old_propstat = await webdav.get_property_from_element(
130 | href, old_resource, properties, environ, prop
131 | )
132 | else:
133 | old_propstat = None
134 | new_propstat = await webdav.get_property_from_element(
135 | href, new_resource, properties, environ, prop
136 | )
137 | if old_propstat != new_propstat:
138 | propstat.append(new_propstat)
139 | yield webdav.Status(subhref, propstat=propstat)
140 | except InvalidToken as exc:
141 | raise webdav.PreconditionFailure(
142 | "{DAV:}valid-sync-token", f"Requested sync token {exc.token} is invalid"
143 | ) from exc
144 | yield SyncToken(new_token)
145 |
146 |
147 | class SyncTokenProperty(webdav.Property):
148 | """sync-token property.
149 |
150 | See https://tools.ietf.org/html/rfc6578, section 4
151 | """
152 |
153 | name = "{DAV:}sync-token"
154 | resource_type = webdav.COLLECTION_RESOURCE_TYPE
155 | in_allprops = False
156 | live = True
157 |
158 | async def get_value(self, href, resource, el, environ):
159 | el.text = resource.get_sync_token()
160 |
--------------------------------------------------------------------------------
/NEWS:
--------------------------------------------------------------------------------
1 | 0.3.0 2025-11-26
2 |
3 | * Add RFC 7953 (Calendar Availability) support. (Jelmer Vernooij, #483)
4 |
5 | * Add WebDAV MOVE and COPY method support for collections and resources.
6 | (Jelmer Vernooij, #458, #460, #466)
7 |
8 | * Add CardDAV indexing support for improved search performance.
9 | (Jelmer Vernooij, #451)
10 |
11 | * Add support for CALDAV:limit-recurrence-set. (Jelmer Vernooij, #450)
12 |
13 | * Add support for VALARM search. (Jelmer Vernooij, #447)
14 |
15 | * Add support for filtering on recurring (rrule) events.
16 | (Jelmer Vernooij, #224)
17 |
18 | * Add caldav-server-tester integration for RFC4791 compliance testing.
19 | (Jelmer Vernooij, #516)
20 |
21 | * Add MemoryStore implementation. (Jelmer Vernooij, #464)
22 |
23 | * Add create-collection subcommand. (Jelmer Vernooij, #454)
24 |
25 | * Add 'serve' subcommand. (Jelmer Vernooij, #361, #362)
26 |
27 | * Show CalDAV URLs and QR barcode on homepage. (Jelmer Vernooij, #448)
28 |
29 | * Store User-Agent header in commit messages. (Jelmer Vernooij, #456)
30 |
31 | * Add Docker healthchecks. (Jelmer Vernooij, #506, #504)
32 |
33 | * Improve Docker configuration with environment variables and graceful shutdown.
34 | (Jelmer Vernooij, #485)
35 |
36 | * Ensure /data directory has correct ownership for named volumes in Docker.
37 | (Jelmer Vernooij)
38 |
39 | * Add per-store LRU cache for parsed files in GitStore for improved performance.
40 | (Jelmer Vernooij, #473)
41 |
42 | * Fix WebDAV litmus test failures and improve compliance. (Jelmer Vernooij, #497)
43 |
44 | * Fix contradictory POST Allow header behavior. (Jelmer Vernooij, #498, #495)
45 |
46 | * Fix high CPU usage from recurring events without bounds. (Jelmer Vernooij, #473)
47 |
48 | * Fix text search to use substring matching per RFC. (Jelmer Vernooij, #468)
49 |
50 | * Fix QR code URLs. (Jelmer Vernooij, #470)
51 |
52 | * Fix rrule filtering bug where expand_calendar_rrule modified the original calendar.
53 | (Jelmer Vernooij)
54 |
55 | * Fix index_keys() return type inconsistency causing paranoid mode assertion error.
56 | (Jelmer Vernooij, #457)
57 |
58 | * Fix handling of %2f in item names. (Jelmer Vernooij, #440)
59 |
60 | * Fix handling of whole-day recurring events. (Jelmer Vernooij, #442)
61 |
62 | * Fix property removal returning 500 error. (Jelmer Vernooij, #441)
63 |
64 | * Fix CardDAV/CalDAV report not returning 404 when no content type is available.
65 | (Jelmer Vernooij, #443)
66 |
67 | * Install dulwich from pip to fix Docker compatibility issue. (Jelmer Vernooij)
68 |
69 | * Disable autogc to prevent requests from timing out. (Jelmer Vernooij, #439)
70 |
71 | * Bump icalendar dependency, prevent upgrading beyond 7.0. (Jelmer Vernooij, #436)
72 |
73 | * Drop Python 3.9 support, add Python 3.14 support. (Jelmer Vernooij, #517)
74 |
75 | * Add documentation for installation, configuration, troubleshooting, and client setup.
76 | (Jelmer Vernooij, #459, #461, #453, #452)
77 |
78 | 0.2.12 2024-10-07
79 |
80 | * Migrate from pytz to zoneinfo (#353, Jelmer Vernooij)
81 |
82 | * Fix compatibility with newer icalendar. (#351, Jelmer Vernooij)
83 |
84 | * Fix docker command. (Artur Neumann)
85 |
86 | * web: Don't assume particular directory layout. (Jelmer Vernooij)
87 |
88 | * git: don't assume default branch is named 'master'.
89 | (Jelmer Vernooij)
90 |
91 | * Add git clone support for WSGI (Daniel Hőxtermann)
92 |
93 | * Document the valid settings for AUTOCREATE in the WSGI app
94 | (Jelmer Vernooij, #342)
95 |
96 | * Disable metrics port by default. (Jelmer Vernooij)
97 |
98 | * docs: Drop mention that Thunderbird doesn't support discovery,
99 | which is no longer true. (Jelmer Vernooij)
100 |
101 | * Update requirements to add vobject dependency (Wilco Baan Hofman)
102 |
103 | 0.2.11 2024-03-29
104 |
105 | * Various build cleanups/fixes. (Jelmer Vernooij)
106 |
107 | * Add multi-arch docker builds. (Maya)
108 |
109 | * do not listen on default address if systemd sockets (schnusch)
110 |
111 | * Use correct port in kubernetes to not conflict with the metrics port (Marcel, #286)
112 |
113 | 0.2.10 2023-09-04
114 |
115 | * Add support for systemd socket activation.
116 | (schnusch, #136, #155)
117 |
118 | * Add basic documentation.
119 | (Jelmer Vernooij)
120 |
121 | * Use entry points to install xandikos script.
122 | (Jelmer Vernooij, #163)
123 |
124 | * ``sync-collection``: handle invalid tokens.
125 | (Jelmer Vernooij)
126 |
127 | 0.2.8 2022-01-09
128 |
129 | 0.2.7 2021-12-27
130 |
131 | * Add basic XMP property support. (Jelmer Vernooij)
132 |
133 | * Add a /health target. (Jelmer Vernooij)
134 |
135 | 0.2.6 2021-03-20
136 |
137 | * Don't listen on TCP port (defautlting to 0.0.0.0) when a UNIX domain socket
138 | is specified. (schnusch, #134)
139 |
140 | 0.2.5 2021-02-18
141 |
142 | * Fix support for uwsgi when environ['wsgi.input'].read() does not
143 | accept a size=None. (Jelmer Vernooij)
144 |
145 | 0.2.4 2021-02-16
146 |
147 | * Wait for entire body to arrive. (Michael Alyn Miller, #129)
148 |
149 | 0.2.3 2020-07-25
150 |
151 | * Fix handling of WSGI - not all versions of start_response take
152 | keyword arguments. (Jelmer Vernooij, #124)
153 |
154 | * Add --no-strict option for clients that don't follow
155 | the spec. (Jelmer Vernooij)
156 |
157 | * Add basic support for expanding RRULE. (Jelmer Vernooij, #8)
158 |
159 | * Add parsing support for CALDAV:schedule-tag property.
160 | (Jelmer Vernooij)
161 |
162 | * Fix support for HTTP Expect. (Jelmer Vernooij, #126)
163 |
164 | 0.2.2 2020-05-14
165 |
166 | * Fix use of xandikos.wsgi module in uwsgi. (Jelmer Vernooij)
167 |
168 | 0.2.1 2020-05-06
169 |
170 | * Add missing dependencies in setup.py. (Jelmer Vernooij)
171 |
172 | * Fix syntax errors in xandikos/store/vdir.py.
173 | (Unused, but breaks bytecompilation). (Jelmer Vernooij)
174 |
175 | 0.2.0 2020-05-04
176 |
177 | * Fix subelement filtering. (Jelmer Vernooij)
178 |
179 | * Skip non-calendar files for calendar-query operations.
180 | (Jelmer Vernooij, #108)
181 |
182 | * Switch to using aiohttp rather than uWSGI.
183 | (Jelmer Vernooij)
184 |
185 | * Query component's SUMMARY in ICalendarFile.describe().
186 | (Denis Laxalde)
187 |
188 | * Add /metrics support. (Jelmer Vernooij)
189 |
190 | * Drop support for Python 3.4, add support for 3.8.
191 | (Jelmer Vernooij)
192 |
193 | 0.1.0 2019-04-07
194 |
195 | Initial release.
196 |
--------------------------------------------------------------------------------
/xandikos/store/config.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2016-2017 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Collection configuration file."""
21 |
22 | import configparser
23 |
24 | CONFIG_FILENAME = ".xandikos" # Legacy config file name
25 | METADATA_DIRECTORY = ".xandikos" # Metadata directory name
26 | CONFIG_FILE = ".xandikos/config" # Config file within metadata directory
27 | AVAILABILITY_FILE = (
28 | ".xandikos/availability.ics" # Availability file within metadata directory
29 | )
30 |
31 |
32 | def is_metadata_file(name):
33 | """Check if a file or directory should be ignored from collection listings.
34 |
35 | This function identifies collection metadata files/directories that should be
36 | hidden from regular collection operations.
37 |
38 | Args:
39 | name: File or directory name to check
40 |
41 | Returns:
42 | True if the name represents a metadata file/directory that should be hidden
43 | """
44 | return (
45 | name == CONFIG_FILENAME
46 | or name == METADATA_DIRECTORY
47 | or name.startswith(METADATA_DIRECTORY + "/")
48 | )
49 |
50 |
51 | class CollectionMetadata:
52 | """Metadata for a configuration."""
53 |
54 | def get_color(self) -> str:
55 | """Get the color for this collection."""
56 | raise NotImplementedError(self.get_color)
57 |
58 | def set_color(self, color: str) -> None:
59 | """Change the color of this collection."""
60 | raise NotImplementedError(self.set_color)
61 |
62 | def get_source_url(self) -> str:
63 | """Get the source URL for this collection."""
64 | raise NotImplementedError(self.get_source_url)
65 |
66 | def set_source_url(self, url: str) -> None:
67 | """Set the source URL for this collection."""
68 | raise NotImplementedError(self.set_source_url)
69 |
70 | def get_comment(self) -> str:
71 | raise NotImplementedError(self.get_comment)
72 |
73 | def get_displayname(self) -> str:
74 | raise NotImplementedError(self.get_displayname)
75 |
76 | def get_description(self) -> str:
77 | raise NotImplementedError(self.get_description)
78 |
79 | def get_order(self) -> str:
80 | raise NotImplementedError(self.get_order)
81 |
82 | def set_order(self, order: str) -> None:
83 | raise NotImplementedError(self.set_order)
84 |
85 |
86 | class FileBasedCollectionMetadata(CollectionMetadata):
87 | """Metadata for a configuration."""
88 |
89 | def __init__(self, cp=None, save=None) -> None:
90 | if cp is None:
91 | cp = configparser.ConfigParser()
92 | self._configparser = cp
93 | self._save_cb = save
94 |
95 | def _save(self, message):
96 | if self._save_cb is None:
97 | return
98 | self._save_cb(self._configparser, message)
99 |
100 | @classmethod
101 | def from_file(cls, f):
102 | cp = configparser.ConfigParser()
103 | cp.read_file(f)
104 | return cls(cp)
105 |
106 | def get_source_url(self):
107 | return self._configparser["DEFAULT"]["source"]
108 |
109 | def set_source_url(self, url):
110 | if url is not None:
111 | self._configparser["DEFAULT"]["source"] = url
112 | else:
113 | del self._configparser["DEFAULT"]["source"]
114 | self._save("Set source URL.")
115 |
116 | def get_color(self):
117 | return self._configparser["DEFAULT"]["color"]
118 |
119 | def get_comment(self):
120 | return self._configparser["DEFAULT"]["comment"]
121 |
122 | def get_displayname(self):
123 | return self._configparser["DEFAULT"]["displayname"]
124 |
125 | def get_description(self):
126 | return self._configparser["DEFAULT"]["description"]
127 |
128 | def set_color(self, color):
129 | if color is not None:
130 | self._configparser["DEFAULT"]["color"] = color
131 | else:
132 | del self._configparser["DEFAULT"]["color"]
133 | self._save("Set color.")
134 |
135 | def set_displayname(self, displayname):
136 | if displayname is not None:
137 | self._configparser["DEFAULT"]["displayname"] = displayname
138 | else:
139 | del self._configparser["DEFAULT"]["displayname"]
140 | self._save("Set display name.")
141 |
142 | def set_description(self, description):
143 | if description is not None:
144 | self._configparser["DEFAULT"]["description"] = description
145 | else:
146 | del self._configparser["DEFAULT"]["description"]
147 | self._save("Set description.")
148 |
149 | def set_comment(self, comment):
150 | if comment is not None:
151 | self._configparser["DEFAULT"]["comment"] = comment
152 | else:
153 | del self._configparser["DEFAULT"]["comment"]
154 | self._save("Set comment.")
155 |
156 | def set_type(self, store_type):
157 | self._configparser["DEFAULT"]["type"] = store_type
158 | self._save("Set collection type.")
159 |
160 | def get_type(self):
161 | return self._configparser["DEFAULT"]["type"]
162 |
163 | def get_order(self):
164 | return self._configparser["calendar"]["order"]
165 |
166 | def set_order(self, order):
167 | try:
168 | self._configparser.add_section("calendar")
169 | except configparser.DuplicateSectionError:
170 | pass
171 | if order is None:
172 | del self._configparser["calendar"]["order"]
173 | else:
174 | self._configparser["calendar"]["order"] = order
175 |
--------------------------------------------------------------------------------
/docs/source/configuration.rst:
--------------------------------------------------------------------------------
1 | Configuration Reference
2 | =======================
3 |
4 | This page provides a comprehensive reference for all Xandikos configuration options.
5 |
6 | Command-Line Options
7 | --------------------
8 |
9 | Basic Options
10 | ~~~~~~~~~~~~~
11 |
12 | ``-d, --directory``
13 | Root directory to serve DAV collections from (required).
14 |
15 | Example: ``--directory /var/lib/xandikos``
16 |
17 | ``-p, --port``
18 | Port to listen on (default: 8080).
19 |
20 | Example: ``--port 8090``
21 |
22 | ``-l, --listen-address``
23 | Address to listen on (default: localhost). Can also be a Unix socket path.
24 |
25 | Example: ``--listen-address 0.0.0.0``
26 | Example: ``--listen-address /var/run/xandikos.sock``
27 |
28 | ``--route-prefix``
29 | Path prefix for the application. Use this when running behind a reverse proxy on a subpath.
30 |
31 | Example: ``--route-prefix /dav``
32 |
33 |
34 | Collection Management
35 | ~~~~~~~~~~~~~~~~~~~~~
36 |
37 | ``--defaults``
38 | Create default calendar and addressbook collections if they don't exist.
39 | Collections created:
40 |
41 | - ``calendars/calendar`` - Default calendar
42 | - ``contacts/addressbook`` - Default addressbook
43 |
44 | ``--autocreate``
45 | Automatically create missing directories when accessed.
46 | Options:
47 |
48 | - ``yes`` - Create all missing directories
49 | - ``no`` - Never create directories (default)
50 |
51 | Example: ``--autocreate yes``
52 |
53 | Authentication and Permissions
54 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
55 |
56 | ``--current-user-principal``
57 | Path to current user principal (default: /user/).
58 |
59 | Example: ``--current-user-principal /alice``
60 |
61 | Debugging Options
62 | ~~~~~~~~~~~~~~~~~
63 |
64 | ``--debug``
65 | Enable debug logging. Shows detailed internal operations.
66 |
67 | ``--dump-dav-xml``
68 | Dump all WebDAV request/response XML to stdout. Useful for debugging client issues.
69 |
70 | ``--no-strict``
71 | Don't be strict about WebDAV compliance. Enable workarounds for broken clients.
72 |
73 | Service Discovery
74 | ~~~~~~~~~~~~~~~~~
75 |
76 | ``--avahi``
77 | Announce services with Avahi/Bonjour for automatic discovery.
78 |
79 | ``--metrics-port``
80 | Port to listen on for metrics endpoint.
81 |
82 | Example: ``--metrics-port 9090``
83 |
84 | System Integration
85 | ~~~~~~~~~~~~~~~~~~
86 |
87 | ``--no-detect-systemd``
88 | Disable systemd socket activation detection.
89 |
90 | Docker Environment Variables
91 | ----------------------------
92 |
93 | When running in Docker, these environment variables are supported:
94 |
95 | ``CURRENT_USER_PRINCIPAL``
96 | Path to current user principal (default: ``/$USER``).
97 |
98 | ``AUTOCREATE``
99 | Whether to autocreate collections. Options: ``defaults``, ``empty``.
100 |
101 | * ``defaults`` - Create default collections
102 | * ``empty`` - Create principal without collections
103 | * ``no`` - Do not create collections (default)
104 |
105 | ``ROUTE_PREFIX``
106 | HTTP path prefix for the application.
107 |
108 | ``XANDIKOS_LISTEN_ADDRESS``
109 | Address to bind to (default: ``localhost``, ``0.0.0.0`` in Docker).
110 |
111 | ``XANDIKOS_PORT``
112 | Port to listen on (default: ``8080``).
113 |
114 | Configuration Examples
115 | ----------------------
116 |
117 | Basic Standalone Server
118 | ~~~~~~~~~~~~~~~~~~~~~~~
119 |
120 | .. code-block:: bash
121 |
122 | xandikos \
123 | --directory /var/lib/xandikos \
124 | --defaults \
125 | --current-user-principal /john
126 |
127 | Behind nginx on Subpath
128 | ~~~~~~~~~~~~~~~~~~~~~~~
129 |
130 | .. code-block:: bash
131 |
132 | xandikos \
133 | --directory /var/lib/xandikos \
134 | --route-prefix /dav \
135 | --listen-address /var/run/xandikos.sock \
136 | --defaults
137 |
138 | Production with Logging
139 | ~~~~~~~~~~~~~~~~~~~~~~~
140 |
141 | .. code-block:: bash
142 |
143 | xandikos \
144 | --directory /var/lib/xandikos \
145 | --listen-address localhost \
146 | --port 8080 \
147 | --debug \
148 | --defaults
149 |
150 | Docker Compose Configuration
151 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
152 |
153 | .. code-block:: yaml
154 |
155 | version: '3'
156 | services:
157 | xandikos:
158 | image: ghcr.io/jelmer/xandikos:latest
159 | environment:
160 | - AUTOCREATE=defaults
161 | - CURRENT_USER_PRINCIPAL=/alice
162 | - ROUTE_PREFIX=/dav
163 | volumes:
164 | - ./data:/data
165 | ports:
166 | - "127.0.0.1:8080:8080"
167 |
168 | Systemd Socket Activation
169 | ~~~~~~~~~~~~~~~~~~~~~~~~~
170 |
171 | Create ``/etc/systemd/system/xandikos.socket``:
172 |
173 | .. code-block:: ini
174 |
175 | [Unit]
176 | Description=Xandikos CalDAV/CardDAV server socket
177 |
178 | [Socket]
179 | ListenStream=/var/run/xandikos.sock
180 |
181 | [Install]
182 | WantedBy=sockets.target
183 |
184 | Create ``/etc/systemd/system/xandikos.service``:
185 |
186 | .. code-block:: ini
187 |
188 | [Unit]
189 | Description=Xandikos CalDAV/CardDAV server
190 | After=network.target
191 |
192 | [Service]
193 | Type=notify
194 | ExecStart=/usr/bin/xandikos \
195 | --directory /var/lib/xandikos \
196 | --listen-address /var/run/xandikos.sock \
197 | --defaults
198 | User=xandikos
199 | Group=xandikos
200 |
201 | [Install]
202 | WantedBy=multi-user.target
203 |
204 | Directory Structure
205 | -------------------
206 |
207 | Xandikos organizes data in the following directory structure:
208 |
209 | .. code-block:: text
210 |
211 | /var/lib/xandikos/ # Root directory (configured with --directory)
212 | ├── calendars/ # Calendar collections
213 | │ ├── calendar/ # Default calendar
214 | │ │ ├── .git/ # Git repository
215 | │ │ └── *.ics # iCalendar files
216 | │ └── tasks/ # Task list
217 | └── contacts/ # Addressbook collections
218 | └── addressbook/ # Default addressbook
219 | ├── .git/ # Git repository
220 | └── *.vcf # vCard files
221 |
222 | File Naming
223 | ~~~~~~~~~~~
224 |
225 | - Calendar events: ``{UID}.ics``
226 | - Contacts: ``{UID}.vcf``
227 | - UIDs are automatically generated if not provided
228 |
229 | Git Storage
230 | ~~~~~~~~~~~
231 |
232 | Each collection is stored as a Git repository, providing:
233 |
234 | - Version history for all changes
235 | - Ability to revert changes
236 | - Efficient storage of modifications
237 | - Built-in backup mechanism
238 |
--------------------------------------------------------------------------------
/tests/test_store_regression.py:
--------------------------------------------------------------------------------
1 | """Test for the time-range filtering regression in Store._iter_with_filter_indexes."""
2 |
3 | import unittest
4 | from datetime import datetime, timezone
5 |
6 | from xandikos.icalendar import (
7 | ICalendarFile,
8 | CalendarFilter,
9 | ComponentFilter,
10 | ComponentTimeRangeMatcher,
11 | )
12 | from xandikos.store.git import BareGitStore
13 |
14 |
15 | # Calendar data matching the CalDAV server checker test
16 | MONTHLY_RECURRING_EVENT = b"""BEGIN:VCALENDAR
17 | VERSION:2.0
18 | PRODID:-//python-caldav//caldav//en_DK
19 | BEGIN:VEVENT
20 | SUMMARY:monthly recurring event
21 | DTSTART:20000112T120000Z
22 | DTEND:20000112T130000Z
23 | DTSTAMP:20250714T170716Z
24 | UID:csc_monthly_recurring_event
25 | RRULE:FREQ=MONTHLY
26 | END:VEVENT
27 | END:VCALENDAR
28 | """
29 |
30 | SIMPLE_EVENT_JAN1 = b"""BEGIN:VCALENDAR
31 | VERSION:2.0
32 | PRODID:-//python-caldav//caldav//en_DK
33 | BEGIN:VEVENT
34 | SUMMARY:simple event with a start time and an end time
35 | DTSTART:20000101T120000Z
36 | DTEND:20000101T130000Z
37 | DTSTAMP:20250714T170715Z
38 | UID:csc_simple_event1
39 | END:VEVENT
40 | END:VCALENDAR
41 | """
42 |
43 | SIMPLE_EVENT_JAN2 = b"""BEGIN:VCALENDAR
44 | VERSION:2.0
45 | PRODID:-//python-caldav//caldav//en_DK
46 | BEGIN:VEVENT
47 | SUMMARY:event with a start time but no end time
48 | DTSTART:20000102T120000Z
49 | DTSTAMP:20250714T170716Z
50 | UID:csc_simple_event2
51 | END:VEVENT
52 | END:VCALENDAR
53 | """
54 |
55 | SIMPLE_EVENT_JAN3 = b"""BEGIN:VCALENDAR
56 | VERSION:2.0
57 | PRODID:-//python-caldav//caldav//en_DK
58 | BEGIN:VEVENT
59 | SUMMARY:event with a start date but no end date
60 | DTSTART;VALUE=DATE:20000103
61 | DTSTAMP:20250714T170716Z
62 | UID:csc_simple_event3
63 | END:VEVENT
64 | END:VCALENDAR
65 | """
66 |
67 |
68 | class TimeRangeFilterRegressionTest(unittest.TestCase):
69 | """Test for the regression where time-range filters returned all files."""
70 |
71 | def setUp(self):
72 | """Set up a test store with calendar files."""
73 | self.store = BareGitStore.create_memory()
74 | self.store.load_extra_file_handler(ICalendarFile)
75 |
76 | # Import test calendar files
77 | self.store.import_one(
78 | "csc_monthly_recurring_event.ics",
79 | "text/calendar",
80 | [MONTHLY_RECURRING_EVENT],
81 | )
82 | self.store.import_one(
83 | "csc_simple_event1.ics", "text/calendar", [SIMPLE_EVENT_JAN1]
84 | )
85 | self.store.import_one(
86 | "csc_simple_event2.ics", "text/calendar", [SIMPLE_EVENT_JAN2]
87 | )
88 | self.store.import_one(
89 | "csc_simple_event3.ics", "text/calendar", [SIMPLE_EVENT_JAN3]
90 | )
91 |
92 | def test_time_range_filter_without_indexes(self):
93 | """Test time-range filtering without indexes (naive path)."""
94 | # Create filter for Feb 12-13, 2000
95 | start = datetime(2000, 2, 12, 0, 0, 0, tzinfo=timezone.utc)
96 | end = datetime(2000, 2, 13, 0, 0, 0, tzinfo=timezone.utc)
97 |
98 | cal_filter = CalendarFilter(timezone.utc)
99 | comp_filter = ComponentFilter("VCALENDAR")
100 | event_filter = ComponentFilter("VEVENT")
101 | event_filter.time_range = ComponentTimeRangeMatcher(start, end, comp="VEVENT")
102 | comp_filter.children.append(event_filter)
103 | cal_filter.children.append(comp_filter)
104 |
105 | # Get matching files
106 | matches = list(self.store.iter_with_filter(cal_filter))
107 |
108 | # Only the monthly recurring event should match (it recurs on the 12th)
109 | self.assertEqual(len(matches), 1)
110 | self.assertEqual(matches[0][0], "csc_monthly_recurring_event.ics")
111 |
112 | def test_time_range_filter_with_indexes(self):
113 | """Test time-range filtering with indexes (the regression case)."""
114 | # Force indexing by setting threshold to 0
115 | self.store.index_manager.indexing_threshold = 0
116 |
117 | # Create filter for Feb 12-13, 2000
118 | start = datetime(2000, 2, 12, 0, 0, 0, tzinfo=timezone.utc)
119 | end = datetime(2000, 2, 13, 0, 0, 0, tzinfo=timezone.utc)
120 |
121 | cal_filter = CalendarFilter(timezone.utc)
122 | comp_filter = ComponentFilter("VCALENDAR")
123 | event_filter = ComponentFilter("VEVENT")
124 | event_filter.time_range = ComponentTimeRangeMatcher(start, end, comp="VEVENT")
125 | comp_filter.children.append(event_filter)
126 | cal_filter.children.append(comp_filter)
127 |
128 | # First, we need to tell the index what keys we need
129 | # We need to reset with ALL the keys the filter might need
130 | all_keys = []
131 | for key_set in cal_filter.index_keys():
132 | all_keys.extend(key_set)
133 | # Remove duplicates
134 | all_keys = list(set(all_keys))
135 | self.store.index.reset(all_keys)
136 |
137 | # Force index creation
138 | for name, content_type, etag in self.store.iter_with_etag():
139 | file = self.store.get_file(name, content_type, etag)
140 | indexes = file.get_indexes(self.store.index.available_keys())
141 | self.store.index.add_values(name, etag, indexes)
142 |
143 | # Get matching files
144 | matches = list(self.store.iter_with_filter(cal_filter))
145 |
146 | # This is the regression test:
147 | # Without the fix, ALL calendar files would be returned because
148 | # check_from_indexes returns True for time-range queries
149 | self.assertEqual(
150 | len(matches),
151 | 1,
152 | "Time-range filter should only return events in the range, "
153 | "not all calendar files",
154 | )
155 | self.assertEqual(matches[0][0], "csc_monthly_recurring_event.ics")
156 |
157 | def test_check_from_indexes_behavior(self):
158 | """Test that check_from_indexes works properly with expanded indexes."""
159 | # Create a filter with a time range
160 | cal_filter = CalendarFilter(timezone.utc)
161 | comp_filter = ComponentFilter("VCALENDAR")
162 | event_filter = ComponentFilter("VEVENT")
163 | event_filter.time_range = ComponentTimeRangeMatcher(
164 | datetime(2000, 2, 12, 0, 0, 0, tzinfo=timezone.utc),
165 | datetime(2000, 2, 13, 0, 0, 0, tzinfo=timezone.utc),
166 | comp="VEVENT",
167 | )
168 | comp_filter.children.append(event_filter)
169 | cal_filter.children.append(comp_filter)
170 |
171 | # With proper expanded indexes, check_from_indexes should be able to determine
172 | # the result accurately for time-range queries
173 | matching_indexes = {
174 | "C=VCALENDAR/C=VEVENT": [True],
175 | "C=VCALENDAR/C=VEVENT/P=DTSTART": [b"20000212T120000Z"], # Matches range
176 | }
177 | self.assertTrue(cal_filter.check_from_indexes("test.ics", matching_indexes))
178 |
179 | non_matching_indexes = {
180 | "C=VCALENDAR/C=VEVENT": [True],
181 | "C=VCALENDAR/C=VEVENT/P=DTSTART": [b"20000101T120000Z"], # Outside range
182 | }
183 | self.assertFalse(
184 | cal_filter.check_from_indexes("test.ics", non_matching_indexes)
185 | )
186 |
187 |
188 | if __name__ == "__main__":
189 | unittest.main()
190 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2018 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Tests for xandikos.store.config."""
21 |
22 | from io import StringIO
23 | from unittest import TestCase
24 |
25 | import dulwich.repo
26 |
27 | from xandikos.store.config import FileBasedCollectionMetadata, is_metadata_file
28 | from xandikos.store.git import RepoCollectionMetadata
29 |
30 |
31 | class FileBasedCollectionMetadataTests(TestCase):
32 | def test_get_color(self):
33 | f = StringIO(
34 | """\
35 | [DEFAULT]
36 | color = #ffffff
37 | """
38 | )
39 | cc = FileBasedCollectionMetadata.from_file(f)
40 | self.assertEqual("#ffffff", cc.get_color())
41 |
42 | def test_get_color_missing(self):
43 | f = StringIO("")
44 | cc = FileBasedCollectionMetadata.from_file(f)
45 | self.assertRaises(KeyError, cc.get_color)
46 |
47 | def test_get_comment(self):
48 | f = StringIO(
49 | """\
50 | [DEFAULT]
51 | comment = this is a comment
52 | """
53 | )
54 | cc = FileBasedCollectionMetadata.from_file(f)
55 | self.assertEqual("this is a comment", cc.get_comment())
56 |
57 | def test_get_comment_missing(self):
58 | f = StringIO("")
59 | cc = FileBasedCollectionMetadata.from_file(f)
60 | self.assertRaises(KeyError, cc.get_comment)
61 |
62 | def test_get_description(self):
63 | f = StringIO(
64 | """\
65 | [DEFAULT]
66 | description = this is a description
67 | """
68 | )
69 | cc = FileBasedCollectionMetadata.from_file(f)
70 | self.assertEqual("this is a description", cc.get_description())
71 |
72 | def test_get_description_missing(self):
73 | f = StringIO("")
74 | cc = FileBasedCollectionMetadata.from_file(f)
75 | self.assertRaises(KeyError, cc.get_description)
76 |
77 | def test_get_displayname(self):
78 | f = StringIO(
79 | """\
80 | [DEFAULT]
81 | displayname = DISPLAY-NAME
82 | """
83 | )
84 | cc = FileBasedCollectionMetadata.from_file(f)
85 | self.assertEqual("DISPLAY-NAME", cc.get_displayname())
86 |
87 | def test_get_displayname_missing(self):
88 | f = StringIO("")
89 | cc = FileBasedCollectionMetadata.from_file(f)
90 | self.assertRaises(KeyError, cc.get_displayname)
91 |
92 |
93 | class MetadataTests:
94 | def test_color(self):
95 | self.assertRaises(KeyError, self._config.get_color)
96 | self._config.set_color("#ffffff")
97 | self.assertEqual("#ffffff", self._config.get_color())
98 | self._config.set_color(None)
99 | self.assertRaises(KeyError, self._config.get_color)
100 |
101 | def test_comment(self):
102 | self.assertRaises(KeyError, self._config.get_comment)
103 | self._config.set_comment("this is a comment")
104 | self.assertEqual("this is a comment", self._config.get_comment())
105 | self._config.set_comment(None)
106 | self.assertRaises(KeyError, self._config.get_comment)
107 |
108 | def test_displayname(self):
109 | self.assertRaises(KeyError, self._config.get_displayname)
110 | self._config.set_displayname("DiSpLaYName")
111 | self.assertEqual("DiSpLaYName", self._config.get_displayname())
112 | self._config.set_displayname(None)
113 | self.assertRaises(KeyError, self._config.get_displayname)
114 |
115 | def test_description(self):
116 | self.assertRaises(KeyError, self._config.get_description)
117 | self._config.set_description("this is a description")
118 | self.assertEqual("this is a description", self._config.get_description())
119 | self._config.set_description(None)
120 | self.assertRaises(KeyError, self._config.get_description)
121 |
122 | def test_order(self):
123 | self.assertRaises(KeyError, self._config.get_order)
124 | self._config.set_order("this is a order")
125 | self.assertEqual("this is a order", self._config.get_order())
126 | self._config.set_order(None)
127 | self.assertRaises(KeyError, self._config.get_order)
128 |
129 |
130 | class FileMetadataTests(TestCase, MetadataTests):
131 | def setUp(self):
132 | super().setUp()
133 | self._config = FileBasedCollectionMetadata()
134 |
135 |
136 | class RepoMetadataTests(TestCase, MetadataTests):
137 | def setUp(self):
138 | super().setUp()
139 | self._repo = dulwich.repo.MemoryRepo()
140 | self._repo._autogc_disabled = True
141 | self._config = RepoCollectionMetadata(self._repo)
142 |
143 |
144 | class IsMetadataFileTests(TestCase):
145 | """Test the is_metadata_file() helper function."""
146 |
147 | def test_old_config_file_detected(self):
148 | """Test that the old .xandikos config file is detected as metadata."""
149 | self.assertTrue(is_metadata_file(".xandikos"))
150 |
151 | def test_new_metadata_directory_detected(self):
152 | """Test that the new .xandikos metadata directory is detected."""
153 | self.assertTrue(is_metadata_file(".xandikos"))
154 |
155 | def test_regular_files_not_detected(self):
156 | """Test that regular files are not detected as metadata files."""
157 | self.assertFalse(is_metadata_file("event.ics"))
158 | self.assertFalse(is_metadata_file("calendar.ics"))
159 | self.assertFalse(is_metadata_file("todo.ics"))
160 | self.assertFalse(is_metadata_file("README.md"))
161 | self.assertFalse(is_metadata_file("config.txt"))
162 |
163 | def test_metadata_subdirectory_files_detected(self):
164 | """Test that files within .xandikos/ metadata directory are detected."""
165 | self.assertTrue(is_metadata_file(".xandikos/config"))
166 | self.assertTrue(is_metadata_file(".xandikos/availability.ics"))
167 | self.assertTrue(is_metadata_file(".xandikos/any-other-file.txt"))
168 |
169 | def test_similar_names_not_detected(self):
170 | """Test that similar but different names are not detected as metadata files."""
171 | self.assertFalse(is_metadata_file("xandikos"))
172 | self.assertFalse(is_metadata_file(".xandikos.bak"))
173 | self.assertFalse(is_metadata_file(".xandikos_backup"))
174 | self.assertFalse(is_metadata_file("my.xandikos"))
175 | self.assertFalse(is_metadata_file(".xandikos.bak/config"))
176 | self.assertFalse(is_metadata_file("something/.xandikos/config"))
177 |
178 | def test_empty_string_not_detected(self):
179 | """Test that empty string is not detected as a metadata file."""
180 | self.assertFalse(is_metadata_file(""))
181 |
182 | def test_case_sensitivity(self):
183 | """Test that the function is case sensitive."""
184 | self.assertFalse(is_metadata_file(".XANDIKOS"))
185 | self.assertFalse(is_metadata_file(".Xandikos"))
186 | self.assertFalse(is_metadata_file(".XandikoS"))
187 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Xandikos is a lightweight yet complete CardDAV/CalDAV server that backs onto a Git repository.
2 |
3 | Xandikos (Ξανδικός or Ξανθικός) takes its name from the name of the March month
4 | in the ancient Macedonian calendar, used in Macedon in the first millennium BC.
5 |
6 | .. image:: logo.png
7 | :alt: Xandikos logo
8 | :width: 200px
9 | :align: center
10 |
11 | Extended documentation can be found `on the home page `_.
12 |
13 | Implemented standards
14 | =====================
15 |
16 | The following standards are implemented:
17 |
18 | - :RFC:`4918`/:RFC:`2518` (Core WebDAV) - *implemented, except for LOCK operations (COPY/MOVE implemented for non-collections)*
19 | - :RFC:`4791` (CalDAV) - *fully implemented*
20 | - :RFC:`6352` (CardDAV) - *fully implemented*
21 | - :RFC:`5397` (Current Principal) - *fully implemented*
22 | - :RFC:`3253` (Versioning Extensions) - *partially implemented, only the REPORT method and {DAV:}expand-property property*
23 | - :RFC:`3744` (Access Control) - *partially implemented*
24 | - :RFC:`5995` (POST to create members) - *fully implemented*
25 | - :RFC:`5689` (Extended MKCOL) - *fully implemented*
26 | - :RFC:`6578` (Collection Synchronization for WebDAV) - *fully implemented*
27 | - :RFC:`7953` (Calendar Availability) - *fully implemented*
28 |
29 | The following standards are not implemented:
30 |
31 | - :RFC:`6638` (CalDAV Scheduling Extensions) - *not implemented*
32 | - :RFC:`7809` (CalDAV Time Zone Extensions) - *not implemented*
33 | - :RFC:`7529` (WebDAV Quota) - *not implemented*
34 | - :RFC:`4709` (WebDAV Mount) - `intentionally `_ *not implemented*
35 | - :RFC:`5546` (iCal iTIP) - *not implemented*
36 | - :RFC:`4324` (iCAL CAP) - *not implemented*
37 |
38 | See `DAV compliance `_ for more detail on specification compliance.
39 |
40 | Limitations
41 | -----------
42 |
43 | - No multi-user support
44 |
45 | Supported clients
46 | =================
47 |
48 | Xandikos has been tested and works with the following CalDAV/CardDAV clients:
49 |
50 | - `Vdirsyncer `_
51 | - `caldavzap `_/`carddavmate `_
52 | - `evolution `_
53 | - `DAVx5 `_ (formerly DAVDroid)
54 | - `sogo connector for Icedove/Thunderbird `_
55 | - aCALdav syncer for Android
56 | - `pycardsyncer `_
57 | - `akonadi `_
58 | - `CalDAV-Sync `_
59 | - `CardDAV-Sync `_
60 | - `Calendarsync `_
61 | - `Tasks `_
62 | - `AgendaV `_
63 | - `CardBook `_
64 | - Apple's iOS
65 | - `homeassistant's CalDAV integration `_
66 | - `pimsync `_
67 | - `davcli `_
68 | - `Thunderbird `_
69 |
70 | Dependencies
71 | ============
72 |
73 | At the moment, Xandikos supports Python 3 (see pyproject.toml for specific version)
74 | as well as Pypy 3. It also uses `Dulwich `_,
75 | `Jinja2 `_,
76 | `icalendar `_, and
77 | `defusedxml `_.
78 |
79 | E.g. to install those dependencies on Debian:
80 |
81 | .. code:: shell
82 |
83 | sudo apt install python3-dulwich python3-defusedxml python3-icalendar python3-jinja2
84 |
85 | Or to install them using pip:
86 |
87 | .. code:: shell
88 |
89 | python setup.py develop
90 |
91 | Container
92 | ---------
93 |
94 | A Containerfile is also provided; see the comments on the top of the file for
95 | configuration instructions. The container image is regularly built and
96 | published at ``ghcr.io/jelmer/xandikos``. For each release,
97 | a ``v$RELEASE`` tag is available - e.g. ``v0.2.11`` for release *0.2.11*.
98 | For a full list, see `the Container overview page
99 | `_.
100 |
101 | The container image can be configured using environment variables:
102 |
103 | - ``PORT`` - Port to listen on (default: 8000)
104 | - ``METRICS_PORT`` - Port for metrics endpoint (default: 8001)
105 | - ``LISTEN_ADDRESS`` - Address to bind to (default: 0.0.0.0)
106 | - ``DATA_DIR`` - Data directory path (default: /data)
107 | - ``CURRENT_USER_PRINCIPAL`` - User principal path (default: /user/)
108 | - ``ROUTE_PREFIX`` - URL route prefix (default: /)
109 | - ``AUTOCREATE`` - Auto-create directories (true/false)
110 | - ``DEFAULTS`` - Create default calendar/addressbook (true/false)
111 | - ``DEBUG`` - Enable debug logging (true/false)
112 | - ``DUMP_DAV_XML`` - Print DAV XML requests/responses (true/false)
113 | - ``NO_STRICT`` - Enable client compatibility workarounds (true/false)
114 |
115 | See ``examples/docker-compose.yml`` and the
116 | `man page `_ for more info.
117 |
118 | Running
119 | =======
120 |
121 | Xandikos can either directly listen on a plain HTTP socket, or it can sit
122 | behind a reverse HTTP proxy.
123 |
124 | Testing
125 | -------
126 |
127 | To run a standalone (no authentication) instance of Xandikos,
128 | with a pre-created calendar and addressbook (storing data in *$HOME/dav*):
129 |
130 | .. code:: shell
131 |
132 | ./bin/xandikos --defaults -d $HOME/dav
133 |
134 | A server should now be listening on `localhost:8080 `_.
135 |
136 | Note that Xandikos does not create any collections unless --defaults is
137 | specified. You can also either create collections from your CalDAV/CardDAV client,
138 | or by creating git repositories under the *contacts* or *calendars* directories
139 | it has created.
140 |
141 | Production
142 | ----------
143 |
144 | The easiest way to run Xandikos in production is by running a reverse HTTP proxy
145 | like Apache or nginx in front of it.
146 | The xandikos script can either listen on the local host on a particular port, or
147 | it can listen on a unix domain socket.
148 |
149 |
150 | For example init system configurations, see examples/.
151 |
152 | Client instructions
153 | ===================
154 |
155 | Some clients can automatically discover the calendars and addressbook URLs from
156 | a DAV server (if they support RFC:`5397`). For such clients you can simply
157 | provide the base URL to Xandikos during setup.
158 |
159 | Clients that lack such automated discovery require the direct URL to a calendar
160 | or addressbook. In this case you should provide the full URL to the calendar or
161 | addressbook; if you initialized Xandikos using the ``--defaults`` argument
162 | mentioned in the previous section, these URLs will look something like this::
163 |
164 | http://dav.example.com/user/calendars/calendar
165 |
166 | http://dav.example.com/user/contacts/addressbook
167 |
168 |
169 | Contributing
170 | ============
171 |
172 | Contributions to Xandikos are very welcome. If you run into bugs or have
173 | feature requests, please file issues `on GitHub
174 | `_. If you're interested in
175 | contributing code or documentation, please read `CONTRIBUTING
176 | `_. Issues that are good for new contributors are tagged
177 | `new-contributor `_
178 | on GitHub.
179 |
180 | Help
181 | ====
182 |
183 | There is a *#xandikos* IRC channel on the `OFTC `_
184 | IRC network, and a `Xandikos `_
185 | mailing list.
186 |
--------------------------------------------------------------------------------
/xandikos/store/memory.py:
--------------------------------------------------------------------------------
1 | # Xandikos
2 | # Copyright (C) 2025 Jelmer Vernooij , et al.
3 | #
4 | # This program is free software; you can redistribute it and/or
5 | # modify it under the terms of the GNU General Public License
6 | # as published by the Free Software Foundation; version 3
7 | # of the License or (at your option) any later version of
8 | # the License.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program; if not, write to the Free Software
17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 | # MA 02110-1301, USA.
19 |
20 | """Memory store implementation."""
21 |
22 | import uuid
23 | from collections.abc import Iterable
24 |
25 | from . import (
26 | MIMETYPES,
27 | DuplicateUidError,
28 | InvalidETag,
29 | NoSuchItem,
30 | Store,
31 | open_by_content_type,
32 | open_by_extension,
33 | )
34 | from .index import MemoryIndex
35 |
36 |
37 | class MemoryStore(Store):
38 | """Pure in-memory store implementation."""
39 |
40 | def __init__(self, *, check_for_duplicate_uids=True):
41 | super().__init__(MemoryIndex())
42 | self._items = {} # name -> (content_type, data, etag)
43 | self._etag_counter = 0
44 | self._check_for_duplicate_uids = check_for_duplicate_uids
45 | # Maps uids to (name, etag)
46 | self._uid_to_name = {}
47 | # Maps names to (etag, uid)
48 | self._name_to_uid = {}
49 | self._source_url = None
50 | self._comment = None
51 | self._color = None
52 |
53 | def _generate_etag(self) -> str:
54 | """Generate a unique etag."""
55 | self._etag_counter += 1
56 | return f"etag-{self._etag_counter:06d}"
57 |
58 | def _get_raw(self, name: str, etag: str | None = None) -> Iterable[bytes]:
59 | """Get raw contents of an item."""
60 | if name not in self._items:
61 | raise KeyError(name)
62 | return self._items[name][1]
63 |
64 | def iter_with_etag(self, ctag: str | None = None):
65 | """Iterate over all items with etag."""
66 | for name, (content_type, data, etag) in self._items.items():
67 | yield (name, content_type, etag)
68 |
69 | def _check_duplicate(self, uid, name, replace_etag):
70 | if uid is not None and self._check_for_duplicate_uids:
71 | try:
72 | (existing_name, _) = self._uid_to_name[uid]
73 | except KeyError:
74 | pass
75 | else:
76 | if existing_name != name:
77 | raise DuplicateUidError(uid, existing_name, name)
78 |
79 | try:
80 | current_etag = self._items[name][2]
81 | except KeyError:
82 | current_etag = None
83 | if replace_etag is not None and current_etag != replace_etag:
84 | raise InvalidETag(name, current_etag, replace_etag)
85 | return current_etag
86 |
87 | def import_one(
88 | self,
89 | name: str,
90 | content_type: str,
91 | data: Iterable[bytes],
92 | message: str | None = None,
93 | author: str | None = None,
94 | replace_etag: str | None = None,
95 | requester: str | None = None,
96 | ) -> tuple[str, str]:
97 | """Import a single item."""
98 | if content_type is None:
99 | fi = open_by_extension(data, name, self.extra_file_handlers)
100 | else:
101 | fi = open_by_content_type(data, content_type, self.extra_file_handlers)
102 | if name is None:
103 | name = str(uuid.uuid4())
104 | extension = MIMETYPES.guess_extension(content_type)
105 | if extension is not None:
106 | name += extension
107 | fi.validate()
108 | try:
109 | uid = fi.get_uid()
110 | except (KeyError, NotImplementedError):
111 | uid = None
112 | self._check_duplicate(uid, name, replace_etag)
113 |
114 | etag = self._generate_etag()
115 | # Store normalized data
116 | normalized_data = list(fi.normalized())
117 | self._items[name] = (content_type, normalized_data, etag)
118 |
119 | # Update UID tracking
120 | if name in self._name_to_uid:
121 | old_uid = self._name_to_uid[name][1]
122 | if old_uid is not None and old_uid in self._uid_to_name:
123 | del self._uid_to_name[old_uid]
124 |
125 | self._name_to_uid[name] = (etag, uid)
126 | if uid is not None:
127 | self._uid_to_name[uid] = (name, etag)
128 |
129 | return (name, etag)
130 |
131 | def delete_one(
132 | self,
133 | name: str,
134 | message: str | None = None,
135 | author: str | None = None,
136 | etag: str | None = None,
137 | ) -> None:
138 | """Delete an item."""
139 | if name not in self._items:
140 | raise NoSuchItem(name)
141 |
142 | if etag is not None:
143 | current_etag = self._items[name][2]
144 | if current_etag != etag:
145 | raise InvalidETag(name, etag, current_etag)
146 |
147 | # Clean up UID tracking
148 | if name in self._name_to_uid:
149 | old_uid = self._name_to_uid[name][1]
150 | if old_uid is not None and old_uid in self._uid_to_name:
151 | del self._uid_to_name[old_uid]
152 | del self._name_to_uid[name]
153 |
154 | del self._items[name]
155 |
156 | def get_ctag(self) -> str:
157 | """Return a ctag representing current state."""
158 | return f"ctag-{len(self._items)}-{self._etag_counter}"
159 |
160 | def set_type(self, store_type: str) -> None:
161 | """Set store type (no-op for memory store)."""
162 | pass
163 |
164 | def set_description(self, description: str) -> None:
165 | """Set description (no-op for memory store)."""
166 | pass
167 |
168 | def get_description(self) -> str:
169 | """Get description."""
170 | return "Memory Store"
171 |
172 | def get_displayname(self) -> str:
173 | """Get display name."""
174 | return "Memory Store"
175 |
176 | def set_displayname(self, displayname: str) -> None:
177 | """Set display name (no-op for memory store)."""
178 | pass
179 |
180 | def get_color(self) -> str:
181 | """Get color."""
182 | if self._color is None:
183 | raise KeyError("Color not set")
184 | return self._color
185 |
186 | def set_color(self, color: str) -> None:
187 | """Set color (no-op for memory store)."""
188 | self._color = color
189 |
190 | def iter_changes(self, old_ctag: str, new_ctag: str):
191 | """Get changes between versions (not implemented for memory store)."""
192 | raise NotImplementedError(self.iter_changes)
193 |
194 | def get_comment(self) -> str:
195 | """Get comment."""
196 | if self._comment is None:
197 | raise KeyError("Comment not set")
198 | return self._comment
199 |
200 | def set_comment(self, comment: str) -> None:
201 | """Set comment (no-op for memory store)."""
202 | self._comment = comment
203 |
204 | def destroy(self) -> None:
205 | """Destroy store."""
206 | self._items.clear()
207 | self._uid_to_name.clear()
208 | self._name_to_uid.clear()
209 |
210 | def subdirectories(self):
211 | """Return subdirectories (empty for memory store)."""
212 | return []
213 |
214 | def get_source_url(self) -> str:
215 | """Get source URL."""
216 | if self._source_url is None:
217 | raise KeyError("Source URL not set")
218 | return self._source_url
219 |
220 | def set_source_url(self, url: str) -> None:
221 | """Set source URL (no-op for memory store)."""
222 | self._source_url = url
223 |
--------------------------------------------------------------------------------
/docs/source/troubleshooting.rst:
--------------------------------------------------------------------------------
1 | Troubleshooting
2 | ===============
3 |
4 | This guide helps diagnose and resolve common issues with Xandikos.
5 |
6 | Support channels
7 | ----------------
8 |
9 | For help, please try the `Xandikos Discussions Forum
10 | `_,
11 | IRC (``#xandikos`` on irc.oftc.net), or Matrix (`#xandikos:matrix.org
12 | `_).
13 |
14 | Common Issues
15 | -------------
16 |
17 | Collections Not Found (404 Errors)
18 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
19 |
20 | **Symptoms**: Clients report 404 errors when trying to access calendars or addressbooks.
21 |
22 | **Solutions**:
23 |
24 | 1. Ensure collections exist:
25 |
26 | .. code-block:: bash
27 |
28 | ls -la /path/to/xandikos/data/calendars/
29 | ls -la /path/to/xandikos/data/contacts/
30 |
31 | 2. Use ``--defaults`` flag to create default collections:
32 |
33 | .. code-block:: bash
34 |
35 | xandikos --defaults -d /path/to/data
36 |
37 | 3. Check route prefix configuration if behind a reverse proxy:
38 |
39 | .. code-block:: bash
40 |
41 | xandikos --route-prefix /dav -d /path/to/data
42 |
43 | Authentication Failures
44 | ~~~~~~~~~~~~~~~~~~~~~~~
45 |
46 | **Symptoms**: Clients repeatedly prompt for credentials or report authentication errors.
47 |
48 | **Solutions**:
49 |
50 | 1. Remember that Xandikos doesn't provide authentication - check your reverse proxy configuration
51 | 2. Verify credentials in your reverse proxy (htpasswd file, LDAP, etc.)
52 | 3. Check that authentication headers are being passed correctly:
53 |
54 | .. code-block:: nginx
55 |
56 | proxy_set_header Authorization $http_authorization;
57 | proxy_pass_header Authorization;
58 |
59 | 4. For Basic Auth issues, ensure the client supports it or try Digest Auth
60 |
61 | Permission Denied Errors
62 | ~~~~~~~~~~~~~~~~~~~~~~~~
63 |
64 | **Symptoms**: "Permission denied" errors in logs or when creating/modifying items.
65 |
66 | **Solutions**:
67 |
68 | 1. Check file ownership:
69 |
70 | .. code-block:: bash
71 |
72 | chown -R xandikos:xandikos /path/to/data
73 |
74 | 2. Verify directory permissions:
75 |
76 | .. code-block:: bash
77 |
78 | chmod -R 750 /path/to/data
79 |
80 | 3. Ensure the Xandikos process user matches file ownership
81 |
82 | Sync Not Working
83 | ~~~~~~~~~~~~~~~~
84 |
85 | **Symptoms**: Changes not syncing between clients or sync errors.
86 |
87 | **Solutions**:
88 |
89 | 1. Enable debug logging to see sync requests:
90 |
91 | .. code-block:: bash
92 |
93 | xandikos --debug --dump-dav-xml -d /path/to/data
94 |
95 | 2. Check for client-specific issues:
96 |
97 | - iOS: Ensure account is properly configured with full URLs
98 | - Android: Try force-refreshing the account
99 | - Evolution: Check collection discovery settings
100 |
101 | 3. Verify WebDAV methods are not blocked by reverse proxy
102 | 4. Check that all required DAV headers are passed through
103 |
104 | "Method Not Allowed" Errors
105 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
106 |
107 | **Symptoms**: 405 Method Not Allowed responses from server.
108 |
109 | **Solutions**:
110 |
111 | 1. Ensure reverse proxy allows all DAV methods:
112 |
113 | .. code-block:: nginx
114 |
115 | location / {
116 | proxy_pass http://localhost:8080;
117 | proxy_method $request_method;
118 |
119 | # Allow all DAV methods
120 | if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE|OPTIONS|PROPFIND|PROPPATCH|MKCOL|COPY|MOVE|LOCK|UNLOCK|REPORT)$ ) {
121 | return 405;
122 | }
123 | }
124 |
125 | 2. Check that your reverse proxy isn't filtering methods
126 |
127 | Large File Upload Failures
128 | ~~~~~~~~~~~~~~~~~~~~~~~~~~
129 |
130 | **Symptoms**: Failures when syncing large calendars or many contacts.
131 |
132 | **Solutions**:
133 |
134 | Xandikos itself does not limit file sizes, but reverse proxies may.
135 |
136 | 1. Configure reverse proxy limits:
137 |
138 | .. code-block:: nginx
139 |
140 | client_max_body_size 50M;
141 | proxy_request_buffering off;
142 |
143 |
144 | Git Repository Corruption
145 | ~~~~~~~~~~~~~~~~~~~~~~~~~
146 |
147 | **Symptoms**: Errors mentioning Git or repository corruption.
148 |
149 | **Solutions**:
150 |
151 | 1. Run Git fsck on affected collection:
152 |
153 | .. code-block:: bash
154 |
155 | cd /path/to/data/calendars/calendar
156 | git fsck
157 |
158 | 2. Try to recover repository:
159 |
160 | .. code-block:: bash
161 |
162 | git gc --aggressive
163 | git prune
164 |
165 | 3. As last resort, clone to new repository:
166 |
167 | .. code-block:: bash
168 |
169 | git clone file:///path/to/data/calendars/calendar /tmp/calendar-backup
170 | mv /path/to/data/calendars/calendar /path/to/data/calendars/calendar.broken
171 | mv /tmp/calendar-backup /path/to/data/calendars/calendar
172 |
173 | Client-Specific Issues
174 | ----------------------
175 |
176 | Evolution
177 | ~~~~~~~~~
178 |
179 | **Issue**: Evolution shows empty calendar list
180 |
181 | **Solution**: Use the "Find Calendars" button instead of manual configuration
182 |
183 | DAVx5
184 | ~~~~~
185 |
186 | **Issue**: DAVx5 reports "Couldn't find CalDAV or CardDAV service"
187 |
188 | **Solution**: Ensure well-known redirects are configured:
189 |
190 | .. code-block:: nginx
191 |
192 | location /.well-known/caldav {
193 | return 301 $scheme://$host/;
194 | }
195 |
196 | location /.well-known/carddav {
197 | return 301 $scheme://$host/;
198 | }
199 |
200 | iOS
201 | ~~~
202 |
203 | **Issue**: iOS account verification fails
204 |
205 | **Solution**:
206 |
207 | 1. Use the server hostname without https://
208 | 2. Ensure SSL certificates are valid and trusted
209 | 3. Try advanced settings with full URLs
210 |
211 | Thunderbird
212 | ~~~~~~~~~~~
213 |
214 | **Issue**: Thunderbird can't find calendars
215 |
216 | **Solution**: Use the full calendar URL instead of autodiscovery:
217 | ``https://dav.example.com/calendars/calendar``
218 |
219 | Debugging Tools
220 | ---------------
221 |
222 | Command-Line Debugging
223 | ~~~~~~~~~~~~~~~~~~~~~~
224 |
225 | Use these flags for detailed debugging:
226 |
227 | .. code-block:: bash
228 |
229 | xandikos \
230 | --debug \
231 | --dump-dav-xml \
232 | --log-level DEBUG \
233 | -d /path/to/data 2>&1 | tee xandikos-debug.log
234 |
235 | Testing with curl
236 | ~~~~~~~~~~~~~~~~~
237 |
238 | Test basic connectivity:
239 |
240 | .. code-block:: bash
241 |
242 | # Test OPTIONS
243 | curl -X OPTIONS https://dav.example.com/ -u username:password
244 |
245 | # Test PROPFIND
246 | curl -X PROPFIND https://dav.example.com/ \
247 | -u username:password \
248 | -H "Depth: 0" \
249 | -H "Content-Type: application/xml" \
250 | -d '
251 |
252 |
253 |
254 |
255 |
256 | '
257 |
258 | Monitoring Logs
259 | ~~~~~~~~~~~~~~~
260 |
261 | Watch logs in real-time:
262 |
263 | .. code-block:: bash
264 |
265 | # Xandikos logs
266 | journalctl -u xandikos -f
267 |
268 | # Reverse proxy logs
269 | tail -f /var/log/nginx/access.log /var/log/nginx/error.log
270 |
271 | Performance Issues
272 | ------------------
273 |
274 | Slow Response Times
275 | ~~~~~~~~~~~~~~~~~~~
276 |
277 | 1. Enable compression:
278 |
279 | .. code-block:: bash
280 |
281 | xandikos --compress -d /path/to/data
282 |
283 | 2. Check Git repository size:
284 |
285 | .. code-block:: bash
286 |
287 | du -sh /path/to/data/*/.git
288 |
289 | 3. Run Git garbage collection:
290 |
291 | .. code-block:: bash
292 |
293 | find /path/to/data -name ".git" -type d -exec git -C {} gc \;
294 |
295 | Getting Help
296 | ------------
297 |
298 | When requesting help, provide:
299 |
300 | 1. Xandikos version: ``xandikos --version``
301 | 2. Client name and version
302 | 3. Relevant error messages from:
303 |
304 | - Xandikos output (with ``--debug``)
305 | - Reverse proxy logs
306 | - Client logs
307 |
308 | 4. Output from ``--dump-dav-xml`` for protocol issues
309 | 5. Minimal steps to reproduce the issue
310 |
--------------------------------------------------------------------------------