🏗️(caldav) migrate from davical to sabre/dav

This commit is contained in:
Sylvain Zimmer
2026-01-11 02:28:04 +01:00
parent a36348ead1
commit bc801d3007
35 changed files with 1059 additions and 549 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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"]

View File

@@ -1,64 +0,0 @@
<?php
/**
* DAViCal Configuration
* This file is mounted as /etc/davical/config.php
* Overrides the default config generated by the fintechstudios/davical image
*/
// Database connection - uses shared calendars database in public schema
// The image will set these from PGHOST, PGDATABASE, PGUSER, PGPASSWORD
$c->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()
);

View File

@@ -1,22 +0,0 @@
<VirtualHost *:80>
ServerName localhost
DocumentRoot /usr/share/davical/htdocs
DirectoryIndex index.php
Alias /images/ /usr/share/davical/htdocs/images/
<Directory /usr/share/davical/htdocs>
AllowOverride All
Require all granted
</Directory>
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
</VirtualHost>

View File

@@ -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'" \
<<EOF
UPDATE usr SET password = :pw WHERE user_no = 1;
EOF

View File

@@ -37,10 +37,4 @@ EOSQL
# Create databases for all services
# The main 'calendar' database is created by default via POSTGRES_DB
# DAViCal database
create_database "davical" "davical" "davical_pass"
# Keycloak database
create_database "keycloak" "keycloak" "keycloak_pass"
echo "All databases initialized successfully!"

View File

@@ -1,6 +1,6 @@
#!/bin/sh
# Initialize shared calendars database for local development
# All services (Django, DAViCal, Keycloak) use the same database in public schema
# All services (Django, Caldav, Keycloak) use the same database in public schema
# This script runs as POSTGRES_USER on first database initialization
set -e

View File

@@ -0,0 +1,53 @@
# sabre/dav CalDAV Server
# Based on Debian with Apache and PHP
FROM php:8.2-apache-bookworm
ENV DEBIAN_FRONTEND=noninteractive
# Install dependencies
RUN apt-get update && apt-get install -y \
libpq-dev \
postgresql-client \
git \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-install pdo pdo_pgsql
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Create application directory
WORKDIR /var/www/sabredav
# Copy composer files
COPY composer.json ./
# Install sabre/dav and dependencies
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Copy server configuration
COPY server.php ./
COPY sabredav.conf /etc/apache2/sites-available/sabredav.conf
COPY init-database.sh /usr/local/bin/init-database.sh
# Copy SQL schema files for database initialization
COPY sql/ ./sql/
# Copy custom principal backend
COPY src/ ./src/
# Enable Apache modules and site
RUN a2enmod rewrite headers \
&& a2dissite 000-default \
&& a2ensite sabredav \
&& chmod +x /usr/local/bin/init-database.sh
# Set permissions
RUN chown -R www-data:www-data /var/www/sabredav \
&& chmod -R 755 /var/www/sabredav
EXPOSE 80
CMD ["apache2-foreground"]

View File

@@ -0,0 +1,16 @@
{
"name": "calendars/sabredav-server",
"description": "sabre/dav CalDAV server for calendars",
"type": "project",
"require": {
"php": ">=8.1",
"sabre/dav": "^4.5",
"ext-pdo": "*",
"ext-pdo_pgsql": "*"
},
"autoload": {
"psr-4": {
"Calendars\\SabreDav\\": "src/"
}
}
}

View File

@@ -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!"

View File

@@ -0,0 +1,28 @@
<VirtualHost *:80>
ServerName localhost
DocumentRoot /var/www/sabredav
<Directory /var/www/sabredav>
AllowOverride All
Require all granted
Options -Indexes +FollowSymLinks
</Directory>
# Set REMOTE_USER from X-Forwarded-User header (set by Django proxy)
# This allows sabre/dav to use Apache auth backend
<IfModule mod_headers.c>
RequestHeader set REMOTE_USER %{HTTP:X-Forwarded-User}e env=HTTP_X_FORWARDED_USER
</IfModule>
# 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
</VirtualHost>

View File

@@ -0,0 +1,79 @@
<?php
/**
* sabre/dav CalDAV Server
* Configured to use PostgreSQL backend and Apache authentication
*/
use Sabre\DAV\Auth;
use Sabre\DAVACL;
use Sabre\CalDAV;
use Sabre\CardDAV;
use Sabre\DAV;
use Calendars\SabreDav\AutoCreatePrincipalBackend;
// Composer autoloader
require_once __DIR__ . '/vendor/autoload.php';
// Set REMOTE_USER from X-Forwarded-User header (set by Django proxy)
// This allows sabre/dav Apache auth backend to work with proxied requests
if (isset($_SERVER['HTTP_X_FORWARDED_USER']) && !isset($_SERVER['REMOTE_USER'])) {
$_SERVER['REMOTE_USER'] = $_SERVER['HTTP_X_FORWARDED_USER'];
}
// Get base URI from environment variable (set by compose.yaml)
// This ensures sabre/dav generates URLs with the correct proxy path
$baseUri = getenv('CALENDARS_BASE_URI') ?: '/';
// Database connection from environment variables
$dbHost = getenv('PGHOST') ?: 'postgresql';
$dbPort = getenv('PGPORT') ?: '5432';
$dbName = getenv('PGDATABASE') ?: 'calendars';
$dbUser = getenv('PGUSER') ?: 'pgroot';
$dbPass = getenv('PGPASSWORD') ?: 'pass';
// Create PDO connection
$pdo = new PDO(
"pgsql:host={$dbHost};port={$dbPort};dbname={$dbName}",
$dbUser,
$dbPass,
[
PDO::ATTR_ERRMODE => 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();

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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');

View File

@@ -0,0 +1,53 @@
<?php
/**
* Custom principal backend that auto-creates principals when they don't exist.
* This allows Apache authentication to work without pre-creating principals.
*/
namespace Calendars\SabreDav;
use Sabre\DAVACL\PrincipalBackend\PDO as BasePDO;
use Sabre\DAV\MkCol;
class AutoCreatePrincipalBackend extends BasePDO
{
/**
* Returns a specific principal, specified by it's path.
* Auto-creates the principal if it doesn't exist.
*
* @param string $path
*
* @return array|null
*/
public function getPrincipalByPath($path)
{
$principal = parent::getPrincipalByPath($path);
// If principal doesn't exist, create it automatically
if (!$principal && strpos($path, 'principals/') === 0) {
// Extract username from path (e.g., "principals/user@example.com" -> "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;
}
}

View File

@@ -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.

View File

@@ -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

View File

@@ -0,0 +1,6 @@
PGHOST=postgresql
PGPORT=5432
PGDATABASE=calendars
PGUSER=pgroot
PGPASSWORD=pass
CALENDARS_BASE_URI=/api/v1.0/caldav/

View File

@@ -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

View File

@@ -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

View File

@@ -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"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>{request_path}</D:href>
<D:propstat>
<D:prop>
<D:displayname>{user_principal}</D:displayname>
<C:calendar-home-set>
<D:href>/api/v1.0/caldav/{user_principal}/</D:href>
</C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
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,

View File

@@ -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)),

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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",

View File

@@ -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 $$;
""")

View File

@@ -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()

View File

@@ -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='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
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='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
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 = '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><resourcetype/></prop></propfind>'
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 = """<?xml version="1.0"?>
<multistatus xmlns="DAV:">
<response>
<href>/api/v1.0/caldav/calendars/test@example.com/calendar-id/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<calendar xmlns="urn:ietf:params:xml:ns:caldav"/>
</resourcetype>
</prop>
</propstat>
</response>
</multistatus>"""
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 = """<?xml version="1.0"?>
<multistatus xmlns="DAV:" xmlns:D="DAV:">
<response>
<D:href>/api/v1.0/caldav/principals/test@example.com/</D:href>
<propstat>
<prop>
<resourcetype>
<principal/>
</resourcetype>
</prop>
</propstat>
</response>
</multistatus>"""
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='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
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"]

View File

@@ -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