From fb49d9c7ea4515918d0a1ea2a0682684334137c8 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 15 Jan 2026 18:12:44 +0100 Subject: [PATCH 01/31] fix: only refresh mounts once per user Signed-off-by: Robin Appelman --- lib/private/Files/SetupManager.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/private/Files/SetupManager.php b/lib/private/Files/SetupManager.php index 06ec47d165489..163aaf7cb6c8a 100644 --- a/lib/private/Files/SetupManager.php +++ b/lib/private/Files/SetupManager.php @@ -69,6 +69,8 @@ class SetupManager { private array $setupUsers = []; // List of users for which all mounts are setup private array $setupUsersComplete = []; + // List of users for which we've already refreshed the non-authoritative mounts + private array $usersMountsUpdated = []; /** * An array of provider classes that have been set up, indexed by UserUID. * @@ -233,6 +235,10 @@ function ($mountPoint, IStorage $storage, IMountPoint $mount) use ($reSharingEna * Update the cached mounts for all non-authoritative mount providers for a user. */ private function updateNonAuthoritativeProviders(IUser $user): void { + if (isset($this->usersMountsUpdated[$user->getUID()])) { + return; + } + // prevent recursion loop from when getting mounts from providers ends up setting up the filesystem static $updatingProviders = false; if ($updatingProviders) { @@ -253,6 +259,7 @@ private function updateNonAuthoritativeProviders(IUser $user): void { $mount = $this->mountProviderCollection->getUserMountsForProviderClasses($user, $providerNames); $this->userMountCache->registerMounts($user, $mount, $providerNames); + $this->usersMountsUpdated[$user->getUID()] = true; $updatingProviders = false; } @@ -730,6 +737,7 @@ public function tearDown() { $this->setupUserMountProviders = []; $this->setupMountProviderPaths = []; $this->fullSetupRequired = []; + $this->usersMountsUpdated = []; $this->rootSetup = false; $this->mountManager->clear(); $this->userMountCache->clear(); From 824c1421986ca2b4b283900bf046dd794a5d3f9b Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 15 Jan 2026 18:13:15 +0100 Subject: [PATCH 02/31] fix: also use authoritative mount info for setupForProvider Signed-off-by: Robin Appelman --- lib/private/Files/SetupManager.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/private/Files/SetupManager.php b/lib/private/Files/SetupManager.php index 163aaf7cb6c8a..ac927f48fc23e 100644 --- a/lib/private/Files/SetupManager.php +++ b/lib/private/Files/SetupManager.php @@ -692,8 +692,13 @@ public function setupForProvider(string $path, array $providers): void { } if (!$providersAreAuthoritative && $this->fullSetupRequired($user)) { - $this->setupForUser($user); - return; + if ($this->optimizeAuthoritativeProviders) { + $this->updateNonAuthoritativeProviders($user); + $this->markUserMountsCached($user); + } else { + $this->setupForUser($user); + return; + } } $this->eventLogger->start('fs:setup:user:providers', 'Setup filesystem for ' . implode(', ', $providers)); From ffa6784bba38638f813bd92fadfb15c4d1a65e2f Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 18 Dec 2025 13:34:00 +0100 Subject: [PATCH 03/31] feat: perform share mount validation on share instead of on mount Signed-off-by: Robin Appelman --- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + .../files_sharing/lib/AppInfo/Application.php | 11 + .../lib/Listener/SharesUpdatedListener.php | 66 ++++++ apps/files_sharing/lib/MountProvider.php | 30 ++- .../lib/ShareTargetValidator.php | 9 +- apps/files_sharing/lib/SharedMount.php | 6 +- apps/files_sharing/tests/ApiTest.php | 2 + .../tests/ShareTargetValidatorTest.php | 141 +++++++++++++ apps/files_sharing/tests/SharedMountTest.php | 191 +----------------- .../files_sharing/tests/SharedStorageTest.php | 97 --------- apps/files_versions/tests/VersioningTest.php | 2 + .../sharing_features/sharing-v1-part2.feature | 12 +- .../sharing_features/sharing-v1.feature | 2 + lib/private/Files/Config/UserMountCache.php | 9 + tests/lib/TestCase.php | 3 + 16 files changed, 270 insertions(+), 313 deletions(-) create mode 100644 apps/files_sharing/lib/Listener/SharesUpdatedListener.php create mode 100644 apps/files_sharing/tests/ShareTargetValidatorTest.php diff --git a/apps/files_sharing/composer/composer/autoload_classmap.php b/apps/files_sharing/composer/composer/autoload_classmap.php index 6750f63164d97..0ba6ba1b8167f 100644 --- a/apps/files_sharing/composer/composer/autoload_classmap.php +++ b/apps/files_sharing/composer/composer/autoload_classmap.php @@ -70,6 +70,7 @@ 'OCA\\Files_Sharing\\Listener\\LoadPublicFileRequestAuthListener' => $baseDir . '/../lib/Listener/LoadPublicFileRequestAuthListener.php', 'OCA\\Files_Sharing\\Listener\\LoadSidebarListener' => $baseDir . '/../lib/Listener/LoadSidebarListener.php', '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\\UserShareAcceptanceListener' => $baseDir . '/../lib/Listener/UserShareAcceptanceListener.php', 'OCA\\Files_Sharing\\Middleware\\OCSShareAPIMiddleware' => $baseDir . '/../lib/Middleware/OCSShareAPIMiddleware.php', diff --git a/apps/files_sharing/composer/composer/autoload_static.php b/apps/files_sharing/composer/composer/autoload_static.php index 922bc6d7fd3a3..03906cda0473b 100644 --- a/apps/files_sharing/composer/composer/autoload_static.php +++ b/apps/files_sharing/composer/composer/autoload_static.php @@ -85,6 +85,7 @@ class ComposerStaticInitFiles_Sharing 'OCA\\Files_Sharing\\Listener\\LoadPublicFileRequestAuthListener' => __DIR__ . '/..' . '/../lib/Listener/LoadPublicFileRequestAuthListener.php', 'OCA\\Files_Sharing\\Listener\\LoadSidebarListener' => __DIR__ . '/..' . '/../lib/Listener/LoadSidebarListener.php', '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\\UserShareAcceptanceListener' => __DIR__ . '/..' . '/../lib/Listener/UserShareAcceptanceListener.php', 'OCA\\Files_Sharing\\Middleware\\OCSShareAPIMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/OCSShareAPIMiddleware.php', diff --git a/apps/files_sharing/lib/AppInfo/Application.php b/apps/files_sharing/lib/AppInfo/Application.php index 084c33b5fed42..9701191f018fc 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -14,6 +14,7 @@ use OCA\Files\Event\LoadSidebar; use OCA\Files_Sharing\Capabilities; use OCA\Files_Sharing\Config\ConfigLexicon; +use OCA\Files_Sharing\Event\UserShareAccessUpdatedEvent; use OCA\Files_Sharing\External\Manager; use OCA\Files_Sharing\External\MountProvider as ExternalMountProvider; use OCA\Files_Sharing\Helper; @@ -24,6 +25,7 @@ use OCA\Files_Sharing\Listener\LoadPublicFileRequestAuthListener; use OCA\Files_Sharing\Listener\LoadSidebarListener; use OCA\Files_Sharing\Listener\ShareInteractionListener; +use OCA\Files_Sharing\Listener\SharesUpdatedListener; use OCA\Files_Sharing\Listener\UserAddedToGroupListener; use OCA\Files_Sharing\Listener\UserShareAcceptanceListener; use OCA\Files_Sharing\Middleware\OCSShareAPIMiddleware; @@ -49,9 +51,11 @@ use OCP\Group\Events\GroupChangedEvent; use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserAddedEvent; +use OCP\Group\Events\UserRemovedEvent; use OCP\IConfig; use OCP\IDBConnection; use OCP\IGroup; +use OCP\Share\Events\BeforeShareDeletedEvent; use OCP\Share\Events\ShareCreatedEvent; use OCP\User\Events\UserChangedEvent; use OCP\User\Events\UserDeletedEvent; @@ -111,6 +115,13 @@ function () use ($c) { // File request auth $context->registerEventListener(BeforeTemplateRenderedEvent::class, LoadPublicFileRequestAuthListener::class); + // Update mounts + $context->registerEventListener(ShareCreatedEvent::class, SharesUpdatedListener::class); + $context->registerEventListener(BeforeShareDeletedEvent::class, SharesUpdatedListener::class); + $context->registerEventListener(UserAddedEvent::class, SharesUpdatedListener::class); + $context->registerEventListener(UserRemovedEvent::class, SharesUpdatedListener::class); + $context->registerEventListener(UserShareAccessUpdatedEvent::class, SharesUpdatedListener::class); + $context->registerConfigLexicon(ConfigLexicon::class); } diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php new file mode 100644 index 0000000000000..9a7941888d43b --- /dev/null +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -0,0 +1,66 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_Sharing\Listener; + +use OCA\Files_Sharing\Event\UserShareAccessUpdatedEvent; +use OCA\Files_Sharing\MountProvider; +use OCA\Files_Sharing\ShareTargetValidator; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Config\ICachedMountInfo; +use OCP\Files\Config\IUserMountCache; +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\IManager; + +/** + * Listen to various events that can change what shares a user has access to + * + * @template-implements IEventListener + */ +class SharesUpdatedListener implements IEventListener { + public function __construct( + private readonly IManager $shareManager, + private readonly IUserMountCache $userMountCache, + private readonly MountProvider $shareMountProvider, + private readonly ShareTargetValidator $shareTargetValidator, + ) { + } + + public function handle(Event $event): void { + if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent || $event instanceof UserShareAccessUpdatedEvent) { + $this->updateForUser($event->getUser()); + } + if ($event instanceof ShareCreatedEvent || $event instanceof BeforeShareDeletedEvent) { + foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) { + $this->updateForUser($user); + } + } + } + + private function updateForUser(IUser $user): void { + $cachedMounts = $this->userMountCache->getMountsForUser($user); + $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); + $mountsByPath = array_combine($mountPoints, $cachedMounts); + + $shares = $this->shareMountProvider->getSuperSharesForUser($user); + + foreach ($shares as &$share) { + [$parentShare, $groupedShares] = $share; + $mountPoint = '/' . $user->getUID() . '/files/' . trim($parentShare->getTarget(), '/') . '/'; + $mountKey = $parentShare->getNodeId() . '::' . $mountPoint; + if (!isset($cachedMounts[$mountKey])) { + $this->shareTargetValidator->verifyMountPoint($user, $parentShare, $mountsByPath, $groupedShares); + } + } + } +} diff --git a/apps/files_sharing/lib/MountProvider.php b/apps/files_sharing/lib/MountProvider.php index 005bf9caf4212..790ea11db2920 100644 --- a/apps/files_sharing/lib/MountProvider.php +++ b/apps/files_sharing/lib/MountProvider.php @@ -53,6 +53,14 @@ public function __construct( * @return IMountPoint[] */ public function getMountsForUser(IUser $user, IStorageFactory $loader) { + return array_values($this->getMountsFromSuperShares($user, $this->getSuperSharesForUser($user), $loader)); + } + + /** + * @param IUser $user + * @return list}> Tuple of [superShare, groupedShares] + */ + public function getSuperSharesForUser(IUser $user): array { $userId = $user->getUID(); $shares = $this->mergeIterables( $this->shareManager->getSharedWith($userId, IShare::TYPE_USER, null, -1), @@ -63,16 +71,7 @@ public function getMountsForUser(IUser $user, IStorageFactory $loader) { ); $shares = $this->filterShares($shares, $userId); - $superShares = $this->buildSuperShares($shares, $user); - - return array_values( - $this->getMountsFromSuperShares( - $userId, - $superShares, - $loader, - $user, - ), - ); + return $this->buildSuperShares($shares, $user); } /** @@ -254,18 +253,18 @@ private function adjustTarget( } /** * @param string $userId - * @param array $superShares + * @param list}> $superShares * @param IStorageFactory $loader * @param IUser $user * @return array IMountPoint indexed by mount point * @throws Exception */ - private function getMountsFromSuperShares( - string $userId, + public function getMountsFromSuperShares( + IUser $user, array $superShares, IStorageFactory $loader, - IUser $user, ): array { + $userId = $user->getUID(); $allMounts = $this->mountManager->getAll(); $mounts = []; $view = new View('/' . $userId . '/files'); @@ -312,7 +311,6 @@ private function getMountsFromSuperShares( 'sharingDisabledForUser' => $sharingDisabledForUser ], $loader, - $view, $this->eventDispatcher, $user, ); @@ -399,7 +397,7 @@ public function getMountsForPath( $shares = $this->filterShares($shares, $userId); $superShares = $this->buildSuperShares($shares, $user); - return $this->getMountsFromSuperShares($userId, $superShares, $loader, $user); + return $this->getMountsFromSuperShares($user, $superShares, $loader); } /** diff --git a/apps/files_sharing/lib/ShareTargetValidator.php b/apps/files_sharing/lib/ShareTargetValidator.php index 3dbd3877819c4..7534cabc33a36 100644 --- a/apps/files_sharing/lib/ShareTargetValidator.php +++ b/apps/files_sharing/lib/ShareTargetValidator.php @@ -13,6 +13,7 @@ use OC\Files\View; use OCP\Cache\CappedMemoryCache; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Config\ICachedMountInfo; use OCP\Files\Mount\IMountManager; use OCP\Files\Mount\IMountPoint; use OCP\IUser; @@ -46,7 +47,7 @@ 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 array $allCachedMounts Other mounts for the user, indexed by path * @param IShare[] $childShares * @return string */ @@ -105,7 +106,7 @@ public function verifyMountPoint( /** - * @param IMountPoint[] $allCachedMounts + * @param ICachedMountInfo[] $allCachedMounts */ public function generateUniqueTarget( int $shareNodeId, @@ -131,7 +132,7 @@ public function generateUniqueTarget( } /** - * @param IMountPoint[] $allCachedMounts + * @param ICachedMountInfo[] $allCachedMounts */ private function hasConflictingMount(int $shareNodeId, array $allCachedMounts, string $absolutePath): bool { if (!isset($allCachedMounts[$absolutePath . '/'])) { @@ -139,7 +140,7 @@ private function hasConflictingMount(int $shareNodeId, array $allCachedMounts, s } $mount = $allCachedMounts[$absolutePath . '/']; - if ($mount instanceof SharedMount && $mount->getShare()->getNodeId() === $shareNodeId) { + 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/lib/SharedMount.php b/apps/files_sharing/lib/SharedMount.php index 4dff3bcf3b75b..8759272eb60b5 100644 --- a/apps/files_sharing/lib/SharedMount.php +++ b/apps/files_sharing/lib/SharedMount.php @@ -11,7 +11,6 @@ use OC\Files\Filesystem; use OC\Files\Mount\MountPoint; use OC\Files\Mount\MoveableMount; -use OC\Files\View; use OCA\Files_Sharing\Exceptions\BrokenPath; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Events\InvalidateMountCacheEvent; @@ -41,7 +40,6 @@ public function __construct( $storage, $arguments, IStorageFactory $loader, - private View $recipientView, private IEventDispatcher $eventDispatcher, private IUser $user, ) { @@ -188,4 +186,8 @@ public function getNumericStorageId() { public function getMountType() { return 'shared'; } + + public function getUser(): IUser { + return $this->user; + } } diff --git a/apps/files_sharing/tests/ApiTest.php b/apps/files_sharing/tests/ApiTest.php index 40f768ffab495..e0340b3ce0015 100644 --- a/apps/files_sharing/tests/ApiTest.php +++ b/apps/files_sharing/tests/ApiTest.php @@ -826,6 +826,8 @@ public function testGetShareMultipleSharedFolder(): void { $share3->setStatus(IShare::STATUS_ACCEPTED); $this->shareManager->updateShare($share3); + $this->logout(); + // $request = $this->createRequest(['path' => $this->subfolder]); $ocs = $this->createOCS(self::TEST_FILES_SHARING_API_USER2); $result1 = $ocs->getShares('false', 'false', 'false', $this->subfolder); diff --git a/apps/files_sharing/tests/ShareTargetValidatorTest.php b/apps/files_sharing/tests/ShareTargetValidatorTest.php new file mode 100644 index 0000000000000..18fcd35ed285c --- /dev/null +++ b/apps/files_sharing/tests/ShareTargetValidatorTest.php @@ -0,0 +1,141 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_Sharing\Tests; + +use OCA\Files_Sharing\ShareTargetValidator; +use OCP\Constants; +use OCP\Files\Config\ICachedMountInfo; +use OCP\Files\Folder; +use OCP\IUser; +use OCP\Server; +use OCP\Share\IShare; + +#[\PHPUnit\Framework\Attributes\Group('DB')] +class ShareTargetValidatorTest extends TestCase { + private ShareTargetValidator $targetValidator; + + private IUser $user2; + protected string $folder2; + + protected function setUp(): void { + parent::setUp(); + + $this->folder = '/folder_share_storage_test'; + $this->folder2 = '/folder_share_storage_test2'; + + $this->filename = '/share-api-storage.txt'; + + + $this->view->mkdir($this->folder); + $this->view->mkdir($this->folder2); + + // save file with content + $this->view->file_put_contents($this->filename, 'root file'); + $this->view->file_put_contents($this->folder . $this->filename, 'file in subfolder'); + $this->view->file_put_contents($this->folder2 . $this->filename, 'file in subfolder2'); + + $this->targetValidator = Server::get(ShareTargetValidator::class); + $this->user2 = $this->createMock(IUser::class); + $this->user2->method('getUID') + ->willReturn(self::TEST_FILES_SHARING_API_USER2); + } + + + /** + * test if the mount point moves up if the parent folder no longer exists + */ + public function testShareMountLoseParentFolder(): void { + // share to user + $share = $this->share( + IShare::TYPE_USER, + $this->folder, + self::TEST_FILES_SHARING_API_USER1, + self::TEST_FILES_SHARING_API_USER2, + Constants::PERMISSION_ALL); + $this->shareManager->acceptShare($share, self::TEST_FILES_SHARING_API_USER2); + + $share->setTarget('/foo/bar' . $this->folder); + $this->shareManager->moveShare($share, self::TEST_FILES_SHARING_API_USER2); + + $share = $this->shareManager->getShareById($share->getFullId()); + $this->assertSame('/foo/bar' . $this->folder, $share->getTarget()); + + $this->targetValidator->verifyMountPoint($this->user2, $share, [], [$share]); + + $share = $this->shareManager->getShareById($share->getFullId()); + $this->assertSame($this->folder, $share->getTarget()); + + //cleanup + self::loginHelper(self::TEST_FILES_SHARING_API_USER1); + $this->shareManager->deleteShare($share); + $this->view->unlink($this->folder); + } + + /** + * test if the mount point gets renamed if a folder exists at the target + */ + public function testShareMountOverFolder(): void { + self::loginHelper(self::TEST_FILES_SHARING_API_USER2); + $this->view2->mkdir('bar'); + + self::loginHelper(self::TEST_FILES_SHARING_API_USER1); + + // share to user + $share = $this->share( + IShare::TYPE_USER, + $this->folder, + self::TEST_FILES_SHARING_API_USER1, + self::TEST_FILES_SHARING_API_USER2, + Constants::PERMISSION_ALL); + $this->shareManager->acceptShare($share, self::TEST_FILES_SHARING_API_USER2); + + $share->setTarget('/bar'); + $this->shareManager->moveShare($share, self::TEST_FILES_SHARING_API_USER2); + + $share = $this->shareManager->getShareById($share->getFullId()); + + $this->targetValidator->verifyMountPoint($this->user2, $share, [], [$share]); + + $share = $this->shareManager->getShareById($share->getFullId()); + $this->assertSame('/bar (2)', $share->getTarget()); + + //cleanup + self::loginHelper(self::TEST_FILES_SHARING_API_USER1); + $this->shareManager->deleteShare($share); + $this->view->unlink($this->folder); + } + + /** + * test if the mount point gets renamed if another share exists at the target + */ + public function testShareMountOverShare(): void { + // share to user + $share2 = $this->share( + IShare::TYPE_USER, + $this->folder2, + self::TEST_FILES_SHARING_API_USER1, + self::TEST_FILES_SHARING_API_USER2, + Constants::PERMISSION_ALL); + $this->shareManager->acceptShare($share2, self::TEST_FILES_SHARING_API_USER2); + + $conflictingMount = $this->createMock(ICachedMountInfo::class); + $this->targetValidator->verifyMountPoint($this->user2, $share2, [ + '/' . $this->user2->getUID() . '/files' . $this->folder2 . '/' => $conflictingMount + ], [$share2]); + + $share2 = $this->shareManager->getShareById($share2->getFullId()); + + $this->assertSame("{$this->folder2} (2)", $share2->getTarget()); + + //cleanup + self::loginHelper(self::TEST_FILES_SHARING_API_USER1); + $this->shareManager->deleteShare($share2); + $this->view->unlink($this->folder); + } +} diff --git a/apps/files_sharing/tests/SharedMountTest.php b/apps/files_sharing/tests/SharedMountTest.php index 48cd940bf50c5..020b6ec41c5c8 100644 --- a/apps/files_sharing/tests/SharedMountTest.php +++ b/apps/files_sharing/tests/SharedMountTest.php @@ -8,12 +8,8 @@ namespace OCA\Files_Sharing\Tests; use OC\Files\Filesystem; -use OC\Files\View; -use OC\Memcache\ArrayCache; -use OCA\Files_Sharing\MountProvider; use OCA\Files_Sharing\SharedMount; use OCP\Constants; -use OCP\ICacheFactory; use OCP\IDBConnection; use OCP\IGroupManager; use OCP\IUserManager; @@ -25,14 +21,10 @@ */ #[\PHPUnit\Framework\Attributes\Group(name: 'SLOWDB')] class SharedMountTest extends TestCase { + private IGroupManager $groupManager; + private IUserManager $userManager; - /** @var IGroupManager */ - private $groupManager; - - /** @var IUserManager */ - private $userManager; - - private $folder2; + private string $folder2; protected function setUp(): void { parent::setUp(); @@ -68,78 +60,6 @@ protected function tearDown(): void { parent::tearDown(); } - /** - * test if the mount point moves up if the parent folder no longer exists - */ - public function testShareMountLoseParentFolder(): void { - - // share to user - $share = $this->share( - IShare::TYPE_USER, - $this->folder, - self::TEST_FILES_SHARING_API_USER1, - self::TEST_FILES_SHARING_API_USER2, - Constants::PERMISSION_ALL); - $this->shareManager->acceptShare($share, self::TEST_FILES_SHARING_API_USER2); - - $share->setTarget('/foo/bar' . $this->folder); - $this->shareManager->moveShare($share, self::TEST_FILES_SHARING_API_USER2); - - $share = $this->shareManager->getShareById($share->getFullId()); - $this->assertSame('/foo/bar' . $this->folder, $share->getTarget()); - - self::loginHelper(self::TEST_FILES_SHARING_API_USER2); - // share should have moved up - - $share = $this->shareManager->getShareById($share->getFullId()); - $this->assertSame($this->folder, $share->getTarget()); - - //cleanup - self::loginHelper(self::TEST_FILES_SHARING_API_USER1); - $this->shareManager->deleteShare($share); - $this->view->unlink($this->folder); - } - - public function testDeleteParentOfMountPoint(): void { - // share to user - $share = $this->share( - IShare::TYPE_USER, - $this->folder, - self::TEST_FILES_SHARING_API_USER1, - self::TEST_FILES_SHARING_API_USER2, - Constants::PERMISSION_ALL - ); - - self::loginHelper(self::TEST_FILES_SHARING_API_USER2); - $user2View = new View('/' . self::TEST_FILES_SHARING_API_USER2 . '/files'); - $this->assertTrue($user2View->file_exists($this->folder)); - - // create a local folder - $result = $user2View->mkdir('localfolder'); - $this->assertTrue($result); - - // move mount point to local folder - $result = $user2View->rename($this->folder, '/localfolder/' . $this->folder); - $this->assertTrue($result); - - // mount point in the root folder should no longer exist - $this->assertFalse($user2View->is_dir($this->folder)); - - // delete the local folder - $result = $user2View->unlink('/localfolder'); - $this->assertTrue($result); - - //enforce reload of the mount points - self::loginHelper(self::TEST_FILES_SHARING_API_USER2); - - //mount point should be back at the root - $this->assertTrue($user2View->is_dir($this->folder)); - - //cleanup - self::loginHelper(self::TEST_FILES_SHARING_API_USER1); - $this->view->unlink($this->folder); - } - public function testMoveSharedFile(): void { $share = $this->share( IShare::TYPE_USER, @@ -313,111 +233,6 @@ public function testPermissionUpgradeOnUserDeletedGroupShare(): void { $testGroup->removeUser($user2); $testGroup->removeUser($user3); } - - /** - * test if the mount point gets renamed if a folder exists at the target - */ - public function testShareMountOverFolder(): void { - self::loginHelper(self::TEST_FILES_SHARING_API_USER2); - $this->view2->mkdir('bar'); - - self::loginHelper(self::TEST_FILES_SHARING_API_USER1); - - // share to user - $share = $this->share( - IShare::TYPE_USER, - $this->folder, - self::TEST_FILES_SHARING_API_USER1, - self::TEST_FILES_SHARING_API_USER2, - Constants::PERMISSION_ALL); - $this->shareManager->acceptShare($share, self::TEST_FILES_SHARING_API_USER2); - - $share->setTarget('/bar'); - $this->shareManager->moveShare($share, self::TEST_FILES_SHARING_API_USER2); - - $share = $this->shareManager->getShareById($share->getFullId()); - - self::loginHelper(self::TEST_FILES_SHARING_API_USER2); - // share should have been moved - - $share = $this->shareManager->getShareById($share->getFullId()); - $this->assertSame('/bar (2)', $share->getTarget()); - - //cleanup - self::loginHelper(self::TEST_FILES_SHARING_API_USER1); - $this->shareManager->deleteShare($share); - $this->view->unlink($this->folder); - } - - /** - * test if the mount point gets renamed if another share exists at the target - */ - public function testShareMountOverShare(): void { - // create a shared cache - $caches = []; - $cacheFactory = $this->createMock(ICacheFactory::class); - $cacheFactory->method('createLocal') - ->willReturnCallback(function (string $prefix) use (&$caches) { - if (!isset($caches[$prefix])) { - $caches[$prefix] = new ArrayCache($prefix); - } - return $caches[$prefix]; - }); - $cacheFactory->method('createDistributed') - ->willReturnCallback(function (string $prefix) use (&$caches) { - if (!isset($caches[$prefix])) { - $caches[$prefix] = new ArrayCache($prefix); - } - return $caches[$prefix]; - }); - - // hack to overwrite the cache factory, we can't use the proper "overwriteService" since the mount provider is created before this test is called - $mountProvider = Server::get(MountProvider::class); - $reflectionClass = new \ReflectionClass($mountProvider); - $reflectionCacheFactory = $reflectionClass->getProperty('cacheFactory'); - $reflectionCacheFactory->setValue($mountProvider, $cacheFactory); - - // share to user - $share = $this->share( - IShare::TYPE_USER, - $this->folder, - self::TEST_FILES_SHARING_API_USER1, - self::TEST_FILES_SHARING_API_USER2, - Constants::PERMISSION_ALL); - $this->shareManager->acceptShare($share, self::TEST_FILES_SHARING_API_USER2); - - $share->setTarget('/foobar'); - $this->shareManager->moveShare($share, self::TEST_FILES_SHARING_API_USER2); - - - // share to user - $share2 = $this->share( - IShare::TYPE_USER, - $this->folder2, - self::TEST_FILES_SHARING_API_USER1, - self::TEST_FILES_SHARING_API_USER2, - Constants::PERMISSION_ALL); - $this->shareManager->acceptShare($share2, self::TEST_FILES_SHARING_API_USER2); - - $share2->setTarget('/foobar'); - $this->shareManager->moveShare($share2, self::TEST_FILES_SHARING_API_USER2); - - self::loginHelper(self::TEST_FILES_SHARING_API_USER2); - // one of the shares should have been moved - - $share = $this->shareManager->getShareById($share->getFullId()); - $share2 = $this->shareManager->getShareById($share2->getFullId()); - - // we don't know or care which share got the "(2)" just that one of them did - $this->assertNotEquals($share->getTarget(), $share2->getTarget()); - $this->assertSame('/foobar', min($share->getTarget(), $share2->getTarget())); - $this->assertSame('/foobar (2)', max($share->getTarget(), $share2->getTarget())); - - //cleanup - self::loginHelper(self::TEST_FILES_SHARING_API_USER1); - $this->shareManager->deleteShare($share); - $this->view->unlink($this->folder); - } } class DummyTestClassSharedMount extends SharedMount { diff --git a/apps/files_sharing/tests/SharedStorageTest.php b/apps/files_sharing/tests/SharedStorageTest.php index ada3d84734c7d..cd0a06f24363e 100644 --- a/apps/files_sharing/tests/SharedStorageTest.php +++ b/apps/files_sharing/tests/SharedStorageTest.php @@ -10,7 +10,6 @@ use OC\Files\Cache\FailedCache; use OC\Files\Filesystem; use OC\Files\Storage\FailedStorage; -use OC\Files\Storage\Storage; use OC\Files\Storage\Temporary; use OC\Files\View; use OCA\Files_Sharing\SharedStorage; @@ -60,51 +59,6 @@ protected function tearDown(): void { parent::tearDown(); } - /** - * if the parent of the mount point is gone then the mount point should move up - */ - public function testParentOfMountPointIsGone(): void { - - // share to user - $share = $this->share( - IShare::TYPE_USER, - $this->folder, - self::TEST_FILES_SHARING_API_USER1, - self::TEST_FILES_SHARING_API_USER2, - Constants::PERMISSION_ALL - ); - - self::loginHelper(self::TEST_FILES_SHARING_API_USER2); - $user2View = new View('/' . self::TEST_FILES_SHARING_API_USER2 . '/files'); - $this->assertTrue($user2View->file_exists($this->folder)); - - // create a local folder - $result = $user2View->mkdir('localfolder'); - $this->assertTrue($result); - - // move mount point to local folder - $result = $user2View->rename($this->folder, '/localfolder/' . $this->folder); - $this->assertTrue($result); - - // mount point in the root folder should no longer exist - $this->assertFalse($user2View->is_dir($this->folder)); - - // delete the local folder - /** @var Storage $storage */ - [$storage, $internalPath] = Filesystem::resolvePath('/' . self::TEST_FILES_SHARING_API_USER2 . '/files/localfolder'); - $storage->rmdir($internalPath); - - //enforce reload of the mount points - self::loginHelper(self::TEST_FILES_SHARING_API_USER2); - - //mount point should be back at the root - $this->assertTrue($user2View->is_dir($this->folder)); - - //cleanup - self::loginHelper(self::TEST_FILES_SHARING_API_USER1); - $this->view->unlink($this->folder); - } - public function testRenamePartFile(): void { // share to user @@ -466,57 +420,6 @@ public function testMoveFromStorage(): void { $this->shareManager->deleteShare($share); } - public function testNameConflict(): void { - self::loginHelper(self::TEST_FILES_SHARING_API_USER1); - $view1 = new View('/' . self::TEST_FILES_SHARING_API_USER1 . '/files'); - $view1->mkdir('foo'); - - self::loginHelper(self::TEST_FILES_SHARING_API_USER3); - $view3 = new View('/' . self::TEST_FILES_SHARING_API_USER3 . '/files'); - $view3->mkdir('foo'); - - // share a folder with the same name from two different users to the same user - self::loginHelper(self::TEST_FILES_SHARING_API_USER1); - - $share1 = $this->share( - IShare::TYPE_GROUP, - 'foo', - self::TEST_FILES_SHARING_API_USER1, - self::TEST_FILES_SHARING_API_GROUP1, - Constants::PERMISSION_ALL - ); - $this->shareManager->acceptShare($share1, self::TEST_FILES_SHARING_API_USER2); - - self::loginHelper(self::TEST_FILES_SHARING_API_USER2); - - self::loginHelper(self::TEST_FILES_SHARING_API_USER3); - - $share2 = $this->share( - IShare::TYPE_GROUP, - 'foo', - self::TEST_FILES_SHARING_API_USER3, - self::TEST_FILES_SHARING_API_GROUP1, - Constants::PERMISSION_ALL - ); - $this->shareManager->acceptShare($share2, self::TEST_FILES_SHARING_API_USER2); - - self::loginHelper(self::TEST_FILES_SHARING_API_USER2); - $view2 = new View('/' . self::TEST_FILES_SHARING_API_USER2 . '/files'); - - $this->assertTrue($view2->file_exists('/foo')); - $this->assertTrue($view2->file_exists('/foo (2)')); - - $mount = $view2->getMount('/foo'); - $this->assertInstanceOf('\OCA\Files_Sharing\SharedMount', $mount); - /** @var SharedStorage $storage */ - $storage = $mount->getStorage(); - - $this->assertEquals(self::TEST_FILES_SHARING_API_USER1, $storage->getOwner('')); - - $this->shareManager->deleteShare($share1); - $this->shareManager->deleteShare($share2); - } - public function testOwnerPermissions(): void { self::loginHelper(self::TEST_FILES_SHARING_API_USER1); diff --git a/apps/files_versions/tests/VersioningTest.php b/apps/files_versions/tests/VersioningTest.php index 6faa324f9ca1b..fdc37016b5573 100644 --- a/apps/files_versions/tests/VersioningTest.php +++ b/apps/files_versions/tests/VersioningTest.php @@ -104,6 +104,8 @@ protected function setUp(): void { \OC::registerShareHooks(Server::get(SystemConfig::class)); \OC::$server->boot(); + // ensure both users have an up-to-date state + self::loginHelper(self::TEST_VERSIONS_USER2); self::loginHelper(self::TEST_VERSIONS_USER); $this->rootView = new View(); if (!$this->rootView->file_exists(self::USERS_VERSIONS_ROOT)) { diff --git a/build/integration/sharing_features/sharing-v1-part2.feature b/build/integration/sharing_features/sharing-v1-part2.feature index a6e4c67165a0f..0c83975fc39b5 100644 --- a/build/integration/sharing_features/sharing-v1-part2.feature +++ b/build/integration/sharing_features/sharing-v1-part2.feature @@ -43,7 +43,7 @@ Feature: sharing | item_type | file | | mimetype | text/plain | | storage_id | shared::/textfile0 (2).txt | - | file_target | /textfile0.txt | + | file_target | /textfile0 (2).txt | | share_with | user2 | | share_with_displayname | user2 | @@ -84,7 +84,7 @@ Feature: sharing | item_type | file | | mimetype | text/plain | | storage_id | shared::/textfile0 (2).txt | - | file_target | /textfile0.txt | + | file_target | /textfile0 (2).txt | | share_with | user2 | | share_with_displayname | user2 | @@ -120,7 +120,7 @@ Feature: sharing | share_type | 0 | | share_with | user1 | | file_source | A_NUMBER | - | file_target | /textfile0.txt | + | file_target | /textfile0 (2).txt | | path | /textfile0.txt | | permissions | 19 | | stime | A_NUMBER | @@ -401,7 +401,7 @@ Feature: sharing | item_type | file | | mimetype | text/plain | | storage_id | shared::/FOLDER/textfile0.txt | - | file_target | /textfile0.txt | + | file_target | /textfile0 (2).txt | | share_with | user2 | | share_with_displayname | user2 | @@ -440,7 +440,7 @@ Feature: sharing | item_type | file | | mimetype | text/plain | | storage_id | shared::/FOLDER/textfile0 (2).txt | - | file_target | /textfile0.txt | + | file_target | /textfile0 (2).txt | | share_with | user2 | | share_with_displayname | user2 | @@ -887,7 +887,7 @@ Feature: sharing | share_type | 0 | | share_with | user2 | | file_source | A_NUMBER | - | file_target | /textfile0.txt | + | file_target | /textfile0 (2).txt | | path | /textfile0 (2).txt | | permissions | 19 | | stime | A_NUMBER | diff --git a/build/integration/sharing_features/sharing-v1.feature b/build/integration/sharing_features/sharing-v1.feature index 25f168db2e7cb..dad3d6ee6fd9a 100644 --- a/build/integration/sharing_features/sharing-v1.feature +++ b/build/integration/sharing_features/sharing-v1.feature @@ -559,6 +559,8 @@ Feature: sharing Scenario: getting all shares of a user using that user Given user "user0" exists And user "user1" exists + When User "user1" deletes file "/textfile0.txt" + And the HTTP status code should be "204" And file "textfile0.txt" of user "user0" is shared with user "user1" And As an "user0" When sending "GET" to "/apps/files_sharing/api/v1/shares" diff --git a/lib/private/Files/Config/UserMountCache.php b/lib/private/Files/Config/UserMountCache.php index 3250ba371e8d2..e41ba2059b8b6 100644 --- a/lib/private/Files/Config/UserMountCache.php +++ b/lib/private/Files/Config/UserMountCache.php @@ -537,4 +537,13 @@ public function addMount(IUser $user, string $mountPoint, ICacheEntry $rootCache 'mount_provider_class' => $mountProvider ]); } + + /** + * Clear the internal in-memory caches + */ + public function flush(): void { + $this->cacheInfoCache = new CappedMemoryCache(); + $this->internalPathCache = new CappedMemoryCache(); + $this->mountsForUsers = new CappedMemoryCache(); + } } diff --git a/tests/lib/TestCase.php b/tests/lib/TestCase.php index 6fb5bc309ed9b..551c1024e0bef 100644 --- a/tests/lib/TestCase.php +++ b/tests/lib/TestCase.php @@ -13,6 +13,7 @@ use OC\Files\AppData\Factory; use OC\Files\Cache\Storage; use OC\Files\Config\MountProviderCollection; +use OC\Files\Config\UserMountCache; use OC\Files\Filesystem; use OC\Files\Mount\CacheMountProvider; use OC\Files\Mount\LocalHomeMountProvider; @@ -180,6 +181,8 @@ protected function tearDown(): void { Storage::getGlobalCache()->clearCache(); } + Server::get(UserMountCache::class)->flush(); + // tearDown the traits $traits = $this->getTestTraits(); foreach ($traits as $trait) { From 7e16d3fe49cec815eac2025d52182b8bb5d78d26 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Mon, 19 Jan 2026 15:42:28 +0100 Subject: [PATCH 04/31] perf: only update shares for users once Signed-off-by: Robin Appelman --- apps/files_sharing/lib/AppInfo/Application.php | 2 ++ .../lib/Listener/SharesUpdatedListener.php | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/files_sharing/lib/AppInfo/Application.php b/apps/files_sharing/lib/AppInfo/Application.php index 9701191f018fc..9a67dad408b32 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -48,6 +48,7 @@ use OCP\Files\Events\BeforeDirectFileDownloadEvent; use OCP\Files\Events\BeforeZipCreatedEvent; use OCP\Files\Events\Node\BeforeNodeReadEvent; +use OCP\Files\Events\Node\FilesystemTornDownEvent; use OCP\Group\Events\GroupChangedEvent; use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserAddedEvent; @@ -121,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(FilesystemTornDownEvent::class, SharesUpdatedListener::class); $context->registerConfigLexicon(ConfigLexicon::class); } diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 9a7941888d43b..ba0073f41f523 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -11,10 +11,12 @@ use OCA\Files_Sharing\Event\UserShareAccessUpdatedEvent; use OCA\Files_Sharing\MountProvider; use OCA\Files_Sharing\ShareTargetValidator; +use OCP\Cache\CappedMemoryCache; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Files\Config\ICachedMountInfo; use OCP\Files\Config\IUserMountCache; +use OCP\Files\Events\Node\FilesystemTornDownEvent; use OCP\Group\Events\UserAddedEvent; use OCP\Group\Events\UserRemovedEvent; use OCP\IUser; @@ -25,18 +27,23 @@ /** * Listen to various events that can change what shares a user has access to * - * @template-implements IEventListener + * @template-implements IEventListener */ class SharesUpdatedListener implements IEventListener { + private CappedMemoryCache $updatedUsers; + public function __construct( private readonly IManager $shareManager, private readonly IUserMountCache $userMountCache, private readonly MountProvider $shareMountProvider, private readonly ShareTargetValidator $shareTargetValidator, ) { + $this->updatedUsers = new CappedMemoryCache(); } - public function handle(Event $event): void { + if ($event instanceof FilesystemTornDownEvent) { + $this->updatedUsers = new CappedMemoryCache(); + } if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent || $event instanceof UserShareAccessUpdatedEvent) { $this->updateForUser($event->getUser()); } @@ -48,6 +55,11 @@ public function handle(Event $event): void { } private function updateForUser(IUser $user): void { + if (isset($this->updatedUsers[$user->getUID()])) { + return; + } + $this->updatedUsers[$user->getUID()] = true; + $cachedMounts = $this->userMountCache->getMountsForUser($user); $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); $mountsByPath = array_combine($mountPoints, $cachedMounts); From ecbc0d837ad201c6ee9d26decba15e9f0ac4cc8b Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Mon, 19 Jan 2026 15:58:30 +0100 Subject: [PATCH 05/31] fix: adjust SharesUpdatedListener to event change Signed-off-by: Robin Appelman --- apps/files_sharing/lib/Listener/SharesUpdatedListener.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index ba0073f41f523..b7b85689f8ad7 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -44,7 +44,12 @@ public function handle(Event $event): void { if ($event instanceof FilesystemTornDownEvent) { $this->updatedUsers = new CappedMemoryCache(); } - if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent || $event instanceof UserShareAccessUpdatedEvent) { + if ($event instanceof UserShareAccessUpdatedEvent) { + foreach ($event->getUsers() as $user) { + $this->updateForUser($user); + } + } + if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent) { $this->updateForUser($event->getUser()); } if ($event instanceof ShareCreatedEvent || $event instanceof BeforeShareDeletedEvent) { From b4bc48bf91a37a8ed3640bf3db33a60e8f01a0b8 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 22 Jan 2026 19:15:29 +0100 Subject: [PATCH 06/31] feat: add event for tranfered shares Signed-off-by: Robin Appelman --- .../lib/Service/OwnershipTransferService.php | 4 +++ .../files_sharing/lib/AppInfo/Application.php | 2 ++ .../lib/Listener/SharesUpdatedListener.php | 9 +++-- lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + .../Share/Events/ShareTransferredEvent.php | 33 +++++++++++++++++++ 6 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 lib/public/Share/Events/ShareTransferredEvent.php diff --git a/apps/files/lib/Service/OwnershipTransferService.php b/apps/files/lib/Service/OwnershipTransferService.php index 4df53d7d6c4d0..5ee25e98ea8b6 100644 --- a/apps/files/lib/Service/OwnershipTransferService.php +++ b/apps/files/lib/Service/OwnershipTransferService.php @@ -19,6 +19,7 @@ use OCA\Files_External\Config\ConfigAdapter; use OCA\GroupFolders\Mount\GroupMountPoint; use OCP\Encryption\IManager as IEncryptionManager; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Config\IHomeMountProvider; use OCP\Files\Config\IUserMountCache; use OCP\Files\File; @@ -31,6 +32,7 @@ use OCP\IUserManager; use OCP\L10N\IFactory; use OCP\Server; +use OCP\Share\Events\ShareTransferredEvent; use OCP\Share\IManager as IShareManager; use OCP\Share\IShare; use Symfony\Component\Console\Helper\ProgressBar; @@ -53,6 +55,7 @@ public function __construct( private IUserManager $userManager, private IFactory $l10nFactory, private IRootFolder $rootFolder, + private IEventDispatcher $eventDispatcher, ) { } @@ -567,6 +570,7 @@ private function restoreShares( } catch (\Throwable $e) { $output->writeln('Could not restore share with id ' . $share->getId() . ':' . $e->getMessage() . ' : ' . $e->getTraceAsString() . ''); } + $this->eventDispatcher->dispatchTyped(new ShareTransferredEvent($share)); $progress->advance(); } $progress->finish(); diff --git a/apps/files_sharing/lib/AppInfo/Application.php b/apps/files_sharing/lib/AppInfo/Application.php index 9a67dad408b32..e4bcd952e8b90 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -58,6 +58,7 @@ use OCP\IGroup; use OCP\Share\Events\BeforeShareDeletedEvent; use OCP\Share\Events\ShareCreatedEvent; +use OCP\Share\Events\ShareTransferredEvent; use OCP\User\Events\UserChangedEvent; use OCP\User\Events\UserDeletedEvent; use OCP\Util; @@ -119,6 +120,7 @@ function () use ($c) { // Update mounts $context->registerEventListener(ShareCreatedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(BeforeShareDeletedEvent::class, SharesUpdatedListener::class); + $context->registerEventListener(ShareTransferredEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserAddedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserRemovedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserShareAccessUpdatedEvent::class, SharesUpdatedListener::class); diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index b7b85689f8ad7..f7558afc58c75 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -22,12 +22,13 @@ use OCP\IUser; use OCP\Share\Events\BeforeShareDeletedEvent; use OCP\Share\Events\ShareCreatedEvent; +use OCP\Share\Events\ShareTransferredEvent; use OCP\Share\IManager; /** * Listen to various events that can change what shares a user has access to * - * @template-implements IEventListener + * @template-implements IEventListener */ class SharesUpdatedListener implements IEventListener { private CappedMemoryCache $updatedUsers; @@ -52,7 +53,11 @@ public function handle(Event $event): void { if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent) { $this->updateForUser($event->getUser()); } - if ($event instanceof ShareCreatedEvent || $event instanceof BeforeShareDeletedEvent) { + if ( + $event instanceof ShareCreatedEvent + || $event instanceof BeforeShareDeletedEvent + || $event instanceof ShareTransferredEvent + ) { foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) { $this->updateForUser($user); } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index d0a26e865a438..9080770a00977 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -845,6 +845,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\\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', 'OCP\\Share\\Exceptions\\GenericShareException' => $baseDir . '/lib/public/Share/Exceptions/GenericShareException.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 3f3ffd36be655..4dd5b1a261578 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -886,6 +886,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\\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', 'OCP\\Share\\Exceptions\\GenericShareException' => __DIR__ . '/../../..' . '/lib/public/Share/Exceptions/GenericShareException.php', diff --git a/lib/public/Share/Events/ShareTransferredEvent.php b/lib/public/Share/Events/ShareTransferredEvent.php new file mode 100644 index 0000000000000..06a7ac3759bae --- /dev/null +++ b/lib/public/Share/Events/ShareTransferredEvent.php @@ -0,0 +1,33 @@ +share; + } +} From 7c7d0bd574d4f648b702c6d9844512a72b87d9d2 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 22 Jan 2026 19:30:19 +0100 Subject: [PATCH 07/31] fix: remove validate-user-shares-once optimization Signed-off-by: Robin Appelman --- apps/files_sharing/lib/AppInfo/Application.php | 2 -- .../lib/Listener/SharesUpdatedListener.php | 15 +-------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/apps/files_sharing/lib/AppInfo/Application.php b/apps/files_sharing/lib/AppInfo/Application.php index e4bcd952e8b90..75fa6915b889a 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -48,7 +48,6 @@ use OCP\Files\Events\BeforeDirectFileDownloadEvent; use OCP\Files\Events\BeforeZipCreatedEvent; use OCP\Files\Events\Node\BeforeNodeReadEvent; -use OCP\Files\Events\Node\FilesystemTornDownEvent; use OCP\Group\Events\GroupChangedEvent; use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserAddedEvent; @@ -124,7 +123,6 @@ function () use ($c) { $context->registerEventListener(UserAddedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserRemovedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserShareAccessUpdatedEvent::class, SharesUpdatedListener::class); - $context->registerEventListener(FilesystemTornDownEvent::class, SharesUpdatedListener::class); $context->registerConfigLexicon(ConfigLexicon::class); } diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index f7558afc58c75..b7763b3d33d75 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -11,12 +11,10 @@ use OCA\Files_Sharing\Event\UserShareAccessUpdatedEvent; use OCA\Files_Sharing\MountProvider; use OCA\Files_Sharing\ShareTargetValidator; -use OCP\Cache\CappedMemoryCache; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Files\Config\ICachedMountInfo; use OCP\Files\Config\IUserMountCache; -use OCP\Files\Events\Node\FilesystemTornDownEvent; use OCP\Group\Events\UserAddedEvent; use OCP\Group\Events\UserRemovedEvent; use OCP\IUser; @@ -28,23 +26,17 @@ /** * Listen to various events that can change what shares a user has access to * - * @template-implements IEventListener + * @template-implements IEventListener */ class SharesUpdatedListener implements IEventListener { - private CappedMemoryCache $updatedUsers; - public function __construct( private readonly IManager $shareManager, private readonly IUserMountCache $userMountCache, private readonly MountProvider $shareMountProvider, private readonly ShareTargetValidator $shareTargetValidator, ) { - $this->updatedUsers = new CappedMemoryCache(); } public function handle(Event $event): void { - if ($event instanceof FilesystemTornDownEvent) { - $this->updatedUsers = new CappedMemoryCache(); - } if ($event instanceof UserShareAccessUpdatedEvent) { foreach ($event->getUsers() as $user) { $this->updateForUser($user); @@ -65,11 +57,6 @@ public function handle(Event $event): void { } private function updateForUser(IUser $user): void { - if (isset($this->updatedUsers[$user->getUID()])) { - return; - } - $this->updatedUsers[$user->getUID()] = true; - $cachedMounts = $this->userMountCache->getMountsForUser($user); $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); $mountsByPath = array_combine($mountPoints, $cachedMounts); From 563ff9f4310705d8024f03d092566ee4c28cf89a Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 23 Jan 2026 14:52:20 +0100 Subject: [PATCH 08/31] fix: prevent recursion in SharesUpdatedListener Signed-off-by: Robin Appelman --- .../lib/Listener/SharesUpdatedListener.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index b7763b3d33d75..7d70d89affb82 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -29,6 +29,8 @@ * @template-implements IEventListener */ class SharesUpdatedListener implements IEventListener { + private array $inUpdate = []; + public function __construct( private readonly IManager $shareManager, private readonly IUserMountCache $userMountCache, @@ -57,6 +59,12 @@ public function handle(Event $event): void { } private function updateForUser(IUser $user): void { + // prevent recursion + if (isset($this->inUpdate[$user->getUID()])) { + return; + } + $this->inUpdate[$user->getUID()] = true; + $cachedMounts = $this->userMountCache->getMountsForUser($user); $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); $mountsByPath = array_combine($mountPoints, $cachedMounts); @@ -71,5 +79,7 @@ private function updateForUser(IUser $user): void { $this->shareTargetValidator->verifyMountPoint($user, $parentShare, $mountsByPath, $groupedShares); } } + + unset($this->inUpdate[$user->getUID()]); } } From d718d68e264b7dd0f40b513cadf07ef5c9f00f92 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 13 Jan 2026 15:14:36 +0100 Subject: [PATCH 09/31] feat: implement authoritative mount provider for share provider Signed-off-by: Robin Appelman --- .../lib/Listener/SharesUpdatedListener.php | 31 ++++++++++++++----- apps/files_sharing/lib/MountProvider.php | 19 ++++++------ .../lib/Repair/CleanupShareTarget.php | 9 ++++-- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 7d70d89affb82..ccef71bad5c87 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -15,6 +15,7 @@ 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; @@ -36,50 +37,64 @@ public function __construct( private readonly IUserMountCache $userMountCache, private readonly MountProvider $shareMountProvider, private readonly ShareTargetValidator $shareTargetValidator, + private readonly IStorageFactory $storageFactory, ) { } public function handle(Event $event): void { if ($event instanceof UserShareAccessUpdatedEvent) { foreach ($event->getUsers() as $user) { - $this->updateForUser($user); + $this->updateForUser($user, true); } } if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent) { - $this->updateForUser($event->getUser()); + $this->updateForUser($event->getUser(), true); } if ( $event instanceof ShareCreatedEvent - || $event instanceof BeforeShareDeletedEvent || $event instanceof ShareTransferredEvent ) { foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) { - $this->updateForUser($user); + $this->updateForUser($user, true); + } + } + if ($event instanceof BeforeShareDeletedEvent) { + foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) { + $this->updateForUser($user, false, [$event->getShare()]); } } } - private function updateForUser(IUser $user): void { + 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); + $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])) { - $this->shareTargetValidator->verifyMountPoint($user, $parentShare, $mountsByPath, $groupedShares); + $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()]); } } diff --git a/apps/files_sharing/lib/MountProvider.php b/apps/files_sharing/lib/MountProvider.php index 790ea11db2920..68202483dbcd4 100644 --- a/apps/files_sharing/lib/MountProvider.php +++ b/apps/files_sharing/lib/MountProvider.php @@ -12,6 +12,7 @@ use OC\Files\View; use OCA\Files_Sharing\Event\ShareMountedEvent; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Config\IAuthoritativeMountProvider; use OCP\Files\Config\IMountProvider; use OCP\Files\Config\IPartialMountProvider; use OCP\Files\Mount\IMountManager; @@ -27,7 +28,7 @@ use function count; -class MountProvider implements IMountProvider, IPartialMountProvider { +class MountProvider implements IMountProvider, IAuthoritativeMountProvider, IPartialMountProvider { /** * @param IConfig $config @@ -58,9 +59,10 @@ public function getMountsForUser(IUser $user, IStorageFactory $loader) { /** * @param IUser $user + * @param list $excludeShares * @return list}> Tuple of [superShare, groupedShares] */ - public function getSuperSharesForUser(IUser $user): array { + public function getSuperSharesForUser(IUser $user, array $excludeShares = []): array { $userId = $user->getUID(); $shares = $this->mergeIterables( $this->shareManager->getSharedWith($userId, IShare::TYPE_USER, null, -1), @@ -70,7 +72,8 @@ public function getSuperSharesForUser(IUser $user): array { $this->shareManager->getSharedWith($userId, IShare::TYPE_DECK, null, -1), ); - $shares = $this->filterShares($shares, $userId); + $excludeShareIds = array_map(fn (IShare $share) => $share->getFullId(), $excludeShares); + $shares = $this->filterShares($shares, $userId, $excludeShareIds); return $this->buildSuperShares($shares, $user); } @@ -292,12 +295,6 @@ public function getMountsFromSuperShares( } $shareId = (int)$parentShare->getId(); - $absMountPoint = '/' . $user->getUID() . '/files/' . trim($parentShare->getTarget(), '/') . '/'; - - // after the mountpoint is verified for the first time, only new mountpoints (e.g. groupfolders can overwrite the target) - if ($shareId > $maxValidatedShare || isset($allMounts[$absMountPoint])) { - $this->shareTargetValidator->verifyMountPoint($user, $parentShare, $allMounts, $groupedShares); - } $mount = new SharedMount( '\OCA\Files_Sharing\SharedStorage', @@ -347,14 +344,16 @@ public function getMountsFromSuperShares( * user has no permissions. * * @param iterable $shares + * @param list $excludeShareIds * @return iterable */ - private function filterShares(iterable $shares, string $userId): iterable { + private function filterShares(iterable $shares, string $userId, array $excludeShareIds = []): iterable { foreach ($shares as $share) { if ( $share->getPermissions() > 0 && $share->getShareOwner() !== $userId && $share->getSharedBy() !== $userId + && !in_array($share->getFullId(), $excludeShareIds) ) { yield $share; } diff --git a/apps/files_sharing/lib/Repair/CleanupShareTarget.php b/apps/files_sharing/lib/Repair/CleanupShareTarget.php index d32d5dc21d258..845e24e036725 100644 --- a/apps/files_sharing/lib/Repair/CleanupShareTarget.php +++ b/apps/files_sharing/lib/Repair/CleanupShareTarget.php @@ -11,8 +11,9 @@ use OC\Files\SetupManager; use OCA\Files_Sharing\ShareTargetValidator; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\Config\ICachedMountInfo; +use OCP\Files\Config\IUserMountCache; use OCP\Files\IRootFolder; -use OCP\Files\Mount\IMountManager; use OCP\Files\NotFoundException; use OCP\ICacheFactory; use OCP\IDBConnection; @@ -42,7 +43,7 @@ public function __construct( private readonly ShareTargetValidator $shareTargetValidator, private readonly IUserManager $userManager, private readonly SetupManager $setupManager, - private readonly IMountManager $mountManager, + private readonly IUserMountCache $userMountCache, private readonly IRootFolder $rootFolder, private readonly LoggerInterface $logger, private readonly ICacheFactory $cacheFactory, @@ -85,7 +86,9 @@ public function run(IOutput $output) { $this->setupManager->tearDown(); $this->setupManager->setupForUser($recipient); - $userMounts = $this->mountManager->getAll(); + $mounts = $this->userMountCache->getMountsForUser($recipient); + $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $mounts); + $userMounts = array_combine($mountPoints, $mounts); $userFolder = $this->rootFolder->getUserFolder($recipient->getUID()); } From aa9697cdefa1d3a9c6300a51ed6654ddc6daf7b2 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 23 Jan 2026 16:01:24 +0100 Subject: [PATCH 10/31] fix: only validate mounts for new share Signed-off-by: Robin Appelman --- .../lib/Listener/SharesUpdatedListener.php | 32 +++++++++++++++---- lib/private/Files/FileInfo.php | 10 ++++-- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index ccef71bad5c87..fea681bd31cad 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -8,6 +8,7 @@ namespace OCA\Files_Sharing\Listener; +use OC\Files\FileInfo; use OCA\Files_Sharing\Event\UserShareAccessUpdatedEvent; use OCA\Files_Sharing\MountProvider; use OCA\Files_Sharing\ShareTargetValidator; @@ -23,6 +24,7 @@ use OCP\Share\Events\ShareCreatedEvent; use OCP\Share\Events\ShareTransferredEvent; use OCP\Share\IManager; +use OCP\Share\IShare; /** * Listen to various events that can change what shares a user has access to @@ -49,12 +51,15 @@ public function handle(Event $event): void { if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent) { $this->updateForUser($event->getUser(), true); } - if ( - $event instanceof ShareCreatedEvent - || $event instanceof ShareTransferredEvent - ) { - foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) { - $this->updateForUser($user, true); + if ($event instanceof ShareCreatedEvent || $event instanceof ShareTransferredEvent) { + $share = $event->getShare(); + $shareTarget = $share->getTarget(); + foreach ($this->shareManager->getUsersForShare($share) as $user) { + if ($share->getSharedBy() !== $user->getUID()) { + $this->updateForShare($user, $share); + // Share target validation might have changed the target, restore it for the next user + $share->setTarget($shareTarget); + } } } if ($event instanceof BeforeShareDeletedEvent) { @@ -97,4 +102,19 @@ private function updateForUser(IUser $user, bool $verifyMountPoints, array $igno unset($this->inUpdate[$user->getUID()]); } + + private function updateForShare(IUser $user, IShare $share): void { + $cachedMounts = $this->userMountCache->getMountsForUser($user); + $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); + $mountsByPath = array_combine($mountPoints, $cachedMounts); + + $target = $this->shareTargetValidator->verifyMountPoint($user, $share, $mountsByPath, [$share]); + $mountPoint = '/' . $user->getUID() . '/files/' . trim($target, '/') . '/'; + + $fileInfo = $share->getNode(); + if (!$fileInfo instanceof FileInfo) { + throw new \Exception("share node is the wrong fileinfo"); + } + $this->userMountCache->addMount($user, $mountPoint, $fileInfo->getData(), MountProvider::class); + } } diff --git a/lib/private/Files/FileInfo.php b/lib/private/Files/FileInfo.php index 967d404b8a4f0..cc01c8f6c79fc 100644 --- a/lib/private/Files/FileInfo.php +++ b/lib/private/Files/FileInfo.php @@ -7,8 +7,8 @@ */ namespace OC\Files; +use OC\Files\Cache\CacheEntry; use OC\Files\Mount\HomeMountPoint; -use OCA\Files_Sharing\External\Mount; use OCA\Files_Sharing\ISharedMountPoint; use OCP\Files\Cache\ICacheEntry; use OCP\Files\Mount\IMountPoint; @@ -223,8 +223,12 @@ public function getType() { return $this->data['type']; } - public function getData() { - return $this->data; + public function getData(): ICacheEntry { + if ($this->data instanceof ICacheEntry) { + return $this->data; + } else { + return new CacheEntry($this->data); + } } /** From 92274d90ccf249065ad71465987fc2b4c8f2fac3 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 12 Feb 2026 15:19:18 +0100 Subject: [PATCH 11/31] 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 9080770a00977..e3e911a77bb51 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -467,6 +467,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 4dd5b1a261578..5935c5ad3ae33 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -508,6 +508,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 ac927f48fc23e..4aab010f42357 100644 --- a/lib/private/Files/SetupManager.php +++ b/lib/private/Files/SetupManager.php @@ -43,6 +43,7 @@ use OCP\Files\Events\InvalidateMountCacheEvent; use OCP\Files\Events\Node\BeforeNodeRenamedEvent; use OCP\Files\Events\Node\FilesystemTornDownEvent; +use OCP\Files\Events\UserHomeSetupEvent; use OCP\Files\Mount\IMountManager; use OCP\Files\Mount\IMountPoint; use OCP\Files\NotFoundException; @@ -332,6 +333,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 e3060179fc80e7f66ec622f47897f786ece9a15e Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 12 Feb 2026 15:34:50 +0100 Subject: [PATCH 12/31] chore: move share recipient validation logic to a separate class Signed-off-by: Robin Appelman --- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + .../lib/Listener/SharesUpdatedListener.php | 73 ++-------------- .../lib/ShareRecipientUpdater.php | 86 +++++++++++++++++++ 4 files changed, 94 insertions(+), 67 deletions(-) create mode 100644 apps/files_sharing/lib/ShareRecipientUpdater.php diff --git a/apps/files_sharing/composer/composer/autoload_classmap.php b/apps/files_sharing/composer/composer/autoload_classmap.php index 0ba6ba1b8167f..c3dea9de9ce0b 100644 --- a/apps/files_sharing/composer/composer/autoload_classmap.php +++ b/apps/files_sharing/composer/composer/autoload_classmap.php @@ -98,6 +98,7 @@ 'OCA\\Files_Sharing\\Settings\\Personal' => $baseDir . '/../lib/Settings/Personal.php', 'OCA\\Files_Sharing\\ShareBackend\\File' => $baseDir . '/../lib/ShareBackend/File.php', 'OCA\\Files_Sharing\\ShareBackend\\Folder' => $baseDir . '/../lib/ShareBackend/Folder.php', + 'OCA\\Files_Sharing\\ShareRecipientUpdater' => $baseDir . '/../lib/ShareRecipientUpdater.php', 'OCA\\Files_Sharing\\ShareTargetValidator' => $baseDir . '/../lib/ShareTargetValidator.php', 'OCA\\Files_Sharing\\SharedMount' => $baseDir . '/../lib/SharedMount.php', 'OCA\\Files_Sharing\\SharedStorage' => $baseDir . '/../lib/SharedStorage.php', diff --git a/apps/files_sharing/composer/composer/autoload_static.php b/apps/files_sharing/composer/composer/autoload_static.php index 03906cda0473b..57cf4cc29fba1 100644 --- a/apps/files_sharing/composer/composer/autoload_static.php +++ b/apps/files_sharing/composer/composer/autoload_static.php @@ -113,6 +113,7 @@ class ComposerStaticInitFiles_Sharing 'OCA\\Files_Sharing\\Settings\\Personal' => __DIR__ . '/..' . '/../lib/Settings/Personal.php', 'OCA\\Files_Sharing\\ShareBackend\\File' => __DIR__ . '/..' . '/../lib/ShareBackend/File.php', 'OCA\\Files_Sharing\\ShareBackend\\Folder' => __DIR__ . '/..' . '/../lib/ShareBackend/Folder.php', + 'OCA\\Files_Sharing\\ShareRecipientUpdater' => __DIR__ . '/..' . '/../lib/ShareRecipientUpdater.php', 'OCA\\Files_Sharing\\ShareTargetValidator' => __DIR__ . '/..' . '/../lib/ShareTargetValidator.php', 'OCA\\Files_Sharing\\SharedMount' => __DIR__ . '/..' . '/../lib/SharedMount.php', 'OCA\\Files_Sharing\\SharedStorage' => __DIR__ . '/..' . '/../lib/SharedStorage.php', diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index fea681bd31cad..3cfe3c3a3a5bf 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -8,23 +8,16 @@ namespace OCA\Files_Sharing\Listener; -use OC\Files\FileInfo; use OCA\Files_Sharing\Event\UserShareAccessUpdatedEvent; -use OCA\Files_Sharing\MountProvider; -use OCA\Files_Sharing\ShareTargetValidator; +use OCA\Files_Sharing\ShareRecipientUpdater; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; -use OCP\Files\Config\ICachedMountInfo; -use OCP\Files\Config\IUserMountCache; -use OCP\Files\Storage\IStorageFactory; use OCP\Group\Events\UserAddedEvent; use OCP\Group\Events\UserRemovedEvent; -use OCP\IUser; use OCP\Share\Events\BeforeShareDeletedEvent; use OCP\Share\Events\ShareCreatedEvent; use OCP\Share\Events\ShareTransferredEvent; use OCP\Share\IManager; -use OCP\Share\IShare; /** * Listen to various events that can change what shares a user has access to @@ -32,31 +25,26 @@ * @template-implements IEventListener */ class SharesUpdatedListener implements IEventListener { - private array $inUpdate = []; - public function __construct( private readonly IManager $shareManager, - private readonly IUserMountCache $userMountCache, - private readonly MountProvider $shareMountProvider, - private readonly ShareTargetValidator $shareTargetValidator, - private readonly IStorageFactory $storageFactory, + private readonly ShareRecipientUpdater $shareUpdater, ) { } public function handle(Event $event): void { if ($event instanceof UserShareAccessUpdatedEvent) { foreach ($event->getUsers() as $user) { - $this->updateForUser($user, true); + $this->shareUpdater->updateForUser($user, true); } } if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent) { - $this->updateForUser($event->getUser(), true); + $this->shareUpdater->updateForUser($event->getUser(), true); } if ($event instanceof ShareCreatedEvent || $event instanceof ShareTransferredEvent) { $share = $event->getShare(); $shareTarget = $share->getTarget(); foreach ($this->shareManager->getUsersForShare($share) as $user) { if ($share->getSharedBy() !== $user->getUID()) { - $this->updateForShare($user, $share); + $this->shareUpdater->updateForShare($user, $share); // Share target validation might have changed the target, restore it for the next user $share->setTarget($shareTarget); } @@ -64,57 +52,8 @@ public function handle(Event $event): void { } if ($event instanceof BeforeShareDeletedEvent) { foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) { - $this->updateForUser($user, false, [$event->getShare()]); - } - } - } - - private function updateForUser(IUser $user, bool $verifyMountPoints, array $ignoreShares = []): void { - // prevent recursion - if (isset($this->inUpdate[$user->getUID()])) { - return; - } - $this->inUpdate[$user->getUID()] = true; - $cachedMounts = $this->userMountCache->getMountsForUser($user); - $shareMounts = array_filter($cachedMounts, fn (ICachedMountInfo $mount) => $mount->getMountProvider() === MountProvider::class); - $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); - $mountsByPath = array_combine($mountPoints, $cachedMounts); - - $shares = $this->shareMountProvider->getSuperSharesForUser($user, $ignoreShares); - - $mountsChanged = count($shares) !== count($shareMounts); - foreach ($shares as &$share) { - [$parentShare, $groupedShares] = $share; - $mountPoint = '/' . $user->getUID() . '/files/' . trim($parentShare->getTarget(), '/') . '/'; - $mountKey = $parentShare->getNodeId() . '::' . $mountPoint; - if (!isset($cachedMounts[$mountKey])) { - $mountsChanged = true; - if ($verifyMountPoints) { - $this->shareTargetValidator->verifyMountPoint($user, $parentShare, $mountsByPath, $groupedShares); - } + $this->shareManager->updateForUser($user, false, [$event->getShare()]); } } - - if ($mountsChanged) { - $newMounts = $this->shareMountProvider->getMountsFromSuperShares($user, $shares, $this->storageFactory); - $this->userMountCache->registerMounts($user, $newMounts, [MountProvider::class]); - } - - unset($this->inUpdate[$user->getUID()]); - } - - private function updateForShare(IUser $user, IShare $share): void { - $cachedMounts = $this->userMountCache->getMountsForUser($user); - $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); - $mountsByPath = array_combine($mountPoints, $cachedMounts); - - $target = $this->shareTargetValidator->verifyMountPoint($user, $share, $mountsByPath, [$share]); - $mountPoint = '/' . $user->getUID() . '/files/' . trim($target, '/') . '/'; - - $fileInfo = $share->getNode(); - if (!$fileInfo instanceof FileInfo) { - throw new \Exception("share node is the wrong fileinfo"); - } - $this->userMountCache->addMount($user, $mountPoint, $fileInfo->getData(), MountProvider::class); } } diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php new file mode 100644 index 0000000000000..979dc41dfb4b7 --- /dev/null +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -0,0 +1,86 @@ +inUpdate[$user->getUID()])) { + return; + } + $this->inUpdate[$user->getUID()] = true; + + $cachedMounts = $this->userMountCache->getMountsForUser($user); + $shareMounts = array_filter($cachedMounts, fn (ICachedMountInfo $mount) => $mount->getMountProvider() === MountProvider::class); + $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); + $mountsByPath = array_combine($mountPoints, $cachedMounts); + + $shares = $this->shareMountProvider->getSuperSharesForUser($user, $ignoreShares); + + // the share mounts have changed if either the number of shares doesn't matched the number of share mounts + // or there is a share for which we don't have a mount yet. + $mountsChanged = count($shares) !== count($shareMounts); + foreach ($shares as &$share) { + [$parentShare, $groupedShares] = $share; + $mountPoint = '/' . $user->getUID() . '/files/' . trim($parentShare->getTarget(), '/') . '/'; + $mountKey = $parentShare->getNodeId() . '::' . $mountPoint; + if (!isset($cachedMounts[$mountKey])) { + $mountsChanged = true; + if ($verifyMountPoints) { + $this->shareTargetValidator->verifyMountPoint($user, $parentShare, $mountsByPath, $groupedShares); + } + } + } + + if ($mountsChanged) { + $newMounts = $this->shareMountProvider->getMountsFromSuperShares($user, $shares, $this->storageFactory); + $this->userMountCache->registerMounts($user, $newMounts, [MountProvider::class]); + } + + unset($this->inUpdate[$user->getUID()]); + } + + /** + * Validate a single received share for a user + */ + public function updateForShare(IUser $user, IShare $share): void { + $cachedMounts = $this->userMountCache->getMountsForUser($user); + $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); + $mountsByPath = array_combine($mountPoints, $cachedMounts); + + $target = $this->shareTargetValidator->verifyMountPoint($user, $share, $mountsByPath, [$share]); + $mountPoint = '/' . $user->getUID() . '/files/' . trim($target, '/') . '/'; + + $fileInfo = $share->getNode(); + if (!$fileInfo instanceof FileInfo) { + throw new \Exception('share node is the wrong fileinfo'); + } + $this->userMountCache->addMount($user, $mountPoint, $fileInfo->getData(), MountProvider::class); + } +} From 08bec7ef055a4865884f967489bcc94b9028cc8c Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 12 Feb 2026 15:42:16 +0100 Subject: [PATCH 13/31] feat: postpone receiving share validation after processing a certain number of users Signed-off-by: Robin Appelman --- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + .../files_sharing/lib/AppInfo/Application.php | 3 ++ .../lib/Config/ConfigLexicon.php | 10 +++- .../lib/Listener/SharesUpdatedListener.php | 53 ++++++++++++++++--- .../lib/Listener/UserHomeSetupListener.php | 44 +++++++++++++++ 6 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 apps/files_sharing/lib/Listener/UserHomeSetupListener.php diff --git a/apps/files_sharing/composer/composer/autoload_classmap.php b/apps/files_sharing/composer/composer/autoload_classmap.php index c3dea9de9ce0b..138746aad8f83 100644 --- a/apps/files_sharing/composer/composer/autoload_classmap.php +++ b/apps/files_sharing/composer/composer/autoload_classmap.php @@ -72,6 +72,7 @@ 'OCA\\Files_Sharing\\Listener\\ShareInteractionListener' => $baseDir . '/../lib/Listener/ShareInteractionListener.php', 'OCA\\Files_Sharing\\Listener\\SharesUpdatedListener' => $baseDir . '/../lib/Listener/SharesUpdatedListener.php', 'OCA\\Files_Sharing\\Listener\\UserAddedToGroupListener' => $baseDir . '/../lib/Listener/UserAddedToGroupListener.php', + 'OCA\\Files_Sharing\\Listener\\UserHomeSetupListener' => $baseDir . '/../lib/Listener/UserHomeSetupListener.php', 'OCA\\Files_Sharing\\Listener\\UserShareAcceptanceListener' => $baseDir . '/../lib/Listener/UserShareAcceptanceListener.php', 'OCA\\Files_Sharing\\Middleware\\OCSShareAPIMiddleware' => $baseDir . '/../lib/Middleware/OCSShareAPIMiddleware.php', 'OCA\\Files_Sharing\\Middleware\\ShareInfoMiddleware' => $baseDir . '/../lib/Middleware/ShareInfoMiddleware.php', diff --git a/apps/files_sharing/composer/composer/autoload_static.php b/apps/files_sharing/composer/composer/autoload_static.php index 57cf4cc29fba1..3decf0b9c1acc 100644 --- a/apps/files_sharing/composer/composer/autoload_static.php +++ b/apps/files_sharing/composer/composer/autoload_static.php @@ -87,6 +87,7 @@ class ComposerStaticInitFiles_Sharing 'OCA\\Files_Sharing\\Listener\\ShareInteractionListener' => __DIR__ . '/..' . '/../lib/Listener/ShareInteractionListener.php', 'OCA\\Files_Sharing\\Listener\\SharesUpdatedListener' => __DIR__ . '/..' . '/../lib/Listener/SharesUpdatedListener.php', 'OCA\\Files_Sharing\\Listener\\UserAddedToGroupListener' => __DIR__ . '/..' . '/../lib/Listener/UserAddedToGroupListener.php', + 'OCA\\Files_Sharing\\Listener\\UserHomeSetupListener' => __DIR__ . '/..' . '/../lib/Listener/UserHomeSetupListener.php', 'OCA\\Files_Sharing\\Listener\\UserShareAcceptanceListener' => __DIR__ . '/..' . '/../lib/Listener/UserShareAcceptanceListener.php', 'OCA\\Files_Sharing\\Middleware\\OCSShareAPIMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/OCSShareAPIMiddleware.php', 'OCA\\Files_Sharing\\Middleware\\ShareInfoMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/ShareInfoMiddleware.php', diff --git a/apps/files_sharing/lib/AppInfo/Application.php b/apps/files_sharing/lib/AppInfo/Application.php index 75fa6915b889a..0cdb2e71f2458 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -27,6 +27,7 @@ use OCA\Files_Sharing\Listener\ShareInteractionListener; use OCA\Files_Sharing\Listener\SharesUpdatedListener; use OCA\Files_Sharing\Listener\UserAddedToGroupListener; +use OCA\Files_Sharing\Listener\UserHomeSetupListener; use OCA\Files_Sharing\Listener\UserShareAcceptanceListener; use OCA\Files_Sharing\Middleware\OCSShareAPIMiddleware; use OCA\Files_Sharing\Middleware\ShareInfoMiddleware; @@ -48,6 +49,7 @@ use OCP\Files\Events\BeforeDirectFileDownloadEvent; use OCP\Files\Events\BeforeZipCreatedEvent; use OCP\Files\Events\Node\BeforeNodeReadEvent; +use OCP\Files\Events\UserHomeSetupEvent; use OCP\Group\Events\GroupChangedEvent; use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserAddedEvent; @@ -123,6 +125,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 2b8f96ba480af008f2623522c78387569db6869f Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 17 Feb 2026 00:04:17 +0100 Subject: [PATCH 14/31] test: add test for delayed share validate Signed-off-by: Robin Appelman --- .../features/bootstrap/SharingContext.php | 2 + .../sharing_features/sharing-v1-part2.feature | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/build/integration/features/bootstrap/SharingContext.php b/build/integration/features/bootstrap/SharingContext.php index c442317a32a38..2652667f02590 100644 --- a/build/integration/features/bootstrap/SharingContext.php +++ b/build/integration/features/bootstrap/SharingContext.php @@ -32,6 +32,8 @@ protected function resetAppConfigs() { $this->deleteServerConfig('core', 'shareapi_allow_federation_on_public_shares'); $this->deleteServerConfig('files_sharing', 'outgoing_server2server_share_enabled'); $this->deleteServerConfig('core', 'shareapi_allow_view_without_download'); + $this->deleteServerConfig('files_sharing', 'update_single_cutoff'); + $this->deleteServerConfig('files_sharing', 'update_all_cutoff'); $this->runOcc(['config:system:delete', 'share_folder']); } diff --git a/build/integration/sharing_features/sharing-v1-part2.feature b/build/integration/sharing_features/sharing-v1-part2.feature index 0c83975fc39b5..a2b7682db1d73 100644 --- a/build/integration/sharing_features/sharing-v1-part2.feature +++ b/build/integration/sharing_features/sharing-v1-part2.feature @@ -47,6 +47,50 @@ Feature: sharing | share_with | user2 | | share_with_displayname | user2 | +Scenario: getting all shares of a file with a received share after revoking the resharing rights with delayed share check + Given user "user0" exists + And parameter "update_single_cutoff" of app "files_sharing" is set to "0" + And parameter "update_all_cutoff" of app "files_sharing" is set to "0" + And user "user1" exists + And user "user2" exists + And file "textfile0.txt" of user "user1" is shared with user "user0" + And user "user0" accepts last share + And Updating last share with + | permissions | 1 | + And file "textfile0.txt" of user "user1" is shared with user "user2" + When As an "user0" + And sending "GET" to "/apps/files_sharing/api/v1/shares?reshares=true&path=/textfile0 (2).txt" + Then the list of returned shares has 1 shares + And share 0 is returned with + | share_type | 0 | + | uid_owner | user1 | + | displayname_owner | user1 | + | path | /textfile0 (2).txt | + | item_type | file | + | mimetype | text/plain | + | storage_id | shared::/textfile0 (2).txt | + | file_target | /textfile0.txt | + | share_with | user2 | + | share_with_displayname | user2 | + # After user2 does an FS setup the share is renamed + When As an "user2" + And Downloading file "/textfile0 (2).txt" with range "bytes=10-18" + Then Downloaded content should be "test text" + When As an "user0" + And sending "GET" to "/apps/files_sharing/api/v1/shares?reshares=true&path=/textfile0 (2).txt" + Then the list of returned shares has 1 shares + And share 0 is returned with + | share_type | 0 | + | uid_owner | user1 | + | displayname_owner | user1 | + | path | /textfile0 (2).txt | + | item_type | file | + | mimetype | text/plain | + | storage_id | shared::/textfile0 (2).txt | + | file_target | /textfile0 (2).txt | + | share_with | user2 | + | share_with_displayname | user2 | + Scenario: getting all shares of a file with a received share also reshared after revoking the resharing rights Given user "user0" exists And user "user1" exists From 7c53aff96ff4571d575b768eadaaefcb55c48ff8 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 17 Feb 2026 17:12:54 +0100 Subject: [PATCH 15/31] 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 42c6ffa7a94a937d528116200ac695c73125e733 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Tue, 17 Feb 2026 18:19:30 +0100 Subject: [PATCH 16/31] feat: export getData for public FileInfo interface Signed-off-by: Robin Appelman --- apps/files_sharing/lib/ShareRecipientUpdater.php | 7 +------ apps/files_trashbin/lib/Trash/TrashItem.php | 5 +++++ lib/private/Files/Node/LazyFolder.php | 5 +++++ lib/private/Files/Node/Node.php | 5 +++++ lib/public/Files/FileInfo.php | 9 +++++++++ 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php index 979dc41dfb4b7..cd4f389072176 100644 --- a/apps/files_sharing/lib/ShareRecipientUpdater.php +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -8,7 +8,6 @@ namespace OCA\Files_Sharing; -use OC\Files\FileInfo; use OCP\Files\Config\ICachedMountInfo; use OCP\Files\Config\IUserMountCache; use OCP\Files\Storage\IStorageFactory; @@ -77,10 +76,6 @@ public function updateForShare(IUser $user, IShare $share): void { $target = $this->shareTargetValidator->verifyMountPoint($user, $share, $mountsByPath, [$share]); $mountPoint = '/' . $user->getUID() . '/files/' . trim($target, '/') . '/'; - $fileInfo = $share->getNode(); - if (!$fileInfo instanceof FileInfo) { - throw new \Exception('share node is the wrong fileinfo'); - } - $this->userMountCache->addMount($user, $mountPoint, $fileInfo->getData(), MountProvider::class); + $this->userMountCache->addMount($user, $mountPoint, $share->getNode()->getData(), MountProvider::class); } } diff --git a/apps/files_trashbin/lib/Trash/TrashItem.php b/apps/files_trashbin/lib/Trash/TrashItem.php index 70d5164747f0b..2864a8cd942f4 100644 --- a/apps/files_trashbin/lib/Trash/TrashItem.php +++ b/apps/files_trashbin/lib/Trash/TrashItem.php @@ -6,6 +6,7 @@ */ namespace OCA\Files_Trashbin\Trash; +use OCP\Files\Cache\ICacheEntry; use OCP\Files\FileInfo; use OCP\IUser; @@ -169,4 +170,8 @@ public function getDeletedBy(): ?IUser { public function getMetadata(): array { return $this->fileInfo->getMetadata(); } + + public function getData(): ICacheEntry { + return $this->fileInfo->getData(); + } } diff --git a/lib/private/Files/Node/LazyFolder.php b/lib/private/Files/Node/LazyFolder.php index c23a7d03ada9e..58f58c98660b2 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; @@ -565,6 +566,10 @@ public function getMetadata(): array { return $this->data['metadata'] ?? $this->__call(__FUNCTION__, func_get_args()); } + public function getData(): ICacheEntry { + return $this->__call(__FUNCTION__, func_get_args()); + } + public function verifyPath($fileName, $readonly = false): void { $this->__call(__FUNCTION__, func_get_args()); } diff --git a/lib/private/Files/Node/Node.php b/lib/private/Files/Node/Node.php index fd8d84883d964..7a7867e6a4e7a 100644 --- a/lib/private/Files/Node/Node.php +++ b/lib/private/Files/Node/Node.php @@ -12,6 +12,7 @@ use OC\Files\Utils\PathHelper; use OCP\EventDispatcher\GenericEvent; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Cache\ICacheEntry; use OCP\Files\FileInfo; use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; @@ -486,4 +487,8 @@ public function getParentId(): int { public function getMetadata(): array { return $this->fileInfo->getMetadata(); } + + public function getData(): ICacheEntry { + return $this->fileInfo->getData(); + } } diff --git a/lib/public/Files/FileInfo.php b/lib/public/Files/FileInfo.php index 95419d6354ac4..117408f23bc63 100644 --- a/lib/public/Files/FileInfo.php +++ b/lib/public/Files/FileInfo.php @@ -8,6 +8,7 @@ namespace OCP\Files; use OCP\AppFramework\Attribute\Consumable; +use OCP\Files\Cache\ICacheEntry; use OCP\Files\Storage\IStorage; /** @@ -298,4 +299,12 @@ public function getParentId(): int; * @since 28.0.0 */ public function getMetadata(): array; + + /** + * Get the filecache data for the file + * + * @return ICacheEntry + * @since 34.0.0 + */ + public function getData(): ICacheEntry; } From 41825529e71428ee32b70e9b3f58af5cf952dbd5 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 18 Feb 2026 16:57:11 +0100 Subject: [PATCH 17/31] fix: clear in-memory cached mounts for user when adding/removing mounts Signed-off-by: Robin Appelman --- lib/private/Files/Config/UserMountCache.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/private/Files/Config/UserMountCache.php b/lib/private/Files/Config/UserMountCache.php index e41ba2059b8b6..4b58055f2cf05 100644 --- a/lib/private/Files/Config/UserMountCache.php +++ b/lib/private/Files/Config/UserMountCache.php @@ -524,6 +524,12 @@ public function removeMount(string $mountPoint): void { $query->delete('mounts') ->where($query->expr()->eq('mount_point', $query->createNamedParameter($mountPoint))); $query->executeStatement(); + + $parts = explode('/', $mountPoint); + if (count($parts) > 3) { + [, $userId] = $parts; + unset($this->mountsForUsers[$userId]); + } } public function addMount(IUser $user, string $mountPoint, ICacheEntry $rootCacheEntry, string $mountProvider, ?int $mountId = null): void { @@ -536,6 +542,7 @@ public function addMount(IUser $user, string $mountPoint, ICacheEntry $rootCache 'mount_id' => $mountId, 'mount_provider_class' => $mountProvider ]); + unset($this->mountsForUsers[$user->getUID()]); } /** From ca87da8cd957a9cd25681cb1063fb97c6d1be33a Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 19 Feb 2026 17:17:05 +0100 Subject: [PATCH 18/31] 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 7b3702baf7f5045f7e0626ba0b62554ac335cbb8 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 19 Feb 2026 17:28:24 +0100 Subject: [PATCH 19/31] 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 b86f1450916a2d7dad1174f1f276708cf9869bf1 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 19 Feb 2026 18:01:29 +0100 Subject: [PATCH 20/31] 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 320d0d04d102d70f0fdb7ff9ad16c86df2e7b7c2 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 19 Feb 2026 18:31:47 +0100 Subject: [PATCH 21/31] 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 3a804a4e23a166e1f61ebd58214d149a952121b8 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 20 Feb 2026 16:19:28 +0100 Subject: [PATCH 22/31] feat: use time-based cutoff for share updating instead of count Signed-off-by: Robin Appelman --- .../lib/Config/ConfigLexicon.php | 6 +- .../lib/Listener/SharesUpdatedListener.php | 56 +++++++++--------- .../lib/ShareRecipientUpdater.php | 2 +- .../tests/ShareRecipientUpdaterTest.php | 2 +- .../tests/SharesUpdatedListenerTest.php | 59 +++++++++++++++++-- apps/files_sharing/tests/TestCase.php | 3 +- .../features/bootstrap/SharingContext.php | 3 +- .../sharing_features/sharing-v1-part2.feature | 3 +- 8 files changed, 90 insertions(+), 44 deletions(-) diff --git a/apps/files_sharing/lib/Config/ConfigLexicon.php b/apps/files_sharing/lib/Config/ConfigLexicon.php index a6fb9f11ae61d..623d1340f2617 100644 --- a/apps/files_sharing/lib/Config/ConfigLexicon.php +++ b/apps/files_sharing/lib/Config/ConfigLexicon.php @@ -24,8 +24,7 @@ class ConfigLexicon implements ILexicon { public const SHOW_FEDERATED_AS_INTERNAL = 'show_federated_shares_as_internal'; public const SHOW_FEDERATED_TO_TRUSTED_AS_INTERNAL = 'show_federated_shares_to_trusted_servers_as_internal'; public const EXCLUDE_RESHARE_FROM_EDIT = 'shareapi_exclude_reshare_from_edit'; - public const UPDATE_SINGLE_CUTOFF = 'update_single_cutoff'; - public const UPDATE_ALL_CUTOFF = 'update_all_cutoff'; + public const UPDATE_CUTOFF_TIME = 'update_cutoff_time'; public const USER_NEEDS_SHARE_REFRESH = 'user_needs_share_refresh'; public function getStrictness(): Strictness { @@ -38,8 +37,7 @@ public function getAppConfigs(): array { new Entry(self::SHOW_FEDERATED_TO_TRUSTED_AS_INTERNAL, ValueType::BOOL, false, 'shows federated shares to trusted servers as internal shares', true), new Entry(self::EXCLUDE_RESHARE_FROM_EDIT, ValueType::BOOL, false, 'Exclude reshare permission from "Allow editing" bundled permissions'), - new Entry(self::UPDATE_SINGLE_CUTOFF, ValueType::INT, 10, 'For how many users do we update the share data immediately for single-share updates'), - new Entry(self::UPDATE_ALL_CUTOFF, ValueType::INT, 3, 'For how many users do we update the share data immediately for all-share updates'), + new Entry(self::UPDATE_CUTOFF_TIME, ValueType::FLOAT, 3.0, 'For how how long do we update the share data immediately before switching to only marking the user'), ]; } diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 39f9e4e928940..880fd9792b8b9 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -23,6 +23,7 @@ use OCP\Share\Events\ShareCreatedEvent; use OCP\Share\Events\ShareTransferredEvent; use OCP\Share\IManager; +use Psr\Clock\ClockInterface; /** * Listen to various events that can change what shares a user has access to @@ -31,26 +32,24 @@ */ class SharesUpdatedListener implements IEventListener { /** - * for how many users do we update the share date immediately, - * before just marking the other users when we know the relevant share + * for how long do we update the share date immediately, + * before just marking the other users */ - private int $cutOffMarkAllSingleShare; + private float $cutOffMarkTime; + /** - * for how many users do we update the share date immediately, - * before just marking the other users when the relevant shares are unknown + * The total amount of time we've spent so far processing updates */ - private int $cutOffMarkAllShares ; - - private int $updatedCount = 0; + private float $updatedTime = 0.0; public function __construct( private readonly IManager $shareManager, private readonly ShareRecipientUpdater $shareUpdater, private readonly IUserConfig $userConfig, + private readonly ClockInterface $clock, IAppConfig $appConfig, ) { - $this->cutOffMarkAllSingleShare = $appConfig->getValueInt(Application::APP_ID, ConfigLexicon::UPDATE_SINGLE_CUTOFF, 10); - $this->cutOffMarkAllShares = $appConfig->getValueInt(Application::APP_ID, ConfigLexicon::UPDATE_ALL_CUTOFF, 3); + $this->cutOffMarkTime = $appConfig->getValueFloat(Application::APP_ID, ConfigLexicon::UPDATE_CUTOFF_TIME, 3.0); } public function handle(Event $event): void { @@ -67,14 +66,11 @@ public function handle(Event $event): void { $shareTarget = $share->getTarget(); foreach ($this->shareManager->getUsersForShare($share) as $user) { if ($share->getSharedBy() !== $user->getUID()) { - $this->updatedCount++; - if ($this->cutOffMarkAllSingleShare === -1 || $this->updatedCount <= $this->cutOffMarkAllSingleShare) { - $this->shareUpdater->updateForShare($user, $share); - // Share target validation might have changed the target, restore it for the next user - $share->setTarget($shareTarget); - } else { - $this->markUserForRefresh($user); - } + $this->markOrRun($user, function () use ($user, $share) { + $this->shareUpdater->updateForAddedShare($user, $share); + }); + // Share target validation might have changed the target, restore it for the next user + $share->setTarget($shareTarget); } } } @@ -85,24 +81,28 @@ public function handle(Event $event): void { } } - private function updateOrMarkUser(IUser $user, bool $verifyMountPoints, array $ignoreShares = []): void { - $this->updatedCount++; - if ($this->cutOffMarkAllShares === -1 || $this->updatedCount <= $this->cutOffMarkAllShares) { - $this->shareUpdater->updateForUser($user, $verifyMountPoints, $ignoreShares); + private function markOrRun(IUser $user, callable $callback): void { + $start = floatval($this->clock->now()->format('U.u')); + if ($this->cutOffMarkTime === -1.0 || $this->updatedTime < $this->cutOffMarkTime) { + $callback(); } else { $this->markUserForRefresh($user); } + $end = floatval($this->clock->now()->format('U.u')); + $this->updatedTime += $end - $start; } - private function markUserForRefresh(IUser $user): void { - $this->userConfig->setValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true); + private function updateOrMarkUser(IUser $user, bool $verifyMountPoints, array $ignoreShares = []): void { + $this->markOrRun($user, function () use ($user, $verifyMountPoints, $ignoreShares) { + $this->shareUpdater->updateForUser($user, $verifyMountPoints, $ignoreShares); + }); } - public function setCutOffMarkAllSingleShare(int $cutOffMarkAllSingleShare): void { - $this->cutOffMarkAllSingleShare = $cutOffMarkAllSingleShare; + private function markUserForRefresh(IUser $user): void { + $this->userConfig->setValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true); } - public function setCutOffMarkAllShares(int $cutOffMarkAllShares): void { - $this->cutOffMarkAllShares = $cutOffMarkAllShares; + public function setCutOffMarkTime(float|int $cutOffMarkTime): void { + $this->cutOffMarkTime = (float)$cutOffMarkTime; } } diff --git a/apps/files_sharing/lib/ShareRecipientUpdater.php b/apps/files_sharing/lib/ShareRecipientUpdater.php index cd4f389072176..996c051749ac1 100644 --- a/apps/files_sharing/lib/ShareRecipientUpdater.php +++ b/apps/files_sharing/lib/ShareRecipientUpdater.php @@ -68,7 +68,7 @@ public function updateForUser(IUser $user, bool $verifyMountPoints = true, array /** * Validate a single received share for a user */ - public function updateForShare(IUser $user, IShare $share): void { + public function updateForAddedShare(IUser $user, IShare $share): void { $cachedMounts = $this->userMountCache->getMountsForUser($user); $mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts); $mountsByPath = array_combine($mountPoints, $cachedMounts); diff --git a/apps/files_sharing/tests/ShareRecipientUpdaterTest.php b/apps/files_sharing/tests/ShareRecipientUpdaterTest.php index e32f7d88fb414..5ad995c3b1c58 100644 --- a/apps/files_sharing/tests/ShareRecipientUpdaterTest.php +++ b/apps/files_sharing/tests/ShareRecipientUpdaterTest.php @@ -68,7 +68,7 @@ public function testUpdateForShare() { ->method('addMount') ->with($user1, '/user1/files/new-target/', $cacheEntry, MountProvider::class); - $this->updater->updateForShare($user1, $share); + $this->updater->updateForAddedShare($user1, $share); } /** diff --git a/apps/files_sharing/tests/SharesUpdatedListenerTest.php b/apps/files_sharing/tests/SharesUpdatedListenerTest.php index 3e3211dae369c..ccba9d41c2e4b 100644 --- a/apps/files_sharing/tests/SharesUpdatedListenerTest.php +++ b/apps/files_sharing/tests/SharesUpdatedListenerTest.php @@ -18,7 +18,9 @@ use OCP\Share\Events\ShareCreatedEvent; use OCP\Share\IManager; use OCP\Share\IShare; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Clock\ClockInterface; use Test\Mock\Config\MockAppConfig; use Test\Mock\Config\MockUserConfig; use Test\Traits\UserTrait; @@ -31,6 +33,8 @@ class SharesUpdatedListenerTest extends \Test\TestCase { private IManager&MockObject $manager; private IUserConfig $userConfig; private IAppConfig $appConfig; + private ClockInterface&MockObject $clock; + private $clockFn; protected function setUp(): void { parent::setUp(); @@ -38,14 +42,23 @@ protected function setUp(): void { $this->shareRecipientUpdater = $this->createMock(ShareRecipientUpdater::class); $this->manager = $this->createMock(IManager::class); $this->appConfig = new MockAppConfig([ - ConfigLexicon::UPDATE_ALL_CUTOFF => -1, - ConfigLexicon::UPDATE_SINGLE_CUTOFF => -1, + ConfigLexicon::UPDATE_CUTOFF_TIME => -1, ]); $this->userConfig = new MockUserConfig(); + $this->clock = $this->createMock(ClockInterface::class); + $this->clockFn = function () { + return new \DateTimeImmutable('@0'); + }; + $this->clock->method('now') + ->willReturnCallback(function () { + // extra wrapper so we can modify clockFn + return ($this->clockFn)(); + }); $this->sharesUpdatedListener = new SharesUpdatedListener( $this->manager, $this->shareRecipientUpdater, $this->userConfig, + $this->clock, $this->appConfig, ); } @@ -62,7 +75,7 @@ public function testShareAdded() { $this->shareRecipientUpdater ->expects($this->exactly(2)) - ->method('updateForShare') + ->method('updateForAddedShare') ->willReturnCallback(function (IUser $user, IShare $eventShare) use ($user1, $user2, $share) { $this->assertContains($user, [$user1, $user2]); $this->assertEquals($share, $eventShare); @@ -85,7 +98,7 @@ public function testShareAddedFilterOwner() { $this->shareRecipientUpdater ->expects($this->exactly(1)) - ->method('updateForShare') + ->method('updateForAddedShare') ->willReturnCallback(function (IUser $user, IShare $eventShare) use ($user2, $share) { $this->assertEquals($user, $user2); $this->assertEquals($share, $eventShare); @@ -133,4 +146,42 @@ public function testShareDeleted() { $this->sharesUpdatedListener->handle($event); } + + public static function shareMarkAfterTimeProvider(): array { + // note that each user will take exactly 1s in this test + return [ + [0, 0], + [0.9, 1], + [1.1, 2], + [-1, 2], + ]; + } + + #[DataProvider('shareMarkAfterTimeProvider')] + public function testShareMarkAfterTime(float $cutOff, int $expectedCount) { + $share = $this->createMock(IShare::class); + $user1 = $this->createUser('user1', ''); + $user2 = $this->createUser('user2', ''); + + $this->manager->method('getUsersForShare') + ->willReturn([$user1, $user2]); + + $event = new ShareCreatedEvent($share); + + $this->sharesUpdatedListener->setCutOffMarkTime($cutOff); + $time = 0; + $this->clockFn = function () use (&$time) { + $time++; + return new \DateTimeImmutable('@' . $time); + }; + + $this->shareRecipientUpdater + ->expects($this->exactly($expectedCount)) + ->method('updateForAddedShare'); + + $this->sharesUpdatedListener->handle($event); + + $this->assertEquals($expectedCount < 1, $this->userConfig->getValueBool($user1->getUID(), 'files_sharing', ConfigLexicon::USER_NEEDS_SHARE_REFRESH)); + $this->assertEquals($expectedCount < 2, $this->userConfig->getValueBool($user2->getUID(), 'files_sharing', ConfigLexicon::USER_NEEDS_SHARE_REFRESH)); + } } diff --git a/apps/files_sharing/tests/TestCase.php b/apps/files_sharing/tests/TestCase.php index 9ed3bacc39175..02ee66d096118 100644 --- a/apps/files_sharing/tests/TestCase.php +++ b/apps/files_sharing/tests/TestCase.php @@ -101,8 +101,7 @@ public static function setUpBeforeClass(): void { $groupBackend->addToGroup(self::TEST_FILES_SHARING_API_USER2, self::TEST_FILES_SHARING_API_GROUP1); Server::get(IGroupManager::class)->addBackend($groupBackend); - Server::get(SharesUpdatedListener::class)->setCutOffMarkAllShares(-1); - Server::get(SharesUpdatedListener::class)->setCutOffMarkAllSingleShare(-1); + Server::get(SharesUpdatedListener::class)->setCutOffMarkTime(-1); } protected function setUp(): void { diff --git a/build/integration/features/bootstrap/SharingContext.php b/build/integration/features/bootstrap/SharingContext.php index 2652667f02590..9f70382438467 100644 --- a/build/integration/features/bootstrap/SharingContext.php +++ b/build/integration/features/bootstrap/SharingContext.php @@ -32,8 +32,7 @@ protected function resetAppConfigs() { $this->deleteServerConfig('core', 'shareapi_allow_federation_on_public_shares'); $this->deleteServerConfig('files_sharing', 'outgoing_server2server_share_enabled'); $this->deleteServerConfig('core', 'shareapi_allow_view_without_download'); - $this->deleteServerConfig('files_sharing', 'update_single_cutoff'); - $this->deleteServerConfig('files_sharing', 'update_all_cutoff'); + $this->deleteServerConfig('files_sharing', 'update_cutoff_time'); $this->runOcc(['config:system:delete', 'share_folder']); } diff --git a/build/integration/sharing_features/sharing-v1-part2.feature b/build/integration/sharing_features/sharing-v1-part2.feature index a2b7682db1d73..36ddcba92d29a 100644 --- a/build/integration/sharing_features/sharing-v1-part2.feature +++ b/build/integration/sharing_features/sharing-v1-part2.feature @@ -49,8 +49,7 @@ Feature: sharing Scenario: getting all shares of a file with a received share after revoking the resharing rights with delayed share check Given user "user0" exists - And parameter "update_single_cutoff" of app "files_sharing" is set to "0" - And parameter "update_all_cutoff" of app "files_sharing" is set to "0" + And parameter "update_cutoff_time" of app "files_sharing" is set to "0" And user "user1" exists And user "user2" exists And file "textfile0.txt" of user "user1" is shared with user "user0" From 92515d1df1064b9696021da91494e6976f0b85a8 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 25 Feb 2026 16:28:21 +0100 Subject: [PATCH 23/31] 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 ce90ec02d464c774eba57649cee6c3707a25f542 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 25 Feb 2026 18:19:58 +0100 Subject: [PATCH 24/31] 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 e63fe87b5cc8bd249b27721b40a57dfad9d2d66d Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 25 Feb 2026 19:51:27 +0100 Subject: [PATCH 25/31] fix: update shares on group delete Signed-off-by: Robin Appelman --- .../files_sharing/lib/AppInfo/Application.php | 3 ++ .../lib/Listener/SharesUpdatedListener.php | 15 ++++++- .../features/bootstrap/Sharing.php | 40 ++++++++++++++++--- .../integration/features/bootstrap/WebDav.php | 2 +- .../sharing_features/sharing-v1-part4.feature | 32 +++++++++++++++ 5 files changed, 85 insertions(+), 7 deletions(-) diff --git a/apps/files_sharing/lib/AppInfo/Application.php b/apps/files_sharing/lib/AppInfo/Application.php index 0cdb2e71f2458..2fa251802fe1a 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -50,6 +50,7 @@ use OCP\Files\Events\BeforeZipCreatedEvent; use OCP\Files\Events\Node\BeforeNodeReadEvent; use OCP\Files\Events\UserHomeSetupEvent; +use OCP\Group\Events\BeforeGroupDeletedEvent; use OCP\Group\Events\GroupChangedEvent; use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserAddedEvent; @@ -124,6 +125,8 @@ function () use ($c) { $context->registerEventListener(ShareTransferredEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserAddedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserRemovedEvent::class, SharesUpdatedListener::class); + $context->registerEventListener(BeforeGroupDeletedEvent::class, SharesUpdatedListener::class); + $context->registerEventListener(GroupDeletedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserShareAccessUpdatedEvent::class, SharesUpdatedListener::class); $context->registerEventListener(UserHomeSetupEvent::class, UserHomeSetupListener::class); diff --git a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php index 1ab52a069ba38..e17e392c906d7 100644 --- a/apps/files_sharing/lib/Listener/SharesUpdatedListener.php +++ b/apps/files_sharing/lib/Listener/SharesUpdatedListener.php @@ -15,6 +15,8 @@ use OCP\Config\IUserConfig; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; +use OCP\Group\Events\BeforeGroupDeletedEvent; +use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserAddedEvent; use OCP\Group\Events\UserRemovedEvent; use OCP\IAppConfig; @@ -28,7 +30,8 @@ /** * Listen to various events that can change what shares a user has access to * - * @template-implements IEventListener + * @psalm-type GroupEvents = UserAddedEvent|UserRemovedEvent|GroupDeletedEvent|BeforeGroupDeletedEvent + * @template-implements IEventListener */ class SharesUpdatedListener implements IEventListener { /** @@ -58,6 +61,16 @@ public function handle(Event $event): void { $this->updateOrMarkUser($user); } } + if ($event instanceof BeforeGroupDeletedEvent) { + // ensure the group users are loaded before the group is deleted + $event->getGroup()->getUsers(); + } + if ($event instanceof GroupDeletedEvent) { + // so we can iterate them after the group is deleted + foreach ($event->getGroup()->getUsers() as $user) { + $this->updateOrMarkUser($user); + } + } if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent) { $this->updateOrMarkUser($event->getUser()); } diff --git a/build/integration/features/bootstrap/Sharing.php b/build/integration/features/bootstrap/Sharing.php index 72f6902af167e..5fe98aff35d1e 100644 --- a/build/integration/features/bootstrap/Sharing.php +++ b/build/integration/features/bootstrap/Sharing.php @@ -5,6 +5,7 @@ * SPDX-FileCopyrightText: 2016 ownCloud, Inc. * SPDX-License-Identifier: AGPL-3.0-or-later */ + use Behat\Gherkin\Node\TableNode; use GuzzleHttp\Client; use PHPUnit\Framework\Assert; @@ -13,7 +14,6 @@ require __DIR__ . '/autoload.php'; - trait Sharing { use Provisioning; @@ -566,17 +566,17 @@ public function shareXIsReturnedWith(int $number, TableNode $body) { $expectedFields = array_merge($defaultExpectedFields, $body->getRowsHash()); if (!array_key_exists('uid_file_owner', $expectedFields) - && array_key_exists('uid_owner', $expectedFields)) { + && array_key_exists('uid_owner', $expectedFields)) { $expectedFields['uid_file_owner'] = $expectedFields['uid_owner']; } if (!array_key_exists('displayname_file_owner', $expectedFields) - && array_key_exists('displayname_owner', $expectedFields)) { + && array_key_exists('displayname_owner', $expectedFields)) { $expectedFields['displayname_file_owner'] = $expectedFields['displayname_owner']; } if (array_key_exists('share_type', $expectedFields) - && $expectedFields['share_type'] == 10 /* IShare::TYPE_ROOM */ - && array_key_exists('share_with', $expectedFields)) { + && $expectedFields['share_type'] == 10 /* IShare::TYPE_ROOM */ + && array_key_exists('share_with', $expectedFields)) { if ($expectedFields['share_with'] === 'private_conversation') { $expectedFields['share_with'] = 'REGEXP /^private_conversation_[0-9a-f]{6}$/'; } else { @@ -782,4 +782,34 @@ public function getArrayOfShareesResponded(ResponseInterface $response, $shareeT } return $sharees; } + + /** + * @Then /^Share mounts for "([^"]*)" match$/ + */ + public function checkShareMounts(string $user, ?TableNode $body) { + if ($body instanceof TableNode) { + $fd = $body->getRows(); + + $expected = []; + foreach ($fd as $row) { + $expected[] = $row[0]; + } + $this->runOcc(['files:mount:list', '--output', 'json', '--cached-only', $user]); + $mounts = json_decode($this->lastStdOut, true)['cached']; + $shareMounts = array_filter($mounts, fn (array $data) => $data['provider'] === \OCA\Files_Sharing\MountProvider::class); + $actual = array_values(array_map(fn (array $data) => $data['mountpoint'], $shareMounts)); + Assert::assertEquals($expected, $actual); + } + } + + /** + * @Then /^Share mounts for "([^"]*)" are empty$/ + */ + public function checkShareMountsEmpty(string $user) { + $this->runOcc(['files:mount:list', '--output', 'json', '--cached-only', $user]); + $mounts = json_decode($this->lastStdOut, true)['cached']; + $shareMounts = array_filter($mounts, fn (array $data) => $data['provider'] === \OCA\Files_Sharing\MountProvider::class); + $actual = array_values(array_map(fn (array $data) => $data['mountpoint'], $shareMounts)); + Assert::assertEquals([], $actual); + } } diff --git a/build/integration/features/bootstrap/WebDav.php b/build/integration/features/bootstrap/WebDav.php index fb552ce785b75..fb2e441d93791 100644 --- a/build/integration/features/bootstrap/WebDav.php +++ b/build/integration/features/bootstrap/WebDav.php @@ -1011,7 +1011,7 @@ public function checkIfETAGHasChanged($path, $user) { */ public function connectingToDavEndpoint() { try { - $this->response = $this->makeDavRequest(null, 'PROPFIND', '', []); + $this->response = $this->makeDavRequest($this->currentUser, 'PROPFIND', '', []); } catch (\GuzzleHttp\Exception\ClientException $e) { $this->response = $e->getResponse(); } diff --git a/build/integration/sharing_features/sharing-v1-part4.feature b/build/integration/sharing_features/sharing-v1-part4.feature index 3b825aebd1813..48f2e2e3b123f 100644 --- a/build/integration/sharing_features/sharing-v1-part4.feature +++ b/build/integration/sharing_features/sharing-v1-part4.feature @@ -315,3 +315,35 @@ Scenario: Can copy file between shares if share permissions And the OCS status code should be "100" When User "user1" copies file "/share/test.txt" to "/re-share/movetest.txt" Then the HTTP status code should be "201" + +Scenario: Group deletes removes mount without marking + Given As an "admin" + And user "user0" exists + And user "user1" exists + And group "group0" exists + And user "user0" belongs to group "group0" + And file "textfile0.txt" of user "user1" is shared with group "group0" + And As an "user0" + Then Share mounts for "user0" match + | /user0/files/textfile0 (2).txt/ | + And group "group0" does not exist + Then Share mounts for "user0" are empty + +Scenario: Group deletes removes mount with marking + Given As an "admin" + And parameter "update_cutoff_time" of app "files_sharing" is set to "0" + And user "user0" exists + And user "user1" exists + And group "group0" exists + And user "user0" belongs to group "group0" + And file "textfile0.txt" of user "user1" is shared with group "group0" + And As an "user0" + Then Share mounts for "user0" are empty + When Connecting to dav endpoint + Then Share mounts for "user0" match + | /user0/files/textfile0 (2).txt/ | + And group "group0" does not exist + Then Share mounts for "user0" match + | /user0/files/textfile0 (2).txt/ | + When Connecting to dav endpoint + Then Share mounts for "user0" are empty From 03a18bd2105fcdc0e518561c42a652b27a8815ce Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 25 Feb 2026 20:11:29 +0100 Subject: [PATCH 26/31] 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 fad6a86cc80b6133f3ea786d20bec903bb0c89d8 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Thu, 12 Mar 2026 19:15:39 +0100 Subject: [PATCH 27/31] 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 +++++++++++--- .../tests/Repair/CleanupShareTargetTest.php | 2 ++ .../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 ++++ 11 files changed, 108 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 2fa251802fe1a..d71bb7e3b034b 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -60,6 +60,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; @@ -128,6 +129,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 e17e392c906d7..f8ac348878598 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 { /** @@ -87,6 +88,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/apps/files_sharing/tests/Repair/CleanupShareTargetTest.php b/apps/files_sharing/tests/Repair/CleanupShareTargetTest.php index 8c752c52a0c25..3e3997d83bda6 100644 --- a/apps/files_sharing/tests/Repair/CleanupShareTargetTest.php +++ b/apps/files_sharing/tests/Repair/CleanupShareTargetTest.php @@ -6,6 +6,7 @@ */ namespace OCA\Files_Sharing\Tests\Repair; +use OC\Files\Filesystem; use OC\Migration\NullOutput; use OCA\Files_Sharing\Repair\CleanupShareTarget; use OCA\Files_Sharing\Tests\TestCase; @@ -49,6 +50,7 @@ private function createUserShare(string $by, string $target = self::TEST_FOLDER_ $share->setTarget($target); $this->shareManager->moveShare($share, self::TEST_FILES_SHARING_API_USER2); + Filesystem::getMountManager()->moveMount('/' . self::TEST_FILES_SHARING_API_USER2 . '/files' . self::TEST_FOLDER_NAME . '/', '/' . self::TEST_FILES_SHARING_API_USER2 . '/files' . $target . '/'); $share = $this->shareManager->getShareById($share->getFullId()); $this->assertEquals($target, $share->getTarget()); 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 e3e911a77bb51..17093934f8710 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -846,6 +846,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 5935c5ad3ae33..2fad1de7fca89 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -887,6 +887,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 163526cdfbd1a..edc906feef7bd 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -47,6 +47,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; @@ -1191,7 +1192,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 1069d0a706c1c..7593ff45636de 100644 --- a/lib/private/Share20/Share.php +++ b/lib/private/Share20/Share.php @@ -64,6 +64,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 */ @@ -539,10 +541,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 b268fd00758f3..0fc17958df10f 100644 --- a/lib/public/Share/IShare.php +++ b/lib/public/Share/IShare.php @@ -553,6 +553,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 15cef14219f3bfce449c783ef008f70fd2756f18 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Mon, 16 Mar 2026 15:40:11 +0100 Subject: [PATCH 28/31] test: adjust tests Signed-off-by: Robin Appelman --- tests/lib/Files/ViewTest.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/lib/Files/ViewTest.php b/tests/lib/Files/ViewTest.php index 5d431764a8cae..72df3810f1909 100644 --- a/tests/lib/Files/ViewTest.php +++ b/tests/lib/Files/ViewTest.php @@ -1550,6 +1550,9 @@ private function createTestMovableMountPoints($mountPoints) { $storage->method('getStorageCache')->willReturnCallback(function () use ($storage) { return new \OC\Files\Cache\Storage($storage, true, Server::get(IDBConnection::class)); }); + $storage->method('getCache')->willReturnCallback(function () use ($storage) { + return new \OC\Files\Cache\Cache($storage); + }); $mounts[] = $this->getMockBuilder(TestMoveableMountPoint::class) ->onlyMethods(['moveMount']) @@ -1650,7 +1653,10 @@ public function testMoveMountPointIntoSharedFolder(): void { $mount2->expects($this->once()) ->method('moveMount') - ->willReturn(true); + ->willReturnCallback(function ($target) use ($mount2) { + $mount2->setMountPoint($target); + return true; + }); $view = new View('/' . $this->user . '/files/'); $view->mkdir('shareddir'); From 6464ecfa84e0d1ee0443dcc060f25e53ba73a8e5 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Mon, 16 Mar 2026 15:52:19 +0100 Subject: [PATCH 29/31] fix: don't error when moving a non-existing mount Signed-off-by: Robin Appelman --- lib/private/Files/Mount/Manager.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/private/Files/Mount/Manager.php b/lib/private/Files/Mount/Manager.php index 3d586ba4c5911..8692b9cb1926b 100644 --- a/lib/private/Files/Mount/Manager.php +++ b/lib/private/Files/Mount/Manager.php @@ -59,8 +59,10 @@ public function removeMount(string $mountPoint): void { } public function moveMount(string $mountPoint, string $target): void { - $this->mounts[$target] = $this->mounts[$mountPoint]; - unset($this->mounts[$mountPoint]); + if (isset($this->mounts[$mountPoint])) { + $this->mounts[$target] = $this->mounts[$mountPoint]; + unset($this->mounts[$mountPoint]); + } $this->pathCache->clear(); $this->inPathCache->clear(); $this->areMountsSorted = false; From 64151e9b11677c0da1514ac58d68f589ebc17df6 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Mon, 16 Mar 2026 23:07:03 +0100 Subject: [PATCH 30/31] fix: fix moving mountpoints Signed-off-by: Robin Appelman --- lib/private/Files/Mount/Manager.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/private/Files/Mount/Manager.php b/lib/private/Files/Mount/Manager.php index 8692b9cb1926b..0d95a2e9917d3 100644 --- a/lib/private/Files/Mount/Manager.php +++ b/lib/private/Files/Mount/Manager.php @@ -59,13 +59,14 @@ public function removeMount(string $mountPoint): void { } public function moveMount(string $mountPoint, string $target): void { - if (isset($this->mounts[$mountPoint])) { + if ($mountPoint !== $target && isset($this->mounts[$mountPoint])) { $this->mounts[$target] = $this->mounts[$mountPoint]; + $this->mounts[$target]->setMountPoint($target); unset($this->mounts[$mountPoint]); + $this->pathCache->clear(); + $this->inPathCache->clear(); + $this->areMountsSorted = false; } - $this->pathCache->clear(); - $this->inPathCache->clear(); - $this->areMountsSorted = false; } /** From 9b9b7e269f6a741c8674bbde0bf97619198a634b Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Mon, 16 Mar 2026 23:07:15 +0100 Subject: [PATCH 31/31] 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, '/') . '/'; + } }