diff options
Diffstat (limited to 'apps/dav/lib')
25 files changed, 620 insertions, 85 deletions
diff --git a/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php b/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php index 4d25f5bb501..74efebb6e2a 100644 --- a/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php +++ b/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php @@ -9,11 +9,12 @@ declare(strict_types=1); namespace OCA\DAV\CalDAV; use OCP\Calendar\ICalendar; +use OCP\Calendar\ICalendarIsEnabled; use OCP\Calendar\ICalendarIsShared; use OCP\Calendar\ICalendarIsWritable; use OCP\Constants; -class CachedSubscriptionImpl implements ICalendar, ICalendarIsShared, ICalendarIsWritable { +class CachedSubscriptionImpl implements ICalendar, ICalendarIsEnabled, ICalendarIsShared, ICalendarIsWritable { public function __construct( private CachedSubscription $calendar, @@ -86,6 +87,13 @@ class CachedSubscriptionImpl implements ICalendar, ICalendarIsShared, ICalendarI return $result; } + /** + * @since 32.0.0 + */ + public function isEnabled(): bool { + return $this->calendarInfo['{http://owncloud.org/ns}calendar-enabled'] ?? true; + } + public function isWritable(): bool { return false; } diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 2ef57ca77bb..e69fe9ed3f0 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -9,6 +9,7 @@ namespace OCA\DAV\CalDAV; use DateTime; use DateTimeImmutable; use DateTimeInterface; +use Generator; use OCA\DAV\AppInfo\Application; use OCA\DAV\CalDAV\Sharing\Backend; use OCA\DAV\Connector\Sabre\Principal; @@ -28,6 +29,7 @@ use OCA\DAV\Events\SubscriptionCreatedEvent; use OCA\DAV\Events\SubscriptionDeletedEvent; use OCA\DAV\Events\SubscriptionUpdatedEvent; use OCP\AppFramework\Db\TTransactional; +use OCP\Calendar\CalendarExportOptions; use OCP\Calendar\Events\CalendarObjectCreatedEvent; use OCP\Calendar\Events\CalendarObjectDeletedEvent; use OCP\Calendar\Events\CalendarObjectMovedEvent; @@ -988,6 +990,44 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** + * Returns all calendar entries as a stream of data + * + * @since 32.0.0 + * + * @return Generator<array> + */ + public function exportCalendar(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, ?CalendarExportOptions $options = null): Generator { + // extract options + $rangeStart = $options?->getRangeStart(); + $rangeCount = $options?->getRangeCount(); + // construct query + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('calendarobjects') + ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) + ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) + ->andWhere($qb->expr()->isNull('deleted_at')); + if ($rangeStart !== null) { + $qb->andWhere($qb->expr()->gt('uid', $qb->createNamedParameter($rangeStart))); + } + if ($rangeCount !== null) { + $qb->setMaxResults($rangeCount); + } + if ($rangeStart !== null || $rangeCount !== null) { + $qb->orderBy('uid', 'ASC'); + } + $rs = $qb->executeQuery(); + // iterate through results + try { + while (($row = $rs->fetch()) !== false) { + yield $row; + } + } finally { + $rs->closeCursor(); + } + } + + /** * Returns all calendar objects with limited metadata for a calendar * * Every item contains an array with the following keys: diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php index b3062f005ee..d36f46df901 100644 --- a/apps/dav/lib/CalDAV/CalendarImpl.php +++ b/apps/dav/lib/CalDAV/CalendarImpl.php @@ -8,9 +8,15 @@ declare(strict_types=1); */ namespace OCA\DAV\CalDAV; +use Generator; use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin; use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer; +use OCP\Calendar\CalendarExportOptions; use OCP\Calendar\Exceptions\CalendarException; +use OCP\Calendar\ICalendarExport; +use OCP\Calendar\ICalendarIsEnabled; +use OCP\Calendar\ICalendarIsShared; +use OCP\Calendar\ICalendarIsWritable; use OCP\Calendar\ICreateFromString; use OCP\Calendar\IHandleImipMessage; use OCP\Constants; @@ -24,7 +30,7 @@ use Sabre\VObject\Property; use Sabre\VObject\Reader; use function Sabre\Uri\split as uriSplit; -class CalendarImpl implements ICreateFromString, IHandleImipMessage { +class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIsWritable, ICalendarIsShared, ICalendarExport, ICalendarIsEnabled { public function __construct( private Calendar $calendar, /** @var array<string, mixed> */ @@ -132,6 +138,13 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage { } /** + * @since 32.0.0 + */ + public function isEnabled(): bool { + return $this->calendarInfo['{http://owncloud.org/ns}calendar-enabled'] ?? true; + } + + /** * @since 31.0.0 */ public function isWritable(): bool { @@ -257,4 +270,27 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage { public function getInvitationResponseServer(): InvitationResponseServer { return new InvitationResponseServer(false); } + + /** + * Export objects + * + * @since 32.0.0 + * + * @return Generator<mixed, \Sabre\VObject\Component\VCalendar, mixed, mixed> + */ + public function export(?CalendarExportOptions $options = null): Generator { + foreach ( + $this->backend->exportCalendar( + $this->calendarInfo['id'], + $this->backend::CALENDAR_TYPE_CALENDAR, + $options + ) as $event + ) { + $vObject = Reader::read($event['calendardata']); + if ($vObject instanceof VCalendar) { + yield $vObject; + } + } + } + } diff --git a/apps/dav/lib/CalDAV/CalendarProvider.php b/apps/dav/lib/CalDAV/CalendarProvider.php index a31322b2b49..3cc4039ed36 100644 --- a/apps/dav/lib/CalDAV/CalendarProvider.php +++ b/apps/dav/lib/CalDAV/CalendarProvider.php @@ -8,6 +8,8 @@ declare(strict_types=1); */ namespace OCA\DAV\CalDAV; +use OCA\DAV\Db\Property; +use OCA\DAV\Db\PropertyMapper; use OCP\Calendar\ICalendarProvider; use OCP\IConfig; use OCP\IL10N; @@ -20,6 +22,7 @@ class CalendarProvider implements ICalendarProvider { private IL10N $l10n, private IConfig $config, private LoggerInterface $logger, + private PropertyMapper $propertyMapper, ) { } @@ -35,6 +38,7 @@ class CalendarProvider implements ICalendarProvider { $iCalendars = []; foreach ($calendarInfos as $calendarInfo) { + $calendarInfo = array_merge($calendarInfo, $this->getAdditionalProperties($calendarInfo['principaluri'], $calendarInfo['uri'])); $calendar = new Calendar($this->calDavBackend, $calendarInfo, $this->l10n, $this->config, $this->logger); $iCalendars[] = new CalendarImpl( $calendar, @@ -44,4 +48,23 @@ class CalendarProvider implements ICalendarProvider { } return $iCalendars; } + + public function getAdditionalProperties(string $principalUri, string $calendarUri): array { + $user = str_replace('principals/users/', '', $principalUri); + $path = 'calendars/' . $user . '/' . $calendarUri; + + $properties = $this->propertyMapper->findPropertiesByPath($user, $path); + + $list = []; + foreach ($properties as $property) { + if ($property instanceof Property) { + $list[$property->getPropertyname()] = match ($property->getPropertyname()) { + '{http://owncloud.org/ns}calendar-enabled' => (bool)$property->getPropertyvalue(), + default => $property->getPropertyvalue() + }; + } + } + + return $list; + } } diff --git a/apps/dav/lib/CalDAV/Export/ExportService.php b/apps/dav/lib/CalDAV/Export/ExportService.php new file mode 100644 index 00000000000..393c53b92e4 --- /dev/null +++ b/apps/dav/lib/CalDAV/Export/ExportService.php @@ -0,0 +1,107 @@ +<?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\Export; + +use Generator; +use OCP\Calendar\CalendarExportOptions; +use OCP\Calendar\ICalendarExport; +use OCP\ServerVersion; +use Sabre\VObject\Component; +use Sabre\VObject\Writer; + +/** + * Calendar Export Service + */ +class ExportService { + + public const FORMATS = ['ical', 'jcal', 'xcal']; + private string $systemVersion; + + public function __construct(ServerVersion $serverVersion) { + $this->systemVersion = $serverVersion->getVersionString(); + } + + /** + * Generates serialized content stream for a calendar and objects based in selected format + * + * @return Generator<string> + */ + public function export(ICalendarExport $calendar, CalendarExportOptions $options): Generator { + // output start of serialized content based on selected format + yield $this->exportStart($options->getFormat()); + // iterate through each returned vCalendar entry + // extract each component except timezones, convert to appropriate format and output + // extract any timezones and save them but do not output + $timezones = []; + foreach ($calendar->export($options) as $entry) { + $consecutive = false; + foreach ($entry->getComponents() as $vComponent) { + if ($vComponent->name === 'VTIMEZONE') { + if (isset($vComponent->TZID) && !isset($timezones[$vComponent->TZID->getValue()])) { + $timezones[$vComponent->TZID->getValue()] = clone $vComponent; + } + } else { + yield $this->exportObject($vComponent, $options->getFormat(), $consecutive); + $consecutive = true; + } + } + } + // iterate through each saved vTimezone entry, convert to appropriate format and output + foreach ($timezones as $vComponent) { + yield $this->exportObject($vComponent, $options->getFormat(), $consecutive); + $consecutive = true; + } + // output end of serialized content based on selected format + yield $this->exportFinish($options->getFormat()); + } + + /** + * Generates serialized content start based on selected format + */ + private function exportStart(string $format): string { + return match ($format) { + 'jcal' => '["vcalendar",[["version",{},"text","2.0"],["prodid",{},"text","-\/\/IDN nextcloud.com\/\/Calendar Export v' . $this->systemVersion . '\/\/EN"]],[', + 'xcal' => '<?xml version="1.0" encoding="UTF-8"?><icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0"><vcalendar><properties><version><text>2.0</text></version><prodid><text>-//IDN nextcloud.com//Calendar Export v' . $this->systemVersion . '//EN</text></prodid></properties><components>', + default => "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//IDN nextcloud.com//Calendar Export v" . $this->systemVersion . "//EN\n" + }; + } + + /** + * Generates serialized content end based on selected format + */ + private function exportFinish(string $format): string { + return match ($format) { + 'jcal' => ']]', + 'xcal' => '</components></vcalendar></icalendar>', + default => "END:VCALENDAR\n" + }; + } + + /** + * Generates serialized content for a component based on selected format + */ + private function exportObject(Component $vobject, string $format, bool $consecutive): string { + return match ($format) { + 'jcal' => $consecutive ? ',' . Writer::writeJson($vobject) : Writer::writeJson($vobject), + 'xcal' => $this->exportObjectXml($vobject), + default => Writer::write($vobject) + }; + } + + /** + * Generates serialized content for a component in xml format + */ + private function exportObjectXml(Component $vobject): string { + $writer = new \Sabre\Xml\Writer(); + $writer->openMemory(); + $writer->setIndent(false); + $vobject->xmlSerialize($writer); + return $writer->outputMemory(); + } + +} diff --git a/apps/dav/lib/Capabilities.php b/apps/dav/lib/Capabilities.php index ab4e53fce37..f321222b285 100644 --- a/apps/dav/lib/Capabilities.php +++ b/apps/dav/lib/Capabilities.php @@ -17,12 +17,13 @@ class Capabilities implements ICapability { } /** - * @return array{dav: array{chunking: string, bulkupload?: string, absence-supported?: bool, absence-replacement?: bool}} + * @return array{dav: array{chunking: string, public_shares_chunking: bool, bulkupload?: string, absence-supported?: bool, absence-replacement?: bool}} */ public function getCapabilities() { $capabilities = [ 'dav' => [ 'chunking' => '1.0', + 'public_shares_chunking' => true, ] ]; if ($this->config->getSystemValueBool('bulkupload.enabled', true)) { diff --git a/apps/dav/lib/Command/ExportCalendar.php b/apps/dav/lib/Command/ExportCalendar.php new file mode 100644 index 00000000000..5758cd4fa87 --- /dev/null +++ b/apps/dav/lib/Command/ExportCalendar.php @@ -0,0 +1,95 @@ +<?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 InvalidArgumentException; +use OCA\DAV\CalDAV\Export\ExportService; +use OCP\Calendar\CalendarExportOptions; +use OCP\Calendar\ICalendarExport; +use OCP\Calendar\IManager; +use OCP\IUserManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Calendar Export Command + * + * Used to export data from supported calendars to disk or stdout + */ +#[AsCommand( + name: 'calendar:export', + description: 'Export calendar data from supported calendars to disk or stdout', + hidden: false +)] +class ExportCalendar extends Command { + public function __construct( + private IUserManager $userManager, + private IManager $calendarManager, + private ExportService $exportService, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this->setName('calendar:export') + ->setDescription('Export calendar data from supported calendars to disk or stdout') + ->addArgument('uid', InputArgument::REQUIRED, 'Id of system user') + ->addArgument('uri', InputArgument::REQUIRED, 'Uri of calendar') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Format of output (ical, jcal, xcal) defaults to ical', 'ical') + ->addOption('location', null, InputOption::VALUE_REQUIRED, 'Location of where to write the output. defaults to stdout'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $userId = $input->getArgument('uid'); + $calendarId = $input->getArgument('uri'); + $format = $input->getOption('format'); + $location = $input->getOption('location'); + + if (!$this->userManager->userExists($userId)) { + throw new InvalidArgumentException("User <$userId> not found."); + } + // retrieve calendar and evaluate if export is supported + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + throw new InvalidArgumentException("Calendar <$calendarId> not found."); + } + $calendar = $calendars[0]; + if (!$calendar instanceof ICalendarExport) { + throw new InvalidArgumentException("Calendar <$calendarId> does not support exporting"); + } + // construct options object + $options = new CalendarExportOptions(); + // evaluate if provided format is supported + if (!in_array($format, ExportService::FORMATS, true)) { + throw new InvalidArgumentException("Format <$format> is not valid."); + } + $options->setFormat($format); + // evaluate is a valid location was given and is usable otherwise output to stdout + if ($location !== null) { + $handle = fopen($location, 'wb'); + if ($handle === false) { + throw new InvalidArgumentException("Location <$location> is not valid. Can not open location for write operation."); + } + + foreach ($this->exportService->export($calendar, $options) as $chunk) { + fwrite($handle, $chunk); + } + fclose($handle); + } else { + foreach ($this->exportService->export($calendar, $options) as $chunk) { + $output->writeln($chunk); + } + } + + return self::SUCCESS; + } +} diff --git a/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php b/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php index 64a61a43a9b..18009080585 100644 --- a/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php +++ b/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php @@ -27,20 +27,6 @@ class ChecksumUpdatePlugin extends ServerPlugin { } /** @return string[] */ - public function getHTTPMethods($path): array { - $tree = $this->server->tree; - - if ($tree->nodeExists($path)) { - $node = $tree->getNodeForPath($path); - if ($node instanceof File) { - return ['PATCH']; - } - } - - return []; - } - - /** @return string[] */ public function getFeatures(): array { return ['nextcloud-checksum-update']; } diff --git a/apps/dav/lib/Connector/Sabre/Directory.php b/apps/dav/lib/Connector/Sabre/Directory.php index 7f8fe3a84de..fe09c3f423f 100644 --- a/apps/dav/lib/Connector/Sabre/Directory.php +++ b/apps/dav/lib/Connector/Sabre/Directory.php @@ -13,7 +13,9 @@ use OCA\DAV\AppInfo\Application; use OCA\DAV\Connector\Sabre\Exception\FileLocked; use OCA\DAV\Connector\Sabre\Exception\Forbidden; use OCA\DAV\Connector\Sabre\Exception\InvalidPath; +use OCA\DAV\Storage\PublicShareWrapper; use OCP\App\IAppManager; +use OCP\Constants; use OCP\Files\FileInfo; use OCP\Files\Folder; use OCP\Files\ForbiddenException; @@ -172,7 +174,20 @@ class Directory extends Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuot * @throws \Sabre\DAV\Exception\ServiceUnavailable */ public function getChild($name, $info = null, ?IRequest $request = null, ?IL10N $l10n = null) { - if (!$this->info->isReadable()) { + $storage = $this->info->getStorage(); + $allowDirectory = false; + + // Checking if we're in a file drop + // If we are, then only PUT and MKCOL are allowed (see plugin) + // so we are safe to return the directory without a risk of + // leaking files and folders structure. + if ($storage instanceof PublicShareWrapper) { + $share = $storage->getShare(); + $allowDirectory = ($share->getPermissions() & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ; + } + + // For file drop we need to be allowed to read the directory with the nickname + if (!$allowDirectory && !$this->info->isReadable()) { // avoid detecting files through this way throw new NotFound(); } @@ -198,6 +213,11 @@ class Directory extends Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuot if ($info->getMimeType() === FileInfo::MIMETYPE_FOLDER) { $node = new \OCA\DAV\Connector\Sabre\Directory($this->fileView, $info, $this->tree, $this->shareManager); } else { + // In case reading a directory was allowed but it turns out the node was a not a directory, reject it now. + if (!$this->info->isReadable()) { + throw new NotFound(); + } + $node = new File($this->fileView, $info, $this->shareManager, $request, $l10n); } if ($this->tree) { diff --git a/apps/dav/lib/Connector/Sabre/FilesPlugin.php b/apps/dav/lib/Connector/Sabre/FilesPlugin.php index b886534f9de..9e2affddb6b 100644 --- a/apps/dav/lib/Connector/Sabre/FilesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -720,15 +720,15 @@ class FilesPlugin extends ServerPlugin { */ public function sendFileIdHeader($filePath, ?\Sabre\DAV\INode $node = null) { // we get the node for the given $filePath here because in case of afterCreateFile $node is the parent folder - if (!$this->server->tree->nodeExists($filePath)) { - return; - } - $node = $this->server->tree->getNodeForPath($filePath); - if ($node instanceof Node) { - $fileId = $node->getFileId(); - if (!is_null($fileId)) { - $this->server->httpResponse->setHeader('OC-FileId', $fileId); + try { + $node = $this->server->tree->getNodeForPath($filePath); + if ($node instanceof Node) { + $fileId = $node->getFileId(); + if (!is_null($fileId)) { + $this->server->httpResponse->setHeader('OC-FileId', $fileId); + } } + } catch (NotFound) { } } } diff --git a/apps/dav/lib/Connector/Sabre/Principal.php b/apps/dav/lib/Connector/Sabre/Principal.php index 515ef807a25..67edb1c4035 100644 --- a/apps/dav/lib/Connector/Sabre/Principal.php +++ b/apps/dav/lib/Connector/Sabre/Principal.php @@ -155,6 +155,11 @@ class Principal implements BackendInterface { 'uri' => 'principals/system/' . $name, '{DAV:}displayname' => $this->languageFactory->get('dav')->t('Accounts'), ]; + } elseif ($prefix === 'principals/shares') { + return [ + 'uri' => 'principals/shares/' . $name, + '{DAV:}displayname' => $name, + ]; } return null; } diff --git a/apps/dav/lib/Connector/Sabre/PublicAuth.php b/apps/dav/lib/Connector/Sabre/PublicAuth.php index ea59d9efc8f..b5d9ce3db72 100644 --- a/apps/dav/lib/Connector/Sabre/PublicAuth.php +++ b/apps/dav/lib/Connector/Sabre/PublicAuth.php @@ -15,6 +15,7 @@ use OCP\Defaults; use OCP\IRequest; use OCP\ISession; use OCP\Security\Bruteforce\IThrottler; +use OCP\Security\Bruteforce\MaxDelayReached; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; use OCP\Share\IShare; @@ -56,6 +57,7 @@ class PublicAuth extends AbstractBasic { * * @return array * @throws NotAuthenticated + * @throws MaxDelayReached * @throws ServiceUnavailable */ public function check(RequestInterface $request, ResponseInterface $response): array { @@ -75,7 +77,8 @@ class PublicAuth extends AbstractBasic { } return $this->checkToken(); - } catch (NotAuthenticated $e) { + } catch (NotAuthenticated|MaxDelayReached $e) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); throw $e; } catch (\Exception $e) { $class = get_class($e); @@ -94,7 +97,7 @@ class PublicAuth extends AbstractBasic { $path = $this->request->getPathInfo() ?: ''; // ['', 'dav', 'files', 'token'] $splittedPath = explode('/', $path); - + if (count($splittedPath) < 4 || $splittedPath[3] === '') { throw new NotFound(); } @@ -176,7 +179,7 @@ class PublicAuth extends AbstractBasic { } return true; } - + if ($this->session->exists(PublicAuth::DAV_AUTHENTICATED) && $this->session->get(PublicAuth::DAV_AUTHENTICATED) === $share->getId()) { return true; diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index 55cbb416457..bdd13b7f44e 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -8,11 +8,14 @@ namespace OCA\DAV\Connector\Sabre; use OC\Files\View; +use OC\KnownUser\KnownUserService; use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\CalDAV\DefaultCalendarValidator; +use OCA\DAV\CalDAV\Proxy\ProxyMapper; use OCA\DAV\DAV\CustomPropertiesBackend; use OCA\DAV\DAV\ViewOnlyPlugin; use OCA\DAV\Files\BrowserErrorPagePlugin; +use OCA\DAV\Upload\CleanupService; use OCA\Theming\ThemingDefaults; use OCP\Accounts\IAccountManager; use OCP\App\IAppManager; @@ -20,6 +23,7 @@ use OCP\Comments\ICommentsManager; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Folder; use OCP\Files\IFilenameValidator; +use OCP\Files\IRootFolder; use OCP\Files\Mount\IMountManager; use OCP\IConfig; use OCP\IDBConnection; @@ -28,12 +32,14 @@ use OCP\IL10N; use OCP\IPreview; use OCP\IRequest; use OCP\ITagManager; +use OCP\IUserManager; use OCP\IUserSession; use OCP\SabrePluginEvent; use OCP\SystemTag\ISystemTagManager; use OCP\SystemTag\ISystemTagObjectMapper; use Psr\Log\LoggerInterface; use Sabre\DAV\Auth\Plugin; +use Sabre\DAV\SimpleCollection; class ServerFactory { @@ -54,13 +60,22 @@ class ServerFactory { /** * @param callable $viewCallBack callback that should return the view for the dav endpoint */ - public function createServer(string $baseUri, + public function createServer( + bool $isPublicShare, + string $baseUri, string $requestUri, Plugin $authPlugin, - callable $viewCallBack): Server { + callable $viewCallBack, + ): Server { // Fire up server - $objectTree = new ObjectTree(); - $server = new Server($objectTree); + if ($isPublicShare) { + $rootCollection = new SimpleCollection('root'); + $tree = new CachingTree($rootCollection); + } else { + $rootCollection = null; + $tree = new ObjectTree(); + } + $server = new Server($tree); // Set URL explicitly due to reverse-proxy situations $server->httpRequest->setUrl($requestUri); $server->setBaseUri($baseUri); @@ -81,7 +96,7 @@ class ServerFactory { $server->addPlugin(new RequestIdHeaderPlugin($this->request)); $server->addPlugin(new ZipFolderPlugin( - $objectTree, + $tree, $this->logger, $this->eventDispatcher, )); @@ -101,7 +116,7 @@ class ServerFactory { } // wait with registering these until auth is handled and the filesystem is setup - $server->on('beforeMethod:*', function () use ($server, $objectTree, $viewCallBack): void { + $server->on('beforeMethod:*', function () use ($server, $tree, $viewCallBack, $isPublicShare, $rootCollection): void { // ensure the skeleton is copied $userFolder = \OC::$server->getUserFolder(); @@ -115,15 +130,49 @@ class ServerFactory { // Create Nextcloud Dir if ($rootInfo->getType() === 'dir') { - $root = new Directory($view, $rootInfo, $objectTree); + $root = new Directory($view, $rootInfo, $tree); } else { $root = new File($view, $rootInfo); } - $objectTree->init($root, $view, $this->mountManager); + + if ($isPublicShare) { + $userPrincipalBackend = new Principal( + \OCP\Server::get(IUserManager::class), + \OCP\Server::get(IGroupManager::class), + \OCP\Server::get(IAccountManager::class), + \OCP\Server::get(\OCP\Share\IManager::class), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(IAppManager::class), + \OCP\Server::get(ProxyMapper::class), + \OCP\Server::get(KnownUserService::class), + \OCP\Server::get(IConfig::class), + \OC::$server->getL10NFactory(), + ); + + // Mount the share collection at /public.php/dav/shares/<share token> + $rootCollection->addChild(new \OCA\DAV\Files\Sharing\RootCollection( + $root, + $userPrincipalBackend, + 'principals/shares', + )); + + // Mount the upload collection at /public.php/dav/uploads/<share token> + $rootCollection->addChild(new \OCA\DAV\Upload\RootCollection( + $userPrincipalBackend, + 'principals/shares', + \OCP\Server::get(CleanupService::class), + \OCP\Server::get(IRootFolder::class), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(\OCP\Share\IManager::class), + )); + } else { + /** @var ObjectTree $tree */ + $tree->init($root, $view, $this->mountManager); + } $server->addPlugin( new FilesPlugin( - $objectTree, + $tree, $this->config, $this->request, $this->previewManager, @@ -143,16 +192,16 @@ class ServerFactory { )); if ($this->userSession->isLoggedIn()) { - $server->addPlugin(new TagsPlugin($objectTree, $this->tagManager, $this->eventDispatcher, $this->userSession)); + $server->addPlugin(new TagsPlugin($tree, $this->tagManager, $this->eventDispatcher, $this->userSession)); $server->addPlugin(new SharesPlugin( - $objectTree, + $tree, $this->userSession, $userFolder, \OCP\Server::get(\OCP\Share\IManager::class) )); $server->addPlugin(new CommentPropertiesPlugin(\OCP\Server::get(ICommentsManager::class), $this->userSession)); $server->addPlugin(new FilesReportPlugin( - $objectTree, + $tree, $view, \OCP\Server::get(ISystemTagManager::class), \OCP\Server::get(ISystemTagObjectMapper::class), @@ -167,7 +216,7 @@ class ServerFactory { new \Sabre\DAV\PropertyStorage\Plugin( new CustomPropertiesBackend( $server, - $objectTree, + $tree, $this->databaseConnection, $this->userSession->getUser(), \OCP\Server::get(DefaultCalendarValidator::class), diff --git a/apps/dav/lib/Db/PropertyMapper.php b/apps/dav/lib/Db/PropertyMapper.php index a0ecb348ba4..1789194ee7a 100644 --- a/apps/dav/lib/Db/PropertyMapper.php +++ b/apps/dav/lib/Db/PropertyMapper.php @@ -38,4 +38,18 @@ class PropertyMapper extends QBMapper { return $this->findEntities($selectQb); } + /** + * @return Property[] + */ + public function findPropertiesByPath(string $userId, string $path): array { + $selectQb = $this->db->getQueryBuilder(); + $selectQb->select('*') + ->from(self::TABLE_NAME) + ->where( + $selectQb->expr()->eq('userid', $selectQb->createNamedParameter($userId)), + $selectQb->expr()->eq('propertypath', $selectQb->createNamedParameter($path)), + ); + return $this->findEntities($selectQb); + } + } diff --git a/apps/dav/lib/Direct/DirectHome.php b/apps/dav/lib/Direct/DirectHome.php index 10e1017f5a4..ac411c9b52f 100644 --- a/apps/dav/lib/Direct/DirectHome.php +++ b/apps/dav/lib/Direct/DirectHome.php @@ -53,7 +53,7 @@ class DirectHome implements ICollection { } catch (DoesNotExistException $e) { // Since the token space is so huge only throttle on non-existing token $this->throttler->registerAttempt('directlink', $this->request->getRemoteAddress()); - $this->throttler->sleepDelay($this->request->getRemoteAddress(), 'directlink'); + $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), 'directlink'); throw new NotFound(); } diff --git a/apps/dav/lib/Files/BrowserErrorPagePlugin.php b/apps/dav/lib/Files/BrowserErrorPagePlugin.php index de86c4995e2..85ed975a409 100644 --- a/apps/dav/lib/Files/BrowserErrorPagePlugin.php +++ b/apps/dav/lib/Files/BrowserErrorPagePlugin.php @@ -11,6 +11,7 @@ use OC\AppFramework\Http\Request; use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\TemplateResponse; use OCP\IRequest; +use OCP\Security\Bruteforce\MaxDelayReached; use OCP\Template\ITemplateManager; use Sabre\DAV\Exception; use Sabre\DAV\Server; @@ -60,6 +61,9 @@ class BrowserErrorPagePlugin extends ServerPlugin { if ($ex instanceof Exception) { $httpCode = $ex->getHTTPCode(); $headers = $ex->getHTTPHeaders($this->server); + } elseif ($ex instanceof MaxDelayReached) { + $httpCode = 429; + $headers = []; } else { $httpCode = 500; $headers = []; @@ -81,7 +85,7 @@ class BrowserErrorPagePlugin extends ServerPlugin { $request = \OCP\Server::get(IRequest::class); $templateName = 'exception'; - if ($httpCode === 403 || $httpCode === 404) { + if ($httpCode === 403 || $httpCode === 404 || $httpCode === 429) { $templateName = (string)$httpCode; } diff --git a/apps/dav/lib/Files/Sharing/FilesDropPlugin.php b/apps/dav/lib/Files/Sharing/FilesDropPlugin.php index 9d883be81fc..ad7648795da 100644 --- a/apps/dav/lib/Files/Sharing/FilesDropPlugin.php +++ b/apps/dav/lib/Files/Sharing/FilesDropPlugin.php @@ -36,57 +36,136 @@ class FilesDropPlugin extends ServerPlugin { /** * This initializes the plugin. - * - * @param \Sabre\DAV\Server $server Sabre server - * - * @return void - * @throws MethodNotAllowed + * It is ONLY initialized by the server on a file drop request. */ public function initialize(\Sabre\DAV\Server $server): void { $server->on('beforeMethod:*', [$this, 'beforeMethod'], 999); + $server->on('method:MKCOL', [$this, 'onMkcol']); $this->enabled = false; } - public function beforeMethod(RequestInterface $request, ResponseInterface $response): void { + public function onMkcol(RequestInterface $request, ResponseInterface $response) { if (!$this->enabled || $this->share === null || $this->view === null) { return; } - // Only allow file drop + // If this is a folder creation request we need + // to fake a success so we can pretend every + // folder now exists. + $response->setStatus(201); + return false; + } + + public function beforeMethod(RequestInterface $request, ResponseInterface $response) { + if (!$this->enabled || $this->share === null || $this->view === null) { + return; + } + + // Retrieve the nickname from the request + $nickname = $request->hasHeader('X-NC-Nickname') + ? trim(urldecode($request->getHeader('X-NC-Nickname'))) + : null; + + // if ($request->getMethod() !== 'PUT') { - throw new MethodNotAllowed('Only PUT is allowed on files drop'); + // If uploading subfolders we need to ensure they get created + // within the nickname folder + if ($request->getMethod() === 'MKCOL') { + if (!$nickname) { + throw new MethodNotAllowed('A nickname header is required when uploading subfolders'); + } + } else { + throw new MethodNotAllowed('Only PUT is allowed on files drop'); + } } - // Always upload at the root level - $path = explode('/', $request->getPath()); - $path = array_pop($path); + // If this is a folder creation request + // let's stop there and let the onMkcol handle it + if ($request->getMethod() === 'MKCOL') { + return; + } + + // Now if we create a file, we need to create the + // full path along the way. We'll only handle conflict + // resolution on file conflicts, but not on folders. + + // e.g files/dCP8yn3N86EK9sL/Folder/image.jpg + $path = $request->getPath(); + $token = $this->share->getToken(); + + // e.g files/dCP8yn3N86EK9sL + $rootPath = substr($path, 0, strpos($path, $token) + strlen($token)); + // e.g /Folder/image.jpg + $relativePath = substr($path, strlen($rootPath)); + $isRootUpload = substr_count($relativePath, '/') === 1; // Extract the attributes for the file request $isFileRequest = false; $attributes = $this->share->getAttributes(); - $nickName = $request->hasHeader('X-NC-Nickname') ? urldecode($request->getHeader('X-NC-Nickname')) : null; if ($attributes !== null) { $isFileRequest = $attributes->getAttribute('fileRequest', 'enabled') === true; } // We need a valid nickname for file requests - if ($isFileRequest && ($nickName == null || trim($nickName) === '')) { - throw new MethodNotAllowed('Nickname is required for file requests'); + if ($isFileRequest && !$nickname) { + throw new MethodNotAllowed('A nickname header is required for file requests'); } - - // If this is a file request we need to create a folder for the user - if ($isFileRequest) { - // Check if the folder already exists - if (!($this->view->file_exists($nickName) === true)) { - $this->view->mkdir($nickName); - } + + // We're only allowing the upload of + // long path with subfolders if a nickname is set. + // This prevents confusion when uploading files and help + // classify them by uploaders. + if (!$nickname && !$isRootUpload) { + throw new MethodNotAllowed('A nickname header is required when uploading subfolders'); + } + + // If we have a nickname, let's put everything inside + if ($nickname) { // Put all files in the subfolder - $path = $nickName . '/' . $path; + $relativePath = '/' . $nickname . '/' . $relativePath; + $relativePath = str_replace('//', '/', $relativePath); } - - $newName = \OC_Helper::buildNotExistingFileNameForView('/', $path, $this->view); - $url = $request->getBaseUrl() . $newName; + + // Create the folders along the way + $folders = $this->getPathSegments(dirname($relativePath)); + foreach ($folders as $folder) { + if ($folder === '') { + continue; + } // skip empty parts + if (!$this->view->file_exists($folder)) { + $this->view->mkdir($folder); + } + } + + // Finally handle conflicts on the end files + $noConflictPath = \OC_Helper::buildNotExistingFileNameForView(dirname($relativePath), basename($relativePath), $this->view); + $path = '/files/' . $token . '/' . $noConflictPath; + $url = $request->getBaseUrl() . str_replace('//', '/', $path); $request->setUrl($url); } + private function getPathSegments(string $path): array { + // Normalize slashes and remove trailing slash + $path = rtrim(str_replace('\\', '/', $path), '/'); + + // Handle absolute paths starting with / + $isAbsolute = str_starts_with($path, '/'); + + $segments = explode('/', $path); + + // Add back the leading slash for the first segment if needed + $result = []; + $current = $isAbsolute ? '/' : ''; + + foreach ($segments as $segment) { + if ($segment === '') { + // skip empty parts + continue; + } + $current = rtrim($current, '/') . '/' . $segment; + $result[] = $current; + } + + return $result; + } } diff --git a/apps/dav/lib/Files/Sharing/RootCollection.php b/apps/dav/lib/Files/Sharing/RootCollection.php new file mode 100644 index 00000000000..dd585fbb59b --- /dev/null +++ b/apps/dav/lib/Files/Sharing/RootCollection.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Files\Sharing; + +use Sabre\DAV\INode; +use Sabre\DAVACL\AbstractPrincipalCollection; +use Sabre\DAVACL\PrincipalBackend\BackendInterface; + +class RootCollection extends AbstractPrincipalCollection { + public function __construct( + private INode $root, + BackendInterface $principalBackend, + string $principalPrefix = 'principals', + ) { + parent::__construct($principalBackend, $principalPrefix); + } + + public function getChildForPrincipal(array $principalInfo): INode { + return $this->root; + } + + public function getName() { + return 'files'; + } +} diff --git a/apps/dav/lib/Listener/AddMissingIndicesListener.php b/apps/dav/lib/Listener/AddMissingIndicesListener.php index 035c6c9582e..d3a1cf4b224 100644 --- a/apps/dav/lib/Listener/AddMissingIndicesListener.php +++ b/apps/dav/lib/Listener/AddMissingIndicesListener.php @@ -30,6 +30,11 @@ class AddMissingIndicesListener implements IEventListener { 'dav_shares_resourceid_access', ['resourceid', 'access'] ); + $event->addMissingIndex( + 'calendarobjects', + 'calobjects_by_uid_index', + ['calendarid', 'calendartype', 'uid'] + ); } } diff --git a/apps/dav/lib/Migration/Version1006Date20180628111625.php b/apps/dav/lib/Migration/Version1006Date20180628111625.php index 5f3aa4b6fe2..f4be26e6ad0 100644 --- a/apps/dav/lib/Migration/Version1006Date20180628111625.php +++ b/apps/dav/lib/Migration/Version1006Date20180628111625.php @@ -49,6 +49,7 @@ class Version1006Date20180628111625 extends SimpleMigrationStep { $calendarObjectsTable->dropIndex('calobjects_index'); } $calendarObjectsTable->addUniqueIndex(['calendarid', 'calendartype', 'uri'], 'calobjects_index'); + $calendarObjectsTable->addUniqueIndex(['calendarid', 'calendartype', 'uid'], 'calobjects_by_uid_index'); } if ($schema->hasTable('calendarobjects_props')) { diff --git a/apps/dav/lib/RootCollection.php b/apps/dav/lib/RootCollection.php index b2b34b26980..f1963c0ef01 100644 --- a/apps/dav/lib/RootCollection.php +++ b/apps/dav/lib/RootCollection.php @@ -160,6 +160,7 @@ class RootCollection extends SimpleCollection { Server::get(CleanupService::class), $rootFolder, $userSession, + $shareManager, ); $uploadCollection->disableListing = $disableListing; diff --git a/apps/dav/lib/Upload/CleanupService.php b/apps/dav/lib/Upload/CleanupService.php index 36b75280504..ffa6bad533c 100644 --- a/apps/dav/lib/Upload/CleanupService.php +++ b/apps/dav/lib/Upload/CleanupService.php @@ -10,20 +10,18 @@ namespace OCA\DAV\Upload; use OCA\DAV\BackgroundJob\UploadCleanup; use OCP\BackgroundJob\IJobList; -use OCP\IUserSession; class CleanupService { public function __construct( - private IUserSession $userSession, private IJobList $jobList, ) { } - public function addJob(string $folder) { - $this->jobList->add(UploadCleanup::class, ['uid' => $this->userSession->getUser()->getUID(), 'folder' => $folder]); + public function addJob(string $uid, string $folder) { + $this->jobList->add(UploadCleanup::class, ['uid' => $uid, 'folder' => $folder]); } - public function removeJob(string $folder) { - $this->jobList->remove(UploadCleanup::class, ['uid' => $this->userSession->getUser()->getUID(), 'folder' => $folder]); + public function removeJob(string $uid, string $folder) { + $this->jobList->remove(UploadCleanup::class, ['uid' => $uid, 'folder' => $folder]); } } diff --git a/apps/dav/lib/Upload/RootCollection.php b/apps/dav/lib/Upload/RootCollection.php index 9ea2592702b..cd7ab7f5e0a 100644 --- a/apps/dav/lib/Upload/RootCollection.php +++ b/apps/dav/lib/Upload/RootCollection.php @@ -11,6 +11,7 @@ namespace OCA\DAV\Upload; use OCP\Files\IRootFolder; use OCP\IUserSession; +use OCP\Share\IManager; use Sabre\DAVACL\AbstractPrincipalCollection; use Sabre\DAVACL\PrincipalBackend; @@ -22,6 +23,7 @@ class RootCollection extends AbstractPrincipalCollection { private CleanupService $cleanupService, private IRootFolder $rootFolder, private IUserSession $userSession, + private IManager $shareManager, ) { parent::__construct($principalBackend, $principalPrefix); } @@ -30,7 +32,13 @@ class RootCollection extends AbstractPrincipalCollection { * @inheritdoc */ public function getChildForPrincipal(array $principalInfo): UploadHome { - return new UploadHome($principalInfo, $this->cleanupService, $this->rootFolder, $this->userSession); + return new UploadHome( + $principalInfo, + $this->cleanupService, + $this->rootFolder, + $this->userSession, + $this->shareManager, + ); } /** diff --git a/apps/dav/lib/Upload/UploadFolder.php b/apps/dav/lib/Upload/UploadFolder.php index 57e95d2b17b..8890d472f87 100644 --- a/apps/dav/lib/Upload/UploadFolder.php +++ b/apps/dav/lib/Upload/UploadFolder.php @@ -21,6 +21,7 @@ class UploadFolder implements ICollection { private Directory $node, private CleanupService $cleanupService, private IStorage $storage, + private string $uid, ) { } @@ -89,7 +90,7 @@ class UploadFolder implements ICollection { $this->node->delete(); // Background cleanup job is not needed anymore - $this->cleanupService->removeJob($this->getName()); + $this->cleanupService->removeJob($this->uid, $this->getName()); } public function getName() { diff --git a/apps/dav/lib/Upload/UploadHome.php b/apps/dav/lib/Upload/UploadHome.php index a6551d4d079..4042f1c4101 100644 --- a/apps/dav/lib/Upload/UploadHome.php +++ b/apps/dav/lib/Upload/UploadHome.php @@ -17,6 +17,7 @@ use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\ICollection; class UploadHome implements ICollection { + private string $uid; private ?Folder $uploadFolder = null; public function __construct( @@ -24,7 +25,19 @@ class UploadHome implements ICollection { private readonly CleanupService $cleanupService, private readonly IRootFolder $rootFolder, private readonly IUserSession $userSession, + private readonly \OCP\Share\IManager $shareManager, ) { + [$prefix, $name] = \Sabre\Uri\split($principalInfo['uri']); + if ($prefix === 'principals/shares') { + $this->uid = $this->shareManager->getShareByToken($name)->getShareOwner(); + } else { + $user = $this->userSession->getUser(); + if (!$user) { + throw new Forbidden('Not logged in'); + } + + $this->uid = $user->getUID(); + } } public function createFile($name, $data = null) { @@ -35,16 +48,26 @@ class UploadHome implements ICollection { $this->impl()->createDirectory($name); // Add a cleanup job - $this->cleanupService->addJob($name); + $this->cleanupService->addJob($this->uid, $name); } public function getChild($name): UploadFolder { - return new UploadFolder($this->impl()->getChild($name), $this->cleanupService, $this->getStorage()); + return new UploadFolder( + $this->impl()->getChild($name), + $this->cleanupService, + $this->getStorage(), + $this->uid, + ); } public function getChildren(): array { return array_map(function ($node) { - return new UploadFolder($node, $this->cleanupService, $this->getStorage()); + return new UploadFolder( + $node, + $this->cleanupService, + $this->getStorage(), + $this->uid, + ); }, $this->impl()->getChildren()); } @@ -71,11 +94,7 @@ class UploadHome implements ICollection { private function getUploadFolder(): Folder { if ($this->uploadFolder === null) { - $user = $this->userSession->getUser(); - if (!$user) { - throw new Forbidden('Not logged in'); - } - $path = '/' . $user->getUID() . '/uploads'; + $path = '/' . $this->uid . '/uploads'; try { $folder = $this->rootFolder->get($path); if (!$folder instanceof Folder) { |