Files
calendars/src/backend/core/api/viewsets_caldav.py
Nathan Panchout f329de9354 (back) add subscription and iCal API
Add API endpoints for calendar subscription token management
and iCal export. Includes serializers, viewsets and URL
configuration for subscription URLs and .ics file generation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:56:21 +01:00

314 lines
12 KiB
Python

"""CalDAV proxy views for forwarding requests to CalDAV server."""
import logging
import secrets
from django.conf import settings
from django.http import HttpResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
import requests
from core.services.calendar_invitation_service import calendar_invitation_service
logger = logging.getLogger(__name__)
@method_decorator(csrf_exempt, name="dispatch")
class CalDAVProxyView(View):
"""
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
(PROPFIND, REPORT, etc.) that don't work with Django's CSRF middleware.
Authentication is handled via session cookies instead.
"""
def dispatch(self, request, *args, **kwargs):
"""Forward all HTTP methods to CalDAV server."""
# Handle CORS preflight requests
if request.method == "OPTIONS":
response = HttpResponse(status=200)
response["Access-Control-Allow-Methods"] = (
"GET, OPTIONS, PROPFIND, PROPPATCH, REPORT, MKCOL, MKCALENDAR, PUT, DELETE, POST"
)
response["Access-Control-Allow-Headers"] = (
"Content-Type, depth, authorization, if-match, if-none-match, prefer"
)
return response
if not request.user.is_authenticated:
return HttpResponse(status=401)
# Build the CalDAV server URL
caldav_url = settings.CALDAV_URL
path = kwargs.get("path", "")
# Use user email as the principal (CalDAV server uses email as username)
user_principal = request.user.email
# 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 ""
# Construct target URL - always include the base URI path
if clean_path:
target_url = f"{caldav_url}{base_uri_path}/{clean_path}"
else:
# Root request - use base URI path
target_url = f"{caldav_url}{base_uri_path}/"
# Prepare headers for CalDAV server
# CalDAV server uses custom auth backend that requires X-Forwarded-User header and API key
headers = {
"Content-Type": request.content_type or "application/xml",
"X-Forwarded-User": user_principal,
"X-Forwarded-For": request.META.get("REMOTE_ADDR", ""),
"X-Forwarded-Host": request.get_host(),
"X-Forwarded-Proto": request.scheme,
}
# API key is required for authentication
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
if not outbound_api_key:
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
return HttpResponse(
status=500, content="CalDAV authentication not configured"
)
headers["X-Api-Key"] = outbound_api_key
# Add callback URL for CalDAV scheduling (iTip/iMip)
# The CalDAV server will call this URL when it needs to send invitations
# Use CALDAV_CALLBACK_BASE_URL if configured (for Docker environments where
# the CalDAV container needs to reach Django via internal network)
callback_path = reverse("caldav-scheduling-callback")
callback_base_url = getattr(settings, "CALDAV_CALLBACK_BASE_URL", None)
if callback_base_url:
# Use configured internal URL (e.g., http://backend:8000)
headers["X-CalDAV-Callback-URL"] = (
f"{callback_base_url.rstrip('/')}{callback_path}"
)
else:
# Fall back to external URL (works when CalDAV can reach Django externally)
headers["X-CalDAV-Callback-URL"] = request.build_absolute_uri(callback_path)
# No Basic Auth - our custom backend uses X-Forwarded-User header and API key
auth = None
# Copy relevant headers from the original request
if "HTTP_DEPTH" in request.META:
headers["Depth"] = request.META["HTTP_DEPTH"]
if "HTTP_IF_MATCH" in request.META:
headers["If-Match"] = request.META["HTTP_IF_MATCH"]
if "HTTP_IF_NONE_MATCH" in request.META:
headers["If-None-Match"] = request.META["HTTP_IF_NONE_MATCH"]
if "HTTP_PREFER" in request.META:
headers["Prefer"] = request.META["HTTP_PREFER"]
# Get request body
body = request.body if request.body else None
try:
# Forward the request to CalDAV server
# CalDAV server authenticates via X-Forwarded-User header and API key
logger.debug(
"Forwarding %s request to CalDAV server: %s (user: %s)",
request.method,
target_url,
user_principal,
)
response = requests.request(
method=request.method,
url=target_url,
headers=headers,
data=body,
auth=auth,
timeout=30,
allow_redirects=False,
)
# Log authentication failures for debugging (without sensitive headers)
if response.status_code == 401:
logger.warning(
"CalDAV server returned 401 for user %s at %s",
user_principal,
target_url,
)
# Build Django response
django_response = HttpResponse(
content=response.content,
status=response.status_code,
content_type=response.headers.get("Content-Type", "application/xml"),
)
# 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]
return django_response
except requests.exceptions.RequestException as e:
logger.error("CalDAV server proxy error: %s", str(e))
return HttpResponse(
content=f"CalDAV server error: {str(e)}",
status=502,
content_type="text/plain",
)
@method_decorator(csrf_exempt, name="dispatch")
class CalDAVDiscoveryView(View):
"""
Handle CalDAV discovery requests (well-known URLs).
Per RFC 6764, this endpoint should redirect to the CalDAV server base URL,
not to a user-specific principal. Clients will then perform PROPFIND on
the base URL to discover their principal.
CSRF protection is disabled because CalDAV uses non-standard HTTP methods
and this endpoint should be accessible without authentication.
"""
def dispatch(self, request, *args, **kwargs):
"""Handle discovery requests."""
# Handle CORS preflight requests
if request.method == "OPTIONS":
response = HttpResponse(status=200)
response["Access-Control-Allow-Methods"] = "GET, OPTIONS, PROPFIND"
response["Access-Control-Allow-Headers"] = (
"Content-Type, depth, authorization"
)
return response
# Note: Authentication is not required for discovery per RFC 6764
# Clients need to discover the CalDAV URL before authenticating
# Return redirect to CalDAV server base URL
caldav_base_url = f"/api/{settings.API_VERSION}/caldav/"
response = HttpResponse(status=301)
response["Location"] = caldav_base_url
return response
@method_decorator(csrf_exempt, name="dispatch")
class CalDAVSchedulingCallbackView(View):
"""
Endpoint for receiving CalDAV scheduling messages (iMip) from sabre/dav.
This endpoint receives scheduling messages (invites, responses, cancellations)
from the CalDAV server and processes them by sending email notifications
with ICS attachments. Authentication is via API key.
Supported iTip methods (RFC 5546):
- REQUEST: New invitation or event update
- CANCEL: Event cancellation
- REPLY: Attendee response (accept/decline/tentative)
See: https://sabre.io/dav/scheduling/
"""
def dispatch(self, request, *args, **kwargs):
"""Handle scheduling messages from CalDAV server."""
# Authenticate via API key
api_key = request.headers.get("X-Api-Key", "").strip()
expected_key = settings.CALDAV_INBOUND_API_KEY
if not expected_key or not secrets.compare_digest(api_key, expected_key):
logger.warning(
"CalDAV scheduling callback request with invalid API key. "
"Expected: %s..., Got: %s...",
expected_key[:10] if expected_key else "None",
api_key[:10] if api_key else "None",
)
return HttpResponse(status=401)
# Extract headers
sender = request.headers.get("X-CalDAV-Sender", "")
recipient = request.headers.get("X-CalDAV-Recipient", "")
method = request.headers.get("X-CalDAV-Method", "").upper()
# Validate required fields
if not sender or not recipient or not method:
logger.error(
"CalDAV scheduling callback missing required headers: "
"sender=%s, recipient=%s, method=%s",
sender,
recipient,
method,
)
return HttpResponse(
status=400,
content="Missing required headers: X-CalDAV-Sender, X-CalDAV-Recipient, X-CalDAV-Method",
content_type="text/plain",
)
# Get iCalendar data from request body
icalendar_data = (
request.body.decode("utf-8", errors="replace") if request.body else ""
)
if not icalendar_data:
logger.error("CalDAV scheduling callback received empty body")
return HttpResponse(
status=400,
content="Missing iCalendar data in request body",
content_type="text/plain",
)
logger.info(
"Processing CalDAV scheduling message: %s -> %s (method: %s)",
sender,
recipient,
method,
)
# Send the invitation/notification email
try:
success = calendar_invitation_service.send_invitation(
sender_email=sender,
recipient_email=recipient,
method=method,
icalendar_data=icalendar_data,
)
if success:
logger.info(
"Successfully sent calendar %s email: %s -> %s",
method,
sender,
recipient,
)
return HttpResponse(
status=200,
content="OK",
content_type="text/plain",
)
else:
logger.error(
"Failed to send calendar %s email: %s -> %s",
method,
sender,
recipient,
)
return HttpResponse(
status=500,
content="Failed to send email",
content_type="text/plain",
)
except Exception as e:
logger.exception("Error processing CalDAV scheduling callback: %s", e)
return HttpResponse(
status=500,
content=f"Internal error: {str(e)}",
content_type="text/plain",
)