✨(import) add import modal
Synchronous for now, can be offloaded to worker later. Also lint the codebase
This commit is contained in:
@@ -85,7 +85,7 @@ PHP SabreDAV server providing CalDAV protocol support, running against the share
|
|||||||
- Backend API: http://localhost:8921
|
- Backend API: http://localhost:8921
|
||||||
- CalDAV: http://localhost:8922
|
- CalDAV: http://localhost:8922
|
||||||
- Keycloak: http://localhost:8925
|
- Keycloak: http://localhost:8925
|
||||||
- PostgreSQL: 8912
|
- PostgreSQL: 8926
|
||||||
- Mailcatcher: http://localhost:1081
|
- Mailcatcher: http://localhost:1081
|
||||||
|
|
||||||
## Key Technologies
|
## Key Technologies
|
||||||
|
|||||||
1
Makefile
1
Makefile
@@ -108,6 +108,7 @@ build: cache ?= # --no-cache
|
|||||||
build: ## build the project containers
|
build: ## build the project containers
|
||||||
@$(MAKE) build-backend cache=$(cache)
|
@$(MAKE) build-backend cache=$(cache)
|
||||||
@$(MAKE) build-frontend cache=$(cache)
|
@$(MAKE) build-frontend cache=$(cache)
|
||||||
|
@$(MAKE) build-caldav cache=$(cache)
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
|
||||||
build-backend: cache ?=
|
build-backend: cache ?=
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ services:
|
|||||||
postgresql:
|
postgresql:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
ports:
|
ports:
|
||||||
- "8912:5432"
|
- "8926:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
|
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
|
||||||
interval: 1s
|
interval: 1s
|
||||||
@@ -138,6 +138,11 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- env.d/development/caldav.defaults
|
- env.d/development/caldav.defaults
|
||||||
- env.d/development/caldav.local
|
- env.d/development/caldav.local
|
||||||
|
volumes:
|
||||||
|
- ./docker/sabredav/server.php:/var/www/sabredav/server.php
|
||||||
|
- ./docker/sabredav/src:/var/www/sabredav/src
|
||||||
|
- ./docker/sabredav/sql:/var/www/sabredav/sql
|
||||||
|
- ./docker/sabredav/init-database.sh:/usr/local/bin/init-database.sh
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- lasuite
|
- lasuite
|
||||||
|
|||||||
@@ -21,10 +21,8 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
|||||||
# Create application directory
|
# Create application directory
|
||||||
WORKDIR /var/www/sabredav
|
WORKDIR /var/www/sabredav
|
||||||
|
|
||||||
# Copy composer files
|
# Copy composer files and install dependencies
|
||||||
COPY composer.json ./
|
COPY composer.json ./
|
||||||
|
|
||||||
# Install sabre/dav and dependencies
|
|
||||||
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
||||||
|
|
||||||
# Copy server configuration
|
# 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 \
|
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 "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_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
|
# Set permissions
|
||||||
RUN chown -R www-data:www-data /var/www/sabredav \
|
RUN chown -R www-data:www-data /var/www/sabredav \
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
"name": "calendars/sabredav-server",
|
"name": "calendars/sabredav-server",
|
||||||
"description": "sabre/dav CalDAV server for calendars",
|
"description": "sabre/dav CalDAV server for calendars",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "https://github.com/sylvinus/dav"
|
||||||
|
}
|
||||||
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.1",
|
"php": ">=8.1",
|
||||||
"sabre/dav": "^4.5",
|
"sabre/dav": "dev-master",
|
||||||
"ext-pdo": "*",
|
"ext-pdo": "*",
|
||||||
"ext-pdo_pgsql": "*"
|
"ext-pdo_pgsql": "*"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ use Sabre\DAV;
|
|||||||
use Calendars\SabreDav\AutoCreatePrincipalBackend;
|
use Calendars\SabreDav\AutoCreatePrincipalBackend;
|
||||||
use Calendars\SabreDav\HttpCallbackIMipPlugin;
|
use Calendars\SabreDav\HttpCallbackIMipPlugin;
|
||||||
use Calendars\SabreDav\ApiKeyAuthBackend;
|
use Calendars\SabreDav\ApiKeyAuthBackend;
|
||||||
|
use Calendars\SabreDav\CalendarSanitizerPlugin;
|
||||||
use Calendars\SabreDav\AttendeeNormalizerPlugin;
|
use Calendars\SabreDav\AttendeeNormalizerPlugin;
|
||||||
|
use Calendars\SabreDav\ICSImportPlugin;
|
||||||
|
|
||||||
// Composer autoloader
|
// Composer autoloader
|
||||||
require_once __DIR__ . '/vendor/autoload.php';
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
@@ -89,39 +91,56 @@ $server->addPlugin(new CalDAV\ICSExportPlugin());
|
|||||||
$server->addPlugin(new DAV\Sharing\Plugin());
|
$server->addPlugin(new DAV\Sharing\Plugin());
|
||||||
$server->addPlugin(new CalDAV\SharingPlugin());
|
$server->addPlugin(new CalDAV\SharingPlugin());
|
||||||
|
|
||||||
// Debug logging for sharing requests
|
// Debug logging for POST requests - commented out to avoid PII in logs
|
||||||
$server->on('method:POST', function($request) {
|
// Uncomment for local debugging only, never in production.
|
||||||
$contentType = $request->getHeader('Content-Type');
|
// $server->on('method:POST', function($request) {
|
||||||
$path = $request->getPath();
|
// $contentType = $request->getHeader('Content-Type');
|
||||||
$body = $request->getBodyAsString();
|
// $path = $request->getPath();
|
||||||
error_log("[sabre/dav] POST request received:");
|
// $body = $request->getBodyAsString();
|
||||||
error_log("[sabre/dav] Path: " . $path);
|
// error_log("[sabre/dav] POST request received:");
|
||||||
error_log("[sabre/dav] Content-Type: " . $contentType);
|
// error_log("[sabre/dav] Path: " . $path);
|
||||||
error_log("[sabre/dav] Body: " . substr($body, 0, 1000));
|
// error_log("[sabre/dav] Content-Type: " . $contentType);
|
||||||
// Reset body stream position
|
// error_log("[sabre/dav] Body: " . substr($body, 0, 1000));
|
||||||
$request->setBody($body);
|
// $request->setBody($body);
|
||||||
}, 50); // Priority 50 to run early
|
// }, 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
|
// Log unhandled exceptions
|
||||||
$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
|
|
||||||
$server->on('exception', function($e) {
|
$server->on('exception', function($e) {
|
||||||
error_log("[sabre/dav] Exception: " . get_class($e) . " - " . $e->getMessage());
|
error_log("[sabre/dav] Exception: " . get_class($e) . " - " . $e->getMessage());
|
||||||
error_log("[sabre/dav] Exception trace: " . $e->getTraceAsString());
|
error_log("[sabre/dav] Exception trace: " . $e->getTraceAsString());
|
||||||
}, 50);
|
}, 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
|
// Add attendee normalizer plugin to fix duplicate attendees issue
|
||||||
// This plugin normalizes attendee emails (lowercase) and deduplicates them
|
// This plugin normalizes attendee emails (lowercase) and deduplicates them
|
||||||
// when processing calendar objects, fixing issues with REPLY handling
|
// when processing calendar objects, fixing issues with REPLY handling
|
||||||
$server->addPlugin(new AttendeeNormalizerPlugin());
|
$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
|
// Add custom IMipPlugin that forwards scheduling messages via HTTP callback
|
||||||
// This MUST be added BEFORE the Schedule\Plugin so that Schedule\Plugin finds it
|
// 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
|
// 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
|
// Hook into calendar object creation and updates
|
||||||
// Priority 90 to run before most other plugins but after authentication
|
// Priority 90 to run before most other plugins but after authentication
|
||||||
$server->on('beforeCreateFile', [$this, 'beforeWriteCalendarObject'], 90);
|
// Note: beforeCreateFile and beforeWriteContent have different signatures
|
||||||
$server->on('beforeWriteContent', [$this, 'beforeWriteCalendarObject'], 90);
|
$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 string $path The path to the file
|
||||||
* @param resource|string $data The data being written
|
* @param resource|string $data The data being written
|
||||||
@@ -73,18 +75,41 @@ class AttendeeNormalizerPlugin extends ServerPlugin
|
|||||||
* @param bool $modified Whether the data was modified
|
* @param bool $modified Whether the data was modified
|
||||||
* @return void
|
* @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
|
// Only process .ics files in calendar collections
|
||||||
if (!preg_match('/\.ics$/i', $path)) {
|
if (!preg_match('/\.ics$/i', $path)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if parent is a calendar collection
|
|
||||||
if ($parentNode && !($parentNode instanceof \Sabre\CalDAV\ICalendarObjectContainer)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the data as string
|
// Get the data as string
|
||||||
if (is_resource($data)) {
|
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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,4 +8,11 @@ CALDAV_INBOUND_API_KEY=changeme-inbound-in-production
|
|||||||
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
|
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
|
||||||
# Default callback URL for sending scheduling notifications (emails)
|
# Default callback URL for sending scheduling notifications (emails)
|
||||||
# Used when clients (like Apple Calendar) don't provide X-CalDAV-Callback-URL header
|
# Used when clients (like Apple Calendar) don't provide X-CalDAV-Callback-URL header
|
||||||
CALDAV_CALLBACK_URL=http://backend-dev:8000/api/v1.0/caldav-scheduling-callback
|
CALDAV_CALLBACK_URL=http://backend-dev:8000/api/v1.0/caldav-scheduling-callback
|
||||||
|
# Calendar sanitizer: strip inline binary attachments (base64 images from Outlook/Exchange)
|
||||||
|
# Applies to all CalDAV writes (client PUTs + import)
|
||||||
|
SANITIZER_STRIP_BINARY_ATTACHMENTS=true
|
||||||
|
# Calendar sanitizer: max DESCRIPTION size in bytes (0 = no limit)
|
||||||
|
SANITIZER_MAX_DESCRIPTION_BYTES=102400
|
||||||
|
# Calendar sanitizer: max total resource size in bytes per calendar object (0 = no limit)
|
||||||
|
SANITIZER_MAX_RESOURCE_SIZE=1048576
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
"""Client serializers for the calendars core app."""
|
"""Client serializers for the calendars core app."""
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -130,10 +127,17 @@ class CalendarSerializer(serializers.ModelSerializer):
|
|||||||
"description",
|
"description",
|
||||||
"is_default",
|
"is_default",
|
||||||
"is_visible",
|
"is_visible",
|
||||||
|
"caldav_path",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"is_default",
|
||||||
|
"caldav_path",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "is_default", "created_at", "updated_at"]
|
|
||||||
|
|
||||||
|
|
||||||
class CalendarCreateSerializer(serializers.ModelSerializer):
|
class CalendarCreateSerializer(serializers.ModelSerializer):
|
||||||
@@ -198,7 +202,7 @@ class CalendarSubscriptionTokenSerializer(serializers.ModelSerializer):
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
class CalendarSubscriptionTokenCreateSerializer(serializers.Serializer):
|
class CalendarSubscriptionTokenCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method
|
||||||
"""Serializer for creating a CalendarSubscriptionToken."""
|
"""Serializer for creating a CalendarSubscriptionToken."""
|
||||||
|
|
||||||
caldav_path = serializers.CharField(max_length=512)
|
caldav_path = serializers.CharField(max_length=512)
|
||||||
|
|||||||
@@ -4,32 +4,24 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.db import models as db
|
from django.db import models as db
|
||||||
from django.db import transaction
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
import rest_framework as drf
|
import rest_framework as drf
|
||||||
from corsheaders.middleware import (
|
from rest_framework import mixins, status, viewsets
|
||||||
ACCESS_CONTROL_ALLOW_METHODS,
|
|
||||||
ACCESS_CONTROL_ALLOW_ORIGIN,
|
|
||||||
)
|
|
||||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
|
||||||
from rest_framework import filters, mixins, status, viewsets
|
|
||||||
from rest_framework import response as drf_response
|
from rest_framework import response as drf_response
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.parsers import MultiPartParser
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.throttling import UserRateThrottle
|
from rest_framework.throttling import UserRateThrottle
|
||||||
from rest_framework_api_key.permissions import HasAPIKey
|
|
||||||
|
|
||||||
from core import enums, models
|
from core import models
|
||||||
from core.services.caldav_service import CalendarService
|
from core.services.caldav_service import CalendarService
|
||||||
|
from core.services.import_service import MAX_FILE_SIZE, ICSImportService
|
||||||
|
|
||||||
from . import permissions, serializers
|
from . import permissions, serializers
|
||||||
|
|
||||||
@@ -295,12 +287,14 @@ class CalendarViewSet(
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return calendars owned by or shared with the current user."""
|
"""Return calendars owned by or shared with the current user."""
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
owned = models.Calendar.objects.filter(owner=user)
|
|
||||||
shared_ids = models.CalendarShare.objects.filter(shared_with=user).values_list(
|
shared_ids = models.CalendarShare.objects.filter(shared_with=user).values_list(
|
||||||
"calendar_id", flat=True
|
"calendar_id", flat=True
|
||||||
)
|
)
|
||||||
shared = models.Calendar.objects.filter(id__in=shared_ids)
|
return (
|
||||||
return owned.union(shared).order_by("-is_default", "name")
|
models.Calendar.objects.filter(db.Q(owner=user) | db.Q(id__in=shared_ids))
|
||||||
|
.distinct()
|
||||||
|
.order_by("-is_default", "name")
|
||||||
|
)
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
if self.action == "create":
|
if self.action == "create":
|
||||||
@@ -327,7 +321,7 @@ class CalendarViewSet(
|
|||||||
instance.delete()
|
instance.delete()
|
||||||
|
|
||||||
@action(detail=True, methods=["patch"])
|
@action(detail=True, methods=["patch"])
|
||||||
def toggle_visibility(self, request, pk=None):
|
def toggle_visibility(self, request, **kwargs):
|
||||||
"""Toggle calendar visibility."""
|
"""Toggle calendar visibility."""
|
||||||
calendar = self.get_object()
|
calendar = self.get_object()
|
||||||
|
|
||||||
@@ -356,7 +350,7 @@ class CalendarViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
serializer_class=serializers.CalendarShareSerializer,
|
serializer_class=serializers.CalendarShareSerializer,
|
||||||
)
|
)
|
||||||
def share(self, request, pk=None):
|
def share(self, request, **kwargs):
|
||||||
"""Share calendar with another user."""
|
"""Share calendar with another user."""
|
||||||
calendar = self.get_object()
|
calendar = self.get_object()
|
||||||
|
|
||||||
@@ -396,6 +390,55 @@ class CalendarViewSet(
|
|||||||
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
|
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
detail=True,
|
||||||
|
methods=["post"],
|
||||||
|
parser_classes=[MultiPartParser],
|
||||||
|
url_path="import_events",
|
||||||
|
url_name="import-events",
|
||||||
|
)
|
||||||
|
def import_events(self, request, **kwargs):
|
||||||
|
"""Import events from an ICS file into this calendar."""
|
||||||
|
calendar = self.get_object()
|
||||||
|
|
||||||
|
# Only the owner can import events
|
||||||
|
if calendar.owner != request.user:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "Only the owner can import events"},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate file presence
|
||||||
|
if "file" not in request.FILES:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "No file provided"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
uploaded_file = request.FILES["file"]
|
||||||
|
|
||||||
|
# Validate file size
|
||||||
|
if uploaded_file.size > MAX_FILE_SIZE:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "File too large. Maximum size is 10 MB."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
ics_data = uploaded_file.read()
|
||||||
|
service = ICSImportService()
|
||||||
|
result = service.import_events(request.user, calendar, ics_data)
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
"total_events": result.total_events,
|
||||||
|
"imported_count": result.imported_count,
|
||||||
|
"duplicate_count": result.duplicate_count,
|
||||||
|
"skipped_count": result.skipped_count,
|
||||||
|
}
|
||||||
|
if result.errors:
|
||||||
|
response_data["errors"] = result.errors
|
||||||
|
|
||||||
|
return drf_response.Response(response_data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionTokenViewSet(viewsets.GenericViewSet):
|
class SubscriptionTokenViewSet(viewsets.GenericViewSet):
|
||||||
"""
|
"""
|
||||||
@@ -535,6 +578,7 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet):
|
|||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
serializer = self.get_serializer(token, context={"request": request})
|
serializer = self.get_serializer(token, context={"request": request})
|
||||||
return drf_response.Response(serializer.data)
|
return drf_response.Response(serializer.data)
|
||||||
elif request.method == "DELETE":
|
|
||||||
token.delete()
|
# DELETE
|
||||||
return drf_response.Response(status=status.HTTP_204_NO_CONTENT)
|
token.delete()
|
||||||
|
return drf_response.Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class CalDAVProxyView(View):
|
|||||||
Authentication is handled via session cookies instead.
|
Authentication is handled via session cookies instead.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs): # noqa: PLR0912 # pylint: disable=too-many-branches
|
||||||
"""Forward all HTTP methods to CalDAV server."""
|
"""Forward all HTTP methods to CalDAV server."""
|
||||||
# Handle CORS preflight requests
|
# Handle CORS preflight requests
|
||||||
if request.method == "OPTIONS":
|
if request.method == "OPTIONS":
|
||||||
@@ -247,7 +247,8 @@ class CalDAVSchedulingCallbackView(View):
|
|||||||
)
|
)
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status=400,
|
status=400,
|
||||||
content="Missing required headers: X-CalDAV-Sender, X-CalDAV-Recipient, X-CalDAV-Method",
|
content="Missing required headers: X-CalDAV-Sender, "
|
||||||
|
"X-CalDAV-Recipient, X-CalDAV-Method",
|
||||||
content_type="text/plain",
|
content_type="text/plain",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -291,20 +292,20 @@ class CalDAVSchedulingCallbackView(View):
|
|||||||
content="OK",
|
content="OK",
|
||||||
content_type="text/plain",
|
content_type="text/plain",
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
"Failed to send calendar %s email: %s -> %s",
|
|
||||||
method,
|
|
||||||
sender,
|
|
||||||
recipient,
|
|
||||||
)
|
|
||||||
return HttpResponse(
|
|
||||||
status=500,
|
|
||||||
content="Failed to send email",
|
|
||||||
content_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
logger.error(
|
||||||
|
"Failed to send calendar %s email: %s -> %s",
|
||||||
|
method,
|
||||||
|
sender,
|
||||||
|
recipient,
|
||||||
|
)
|
||||||
|
return HttpResponse(
|
||||||
|
status=500,
|
||||||
|
content="Failed to send email",
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
logger.exception("Error processing CalDAV scheduling callback: %s", e)
|
logger.exception("Error processing CalDAV scheduling callback: %s", e)
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status=500,
|
status=500,
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class CalDAVClient:
|
|||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
logger.warning("Calendar not found at path: %s", calendar_path)
|
logger.warning("Calendar not found at path: %s", calendar_path)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||||
logger.error("Failed to get calendar info from CalDAV: %s", str(e))
|
logger.error("Failed to get calendar info from CalDAV: %s", str(e))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -186,6 +186,25 @@ class CalDAVClient:
|
|||||||
logger.error("Failed to get events from CalDAV server: %s", str(e))
|
logger.error("Failed to get events from CalDAV server: %s", str(e))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def create_event_raw(self, user, calendar_path: str, ics_data: str) -> str:
|
||||||
|
"""
|
||||||
|
Create an event in CalDAV server from raw ICS data.
|
||||||
|
The ics_data should be a complete VCALENDAR string.
|
||||||
|
Returns the event UID.
|
||||||
|
"""
|
||||||
|
client = self._get_client(user)
|
||||||
|
calendar_url = f"{self.base_url}{calendar_path}"
|
||||||
|
calendar = client.calendar(url=calendar_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = calendar.save_event(ics_data)
|
||||||
|
event_uid = str(event.icalendar_component.get("uid", ""))
|
||||||
|
logger.info("Created event in CalDAV server: %s", event_uid)
|
||||||
|
return event_uid
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to create event in CalDAV server: %s", str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
def create_event(self, user, calendar_path: str, event_data: dict) -> str:
|
def create_event(self, user, calendar_path: str, event_data: dict) -> str:
|
||||||
"""
|
"""
|
||||||
Create a new event in CalDAV server.
|
Create a new event in CalDAV server.
|
||||||
@@ -353,7 +372,7 @@ class CalDAVClient:
|
|||||||
event_data["end"] = event_data["end"].strftime("%Y%m%d")
|
event_data["end"] = event_data["end"].strftime("%Y%m%d")
|
||||||
|
|
||||||
return event_data if event_data.get("uid") else None
|
return event_data if event_data.get("uid") else None
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||||
logger.warning("Failed to parse event: %s", str(e))
|
logger.warning("Failed to parse event: %s", str(e))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ from email import encoders
|
|||||||
from email.mime.base import MIMEBase
|
from email.mime.base import MIMEBase
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
# French month and day names for date formatting
|
# French month and day names for date formatting
|
||||||
FRENCH_DAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
|
FRENCH_DAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
|
||||||
FRENCH_MONTHS = [
|
FRENCH_MONTHS = [
|
||||||
@@ -35,15 +39,11 @@ FRENCH_MONTHS = [
|
|||||||
"décembre",
|
"décembre",
|
||||||
]
|
]
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.mail import EmailMultiAlternatives
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EventDetails:
|
class EventDetails: # pylint: disable=too-many-instance-attributes
|
||||||
"""Parsed event details from iCalendar data."""
|
"""Parsed event details from iCalendar data."""
|
||||||
|
|
||||||
uid: str
|
uid: str
|
||||||
@@ -127,10 +127,9 @@ class ICalendarParser:
|
|||||||
if params_str:
|
if params_str:
|
||||||
# Split by ; but not within quotes
|
# Split by ; but not within quotes
|
||||||
param_matches = re.findall(r";([^=]+)=([^;]+)", params_str)
|
param_matches = re.findall(r";([^=]+)=([^;]+)", params_str)
|
||||||
for param_name, param_value in param_matches:
|
for param_name, raw_value in param_matches:
|
||||||
# Remove quotes if present
|
# Remove quotes if present
|
||||||
param_value = param_value.strip('"')
|
params[param_name.upper()] = raw_value.strip('"')
|
||||||
params[param_name.upper()] = param_value
|
|
||||||
|
|
||||||
return value, params
|
return value, params
|
||||||
|
|
||||||
@@ -160,13 +159,17 @@ class ICalendarParser:
|
|||||||
elif tzid:
|
elif tzid:
|
||||||
# Has timezone info - try to convert using zoneinfo
|
# Has timezone info - try to convert using zoneinfo
|
||||||
try:
|
try:
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
||||||
|
ZoneInfo,
|
||||||
|
)
|
||||||
|
|
||||||
tz = ZoneInfo(tzid)
|
tz = ZoneInfo(tzid)
|
||||||
dt = dt.replace(tzinfo=tz)
|
dt = dt.replace(tzinfo=tz)
|
||||||
except Exception:
|
except (KeyError, ValueError):
|
||||||
# If timezone conversion fails, keep as naive datetime
|
# If timezone conversion fails, keep as naive datetime
|
||||||
pass
|
logger.debug(
|
||||||
|
"Unknown timezone %s, keeping naive datetime", tzid
|
||||||
|
)
|
||||||
return dt
|
return dt
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
@@ -175,7 +178,9 @@ class ICalendarParser:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse(cls, icalendar: str, recipient_email: str) -> Optional[EventDetails]:
|
def parse( # pylint: disable=too-many-locals,too-many-branches
|
||||||
|
cls, icalendar: str, recipient_email: str
|
||||||
|
) -> Optional[EventDetails]:
|
||||||
"""
|
"""
|
||||||
Parse iCalendar data and extract event details.
|
Parse iCalendar data and extract event details.
|
||||||
|
|
||||||
@@ -272,12 +277,12 @@ class ICalendarParser:
|
|||||||
raw_icalendar=icalendar,
|
raw_icalendar=icalendar,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
logger.exception("Failed to parse iCalendar data: %s", e)
|
logger.exception("Failed to parse iCalendar data: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class CalendarInvitationService:
|
class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
|
||||||
"""
|
"""
|
||||||
Service for sending calendar invitation emails.
|
Service for sending calendar invitation emails.
|
||||||
|
|
||||||
@@ -369,7 +374,7 @@ class CalendarInvitationService:
|
|||||||
event_uid=event.uid,
|
event_uid=event.uid,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to send calendar invitation to %s: %s", recipient, e
|
"Failed to send calendar invitation to %s: %s", recipient, e
|
||||||
)
|
)
|
||||||
@@ -456,7 +461,7 @@ class CalendarInvitationService:
|
|||||||
|
|
||||||
return icalendar_data
|
return icalendar_data
|
||||||
|
|
||||||
def _send_email(
|
def _send_email( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
self,
|
self,
|
||||||
from_email: str,
|
from_email: str,
|
||||||
to_email: str,
|
to_email: str,
|
||||||
@@ -506,7 +511,7 @@ class CalendarInvitationService:
|
|||||||
"Content-Type", f"text/calendar; charset=utf-8; method={ics_method}"
|
"Content-Type", f"text/calendar; charset=utf-8; method={ics_method}"
|
||||||
)
|
)
|
||||||
ics_attachment.add_header(
|
ics_attachment.add_header(
|
||||||
"Content-Disposition", f'attachment; filename="invite.ics"'
|
"Content-Disposition", 'attachment; filename="invite.ics"'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Attach the ICS file
|
# Attach the ICS file
|
||||||
@@ -524,7 +529,7 @@ class CalendarInvitationService:
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to send calendar invitation email to %s: %s", to_email, e
|
"Failed to send calendar invitation email to %s: %s", to_email, e
|
||||||
)
|
)
|
||||||
|
|||||||
115
src/backend/core/services/import_service.py
Normal file
115
src/backend/core/services/import_service.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Service for importing events from ICS files."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportResult:
|
||||||
|
"""Result of an ICS import operation.
|
||||||
|
|
||||||
|
errors contains event names (summaries) of failed events,
|
||||||
|
at most 10 entries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
total_events: int = 0
|
||||||
|
imported_count: int = 0
|
||||||
|
duplicate_count: int = 0
|
||||||
|
skipped_count: int = 0
|
||||||
|
errors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ICSImportService:
|
||||||
|
"""Service for importing events from ICS data into a CalDAV calendar.
|
||||||
|
|
||||||
|
Sends the raw ICS file in a single POST to the SabreDAV ICS import
|
||||||
|
plugin which handles splitting, validation/repair, and direct DB
|
||||||
|
insertion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = settings.CALDAV_URL.rstrip("/")
|
||||||
|
|
||||||
|
def import_events(self, user, calendar, ics_data: bytes) -> ImportResult:
|
||||||
|
"""Import events from ICS data into a calendar.
|
||||||
|
|
||||||
|
Sends the raw ICS bytes to SabreDAV's ?import endpoint which
|
||||||
|
handles all ICS parsing, splitting by UID, VALARM repair, and
|
||||||
|
per-event insertion.
|
||||||
|
"""
|
||||||
|
result = ImportResult()
|
||||||
|
|
||||||
|
# caldav_path already includes the base URI prefix
|
||||||
|
# e.g. /api/v1.0/caldav/calendars/user@example.com/uuid/
|
||||||
|
url = f"{self.base_url}{calendar.caldav_path}?import"
|
||||||
|
|
||||||
|
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
|
||||||
|
if not outbound_api_key:
|
||||||
|
result.errors.append("CALDAV_OUTBOUND_API_KEY is not configured")
|
||||||
|
return result
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "text/calendar",
|
||||||
|
"X-Api-Key": outbound_api_key,
|
||||||
|
"X-Forwarded-User": user.email,
|
||||||
|
"X-Calendars-Import": outbound_api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Timeout scales with file size: 60s base + 30s per MB of ICS data.
|
||||||
|
# 8000 events (~4MB) took ~70s in practice.
|
||||||
|
timeout = 60 + int(len(ics_data) / 1024 / 1024) * 30
|
||||||
|
response = requests.post(
|
||||||
|
url, data=ics_data, headers=headers, timeout=timeout
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.error("Failed to reach SabreDAV import endpoint: %s", exc)
|
||||||
|
result.errors.append("Failed to reach CalDAV server")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
"SabreDAV import returned %s: %s",
|
||||||
|
response.status_code,
|
||||||
|
response.text[:500],
|
||||||
|
)
|
||||||
|
result.errors.append("CalDAV server error")
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
logger.error("Invalid JSON from SabreDAV import: %s", response.text[:500])
|
||||||
|
result.errors.append("Invalid response from CalDAV server")
|
||||||
|
return result
|
||||||
|
|
||||||
|
result.total_events = data.get("total_events", 0)
|
||||||
|
result.imported_count = data.get("imported_count", 0)
|
||||||
|
result.duplicate_count = data.get("duplicate_count", 0)
|
||||||
|
result.skipped_count = data.get("skipped_count", 0)
|
||||||
|
|
||||||
|
# SabreDAV returns structured errors {uid, summary, error}.
|
||||||
|
# Log full details server-side, expose only event names to the frontend.
|
||||||
|
for err in data.get("errors", []):
|
||||||
|
if isinstance(err, dict):
|
||||||
|
logger.warning(
|
||||||
|
"Import failed for uid=%s summary=%s: %s",
|
||||||
|
err.get("uid", "?"),
|
||||||
|
err.get("summary", "?"),
|
||||||
|
err.get("error", "?"),
|
||||||
|
)
|
||||||
|
result.errors.append(
|
||||||
|
err.get("summary") or err.get("uid", "Unknown event")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result.errors.append(str(err))
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -16,7 +16,7 @@ User = get_user_model()
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
def provision_default_calendar(sender, instance, created, **kwargs):
|
def provision_default_calendar(sender, instance, created, **kwargs): # pylint: disable=unused-argument
|
||||||
"""
|
"""
|
||||||
Auto-provision a default calendar when a new user is created.
|
Auto-provision a default calendar when a new user is created.
|
||||||
"""
|
"""
|
||||||
@@ -35,7 +35,7 @@ def provision_default_calendar(sender, instance, created, **kwargs):
|
|||||||
service = CalendarService()
|
service = CalendarService()
|
||||||
service.create_default_calendar(instance)
|
service.create_default_calendar(instance)
|
||||||
logger.info("Created default calendar for user %s", instance.email)
|
logger.info("Created default calendar for user %s", instance.email)
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||||
# In tests, CalDAV server may not be available, so fail silently
|
# In tests, CalDAV server may not be available, so fail silently
|
||||||
# Check if it's a database error that suggests we're in tests
|
# Check if it's a database error that suggests we're in tests
|
||||||
error_str = str(e).lower()
|
error_str = str(e).lower()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.callback_data = callback_data
|
self.callback_data = callback_data
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self): # pylint: disable=invalid-name
|
||||||
"""Handle POST requests (scheduling callbacks)."""
|
"""Handle POST requests (scheduling callbacks)."""
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
body = self.rfile.read(content_length) if content_length > 0 else b""
|
body = self.rfile.read(content_length) if content_length > 0 else b""
|
||||||
@@ -44,9 +44,8 @@ class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(b"OK")
|
self.wfile.write(b"OK")
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
def log_message(self, format, *args): # pylint: disable=redefined-builtin
|
||||||
"""Suppress default logging."""
|
"""Suppress default logging."""
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def create_test_server() -> tuple:
|
def create_test_server() -> tuple:
|
||||||
@@ -79,7 +78,9 @@ class TestCalDAVScheduling:
|
|||||||
not settings.CALDAV_URL,
|
not settings.CALDAV_URL,
|
||||||
reason="CalDAV server URL not configured - integration test requires real server",
|
reason="CalDAV server URL not configured - integration test requires real server",
|
||||||
)
|
)
|
||||||
def test_scheduling_callback_received_when_creating_event_with_attendee(self):
|
def test_scheduling_callback_received_when_creating_event_with_attendee( # noqa: PLR0915 # pylint: disable=too-many-locals,too-many-statements
|
||||||
|
self,
|
||||||
|
):
|
||||||
"""Test that creating an event with an attendee triggers scheduling callback.
|
"""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,
|
This test verifies that when an event is created with an attendee via CalDAV,
|
||||||
@@ -114,7 +115,7 @@ class TestCalDAVScheduling:
|
|||||||
try:
|
try:
|
||||||
test_socket.connect(("127.0.0.1", port))
|
test_socket.connect(("127.0.0.1", port))
|
||||||
test_socket.close()
|
test_socket.close()
|
||||||
except Exception as e:
|
except OSError as e:
|
||||||
pytest.fail(f"Test server failed to start on port {port}: {e}")
|
pytest.fail(f"Test server failed to start on port {port}: {e}")
|
||||||
|
|
||||||
# Use the named test container hostname
|
# Use the named test container hostname
|
||||||
@@ -124,7 +125,7 @@ class TestCalDAVScheduling:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Create an event with an attendee
|
# Create an event with an attendee
|
||||||
client = service.caldav._get_client(organizer)
|
client = service.caldav._get_client(organizer) # pylint: disable=protected-access
|
||||||
calendar_url = f"{settings.CALDAV_URL}{calendar.caldav_path}"
|
calendar_url = f"{settings.CALDAV_URL}{calendar.caldav_path}"
|
||||||
|
|
||||||
# Add custom callback URL header to the client
|
# Add custom callback URL header to the client
|
||||||
@@ -159,7 +160,7 @@ END:VEVENT
|
|||||||
END:VCALENDAR"""
|
END:VCALENDAR"""
|
||||||
|
|
||||||
# Save event to trigger scheduling
|
# Save event to trigger scheduling
|
||||||
event = caldav_calendar.save_event(ical_content)
|
caldav_calendar.save_event(ical_content)
|
||||||
|
|
||||||
# Give the callback a moment to be called (scheduling may be async)
|
# Give the callback a moment to be called (scheduling may be async)
|
||||||
# sabre/dav processes scheduling synchronously during the request
|
# sabre/dav processes scheduling synchronously during the request
|
||||||
@@ -167,21 +168,24 @@ END:VCALENDAR"""
|
|||||||
|
|
||||||
# Verify callback was called
|
# Verify callback was called
|
||||||
assert callback_data["called"], (
|
assert callback_data["called"], (
|
||||||
"Scheduling callback was not called when creating event with attendee. "
|
"Scheduling callback was not called when creating event "
|
||||||
"This may indicate that sabre/dav's scheduling plugin is not working correctly. "
|
"with attendee. This may indicate that sabre/dav's "
|
||||||
|
"scheduling plugin is not working correctly. "
|
||||||
"Check CalDAV server logs for scheduling errors."
|
"Check CalDAV server logs for scheduling errors."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify callback request details
|
# Verify callback request details
|
||||||
request_data = callback_data["request_data"]
|
# pylint: disable=unsubscriptable-object
|
||||||
|
request_data: dict = callback_data["request_data"]
|
||||||
assert request_data is not None
|
assert request_data is not None
|
||||||
|
|
||||||
# Verify API key authentication
|
# Verify API key authentication
|
||||||
api_key = request_data["headers"].get("X-Api-Key", "")
|
api_key = request_data["headers"].get("X-Api-Key", "")
|
||||||
expected_key = settings.CALDAV_INBOUND_API_KEY
|
expected_key = settings.CALDAV_INBOUND_API_KEY
|
||||||
assert expected_key and secrets.compare_digest(api_key, expected_key), (
|
assert expected_key and secrets.compare_digest(api_key, expected_key), (
|
||||||
f"Callback request missing or invalid X-Api-Key header. "
|
"Callback request missing or invalid X-Api-Key header. "
|
||||||
f"Expected: {expected_key[:10]}..., Got: {api_key[:10] if api_key else 'None'}..."
|
f"Expected: {expected_key[:10]}..., "
|
||||||
|
f"Got: {api_key[:10] if api_key else 'None'}..."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify scheduling headers
|
# Verify scheduling headers
|
||||||
@@ -238,7 +242,7 @@ END:VCALENDAR"""
|
|||||||
|
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
pytest.skip("Calendar not found - CalDAV server may not be running")
|
pytest.skip("Calendar not found - CalDAV server may not be running")
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||||
pytest.fail(f"Failed to create event with attendee: {str(e)}")
|
pytest.fail(f"Failed to create event with attendee: {str(e)}")
|
||||||
finally:
|
finally:
|
||||||
# Shutdown server
|
# Shutdown server
|
||||||
|
|||||||
1088
src/backend/core/tests/test_import_events.py
Normal file
1088
src/backend/core/tests/test_import_events.py
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 670 B After Width: | Height: | Size: 621 B |
@@ -53,3 +53,37 @@ export const fetchAPI = async (
|
|||||||
|
|
||||||
throw new APIError(response.status);
|
throw new APIError(response.status);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchAPIFormData = async (
|
||||||
|
input: string,
|
||||||
|
init?: RequestInit & { params?: Record<string, string | number> },
|
||||||
|
) => {
|
||||||
|
const apiUrl = new URL(`${baseApiUrl("1.0")}${input}`);
|
||||||
|
if (init?.params) {
|
||||||
|
Object.entries(init.params).forEach(([key, value]) => {
|
||||||
|
apiUrl.searchParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const csrfToken = getCSRFToken();
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
...init,
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
...init?.headers,
|
||||||
|
...(csrfToken && { "X-CSRFToken": csrfToken }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.text();
|
||||||
|
|
||||||
|
if (isJson(data)) {
|
||||||
|
throw new APIError(response.status, JSON.parse(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new APIError(response.status);
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* API functions for calendar operations.
|
* API functions for calendar operations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchAPI } from "@/features/api/fetchApi";
|
import { fetchAPI, fetchAPIFormData } from "@/features/api/fetchApi";
|
||||||
|
|
||||||
export interface Calendar {
|
export interface Calendar {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,6 +11,7 @@ export interface Calendar {
|
|||||||
description: string;
|
description: string;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
is_visible: boolean;
|
is_visible: boolean;
|
||||||
|
caldav_path: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,3 +205,34 @@ export const deleteSubscriptionToken = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of an ICS import operation.
|
||||||
|
*/
|
||||||
|
export interface ImportEventsResult {
|
||||||
|
total_events: number;
|
||||||
|
imported_count: number;
|
||||||
|
duplicate_count: number;
|
||||||
|
skipped_count: number;
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import events from an ICS file into a calendar.
|
||||||
|
*/
|
||||||
|
export const importEventsApi = async (
|
||||||
|
calendarId: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<ImportEventsResult> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const response = await fetchAPIFormData(
|
||||||
|
`calendars/${calendarId}/import_events/`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const CalendarItemMenu = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onImport,
|
||||||
onSubscription,
|
onSubscription,
|
||||||
}: CalendarItemMenuProps) => {
|
}: CalendarItemMenuProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -28,6 +29,14 @@ export const CalendarItemMenu = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (onImport) {
|
||||||
|
items.push({
|
||||||
|
label: t("calendar.list.import"),
|
||||||
|
icon: <span className="material-icons">upload_file</span>,
|
||||||
|
callback: onImport,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (onSubscription) {
|
if (onSubscription) {
|
||||||
items.push({
|
items.push({
|
||||||
label: t("calendar.list.subscription"),
|
label: t("calendar.list.subscription"),
|
||||||
@@ -43,7 +52,7 @@ export const CalendarItemMenu = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [t, onEdit, onDelete, onSubscription]);
|
}, [t, onEdit, onDelete, onImport, onSubscription]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu options={options} isOpen={isOpen} onOpenChange={onOpenChange}>
|
<DropdownMenu options={options} isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
|||||||
@@ -389,3 +389,110 @@
|
|||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Import Events Modal Styles
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.import-events-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--c--theme--colors--greyscale-600);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__filename {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--c--theme--colors--greyscale-700);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__error {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result-header {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c--theme--colors--greyscale-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--success {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--neutral {
|
||||||
|
color: var(--c--theme--colors--greyscale-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__errors {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--c--theme--colors--greyscale-600);
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,22 +2,26 @@
|
|||||||
* CalendarList component - List of calendars with visibility toggles.
|
* CalendarList component - List of calendars with visibility toggles.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { Calendar } from "../../types";
|
import type { Calendar } from "../../types";
|
||||||
import { useCalendarContext } from "../../contexts";
|
import { useCalendarContext } from "../../contexts";
|
||||||
|
|
||||||
import { CalendarModal } from "./CalendarModal";
|
import { CalendarModal } from "./CalendarModal";
|
||||||
import { DeleteConfirmModal } from "./DeleteConfirmModal";
|
import { DeleteConfirmModal } from "./DeleteConfirmModal";
|
||||||
|
import { ImportEventsModal } from "./ImportEventsModal";
|
||||||
import { SubscriptionUrlModal } from "./SubscriptionUrlModal";
|
import { SubscriptionUrlModal } from "./SubscriptionUrlModal";
|
||||||
import { CalendarListItem, SharedCalendarListItem } from "./CalendarListItem";
|
import { CalendarListItem, SharedCalendarListItem } from "./CalendarListItem";
|
||||||
import { useCalendarListState } from "./hooks/useCalendarListState";
|
import { useCalendarListState } from "./hooks/useCalendarListState";
|
||||||
import type { CalendarListProps } from "./types";
|
import type { CalendarListProps } from "./types";
|
||||||
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
|
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
|
||||||
|
import { Calendar as DjangoCalendar, getCalendars } from "../../api";
|
||||||
|
|
||||||
export const CalendarList = ({ calendars }: CalendarListProps) => {
|
export const CalendarList = ({ calendars }: CalendarListProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const {
|
const {
|
||||||
davCalendars,
|
davCalendars,
|
||||||
visibleCalendarUrls,
|
visibleCalendarUrls,
|
||||||
@@ -26,6 +30,7 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
|
|||||||
updateCalendar,
|
updateCalendar,
|
||||||
deleteCalendar,
|
deleteCalendar,
|
||||||
shareCalendar,
|
shareCalendar,
|
||||||
|
calendarRef,
|
||||||
} = useCalendarContext();
|
} = useCalendarContext();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -108,6 +113,68 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
|
|||||||
// Ensure calendars is an array
|
// Ensure calendars is an array
|
||||||
const calendarsArray = Array.isArray(calendars) ? calendars : [];
|
const calendarsArray = Array.isArray(calendars) ? calendars : [];
|
||||||
|
|
||||||
|
// Import modal state
|
||||||
|
const [importModal, setImportModal] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
calendarId: string | null;
|
||||||
|
calendarName: string;
|
||||||
|
}>({ isOpen: false, calendarId: null, calendarName: "" });
|
||||||
|
|
||||||
|
const handleOpenImportModal = async (davCalendar: CalDavCalendar) => {
|
||||||
|
try {
|
||||||
|
// Extract the CalDAV path from the calendar URL
|
||||||
|
const url = new URL(davCalendar.url);
|
||||||
|
const pathParts = url.pathname.split("/").filter(Boolean);
|
||||||
|
const calendarsIndex = pathParts.findIndex((part) => part === "calendars");
|
||||||
|
|
||||||
|
if (calendarsIndex === -1 || pathParts.slice(calendarsIndex).length < 3) {
|
||||||
|
console.error("Invalid calendar URL format:", davCalendar.url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const caldavPath = "/" + pathParts.slice(calendarsIndex).join("/") + "/";
|
||||||
|
|
||||||
|
// Find the matching Django Calendar by caldav_path
|
||||||
|
const caldavApiRoot = "/api/v1.0/caldav";
|
||||||
|
const normalize = (p: string) =>
|
||||||
|
decodeURIComponent(p).replace(caldavApiRoot, "").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
const findCalendar = (cals: DjangoCalendar[]) =>
|
||||||
|
cals.find((cal) => normalize(cal.caldav_path) === normalize(caldavPath));
|
||||||
|
|
||||||
|
// Fetch fresh Django calendars to ensure newly created calendars are included.
|
||||||
|
// Uses React Query cache, forcing a refetch if stale.
|
||||||
|
const freshCalendars = await queryClient.fetchQuery({
|
||||||
|
queryKey: ["calendars"],
|
||||||
|
queryFn: getCalendars,
|
||||||
|
});
|
||||||
|
const djangoCalendar = findCalendar(freshCalendars);
|
||||||
|
|
||||||
|
if (!djangoCalendar) {
|
||||||
|
console.error("No matching Django calendar found for path:", caldavPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportModal({
|
||||||
|
isOpen: true,
|
||||||
|
calendarId: djangoCalendar.id,
|
||||||
|
calendarName: davCalendar.displayName || "",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse calendar URL:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseImportModal = () => {
|
||||||
|
setImportModal({ isOpen: false, calendarId: null, calendarName: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportSuccess = useCallback(() => {
|
||||||
|
if (calendarRef.current) {
|
||||||
|
calendarRef.current.refetchEvents();
|
||||||
|
}
|
||||||
|
}, [calendarRef]);
|
||||||
|
|
||||||
// Use translation key for shared marker
|
// Use translation key for shared marker
|
||||||
const sharedMarker = t('calendar.list.shared');
|
const sharedMarker = t('calendar.list.shared');
|
||||||
|
|
||||||
@@ -160,6 +227,7 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
|
|||||||
onMenuToggle={handleMenuToggle}
|
onMenuToggle={handleMenuToggle}
|
||||||
onEdit={handleOpenEditModal}
|
onEdit={handleOpenEditModal}
|
||||||
onDelete={handleOpenDeleteModal}
|
onDelete={handleOpenDeleteModal}
|
||||||
|
onImport={handleOpenImportModal}
|
||||||
onSubscription={handleOpenSubscriptionModal}
|
onSubscription={handleOpenSubscriptionModal}
|
||||||
onCloseMenu={handleCloseMenu}
|
onCloseMenu={handleCloseMenu}
|
||||||
/>
|
/>
|
||||||
@@ -231,6 +299,16 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
|
|||||||
onClose={handleCloseSubscriptionModal}
|
onClose={handleCloseSubscriptionModal}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{importModal.isOpen && importModal.calendarId && (
|
||||||
|
<ImportEventsModal
|
||||||
|
isOpen={importModal.isOpen}
|
||||||
|
calendarId={importModal.calendarId}
|
||||||
|
calendarName={importModal.calendarName}
|
||||||
|
onClose={handleCloseImportModal}
|
||||||
|
onImportSuccess={handleImportSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const CalendarListItem = ({
|
|||||||
onMenuToggle,
|
onMenuToggle,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onImport,
|
||||||
onSubscription,
|
onSubscription,
|
||||||
onCloseMenu,
|
onCloseMenu,
|
||||||
}: CalendarListItemProps) => {
|
}: CalendarListItemProps) => {
|
||||||
@@ -55,6 +56,9 @@ export const CalendarListItem = ({
|
|||||||
}
|
}
|
||||||
onEdit={() => onEdit(calendar)}
|
onEdit={() => onEdit(calendar)}
|
||||||
onDelete={() => onDelete(calendar)}
|
onDelete={() => onDelete(calendar)}
|
||||||
|
onImport={
|
||||||
|
onImport ? () => onImport(calendar) : undefined
|
||||||
|
}
|
||||||
onSubscription={
|
onSubscription={
|
||||||
onSubscription ? () => onSubscription(calendar) : undefined
|
onSubscription ? () => onSubscription(calendar) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* ImportEventsModal component.
|
||||||
|
* Allows users to import events from an ICS file into a calendar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button, Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react";
|
||||||
|
import { Spinner } from "@gouvfr-lasuite/ui-kit";
|
||||||
|
|
||||||
|
import { useImportEvents } from "../../hooks/useCalendars";
|
||||||
|
import type { ImportEventsResult } from "../../api";
|
||||||
|
|
||||||
|
interface ImportEventsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
calendarId: string;
|
||||||
|
calendarName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onImportSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImportEventsModal = ({
|
||||||
|
isOpen,
|
||||||
|
calendarId,
|
||||||
|
calendarName,
|
||||||
|
onClose,
|
||||||
|
onImportSuccess,
|
||||||
|
}: ImportEventsModalProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [result, setResult] = useState<ImportEventsResult | null>(null);
|
||||||
|
const importMutation = useImportEvents();
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0] ?? null;
|
||||||
|
setSelectedFile(file);
|
||||||
|
setResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
const importResult = await importMutation.mutateAsync({
|
||||||
|
calendarId,
|
||||||
|
file: selectedFile,
|
||||||
|
});
|
||||||
|
setResult(importResult);
|
||||||
|
if (importResult.imported_count > 0) {
|
||||||
|
onImportSuccess?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setResult(null);
|
||||||
|
importMutation.reset();
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasResult = result !== null;
|
||||||
|
const hasErrors = result && result.errors && result.errors.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
size={ModalSize.MEDIUM}
|
||||||
|
title={t("calendar.importEvents.title")}
|
||||||
|
rightActions={
|
||||||
|
hasResult ? (
|
||||||
|
<Button color="brand" onClick={handleClose}>
|
||||||
|
{t("calendar.subscription.close")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button color="neutral" onClick={handleClose}>
|
||||||
|
{t("calendar.event.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="brand"
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={!selectedFile || importMutation.isPending}
|
||||||
|
>
|
||||||
|
{importMutation.isPending
|
||||||
|
? <Spinner size="sm" />
|
||||||
|
: t("calendar.importEvents.import")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="import-events-modal">
|
||||||
|
<p className="import-events-modal__description">
|
||||||
|
{t("calendar.importEvents.description", { name: calendarName })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!hasResult && (
|
||||||
|
<div className="import-events-modal__file-section">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".ics,.ical,.ifb,.icalendar,text/calendar"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="neutral"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
icon={
|
||||||
|
<span className="material-icons">upload_file</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("calendar.importEvents.selectFile")}
|
||||||
|
</Button>
|
||||||
|
{selectedFile && (
|
||||||
|
<span className="import-events-modal__filename">
|
||||||
|
{selectedFile.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importMutation.isError && !hasResult && (
|
||||||
|
<div className="import-events-modal__error">
|
||||||
|
{t("calendar.importEvents.error")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasResult && (
|
||||||
|
<div className="import-events-modal__result">
|
||||||
|
<p className="import-events-modal__result-header">
|
||||||
|
{t("calendar.importEvents.resultHeader")}
|
||||||
|
</p>
|
||||||
|
<ul className="import-events-modal__stats">
|
||||||
|
{result.imported_count > 0 && (
|
||||||
|
<li className="import-events-modal__stat import-events-modal__stat--success">
|
||||||
|
<span className="material-icons">check_circle</span>
|
||||||
|
<span><strong>{result.imported_count}</strong> {t("calendar.importEvents.imported")}</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{result.duplicate_count > 0 && (
|
||||||
|
<li className="import-events-modal__stat import-events-modal__stat--neutral">
|
||||||
|
<span className="material-icons">content_copy</span>
|
||||||
|
<span><strong>{result.duplicate_count}</strong> {t("calendar.importEvents.duplicates")}</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{result.skipped_count > 0 && (
|
||||||
|
<li className="import-events-modal__stat import-events-modal__stat--warning">
|
||||||
|
<span className="material-icons">warning_amber</span>
|
||||||
|
<span><strong>{result.skipped_count}</strong> {t("calendar.importEvents.skipped")}</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
{hasErrors && (
|
||||||
|
<details className="import-events-modal__errors">
|
||||||
|
<summary>{t("calendar.importEvents.errorDetails")}</summary>
|
||||||
|
<ul>
|
||||||
|
{result.errors!.map((error, index) => (
|
||||||
|
<li key={index}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -25,6 +25,7 @@ export interface CalendarItemMenuProps {
|
|||||||
onOpenChange: (isOpen: boolean) => void;
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onImport?: () => void;
|
||||||
onSubscription?: () => void;
|
onSubscription?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export interface CalendarListItemProps {
|
|||||||
onMenuToggle: (url: string) => void;
|
onMenuToggle: (url: string) => void;
|
||||||
onEdit: (calendar: CalDavCalendar) => void;
|
onEdit: (calendar: CalDavCalendar) => void;
|
||||||
onDelete: (calendar: CalDavCalendar) => void;
|
onDelete: (calendar: CalDavCalendar) => void;
|
||||||
|
onImport?: (calendar: CalDavCalendar) => void;
|
||||||
onSubscription?: (calendar: CalDavCalendar) => void;
|
onSubscription?: (calendar: CalDavCalendar) => void;
|
||||||
onCloseMenu: () => void;
|
onCloseMenu: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
getCalendars,
|
getCalendars,
|
||||||
getSubscriptionToken,
|
getSubscriptionToken,
|
||||||
GetSubscriptionTokenResult,
|
GetSubscriptionTokenResult,
|
||||||
|
importEventsApi,
|
||||||
|
ImportEventsResult,
|
||||||
SubscriptionToken,
|
SubscriptionToken,
|
||||||
SubscriptionTokenError,
|
SubscriptionTokenError,
|
||||||
SubscriptionTokenParams,
|
SubscriptionTokenParams,
|
||||||
@@ -132,3 +134,21 @@ export const useDeleteSubscriptionToken = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to import events from an ICS file.
|
||||||
|
*/
|
||||||
|
export const useImportEvents = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
ImportEventsResult,
|
||||||
|
Error,
|
||||||
|
{ calendarId: string; file: File }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ calendarId, file }) => importEventsApi(calendarId, file),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: CALENDARS_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -182,7 +182,21 @@
|
|||||||
"showCalendar": "Show calendar",
|
"showCalendar": "Show calendar",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"subscription": "Subscription URL"
|
"import": "Import events",
|
||||||
|
"subscription": "Subscription URL",
|
||||||
|
"options": "Options"
|
||||||
|
},
|
||||||
|
"importEvents": {
|
||||||
|
"title": "Import events",
|
||||||
|
"description": "Import events from an ICS file into \"{{name}}\".",
|
||||||
|
"selectFile": "Select file",
|
||||||
|
"import": "Import",
|
||||||
|
"resultHeader": "Import results",
|
||||||
|
"imported": "events imported",
|
||||||
|
"duplicates": "events already existed",
|
||||||
|
"skipped": "events skipped (unsupported format)",
|
||||||
|
"error": "An error occurred during import. Please try again.",
|
||||||
|
"errorDetails": "Error details"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Calendar Subscription URL",
|
"title": "Calendar Subscription URL",
|
||||||
@@ -793,7 +807,21 @@
|
|||||||
"showCalendar": "Afficher le calendrier",
|
"showCalendar": "Afficher le calendrier",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"subscription": "URL d'abonnement"
|
"import": "Importer des événements",
|
||||||
|
"subscription": "URL d'abonnement",
|
||||||
|
"options": "Options"
|
||||||
|
},
|
||||||
|
"importEvents": {
|
||||||
|
"title": "Importer des événements",
|
||||||
|
"description": "Importer des événements depuis un fichier ICS dans \"{{name}}\".",
|
||||||
|
"selectFile": "Choisir un fichier",
|
||||||
|
"import": "Importer",
|
||||||
|
"resultHeader": "Résultats de l'import",
|
||||||
|
"imported": "événements importés",
|
||||||
|
"duplicates": "événements existaient déjà",
|
||||||
|
"skipped": "événements ignorés (format non supporté)",
|
||||||
|
"error": "Une erreur est survenue lors de l'importation. Veuillez réessayer.",
|
||||||
|
"errorDetails": "Détails des erreurs"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "URL d'abonnement au calendrier",
|
"title": "URL d'abonnement au calendrier",
|
||||||
@@ -1151,7 +1179,21 @@
|
|||||||
"showCalendar": "Agenda tonen",
|
"showCalendar": "Agenda tonen",
|
||||||
"edit": "Bewerken",
|
"edit": "Bewerken",
|
||||||
"delete": "Verwijderen",
|
"delete": "Verwijderen",
|
||||||
"subscription": "Abonnements-URL"
|
"import": "Evenementen importeren",
|
||||||
|
"subscription": "Abonnements-URL",
|
||||||
|
"options": "Opties"
|
||||||
|
},
|
||||||
|
"importEvents": {
|
||||||
|
"title": "Evenementen importeren",
|
||||||
|
"description": "Importeer evenementen vanuit een ICS-bestand in \"{{name}}\".",
|
||||||
|
"selectFile": "Bestand kiezen",
|
||||||
|
"import": "Importeren",
|
||||||
|
"resultHeader": "Importresultaten",
|
||||||
|
"imported": "evenementen geïmporteerd",
|
||||||
|
"duplicates": "evenementen bestonden al",
|
||||||
|
"skipped": "evenementen overgeslagen (niet-ondersteund formaat)",
|
||||||
|
"error": "Er is een fout opgetreden tijdens het importeren. Probeer het opnieuw.",
|
||||||
|
"errorDetails": "Foutdetails"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Agenda-abonnements-URL",
|
"title": "Agenda-abonnements-URL",
|
||||||
|
|||||||
Reference in New Issue
Block a user