aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dav/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dav/lib')
-rw-r--r--apps/dav/lib/CalDAV/CalendarImpl.php30
-rw-r--r--apps/dav/lib/CalDAV/EmbeddedCalDavServer.php118
-rw-r--r--apps/dav/lib/CalDAV/EventReader.php4
-rw-r--r--apps/dav/lib/CalDAV/Schedule/IMipService.php2
-rw-r--r--apps/dav/lib/CalDAV/Status/StatusService.php2
-rw-r--r--apps/dav/lib/CalDAV/UpcomingEventsService.php8
-rw-r--r--apps/dav/lib/CardDAV/AddressBook.php11
-rw-r--r--apps/dav/lib/CardDAV/CardDavBackend.php104
-rw-r--r--apps/dav/lib/CardDAV/SyncService.php61
-rw-r--r--apps/dav/lib/CardDAV/SystemAddressbook.php8
-rw-r--r--apps/dav/lib/Command/GetAbsenceCommand.php62
-rw-r--r--apps/dav/lib/Command/SetAbsenceCommand.php95
-rw-r--r--apps/dav/lib/Connector/Sabre/File.php12
-rw-r--r--apps/dav/lib/Connector/Sabre/FilesReportPlugin.php2
-rw-r--r--apps/dav/lib/Connector/Sabre/Principal.php3
-rw-r--r--apps/dav/lib/Connector/Sabre/ServerFactory.php2
-rw-r--r--apps/dav/lib/RootCollection.php3
17 files changed, 456 insertions, 71 deletions
diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php
index b79bf7ea2d0..5f912da732e 100644
--- a/apps/dav/lib/CalDAV/CalendarImpl.php
+++ b/apps/dav/lib/CalDAV/CalendarImpl.php
@@ -156,19 +156,15 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIs
}
/**
- * Create a new calendar event for this calendar
- * by way of an ICS string
- *
- * @param string $name the file name - needs to contain the .ics ending
- * @param string $calendarData a string containing a valid VEVENT ics
- *
* @throws CalendarException
*/
- public function createFromString(string $name, string $calendarData): void {
- $server = new InvitationResponseServer(false);
-
+ private function createFromStringInServer(
+ string $name,
+ string $calendarData,
+ \OCA\DAV\Connector\Sabre\Server $server,
+ ): void {
/** @var CustomPrincipalPlugin $plugin */
- $plugin = $server->getServer()->getPlugin('auth');
+ $plugin = $server->getPlugin('auth');
// we're working around the previous implementation
// that only allowed the public system principal to be used
// so set the custom principal here
@@ -184,14 +180,14 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIs
// Force calendar change URI
/** @var Schedule\Plugin $schedulingPlugin */
- $schedulingPlugin = $server->getServer()->getPlugin('caldav-schedule');
+ $schedulingPlugin = $server->getPlugin('caldav-schedule');
$schedulingPlugin->setPathOfCalendarObjectChange($fullCalendarFilename);
$stream = fopen('php://memory', 'rb+');
fwrite($stream, $calendarData);
rewind($stream);
try {
- $server->getServer()->createFile($fullCalendarFilename, $stream);
+ $server->createFile($fullCalendarFilename, $stream);
} catch (Conflict $e) {
throw new CalendarException('Could not create new calendar event: ' . $e->getMessage(), 0, $e);
} finally {
@@ -199,6 +195,16 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIs
}
}
+ public function createFromString(string $name, string $calendarData): void {
+ $server = new EmbeddedCalDavServer(false);
+ $this->createFromStringInServer($name, $calendarData, $server->getServer());
+ }
+
+ public function createFromStringMinimal(string $name, string $calendarData): void {
+ $server = new InvitationResponseServer(false);
+ $this->createFromStringInServer($name, $calendarData, $server->getServer());
+ }
+
/**
* @throws CalendarException
*/
diff --git a/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php
new file mode 100644
index 00000000000..21d8c06fa99
--- /dev/null
+++ b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php
@@ -0,0 +1,118 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV;
+
+use OCA\DAV\AppInfo\PluginManager;
+use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin;
+use OCA\DAV\CalDAV\Auth\PublicPrincipalPlugin;
+use OCA\DAV\CalDAV\Publishing\PublishPlugin;
+use OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin;
+use OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin;
+use OCA\DAV\Connector\Sabre\CachingTree;
+use OCA\DAV\Connector\Sabre\DavAclPlugin;
+use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin;
+use OCA\DAV\Connector\Sabre\LockPlugin;
+use OCA\DAV\Connector\Sabre\MaintenancePlugin;
+use OCA\DAV\Events\SabrePluginAuthInitEvent;
+use OCA\DAV\RootCollection;
+use OCA\Theming\ThemingDefaults;
+use OCP\App\IAppManager;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IAppConfig;
+use OCP\IConfig;
+use OCP\IURLGenerator;
+use OCP\L10N\IFactory as IL10NFactory;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
+
+class EmbeddedCalDavServer {
+ private readonly \OCA\DAV\Connector\Sabre\Server $server;
+
+ public function __construct(bool $public = true) {
+ $baseUri = \OC::$WEBROOT . '/remote.php/dav/';
+ $logger = Server::get(LoggerInterface::class);
+ $dispatcher = Server::get(IEventDispatcher::class);
+ $appConfig = Server::get(IAppConfig::class);
+ $l10nFactory = Server::get(IL10NFactory::class);
+ $l10n = $l10nFactory->get('dav');
+
+ $root = new RootCollection();
+ $this->server = new \OCA\DAV\Connector\Sabre\Server(new CachingTree($root));
+
+ // Add maintenance plugin
+ $this->server->addPlugin(new MaintenancePlugin(Server::get(IConfig::class), $l10n));
+
+ // Set URL explicitly due to reverse-proxy situations
+ $this->server->httpRequest->setUrl($baseUri);
+ $this->server->setBaseUri($baseUri);
+
+ $this->server->addPlugin(new BlockLegacyClientPlugin(
+ Server::get(IConfig::class),
+ Server::get(ThemingDefaults::class),
+ ));
+ $this->server->addPlugin(new AnonymousOptionsPlugin());
+
+ // allow custom principal uri option
+ if ($public) {
+ $this->server->addPlugin(new PublicPrincipalPlugin());
+ } else {
+ $this->server->addPlugin(new CustomPrincipalPlugin());
+ }
+
+ // allow setup of additional auth backends
+ $event = new SabrePluginAuthInitEvent($this->server);
+ $dispatcher->dispatchTyped($event);
+
+ $this->server->addPlugin(new ExceptionLoggerPlugin('webdav', $logger));
+ $this->server->addPlugin(new LockPlugin());
+ $this->server->addPlugin(new \Sabre\DAV\Sync\Plugin());
+
+ // acl
+ $acl = new DavAclPlugin();
+ $acl->principalCollectionSet = [
+ 'principals/users', 'principals/groups'
+ ];
+ $this->server->addPlugin($acl);
+
+ // calendar plugins
+ $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin());
+ $this->server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin());
+ $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class)));
+ $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin());
+ $this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin());
+ //$this->server->addPlugin(new \OCA\DAV\DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest()));
+ $this->server->addPlugin(new PublishPlugin(
+ Server::get(IConfig::class),
+ Server::get(IURLGenerator::class)
+ ));
+ if ($appConfig->getValueString('dav', 'sendInvitations', 'yes') === 'yes') {
+ $this->server->addPlugin(Server::get(\OCA\DAV\CalDAV\Schedule\IMipPlugin::class));
+ }
+
+ // wait with registering these until auth is handled and the filesystem is setup
+ $this->server->on('beforeMethod:*', function () use ($root): void {
+ // register plugins from apps
+ $pluginManager = new PluginManager(
+ \OC::$server,
+ Server::get(IAppManager::class)
+ );
+ foreach ($pluginManager->getAppPlugins() as $appPlugin) {
+ $this->server->addPlugin($appPlugin);
+ }
+ foreach ($pluginManager->getAppCollections() as $appCollection) {
+ $root->addChild($appCollection);
+ }
+ });
+ }
+
+ public function getServer(): \OCA\DAV\Connector\Sabre\Server {
+ return $this->server;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/EventReader.php b/apps/dav/lib/CalDAV/EventReader.php
index b7dd2889956..ee2b8f33f9a 100644
--- a/apps/dav/lib/CalDAV/EventReader.php
+++ b/apps/dav/lib/CalDAV/EventReader.php
@@ -46,8 +46,8 @@ class EventReader {
7 => 'July', 8 => 'August', 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December'
];
protected array $relativePositionNamesMap = [
- 1 => 'First', 2 => 'Second', 3 => 'Third', 4 => 'Fourth', 5 => 'Fifty',
- -1 => 'Last', -2 => 'Second Last', -3 => 'Third Last', -4 => 'Fourth Last', -5 => 'Fifty Last'
+ 1 => 'First', 2 => 'Second', 3 => 'Third', 4 => 'Fourth', 5 => 'Fifth',
+ -1 => 'Last', -2 => 'Second Last', -3 => 'Third Last', -4 => 'Fourth Last', -5 => 'Fifth Last'
];
/**
diff --git a/apps/dav/lib/CalDAV/Schedule/IMipService.php b/apps/dav/lib/CalDAV/Schedule/IMipService.php
index f7054eb2d34..54c0bc31849 100644
--- a/apps/dav/lib/CalDAV/Schedule/IMipService.php
+++ b/apps/dav/lib/CalDAV/Schedule/IMipService.php
@@ -1110,7 +1110,7 @@ class IMipService {
$sequence = $iTipMessage->sequence;
$recurrenceId = isset($vevent->{'RECURRENCE-ID'})
? $vevent->{'RECURRENCE-ID'}->serialize() : null;
- $uid = $vevent->{'UID'};
+ $uid = $vevent->{'UID'}?->getValue();
$query = $this->db->getQueryBuilder();
$query->insert('calendar_invitations')
diff --git a/apps/dav/lib/CalDAV/Status/StatusService.php b/apps/dav/lib/CalDAV/Status/StatusService.php
index de19174de58..9ee0e9bf356 100644
--- a/apps/dav/lib/CalDAV/Status/StatusService.php
+++ b/apps/dav/lib/CalDAV/Status/StatusService.php
@@ -141,7 +141,7 @@ class StatusService {
$this->logger->debug("Found $count applicable event(s), changing user status", ['user' => $userId]);
$this->userStatusService->setUserStatus(
$userId,
- IUserStatus::AWAY,
+ IUserStatus::BUSY,
IUserStatus::MESSAGE_CALENDAR_BUSY,
true
);
diff --git a/apps/dav/lib/CalDAV/UpcomingEventsService.php b/apps/dav/lib/CalDAV/UpcomingEventsService.php
index 6614d937ff7..1a8aed5bd71 100644
--- a/apps/dav/lib/CalDAV/UpcomingEventsService.php
+++ b/apps/dav/lib/CalDAV/UpcomingEventsService.php
@@ -47,7 +47,7 @@ class UpcomingEventsService {
$this->userManager->get($userId),
);
- return array_map(function (array $event) use ($userId, $calendarAppEnabled) {
+ return array_filter(array_map(function (array $event) use ($userId, $calendarAppEnabled) {
$calendarAppUrl = null;
if ($calendarAppEnabled) {
@@ -67,6 +67,10 @@ class UpcomingEventsService {
$calendarAppUrl = $this->urlGenerator->linkToRouteAbsolute('calendar.view.indexdirect.edit', $arguments);
}
+ if (isset($event['objects'][0]['STATUS']) && $event['objects'][0]['STATUS'][0] === 'CANCELLED') {
+ return false;
+ }
+
return new UpcomingEvent(
$event['uri'],
($event['objects'][0]['RECURRENCE-ID'][0] ?? null)?->getTimeStamp(),
@@ -76,7 +80,7 @@ class UpcomingEventsService {
$event['objects'][0]['LOCATION'][0] ?? null,
$calendarAppUrl,
);
- }, $events);
+ }, $events));
}
}
diff --git a/apps/dav/lib/CardDAV/AddressBook.php b/apps/dav/lib/CardDAV/AddressBook.php
index 67c0b7167fa..4d30d507a7d 100644
--- a/apps/dav/lib/CardDAV/AddressBook.php
+++ b/apps/dav/lib/CardDAV/AddressBook.php
@@ -8,7 +8,6 @@
namespace OCA\DAV\CardDAV;
use OCA\DAV\DAV\Sharing\IShareable;
-use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException;
use OCP\DB\Exception;
use OCP\IL10N;
use OCP\Server;
@@ -234,9 +233,6 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable, IMov
}
public function getChanges($syncToken, $syncLevel, $limit = null) {
- if (!$syncToken && $limit) {
- throw new UnsupportedLimitOnInitialSyncException();
- }
return parent::getChanges($syncToken, $syncLevel, $limit);
}
@@ -250,7 +246,12 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable, IMov
}
try {
- return $this->carddavBackend->moveCard($sourceNode->getAddressbookId(), (int)$this->addressBookInfo['id'], $sourceNode->getUri(), $sourceNode->getOwner());
+ return $this->carddavBackend->moveCard(
+ $sourceNode->getAddressbookId(),
+ $sourceNode->getUri(),
+ $this->getResourceId(),
+ $targetName,
+ );
} catch (Exception $e) {
// Avoid injecting LoggerInterface everywhere
Server::get(LoggerInterface::class)->error('Could not move calendar object: ' . $e->getMessage(), ['exception' => $e]);
diff --git a/apps/dav/lib/CardDAV/CardDavBackend.php b/apps/dav/lib/CardDAV/CardDavBackend.php
index d1631bb762c..a78686eb61d 100644
--- a/apps/dav/lib/CardDAV/CardDavBackend.php
+++ b/apps/dav/lib/CardDAV/CardDavBackend.php
@@ -23,6 +23,7 @@ use OCP\AppFramework\Db\TTransactional;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUserManager;
use PDO;
@@ -59,6 +60,7 @@ class CardDavBackend implements BackendInterface, SyncSupport {
private IUserManager $userManager,
private IEventDispatcher $dispatcher,
private Sharing\Backend $sharingBackend,
+ private IConfig $config,
) {
}
@@ -473,7 +475,7 @@ class CardDavBackend implements BackendInterface, SyncSupport {
*/
public function getCards($addressbookId) {
$query = $this->db->getQueryBuilder();
- $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
+ $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
->from($this->dbCardsTable)
->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId)));
@@ -510,7 +512,7 @@ class CardDavBackend implements BackendInterface, SyncSupport {
*/
public function getCard($addressBookId, $cardUri) {
$query = $this->db->getQueryBuilder();
- $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
+ $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
->from($this->dbCardsTable)
->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
@@ -553,7 +555,7 @@ class CardDavBackend implements BackendInterface, SyncSupport {
$cards = [];
$query = $this->db->getQueryBuilder();
- $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
+ $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
->from($this->dbCardsTable)
->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
->andWhere($query->expr()->in('uri', $query->createParameter('uri')));
@@ -715,32 +717,33 @@ class CardDavBackend implements BackendInterface, SyncSupport {
/**
* @throws Exception
*/
- public function moveCard(int $sourceAddressBookId, int $targetAddressBookId, string $cardUri, string $oldPrincipalUri): bool {
- return $this->atomic(function () use ($sourceAddressBookId, $targetAddressBookId, $cardUri, $oldPrincipalUri) {
- $card = $this->getCard($sourceAddressBookId, $cardUri);
+ public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int $targetAddressBookId, string $tragetObjectUri): bool {
+ return $this->atomic(function () use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) {
+ $card = $this->getCard($sourceAddressBookId, $sourceObjectUri);
if (empty($card)) {
return false;
}
+ $sourceObjectId = (int)$card['id'];
$query = $this->db->getQueryBuilder();
$query->update('cards')
->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT))
- ->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
+ ->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR))
+ ->where($query->expr()->eq('uri', $query->createNamedParameter($sourceObjectUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($sourceAddressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
->executeStatement();
- $this->purgeProperties($sourceAddressBookId, (int)$card['id']);
- $this->updateProperties($sourceAddressBookId, $card['uri'], $card['carddata']);
+ $this->purgeProperties($sourceAddressBookId, $sourceObjectId);
+ $this->updateProperties($targetAddressBookId, $tragetObjectUri, $card['carddata']);
- $this->addChange($sourceAddressBookId, $card['uri'], 3);
- $this->addChange($targetAddressBookId, $card['uri'], 1);
+ $this->addChange($sourceAddressBookId, $sourceObjectUri, 3);
+ $this->addChange($targetAddressBookId, $tragetObjectUri, 1);
- $card = $this->getCard($targetAddressBookId, $cardUri);
+ $card = $this->getCard($targetAddressBookId, $tragetObjectUri);
// Card wasn't found - possibly because it was deleted in the meantime by a different client
if (empty($card)) {
return false;
}
-
$targetAddressBookRow = $this->getAddressBookById($targetAddressBookId);
// the address book this card is being moved to does not exist any longer
if (empty($targetAddressBookRow)) {
@@ -850,6 +853,8 @@ class CardDavBackend implements BackendInterface, SyncSupport {
* @return array
*/
public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
+ $maxLimit = $this->config->getSystemValueInt('carddav_sync_request_truncation', 2500);
+ $limit = ($limit === null) ? $maxLimit : min($limit, $maxLimit);
// Current synctoken
return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) {
$qb = $this->db->getQueryBuilder();
@@ -872,10 +877,35 @@ class CardDavBackend implements BackendInterface, SyncSupport {
'modified' => [],
'deleted' => [],
];
-
- if ($syncToken) {
+ if (str_starts_with($syncToken, 'init_')) {
+ $syncValues = explode('_', $syncToken);
+ $lastID = $syncValues[1];
+ $initialSyncToken = $syncValues[2];
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('id', 'uri')
+ ->from('cards')
+ ->where(
+ $qb->expr()->andX(
+ $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)),
+ $qb->expr()->gt('id', $qb->createNamedParameter($lastID)))
+ )->orderBy('id')
+ ->setMaxResults($limit);
+ $stmt = $qb->executeQuery();
+ $values = $stmt->fetchAll(\PDO::FETCH_ASSOC);
+ $stmt->closeCursor();
+ if (count($values) === 0) {
+ $result['syncToken'] = $initialSyncToken;
+ $result['result_truncated'] = false;
+ $result['added'] = [];
+ } else {
+ $lastID = $values[array_key_last($values)]['id'];
+ $result['added'] = array_column($values, 'uri');
+ $result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken ;
+ $result['result_truncated'] = count($result['added']) >= $limit;
+ }
+ } elseif ($syncToken) {
$qb = $this->db->getQueryBuilder();
- $qb->select('uri', 'operation')
+ $qb->select('uri', 'operation', 'synctoken')
->from('addressbookchanges')
->where(
$qb->expr()->andX(
@@ -885,22 +915,31 @@ class CardDavBackend implements BackendInterface, SyncSupport {
)
)->orderBy('synctoken');
- if (is_int($limit) && $limit > 0) {
+ if ($limit > 0) {
$qb->setMaxResults($limit);
}
// Fetching all changes
$stmt = $qb->executeQuery();
+ $rowCount = $stmt->rowCount();
$changes = [];
+ $highestSyncToken = 0;
// This loop ensures that any duplicates are overwritten, only the
// last change on a node is relevant.
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$changes[$row['uri']] = $row['operation'];
+ $highestSyncToken = $row['synctoken'];
}
+
$stmt->closeCursor();
+ // No changes found, use current token
+ if (empty($changes)) {
+ $result['syncToken'] = $currentToken;
+ }
+
foreach ($changes as $uri => $operation) {
switch ($operation) {
case 1:
@@ -914,16 +953,43 @@ class CardDavBackend implements BackendInterface, SyncSupport {
break;
}
}
+
+ /*
+ * The synctoken in oc_addressbooks is always the highest synctoken in oc_addressbookchanges for a given addressbook plus one (see addChange).
+ *
+ * For truncated results, it is expected that we return the highest token from the response, so the client can continue from the latest change.
+ *
+ * For non-truncated results, it is expected to return the currentToken. If we return the highest token, as with truncated results, the client will always think it is one change behind.
+ *
+ * Therefore, we differentiate between truncated and non-truncated results when returning the synctoken.
+ */
+ if ($rowCount === $limit && $highestSyncToken < $currentToken) {
+ $result['syncToken'] = $highestSyncToken;
+ $result['result_truncated'] = true;
+ }
} else {
$qb = $this->db->getQueryBuilder();
- $qb->select('uri')
+ $qb->select('id', 'uri')
->from('cards')
->where(
$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
);
// No synctoken supplied, this is the initial sync.
+ $qb->setMaxResults($limit);
$stmt = $qb->executeQuery();
- $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN);
+ $values = $stmt->fetchAll(\PDO::FETCH_ASSOC);
+ if (empty($values)) {
+ $result['added'] = [];
+ return $result;
+ }
+ $lastID = $values[array_key_last($values)]['id'];
+ if (count($values) >= $limit) {
+ $result['syncToken'] = 'init_' . $lastID . '_' . $currentToken;
+ $result['result_truncated'] = true;
+ }
+
+ $result['added'] = array_column($values, 'uri');
+
$stmt->closeCursor();
}
return $result;
diff --git a/apps/dav/lib/CardDAV/SyncService.php b/apps/dav/lib/CardDAV/SyncService.php
index 4a75f8ced6c..e6da3ed5923 100644
--- a/apps/dav/lib/CardDAV/SyncService.php
+++ b/apps/dav/lib/CardDAV/SyncService.php
@@ -22,6 +22,7 @@ use Psr\Log\LoggerInterface;
use Sabre\DAV\Xml\Response\MultiStatus;
use Sabre\DAV\Xml\Service;
use Sabre\VObject\Reader;
+use Sabre\Xml\ParseException;
use function is_null;
class SyncService {
@@ -43,9 +44,10 @@ class SyncService {
}
/**
+ * @psalm-return list{0: ?string, 1: boolean}
* @throws \Exception
*/
- public function syncRemoteAddressBook(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken, string $targetBookHash, string $targetPrincipal, array $targetProperties): string {
+ public function syncRemoteAddressBook(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken, string $targetBookHash, string $targetPrincipal, array $targetProperties): array {
// 1. create addressbook
$book = $this->ensureSystemAddressBookExists($targetPrincipal, $targetBookHash, $targetProperties);
$addressBookId = $book['id'];
@@ -83,7 +85,10 @@ class SyncService {
}
}
- return $response['token'];
+ return [
+ $response['token'],
+ $response['truncated'],
+ ];
}
/**
@@ -127,7 +132,7 @@ class SyncService {
private function prepareUri(string $host, string $path): string {
/*
- * The trailing slash is important for merging the uris together.
+ * The trailing slash is important for merging the uris.
*
* $host is stored in oc_trusted_servers.url and usually without a trailing slash.
*
@@ -158,7 +163,9 @@ class SyncService {
}
/**
+ * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool}
* @throws ClientExceptionInterface
+ * @throws ParseException
*/
protected function requestSyncReport(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken): array {
$client = $this->clientService->newClient();
@@ -181,7 +188,7 @@ class SyncService {
$body = $response->getBody();
assert(is_string($body));
- return $this->parseMultiStatus($body);
+ return $this->parseMultiStatus($body, $addressBookUrl);
}
protected function download(string $url, string $userName, string $sharedSecret, string $resourcePath): string {
@@ -219,22 +226,50 @@ class SyncService {
}
/**
- * @param string $body
- * @return array
- * @throws \Sabre\Xml\ParseException
+ * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool}
+ * @throws ParseException
*/
- private function parseMultiStatus($body) {
- $xml = new Service();
-
+ private function parseMultiStatus(string $body, string $addressBookUrl): array {
/** @var MultiStatus $multiStatus */
- $multiStatus = $xml->expect('{DAV:}multistatus', $body);
+ $multiStatus = (new Service())->expect('{DAV:}multistatus', $body);
$result = [];
+ $truncated = false;
+
foreach ($multiStatus->getResponses() as $response) {
- $result[$response->getHref()] = $response->getResponseProperties();
+ $href = $response->getHref();
+ if ($response->getHttpStatus() === '507' && $this->isResponseForRequestUri($href, $addressBookUrl)) {
+ $truncated = true;
+ } else {
+ $result[$response->getHref()] = $response->getResponseProperties();
+ }
}
- return ['response' => $result, 'token' => $multiStatus->getSyncToken()];
+ return ['response' => $result, 'token' => $multiStatus->getSyncToken(), 'truncated' => $truncated];
+ }
+
+ /**
+ * Determines whether the provided response URI corresponds to the given request URI.
+ */
+ private function isResponseForRequestUri(string $responseUri, string $requestUri): bool {
+ /*
+ * Example response uri:
+ *
+ * /remote.php/dav/addressbooks/system/system/system/
+ * /cloud/remote.php/dav/addressbooks/system/system/system/ (when installed in a subdirectory)
+ *
+ * Example request uri:
+ *
+ * remote.php/dav/addressbooks/system/system/system
+ *
+ * References:
+ * https://github.com/nextcloud/3rdparty/blob/e0a509739b13820f0a62ff9cad5d0fede00e76ee/sabre/dav/lib/DAV/Sync/Plugin.php#L172-L174
+ * https://github.com/nextcloud/server/blob/b40acb34a39592070d8455eb91c5364c07928c50/apps/federation/lib/SyncFederationAddressBooks.php#L41
+ */
+ return str_ends_with(
+ rtrim($responseUri, '/'),
+ rtrim($requestUri, '/')
+ );
}
/**
diff --git a/apps/dav/lib/CardDAV/SystemAddressbook.php b/apps/dav/lib/CardDAV/SystemAddressbook.php
index e0032044e70..912a2f1dcee 100644
--- a/apps/dav/lib/CardDAV/SystemAddressbook.php
+++ b/apps/dav/lib/CardDAV/SystemAddressbook.php
@@ -8,7 +8,6 @@ declare(strict_types=1);
*/
namespace OCA\DAV\CardDAV;
-use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException;
use OCA\Federation\TrustedServers;
use OCP\Accounts\IAccountManager;
use OCP\IConfig;
@@ -212,14 +211,7 @@ class SystemAddressbook extends AddressBook {
}
return new Card($this->carddavBackend, $this->addressBookInfo, $obj);
}
-
- /**
- * @throws UnsupportedLimitOnInitialSyncException
- */
public function getChanges($syncToken, $syncLevel, $limit = null) {
- if (!$syncToken && $limit) {
- throw new UnsupportedLimitOnInitialSyncException();
- }
if (!$this->carddavBackend instanceof SyncSupport) {
return null;
diff --git a/apps/dav/lib/Command/GetAbsenceCommand.php b/apps/dav/lib/Command/GetAbsenceCommand.php
new file mode 100644
index 00000000000..50d8df4ab38
--- /dev/null
+++ b/apps/dav/lib/Command/GetAbsenceCommand.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+namespace OCA\DAV\Command;
+
+use OCA\DAV\Service\AbsenceService;
+use OCP\IUserManager;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class GetAbsenceCommand extends Command {
+
+ public function __construct(
+ private IUserManager $userManager,
+ private AbsenceService $absenceService,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this->setName('dav:absence:get');
+ $this->addArgument(
+ 'user-id',
+ InputArgument::REQUIRED,
+ 'User ID of the affected account'
+ );
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $userId = $input->getArgument('user-id');
+
+ $user = $this->userManager->get($userId);
+ if ($user === null) {
+ $output->writeln('<error>User not found</error>');
+ return 1;
+ }
+
+ $absence = $this->absenceService->getAbsence($userId);
+ if ($absence === null) {
+ $output->writeln('<info>No absence set</info>');
+ return 0;
+ }
+
+ $output->writeln('<info>Start day:</info> ' . $absence->getFirstDay());
+ $output->writeln('<info>End day:</info> ' . $absence->getLastDay());
+ $output->writeln('<info>Short message:</info> ' . $absence->getStatus());
+ $output->writeln('<info>Message:</info> ' . $absence->getMessage());
+ $output->writeln('<info>Replacement user:</info> ' . ($absence->getReplacementUserId() ?? 'none'));
+ $output->writeln('<info>Replacement display name:</info> ' . ($absence->getReplacementUserDisplayName() ?? 'none'));
+
+ return 0;
+ }
+
+}
diff --git a/apps/dav/lib/Command/SetAbsenceCommand.php b/apps/dav/lib/Command/SetAbsenceCommand.php
new file mode 100644
index 00000000000..bf91a163f95
--- /dev/null
+++ b/apps/dav/lib/Command/SetAbsenceCommand.php
@@ -0,0 +1,95 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+namespace OCA\DAV\Command;
+
+use OCA\DAV\Service\AbsenceService;
+use OCP\IUserManager;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class SetAbsenceCommand extends Command {
+
+ public function __construct(
+ private IUserManager $userManager,
+ private AbsenceService $absenceService,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this->setName('dav:absence:set');
+ $this->addArgument(
+ 'user-id',
+ InputArgument::REQUIRED,
+ 'User ID of the affected account'
+ );
+ $this->addArgument(
+ 'first-day',
+ InputArgument::REQUIRED,
+ 'Inclusive start day formatted as YYYY-MM-DD'
+ );
+ $this->addArgument(
+ 'last-day',
+ InputArgument::REQUIRED,
+ 'Inclusive end day formatted as YYYY-MM-DD'
+ );
+ $this->addArgument(
+ 'short-message',
+ InputArgument::REQUIRED,
+ 'Short message'
+ );
+ $this->addArgument(
+ 'message',
+ InputArgument::REQUIRED,
+ 'Message'
+ );
+ $this->addArgument(
+ 'replacement-user-id',
+ InputArgument::OPTIONAL,
+ 'Replacement user id'
+ );
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $userId = $input->getArgument('user-id');
+
+ $user = $this->userManager->get($userId);
+ if ($user === null) {
+ $output->writeln('<error>User not found</error>');
+ return 1;
+ }
+
+ $replacementUserId = $input->getArgument('replacement-user-id');
+ if ($replacementUserId === null) {
+ $replacementUser = null;
+ } else {
+ $replacementUser = $this->userManager->get($replacementUserId);
+ if ($replacementUser === null) {
+ $output->writeln('<error>Replacement user not found</error>');
+ return 2;
+ }
+ }
+
+ $this->absenceService->createOrUpdateAbsence(
+ $user,
+ $input->getArgument('first-day'),
+ $input->getArgument('last-day'),
+ $input->getArgument('short-message'),
+ $input->getArgument('message'),
+ $replacementUser?->getUID(),
+ $replacementUser?->getDisplayName(),
+ );
+
+ return 0;
+ }
+
+}
diff --git a/apps/dav/lib/Connector/Sabre/File.php b/apps/dav/lib/Connector/Sabre/File.php
index 218d38e1c4b..d2a71eb3e7b 100644
--- a/apps/dav/lib/Connector/Sabre/File.php
+++ b/apps/dav/lib/Connector/Sabre/File.php
@@ -204,6 +204,9 @@ class File extends Node implements IFile {
}
}
+ $lengthHeader = $this->request->getHeader('content-length');
+ $expected = $lengthHeader !== '' ? (int)$lengthHeader : null;
+
if ($partStorage->instanceOfStorage(IWriteStreamStorage::class)) {
$isEOF = false;
$wrappedData = CallbackWrapper::wrap($data, null, null, null, null, function ($stream) use (&$isEOF): void {
@@ -215,7 +218,7 @@ class File extends Node implements IFile {
$count = -1;
try {
/** @var IWriteStreamStorage $partStorage */
- $count = $partStorage->writeStream($internalPartPath, $wrappedData);
+ $count = $partStorage->writeStream($internalPartPath, $wrappedData, $expected);
} catch (GenericFileException $e) {
$logger = Server::get(LoggerInterface::class);
$logger->error('Error while writing stream to storage: ' . $e->getMessage(), ['exception' => $e, 'app' => 'webdav']);
@@ -235,10 +238,7 @@ class File extends Node implements IFile {
[$count, $result] = Files::streamCopy($data, $target, true);
fclose($target);
}
-
- $lengthHeader = $this->request->getHeader('content-length');
- $expected = $lengthHeader !== '' ? (int)$lengthHeader : -1;
- if ($result === false && $expected >= 0) {
+ if ($result === false && $expected !== null) {
throw new Exception(
$this->l10n->t(
'Error while copying file to target location (copied: %1$s, expected filesize: %2$s)',
@@ -253,7 +253,7 @@ class File extends Node implements IFile {
// if content length is sent by client:
// double check if the file was fully received
// compare expected and actual size
- if ($expected >= 0
+ if ($expected !== null
&& $expected !== $count
&& $this->request->getMethod() === 'PUT'
) {
diff --git a/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php b/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php
index 9e625ce3184..b59d1373af5 100644
--- a/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php
+++ b/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php
@@ -156,7 +156,7 @@ class FilesReportPlugin extends ServerPlugin {
// to user backends. I.e. the final result may return more results than requested.
$resultNodes = $this->processFilterRulesForFileNodes($filterRules, $limit ?? null, $offset ?? null);
} catch (TagNotFoundException $e) {
- throw new PreconditionFailed('Cannot filter by non-existing tag', 0, $e);
+ throw new PreconditionFailed('Cannot filter by non-existing tag');
}
$results = [];
diff --git a/apps/dav/lib/Connector/Sabre/Principal.php b/apps/dav/lib/Connector/Sabre/Principal.php
index b61cabedf5f..d6ea9fd887d 100644
--- a/apps/dav/lib/Connector/Sabre/Principal.php
+++ b/apps/dav/lib/Connector/Sabre/Principal.php
@@ -186,6 +186,9 @@ class Principal implements BackendInterface {
if ($this->hasGroups || $needGroups) {
$userGroups = $this->groupManager->getUserGroups($user);
foreach ($userGroups as $userGroup) {
+ if ($userGroup->hideFromCollaboration()) {
+ continue;
+ }
$groups[] = 'principals/groups/' . urlencode($userGroup->getGID());
}
}
diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php
index 214412e1744..3749b506d16 100644
--- a/apps/dav/lib/Connector/Sabre/ServerFactory.php
+++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php
@@ -184,7 +184,7 @@ class ServerFactory {
!$this->config->getSystemValue('debug', false)
)
);
- $server->addPlugin(new QuotaPlugin($view, true));
+ $server->addPlugin(new QuotaPlugin($view));
$server->addPlugin(new ChecksumUpdatePlugin());
// Allow view-only plugin for webdav requests
diff --git a/apps/dav/lib/RootCollection.php b/apps/dav/lib/RootCollection.php
index f1963c0ef01..870aa0d4540 100644
--- a/apps/dav/lib/RootCollection.php
+++ b/apps/dav/lib/RootCollection.php
@@ -132,6 +132,7 @@ class RootCollection extends SimpleCollection {
);
$contactsSharingBackend = Server::get(\OCA\DAV\CardDAV\Sharing\Backend::class);
+ $config = Server::get(IConfig::class);
$pluginManager = new PluginManager(\OC::$server, Server::get(IAppManager::class));
$usersCardDavBackend = new CardDavBackend(
@@ -140,6 +141,7 @@ class RootCollection extends SimpleCollection {
$userManager,
$dispatcher,
$contactsSharingBackend,
+ $config
);
$usersAddressBookRoot = new AddressBookRoot($userPrincipalBackend, $usersCardDavBackend, $pluginManager, $userSession->getUser(), $groupManager, 'principals/users');
$usersAddressBookRoot->disableListing = $disableListing;
@@ -150,6 +152,7 @@ class RootCollection extends SimpleCollection {
$userManager,
$dispatcher,
$contactsSharingBackend,
+ $config
);
$systemAddressBookRoot = new AddressBookRoot(new SystemPrincipalBackend(), $systemCardDavBackend, $pluginManager, $userSession->getUser(), $groupManager, 'principals/system');
$systemAddressBookRoot->disableListing = $disableListing;