From 491fb8d483547e49a124777797368e32885387f7 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 23 Jan 2026 16:01:24 +0100 Subject: [PATCH 01/35] 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 2c0f412b735cd..81e56345e3ba0 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\Constants; use OCP\Files\Cache\ICacheEntry; @@ -209,8 +209,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 4e6975cc11874e71434d3a28c913aaa432d6ec13 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 12 Feb 2026 15:19:18 +0100 Subject: [PATCH 02/35] 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 44d9dd2c6a998..736624f93c7db 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -470,6 +470,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 36080f5fac4bc..4caffadca611b 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -511,6 +511,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 ce6e479f6522f..a8c46a20bd31a 100644 --- a/lib/private/Files/SetupManager.php +++ b/lib/private/Files/SetupManager.php @@ -42,6 +42,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; @@ -336,6 +337,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 c1a56cfb99d8386a895943ee0ef4e6034ea765a3 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 12 Feb 2026 15:34:50 +0100 Subject: [PATCH 03/35] 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 5962eab0d5f50..ae5e76f5e00d4 100644 --- a/apps/files_sharing/composer/composer/autoload_classmap.php +++ b/apps/files_sharing/composer/composer/autoload_classmap.php @@ -96,6 +96,7 @@ 'OCA\\Files_Sharing\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php', 'OCA\\Files_Sharing\\Scanner' => $baseDir . '/../lib/Scanner.php', 'OCA\\Files_Sharing\\Settings\\Personal' => $baseDir . '/../lib/Settings/Personal.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 51895e2be730a..8ef3bcbcb6ff1 100644 --- a/apps/files_sharing/composer/composer/autoload_static.php +++ b/apps/files_sharing/composer/composer/autoload_static.php @@ -111,6 +111,7 @@ class ComposerStaticInitFiles_Sharing 'OCA\\Files_Sharing\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', 'OCA\\Files_Sharing\\Scanner' => __DIR__ . '/..' . '/../lib/Scanner.php', 'OCA\\Files_Sharing\\Settings\\Personal' => __DIR__ . '/..' . '/../lib/Settings/Personal.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 6f253302aef44e68a40843e2c7662122d99b54a3 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 12 Feb 2026 15:42:16 +0100 Subject: [PATCH 04/35] 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 ae5e76f5e00d4..2d6b545a17a63 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 8ef3bcbcb6ff1..1fc2ddd2dfd8a 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 d50aaa71e8f2b..770ac66957927 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -26,6 +26,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; @@ -45,6 +46,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; @@ -120,6 +122,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 94226341854763ec37037d5353fca264892f133e Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 17 Feb 2026 00:04:17 +0100 Subject: [PATCH 05/35] 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 36c76bf824462..017c1486fcde3 100644 --- a/build/integration/features/bootstrap/SharingContext.php +++ b/build/integration/features/bootstrap/SharingContext.php @@ -34,6 +34,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 fb391878b9b45..ba5cd18f6554f 100644 --- a/build/integration/sharing_features/sharing-v1-part2.feature +++ b/build/integration/sharing_features/sharing-v1-part2.feature @@ -77,6 +77,50 @@ Scenario: getting all shares of a file with reshares with link share with less p | 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 8de080580bac4ec435632f573b093f14f2bb3d03 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 17 Feb 2026 17:12:54 +0100 Subject: [PATCH 06/35] 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 68448a4a427f8161268bf94fbfa1020e73701975 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 17 Feb 2026 18:19:30 +0100 Subject: [PATCH 07/35] 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 120d08bcd7c58..47fc5e9d73593 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; @@ -173,4 +174,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 3be4a31b8b5bf..1bc663f76b6ae 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; @@ -570,6 +571,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 40bd0626542c7..ca042dacf91bd 100644 --- a/lib/private/Files/Node/Node.php +++ b/lib/private/Files/Node/Node.php @@ -14,6 +14,7 @@ use OCP\Constants; 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; @@ -485,4 +486,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 942e4f51c31dd..76b2083085daf 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; /** @@ -308,4 +309,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 0da86facc53759a289b52a4b7a21404b983e7a47 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 18 Feb 2026 16:57:11 +0100 Subject: [PATCH 08/35] fix: clear in-memory cached mounts for user when adding/removing mounts Signed-off-by: Robin Appelman --- lib/private/Files/Config/UserMountCache.php | 36 +++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/private/Files/Config/UserMountCache.php b/lib/private/Files/Config/UserMountCache.php index 65495434ad6b1..3225afd2bc4d5 100644 --- a/lib/private/Files/Config/UserMountCache.php +++ b/lib/private/Files/Config/UserMountCache.php @@ -7,6 +7,7 @@ */ namespace OC\Files\Config; +use OC\DB\Exceptions\DbalException; use OC\User\LazyUser; use OCP\Cache\CappedMemoryCache; use OCP\DB\QueryBuilder\IQueryBuilder; @@ -524,18 +525,35 @@ public function removeMount(string $mountPoint): void { $query->delete('mounts') ->where($query->expr()->eq('mount_point_hash', $query->createNamedParameter(hash('xxh128', $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 { - $this->connection->insertIgnoreConflict('mounts', [ - 'storage_id' => $rootCacheEntry->getStorageId(), - 'root_id' => $rootCacheEntry->getId(), - 'user_id' => $user->getUID(), - 'mount_point' => $mountPoint, - 'mount_point_hash' => hash('xxh128', $mountPoint), - 'mount_id' => $mountId, - 'mount_provider_class' => $mountProvider - ]); + $query = $this->connection->getQueryBuilder(); + $query->insert('mounts') + ->values([ + 'storage_id' => $query->createNamedParameter($rootCacheEntry->getStorageId()), + 'root_id' => $query->createNamedParameter($rootCacheEntry->getId()), + 'user_id' => $query->createNamedParameter($user->getUID()), + 'mount_point' => $query->createNamedParameter($mountPoint), + 'mount_point_hash' => $query->createNamedParameter(hash('xxh128', $mountPoint)), + 'mount_id' => $query->createNamedParameter($mountId), + 'mount_provider_class' => $query->createNamedParameter($mountProvider) + ]); + + try { + $query->executeStatement(); + unset($this->mountsForUsers[$user->getUID()]); + } catch (DbalException $e) { + if ($e->getReason() !== DbalException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } + } } /** From 3a50ee887bcc0804efbbbbec1660d20ad944f22f Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 19 Feb 2026 17:17:05 +0100 Subject: [PATCH 09/35] 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 1b84a8555810b9e157096e2fca47c800a2715bf2 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 19 Feb 2026 17:28:24 +0100 Subject: [PATCH 10/35] 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 8680d4446b35d47affa7e135a875efe6a1d15a1e Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 19 Feb 2026 18:01:29 +0100 Subject: [PATCH 11/35] 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 b57959d5d2e5949f9ece74ef915e1151273b584c Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 19 Feb 2026 18:31:47 +0100 Subject: [PATCH 12/35] 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 9c399ba4ac1d94098bc4437705fcc1734c8e2f68 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 20 Feb 2026 16:19:28 +0100 Subject: [PATCH 13/35] 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..1ec2277f9fc24 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, 'Maximum time in second during which 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 017c1486fcde3..3d13c0ae3f565 100644 --- a/build/integration/features/bootstrap/SharingContext.php +++ b/build/integration/features/bootstrap/SharingContext.php @@ -34,8 +34,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 ba5cd18f6554f..02bb84750f278 100644 --- a/build/integration/sharing_features/sharing-v1-part2.feature +++ b/build/integration/sharing_features/sharing-v1-part2.feature @@ -79,8 +79,7 @@ Scenario: getting all shares of a file with reshares with link share with less p 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 9853bc880d68ca5034f93eee3fcde48abcb2fb8e Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 25 Feb 2026 16:28:21 +0100 Subject: [PATCH 14/35] 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 1a3da809322fae4d3132d2d6e7cf8820174d8fe9 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 25 Feb 2026 18:19:58 +0100 Subject: [PATCH 15/35] 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 7b3457003c4f3a235f8a510d6ee42418557d6142 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 25 Feb 2026 19:51:27 +0100 Subject: [PATCH 16/35] fix: update shares on group delete Signed-off-by: Robin Appelman --- .../files_sharing/lib/AppInfo/Application.php | 3 ++ .../lib/Listener/SharesUpdatedListener.php | 16 +++++++- .../features/bootstrap/Sharing.php | 40 ++++++++++++++++--- .../integration/features/bootstrap/WebDav.php | 2 +- .../sharing_features/sharing-v1-part4.feature | 32 +++++++++++++++ 5 files changed, 86 insertions(+), 7 deletions(-) diff --git a/apps/files_sharing/lib/AppInfo/Application.php b/apps/files_sharing/lib/AppInfo/Application.php index 770ac66957927..faef075d4e73d 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -47,6 +47,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; @@ -121,6 +122,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..163a1aee22992 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,17 @@ public function handle(Event $event): void { $this->updateOrMarkUser($user); } } + if ($event instanceof BeforeGroupDeletedEvent) { + // ensure the group users are loaded before the group is deleted + // `IGroup::getUsers` does internal caching, so we don't need to store this separately + $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 5c581c43c7227..4aca7ae697c2d 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; @@ -575,17 +575,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 { @@ -791,4 +791,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 adb8ed914f91a4f40e659a5a81013c4b847f8109 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 25 Feb 2026 20:11:29 +0100 Subject: [PATCH 17/35] 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 From e7fcb6c7f503d1593f4e378c3a9e0bc44670db37 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 12 Mar 2026 19:15:39 +0100 Subject: [PATCH 18/35] fix: handle share moves Signed-off-by: Robin Appelman --- .../files_sharing/lib/AppInfo/Application.php | 2 ++ .../lib/Listener/SharesUpdatedListener.php | 11 ++++++- .../lib/ShareRecipientUpdater.php | 25 +++++++++++--- .../sharing_features/sharing-v1-part4.feature | 12 +++++++ lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + lib/private/Share20/Manager.php | 7 +++- lib/private/Share20/Share.php | 13 ++++++++ lib/public/Share/Events/ShareMovedEvent.php | 33 +++++++++++++++++++ lib/public/Share/IShare.php | 7 ++++ 10 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 lib/public/Share/Events/ShareMovedEvent.php diff --git a/apps/files_sharing/lib/AppInfo/Application.php b/apps/files_sharing/lib/AppInfo/Application.php index faef075d4e73d..8f5c4c4c4617a 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -57,6 +57,7 @@ use OCP\IGroup; use OCP\Share\Events\BeforeShareDeletedEvent; use OCP\Share\Events\ShareCreatedEvent; +use OCP\Share\Events\ShareMovedEvent; use OCP\Share\Events\ShareTransferredEvent; use OCP\User\Events\UserChangedEvent; use OCP\User\Events\UserDeletedEvent; @@ -125,6 +126,7 @@ function () use ($c) { $context->registerEventListener(BeforeGroupDeletedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(GroupDeletedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserShareAccessUpdatedEvent::class, SharesUpdatedListener::class); + $context->registerEventListener(ShareMovedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserHomeSetupEvent::class, UserHomeSetupListener::class); $context->registerConfigLexicon(ConfigLexicon::class); diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 163a1aee22992..92a1163df8fe4 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -23,6 +23,7 @@ use OCP\IUser; use OCP\Share\Events\BeforeShareDeletedEvent; use OCP\Share\Events\ShareCreatedEvent; +use OCP\Share\Events\ShareMovedEvent; use OCP\Share\Events\ShareTransferredEvent; use OCP\Share\IManager; use Psr\Clock\ClockInterface; @@ -31,7 +32,7 @@ * Listen to various events that can change what shares a user has access to * * @psalm-type GroupEvents = UserAddedEvent|UserRemovedEvent|GroupDeletedEvent|BeforeGroupDeletedEvent - * @template-implements IEventListener + * @template-implements IEventListener */ class SharesUpdatedListener implements IEventListener { /** @@ -88,6 +89,14 @@ public function handle(Event $event): void { } } } + if ($event instanceof ShareMovedEvent) { + $share = $event->getShare(); + foreach ($this->shareManager->getUsersForShare($share) as $user) { + $this->markOrRun($user, function () use ($user, $share) { + $this->shareUpdater->updateForMovedShare($user, $share); + }); + } + } if ($event instanceof BeforeShareDeletedEvent) { $share = $event->getShare(); foreach ($this->shareManager->getUsersForShare($share) as $user) { diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php index 83cf681344cab..62033b7dd0ab2 100644 --- a/apps/files_sharing/lib/ShareRecipientUpdater.php +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -47,7 +47,7 @@ public function updateForUser(IUser $user): void { $mountsChanged = count($shares) !== count($shareMounts); foreach ($shares as $share) { [$parentShare, $groupedShares] = $share; - $mountPoint = '/' . $user->getUID() . '/files/' . trim($parentShare->getTarget(), '/') . '/'; + $mountPoint = $this->getMountPointFromTarget($user, $parentShare->getTarget()); $mountKey = $parentShare->getNodeId() . '::' . $mountPoint; if (!isset($cachedMounts[$mountKey])) { $mountsChanged = true; @@ -72,17 +72,34 @@ public function updateForAddedShare(IUser $user, IShare $share): void { $mountsByPath = array_combine($mountPoints, $cachedMounts); $target = $this->shareTargetValidator->verifyMountPoint($user, $share, $mountsByPath, [$share]); - $mountPoint = '/' . $user->getUID() . '/files/' . trim($target, '/') . '/'; + $mountPoint = $this->getMountPointFromTarget($user, $target); $this->userMountCache->addMount($user, $mountPoint, $share->getNode()->getData(), MountProvider::class); } + private function getMountPointFromTarget(IUser $user, string $target): string { + return '/' . $user->getUID() . '/files/' . trim($target, '/') . '/'; + } + /** * 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($this->getMountPointFromTarget($user, $share->getTarget())); + } - $this->userMountCache->removeMount($mountPoint); + /** + * Process a single moved share for a user + */ + public function updateForMovedShare(IUser $user, IShare $share): void { + $originalTarget = $share->getOriginalTarget(); + if ($originalTarget != null) { + $newMountPoint = $this->getMountPointFromTarget($user, $share->getTarget()); + $oldMountPoint = $this->getMountPointFromTarget($user, $originalTarget); + $this->userMountCache->removeMount($oldMountPoint); + $this->userMountCache->addMount($user, $newMountPoint, $share->getNode()->getData(), MountProvider::class); + } else { + $this->updateForUser($user); + } } } diff --git a/build/integration/sharing_features/sharing-v1-part4.feature b/build/integration/sharing_features/sharing-v1-part4.feature index 746ac93d6a36d..5e710d7f28647 100644 --- a/build/integration/sharing_features/sharing-v1-part4.feature +++ b/build/integration/sharing_features/sharing-v1-part4.feature @@ -414,3 +414,15 @@ Scenario: User added/removed to group share with marking | /user0/files/textfile0 (2).txt/ | When Connecting to dav endpoint Then Share mounts for "user0" are empty + + Scenario: Share moved 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 User "user0" moves file "/textfile0 (2).txt" to "/target.txt" + Then Share mounts for "user0" match + | /user0/files/target.txt/ | diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 736624f93c7db..ca55f66b3cf5b 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -850,6 +850,7 @@ 'OCP\\Share\\Events\\ShareCreatedEvent' => $baseDir . '/lib/public/Share/Events/ShareCreatedEvent.php', 'OCP\\Share\\Events\\ShareDeletedEvent' => $baseDir . '/lib/public/Share/Events/ShareDeletedEvent.php', 'OCP\\Share\\Events\\ShareDeletedFromSelfEvent' => $baseDir . '/lib/public/Share/Events/ShareDeletedFromSelfEvent.php', + 'OCP\\Share\\Events\\ShareMovedEvent' => $baseDir . '/lib/public/Share/Events/ShareMovedEvent.php', 'OCP\\Share\\Events\\ShareTransferredEvent' => $baseDir . '/lib/public/Share/Events/ShareTransferredEvent.php', 'OCP\\Share\\Events\\VerifyMountPointEvent' => $baseDir . '/lib/public/Share/Events/VerifyMountPointEvent.php', 'OCP\\Share\\Exceptions\\AlreadySharedException' => $baseDir . '/lib/public/Share/Exceptions/AlreadySharedException.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 4caffadca611b..0b4375c0a7f57 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -891,6 +891,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Share\\Events\\ShareCreatedEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareCreatedEvent.php', 'OCP\\Share\\Events\\ShareDeletedEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareDeletedEvent.php', 'OCP\\Share\\Events\\ShareDeletedFromSelfEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareDeletedFromSelfEvent.php', + 'OCP\\Share\\Events\\ShareMovedEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareMovedEvent.php', 'OCP\\Share\\Events\\ShareTransferredEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareTransferredEvent.php', 'OCP\\Share\\Events\\VerifyMountPointEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/VerifyMountPointEvent.php', 'OCP\\Share\\Exceptions\\AlreadySharedException' => __DIR__ . '/../../..' . '/lib/public/Share/Exceptions/AlreadySharedException.php', diff --git a/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index 9515a578da9e9..27fb205d5f8ed 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -51,6 +51,7 @@ use OCP\Share\Events\ShareCreatedEvent; use OCP\Share\Events\ShareDeletedEvent; use OCP\Share\Events\ShareDeletedFromSelfEvent; +use OCP\Share\Events\ShareMovedEvent; use OCP\Share\Exceptions\AlreadySharedException; use OCP\Share\Exceptions\GenericShareException; use OCP\Share\Exceptions\ShareNotFound; @@ -1200,7 +1201,11 @@ public function moveShare(IShare $share, string $recipientId): IShare { [$providerId,] = $this->splitFullId($share->getFullId()); $provider = $this->factory->getProvider($providerId); - return $provider->move($share, $recipientId); + $result = $provider->move($share, $recipientId); + + $this->dispatchEvent(new ShareMovedEvent($share), 'share moved'); + + return $result; } #[Override] diff --git a/lib/private/Share20/Share.php b/lib/private/Share20/Share.php index 3c395caec4b6f..3dd57fb35653e 100644 --- a/lib/private/Share20/Share.php +++ b/lib/private/Share20/Share.php @@ -62,6 +62,8 @@ class Share implements IShare { private ?int $parent = null; /** @var string */ private $target; + /** @var string */ + private ?string $originalTarget = null; /** @var \DateTime */ private $shareTime; /** @var bool */ @@ -514,10 +516,21 @@ public function getParent(): ?int { * @inheritdoc */ public function setTarget($target) { + // if the target is changed, save the original target + if ($this->target && !$this->originalTarget) { + $this->originalTarget = $this->target; + } $this->target = $target; return $this; } + /** + * Return the original target, if this share was moved + */ + public function getOriginalTarget(): ?string { + return $this->originalTarget; + } + /** * @inheritdoc */ diff --git a/lib/public/Share/Events/ShareMovedEvent.php b/lib/public/Share/Events/ShareMovedEvent.php new file mode 100644 index 0000000000000..36c020fc0c488 --- /dev/null +++ b/lib/public/Share/Events/ShareMovedEvent.php @@ -0,0 +1,33 @@ +share; + } +} diff --git a/lib/public/Share/IShare.php b/lib/public/Share/IShare.php index da8b9e4868ace..7bd1b8c5ebda3 100644 --- a/lib/public/Share/IShare.php +++ b/lib/public/Share/IShare.php @@ -545,6 +545,13 @@ public function getParent(): ?int; */ public function setTarget($target); + /** + * Return the original target, if this share was moved + * + * @since 33.0.0 + */ + public function getOriginalTarget(): ?string; + /** * Get the target path of this share relative to the recipients user folder. * From e5f77f004ef4e2cbee018f333c6e66d2483c14de Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Mon, 16 Mar 2026 23:07:03 +0100 Subject: [PATCH 19/35] fix: fix moving mountpoints Signed-off-by: Robin Appelman --- lib/private/Files/Mount/Manager.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/private/Files/Mount/Manager.php b/lib/private/Files/Mount/Manager.php index 3d586ba4c5911..9a29752229d3e 100644 --- a/lib/private/Files/Mount/Manager.php +++ b/lib/private/Files/Mount/Manager.php @@ -59,11 +59,14 @@ public function removeMount(string $mountPoint): void { } public function moveMount(string $mountPoint, string $target): void { - $this->mounts[$target] = $this->mounts[$mountPoint]; - unset($this->mounts[$mountPoint]); - $this->pathCache->clear(); - $this->inPathCache->clear(); - $this->areMountsSorted = false; + if ($mountPoint !== $target) { + $this->mounts[$target] = $this->mounts[$mountPoint]; + $this->mounts[$target]->setMountPoint($target); + unset($this->mounts[$mountPoint]); + $this->pathCache->clear(); + $this->inPathCache->clear(); + $this->areMountsSorted = false; + } } /** From 3385cd43428f897adc3ad6d7b2a8506678bccfa8 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Mon, 16 Mar 2026 23:07:15 +0100 Subject: [PATCH 20/35] fix: move mountpoint when transfering share Signed-off-by: Robin Appelman --- .../lib/Service/OwnershipTransferService.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/files/lib/Service/OwnershipTransferService.php b/apps/files/lib/Service/OwnershipTransferService.php index 5ee25e98ea8b6..3864710ba3d88 100644 --- a/apps/files/lib/Service/OwnershipTransferService.php +++ b/apps/files/lib/Service/OwnershipTransferService.php @@ -577,14 +577,16 @@ private function restoreShares( $output->writeln(''); } - private function transferIncomingShares(string $sourceUid, + private function transferIncomingShares( + string $sourceUid, string $destinationUid, array $sourceShares, array $destinationShares, OutputInterface $output, string $path, string $finalTarget, - bool $move): void { + bool $move, + ): void { $output->writeln('Restoring incoming shares ...'); $progress = new ProgressBar($output, count($sourceShares)); $prefix = "$destinationUid/files"; @@ -623,8 +625,11 @@ private function transferIncomingShares(string $sourceUid, if ($move) { continue; } + $oldMountPoint = $this->getShareMountPoint($destinationUid, $share->getTarget()); + $newMountPoint = $this->getShareMountPoint($destinationUid, $shareTarget); $share->setTarget($shareTarget); $this->shareManager->moveShare($share, $destinationUid); + $this->mountManager->moveMount($oldMountPoint, $newMountPoint); continue; } $this->shareManager->deleteShare($share); @@ -642,8 +647,11 @@ private function transferIncomingShares(string $sourceUid, if ($move) { continue; } + $oldMountPoint = $this->getShareMountPoint($destinationUid, $share->getTarget()); + $newMountPoint = $this->getShareMountPoint($destinationUid, $shareTarget); $share->setTarget($shareTarget); $this->shareManager->moveShare($share, $destinationUid); + $this->mountManager->moveMount($oldMountPoint, $newMountPoint); continue; } } catch (NotFoundException $e) { @@ -656,4 +664,8 @@ private function transferIncomingShares(string $sourceUid, $progress->finish(); $output->writeln(''); } + + private function getShareMountPoint(string $uid, string $target): string { + return '/' . $uid . '/files/' . trim($target, '/') . '/'; + } } From f7803905feab868b6b786e6487b26561f3191938 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 27 Mar 2026 18:52:30 +0100 Subject: [PATCH 21/35] fix: default user_needs_share_refresh to true Signed-off-by: Robin Appelman --- apps/files_sharing/lib/Config/ConfigLexicon.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/files_sharing/lib/Config/ConfigLexicon.php b/apps/files_sharing/lib/Config/ConfigLexicon.php index 1ec2277f9fc24..e34010edf79ac 100644 --- a/apps/files_sharing/lib/Config/ConfigLexicon.php +++ b/apps/files_sharing/lib/Config/ConfigLexicon.php @@ -43,7 +43,7 @@ public function getAppConfigs(): array { public function getUserConfigs(): array { 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'), + new Entry(self::USER_NEEDS_SHARE_REFRESH, ValueType::BOOL, true, 'whether a user needs to have the receiving share data refreshed for possible changes'), ]; } } From 940a68649a257811ae6ad736b07488c8b90064f3 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 2 Apr 2026 18:26:02 +0200 Subject: [PATCH 22/35] test: add test for UserHomeSetupListener Signed-off-by: Robin Appelman --- .../lib/Listener/UserHomeSetupListener.php | 2 +- .../Listener/UserHomeSetupListenerTest.php | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 apps/files_sharing/tests/Listener/UserHomeSetupListenerTest.php diff --git a/apps/files_sharing/lib/Listener/UserHomeSetupListener.php b/apps/files_sharing/lib/Listener/UserHomeSetupListener.php index 8886660879fa9..9e1d0ea765b32 100644 --- a/apps/files_sharing/lib/Listener/UserHomeSetupListener.php +++ b/apps/files_sharing/lib/Listener/UserHomeSetupListener.php @@ -35,7 +35,7 @@ public function handle(Event $event): void { } $user = $event->getUser(); - if ($this->userConfig->getValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH)) { + if ($this->userConfig->getValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true)) { $this->updater->updateForUser($user); $this->userConfig->setValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, false); } diff --git a/apps/files_sharing/tests/Listener/UserHomeSetupListenerTest.php b/apps/files_sharing/tests/Listener/UserHomeSetupListenerTest.php new file mode 100644 index 0000000000000..4042b7b8971c8 --- /dev/null +++ b/apps/files_sharing/tests/Listener/UserHomeSetupListenerTest.php @@ -0,0 +1,62 @@ +updater = $this->createMock(ShareRecipientUpdater::class); + $this->userConfig = new MockUserConfig([]); + $this->listener = new UserHomeSetupListener($this->updater, $this->userConfig); + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID') + ->willReturn('test'); + } + + private function getEvent(): UserHomeSetupEvent { + $homeMount = $this->createMock(IMountPoint::class); + return new UserHomeSetupEvent($this->user, $homeMount); + } + + public function testClearNeedsUpdate(): void { + $this->userConfig->setValueBool('test', Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true); + $this->updater->expects($this->once()) + ->method('updateForUser'); + + $this->listener->handle($this->getEvent()); + $this->assertFalse($this->userConfig->getValueBool('test', Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true)); + } + + public function testNoUpdateIfNotNeeded(): void { + $this->userConfig->setValueBool('test', Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, false); + $this->updater->expects($this->never()) + ->method('updateForUser'); + + $this->listener->handle($this->getEvent()); + $this->assertFalse($this->userConfig->getValueBool('test', Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true)); + } +} From a1b4a6b6b77943abf2b32233eec916e045010299 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 2 Apr 2026 18:38:17 +0200 Subject: [PATCH 23/35] fix: log when user is marked as needing share mount refresh Signed-off-by: Robin Appelman --- apps/files_sharing/lib/Listener/SharesUpdatedListener.php | 5 +++++ apps/files_sharing/tests/SharesUpdatedListenerTest.php | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 92a1163df8fe4..06f09d8a0262c 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -27,6 +27,7 @@ use OCP\Share\Events\ShareTransferredEvent; use OCP\Share\IManager; use Psr\Clock\ClockInterface; +use Psr\Log\LoggerInterface; /** * Listen to various events that can change what shares a user has access to @@ -51,6 +52,7 @@ public function __construct( private readonly ShareRecipientUpdater $shareUpdater, private readonly IUserConfig $userConfig, private readonly ClockInterface $clock, + private readonly LoggerInterface $logger, IAppConfig $appConfig, ) { $this->cutOffMarkTime = $appConfig->getValueFloat(Application::APP_ID, ConfigLexicon::UPDATE_CUTOFF_TIME, 3.0); @@ -125,6 +127,9 @@ private function updateOrMarkUser(IUser $user): void { } private function markUserForRefresh(IUser $user): void { + // log with exception to capture the trace + $ex = new \Exception('Marking ' . $user->getUID() . ' as needing the share mounts refreshed'); + $this->logger->debug($ex->getMessage(), ['exception' => $ex]); $this->userConfig->setValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true); } diff --git a/apps/files_sharing/tests/SharesUpdatedListenerTest.php b/apps/files_sharing/tests/SharesUpdatedListenerTest.php index a6ec4ace499bf..5349ec0fd3a89 100644 --- a/apps/files_sharing/tests/SharesUpdatedListenerTest.php +++ b/apps/files_sharing/tests/SharesUpdatedListenerTest.php @@ -21,6 +21,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use Psr\Clock\ClockInterface; +use Psr\Log\LoggerInterface; use Test\Mock\Config\MockAppConfig; use Test\Mock\Config\MockUserConfig; use Test\Traits\UserTrait; @@ -34,6 +35,7 @@ class SharesUpdatedListenerTest extends \Test\TestCase { private IUserConfig $userConfig; private IAppConfig $appConfig; private ClockInterface&MockObject $clock; + private LoggerInterface&MockObject $logger; private $clockFn; protected function setUp(): void { @@ -54,11 +56,14 @@ protected function setUp(): void { // extra wrapper so we can modify clockFn return ($this->clockFn)(); }); + $this->logger = $this->createMock(LoggerInterface::class); + $this->sharesUpdatedListener = new SharesUpdatedListener( $this->manager, $this->shareRecipientUpdater, $this->userConfig, $this->clock, + $this->logger, $this->appConfig, ); } From aedc57afe72cc94dc08413f8ad2a840c4dcd849f Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 9 Apr 2026 18:35:30 +0200 Subject: [PATCH 24/35] fix: add optional user param to IUserMountCache::removeMount Signed-off-by: Robin Appelman --- apps/files_external/lib/Service/MountCacheService.php | 8 ++++---- apps/files_sharing/lib/ShareRecipientUpdater.php | 4 ++-- lib/private/Files/Config/UserMountCache.php | 5 ++++- lib/public/Files/Config/IUserMountCache.php | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/files_external/lib/Service/MountCacheService.php b/apps/files_external/lib/Service/MountCacheService.php index 2be17b9d59863..119b7b6f8b6ec 100644 --- a/apps/files_external/lib/Service/MountCacheService.php +++ b/apps/files_external/lib/Service/MountCacheService.php @@ -75,7 +75,7 @@ public function handle(Event $event): void { public function handleDeletedStorage(StorageConfig $storage): void { foreach ($this->applicableHelper->getUsersForStorage($storage) as $user) { - $this->userMountCache->removeMount($storage->getMountPointForUser($user)); + $this->userMountCache->removeMount($storage->getMountPointForUser($user), $user); } } @@ -87,7 +87,7 @@ public function handleAddedStorage(StorageConfig $storage): void { public function handleUpdatedStorage(StorageConfig $oldStorage, StorageConfig $newStorage): void { foreach ($this->applicableHelper->diffApplicable($oldStorage, $newStorage) as $user) { - $this->userMountCache->removeMount($oldStorage->getMountPointForUser($user)); + $this->userMountCache->removeMount($oldStorage->getMountPointForUser($user), $user); } foreach ($this->applicableHelper->diffApplicable($newStorage, $oldStorage) as $user) { $this->registerForUser($user, $newStorage); @@ -156,7 +156,7 @@ private function handleUserRemoved(IGroup $group, IUser $user): void { $storages = $this->storagesService->getAllStoragesForGroup($group); foreach ($storages as $storage) { if (!$this->applicableHelper->isApplicableForUser($storage, $user)) { - $this->userMountCache->removeMount($storage->getMountPointForUser($user)); + $this->userMountCache->removeMount($storage->getMountPointForUser($user), $user); } } } @@ -181,7 +181,7 @@ private function handleGroupDeleted(IGroup $group): void { private function removeGroupFromStorage(StorageConfig $storage, IGroup $group): void { foreach ($group->searchUsers('') as $user) { if (!$this->applicableHelper->isApplicableForUser($storage, $user)) { - $this->userMountCache->removeMount($storage->getMountPointForUser($user)); + $this->userMountCache->removeMount($storage->getMountPointForUser($user), $user); } } } diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php index 62033b7dd0ab2..4b410f97cbd50 100644 --- a/apps/files_sharing/lib/ShareRecipientUpdater.php +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -85,7 +85,7 @@ private function getMountPointFromTarget(IUser $user, string $target): string { * Process a single deleted share for a user */ public function updateForDeletedShare(IUser $user, IShare $share): void { - $this->userMountCache->removeMount($this->getMountPointFromTarget($user, $share->getTarget())); + $this->userMountCache->removeMount($this->getMountPointFromTarget($user, $share->getTarget()), $user); } /** @@ -96,7 +96,7 @@ public function updateForMovedShare(IUser $user, IShare $share): void { if ($originalTarget != null) { $newMountPoint = $this->getMountPointFromTarget($user, $share->getTarget()); $oldMountPoint = $this->getMountPointFromTarget($user, $originalTarget); - $this->userMountCache->removeMount($oldMountPoint); + $this->userMountCache->removeMount($oldMountPoint, $user); $this->userMountCache->addMount($user, $newMountPoint, $share->getNode()->getData(), MountProvider::class); } else { $this->updateForUser($user); diff --git a/lib/private/Files/Config/UserMountCache.php b/lib/private/Files/Config/UserMountCache.php index 3225afd2bc4d5..2d20b821c5197 100644 --- a/lib/private/Files/Config/UserMountCache.php +++ b/lib/private/Files/Config/UserMountCache.php @@ -520,10 +520,13 @@ public function getMountsInPath(IUser $user, string $path): array { return $result; } - public function removeMount(string $mountPoint): void { + public function removeMount(string $mountPoint, ?IUser $user = null): void { $query = $this->connection->getQueryBuilder(); $query->delete('mounts') ->where($query->expr()->eq('mount_point_hash', $query->createNamedParameter(hash('xxh128', $mountPoint)))); + if ($user) { + $query->andWhere($query->expr()->eq('user_id', $query->createNamedParameter($user->getUID()))); + } $query->executeStatement(); $parts = explode('/', $mountPoint); diff --git a/lib/public/Files/Config/IUserMountCache.php b/lib/public/Files/Config/IUserMountCache.php index 6e4ca41cd2816..d1541c7c4f54a 100644 --- a/lib/public/Files/Config/IUserMountCache.php +++ b/lib/public/Files/Config/IUserMountCache.php @@ -139,7 +139,7 @@ public function getMountsInPath(IUser $user, string $path): array; * * @since 33.0.0 */ - public function removeMount(string $mountPoint): void; + public function removeMount(string $mountPoint, ?IUser $user = null): void; /** * Register a new mountpoint for a user From 1aa347dc769b28648c0d0bd3295383fe25558491 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 10 Apr 2026 18:40:18 +0200 Subject: [PATCH 25/35] fix: check share target parent in userfolders mount Signed-off-by: Robin Appelman --- apps/files_sharing/lib/ShareTargetValidator.php | 12 ++++++------ .../files_sharing/tests/ShareTargetValidatorTest.php | 6 ++---- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/files_sharing/lib/ShareTargetValidator.php b/apps/files_sharing/lib/ShareTargetValidator.php index e1c8c0345cdd5..40aa5e7b7fc91 100644 --- a/apps/files_sharing/lib/ShareTargetValidator.php +++ b/apps/files_sharing/lib/ShareTargetValidator.php @@ -13,8 +13,7 @@ use OCP\Cache\CappedMemoryCache; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Config\ICachedMountInfo; -use OCP\Files\ISetupManager; -use OCP\Files\Mount\IMountManager; +use OCP\Files\IRootFolder; use OCP\Files\Mount\IMountPoint; use OCP\IUser; use OCP\Share\Events\VerifyMountPointEvent; @@ -30,8 +29,7 @@ class ShareTargetValidator { public function __construct( private readonly IManager $shareManager, private readonly IEventDispatcher $eventDispatcher, - private readonly ISetupManager $setupManager, - private readonly IMountManager $mountManager, + private readonly IRootFolder $rootFolder, ) { $this->folderExistsCache = new CappedMemoryCache(); } @@ -67,8 +65,10 @@ public function verifyMountPoint( /** @psalm-suppress InternalMethod */ $absoluteParent = $recipientView->getAbsolutePath($parent); - $this->setupManager->setupForPath($absoluteParent); - $parentMount = $this->mountManager->find($absoluteParent); + + // the share target always has to be in the users home + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $parentMount = $userFolder->getMountPoint(); $cached = $this->folderExistsCache->get($parent); if ($cached) { diff --git a/apps/files_sharing/tests/ShareTargetValidatorTest.php b/apps/files_sharing/tests/ShareTargetValidatorTest.php index 7bc5820d39e66..df7766d8ea2c2 100644 --- a/apps/files_sharing/tests/ShareTargetValidatorTest.php +++ b/apps/files_sharing/tests/ShareTargetValidatorTest.php @@ -9,12 +9,11 @@ namespace OCA\Files_Sharing\Tests; use OC\EventDispatcher\EventDispatcher; -use OC\Files\SetupManager; use OCA\Files_Sharing\ShareTargetValidator; use OCP\Constants; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Config\ICachedMountInfo; -use OCP\Files\Mount\IMountManager; +use OCP\Files\IRootFolder; use OCP\IUser; use OCP\Server; use OCP\Share\Events\VerifyMountPointEvent; @@ -57,8 +56,7 @@ protected function setUp(): void { $this->targetValidator = new ShareTargetValidator( Server::get(IManager::class), $this->eventDispatcher, - Server::get(SetupManager::class), - Server::get(IMountManager::class), + Server::get(IRootFolder::class), ); $this->user2 = $this->createMock(IUser::class); $this->user2->method('getUID') From cb8fb349d24cd2fa404ce1ab3fe0c490a171c46e Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 9 Apr 2026 19:08:02 +0200 Subject: [PATCH 26/35] fix: don't trigger on-setup share update from inside the share listener Signed-off-by: Robin Appelman --- apps/files_sharing/lib/Listener/SharesUpdatedListener.php | 6 ++++++ apps/files_sharing/lib/Listener/UserHomeSetupListener.php | 7 +++++++ apps/files_sharing/tests/SharesUpdatedListenerTest.php | 3 +++ 3 files changed, 16 insertions(+) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 06f09d8a0262c..d3ebf7afa82b6 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -54,11 +54,15 @@ public function __construct( private readonly ClockInterface $clock, private readonly LoggerInterface $logger, IAppConfig $appConfig, + private readonly UserHomeSetupListener $homeSetupListener, ) { $this->cutOffMarkTime = $appConfig->getValueFloat(Application::APP_ID, ConfigLexicon::UPDATE_CUTOFF_TIME, 3.0); } public function handle(Event $event): void { + // don't trigger the on-setup checks if this handler triggers an fs setup + $this->homeSetupListener->setDisabled(true); + if ($event instanceof UserShareAccessUpdatedEvent) { foreach ($event->getUsers() as $user) { $this->updateOrMarkUser($user); @@ -107,6 +111,8 @@ public function handle(Event $event): void { }); } } + + $this->homeSetupListener->setDisabled(false); } private function markOrRun(IUser $user, callable $callback): void { diff --git a/apps/files_sharing/lib/Listener/UserHomeSetupListener.php b/apps/files_sharing/lib/Listener/UserHomeSetupListener.php index 9e1d0ea765b32..b057a3d082d88 100644 --- a/apps/files_sharing/lib/Listener/UserHomeSetupListener.php +++ b/apps/files_sharing/lib/Listener/UserHomeSetupListener.php @@ -23,16 +23,23 @@ * @template-implements IEventListener */ class UserHomeSetupListener implements IEventListener { + private bool $disabled = false; public function __construct( private readonly ShareRecipientUpdater $updater, private readonly IUserConfig $userConfig, ) { } + public function setDisabled(bool $disabled): void { + $this->disabled = $disabled; + } public function handle(Event $event): void { if (!$event instanceof UserHomeSetupEvent) { return; } + if ($this->disabled) { + return; + } $user = $event->getUser(); if ($this->userConfig->getValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true)) { diff --git a/apps/files_sharing/tests/SharesUpdatedListenerTest.php b/apps/files_sharing/tests/SharesUpdatedListenerTest.php index 5349ec0fd3a89..3598f6cfb5b09 100644 --- a/apps/files_sharing/tests/SharesUpdatedListenerTest.php +++ b/apps/files_sharing/tests/SharesUpdatedListenerTest.php @@ -10,6 +10,7 @@ use OCA\Files_Sharing\Config\ConfigLexicon; use OCA\Files_Sharing\Event\UserShareAccessUpdatedEvent; use OCA\Files_Sharing\Listener\SharesUpdatedListener; +use OCA\Files_Sharing\Listener\UserHomeSetupListener; use OCA\Files_Sharing\ShareRecipientUpdater; use OCP\Config\IUserConfig; use OCP\IAppConfig; @@ -57,6 +58,7 @@ protected function setUp(): void { return ($this->clockFn)(); }); $this->logger = $this->createMock(LoggerInterface::class); + $homeSetupListener = new UserHomeSetupListener($this->shareRecipientUpdater, $this->userConfig); $this->sharesUpdatedListener = new SharesUpdatedListener( $this->manager, @@ -65,6 +67,7 @@ protected function setUp(): void { $this->clock, $this->logger, $this->appConfig, + $homeSetupListener, ); } From 16a422e58d7c6857c2c94e61611918e95931b096 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 10 Apr 2026 20:03:11 +0200 Subject: [PATCH 27/35] fix: fix UserHomeSetupListener disabling with nested events Signed-off-by: Robin Appelman --- apps/files_sharing/lib/Listener/SharesUpdatedListener.php | 4 ++-- apps/files_sharing/lib/Listener/UserHomeSetupListener.php | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index d3ebf7afa82b6..8dcbe3b7a42cd 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -61,7 +61,7 @@ public function __construct( public function handle(Event $event): void { // don't trigger the on-setup checks if this handler triggers an fs setup - $this->homeSetupListener->setDisabled(true); + $oldState = $this->homeSetupListener->setDisabled(true); if ($event instanceof UserShareAccessUpdatedEvent) { foreach ($event->getUsers() as $user) { @@ -112,7 +112,7 @@ public function handle(Event $event): void { } } - $this->homeSetupListener->setDisabled(false); + $this->homeSetupListener->setDisabled($oldState); } private function markOrRun(IUser $user, callable $callback): void { diff --git a/apps/files_sharing/lib/Listener/UserHomeSetupListener.php b/apps/files_sharing/lib/Listener/UserHomeSetupListener.php index b057a3d082d88..7f95605b3b601 100644 --- a/apps/files_sharing/lib/Listener/UserHomeSetupListener.php +++ b/apps/files_sharing/lib/Listener/UserHomeSetupListener.php @@ -30,8 +30,10 @@ public function __construct( ) { } - public function setDisabled(bool $disabled): void { + public function setDisabled(bool $disabled): bool { + $previous = $this->disabled; $this->disabled = $disabled; + return $previous; } public function handle(Event $event): void { if (!$event instanceof UserHomeSetupEvent) { From 41e6124dcc816f1bf495b2edf68e40615708eedc Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 14 Apr 2026 16:43:08 +0200 Subject: [PATCH 28/35] perf: only load a single mount at a time when checking for share conflicts Signed-off-by: Robin Appelman --- .../lib/Repair/CleanupShareTarget.php | 2 +- .../lib/ShareRecipientUpdater.php | 8 +--- .../lib/ShareTargetValidator.php | 20 +++++----- .../tests/ShareRecipientUpdaterTest.php | 4 +- .../tests/ShareTargetValidatorTest.php | 11 +++--- lib/private/Files/Config/UserMountCache.php | 37 ++++++++++++++++++- lib/public/Files/Config/IUserMountCache.php | 12 +++++- 7 files changed, 67 insertions(+), 27 deletions(-) diff --git a/apps/files_sharing/lib/Repair/CleanupShareTarget.php b/apps/files_sharing/lib/Repair/CleanupShareTarget.php index 60a62c7add11d..880c2a15af6c6 100644 --- a/apps/files_sharing/lib/Repair/CleanupShareTarget.php +++ b/apps/files_sharing/lib/Repair/CleanupShareTarget.php @@ -107,7 +107,7 @@ public function run(IOutput $output) { (int)$shareInfo['file_source'], $absoluteNewTarget, $targetParentNode->getMountPoint(), - $userMounts, + fn ($path) => $userMounts[$path] ?? null, ); $newTarget = $userFolder->getRelativePath($absoluteNewTarget); diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php index 4b410f97cbd50..cdb7a7c1b7562 100644 --- a/apps/files_sharing/lib/ShareRecipientUpdater.php +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -51,7 +51,7 @@ public function updateForUser(IUser $user): void { $mountKey = $parentShare->getNodeId() . '::' . $mountPoint; if (!isset($cachedMounts[$mountKey])) { $mountsChanged = true; - $this->shareTargetValidator->verifyMountPoint($user, $parentShare, $mountsByPath, $groupedShares); + $this->shareTargetValidator->verifyMountPoint($user, $parentShare, fn ($path) => $mountsByPath[$path] ?? null, $groupedShares); } } @@ -67,11 +67,7 @@ public function updateForUser(IUser $user): void { * Validate a single received share for a user */ 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); - - $target = $this->shareTargetValidator->verifyMountPoint($user, $share, $mountsByPath, [$share]); + $target = $this->shareTargetValidator->verifyMountPoint($user, $share, fn ($path) => $this->userMountCache->getMountAtPath($user, $path), [$share]); $mountPoint = $this->getMountPointFromTarget($user, $target); $this->userMountCache->addMount($user, $mountPoint, $share->getNode()->getData(), MountProvider::class); diff --git a/apps/files_sharing/lib/ShareTargetValidator.php b/apps/files_sharing/lib/ShareTargetValidator.php index 40aa5e7b7fc91..e77dc5ddad07a 100644 --- a/apps/files_sharing/lib/ShareTargetValidator.php +++ b/apps/files_sharing/lib/ShareTargetValidator.php @@ -45,14 +45,14 @@ private function getViewForUser(IUser $user): View { /** * check if the parent folder exists otherwise move the mount point up * - * @param array $allCachedMounts Other mounts for the user, indexed by path + * @param callable(string):?ICachedMountInfo $getMountByPath * @param IShare[] $childShares * @return string */ public function verifyMountPoint( IUser $user, IShare &$share, - array $allCachedMounts, + callable $getMountByPath, array $childShares, ): string { $mountPoint = basename($share->getTarget()); @@ -94,7 +94,7 @@ public function verifyMountPoint( $share->getNodeId(), Filesystem::normalizePath($absoluteParent . '/' . $mountPoint), $parentMount, - $allCachedMounts, + $getMountByPath, ); /** @psalm-suppress InternalMethod */ @@ -112,13 +112,13 @@ public function verifyMountPoint( /** - * @param ICachedMountInfo[] $allCachedMounts + * @param callable(string):?ICachedMountInfo $getMountByPath */ public function generateUniqueTarget( int $shareNodeId, string $absolutePath, IMountPoint $parentMount, - array $allCachedMounts, + callable $getMountByPath, ): string { $pathInfo = pathinfo($absolutePath); $ext = isset($pathInfo['extension']) ? '.' . $pathInfo['extension'] : ''; @@ -128,7 +128,7 @@ public function generateUniqueTarget( $i = 2; $parentCache = $parentMount->getStorage()->getCache(); $internalPath = $parentMount->getInternalPath($absolutePath); - while ($parentCache->inCache($internalPath) || $this->hasConflictingMount($shareNodeId, $allCachedMounts, $absolutePath)) { + while ($parentCache->inCache($internalPath) || $this->hasConflictingMount($shareNodeId, $getMountByPath, $absolutePath)) { $absolutePath = Filesystem::normalizePath($dir . '/' . $name . ' (' . $i . ')' . $ext); $internalPath = $parentMount->getInternalPath($absolutePath); $i++; @@ -138,14 +138,14 @@ public function generateUniqueTarget( } /** - * @param ICachedMountInfo[] $allCachedMounts + * @param callable(string):?ICachedMountInfo $getMountByPath */ - private function hasConflictingMount(int $shareNodeId, array $allCachedMounts, string $absolutePath): bool { - if (!isset($allCachedMounts[$absolutePath . '/'])) { + private function hasConflictingMount(int $shareNodeId, callable $getMountByPath, string $absolutePath): bool { + $mount = $getMountByPath($absolutePath . '/'); + if ($mount === null) { return false; } - $mount = $allCachedMounts[$absolutePath . '/']; if ($mount->getMountProvider() === MountProvider::class && $mount->getRootId() === $shareNodeId) { // "conflicting" mount is a mount for the current share return false; diff --git a/apps/files_sharing/tests/ShareRecipientUpdaterTest.php b/apps/files_sharing/tests/ShareRecipientUpdaterTest.php index 2316e6b8b7e56..91c449f584353 100644 --- a/apps/files_sharing/tests/ShareRecipientUpdaterTest.php +++ b/apps/files_sharing/tests/ShareRecipientUpdaterTest.php @@ -61,7 +61,7 @@ public function testUpdateForShare() { ->willReturn([]); $this->shareTargetValidator->method('verifyMountPoint') - ->with($user1, $share, [], [$share]) + ->with($user1, $share, fn ($path) => null, [$share]) ->willReturn('/new-target'); $this->userMountCache->expects($this->exactly(1)) @@ -122,7 +122,7 @@ public function testUpdateForUserAddedNoExisting() { $this->setCachedMounts($user1, []); $this->shareTargetValidator->method('verifyMountPoint') - ->with($user1, $share, [], [$share]) + ->with($user1, $share, fn ($path) => null, [$share]) ->willReturn('/new-target'); $this->userMountCache->expects($this->exactly(1)) diff --git a/apps/files_sharing/tests/ShareTargetValidatorTest.php b/apps/files_sharing/tests/ShareTargetValidatorTest.php index df7766d8ea2c2..1c5b8b619c27a 100644 --- a/apps/files_sharing/tests/ShareTargetValidatorTest.php +++ b/apps/files_sharing/tests/ShareTargetValidatorTest.php @@ -83,7 +83,7 @@ public function testShareMountLoseParentFolder(): void { $share = $this->shareManager->getShareById($share->getFullId()); $this->assertSame('/foo/bar' . $this->folder, $share->getTarget()); - $this->targetValidator->verifyMountPoint($this->user2, $share, [], [$share]); + $this->targetValidator->verifyMountPoint($this->user2, $share, fn ($path) => null, [$share]); $share = $this->shareManager->getShareById($share->getFullId()); $this->assertSame($this->folder, $share->getTarget()); @@ -117,7 +117,7 @@ public function testShareMountOverFolder(): void { $share = $this->shareManager->getShareById($share->getFullId()); - $this->targetValidator->verifyMountPoint($this->user2, $share, [], [$share]); + $this->targetValidator->verifyMountPoint($this->user2, $share, fn ($path) => null, [$share]); $share = $this->shareManager->getShareById($share->getFullId()); $this->assertSame('/bar (2)', $share->getTarget()); @@ -142,9 +142,10 @@ public function testShareMountOverShare(): void { $this->shareManager->acceptShare($share2, self::TEST_FILES_SHARING_API_USER2); $conflictingMount = $this->createMock(ICachedMountInfo::class); - $this->targetValidator->verifyMountPoint($this->user2, $share2, [ + $conflictingMounts = [ '/' . $this->user2->getUID() . '/files' . $this->folder2 . '/' => $conflictingMount - ], [$share2]); + ]; + $this->targetValidator->verifyMountPoint($this->user2, $share2, fn ($path) => $conflictingMounts[$path] ?? null, [$share2]); $share2 = $this->shareManager->getShareById($share2->getFullId()); @@ -179,7 +180,7 @@ public function testShareMountCreateParentFolder(): void { $this->eventDispatcher->addListener(VerifyMountPointEvent::class, function (VerifyMountPointEvent $event): void { $event->setCreateParent(true); }); - $this->targetValidator->verifyMountPoint($this->user2, $share, [], [$share]); + $this->targetValidator->verifyMountPoint($this->user2, $share, fn ($path) => null, [$share]); $share = $this->shareManager->getShareById($share->getFullId()); $this->assertSame('/foo/bar' . $this->folder, $share->getTarget()); diff --git a/lib/private/Files/Config/UserMountCache.php b/lib/private/Files/Config/UserMountCache.php index 2d20b821c5197..8bc04c62b3308 100644 --- a/lib/private/Files/Config/UserMountCache.php +++ b/lib/private/Files/Config/UserMountCache.php @@ -5,6 +5,7 @@ * SPDX-FileCopyrightText: 2016 ownCloud, Inc. * SPDX-License-Identifier: AGPL-3.0-only */ + namespace OC\Files\Config; use OC\DB\Exceptions\DbalException; @@ -33,11 +34,13 @@ class UserMountCache implements IUserMountCache { /** * Cached mount info. + * * @var CappedMemoryCache **/ private CappedMemoryCache $mountsForUsers; /** * fileid => internal path mapping for cached mount info. + * * @var CappedMemoryCache **/ private CappedMemoryCache $internalPathCache; @@ -73,7 +76,9 @@ public function registerMounts(IUser $user, array $mounts, ?array $mountProvider $cachedMounts = $this->getMountsForUser($user); if (is_array($mountProviderClasses)) { - $cachedMounts = array_filter($cachedMounts, function (ICachedMountInfo $mountInfo) use ($mountProviderClasses, $newMounts) { + $cachedMounts = array_filter($cachedMounts, function ( + ICachedMountInfo $mountInfo, + ) use ($mountProviderClasses, $newMounts) { // for existing mounts that didn't have a mount provider set // we still want the ones that map to new mounts if ($mountInfo->getMountProvider() === '' && isset($newMounts[$mountInfo->getKey()])) { @@ -536,7 +541,13 @@ public function removeMount(string $mountPoint, ?IUser $user = null): void { } } - public function addMount(IUser $user, string $mountPoint, ICacheEntry $rootCacheEntry, string $mountProvider, ?int $mountId = null): void { + public function addMount( + IUser $user, + string $mountPoint, + ICacheEntry $rootCacheEntry, + string $mountProvider, + ?int $mountId = null, + ): void { $query = $this->connection->getQueryBuilder(); $query->insert('mounts') ->values([ @@ -567,4 +578,26 @@ public function flush(): void { $this->internalPathCache = new CappedMemoryCache(); $this->mountsForUsers = new CappedMemoryCache(); } + + public function getMountAtPath(IUser $user, string $mountPoint): ?ICachedMountInfo { + if (isset($this->mountsForUsers[$user->getUID()])) { + foreach ($this->mountsForUsers[$user->getUID()] as $mount) { + if ($mount->getMountPoint() === $mountPoint) { + return $mount; + } + } + return null; + } + + $builder = $this->connection->getQueryBuilder(); + $query = $builder->select('storage_id', 'root_id', 'user_id', 'mount_point', 'mount_id', 'f.path', 'mount_provider_class') + ->from('mounts', 'm') + ->innerJoin('m', 'filecache', 'f', $builder->expr()->eq('m.root_id', 'f.fileid')) + ->where($builder->expr()->eq('user_id', $builder->createNamedParameter($user->getUID()))) + ->andWhere($builder->expr()->eq('mount_point_hash', $builder->createNamedParameter(hash('xxh128', $mountPoint)))) + ->setMaxResults(1); + + $row = $query->executeQuery()->fetch(); + return $row ? $this->dbRowToMountInfo($row) : null; + } } diff --git a/lib/public/Files/Config/IUserMountCache.php b/lib/public/Files/Config/IUserMountCache.php index d1541c7c4f54a..fc0ec3f1f9284 100644 --- a/lib/public/Files/Config/IUserMountCache.php +++ b/lib/public/Files/Config/IUserMountCache.php @@ -113,7 +113,10 @@ public function getUsedSpaceForUsers(array $users); public function clear(): void; /** - * Get all cached mounts for a user + * Get the cached mount for a path + * + * This walks up the directly tree until a mount is found, if you only want + * to get the mount at the specific path, use `getMountAtPath` instead. * * @param IUser $user * @param string $path @@ -147,4 +150,11 @@ public function removeMount(string $mountPoint, ?IUser $user = null): void; * @since 33.0.0 */ public function addMount(IUser $user, string $mountPoint, ICacheEntry $rootCacheEntry, string $mountProvider, ?int $mountId = null): void; + + /** + * Get the mount at the specified path, if any + * + * @since 33.0.2 + */ + public function getMountAtPath(IUser $user, string $mountPoint): ?ICachedMountInfo; } From ac9da6eaa076013edf9a97c08388197fe63e3b73 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 14 Apr 2026 22:09:54 +0200 Subject: [PATCH 29/35] perf: only load possible results in UserMountCache::getMountForPath Signed-off-by: Robin Appelman --- lib/private/Files/Config/UserMountCache.php | 46 +++++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/lib/private/Files/Config/UserMountCache.php b/lib/private/Files/Config/UserMountCache.php index 8bc04c62b3308..5e0653b1c63f6 100644 --- a/lib/private/Files/Config/UserMountCache.php +++ b/lib/private/Files/Config/UserMountCache.php @@ -488,21 +488,13 @@ public function clear(): void { } public function getMountForPath(IUser $user, string $path): ICachedMountInfo { - $mounts = []; - foreach ($this->getMountsForUser($user) as $mount) { - $mounts[$mount->getMountPoint()] = $mount; - } - + $searchPaths = []; $current = rtrim($path, '/'); - // walk up the directory tree until we find a path that has a mountpoint set - // the loop will return if a mountpoint is found or break if none are found - while (true) { + // get all paths that we are interested in, $path and all it's parents + while ($current !== '') { $mountPoint = $current . '/'; - if (isset($mounts[$mountPoint])) { - return $mounts[$mountPoint]; - } elseif ($current === '') { - break; - } + + $searchPaths[] = $mountPoint; $current = dirname($current); if ($current === '.' || $current === '/') { @@ -510,6 +502,34 @@ public function getMountForPath(IUser $user, string $path): ICachedMountInfo { } } + $mounts = []; + if (isset($this->mountsForUsers[$user->getUID()])) { + foreach ($this->mountsForUsers[$user->getUID()] as $mount) { + $mounts[$mount->getMountPoint()] = $mount; + } + } else { + $searchPathHashes = array_map(static fn (string $path) => hash('xxh128', $path), $searchPaths); + + $builder = $this->connection->getQueryBuilder(); + $query = $builder->select('storage_id', 'root_id', 'user_id', 'mount_point', 'mount_id', 'f.path', 'mount_provider_class') + ->from('mounts', 'm') + ->innerJoin('m', 'filecache', 'f', $builder->expr()->eq('m.root_id', 'f.fileid')) + ->where($builder->expr()->eq('user_id', $builder->createNamedParameter($user->getUID()))) + ->andWhere($builder->expr()->in('mount_point_hash', $builder->createNamedParameter($searchPathHashes, IQueryBuilder::PARAM_STR_ARRAY))); + + foreach ($query->executeQuery()->fetchAll() as $row) { + $mount = $this->dbRowToMountInfo($row); + $mounts[$mount->getMountPoint()] = $mount; + } + } + + // note that $searchPaths is sorted deepest path first + foreach ($searchPaths as $searchPath) { + if (isset($mounts[$searchPath])) { + return $mounts[$searchPath]; + } + } + throw new NotFoundException('No cached mount for path ' . $path); } From cf06ebf999ff5fbce2e33e232ddedccaf301d158 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 14 Apr 2026 22:24:30 +0200 Subject: [PATCH 30/35] test: more tests for UserMountCache Signed-off-by: Robin Appelman --- tests/lib/Files/Config/UserMountCacheTest.php | 65 +++++++++++++++++-- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/tests/lib/Files/Config/UserMountCacheTest.php b/tests/lib/Files/Config/UserMountCacheTest.php index 6e6630b5be308..badb8f7c45b6b 100644 --- a/tests/lib/Files/Config/UserMountCacheTest.php +++ b/tests/lib/Files/Config/UserMountCacheTest.php @@ -183,7 +183,9 @@ public function testRemoveMounts(): void { $this->eventDispatcher ->expects($this->exactly(2)) ->method('dispatchTyped') - ->with($this->callback(function (UserMountAddedEvent|UserMountRemovedEvent $event) use (&$operation) { + ->with($this->callback(function ( + UserMountAddedEvent|UserMountRemovedEvent $event, + ) use (&$operation) { return match (++$operation) { 1 => $event instanceof UserMountAddedEvent && $event->mountPoint->getMountPoint() === '/asd/', 2 => $event instanceof UserMountRemovedEvent && $event->mountPoint->getMountPoint() === '/asd/', @@ -214,7 +216,9 @@ public function testChangeMounts(): void { $this->eventDispatcher ->expects($this->exactly(3)) ->method('dispatchTyped') - ->with($this->callback(function (UserMountAddedEvent|UserMountRemovedEvent $event) use (&$operation) { + ->with($this->callback(function ( + UserMountAddedEvent|UserMountRemovedEvent $event, + ) use (&$operation) { return match (++$operation) { 1 => $event instanceof UserMountAddedEvent && $event->mountPoint->getMountPoint() === '/bar/', 2 => $event instanceof UserMountAddedEvent && $event->mountPoint->getMountPoint() === '/foo/', @@ -250,7 +254,9 @@ public function testChangeMountId(): void { $this->eventDispatcher ->expects($this->exactly(2)) ->method('dispatchTyped') - ->with($this->callback(function (UserMountAddedEvent|UserMountUpdatedEvent $event) use (&$operation) { + ->with($this->callback(function ( + UserMountAddedEvent|UserMountUpdatedEvent $event, + ) use (&$operation) { return match (++$operation) { 1 => $event instanceof UserMountAddedEvent && $event->mountPoint->getMountPoint() === '/foo/', 2 => $event instanceof UserMountUpdatedEvent && $event->oldMountPoint->getMountId() === null && $event->newMountPoint->getMountId() === 1, @@ -611,12 +617,59 @@ public function testChangedSameRootId(): void { $this->cache->flush(); $cached = $this->cache->getMountsForUser($user); - usort($cached, fn (ICachedMountInfo $a, ICachedMountInfo $b) => $a->getMountPoint() <=> $b->getMountPoint()); + usort($cached, fn (ICachedMountInfo $a, ICachedMountInfo $b, + ) => $a->getMountPoint() <=> $b->getMountPoint()); - $mountPoints = array_map(fn (ICachedMountInfo $mountInfo) => $mountInfo->getMountPoint(), $cached); + $mountPoints = array_map(fn (ICachedMountInfo $mountInfo, + ) => $mountInfo->getMountPoint(), $cached); $this->assertEquals(['/asd/', '/asd2/'], $mountPoints); - $mountIds = array_map(fn (ICachedMountInfo $mountInfo) => $mountInfo->getMountId(), $cached); + $mountIds = array_map(fn (ICachedMountInfo $mountInfo, + ) => $mountInfo->getMountId(), $cached); $this->assertEquals([null, 1], $mountIds); } + + public function testGetMountForPath(): void { + $user = $this->userManager->get('u1'); + + [$storage] = $this->getStorage(10); + $mount1 = new MountPoint($storage, '/asd/'); + $mount2 = new MountPoint($storage, '/asd/foo'); + + $this->cache->registerMounts($user, [$mount1, $mount2]); + $this->cache->flush(); + + $this->assertEquals('/asd/', $this->cache->getMountForPath($user, '/asd/bar/')->getMountPoint()); + $this->assertEquals('/asd/', $this->cache->getMountForPath($user, '/asd/')->getMountPoint()); + $this->assertEquals('/asd/foo/', $this->cache->getMountForPath($user, '/asd/foo/bar/')->getMountPoint()); + $this->assertEquals('/asd/foo/', $this->cache->getMountForPath($user, '/asd/foo/')->getMountPoint()); + } + + public function testGetMountsInPath(): void { + $user = $this->userManager->get('u1'); + + [$storage] = $this->getStorage(10); + $mount1 = new MountPoint($storage, '/asd/'); + $mount2 = new MountPoint($storage, '/asd/foo/'); + $mount3 = new MountPoint($storage, '/asd/foo/bar/'); + + $this->cache->registerMounts($user, [$mount1, $mount2, $mount3]); + $this->cache->flush(); + + $getMountPaths = function (string $path) use ($user) { + $mountPoints = array_values( + array_map( + fn (ICachedMountInfo $mount) => $mount->getMountPoint(), + $this->cache->getMountsInPath($user, $path) + ) + ); + sort($mountPoints); + return $mountPoints; + }; + + $this->assertEquals(['/asd/foo/', '/asd/foo/bar/'], $getMountPaths('/asd/')); + $this->assertEquals([], $getMountPaths('/asd/foo/bar/')); + $this->assertEquals(['/asd/foo/bar/'], $getMountPaths('/asd/foo/')); + $this->assertEquals([], $getMountPaths('/asd/bar/')); + } } From a05a3b900583637c41ec06444ad185ccb493746c Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 17 Apr 2026 14:05:53 +0200 Subject: [PATCH 31/35] fix: only update share for the user who moved the share Signed-off-by: Robin Appelman --- .../files_sharing/lib/Listener/SharesUpdatedListener.php | 9 ++++----- lib/private/Share20/Manager.php | 7 +++++-- lib/public/Share/Events/ShareMovedEvent.php | 9 +++++++++ tests/lib/Share20/ManagerTest.php | 3 +++ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 8dcbe3b7a42cd..aba49b74ad3e0 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -97,11 +97,10 @@ public function handle(Event $event): void { } if ($event instanceof ShareMovedEvent) { $share = $event->getShare(); - foreach ($this->shareManager->getUsersForShare($share) as $user) { - $this->markOrRun($user, function () use ($user, $share) { - $this->shareUpdater->updateForMovedShare($user, $share); - }); - } + $user = $event->getUser(); + $this->markOrRun($user, function () use ($user, $share) { + $this->shareUpdater->updateForMovedShare($user, $share); + }); } if ($event instanceof BeforeShareDeletedEvent) { $share = $event->getShare(); diff --git a/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index 27fb205d5f8ed..f518b7ddafb5d 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -1186,13 +1186,16 @@ public function moveShare(IShare $share, string $recipientId): IShare { if ($share->getShareType() === IShare::TYPE_USER && $share->getSharedWith() !== $recipientId) { throw new \InvalidArgumentException($this->l->t('Invalid share recipient')); } + $recipient = $this->userManager->get($recipientId); + if (!$recipient) { + throw new \InvalidArgumentException($this->l->t('Unknown share recipient')); + } if ($share->getShareType() === IShare::TYPE_GROUP) { $sharedWith = $this->groupManager->get($share->getSharedWith()); if (is_null($sharedWith)) { throw new \InvalidArgumentException($this->l->t('Group "%s" does not exist', [$share->getSharedWith()])); } - $recipient = $this->userManager->get($recipientId); if (!$sharedWith->inGroup($recipient)) { throw new \InvalidArgumentException($this->l->t('Invalid share recipient')); } @@ -1203,7 +1206,7 @@ public function moveShare(IShare $share, string $recipientId): IShare { $result = $provider->move($share, $recipientId); - $this->dispatchEvent(new ShareMovedEvent($share), 'share moved'); + $this->dispatchEvent(new ShareMovedEvent($share, $recipient), 'share moved'); return $result; } diff --git a/lib/public/Share/Events/ShareMovedEvent.php b/lib/public/Share/Events/ShareMovedEvent.php index 36c020fc0c488..3c9fc71f1933f 100644 --- a/lib/public/Share/Events/ShareMovedEvent.php +++ b/lib/public/Share/Events/ShareMovedEvent.php @@ -9,6 +9,7 @@ namespace OCP\Share\Events; use OCP\EventDispatcher\Event; +use OCP\IUser; use OCP\Share\IShare; /** @@ -20,6 +21,7 @@ class ShareMovedEvent extends Event { */ public function __construct( private readonly IShare $share, + private readonly IUser $user, ) { parent::__construct(); } @@ -30,4 +32,11 @@ public function __construct( public function getShare(): IShare { return $this->share; } + + /** + * @since 33.0.0 + */ + public function getUser(): IUser { + return $this->user; + } } diff --git a/tests/lib/Share20/ManagerTest.php b/tests/lib/Share20/ManagerTest.php index 4b8c4efb064e5..5600d827f4d02 100644 --- a/tests/lib/Share20/ManagerTest.php +++ b/tests/lib/Share20/ManagerTest.php @@ -4679,6 +4679,9 @@ public function testMoveShareUser(): void { $share->setShareType(IShare::TYPE_USER) ->setId('42') ->setProviderId('foo'); + $this->userManager->method('get') + ->with('recipient') + ->willReturn($this->createMock(IUser::class)); $share->setSharedWith('recipient'); From f42bfebcc0d7460ffd48b492a8278ffb48d52f08 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 17 Apr 2026 14:56:49 +0200 Subject: [PATCH 32/35] fix: don't trigger recursive SharesUpdatedListener when share is moved on validate Signed-off-by: Robin Appelman --- .../lib/Listener/SharesUpdatedListener.php | 10 +++++++--- apps/files_sharing/lib/ShareRecipientUpdater.php | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index aba49b74ad3e0..d80d76f644e08 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -98,9 +98,13 @@ public function handle(Event $event): void { if ($event instanceof ShareMovedEvent) { $share = $event->getShare(); $user = $event->getUser(); - $this->markOrRun($user, function () use ($user, $share) { - $this->shareUpdater->updateForMovedShare($user, $share); - }); + + // don't trigger if the share is moved as part of the conflict resolution + if (!$this->shareUpdater->isInUpdate($user)) { + $this->markOrRun($user, function () use ($user, $share) { + $this->shareUpdater->updateForMovedShare($user, $share); + }); + } } if ($event instanceof BeforeShareDeletedEvent) { $share = $event->getShare(); diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php index cdb7a7c1b7562..fe1e74c770b16 100644 --- a/apps/files_sharing/lib/ShareRecipientUpdater.php +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -30,7 +30,7 @@ public function __construct( */ public function updateForUser(IUser $user): void { // prevent recursion - if (isset($this->inUpdate[$user->getUID()])) { + if ($this->isInUpdate($user)) { return; } $this->inUpdate[$user->getUID()] = true; @@ -63,6 +63,10 @@ public function updateForUser(IUser $user): void { unset($this->inUpdate[$user->getUID()]); } + public function isInUpdate(IUser $user): bool { + return isset($this->inUpdate[$user->getUID()]); + } + /** * Validate a single received share for a user */ From f3830d071c8e7a8fbc839dee6df74ee2dd2cdc5f Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Mon, 20 Apr 2026 20:26:44 +0200 Subject: [PATCH 33/35] fix: skip owner when updating for added or removed share Signed-off-by: Robin Appelman --- apps/files_sharing/lib/Listener/SharesUpdatedListener.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index d80d76f644e08..9cb3740671051 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -86,6 +86,10 @@ public function handle(Event $event): void { $share = $event->getShare(); $shareTarget = $share->getTarget(); foreach ($this->shareManager->getUsersForShare($share) as $user) { + if ($share->getShareOwner() === $user->getUID() || $share->getSharedBy() === $user->getUID()) { + continue; + } + if ($share->getSharedBy() !== $user->getUID()) { $this->markOrRun($user, function () use ($user, $share) { $this->shareUpdater->updateForAddedShare($user, $share); @@ -109,6 +113,10 @@ public function handle(Event $event): void { if ($event instanceof BeforeShareDeletedEvent) { $share = $event->getShare(); foreach ($this->shareManager->getUsersForShare($share) as $user) { + if ($share->getShareOwner() === $user->getUID() || $share->getSharedBy() === $user->getUID()) { + continue; + } + $this->markOrRun($user, function () use ($user, $share) { $this->shareUpdater->updateForDeletedShare($user, $share); }); From 987a193a5687a69924c2a5cabaa112830a8785dd Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Mon, 20 Apr 2026 20:27:25 +0200 Subject: [PATCH 34/35] fix: properly remove mount with moved child-shares Signed-off-by: Robin Appelman --- apps/files_sharing/lib/ShareRecipientUpdater.php | 10 +++++++++- apps/files_sharing/tests/ShareRecipientUpdaterTest.php | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php index fe1e74c770b16..2d8f3bddeabeb 100644 --- a/apps/files_sharing/lib/ShareRecipientUpdater.php +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -12,6 +12,8 @@ use OCP\Files\Config\IUserMountCache; use OCP\Files\Storage\IStorageFactory; use OCP\IUser; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IManager; use OCP\Share\IShare; class ShareRecipientUpdater { @@ -22,6 +24,7 @@ public function __construct( private readonly MountProvider $shareMountProvider, private readonly ShareTargetValidator $shareTargetValidator, private readonly IStorageFactory $storageFactory, + private readonly IManager $shareManager, ) { } @@ -85,7 +88,12 @@ private function getMountPointFromTarget(IUser $user, string $target): string { * Process a single deleted share for a user */ public function updateForDeletedShare(IUser $user, IShare $share): void { - $this->userMountCache->removeMount($this->getMountPointFromTarget($user, $share->getTarget()), $user); + try { + $userShare = $this->shareManager->getShareById($share->getFullId(), $user->getUID()); + $this->userMountCache->removeMount($this->getMountPointFromTarget($user, $userShare->getTarget()), $user); + } catch (ShareNotFound) { + // user doesn't actually have access to the share + } } /** diff --git a/apps/files_sharing/tests/ShareRecipientUpdaterTest.php b/apps/files_sharing/tests/ShareRecipientUpdaterTest.php index 91c449f584353..ed365385d8df2 100644 --- a/apps/files_sharing/tests/ShareRecipientUpdaterTest.php +++ b/apps/files_sharing/tests/ShareRecipientUpdaterTest.php @@ -17,6 +17,7 @@ use OCP\Files\Node; use OCP\Files\Storage\IStorageFactory; use OCP\IUser; +use OCP\Share\IManager; use OCP\Share\IShare; use PHPUnit\Framework\MockObject\MockObject; use Test\Traits\UserTrait; @@ -29,6 +30,7 @@ class ShareRecipientUpdaterTest extends \Test\TestCase { private ShareTargetValidator&MockObject $shareTargetValidator; private IStorageFactory&MockObject $storageFactory; private ShareRecipientUpdater $updater; + private IManager $shareManager; protected function setUp(): void { parent::setUp(); @@ -37,12 +39,14 @@ protected function setUp(): void { $this->shareMountProvider = $this->createMock(MountProvider::class); $this->shareTargetValidator = $this->createMock(ShareTargetValidator::class); $this->storageFactory = $this->createMock(IStorageFactory::class); + $this->shareManager = $this->createMock(IManager::class); $this->updater = new ShareRecipientUpdater( $this->userMountCache, $this->shareMountProvider, $this->shareTargetValidator, $this->storageFactory, + $this->shareManager, ); } @@ -192,8 +196,14 @@ public function testDeletedShare() { ->willReturn('/target'); $share->method('getNodeId') ->willReturn(111); + $share->method('getFullId') + ->willReturn('id'); $user1 = $this->createUser('user1', ''); + $this->shareManager->method('getShareById') + ->with('id') + ->willReturn($share); + $this->shareTargetValidator->expects($this->never()) ->method('verifyMountPoint'); From e548d718738fea82fc72d4a4e22a61ad2710cd43 Mon Sep 17 00:00:00 2001 From: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:30:08 +0200 Subject: [PATCH 35/35] fix: avoid checking share validity during mount updates Signed-off-by: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com> --- apps/files_sharing/lib/ShareRecipientUpdater.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php index 2d8f3bddeabeb..144bdc7bc947b 100644 --- a/apps/files_sharing/lib/ShareRecipientUpdater.php +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -89,7 +89,7 @@ private function getMountPointFromTarget(IUser $user, string $target): string { */ public function updateForDeletedShare(IUser $user, IShare $share): void { try { - $userShare = $this->shareManager->getShareById($share->getFullId(), $user->getUID()); + $userShare = $this->shareManager->getShareById($share->getFullId(), $user->getUID(), false); $this->userMountCache->removeMount($this->getMountPointFromTarget($user, $userShare->getTarget()), $user); } catch (ShareNotFound) { // user doesn't actually have access to the share