✨(scheduling) add callback from caldav to django for imip
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user