221 lines
8.5 KiB
Python
221 lines
8.5 KiB
Python
|
|
"""CalDAV proxy views for forwarding requests to DAViCal."""
|
||
|
|
|
||
|
|
import logging
|
||
|
|
|
||
|
|
from django.conf import settings
|
||
|
|
from django.http import HttpResponse
|
||
|
|
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.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.
|
||
|
|
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 DAViCal."""
|
||
|
|
# Handle CORS preflight requests
|
||
|
|
if request.method == "OPTIONS":
|
||
|
|
response = HttpResponse(status=200)
|
||
|
|
response["Access-Control-Allow-Methods"] = (
|
||
|
|
"GET, OPTIONS, PROPFIND, REPORT, MKCOL, MKCALENDAR, PUT, DELETE"
|
||
|
|
)
|
||
|
|
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)
|
||
|
|
|
||
|
|
# 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")
|
||
|
|
path = kwargs.get("path", "")
|
||
|
|
|
||
|
|
# Use user email as the principal (DAViCal 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 += "/"
|
||
|
|
|
||
|
|
# 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}/"
|
||
|
|
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}"
|
||
|
|
|
||
|
|
# 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"
|
||
|
|
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
|
||
|
|
# 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
|
||
|
|
auth = (user_principal, "")
|
||
|
|
|
||
|
|
# 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 DAViCal
|
||
|
|
# 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
|
||
|
|
logger.debug(
|
||
|
|
"Forwarding %s request to DAViCal: %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
|
||
|
|
if response.status_code == 401:
|
||
|
|
logger.warning(
|
||
|
|
"DAViCal returned 401 for user %s at %s. Headers sent: %s",
|
||
|
|
user_principal,
|
||
|
|
target_url,
|
||
|
|
headers,
|
||
|
|
)
|
||
|
|
|
||
|
|
# 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 DAViCal 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("DAViCal 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/v1.0/caldav/"
|
||
|
|
response = HttpResponse(status=301)
|
||
|
|
response["Location"] = caldav_base_url
|
||
|
|
return response
|