From acba831b6c22547380335dc5d9e0c0af1b73887c Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 23 Jan 2026 16:01:24 +0100 Subject: [PATCH 01/17] fix: only validate mounts for new share Signed-off-by: Robin Appelman --- .../lib/Listener/SharesUpdatedListener.php | 32 +++++++++++++++---- lib/private/Files/FileInfo.php | 10 ++++-- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index ccef71bad5c87..fea681bd31cad 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -8,6 +8,7 @@ namespace OCA\Files_Sharing\Listener; +use OC\Files\FileInfo; use OCA\Files_Sharing\Event\UserShareAccessUpdatedEvent; use OCA\Files_Sharing\MountProvider; use OCA\Files_Sharing\ShareTargetValidator; @@ -23,6 +24,7 @@ use OCP\Share\Events\ShareCreatedEvent; use OCP\Share\Events\ShareTransferredEvent; use OCP\Share\IManager; +use OCP\Share\IShare; /** * Listen to various events that can change what shares a user has access to @@ -49,12 +51,15 @@ public function handle(Event $event): void { if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent) { $this->updateForUser($event->getUser(), true); } - if ( - $event instanceof ShareCreatedEvent - || $event instanceof ShareTransferredEvent - ) { - foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) { - $this->updateForUser($user, true); + if ($event instanceof ShareCreatedEvent || $event instanceof ShareTransferredEvent) { + $share = $event->getShare(); + $shareTarget = $share->getTarget(); + foreach ($this->shareManager->getUsersForShare($share) as $user) { + if ($share->getSharedBy() !== $user->getUID()) { + $this->updateForShare($user, $share); + // Share target validation might have changed the target, restore it for the next user + $share->setTarget($shareTarget); + } } } if ($event instanceof BeforeShareDeletedEvent) { @@ -97,4 +102,19 @@ private function updateForUser(IUser $user, bool $verifyMountPoints, array $igno unset($this->inUpdate[$user->getUID()]); } + + private function updateForShare(IUser $user, IShare $share): void { + $cachedMounts = $this->userMountCache->getMountsForUser($user); + $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); + $mountsByPath = array_combine($mountPoints, $cachedMounts); + + $target = $this->shareTargetValidator->verifyMountPoint($user, $share, $mountsByPath, [$share]); + $mountPoint = '/' . $user->getUID() . '/files/' . trim($target, '/') . '/'; + + $fileInfo = $share->getNode(); + if (!$fileInfo instanceof FileInfo) { + throw new \Exception("share node is the wrong fileinfo"); + } + $this->userMountCache->addMount($user, $mountPoint, $fileInfo->getData(), MountProvider::class); + } } diff --git a/lib/private/Files/FileInfo.php b/lib/private/Files/FileInfo.php index 967d404b8a4f0..cc01c8f6c79fc 100644 --- a/lib/private/Files/FileInfo.php +++ b/lib/private/Files/FileInfo.php @@ -7,8 +7,8 @@ */ namespace OC\Files; +use OC\Files\Cache\CacheEntry; use OC\Files\Mount\HomeMountPoint; -use OCA\Files_Sharing\External\Mount; use OCA\Files_Sharing\ISharedMountPoint; use OCP\Files\Cache\ICacheEntry; use OCP\Files\Mount\IMountPoint; @@ -223,8 +223,12 @@ public function getType() { return $this->data['type']; } - public function getData() { - return $this->data; + public function getData(): ICacheEntry { + if ($this->data instanceof ICacheEntry) { + return $this->data; + } else { + return new CacheEntry($this->data); + } } /** From 4c95214a4fd9034f953e0e1afcaa63c2f8afbe14 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 12 Feb 2026 15:19:18 +0100 Subject: [PATCH 02/17] feat: add event for user home mount having being setup Signed-off-by: Robin Appelman --- lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + lib/private/Files/SetupManager.php | 4 ++ .../Files/Events/UserHomeSetupEvent.php | 46 +++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 lib/public/Files/Events/UserHomeSetupEvent.php diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 5b6de5ff356d1..1ad36ab1679f0 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -471,6 +471,7 @@ 'OCP\\Files\\Events\\Node\\NodeRenamedEvent' => $baseDir . '/lib/public/Files/Events/Node/NodeRenamedEvent.php', 'OCP\\Files\\Events\\Node\\NodeTouchedEvent' => $baseDir . '/lib/public/Files/Events/Node/NodeTouchedEvent.php', 'OCP\\Files\\Events\\Node\\NodeWrittenEvent' => $baseDir . '/lib/public/Files/Events/Node/NodeWrittenEvent.php', + 'OCP\\Files\\Events\\UserHomeSetupEvent' => $baseDir . '/lib/public/Files/Events/UserHomeSetupEvent.php', 'OCP\\Files\\File' => $baseDir . '/lib/public/Files/File.php', 'OCP\\Files\\FileInfo' => $baseDir . '/lib/public/Files/FileInfo.php', 'OCP\\Files\\FileNameTooLongException' => $baseDir . '/lib/public/Files/FileNameTooLongException.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index fbee07dafc6b4..7ffedacb39312 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -512,6 +512,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Files\\Events\\Node\\NodeRenamedEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeRenamedEvent.php', 'OCP\\Files\\Events\\Node\\NodeTouchedEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeTouchedEvent.php', 'OCP\\Files\\Events\\Node\\NodeWrittenEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeWrittenEvent.php', + 'OCP\\Files\\Events\\UserHomeSetupEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/UserHomeSetupEvent.php', 'OCP\\Files\\File' => __DIR__ . '/../../..' . '/lib/public/Files/File.php', 'OCP\\Files\\FileInfo' => __DIR__ . '/../../..' . '/lib/public/Files/FileInfo.php', 'OCP\\Files\\FileNameTooLongException' => __DIR__ . '/../../..' . '/lib/public/Files/FileNameTooLongException.php', diff --git a/lib/private/Files/SetupManager.php b/lib/private/Files/SetupManager.php index e058be0663b93..af2c05387048f 100644 --- a/lib/private/Files/SetupManager.php +++ b/lib/private/Files/SetupManager.php @@ -43,6 +43,7 @@ use OCP\Files\Events\InvalidateMountCacheEvent; use OCP\Files\Events\Node\BeforeNodeRenamedEvent; use OCP\Files\Events\Node\FilesystemTornDownEvent; +use OCP\Files\Events\UserHomeSetupEvent; use OCP\Files\ISetupManager; use OCP\Files\Mount\IMountManager; use OCP\Files\Mount\IMountPoint; @@ -334,6 +335,9 @@ private function oneTimeUserSetup(IUser $user) { $this->eventLogger->end('fs:setup:user:home:scan'); } $this->eventLogger->end('fs:setup:user:home'); + + $event = new UserHomeSetupEvent($user, $homeMount); + $this->eventDispatcher->dispatchTyped($event); } else { $this->mountManager->addMount(new MountPoint( new NullStorage([]), diff --git a/lib/public/Files/Events/UserHomeSetupEvent.php b/lib/public/Files/Events/UserHomeSetupEvent.php new file mode 100644 index 0000000000000..2b49f64a28b92 --- /dev/null +++ b/lib/public/Files/Events/UserHomeSetupEvent.php @@ -0,0 +1,46 @@ +user; + } + + /** + * @since 34.0.0 + */ + public function getHomeMount(): IMountPoint { + return $this->homeMount; + } +} From 2fef6c2e6cf00a6a2bd84497b6fd210598de9f69 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 12 Feb 2026 15:34:50 +0100 Subject: [PATCH 03/17] chore: move share recipient validation logic to a separate class Signed-off-by: Robin Appelman --- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + .../lib/Listener/SharesUpdatedListener.php | 73 ++-------------- .../lib/ShareRecipientUpdater.php | 86 +++++++++++++++++++ 4 files changed, 94 insertions(+), 67 deletions(-) create mode 100644 apps/files_sharing/lib/ShareRecipientUpdater.php diff --git a/apps/files_sharing/composer/composer/autoload_classmap.php b/apps/files_sharing/composer/composer/autoload_classmap.php index 0ba6ba1b8167f..c3dea9de9ce0b 100644 --- a/apps/files_sharing/composer/composer/autoload_classmap.php +++ b/apps/files_sharing/composer/composer/autoload_classmap.php @@ -98,6 +98,7 @@ 'OCA\\Files_Sharing\\Settings\\Personal' => $baseDir . '/../lib/Settings/Personal.php', 'OCA\\Files_Sharing\\ShareBackend\\File' => $baseDir . '/../lib/ShareBackend/File.php', 'OCA\\Files_Sharing\\ShareBackend\\Folder' => $baseDir . '/../lib/ShareBackend/Folder.php', + 'OCA\\Files_Sharing\\ShareRecipientUpdater' => $baseDir . '/../lib/ShareRecipientUpdater.php', 'OCA\\Files_Sharing\\ShareTargetValidator' => $baseDir . '/../lib/ShareTargetValidator.php', 'OCA\\Files_Sharing\\SharedMount' => $baseDir . '/../lib/SharedMount.php', 'OCA\\Files_Sharing\\SharedStorage' => $baseDir . '/../lib/SharedStorage.php', diff --git a/apps/files_sharing/composer/composer/autoload_static.php b/apps/files_sharing/composer/composer/autoload_static.php index 03906cda0473b..57cf4cc29fba1 100644 --- a/apps/files_sharing/composer/composer/autoload_static.php +++ b/apps/files_sharing/composer/composer/autoload_static.php @@ -113,6 +113,7 @@ class ComposerStaticInitFiles_Sharing 'OCA\\Files_Sharing\\Settings\\Personal' => __DIR__ . '/..' . '/../lib/Settings/Personal.php', 'OCA\\Files_Sharing\\ShareBackend\\File' => __DIR__ . '/..' . '/../lib/ShareBackend/File.php', 'OCA\\Files_Sharing\\ShareBackend\\Folder' => __DIR__ . '/..' . '/../lib/ShareBackend/Folder.php', + 'OCA\\Files_Sharing\\ShareRecipientUpdater' => __DIR__ . '/..' . '/../lib/ShareRecipientUpdater.php', 'OCA\\Files_Sharing\\ShareTargetValidator' => __DIR__ . '/..' . '/../lib/ShareTargetValidator.php', 'OCA\\Files_Sharing\\SharedMount' => __DIR__ . '/..' . '/../lib/SharedMount.php', 'OCA\\Files_Sharing\\SharedStorage' => __DIR__ . '/..' . '/../lib/SharedStorage.php', diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index fea681bd31cad..3cfe3c3a3a5bf 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -8,23 +8,16 @@ namespace OCA\Files_Sharing\Listener; -use OC\Files\FileInfo; use OCA\Files_Sharing\Event\UserShareAccessUpdatedEvent; -use OCA\Files_Sharing\MountProvider; -use OCA\Files_Sharing\ShareTargetValidator; +use OCA\Files_Sharing\ShareRecipientUpdater; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; -use OCP\Files\Config\ICachedMountInfo; -use OCP\Files\Config\IUserMountCache; -use OCP\Files\Storage\IStorageFactory; use OCP\Group\Events\UserAddedEvent; use OCP\Group\Events\UserRemovedEvent; -use OCP\IUser; use OCP\Share\Events\BeforeShareDeletedEvent; use OCP\Share\Events\ShareCreatedEvent; use OCP\Share\Events\ShareTransferredEvent; use OCP\Share\IManager; -use OCP\Share\IShare; /** * Listen to various events that can change what shares a user has access to @@ -32,31 +25,26 @@ * @template-implements IEventListener */ class SharesUpdatedListener implements IEventListener { - private array $inUpdate = []; - public function __construct( private readonly IManager $shareManager, - private readonly IUserMountCache $userMountCache, - private readonly MountProvider $shareMountProvider, - private readonly ShareTargetValidator $shareTargetValidator, - private readonly IStorageFactory $storageFactory, + private readonly ShareRecipientUpdater $shareUpdater, ) { } public function handle(Event $event): void { if ($event instanceof UserShareAccessUpdatedEvent) { foreach ($event->getUsers() as $user) { - $this->updateForUser($user, true); + $this->shareUpdater->updateForUser($user, true); } } if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent) { - $this->updateForUser($event->getUser(), true); + $this->shareUpdater->updateForUser($event->getUser(), true); } if ($event instanceof ShareCreatedEvent || $event instanceof ShareTransferredEvent) { $share = $event->getShare(); $shareTarget = $share->getTarget(); foreach ($this->shareManager->getUsersForShare($share) as $user) { if ($share->getSharedBy() !== $user->getUID()) { - $this->updateForShare($user, $share); + $this->shareUpdater->updateForShare($user, $share); // Share target validation might have changed the target, restore it for the next user $share->setTarget($shareTarget); } @@ -64,57 +52,8 @@ public function handle(Event $event): void { } if ($event instanceof BeforeShareDeletedEvent) { foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) { - $this->updateForUser($user, false, [$event->getShare()]); - } - } - } - - private function updateForUser(IUser $user, bool $verifyMountPoints, array $ignoreShares = []): void { - // prevent recursion - if (isset($this->inUpdate[$user->getUID()])) { - return; - } - $this->inUpdate[$user->getUID()] = true; - $cachedMounts = $this->userMountCache->getMountsForUser($user); - $shareMounts = array_filter($cachedMounts, fn (ICachedMountInfo $mount) => $mount->getMountProvider() === MountProvider::class); - $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); - $mountsByPath = array_combine($mountPoints, $cachedMounts); - - $shares = $this->shareMountProvider->getSuperSharesForUser($user, $ignoreShares); - - $mountsChanged = count($shares) !== count($shareMounts); - foreach ($shares as &$share) { - [$parentShare, $groupedShares] = $share; - $mountPoint = '/' . $user->getUID() . '/files/' . trim($parentShare->getTarget(), '/') . '/'; - $mountKey = $parentShare->getNodeId() . '::' . $mountPoint; - if (!isset($cachedMounts[$mountKey])) { - $mountsChanged = true; - if ($verifyMountPoints) { - $this->shareTargetValidator->verifyMountPoint($user, $parentShare, $mountsByPath, $groupedShares); - } + $this->shareManager->updateForUser($user, false, [$event->getShare()]); } } - - if ($mountsChanged) { - $newMounts = $this->shareMountProvider->getMountsFromSuperShares($user, $shares, $this->storageFactory); - $this->userMountCache->registerMounts($user, $newMounts, [MountProvider::class]); - } - - unset($this->inUpdate[$user->getUID()]); - } - - private function updateForShare(IUser $user, IShare $share): void { - $cachedMounts = $this->userMountCache->getMountsForUser($user); - $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); - $mountsByPath = array_combine($mountPoints, $cachedMounts); - - $target = $this->shareTargetValidator->verifyMountPoint($user, $share, $mountsByPath, [$share]); - $mountPoint = '/' . $user->getUID() . '/files/' . trim($target, '/') . '/'; - - $fileInfo = $share->getNode(); - if (!$fileInfo instanceof FileInfo) { - throw new \Exception("share node is the wrong fileinfo"); - } - $this->userMountCache->addMount($user, $mountPoint, $fileInfo->getData(), MountProvider::class); } } diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php new file mode 100644 index 0000000000000..979dc41dfb4b7 --- /dev/null +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -0,0 +1,86 @@ +inUpdate[$user->getUID()])) { + return; + } + $this->inUpdate[$user->getUID()] = true; + + $cachedMounts = $this->userMountCache->getMountsForUser($user); + $shareMounts = array_filter($cachedMounts, fn (ICachedMountInfo $mount) => $mount->getMountProvider() === MountProvider::class); + $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); + $mountsByPath = array_combine($mountPoints, $cachedMounts); + + $shares = $this->shareMountProvider->getSuperSharesForUser($user, $ignoreShares); + + // the share mounts have changed if either the number of shares doesn't matched the number of share mounts + // or there is a share for which we don't have a mount yet. + $mountsChanged = count($shares) !== count($shareMounts); + foreach ($shares as &$share) { + [$parentShare, $groupedShares] = $share; + $mountPoint = '/' . $user->getUID() . '/files/' . trim($parentShare->getTarget(), '/') . '/'; + $mountKey = $parentShare->getNodeId() . '::' . $mountPoint; + if (!isset($cachedMounts[$mountKey])) { + $mountsChanged = true; + if ($verifyMountPoints) { + $this->shareTargetValidator->verifyMountPoint($user, $parentShare, $mountsByPath, $groupedShares); + } + } + } + + if ($mountsChanged) { + $newMounts = $this->shareMountProvider->getMountsFromSuperShares($user, $shares, $this->storageFactory); + $this->userMountCache->registerMounts($user, $newMounts, [MountProvider::class]); + } + + unset($this->inUpdate[$user->getUID()]); + } + + /** + * Validate a single received share for a user + */ + public function updateForShare(IUser $user, IShare $share): void { + $cachedMounts = $this->userMountCache->getMountsForUser($user); + $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); + $mountsByPath = array_combine($mountPoints, $cachedMounts); + + $target = $this->shareTargetValidator->verifyMountPoint($user, $share, $mountsByPath, [$share]); + $mountPoint = '/' . $user->getUID() . '/files/' . trim($target, '/') . '/'; + + $fileInfo = $share->getNode(); + if (!$fileInfo instanceof FileInfo) { + throw new \Exception('share node is the wrong fileinfo'); + } + $this->userMountCache->addMount($user, $mountPoint, $fileInfo->getData(), MountProvider::class); + } +} From a21103fbe19ba85ea383d8d9339c12d9c656da95 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 12 Feb 2026 15:42:16 +0100 Subject: [PATCH 04/17] feat: postpone receiving share validation after processing a certain number of users Signed-off-by: Robin Appelman --- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + .../files_sharing/lib/AppInfo/Application.php | 3 ++ .../lib/Config/ConfigLexicon.php | 10 +++- .../lib/Listener/SharesUpdatedListener.php | 53 ++++++++++++++++--- .../lib/Listener/UserHomeSetupListener.php | 44 +++++++++++++++ 6 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 apps/files_sharing/lib/Listener/UserHomeSetupListener.php diff --git a/apps/files_sharing/composer/composer/autoload_classmap.php b/apps/files_sharing/composer/composer/autoload_classmap.php index c3dea9de9ce0b..138746aad8f83 100644 --- a/apps/files_sharing/composer/composer/autoload_classmap.php +++ b/apps/files_sharing/composer/composer/autoload_classmap.php @@ -72,6 +72,7 @@ 'OCA\\Files_Sharing\\Listener\\ShareInteractionListener' => $baseDir . '/../lib/Listener/ShareInteractionListener.php', 'OCA\\Files_Sharing\\Listener\\SharesUpdatedListener' => $baseDir . '/../lib/Listener/SharesUpdatedListener.php', 'OCA\\Files_Sharing\\Listener\\UserAddedToGroupListener' => $baseDir . '/../lib/Listener/UserAddedToGroupListener.php', + 'OCA\\Files_Sharing\\Listener\\UserHomeSetupListener' => $baseDir . '/../lib/Listener/UserHomeSetupListener.php', 'OCA\\Files_Sharing\\Listener\\UserShareAcceptanceListener' => $baseDir . '/../lib/Listener/UserShareAcceptanceListener.php', 'OCA\\Files_Sharing\\Middleware\\OCSShareAPIMiddleware' => $baseDir . '/../lib/Middleware/OCSShareAPIMiddleware.php', 'OCA\\Files_Sharing\\Middleware\\ShareInfoMiddleware' => $baseDir . '/../lib/Middleware/ShareInfoMiddleware.php', diff --git a/apps/files_sharing/composer/composer/autoload_static.php b/apps/files_sharing/composer/composer/autoload_static.php index 57cf4cc29fba1..3decf0b9c1acc 100644 --- a/apps/files_sharing/composer/composer/autoload_static.php +++ b/apps/files_sharing/composer/composer/autoload_static.php @@ -87,6 +87,7 @@ class ComposerStaticInitFiles_Sharing 'OCA\\Files_Sharing\\Listener\\ShareInteractionListener' => __DIR__ . '/..' . '/../lib/Listener/ShareInteractionListener.php', 'OCA\\Files_Sharing\\Listener\\SharesUpdatedListener' => __DIR__ . '/..' . '/../lib/Listener/SharesUpdatedListener.php', 'OCA\\Files_Sharing\\Listener\\UserAddedToGroupListener' => __DIR__ . '/..' . '/../lib/Listener/UserAddedToGroupListener.php', + 'OCA\\Files_Sharing\\Listener\\UserHomeSetupListener' => __DIR__ . '/..' . '/../lib/Listener/UserHomeSetupListener.php', 'OCA\\Files_Sharing\\Listener\\UserShareAcceptanceListener' => __DIR__ . '/..' . '/../lib/Listener/UserShareAcceptanceListener.php', 'OCA\\Files_Sharing\\Middleware\\OCSShareAPIMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/OCSShareAPIMiddleware.php', 'OCA\\Files_Sharing\\Middleware\\ShareInfoMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/ShareInfoMiddleware.php', diff --git a/apps/files_sharing/lib/AppInfo/Application.php b/apps/files_sharing/lib/AppInfo/Application.php index da4984d378533..3af5f5265035b 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -27,6 +27,7 @@ use OCA\Files_Sharing\Listener\ShareInteractionListener; use OCA\Files_Sharing\Listener\SharesUpdatedListener; use OCA\Files_Sharing\Listener\UserAddedToGroupListener; +use OCA\Files_Sharing\Listener\UserHomeSetupListener; use OCA\Files_Sharing\Listener\UserShareAcceptanceListener; use OCA\Files_Sharing\Middleware\OCSShareAPIMiddleware; use OCA\Files_Sharing\Middleware\ShareInfoMiddleware; @@ -48,6 +49,7 @@ use OCP\Files\Events\BeforeDirectFileDownloadEvent; use OCP\Files\Events\BeforeZipCreatedEvent; use OCP\Files\Events\Node\BeforeNodeReadEvent; +use OCP\Files\Events\UserHomeSetupEvent; use OCP\Group\Events\GroupChangedEvent; use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserAddedEvent; @@ -121,6 +123,7 @@ function () use ($c) { $context->registerEventListener(UserAddedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserRemovedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserShareAccessUpdatedEvent::class, SharesUpdatedListener::class); + $context->registerEventListener(UserHomeSetupEvent::class, UserHomeSetupListener::class); $context->registerConfigLexicon(ConfigLexicon::class); } diff --git a/apps/files_sharing/lib/Config/ConfigLexicon.php b/apps/files_sharing/lib/Config/ConfigLexicon.php index c063153765e26..a6fb9f11ae61d 100644 --- a/apps/files_sharing/lib/Config/ConfigLexicon.php +++ b/apps/files_sharing/lib/Config/ConfigLexicon.php @@ -24,6 +24,9 @@ class ConfigLexicon implements ILexicon { public const SHOW_FEDERATED_AS_INTERNAL = 'show_federated_shares_as_internal'; public const SHOW_FEDERATED_TO_TRUSTED_AS_INTERNAL = 'show_federated_shares_to_trusted_servers_as_internal'; public const EXCLUDE_RESHARE_FROM_EDIT = 'shareapi_exclude_reshare_from_edit'; + public const UPDATE_SINGLE_CUTOFF = 'update_single_cutoff'; + public const UPDATE_ALL_CUTOFF = 'update_all_cutoff'; + public const USER_NEEDS_SHARE_REFRESH = 'user_needs_share_refresh'; public function getStrictness(): Strictness { return Strictness::IGNORE; @@ -34,10 +37,15 @@ public function getAppConfigs(): array { new Entry(self::SHOW_FEDERATED_AS_INTERNAL, ValueType::BOOL, false, 'shows federated shares as internal shares', true), new Entry(self::SHOW_FEDERATED_TO_TRUSTED_AS_INTERNAL, ValueType::BOOL, false, 'shows federated shares to trusted servers as internal shares', true), new Entry(self::EXCLUDE_RESHARE_FROM_EDIT, ValueType::BOOL, false, 'Exclude reshare permission from "Allow editing" bundled permissions'), + + new Entry(self::UPDATE_SINGLE_CUTOFF, ValueType::INT, 10, 'For how many users do we update the share data immediately for single-share updates'), + new Entry(self::UPDATE_ALL_CUTOFF, ValueType::INT, 3, 'For how many users do we update the share data immediately for all-share updates'), ]; } public function getUserConfigs(): array { - return []; + return [ + new Entry(self::USER_NEEDS_SHARE_REFRESH, ValueType::BOOL, false, 'whether a user needs to have the receiving share data refreshed for possible changes'), + ]; } } diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 3cfe3c3a3a5bf..5e9d26a53da80 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -8,12 +8,17 @@ namespace OCA\Files_Sharing\Listener; +use OCA\Files_Sharing\AppInfo\Application; +use OCA\Files_Sharing\Config\ConfigLexicon; use OCA\Files_Sharing\Event\UserShareAccessUpdatedEvent; use OCA\Files_Sharing\ShareRecipientUpdater; +use OCP\Config\IUserConfig; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Group\Events\UserAddedEvent; use OCP\Group\Events\UserRemovedEvent; +use OCP\IAppConfig; +use OCP\IUser; use OCP\Share\Events\BeforeShareDeletedEvent; use OCP\Share\Events\ShareCreatedEvent; use OCP\Share\Events\ShareTransferredEvent; @@ -25,35 +30,71 @@ * @template-implements IEventListener */ class SharesUpdatedListener implements IEventListener { + /** + * for how many users do we update the share date immediately, + * before just marking the other users when we know the relevant share + */ + private int $cutOffMarkAllSingleShare; + /** + * for how many users do we update the share date immediately, + * before just marking the other users when the relevant shares are unknown + */ + private int $cutOffMarkAllShares ; + + private int $updatedCount = 0; + public function __construct( private readonly IManager $shareManager, private readonly ShareRecipientUpdater $shareUpdater, + private readonly IUserConfig $userConfig, + IAppConfig $appConfig, ) { + $this->cutOffMarkAllSingleShare = $appConfig->getValueInt(Application::APP_ID, ConfigLexicon::UPDATE_SINGLE_CUTOFF, 10); + $this->cutOffMarkAllShares = $appConfig->getValueInt(Application::APP_ID, ConfigLexicon::UPDATE_ALL_CUTOFF, 3); } + public function handle(Event $event): void { if ($event instanceof UserShareAccessUpdatedEvent) { foreach ($event->getUsers() as $user) { - $this->shareUpdater->updateForUser($user, true); + $this->updateOrMarkUser($user, true); } } if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent) { - $this->shareUpdater->updateForUser($event->getUser(), true); + $this->updateOrMarkUser($event->getUser(), true); } if ($event instanceof ShareCreatedEvent || $event instanceof ShareTransferredEvent) { $share = $event->getShare(); $shareTarget = $share->getTarget(); foreach ($this->shareManager->getUsersForShare($share) as $user) { if ($share->getSharedBy() !== $user->getUID()) { - $this->shareUpdater->updateForShare($user, $share); - // Share target validation might have changed the target, restore it for the next user - $share->setTarget($shareTarget); + $this->updatedCount++; + if ($this->updatedCount <= $this->cutOffMarkAllSingleShare) { + $this->shareUpdater->updateForShare($user, $share); + // Share target validation might have changed the target, restore it for the next user + $share->setTarget($shareTarget); + } else { + $this->markUserForRefresh($user); + } } } } if ($event instanceof BeforeShareDeletedEvent) { foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) { - $this->shareManager->updateForUser($user, false, [$event->getShare()]); + $this->updateOrMarkUser($user, false, [$event->getShare()]); } } } + + private function updateOrMarkUser(IUser $user, bool $verifyMountPoints, array $ignoreShares = []): void { + $this->updatedCount++; + if ($this->updatedCount <= $this->cutOffMarkAllShares) { + $this->shareUpdater->updateForUser($user, $verifyMountPoints, $ignoreShares); + } else { + $this->markUserForRefresh($user); + } + } + + private function markUserForRefresh(IUser $user): void { + $this->userConfig->setValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true); + } } diff --git a/apps/files_sharing/lib/Listener/UserHomeSetupListener.php b/apps/files_sharing/lib/Listener/UserHomeSetupListener.php new file mode 100644 index 0000000000000..8886660879fa9 --- /dev/null +++ b/apps/files_sharing/lib/Listener/UserHomeSetupListener.php @@ -0,0 +1,44 @@ + + */ +class UserHomeSetupListener implements IEventListener { + public function __construct( + private readonly ShareRecipientUpdater $updater, + private readonly IUserConfig $userConfig, + ) { + } + + public function handle(Event $event): void { + if (!$event instanceof UserHomeSetupEvent) { + return; + } + + $user = $event->getUser(); + if ($this->userConfig->getValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH)) { + $this->updater->updateForUser($user); + $this->userConfig->setValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, false); + } + } + +} From e683d4725b94650fdb647958ed4e7db7e1520d9c Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 17 Feb 2026 00:04:17 +0100 Subject: [PATCH 05/17] test: add test for delayed share validate Signed-off-by: Robin Appelman --- .../features/bootstrap/SharingContext.php | 2 + .../sharing_features/sharing-v1-part2.feature | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/build/integration/features/bootstrap/SharingContext.php b/build/integration/features/bootstrap/SharingContext.php index c442317a32a38..2652667f02590 100644 --- a/build/integration/features/bootstrap/SharingContext.php +++ b/build/integration/features/bootstrap/SharingContext.php @@ -32,6 +32,8 @@ protected function resetAppConfigs() { $this->deleteServerConfig('core', 'shareapi_allow_federation_on_public_shares'); $this->deleteServerConfig('files_sharing', 'outgoing_server2server_share_enabled'); $this->deleteServerConfig('core', 'shareapi_allow_view_without_download'); + $this->deleteServerConfig('files_sharing', 'update_single_cutoff'); + $this->deleteServerConfig('files_sharing', 'update_all_cutoff'); $this->runOcc(['config:system:delete', 'share_folder']); } diff --git a/build/integration/sharing_features/sharing-v1-part2.feature b/build/integration/sharing_features/sharing-v1-part2.feature index 0c83975fc39b5..a2b7682db1d73 100644 --- a/build/integration/sharing_features/sharing-v1-part2.feature +++ b/build/integration/sharing_features/sharing-v1-part2.feature @@ -47,6 +47,50 @@ Feature: sharing | share_with | user2 | | share_with_displayname | user2 | +Scenario: getting all shares of a file with a received share after revoking the resharing rights with delayed share check + Given user "user0" exists + And parameter "update_single_cutoff" of app "files_sharing" is set to "0" + And parameter "update_all_cutoff" of app "files_sharing" is set to "0" + And user "user1" exists + And user "user2" exists + And file "textfile0.txt" of user "user1" is shared with user "user0" + And user "user0" accepts last share + And Updating last share with + | permissions | 1 | + And file "textfile0.txt" of user "user1" is shared with user "user2" + When As an "user0" + And sending "GET" to "/apps/files_sharing/api/v1/shares?reshares=true&path=/textfile0 (2).txt" + Then the list of returned shares has 1 shares + And share 0 is returned with + | share_type | 0 | + | uid_owner | user1 | + | displayname_owner | user1 | + | path | /textfile0 (2).txt | + | item_type | file | + | mimetype | text/plain | + | storage_id | shared::/textfile0 (2).txt | + | file_target | /textfile0.txt | + | share_with | user2 | + | share_with_displayname | user2 | + # After user2 does an FS setup the share is renamed + When As an "user2" + And Downloading file "/textfile0 (2).txt" with range "bytes=10-18" + Then Downloaded content should be "test text" + When As an "user0" + And sending "GET" to "/apps/files_sharing/api/v1/shares?reshares=true&path=/textfile0 (2).txt" + Then the list of returned shares has 1 shares + And share 0 is returned with + | share_type | 0 | + | uid_owner | user1 | + | displayname_owner | user1 | + | path | /textfile0 (2).txt | + | item_type | file | + | mimetype | text/plain | + | storage_id | shared::/textfile0 (2).txt | + | file_target | /textfile0 (2).txt | + | share_with | user2 | + | share_with_displayname | user2 | + Scenario: getting all shares of a file with a received share also reshared after revoking the resharing rights Given user "user0" exists And user "user1" exists From 794cc2815cb2567102ae7a4dd7bde1c9b594e53e Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 17 Feb 2026 17:12:54 +0100 Subject: [PATCH 06/17] fix: disable share resolve postpone in tests Signed-off-by: Robin Appelman --- .../lib/Listener/SharesUpdatedListener.php | 12 ++++++++++-- apps/files_sharing/tests/TestCase.php | 4 ++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 5e9d26a53da80..39f9e4e928940 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -68,7 +68,7 @@ public function handle(Event $event): void { foreach ($this->shareManager->getUsersForShare($share) as $user) { if ($share->getSharedBy() !== $user->getUID()) { $this->updatedCount++; - if ($this->updatedCount <= $this->cutOffMarkAllSingleShare) { + if ($this->cutOffMarkAllSingleShare === -1 || $this->updatedCount <= $this->cutOffMarkAllSingleShare) { $this->shareUpdater->updateForShare($user, $share); // Share target validation might have changed the target, restore it for the next user $share->setTarget($shareTarget); @@ -87,7 +87,7 @@ public function handle(Event $event): void { private function updateOrMarkUser(IUser $user, bool $verifyMountPoints, array $ignoreShares = []): void { $this->updatedCount++; - if ($this->updatedCount <= $this->cutOffMarkAllShares) { + if ($this->cutOffMarkAllShares === -1 || $this->updatedCount <= $this->cutOffMarkAllShares) { $this->shareUpdater->updateForUser($user, $verifyMountPoints, $ignoreShares); } else { $this->markUserForRefresh($user); @@ -97,4 +97,12 @@ private function updateOrMarkUser(IUser $user, bool $verifyMountPoints, array $i private function markUserForRefresh(IUser $user): void { $this->userConfig->setValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true); } + + public function setCutOffMarkAllSingleShare(int $cutOffMarkAllSingleShare): void { + $this->cutOffMarkAllSingleShare = $cutOffMarkAllSingleShare; + } + + public function setCutOffMarkAllShares(int $cutOffMarkAllShares): void { + $this->cutOffMarkAllShares = $cutOffMarkAllShares; + } } diff --git a/apps/files_sharing/tests/TestCase.php b/apps/files_sharing/tests/TestCase.php index 6b72ecb259cab..9ed3bacc39175 100644 --- a/apps/files_sharing/tests/TestCase.php +++ b/apps/files_sharing/tests/TestCase.php @@ -15,6 +15,7 @@ use OC\User\DisplayNameCache; use OCA\Files_Sharing\AppInfo\Application; use OCA\Files_Sharing\External\MountProvider as ExternalMountProvider; +use OCA\Files_Sharing\Listener\SharesUpdatedListener; use OCA\Files_Sharing\MountProvider; use OCP\Files\Config\IMountProviderCollection; use OCP\Files\IRootFolder; @@ -99,6 +100,9 @@ public static function setUpBeforeClass(): void { $groupBackend->addToGroup(self::TEST_FILES_SHARING_API_USER4, 'group3'); $groupBackend->addToGroup(self::TEST_FILES_SHARING_API_USER2, self::TEST_FILES_SHARING_API_GROUP1); Server::get(IGroupManager::class)->addBackend($groupBackend); + + Server::get(SharesUpdatedListener::class)->setCutOffMarkAllShares(-1); + Server::get(SharesUpdatedListener::class)->setCutOffMarkAllSingleShare(-1); } protected function setUp(): void { From 81fc3a3e1c490cbc53b66543753bd8e13e00d060 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 17 Feb 2026 18:19:30 +0100 Subject: [PATCH 07/17] feat: export getData for public FileInfo interface Signed-off-by: Robin Appelman --- apps/files_sharing/lib/ShareRecipientUpdater.php | 7 +------ apps/files_trashbin/lib/Trash/TrashItem.php | 5 +++++ lib/private/Files/Node/LazyFolder.php | 5 +++++ lib/private/Files/Node/Node.php | 5 +++++ lib/public/Files/FileInfo.php | 9 +++++++++ 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php index 979dc41dfb4b7..cd4f389072176 100644 --- a/apps/files_sharing/lib/ShareRecipientUpdater.php +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -8,7 +8,6 @@ namespace OCA\Files_Sharing; -use OC\Files\FileInfo; use OCP\Files\Config\ICachedMountInfo; use OCP\Files\Config\IUserMountCache; use OCP\Files\Storage\IStorageFactory; @@ -77,10 +76,6 @@ public function updateForShare(IUser $user, IShare $share): void { $target = $this->shareTargetValidator->verifyMountPoint($user, $share, $mountsByPath, [$share]); $mountPoint = '/' . $user->getUID() . '/files/' . trim($target, '/') . '/'; - $fileInfo = $share->getNode(); - if (!$fileInfo instanceof FileInfo) { - throw new \Exception('share node is the wrong fileinfo'); - } - $this->userMountCache->addMount($user, $mountPoint, $fileInfo->getData(), MountProvider::class); + $this->userMountCache->addMount($user, $mountPoint, $share->getNode()->getData(), MountProvider::class); } } diff --git a/apps/files_trashbin/lib/Trash/TrashItem.php b/apps/files_trashbin/lib/Trash/TrashItem.php index 70d5164747f0b..2864a8cd942f4 100644 --- a/apps/files_trashbin/lib/Trash/TrashItem.php +++ b/apps/files_trashbin/lib/Trash/TrashItem.php @@ -6,6 +6,7 @@ */ namespace OCA\Files_Trashbin\Trash; +use OCP\Files\Cache\ICacheEntry; use OCP\Files\FileInfo; use OCP\IUser; @@ -169,4 +170,8 @@ public function getDeletedBy(): ?IUser { public function getMetadata(): array { return $this->fileInfo->getMetadata(); } + + public function getData(): ICacheEntry { + return $this->fileInfo->getData(); + } } diff --git a/lib/private/Files/Node/LazyFolder.php b/lib/private/Files/Node/LazyFolder.php index d04c8aefb7e06..0e11855fc32c0 100644 --- a/lib/private/Files/Node/LazyFolder.php +++ b/lib/private/Files/Node/LazyFolder.php @@ -10,6 +10,7 @@ use OC\Files\Filesystem; use OC\Files\Utils\PathHelper; use OCP\Constants; +use OCP\Files\Cache\ICacheEntry; use OCP\Files\Folder; use OCP\Files\IRootFolder; use OCP\Files\Mount\IMountPoint; @@ -563,6 +564,10 @@ public function getMetadata(): array { return $this->data['metadata'] ?? $this->__call(__FUNCTION__, func_get_args()); } + public function getData(): ICacheEntry { + return $this->__call(__FUNCTION__, func_get_args()); + } + public function verifyPath($fileName, $readonly = false): void { $this->__call(__FUNCTION__, func_get_args()); } diff --git a/lib/private/Files/Node/Node.php b/lib/private/Files/Node/Node.php index fd8d84883d964..7a7867e6a4e7a 100644 --- a/lib/private/Files/Node/Node.php +++ b/lib/private/Files/Node/Node.php @@ -12,6 +12,7 @@ use OC\Files\Utils\PathHelper; use OCP\EventDispatcher\GenericEvent; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Cache\ICacheEntry; use OCP\Files\FileInfo; use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; @@ -486,4 +487,8 @@ public function getParentId(): int { public function getMetadata(): array { return $this->fileInfo->getMetadata(); } + + public function getData(): ICacheEntry { + return $this->fileInfo->getData(); + } } diff --git a/lib/public/Files/FileInfo.php b/lib/public/Files/FileInfo.php index 95419d6354ac4..117408f23bc63 100644 --- a/lib/public/Files/FileInfo.php +++ b/lib/public/Files/FileInfo.php @@ -8,6 +8,7 @@ namespace OCP\Files; use OCP\AppFramework\Attribute\Consumable; +use OCP\Files\Cache\ICacheEntry; use OCP\Files\Storage\IStorage; /** @@ -298,4 +299,12 @@ public function getParentId(): int; * @since 28.0.0 */ public function getMetadata(): array; + + /** + * Get the filecache data for the file + * + * @return ICacheEntry + * @since 34.0.0 + */ + public function getData(): ICacheEntry; } From 8c405f19e7307b4d08c5ed1cf2d7be8074495a7e Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 18 Feb 2026 16:57:11 +0100 Subject: [PATCH 08/17] fix: clear in-memory cached mounts for user when adding/removing mounts Signed-off-by: Robin Appelman --- lib/private/Files/Config/UserMountCache.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/private/Files/Config/UserMountCache.php b/lib/private/Files/Config/UserMountCache.php index ebdfc64d83ece..5233cebc5da5a 100644 --- a/lib/private/Files/Config/UserMountCache.php +++ b/lib/private/Files/Config/UserMountCache.php @@ -536,6 +536,12 @@ public function removeMount(string $mountPoint): void { $query->delete('mounts') ->where($query->expr()->eq('mount_point', $query->createNamedParameter($mountPoint))); $query->executeStatement(); + + $parts = explode('/', $mountPoint); + if (count($parts) > 3) { + [, $userId] = $parts; + unset($this->mountsForUsers[$userId]); + } } public function addMount(IUser $user, string $mountPoint, ICacheEntry $rootCacheEntry, string $mountProvider, ?int $mountId = null): void { @@ -553,6 +559,7 @@ public function addMount(IUser $user, string $mountPoint, ICacheEntry $rootCache try { $query->executeStatement(); + unset($this->mountsForUsers[$user->getUID()]); } catch (DbalException $e) { if ($e->getReason() !== DbalException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { throw $e; From 7fd2baba38856fc7136fbd5122ce22ab31679fa9 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 19 Feb 2026 17:17:05 +0100 Subject: [PATCH 09/17] test: add reusable mock implementation for IAppConfig Signed-off-by: Robin Appelman --- tests/lib/Mock/Config/MockAppConfig.php | 169 ++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 tests/lib/Mock/Config/MockAppConfig.php diff --git a/tests/lib/Mock/Config/MockAppConfig.php b/tests/lib/Mock/Config/MockAppConfig.php new file mode 100644 index 0000000000000..f601bf6dbc570 --- /dev/null +++ b/tests/lib/Mock/Config/MockAppConfig.php @@ -0,0 +1,169 @@ +config[$app][$key]); + } + + public function getValues($app, $key): array { + throw new \Exception('not implemented'); + } + + public function getFilteredValues($app): array { + throw new \Exception('not implemented'); + } + + public function getApps(): array { + return array_keys($this->config); + } + + public function getKeys(string $app): array { + return array_keys($this->config[$app] ?? []); + } + + public function isSensitive(string $app, string $key, ?bool $lazy = false): bool { + throw new \Exception('not implemented'); + } + + public function isLazy(string $app, string $key): bool { + throw new \Exception('not implemented'); + } + + public function getAllValues(string $app, string $prefix = '', bool $filtered = false): array { + throw new \Exception('not implemented'); + } + + public function searchValues(string $key, bool $lazy = false, ?int $typedAs = null): array { + throw new \Exception('not implemented'); + } + + public function getValueString(string $app, string $key, string $default = '', bool $lazy = false): string { + return (string)(($this->config[$app] ?? [])[$key] ?? $default); + } + + public function getValueInt(string $app, string $key, int $default = 0, bool $lazy = false): int { + return (int)(($this->config[$app] ?? [])[$key] ?? $default); + } + + public function getValueFloat(string $app, string $key, float $default = 0, bool $lazy = false): float { + return (float)(($this->config[$app] ?? [])[$key] ?? $default); + } + + public function getValueBool(string $app, string $key, bool $default = false, bool $lazy = false): bool { + return (bool)(($this->config[$app] ?? [])[$key] ?? $default); + } + + public function getValueArray(string $app, string $key, array $default = [], bool $lazy = false): array { + return ($this->config[$app] ?? [])[$key] ?? $default; + } + + public function getValueType(string $app, string $key, ?bool $lazy = null): int { + throw new \Exception('not implemented'); + } + + public function setValueString(string $app, string $key, string $value, bool $lazy = false, bool $sensitive = false): bool { + $this->config[$app][$key] = $value; + return true; + } + + public function setValueInt(string $app, string $key, int $value, bool $lazy = false, bool $sensitive = false): bool { + $this->config[$app][$key] = $value; + return true; + } + + public function setValueFloat(string $app, string $key, float $value, bool $lazy = false, bool $sensitive = false): bool { + $this->config[$app][$key] = $value; + return true; + } + + public function setValueBool(string $app, string $key, bool $value, bool $lazy = false): bool { + $this->config[$app][$key] = $value; + return true; + } + + public function setValueArray(string $app, string $key, array $value, bool $lazy = false, bool $sensitive = false): bool { + $this->config[$app][$key] = $value; + return true; + } + + public function updateSensitive(string $app, string $key, bool $sensitive): bool { + throw new \Exception('not implemented'); + } + + public function updateLazy(string $app, string $key, bool $lazy): bool { + throw new \Exception('not implemented'); + } + + public function getDetails(string $app, string $key): array { + throw new \Exception('not implemented'); + } + + public function convertTypeToInt(string $type): int { + return match (strtolower($type)) { + 'mixed' => IAppConfig::VALUE_MIXED, + 'string' => IAppConfig::VALUE_STRING, + 'integer' => IAppConfig::VALUE_INT, + 'float' => IAppConfig::VALUE_FLOAT, + 'boolean' => IAppConfig::VALUE_BOOL, + 'array' => IAppConfig::VALUE_ARRAY, + default => throw new AppConfigIncorrectTypeException('Unknown type ' . $type) + }; + } + + public function convertTypeToString(int $type): string { + $type &= ~self::VALUE_SENSITIVE; + + return match ($type) { + IAppConfig::VALUE_MIXED => 'mixed', + IAppConfig::VALUE_STRING => 'string', + IAppConfig::VALUE_INT => 'integer', + IAppConfig::VALUE_FLOAT => 'float', + IAppConfig::VALUE_BOOL => 'boolean', + IAppConfig::VALUE_ARRAY => 'array', + default => throw new AppConfigIncorrectTypeException('Unknown numeric type ' . $type) + }; + } + + public function deleteKey(string $app, string $key): void { + if ($this->hasKey($app, $key)) { + unset($this->config[$app][$key]); + } + } + + public function deleteApp(string $app): void { + if (isset($this->config[$app])) { + unset($this->config[$app]); + } + } + + public function clearCache(bool $reload = false): void { + } + + public function searchKeys(string $app, string $prefix = '', bool $lazy = false): array { + throw new \Exception('not implemented'); + } + + public function getKeyDetails(string $app, string $key): array { + throw new \Exception('not implemented'); + } + + public function getAppInstalledVersions(bool $onlyEnabled = false): array { + throw new \Exception('not implemented'); + } +} From 22c2af3c5ce21975a1aaebff5f2de73cdc517eb5 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 19 Feb 2026 17:28:24 +0100 Subject: [PATCH 10/17] test: add reusable mock implementation for IUserConfig Signed-off-by: Robin Appelman --- tests/lib/Mock/Config/MockUserConfig.php | 209 +++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 tests/lib/Mock/Config/MockUserConfig.php diff --git a/tests/lib/Mock/Config/MockUserConfig.php b/tests/lib/Mock/Config/MockUserConfig.php new file mode 100644 index 0000000000000..cc4619ef191ef --- /dev/null +++ b/tests/lib/Mock/Config/MockUserConfig.php @@ -0,0 +1,209 @@ +config); + } + + public function getApps(string $userId): array { + return array_keys($this->config[$userId] ?? []); + } + + public function getKeys(string $userId, string $app): array { + if (isset($this->config[$userId][$app])) { + return array_keys($this->config[$userId][$app]); + } else { + return []; + } + } + + public function hasKey(string $userId, string $app, string $key, ?bool $lazy = false): bool { + return isset($this->config[$userId][$app][$key]); + } + + public function isSensitive(string $userId, string $app, string $key, ?bool $lazy = false): bool { + throw new \Exception('not implemented'); + } + + public function isIndexed(string $userId, string $app, string $key, ?bool $lazy = false): bool { + throw new \Exception('not implemented'); + } + + public function isLazy(string $userId, string $app, string $key): bool { + throw new \Exception('not implemented'); + } + + public function getValues(string $userId, string $app, string $prefix = '', bool $filtered = false): array { + throw new \Exception('not implemented'); + } + + public function getAllValues(string $userId, bool $filtered = false): array { + throw new \Exception('not implemented'); + } + + public function getValuesByApps(string $userId, string $key, bool $lazy = false, ?ValueType $typedAs = null): array { + throw new \Exception('not implemented'); + } + + public function getValuesByUsers(string $app, string $key, ?ValueType $typedAs = null, ?array $userIds = null): array { + throw new \Exception('not implemented'); + } + + public function searchUsersByValueString(string $app, string $key, string $value, bool $caseInsensitive = false): Generator { + throw new \Exception('not implemented'); + } + + public function searchUsersByValueInt(string $app, string $key, int $value): Generator { + throw new \Exception('not implemented'); + } + + public function searchUsersByValues(string $app, string $key, array $values): Generator { + throw new \Exception('not implemented'); + } + + public function searchUsersByValueBool(string $app, string $key, bool $value): Generator { + throw new \Exception('not implemented'); + } + + public function getValueString(string $userId, string $app, string $key, string $default = '', bool $lazy = false): string { + if (isset($this->config[$userId][$app])) { + return (string)$this->config[$userId][$app][$key]; + } else { + return $default; + } + } + + public function getValueInt(string $userId, string $app, string $key, int $default = 0, bool $lazy = false): int { + if (isset($this->config[$userId][$app])) { + return (int)$this->config[$userId][$app][$key]; + } else { + return $default; + } + } + + public function getValueFloat(string $userId, string $app, string $key, float $default = 0, bool $lazy = false): float { + if (isset($this->config[$userId][$app])) { + return (float)$this->config[$userId][$app][$key]; + } else { + return $default; + } + } + + public function getValueBool(string $userId, string $app, string $key, bool $default = false, bool $lazy = false): bool { + if (isset($this->config[$userId][$app])) { + return (bool)$this->config[$userId][$app][$key]; + } else { + return $default; + } + } + + public function getValueArray(string $userId, string $app, string $key, array $default = [], bool $lazy = false): array { + if (isset($this->config[$userId][$app])) { + return $this->config[$userId][$app][$key]; + } else { + return $default; + } + } + + public function getValueType(string $userId, string $app, string $key, ?bool $lazy = null): ValueType { + throw new \Exception('not implemented'); + } + + public function getValueFlags(string $userId, string $app, string $key, bool $lazy = false): int { + throw new \Exception('not implemented'); + } + + public function setValueString(string $userId, string $app, string $key, string $value, bool $lazy = false, int $flags = 0): bool { + $this->config[$userId][$app][$key] = $value; + return true; + } + + public function setValueInt(string $userId, string $app, string $key, int $value, bool $lazy = false, int $flags = 0): bool { + $this->config[$userId][$app][$key] = $value; + return true; + } + + public function setValueFloat(string $userId, string $app, string $key, float $value, bool $lazy = false, int $flags = 0): bool { + $this->config[$userId][$app][$key] = $value; + return true; + } + + public function setValueBool(string $userId, string $app, string $key, bool $value, bool $lazy = false): bool { + $this->config[$userId][$app][$key] = $value; + return true; + } + + public function setValueArray(string $userId, string $app, string $key, array $value, bool $lazy = false, int $flags = 0): bool { + $this->config[$userId][$app][$key] = $value; + return true; + } + + public function updateSensitive(string $userId, string $app, string $key, bool $sensitive): bool { + throw new \Exception('not implemented'); + } + + public function updateGlobalSensitive(string $app, string $key, bool $sensitive): void { + throw new \Exception('not implemented'); + } + + public function updateIndexed(string $userId, string $app, string $key, bool $indexed): bool { + throw new \Exception('not implemented'); + } + + public function updateGlobalIndexed(string $app, string $key, bool $indexed): void { + throw new \Exception('not implemented'); + } + + public function updateLazy(string $userId, string $app, string $key, bool $lazy): bool { + throw new \Exception('not implemented'); + } + + public function updateGlobalLazy(string $app, string $key, bool $lazy): void { + throw new \Exception('not implemented'); + } + + public function getDetails(string $userId, string $app, string $key): array { + throw new \Exception('not implemented'); + } + + public function deleteUserConfig(string $userId, string $app, string $key): void { + unset($this->config[$userId][$app][$key]); + } + + public function deleteKey(string $app, string $key): void { + throw new \Exception('not implemented'); + } + + public function deleteApp(string $app): void { + throw new \Exception('not implemented'); + } + + public function deleteAllUserConfig(string $userId): void { + unset($this->config[$userId]); + } + + public function clearCache(string $userId, bool $reload = false): void { + throw new \Exception('not implemented'); + } + + public function clearCacheAll(): void { + throw new \Exception('not implemented'); + } +} From 4e037421345d48b8f882b24d0b8a6286bcc6a8df Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 19 Feb 2026 18:01:29 +0100 Subject: [PATCH 11/17] test: add some tests for SharesUpdatedListenerTest Signed-off-by: Robin Appelman --- .../tests/SharesUpdatedListenerTest.php | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 apps/files_sharing/tests/SharesUpdatedListenerTest.php diff --git a/apps/files_sharing/tests/SharesUpdatedListenerTest.php b/apps/files_sharing/tests/SharesUpdatedListenerTest.php new file mode 100644 index 0000000000000..3e3211dae369c --- /dev/null +++ b/apps/files_sharing/tests/SharesUpdatedListenerTest.php @@ -0,0 +1,136 @@ +shareRecipientUpdater = $this->createMock(ShareRecipientUpdater::class); + $this->manager = $this->createMock(IManager::class); + $this->appConfig = new MockAppConfig([ + ConfigLexicon::UPDATE_ALL_CUTOFF => -1, + ConfigLexicon::UPDATE_SINGLE_CUTOFF => -1, + ]); + $this->userConfig = new MockUserConfig(); + $this->sharesUpdatedListener = new SharesUpdatedListener( + $this->manager, + $this->shareRecipientUpdater, + $this->userConfig, + $this->appConfig, + ); + } + + public function testShareAdded() { + $share = $this->createMock(IShare::class); + $user1 = $this->createUser('user1', ''); + $user2 = $this->createUser('user2', ''); + + $this->manager->method('getUsersForShare') + ->willReturn([$user1, $user2]); + + $event = new ShareCreatedEvent($share); + + $this->shareRecipientUpdater + ->expects($this->exactly(2)) + ->method('updateForShare') + ->willReturnCallback(function (IUser $user, IShare $eventShare) use ($user1, $user2, $share) { + $this->assertContains($user, [$user1, $user2]); + $this->assertEquals($share, $eventShare); + }); + + $this->sharesUpdatedListener->handle($event); + } + + public function testShareAddedFilterOwner() { + $share = $this->createMock(IShare::class); + $user1 = $this->createUser('user1', ''); + $user2 = $this->createUser('user2', ''); + $share->method('getSharedBy') + ->willReturn($user1->getUID()); + + $this->manager->method('getUsersForShare') + ->willReturn([$user1, $user2]); + + $event = new ShareCreatedEvent($share); + + $this->shareRecipientUpdater + ->expects($this->exactly(1)) + ->method('updateForShare') + ->willReturnCallback(function (IUser $user, IShare $eventShare) use ($user2, $share) { + $this->assertEquals($user, $user2); + $this->assertEquals($share, $eventShare); + }); + + $this->sharesUpdatedListener->handle($event); + } + + public function testShareAccessUpdated() { + $user1 = $this->createUser('user1', ''); + $user2 = $this->createUser('user2', ''); + + $event = new UserShareAccessUpdatedEvent([$user1, $user2]); + + $this->shareRecipientUpdater + ->expects($this->exactly(2)) + ->method('updateForUser') + ->willReturnCallback(function (IUser $user, bool $verifyMountPoints = true, array $ignoreShares = []) use ($user1, $user2) { + $this->assertContains($user, [$user1, $user2]); + $this->assertEquals(true, $verifyMountPoints); + $this->assertEquals([], $ignoreShares); + }); + + $this->sharesUpdatedListener->handle($event); + } + + public function testShareDeleted() { + $share = $this->createMock(IShare::class); + $user1 = $this->createUser('user1', ''); + $user2 = $this->createUser('user2', ''); + + $this->manager->method('getUsersForShare') + ->willReturn([$user1, $user2]); + + $event = new BeforeShareDeletedEvent($share); + + $this->shareRecipientUpdater + ->expects($this->exactly(2)) + ->method('updateForUser') + ->willReturnCallback(function (IUser $user, bool $verifyMountPoints = true, array $ignoreShares = []) use ($user1, $user2, $share) { + $this->assertContains($user, [$user1, $user2]); + $this->assertEquals(false, $verifyMountPoints); + $this->assertEquals([$share], $ignoreShares); + }); + + $this->sharesUpdatedListener->handle($event); + } +} From 025f9eea05de32e0bcf15f71108b1fe57d4bd428 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 19 Feb 2026 18:31:47 +0100 Subject: [PATCH 12/17] test: add some tests for ShareRecipientUpdaterTest Signed-off-by: Robin Appelman --- .../tests/ShareRecipientUpdaterTest.php | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 apps/files_sharing/tests/ShareRecipientUpdaterTest.php diff --git a/apps/files_sharing/tests/ShareRecipientUpdaterTest.php b/apps/files_sharing/tests/ShareRecipientUpdaterTest.php new file mode 100644 index 0000000000000..e32f7d88fb414 --- /dev/null +++ b/apps/files_sharing/tests/ShareRecipientUpdaterTest.php @@ -0,0 +1,188 @@ +userMountCache = $this->createMock(IUserMountCache::class); + $this->shareMountProvider = $this->createMock(MountProvider::class); + $this->shareTargetValidator = $this->createMock(ShareTargetValidator::class); + $this->storageFactory = $this->createMock(IStorageFactory::class); + + $this->updater = new ShareRecipientUpdater( + $this->userMountCache, + $this->shareMountProvider, + $this->shareTargetValidator, + $this->storageFactory, + ); + } + + public function testUpdateForShare() { + $share = $this->createMock(IShare::class); + $node = $this->createMock(Node::class); + $cacheEntry = $this->createMock(ICacheEntry::class); + $share->method('getNode') + ->willReturn($node); + $node->method('getData') + ->willReturn($cacheEntry); + $user1 = $this->createUser('user1', ''); + + $this->userMountCache->method('getMountsForUser') + ->with($user1) + ->willReturn([]); + + $this->shareTargetValidator->method('verifyMountPoint') + ->with($user1, $share, [], [$share]) + ->willReturn('/new-target'); + + $this->userMountCache->expects($this->exactly(1)) + ->method('addMount') + ->with($user1, '/user1/files/new-target/', $cacheEntry, MountProvider::class); + + $this->updater->updateForShare($user1, $share); + } + + /** + * @param IUser $user + * @param list $mounts + * @return void + */ + private function setCachedMounts(IUser $user, array $mounts) { + $cachedMounts = array_map(function (array $mount): ICachedMountInfo { + $cachedMount = $this->createMock(ICachedMountInfo::class); + $cachedMount->method('getRootId') + ->willReturn($mount['fileid']); + $cachedMount->method('getMountPoint') + ->willReturn($mount['mount_point']); + $cachedMount->method('getMountProvider') + ->willReturn($mount['provider']); + return $cachedMount; + }, $mounts); + $mountKeys = array_map(function (array $mount): string { + return $mount['fileid'] . '::' . $mount['mount_point']; + }, $mounts); + + $this->userMountCache->method('getMountsForUser') + ->with($user) + ->willReturn(array_combine($mountKeys, $cachedMounts)); + } + + public function testUpdateForUserAddedNoExisting() { + $share = $this->createMock(IShare::class); + $share->method('getTarget') + ->willReturn('/target'); + $share->method('getNodeId') + ->willReturn(111); + $user1 = $this->createUser('user1', ''); + $newMount = $this->createMock(IMountPoint::class); + + $this->shareMountProvider->method('getSuperSharesForUser') + ->with($user1, []) + ->willReturn([[ + $share, + [$share], + ]]); + + $this->shareMountProvider->method('getMountsFromSuperShares') + ->with($user1, [[ + $share, + [$share], + ]], $this->storageFactory) + ->willReturn([$newMount]); + + $this->setCachedMounts($user1, []); + + $this->shareTargetValidator->method('verifyMountPoint') + ->with($user1, $share, [], [$share]) + ->willReturn('/new-target'); + + $this->userMountCache->expects($this->exactly(1)) + ->method('registerMounts') + ->with($user1, [$newMount], [MountProvider::class]); + + $this->updater->updateForUser($user1); + } + + public function testUpdateForUserNoChanges() { + $share = $this->createMock(IShare::class); + $share->method('getTarget') + ->willReturn('/target'); + $share->method('getNodeId') + ->willReturn(111); + $user1 = $this->createUser('user1', ''); + + $this->shareMountProvider->method('getSuperSharesForUser') + ->with($user1, []) + ->willReturn([[ + $share, + [$share], + ]]); + + $this->setCachedMounts($user1, [ + ['fileid' => 111, 'mount_point' => '/user1/files/target/', 'provider' => MountProvider::class], + ]); + + $this->shareTargetValidator->expects($this->never()) + ->method('verifyMountPoint'); + + $this->userMountCache->expects($this->never()) + ->method('registerMounts'); + + $this->updater->updateForUser($user1); + } + + public function testUpdateForUserRemoved() { + $share = $this->createMock(IShare::class); + $share->method('getTarget') + ->willReturn('/target'); + $share->method('getNodeId') + ->willReturn(111); + $user1 = $this->createUser('user1', ''); + + $this->shareMountProvider->method('getSuperSharesForUser') + ->with($user1, []) + ->willReturn([]); + + $this->setCachedMounts($user1, [ + ['fileid' => 111, 'mount_point' => '/user1/files/target/', 'provider' => MountProvider::class], + ]); + + $this->shareTargetValidator->expects($this->never()) + ->method('verifyMountPoint'); + + $this->userMountCache->expects($this->exactly(1)) + ->method('registerMounts') + ->with($user1, [], [MountProvider::class]); + + $this->updater->updateForUser($user1); + } +} From a66c792c92678d347076de79ac5b4b975bfe9f2e Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 20 Feb 2026 16:19:28 +0100 Subject: [PATCH 13/17] feat: use time-based cutoff for share updating instead of count Signed-off-by: Robin Appelman --- .../lib/Config/ConfigLexicon.php | 6 +- .../lib/Listener/SharesUpdatedListener.php | 56 +++++++++--------- .../lib/ShareRecipientUpdater.php | 2 +- .../tests/ShareRecipientUpdaterTest.php | 2 +- .../tests/SharesUpdatedListenerTest.php | 59 +++++++++++++++++-- apps/files_sharing/tests/TestCase.php | 3 +- .../features/bootstrap/SharingContext.php | 3 +- .../sharing_features/sharing-v1-part2.feature | 3 +- 8 files changed, 90 insertions(+), 44 deletions(-) diff --git a/apps/files_sharing/lib/Config/ConfigLexicon.php b/apps/files_sharing/lib/Config/ConfigLexicon.php index a6fb9f11ae61d..623d1340f2617 100644 --- a/apps/files_sharing/lib/Config/ConfigLexicon.php +++ b/apps/files_sharing/lib/Config/ConfigLexicon.php @@ -24,8 +24,7 @@ class ConfigLexicon implements ILexicon { public const SHOW_FEDERATED_AS_INTERNAL = 'show_federated_shares_as_internal'; public const SHOW_FEDERATED_TO_TRUSTED_AS_INTERNAL = 'show_federated_shares_to_trusted_servers_as_internal'; public const EXCLUDE_RESHARE_FROM_EDIT = 'shareapi_exclude_reshare_from_edit'; - public const UPDATE_SINGLE_CUTOFF = 'update_single_cutoff'; - public const UPDATE_ALL_CUTOFF = 'update_all_cutoff'; + public const UPDATE_CUTOFF_TIME = 'update_cutoff_time'; public const USER_NEEDS_SHARE_REFRESH = 'user_needs_share_refresh'; public function getStrictness(): Strictness { @@ -38,8 +37,7 @@ public function getAppConfigs(): array { new Entry(self::SHOW_FEDERATED_TO_TRUSTED_AS_INTERNAL, ValueType::BOOL, false, 'shows federated shares to trusted servers as internal shares', true), new Entry(self::EXCLUDE_RESHARE_FROM_EDIT, ValueType::BOOL, false, 'Exclude reshare permission from "Allow editing" bundled permissions'), - new Entry(self::UPDATE_SINGLE_CUTOFF, ValueType::INT, 10, 'For how many users do we update the share data immediately for single-share updates'), - new Entry(self::UPDATE_ALL_CUTOFF, ValueType::INT, 3, 'For how many users do we update the share data immediately for all-share updates'), + new Entry(self::UPDATE_CUTOFF_TIME, ValueType::FLOAT, 3.0, 'For how how long do we update the share data immediately before switching to only marking the user'), ]; } diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 39f9e4e928940..880fd9792b8b9 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -23,6 +23,7 @@ use OCP\Share\Events\ShareCreatedEvent; use OCP\Share\Events\ShareTransferredEvent; use OCP\Share\IManager; +use Psr\Clock\ClockInterface; /** * Listen to various events that can change what shares a user has access to @@ -31,26 +32,24 @@ */ class SharesUpdatedListener implements IEventListener { /** - * for how many users do we update the share date immediately, - * before just marking the other users when we know the relevant share + * for how long do we update the share date immediately, + * before just marking the other users */ - private int $cutOffMarkAllSingleShare; + private float $cutOffMarkTime; + /** - * for how many users do we update the share date immediately, - * before just marking the other users when the relevant shares are unknown + * The total amount of time we've spent so far processing updates */ - private int $cutOffMarkAllShares ; - - private int $updatedCount = 0; + private float $updatedTime = 0.0; public function __construct( private readonly IManager $shareManager, private readonly ShareRecipientUpdater $shareUpdater, private readonly IUserConfig $userConfig, + private readonly ClockInterface $clock, IAppConfig $appConfig, ) { - $this->cutOffMarkAllSingleShare = $appConfig->getValueInt(Application::APP_ID, ConfigLexicon::UPDATE_SINGLE_CUTOFF, 10); - $this->cutOffMarkAllShares = $appConfig->getValueInt(Application::APP_ID, ConfigLexicon::UPDATE_ALL_CUTOFF, 3); + $this->cutOffMarkTime = $appConfig->getValueFloat(Application::APP_ID, ConfigLexicon::UPDATE_CUTOFF_TIME, 3.0); } public function handle(Event $event): void { @@ -67,14 +66,11 @@ public function handle(Event $event): void { $shareTarget = $share->getTarget(); foreach ($this->shareManager->getUsersForShare($share) as $user) { if ($share->getSharedBy() !== $user->getUID()) { - $this->updatedCount++; - if ($this->cutOffMarkAllSingleShare === -1 || $this->updatedCount <= $this->cutOffMarkAllSingleShare) { - $this->shareUpdater->updateForShare($user, $share); - // Share target validation might have changed the target, restore it for the next user - $share->setTarget($shareTarget); - } else { - $this->markUserForRefresh($user); - } + $this->markOrRun($user, function () use ($user, $share) { + $this->shareUpdater->updateForAddedShare($user, $share); + }); + // Share target validation might have changed the target, restore it for the next user + $share->setTarget($shareTarget); } } } @@ -85,24 +81,28 @@ public function handle(Event $event): void { } } - private function updateOrMarkUser(IUser $user, bool $verifyMountPoints, array $ignoreShares = []): void { - $this->updatedCount++; - if ($this->cutOffMarkAllShares === -1 || $this->updatedCount <= $this->cutOffMarkAllShares) { - $this->shareUpdater->updateForUser($user, $verifyMountPoints, $ignoreShares); + private function markOrRun(IUser $user, callable $callback): void { + $start = floatval($this->clock->now()->format('U.u')); + if ($this->cutOffMarkTime === -1.0 || $this->updatedTime < $this->cutOffMarkTime) { + $callback(); } else { $this->markUserForRefresh($user); } + $end = floatval($this->clock->now()->format('U.u')); + $this->updatedTime += $end - $start; } - private function markUserForRefresh(IUser $user): void { - $this->userConfig->setValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true); + private function updateOrMarkUser(IUser $user, bool $verifyMountPoints, array $ignoreShares = []): void { + $this->markOrRun($user, function () use ($user, $verifyMountPoints, $ignoreShares) { + $this->shareUpdater->updateForUser($user, $verifyMountPoints, $ignoreShares); + }); } - public function setCutOffMarkAllSingleShare(int $cutOffMarkAllSingleShare): void { - $this->cutOffMarkAllSingleShare = $cutOffMarkAllSingleShare; + private function markUserForRefresh(IUser $user): void { + $this->userConfig->setValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true); } - public function setCutOffMarkAllShares(int $cutOffMarkAllShares): void { - $this->cutOffMarkAllShares = $cutOffMarkAllShares; + public function setCutOffMarkTime(float|int $cutOffMarkTime): void { + $this->cutOffMarkTime = (float)$cutOffMarkTime; } } diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php index cd4f389072176..996c051749ac1 100644 --- a/apps/files_sharing/lib/ShareRecipientUpdater.php +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -68,7 +68,7 @@ public function updateForUser(IUser $user, bool $verifyMountPoints = true, array /** * Validate a single received share for a user */ - public function updateForShare(IUser $user, IShare $share): void { + public function updateForAddedShare(IUser $user, IShare $share): void { $cachedMounts = $this->userMountCache->getMountsForUser($user); $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); $mountsByPath = array_combine($mountPoints, $cachedMounts); diff --git a/apps/files_sharing/tests/ShareRecipientUpdaterTest.php b/apps/files_sharing/tests/ShareRecipientUpdaterTest.php index e32f7d88fb414..5ad995c3b1c58 100644 --- a/apps/files_sharing/tests/ShareRecipientUpdaterTest.php +++ b/apps/files_sharing/tests/ShareRecipientUpdaterTest.php @@ -68,7 +68,7 @@ public function testUpdateForShare() { ->method('addMount') ->with($user1, '/user1/files/new-target/', $cacheEntry, MountProvider::class); - $this->updater->updateForShare($user1, $share); + $this->updater->updateForAddedShare($user1, $share); } /** diff --git a/apps/files_sharing/tests/SharesUpdatedListenerTest.php b/apps/files_sharing/tests/SharesUpdatedListenerTest.php index 3e3211dae369c..ccba9d41c2e4b 100644 --- a/apps/files_sharing/tests/SharesUpdatedListenerTest.php +++ b/apps/files_sharing/tests/SharesUpdatedListenerTest.php @@ -18,7 +18,9 @@ use OCP\Share\Events\ShareCreatedEvent; use OCP\Share\IManager; use OCP\Share\IShare; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Clock\ClockInterface; use Test\Mock\Config\MockAppConfig; use Test\Mock\Config\MockUserConfig; use Test\Traits\UserTrait; @@ -31,6 +33,8 @@ class SharesUpdatedListenerTest extends \Test\TestCase { private IManager&MockObject $manager; private IUserConfig $userConfig; private IAppConfig $appConfig; + private ClockInterface&MockObject $clock; + private $clockFn; protected function setUp(): void { parent::setUp(); @@ -38,14 +42,23 @@ protected function setUp(): void { $this->shareRecipientUpdater = $this->createMock(ShareRecipientUpdater::class); $this->manager = $this->createMock(IManager::class); $this->appConfig = new MockAppConfig([ - ConfigLexicon::UPDATE_ALL_CUTOFF => -1, - ConfigLexicon::UPDATE_SINGLE_CUTOFF => -1, + ConfigLexicon::UPDATE_CUTOFF_TIME => -1, ]); $this->userConfig = new MockUserConfig(); + $this->clock = $this->createMock(ClockInterface::class); + $this->clockFn = function () { + return new \DateTimeImmutable('@0'); + }; + $this->clock->method('now') + ->willReturnCallback(function () { + // extra wrapper so we can modify clockFn + return ($this->clockFn)(); + }); $this->sharesUpdatedListener = new SharesUpdatedListener( $this->manager, $this->shareRecipientUpdater, $this->userConfig, + $this->clock, $this->appConfig, ); } @@ -62,7 +75,7 @@ public function testShareAdded() { $this->shareRecipientUpdater ->expects($this->exactly(2)) - ->method('updateForShare') + ->method('updateForAddedShare') ->willReturnCallback(function (IUser $user, IShare $eventShare) use ($user1, $user2, $share) { $this->assertContains($user, [$user1, $user2]); $this->assertEquals($share, $eventShare); @@ -85,7 +98,7 @@ public function testShareAddedFilterOwner() { $this->shareRecipientUpdater ->expects($this->exactly(1)) - ->method('updateForShare') + ->method('updateForAddedShare') ->willReturnCallback(function (IUser $user, IShare $eventShare) use ($user2, $share) { $this->assertEquals($user, $user2); $this->assertEquals($share, $eventShare); @@ -133,4 +146,42 @@ public function testShareDeleted() { $this->sharesUpdatedListener->handle($event); } + + public static function shareMarkAfterTimeProvider(): array { + // note that each user will take exactly 1s in this test + return [ + [0, 0], + [0.9, 1], + [1.1, 2], + [-1, 2], + ]; + } + + #[DataProvider('shareMarkAfterTimeProvider')] + public function testShareMarkAfterTime(float $cutOff, int $expectedCount) { + $share = $this->createMock(IShare::class); + $user1 = $this->createUser('user1', ''); + $user2 = $this->createUser('user2', ''); + + $this->manager->method('getUsersForShare') + ->willReturn([$user1, $user2]); + + $event = new ShareCreatedEvent($share); + + $this->sharesUpdatedListener->setCutOffMarkTime($cutOff); + $time = 0; + $this->clockFn = function () use (&$time) { + $time++; + return new \DateTimeImmutable('@' . $time); + }; + + $this->shareRecipientUpdater + ->expects($this->exactly($expectedCount)) + ->method('updateForAddedShare'); + + $this->sharesUpdatedListener->handle($event); + + $this->assertEquals($expectedCount < 1, $this->userConfig->getValueBool($user1->getUID(), 'files_sharing', ConfigLexicon::USER_NEEDS_SHARE_REFRESH)); + $this->assertEquals($expectedCount < 2, $this->userConfig->getValueBool($user2->getUID(), 'files_sharing', ConfigLexicon::USER_NEEDS_SHARE_REFRESH)); + } } diff --git a/apps/files_sharing/tests/TestCase.php b/apps/files_sharing/tests/TestCase.php index 9ed3bacc39175..02ee66d096118 100644 --- a/apps/files_sharing/tests/TestCase.php +++ b/apps/files_sharing/tests/TestCase.php @@ -101,8 +101,7 @@ public static function setUpBeforeClass(): void { $groupBackend->addToGroup(self::TEST_FILES_SHARING_API_USER2, self::TEST_FILES_SHARING_API_GROUP1); Server::get(IGroupManager::class)->addBackend($groupBackend); - Server::get(SharesUpdatedListener::class)->setCutOffMarkAllShares(-1); - Server::get(SharesUpdatedListener::class)->setCutOffMarkAllSingleShare(-1); + Server::get(SharesUpdatedListener::class)->setCutOffMarkTime(-1); } protected function setUp(): void { diff --git a/build/integration/features/bootstrap/SharingContext.php b/build/integration/features/bootstrap/SharingContext.php index 2652667f02590..9f70382438467 100644 --- a/build/integration/features/bootstrap/SharingContext.php +++ b/build/integration/features/bootstrap/SharingContext.php @@ -32,8 +32,7 @@ protected function resetAppConfigs() { $this->deleteServerConfig('core', 'shareapi_allow_federation_on_public_shares'); $this->deleteServerConfig('files_sharing', 'outgoing_server2server_share_enabled'); $this->deleteServerConfig('core', 'shareapi_allow_view_without_download'); - $this->deleteServerConfig('files_sharing', 'update_single_cutoff'); - $this->deleteServerConfig('files_sharing', 'update_all_cutoff'); + $this->deleteServerConfig('files_sharing', 'update_cutoff_time'); $this->runOcc(['config:system:delete', 'share_folder']); } diff --git a/build/integration/sharing_features/sharing-v1-part2.feature b/build/integration/sharing_features/sharing-v1-part2.feature index a2b7682db1d73..36ddcba92d29a 100644 --- a/build/integration/sharing_features/sharing-v1-part2.feature +++ b/build/integration/sharing_features/sharing-v1-part2.feature @@ -49,8 +49,7 @@ Feature: sharing Scenario: getting all shares of a file with a received share after revoking the resharing rights with delayed share check Given user "user0" exists - And parameter "update_single_cutoff" of app "files_sharing" is set to "0" - And parameter "update_all_cutoff" of app "files_sharing" is set to "0" + And parameter "update_cutoff_time" of app "files_sharing" is set to "0" And user "user1" exists And user "user2" exists And file "textfile0.txt" of user "user1" is shared with user "user0" From 4de187a4dfd329b63d67be829473509f6e2e52ad Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 25 Feb 2026 16:28:21 +0100 Subject: [PATCH 14/17] fix: improve performance of handling delete shares Signed-off-by: Robin Appelman --- .../lib/Listener/SharesUpdatedListener.php | 17 ++++++++++------- .../lib/ShareRecipientUpdater.php | 19 +++++++++++++------ .../tests/ShareRecipientUpdaterTest.php | 18 ++++++++++++++++++ .../tests/SharesUpdatedListenerTest.php | 10 +++------- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 880fd9792b8b9..1ab52a069ba38 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -55,11 +55,11 @@ public function __construct( public function handle(Event $event): void { if ($event instanceof UserShareAccessUpdatedEvent) { foreach ($event->getUsers() as $user) { - $this->updateOrMarkUser($user, true); + $this->updateOrMarkUser($user); } } if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent) { - $this->updateOrMarkUser($event->getUser(), true); + $this->updateOrMarkUser($event->getUser()); } if ($event instanceof ShareCreatedEvent || $event instanceof ShareTransferredEvent) { $share = $event->getShare(); @@ -75,8 +75,11 @@ public function handle(Event $event): void { } } if ($event instanceof BeforeShareDeletedEvent) { - foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) { - $this->updateOrMarkUser($user, false, [$event->getShare()]); + $share = $event->getShare(); + foreach ($this->shareManager->getUsersForShare($share) as $user) { + $this->markOrRun($user, function () use ($user, $share) { + $this->shareUpdater->updateForDeletedShare($user, $share); + }); } } } @@ -92,9 +95,9 @@ private function markOrRun(IUser $user, callable $callback): void { $this->updatedTime += $end - $start; } - private function updateOrMarkUser(IUser $user, bool $verifyMountPoints, array $ignoreShares = []): void { - $this->markOrRun($user, function () use ($user, $verifyMountPoints, $ignoreShares) { - $this->shareUpdater->updateForUser($user, $verifyMountPoints, $ignoreShares); + private function updateOrMarkUser(IUser $user): void { + $this->markOrRun($user, function () use ($user) { + $this->shareUpdater->updateForUser($user); }); } diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php index 996c051749ac1..83cf681344cab 100644 --- a/apps/files_sharing/lib/ShareRecipientUpdater.php +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -28,7 +28,7 @@ public function __construct( /** * Validate all received shares for a user */ - public function updateForUser(IUser $user, bool $verifyMountPoints = true, array $ignoreShares = []): void { + public function updateForUser(IUser $user): void { // prevent recursion if (isset($this->inUpdate[$user->getUID()])) { return; @@ -40,20 +40,18 @@ public function updateForUser(IUser $user, bool $verifyMountPoints = true, array $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); $mountsByPath = array_combine($mountPoints, $cachedMounts); - $shares = $this->shareMountProvider->getSuperSharesForUser($user, $ignoreShares); + $shares = $this->shareMountProvider->getSuperSharesForUser($user); // the share mounts have changed if either the number of shares doesn't matched the number of share mounts // or there is a share for which we don't have a mount yet. $mountsChanged = count($shares) !== count($shareMounts); - foreach ($shares as &$share) { + foreach ($shares as $share) { [$parentShare, $groupedShares] = $share; $mountPoint = '/' . $user->getUID() . '/files/' . trim($parentShare->getTarget(), '/') . '/'; $mountKey = $parentShare->getNodeId() . '::' . $mountPoint; if (!isset($cachedMounts[$mountKey])) { $mountsChanged = true; - if ($verifyMountPoints) { - $this->shareTargetValidator->verifyMountPoint($user, $parentShare, $mountsByPath, $groupedShares); - } + $this->shareTargetValidator->verifyMountPoint($user, $parentShare, $mountsByPath, $groupedShares); } } @@ -78,4 +76,13 @@ public function updateForAddedShare(IUser $user, IShare $share): void { $this->userMountCache->addMount($user, $mountPoint, $share->getNode()->getData(), MountProvider::class); } + + /** + * Process a single deleted share for a user + */ + public function updateForDeletedShare(IUser $user, IShare $share): void { + $mountPoint = '/' . $user->getUID() . '/files/' . trim($share->getTarget(), '/') . '/'; + + $this->userMountCache->removeMount($mountPoint); + } } diff --git a/apps/files_sharing/tests/ShareRecipientUpdaterTest.php b/apps/files_sharing/tests/ShareRecipientUpdaterTest.php index 5ad995c3b1c58..2316e6b8b7e56 100644 --- a/apps/files_sharing/tests/ShareRecipientUpdaterTest.php +++ b/apps/files_sharing/tests/ShareRecipientUpdaterTest.php @@ -185,4 +185,22 @@ public function testUpdateForUserRemoved() { $this->updater->updateForUser($user1); } + + public function testDeletedShare() { + $share = $this->createMock(IShare::class); + $share->method('getTarget') + ->willReturn('/target'); + $share->method('getNodeId') + ->willReturn(111); + $user1 = $this->createUser('user1', ''); + + $this->shareTargetValidator->expects($this->never()) + ->method('verifyMountPoint'); + + $this->userMountCache->expects($this->exactly(1)) + ->method('removeMount') + ->with('/user1/files/target/'); + + $this->updater->updateForDeletedShare($user1, $share); + } } diff --git a/apps/files_sharing/tests/SharesUpdatedListenerTest.php b/apps/files_sharing/tests/SharesUpdatedListenerTest.php index ccba9d41c2e4b..a6ec4ace499bf 100644 --- a/apps/files_sharing/tests/SharesUpdatedListenerTest.php +++ b/apps/files_sharing/tests/SharesUpdatedListenerTest.php @@ -116,10 +116,8 @@ public function testShareAccessUpdated() { $this->shareRecipientUpdater ->expects($this->exactly(2)) ->method('updateForUser') - ->willReturnCallback(function (IUser $user, bool $verifyMountPoints = true, array $ignoreShares = []) use ($user1, $user2) { + ->willReturnCallback(function (IUser $user) use ($user1, $user2) { $this->assertContains($user, [$user1, $user2]); - $this->assertEquals(true, $verifyMountPoints); - $this->assertEquals([], $ignoreShares); }); $this->sharesUpdatedListener->handle($event); @@ -137,11 +135,9 @@ public function testShareDeleted() { $this->shareRecipientUpdater ->expects($this->exactly(2)) - ->method('updateForUser') - ->willReturnCallback(function (IUser $user, bool $verifyMountPoints = true, array $ignoreShares = []) use ($user1, $user2, $share) { + ->method('updateForDeletedShare') + ->willReturnCallback(function (IUser $user) use ($user1, $user2, $share) { $this->assertContains($user, [$user1, $user2]); - $this->assertEquals(false, $verifyMountPoints); - $this->assertEquals([$share], $ignoreShares); }); $this->sharesUpdatedListener->handle($event); From 5fecb00a2dfe53e6a5709fc56b2beca4779ffa49 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 25 Feb 2026 18:19:58 +0100 Subject: [PATCH 15/17] feat: add output options and '--cached-only' to list mounts command Signed-off-by: Robin Appelman --- apps/files/lib/Command/Mount/ListMounts.php | 82 ++++++++++++++------- 1 file changed, 56 insertions(+), 26 deletions(-) diff --git a/apps/files/lib/Command/Mount/ListMounts.php b/apps/files/lib/Command/Mount/ListMounts.php index b4abeac5ab8af..487e769ad2c94 100644 --- a/apps/files/lib/Command/Mount/ListMounts.php +++ b/apps/files/lib/Command/Mount/ListMounts.php @@ -8,17 +8,18 @@ namespace OCA\Files\Command\Mount; +use OC\Core\Command\Base; use OCP\Files\Config\ICachedMountInfo; use OCP\Files\Config\IMountProviderCollection; use OCP\Files\Config\IUserMountCache; use OCP\Files\Mount\IMountPoint; use OCP\IUserManager; -use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class ListMounts extends Command { +class ListMounts extends Base { public function __construct( private readonly IUserManager $userManager, private readonly IUserMountCache $userMountCache, @@ -28,52 +29,81 @@ public function __construct( } protected function configure(): void { + parent::configure(); $this ->setName('files:mount:list') ->setDescription('List of mounts for a user') - ->addArgument('user', InputArgument::REQUIRED, 'User to list mounts for'); + ->addArgument('user', InputArgument::REQUIRED, 'User to list mounts for') + ->addOption('cached-only', null, InputOption::VALUE_NONE, 'Only return cached mounts, prevents filesystem setup'); } public function execute(InputInterface $input, OutputInterface $output): int { $userId = $input->getArgument('user'); + $cachedOnly = $input->getOption('cached-only'); $user = $this->userManager->get($userId); if (!$user) { $output->writeln("User $userId not found"); return 1; } - $mounts = $this->mountProviderCollection->getMountsForUser($user); - $mounts[] = $this->mountProviderCollection->getHomeMountForUser($user); - /** @var array $cachedByMountpoint */ - $mountsByMountpoint = array_combine(array_map(fn (IMountPoint $mount) => $mount->getMountPoint(), $mounts), $mounts); + if ($cachedOnly) { + $mounts = []; + } else { + $mounts = $this->mountProviderCollection->getMountsForUser($user); + $mounts[] = $this->mountProviderCollection->getHomeMountForUser($user); + } + /** @var array $cachedByMountPoint */ + $mountsByMountPoint = array_combine(array_map(fn (IMountPoint $mount) => $mount->getMountPoint(), $mounts), $mounts); usort($mounts, fn (IMountPoint $a, IMountPoint $b) => $a->getMountPoint() <=> $b->getMountPoint()); $cachedMounts = $this->userMountCache->getMountsForUser($user); usort($cachedMounts, fn (ICachedMountInfo $a, ICachedMountInfo $b) => $a->getMountPoint() <=> $b->getMountPoint()); /** @var array $cachedByMountpoint */ - $cachedByMountpoint = array_combine(array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts), $cachedMounts); + $cachedByMountPoint = array_combine(array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts), $cachedMounts); + + $format = $input->getOption('output'); - foreach ($mounts as $mount) { - $output->writeln('' . $mount->getMountPoint() . ': ' . $mount->getStorageId()); - if (isset($cachedByMountpoint[$mount->getMountPoint()])) { - $cached = $cachedByMountpoint[$mount->getMountPoint()]; - $output->writeln("\t- provider: " . $cached->getMountProvider()); - $output->writeln("\t- storage id: " . $cached->getStorageId()); - $output->writeln("\t- root id: " . $cached->getRootId()); - } else { - $output->writeln("\tnot registered"); + if ($format === self::OUTPUT_FORMAT_PLAIN) { + foreach ($mounts as $mount) { + $output->writeln('' . $mount->getMountPoint() . ': ' . $mount->getStorageId()); + if (isset($cachedByMountPoint[$mount->getMountPoint()])) { + $cached = $cachedByMountPoint[$mount->getMountPoint()]; + $output->writeln("\t- provider: " . $cached->getMountProvider()); + $output->writeln("\t- storage id: " . $cached->getStorageId()); + $output->writeln("\t- root id: " . $cached->getRootId()); + } else { + $output->writeln("\tnot registered"); + } } - } - foreach ($cachedMounts as $cachedMount) { - if (!isset($mountsByMountpoint[$cachedMount->getMountPoint()])) { - $output->writeln('' . $cachedMount->getMountPoint() . ':'); - $output->writeln("\tregistered but no longer provided"); - $output->writeln("\t- provider: " . $cachedMount->getMountProvider()); - $output->writeln("\t- storage id: " . $cachedMount->getStorageId()); - $output->writeln("\t- root id: " . $cachedMount->getRootId()); + foreach ($cachedMounts as $cachedMount) { + if ($cachedOnly || !isset($mountsByMountPoint[$cachedMount->getMountPoint()])) { + $output->writeln('' . $cachedMount->getMountPoint() . ':'); + if (!$cachedOnly) { + $output->writeln("\tregistered but no longer provided"); + } + $output->writeln("\t- provider: " . $cachedMount->getMountProvider()); + $output->writeln("\t- storage id: " . $cachedMount->getStorageId()); + $output->writeln("\t- root id: " . $cachedMount->getRootId()); + } } + } else { + $cached = array_map(fn (ICachedMountInfo $cachedMountInfo) => [ + 'mountpoint' => $cachedMountInfo->getMountPoint(), + 'provider' => $cachedMountInfo->getMountProvider(), + 'storage_id' => $cachedMountInfo->getStorageId(), + 'root_id' => $cachedMountInfo->getRootId(), + ], $cachedMounts); + $provided = array_map(fn (IMountPoint $cachedMountInfo) => [ + 'mountpoint' => $cachedMountInfo->getMountPoint(), + 'provider' => $cachedMountInfo->getMountProvider(), + 'storage_id' => $cachedMountInfo->getStorageId(), + 'root_id' => $cachedMountInfo->getStorageRootId(), + ], $mounts); + $this->writeArrayInOutputFormat($input, $output, array_filter([ + 'cached' => $cached, + 'provided' => $cachedOnly ? null : $provided, + ])); } - return 0; } From 207fc973e95efac2688860021362c2fc7c4cd9d6 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 25 Feb 2026 19:51:27 +0100 Subject: [PATCH 16/17] fix: update shares on group delete Signed-off-by: Robin Appelman --- .../files_sharing/lib/AppInfo/Application.php | 3 ++ .../lib/Listener/SharesUpdatedListener.php | 15 ++++++- .../features/bootstrap/Sharing.php | 40 ++++++++++++++++--- .../integration/features/bootstrap/WebDav.php | 2 +- .../sharing_features/sharing-v1-part4.feature | 32 +++++++++++++++ 5 files changed, 85 insertions(+), 7 deletions(-) diff --git a/apps/files_sharing/lib/AppInfo/Application.php b/apps/files_sharing/lib/AppInfo/Application.php index 3af5f5265035b..370d8fe8fa7c7 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -50,6 +50,7 @@ use OCP\Files\Events\BeforeZipCreatedEvent; use OCP\Files\Events\Node\BeforeNodeReadEvent; use OCP\Files\Events\UserHomeSetupEvent; +use OCP\Group\Events\BeforeGroupDeletedEvent; use OCP\Group\Events\GroupChangedEvent; use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserAddedEvent; @@ -122,6 +123,8 @@ function () use ($c) { $context->registerEventListener(ShareTransferredEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserAddedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserRemovedEvent::class, SharesUpdatedListener::class); + $context->registerEventListener(BeforeGroupDeletedEvent::class, SharesUpdatedListener::class); + $context->registerEventListener(GroupDeletedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserShareAccessUpdatedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserHomeSetupEvent::class, UserHomeSetupListener::class); diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 1ab52a069ba38..e17e392c906d7 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -15,6 +15,8 @@ use OCP\Config\IUserConfig; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; +use OCP\Group\Events\BeforeGroupDeletedEvent; +use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserAddedEvent; use OCP\Group\Events\UserRemovedEvent; use OCP\IAppConfig; @@ -28,7 +30,8 @@ /** * Listen to various events that can change what shares a user has access to * - * @template-implements IEventListener + * @psalm-type GroupEvents = UserAddedEvent|UserRemovedEvent|GroupDeletedEvent|BeforeGroupDeletedEvent + * @template-implements IEventListener */ class SharesUpdatedListener implements IEventListener { /** @@ -58,6 +61,16 @@ public function handle(Event $event): void { $this->updateOrMarkUser($user); } } + if ($event instanceof BeforeGroupDeletedEvent) { + // ensure the group users are loaded before the group is deleted + $event->getGroup()->getUsers(); + } + if ($event instanceof GroupDeletedEvent) { + // so we can iterate them after the group is deleted + foreach ($event->getGroup()->getUsers() as $user) { + $this->updateOrMarkUser($user); + } + } if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent) { $this->updateOrMarkUser($event->getUser()); } diff --git a/build/integration/features/bootstrap/Sharing.php b/build/integration/features/bootstrap/Sharing.php index 72f6902af167e..5fe98aff35d1e 100644 --- a/build/integration/features/bootstrap/Sharing.php +++ b/build/integration/features/bootstrap/Sharing.php @@ -5,6 +5,7 @@ * SPDX-FileCopyrightText: 2016 ownCloud, Inc. * SPDX-License-Identifier: AGPL-3.0-or-later */ + use Behat\Gherkin\Node\TableNode; use GuzzleHttp\Client; use PHPUnit\Framework\Assert; @@ -13,7 +14,6 @@ require __DIR__ . '/autoload.php'; - trait Sharing { use Provisioning; @@ -566,17 +566,17 @@ public function shareXIsReturnedWith(int $number, TableNode $body) { $expectedFields = array_merge($defaultExpectedFields, $body->getRowsHash()); if (!array_key_exists('uid_file_owner', $expectedFields) - && array_key_exists('uid_owner', $expectedFields)) { + && array_key_exists('uid_owner', $expectedFields)) { $expectedFields['uid_file_owner'] = $expectedFields['uid_owner']; } if (!array_key_exists('displayname_file_owner', $expectedFields) - && array_key_exists('displayname_owner', $expectedFields)) { + && array_key_exists('displayname_owner', $expectedFields)) { $expectedFields['displayname_file_owner'] = $expectedFields['displayname_owner']; } if (array_key_exists('share_type', $expectedFields) - && $expectedFields['share_type'] == 10 /* IShare::TYPE_ROOM */ - && array_key_exists('share_with', $expectedFields)) { + && $expectedFields['share_type'] == 10 /* IShare::TYPE_ROOM */ + && array_key_exists('share_with', $expectedFields)) { if ($expectedFields['share_with'] === 'private_conversation') { $expectedFields['share_with'] = 'REGEXP /^private_conversation_[0-9a-f]{6}$/'; } else { @@ -782,4 +782,34 @@ public function getArrayOfShareesResponded(ResponseInterface $response, $shareeT } return $sharees; } + + /** + * @Then /^Share mounts for "([^"]*)" match$/ + */ + public function checkShareMounts(string $user, ?TableNode $body) { + if ($body instanceof TableNode) { + $fd = $body->getRows(); + + $expected = []; + foreach ($fd as $row) { + $expected[] = $row[0]; + } + $this->runOcc(['files:mount:list', '--output', 'json', '--cached-only', $user]); + $mounts = json_decode($this->lastStdOut, true)['cached']; + $shareMounts = array_filter($mounts, fn (array $data) => $data['provider'] === \OCA\Files_Sharing\MountProvider::class); + $actual = array_values(array_map(fn (array $data) => $data['mountpoint'], $shareMounts)); + Assert::assertEquals($expected, $actual); + } + } + + /** + * @Then /^Share mounts for "([^"]*)" are empty$/ + */ + public function checkShareMountsEmpty(string $user) { + $this->runOcc(['files:mount:list', '--output', 'json', '--cached-only', $user]); + $mounts = json_decode($this->lastStdOut, true)['cached']; + $shareMounts = array_filter($mounts, fn (array $data) => $data['provider'] === \OCA\Files_Sharing\MountProvider::class); + $actual = array_values(array_map(fn (array $data) => $data['mountpoint'], $shareMounts)); + Assert::assertEquals([], $actual); + } } diff --git a/build/integration/features/bootstrap/WebDav.php b/build/integration/features/bootstrap/WebDav.php index fb552ce785b75..fb2e441d93791 100644 --- a/build/integration/features/bootstrap/WebDav.php +++ b/build/integration/features/bootstrap/WebDav.php @@ -1011,7 +1011,7 @@ public function checkIfETAGHasChanged($path, $user) { */ public function connectingToDavEndpoint() { try { - $this->response = $this->makeDavRequest(null, 'PROPFIND', '', []); + $this->response = $this->makeDavRequest($this->currentUser, 'PROPFIND', '', []); } catch (\GuzzleHttp\Exception\ClientException $e) { $this->response = $e->getResponse(); } diff --git a/build/integration/sharing_features/sharing-v1-part4.feature b/build/integration/sharing_features/sharing-v1-part4.feature index 3b825aebd1813..48f2e2e3b123f 100644 --- a/build/integration/sharing_features/sharing-v1-part4.feature +++ b/build/integration/sharing_features/sharing-v1-part4.feature @@ -315,3 +315,35 @@ Scenario: Can copy file between shares if share permissions And the OCS status code should be "100" When User "user1" copies file "/share/test.txt" to "/re-share/movetest.txt" Then the HTTP status code should be "201" + +Scenario: Group deletes removes mount without marking + Given As an "admin" + And user "user0" exists + And user "user1" exists + And group "group0" exists + And user "user0" belongs to group "group0" + And file "textfile0.txt" of user "user1" is shared with group "group0" + And As an "user0" + Then Share mounts for "user0" match + | /user0/files/textfile0 (2).txt/ | + And group "group0" does not exist + Then Share mounts for "user0" are empty + +Scenario: Group deletes removes mount with marking + Given As an "admin" + And parameter "update_cutoff_time" of app "files_sharing" is set to "0" + And user "user0" exists + And user "user1" exists + And group "group0" exists + And user "user0" belongs to group "group0" + And file "textfile0.txt" of user "user1" is shared with group "group0" + And As an "user0" + Then Share mounts for "user0" are empty + When Connecting to dav endpoint + Then Share mounts for "user0" match + | /user0/files/textfile0 (2).txt/ | + And group "group0" does not exist + Then Share mounts for "user0" match + | /user0/files/textfile0 (2).txt/ | + When Connecting to dav endpoint + Then Share mounts for "user0" are empty From 0a8ad13ccabf295d7fb98d780028ea8277fdc6e7 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 25 Feb 2026 20:11:29 +0100 Subject: [PATCH 17/17] test: add more integration tests for share mount handling Signed-off-by: Robin Appelman --- .../sharing_features/sharing-v1-part4.feature | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/build/integration/sharing_features/sharing-v1-part4.feature b/build/integration/sharing_features/sharing-v1-part4.feature index 48f2e2e3b123f..746ac93d6a36d 100644 --- a/build/integration/sharing_features/sharing-v1-part4.feature +++ b/build/integration/sharing_features/sharing-v1-part4.feature @@ -347,3 +347,70 @@ Scenario: Group deletes removes mount with marking | /user0/files/textfile0 (2).txt/ | When Connecting to dav endpoint Then Share mounts for "user0" are empty + +Scenario: User share mount without marking + Given As an "admin" + And user "user0" exists + And user "user1" exists + And file "textfile0.txt" of user "user1" is shared with user "user0" + And As an "user0" + Then Share mounts for "user0" match + | /user0/files/textfile0 (2).txt/ | + When Deleting last share + Then Share mounts for "user0" are empty + +Scenario: User share mount with marking + Given As an "admin" + And parameter "update_cutoff_time" of app "files_sharing" is set to "0" + And user "user0" exists + And user "user1" exists + And file "textfile0.txt" of user "user1" is shared with user "user0" + And As an "user0" + Then Share mounts for "user0" are empty + When Connecting to dav endpoint + Then Share mounts for "user0" match + | /user0/files/textfile0 (2).txt/ | + When Deleting last share + Then Share mounts for "user0" match + | /user0/files/textfile0 (2).txt/ | + When Connecting to dav endpoint + Then Share mounts for "user0" are empty + +Scenario: User added/removed to group share without marking + Given As an "admin" + And user "user0" exists + And user "user1" exists + And group "group0" exists + And file "textfile0.txt" of user "user1" is shared with group "group0" + And As an "user0" + Then Share mounts for "user0" are empty + When user "user0" belongs to group "group0" + Then Share mounts for "user0" match + | /user0/files/textfile0 (2).txt/ | + When As an "admin" + Then sending "DELETE" to "/cloud/users/user0/groups" with + | groupid | group0 | + Then As an "user0" + And Share mounts for "user0" are empty + +Scenario: User added/removed to group share with marking + Given As an "admin" + And parameter "update_cutoff_time" of app "files_sharing" is set to "0" + And user "user0" exists + And user "user1" exists + And group "group0" exists + And file "textfile0.txt" of user "user1" is shared with group "group0" + And As an "user0" + When user "user0" belongs to group "group0" + Then Share mounts for "user0" are empty + When Connecting to dav endpoint + Then Share mounts for "user0" match + | /user0/files/textfile0 (2).txt/ | + When As an "admin" + Then sending "DELETE" to "/cloud/users/user0/groups" with + | groupid | group0 | + Then As an "user0" + And Share mounts for "user0" match + | /user0/files/textfile0 (2).txt/ | + When Connecting to dav endpoint + Then Share mounts for "user0" are empty