From bc801d30072ed5b479cf52308a5f918a38525e7b Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Sun, 11 Jan 2026 02:28:04 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F(caldav)=20migrate=20from?= =?UTF-8?q?=20davical=20to=20sabre/dav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 26 +- compose.yaml | 27 +- docker/davical/Dockerfile | 39 --- docker/davical/config.php | 64 ---- docker/davical/davical.conf | 22 -- docker/davical/run-migrations | 93 ------ .../init-databases.sh | 6 - docker/postgresql/init-databases.sh | 2 +- docker/sabredav/Dockerfile | 53 +++ docker/sabredav/composer.json | 16 + docker/sabredav/init-database.sh | 87 +++++ docker/sabredav/sabredav.conf | 28 ++ docker/sabredav/server.php | 79 +++++ docker/sabredav/sql/pgsql.addressbooks.sql | 44 +++ docker/sabredav/sql/pgsql.calendars.sql | 108 ++++++ docker/sabredav/sql/pgsql.locks.sql | 19 ++ docker/sabredav/sql/pgsql.principals.sql | 30 ++ docker/sabredav/sql/pgsql.propertystorage.sql | 13 + docker/sabredav/sql/pgsql.users.sql | 14 + .../src/AutoCreatePrincipalBackend.php | 53 +++ docs/architecture.md | 83 ++--- env.d/development/backend.defaults | 4 +- env.d/development/caldav.defaults | 6 + env.d/development/davical.defaults | 13 - src/backend/calendars/settings.py | 6 +- src/backend/core/api/viewsets_caldav.py | 97 ++---- src/backend/core/migrations/0001_initial.py | 4 +- src/backend/core/models.py | 6 +- src/backend/core/services/caldav_service.py | 186 +++-------- src/backend/core/signals.py | 6 +- .../tests/authentication/test_backends.py | 2 +- src/backend/core/tests/conftest.py | 25 +- src/backend/core/tests/test_api_users.py | 5 +- src/backend/core/tests/test_caldav_proxy.py | 307 ++++++++++++++++++ src/backend/core/tests/test_caldav_service.py | 35 +- 35 files changed, 1059 insertions(+), 549 deletions(-) delete mode 100644 docker/davical/Dockerfile delete mode 100644 docker/davical/config.php delete mode 100644 docker/davical/davical.conf delete mode 100755 docker/davical/run-migrations create mode 100644 docker/sabredav/Dockerfile create mode 100644 docker/sabredav/composer.json create mode 100644 docker/sabredav/init-database.sh create mode 100644 docker/sabredav/sabredav.conf create mode 100644 docker/sabredav/server.php create mode 100644 docker/sabredav/sql/pgsql.addressbooks.sql create mode 100644 docker/sabredav/sql/pgsql.calendars.sql create mode 100644 docker/sabredav/sql/pgsql.locks.sql create mode 100644 docker/sabredav/sql/pgsql.principals.sql create mode 100644 docker/sabredav/sql/pgsql.propertystorage.sql create mode 100644 docker/sabredav/sql/pgsql.users.sql create mode 100644 docker/sabredav/src/AutoCreatePrincipalBackend.php create mode 100644 env.d/development/caldav.defaults delete mode 100644 env.d/development/davical.defaults create mode 100644 src/backend/core/tests/test_caldav_proxy.py diff --git a/Makefile b/Makefile index 03cfe21..564be76 100644 --- a/Makefile +++ b/Makefile @@ -76,7 +76,7 @@ create-env-files: \ env.d/development/keycloak.local \ env.d/development/backend.local \ env.d/development/frontend.local \ - env.d/development/davical.local + env.d/development/caldav.local .PHONY: create-env-files env.d/development/%.local: @@ -98,7 +98,7 @@ bootstrap: \ build \ create-docker-network \ migrate \ - migrate-davical \ + migrate-caldav \ back-i18n-compile \ run .PHONY: bootstrap @@ -115,6 +115,11 @@ build-backend: ## build the backend-dev container @$(COMPOSE) build backend-dev $(cache) .PHONY: build-backend +build-caldav: cache ?= +build-caldav: ## build the caldav container + @$(COMPOSE) build caldav $(cache) +.PHONY: build-caldav + build-frontend: cache ?= build-frontend: ## build the frontend container @$(COMPOSE) build frontend-dev $(cache) @@ -172,11 +177,11 @@ run: @$(MAKE) run-backend @$(COMPOSE) up --force-recreate -d frontend-dev -migrate-davical: ## Initialize DAViCal database schema - @echo "$(BOLD)Initializing DAViCal database schema...$(RESET)" - @$(COMPOSE) run --rm davical run-migrations - @echo "$(GREEN)DAViCal initialized$(RESET)" -.PHONY: migrate-davical +migrate-caldav: ## Initialize CalDAV server database schema + @echo "$(BOLD)Initializing CalDAV server database schema...$(RESET)" + @$(COMPOSE) run --rm caldav /usr/local/bin/init-database.sh + @echo "$(GREEN)CalDAV server initialized$(RESET)" +.PHONY: migrate-caldav status: ## an alias for "docker compose ps" @$(COMPOSE) ps @@ -224,7 +229,12 @@ test: ## run project tests @$(MAKE) test-back-parallel .PHONY: test -test-back: ## run back-end tests +test-back: ## run back-end tests (rebuilds and recreates containers) + @echo "$(BOLD)Rebuilding containers (using Docker cache)...$(RESET)" + @$(MAKE) build-caldav + @echo "$(BOLD)Recreating containers...$(RESET)" + @$(COMPOSE) up -d --force-recreate postgresql caldav + @echo "$(BOLD)Running tests...$(RESET)" @args="$(filter-out $@,$(MAKECMDGOALS))" && \ bin/pytest $${args:-${1}} .PHONY: test-back diff --git a/compose.yaml b/compose.yaml index d7f8daa..b89bf89 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,7 @@ name: calendars services: - # Shared PostgreSQL for all services (calendar, davical, keycloak) + # Shared PostgreSQL for all services (calendar, caldav server, keycloak) postgresql: image: postgres:15 ports: @@ -36,7 +36,7 @@ services: environment: - PYLINTHOME=/app/.pylint.d - DJANGO_CONFIGURATION=Development - - DAVICAL_URL=http://davical:80 + - CALDAV_URL=http://caldav:80 env_file: - env.d/development/backend.defaults - env.d/development/backend.local @@ -59,7 +59,7 @@ services: condition: service_started celery-dev: condition: service_started - davical: + caldav: condition: service_started celery-dev: @@ -132,25 +132,26 @@ services: volumes: - ".:/app" - # DAViCal CalDAV Server - davical: - image: fintechstudios/davical:latest - platform: linux/amd64 + # CalDAV Server + caldav: + build: + context: docker/sabredav ports: - "8922:80" env_file: - - env.d/development/davical.defaults - - env.d/development/davical.local - volumes: - # Mount our custom config.php if we need to override defaults - - ./docker/davical/config.php:/etc/davical/config.php:ro - - ./docker/davical/run-migrations:/usr/local/bin/run-migrations:ro + - env.d/development/caldav.defaults + - env.d/development/caldav.local networks: - default - lasuite depends_on: postgresql: condition: service_healthy + command: > + sh -c " + /usr/local/bin/init-database.sh || true && + apache2-foreground + " # Keycloak - now using shared PostgreSQL keycloak: diff --git a/docker/davical/Dockerfile b/docker/davical/Dockerfile deleted file mode 100644 index 11c91a0..0000000 --- a/docker/davical/Dockerfile +++ /dev/null @@ -1,39 +0,0 @@ -# DAViCal CalDAV Server -# Based on Debian with Apache and PHP -FROM debian:bookworm-slim - -ENV DEBIAN_FRONTEND=noninteractive - -# Install dependencies -RUN apt-get update && apt-get install -y \ - apache2 \ - libapache2-mod-php \ - php-pgsql \ - php-xml \ - php-curl \ - php-imap \ - php-ldap \ - davical \ - postgresql-client \ - && rm -rf /var/lib/apt/lists/* - -# Enable required Apache modules -RUN a2enmod rewrite - -# Copy Apache configuration -COPY davical.conf /etc/apache2/sites-available/davical.conf -RUN a2dissite 000-default && a2ensite davical - -# Copy DAViCal configuration -COPY config.php /etc/davical/config.php - -# Copy and setup entrypoint -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -# Set permissions -RUN chown -R www-data:www-data /var/log/apache2 - -EXPOSE 80 - -ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/davical/config.php b/docker/davical/config.php deleted file mode 100644 index a5f646f..0000000 --- a/docker/davical/config.php +++ /dev/null @@ -1,64 +0,0 @@ -pg_connect[] = 'host=' . getenv('PGHOST') . ' port=' . (getenv('PGPORT') ?: '5432') . ' dbname=' . getenv('PGDATABASE') . ' user=' . getenv('PGUSER') . ' password=' . getenv('PGPASSWORD'); - -// System name -$c->system_name = 'Calendars DAViCal Server'; - -// Admin email -$c->admin_email = 'admin@example.com'; - -// Allow public access for CalDAV discovery -$c->public_freebusy_url = true; - -// Default locale -$c->default_locale = 'en_US.UTF-8'; - -// Logging - enable for debugging authentication issues -$c->log_caldav_queries = true; - -// Trust proxy headers for auth -$c->trust_x_forwarded = true; - -// Configure base path when behind reverse proxy -// Override SCRIPT_NAME so DAViCal generates correct URLs -// DAViCal uses $_SERVER['SCRIPT_NAME'] to determine the base path for URLs -// We set it to the proxy path WITHOUT /caldav.php since DAViCal will add that itself -if (isset($_SERVER['HTTP_X_FORWARDED_PREFIX'])) { - $_SERVER['SCRIPT_NAME'] = rtrim($_SERVER['HTTP_X_FORWARDED_PREFIX'], '/'); -} elseif (isset($_SERVER['HTTP_X_SCRIPT_NAME'])) { - $_SERVER['SCRIPT_NAME'] = rtrim($_SERVER['HTTP_X_SCRIPT_NAME'], '/'); -} - -// Custom authentication function to use X-Forwarded-User header -// This function is called by DAViCal's authentication system -function authenticate_via_forwarded_user( $username, $password ) { - // Check if X-Forwarded-User header is present - if (isset($_SERVER['HTTP_X_FORWARDED_USER'])) { - $forwarded_user = trim($_SERVER['HTTP_X_FORWARDED_USER']); - - // If the username from Basic Auth matches X-Forwarded-User, authenticate - // Users with password '*' are externally authenticated - if (strtolower($username) === strtolower($forwarded_user)) { - // Return the username to authenticate as this user - // DAViCal will check if user exists and has password '*' - return $forwarded_user; - } - } - - // Fall back to standard authentication - return false; -} - -// Use custom authentication hook -$c->authenticate_hook = array( - 'call' => 'authenticate_via_forwarded_user', - 'config' => array() -); diff --git a/docker/davical/davical.conf b/docker/davical/davical.conf deleted file mode 100644 index fab6fe8..0000000 --- a/docker/davical/davical.conf +++ /dev/null @@ -1,22 +0,0 @@ - - ServerName localhost - DocumentRoot /usr/share/davical/htdocs - DirectoryIndex index.php - - Alias /images/ /usr/share/davical/htdocs/images/ - - - AllowOverride All - Require all granted - - - AcceptPathInfo On - - # CalDAV principal URL - RewriteEngine On - RewriteRule ^/caldav/(.*)$ /caldav.php/$1 [L] - RewriteRule ^/\.well-known/caldav /caldav.php [R=301,L] - - ErrorLog ${APACHE_LOG_DIR}/davical_error.log - CustomLog ${APACHE_LOG_DIR}/davical_access.log combined - diff --git a/docker/davical/run-migrations b/docker/davical/run-migrations deleted file mode 100755 index 8caef0f..0000000 --- a/docker/davical/run-migrations +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/bash -### -# Run DB migrations necessary to use Davical. -# Will create the database on first-run, and only run necessary migrations on subsequent runs. -# -# Requires the following environment variables in addition to the container variables. -# - ROOT_PGUSER -# - ROOT_PGPASSWORD -# - DAVICAL_ADMIN_PASS -### - -set -e - -if [ -z ${ROOT_PGUSER+x} ]; then - echo "ROOT_PGUSER must be set" - exit 1 -fi -if [ -z ${ROOT_PGPASSWORD+x} ]; then - echo "ROOT_PGPASSWORD must be set" - exit 1 -fi -if [ -z ${DAVICAL_ADMIN_PASS+x} ]; then - echo "DAVICAL_ADMIN_PASS must be set" - exit 1 -fi - -if [ -z ${DBA_PGPASSWORD+x} ]; then - DBA_PGPASSWORD=$PGPASSWORD -fi - -if [ -z ${DAVICAL_SCHEMA+x} ]; then - DAVICAL_SCHEMA=$DBA_PGUSER -fi - -# store PG environment so it can be overridden as-needed -DAVICAL_PGUSER=$PGUSER -DAVICAL_PGPASSWORD=$PGPASSWORD -DAVICAL_PGDATABASE=$PGDATABASE - -run_migrations() { - echo "Running dba/update-davical-database, which should automatically apply any necessary DB migrations." - /usr/share/davical/dba/update-davical-database \ - --dbname $DAVICAL_PGDATABASE \ - --dbuser $DBA_PGUSER \ - --dbhost $PGHOST \ - --dbpass $DBA_PGPASSWORD \ - --appuser $DAVICAL_PGUSER \ - --owner $DBA_PGUSER -} - -export PGUSER=$ROOT_PGUSER -export PGPASSWORD=$ROOT_PGPASSWORD -export PGDATABASE= - -# Wait for PG connection -retries=10 -until pg_isready -q -t 3; do - [[ retries -eq 0 ]] && echo "Could not connect to Postgres" && exit 1 - echo "Waiting for Postgres to be available" - retries=$((retries-1)) - sleep 1 -done - -# Check whether the database has already been setup, with awl tables. -tables=$(psql -d $DAVICAL_PGDATABASE -c "\\dt") -if echo "$tables" | grep -q "awl_db_revision"; then - # The database already exists - just run any outstanding migrations - run_migrations - exit 0 -fi - -echo "Database has not been created - running first-time database setup" - -# the rest of the commands are run as the dba superuser -export PGUSER=$DBA_PGUSER -export PGPASSWORD=$DBA_PGPASSWORD -export PGDATABASE=$DAVICAL_PGDATABASE - -psql -qXAt -f /usr/share/awl/dba/awl-tables.sql -psql -qXAt -f /usr/share/awl/dba/schema-management.sql -psql -qXAt -f /usr/share/davical/dba/davical.sql -run_migrations -psql -qXAt -f /usr/share/davical/dba/base-data.sql - -# DAViCal only uses salted SHA1 at-best, but it's better than storing the password in plaintext! -# see https://wiki.davical.org/index.php?title=Force_Admin_Password -# from https://gitlab.com/davical-project/awl/-/blob/3f044e2dc8435c2eeba61a3c41ec11c820711ab3/inc/DataUpdate.php#L48-58 -salted_password=$(php -r 'require "/usr/share/awl/inc/AWLUtilities.php"; echo session_salted_sha1($argv[1]);' "$DAVICAL_ADMIN_PASS") -psql -qX \ - -v pw="'$salted_password'" \ - <=8.1", + "sabre/dav": "^4.5", + "ext-pdo": "*", + "ext-pdo_pgsql": "*" + }, + "autoload": { + "psr-4": { + "Calendars\\SabreDav\\": "src/" + } + } +} diff --git a/docker/sabredav/init-database.sh b/docker/sabredav/init-database.sh new file mode 100644 index 0000000..f421470 --- /dev/null +++ b/docker/sabredav/init-database.sh @@ -0,0 +1,87 @@ +#!/bin/bash +### +# Initialize sabre/dav database schema in PostgreSQL +# This script creates all necessary tables for sabre/dav to work +### + +set -e + +if [ -z ${PGHOST+x} ]; then + echo "PGHOST must be set" + exit 1 +fi +if [ -z ${PGDATABASE+x} ]; then + echo "PGDATABASE must be set" + exit 1 +fi +if [ -z ${PGUSER+x} ]; then + echo "PGUSER must be set" + exit 1 +fi +if [ -z ${PGPASSWORD+x} ]; then + echo "PGPASSWORD must be set" + exit 1 +fi + +export PGHOST +export PGPORT=${PGPORT:-5432} +export PGDATABASE +export PGUSER +export PGPASSWORD + +# Wait for PostgreSQL to be ready +retries=30 +until pg_isready -q -h "$PGHOST" -p "$PGPORT" -U "$PGUSER"; do + [[ retries -eq 0 ]] && echo "Could not connect to Postgres" && exit 1 + echo "Waiting for Postgres to be available..." + retries=$((retries-1)) + sleep 1 +done + +echo "PostgreSQL is ready. Initializing sabre/dav database schema..." + +# SQL files directory (will be copied into container) +SQL_DIR="/var/www/sabredav/sql" + +# Check if tables already exist +TABLES_EXIST=$(psql -tAc "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('users', 'principals', 'calendars')" 2>/dev/null || echo "0") + +if [ "$TABLES_EXIST" -gt "0" ]; then + echo "sabre/dav tables already exist, skipping initialization" + exit 0 +fi + +# Create tables +echo "Creating sabre/dav tables..." + +if [ -f "$SQL_DIR/pgsql.users.sql" ]; then + psql -f "$SQL_DIR/pgsql.users.sql" + echo "Created users table" +fi + +if [ -f "$SQL_DIR/pgsql.principals.sql" ]; then + psql -f "$SQL_DIR/pgsql.principals.sql" + echo "Created principals table" +fi + +if [ -f "$SQL_DIR/pgsql.calendars.sql" ]; then + psql -f "$SQL_DIR/pgsql.calendars.sql" + echo "Created calendars table" +fi + +if [ -f "$SQL_DIR/pgsql.addressbooks.sql" ]; then + psql -f "$SQL_DIR/pgsql.addressbooks.sql" + echo "Created addressbooks table" +fi + +if [ -f "$SQL_DIR/pgsql.locks.sql" ]; then + psql -f "$SQL_DIR/pgsql.locks.sql" + echo "Created locks table" +fi + +if [ -f "$SQL_DIR/pgsql.propertystorage.sql" ]; then + psql -f "$SQL_DIR/pgsql.propertystorage.sql" + echo "Created propertystorage table" +fi + +echo "sabre/dav database schema initialized successfully!" diff --git a/docker/sabredav/sabredav.conf b/docker/sabredav/sabredav.conf new file mode 100644 index 0000000..deef0d3 --- /dev/null +++ b/docker/sabredav/sabredav.conf @@ -0,0 +1,28 @@ + + ServerName localhost + DocumentRoot /var/www/sabredav + + + AllowOverride All + Require all granted + Options -Indexes +FollowSymLinks + + + # Set REMOTE_USER from X-Forwarded-User header (set by Django proxy) + # This allows sabre/dav to use Apache auth backend + + RequestHeader set REMOTE_USER %{HTTP:X-Forwarded-User}e env=HTTP_X_FORWARDED_USER + + + # Rewrite rules for CalDAV + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^(.*)$ server.php [QSA,L] + + # Well-known CalDAV discovery + RewriteRule ^\.well-known/caldav / [R=301,L] + + ErrorLog ${APACHE_LOG_DIR}/sabredav_error.log + CustomLog ${APACHE_LOG_DIR}/sabredav_access.log combined + diff --git a/docker/sabredav/server.php b/docker/sabredav/server.php new file mode 100644 index 0000000..9065db2 --- /dev/null +++ b/docker/sabredav/server.php @@ -0,0 +1,79 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +// Create backend +$authBackend = new Auth\Backend\Apache(); + +// Create authentication plugin +$authPlugin = new Auth\Plugin($authBackend); + +// Create CalDAV backend +$caldavBackend = new CalDAV\Backend\PDO($pdo); + +// Create CardDAV backend (optional, for future use) +$carddavBackend = new CardDAV\Backend\PDO($pdo); + +// Create principal backend with auto-creation support +$principalBackend = new AutoCreatePrincipalBackend($pdo); + +// Create directory tree +$nodes = [ + new CalDAV\Principal\Collection($principalBackend), + new CalDAV\CalendarRoot($principalBackend, $caldavBackend), + new CardDAV\AddressBookRoot($principalBackend, $carddavBackend), +]; + +// Create server +$server = new DAV\Server($nodes); +$server->setBaseUri($baseUri); + +// Add plugins +$server->addPlugin($authPlugin); +$server->addPlugin(new CalDAV\Plugin()); +$server->addPlugin(new CardDAV\Plugin()); +$server->addPlugin(new DAVACL\Plugin()); +$server->addPlugin(new DAV\Browser\Plugin()); + +// Start server +$server->start(); diff --git a/docker/sabredav/sql/pgsql.addressbooks.sql b/docker/sabredav/sql/pgsql.addressbooks.sql new file mode 100644 index 0000000..98f414f --- /dev/null +++ b/docker/sabredav/sql/pgsql.addressbooks.sql @@ -0,0 +1,44 @@ +CREATE TABLE addressbooks ( + id SERIAL NOT NULL, + principaluri VARCHAR(255), + displayname VARCHAR(255), + uri VARCHAR(200), + description TEXT, + synctoken INTEGER NOT NULL DEFAULT 1 +); + +ALTER TABLE ONLY addressbooks + ADD CONSTRAINT addressbooks_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX addressbooks_ukey + ON addressbooks USING btree (principaluri, uri); + +CREATE TABLE cards ( + id SERIAL NOT NULL, + addressbookid INTEGER NOT NULL, + carddata BYTEA, + uri VARCHAR(200), + lastmodified INTEGER, + etag VARCHAR(32), + size INTEGER NOT NULL +); + +ALTER TABLE ONLY cards + ADD CONSTRAINT cards_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX cards_ukey + ON cards USING btree (addressbookid, uri); + +CREATE TABLE addressbookchanges ( + id SERIAL NOT NULL, + uri VARCHAR(200) NOT NULL, + synctoken INTEGER NOT NULL, + addressbookid INTEGER NOT NULL, + operation SMALLINT NOT NULL +); + +ALTER TABLE ONLY addressbookchanges + ADD CONSTRAINT addressbookchanges_pkey PRIMARY KEY (id); + +CREATE INDEX addressbookchanges_addressbookid_synctoken_ix + ON addressbookchanges USING btree (addressbookid, synctoken); diff --git a/docker/sabredav/sql/pgsql.calendars.sql b/docker/sabredav/sql/pgsql.calendars.sql new file mode 100644 index 0000000..caaaa88 --- /dev/null +++ b/docker/sabredav/sql/pgsql.calendars.sql @@ -0,0 +1,108 @@ +CREATE TABLE calendarobjects ( + id SERIAL NOT NULL, + calendardata BYTEA, + uri VARCHAR(200), + calendarid INTEGER NOT NULL, + lastmodified INTEGER, + etag VARCHAR(32), + size INTEGER NOT NULL, + componenttype VARCHAR(8), + firstoccurence INTEGER, + lastoccurence INTEGER, + uid VARCHAR(200) +); + +ALTER TABLE ONLY calendarobjects + ADD CONSTRAINT calendarobjects_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX calendarobjects_ukey + ON calendarobjects USING btree (calendarid, uri); + + +CREATE TABLE calendars ( + id SERIAL NOT NULL, + synctoken INTEGER NOT NULL DEFAULT 1, + components VARCHAR(21) +); + +ALTER TABLE ONLY calendars + ADD CONSTRAINT calendars_pkey PRIMARY KEY (id); + + +CREATE TABLE calendarinstances ( + id SERIAL NOT NULL, + calendarid INTEGER NOT NULL, + principaluri VARCHAR(100), + access SMALLINT NOT NULL DEFAULT '1', -- '1 = owner, 2 = read, 3 = readwrite' + displayname VARCHAR(100), + uri VARCHAR(200), + description TEXT, + calendarorder INTEGER NOT NULL DEFAULT 0, + calendarcolor VARCHAR(10), + timezone TEXT, + transparent SMALLINT NOT NULL DEFAULT '0', + share_href VARCHAR(100), + share_displayname VARCHAR(100), + share_invitestatus SMALLINT NOT NULL DEFAULT '2' -- '1 = noresponse, 2 = accepted, 3 = declined, 4 = invalid' +); + +ALTER TABLE ONLY calendarinstances + ADD CONSTRAINT calendarinstances_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX calendarinstances_principaluri_uri + ON calendarinstances USING btree (principaluri, uri); + + +CREATE UNIQUE INDEX calendarinstances_principaluri_calendarid + ON calendarinstances USING btree (principaluri, calendarid); + +CREATE UNIQUE INDEX calendarinstances_principaluri_share_href + ON calendarinstances USING btree (principaluri, share_href); + +CREATE TABLE calendarsubscriptions ( + id SERIAL NOT NULL, + uri VARCHAR(200) NOT NULL, + principaluri VARCHAR(100) NOT NULL, + source TEXT, + displayname VARCHAR(100), + refreshrate VARCHAR(10), + calendarorder INTEGER NOT NULL DEFAULT 0, + calendarcolor VARCHAR(10), + striptodos SMALLINT NULL, + stripalarms SMALLINT NULL, + stripattachments SMALLINT NULL, + lastmodified INTEGER +); + +ALTER TABLE ONLY calendarsubscriptions + ADD CONSTRAINT calendarsubscriptions_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX calendarsubscriptions_ukey + ON calendarsubscriptions USING btree (principaluri, uri); + +CREATE TABLE calendarchanges ( + id SERIAL NOT NULL, + uri VARCHAR(200) NOT NULL, + synctoken INTEGER NOT NULL, + calendarid INTEGER NOT NULL, + operation SMALLINT NOT NULL DEFAULT 0 +); + +ALTER TABLE ONLY calendarchanges + ADD CONSTRAINT calendarchanges_pkey PRIMARY KEY (id); + +CREATE INDEX calendarchanges_calendarid_synctoken_ix + ON calendarchanges USING btree (calendarid, synctoken); + +CREATE TABLE schedulingobjects ( + id SERIAL NOT NULL, + principaluri VARCHAR(255), + calendardata BYTEA, + uri VARCHAR(200), + lastmodified INTEGER, + etag VARCHAR(32), + size INTEGER NOT NULL +); + +ALTER TABLE ONLY schedulingobjects + ADD CONSTRAINT schedulingobjects_pkey PRIMARY KEY (id); diff --git a/docker/sabredav/sql/pgsql.locks.sql b/docker/sabredav/sql/pgsql.locks.sql new file mode 100644 index 0000000..0290528 --- /dev/null +++ b/docker/sabredav/sql/pgsql.locks.sql @@ -0,0 +1,19 @@ +CREATE TABLE locks ( + id SERIAL NOT NULL, + owner VARCHAR(100), + timeout INTEGER, + created INTEGER, + token VARCHAR(100), + scope SMALLINT, + depth SMALLINT, + uri TEXT +); + +ALTER TABLE ONLY locks + ADD CONSTRAINT locks_pkey PRIMARY KEY (id); + +CREATE INDEX locks_token_ix + ON locks USING btree (token); + +CREATE INDEX locks_uri_ix + ON locks USING btree (uri); diff --git a/docker/sabredav/sql/pgsql.principals.sql b/docker/sabredav/sql/pgsql.principals.sql new file mode 100644 index 0000000..5a65260 --- /dev/null +++ b/docker/sabredav/sql/pgsql.principals.sql @@ -0,0 +1,30 @@ +CREATE TABLE principals ( + id SERIAL NOT NULL, + uri VARCHAR(200) NOT NULL, + email VARCHAR(80), + displayname VARCHAR(80) +); + +ALTER TABLE ONLY principals + ADD CONSTRAINT principals_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX principals_ukey + ON principals USING btree (uri); + +CREATE TABLE groupmembers ( + id SERIAL NOT NULL, + principal_id INTEGER NOT NULL, + member_id INTEGER NOT NULL +); + +ALTER TABLE ONLY groupmembers + ADD CONSTRAINT groupmembers_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX groupmembers_ukey + ON groupmembers USING btree (principal_id, member_id); + +INSERT INTO principals (uri,email,displayname) VALUES +('principals/admin', 'admin@example.org','Administrator'), +('principals/admin/calendar-proxy-read', null, null), +('principals/admin/calendar-proxy-write', null, null); + diff --git a/docker/sabredav/sql/pgsql.propertystorage.sql b/docker/sabredav/sql/pgsql.propertystorage.sql new file mode 100644 index 0000000..d1463fa --- /dev/null +++ b/docker/sabredav/sql/pgsql.propertystorage.sql @@ -0,0 +1,13 @@ +CREATE TABLE propertystorage ( + id SERIAL NOT NULL, + path VARCHAR(1024) NOT NULL, + name VARCHAR(100) NOT NULL, + valuetype INT, + value BYTEA +); + +ALTER TABLE ONLY propertystorage + ADD CONSTRAINT propertystorage_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX propertystorage_ukey + ON propertystorage (path, name); diff --git a/docker/sabredav/sql/pgsql.users.sql b/docker/sabredav/sql/pgsql.users.sql new file mode 100644 index 0000000..9d6047b --- /dev/null +++ b/docker/sabredav/sql/pgsql.users.sql @@ -0,0 +1,14 @@ +CREATE TABLE users ( + id SERIAL NOT NULL, + username VARCHAR(50), + digesta1 VARCHAR(32) +); + +ALTER TABLE ONLY users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX users_ukey + ON users USING btree (username); + +INSERT INTO users (username,digesta1) VALUES +('admin', '87fd274b7b6c01e48d7c2f965da8ddf7'); diff --git a/docker/sabredav/src/AutoCreatePrincipalBackend.php b/docker/sabredav/src/AutoCreatePrincipalBackend.php new file mode 100644 index 0000000..7c2361c --- /dev/null +++ b/docker/sabredav/src/AutoCreatePrincipalBackend.php @@ -0,0 +1,53 @@ + "user@example.com") + $username = substr($path, strlen('principals/')); + + // Create principal directly in database + // Access protected pdo property from parent + $pdo = $this->pdo; + $tableName = $this->tableName; + + try { + $stmt = $pdo->prepare( + 'INSERT INTO ' . $tableName . ' (uri, email, displayname) VALUES (?, ?, ?) ON CONFLICT (uri) DO NOTHING' + ); + $stmt->execute([$path, $username, $username]); + + // Retry getting the principal + $principal = parent::getPrincipalByPath($path); + } catch (\Exception $e) { + // If creation fails, return null + error_log("Failed to auto-create principal: " . $e->getMessage()); + return null; + } + } + + return $principal; + } +} diff --git a/docs/architecture.md b/docs/architecture.md index bb970ae..4aed7bf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -The Calendar application is a modern, self-hosted calendar solution that combines a Django REST API backend with a separate CalDAV server (DAViCal) for standards-compliant calendar data storage and synchronization. This architecture provides both a modern web interface and full CalDAV protocol support for compatibility with standard calendar clients. +The Calendar application is a modern, self-hosted calendar solution that combines a Django REST API backend with a separate CalDAV server for standards-compliant calendar data storage and synchronization. This architecture provides both a modern web interface and full CalDAV protocol support for compatibility with standard calendar clients. ## System Architecture @@ -34,7 +34,7 @@ The Calendar application is a modern, self-hosted calendar solution that combine │ HTTP/CalDAV Protocol │ ┌────────▼─────────────────────────────────────┐ -│ DAViCal Server │ +│ CalDAV Server │ │ (CalDAV Protocol Implementation) │ │ - Calendar storage │ │ - Event storage (iCalendar format) │ @@ -46,7 +46,7 @@ The Calendar application is a modern, self-hosted calendar solution that combine ┌────────▼─────────────────────────────────────┐ │ PostgreSQL Database │ │ - Django models (users, calendars metadata) │ -│ - DAViCal schema (calendar data) │ +│ - CalDAV server schema (calendar data) │ └──────────────────────────────────────────────┘ ``` @@ -60,7 +60,7 @@ The Django backend serves as the **orchestration layer** and **business logic en - **User Management & Authentication**: OIDC authentication via Keycloak, user profiles, sessions, authorization - **Calendar Metadata Management**: Calendar creation/deletion, sharing, visibility settings, display preferences - **REST API Layer**: Modern RESTful API for the web frontend (JSON, standard HTTP methods, versioned at `/api/v1.0/`) -- **CalDAV Proxy**: Proxies CalDAV requests to DAViCal, handles authentication translation, URL routing, discovery endpoint +- **CalDAV Proxy**: Proxies CalDAV requests to CalDAV server, handles authentication translation, URL routing, discovery endpoint - **Business Logic**: Calendar sharing logic, permission checks, data validation, integration coordination **Data Storage:** @@ -69,11 +69,11 @@ The Django backend serves as the **orchestration layer** and **business logic en - Sharing relationships - Application configuration -**Important**: Django does NOT store actual calendar events. Events are stored in DAViCal. +**Important**: Django does NOT store actual calendar events. Events are stored in the CalDAV server. -### DAViCal CalDAV Server +### CalDAV Server -DAViCal is a **standards-compliant CalDAV server** that handles all calendar data storage and protocol operations. +The CalDAV server is a **standards-compliant CalDAV server** that handles all calendar data storage and protocol operations. **Primary Responsibilities:** - **Calendar Data Storage**: Stores actual calendar events in iCalendar format, manages calendar collections @@ -82,9 +82,9 @@ DAViCal is a **standards-compliant CalDAV server** that handles all calendar dat - **Database Schema**: Uses PostgreSQL with its own schema for calendar data **Authentication Integration:** -- Trusts authentication from Django backend via `X-Forwarded-User` header -- Users with password `*` are externally authenticated -- Custom authentication hook validates forwarded user headers +- Uses Apache authentication backend which reads `REMOTE_USER` environment variable +- Django proxy sets `X-Forwarded-User` header, which the CalDAV server converts to `REMOTE_USER` +- All communication is via HTTP - no direct database access from Django ### Frontend (Next.js) @@ -98,17 +98,19 @@ The frontend provides the user interface and interacts with both REST API and Ca ### Design Decision: CalDAV Server Separation -The decision to use a separate CalDAV server (DAViCal) rather than implementing CalDAV directly in Django was made for several reasons: +The decision to use a separate CalDAV server rather than implementing CalDAV directly in Django was made for several reasons: -1. **Standards Compliance**: DAViCal is a mature, well-tested CalDAV server that fully implements RFC 4791. Implementing CalDAV from scratch would be error-prone and time-consuming. +1. **Standards Compliance**: Using a mature, well-tested CalDAV server that fully implements RFC 4791. Implementing CalDAV from scratch would be error-prone and time-consuming. -2. **Protocol Complexity**: CalDAV is built on WebDAV, involving complex XML handling, property management, and collection hierarchies. DAViCal handles all of this complexity. +2. **Protocol Complexity**: CalDAV is built on WebDAV, involving complex XML handling, property management, and collection hierarchies. A dedicated CalDAV server handles all of this complexity. 3. **Maintenance**: Using a proven, maintained CalDAV server reduces maintenance burden and ensures compatibility with various CalDAV clients. -4. **Focus**: Django backend can focus on business logic, user management, and REST API, while DAViCal handles calendar protocol operations. +4. **Focus**: Django backend can focus on business logic, user management, and REST API, while the CalDAV server handles calendar protocol operations. -5. **Shared database**: DAViCal was specifically selected because it stores its data into Postgres, which use use in all LaSuite projects. +5. **Shared database**: The CalDAV server stores its data into Postgres, which we use in all LaSuite projects. + +6. **Clean separation**: All communication between Django and the CalDAV server is via HTTP, ensuring clean separation of concerns and no direct database access. ### Benefits @@ -119,7 +121,7 @@ The decision to use a separate CalDAV server (DAViCal) rather than implementing 2. **Separation of Concerns** - Django handles business logic and user management - - DAViCal handles calendar protocol and data storage + - CalDAV server handles calendar protocol and data storage - Each component focuses on its core competency 3. **Flexibility** @@ -133,7 +135,7 @@ The decision to use a separate CalDAV server (DAViCal) rather than implementing - Can update components independently 5. **Performance** - - DAViCal is optimized for CalDAV operations + - CalDAV server is optimized for CalDAV operations - Django can focus on application logic - Database can be optimized separately for each use case @@ -144,55 +146,56 @@ The decision to use a separate CalDAV server (DAViCal) rather than implementing TODO: should this only be via caldav too? 1. **Frontend** → POST `/api/v1.0/calendars` (REST API) -2. **Django Backend**: Validates request, creates `Calendar` model, calls DAViCal to create calendar collection -3. **DAViCal**: Receives MKCALENDAR request, creates calendar collection, returns calendar path -4. **Django Backend**: Stores DAViCal path in `Calendar.davical_path`, returns calendar data to frontend +2. **Django Backend**: Validates request, creates `Calendar` model, calls CalDAV server to create calendar collection +3. **CalDAV Server**: Receives MKCALENDAR request, creates calendar collection, returns calendar path +4. **Django Backend**: Stores CalDAV server path in `Calendar.caldav_path`, returns calendar data to frontend ### Creating an Event Events are created directly via CalDAV protocol: 1. **Frontend** → PUT `/api/v1.0/caldav/{user}/{calendar}/{event_uid}.ics` (CalDAV) -2. **Django Backend**: `CalDAVProxyView` authenticates user, forwards request to DAViCal with authentication headers -3. **DAViCal**: Receives PUT request with iCalendar data, stores event in calendar collection +2. **Django Backend**: `CalDAVProxyView` authenticates user, forwards request to CalDAV server with authentication headers +3. **CalDAV Server**: Receives PUT request with iCalendar data, stores event in calendar collection 4. **Django Backend**: Forwards CalDAV response to frontend ### CalDAV Client Access 1. **CalDAV Client** → PROPFIND `/api/v1.0/caldav/` (CalDAV protocol) -2. **Django Backend**: Authenticates user via Django session, forwards request to DAViCal with `X-Forwarded-User` header -3. **DAViCal**: Processes CalDAV request, returns CalDAV response +2. **Django Backend**: Authenticates user via Django session, forwards request to CalDAV server with `X-Forwarded-User` header +3. **CalDAV Server**: Processes CalDAV request, returns CalDAV response 4. **Django Backend**: Forwards response to client ## Integration Points ### User Synchronization -When a user is created in Django, they must also exist in DAViCal. The `ensure_user_exists()` method automatically creates DAViCal users when needed, called before any DAViCal operation. +Users are automatically created in the CalDAV server when they first access it. The CalDAV server's Apache authentication backend reads the `REMOTE_USER` environment variable, which is set from the `X-Forwarded-User` header sent by Django. No explicit user creation is needed - the CalDAV server will create principals on-demand. ### Calendar Creation When creating a calendar via REST API: 1. Django creates `Calendar` model with metadata -2. Django calls DAViCal via HTTP to create calendar collection -3. Django stores DAViCal path in `Calendar.davical_path` +2. Django calls CalDAV server via HTTP to create calendar collection +3. Django stores CalDAV server path in `Calendar.caldav_path` ### Authentication Translation -Django sessions are translated to DAViCal authentication: +Django sessions are translated to CalDAV server authentication: - Django adds `X-Forwarded-User` header with user email -- DAViCal's custom authentication hook validates this header -- Users have password `*` indicating external authentication +- CalDAV server converts `X-Forwarded-User` to `REMOTE_USER` environment variable +- CalDAV server's Apache authentication backend reads `REMOTE_USER` for authentication +- All communication is via HTTP - no direct database access ### URL Routing CalDAV clients expect specific URL patterns. The CalDAV proxy handles path translation: - Discovery endpoint at `.well-known/caldav` redirects to `/api/v1.0/caldav/` -- Proxy forwards requests to DAViCal with correct paths +- Proxy forwards requests to CalDAV server with correct paths ## Database Schema -Both Django and DAViCal use the same PostgreSQL database in a local Docker install, but maintain separate schemas: +Both Django and the CalDAV server use the same PostgreSQL database in a local Docker install, but maintain separate schemas: **Django Schema (public schema):** - `calendars_user` - User accounts @@ -200,11 +203,13 @@ Both Django and DAViCal use the same PostgreSQL database in a local Docker insta - `caldav_calendarshare` - Sharing relationships - Other Django app tables -**DAViCal Schema (public schema, same database):** -- `usr` - DAViCal user records -- `principal` - DAViCal principals -- `collection` - Calendar collections -- `dav_resource` - Calendar resources (events) -- Other DAViCal-specific tables +**CalDAV Server Schema (public schema, same database):** +- `users` - CalDAV server user records (for digest auth, not used with Apache auth) +- `principals` - CalDAV server principals +- `calendars` - Calendar collections +- `calendarinstances` - Calendar instance metadata +- `calendarobjects` - Calendar resources (events) +- `calendarchanges` - Change tracking +- Other CalDAV server-specific tables -This allows them to share the database locally while keeping data organized. +This allows them to share the database locally while keeping data organized. Note that Django never directly accesses CalDAV server tables - all communication is via HTTP. diff --git a/env.d/development/backend.defaults b/env.d/development/backend.defaults index 03b0aab..2a5fd1f 100644 --- a/env.d/development/backend.defaults +++ b/env.d/development/backend.defaults @@ -46,8 +46,8 @@ OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly OIDC_RS_AUDIENCE_CLAIM="client_id" OIDC_RS_ALLOWED_AUDIENCES="" -# DAViCal CalDAV Server -DAVICAL_URL=http://davical:80 +# CalDAV Server +CALDAV_URL=http://caldav:80 # Frontend FRONTEND_THEME=default diff --git a/env.d/development/caldav.defaults b/env.d/development/caldav.defaults new file mode 100644 index 0000000..c3fab37 --- /dev/null +++ b/env.d/development/caldav.defaults @@ -0,0 +1,6 @@ +PGHOST=postgresql +PGPORT=5432 +PGDATABASE=calendars +PGUSER=pgroot +PGPASSWORD=pass +CALENDARS_BASE_URI=/api/v1.0/caldav/ \ No newline at end of file diff --git a/env.d/development/davical.defaults b/env.d/development/davical.defaults deleted file mode 100644 index 969e929..0000000 --- a/env.d/development/davical.defaults +++ /dev/null @@ -1,13 +0,0 @@ -PGHOST=postgresql -PGPORT=5432 -PGDATABASE=calendars -PGUSER=pgroot -PGPASSWORD=pass -ROOT_PGUSER=pgroot -ROOT_PGPASSWORD=pass -DBA_PGUSER=pgroot -DBA_PGPASSWORD=pass -DAVICAL_ADMIN_PASS=admin -HOST_NAME=localhost -ADMIN_EMAIL=admin@example.com -TZ=UTC \ No newline at end of file diff --git a/src/backend/calendars/settings.py b/src/backend/calendars/settings.py index a489afe..aca0a0c 100755 --- a/src/backend/calendars/settings.py +++ b/src/backend/calendars/settings.py @@ -67,9 +67,9 @@ class Base(Configuration): API_VERSION = "v1.0" - # DAViCal CalDAV server URL - DAVICAL_URL = values.Value( - "http://davical:80", environ_name="DAVICAL_URL", environ_prefix=None + # CalDAV server URL + CALDAV_URL = values.Value( + "http://caldav:80", environ_name="CALDAV_URL", environ_prefix=None ) # Security diff --git a/src/backend/core/api/viewsets_caldav.py b/src/backend/core/api/viewsets_caldav.py index 710b4df..26eb358 100644 --- a/src/backend/core/api/viewsets_caldav.py +++ b/src/backend/core/api/viewsets_caldav.py @@ -1,4 +1,4 @@ -"""CalDAV proxy views for forwarding requests to DAViCal.""" +"""CalDAV proxy views for forwarding requests to CalDAV server.""" import logging @@ -10,15 +10,13 @@ from django.views.decorators.csrf import csrf_exempt import requests -from core.services.caldav_service import DAViCalClient - logger = logging.getLogger(__name__) @method_decorator(csrf_exempt, name="dispatch") class CalDAVProxyView(View): """ - Proxy view that forwards all CalDAV requests to DAViCal. + Proxy view that forwards all CalDAV requests to CalDAV server. Handles authentication and adds appropriate headers. CSRF protection is disabled because CalDAV uses non-standard HTTP methods @@ -27,7 +25,7 @@ class CalDAVProxyView(View): """ def dispatch(self, request, *args, **kwargs): - """Forward all HTTP methods to DAViCal.""" + """Forward all HTTP methods to CalDAV server.""" # Handle CORS preflight requests if request.method == "OPTIONS": response = HttpResponse(status=200) @@ -42,83 +40,40 @@ class CalDAVProxyView(View): if not request.user.is_authenticated: return HttpResponse(status=401) - # Ensure user exists in DAViCal before making requests - try: - davical_client = DAViCalClient() - davical_client.ensure_user_exists(request.user) - except Exception as e: - logger.warning("Failed to ensure user exists in DAViCal: %s", str(e)) - # Continue anyway - user might already exist - - # Build the DAViCal URL - davical_url = getattr(settings, "DAVICAL_URL", "http://davical:80") + # Build the CalDAV server URL + caldav_url = settings.CALDAV_URL path = kwargs.get("path", "") - # Use user email as the principal (DAViCal uses email as username) + # Use user email as the principal (CalDAV server uses email as username) user_principal = request.user.email - # Handle root CalDAV requests - return principal collection - if not path or path == user_principal: - # For PROPFIND on root, return the user's principal collection - if request.method == "PROPFIND": - # Get the request path to match the href in response - request_path = request.path - if not request_path.endswith("/"): - request_path += "/" + # Build target URL - CalDAV server uses base URI /api/v1.0/caldav/ + # The proxy receives requests at /api/v1.0/caldav/... and forwards them + # to the CalDAV server at the same path (sabre/dav expects requests at its base URI) + base_uri_path = "/api/v1.0/caldav" + clean_path = path.lstrip("/") if path else "" - # Return multistatus with href matching request URL and calendar-home-set - multistatus = f""" - - - {request_path} - - - {user_principal} - - /api/v1.0/caldav/{user_principal}/ - - - HTTP/1.1 200 OK - - -""" - response = HttpResponse( - content=multistatus, - status=207, - content_type="application/xml; charset=utf-8", - ) - return response - - # For other methods, redirect to principal URL - target_url = f"{davical_url}/caldav.php/{user_principal}/" + # Construct target URL - always include the base URI path + if clean_path: + target_url = f"{caldav_url}{base_uri_path}/{clean_path}" else: - # Build target URL with path - # Remove leading slash if present - clean_path = path.lstrip("/") - if clean_path.startswith(user_principal): - # Path already includes principal - target_url = f"{davical_url}/caldav.php/{clean_path}" - else: - # Path is relative to principal - target_url = f"{davical_url}/caldav.php/{user_principal}/{clean_path}" + # Root request - use base URI path + target_url = f"{caldav_url}{base_uri_path}/" - # Prepare headers for DAViCal - # Set headers to tell DAViCal it's behind a proxy so it generates correct URLs - script_name = "/api/v1.0/caldav" + # Prepare headers for CalDAV server + # CalDAV server Apache backend reads REMOTE_USER, which we set via X-Forwarded-User headers = { "Content-Type": request.content_type or "application/xml", "X-Forwarded-User": user_principal, "X-Forwarded-For": request.META.get("REMOTE_ADDR", ""), - "X-Forwarded-Prefix": script_name, "X-Forwarded-Host": request.get_host(), "X-Forwarded-Proto": request.scheme, - "X-Script-Name": script_name, # Tell DAViCal the base path } - # DAViCal authentication: users with password '*' use external auth + # CalDAV server authentication: Apache backend reads REMOTE_USER # We send the username via X-Forwarded-User header # For HTTP Basic Auth, we use the email as username with empty password - # This works with DAViCal's external authentication when trust_x_forwarded is true + # CalDAV server converts X-Forwarded-User to REMOTE_USER auth = (user_principal, "") # Copy relevant headers from the original request @@ -135,11 +90,11 @@ class CalDAVProxyView(View): body = request.body if request.body else None try: - # Forward the request to DAViCal + # Forward the request to CalDAV server # Use HTTP Basic Auth with username (email) and empty password - # DAViCal will authenticate based on X-Forwarded-User header when trust_x_forwarded is true + # CalDAV server will authenticate based on X-Forwarded-User header (converted to REMOTE_USER) logger.debug( - "Forwarding %s request to DAViCal: %s (user: %s)", + "Forwarding %s request to CalDAV server: %s (user: %s)", request.method, target_url, user_principal, @@ -157,7 +112,7 @@ class CalDAVProxyView(View): # Log authentication failures for debugging if response.status_code == 401: logger.warning( - "DAViCal returned 401 for user %s at %s. Headers sent: %s", + "CalDAV server returned 401 for user %s at %s. Headers sent: %s", user_principal, target_url, headers, @@ -170,7 +125,7 @@ class CalDAVProxyView(View): content_type=response.headers.get("Content-Type", "application/xml"), ) - # Copy relevant headers from DAViCal response + # Copy relevant headers from CalDAV server response for header in ["ETag", "DAV", "Allow", "Location"]: if header in response.headers: django_response[header] = response.headers[header] @@ -178,7 +133,7 @@ class CalDAVProxyView(View): return django_response except requests.exceptions.RequestException as e: - logger.error("DAViCal proxy error: %s", str(e)) + logger.error("CalDAV server proxy error: %s", str(e)) return HttpResponse( content=f"CalDAV server error: {str(e)}", status=502, diff --git a/src/backend/core/migrations/0001_initial.py b/src/backend/core/migrations/0001_initial.py index f22e6ea..9315338 100644 --- a/src/backend/core/migrations/0001_initial.py +++ b/src/backend/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.9 on 2026-01-08 23:49 +# Generated by Django 5.2.9 on 2026-01-11 00:45 import core.models import django.core.validators @@ -59,7 +59,7 @@ class Migration(migrations.Migration): ('description', models.TextField(blank=True, default='')), ('is_default', models.BooleanField(default=False)), ('is_visible', models.BooleanField(default=True)), - ('davical_path', models.CharField(max_length=512, unique=True)), + ('caldav_path', models.CharField(max_length=512, unique=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calendars', to=settings.AUTH_USER_MODEL)), diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 3fc9df1..dd50c1b 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -324,7 +324,7 @@ class BaseAccess(BaseModel): class Calendar(models.Model): """ Represents a calendar owned by a user. - This model tracks calendars stored in DAViCal and links them to Django users. + This model tracks calendars stored in the CalDAV server and links them to Django users. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -339,8 +339,8 @@ class Calendar(models.Model): is_default = models.BooleanField(default=False) is_visible = models.BooleanField(default=True) - # DAViCal reference - the calendar path in DAViCal - davical_path = models.CharField(max_length=512, unique=True) + # CalDAV server reference - the calendar path in the CalDAV server + caldav_path = models.CharField(max_length=512, unique=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/src/backend/core/services/caldav_service.py b/src/backend/core/services/caldav_service.py index 405be4b..c6589ff 100644 --- a/src/backend/core/services/caldav_service.py +++ b/src/backend/core/services/caldav_service.py @@ -1,4 +1,4 @@ -"""Services for CalDAV integration with DAViCal.""" +"""Services for CalDAV integration.""" import logging from datetime import date, datetime, timedelta @@ -8,8 +8,6 @@ from uuid import uuid4 from django.conf import settings from django.utils import timezone -import psycopg - from caldav import DAVClient from caldav.lib.error import NotFoundError from core.models import Calendar @@ -17,131 +15,47 @@ from core.models import Calendar logger = logging.getLogger(__name__) -class DAViCalClient: +class CalDAVClient: """ - Client for communicating with DAViCal CalDAV server using the caldav library. + Client for communicating with CalDAV server using the caldav library. """ def __init__(self): - self.base_url = getattr(settings, "DAVICAL_URL", "http://davical:80") + self.base_url = settings.CALDAV_URL + # Set the base URI path as expected by the CalDAV server + self.base_uri_path = "/api/v1.0/caldav/" self.timeout = 30 def _get_client(self, user) -> DAVClient: """ Get a CalDAV client for the given user. - DAViCal uses X-Forwarded-User header for authentication. The caldav - library requires username/password for Basic Auth, but DAViCal users have - password '*' (external auth). We pass the X-Forwarded-User header directly - to the DAVClient constructor. + The CalDAV server uses Apache authentication backend which reads REMOTE_USER. + We pass the X-Forwarded-User header which the server converts to REMOTE_USER. + The caldav library requires username/password for Basic Auth, but we use + empty password since authentication is handled via headers. """ - # DAViCal base URL - the caldav library will discover the principal - caldav_url = f"{self.base_url}/caldav.php/" + # CalDAV server base URL - include the base URI path that sabre/dav expects + # Remove trailing slash from base_url and base_uri_path to avoid double slashes + base_url_clean = self.base_url.rstrip("/") + base_uri_clean = self.base_uri_path.rstrip("/") + caldav_url = f"{base_url_clean}{base_uri_clean}/" return DAVClient( url=caldav_url, username=user.email, - password="", # Empty password - DAViCal uses X-Forwarded-User header + password="", # Empty password - server uses X-Forwarded-User header timeout=self.timeout, headers={ "X-Forwarded-User": user.email, }, ) - def ensure_user_exists(self, user) -> None: - """ - Ensure the user exists in DAViCal's database. - Creates the user if they don't exist. - """ - # Connect to shared calendars database (public schema) - default_db = settings.DATABASES["default"] - db_name = default_db.get("NAME", "calendars") - - # Get password - handle SecretValue objects - password = default_db.get("PASSWORD", "pass") - if hasattr(password, "value"): - password = password.value - - # Connect to calendars database - conn = psycopg.connect( - host=default_db.get("HOST", "localhost"), - port=default_db.get("PORT", 5432), - dbname=db_name, - user=default_db.get("USER", "pgroot"), - password=password, - ) - - try: - with conn.cursor() as cursor: - # Check if user exists (in public schema) - cursor.execute( - "SELECT user_no FROM usr WHERE lower(username) = lower(%s)", - [user.email], - ) - if cursor.fetchone(): - # User already exists - return - - # Create user in DAViCal (public schema) - # Use email as username, password '*' means external auth - # Get user's full name or use email prefix - fullname = ( - getattr(user, "full_name", None) - or getattr(user, "get_full_name", lambda: None)() - or user.email.split("@")[0] - ) - - cursor.execute( - """ - INSERT INTO usr (username, email, fullname, active, password) - VALUES (%s, %s, %s, true, '*') - ON CONFLICT (lower(username)) DO NOTHING - RETURNING user_no - """, - [user.email, user.email, fullname], - ) - result = cursor.fetchone() - if result: - user_no = result[0] - logger.info( - "Created DAViCal user: %s (user_no: %s)", user.email, user_no - ) - - # Also create a principal record for the user (public schema) - # DAViCal needs both usr and principal records - # Principal type 1 is for users - type_id = 1 - - cursor.execute( - """ - INSERT INTO principal (type_id, user_no, displayname) - SELECT %s, %s, %s - WHERE NOT EXISTS (SELECT 1 FROM principal WHERE user_no = %s) - RETURNING principal_id - """, - [type_id, user_no, fullname, user_no], - ) - principal_result = cursor.fetchone() - if principal_result: - logger.info( - "Created DAViCal principal: %s (principal_id: %s)", - user.email, - principal_result[0], - ) - else: - logger.warning("User %s already exists in DAViCal", user.email) - conn.commit() - finally: - conn.close() - def create_calendar(self, user, calendar_name: str, calendar_id: str) -> str: """ - Create a new calendar in DAViCal for the given user. - Returns the DAViCal path for the calendar. + Create a new calendar in CalDAV server for the given user. + Returns the CalDAV server path for the calendar. """ - # Ensure user exists first - self.ensure_user_exists(user) - client = self._get_client(user) principal = client.principal() @@ -149,21 +63,23 @@ class DAViCalClient: # Create calendar using caldav library calendar = principal.make_calendar(name=calendar_name) - # DAViCal calendar path format: /caldav.php/{username}/{calendar_id}/ + # CalDAV server calendar path format: /calendars/{username}/{calendar_id}/ # The caldav library returns a URL object, convert to string and extract path calendar_url = str(calendar.url) # Extract path from full URL if calendar_url.startswith(self.base_url): path = calendar_url[len(self.base_url) :] else: - # Fallback: construct path manually based on DAViCal's structure - # DAViCal creates calendars with a specific path structure - path = f"/caldav.php/{user.email}/{calendar_id}/" + # Fallback: construct path manually based on standard CalDAV structure + # CalDAV servers typically create calendars under /calendars/{principal}/ + path = f"/calendars/{user.email}/{calendar_id}/" - logger.info("Created calendar in DAViCal: %s at %s", calendar_name, path) + logger.info( + "Created calendar in CalDAV server: %s at %s", calendar_name, path + ) return path except Exception as e: - logger.error("Failed to create calendar in DAViCal: %s", str(e)) + logger.error("Failed to create calendar in CalDAV server: %s", str(e)) raise def get_events( @@ -177,8 +93,6 @@ class DAViCalClient: Get events from a calendar within a time range. Returns list of event dictionaries with parsed data. """ - # Ensure user exists first - self.ensure_user_exists(user) # Default to current month if no range specified if start is None: @@ -217,16 +131,14 @@ class DAViCalClient: logger.warning("Calendar not found at path: %s", calendar_path) return [] except Exception as e: - logger.error("Failed to get events from DAViCal: %s", str(e)) + logger.error("Failed to get events from CalDAV server: %s", str(e)) raise def create_event(self, user, calendar_path: str, event_data: dict) -> str: """ - Create a new event in DAViCal. + Create a new event in CalDAV server. Returns the event UID. """ - # Ensure user exists first - self.ensure_user_exists(user) client = self._get_client(user) calendar_url = f"{self.base_url}{calendar_path}" @@ -260,18 +172,16 @@ class DAViCalClient: elif hasattr(event, "vobject_instance"): event_uid = event.vobject_instance.vevent.uid.value - logger.info("Created event in DAViCal: %s", event_uid) + logger.info("Created event in CalDAV server: %s", event_uid) return event_uid except Exception as e: - logger.error("Failed to create event in DAViCal: %s", str(e)) + logger.error("Failed to create event in CalDAV server: %s", str(e)) raise def update_event( self, user, calendar_path: str, event_uid: str, event_data: dict ) -> None: - """Update an existing event in DAViCal.""" - # Ensure user exists first - self.ensure_user_exists(user) + """Update an existing event in CalDAV server.""" client = self._get_client(user) calendar_url = f"{self.base_url}{calendar_path}" @@ -320,15 +230,13 @@ class DAViCalClient: # Save the updated event target_event.save() - logger.info("Updated event in DAViCal: %s", event_uid) + logger.info("Updated event in CalDAV server: %s", event_uid) except Exception as e: - logger.error("Failed to update event in DAViCal: %s", str(e)) + logger.error("Failed to update event in CalDAV server: %s", str(e)) raise def delete_event(self, user, calendar_path: str, event_uid: str) -> None: - """Delete an event from DAViCal.""" - # Ensure user exists first - self.ensure_user_exists(user) + """Delete an event from CalDAV server.""" client = self._get_client(user) calendar_url = f"{self.base_url}{calendar_path}" @@ -356,9 +264,9 @@ class DAViCalClient: # Delete the event target_event.delete() - logger.info("Deleted event from DAViCal: %s", event_uid) + logger.info("Deleted event from CalDAV server: %s", event_uid) except Exception as e: - logger.error("Failed to delete event from DAViCal: %s", str(e)) + logger.error("Failed to delete event from CalDAV server: %s", str(e)) raise def _parse_event(self, event) -> Optional[dict]: @@ -404,7 +312,7 @@ class CalendarService: """ def __init__(self): - self.davical = DAViCalClient() + self.caldav = CalDAVClient() def create_default_calendar(self, user) -> Calendar: """ @@ -413,14 +321,14 @@ class CalendarService: calendar_id = str(uuid4()) calendar_name = "Mon calendrier" - # Create calendar in DAViCal - davical_path = self.davical.create_calendar(user, calendar_name, calendar_id) + # Create calendar in CalDAV server + caldav_path = self.caldav.create_calendar(user, calendar_name, calendar_id) # Create local Calendar record calendar = Calendar.objects.create( owner=user, name=calendar_name, - davical_path=davical_path, + caldav_path=caldav_path, is_default=True, color="#3174ad", ) @@ -433,14 +341,14 @@ class CalendarService: """ calendar_id = str(uuid4()) - # Create calendar in DAViCal - davical_path = self.davical.create_calendar(user, name, calendar_id) + # Create calendar in CalDAV server + caldav_path = self.caldav.create_calendar(user, name, calendar_id) # Create local Calendar record calendar = Calendar.objects.create( owner=user, name=name, - davical_path=davical_path, + caldav_path=caldav_path, is_default=False, color=color, ) @@ -460,18 +368,18 @@ class CalendarService: Get events from a calendar. Returns parsed event data. """ - return self.davical.get_events(user, calendar.davical_path, start, end) + return self.caldav.get_events(user, calendar.caldav_path, start, end) def create_event(self, user, calendar: Calendar, event_data: dict) -> str: """Create a new event.""" - return self.davical.create_event(user, calendar.davical_path, event_data) + return self.caldav.create_event(user, calendar.caldav_path, event_data) def update_event( self, user, calendar: Calendar, event_uid: str, event_data: dict ) -> None: """Update an existing event.""" - self.davical.update_event(user, calendar.davical_path, event_uid, event_data) + self.caldav.update_event(user, calendar.caldav_path, event_uid, event_data) def delete_event(self, user, calendar: Calendar, event_uid: str) -> None: """Delete an event.""" - self.davical.delete_event(user, calendar.davical_path, event_uid) + self.caldav.delete_event(user, calendar.caldav_path, event_uid) diff --git a/src/backend/core/signals.py b/src/backend/core/signals.py index ee9266c..aebd1d6 100644 --- a/src/backend/core/signals.py +++ b/src/backend/core/signals.py @@ -27,8 +27,8 @@ def provision_default_calendar(sender, instance, created, **kwargs): if instance.calendars.filter(is_default=True).exists(): return - # Skip calendar creation if DAViCal is not configured - if not getattr(settings, "DAVICAL_URL", None): + # Skip calendar creation if CalDAV server is not configured + if not settings.CALDAV_URL: return try: @@ -36,7 +36,7 @@ def provision_default_calendar(sender, instance, created, **kwargs): service.create_default_calendar(instance) logger.info("Created default calendar for user %s", instance.email) except Exception as e: - # In tests, DAViCal tables don't exist, so fail silently + # In tests, CalDAV server may not be available, so fail silently # Check if it's a database error that suggests we're in tests error_str = str(e).lower() if "does not exist" in error_str or "relation" in error_str: diff --git a/src/backend/core/tests/authentication/test_backends.py b/src/backend/core/tests/authentication/test_backends.py index 1bda314..f1131fd 100644 --- a/src/backend/core/tests/authentication/test_backends.py +++ b/src/backend/core/tests/authentication/test_backends.py @@ -500,7 +500,7 @@ def test_authentication_session_tokens( status=200, ) - with django_assert_num_queries(27): + with django_assert_num_queries(7): user = klass.authenticate( request, code="test-code", diff --git a/src/backend/core/tests/conftest.py b/src/backend/core/tests/conftest.py index 64c2a43..77f8bdd 100644 --- a/src/backend/core/tests/conftest.py +++ b/src/backend/core/tests/conftest.py @@ -19,23 +19,32 @@ VIA = [USER, TEAM] @pytest.fixture(autouse=True) -def truncate_davical_tables(django_db_setup, django_db_blocker): - """Fixture to truncate DAViCal tables at the start of each test. +def truncate_caldav_tables(django_db_setup, django_db_blocker): + """Fixture to truncate CalDAV server tables at the start of each test. - DAViCal tables are created by the DAViCal container migrations, not Django. + CalDAV server tables are created by the CalDAV server container migrations, not Django. We just truncate them to ensure clean state for each test. """ with django_db_blocker.unblock(): with connection.cursor() as cursor: - # Truncate DAViCal tables if they exist (created by DAViCal container) + # Truncate CalDAV server tables if they exist (created by CalDAV server container) cursor.execute(""" DO $$ BEGIN - IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'principal') THEN - TRUNCATE TABLE principal CASCADE; + IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'principals') THEN + TRUNCATE TABLE principals CASCADE; END IF; - IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'usr') THEN - TRUNCATE TABLE usr CASCADE; + IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'users') THEN + TRUNCATE TABLE users CASCADE; + END IF; + IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendars') THEN + TRUNCATE TABLE calendars CASCADE; + END IF; + IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendarinstances') THEN + TRUNCATE TABLE calendarinstances CASCADE; + END IF; + IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendarobjects') THEN + TRUNCATE TABLE calendarobjects CASCADE; END IF; END $$; """) diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index bcd320e..94fdd35 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -148,11 +148,14 @@ def test_api_users_list_throttling_authenticated(settings): assert response.status_code == 429 -def test_api_users_list_query_email(): +def test_api_users_list_query_email(settings): """ Authenticated users should be able to list users and filter by email. Only exact email matches are returned (case-insensitive). """ + + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "9999/minute" + user = factories.UserFactory() client = APIClient() diff --git a/src/backend/core/tests/test_caldav_proxy.py b/src/backend/core/tests/test_caldav_proxy.py new file mode 100644 index 0000000..5e92b3f --- /dev/null +++ b/src/backend/core/tests/test_caldav_proxy.py @@ -0,0 +1,307 @@ +"""Tests for CalDAV proxy view.""" + +from xml.etree import ElementTree as ET + +from django.conf import settings + +import pytest +import responses +from rest_framework.status import ( + HTTP_200_OK, + HTTP_207_MULTI_STATUS, + HTTP_401_UNAUTHORIZED, +) +from rest_framework.test import APIClient + +from core import factories + + +@pytest.mark.django_db +class TestCalDAVProxy: + """Tests for CalDAVProxyView.""" + + def test_proxy_requires_authentication(self): + """Test that unauthenticated requests return 401.""" + client = APIClient() + response = client.generic("PROPFIND", "/api/v1.0/caldav/") + assert response.status_code == HTTP_401_UNAUTHORIZED + + @responses.activate + def test_proxy_forwards_headers_correctly(self): + """Test that proxy forwards X-Forwarded-User headers.""" + user = factories.UserFactory(email="test@example.com") + client = APIClient() + client.force_login(user) + + # Mock CalDAV server response + caldav_url = settings.CALDAV_URL + responses.add( + responses.Response( + method="PROPFIND", + url=f"{caldav_url}/", + status=HTTP_207_MULTI_STATUS, + body='', + headers={"Content-Type": "application/xml"}, + ) + ) + + response = client.generic("PROPFIND", "/api/v1.0/caldav/") + + # Verify request was made to CalDAV server + assert len(responses.calls) == 1 + request = responses.calls[0].request + + # Verify headers were forwarded + assert request.headers["X-Forwarded-User"] == user.email + assert request.headers["X-Forwarded-Host"] is not None + assert request.headers["X-Forwarded-Proto"] == "http" + + @responses.activate + def test_proxy_ignores_client_sent_x_forwarded_user_header(self): + """Test that proxy ignores and overwrites any X-Forwarded-User header sent by client. + + This is a security test to ensure that hostile clients cannot impersonate other users + by sending a malicious X-Forwarded-User header. The proxy should always use the + authenticated Django user's email, not any header value sent by the client. + """ + user = factories.UserFactory(email="legitimate@example.com") + client = APIClient() + client.force_login(user) + + # Mock CalDAV server response + caldav_url = settings.CALDAV_URL + responses.add( + responses.Response( + method="PROPFIND", + url=f"{caldav_url}/api/v1.0/caldav/", + status=HTTP_207_MULTI_STATUS, + body='', + headers={"Content-Type": "application/xml"}, + ) + ) + + # Try to send a malicious X-Forwarded-User header as if we were another user + malicious_email = "attacker@example.com" + response = client.generic( + "PROPFIND", + "/api/v1.0/caldav/", + HTTP_X_FORWARDED_USER=malicious_email, + ) + + # Verify request was made to CalDAV server + assert len(responses.calls) == 1 + request = responses.calls[0].request + + # Verify that the X-Forwarded-User header uses the authenticated user's email, + # NOT the malicious header value sent by the client + assert request.headers["X-Forwarded-User"] == user.email, ( + f"Expected X-Forwarded-User to be {user.email} (authenticated user), " + f"but got {request.headers.get('X-Forwarded-User')}. " + f"This indicates a security vulnerability - client-sent headers are being trusted!" + ) + assert request.headers["X-Forwarded-User"] != malicious_email, ( + "X-Forwarded-User should NOT use client-sent header value" + ) + + @pytest.mark.skipif( + not settings.CALDAV_URL, + reason="CalDAV server URL not configured - integration test requires real server", + ) + def test_proxy_propfind_response_contains_prefixed_urls(self): + """Integration test: PROPFIND responses from sabre/dav should contain URLs with proxy prefix. + + This test verifies that sabre/dav's BaseUriPlugin correctly uses X-Forwarded-Prefix + to generate URLs with the proxy prefix. It requires the CalDAV server to be running. + Note: This test does NOT use @responses.activate as it needs to hit the real server. + """ + user = factories.UserFactory(email="test@example.com") + client = APIClient() + client.force_login(user) + + # Make actual request to CalDAV server through proxy + # The server should use X-Forwarded-Prefix to generate URLs + propfind_body = '' + response = client.generic( + "PROPFIND", + "/api/v1.0/caldav/", + data=propfind_body, + content_type="application/xml", + ) + + assert response.status_code == HTTP_207_MULTI_STATUS, ( + f"Expected 207 Multi-Status, got {response.status_code}: {response.content.decode('utf-8', errors='ignore')}" + ) + + # Parse the response XML + root = ET.fromstring(response.content) + + # Find all href elements + href_elems = root.findall(".//{DAV:}href") + assert len(href_elems) > 0, "PROPFIND response should contain href elements" + + # Verify all URLs that start with /principals/ or /calendars/ include the proxy prefix + # This verifies that sabre/dav's BaseUriPlugin is working correctly + for href_elem in href_elems: + href = href_elem.text + if href and ( + href.startswith("/principals/") or href.startswith("/calendars/") + ): + assert href.startswith("/api/v1.0/caldav/"), ( + f"Expected URL to start with /api/v1.0/caldav/, got {href}. " + f"This indicates sabre/dav BaseUriPlugin is not using X-Forwarded-Prefix correctly. " + f"Full response: {response.content.decode('utf-8', errors='ignore')}" + ) + + @responses.activate + def test_proxy_passes_through_calendar_urls(self): + """Test that calendar URLs in PROPFIND responses are passed through unchanged. + + Since we removed URL rewriting from the proxy, sabre/dav should generate + URLs with the correct prefix. This test verifies the proxy passes responses through. + """ + user = factories.UserFactory(email="test@example.com") + client = APIClient() + client.force_login(user) + + # Mock CalDAV server PROPFIND response with calendar URL that already has prefix + # (sabre/dav should generate URLs with prefix when X-Forwarded-Prefix is set) + caldav_url = settings.CALDAV_URL + propfind_xml = """ + + + /api/v1.0/caldav/calendars/test@example.com/calendar-id/ + + + + + + + + + + """ + + responses.add( + responses.Response( + method="PROPFIND", + url=f"{caldav_url}/api/v1.0/caldav/", + status=HTTP_207_MULTI_STATUS, + body=propfind_xml, + headers={"Content-Type": "application/xml"}, + ) + ) + + response = client.generic("PROPFIND", "/api/v1.0/caldav/") + + assert response.status_code == HTTP_207_MULTI_STATUS + + # Parse the response XML + root = ET.fromstring(response.content) + + # Find the href element + href_elem = root.find(".//{DAV:}href") + assert href_elem is not None + + # Verify the URL is passed through unchanged (sabre/dav should generate it with prefix) + href = href_elem.text + assert href == "/api/v1.0/caldav/calendars/test@example.com/calendar-id/", ( + f"Expected URL to be passed through unchanged, got {href}" + ) + + @responses.activate + def test_proxy_passes_through_namespaced_href_attributes(self): + """Test that namespaced href attributes (D:href) are passed through unchanged. + + Since we removed URL rewriting from the proxy, sabre/dav should generate + URLs with the correct prefix. This test verifies the proxy passes responses through. + """ + user = factories.UserFactory(email="test@example.com") + client = APIClient() + client.force_login(user) + + # Mock CalDAV server PROPFIND response with D:href that already has prefix + # (sabre/dav should generate URLs with prefix when X-Forwarded-Prefix is set) + caldav_url = settings.CALDAV_URL + propfind_xml = """ + + + /api/v1.0/caldav/principals/test@example.com/ + + + + + + + + + """ + + responses.add( + responses.Response( + method="PROPFIND", + url=f"{caldav_url}/api/v1.0/caldav/", + status=HTTP_207_MULTI_STATUS, + body=propfind_xml, + headers={"Content-Type": "application/xml"}, + ) + ) + + response = client.generic("PROPFIND", "/api/v1.0/caldav/") + + assert response.status_code == HTTP_207_MULTI_STATUS + + # Parse the response XML + root = ET.fromstring(response.content) + + # Find the D:href element (namespaced) + href_elem = root.find(".//{DAV:}href") + assert href_elem is not None + + # Verify the URL is passed through unchanged (sabre/dav should generate it with prefix) + href = href_elem.text + assert href == "/api/v1.0/caldav/principals/test@example.com/", ( + f"Expected URL to be passed through unchanged, got {href}" + ) + + @responses.activate + def test_proxy_forwards_path_correctly(self): + """Test that proxy forwards the path correctly to CalDAV server.""" + user = factories.UserFactory(email="test@example.com") + client = APIClient() + client.force_login(user) + + caldav_url = settings.CALDAV_URL + responses.add( + responses.Response( + method="PROPFIND", + url=f"{caldav_url}/api/v1.0/caldav/principals/test@example.com/", + status=HTTP_207_MULTI_STATUS, + body='', + headers={"Content-Type": "application/xml"}, + ) + ) + + # Request a specific path + response = client.generic( + "PROPFIND", "/api/v1.0/caldav/principals/test@example.com/" + ) + + # Verify the request was made to the correct URL + assert len(responses.calls) == 1 + request = responses.calls[0].request + assert ( + request.url == f"{caldav_url}/api/v1.0/caldav/principals/test@example.com/" + ) + + @responses.activate + def test_proxy_handles_options_request(self): + """Test that OPTIONS requests are handled for CORS.""" + user = factories.UserFactory(email="test@example.com") + client = APIClient() + client.force_login(user) + + response = client.options("/api/v1.0/caldav/") + + assert response.status_code == HTTP_200_OK + assert "Access-Control-Allow-Methods" in response + assert "PROPFIND" in response["Access-Control-Allow-Methods"] diff --git a/src/backend/core/tests/test_caldav_service.py b/src/backend/core/tests/test_caldav_service.py index 3baa426..1459902 100644 --- a/src/backend/core/tests/test_caldav_service.py +++ b/src/backend/core/tests/test_caldav_service.py @@ -1,4 +1,4 @@ -"""Tests for CalDAV service integration with DAViCal.""" +"""Tests for CalDAV service integration.""" from unittest.mock import Mock, patch @@ -7,17 +7,17 @@ from django.conf import settings import pytest from core import factories -from core.services.caldav_service import CalendarService, DAViCalClient +from core.services.caldav_service import CalDAVClient, CalendarService @pytest.mark.django_db -class TestDAViCalClient: - """Tests for DAViCalClient authentication and communication.""" +class TestCalDAVClient: + """Tests for CalDAVClient authentication and communication.""" def test_get_client_sends_x_forwarded_user_header(self): """Test that DAVClient is configured with X-Forwarded-User header.""" user = factories.UserFactory(email="test@example.com") - client = DAViCalClient() + client = CalDAVClient() dav_client = client._get_client(user) @@ -33,16 +33,13 @@ class TestDAViCalClient: assert dav_client.headers["X-Forwarded-User"] == user.email @pytest.mark.skipif( - not getattr(settings, "DAVICAL_URL", None), - reason="DAViCal URL not configured", + not settings.CALDAV_URL, + reason="CalDAV server URL not configured", ) - def test_create_calendar_authenticates_with_davical(self): - """Test that calendar creation authenticates successfully with DAViCal.""" + def test_create_calendar_authenticates_with_caldav_server(self): + """Test that calendar creation authenticates successfully with CalDAV server.""" user = factories.UserFactory(email="test@example.com") - client = DAViCalClient() - - # Ensure user exists in DAViCal - client.ensure_user_exists(user) + client = CalDAVClient() # Try to create a calendar - this should authenticate successfully calendar_path = client.create_calendar( @@ -51,11 +48,14 @@ class TestDAViCalClient: # Verify calendar path was returned assert calendar_path is not None - assert calendar_path.startswith("/caldav.php/") - assert user.email in calendar_path + # Email may be URL-encoded in the path (e.g., test%40example.com) + assert ( + user.email.replace("@", "%40") in calendar_path + or user.email in calendar_path + ) def test_calendar_service_creates_calendar(self): - """Test that CalendarService can create a calendar through DAViCal.""" + """Test that CalendarService can create a calendar through CalDAV server.""" user = factories.UserFactory(email="test@example.com") service = CalendarService() @@ -67,5 +67,4 @@ class TestDAViCalClient: assert calendar.owner == user assert calendar.name == "My Calendar" assert calendar.color == "#ff0000" - assert calendar.davical_path is not None - assert calendar.davical_path.startswith("/caldav.php/") + assert calendar.caldav_path is not None