(import) add import modal

Synchronous for now, can be offloaded to worker later.

Also lint the codebase
This commit is contained in:
Sylvain Zimmer
2026-02-09 18:43:49 +01:00
parent 23a66f21e6
commit 3a0f64e791
30 changed files with 2476 additions and 121 deletions

View File

@@ -21,10 +21,8 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Create application directory
WORKDIR /var/www/sabredav
# Copy composer files
# Copy composer files and install dependencies
COPY composer.json ./
# Install sabre/dav and dependencies
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Copy server configuration
@@ -51,7 +49,8 @@ RUN a2enmod rewrite headers \
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
&& echo "display_startup_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini \
&& echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/error-logging.ini
# Set permissions
RUN chown -R www-data:www-data /var/www/sabredav \

View File

@@ -2,9 +2,15 @@
"name": "calendars/sabredav-server",
"description": "sabre/dav CalDAV server for calendars",
"type": "project",
"repositories": [
{
"type": "vcs",
"url": "https://github.com/sylvinus/dav"
}
],
"require": {
"php": ">=8.1",
"sabre/dav": "^4.5",
"sabre/dav": "dev-master",
"ext-pdo": "*",
"ext-pdo_pgsql": "*"
},

View File

@@ -12,7 +12,9 @@ use Sabre\DAV;
use Calendars\SabreDav\AutoCreatePrincipalBackend;
use Calendars\SabreDav\HttpCallbackIMipPlugin;
use Calendars\SabreDav\ApiKeyAuthBackend;
use Calendars\SabreDav\CalendarSanitizerPlugin;
use Calendars\SabreDav\AttendeeNormalizerPlugin;
use Calendars\SabreDav\ICSImportPlugin;
// Composer autoloader
require_once __DIR__ . '/vendor/autoload.php';
@@ -89,39 +91,56 @@ $server->addPlugin(new CalDAV\ICSExportPlugin());
$server->addPlugin(new DAV\Sharing\Plugin());
$server->addPlugin(new CalDAV\SharingPlugin());
// Debug logging for sharing requests
$server->on('method:POST', function($request) {
$contentType = $request->getHeader('Content-Type');
$path = $request->getPath();
$body = $request->getBodyAsString();
error_log("[sabre/dav] POST request received:");
error_log("[sabre/dav] Path: " . $path);
error_log("[sabre/dav] Content-Type: " . $contentType);
error_log("[sabre/dav] Body: " . substr($body, 0, 1000));
// Reset body stream position
$request->setBody($body);
}, 50); // Priority 50 to run early
// Debug logging for POST requests - commented out to avoid PII in logs
// Uncomment for local debugging only, never in production.
// $server->on('method:POST', function($request) {
// $contentType = $request->getHeader('Content-Type');
// $path = $request->getPath();
// $body = $request->getBodyAsString();
// error_log("[sabre/dav] POST request received:");
// error_log("[sabre/dav] Path: " . $path);
// error_log("[sabre/dav] Content-Type: " . $contentType);
// error_log("[sabre/dav] Body: " . substr($body, 0, 1000));
// $request->setBody($body);
// }, 50);
//
// $server->on('afterMethod:POST', function($request, $response) {
// error_log("[sabre/dav] POST response status: " . $response->getStatus());
// $body = $response->getBodyAsString();
// if ($body) {
// error_log("[sabre/dav] POST response body: " . substr($body, 0, 500));
// }
// }, 50);
// Debug: Log when share plugin processes request
$server->on('afterMethod:POST', function($request, $response) {
error_log("[sabre/dav] POST response status: " . $response->getStatus());
$body = $response->getBodyAsString();
if ($body) {
error_log("[sabre/dav] POST response body: " . substr($body, 0, 500));
}
}, 50);
// Debug: Log exceptions
// Log unhandled exceptions
$server->on('exception', function($e) {
error_log("[sabre/dav] Exception: " . get_class($e) . " - " . $e->getMessage());
error_log("[sabre/dav] Exception trace: " . $e->getTraceAsString());
}, 50);
// Add calendar sanitizer plugin (priority 85, runs before all other calendar plugins)
// Strips inline binary attachments (Outlook/Exchange base64 images) and truncates
// oversized DESCRIPTION fields. Applies to ALL CalDAV writes (PUT from any client).
$sanitizerStripAttachments = getenv('SANITIZER_STRIP_BINARY_ATTACHMENTS') !== 'false';
$sanitizerMaxDescBytes = getenv('SANITIZER_MAX_DESCRIPTION_BYTES');
$sanitizerMaxDescBytes = ($sanitizerMaxDescBytes !== false) ? (int)$sanitizerMaxDescBytes : 102400;
$sanitizerMaxResourceSize = getenv('SANITIZER_MAX_RESOURCE_SIZE');
$sanitizerMaxResourceSize = ($sanitizerMaxResourceSize !== false) ? (int)$sanitizerMaxResourceSize : 1048576;
$server->addPlugin(new CalendarSanitizerPlugin(
$sanitizerStripAttachments,
$sanitizerMaxDescBytes,
$sanitizerMaxResourceSize
));
// Add attendee normalizer plugin to fix duplicate attendees issue
// This plugin normalizes attendee emails (lowercase) and deduplicates them
// when processing calendar objects, fixing issues with REPLY handling
$server->addPlugin(new AttendeeNormalizerPlugin());
// Add ICS import plugin for bulk event import from a single POST request
// Only accessible via the X-Calendars-Import header (backend-only)
$server->addPlugin(new ICSImportPlugin($caldavBackend, $apiKey));
// 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 can be provided per-request via X-CalDAV-Callback-URL header

View File

@@ -60,12 +60,14 @@ class AttendeeNormalizerPlugin extends ServerPlugin
// Hook into calendar object creation and updates
// Priority 90 to run before most other plugins but after authentication
$server->on('beforeCreateFile', [$this, 'beforeWriteCalendarObject'], 90);
$server->on('beforeWriteContent', [$this, 'beforeWriteCalendarObject'], 90);
// Note: beforeCreateFile and beforeWriteContent have different signatures
$server->on('beforeCreateFile', [$this, 'beforeCreateCalendarObject'], 90);
$server->on('beforeWriteContent', [$this, 'beforeUpdateCalendarObject'], 90);
}
/**
* Called before a calendar object is created or updated.
* Called before a calendar object is created.
* Signature: ($path, &$data, \Sabre\DAV\ICollection $parent, &$modified)
*
* @param string $path The path to the file
* @param resource|string $data The data being written
@@ -73,18 +75,41 @@ class AttendeeNormalizerPlugin extends ServerPlugin
* @param bool $modified Whether the data was modified
* @return void
*/
public function beforeWriteCalendarObject($path, &$data, $parentNode = null, &$modified = false)
public function beforeCreateCalendarObject($path, &$data, $parentNode = null, &$modified = false)
{
$this->processCalendarData($path, $data, $modified);
}
/**
* Called before a calendar object is updated.
* Signature: ($path, \Sabre\DAV\IFile $node, &$data, &$modified)
*
* @param string $path The path to the file
* @param \Sabre\DAV\IFile $node The existing file node
* @param resource|string $data The data being written
* @param bool $modified Whether the data was modified
* @return void
*/
public function beforeUpdateCalendarObject($path, $node, &$data, &$modified = false)
{
$this->processCalendarData($path, $data, $modified);
}
/**
* Process calendar data to normalize and deduplicate attendees.
*
* @param string $path The path to the file
* @param resource|string &$data The data being written (modified in place)
* @param bool &$modified Whether the data was modified
* @return void
*/
private function processCalendarData($path, &$data, &$modified)
{
// Only process .ics files in calendar collections
if (!preg_match('/\.ics$/i', $path)) {
return;
}
// Check if parent is a calendar collection
if ($parentNode && !($parentNode instanceof \Sabre\CalDAV\ICalendarObjectContainer)) {
return;
}
try {
// Get the data as string
if (is_resource($data)) {

View File

@@ -0,0 +1,234 @@
<?php
/**
* CalendarSanitizerPlugin - Sanitizes calendar data on all CalDAV writes.
*
* Applied to both new creates (PUT to new URI) and updates (PUT to existing URI).
* This covers events coming from any CalDAV client (Thunderbird, Apple Calendar,
* Outlook, etc.) as well as the bulk import plugin.
*
* Sanitizations:
* 1. Strip inline binary attachments (ATTACH;VALUE=BINARY / ENCODING=BASE64)
* These are typically Outlook/Exchange email signature images that bloat storage.
* URL-based attachments (e.g. Google Drive links) are preserved.
* 2. Truncate oversized text properties:
* - Long text fields (DESCRIPTION, X-ALT-DESC, COMMENT): configurable limit (default 100KB)
* - Short text fields (SUMMARY, LOCATION): fixed 1KB safety guardrail
* 3. Enforce max resource size (default 1MB) on the final serialized object.
* Returns HTTP 507 Insufficient Storage if exceeded after sanitization.
*
* Controlled by constructor parameters (read from env vars in server.php).
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\DAV\Exception\InsufficientStorage;
use Sabre\VObject\Reader;
use Sabre\VObject\Component\VCalendar;
class CalendarSanitizerPlugin extends ServerPlugin
{
/** @var Server */
protected $server;
/** @var bool Whether to strip inline binary attachments */
private $stripBinaryAttachments;
/** @var int Max size in bytes for long text properties: DESCRIPTION, X-ALT-DESC, COMMENT (0 = no limit) */
private $maxDescriptionBytes;
/** @var int Max total resource size in bytes after sanitization (0 = no limit) */
private $maxResourceSize;
/** @var int Max size in bytes for short text properties: SUMMARY, LOCATION */
private const MAX_SHORT_TEXT_BYTES = 1024;
/** @var array Long text properties subject to $maxDescriptionBytes */
private const LONG_TEXT_PROPERTIES = ['DESCRIPTION', 'X-ALT-DESC', 'COMMENT'];
/** @var array Short text properties subject to MAX_SHORT_TEXT_BYTES */
private const SHORT_TEXT_PROPERTIES = ['SUMMARY', 'LOCATION'];
public function __construct(
bool $stripBinaryAttachments = true,
int $maxDescriptionBytes = 102400,
int $maxResourceSize = 1048576
) {
$this->stripBinaryAttachments = $stripBinaryAttachments;
$this->maxDescriptionBytes = $maxDescriptionBytes;
$this->maxResourceSize = $maxResourceSize;
}
public function getPluginName()
{
return 'calendar-sanitizer';
}
public function initialize(Server $server)
{
$this->server = $server;
// Priority 85: run before AttendeeNormalizerPlugin (90) and CalDAV validation (100)
$server->on('beforeCreateFile', [$this, 'beforeCreateCalendarObject'], 85);
$server->on('beforeWriteContent', [$this, 'beforeUpdateCalendarObject'], 85);
}
/**
* Called before a calendar object is created.
* Signature: ($path, &$data, \Sabre\DAV\ICollection $parent, &$modified)
*/
public function beforeCreateCalendarObject($path, &$data, $parentNode = null, &$modified = false)
{
$this->sanitizeCalendarData($path, $data, $modified);
}
/**
* Called before a calendar object is updated.
* Signature: ($path, \Sabre\DAV\IFile $node, &$data, &$modified)
*/
public function beforeUpdateCalendarObject($path, $node, &$data, &$modified = false)
{
$this->sanitizeCalendarData($path, $data, $modified);
}
/**
* Sanitize raw calendar data from a beforeCreateFile/beforeWriteContent hook.
*/
private function sanitizeCalendarData($path, &$data, &$modified)
{
// Only process .ics files
if (!preg_match('/\.ics$/i', $path)) {
return;
}
try {
// Get the data as string
if (is_resource($data)) {
$dataStr = stream_get_contents($data);
rewind($data);
} else {
$dataStr = $data;
}
$vcalendar = Reader::read($dataStr);
if (!$vcalendar instanceof VCalendar) {
return;
}
if ($this->sanitizeVCalendar($vcalendar)) {
$data = $vcalendar->serialize();
$modified = true;
}
// Enforce max resource size after sanitization
$finalSize = is_string($data) ? strlen($data) : strlen($dataStr);
if ($this->maxResourceSize > 0 && $finalSize > $this->maxResourceSize) {
throw new InsufficientStorage(
"Calendar object size ({$finalSize} bytes) exceeds limit ({$this->maxResourceSize} bytes)"
);
}
} catch (InsufficientStorage $e) {
// Re-throw size limit errors — these must reach the client as HTTP 507
throw $e;
} catch (\Exception $e) {
// Log other errors but don't block the request
error_log("[CalendarSanitizerPlugin] Error processing calendar object: " . $e->getMessage());
}
}
/**
* Sanitize a parsed VCalendar object in-place.
* Strips binary attachments and truncates oversized descriptions.
*
* Also called by ICSImportPlugin for direct DB writes that bypass
* the HTTP layer (and thus don't trigger beforeCreateFile hooks).
*
* @return bool True if the VCalendar was modified.
*/
public function sanitizeVCalendar(VCalendar $vcalendar)
{
$wasModified = false;
foreach ($vcalendar->getComponents() as $component) {
if ($component->name === 'VTIMEZONE') {
continue;
}
// Strip inline binary attachments
if ($this->stripBinaryAttachments && isset($component->ATTACH)) {
$toRemove = [];
foreach ($component->select('ATTACH') as $attach) {
$valueParam = $attach->offsetGet('VALUE');
$encodingParam = $attach->offsetGet('ENCODING');
if (
($valueParam && strtoupper((string)$valueParam) === 'BINARY') ||
($encodingParam && strtoupper((string)$encodingParam) === 'BASE64')
) {
$toRemove[] = $attach;
}
}
foreach ($toRemove as $attach) {
$component->remove($attach);
$wasModified = true;
}
}
// Truncate oversized long text properties (DESCRIPTION, X-ALT-DESC, COMMENT)
if ($this->maxDescriptionBytes > 0) {
foreach (self::LONG_TEXT_PROPERTIES as $prop) {
if (isset($component->{$prop})) {
$val = (string)$component->{$prop};
if (strlen($val) > $this->maxDescriptionBytes) {
$component->{$prop} = substr($val, 0, $this->maxDescriptionBytes) . '...';
$wasModified = true;
}
}
}
}
// Truncate oversized short text properties (SUMMARY, LOCATION)
foreach (self::SHORT_TEXT_PROPERTIES as $prop) {
if (isset($component->{$prop})) {
$val = (string)$component->{$prop};
if (strlen($val) > self::MAX_SHORT_TEXT_BYTES) {
$component->{$prop} = substr($val, 0, self::MAX_SHORT_TEXT_BYTES) . '...';
$wasModified = true;
}
}
}
}
return $wasModified;
}
/**
* Check that a VCalendar's serialized size is within the max resource limit.
* Called by ICSImportPlugin for the direct DB write path.
*
* @throws InsufficientStorage if the serialized size exceeds the limit.
*/
public function checkResourceSize(VCalendar $vcalendar)
{
if ($this->maxResourceSize <= 0) {
return;
}
$size = strlen($vcalendar->serialize());
if ($size > $this->maxResourceSize) {
throw new InsufficientStorage(
"Calendar object size ({$size} bytes) exceeds limit ({$this->maxResourceSize} bytes)"
);
}
}
public function getPluginInfo()
{
return [
'name' => $this->getPluginName(),
'description' => 'Sanitizes calendar data (strips binary attachments, truncates descriptions)',
];
}
}

View File

@@ -0,0 +1,278 @@
<?php
/**
* ICSImportPlugin - Bulk import events from a multi-event ICS file.
*
* Accepts a single POST with raw ICS data and splits it into individual
* calendar objects using Sabre\VObject\Splitter\ICalendar. Each split
* VCALENDAR is validated/repaired and inserted directly via the CalDAV
* PDO backend, avoiding N HTTP round-trips from Python.
*
* The endpoint is gated by a dedicated X-Calendars-Import header so that
* only the Python backend can call it (not future proxied CalDAV clients).
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\CalDAV\Backend\PDO as CalDAVBackend;
use Sabre\VObject;
class ICSImportPlugin extends ServerPlugin
{
/** @var Server */
protected $server;
/** @var CalDAVBackend */
private $caldavBackend;
/** @var string */
private $importApiKey;
public function __construct(CalDAVBackend $caldavBackend, string $importApiKey)
{
$this->caldavBackend = $caldavBackend;
$this->importApiKey = $importApiKey;
}
public function getPluginName()
{
return 'ics-import';
}
public function initialize(Server $server)
{
$this->server = $server;
// Priority 90: runs before the debug logger (50)
$server->on('method:POST', [$this, 'httpPost'], 90);
}
/**
* Handle POST requests with ?import query parameter.
*
* @return bool|null false to stop event propagation, null to let
* other handlers proceed.
*/
public function httpPost($request, $response)
{
// Only handle requests with ?import in the query string
$queryParams = $request->getQueryParameters();
if (!array_key_exists('import', $queryParams)) {
return;
}
// Verify the dedicated import header
$headerValue = $request->getHeader('X-Calendars-Import');
if (!$headerValue || $headerValue !== $this->importApiKey) {
$response->setStatus(403);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Forbidden: missing or invalid X-Calendars-Import header',
]));
return false;
}
// Resolve the calendar from the request path.
// getPath() returns a path relative to the base URI, e.g.
// "calendars/user@example.com/cal-uuid"
$path = $request->getPath();
$parts = explode('/', trim($path, '/'));
// Expect exactly: [calendars, <user>, <calendar-uri>]
if (count($parts) < 3 || $parts[0] !== 'calendars') {
error_log("[ICSImportPlugin] Invalid calendar path: " . $path);
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Invalid calendar path',
]));
return false;
}
$principalUser = urldecode($parts[1]);
$calendarUri = $parts[2];
$principalUri = 'principals/' . $principalUser;
// Look up calendarId by iterating the user's calendars
$calendarId = $this->resolveCalendarId($principalUri, $calendarUri);
if ($calendarId === null) {
$response->setStatus(404);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Calendar not found',
]));
return false;
}
// Read and parse the raw ICS body
$icsBody = $request->getBodyAsString();
if (empty($icsBody)) {
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Empty request body',
]));
return false;
}
try {
$vcal = VObject\Reader::read($icsBody);
} catch (\Exception $e) {
error_log("[ICSImportPlugin] Failed to parse ICS: " . $e->getMessage());
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Failed to parse ICS file',
]));
return false;
}
// Validate and auto-repair (fixes missing VALARM ACTION, etc.)
$vcal->validate(VObject\Component::REPAIR);
// Split by UID using the stream-based splitter
// The splitter expects a stream, so we wrap the serialized data
$stream = fopen('php://temp', 'r+');
fwrite($stream, $vcal->serialize());
rewind($stream);
$splitter = new VObject\Splitter\ICalendar($stream);
$totalEvents = 0;
$importedCount = 0;
$duplicateCount = 0;
$skippedCount = 0;
$errors = [];
while ($splitVcal = $splitter->getNext()) {
$totalEvents++;
try {
// Extract UID from the first VEVENT
$uid = null;
foreach ($splitVcal->VEVENT as $vevent) {
if (isset($vevent->UID)) {
$uid = (string)$vevent->UID;
break;
}
}
if (!$uid) {
$uid = \Sabre\DAV\UUIDUtil::getUUID();
}
// Sanitize event data (strip attachments, truncate descriptions)
// and enforce max resource size
$this->sanitizeAndCheckSize($splitVcal);
$objectUri = $uid . '.ics';
$data = $splitVcal->serialize();
$this->caldavBackend->createCalendarObject(
$calendarId,
$objectUri,
$data
);
$importedCount++;
} catch (\Exception $e) {
$msg = $e->getMessage();
$summary = '';
if (isset($splitVcal->VEVENT) && isset($splitVcal->VEVENT->SUMMARY)) {
$summary = (string)$splitVcal->VEVENT->SUMMARY;
}
// Duplicate key (SQLSTATE 23505) = event already exists
// "no valid instances" = dead recurring event (all occurrences excluded)
// Neither is actionable by the user, skip silently.
if (strpos($msg, '23505') !== false) {
$duplicateCount++;
} elseif (strpos($msg, 'valid instances') !== false) {
$skippedCount++;
} else {
$skippedCount++;
if (count($errors) < 10) {
$errors[] = [
'uid' => $uid ?? 'unknown',
'summary' => $summary,
'error' => $msg,
];
}
error_log(
"[ICSImportPlugin] Failed to import event "
. "uid=" . ($uid ?? 'unknown')
. " summary={$summary}: {$msg}"
);
}
}
}
fclose($stream);
error_log(
"[ICSImportPlugin] Import complete: "
. "{$importedCount} imported, "
. "{$duplicateCount} duplicates, "
. "{$skippedCount} failed "
. "out of {$totalEvents} total"
);
$response->setStatus(200);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'total_events' => $totalEvents,
'imported_count' => $importedCount,
'duplicate_count' => $duplicateCount,
'skipped_count' => $skippedCount,
'errors' => $errors,
]));
return false;
}
/**
* Sanitize a split VCALENDAR before import and enforce max resource size.
*
* Delegates to CalendarSanitizerPlugin (if registered). Import bypasses
* the HTTP layer (uses createCalendarObject directly), so beforeCreateFile
* hooks don't fire — we must call the sanitizer explicitly.
*
* @throws \Exception if the sanitized object exceeds the max resource size.
*/
private function sanitizeAndCheckSize(VObject\Component\VCalendar $vcal)
{
$sanitizer = $this->server->getPlugin('calendar-sanitizer');
if ($sanitizer) {
$sanitizer->sanitizeVCalendar($vcal);
$sanitizer->checkResourceSize($vcal);
}
}
/**
* Resolve the internal calendar ID (the [calendarId, instanceId] pair)
* from a principal URI and calendar URI.
*
* @param string $principalUri e.g. "principals/user@example.com"
* @param string $calendarUri e.g. "a1b2c3d4-..."
* @return array|null The calendarId pair, or null if not found.
*/
private function resolveCalendarId(string $principalUri, string $calendarUri)
{
$calendars = $this->caldavBackend->getCalendarsForUser($principalUri);
foreach ($calendars as $calendar) {
if ($calendar['uri'] === $calendarUri) {
return $calendar['id'];
}
}
return null;
}
public function getPluginInfo()
{
return [
'name' => $this->getPluginName(),
'description' => 'Bulk import events from a multi-event ICS file',
];
}
}