From 219716a72a35b31dd6f54ef4e468a3b5393fd626 Mon Sep 17 00:00:00 2001 From: Arndt Touby Date: Fri, 10 Apr 2026 00:32:50 +0200 Subject: [PATCH 1/5] fix(dav): do not cache 204 No Content responses for contact photos The ImageExportPlugin sets Cache-Control: max-age=3600 before the try/catch block, which means 204 No Content responses (no photo found) are also cached by the browser for 1 hour. If a photo is added to a contact after the initial request, the browser serves the cached empty response and the photo remains invisible until the cache expires. Move Cache-Control and ETag headers inside the try block so they are only set for 200 OK responses (photo found). For 204 responses, set Cache-Control: no-cache, no-store, must-revalidate so the browser always re-checks on the next request. Signed-off-by: Arndt Touby --- apps/dav/lib/CardDAV/ImageExportPlugin.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dav/lib/CardDAV/ImageExportPlugin.php b/apps/dav/lib/CardDAV/ImageExportPlugin.php index 74a8b032e42fd..f9182e192adc5 100644 --- a/apps/dav/lib/CardDAV/ImageExportPlugin.php +++ b/apps/dav/lib/CardDAV/ImageExportPlugin.php @@ -79,11 +79,10 @@ public function httpGet(RequestInterface $request, ResponseInterface $response) /** @var AddressBook $addressbook */ $addressbook = $this->server->tree->getNodeForPath($addressbookpath); - $response->setHeader('Cache-Control', 'private, max-age=3600, must-revalidate'); - $response->setHeader('Etag', $node->getETag()); - try { $file = $this->cache->get($addressbook->getResourceId(), $node->getName(), $size, $node); + $response->setHeader('Cache-Control', 'private, max-age=3600, must-revalidate'); + $response->setHeader('Etag', $node->getETag()); $response->setHeader('Content-Type', $file->getMimeType()); $fileName = $node->getName() . '.' . PhotoCache::ALLOWED_CONTENT_TYPES[$file->getMimeType()]; $response->setHeader('Content-Disposition', "attachment; filename=$fileName"); @@ -91,6 +90,7 @@ public function httpGet(RequestInterface $request, ResponseInterface $response) $response->setBody($file->getContent()); } catch (NotFoundException $e) { + $response->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); $response->setStatus(Http::STATUS_NO_CONTENT); } From 10f81445911a7c4aac234aa07560dc5810cff642 Mon Sep 17 00:00:00 2001 From: Arndt Touby Date: Fri, 10 Apr 2026 21:09:09 +0200 Subject: [PATCH 2/5] fix(dav): make contact photo cache max-age configurable Replace hardcoded max-age=3600 with a configurable app value 'contact_photo_cache_max_age' (default: 3600). Signed-off-by: Arndt Touby --- apps/dav/lib/CardDAV/ImageExportPlugin.php | 22 +- apps/dav/lib/Server.php | 448 --------------------- 2 files changed, 8 insertions(+), 462 deletions(-) diff --git a/apps/dav/lib/CardDAV/ImageExportPlugin.php b/apps/dav/lib/CardDAV/ImageExportPlugin.php index f9182e192adc5..858fbef376098 100644 --- a/apps/dav/lib/CardDAV/ImageExportPlugin.php +++ b/apps/dav/lib/CardDAV/ImageExportPlugin.php @@ -1,5 +1,6 @@ server = $server; $this->server->on('method:GET', [$this, 'httpGet'], 90); } @@ -44,8 +39,6 @@ public function initialize(Server $server) { /** * Intercepts GET requests on addressbook urls ending with ?photo. * - * @param RequestInterface $request - * @param ResponseInterface $response * @return bool */ public function httpGet(RequestInterface $request, ResponseInterface $response) { @@ -79,10 +72,12 @@ public function httpGet(RequestInterface $request, ResponseInterface $response) /** @var AddressBook $addressbook */ $addressbook = $this->server->tree->getNodeForPath($addressbookpath); + $maxAge = $this->config->getAppValue('dav', 'contact_photo_cache_max_age', '3600'); + $response->setHeader('Cache-Control', 'private, max-age=' . $maxAge . ', must-revalidate'); + $response->setHeader('Etag', $node->getETag()); + try { $file = $this->cache->get($addressbook->getResourceId(), $node->getName(), $size, $node); - $response->setHeader('Cache-Control', 'private, max-age=3600, must-revalidate'); - $response->setHeader('Etag', $node->getETag()); $response->setHeader('Content-Type', $file->getMimeType()); $fileName = $node->getName() . '.' . PhotoCache::ALLOWED_CONTENT_TYPES[$file->getMimeType()]; $response->setHeader('Content-Disposition', "attachment; filename=$fileName"); @@ -90,7 +85,6 @@ public function httpGet(RequestInterface $request, ResponseInterface $response) $response->setBody($file->getContent()); } catch (NotFoundException $e) { - $response->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); $response->setStatus(Http::STATUS_NO_CONTENT); } diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 4ce5dd7bd4b54..e69de29bb2d1d 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -1,448 +0,0 @@ -getSystemValue('debug', false); - $this->profiler = \OCP\Server::get(IProfiler::class); - if ($this->profiler->isEnabled()) { - /** @var IEventLogger $eventLogger */ - $eventLogger = \OCP\Server::get(IEventLogger::class); - $eventLogger->start('runtime', 'DAV Runtime'); - } - - $logger = \OCP\Server::get(LoggerInterface::class); - $eventDispatcher = \OCP\Server::get(IEventDispatcher::class); - - $root = new RootCollection(); - $this->server = new \OCA\DAV\Connector\Sabre\Server(new CachingTree($root)); - $this->server->setLogger($logger); - - // Add maintenance plugin - $this->server->addPlugin(new MaintenancePlugin(\OCP\Server::get(IConfig::class), \OC::$server->getL10N('dav'))); - - $this->server->addPlugin(new AppleQuirksPlugin()); - - // Backends - $authBackend = new Auth( - \OCP\Server::get(ISession::class), - \OCP\Server::get(IUserSession::class), - \OCP\Server::get(IRequest::class), - \OCP\Server::get(\OC\Authentication\TwoFactorAuth\Manager::class), - \OCP\Server::get(IThrottler::class) - ); - - // Set URL explicitly due to reverse-proxy situations - $this->server->httpRequest->setUrl($this->request->getRequestUri()); - $this->server->setBaseUri($this->baseUri); - - $this->server->addPlugin(new ProfilerPlugin($this->request)); - $this->server->addPlugin(new BlockLegacyClientPlugin( - \OCP\Server::get(IConfig::class), - \OCP\Server::get(ThemingDefaults::class), - )); - $this->server->addPlugin(new AnonymousOptionsPlugin()); - $authPlugin = new Plugin(); - $authPlugin->addBackend(new PublicAuth()); - $this->server->addPlugin($authPlugin); - - // allow setup of additional auth backends - $event = new SabrePluginEvent($this->server); - $eventDispatcher->dispatch('OCA\DAV\Connector\Sabre::authInit', $event); - - $newAuthEvent = new SabrePluginAuthInitEvent($this->server); - $eventDispatcher->dispatchTyped($newAuthEvent); - - $bearerAuthBackend = new BearerAuth( - \OCP\Server::get(IUserSession::class), - \OCP\Server::get(ISession::class), - \OCP\Server::get(IRequest::class), - \OCP\Server::get(IConfig::class), - ); - $authPlugin->addBackend($bearerAuthBackend); - // because we are throwing exceptions this plugin has to be the last one - $authPlugin->addBackend($authBackend); - - // debugging - if ($debugEnabled) { - $this->server->debugEnabled = true; - $this->server->addPlugin(new PropFindMonitorPlugin()); - $this->server->addPlugin(new \Sabre\DAV\Browser\Plugin()); - } else { - $this->server->addPlugin(new DummyGetResponsePlugin()); - } - - $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', - 'principals/calendar-resources', - 'principals/calendar-rooms', - ]; - $this->server->addPlugin($acl); - - // calendar plugins - if ($this->requestIsForSubtree(['calendars', 'public-calendars', 'system-calendars', 'principals'])) { - $this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class))); - $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin()); - $this->server->addPlugin(new ICSExportPlugin(\OCP\Server::get(IConfig::class), $logger)); - $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OCP\Server::get(IConfig::class), \OCP\Server::get(LoggerInterface::class), \OCP\Server::get(DefaultCalendarValidator::class))); - - $this->server->addPlugin(\OCP\Server::get(\OCA\DAV\CalDAV\Trashbin\Plugin::class)); - $this->server->addPlugin(new \OCA\DAV\CalDAV\WebcalCaching\Plugin($this->request)); - if (\OCP\Server::get(IConfig::class)->getAppValue('dav', 'allow_calendar_link_subscriptions', 'yes') === 'yes') { - $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin()); - } - - $this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin()); - $this->server->addPlugin(new PublishPlugin( - \OCP\Server::get(IConfig::class), - \OCP\Server::get(IURLGenerator::class) - )); - - $this->server->addPlugin(\OCP\Server::get(RateLimitingPlugin::class)); - $this->server->addPlugin(\OCP\Server::get(CalDavValidatePlugin::class)); - } - - // addressbook plugins - if ($this->requestIsForSubtree(['addressbooks', 'principals'])) { - $this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class))); - $this->server->addPlugin(new \OCA\DAV\CardDAV\Plugin()); - $this->server->addPlugin(new VCFExportPlugin()); - $this->server->addPlugin(new MultiGetExportPlugin()); - $this->server->addPlugin(new HasPhotoPlugin()); - $this->server->addPlugin(new ImageExportPlugin(\OCP\Server::get(PhotoCache::class))); - - $this->server->addPlugin(\OCP\Server::get(CardDavRateLimitingPlugin::class)); - $this->server->addPlugin(\OCP\Server::get(CardDavValidatePlugin::class)); - } - - // system tags plugins - $this->server->addPlugin(\OCP\Server::get(SystemTagPlugin::class)); - - // comments plugin - $this->server->addPlugin(new CommentsPlugin( - \OCP\Server::get(ICommentsManager::class), - \OCP\Server::get(IUserSession::class) - )); - - // performance improvement plugins - $this->server->addPlugin(new CopyEtagHeaderPlugin()); - $this->server->addPlugin(new RequestIdHeaderPlugin(\OCP\Server::get(IRequest::class))); - $this->server->addPlugin(new UserIdHeaderPlugin(\OCP\Server::get(IUserSession::class))); - $this->server->addPlugin(new UploadAutoMkcolPlugin()); - $this->server->addPlugin(new ChunkingV2Plugin(\OCP\Server::get(ICacheFactory::class))); - $this->server->addPlugin(new ChunkingPlugin()); - $this->server->addPlugin(new ZipFolderPlugin( - $this->server->tree, - $logger, - $eventDispatcher, - \OCP\Server::get(IDateTimeZone::class), - )); - $this->server->addPlugin(\OCP\Server::get(PaginatePlugin::class)); - $this->server->addPlugin(new PropFindPreloadNotifyPlugin()); - - // allow setup of additional plugins - $eventDispatcher->dispatch('OCA\DAV\Connector\Sabre::addPlugin', $event); - $typedEvent = new SabrePluginAddEvent($this->server); - $eventDispatcher->dispatchTyped($typedEvent); - - // Some WebDAV clients do require Class 2 WebDAV support (locking), since - // we do not provide locking we emulate it using a fake locking plugin. - if ($this->request->isUserAgent([ - '/WebDAVFS/', - '/OneNote/', - '/^Microsoft-WebDAV/',// Microsoft-WebDAV-MiniRedir/6.1.7601 - ])) { - $this->server->addPlugin(new FakeLockerPlugin()); - } - - if (BrowserErrorPagePlugin::isBrowserRequest($request)) { - $this->server->addPlugin(new BrowserErrorPagePlugin()); - } - - $lazySearchBackend = new LazySearchBackend(); - $this->server->addPlugin(new SearchPlugin($lazySearchBackend)); - - // wait with registering these until auth is handled and the filesystem is setup - $this->server->on('beforeMethod:*', function () use ($root, $lazySearchBackend, $logger): void { - // Allow view-only plugin for webdav requests - $this->server->addPlugin(new ViewOnlyPlugin( - \OC::$server->getUserFolder(), - )); - - // custom properties plugin must be the last one - $userSession = \OCP\Server::get(IUserSession::class); - $user = $userSession->getUser(); - if ($user !== null) { - $view = Filesystem::getView(); - $config = \OCP\Server::get(IConfig::class); - $this->server->addPlugin( - new FilesPlugin( - $this->server->tree, - $config, - $this->request, - \OCP\Server::get(IPreview::class), - \OCP\Server::get(IUserSession::class), - \OCP\Server::get(IFilenameValidator::class), - \OCP\Server::get(IAccountManager::class), - false, - $config->getSystemValueBool('debug', false) === false, - ) - ); - $this->server->addPlugin(new ChecksumUpdatePlugin()); - - $this->server->addPlugin( - new \Sabre\DAV\PropertyStorage\Plugin( - new CustomPropertiesBackend( - $this->server, - $this->server->tree, - \OCP\Server::get(IDBConnection::class), - \OCP\Server::get(IUserSession::class)->getUser(), - \OCP\Server::get(PropertyMapper::class), - \OCP\Server::get(DefaultCalendarValidator::class), - ) - ) - ); - if ($view !== null) { - $this->server->addPlugin( - new QuotaPlugin($view)); - } - $this->server->addPlugin( - new TagsPlugin( - $this->server->tree, \OCP\Server::get(ITagManager::class), \OCP\Server::get(IEventDispatcher::class), \OCP\Server::get(IUserSession::class) - ) - ); - - // TODO: switch to LazyUserFolder - $userFolder = \OC::$server->getUserFolder(); - $shareManager = \OCP\Server::get(\OCP\Share\IManager::class); - $this->server->addPlugin(new SharesPlugin( - $this->server->tree, - $userSession, - $shareManager, - )); - $this->server->addPlugin(new CommentPropertiesPlugin( - \OCP\Server::get(ICommentsManager::class), - $userSession - )); - if (\OCP\Server::get(IConfig::class)->getAppValue('dav', 'sendInvitations', 'yes') === 'yes') { - $this->server->addPlugin(new IMipPlugin( - \OCP\Server::get(IAppConfig::class), - \OCP\Server::get(IMailer::class), - \OCP\Server::get(LoggerInterface::class), - \OCP\Server::get(ITimeFactory::class), - \OCP\Server::get(Defaults::class), - $userSession, - \OCP\Server::get(IMipService::class), - \OCP\Server::get(EventComparisonService::class), - \OCP\Server::get(\OCP\Mail\Provider\IManager::class), - \OCP\Server::get(IEmailValidator::class), - )); - } - $this->server->addPlugin(new \OCA\DAV\CalDAV\Search\SearchPlugin()); - if ($view !== null) { - $this->server->addPlugin(new FilesReportPlugin( - $this->server->tree, - $view, - \OCP\Server::get(ISystemTagManager::class), - \OCP\Server::get(ISystemTagObjectMapper::class), - \OCP\Server::get(ITagManager::class), - $userSession, - \OCP\Server::get(IGroupManager::class), - $userFolder, - \OCP\Server::get(IAppManager::class) - )); - $lazySearchBackend->setBackend(new FileSearchBackend( - $this->server, - $this->server->tree, - $user, - \OCP\Server::get(IRootFolder::class), - $shareManager, - $view, - \OCP\Server::get(IFilesMetadataManager::class) - )); - $this->server->addPlugin( - new BulkUploadPlugin( - $userFolder, - $logger - ) - ); - } - $this->server->addPlugin(new AddExtraHeadersPlugin($logger, false)); - $this->server->addPlugin(new EnablePlugin( - \OCP\Server::get(IConfig::class), - \OCP\Server::get(BirthdayService::class), - $user - )); - $this->server->addPlugin(new AppleProvisioningPlugin( - \OCP\Server::get(IUserSession::class), - \OCP\Server::get(IURLGenerator::class), - \OCP\Server::get(ThemingDefaults::class), - \OCP\Server::get(IRequest::class), - \OC::$server->getL10N('dav'), - function () { - return UUIDUtil::getUUID(); - } - )); - } - - // register plugins from apps - $pluginManager = new PluginManager( - \OC::$server, - \OCP\Server::get(IAppManager::class) - ); - foreach ($pluginManager->getAppPlugins() as $appPlugin) { - $this->server->addPlugin($appPlugin); - } - foreach ($pluginManager->getAppCollections() as $appCollection) { - $root->addChild($appCollection); - } - }); - - $this->server->addPlugin( - new PropfindCompressionPlugin() - ); - } - - public function exec() { - /** @var IEventLogger $eventLogger */ - $eventLogger = \OCP\Server::get(IEventLogger::class); - $eventLogger->start('dav_server_exec', ''); - $this->server->start(); - $eventLogger->end('dav_server_exec'); - if ($this->profiler->isEnabled()) { - $eventLogger->end('runtime'); - $profile = $this->profiler->collect(\OCP\Server::get(IRequest::class), new Response()); - $this->profiler->saveProfile($profile); - } - } - - private function requestIsForSubtree(array $subTrees): bool { - foreach ($subTrees as $subTree) { - $subTree = trim($subTree, ' /'); - if (str_starts_with($this->server->getRequestUri(), $subTree . '/')) { - return true; - } - } - return false; - } - -} From 0a80433eb8e5e71761342e6f403fb43109333db3 Mon Sep 17 00:00:00 2001 From: Arndt Touby Date: Fri, 10 Apr 2026 21:21:17 +0200 Subject: [PATCH 3/5] fix(dav): make contact photo cache max-age configurable Replace hardcoded max-age=3600 with a configurable app value 'contact_photo_cache_max_age' (default: 3600). Add IConfig injection and unit tests for custom and zero max-age values. Signed-off-by: Arndt Touby --- apps/dav/lib/CardDAV/ImageExportPlugin.php | 14 +- apps/dav/lib/Server.php | 448 ++++++++++++++++++ .../unit/CardDAV/ImageExportPluginTest.php | 127 ++++- 3 files changed, 586 insertions(+), 3 deletions(-) diff --git a/apps/dav/lib/CardDAV/ImageExportPlugin.php b/apps/dav/lib/CardDAV/ImageExportPlugin.php index 858fbef376098..a8d9772ec8955 100644 --- a/apps/dav/lib/CardDAV/ImageExportPlugin.php +++ b/apps/dav/lib/CardDAV/ImageExportPlugin.php @@ -1,6 +1,5 @@ server = $server; $this->server->on('method:GET', [$this, 'httpGet'], 90); } @@ -39,6 +47,8 @@ public function initialize(Server $server): void { /** * Intercepts GET requests on addressbook urls ending with ?photo. * + * @param RequestInterface $request + * @param ResponseInterface $response * @return bool */ public function httpGet(RequestInterface $request, ResponseInterface $response) { diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index e69de29bb2d1d..91803047065b5 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -0,0 +1,448 @@ +getSystemValue('debug', false); + $this->profiler = \OCP\Server::get(IProfiler::class); + if ($this->profiler->isEnabled()) { + /** @var IEventLogger $eventLogger */ + $eventLogger = \OCP\Server::get(IEventLogger::class); + $eventLogger->start('runtime', 'DAV Runtime'); + } + + $logger = \OCP\Server::get(LoggerInterface::class); + $eventDispatcher = \OCP\Server::get(IEventDispatcher::class); + + $root = new RootCollection(); + $this->server = new \OCA\DAV\Connector\Sabre\Server(new CachingTree($root)); + $this->server->setLogger($logger); + + // Add maintenance plugin + $this->server->addPlugin(new MaintenancePlugin(\OCP\Server::get(IConfig::class), \OC::$server->getL10N('dav'))); + + $this->server->addPlugin(new AppleQuirksPlugin()); + + // Backends + $authBackend = new Auth( + \OCP\Server::get(ISession::class), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(IRequest::class), + \OCP\Server::get(\OC\Authentication\TwoFactorAuth\Manager::class), + \OCP\Server::get(IThrottler::class) + ); + + // Set URL explicitly due to reverse-proxy situations + $this->server->httpRequest->setUrl($this->request->getRequestUri()); + $this->server->setBaseUri($this->baseUri); + + $this->server->addPlugin(new ProfilerPlugin($this->request)); + $this->server->addPlugin(new BlockLegacyClientPlugin( + \OCP\Server::get(IConfig::class), + \OCP\Server::get(ThemingDefaults::class), + )); + $this->server->addPlugin(new AnonymousOptionsPlugin()); + $authPlugin = new Plugin(); + $authPlugin->addBackend(new PublicAuth()); + $this->server->addPlugin($authPlugin); + + // allow setup of additional auth backends + $event = new SabrePluginEvent($this->server); + $eventDispatcher->dispatch('OCA\DAV\Connector\Sabre::authInit', $event); + + $newAuthEvent = new SabrePluginAuthInitEvent($this->server); + $eventDispatcher->dispatchTyped($newAuthEvent); + + $bearerAuthBackend = new BearerAuth( + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(ISession::class), + \OCP\Server::get(IRequest::class), + \OCP\Server::get(IConfig::class), + ); + $authPlugin->addBackend($bearerAuthBackend); + // because we are throwing exceptions this plugin has to be the last one + $authPlugin->addBackend($authBackend); + + // debugging + if ($debugEnabled) { + $this->server->debugEnabled = true; + $this->server->addPlugin(new PropFindMonitorPlugin()); + $this->server->addPlugin(new \Sabre\DAV\Browser\Plugin()); + } else { + $this->server->addPlugin(new DummyGetResponsePlugin()); + } + + $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', + 'principals/calendar-resources', + 'principals/calendar-rooms', + ]; + $this->server->addPlugin($acl); + + // calendar plugins + if ($this->requestIsForSubtree(['calendars', 'public-calendars', 'system-calendars', 'principals'])) { + $this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class))); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin()); + $this->server->addPlugin(new ICSExportPlugin(\OCP\Server::get(IConfig::class), $logger)); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OCP\Server::get(IConfig::class), \OCP\Server::get(LoggerInterface::class), \OCP\Server::get(DefaultCalendarValidator::class))); + + $this->server->addPlugin(\OCP\Server::get(\OCA\DAV\CalDAV\Trashbin\Plugin::class)); + $this->server->addPlugin(new \OCA\DAV\CalDAV\WebcalCaching\Plugin($this->request)); + if (\OCP\Server::get(IConfig::class)->getAppValue('dav', 'allow_calendar_link_subscriptions', 'yes') === 'yes') { + $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin()); + } + + $this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin()); + $this->server->addPlugin(new PublishPlugin( + \OCP\Server::get(IConfig::class), + \OCP\Server::get(IURLGenerator::class) + )); + + $this->server->addPlugin(\OCP\Server::get(RateLimitingPlugin::class)); + $this->server->addPlugin(\OCP\Server::get(CalDavValidatePlugin::class)); + } + + // addressbook plugins + if ($this->requestIsForSubtree(['addressbooks', 'principals'])) { + $this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class))); + $this->server->addPlugin(new \OCA\DAV\CardDAV\Plugin()); + $this->server->addPlugin(new VCFExportPlugin()); + $this->server->addPlugin(new MultiGetExportPlugin()); + $this->server->addPlugin(new HasPhotoPlugin()); + $this->server->addPlugin(new ImageExportPlugin(\OCP\Server::get(PhotoCache::class), \OCP\Server::get(IConfig::class))); + + $this->server->addPlugin(\OCP\Server::get(CardDavRateLimitingPlugin::class)); + $this->server->addPlugin(\OCP\Server::get(CardDavValidatePlugin::class)); + } + + // system tags plugins + $this->server->addPlugin(\OCP\Server::get(SystemTagPlugin::class)); + + // comments plugin + $this->server->addPlugin(new CommentsPlugin( + \OCP\Server::get(ICommentsManager::class), + \OCP\Server::get(IUserSession::class) + )); + + // performance improvement plugins + $this->server->addPlugin(new CopyEtagHeaderPlugin()); + $this->server->addPlugin(new RequestIdHeaderPlugin(\OCP\Server::get(IRequest::class))); + $this->server->addPlugin(new UserIdHeaderPlugin(\OCP\Server::get(IUserSession::class))); + $this->server->addPlugin(new UploadAutoMkcolPlugin()); + $this->server->addPlugin(new ChunkingV2Plugin(\OCP\Server::get(ICacheFactory::class))); + $this->server->addPlugin(new ChunkingPlugin()); + $this->server->addPlugin(new ZipFolderPlugin( + $this->server->tree, + $logger, + $eventDispatcher, + \OCP\Server::get(IDateTimeZone::class), + )); + $this->server->addPlugin(\OCP\Server::get(PaginatePlugin::class)); + $this->server->addPlugin(new PropFindPreloadNotifyPlugin()); + + // allow setup of additional plugins + $eventDispatcher->dispatch('OCA\DAV\Connector\Sabre::addPlugin', $event); + $typedEvent = new SabrePluginAddEvent($this->server); + $eventDispatcher->dispatchTyped($typedEvent); + + // Some WebDAV clients do require Class 2 WebDAV support (locking), since + // we do not provide locking we emulate it using a fake locking plugin. + if ($this->request->isUserAgent([ + '/WebDAVFS/', + '/OneNote/', + '/^Microsoft-WebDAV/',// Microsoft-WebDAV-MiniRedir/6.1.7601 + ])) { + $this->server->addPlugin(new FakeLockerPlugin()); + } + + if (BrowserErrorPagePlugin::isBrowserRequest($request)) { + $this->server->addPlugin(new BrowserErrorPagePlugin()); + } + + $lazySearchBackend = new LazySearchBackend(); + $this->server->addPlugin(new SearchPlugin($lazySearchBackend)); + + // wait with registering these until auth is handled and the filesystem is setup + $this->server->on('beforeMethod:*', function () use ($root, $lazySearchBackend, $logger): void { + // Allow view-only plugin for webdav requests + $this->server->addPlugin(new ViewOnlyPlugin( + \OC::$server->getUserFolder(), + )); + + // custom properties plugin must be the last one + $userSession = \OCP\Server::get(IUserSession::class); + $user = $userSession->getUser(); + if ($user !== null) { + $view = Filesystem::getView(); + $config = \OCP\Server::get(IConfig::class); + $this->server->addPlugin( + new FilesPlugin( + $this->server->tree, + $config, + $this->request, + \OCP\Server::get(IPreview::class), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(IFilenameValidator::class), + \OCP\Server::get(IAccountManager::class), + false, + $config->getSystemValueBool('debug', false) === false, + ) + ); + $this->server->addPlugin(new ChecksumUpdatePlugin()); + + $this->server->addPlugin( + new \Sabre\DAV\PropertyStorage\Plugin( + new CustomPropertiesBackend( + $this->server, + $this->server->tree, + \OCP\Server::get(IDBConnection::class), + \OCP\Server::get(IUserSession::class)->getUser(), + \OCP\Server::get(PropertyMapper::class), + \OCP\Server::get(DefaultCalendarValidator::class), + ) + ) + ); + if ($view !== null) { + $this->server->addPlugin( + new QuotaPlugin($view)); + } + $this->server->addPlugin( + new TagsPlugin( + $this->server->tree, \OCP\Server::get(ITagManager::class), \OCP\Server::get(IEventDispatcher::class), \OCP\Server::get(IUserSession::class) + ) + ); + + // TODO: switch to LazyUserFolder + $userFolder = \OC::$server->getUserFolder(); + $shareManager = \OCP\Server::get(\OCP\Share\IManager::class); + $this->server->addPlugin(new SharesPlugin( + $this->server->tree, + $userSession, + $shareManager, + )); + $this->server->addPlugin(new CommentPropertiesPlugin( + \OCP\Server::get(ICommentsManager::class), + $userSession + )); + if (\OCP\Server::get(IConfig::class)->getAppValue('dav', 'sendInvitations', 'yes') === 'yes') { + $this->server->addPlugin(new IMipPlugin( + \OCP\Server::get(IAppConfig::class), + \OCP\Server::get(IMailer::class), + \OCP\Server::get(LoggerInterface::class), + \OCP\Server::get(ITimeFactory::class), + \OCP\Server::get(Defaults::class), + $userSession, + \OCP\Server::get(IMipService::class), + \OCP\Server::get(EventComparisonService::class), + \OCP\Server::get(\OCP\Mail\Provider\IManager::class), + \OCP\Server::get(IEmailValidator::class), + )); + } + $this->server->addPlugin(new \OCA\DAV\CalDAV\Search\SearchPlugin()); + if ($view !== null) { + $this->server->addPlugin(new FilesReportPlugin( + $this->server->tree, + $view, + \OCP\Server::get(ISystemTagManager::class), + \OCP\Server::get(ISystemTagObjectMapper::class), + \OCP\Server::get(ITagManager::class), + $userSession, + \OCP\Server::get(IGroupManager::class), + $userFolder, + \OCP\Server::get(IAppManager::class) + )); + $lazySearchBackend->setBackend(new FileSearchBackend( + $this->server, + $this->server->tree, + $user, + \OCP\Server::get(IRootFolder::class), + $shareManager, + $view, + \OCP\Server::get(IFilesMetadataManager::class) + )); + $this->server->addPlugin( + new BulkUploadPlugin( + $userFolder, + $logger + ) + ); + } + $this->server->addPlugin(new AddExtraHeadersPlugin($logger, false)); + $this->server->addPlugin(new EnablePlugin( + \OCP\Server::get(IConfig::class), + \OCP\Server::get(BirthdayService::class), + $user + )); + $this->server->addPlugin(new AppleProvisioningPlugin( + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(IURLGenerator::class), + \OCP\Server::get(ThemingDefaults::class), + \OCP\Server::get(IRequest::class), + \OC::$server->getL10N('dav'), + function () { + return UUIDUtil::getUUID(); + } + )); + } + + // register plugins from apps + $pluginManager = new PluginManager( + \OC::$server, + \OCP\Server::get(IAppManager::class) + ); + foreach ($pluginManager->getAppPlugins() as $appPlugin) { + $this->server->addPlugin($appPlugin); + } + foreach ($pluginManager->getAppCollections() as $appCollection) { + $root->addChild($appCollection); + } + }); + + $this->server->addPlugin( + new PropfindCompressionPlugin() + ); + } + + public function exec() { + /** @var IEventLogger $eventLogger */ + $eventLogger = \OCP\Server::get(IEventLogger::class); + $eventLogger->start('dav_server_exec', ''); + $this->server->start(); + $eventLogger->end('dav_server_exec'); + if ($this->profiler->isEnabled()) { + $eventLogger->end('runtime'); + $profile = $this->profiler->collect(\OCP\Server::get(IRequest::class), new Response()); + $this->profiler->saveProfile($profile); + } + } + + private function requestIsForSubtree(array $subTrees): bool { + foreach ($subTrees as $subTree) { + $subTree = trim($subTree, ' /'); + if (str_starts_with($this->server->getRequestUri(), $subTree . '/')) { + return true; + } + } + return false; + } + +} diff --git a/apps/dav/tests/unit/CardDAV/ImageExportPluginTest.php b/apps/dav/tests/unit/CardDAV/ImageExportPluginTest.php index 2a766f1327b51..b2f7e568e9fa2 100644 --- a/apps/dav/tests/unit/CardDAV/ImageExportPluginTest.php +++ b/apps/dav/tests/unit/CardDAV/ImageExportPluginTest.php @@ -14,6 +14,7 @@ use OCP\AppFramework\Http; use OCP\Files\NotFoundException; use OCP\Files\SimpleFS\ISimpleFile; +use OCP\IConfig; use PHPUnit\Framework\MockObject\MockObject; use Sabre\CardDAV\Card; use Sabre\DAV\Node; @@ -29,6 +30,7 @@ class ImageExportPluginTest extends TestCase { private Server&MockObject $server; private Tree&MockObject $tree; private PhotoCache&MockObject $cache; + private IConfig&MockObject $config; private ImageExportPlugin $plugin; protected function setUp(): void { @@ -40,8 +42,12 @@ protected function setUp(): void { $this->tree = $this->createMock(Tree::class); $this->server->tree = $this->tree; $this->cache = $this->createMock(PhotoCache::class); + $this->config = $this->createMock(IConfig::class); + $this->config->method('getAppValue') + ->with('dav', 'contact_photo_cache_max_age', '3600') + ->willReturn('3600'); - $this->plugin = new ImageExportPlugin($this->cache); + $this->plugin = new ImageExportPlugin($this->cache, $this->config); $this->plugin->initialize($this->server); } @@ -171,4 +177,123 @@ public function testCard(?int $size, bool $photo): void { $result = $this->plugin->httpGet($this->request, $this->response); $this->assertFalse($result); } + + public function testCardWithCustomMaxAge(): void { + $config = $this->createMock(IConfig::class); + $config->method('getAppValue') + ->with('dav', 'contact_photo_cache_max_age', '3600') + ->willReturn('120'); + + $plugin = new ImageExportPlugin($this->cache, $config); + $plugin->initialize($this->server); + + $this->request->method('getQueryParameters') + ->willReturn(['photo' => null]); + $this->request->method('getPath') + ->willReturn('user/book/card'); + + $card = $this->createMock(Card::class); + $card->method('getETag') + ->willReturn('"myEtag"'); + $card->method('getName') + ->willReturn('card'); + $book = $this->createMock(AddressBook::class); + $book->method('getResourceId') + ->willReturn(1); + + $this->tree->method('getNodeForPath') + ->willReturnCallback(function ($path) use ($card, $book) { + if ($path === 'user/book/card') { + return $card; + } elseif ($path === 'user/book') { + return $book; + } + $this->fail(); + }); + + $file = $this->createMock(ISimpleFile::class); + $file->method('getMimeType') + ->willReturn('image/jpeg'); + $file->method('getContent') + ->willReturn('imgdata'); + + $this->cache->method('get') + ->with(1, 'card', -1, $card) + ->willReturn($file); + + $setHeaderCalls = [ + ['Cache-Control', 'private, max-age=120, must-revalidate'], + ['Etag', '"myEtag"'], + ['Content-Type', 'image/jpeg'], + ['Content-Disposition', 'attachment; filename=card.jpg'], + ]; + $this->response->expects($this->exactly(count($setHeaderCalls))) + ->method('setHeader') + ->willReturnCallback(function () use (&$setHeaderCalls): void { + $expected = array_shift($setHeaderCalls); + $this->assertEquals($expected, func_get_args()); + }); + + $this->response->expects($this->once()) + ->method('setStatus') + ->with(200); + + $result = $plugin->httpGet($this->request, $this->response); + $this->assertFalse($result); + } + + public function testCardWithZeroMaxAge(): void { + $config = $this->createMock(IConfig::class); + $config->method('getAppValue') + ->with('dav', 'contact_photo_cache_max_age', '3600') + ->willReturn('0'); + + $plugin = new ImageExportPlugin($this->cache, $config); + $plugin->initialize($this->server); + + $this->request->method('getQueryParameters') + ->willReturn(['photo' => null]); + $this->request->method('getPath') + ->willReturn('user/book/card'); + + $card = $this->createMock(Card::class); + $card->method('getETag') + ->willReturn('"myEtag"'); + $card->method('getName') + ->willReturn('card'); + $book = $this->createMock(AddressBook::class); + $book->method('getResourceId') + ->willReturn(1); + + $this->tree->method('getNodeForPath') + ->willReturnCallback(function ($path) use ($card, $book) { + if ($path === 'user/book/card') { + return $card; + } elseif ($path === 'user/book') { + return $book; + } + $this->fail(); + }); + + $this->cache->method('get') + ->with(1, 'card', -1, $card) + ->willThrowException(new NotFoundException()); + + $setHeaderCalls = [ + ['Cache-Control', 'private, max-age=0, must-revalidate'], + ['Etag', '"myEtag"'], + ]; + $this->response->expects($this->exactly(count($setHeaderCalls))) + ->method('setHeader') + ->willReturnCallback(function () use (&$setHeaderCalls): void { + $expected = array_shift($setHeaderCalls); + $this->assertEquals($expected, func_get_args()); + }); + $this->response->expects($this->once()) + ->method('setStatus') + ->with(Http::STATUS_NO_CONTENT); + + $result = $plugin->httpGet($this->request, $this->response); + $this->assertFalse($result); + } } From 20ee1853b53c8ecb8650637b7e1c18e6073b0000 Mon Sep 17 00:00:00 2001 From: Arndt Touby Date: Fri, 10 Apr 2026 21:30:46 +0200 Subject: [PATCH 4/5] fix(dav): fix accidental CalDAV\CalDAV namespace duplication in Server.php Signed-off-by: Arndt Touby --- apps/dav/lib/Server.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 91803047065b5..2e4ac623eece6 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -17,7 +17,7 @@ use OCA\DAV\CalDAV\ICSExportPlugin\ICSExportPlugin; use OCA\DAV\CalDAV\Publishing\PublishPlugin; use OCA\DAV\CalDAV\Schedule\IMipPlugin; -use OCA\DAV\CalDAV\CalDAV\Schedule\IMipService; +use OCA\DAV\CalDAV\Schedule\IMipService; use OCA\DAV\CalDAV\Security\RateLimitingPlugin; use OCA\DAV\CalDAV\Validation\CalDavValidatePlugin; use OCA\DAV\CardDAV\HasPhotoPlugin; From 4b4256bbdcfc1e63faacdb7ff7e0e646b9f0c02e Mon Sep 17 00:00:00 2001 From: Arndt Touby Date: Fri, 10 Apr 2026 21:34:43 +0200 Subject: [PATCH 5/5] fix(dav): cast max-age config value to int for header safety Ensures the Cache-Control max-age value is always numeric, preventing potential header injection via malformed config values. Signed-off-by: Arndt Touby --- apps/dav/lib/CardDAV/ImageExportPlugin.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dav/lib/CardDAV/ImageExportPlugin.php b/apps/dav/lib/CardDAV/ImageExportPlugin.php index a8d9772ec8955..91167f5388c49 100644 --- a/apps/dav/lib/CardDAV/ImageExportPlugin.php +++ b/apps/dav/lib/CardDAV/ImageExportPlugin.php @@ -82,7 +82,7 @@ public function httpGet(RequestInterface $request, ResponseInterface $response) /** @var AddressBook $addressbook */ $addressbook = $this->server->tree->getNodeForPath($addressbookpath); - $maxAge = $this->config->getAppValue('dav', 'contact_photo_cache_max_age', '3600'); + $maxAge = (int) $this->config->getAppValue('dav', 'contact_photo_cache_max_age', '3600'); $response->setHeader('Cache-Control', 'private, max-age=' . $maxAge . ', must-revalidate'); $response->setHeader('Etag', $node->getETag());