✨(scheduling) add callback from caldav to django for imip
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
--name backend-test \
|
||||
-e DJANGO_CONFIGURATION=Test \
|
||||
backend-dev \
|
||||
pytest "$@"
|
||||
|
||||
@@ -36,7 +36,6 @@ services:
|
||||
environment:
|
||||
- PYLINTHOME=/app/.pylint.d
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
- CALDAV_URL=http://caldav:80
|
||||
env_file:
|
||||
- env.d/development/backend.defaults
|
||||
- env.d/development/backend.local
|
||||
|
||||
@@ -44,6 +44,15 @@ RUN a2enmod rewrite headers \
|
||||
&& a2ensite sabredav \
|
||||
&& chmod +x /usr/local/bin/init-database.sh
|
||||
|
||||
# Configure PHP error logging to stderr for Docker logs
|
||||
# This ensures all error_log() calls and PHP errors are visible in docker logs
|
||||
# display_errors = Off prevents errors from appearing in HTTP responses (security/UX)
|
||||
# but errors are still logged to stderr (Docker logs) via log_errors = On
|
||||
RUN echo "log_errors = On" >> /usr/local/etc/php/conf.d/error-logging.ini \
|
||||
&& echo "error_log = /proc/self/fd/2" >> /usr/local/etc/php/conf.d/error-logging.ini \
|
||||
&& echo "display_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini \
|
||||
&& echo "display_startup_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R www-data:www-data /var/www/sabredav \
|
||||
&& chmod -R 755 /var/www/sabredav
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
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
|
||||
@@ -23,6 +17,7 @@
|
||||
# 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
|
||||
# Write errors to stderr for Docker logs
|
||||
ErrorLog /proc/self/fd/2
|
||||
# CustomLog /proc/self/fd/1 combined
|
||||
</VirtualHost>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* sabre/dav CalDAV Server
|
||||
* Configured to use PostgreSQL backend and Apache authentication
|
||||
* Configured to use PostgreSQL backend and custom header-based authentication
|
||||
*/
|
||||
|
||||
use Sabre\DAV\Auth;
|
||||
@@ -10,16 +10,12 @@ use Sabre\CalDAV;
|
||||
use Sabre\CardDAV;
|
||||
use Sabre\DAV;
|
||||
use Calendars\SabreDav\AutoCreatePrincipalBackend;
|
||||
use Calendars\SabreDav\HttpCallbackIMipPlugin;
|
||||
use Calendars\SabreDav\ApiKeyAuthBackend;
|
||||
|
||||
// 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') ?: '/';
|
||||
@@ -42,8 +38,14 @@ $pdo = new PDO(
|
||||
]
|
||||
);
|
||||
|
||||
// Create backend
|
||||
$authBackend = new Auth\Backend\Apache();
|
||||
// Create custom authentication backend
|
||||
// Requires API key authentication and X-Forwarded-User header
|
||||
$apiKey = getenv('CALDAV_OUTBOUND_API_KEY');
|
||||
if (!$apiKey) {
|
||||
error_log("[sabre/dav] CALDAV_OUTBOUND_API_KEY environment variable is required");
|
||||
exit(1);
|
||||
}
|
||||
$authBackend = new ApiKeyAuthBackend($apiKey);
|
||||
|
||||
// Create authentication plugin
|
||||
$authPlugin = new Auth\Plugin($authBackend);
|
||||
@@ -75,5 +77,25 @@ $server->addPlugin(new CardDAV\Plugin());
|
||||
$server->addPlugin(new DAVACL\Plugin());
|
||||
$server->addPlugin(new DAV\Browser\Plugin());
|
||||
|
||||
// Add custom IMipPlugin that forwards scheduling messages via HTTP callback
|
||||
// This MUST be added BEFORE the Schedule\Plugin so that Schedule\Plugin finds it
|
||||
// The callback URL must be provided per-request via X-CalDAV-Callback-URL header
|
||||
$callbackApiKey = getenv('CALDAV_INBOUND_API_KEY');
|
||||
if (!$callbackApiKey) {
|
||||
error_log("[sabre/dav] CALDAV_INBOUND_API_KEY environment variable is required for scheduling callback");
|
||||
exit(1);
|
||||
}
|
||||
$imipPlugin = new HttpCallbackIMipPlugin($callbackApiKey);
|
||||
$server->addPlugin($imipPlugin);
|
||||
|
||||
// Add CalDAV scheduling support
|
||||
// See https://sabre.io/dav/scheduling/
|
||||
// The Schedule\Plugin will automatically find and use the IMipPlugin we just added
|
||||
// It looks for plugins that implement CalDAV\Schedule\IMipPlugin interface
|
||||
$schedulePlugin = new CalDAV\Schedule\Plugin();
|
||||
$server->addPlugin($schedulePlugin);
|
||||
|
||||
// error_log("[sabre/dav] Starting server");
|
||||
|
||||
// Start server
|
||||
$server->start();
|
||||
|
||||
86
docker/sabredav/src/ApiKeyAuthBackend.php
Normal file
86
docker/sabredav/src/ApiKeyAuthBackend.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom authentication backend that supports API key and header-based authentication.
|
||||
*
|
||||
* This backend authenticates users via:
|
||||
* - API key authentication: X-Api-Key header and X-Forwarded-User header
|
||||
*
|
||||
* This allows Django to authenticate with CalDAV server using an API key.
|
||||
*/
|
||||
|
||||
namespace Calendars\SabreDav;
|
||||
|
||||
use Sabre\DAV\Auth\Backend\BackendInterface;
|
||||
use Sabre\HTTP\RequestInterface;
|
||||
use Sabre\HTTP\ResponseInterface;
|
||||
|
||||
class ApiKeyAuthBackend implements BackendInterface
|
||||
{
|
||||
/**
|
||||
* Expected API key for outbound authentication (from Django to CalDAV)
|
||||
* @var string
|
||||
*/
|
||||
private $apiKey;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $apiKey The expected API key for authentication
|
||||
*/
|
||||
public function __construct($apiKey)
|
||||
{
|
||||
$this->apiKey = $apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* When this method is called, the backend must check if authentication was
|
||||
* successful.
|
||||
*
|
||||
* The returned value must be one of the following:
|
||||
*
|
||||
* [true, "principals/username"] - authentication was successful, and a principal url is returned.
|
||||
* [false, "reason for failure"] - authentication failed, reason is optional
|
||||
* [null, null] - The backend cannot determine. The next backend will be queried.
|
||||
*
|
||||
* @param RequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return array
|
||||
*/
|
||||
public function check(RequestInterface $request, ResponseInterface $response)
|
||||
{
|
||||
// Get user from X-Forwarded-User header (required)
|
||||
$xForwardedUser = $request->getHeader('X-Forwarded-User');
|
||||
if (!$xForwardedUser) {
|
||||
return [false, 'X-Forwarded-User header is required'];
|
||||
}
|
||||
|
||||
// API key is required
|
||||
$apiKeyHeader = $request->getHeader('X-Api-Key');
|
||||
if (!$apiKeyHeader) {
|
||||
return [false, 'X-Api-Key header is required'];
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
if ($apiKeyHeader !== $this->apiKey) {
|
||||
return [false, 'Invalid API key'];
|
||||
}
|
||||
|
||||
// Authentication successful
|
||||
return [true, 'principals/' . $xForwardedUser];
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called when a user could not be authenticated.
|
||||
*
|
||||
* This gives us a chance to set up authentication challenges (for example HTTP auth).
|
||||
*
|
||||
* @param RequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return void
|
||||
*/
|
||||
public function challenge(RequestInterface $request, ResponseInterface $response)
|
||||
{
|
||||
// We don't use HTTP Basic/Digest auth, so no challenge needed
|
||||
// The error message from check() will be returned
|
||||
}
|
||||
}
|
||||
151
docker/sabredav/src/HttpCallbackIMipPlugin.php
Normal file
151
docker/sabredav/src/HttpCallbackIMipPlugin.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom IMipPlugin that forwards scheduling messages via HTTP callback instead of sending emails.
|
||||
*
|
||||
* This plugin extends sabre/dav's IMipPlugin but instead of sending emails via PHP's mail()
|
||||
* function, it forwards the scheduling messages to an HTTP callback endpoint secured by API key.
|
||||
*
|
||||
* @see https://sabre.io/dav/scheduling/
|
||||
*/
|
||||
|
||||
namespace Calendars\SabreDav;
|
||||
|
||||
use Sabre\CalDAV\Schedule\IMipPlugin;
|
||||
use Sabre\DAV\Server;
|
||||
use Sabre\VObject\ITip\Message;
|
||||
|
||||
class HttpCallbackIMipPlugin extends IMipPlugin
|
||||
{
|
||||
/**
|
||||
* API key for authenticating with the callback endpoint
|
||||
* @var string
|
||||
*/
|
||||
private $apiKey;
|
||||
|
||||
/**
|
||||
* Reference to the DAV server instance
|
||||
* @var Server
|
||||
*/
|
||||
private $server;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $apiKey The API key for authenticating with the callback endpoint
|
||||
*/
|
||||
public function __construct($apiKey)
|
||||
{
|
||||
// Call parent constructor with empty email (we won't use it)
|
||||
parent::__construct('');
|
||||
|
||||
$this->apiKey = $apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the plugin.
|
||||
*
|
||||
* @param Server $server
|
||||
* @return void
|
||||
*/
|
||||
public function initialize(Server $server)
|
||||
{
|
||||
parent::initialize($server);
|
||||
$this->server = $server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler for the 'schedule' event.
|
||||
*
|
||||
* This overrides the parent's schedule() method to forward messages via HTTP callback
|
||||
* instead of sending emails via PHP's mail() function.
|
||||
*
|
||||
* @param Message $iTipMessage The iTip message
|
||||
* @return void
|
||||
*/
|
||||
public function schedule(Message $iTipMessage)
|
||||
{
|
||||
// Not sending any messages if the system considers the update insignificant.
|
||||
if (!$iTipMessage->significantChange) {
|
||||
if (!$iTipMessage->scheduleStatus) {
|
||||
$iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant delivery';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle mailto: recipients (external attendees)
|
||||
if ('mailto' !== parse_url($iTipMessage->sender, PHP_URL_SCHEME)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('mailto' !== parse_url($iTipMessage->recipient, PHP_URL_SCHEME)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get callback URL from the HTTP request header (required)
|
||||
if (!$this->server || !$this->server->httpRequest) {
|
||||
$iTipMessage->scheduleStatus = '5.4;No HTTP request available for callback URL';
|
||||
return;
|
||||
}
|
||||
|
||||
$callbackUrl = $this->server->httpRequest->getHeader('X-CalDAV-Callback-URL');
|
||||
if (!$callbackUrl) {
|
||||
error_log("[HttpCallbackIMipPlugin] ERROR: X-CalDAV-Callback-URL header is required");
|
||||
$iTipMessage->scheduleStatus = '5.4;X-CalDAV-Callback-URL header is required';
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure URL ends with trailing slash for Django's APPEND_SLASH middleware
|
||||
$callbackUrl = rtrim($callbackUrl, '/') . '/';
|
||||
|
||||
// Serialize the iCalendar message
|
||||
$vcalendar = $iTipMessage->message ? $iTipMessage->message->serialize() : '';
|
||||
|
||||
// Prepare headers
|
||||
// Trim API key to remove any whitespace from environment variable
|
||||
$apiKey = trim($this->apiKey);
|
||||
$headers = [
|
||||
'Content-Type: text/calendar',
|
||||
'X-Api-Key: ' . $apiKey,
|
||||
'X-CalDAV-Sender: ' . $iTipMessage->sender,
|
||||
'X-CalDAV-Recipient: ' . $iTipMessage->recipient,
|
||||
'X-CalDAV-Method: ' . $iTipMessage->method,
|
||||
];
|
||||
|
||||
// Make HTTP POST request to Django callback endpoint
|
||||
$ch = curl_init($callbackUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_POSTFIELDS => $vcalendar,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
error_log(sprintf(
|
||||
"[HttpCallbackIMipPlugin] ERROR: cURL failed: %s",
|
||||
$curlError
|
||||
));
|
||||
$iTipMessage->scheduleStatus = '5.4;Failed to forward scheduling message via HTTP callback';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
error_log(sprintf(
|
||||
"[HttpCallbackIMipPlugin] ERROR: HTTP %d - %s",
|
||||
$httpCode,
|
||||
substr($response, 0, 200)
|
||||
));
|
||||
$iTipMessage->scheduleStatus = '5.4;HTTP callback returned error: ' . $httpCode;
|
||||
return;
|
||||
}
|
||||
|
||||
// Success
|
||||
$iTipMessage->scheduleStatus = '1.1;Scheduling message forwarded via HTTP callback';
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ OIDC_RS_ALLOWED_AUDIENCES=""
|
||||
|
||||
# CalDAV Server
|
||||
CALDAV_URL=http://caldav:80
|
||||
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
|
||||
CALDAV_INBOUND_API_KEY=changeme-inbound-in-production
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME=default
|
||||
|
||||
@@ -3,4 +3,6 @@ PGPORT=5432
|
||||
PGDATABASE=calendars
|
||||
PGUSER=pgroot
|
||||
PGPASSWORD=pass
|
||||
CALENDARS_BASE_URI=/api/v1.0/caldav/
|
||||
CALENDARS_BASE_URI=/api/v1.0/caldav/
|
||||
CALDAV_INBOUND_API_KEY=changeme-inbound-in-production
|
||||
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
|
||||
@@ -72,6 +72,16 @@ class Base(Configuration):
|
||||
"http://caldav:80", environ_name="CALDAV_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# CalDAV API keys for bidirectional authentication
|
||||
# INBOUND: API key for authenticating requests FROM CalDAV server TO Django
|
||||
CALDAV_INBOUND_API_KEY = values.Value(
|
||||
None, environ_name="CALDAV_INBOUND_API_KEY", environ_prefix=None
|
||||
)
|
||||
# OUTBOUND: API key for authenticating requests FROM Django TO CalDAV server
|
||||
CALDAV_OUTBOUND_API_KEY = values.Value(
|
||||
None, environ_name="CALDAV_OUTBOUND_API_KEY", environ_prefix=None
|
||||
)
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS = values.ListValue([])
|
||||
SECRET_KEY = SecretFileValue(None)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""CalDAV proxy views for forwarding requests to CalDAV server."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
@@ -61,7 +62,7 @@ class CalDAVProxyView(View):
|
||||
target_url = f"{caldav_url}{base_uri_path}/"
|
||||
|
||||
# Prepare headers for CalDAV server
|
||||
# CalDAV server Apache backend reads REMOTE_USER, which we set via X-Forwarded-User
|
||||
# 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,
|
||||
@@ -70,11 +71,18 @@ class CalDAVProxyView(View):
|
||||
"X-Forwarded-Proto": request.scheme,
|
||||
}
|
||||
|
||||
# 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
|
||||
# CalDAV server converts X-Forwarded-User to REMOTE_USER
|
||||
auth = (user_principal, "")
|
||||
# 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
|
||||
|
||||
# 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:
|
||||
@@ -91,8 +99,7 @@ class CalDAVProxyView(View):
|
||||
|
||||
try:
|
||||
# Forward the request to CalDAV server
|
||||
# Use HTTP Basic Auth with username (email) and empty password
|
||||
# CalDAV server will authenticate based on X-Forwarded-User header (converted to REMOTE_USER)
|
||||
# CalDAV server authenticates via X-Forwarded-User header and API key
|
||||
logger.debug(
|
||||
"Forwarding %s request to CalDAV server: %s (user: %s)",
|
||||
request.method,
|
||||
@@ -169,7 +176,56 @@ class CalDAVDiscoveryView(View):
|
||||
# Clients need to discover the CalDAV URL before authenticating
|
||||
|
||||
# Return redirect to CalDAV server base URL
|
||||
caldav_base_url = f"/api/v1.0/caldav/"
|
||||
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. Authentication is via API key.
|
||||
|
||||
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", "")
|
||||
|
||||
# For now, just log the scheduling message
|
||||
logger.info(
|
||||
"Received CalDAV scheduling callback: %s -> %s (method: %s)",
|
||||
sender,
|
||||
recipient,
|
||||
method,
|
||||
)
|
||||
|
||||
# Log message body (first 500 chars)
|
||||
if request.body:
|
||||
body_preview = request.body[:500].decode("utf-8", errors="ignore")
|
||||
logger.info("Scheduling message body (first 500 chars): %s", body_preview)
|
||||
|
||||
# TODO: Process the scheduling message (send email, update calendar, etc.)
|
||||
# For now, just return success
|
||||
return HttpResponse(status=200, content_type="text/plain")
|
||||
|
||||
@@ -30,10 +30,8 @@ class CalDAVClient:
|
||||
"""
|
||||
Get a CalDAV client for the given user.
|
||||
|
||||
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.
|
||||
The CalDAV server requires API key authentication via Authorization header
|
||||
and X-Forwarded-User header for user identification.
|
||||
"""
|
||||
# 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
|
||||
@@ -41,14 +39,26 @@ class CalDAVClient:
|
||||
base_uri_clean = self.base_uri_path.rstrip("/")
|
||||
caldav_url = f"{base_url_clean}{base_uri_clean}/"
|
||||
|
||||
# Prepare headers
|
||||
# API key is required for authentication
|
||||
headers = {
|
||||
"X-Forwarded-User": user.email,
|
||||
}
|
||||
|
||||
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
|
||||
if not outbound_api_key:
|
||||
raise ValueError("CALDAV_OUTBOUND_API_KEY is not configured")
|
||||
|
||||
headers["X-Api-Key"] = outbound_api_key
|
||||
|
||||
# No username/password needed - authentication is via API key and X-Forwarded-User header
|
||||
# Pass None to prevent the caldav library from trying Basic auth
|
||||
return DAVClient(
|
||||
url=caldav_url,
|
||||
username=user.email,
|
||||
password="", # Empty password - server uses X-Forwarded-User header
|
||||
username=None,
|
||||
password=None,
|
||||
timeout=self.timeout,
|
||||
headers={
|
||||
"X-Forwarded-User": user.email,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def create_calendar(self, user, calendar_name: str, calendar_id: str) -> str:
|
||||
|
||||
246
src/backend/core/tests/test_caldav_scheduling.py
Normal file
246
src/backend/core/tests/test_caldav_scheduling.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Tests for CalDAV scheduling callback integration."""
|
||||
|
||||
import http.server
|
||||
import logging
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import pytest
|
||||
|
||||
from caldav.lib.error import NotFoundError
|
||||
from core import factories
|
||||
from core.services.caldav_service import CalendarService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""HTTP request handler for capturing CalDAV scheduling callbacks in tests."""
|
||||
|
||||
def __init__(self, callback_data, *args, **kwargs):
|
||||
self.callback_data = callback_data
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def do_POST(self):
|
||||
"""Handle POST requests (scheduling callbacks)."""
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length) if content_length > 0 else b""
|
||||
|
||||
# Store callback data
|
||||
self.callback_data["called"] = True
|
||||
self.callback_data["request_data"] = {
|
||||
"headers": dict(self.headers),
|
||||
"body": body.decode("utf-8", errors="ignore") if body else "",
|
||||
}
|
||||
|
||||
# Send success response
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"OK")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Suppress default logging."""
|
||||
pass
|
||||
|
||||
|
||||
def create_test_server() -> tuple:
|
||||
"""Create a test HTTP server that captures callbacks.
|
||||
|
||||
Returns:
|
||||
Tuple of (server, port, callback_data)
|
||||
"""
|
||||
callback_data = {"called": False, "request_data": None}
|
||||
|
||||
def handler_factory(*args, **kwargs):
|
||||
return CallbackHandler(callback_data, *args, **kwargs)
|
||||
|
||||
# Use fixed port 8001 - accessible from other Docker containers
|
||||
port = 8001
|
||||
|
||||
# Create server with SO_REUSEADDR to allow quick port reuse
|
||||
server = http.server.HTTPServer(("0.0.0.0", port), handler_factory)
|
||||
server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
actual_port = server.server_address[1]
|
||||
|
||||
return server, actual_port, callback_data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCalDAVScheduling:
|
||||
"""Tests for CalDAV scheduling callback when creating events with attendees."""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not settings.CALDAV_URL,
|
||||
reason="CalDAV server URL not configured - integration test requires real server",
|
||||
)
|
||||
def test_scheduling_callback_received_when_creating_event_with_attendee(self):
|
||||
"""Test that creating an event with an attendee triggers scheduling callback.
|
||||
|
||||
This test verifies that when an event is created with an attendee via CalDAV,
|
||||
the HttpCallbackIMipPlugin sends a scheduling message to the Django callback endpoint.
|
||||
|
||||
The test starts a local HTTP server to receive the callback, and passes the server URL
|
||||
to the CalDAV server via the X-CalDAV-Callback-URL header.
|
||||
"""
|
||||
# Create users: organizer
|
||||
# Note: attendee should be external (not in CalDAV server) to trigger scheduling
|
||||
organizer = factories.UserFactory(email="organizer@example.com")
|
||||
|
||||
# Create calendar for organizer
|
||||
service = CalendarService()
|
||||
calendar = service.create_calendar(
|
||||
organizer, name="Test Calendar", color="#ff0000"
|
||||
)
|
||||
|
||||
# Start test HTTP server to receive callbacks
|
||||
# Use fixed port 8001 - accessible from other Docker containers
|
||||
server, port, callback_data = create_test_server()
|
||||
|
||||
# Start server in a separate thread
|
||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
# Give the server a moment to start listening
|
||||
time.sleep(0.5)
|
||||
|
||||
# Verify server is actually listening
|
||||
test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
test_socket.connect(("127.0.0.1", port))
|
||||
test_socket.close()
|
||||
except Exception as e:
|
||||
pytest.fail(f"Test server failed to start on port {port}: {e}")
|
||||
|
||||
# Use the named test container hostname
|
||||
# The test container is created with --name backend-test in bin/pytest
|
||||
# Docker Compose networking allows containers to reach each other by name
|
||||
callback_url = f"http://backend-test:{port}/"
|
||||
|
||||
try:
|
||||
# Create an event with an attendee
|
||||
client = service.caldav._get_client(organizer)
|
||||
calendar_url = f"{settings.CALDAV_URL}{calendar.caldav_path}"
|
||||
|
||||
# Add custom callback URL header to the client
|
||||
# The CalDAV server will use this URL for the callback
|
||||
client.headers["X-CalDAV-Callback-URL"] = callback_url
|
||||
|
||||
try:
|
||||
caldav_calendar = client.calendar(url=calendar_url)
|
||||
|
||||
# Create event with attendee using iCalendar format
|
||||
# We need to create the event with attendees to trigger scheduling
|
||||
# Note: sabre/dav's scheduling plugin only sends messages for external attendees
|
||||
# (attendees that don't have a principal in the same CalDAV server)
|
||||
dtstart = datetime.now() + timedelta(days=1)
|
||||
dtend = dtstart + timedelta(hours=1)
|
||||
|
||||
# Use a clearly external attendee email (not in the CalDAV server)
|
||||
external_attendee = "external-attendee@external-domain.com"
|
||||
|
||||
# Create iCalendar event with attendee
|
||||
ical_content = f"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:test-event-{datetime.now().timestamp()}
|
||||
DTSTART:{dtstart.strftime("%Y%m%dT%H%M%SZ")}
|
||||
DTEND:{dtend.strftime("%Y%m%dT%H%M%SZ")}
|
||||
SUMMARY:Test Event with Attendee
|
||||
ORGANIZER;CN=Organizer:mailto:{organizer.email}
|
||||
ATTENDEE;CN=External Attendee;RSVP=TRUE:mailto:{external_attendee}
|
||||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
# Save event to trigger scheduling
|
||||
event = caldav_calendar.save_event(ical_content)
|
||||
|
||||
# Give the callback a moment to be called (scheduling may be async)
|
||||
# sabre/dav processes scheduling synchronously during the request
|
||||
time.sleep(2)
|
||||
|
||||
# Verify callback was called
|
||||
assert callback_data["called"], (
|
||||
"Scheduling callback was not called when creating event with attendee. "
|
||||
"This may indicate that sabre/dav's scheduling plugin is not working correctly. "
|
||||
"Check CalDAV server logs for scheduling errors."
|
||||
)
|
||||
|
||||
# Verify callback request details
|
||||
request_data = callback_data["request_data"]
|
||||
assert request_data is not None
|
||||
|
||||
# Verify API key authentication
|
||||
api_key = request_data["headers"].get("X-Api-Key", "")
|
||||
expected_key = settings.CALDAV_INBOUND_API_KEY
|
||||
assert expected_key and secrets.compare_digest(api_key, expected_key), (
|
||||
f"Callback request missing or invalid X-Api-Key header. "
|
||||
f"Expected: {expected_key[:10]}..., Got: {api_key[:10] if api_key else 'None'}..."
|
||||
)
|
||||
|
||||
# Verify scheduling headers
|
||||
assert "X-CalDAV-Sender" in request_data["headers"], (
|
||||
"Missing X-CalDAV-Sender header"
|
||||
)
|
||||
assert "X-CalDAV-Recipient" in request_data["headers"], (
|
||||
"Missing X-CalDAV-Recipient header"
|
||||
)
|
||||
assert "X-CalDAV-Method" in request_data["headers"], (
|
||||
"Missing X-CalDAV-Method header"
|
||||
)
|
||||
|
||||
# Verify sender is the organizer
|
||||
sender = request_data["headers"]["X-CalDAV-Sender"]
|
||||
assert (
|
||||
organizer.email in sender or f"mailto:{organizer.email}" in sender
|
||||
), f"Expected sender to be {organizer.email}, got {sender}"
|
||||
|
||||
# Verify recipient is the attendee
|
||||
recipient = request_data["headers"]["X-CalDAV-Recipient"]
|
||||
assert (
|
||||
external_attendee in recipient
|
||||
or f"mailto:{external_attendee}" in recipient
|
||||
), f"Expected recipient to be {external_attendee}, got {recipient}"
|
||||
|
||||
# Verify method is REQUEST (for new invitations)
|
||||
method = request_data["headers"]["X-CalDAV-Method"]
|
||||
assert method == "REQUEST", (
|
||||
f"Expected method to be REQUEST for new invitation, got {method}"
|
||||
)
|
||||
|
||||
# Verify iCalendar content is present
|
||||
assert request_data["body"], "Callback request body is empty"
|
||||
assert "BEGIN:VCALENDAR" in request_data["body"], (
|
||||
"Callback body should contain iCalendar content"
|
||||
)
|
||||
assert "VEVENT" in request_data["body"], (
|
||||
"Callback body should contain VEVENT"
|
||||
)
|
||||
|
||||
# Normalize iCalendar body to handle line folding (CRLF + space/tab)
|
||||
# iCalendar format folds long lines at 75 characters, so we need to remove folding
|
||||
# Line folding: CRLF followed by space or tab indicates continuation
|
||||
body = request_data["body"]
|
||||
# Remove line folding: replace CRLF+space and CRLF+tab with nothing
|
||||
normalized_body = body.replace("\r\n ", "").replace("\r\n\t", "")
|
||||
# Also handle Unix-style line endings
|
||||
normalized_body = normalized_body.replace("\n ", "").replace("\n\t", "")
|
||||
assert external_attendee in normalized_body, (
|
||||
f"Callback body should contain attendee email {external_attendee}. "
|
||||
f"Normalized body (first 500 chars): {normalized_body[:500]}"
|
||||
)
|
||||
|
||||
except NotFoundError:
|
||||
pytest.skip("Calendar not found - CalDAV server may not be running")
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed to create event with attendee: {str(e)}")
|
||||
finally:
|
||||
# Shutdown server
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
@@ -22,9 +22,9 @@ class TestCalDAVClient:
|
||||
dav_client = client._get_client(user)
|
||||
|
||||
# Verify the client is configured correctly
|
||||
assert dav_client.username == user.email
|
||||
# Password should be empty (None or empty string) for external auth
|
||||
assert not dav_client.password or dav_client.password == ""
|
||||
# Username and password should be None to prevent Basic auth
|
||||
assert dav_client.username is None
|
||||
assert dav_client.password is None
|
||||
|
||||
# Verify the X-Forwarded-User header is set
|
||||
# The caldav library stores headers as a CaseInsensitiveDict
|
||||
|
||||
@@ -7,7 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import viewsets
|
||||
from core.api.viewsets_caldav import CalDAVProxyView
|
||||
from core.api.viewsets_caldav import CalDAVProxyView, CalDAVSchedulingCallbackView
|
||||
from core.external_api import viewsets as external_api_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
@@ -31,6 +31,12 @@ urlpatterns = [
|
||||
CalDAVProxyView.as_view(),
|
||||
name="caldav-proxy",
|
||||
),
|
||||
# CalDAV scheduling callback endpoint (separate from caldav proxy)
|
||||
path(
|
||||
"caldav-scheduling-callback/",
|
||||
CalDAVSchedulingCallbackView.as_view(),
|
||||
name="caldav-scheduling-callback",
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user