├── 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 | DAVx⁵ QR Code 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 | --------------------------------------------------------------------------------