🏗️(caldav) migrate from davical to sabre/dav
This commit is contained in:
@@ -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