(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

61
src/caldav/Dockerfile Normal file
View File

@@ -0,0 +1,61 @@
# 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"]

22
src/caldav/composer.json Normal file
View File

@@ -0,0 +1,22 @@
{
"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#1000fc028469c240fe13459e36648959f1519d09",
"ext-pdo": "*",
"ext-pdo_pgsql": "*"
},
"autoload": {
"psr-4": {
"Calendars\\SabreDav\\": "src/"
}
}
}

87
src/caldav/init-database.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/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 table creation"
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!"

25
src/caldav/php-fpm.conf Normal file
View File

@@ -0,0 +1,25 @@
[global]
daemonize = no
error_log = /dev/stderr
pid = /tmp/php-fpm.pid
[www]
listen = /tmp/php-fpm.sock
listen.mode = 0660
; 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

23
src/caldav/sabredav.conf Normal file
View File

@@ -0,0 +1,23 @@
<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>

192
src/caldav/server.php Normal file
View File

@@ -0,0 +1,192 @@
<?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\InternalApiPlugin;
use Calendars\SabreDav\ResourceAutoSchedulePlugin;
use Calendars\SabreDav\ResourceMkCalendarBlockPlugin;
use Calendars\SabreDav\CalendarsRoot;
use Calendars\SabreDav\CustomCalDAVPlugin;
use Calendars\SabreDav\PrincipalsRoot;
// 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
// Principal collections: principals/users/ and principals/resources/
// Calendar collections: calendars/users/ and calendars/resources/
$nodes = [
new PrincipalsRoot($principalBackend),
new CalendarsRoot($principalBackend, $caldavBackend),
new CardDAV\AddressBookRoot($principalBackend, $carddavBackend),
];
// Create server
$server = new DAV\Server($nodes);
$server->setBaseUri($baseUri);
// Give the principal backend a reference to the server
// so it can read X-CalDAV-Organization from the HTTP request
$principalBackend->setServer($server);
// Add plugins
$server->addPlugin($authPlugin);
$server->addPlugin(new CustomCalDAVPlugin());
$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 internal API plugin for resource provisioning and ICS import
// Gated by X-Internal-Api-Key header (separate from X-Api-Key used by proxy)
$internalApiKey = getenv('CALDAV_INTERNAL_API_KEY') ?: $apiKey;
$server->addPlugin(new InternalApiPlugin($pdo, $caldavBackend, $internalApiKey));
// 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);
// Add resource auto-scheduling plugin
// Handles automatic accept/decline for resource principals based on availability
$server->addPlugin(new ResourceAutoSchedulePlugin($pdo, $caldavBackend));
// Block MKCALENDAR on resource principals (each resource has exactly one calendar)
$server->addPlugin(new ResourceMkCalendarBlockPlugin());
// Add property storage plugin for custom properties (resource metadata, etc.)
$server->addPlugin(new DAV\PropertyStorage\Plugin(
new DAV\PropertyStorage\Backend\PDO($pdo)
));
// Start server
$server->start();

View File

@@ -0,0 +1,44 @@
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 BIGINT,
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

@@ -0,0 +1,117 @@
CREATE TABLE calendarobjects (
id SERIAL NOT NULL,
calendardata BYTEA,
uri VARCHAR(200),
calendarid INTEGER NOT NULL,
lastmodified BIGINT,
etag VARCHAR(32),
size INTEGER NOT NULL,
componenttype VARCHAR(8),
firstoccurence BIGINT,
lastoccurence BIGINT,
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(255),
access SMALLINT NOT NULL DEFAULT '1', -- '1 = owner, 2 = read, 3 = readwrite'
displayname VARCHAR(255),
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(255),
share_displayname VARCHAR(255),
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(255) NOT NULL,
source TEXT,
displayname VARCHAR(255),
refreshrate VARCHAR(10),
calendarorder INTEGER NOT NULL DEFAULT 0,
calendarcolor VARCHAR(10),
striptodos SMALLINT NULL,
stripalarms SMALLINT NULL,
stripattachments SMALLINT NULL,
lastmodified BIGINT
);
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 BIGINT,
etag VARCHAR(32),
size INTEGER NOT NULL
);
ALTER TABLE ONLY schedulingobjects
ADD CONSTRAINT schedulingobjects_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX schedulingobjects_ukey
ON schedulingobjects USING btree (principaluri, uri);
CREATE INDEX schedulingobjects_principaluri_ix
ON schedulingobjects USING btree (principaluri);

View File

@@ -0,0 +1,19 @@
CREATE TABLE locks (
id SERIAL NOT NULL,
owner VARCHAR(100),
timeout BIGINT,
created BIGINT,
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

@@ -0,0 +1,40 @@
CREATE TABLE principals (
id SERIAL NOT NULL,
uri VARCHAR(200) NOT NULL,
email VARCHAR(255),
displayname VARCHAR(255),
calendar_user_type VARCHAR(20) DEFAULT 'INDIVIDUAL',
org_id VARCHAR(200)
);
ALTER TABLE ONLY principals
ADD CONSTRAINT principals_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX principals_ukey
ON principals USING btree (uri);
CREATE INDEX idx_principals_org_id
ON principals (org_id)
WHERE org_id IS NOT NULL;
CREATE INDEX idx_principals_email
ON principals (email);
CREATE INDEX idx_principals_cutype
ON principals (calendar_user_type)
WHERE calendar_user_type IN ('ROOM', 'RESOURCE');
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);
-- No seed data: principals are created via AutoCreatePrincipalBackend
-- (for users on first access) or InternalApiPlugin (for resources).

View File

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,91 @@
<?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 (!hash_equals($this->apiKey, $apiKeyHeader)) {
return [false, 'Invalid API key'];
}
// Validate X-Forwarded-User to prevent path traversal
if (preg_match('/[\/\\\\]|\.\./', $xForwardedUser)) {
throw new \Sabre\DAV\Exception\NotAuthenticated('Invalid X-Forwarded-User header value');
}
// Authentication successful
return [true, 'principals/users/' . $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

@@ -0,0 +1,281 @@
<?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

@@ -0,0 +1,197 @@
<?php
/**
* Custom principal backend that auto-creates principals when they don't exist
* and supports org-scoped discovery.
*
* - Auto-creates principals on first access (for OIDC-authenticated users)
* - Stores org_id and calendar_user_type on principals
* - Filters searchPrincipals() and getPrincipalsByPrefix() by org_id
* - Does NOT filter getPrincipalByPath() (allows cross-org sharing)
*/
namespace Calendars\SabreDav;
use Sabre\DAVACL\PrincipalBackend\PDO as BasePDO;
use Sabre\DAV\MkCol;
class AutoCreatePrincipalBackend extends BasePDO
{
/**
* Extend the default field map to include calendar-user-type.
*
* SabreDAV's PDO principal backend uses $fieldMap to map WebDAV property
* names to database columns. The base class only maps displayname and email.
* The Schedule\Plugin hardcodes calendar-user-type to 'INDIVIDUAL' via a
* propFind handler, but that handler uses handle() which is a no-op when
* the property is already set. By adding calendar-user-type to the fieldMap,
* the Principal node exposes the real value from the DB via getProperties(),
* and the Schedule\Plugin's hardcoded 'INDIVIDUAL' only serves as a fallback
* for principals that don't have the column set.
*
* @see https://github.com/sabre-io/dav/blob/master/lib/DAVACL/PrincipalBackend/PDO.php
* @see https://github.com/sabre-io/dav/blob/master/lib/CalDAV/Schedule/Plugin.php
*/
protected $fieldMap = [
'{DAV:}displayname' => [
'dbField' => 'displayname',
],
'{http://sabredav.org/ns}email-address' => [
'dbField' => 'email',
],
'{urn:ietf:params:xml:ns:caldav}calendar-user-type' => [
'dbField' => 'calendar_user_type',
],
];
/**
* @var \Sabre\DAV\Server|null
*/
private $server = null;
/**
* Set the server reference (called from server.php after server creation).
*
* @param \Sabre\DAV\Server $server
*/
public function setServer(\Sabre\DAV\Server $server)
{
$this->server = $server;
}
/**
* Get the org_id from the current HTTP request's X-CalDAV-Organization header.
*
* @return string|null
*/
private function getRequestOrgId()
{
if ($this->server && $this->server->httpRequest) {
return $this->server->httpRequest->getHeader('X-CalDAV-Organization');
}
return null;
}
/**
* Returns a specific principal, specified by its path.
* Auto-creates the principal if it doesn't exist.
*
* NOT org-filtered: allows cross-org sharing and scheduling.
*
* @param string $path
* @return array|null
*/
public function getPrincipalByPath($path)
{
$principal = parent::getPrincipalByPath($path);
// If principal doesn't exist, create it automatically
// Only auto-create user principals (principals/users/*).
// Resource principals (principals/resources/*) are provisioned via Django.
if (!$principal && strpos($path, 'principals/users/') === 0) {
// Extract username from path
$username = substr($path, strlen('principals/users/'));
$pdo = $this->pdo;
$tableName = $this->tableName;
$orgId = $this->getRequestOrgId();
try {
$stmt = $pdo->prepare(
'INSERT INTO ' . $tableName
. ' (uri, email, displayname, calendar_user_type, org_id)'
. ' VALUES (?, ?, ?, ?, ?)'
. ' ON CONFLICT (uri) DO UPDATE SET org_id = COALESCE(EXCLUDED.org_id, '
. $tableName . '.org_id)'
);
$stmt->execute([$path, $username, $username, 'INDIVIDUAL', $orgId]);
// Retry getting the principal
$principal = parent::getPrincipalByPath($path);
} catch (\Exception $e) {
error_log("Failed to auto-create principal: " . $e->getMessage());
return null;
}
}
return $principal;
}
/**
* Returns a list of principals based on a prefix.
*
* Org-filtered: only returns principals from the requesting user's org.
*
* @param string $prefixPath
* @return array
*/
public function getPrincipalsByPrefix($prefixPath)
{
$principals = parent::getPrincipalsByPrefix($prefixPath);
$orgId = $this->getRequestOrgId();
if (!$orgId) {
return $principals;
}
// Filter by org_id
$filteredUris = $this->getOrgPrincipalUris($prefixPath, $orgId);
if ($filteredUris === null) {
return $principals;
}
return array_values(array_filter($principals, function ($principal) use ($filteredUris) {
return in_array($principal['uri'], $filteredUris, true);
}));
}
/**
* Search principals matching certain criteria.
*
* Org-filtered: only returns principals from the requesting user's org.
*
* @param string $prefixPath
* @param array $searchProperties
* @param string $test
* @return array
*/
public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof')
{
$results = parent::searchPrincipals($prefixPath, $searchProperties, $test);
$orgId = $this->getRequestOrgId();
if (!$orgId) {
return $results;
}
$filteredUris = $this->getOrgPrincipalUris($prefixPath, $orgId);
if ($filteredUris === null) {
return $results;
}
return array_values(array_filter($results, function ($uri) use ($filteredUris) {
return in_array($uri, $filteredUris, true);
}));
}
/**
* Get principal URIs for a given prefix and org_id.
*
* @param string $prefixPath
* @param string $orgId
* @return array|null
*/
private function getOrgPrincipalUris($prefixPath, $orgId)
{
try {
$stmt = $this->pdo->prepare(
'SELECT uri FROM ' . $this->tableName
. ' WHERE uri LIKE ? AND org_id = ?'
);
$stmt->execute([$prefixPath . '/%', $orgId]);
return $stmt->fetchAll(\PDO::FETCH_COLUMN, 0);
} catch (\Exception $e) {
error_log("Failed to query org principals: " . $e->getMessage());
return null;
}
}
}

View File

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

View File

@@ -0,0 +1,86 @@
<?php
/**
* Custom root node for the /calendars/ collection.
*
* SabreDAV's built-in CalendarRoot maps calendars/{name} → principals/{name}
* using a flat, single-level principal prefix. This doesn't work for nested
* prefixes like principals/users/{email} and principals/resources/{id},
* because CalendarRoot.getChild('users') would look for a principal named
* 'principals/users' rather than a sub-collection.
*
* This node sits at /calendars/ and delegates to child CalendarRoot nodes:
* calendars/users/{email}/{cal} → CalendarRoot(prefix='principals/users')
* calendars/resources/{id}/{cal} → CalendarRoot(prefix='principals/resources')
*
*/
namespace Calendars\SabreDav;
use Sabre\CalDAV;
use Sabre\DAV;
use Sabre\DAVACL\PrincipalBackend\BackendInterface as PrincipalBackendInterface;
use Sabre\CalDAV\Backend\BackendInterface as CalDAVBackendInterface;
class CalendarsRoot extends DAV\Collection
{
/** @var DAV\INode[] */
private $children;
public function __construct(
PrincipalBackendInterface $principalBackend,
CalDAVBackendInterface $caldavBackend
) {
$this->children = [
new NamedCalendarRoot('users', $principalBackend, $caldavBackend, 'principals/users'),
new NamedCalendarRoot('resources', $principalBackend, $caldavBackend, 'principals/resources'),
];
}
public function getName()
{
return 'calendars';
}
public function getChild($name)
{
foreach ($this->children as $child) {
if ($child->getName() === $name) {
return $child;
}
}
throw new DAV\Exception\NotFound('Collection ' . $name . ' not found');
}
public function getChildren()
{
return $this->children;
}
}
/**
* A CalendarRoot whose getName() returns a custom value instead of 'calendars'.
*
* Used as a child of CalendarsRoot so that:
* calendars/users/ → NamedCalendarRoot('users', ..., 'principals/users')
* calendars/resources/ → NamedCalendarRoot('resources', ..., 'principals/resources')
*/
class NamedCalendarRoot extends CalDAV\CalendarRoot
{
/** @var string */
private $nodeName;
public function __construct(
string $nodeName,
PrincipalBackendInterface $principalBackend,
CalDAVBackendInterface $caldavBackend,
string $principalPrefix
) {
parent::__construct($principalBackend, $caldavBackend, $principalPrefix);
$this->nodeName = $nodeName;
}
public function getName()
{
return $this->nodeName;
}
}

View File

@@ -0,0 +1,52 @@
<?php
/**
* Custom CalDAV plugin that handles nested principal prefixes.
*
* SabreDAV's built-in CalDAV\Plugin assumes principals are 2-part:
* principals/{name} → calendars/{name}
*
* We use 3-part principals:
* principals/users/{email} → calendars/users/{email}
* principals/resources/{id} → calendars/resources/{id}
*
* This subclass overrides getCalendarHomeForPrincipal() to handle
* the nested structure.
*/
namespace Calendars\SabreDav;
use Sabre\CalDAV;
class CustomCalDAVPlugin extends CalDAV\Plugin
{
/**
* Returns the path to a principal's calendar home.
*
* Handles both 2-part (principals/{name}) and 3-part
* (principals/{type}/{name}) principal URLs.
*
* @param string $principalUrl
* @return string|null
*/
public function getCalendarHomeForPrincipal($principalUrl)
{
$parts = explode('/', trim($principalUrl, '/'));
if (count($parts) < 2 || 'principals' !== $parts[0]) {
return null;
}
// Standard 2-part: principals/{name} → calendars/{name}
if (count($parts) === 2) {
return self::CALENDAR_ROOT . '/' . $parts[1];
}
// 3-part: principals/users/{email} → calendars/users/{email}
// principals/resources/{id} → calendars/resources/{id}
if (count($parts) === 3 && in_array($parts[1], ['users', 'resources'], true)) {
return self::CALENDAR_ROOT . '/' . $parts[1] . '/' . $parts[2];
}
return null;
}
}

View File

@@ -0,0 +1,164 @@
<?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

@@ -0,0 +1,545 @@
<?php
/**
* InternalApiPlugin - Handles all /internal-api/ routes.
*
* Provides a clean namespace for internal operations (resource provisioning,
* ICS import) that is completely separated from the CalDAV protocol namespace.
*
* Endpoints:
* POST /internal-api/resources/ Create a resource principal
* DELETE /internal-api/resources/{resource_id} Delete a resource principal
* POST /internal-api/import/{user}/{calendar} Bulk import ICS events
*
* Access control (defense in depth):
* 1. Django proxy blocklist rejects /internal-api/ paths
* 2. Requires X-Internal-Api-Key header (different from X-Api-Key used by proxy)
* 3. Test coverage verifies the proxy rejects these paths
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\CalDAV\Backend\PDO as CalDAVBackend;
use Sabre\VObject;
class InternalApiPlugin extends ServerPlugin
{
/** @var Server */
protected $server;
/** @var \PDO */
private $pdo;
/** @var CalDAVBackend */
private $caldavBackend;
/** @var string */
private $apiKey;
public function __construct(\PDO $pdo, CalDAVBackend $caldavBackend, string $apiKey)
{
$this->pdo = $pdo;
$this->caldavBackend = $caldavBackend;
$this->apiKey = $apiKey;
}
public function getPluginName()
{
return 'internal-api';
}
public function initialize(Server $server)
{
$this->server = $server;
// Use method:* (not beforeMethod:*) so SabreDAV calls sendResponse()
// for us after the handler returns false.
$server->on('method:*', [$this, 'handleRequest'], 90);
}
/**
* Intercept all requests under /internal-api/.
*
* @return bool|null false to stop event propagation, null to let
* other handlers proceed.
*/
public function handleRequest($request, $response)
{
$path = $request->getPath();
// Only handle /internal-api/ routes
if (strpos($path, 'internal-api/') !== 0 && $path !== 'internal-api') {
return;
}
// Verify the dedicated internal API key header
$headerValue = $request->getHeader('X-Internal-Api-Key');
if (!$headerValue || !hash_equals($this->apiKey, $headerValue)) {
$response->setStatus(403);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Forbidden: missing or invalid X-Internal-Api-Key header',
]));
return false;
}
$method = $request->getMethod();
// Route: POST /internal-api/resources/
if ($method === 'POST' && preg_match('#^internal-api/resources/?$#', $path)) {
$this->handleCreateResource($request, $response);
return false;
}
// Route: DELETE /internal-api/resources/{resource_id}
if ($method === 'DELETE' && preg_match('#^internal-api/resources/([a-zA-Z0-9-]+)$#', $path, $matches)) {
$this->handleDeleteResource($request, $response, $matches[1]);
return false;
}
// Route: POST /internal-api/users/delete
if ($method === 'POST' && preg_match('#^internal-api/users/delete/?$#', $path)) {
$body = json_decode($request->getBodyAsString(), true);
$email = $body['email'] ?? null;
if (!$email) {
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['error' => 'email is required']));
return false;
}
$this->handleDeleteUser($request, $response, $email);
return false;
}
// Route: POST /internal-api/import/{principalUser}/{calendarUri}
if ($method === 'POST' && preg_match('#^internal-api/import/([^/]+)/([^/]+)$#', $path, $matches)) {
$this->handleImport($request, $response, urldecode($matches[1]), $matches[2]);
return false;
}
$response->setStatus(404);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Not found',
]));
return false;
}
/**
* POST /internal-api/resources/
* Create a resource principal and its default calendar.
*/
private function handleCreateResource($request, $response)
{
$body = json_decode($request->getBodyAsString(), true);
if (!$body) {
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['error' => 'Invalid JSON body']));
return false;
}
$resourceId = $body['resource_id'] ?? null;
$name = $body['name'] ?? null;
$email = $body['email'] ?? null;
$resourceType = $body['resource_type'] ?? 'ROOM';
$orgId = $body['org_id'] ?? null;
if (!$resourceId || !$name || !$email) {
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Missing required fields: resource_id, name, email',
]));
return false;
}
$principalUri = 'principals/resources/' . $resourceId;
// Wrap principal + calendar creation in a transaction for atomicity
$this->pdo->beginTransaction();
try {
// Insert principal with ON CONFLICT DO NOTHING
$stmt = $this->pdo->prepare(
'INSERT INTO principals (uri, email, displayname, calendar_user_type, org_id)'
. ' VALUES (?, ?, ?, ?, ?)'
. ' ON CONFLICT (uri) DO NOTHING'
);
$stmt->execute([$principalUri, $email, $name, $resourceType, $orgId]);
if ($stmt->rowCount() === 0) {
$this->pdo->rollBack();
$response->setStatus(409);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => "Resource '$resourceId' already exists",
]));
return false;
}
// Create default calendar
$calendarUri = 'default';
$this->caldavBackend->createCalendar(
$principalUri,
$calendarUri,
[
'{DAV:}displayname' => $name,
'{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'
=> new \Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet(['VEVENT']),
]
);
$this->pdo->commit();
} catch (\Exception $e) {
$this->pdo->rollBack();
error_log("[InternalApiPlugin] Failed to create resource: " . $e->getMessage());
$response->setStatus(500);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Failed to create resource',
]));
return false;
}
$response->setStatus(201);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'principal_uri' => $principalUri,
'email' => $email,
]));
return false;
}
/**
* DELETE /internal-api/resources/{resource_id}
* Delete a resource principal, its calendars, and all associated data.
*/
private function handleDeleteResource($request, $response, $resourceId)
{
$principalUri = 'principals/resources/' . $resourceId;
$orgId = $request->getHeader('X-CalDAV-Organization');
// Look up the principal
try {
$stmt = $this->pdo->prepare(
'SELECT email, org_id FROM principals WHERE uri = ?'
);
$stmt->execute([$principalUri]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
} catch (\Exception $e) {
error_log("[InternalApiPlugin] Failed to look up principal: " . $e->getMessage());
$response->setStatus(500);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['error' => 'Failed to look up resource']));
return false;
}
if (!$row) {
$response->setStatus(404);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => "Resource '$resourceId' not found",
]));
return false;
}
// Verify org scoping — reject if orgs don't match or either is missing
if (!$orgId || !$row['org_id'] || $orgId !== $row['org_id']) {
$response->setStatus(403);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Cannot delete a resource from a different organization',
]));
return false;
}
// Delete calendars and their objects
try {
$calendars = $this->caldavBackend->getCalendarsForUser($principalUri);
foreach ($calendars as $calendar) {
$this->caldavBackend->deleteCalendar($calendar['id']);
}
} catch (\Exception $e) {
error_log("[InternalApiPlugin] Failed to delete calendars: " . $e->getMessage());
}
// Delete scheduling objects, principal rows
$this->deletePrincipalRows($principalUri);
$response->setStatus(200);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['deleted' => true]));
return false;
}
/**
* Delete principal row and associated proxy/scheduling rows.
*/
private function deletePrincipalRows($principalUri)
{
try {
// Delete scheduling objects if the table exists
$stmt = $this->pdo->prepare(
"SELECT EXISTS ("
. " SELECT FROM information_schema.tables"
. " WHERE table_name = 'schedulingobjects'"
. ")"
);
$stmt->execute();
if ($stmt->fetchColumn()) {
$del = $this->pdo->prepare(
'DELETE FROM schedulingobjects WHERE principaluri = ?'
);
$del->execute([$principalUri]);
}
// Delete principal and proxy rows
$del = $this->pdo->prepare('DELETE FROM principals WHERE uri = ?');
$del->execute([$principalUri]);
$del = $this->pdo->prepare('DELETE FROM principals WHERE uri LIKE ?');
$del->execute([$principalUri . '/%']);
} catch (\Exception $e) {
error_log("[InternalApiPlugin] Failed to delete principal rows: " . $e->getMessage());
}
}
/**
* POST /internal-api/users/delete
* Delete a user principal and all their calendar data.
* Body: {"email": "user@example.com"}
*/
private function handleDeleteUser($request, $response, $email)
{
$principalUri = 'principals/users/' . $email;
$orgId = $request->getHeader('X-CalDAV-Organization');
// Look up the principal
try {
$stmt = $this->pdo->prepare(
'SELECT id, org_id FROM principals WHERE uri = ?'
);
$stmt->execute([$principalUri]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
} catch (\Exception $e) {
error_log("[InternalApiPlugin] Failed to look up user principal: " . $e->getMessage());
$response->setStatus(500);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['error' => 'Failed to look up user']));
return false;
}
if (!$row) {
// Principal doesn't exist — nothing to clean up
$response->setStatus(200);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['deleted' => true, 'existed' => false]));
return false;
}
// Verify org scoping — reject if orgs don't match or either is missing
if ($row['org_id']) {
if (!$orgId || $orgId !== $row['org_id']) {
$response->setStatus(403);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Cannot delete a user from a different organization',
]));
return false;
}
}
// Delete calendars and their objects
try {
$calendars = $this->caldavBackend->getCalendarsForUser($principalUri);
foreach ($calendars as $calendar) {
$this->caldavBackend->deleteCalendar($calendar['id']);
}
} catch (\Exception $e) {
error_log("[InternalApiPlugin] Failed to delete user calendars: " . $e->getMessage());
}
// Delete scheduling objects, principal rows
$this->deletePrincipalRows($principalUri);
$response->setStatus(200);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['deleted' => true, 'existed' => true]));
return false;
}
/**
* POST /internal-api/import/{principalUser}/{calendarUri}
* Bulk import events from a multi-event ICS file.
*/
private function handleImport($request, $response, $principalUser, $calendarUri)
{
$principalUri = 'principals/users/' . $principalUser;
// Look up calendarId
$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("[InternalApiPlugin] 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
$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 = [];
try {
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)
$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;
}
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(
"[InternalApiPlugin] Failed to import event "
. "uid=" . ($uid ?? 'unknown')
. " summary={$summary}: {$msg}"
);
}
}
}
} finally {
fclose($stream);
}
error_log(
"[InternalApiPlugin] 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.
*/
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 from a principal URI and calendar URI.
*
* @param string $principalUri e.g. "principals/users/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' => 'Internal API for resource provisioning and ICS import',
];
}
}

View File

@@ -0,0 +1,119 @@
<?php
/**
* Custom root node for the /principals/ collection.
*
* SabreDAV's built-in Principal\Collection uses getName() = basename($prefix),
* so Principal\Collection('principals/users') would appear at /users/ in the
* tree, not /principals/users/. This node sits at /principals/ and delegates
* to child Principal\Collection nodes:
* principals/users/{email} → Principal\Collection(prefix='principals/users')
* principals/resources/{id} → Principal\Collection(prefix='principals/resources')
*
*/
namespace Calendars\SabreDav;
use Sabre\CalDAV;
use Sabre\DAV;
use Sabre\DAVACL;
use Sabre\DAVACL\PrincipalBackend\BackendInterface as PrincipalBackendInterface;
class PrincipalsRoot extends DAV\Collection
{
/** @var DAV\INode[] */
private $children;
public function __construct(PrincipalBackendInterface $principalBackend)
{
$this->children = [
new NamedPrincipalCollection('users', $principalBackend, 'principals/users'),
new ResourcePrincipalCollection('resources', $principalBackend, 'principals/resources'),
];
}
public function getName()
{
return 'principals';
}
public function getChild($name)
{
foreach ($this->children as $child) {
if ($child->getName() === $name) {
return $child;
}
}
throw new DAV\Exception\NotFound('Collection ' . $name . ' not found');
}
public function getChildren()
{
return $this->children;
}
}
/**
* A Principal\Collection whose getName() returns a custom value.
*
* Used as a child of PrincipalsRoot so that:
* principals/users/ → NamedPrincipalCollection('users', ..., 'principals/users')
* principals/resources/ → NamedPrincipalCollection('resources', ..., 'principals/resources')
*/
class NamedPrincipalCollection extends CalDAV\Principal\Collection
{
/** @var string */
private $nodeName;
public function __construct(
string $nodeName,
PrincipalBackendInterface $principalBackend,
string $principalPrefix
) {
parent::__construct($principalBackend, $principalPrefix);
$this->nodeName = $nodeName;
}
public function getName()
{
return $this->nodeName;
}
}
/**
* Principal collection for resources that returns ResourcePrincipal nodes.
*
* Resource principals have no DAV owner, so the default ACL (which only
* grants {DAV:}all to {DAV:}owner) blocks all property reads with 403.
* This collection returns ResourcePrincipal nodes that additionally grant
* {DAV:}read to {DAV:}authenticated, allowing any logged-in user to
* discover resource names, types, and emails via PROPFIND.
*/
class ResourcePrincipalCollection extends NamedPrincipalCollection
{
public function getChildForPrincipal(array $principal)
{
return new ResourcePrincipal($this->principalBackend, $principal);
}
}
/**
* A principal node with a permissive read ACL for resource discovery.
*/
class ResourcePrincipal extends CalDAV\Principal\User
{
public function getACL()
{
return [
[
'privilege' => '{DAV:}all',
'principal' => '{DAV:}owner',
'protected' => true,
],
[
'privilege' => '{DAV:}read',
'principal' => '{DAV:}authenticated',
'protected' => true,
],
];
}
}

View File

@@ -0,0 +1,385 @@
<?php
/**
* ResourceAutoSchedulePlugin - Automatic scheduling for resource principals.
*
* Intercepts iTIP messages delivered to resource principals (ROOM/RESOURCE)
* and automatically accepts or declines based on:
* - The resource's auto-schedule mode (automatic, accept-always, decline-always, manual)
* - Calendar conflict detection (for 'automatic' mode)
* - Org scoping (rejects cross-org bookings)
*
* Runs after Schedule\Plugin delivers the iTIP message (priority 120 > 110).
*
* This plugin also sets $message->scheduleStatus before HttpCallbackIMipPlugin
* runs, which prevents email delivery to resource addresses (resource addresses
* are not real mailboxes).
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\VObject\ITip\Message;
use Sabre\VObject\Reader;
use Sabre\CalDAV\Backend\PDO as CalDAVBackend;
class ResourceAutoSchedulePlugin extends ServerPlugin
{
/** @var Server */
protected $server;
/** @var \PDO */
private $pdo;
/** @var CalDAVBackend */
private $caldavBackend;
/** Custom namespace for resource properties */
private const NS = 'urn:lasuite:calendars';
public function __construct(\PDO $pdo, CalDAVBackend $caldavBackend)
{
$this->pdo = $pdo;
$this->caldavBackend = $caldavBackend;
}
public function getPluginName()
{
return 'resource-auto-schedule';
}
public function initialize(Server $server)
{
$this->server = $server;
// Priority 120: runs after Schedule\Plugin (110)
$server->on('schedule', [$this, 'autoSchedule'], 120);
// Priority 200: runs BEFORE Schedule\Plugin's propFindEarly (150)
// which hardcodes calendar-user-type to 'INDIVIDUAL'. By setting
// the real value first, the Schedule\Plugin's handle() becomes a no-op.
$server->on('propFind', [$this, 'propFindResourceType'], 200);
}
/**
* Set the correct calendar-user-type for resource principals.
*
* Schedule\Plugin::propFindEarly (priority 150) hardcodes INDIVIDUAL via
* handle(), which only fires when the property isn't already resolved.
* By setting the real DB value here at priority 200 via set(), we pre-empt it.
*/
public function propFindResourceType(\Sabre\DAV\PropFind $propFind, \Sabre\DAV\INode $node)
{
if (!($node instanceof ResourcePrincipal)) {
return;
}
$props = $node->getProperties(
['{urn:ietf:params:xml:ns:caldav}calendar-user-type']
);
$cutype = $props['{urn:ietf:params:xml:ns:caldav}calendar-user-type'] ?? null;
if ($cutype) {
$propFind->set('{urn:ietf:params:xml:ns:caldav}calendar-user-type', $cutype);
}
}
/**
* Handle scheduling messages to resource principals.
*
* @param Message $message
*/
public function autoSchedule(Message $message)
{
// Only handle REQUEST method (new invitations and updates)
if ($message->method !== 'REQUEST') {
return;
}
// Only handle messages to resource principals
$recipientPrincipal = $this->resolveRecipientPrincipal($message->recipient);
if (!$recipientPrincipal) {
return;
}
$cutype = $recipientPrincipal['calendar_user_type'] ?? 'INDIVIDUAL';
if (!in_array($cutype, ['ROOM', 'RESOURCE'], true)) {
return;
}
// Enforce org scoping: reject cross-org bookings
$requestOrgId = $this->server->httpRequest
? $this->server->httpRequest->getHeader('X-CalDAV-Organization')
: null;
$resourceOrgId = $recipientPrincipal['org_id'] ?? null;
if ($resourceOrgId) {
if (!$requestOrgId || $requestOrgId !== $resourceOrgId) {
$this->declineInvitation($message, 'Cross-organization booking not allowed');
return;
}
}
// Read auto-schedule mode from propertystorage
$mode = $this->getAutoScheduleMode($recipientPrincipal['uri']);
switch ($mode) {
case 'accept-always':
$this->acceptInvitation($message);
break;
case 'decline-always':
$this->declineInvitation($message, 'Resource is offline');
break;
case 'manual':
// Leave as NEEDS-ACTION for manual approval
// But still set scheduleStatus to prevent email delivery
$message->scheduleStatus = '1.0;Pending manual approval';
break;
case 'automatic':
default:
if ($this->hasConflict($recipientPrincipal, $message)) {
$this->declineInvitation($message, 'Resource is busy');
} else {
$this->acceptInvitation($message);
}
break;
}
}
/**
* Resolve the recipient email to a principal record.
*
* @param string $recipient mailto: URI
* @return array|null Principal row or null
*/
private function resolveRecipientPrincipal($recipient)
{
$email = $this->extractEmail($recipient);
if (!$email) {
return null;
}
try {
$stmt = $this->pdo->prepare(
'SELECT id, uri, email, calendar_user_type, org_id'
. ' FROM principals WHERE email = ?'
);
$stmt->execute([strtolower($email)]);
return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
} catch (\Exception $e) {
error_log("[ResourceAutoSchedulePlugin] DB error: " . $e->getMessage());
return null;
}
}
/**
* Extract email from a mailto: URI.
*
* @param string $uri
* @return string|null
*/
private function extractEmail($uri)
{
if (stripos($uri, 'mailto:') === 0) {
return strtolower(substr($uri, 7));
}
return null;
}
/**
* Get auto-schedule mode from propertystorage.
*
* @param string $principalUri
* @return string
*/
private function getAutoScheduleMode($principalUri)
{
try {
$stmt = $this->pdo->prepare(
"SELECT value FROM propertystorage"
. " WHERE path = ? AND name = '{" . self::NS . "}auto-schedule-mode'"
);
$stmt->execute([$principalUri]);
$result = $stmt->fetchColumn();
return $result ?: 'automatic';
} catch (\Exception $e) {
error_log("[ResourceAutoSchedulePlugin] Failed to read auto-schedule mode: " . $e->getMessage());
return 'automatic';
}
}
/**
* Check if the resource has a conflict with the incoming event.
*
* @param array $principal
* @param Message $message
* @return bool
*/
private function hasConflict($principal, Message $message)
{
if (!$message->message) {
return false;
}
$vcalendar = $message->message;
// Get the resource's calendar
$calendarId = $this->getResourceCalendarId($principal['uri']);
if (!$calendarId) {
return false; // No calendar = no conflicts
}
// Extract time ranges from all VEVENT components
foreach ($vcalendar->VEVENT as $vevent) {
// Skip transparent events
$transp = isset($vevent->TRANSP) ? (string)$vevent->TRANSP : 'OPAQUE';
if ($transp === 'TRANSPARENT') {
continue;
}
$dtstart = $vevent->DTSTART ? $vevent->DTSTART->getDateTime() : null;
$dtend = null;
if (isset($vevent->DTEND)) {
$dtend = $vevent->DTEND->getDateTime();
} elseif (isset($vevent->DURATION)) {
$dtend = clone $dtstart;
$dtend->add($vevent->DURATION->getDateInterval());
}
if (!$dtstart || !$dtend) {
continue;
}
// Query for overlapping events in the resource's calendar
$startTs = $dtstart->getTimestamp();
$endTs = $dtend->getTimestamp();
// Get UID of the incoming event to exclude updates to the same event
$uid = isset($vevent->UID) ? (string)$vevent->UID : null;
if ($this->hasOverlappingEvents($calendarId, $startTs, $endTs, $uid)) {
return true;
}
}
return false;
}
/**
* Get the resource's default calendar ID.
*
* @param string $principalUri
* @return array|null [calendarId, instanceId] pair or null
*/
private function getResourceCalendarId($principalUri)
{
$calendars = $this->caldavBackend->getCalendarsForUser($principalUri);
if (!empty($calendars)) {
return $calendars[0]['id'];
}
return null;
}
/**
* Check for overlapping events in a calendar.
*
* @param array $calendarId [calendarId, instanceId]
* @param int $startTs Start timestamp
* @param int $endTs End timestamp
* @param string|null $excludeUid UID to exclude (for updates)
* @return bool
*/
private function hasOverlappingEvents($calendarId, $startTs, $endTs, $excludeUid = null)
{
try {
// Normalize calendarId: SabreDAV may return an array [id, instanceId]
// or a scalar integer depending on the version/backend.
$calId = is_array($calendarId) ? $calendarId[0] : $calendarId;
// Use calendarobjects table directly for conflict check
// firstoccurence and lastoccurence are Unix timestamps stored by SabreDAV
$sql = 'SELECT COUNT(*) FROM calendarobjects'
. ' WHERE calendarid = ?'
. ' AND firstoccurence < ? AND lastoccurence > ?';
$params = [$calId, $endTs, $startTs];
if ($excludeUid) {
$sql .= ' AND uid != ?';
$params[] = $excludeUid;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int)$stmt->fetchColumn() > 0;
} catch (\Exception $e) {
error_log("[ResourceAutoSchedulePlugin] Conflict check failed: " . $e->getMessage());
return true; // Fail-closed: reject booking if check fails
}
}
/**
* Accept the invitation.
*
* @param Message $message
*/
private function acceptInvitation(Message $message)
{
$message->scheduleStatus = '1.2;Scheduling message delivered (auto-accepted)';
// Update PARTSTAT in the delivered calendar object
$this->updatePartstat($message, 'ACCEPTED');
}
/**
* Decline the invitation.
*
* @param Message $message
* @param string $reason
*/
private function declineInvitation(Message $message, $reason = '')
{
$message->scheduleStatus = '3.0;Scheduling message declined' . ($reason ? ": $reason" : '');
// Update PARTSTAT in the delivered calendar object
$this->updatePartstat($message, 'DECLINED');
}
/**
* Update the PARTSTAT of the resource attendee in the iTIP message.
*
* @param Message $message
* @param string $partstat ACCEPTED, DECLINED, etc.
*/
private function updatePartstat(Message $message, $partstat)
{
if (!$message->message) {
return;
}
$recipientEmail = $this->extractEmail($message->recipient);
if (!$recipientEmail) {
return;
}
foreach ($message->message->VEVENT as $vevent) {
if (!isset($vevent->ATTENDEE)) {
continue;
}
foreach ($vevent->ATTENDEE as $attendee) {
$email = $this->extractEmail((string)$attendee);
if ($email === $recipientEmail) {
$attendee['PARTSTAT'] = $partstat;
}
}
}
}
public function getPluginInfo()
{
return [
'name' => $this->getPluginName(),
'description' => 'Auto-scheduling for resource principals (rooms, equipment)',
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
/**
* ResourceMkCalendarBlockPlugin - Blocks MKCALENDAR on resource principals.
*
* A resource principal has exactly one calendar (created during provisioning).
* This plugin prevents additional calendars from being created on resource
* principals by rejecting MKCALENDAR requests targeting resource calendar homes.
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\DAV\Exception\Forbidden;
class ResourceMkCalendarBlockPlugin extends ServerPlugin
{
/** @var Server */
protected $server;
public function getPluginName()
{
return 'resource-mkcalendar-block';
}
public function initialize(Server $server)
{
$this->server = $server;
// Hook before MKCALENDAR is processed
$server->on('beforeMethod:MKCALENDAR', [$this, 'beforeMkCalendar'], 90);
}
/**
* Block MKCALENDAR on resource principal calendar homes.
*
* @param \Sabre\HTTP\RequestInterface $request
* @param \Sabre\HTTP\ResponseInterface $response
* @return bool|null false to stop, null to continue
*/
public function beforeMkCalendar($request, $response)
{
$path = $request->getPath();
// Check if the path is under a resource calendar home
// Resource calendar homes: calendars/resources/{id}/
if (preg_match('#^calendars/resources/#', $path)) {
throw new Forbidden(
'Resource principals can only have one calendar. '
. 'Additional calendar creation is not allowed.'
);
}
return null; // Allow for non-resource paths
}
public function getPluginInfo()
{
return [
'name' => $this->getPluginName(),
'description' => 'Blocks MKCALENDAR on resource principal calendar homes',
];
}
}