✨(import) add import modal
Synchronous for now, can be offloaded to worker later. Also lint the codebase
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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": "*"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
234
docker/sabredav/src/CalendarSanitizerPlugin.php
Normal file
234
docker/sabredav/src/CalendarSanitizerPlugin.php
Normal 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)',
|
||||
];
|
||||
}
|
||||
}
|
||||
278
docker/sabredav/src/ICSImportPlugin.php
Normal file
278
docker/sabredav/src/ICSImportPlugin.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user