✨(all) add organizations, resources, channels, and infra migration (#34)
Add multi-tenant organization model populated from OIDC claims with org-scoped user discovery, CalDAV principal filtering, and cross-org isolation at the SabreDAV layer. Add bookable resource principals (rooms, equipment) with CalDAV auto-scheduling that handles conflict detection, auto-accept/decline, and org-scoped booking enforcement. Fixes #14. Replace CalendarSubscriptionToken with a unified Channel model supporting CalDAV integration tokens and iCal feed URLs, with encrypted token storage and role-based access control. Fixes #16. Migrate task queue from Celery to Dramatiq with async ICS import, progress tracking, and task status polling endpoint. Replace nginx with Caddy for both the reverse proxy and frontend static serving. Switch frontend package manager from yarn/pnpm to npm and upgrade Node to 24, Next.js to 16, TypeScript to 5.9. Harden security with fail-closed entitlements, RSVP rate limiting and token expiry, CalDAV proxy path validation blocking internal API routes, channel path scope enforcement, and ETag-based conflict prevention. Add frontend pages for resource management and integration channel CRUD, with resource booking in the event modal. Restructure CalDAV paths to /calendars/users/ and /calendars/resources/ with nested principal collections in SabreDAV.
This commit is contained in:
@@ -694,16 +694,16 @@
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
||||
"redirectUris": [
|
||||
"http://localhost:8920/*",
|
||||
"http://localhost:8921/*",
|
||||
"http://localhost:8922/*",
|
||||
"http://localhost:8923/*"
|
||||
"http://localhost:8930/*",
|
||||
"http://localhost:8931/*",
|
||||
"http://localhost:8932/*",
|
||||
"http://localhost:8933/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:8920",
|
||||
"http://localhost:8921",
|
||||
"http://localhost:8922",
|
||||
"http://localhost:8923"
|
||||
"http://localhost:8930",
|
||||
"http://localhost:8931",
|
||||
"http://localhost:8932",
|
||||
"http://localhost:8933"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
@@ -719,7 +719,7 @@
|
||||
"access.token.lifespan": "-1",
|
||||
"client.secret.creation.time": "1707820779",
|
||||
"user.info.response.signature.alg": "RS256",
|
||||
"post.logout.redirect.uris": "http://localhost:8920/*##http://localhost:8921/*",
|
||||
"post.logout.redirect.uris": "http://localhost:8930/*##http://localhost:8931/*",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"use.jwks.url": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
@@ -765,16 +765,16 @@
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
||||
"redirectUris": [
|
||||
"http://localhost:8920/*",
|
||||
"http://localhost:8921/*",
|
||||
"http://localhost:8922/*",
|
||||
"http://localhost:8923/*"
|
||||
"http://localhost:8930/*",
|
||||
"http://localhost:8931/*",
|
||||
"http://localhost:8932/*",
|
||||
"http://localhost:8933/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:8920",
|
||||
"http://localhost:8921",
|
||||
"http://localhost:8922",
|
||||
"http://localhost:8923"
|
||||
"http://localhost:8930",
|
||||
"http://localhost:8931",
|
||||
"http://localhost:8932",
|
||||
"http://localhost:8933"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
@@ -790,7 +790,7 @@
|
||||
"access.token.lifespan": "-1",
|
||||
"client.secret.creation.time": "1707820779",
|
||||
"user.info.response.signature.alg": "RS256",
|
||||
"post.logout.redirect.uris": "http://localhost:8920/*##http://localhost:8921/*",
|
||||
"post.logout.redirect.uris": "http://localhost:8930/*##http://localhost:8931/*",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"use.jwks.url": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
server {
|
||||
listen 8083;
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
# API routes - proxy to Django backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend-dev:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# RSVP and iCal routes - proxy to Django backend
|
||||
location /rsvp/ {
|
||||
proxy_pass http://backend-dev:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ical/ {
|
||||
proxy_pass http://backend-dev:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Frontend - proxy to Next.js dev server
|
||||
location / {
|
||||
proxy_pass http://frontend-dev:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support for HMR
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
|
||||
server {
|
||||
listen 8923;
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
# Keycloak - all auth-related paths
|
||||
location / {
|
||||
proxy_pass http://keycloak:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
# sabre/dav CalDAV Server
|
||||
# Based on Debian with Apache and PHP
|
||||
FROM php:8.2-apache-bookworm
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libpq-dev \
|
||||
postgresql-client \
|
||||
git \
|
||||
unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install pdo pdo_pgsql
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Create application directory
|
||||
WORKDIR /var/www/sabredav
|
||||
|
||||
# Copy composer files and install dependencies
|
||||
COPY composer.json ./
|
||||
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
# Copy server configuration
|
||||
COPY server.php ./
|
||||
COPY sabredav.conf /etc/apache2/sites-available/sabredav.conf
|
||||
COPY init-database.sh /usr/local/bin/init-database.sh
|
||||
|
||||
# Copy SQL schema files for database initialization
|
||||
COPY sql/ ./sql/
|
||||
|
||||
# Copy custom principal backend
|
||||
COPY src/ ./src/
|
||||
|
||||
# Enable Apache modules and site
|
||||
RUN a2enmod rewrite headers \
|
||||
&& a2dissite 000-default \
|
||||
&& a2ensite sabredav \
|
||||
&& chmod +x /usr/local/bin/init-database.sh
|
||||
|
||||
# Configure PHP error logging to stderr for Docker logs
|
||||
# This ensures all error_log() calls and PHP errors are visible in docker logs
|
||||
# display_errors = Off prevents errors from appearing in HTTP responses (security/UX)
|
||||
# but errors are still logged to stderr (Docker logs) via log_errors = On
|
||||
RUN echo "log_errors = On" >> /usr/local/etc/php/conf.d/error-logging.ini \
|
||||
&& echo "error_log = /proc/self/fd/2" >> /usr/local/etc/php/conf.d/error-logging.ini \
|
||||
&& echo "display_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini \
|
||||
&& echo "display_startup_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini \
|
||||
&& echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/error-logging.ini
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R www-data:www-data /var/www/sabredav \
|
||||
&& chmod -R 755 /var/www/sabredav
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["apache2-foreground"]
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "calendars/sabredav-server",
|
||||
"description": "sabre/dav CalDAV server for calendars",
|
||||
"type": "project",
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/sylvinus/dav"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"sabre/dav": "dev-master",
|
||||
"ext-pdo": "*",
|
||||
"ext-pdo_pgsql": "*"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Calendars\\SabreDav\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/bin/bash
|
||||
###
|
||||
# Initialize sabre/dav database schema in PostgreSQL
|
||||
# This script creates all necessary tables for sabre/dav to work
|
||||
###
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z ${PGHOST+x} ]; then
|
||||
echo "PGHOST must be set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z ${PGDATABASE+x} ]; then
|
||||
echo "PGDATABASE must be set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z ${PGUSER+x} ]; then
|
||||
echo "PGUSER must be set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z ${PGPASSWORD+x} ]; then
|
||||
echo "PGPASSWORD must be set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PGHOST
|
||||
export PGPORT=${PGPORT:-5432}
|
||||
export PGDATABASE
|
||||
export PGUSER
|
||||
export PGPASSWORD
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
retries=30
|
||||
until pg_isready -q -h "$PGHOST" -p "$PGPORT" -U "$PGUSER"; do
|
||||
[[ retries -eq 0 ]] && echo "Could not connect to Postgres" && exit 1
|
||||
echo "Waiting for Postgres to be available..."
|
||||
retries=$((retries-1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "PostgreSQL is ready. Initializing sabre/dav database schema..."
|
||||
|
||||
# SQL files directory (configurable for Scalingo, defaults to Docker path)
|
||||
SQL_DIR="${SQL_DIR:-/var/www/sabredav/sql}"
|
||||
|
||||
# Check if tables already exist
|
||||
TABLES_EXIST=$(psql -tAc "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('users', 'principals', 'calendars')" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$TABLES_EXIST" -gt "0" ]; then
|
||||
echo "sabre/dav tables already exist, skipping initialization"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create tables
|
||||
echo "Creating sabre/dav tables..."
|
||||
|
||||
if [ -f "$SQL_DIR/pgsql.users.sql" ]; then
|
||||
psql -f "$SQL_DIR/pgsql.users.sql"
|
||||
echo "Created users table"
|
||||
fi
|
||||
|
||||
if [ -f "$SQL_DIR/pgsql.principals.sql" ]; then
|
||||
psql -f "$SQL_DIR/pgsql.principals.sql"
|
||||
echo "Created principals table"
|
||||
fi
|
||||
|
||||
if [ -f "$SQL_DIR/pgsql.calendars.sql" ]; then
|
||||
psql -f "$SQL_DIR/pgsql.calendars.sql"
|
||||
echo "Created calendars table"
|
||||
fi
|
||||
|
||||
if [ -f "$SQL_DIR/pgsql.addressbooks.sql" ]; then
|
||||
psql -f "$SQL_DIR/pgsql.addressbooks.sql"
|
||||
echo "Created addressbooks table"
|
||||
fi
|
||||
|
||||
if [ -f "$SQL_DIR/pgsql.locks.sql" ]; then
|
||||
psql -f "$SQL_DIR/pgsql.locks.sql"
|
||||
echo "Created locks table"
|
||||
fi
|
||||
|
||||
if [ -f "$SQL_DIR/pgsql.propertystorage.sql" ]; then
|
||||
psql -f "$SQL_DIR/pgsql.propertystorage.sql"
|
||||
echo "Created propertystorage table"
|
||||
fi
|
||||
|
||||
echo "sabre/dav database schema initialized successfully!"
|
||||
@@ -1,25 +0,0 @@
|
||||
[global]
|
||||
daemonize = no
|
||||
error_log = /dev/stderr
|
||||
pid = /tmp/php-fpm.pid
|
||||
|
||||
[www]
|
||||
listen = /tmp/php-fpm.sock
|
||||
listen.mode = 0666
|
||||
|
||||
; When running as non-root, user/group settings are ignored
|
||||
user = www-data
|
||||
group = www-data
|
||||
|
||||
pm = dynamic
|
||||
pm.max_children = 5
|
||||
pm.start_servers = 2
|
||||
pm.min_spare_servers = 1
|
||||
pm.max_spare_servers = 3
|
||||
|
||||
; Pass all env vars to PHP workers (for PGHOST, CALDAV_* keys, etc.)
|
||||
clear_env = no
|
||||
|
||||
; Logging
|
||||
catch_workers_output = yes
|
||||
decorate_workers_output = no
|
||||
@@ -1,23 +0,0 @@
|
||||
<VirtualHost *:80>
|
||||
ServerName localhost
|
||||
DocumentRoot /var/www/sabredav
|
||||
|
||||
<Directory /var/www/sabredav>
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
Options -Indexes +FollowSymLinks
|
||||
</Directory>
|
||||
|
||||
# Rewrite rules for CalDAV
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.*)$ server.php [QSA,L]
|
||||
|
||||
# Well-known CalDAV discovery
|
||||
RewriteRule ^\.well-known/caldav / [R=301,L]
|
||||
|
||||
# Write errors to stderr for Docker logs
|
||||
ErrorLog /proc/self/fd/2
|
||||
# CustomLog /proc/self/fd/1 combined
|
||||
</VirtualHost>
|
||||
@@ -1,170 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* sabre/dav CalDAV Server
|
||||
* Configured to use PostgreSQL backend and custom header-based authentication
|
||||
*/
|
||||
|
||||
use Sabre\DAV\Auth;
|
||||
use Sabre\DAVACL;
|
||||
use Sabre\CalDAV;
|
||||
use Sabre\CardDAV;
|
||||
use Sabre\DAV;
|
||||
use Calendars\SabreDav\AutoCreatePrincipalBackend;
|
||||
use Calendars\SabreDav\HttpCallbackIMipPlugin;
|
||||
use Calendars\SabreDav\ApiKeyAuthBackend;
|
||||
use Calendars\SabreDav\CalendarSanitizerPlugin;
|
||||
use Calendars\SabreDav\AttendeeNormalizerPlugin;
|
||||
use Calendars\SabreDav\ICSImportPlugin;
|
||||
|
||||
// Composer autoloader
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
// Get base URI from environment variable (set by compose.yaml)
|
||||
// This ensures sabre/dav generates URLs with the correct proxy path
|
||||
$baseUri = getenv('CALDAV_BASE_URI') ?: '/';
|
||||
|
||||
// Database connection from environment variables
|
||||
$dbHost = getenv('PGHOST') ?: 'postgresql';
|
||||
$dbPort = getenv('PGPORT') ?: '5432';
|
||||
$dbName = getenv('PGDATABASE') ?: 'calendars';
|
||||
$dbUser = getenv('PGUSER') ?: 'pgroot';
|
||||
$dbPass = getenv('PGPASSWORD') ?: 'pass';
|
||||
|
||||
// Create PDO connection
|
||||
$pdo = new PDO(
|
||||
"pgsql:host={$dbHost};port={$dbPort};dbname={$dbName}",
|
||||
$dbUser,
|
||||
$dbPass,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]
|
||||
);
|
||||
|
||||
// Create custom authentication backend
|
||||
// Requires API key authentication and X-Forwarded-User header
|
||||
$apiKey = getenv('CALDAV_OUTBOUND_API_KEY');
|
||||
if (!$apiKey) {
|
||||
error_log("[sabre/dav] CALDAV_OUTBOUND_API_KEY environment variable is required");
|
||||
exit(1);
|
||||
}
|
||||
$authBackend = new ApiKeyAuthBackend($apiKey);
|
||||
|
||||
// Create authentication plugin
|
||||
$authPlugin = new Auth\Plugin($authBackend);
|
||||
|
||||
// Create CalDAV backend
|
||||
$caldavBackend = new CalDAV\Backend\PDO($pdo);
|
||||
|
||||
// Create CardDAV backend (optional, for future use)
|
||||
$carddavBackend = new CardDAV\Backend\PDO($pdo);
|
||||
|
||||
// Create principal backend with auto-creation support
|
||||
$principalBackend = new AutoCreatePrincipalBackend($pdo);
|
||||
|
||||
// Create directory tree
|
||||
$nodes = [
|
||||
new CalDAV\Principal\Collection($principalBackend),
|
||||
new CalDAV\CalendarRoot($principalBackend, $caldavBackend),
|
||||
new CardDAV\AddressBookRoot($principalBackend, $carddavBackend),
|
||||
];
|
||||
|
||||
// Create server
|
||||
$server = new DAV\Server($nodes);
|
||||
$server->setBaseUri($baseUri);
|
||||
|
||||
// Add plugins
|
||||
$server->addPlugin($authPlugin);
|
||||
$server->addPlugin(new CalDAV\Plugin());
|
||||
$server->addPlugin(new CardDAV\Plugin());
|
||||
$server->addPlugin(new DAVACL\Plugin());
|
||||
$server->addPlugin(new DAV\Browser\Plugin());
|
||||
|
||||
// Add ICS export plugin for iCal subscription URLs
|
||||
// Allows exporting calendars as .ics files via ?export query parameter
|
||||
// See https://sabre.io/dav/ics-export-plugin/
|
||||
$server->addPlugin(new CalDAV\ICSExportPlugin());
|
||||
|
||||
// Add sharing support
|
||||
// See https://sabre.io/dav/caldav-sharing/
|
||||
// Note: Order matters! CalDAV\SharingPlugin must come after DAV\Sharing\Plugin
|
||||
$server->addPlugin(new DAV\Sharing\Plugin());
|
||||
$server->addPlugin(new CalDAV\SharingPlugin());
|
||||
|
||||
// Debug logging for POST requests - commented out to avoid PII in logs
|
||||
// Uncomment for local debugging only, never in production.
|
||||
// $server->on('method:POST', function($request) {
|
||||
// $contentType = $request->getHeader('Content-Type');
|
||||
// $path = $request->getPath();
|
||||
// $body = $request->getBodyAsString();
|
||||
// error_log("[sabre/dav] POST request received:");
|
||||
// error_log("[sabre/dav] Path: " . $path);
|
||||
// error_log("[sabre/dav] Content-Type: " . $contentType);
|
||||
// error_log("[sabre/dav] Body: " . substr($body, 0, 1000));
|
||||
// $request->setBody($body);
|
||||
// }, 50);
|
||||
//
|
||||
// $server->on('afterMethod:POST', function($request, $response) {
|
||||
// error_log("[sabre/dav] POST response status: " . $response->getStatus());
|
||||
// $body = $response->getBodyAsString();
|
||||
// if ($body) {
|
||||
// error_log("[sabre/dav] POST response body: " . substr($body, 0, 500));
|
||||
// }
|
||||
// }, 50);
|
||||
|
||||
// Log unhandled exceptions
|
||||
$server->on('exception', function($e) {
|
||||
error_log("[sabre/dav] Exception: " . get_class($e) . " - " . $e->getMessage());
|
||||
error_log("[sabre/dav] Exception trace: " . $e->getTraceAsString());
|
||||
}, 50);
|
||||
|
||||
// Add calendar sanitizer plugin (priority 85, runs before all other calendar plugins)
|
||||
// Strips inline binary attachments (Outlook/Exchange base64 images) and truncates
|
||||
// oversized DESCRIPTION fields. Applies to ALL CalDAV writes (PUT from any client).
|
||||
$sanitizerStripAttachments = getenv('SANITIZER_STRIP_BINARY_ATTACHMENTS') !== 'false';
|
||||
$sanitizerMaxDescBytes = getenv('SANITIZER_MAX_DESCRIPTION_BYTES');
|
||||
$sanitizerMaxDescBytes = ($sanitizerMaxDescBytes !== false) ? (int)$sanitizerMaxDescBytes : 102400;
|
||||
$sanitizerMaxResourceSize = getenv('SANITIZER_MAX_RESOURCE_SIZE');
|
||||
$sanitizerMaxResourceSize = ($sanitizerMaxResourceSize !== false) ? (int)$sanitizerMaxResourceSize : 1048576;
|
||||
$server->addPlugin(new CalendarSanitizerPlugin(
|
||||
$sanitizerStripAttachments,
|
||||
$sanitizerMaxDescBytes,
|
||||
$sanitizerMaxResourceSize
|
||||
));
|
||||
|
||||
// Add attendee normalizer plugin to fix duplicate attendees issue
|
||||
// This plugin normalizes attendee emails (lowercase) and deduplicates them
|
||||
// when processing calendar objects, fixing issues with REPLY handling
|
||||
$server->addPlugin(new AttendeeNormalizerPlugin());
|
||||
|
||||
// Add ICS import plugin for bulk event import from a single POST request
|
||||
// Only accessible via the X-Calendars-Import header (backend-only)
|
||||
$server->addPlugin(new ICSImportPlugin($caldavBackend, $apiKey));
|
||||
|
||||
// Add custom IMipPlugin that forwards scheduling messages via HTTP callback
|
||||
// This MUST be added BEFORE the Schedule\Plugin so that Schedule\Plugin finds it
|
||||
// The callback URL can be provided per-request via X-CalDAV-Callback-URL header
|
||||
// or via CALDAV_CALLBACK_URL environment variable as fallback
|
||||
$callbackApiKey = getenv('CALDAV_INBOUND_API_KEY');
|
||||
if (!$callbackApiKey) {
|
||||
error_log("[sabre/dav] CALDAV_INBOUND_API_KEY environment variable is required for scheduling callback");
|
||||
exit(1);
|
||||
}
|
||||
$defaultCallbackUrl = getenv('CALDAV_CALLBACK_URL') ?: null;
|
||||
if ($defaultCallbackUrl) {
|
||||
error_log("[sabre/dav] Using default callback URL for scheduling: {$defaultCallbackUrl}");
|
||||
}
|
||||
$imipPlugin = new HttpCallbackIMipPlugin($callbackApiKey, $defaultCallbackUrl);
|
||||
$server->addPlugin($imipPlugin);
|
||||
|
||||
// Add CalDAV scheduling support
|
||||
// See https://sabre.io/dav/scheduling/
|
||||
// The Schedule\Plugin will automatically find and use the IMipPlugin we just added
|
||||
// It looks for plugins that implement CalDAV\Schedule\IMipPlugin interface
|
||||
$schedulePlugin = new CalDAV\Schedule\Plugin();
|
||||
$server->addPlugin($schedulePlugin);
|
||||
|
||||
// error_log("[sabre/dav] Starting server");
|
||||
|
||||
// Start server
|
||||
$server->start();
|
||||
@@ -1,44 +0,0 @@
|
||||
CREATE TABLE addressbooks (
|
||||
id SERIAL NOT NULL,
|
||||
principaluri VARCHAR(255),
|
||||
displayname VARCHAR(255),
|
||||
uri VARCHAR(200),
|
||||
description TEXT,
|
||||
synctoken INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY addressbooks
|
||||
ADD CONSTRAINT addressbooks_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE UNIQUE INDEX addressbooks_ukey
|
||||
ON addressbooks USING btree (principaluri, uri);
|
||||
|
||||
CREATE TABLE cards (
|
||||
id SERIAL NOT NULL,
|
||||
addressbookid INTEGER NOT NULL,
|
||||
carddata BYTEA,
|
||||
uri VARCHAR(200),
|
||||
lastmodified INTEGER,
|
||||
etag VARCHAR(32),
|
||||
size INTEGER NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY cards
|
||||
ADD CONSTRAINT cards_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE UNIQUE INDEX cards_ukey
|
||||
ON cards USING btree (addressbookid, uri);
|
||||
|
||||
CREATE TABLE addressbookchanges (
|
||||
id SERIAL NOT NULL,
|
||||
uri VARCHAR(200) NOT NULL,
|
||||
synctoken INTEGER NOT NULL,
|
||||
addressbookid INTEGER NOT NULL,
|
||||
operation SMALLINT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY addressbookchanges
|
||||
ADD CONSTRAINT addressbookchanges_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE INDEX addressbookchanges_addressbookid_synctoken_ix
|
||||
ON addressbookchanges USING btree (addressbookid, synctoken);
|
||||
@@ -1,111 +0,0 @@
|
||||
CREATE TABLE calendarobjects (
|
||||
id SERIAL NOT NULL,
|
||||
calendardata BYTEA,
|
||||
uri VARCHAR(200),
|
||||
calendarid INTEGER NOT NULL,
|
||||
lastmodified INTEGER,
|
||||
etag VARCHAR(32),
|
||||
size INTEGER NOT NULL,
|
||||
componenttype VARCHAR(8),
|
||||
firstoccurence INTEGER,
|
||||
lastoccurence INTEGER,
|
||||
uid VARCHAR(200)
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY calendarobjects
|
||||
ADD CONSTRAINT calendarobjects_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE UNIQUE INDEX calendarobjects_ukey
|
||||
ON calendarobjects USING btree (calendarid, uri);
|
||||
|
||||
|
||||
CREATE TABLE calendars (
|
||||
id SERIAL NOT NULL,
|
||||
synctoken INTEGER NOT NULL DEFAULT 1,
|
||||
components VARCHAR(21)
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY calendars
|
||||
ADD CONSTRAINT calendars_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
CREATE TABLE calendarinstances (
|
||||
id SERIAL NOT NULL,
|
||||
calendarid INTEGER NOT NULL,
|
||||
principaluri VARCHAR(100),
|
||||
access SMALLINT NOT NULL DEFAULT '1', -- '1 = owner, 2 = read, 3 = readwrite'
|
||||
displayname VARCHAR(100),
|
||||
uri VARCHAR(200),
|
||||
description TEXT,
|
||||
calendarorder INTEGER NOT NULL DEFAULT 0,
|
||||
calendarcolor VARCHAR(10),
|
||||
timezone TEXT,
|
||||
transparent SMALLINT NOT NULL DEFAULT '0',
|
||||
share_href VARCHAR(100),
|
||||
share_displayname VARCHAR(100),
|
||||
share_invitestatus SMALLINT NOT NULL DEFAULT '2' -- '1 = noresponse, 2 = accepted, 3 = declined, 4 = invalid'
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY calendarinstances
|
||||
ADD CONSTRAINT calendarinstances_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE UNIQUE INDEX calendarinstances_principaluri_uri
|
||||
ON calendarinstances USING btree (principaluri, uri);
|
||||
|
||||
|
||||
CREATE UNIQUE INDEX calendarinstances_principaluri_calendarid
|
||||
ON calendarinstances USING btree (principaluri, calendarid);
|
||||
|
||||
-- Note: The original SabreDAV schema has a unique index on (principaluri, share_href),
|
||||
-- but this prevents sharing multiple calendars with the same user.
|
||||
-- We use a non-unique index instead for query performance.
|
||||
CREATE INDEX calendarinstances_principaluri_share_href
|
||||
ON calendarinstances USING btree (principaluri, share_href);
|
||||
|
||||
CREATE TABLE calendarsubscriptions (
|
||||
id SERIAL NOT NULL,
|
||||
uri VARCHAR(200) NOT NULL,
|
||||
principaluri VARCHAR(100) NOT NULL,
|
||||
source TEXT,
|
||||
displayname VARCHAR(100),
|
||||
refreshrate VARCHAR(10),
|
||||
calendarorder INTEGER NOT NULL DEFAULT 0,
|
||||
calendarcolor VARCHAR(10),
|
||||
striptodos SMALLINT NULL,
|
||||
stripalarms SMALLINT NULL,
|
||||
stripattachments SMALLINT NULL,
|
||||
lastmodified INTEGER
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY calendarsubscriptions
|
||||
ADD CONSTRAINT calendarsubscriptions_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE UNIQUE INDEX calendarsubscriptions_ukey
|
||||
ON calendarsubscriptions USING btree (principaluri, uri);
|
||||
|
||||
CREATE TABLE calendarchanges (
|
||||
id SERIAL NOT NULL,
|
||||
uri VARCHAR(200) NOT NULL,
|
||||
synctoken INTEGER NOT NULL,
|
||||
calendarid INTEGER NOT NULL,
|
||||
operation SMALLINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY calendarchanges
|
||||
ADD CONSTRAINT calendarchanges_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE INDEX calendarchanges_calendarid_synctoken_ix
|
||||
ON calendarchanges USING btree (calendarid, synctoken);
|
||||
|
||||
CREATE TABLE schedulingobjects (
|
||||
id SERIAL NOT NULL,
|
||||
principaluri VARCHAR(255),
|
||||
calendardata BYTEA,
|
||||
uri VARCHAR(200),
|
||||
lastmodified INTEGER,
|
||||
etag VARCHAR(32),
|
||||
size INTEGER NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY schedulingobjects
|
||||
ADD CONSTRAINT schedulingobjects_pkey PRIMARY KEY (id);
|
||||
@@ -1,19 +0,0 @@
|
||||
CREATE TABLE locks (
|
||||
id SERIAL NOT NULL,
|
||||
owner VARCHAR(100),
|
||||
timeout INTEGER,
|
||||
created INTEGER,
|
||||
token VARCHAR(100),
|
||||
scope SMALLINT,
|
||||
depth SMALLINT,
|
||||
uri TEXT
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY locks
|
||||
ADD CONSTRAINT locks_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE INDEX locks_token_ix
|
||||
ON locks USING btree (token);
|
||||
|
||||
CREATE INDEX locks_uri_ix
|
||||
ON locks USING btree (uri);
|
||||
@@ -1,30 +0,0 @@
|
||||
CREATE TABLE principals (
|
||||
id SERIAL NOT NULL,
|
||||
uri VARCHAR(200) NOT NULL,
|
||||
email VARCHAR(80),
|
||||
displayname VARCHAR(80)
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY principals
|
||||
ADD CONSTRAINT principals_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE UNIQUE INDEX principals_ukey
|
||||
ON principals USING btree (uri);
|
||||
|
||||
CREATE TABLE groupmembers (
|
||||
id SERIAL NOT NULL,
|
||||
principal_id INTEGER NOT NULL,
|
||||
member_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY groupmembers
|
||||
ADD CONSTRAINT groupmembers_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE UNIQUE INDEX groupmembers_ukey
|
||||
ON groupmembers USING btree (principal_id, member_id);
|
||||
|
||||
INSERT INTO principals (uri,email,displayname) VALUES
|
||||
('principals/admin', 'admin@example.org','Administrator'),
|
||||
('principals/admin/calendar-proxy-read', null, null),
|
||||
('principals/admin/calendar-proxy-write', null, null);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE propertystorage (
|
||||
id SERIAL NOT NULL,
|
||||
path VARCHAR(1024) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
valuetype INT,
|
||||
value BYTEA
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY propertystorage
|
||||
ADD CONSTRAINT propertystorage_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE UNIQUE INDEX propertystorage_ukey
|
||||
ON propertystorage (path, name);
|
||||
@@ -1,14 +0,0 @@
|
||||
CREATE TABLE users (
|
||||
id SERIAL NOT NULL,
|
||||
username VARCHAR(50),
|
||||
digesta1 VARCHAR(32)
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY users
|
||||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE UNIQUE INDEX users_ukey
|
||||
ON users USING btree (username);
|
||||
|
||||
INSERT INTO users (username,digesta1) VALUES
|
||||
('admin', '87fd274b7b6c01e48d7c2f965da8ddf7');
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom authentication backend that supports API key and header-based authentication.
|
||||
*
|
||||
* This backend authenticates users via:
|
||||
* - API key authentication: X-Api-Key header and X-Forwarded-User header
|
||||
*
|
||||
* This allows Django to authenticate with CalDAV server using an API key.
|
||||
*/
|
||||
|
||||
namespace Calendars\SabreDav;
|
||||
|
||||
use Sabre\DAV\Auth\Backend\BackendInterface;
|
||||
use Sabre\HTTP\RequestInterface;
|
||||
use Sabre\HTTP\ResponseInterface;
|
||||
|
||||
class ApiKeyAuthBackend implements BackendInterface
|
||||
{
|
||||
/**
|
||||
* Expected API key for outbound authentication (from Django to CalDAV)
|
||||
* @var string
|
||||
*/
|
||||
private $apiKey;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $apiKey The expected API key for authentication
|
||||
*/
|
||||
public function __construct($apiKey)
|
||||
{
|
||||
$this->apiKey = $apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* When this method is called, the backend must check if authentication was
|
||||
* successful.
|
||||
*
|
||||
* The returned value must be one of the following:
|
||||
*
|
||||
* [true, "principals/username"] - authentication was successful, and a principal url is returned.
|
||||
* [false, "reason for failure"] - authentication failed, reason is optional
|
||||
* [null, null] - The backend cannot determine. The next backend will be queried.
|
||||
*
|
||||
* @param RequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return array
|
||||
*/
|
||||
public function check(RequestInterface $request, ResponseInterface $response)
|
||||
{
|
||||
// Get user from X-Forwarded-User header (required)
|
||||
$xForwardedUser = $request->getHeader('X-Forwarded-User');
|
||||
if (!$xForwardedUser) {
|
||||
return [false, 'X-Forwarded-User header is required'];
|
||||
}
|
||||
|
||||
// API key is required
|
||||
$apiKeyHeader = $request->getHeader('X-Api-Key');
|
||||
if (!$apiKeyHeader) {
|
||||
return [false, 'X-Api-Key header is required'];
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
if ($apiKeyHeader !== $this->apiKey) {
|
||||
return [false, 'Invalid API key'];
|
||||
}
|
||||
|
||||
// Authentication successful
|
||||
return [true, 'principals/' . $xForwardedUser];
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called when a user could not be authenticated.
|
||||
*
|
||||
* This gives us a chance to set up authentication challenges (for example HTTP auth).
|
||||
*
|
||||
* @param RequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @return void
|
||||
*/
|
||||
public function challenge(RequestInterface $request, ResponseInterface $response)
|
||||
{
|
||||
// We don't use HTTP Basic/Digest auth, so no challenge needed
|
||||
// The error message from check() will be returned
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* AttendeeNormalizerPlugin - Normalizes and deduplicates attendees in CalDAV events.
|
||||
*
|
||||
* This plugin fixes a common issue with CalDAV scheduling where REPLY processing
|
||||
* can create duplicate attendees due to email case sensitivity or format differences.
|
||||
*
|
||||
* The plugin:
|
||||
* 1. Normalizes all attendee emails to lowercase
|
||||
* 2. Deduplicates attendees by email, keeping the one with the most "advanced" status
|
||||
*
|
||||
* Status priority (most to least advanced): ACCEPTED > TENTATIVE > DECLINED > NEEDS-ACTION
|
||||
*/
|
||||
|
||||
namespace Calendars\SabreDav;
|
||||
|
||||
use Sabre\DAV\Server;
|
||||
use Sabre\DAV\ServerPlugin;
|
||||
use Sabre\VObject\Reader;
|
||||
use Sabre\VObject\Component\VCalendar;
|
||||
|
||||
class AttendeeNormalizerPlugin extends ServerPlugin
|
||||
{
|
||||
/**
|
||||
* Reference to the DAV server instance
|
||||
* @var Server
|
||||
*/
|
||||
protected $server;
|
||||
|
||||
/**
|
||||
* Status priority map (higher = more definitive response)
|
||||
* @var array
|
||||
*/
|
||||
private const STATUS_PRIORITY = [
|
||||
'ACCEPTED' => 4,
|
||||
'TENTATIVE' => 3,
|
||||
'DECLINED' => 2,
|
||||
'NEEDS-ACTION' => 1,
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a plugin name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getPluginName()
|
||||
{
|
||||
return 'attendee-normalizer';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the plugin.
|
||||
*
|
||||
* @param Server $server
|
||||
* @return void
|
||||
*/
|
||||
public function initialize(Server $server)
|
||||
{
|
||||
$this->server = $server;
|
||||
|
||||
// Hook into calendar object creation and updates
|
||||
// Priority 90 to run before most other plugins but after authentication
|
||||
// Note: beforeCreateFile and beforeWriteContent have different signatures
|
||||
$server->on('beforeCreateFile', [$this, 'beforeCreateCalendarObject'], 90);
|
||||
$server->on('beforeWriteContent', [$this, 'beforeUpdateCalendarObject'], 90);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before a calendar object is created.
|
||||
* Signature: ($path, &$data, \Sabre\DAV\ICollection $parent, &$modified)
|
||||
*
|
||||
* @param string $path The path to the file
|
||||
* @param resource|string $data The data being written
|
||||
* @param \Sabre\DAV\ICollection $parentNode The parent collection
|
||||
* @param bool $modified Whether the data was modified
|
||||
* @return void
|
||||
*/
|
||||
public function beforeCreateCalendarObject($path, &$data, $parentNode = null, &$modified = false)
|
||||
{
|
||||
$this->processCalendarData($path, $data, $modified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before a calendar object is updated.
|
||||
* Signature: ($path, \Sabre\DAV\IFile $node, &$data, &$modified)
|
||||
*
|
||||
* @param string $path The path to the file
|
||||
* @param \Sabre\DAV\IFile $node The existing file node
|
||||
* @param resource|string $data The data being written
|
||||
* @param bool $modified Whether the data was modified
|
||||
* @return void
|
||||
*/
|
||||
public function beforeUpdateCalendarObject($path, $node, &$data, &$modified = false)
|
||||
{
|
||||
$this->processCalendarData($path, $data, $modified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process calendar data to normalize and deduplicate attendees.
|
||||
*
|
||||
* @param string $path The path to the file
|
||||
* @param resource|string &$data The data being written (modified in place)
|
||||
* @param bool &$modified Whether the data was modified
|
||||
* @return void
|
||||
*/
|
||||
private function processCalendarData($path, &$data, &$modified)
|
||||
{
|
||||
// Only process .ics files in calendar collections
|
||||
if (!preg_match('/\.ics$/i', $path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the data as string
|
||||
if (is_resource($data)) {
|
||||
$dataStr = stream_get_contents($data);
|
||||
rewind($data);
|
||||
} else {
|
||||
$dataStr = $data;
|
||||
}
|
||||
|
||||
// Parse the iCalendar data
|
||||
$vcalendar = Reader::read($dataStr);
|
||||
|
||||
if (!$vcalendar instanceof VCalendar) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wasModified = false;
|
||||
|
||||
// Process all VEVENT components
|
||||
foreach ($vcalendar->VEVENT as $vevent) {
|
||||
if ($this->normalizeAndDeduplicateAttendees($vevent)) {
|
||||
$wasModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we made changes, update the data
|
||||
if ($wasModified) {
|
||||
$newData = $vcalendar->serialize();
|
||||
$data = $newData;
|
||||
$modified = true;
|
||||
|
||||
error_log("[AttendeeNormalizerPlugin] Normalized attendees in: " . $path);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log error but don't block the request
|
||||
error_log("[AttendeeNormalizerPlugin] Error processing calendar object: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and deduplicate attendees in a VEVENT component.
|
||||
*
|
||||
* @param \Sabre\VObject\Component\VEvent $vevent
|
||||
* @return bool True if the component was modified
|
||||
*/
|
||||
private function normalizeAndDeduplicateAttendees($vevent)
|
||||
{
|
||||
if (!isset($vevent->ATTENDEE) || count($vevent->ATTENDEE) === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$attendees = [];
|
||||
$attendeesByEmail = [];
|
||||
$wasModified = false;
|
||||
|
||||
// First pass: collect and normalize all attendees
|
||||
foreach ($vevent->ATTENDEE as $attendee) {
|
||||
$email = $this->normalizeEmail((string)$attendee);
|
||||
$status = isset($attendee['PARTSTAT']) ? strtoupper((string)$attendee['PARTSTAT']) : 'NEEDS-ACTION';
|
||||
$priority = self::STATUS_PRIORITY[$status] ?? 0;
|
||||
|
||||
$attendeeData = [
|
||||
'property' => $attendee,
|
||||
'email' => $email,
|
||||
'status' => $status,
|
||||
'priority' => $priority,
|
||||
];
|
||||
|
||||
if (!isset($attendeesByEmail[$email])) {
|
||||
// First occurrence of this email
|
||||
$attendeesByEmail[$email] = $attendeeData;
|
||||
$attendees[] = $attendeeData;
|
||||
} else {
|
||||
// Duplicate found
|
||||
$existing = $attendeesByEmail[$email];
|
||||
|
||||
if ($priority > $existing['priority']) {
|
||||
// New attendee has higher priority - replace
|
||||
// Find and replace in the array
|
||||
foreach ($attendees as $i => $att) {
|
||||
if ($att['email'] === $email) {
|
||||
$attendees[$i] = $attendeeData;
|
||||
$attendeesByEmail[$email] = $attendeeData;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$wasModified = true;
|
||||
error_log("[AttendeeNormalizerPlugin] Found duplicate attendee: {$email} (keeping status: " . $attendeesByEmail[$email]['status'] . ")");
|
||||
}
|
||||
}
|
||||
|
||||
// Also normalize the email in the value (lowercase the mailto: part)
|
||||
foreach ($vevent->ATTENDEE as $attendee) {
|
||||
$value = (string)$attendee;
|
||||
$normalizedValue = $this->normalizeMailtoValue($value);
|
||||
|
||||
if ($value !== $normalizedValue) {
|
||||
$attendee->setValue($normalizedValue);
|
||||
$wasModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If duplicates were found, rebuild the ATTENDEE list
|
||||
if ($wasModified && count($attendees) < count($vevent->ATTENDEE)) {
|
||||
// Remove all existing ATTENDEEs
|
||||
unset($vevent->ATTENDEE);
|
||||
|
||||
// Add back the deduplicated attendees
|
||||
foreach ($attendees as $attendeeData) {
|
||||
$property = $attendeeData['property'];
|
||||
|
||||
// Clone the property to the vevent
|
||||
$newAttendee = $vevent->add('ATTENDEE', $this->normalizeMailtoValue((string)$property));
|
||||
|
||||
// Copy all parameters
|
||||
foreach ($property->parameters() as $param) {
|
||||
$newAttendee[$param->name] = $param->getValue();
|
||||
}
|
||||
}
|
||||
|
||||
error_log("[AttendeeNormalizerPlugin] Reduced attendees from " . count($vevent->ATTENDEE) . " to " . count($attendees));
|
||||
}
|
||||
|
||||
return $wasModified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an email address extracted from a mailto: URI.
|
||||
*
|
||||
* @param string $value The ATTENDEE value (e.g., "mailto:User@Example.com")
|
||||
* @return string Normalized email (lowercase)
|
||||
*/
|
||||
private function normalizeEmail($value)
|
||||
{
|
||||
// Remove mailto: prefix if present
|
||||
$email = preg_replace('/^mailto:/i', '', $value);
|
||||
|
||||
// Lowercase and trim
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the mailto: value to have lowercase email.
|
||||
*
|
||||
* @param string $value The ATTENDEE value (e.g., "mailto:User@Example.com")
|
||||
* @return string Normalized value (e.g., "mailto:user@example.com")
|
||||
*/
|
||||
private function normalizeMailtoValue($value)
|
||||
{
|
||||
if (stripos($value, 'mailto:') === 0) {
|
||||
$email = substr($value, 7);
|
||||
return 'mailto:' . strtolower(trim($email));
|
||||
}
|
||||
|
||||
return strtolower(trim($value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of features for the DAV: header.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getFeatures()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom principal backend that auto-creates principals when they don't exist.
|
||||
* This allows Apache authentication to work without pre-creating principals.
|
||||
*/
|
||||
|
||||
namespace Calendars\SabreDav;
|
||||
|
||||
use Sabre\DAVACL\PrincipalBackend\PDO as BasePDO;
|
||||
use Sabre\DAV\MkCol;
|
||||
|
||||
class AutoCreatePrincipalBackend extends BasePDO
|
||||
{
|
||||
/**
|
||||
* Returns a specific principal, specified by it's path.
|
||||
* Auto-creates the principal if it doesn't exist.
|
||||
*
|
||||
* @param string $path
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function getPrincipalByPath($path)
|
||||
{
|
||||
$principal = parent::getPrincipalByPath($path);
|
||||
|
||||
// If principal doesn't exist, create it automatically
|
||||
if (!$principal && strpos($path, 'principals/') === 0) {
|
||||
// Extract username from path (e.g., "principals/user@example.com" -> "user@example.com")
|
||||
$username = substr($path, strlen('principals/'));
|
||||
|
||||
// Create principal directly in database
|
||||
// Access protected pdo property from parent
|
||||
$pdo = $this->pdo;
|
||||
$tableName = $this->tableName;
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO ' . $tableName . ' (uri, email, displayname) VALUES (?, ?, ?) ON CONFLICT (uri) DO NOTHING'
|
||||
);
|
||||
$stmt->execute([$path, $username, $username]);
|
||||
|
||||
// Retry getting the principal
|
||||
$principal = parent::getPrincipalByPath($path);
|
||||
} catch (\Exception $e) {
|
||||
// If creation fails, return null
|
||||
error_log("Failed to auto-create principal: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $principal;
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
<?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)',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom IMipPlugin that forwards scheduling messages via HTTP callback instead of sending emails.
|
||||
*
|
||||
* This plugin extends sabre/dav's IMipPlugin but instead of sending emails via PHP's mail()
|
||||
* function, it forwards the scheduling messages to an HTTP callback endpoint secured by API key.
|
||||
*
|
||||
* @see https://sabre.io/dav/scheduling/
|
||||
*/
|
||||
|
||||
namespace Calendars\SabreDav;
|
||||
|
||||
use Sabre\CalDAV\Schedule\IMipPlugin;
|
||||
use Sabre\DAV\Server;
|
||||
use Sabre\VObject\ITip\Message;
|
||||
|
||||
class HttpCallbackIMipPlugin extends IMipPlugin
|
||||
{
|
||||
/**
|
||||
* API key for authenticating with the callback endpoint
|
||||
* @var string
|
||||
*/
|
||||
private $apiKey;
|
||||
|
||||
/**
|
||||
* Reference to the DAV server instance
|
||||
* @var Server
|
||||
*/
|
||||
private $server;
|
||||
|
||||
/**
|
||||
* Default callback URL (fallback if header is not provided)
|
||||
* @var string|null
|
||||
*/
|
||||
private $defaultCallbackUrl;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $apiKey The API key for authenticating with the callback endpoint
|
||||
* @param string|null $defaultCallbackUrl Optional default callback URL
|
||||
*/
|
||||
public function __construct($apiKey, $defaultCallbackUrl = null)
|
||||
{
|
||||
// Call parent constructor with empty email (we won't use it)
|
||||
parent::__construct('');
|
||||
|
||||
$this->apiKey = $apiKey;
|
||||
$this->defaultCallbackUrl = $defaultCallbackUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the plugin.
|
||||
*
|
||||
* @param Server $server
|
||||
* @return void
|
||||
*/
|
||||
public function initialize(Server $server)
|
||||
{
|
||||
parent::initialize($server);
|
||||
$this->server = $server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler for the 'schedule' event.
|
||||
*
|
||||
* This overrides the parent's schedule() method to forward messages via HTTP callback
|
||||
* instead of sending emails via PHP's mail() function.
|
||||
*
|
||||
* @param Message $iTipMessage The iTip message
|
||||
* @return void
|
||||
*/
|
||||
public function schedule(Message $iTipMessage)
|
||||
{
|
||||
// Not sending any messages if the system considers the update insignificant.
|
||||
if (!$iTipMessage->significantChange) {
|
||||
if (!$iTipMessage->scheduleStatus) {
|
||||
$iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant delivery';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle mailto: recipients (external attendees)
|
||||
if ('mailto' !== parse_url($iTipMessage->sender, PHP_URL_SCHEME)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('mailto' !== parse_url($iTipMessage->recipient, PHP_URL_SCHEME)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get callback URL from the HTTP request header or use default
|
||||
$callbackUrl = null;
|
||||
if ($this->server && $this->server->httpRequest) {
|
||||
$callbackUrl = $this->server->httpRequest->getHeader('X-CalDAV-Callback-URL');
|
||||
}
|
||||
|
||||
// Fall back to default callback URL if header is not provided
|
||||
if (!$callbackUrl && $this->defaultCallbackUrl) {
|
||||
$callbackUrl = $this->defaultCallbackUrl;
|
||||
error_log("[HttpCallbackIMipPlugin] Using default callback URL: {$callbackUrl}");
|
||||
}
|
||||
|
||||
if (!$callbackUrl) {
|
||||
error_log("[HttpCallbackIMipPlugin] ERROR: X-CalDAV-Callback-URL header or default URL is required");
|
||||
$iTipMessage->scheduleStatus = '5.4;X-CalDAV-Callback-URL header or default URL is required';
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure URL ends with trailing slash for Django's APPEND_SLASH middleware
|
||||
$callbackUrl = rtrim($callbackUrl, '/') . '/';
|
||||
|
||||
// Serialize the iCalendar message
|
||||
$vcalendar = $iTipMessage->message ? $iTipMessage->message->serialize() : '';
|
||||
|
||||
// Prepare headers
|
||||
// Trim API key to remove any whitespace from environment variable
|
||||
$apiKey = trim($this->apiKey);
|
||||
$headers = [
|
||||
'Content-Type: text/calendar',
|
||||
'X-Api-Key: ' . $apiKey,
|
||||
'X-CalDAV-Sender: ' . $iTipMessage->sender,
|
||||
'X-CalDAV-Recipient: ' . $iTipMessage->recipient,
|
||||
'X-CalDAV-Method: ' . $iTipMessage->method,
|
||||
];
|
||||
|
||||
// Make HTTP POST request to Django callback endpoint
|
||||
$ch = curl_init($callbackUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_POSTFIELDS => $vcalendar,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
error_log(sprintf(
|
||||
"[HttpCallbackIMipPlugin] ERROR: cURL failed: %s",
|
||||
$curlError
|
||||
));
|
||||
$iTipMessage->scheduleStatus = '5.4;Failed to forward scheduling message via HTTP callback';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
error_log(sprintf(
|
||||
"[HttpCallbackIMipPlugin] ERROR: HTTP %d - %s",
|
||||
$httpCode,
|
||||
substr($response, 0, 200)
|
||||
));
|
||||
$iTipMessage->scheduleStatus = '5.4;HTTP callback returned error: ' . $httpCode;
|
||||
return;
|
||||
}
|
||||
|
||||
// Success
|
||||
$iTipMessage->scheduleStatus = '1.1;Scheduling message forwarded via HTTP callback';
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* ICSImportPlugin - Bulk import events from a multi-event ICS file.
|
||||
*
|
||||
* Accepts a single POST with raw ICS data and splits it into individual
|
||||
* calendar objects using Sabre\VObject\Splitter\ICalendar. Each split
|
||||
* VCALENDAR is validated/repaired and inserted directly via the CalDAV
|
||||
* PDO backend, avoiding N HTTP round-trips from Python.
|
||||
*
|
||||
* The endpoint is gated by a dedicated X-Calendars-Import header so that
|
||||
* only the Python backend can call it (not future proxied CalDAV clients).
|
||||
*/
|
||||
|
||||
namespace Calendars\SabreDav;
|
||||
|
||||
use Sabre\DAV\Server;
|
||||
use Sabre\DAV\ServerPlugin;
|
||||
use Sabre\CalDAV\Backend\PDO as CalDAVBackend;
|
||||
use Sabre\VObject;
|
||||
|
||||
class ICSImportPlugin extends ServerPlugin
|
||||
{
|
||||
/** @var Server */
|
||||
protected $server;
|
||||
|
||||
/** @var CalDAVBackend */
|
||||
private $caldavBackend;
|
||||
|
||||
/** @var string */
|
||||
private $importApiKey;
|
||||
|
||||
public function __construct(CalDAVBackend $caldavBackend, string $importApiKey)
|
||||
{
|
||||
$this->caldavBackend = $caldavBackend;
|
||||
$this->importApiKey = $importApiKey;
|
||||
}
|
||||
|
||||
public function getPluginName()
|
||||
{
|
||||
return 'ics-import';
|
||||
}
|
||||
|
||||
public function initialize(Server $server)
|
||||
{
|
||||
$this->server = $server;
|
||||
// Priority 90: runs before the debug logger (50)
|
||||
$server->on('method:POST', [$this, 'httpPost'], 90);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle POST requests with ?import query parameter.
|
||||
*
|
||||
* @return bool|null false to stop event propagation, null to let
|
||||
* other handlers proceed.
|
||||
*/
|
||||
public function httpPost($request, $response)
|
||||
{
|
||||
// Only handle requests with ?import in the query string
|
||||
$queryParams = $request->getQueryParameters();
|
||||
if (!array_key_exists('import', $queryParams)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the dedicated import header
|
||||
$headerValue = $request->getHeader('X-Calendars-Import');
|
||||
if (!$headerValue || $headerValue !== $this->importApiKey) {
|
||||
$response->setStatus(403);
|
||||
$response->setHeader('Content-Type', 'application/json');
|
||||
$response->setBody(json_encode([
|
||||
'error' => 'Forbidden: missing or invalid X-Calendars-Import header',
|
||||
]));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve the calendar from the request path.
|
||||
// getPath() returns a path relative to the base URI, e.g.
|
||||
// "calendars/user@example.com/cal-uuid"
|
||||
$path = $request->getPath();
|
||||
$parts = explode('/', trim($path, '/'));
|
||||
|
||||
// Expect exactly: [calendars, <user>, <calendar-uri>]
|
||||
if (count($parts) < 3 || $parts[0] !== 'calendars') {
|
||||
error_log("[ICSImportPlugin] Invalid calendar path: " . $path);
|
||||
$response->setStatus(400);
|
||||
$response->setHeader('Content-Type', 'application/json');
|
||||
$response->setBody(json_encode([
|
||||
'error' => 'Invalid calendar path',
|
||||
]));
|
||||
return false;
|
||||
}
|
||||
|
||||
$principalUser = urldecode($parts[1]);
|
||||
$calendarUri = $parts[2];
|
||||
$principalUri = 'principals/' . $principalUser;
|
||||
|
||||
// Look up calendarId by iterating the user's calendars
|
||||
$calendarId = $this->resolveCalendarId($principalUri, $calendarUri);
|
||||
if ($calendarId === null) {
|
||||
$response->setStatus(404);
|
||||
$response->setHeader('Content-Type', 'application/json');
|
||||
$response->setBody(json_encode([
|
||||
'error' => 'Calendar not found',
|
||||
]));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read and parse the raw ICS body
|
||||
$icsBody = $request->getBodyAsString();
|
||||
if (empty($icsBody)) {
|
||||
$response->setStatus(400);
|
||||
$response->setHeader('Content-Type', 'application/json');
|
||||
$response->setBody(json_encode([
|
||||
'error' => 'Empty request body',
|
||||
]));
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$vcal = VObject\Reader::read($icsBody);
|
||||
} catch (\Exception $e) {
|
||||
error_log("[ICSImportPlugin] Failed to parse ICS: " . $e->getMessage());
|
||||
$response->setStatus(400);
|
||||
$response->setHeader('Content-Type', 'application/json');
|
||||
$response->setBody(json_encode([
|
||||
'error' => 'Failed to parse ICS file',
|
||||
]));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate and auto-repair (fixes missing VALARM ACTION, etc.)
|
||||
$vcal->validate(VObject\Component::REPAIR);
|
||||
|
||||
// Split by UID using the stream-based splitter
|
||||
// The splitter expects a stream, so we wrap the serialized data
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
fwrite($stream, $vcal->serialize());
|
||||
rewind($stream);
|
||||
|
||||
$splitter = new VObject\Splitter\ICalendar($stream);
|
||||
|
||||
$totalEvents = 0;
|
||||
$importedCount = 0;
|
||||
$duplicateCount = 0;
|
||||
$skippedCount = 0;
|
||||
$errors = [];
|
||||
|
||||
while ($splitVcal = $splitter->getNext()) {
|
||||
$totalEvents++;
|
||||
|
||||
try {
|
||||
// Extract UID from the first VEVENT
|
||||
$uid = null;
|
||||
foreach ($splitVcal->VEVENT as $vevent) {
|
||||
if (isset($vevent->UID)) {
|
||||
$uid = (string)$vevent->UID;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$uid) {
|
||||
$uid = \Sabre\DAV\UUIDUtil::getUUID();
|
||||
}
|
||||
|
||||
// Sanitize event data (strip attachments, truncate descriptions)
|
||||
// and enforce max resource size
|
||||
$this->sanitizeAndCheckSize($splitVcal);
|
||||
|
||||
$objectUri = $uid . '.ics';
|
||||
$data = $splitVcal->serialize();
|
||||
|
||||
$this->caldavBackend->createCalendarObject(
|
||||
$calendarId,
|
||||
$objectUri,
|
||||
$data
|
||||
);
|
||||
$importedCount++;
|
||||
} catch (\Exception $e) {
|
||||
$msg = $e->getMessage();
|
||||
$summary = '';
|
||||
if (isset($splitVcal->VEVENT) && isset($splitVcal->VEVENT->SUMMARY)) {
|
||||
$summary = (string)$splitVcal->VEVENT->SUMMARY;
|
||||
}
|
||||
|
||||
// Duplicate key (SQLSTATE 23505) = event already exists
|
||||
// "no valid instances" = dead recurring event (all occurrences excluded)
|
||||
// Neither is actionable by the user, skip silently.
|
||||
if (strpos($msg, '23505') !== false) {
|
||||
$duplicateCount++;
|
||||
} elseif (strpos($msg, 'valid instances') !== false) {
|
||||
$skippedCount++;
|
||||
} else {
|
||||
$skippedCount++;
|
||||
if (count($errors) < 10) {
|
||||
$errors[] = [
|
||||
'uid' => $uid ?? 'unknown',
|
||||
'summary' => $summary,
|
||||
'error' => $msg,
|
||||
];
|
||||
}
|
||||
error_log(
|
||||
"[ICSImportPlugin] Failed to import event "
|
||||
. "uid=" . ($uid ?? 'unknown')
|
||||
. " summary={$summary}: {$msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose($stream);
|
||||
|
||||
error_log(
|
||||
"[ICSImportPlugin] Import complete: "
|
||||
. "{$importedCount} imported, "
|
||||
. "{$duplicateCount} duplicates, "
|
||||
. "{$skippedCount} failed "
|
||||
. "out of {$totalEvents} total"
|
||||
);
|
||||
|
||||
$response->setStatus(200);
|
||||
$response->setHeader('Content-Type', 'application/json');
|
||||
$response->setBody(json_encode([
|
||||
'total_events' => $totalEvents,
|
||||
'imported_count' => $importedCount,
|
||||
'duplicate_count' => $duplicateCount,
|
||||
'skipped_count' => $skippedCount,
|
||||
'errors' => $errors,
|
||||
]));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a split VCALENDAR before import and enforce max resource size.
|
||||
*
|
||||
* Delegates to CalendarSanitizerPlugin (if registered). Import bypasses
|
||||
* the HTTP layer (uses createCalendarObject directly), so beforeCreateFile
|
||||
* hooks don't fire — we must call the sanitizer explicitly.
|
||||
*
|
||||
* @throws \Exception if the sanitized object exceeds the max resource size.
|
||||
*/
|
||||
private function sanitizeAndCheckSize(VObject\Component\VCalendar $vcal)
|
||||
{
|
||||
$sanitizer = $this->server->getPlugin('calendar-sanitizer');
|
||||
if ($sanitizer) {
|
||||
$sanitizer->sanitizeVCalendar($vcal);
|
||||
$sanitizer->checkResourceSize($vcal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the internal calendar ID (the [calendarId, instanceId] pair)
|
||||
* from a principal URI and calendar URI.
|
||||
*
|
||||
* @param string $principalUri e.g. "principals/user@example.com"
|
||||
* @param string $calendarUri e.g. "a1b2c3d4-..."
|
||||
* @return array|null The calendarId pair, or null if not found.
|
||||
*/
|
||||
private function resolveCalendarId(string $principalUri, string $calendarUri)
|
||||
{
|
||||
$calendars = $this->caldavBackend->getCalendarsForUser($principalUri);
|
||||
|
||||
foreach ($calendars as $calendar) {
|
||||
if ($calendar['uri'] === $calendarUri) {
|
||||
return $calendar['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getPluginInfo()
|
||||
{
|
||||
return [
|
||||
'name' => $this->getPluginName(),
|
||||
'description' => 'Bulk import events from a multi-event ICS file',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user