(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:
Sylvain Zimmer
2026-03-09 09:09:34 +01:00
committed by GitHub
parent cd2b15b3b5
commit 9c18f96090
176 changed files with 26903 additions and 12108 deletions

View File

@@ -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",

View File

@@ -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";
}
}

View File

@@ -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;
}
}

View File

@@ -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"]

View File

@@ -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/"
}
}
}

View File

@@ -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!"

View File

@@ -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

View File

@@ -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>

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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');

View File

@@ -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
}
}

View File

@@ -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 [];
}
}

View File

@@ -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;
}
}

View File

@@ -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)',
];
}
}

View File

@@ -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';
}
}

View File

@@ -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',
];
}
}