Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/dav/appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
</repair-steps>

<commands>
<command>OCA\DAV\Command\ClearCalendarUnshares</command>
<command>OCA\DAV\Command\CreateAddressBook</command>
<command>OCA\DAV\Command\CreateCalendar</command>
<command>OCA\DAV\Command\CreateSubscription</command>
Expand All @@ -63,6 +64,7 @@
<command>OCA\DAV\Command\ExportCalendar</command>
<command>OCA\DAV\Command\FixCalendarSyncCommand</command>
<command>OCA\DAV\Command\ListAddressbooks</command>
<command>OCA\DAV\Command\ListCalendarShares</command>
<command>OCA\DAV\Command\ListCalendars</command>
<command>OCA\DAV\Command\ListSubscriptions</command>
<command>OCA\DAV\Command\MoveCalendar</command>
Expand Down
2 changes: 2 additions & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@
'OCA\\DAV\\CardDAV\\UserAddressBooks' => $baseDir . '/../lib/CardDAV/UserAddressBooks.php',
'OCA\\DAV\\CardDAV\\Validation\\CardDavValidatePlugin' => $baseDir . '/../lib/CardDAV/Validation/CardDavValidatePlugin.php',
'OCA\\DAV\\CardDAV\\Xml\\Groups' => $baseDir . '/../lib/CardDAV/Xml/Groups.php',
'OCA\\DAV\\Command\\ClearCalendarUnshares' => $baseDir . '/../lib/Command/ClearCalendarUnshares.php',
'OCA\\DAV\\Command\\CreateAddressBook' => $baseDir . '/../lib/Command/CreateAddressBook.php',
'OCA\\DAV\\Command\\CreateCalendar' => $baseDir . '/../lib/Command/CreateCalendar.php',
'OCA\\DAV\\Command\\CreateSubscription' => $baseDir . '/../lib/Command/CreateSubscription.php',
Expand All @@ -163,6 +164,7 @@
'OCA\\DAV\\Command\\ExportCalendar' => $baseDir . '/../lib/Command/ExportCalendar.php',
'OCA\\DAV\\Command\\FixCalendarSyncCommand' => $baseDir . '/../lib/Command/FixCalendarSyncCommand.php',
'OCA\\DAV\\Command\\ListAddressbooks' => $baseDir . '/../lib/Command/ListAddressbooks.php',
'OCA\\DAV\\Command\\ListCalendarShares' => $baseDir . '/../lib/Command/ListCalendarShares.php',
'OCA\\DAV\\Command\\ListCalendars' => $baseDir . '/../lib/Command/ListCalendars.php',
'OCA\\DAV\\Command\\ListSubscriptions' => $baseDir . '/../lib/Command/ListSubscriptions.php',
'OCA\\DAV\\Command\\MoveCalendar' => $baseDir . '/../lib/Command/MoveCalendar.php',
Expand Down
2 changes: 2 additions & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CardDAV\\UserAddressBooks' => __DIR__ . '/..' . '/../lib/CardDAV/UserAddressBooks.php',
'OCA\\DAV\\CardDAV\\Validation\\CardDavValidatePlugin' => __DIR__ . '/..' . '/../lib/CardDAV/Validation/CardDavValidatePlugin.php',
'OCA\\DAV\\CardDAV\\Xml\\Groups' => __DIR__ . '/..' . '/../lib/CardDAV/Xml/Groups.php',
'OCA\\DAV\\Command\\ClearCalendarUnshares' => __DIR__ . '/..' . '/../lib/Command/ClearCalendarUnshares.php',
'OCA\\DAV\\Command\\CreateAddressBook' => __DIR__ . '/..' . '/../lib/Command/CreateAddressBook.php',
'OCA\\DAV\\Command\\CreateCalendar' => __DIR__ . '/..' . '/../lib/Command/CreateCalendar.php',
'OCA\\DAV\\Command\\CreateSubscription' => __DIR__ . '/..' . '/../lib/Command/CreateSubscription.php',
Expand All @@ -178,6 +179,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Command\\ExportCalendar' => __DIR__ . '/..' . '/../lib/Command/ExportCalendar.php',
'OCA\\DAV\\Command\\FixCalendarSyncCommand' => __DIR__ . '/..' . '/../lib/Command/FixCalendarSyncCommand.php',
'OCA\\DAV\\Command\\ListAddressbooks' => __DIR__ . '/..' . '/../lib/Command/ListAddressbooks.php',
'OCA\\DAV\\Command\\ListCalendarShares' => __DIR__ . '/..' . '/../lib/Command/ListCalendarShares.php',
'OCA\\DAV\\Command\\ListCalendars' => __DIR__ . '/..' . '/../lib/Command/ListCalendars.php',
'OCA\\DAV\\Command\\ListSubscriptions' => __DIR__ . '/..' . '/../lib/Command/ListSubscriptions.php',
'OCA\\DAV\\Command\\MoveCalendar' => __DIR__ . '/..' . '/../lib/Command/MoveCalendar.php',
Expand Down
40 changes: 38 additions & 2 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@
* Code is heavily inspired by https://github.com/fruux/sabre-dav/blob/master/lib/CalDAV/Backend/PDO.php
*
* @package OCA\DAV\CalDAV
*
* @psalm-type CalendarInfo = array{
* id: int,
* uri: string,
* principaluri: string,
* '{http://calendarserver.org/ns/}getctag': string,
* '{http://sabredav.org/ns}sync-token': int,
* '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set': \Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet,
* '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp': \Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp,
* '{DAV:}displayname': string,
* '{urn:ietf:params:xml:ns:caldav}calendar-timezone': ?string,
* '{http://nextcloud.com/ns}owner-displayname': string,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

owner-displayname is only set if we have displayname:

private function addOwnerPrincipalToCalendar(array $calendarInfo): array {
$ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
$displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
if (isset($calendarInfo[$ownerPrincipalKey])) {
$uri = $calendarInfo[$ownerPrincipalKey];
} else {
$uri = $calendarInfo['principaluri'];
}
$principalInformation = $this->principalBackend->getPrincipalByPath($uri);
if (isset($principalInformation['{DAV:}displayname'])) {
$calendarInfo[$displaynameKey] = $principalInformation['{DAV:}displayname'];
}
return $calendarInfo;

But we use it without an isset before and thus, marking it as optional, will trigger some new psalm warnings.

* }
*/
class CalDavBackend extends AbstractBackend implements SyncSupport, SubscriptionSupport, SchedulingSupport {
use TTransactional;
Expand Down Expand Up @@ -374,7 +387,7 @@ public function getCalendarsForUser($principalUri) {
$subSelect->select('resourceid')
->from('dav_shares', 'd')
->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY));
->andWhere($subSelect->expr()->eq('d.principaluri', $select->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR));

$select->select($fields)
->from('dav_shares', 's')
Expand Down Expand Up @@ -651,7 +664,8 @@ public function getCalendarByUri($principal, $uri) {
}

/**
* @return array{id: int, uri: string, '{http://calendarserver.org/ns/}getctag': string, '{http://sabredav.org/ns}sync-token': int, '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set': SupportedCalendarComponentSet, '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp': ScheduleCalendarTransp, '{urn:ietf:params:xml:ns:caldav}calendar-timezone': ?string }|null
* @psalm-return CalendarInfo|null
* @return array|null
*/
public function getCalendarById(int $calendarId): ?array {
$fields = array_column($this->propertyMap, 0);
Expand Down Expand Up @@ -3671,4 +3685,26 @@ protected function purgeObjectInvitations(string $eventId): void {
->where($cmd->expr()->eq('uid', $cmd->createNamedParameter($eventId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR));
$cmd->executeStatement();
}

public function unshare(IShareable $shareable, string $principal): void {
$this->atomic(function () use ($shareable, $principal): void {
$calendarData = $this->getCalendarById($shareable->getResourceId());
if ($calendarData === null) {
throw new \RuntimeException('Trying to update shares for non-existing calendar: ' . $shareable->getResourceId());
}

$oldShares = $this->getShares($shareable->getResourceId());
$unshare = $this->calendarSharingBackend->unshare($shareable, $principal);

if ($unshare) {
$this->dispatcher->dispatchTyped(new CalendarShareUpdatedEvent(
$shareable->getResourceId(),
$calendarData,
$oldShares,
[],
[$principal]
));
}
}, $this->db);
}
}
8 changes: 2 additions & 6 deletions apps/dav/lib/CalDAV/Calendar.php
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,8 @@ public function getOwner(): ?string {
}

public function delete() {
if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal']) &&
$this->calendarInfo['{http://owncloud.org/ns}owner-principal'] !== $this->calendarInfo['principaluri']) {
$principal = 'principal:' . parent::getOwner();
$this->caldavBackend->updateShares($this, [], [
$principal
]);
if ($this->isShared()) {
$this->caldavBackend->unshare($this, 'principal:' . $this->getPrincipalURI());
return;
}

Expand Down
2 changes: 1 addition & 1 deletion apps/dav/lib/CardDAV/CardDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ public function getAddressBooksForUser($principalUri) {
$subSelect->select('id')
->from('dav_shares', 'd')
->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(\OCA\DAV\CardDAV\Sharing\Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY));
->andWhere($subSelect->expr()->eq('d.principaluri', $select->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR));


$select->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access'])
Expand Down
114 changes: 114 additions & 0 deletions apps/dav/lib/Command/ClearCalendarUnshares.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

declare(strict_types=1);

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

namespace OCA\DAV\Command;

use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Sharing\Backend;
use OCA\DAV\CalDAV\Sharing\Service;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\Sharing\Backend as BackendAlias;
use OCA\DAV\DAV\Sharing\SharingMapper;
use OCP\IAppConfig;
use OCP\IUserManager;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;

#[AsCommand(
name: 'dav:clear-calendar-unshares',
description: 'Clear calendar unshares for a user',
hidden: false,
)]
class ClearCalendarUnshares extends Command {
public function __construct(
private IUserManager $userManager,
private IAppConfig $appConfig,
private Principal $principal,
private CalDavBackend $caldav,
private Backend $sharingBackend,
private Service $sharingService,
private SharingMapper $mapper,
) {
parent::__construct();
}

protected function configure(): void {
$this->addArgument(
'uid',
InputArgument::REQUIRED,
'User whose unshares to clear'
);
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$user = (string)$input->getArgument('uid');
if (!$this->userManager->userExists($user)) {
throw new \InvalidArgumentException("User $user is unknown");
}

$principal = $this->principal->getPrincipalByPath('principals/users/' . $user);
if ($principal === null) {
throw new \InvalidArgumentException("Unable to fetch principal for user $user ");
}

$shares = $this->mapper->getSharesByPrincipals([$principal['uri']], 'calendar');
$unshares = array_filter($shares, static fn ($share) => $share['access'] === BackendAlias::ACCESS_UNSHARED);

if (count($unshares) === 0) {
$output->writeln("User $user has no calendar unshares");
return self::SUCCESS;
}

$rows = array_map(fn ($share) => $this->formatCalendarUnshare($share), $shares);

$table = new Table($output);
$table
->setHeaders(['Share Id', 'Calendar Id', 'Calendar URI', 'Calendar Name'])
->setRows($rows)
->render();

$output->writeln('');

/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$question = new ConfirmationQuestion('Please confirm to delete the above calendar unshare entries [y/n]', false);

if ($helper->ask($input, $output, $question)) {
$this->mapper->deleteUnsharesByPrincipal($principal['uri'], 'calendar');
$output->writeln("Calendar unshares for user $user deleted");
}

return self::SUCCESS;
}

private function formatCalendarUnshare(array $share): array {
$calendarInfo = $this->caldav->getCalendarById($share['resourceid']);

$resourceUri = 'Resource not found';
$resourceName = '';

if ($calendarInfo !== null) {
$resourceUri = $calendarInfo['uri'];
$resourceName = $calendarInfo['{DAV:}displayname'];
}

return [
$share['id'],
$share['resourceid'],
$resourceUri,
$resourceName,
];
}
}
131 changes: 131 additions & 0 deletions apps/dav/lib/Command/ListCalendarShares.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

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

namespace OCA\DAV\Command;

use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Sharing\Backend;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\Sharing\SharingMapper;
use OCP\IUserManager;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
name: 'dav:list-calendar-shares',
description: 'List all calendar shares for a user',
hidden: false,
)]
class ListCalendarShares extends Command {
public function __construct(
private IUserManager $userManager,
private Principal $principal,
private CalDavBackend $caldav,
private SharingMapper $mapper,
) {
parent::__construct();
}

protected function configure(): void {
$this->addArgument(
'uid',
InputArgument::REQUIRED,
'User whose calendar shares will be listed'
);
$this->addOption(
'calendar-id',
'',
InputOption::VALUE_REQUIRED,
'List only shares for the given calendar id id',
null,
);
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$user = (string)$input->getArgument('uid');
if (!$this->userManager->userExists($user)) {
throw new \InvalidArgumentException("User $user is unknown");
}

$principal = $this->principal->getPrincipalByPath('principals/users/' . $user);
if ($principal === null) {
throw new \InvalidArgumentException("Unable to fetch principal for user $user");
}

$memberships = array_merge(
[$principal['uri']],
$this->principal->getGroupMembership($principal['uri']),
$this->principal->getCircleMembership($principal['uri']),
);

$shares = $this->mapper->getSharesByPrincipals($memberships, 'calendar');

$calendarId = $input->getOption('calendar-id');
if ($calendarId !== null) {
$shares = array_filter($shares, fn ($share) => $share['resourceid'] === (int)$calendarId);
}

$rows = array_map(fn ($share) => $this->formatCalendarShare($share), $shares);

if (count($rows) > 0) {
$table = new Table($output);
$table
->setHeaders(['Share Id', 'Calendar Id', 'Calendar URI', 'Calendar Name', 'Calendar Owner', 'Access By', 'Permissions'])
->setRows($rows)
->render();
} else {
$output->writeln("User $user has no calendar shares");
}

return self::SUCCESS;
}

private function formatCalendarShare(array $share): array {
$calendarInfo = $this->caldav->getCalendarById($share['resourceid']);

$calendarUri = 'Resource not found';
$calendarName = '';
$calendarOwner = '';

if ($calendarInfo !== null) {
$calendarUri = $calendarInfo['uri'];
$calendarName = $calendarInfo['{DAV:}displayname'];
$calendarOwner = $calendarInfo['{http://nextcloud.com/ns}owner-displayname'] . ' (' . $calendarInfo['principaluri'] . ')';
}

$accessBy = match (true) {
str_starts_with($share['principaluri'], 'principals/users/') => 'Individual',
str_starts_with($share['principaluri'], 'principals/groups/') => 'Group (' . $share['principaluri'] . ')',
str_starts_with($share['principaluri'], 'principals/circles/') => 'Team (' . $share['principaluri'] . ')',
default => $share['principaluri'],
};

$permissions = match ($share['access']) {
Backend::ACCESS_READ => 'Read',
Backend::ACCESS_READ_WRITE => 'Read/Write',
Backend::ACCESS_UNSHARED => 'Unshare',
default => $share['access'],
};

return [
$share['id'],
$share['resourceid'],
$calendarUri,
$calendarName,
$calendarOwner,
$accessBy,
$permissions,
];
}
}
Loading
Loading