✨(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"
|
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||||
|
|
||||||
_dc_run \
|
_dc_run \
|
||||||
|
--name backend-test \
|
||||||
-e DJANGO_CONFIGURATION=Test \
|
-e DJANGO_CONFIGURATION=Test \
|
||||||
backend-dev \
|
backend-dev \
|
||||||
pytest "$@"
|
pytest "$@"
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PYLINTHOME=/app/.pylint.d
|
- PYLINTHOME=/app/.pylint.d
|
||||||
- DJANGO_CONFIGURATION=Development
|
- DJANGO_CONFIGURATION=Development
|
||||||
- CALDAV_URL=http://caldav:80
|
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/backend.defaults
|
- env.d/development/backend.defaults
|
||||||
- env.d/development/backend.local
|
- env.d/development/backend.local
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ RUN a2enmod rewrite headers \
|
|||||||
&& a2ensite sabredav \
|
&& a2ensite sabredav \
|
||||||
&& chmod +x /usr/local/bin/init-database.sh
|
&& 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
|
# Set permissions
|
||||||
RUN chown -R www-data:www-data /var/www/sabredav \
|
RUN chown -R www-data:www-data /var/www/sabredav \
|
||||||
&& chmod -R 755 /var/www/sabredav
|
&& chmod -R 755 /var/www/sabredav
|
||||||
|
|||||||
@@ -8,12 +8,6 @@
|
|||||||
Options -Indexes +FollowSymLinks
|
Options -Indexes +FollowSymLinks
|
||||||
</Directory>
|
</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
|
# Rewrite rules for CalDAV
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
@@ -23,6 +17,7 @@
|
|||||||
# Well-known CalDAV discovery
|
# Well-known CalDAV discovery
|
||||||
RewriteRule ^\.well-known/caldav / [R=301,L]
|
RewriteRule ^\.well-known/caldav / [R=301,L]
|
||||||
|
|
||||||
ErrorLog ${APACHE_LOG_DIR}/sabredav_error.log
|
# Write errors to stderr for Docker logs
|
||||||
CustomLog ${APACHE_LOG_DIR}/sabredav_access.log combined
|
ErrorLog /proc/self/fd/2
|
||||||
|
# CustomLog /proc/self/fd/1 combined
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* sabre/dav CalDAV Server
|
* 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;
|
use Sabre\DAV\Auth;
|
||||||
@@ -10,16 +10,12 @@ use Sabre\CalDAV;
|
|||||||
use Sabre\CardDAV;
|
use Sabre\CardDAV;
|
||||||
use Sabre\DAV;
|
use Sabre\DAV;
|
||||||
use Calendars\SabreDav\AutoCreatePrincipalBackend;
|
use Calendars\SabreDav\AutoCreatePrincipalBackend;
|
||||||
|
use Calendars\SabreDav\HttpCallbackIMipPlugin;
|
||||||
|
use Calendars\SabreDav\ApiKeyAuthBackend;
|
||||||
|
|
||||||
// Composer autoloader
|
// Composer autoloader
|
||||||
require_once __DIR__ . '/vendor/autoload.php';
|
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)
|
// Get base URI from environment variable (set by compose.yaml)
|
||||||
// This ensures sabre/dav generates URLs with the correct proxy path
|
// This ensures sabre/dav generates URLs with the correct proxy path
|
||||||
$baseUri = getenv('CALENDARS_BASE_URI') ?: '/';
|
$baseUri = getenv('CALENDARS_BASE_URI') ?: '/';
|
||||||
@@ -42,8 +38,14 @@ $pdo = new PDO(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create backend
|
// Create custom authentication backend
|
||||||
$authBackend = new Auth\Backend\Apache();
|
// 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
|
// Create authentication plugin
|
||||||
$authPlugin = new Auth\Plugin($authBackend);
|
$authPlugin = new Auth\Plugin($authBackend);
|
||||||
@@ -75,5 +77,25 @@ $server->addPlugin(new CardDAV\Plugin());
|
|||||||
$server->addPlugin(new DAVACL\Plugin());
|
$server->addPlugin(new DAVACL\Plugin());
|
||||||
$server->addPlugin(new DAV\Browser\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
|
// Start server
|
||||||
$server->start();
|
$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 Server
|
||||||
CALDAV_URL=http://caldav:80
|
CALDAV_URL=http://caldav:80
|
||||||
|
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
|
||||||
|
CALDAV_INBOUND_API_KEY=changeme-inbound-in-production
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
FRONTEND_THEME=default
|
FRONTEND_THEME=default
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ PGDATABASE=calendars
|
|||||||
PGUSER=pgroot
|
PGUSER=pgroot
|
||||||
PGPASSWORD=pass
|
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
|
"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
|
# Security
|
||||||
ALLOWED_HOSTS = values.ListValue([])
|
ALLOWED_HOSTS = values.ListValue([])
|
||||||
SECRET_KEY = SecretFileValue(None)
|
SECRET_KEY = SecretFileValue(None)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""CalDAV proxy views for forwarding requests to CalDAV server."""
|
"""CalDAV proxy views for forwarding requests to CalDAV server."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
@@ -61,7 +62,7 @@ class CalDAVProxyView(View):
|
|||||||
target_url = f"{caldav_url}{base_uri_path}/"
|
target_url = f"{caldav_url}{base_uri_path}/"
|
||||||
|
|
||||||
# Prepare headers for CalDAV server
|
# 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 = {
|
headers = {
|
||||||
"Content-Type": request.content_type or "application/xml",
|
"Content-Type": request.content_type or "application/xml",
|
||||||
"X-Forwarded-User": user_principal,
|
"X-Forwarded-User": user_principal,
|
||||||
@@ -70,11 +71,18 @@ class CalDAVProxyView(View):
|
|||||||
"X-Forwarded-Proto": request.scheme,
|
"X-Forwarded-Proto": request.scheme,
|
||||||
}
|
}
|
||||||
|
|
||||||
# CalDAV server authentication: Apache backend reads REMOTE_USER
|
# API key is required for authentication
|
||||||
# We send the username via X-Forwarded-User header
|
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
|
||||||
# For HTTP Basic Auth, we use the email as username with empty password
|
if not outbound_api_key:
|
||||||
# CalDAV server converts X-Forwarded-User to REMOTE_USER
|
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
|
||||||
auth = (user_principal, "")
|
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
|
# Copy relevant headers from the original request
|
||||||
if "HTTP_DEPTH" in request.META:
|
if "HTTP_DEPTH" in request.META:
|
||||||
@@ -91,8 +99,7 @@ class CalDAVProxyView(View):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Forward the request to CalDAV server
|
# Forward the request to CalDAV server
|
||||||
# Use HTTP Basic Auth with username (email) and empty password
|
# CalDAV server authenticates via X-Forwarded-User header and API key
|
||||||
# CalDAV server will authenticate based on X-Forwarded-User header (converted to REMOTE_USER)
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Forwarding %s request to CalDAV server: %s (user: %s)",
|
"Forwarding %s request to CalDAV server: %s (user: %s)",
|
||||||
request.method,
|
request.method,
|
||||||
@@ -169,7 +176,56 @@ class CalDAVDiscoveryView(View):
|
|||||||
# Clients need to discover the CalDAV URL before authenticating
|
# Clients need to discover the CalDAV URL before authenticating
|
||||||
|
|
||||||
# Return redirect to CalDAV server base URL
|
# 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 = HttpResponse(status=301)
|
||||||
response["Location"] = caldav_base_url
|
response["Location"] = caldav_base_url
|
||||||
return response
|
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.
|
Get a CalDAV client for the given user.
|
||||||
|
|
||||||
The CalDAV server uses Apache authentication backend which reads REMOTE_USER.
|
The CalDAV server requires API key authentication via Authorization header
|
||||||
We pass the X-Forwarded-User header which the server converts to REMOTE_USER.
|
and X-Forwarded-User header for user identification.
|
||||||
The caldav library requires username/password for Basic Auth, but we use
|
|
||||||
empty password since authentication is handled via headers.
|
|
||||||
"""
|
"""
|
||||||
# CalDAV server base URL - include the base URI path that sabre/dav expects
|
# 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
|
# 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("/")
|
base_uri_clean = self.base_uri_path.rstrip("/")
|
||||||
caldav_url = f"{base_url_clean}{base_uri_clean}/"
|
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(
|
return DAVClient(
|
||||||
url=caldav_url,
|
url=caldav_url,
|
||||||
username=user.email,
|
username=None,
|
||||||
password="", # Empty password - server uses X-Forwarded-User header
|
password=None,
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
headers={
|
headers=headers,
|
||||||
"X-Forwarded-User": user.email,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_calendar(self, user, calendar_name: str, calendar_id: str) -> str:
|
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)
|
dav_client = client._get_client(user)
|
||||||
|
|
||||||
# Verify the client is configured correctly
|
# Verify the client is configured correctly
|
||||||
assert dav_client.username == user.email
|
# Username and password should be None to prevent Basic auth
|
||||||
# Password should be empty (None or empty string) for external auth
|
assert dav_client.username is None
|
||||||
assert not dav_client.password or dav_client.password == ""
|
assert dav_client.password is None
|
||||||
|
|
||||||
# Verify the X-Forwarded-User header is set
|
# Verify the X-Forwarded-User header is set
|
||||||
# The caldav library stores headers as a CaseInsensitiveDict
|
# 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 rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from core.api import viewsets
|
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
|
from core.external_api import viewsets as external_api_viewsets
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
@@ -31,6 +31,12 @@ urlpatterns = [
|
|||||||
CalDAVProxyView.as_view(),
|
CalDAVProxyView.as_view(),
|
||||||
name="caldav-proxy",
|
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