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