🏗️(caldav) migrate from davical to sabre/dav
This commit is contained in:
26
Makefile
26
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
|
||||
|
||||
27
compose.yaml
27
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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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!"
|
||||
|
||||
@@ -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
|
||||
|
||||
53
docker/sabredav/Dockerfile
Normal file
53
docker/sabredav/Dockerfile
Normal 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"]
|
||||
16
docker/sabredav/composer.json
Normal file
16
docker/sabredav/composer.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
87
docker/sabredav/init-database.sh
Normal file
87
docker/sabredav/init-database.sh
Normal 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!"
|
||||
28
docker/sabredav/sabredav.conf
Normal file
28
docker/sabredav/sabredav.conf
Normal 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>
|
||||
79
docker/sabredav/server.php
Normal file
79
docker/sabredav/server.php
Normal 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();
|
||||
44
docker/sabredav/sql/pgsql.addressbooks.sql
Normal file
44
docker/sabredav/sql/pgsql.addressbooks.sql
Normal 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);
|
||||
108
docker/sabredav/sql/pgsql.calendars.sql
Normal file
108
docker/sabredav/sql/pgsql.calendars.sql
Normal 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);
|
||||
19
docker/sabredav/sql/pgsql.locks.sql
Normal file
19
docker/sabredav/sql/pgsql.locks.sql
Normal 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);
|
||||
30
docker/sabredav/sql/pgsql.principals.sql
Normal file
30
docker/sabredav/sql/pgsql.principals.sql
Normal 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);
|
||||
|
||||
13
docker/sabredav/sql/pgsql.propertystorage.sql
Normal file
13
docker/sabredav/sql/pgsql.propertystorage.sql
Normal 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);
|
||||
14
docker/sabredav/sql/pgsql.users.sql
Normal file
14
docker/sabredav/sql/pgsql.users.sql
Normal 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');
|
||||
53
docker/sabredav/src/AutoCreatePrincipalBackend.php
Normal file
53
docker/sabredav/src/AutoCreatePrincipalBackend.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
6
env.d/development/caldav.defaults
Normal file
6
env.d/development/caldav.defaults
Normal file
@@ -0,0 +1,6 @@
|
||||
PGHOST=postgresql
|
||||
PGPORT=5432
|
||||
PGDATABASE=calendars
|
||||
PGUSER=pgroot
|
||||
PGPASSWORD=pass
|
||||
CALENDARS_BASE_URI=/api/v1.0/caldav/
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 $$;
|
||||
""")
|
||||
|
||||
@@ -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()
|
||||
|
||||
307
src/backend/core/tests/test_caldav_proxy.py
Normal file
307
src/backend/core/tests/test_caldav_proxy.py
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user