Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@
'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => $baseDir . '/../lib/Connector/Sabre/SharesPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\TagList' => $baseDir . '/../lib/Connector/Sabre/TagList.php',
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => $baseDir . '/../lib/Connector/Sabre/TagsPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\ThunderbirdPutInvitationQuirkPlugin' => $baseDir . '/../lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => $baseDir . '/../lib/Connector/Sabre/ZipFolderPlugin.php',
'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php',
'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/SharesPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\TagList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagList.php',
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagsPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\ThunderbirdPutInvitationQuirkPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ZipFolderPlugin.php',
'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php',
'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php',
Expand Down
129 changes: 129 additions & 0 deletions apps/dav/lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\Connector\Sabre;

use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader as VObjectReader;

/**
* Consider the following situation: A user is invited and tries to accept or decline the event
* attached to the invitation email in Thunderbird before the personal calendar was synced.
*
* Thunderbird attempts to PUT the accepted ics to an invalid name, because it doesn't know the name
* on the remote Nextcloud/CalDAV server (yet). The Nextcloud server responds with an error, as the
* UID is already existing because the invitation was already added to the invitees personal
* calendar by Sabre.
*
* This plugin attempts to handle this situation gracefully by simply replacing the URI of the event
* with the actual one before handing off the request to the CalDAV server.
*
* Note: If Thunderbird knows about the URI of the user's own copy of the event, it will PUT the
* correct event directly. This is the case after syncing the personal calendar.
*/
class ThunderbirdPutInvitationQuirkPlugin extends ServerPlugin {
private ?Server $server = null;

public function __construct(
private readonly IDBConnection $db,
) {
}

public function initialize(Server $server) {
$this->server = $server;

// Run right after the ACL plugin to make sure that the current user principal is available
$server->on('beforeMethod:PUT', $this->beforePut(...), 21);
}

public function beforePut(RequestInterface $request, ResponseInterface $response): void {
$userAgent = $request->getHeader('User-Agent');
if (!$userAgent || !$this->isThunderbirdUserAgent($userAgent)) {
return;
}

if (!str_starts_with($request->getPath(), 'calendars/')) {
return;
}

if (!str_contains($request->getHeader('Content-Type') ?? '', 'text/calendar')) {
return;
}

$currentUserPrincipal = $this->getCurrentUserPrincipal();
if ($currentUserPrincipal === null) {
return;
}

// Need to set the body again here so that other handlers are able to read it afterward
$requestBody = $request->getBodyAsString();
$request->setBody($requestBody);

try {
$vCalendar = VObjectReader::read($requestBody);
} catch (\Throwable $e) {
return;
}
if (!($vCalendar instanceof VCalendar)) {
return;
}

/** @var string|null $uid */
$uid = $vCalendar->getBaseComponent('VEVENT')?->UID?->getValue();
if ($uid === null) {
return;
}

$qb = $this->db->getQueryBuilder();
$qb->select('co.uri')
->from('calendarobjects', 'co')
->join('co', 'calendars', 'c', $qb->expr()->eq('co.calendarid', 'c.id'))
->where(
$qb->expr()->eq(
'c.principaluri',
$qb->createNamedParameter($currentUserPrincipal, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
),
$qb->expr()->eq(
'co.uid',
$qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
),
);
$result = $qb->executeQuery();
$rows = $result->fetchAll();
$result->closeCursor();

if (count($rows) !== 1) {
// Either no collision or too many collisions
return;
}

$requestUrl = $request->getUrl();
[$prefix] = \Sabre\Uri\split($requestUrl);
$objectUri = $rows[0]['uri'];
$request->setUrl("$prefix/$objectUri");
}

private function isThunderbirdUserAgent(string $userAgent): bool {
return str_contains($userAgent, 'Thunderbird/');
}

private function getCurrentUserPrincipal(): ?string {
/** @var \Sabre\DAV\Auth\Plugin $authPlugin */
$authPlugin = $this->server?->getPlugin('auth');
return $authPlugin?->getCurrentPrincipal();
}
}
4 changes: 4 additions & 0 deletions apps/dav/lib/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
use OCA\DAV\Connector\Sabre\RequestIdHeaderPlugin;
use OCA\DAV\Connector\Sabre\SharesPlugin;
use OCA\DAV\Connector\Sabre\TagsPlugin;
use OCA\DAV\Connector\Sabre\ThunderbirdPutInvitationQuirkPlugin;
use OCA\DAV\Connector\Sabre\ZipFolderPlugin;
use OCA\DAV\DAV\CustomPropertiesBackend;
use OCA\DAV\DAV\PublicAuth;
Expand Down Expand Up @@ -130,6 +131,9 @@ public function __construct(
$this->server->addPlugin(new MaintenancePlugin(\OCP\Server::get(IConfig::class), \OC::$server->getL10N('dav')));

$this->server->addPlugin(new AppleQuirksPlugin());
$this->server->addPlugin(new ThunderbirdPutInvitationQuirkPlugin(
\OCP\Server::get(IDBConnection::class),
));

// Backends
$authBackend = new Auth(
Expand Down
Loading
Loading