Merge pull request #59177 from nextcloud/revert-59172-revert-58894-stable33-authoritative-share

[stable33] authoritative share - revival
This commit is contained in:
Stephan Orbaugh 2026-04-28 15:59:54 +02:00 committed by GitHub
commit 84e2ebec72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 2119 additions and 428 deletions

View file

@ -8,17 +8,18 @@ declare(strict_types=1);
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 @@ class ListMounts extends Command {
}
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("<error>User $userId not found</error>");
return 1;
}
$mounts = $this->mountProviderCollection->getMountsForUser($user);
$mounts[] = $this->mountProviderCollection->getHomeMountForUser($user);
/** @var array<string, IMountPoint> $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<string, IMountPoint> $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<string, ICachedMountInfo> $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);
foreach ($mounts as $mount) {
$output->writeln('<info>' . $mount->getMountPoint() . '</info>: ' . $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("\t<error>not registered</error>");
}
}
foreach ($cachedMounts as $cachedMount) {
if (!isset($mountsByMountpoint[$cachedMount->getMountPoint()])) {
$output->writeln('<info>' . $cachedMount->getMountPoint() . '</info>:');
$output->writeln("\t<error>registered but no longer provided</error>");
$output->writeln("\t- provider: " . $cachedMount->getMountProvider());
$output->writeln("\t- storage id: " . $cachedMount->getStorageId());
$output->writeln("\t- root id: " . $cachedMount->getRootId());
}
}
$format = $input->getOption('output');
if ($format === self::OUTPUT_FORMAT_PLAIN) {
foreach ($mounts as $mount) {
$output->writeln('<info>' . $mount->getMountPoint() . '</info>: ' . $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("\t<error>not registered</error>");
}
}
foreach ($cachedMounts as $cachedMount) {
if ($cachedOnly || !isset($mountsByMountPoint[$cachedMount->getMountPoint()])) {
$output->writeln('<info>' . $cachedMount->getMountPoint() . '</info>:');
if (!$cachedOnly) {
$output->writeln("\t<error>registered but no longer provided</error>");
}
$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;
}

View file

@ -19,6 +19,7 @@ use OCA\Files\Exception\TransferOwnershipException;
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\IUser;
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 @@ class OwnershipTransferService {
private IUserManager $userManager,
private IFactory $l10nFactory,
private IRootFolder $rootFolder,
private IEventDispatcher $eventDispatcher,
) {
}
@ -567,20 +570,23 @@ class OwnershipTransferService {
} catch (\Throwable $e) {
$output->writeln('<error>Could not restore share with id ' . $share->getId() . ':' . $e->getMessage() . ' : ' . $e->getTraceAsString() . '</error>');
}
$this->eventDispatcher->dispatchTyped(new ShareTransferredEvent($share));
$progress->advance();
}
$progress->finish();
$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";
@ -619,8 +625,11 @@ class OwnershipTransferService {
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);
@ -638,8 +647,11 @@ class OwnershipTransferService {
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) {
@ -652,4 +664,8 @@ class OwnershipTransferService {
$progress->finish();
$output->writeln('');
}
private function getShareMountPoint(string $uid, string $target): string {
return '/' . $uid . '/files/' . trim($target, '/') . '/';
}
}

View file

@ -75,7 +75,7 @@ class MountCacheService implements IEventListener {
public function handleDeletedStorage(StorageConfig $storage): void {
foreach ($this->applicableHelper->getUsersForStorage($storage) as $user) {
$this->userMountCache->removeMount($storage->getMountPointForUser($user));
$this->userMountCache->removeMount($storage->getMountPointForUser($user), $user);
}
}
@ -87,7 +87,7 @@ class MountCacheService implements IEventListener {
public function handleUpdatedStorage(StorageConfig $oldStorage, StorageConfig $newStorage): void {
foreach ($this->applicableHelper->diffApplicable($oldStorage, $newStorage) as $user) {
$this->userMountCache->removeMount($oldStorage->getMountPointForUser($user));
$this->userMountCache->removeMount($oldStorage->getMountPointForUser($user), $user);
}
foreach ($this->applicableHelper->diffApplicable($newStorage, $oldStorage) as $user) {
$this->registerForUser($user, $newStorage);
@ -156,7 +156,7 @@ class MountCacheService implements IEventListener {
$storages = $this->storagesService->getAllStoragesForGroup($group);
foreach ($storages as $storage) {
if (!$this->applicableHelper->isApplicableForUser($storage, $user)) {
$this->userMountCache->removeMount($storage->getMountPointForUser($user));
$this->userMountCache->removeMount($storage->getMountPointForUser($user), $user);
}
}
}
@ -181,7 +181,7 @@ class MountCacheService implements IEventListener {
private function removeGroupFromStorage(StorageConfig $storage, IGroup $group): void {
foreach ($group->searchUsers('') as $user) {
if (!$this->applicableHelper->isApplicableForUser($storage, $user)) {
$this->userMountCache->removeMount($storage->getMountPointForUser($user));
$this->userMountCache->removeMount($storage->getMountPointForUser($user), $user);
}
}
}

View file

@ -70,7 +70,9 @@ return array(
'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\\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',
@ -97,6 +99,7 @@ return array(
'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',

View file

@ -85,7 +85,9 @@ 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\\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',
@ -112,6 +114,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',

View file

@ -14,6 +14,7 @@ use OCA\Files\Event\LoadAdditionalScriptsEvent;
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,7 +25,9 @@ use OCA\Files_Sharing\Listener\LoadAdditionalListener;
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\UserHomeSetupListener;
use OCA\Files_Sharing\Listener\UserShareAcceptanceListener;
use OCA\Files_Sharing\Middleware\OCSShareAPIMiddleware;
use OCA\Files_Sharing\Middleware\ShareInfoMiddleware;
@ -46,13 +49,19 @@ use OCP\Files\Config\IMountProviderCollection;
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\BeforeGroupDeletedEvent;
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\Share\Events\ShareMovedEvent;
use OCP\Share\Events\ShareTransferredEvent;
use OCP\User\Events\UserChangedEvent;
use OCP\User\Events\UserDeletedEvent;
use OCP\Util;
@ -111,6 +120,18 @@ class Application extends App implements IBootstrap {
// 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(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(ShareMovedEvent::class, SharesUpdatedListener::class);
$context->registerEventListener(UserHomeSetupEvent::class, UserHomeSetupListener::class);
$context->registerConfigLexicon(ConfigLexicon::class);
}

View file

@ -24,6 +24,8 @@ 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_CUTOFF_TIME = 'update_cutoff_time';
public const USER_NEEDS_SHARE_REFRESH = 'user_needs_share_refresh';
public function getStrictness(): Strictness {
return Strictness::IGNORE;
@ -34,10 +36,14 @@ class ConfigLexicon implements ILexicon {
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_CUTOFF_TIME, ValueType::FLOAT, 3.0, 'For how how long do we update the share data immediately before switching to only marking the user'),
];
}
public function getUserConfigs(): array {
return [];
return [
new Entry(self::USER_NEEDS_SHARE_REFRESH, ValueType::BOOL, true, 'whether a user needs to have the receiving share data refreshed for possible changes'),
];
}
}

View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
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\BeforeGroupDeletedEvent;
use OCP\Group\Events\GroupDeletedEvent;
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\ShareMovedEvent;
use OCP\Share\Events\ShareTransferredEvent;
use OCP\Share\IManager;
use Psr\Clock\ClockInterface;
use Psr\Log\LoggerInterface;
/**
* Listen to various events that can change what shares a user has access to
*
* @psalm-type GroupEvents = UserAddedEvent|UserRemovedEvent|GroupDeletedEvent|BeforeGroupDeletedEvent
* @template-implements IEventListener<GroupEvents|ShareCreatedEvent|ShareTransferredEvent|BeforeShareDeletedEvent|UserShareAccessUpdatedEvent|ShareMovedEvent>
*/
class SharesUpdatedListener implements IEventListener {
/**
* for how long do we update the share date immediately,
* before just marking the other users
*/
private float $cutOffMarkTime;
/**
* The total amount of time we've spent so far processing updates
*/
private float $updatedTime = 0.0;
private bool $inUpdate = false;
public function __construct(
private readonly IManager $shareManager,
private readonly ShareRecipientUpdater $shareUpdater,
private readonly IUserConfig $userConfig,
private readonly ClockInterface $clock,
private readonly LoggerInterface $logger,
IAppConfig $appConfig,
private readonly UserHomeSetupListener $homeSetupListener,
) {
$this->cutOffMarkTime = $appConfig->getValueFloat(Application::APP_ID, ConfigLexicon::UPDATE_CUTOFF_TIME, 3.0);
}
public function handle(Event $event): void {
// prevent recursive updates
if ($this->inUpdate) {
return;
}
// don't trigger the on-setup checks if this handler triggers an fs setup
$oldState = $this->homeSetupListener->setDisabled(true);
if ($event instanceof UserShareAccessUpdatedEvent) {
foreach ($event->getUsers() as $user) {
$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());
}
if ($event instanceof ShareCreatedEvent || $event instanceof ShareTransferredEvent) {
$share = $event->getShare();
$shareTarget = $share->getTarget();
foreach ($this->shareManager->getUsersForShare($share) as $user) {
if ($share->getShareOwner() === $user->getUID() || $share->getSharedBy() === $user->getUID()) {
continue;
}
if ($share->getSharedBy() !== $user->getUID()) {
$this->markOrRun($user, function () use ($user, $share) {
$this->inUpdate = true;
$this->shareUpdater->updateForAddedShare($user, $share);
$this->inUpdate = false;
});
// Share target validation might have changed the target, restore it for the next user
$share->setTarget($shareTarget);
}
}
}
if ($event instanceof ShareMovedEvent) {
$share = $event->getShare();
$user = $event->getUser();
// don't trigger if the share is moved as part of the conflict resolution
if (!$this->shareUpdater->isInUpdate($user)) {
$this->markOrRun($user, function () use ($user, $share) {
$this->shareUpdater->updateForMovedShare($user, $share);
});
}
}
if ($event instanceof BeforeShareDeletedEvent) {
$share = $event->getShare();
foreach ($this->shareManager->getUsersForShare($share) as $user) {
if ($share->getShareOwner() === $user->getUID() || $share->getSharedBy() === $user->getUID()) {
continue;
}
$this->markOrRun($user, function () use ($user, $share) {
$this->shareUpdater->updateForDeletedShare($user, $share);
});
}
}
$this->homeSetupListener->setDisabled($oldState);
}
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 updateOrMarkUser(IUser $user): void {
$this->markOrRun($user, function () use ($user) {
$this->shareUpdater->updateForUser($user);
});
}
private function markUserForRefresh(IUser $user): void {
// log with exception to capture the trace
$ex = new \Exception('Marking ' . $user->getUID() . ' as needing the share mounts refreshed');
$this->logger->debug($ex->getMessage(), ['exception' => $ex]);
$this->userConfig->setValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true);
}
public function setCutOffMarkTime(float|int $cutOffMarkTime): void {
$this->cutOffMarkTime = (float)$cutOffMarkTime;
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_Sharing\Listener;
use OCA\Files_Sharing\AppInfo\Application;
use OCA\Files_Sharing\Config\ConfigLexicon;
use OCA\Files_Sharing\ShareRecipientUpdater;
use OCP\Config\IUserConfig;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\UserHomeSetupEvent;
/**
* Listen to the users filesystem setup being started, to perform any receiving share
* work that was postponed.
*
* @template-implements IEventListener<UserHomeSetupEvent>
*/
class UserHomeSetupListener implements IEventListener {
private bool $disabled = false;
public function __construct(
private readonly ShareRecipientUpdater $updater,
private readonly IUserConfig $userConfig,
) {
}
public function setDisabled(bool $disabled): bool {
$previous = $this->disabled;
$this->disabled = $disabled;
return $previous;
}
public function handle(Event $event): void {
if (!$event instanceof UserHomeSetupEvent) {
return;
}
if ($this->disabled) {
return;
}
$user = $event->getUser();
if ($this->userConfig->getValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true)) {
$this->updater->updateForUser($user);
$this->userConfig->setValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, false);
}
}
}

View file

@ -12,6 +12,7 @@ use InvalidArgumentException;
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 Psr\Log\LoggerInterface;
use function count;
class MountProvider implements IMountProvider, IPartialMountProvider {
class MountProvider implements IMountProvider, IAuthoritativeMountProvider, IPartialMountProvider {
/**
* @param IConfig $config
@ -53,6 +54,15 @@ class MountProvider implements IMountProvider, IPartialMountProvider {
* @return IMountPoint[]
*/
public function getMountsForUser(IUser $user, IStorageFactory $loader) {
return array_values($this->getMountsFromSuperShares($user, $this->getSuperSharesForUser($user), $loader));
}
/**
* @param IUser $user
* @param list<IShare> $excludeShares
* @return list<array{IShare, array<IShare>}> Tuple of [superShare, groupedShares]
*/
public function getSuperSharesForUser(IUser $user, array $excludeShares = []): array {
$userId = $user->getUID();
$shares = $this->mergeIterables(
$this->shareManager->getSharedWith($userId, IShare::TYPE_USER, null, -1),
@ -62,17 +72,9 @@ class MountProvider implements IMountProvider, IPartialMountProvider {
$this->shareManager->getSharedWith($userId, IShare::TYPE_DECK, null, -1),
);
$shares = $this->filterShares($shares, $userId);
$superShares = $this->buildSuperShares($shares, $user);
return array_values(
$this->getMountsFromSuperShares(
$userId,
$superShares,
$loader,
$user,
),
);
$excludeShareIds = array_map(fn (IShare $share) => $share->getFullId(), $excludeShares);
$shares = $this->filterShares($shares, $userId, $excludeShareIds);
return $this->buildSuperShares($shares, $user);
}
/**
@ -254,18 +256,18 @@ class MountProvider implements IMountProvider, IPartialMountProvider {
}
/**
* @param string $userId
* @param array $superShares
* @param list<array{IShare, array<IShare>}> $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');
@ -293,12 +295,6 @@ class MountProvider implements IMountProvider, IPartialMountProvider {
}
$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',
@ -312,7 +308,6 @@ class MountProvider implements IMountProvider, IPartialMountProvider {
'sharingDisabledForUser' => $sharingDisabledForUser
],
$loader,
$view,
$this->eventDispatcher,
$user,
);
@ -349,14 +344,16 @@ class MountProvider implements IMountProvider, IPartialMountProvider {
* user has no permissions.
*
* @param iterable<IShare> $shares
* @param list<string> $excludeShareIds
* @return iterable<IShare>
*/
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;
}
@ -399,7 +396,7 @@ class MountProvider implements IMountProvider, IPartialMountProvider {
$shares = $this->filterShares($shares, $userId);
$superShares = $this->buildSuperShares($shares, $user);
return $this->getMountsFromSuperShares($userId, $superShares, $loader, $user);
return $this->getMountsFromSuperShares($user, $superShares, $loader);
}
/**

View file

@ -11,8 +11,9 @@ namespace OCA\Files_Sharing\Repair;
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 @@ class CleanupShareTarget implements IRepairStep {
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 @@ class CleanupShareTarget implements IRepairStep {
$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());
}
@ -104,7 +107,7 @@ class CleanupShareTarget implements IRepairStep {
(int)$shareInfo['file_source'],
$absoluteNewTarget,
$targetParentNode->getMountPoint(),
$userMounts,
fn ($path) => $userMounts[$path] ?? null,
);
$newTarget = $userFolder->getRelativePath($absoluteNewTarget);

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_Sharing;
use OCP\Files\Config\ICachedMountInfo;
use OCP\Files\Config\IUserMountCache;
use OCP\Files\Storage\IStorageFactory;
use OCP\IUser;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager;
use OCP\Share\IShare;
class ShareRecipientUpdater {
private array $inUpdate = [];
public function __construct(
private readonly IUserMountCache $userMountCache,
private readonly MountProvider $shareMountProvider,
private readonly ShareTargetValidator $shareTargetValidator,
private readonly IStorageFactory $storageFactory,
private readonly IManager $shareManager,
) {
}
/**
* Validate all received shares for a user
*/
public function updateForUser(IUser $user): void {
// prevent recursion
if ($this->isInUpdate($user)) {
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);
// 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 = $this->getMountPointFromTarget($user, $parentShare->getTarget());
$mountKey = $parentShare->getNodeId() . '::' . $mountPoint;
if (!isset($cachedMounts[$mountKey])) {
$mountsChanged = true;
$this->shareTargetValidator->verifyMountPoint($user, $parentShare, fn ($path) => $mountsByPath[$path] ?? null, $groupedShares);
}
}
if ($mountsChanged) {
$newMounts = $this->shareMountProvider->getMountsFromSuperShares($user, $shares, $this->storageFactory);
$this->userMountCache->registerMounts($user, $newMounts, [MountProvider::class]);
}
unset($this->inUpdate[$user->getUID()]);
}
public function isInUpdate(IUser $user): bool {
return isset($this->inUpdate[$user->getUID()]);
}
/**
* Validate a single received share for a user
*/
public function updateForAddedShare(IUser $user, IShare $share): void {
$target = $this->shareTargetValidator->verifyMountPoint($user, $share, fn ($path) => $this->userMountCache->getMountAtPath($user, $path), [$share]);
$mountPoint = $this->getMountPointFromTarget($user, $target);
$this->userMountCache->addMount($user, $mountPoint, $share->getNode()->getData(), MountProvider::class);
}
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 {
try {
$userShare = $this->shareManager->getShareById($share->getFullId(), $user->getUID(), false);
$this->userMountCache->removeMount($this->getMountPointFromTarget($user, $userShare->getTarget()), $user);
} catch (ShareNotFound) {
// user doesn't actually have access to the share
}
}
/**
* 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, $user);
$this->userMountCache->addMount($user, $newMountPoint, $share->getNode()->getData(), MountProvider::class);
} else {
$this->updateForUser($user);
}
}
}

View file

@ -9,11 +9,11 @@ declare(strict_types=1);
namespace OCA\Files_Sharing;
use OC\Files\Filesystem;
use OC\Files\SetupManager;
use OC\Files\View;
use OCP\Cache\CappedMemoryCache;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Mount\IMountManager;
use OCP\Files\Config\ICachedMountInfo;
use OCP\Files\IRootFolder;
use OCP\Files\Mount\IMountPoint;
use OCP\IUser;
use OCP\Share\Events\VerifyMountPointEvent;
@ -29,8 +29,7 @@ class ShareTargetValidator {
public function __construct(
private readonly IManager $shareManager,
private readonly IEventDispatcher $eventDispatcher,
private readonly SetupManager $setupManager,
private readonly IMountManager $mountManager,
private readonly IRootFolder $rootFolder,
) {
$this->folderExistsCache = new CappedMemoryCache();
}
@ -46,28 +45,30 @@ class ShareTargetValidator {
/**
* check if the parent folder exists otherwise move the mount point up
*
* @param array<string, IMountPoint> $allCachedMounts Other mounts for the user, indexed by path
* @param callable(string):?ICachedMountInfo $getMountByPath
* @param IShare[] $childShares
* @return string
*/
public function verifyMountPoint(
IUser $user,
IShare &$share,
array $allCachedMounts,
callable $getMountByPath,
array $childShares,
): string {
$mountPoint = basename($share->getTarget());
$parent = dirname($share->getTarget());
$recipientView = $this->getViewForUser($user);
$event = new VerifyMountPointEvent($share, $recipientView, $parent);
$event = new VerifyMountPointEvent($share, $recipientView, $parent, $user);
$this->eventDispatcher->dispatchTyped($event);
$parent = $event->getParent();
/** @psalm-suppress InternalMethod */
$absoluteParent = $recipientView->getAbsolutePath($parent);
$this->setupManager->setupForPath($absoluteParent);
$parentMount = $this->mountManager->find($absoluteParent);
// the share target always has to be in the users home
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$parentMount = $userFolder->getMountPoint();
$cached = $this->folderExistsCache->get($parent);
if ($cached !== null) {
@ -78,16 +79,22 @@ class ShareTargetValidator {
$this->folderExistsCache->set($parent, $parentExists);
}
if (!$parentExists) {
$parent = Helper::getShareFolder($recipientView, $user->getUID());
/** @psalm-suppress InternalMethod */
$absoluteParent = $recipientView->getAbsolutePath($parent);
if ($event->createParent()) {
$internalPath = $parentMount->getInternalPath($absoluteParent);
$parentMount->getStorage()->mkdir($internalPath);
$parentMount->getStorage()->getUpdater()->update($internalPath);
} else {
$parent = Helper::getShareFolder($recipientView, $user->getUID());
/** @psalm-suppress InternalMethod */
$absoluteParent = $recipientView->getAbsolutePath($parent);
}
}
$newAbsoluteMountPoint = $this->generateUniqueTarget(
$share->getNodeId(),
Filesystem::normalizePath($absoluteParent . '/' . $mountPoint),
$parentMount,
$allCachedMounts,
$getMountByPath,
);
/** @psalm-suppress InternalMethod */
@ -105,13 +112,13 @@ class ShareTargetValidator {
/**
* @param IMountPoint[] $allCachedMounts
* @param callable(string):?ICachedMountInfo $getMountByPath
*/
public function generateUniqueTarget(
int $shareNodeId,
string $absolutePath,
IMountPoint $parentMount,
array $allCachedMounts,
callable $getMountByPath,
): string {
$pathInfo = pathinfo($absolutePath);
$ext = isset($pathInfo['extension']) ? '.' . $pathInfo['extension'] : '';
@ -121,7 +128,7 @@ class ShareTargetValidator {
$i = 2;
$parentCache = $parentMount->getStorage()->getCache();
$internalPath = $parentMount->getInternalPath($absolutePath);
while ($parentCache->inCache($internalPath) || $this->hasConflictingMount($shareNodeId, $allCachedMounts, $absolutePath)) {
while ($parentCache->inCache($internalPath) || $this->hasConflictingMount($shareNodeId, $getMountByPath, $absolutePath)) {
$absolutePath = Filesystem::normalizePath($dir . '/' . $name . ' (' . $i . ')' . $ext);
$internalPath = $parentMount->getInternalPath($absolutePath);
$i++;
@ -131,15 +138,15 @@ class ShareTargetValidator {
}
/**
* @param IMountPoint[] $allCachedMounts
* @param callable(string):?ICachedMountInfo $getMountByPath
*/
private function hasConflictingMount(int $shareNodeId, array $allCachedMounts, string $absolutePath): bool {
if (!isset($allCachedMounts[$absolutePath . '/'])) {
private function hasConflictingMount(int $shareNodeId, callable $getMountByPath, string $absolutePath): bool {
$mount = $getMountByPath($absolutePath . '/');
if ($mount === null) {
return false;
}
$mount = $allCachedMounts[$absolutePath . '/'];
if ($mount 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;
}

View file

@ -11,7 +11,6 @@ namespace OCA\Files_Sharing;
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 @@ class SharedMount extends MountPoint implements MoveableMount, ISharedMountPoint
$storage,
$arguments,
IStorageFactory $loader,
private View $recipientView,
private IEventDispatcher $eventDispatcher,
private IUser $user,
) {
@ -188,4 +186,8 @@ class SharedMount extends MountPoint implements MoveableMount, ISharedMountPoint
public function getMountType() {
return 'shared';
}
public function getUser(): IUser {
return $this->user;
}
}

View file

@ -826,6 +826,8 @@ class ApiTest extends TestCase {
$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);

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_Sharing\Tests\Listener;
use OCA\Files_Sharing\AppInfo\Application;
use OCA\Files_Sharing\Config\ConfigLexicon;
use OCA\Files_Sharing\Listener\UserHomeSetupListener;
use OCA\Files_Sharing\ShareRecipientUpdater;
use OCP\Config\IUserConfig;
use OCP\Files\Events\UserHomeSetupEvent;
use OCP\Files\Mount\IMountPoint;
use OCP\IUser;
use PHPUnit\Framework\MockObject\MockObject;
use Test\Mock\Config\MockUserConfig;
use Test\TestCase;
class UserHomeSetupListenerTest extends TestCase {
private ShareRecipientUpdater&MockObject $updater;
private IUserConfig $userConfig;
private UserHomeSetupListener $listener;
private IUser $user;
protected function setUp(): void {
parent::setUp();
$this->updater = $this->createMock(ShareRecipientUpdater::class);
$this->userConfig = new MockUserConfig([]);
$this->listener = new UserHomeSetupListener($this->updater, $this->userConfig);
$this->user = $this->createMock(IUser::class);
$this->user->method('getUID')
->willReturn('test');
}
private function getEvent(): UserHomeSetupEvent {
$homeMount = $this->createMock(IMountPoint::class);
return new UserHomeSetupEvent($this->user, $homeMount);
}
public function testClearNeedsUpdate(): void {
$this->userConfig->setValueBool('test', Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true);
$this->updater->expects($this->once())
->method('updateForUser');
$this->listener->handle($this->getEvent());
$this->assertFalse($this->userConfig->getValueBool('test', Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true));
}
public function testNoUpdateIfNotNeeded(): void {
$this->userConfig->setValueBool('test', Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, false);
$this->updater->expects($this->never())
->method('updateForUser');
$this->listener->handle($this->getEvent());
$this->assertFalse($this->userConfig->getValueBool('test', Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true));
}
}

View file

@ -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 @@ class CleanupShareTargetTest extends TestCase {
$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());

View file

@ -0,0 +1,216 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Files_Sharing\Tests;
use OCA\Files_Sharing\MountProvider;
use OCA\Files_Sharing\ShareRecipientUpdater;
use OCA\Files_Sharing\ShareTargetValidator;
use OCP\Files\Cache\ICacheEntry;
use OCP\Files\Config\ICachedMountInfo;
use OCP\Files\Config\IUserMountCache;
use OCP\Files\Mount\IMountPoint;
use OCP\Files\Node;
use OCP\Files\Storage\IStorageFactory;
use OCP\IUser;
use OCP\Share\IManager;
use OCP\Share\IShare;
use PHPUnit\Framework\MockObject\MockObject;
use Test\Traits\UserTrait;
class ShareRecipientUpdaterTest extends \Test\TestCase {
use UserTrait;
private IUserMountCache&MockObject $userMountCache;
private MountProvider&MockObject $shareMountProvider;
private ShareTargetValidator&MockObject $shareTargetValidator;
private IStorageFactory&MockObject $storageFactory;
private ShareRecipientUpdater $updater;
private IManager $shareManager;
protected function setUp(): void {
parent::setUp();
$this->userMountCache = $this->createMock(IUserMountCache::class);
$this->shareMountProvider = $this->createMock(MountProvider::class);
$this->shareTargetValidator = $this->createMock(ShareTargetValidator::class);
$this->storageFactory = $this->createMock(IStorageFactory::class);
$this->shareManager = $this->createMock(IManager::class);
$this->updater = new ShareRecipientUpdater(
$this->userMountCache,
$this->shareMountProvider,
$this->shareTargetValidator,
$this->storageFactory,
$this->shareManager,
);
}
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, fn ($path) => null, [$share])
->willReturn('/new-target');
$this->userMountCache->expects($this->exactly(1))
->method('addMount')
->with($user1, '/user1/files/new-target/', $cacheEntry, MountProvider::class);
$this->updater->updateForAddedShare($user1, $share);
}
/**
* @param IUser $user
* @param list<array{fileid: int, mount_point: string, provider: string}> $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, fn ($path) => null, [$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);
}
public function testDeletedShare() {
$share = $this->createMock(IShare::class);
$share->method('getTarget')
->willReturn('/target');
$share->method('getNodeId')
->willReturn(111);
$share->method('getFullId')
->willReturn('id');
$user1 = $this->createUser('user1', '');
$this->shareManager->method('getShareById')
->with('id')
->willReturn($share);
$this->shareTargetValidator->expects($this->never())
->method('verifyMountPoint');
$this->userMountCache->expects($this->exactly(1))
->method('removeMount')
->with('/user1/files/target/');
$this->updater->updateForDeletedShare($user1, $share);
}
}

View file

@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_Sharing\Tests;
use OC\EventDispatcher\EventDispatcher;
use OCA\Files_Sharing\ShareTargetValidator;
use OCP\Constants;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Config\ICachedMountInfo;
use OCP\Files\IRootFolder;
use OCP\IUser;
use OCP\Server;
use OCP\Share\Events\VerifyMountPointEvent;
use OCP\Share\IManager;
use OCP\Share\IShare;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher;
#[\PHPUnit\Framework\Attributes\Group('DB')]
class ShareTargetValidatorTest extends TestCase {
private IEventDispatcher $eventDispatcher;
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->eventDispatcher = new EventDispatcher(
new SymfonyEventDispatcher(),
Server::get(ContainerInterface::class),
$this->createMock(LoggerInterface::class),
);
$this->targetValidator = new ShareTargetValidator(
Server::get(IManager::class),
$this->eventDispatcher,
Server::get(IRootFolder::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, fn ($path) => null, [$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, fn ($path) => null, [$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);
$conflictingMounts = [
'/' . $this->user2->getUID() . '/files' . $this->folder2 . '/' => $conflictingMount
];
$this->targetValidator->verifyMountPoint($this->user2, $share2, fn ($path) => $conflictingMounts[$path] ?? null, [$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);
}
/**
* test if the parent folder is created if asked for
*/
public function testShareMountCreateParentFolder(): 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->eventDispatcher->addListener(VerifyMountPointEvent::class, function (VerifyMountPointEvent $event) {
$event->setCreateParent(true);
});
$this->targetValidator->verifyMountPoint($this->user2, $share, fn ($path) => null, [$share]);
$share = $this->shareManager->getShareById($share->getFullId());
$this->assertSame('/foo/bar' . $this->folder, $share->getTarget());
$userFolder = $this->rootFolder->getUserFolder(self::TEST_FILES_SHARING_API_USER2);
$this->assertTrue($userFolder->nodeExists('/foo/bar'));
//cleanup
self::loginHelper(self::TEST_FILES_SHARING_API_USER1);
$this->shareManager->deleteShare($share);
$this->view->unlink($this->folder);
}
}

View file

@ -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 @@ use OCP\Share\IShare;
*/
#[\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 @@ class SharedMountTest extends TestCase {
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 @@ class SharedMountTest extends TestCase {
$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 {

View file

@ -10,7 +10,6 @@ namespace OCA\Files_Sharing\Tests;
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 @@ class SharedStorageTest extends TestCase {
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 @@ class SharedStorageTest extends TestCase {
$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);

View file

@ -0,0 +1,191 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Files_Sharing\Tests;
use OCA\Files_Sharing\Config\ConfigLexicon;
use OCA\Files_Sharing\Event\UserShareAccessUpdatedEvent;
use OCA\Files_Sharing\Listener\SharesUpdatedListener;
use OCA\Files_Sharing\Listener\UserHomeSetupListener;
use OCA\Files_Sharing\ShareRecipientUpdater;
use OCP\Config\IUserConfig;
use OCP\IAppConfig;
use OCP\IUser;
use OCP\Share\Events\BeforeShareDeletedEvent;
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 Psr\Log\LoggerInterface;
use Test\Mock\Config\MockAppConfig;
use Test\Mock\Config\MockUserConfig;
use Test\Traits\UserTrait;
class SharesUpdatedListenerTest extends \Test\TestCase {
use UserTrait;
private SharesUpdatedListener $sharesUpdatedListener;
private ShareRecipientUpdater&MockObject $shareRecipientUpdater;
private IManager&MockObject $manager;
private IUserConfig $userConfig;
private IAppConfig $appConfig;
private ClockInterface&MockObject $clock;
private LoggerInterface&MockObject $logger;
private $clockFn;
protected function setUp(): void {
parent::setUp();
$this->shareRecipientUpdater = $this->createMock(ShareRecipientUpdater::class);
$this->manager = $this->createMock(IManager::class);
$this->appConfig = new MockAppConfig([
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->logger = $this->createMock(LoggerInterface::class);
$homeSetupListener = new UserHomeSetupListener($this->shareRecipientUpdater, $this->userConfig);
$this->sharesUpdatedListener = new SharesUpdatedListener(
$this->manager,
$this->shareRecipientUpdater,
$this->userConfig,
$this->clock,
$this->logger,
$this->appConfig,
$homeSetupListener,
);
}
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('updateForAddedShare')
->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('updateForAddedShare')
->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) use ($user1, $user2) {
$this->assertContains($user, [$user1, $user2]);
});
$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('updateForDeletedShare')
->willReturnCallback(function (IUser $user) use ($user1, $user2, $share) {
$this->assertContains($user, [$user1, $user2]);
});
$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));
}
}

View file

@ -15,6 +15,7 @@ use OC\SystemConfig;
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,8 @@ abstract class TestCase extends \Test\TestCase {
$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)->setCutOffMarkTime(-1);
}
protected function setUp(): void {

View file

@ -6,6 +6,7 @@
*/
namespace OCA\Files_Trashbin\Trash;
use OCP\Files\Cache\ICacheEntry;
use OCP\Files\FileInfo;
use OCP\IUser;
@ -173,4 +174,8 @@ class TrashItem implements ITrashItem {
public function getMetadata(): array {
return $this->fileInfo->getMetadata();
}
public function getData(): ICacheEntry {
return $this->fileInfo->getData();
}
}

View file

@ -104,6 +104,8 @@ class VersioningTest extends \Test\TestCase {
\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)) {

View file

@ -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 @@ use Psr\Http\Message\ResponseInterface;
require __DIR__ . '/autoload.php';
trait Sharing {
use Provisioning;
@ -575,17 +575,17 @@ trait Sharing {
$expectedFields = array_merge($defaultExpectedFields, $body->getRowsHash());
if (!array_key_exists('uid_file_owner', $expectedFields)
&& array_key_exists('uid_owner', $expectedFields)) {
&& array_key_exists('uid_owner', $expectedFields)) {
$expectedFields['uid_file_owner'] = $expectedFields['uid_owner'];
}
if (!array_key_exists('displayname_file_owner', $expectedFields)
&& array_key_exists('displayname_owner', $expectedFields)) {
&& array_key_exists('displayname_owner', $expectedFields)) {
$expectedFields['displayname_file_owner'] = $expectedFields['displayname_owner'];
}
if (array_key_exists('share_type', $expectedFields)
&& $expectedFields['share_type'] == 10 /* IShare::TYPE_ROOM */
&& array_key_exists('share_with', $expectedFields)) {
&& $expectedFields['share_type'] == 10 /* IShare::TYPE_ROOM */
&& array_key_exists('share_with', $expectedFields)) {
if ($expectedFields['share_with'] === 'private_conversation') {
$expectedFields['share_with'] = 'REGEXP /^private_conversation_[0-9a-f]{6}$/';
} else {
@ -791,4 +791,34 @@ trait Sharing {
}
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);
}
}

View file

@ -32,6 +32,7 @@ class SharingContext implements Context, SnippetAcceptingContext {
$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_cutoff_time');
$this->runOcc(['config:system:delete', 'share_folder']);
}

View file

@ -1011,7 +1011,7 @@ trait WebDav {
*/
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();
}

View file

@ -73,10 +73,53 @@ Scenario: getting all shares of a file with reshares with link share with less p
| 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 |
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_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"
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
@ -114,7 +157,7 @@ Scenario: getting all shares of a file with reshares with link share with less p
| 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 |
@ -150,7 +193,7 @@ Scenario: getting all shares of a file with reshares with link share with less p
| 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 |
@ -431,7 +474,7 @@ Scenario: getting all shares of a file with reshares with link share with less p
| 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 |
@ -470,7 +513,7 @@ Scenario: getting all shares of a file with reshares with link share with less p
| 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 |
@ -917,7 +960,7 @@ Scenario: getting all shares of a file with reshares with link share with less p
| 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 |

View file

@ -315,3 +315,114 @@ 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
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
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/ |

View file

@ -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"

View file

@ -468,6 +468,7 @@ return array(
'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',
@ -846,6 +847,8 @@ return array(
'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',
'OCP\\Share\\Exceptions\\GenericShareException' => $baseDir . '/lib/public/Share/Exceptions/GenericShareException.php',

View file

@ -509,6 +509,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',
@ -887,6 +888,8 @@ 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',
'OCP\\Share\\Exceptions\\GenericShareException' => __DIR__ . '/../../..' . '/lib/public/Share/Exceptions/GenericShareException.php',

View file

@ -5,6 +5,7 @@
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OC\Files\Config;
use OC\User\LazyUser;
@ -32,11 +33,13 @@ class UserMountCache implements IUserMountCache {
/**
* Cached mount info.
*
* @var CappedMemoryCache<ICachedMountInfo[]>
**/
private CappedMemoryCache $mountsForUsers;
/**
* fileid => internal path mapping for cached mount info.
*
* @var CappedMemoryCache<string>
**/
private CappedMemoryCache $internalPathCache;
@ -72,7 +75,9 @@ class UserMountCache implements IUserMountCache {
$cachedMounts = $this->getMountsForUser($user);
if (is_array($mountProviderClasses)) {
$cachedMounts = array_filter($cachedMounts, function (ICachedMountInfo $mountInfo) use ($mountProviderClasses, $newMounts) {
$cachedMounts = array_filter($cachedMounts, function (
ICachedMountInfo $mountInfo,
) use ($mountProviderClasses, $newMounts) {
// for existing mounts that didn't have a mount provider set
// we still want the ones that map to new mounts
if ($mountInfo->getMountProvider() === '' && isset($newMounts[$mountInfo->getKey()])) {
@ -482,21 +487,13 @@ class UserMountCache implements IUserMountCache {
}
public function getMountForPath(IUser $user, string $path): ICachedMountInfo {
$mounts = [];
foreach ($this->getMountsForUser($user) as $mount) {
$mounts[$mount->getMountPoint()] = $mount;
}
$searchPaths = [];
$current = rtrim($path, '/');
// walk up the directory tree until we find a path that has a mountpoint set
// the loop will return if a mountpoint is found or break if none are found
while (true) {
// get all paths that we are interested in, $path and all it's parents
while ($current !== '') {
$mountPoint = $current . '/';
if (isset($mounts[$mountPoint])) {
return $mounts[$mountPoint];
} elseif ($current === '') {
break;
}
$searchPaths[] = $mountPoint;
$current = dirname($current);
if ($current === '.' || $current === '/') {
@ -504,6 +501,34 @@ class UserMountCache implements IUserMountCache {
}
}
$mounts = [];
if (isset($this->mountsForUsers[$user->getUID()])) {
foreach ($this->mountsForUsers[$user->getUID()] as $mount) {
$mounts[$mount->getMountPoint()] = $mount;
}
} else {
$searchPathHashes = array_map(static fn (string $path) => hash('xxh128', $path), $searchPaths);
$builder = $this->connection->getQueryBuilder();
$query = $builder->select('storage_id', 'root_id', 'user_id', 'mount_point', 'mount_id', 'f.path', 'mount_provider_class')
->from('mounts', 'm')
->innerJoin('m', 'filecache', 'f', $builder->expr()->eq('m.root_id', 'f.fileid'))
->where($builder->expr()->eq('user_id', $builder->createNamedParameter($user->getUID())))
->andWhere($builder->expr()->in('mount_point_hash', $builder->createNamedParameter($searchPathHashes, IQueryBuilder::PARAM_STR_ARRAY)));
foreach ($query->executeQuery()->fetchAll() as $row) {
$mount = $this->dbRowToMountInfo($row);
$mounts[$mount->getMountPoint()] = $mount;
}
}
// note that $searchPaths is sorted deepest path first
foreach ($searchPaths as $searchPath) {
if (isset($mounts[$searchPath])) {
return $mounts[$searchPath];
}
}
throw new NotFoundException('No cached mount for path ' . $path);
}
@ -519,14 +544,29 @@ class UserMountCache implements IUserMountCache {
return $result;
}
public function removeMount(string $mountPoint): void {
public function removeMount(string $mountPoint, ?IUser $user = null): void {
$query = $this->connection->getQueryBuilder();
$query->delete('mounts')
->where($query->expr()->eq('mount_point_hash', $query->createNamedParameter(hash('xxh128', $mountPoint))));
if ($user) {
$query->andWhere($query->expr()->eq('user_id', $query->createNamedParameter($user->getUID())));
}
$query->executeStatement();
$parts = explode('/', $mountPoint);
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 {
public function addMount(
IUser $user,
string $mountPoint,
ICacheEntry $rootCacheEntry,
string $mountProvider,
?int $mountId = null,
): void {
$this->connection->insertIgnoreConflict('mounts', [
'storage_id' => $rootCacheEntry->getStorageId(),
'root_id' => $rootCacheEntry->getId(),
@ -536,5 +576,37 @@ class UserMountCache implements IUserMountCache {
'mount_id' => $mountId,
'mount_provider_class' => $mountProvider
]);
unset($this->mountsForUsers[$user->getUID()]);
}
/**
* Clear the internal in-memory caches
*/
public function flush(): void {
$this->cacheInfoCache = new CappedMemoryCache();
$this->internalPathCache = new CappedMemoryCache();
$this->mountsForUsers = new CappedMemoryCache();
}
public function getMountAtPath(IUser $user, string $mountPoint): ?ICachedMountInfo {
if (isset($this->mountsForUsers[$user->getUID()])) {
foreach ($this->mountsForUsers[$user->getUID()] as $mount) {
if ($mount->getMountPoint() === $mountPoint) {
return $mount;
}
}
return null;
}
$builder = $this->connection->getQueryBuilder();
$query = $builder->select('storage_id', 'root_id', 'user_id', 'mount_point', 'mount_id', 'f.path', 'mount_provider_class')
->from('mounts', 'm')
->innerJoin('m', 'filecache', 'f', $builder->expr()->eq('m.root_id', 'f.fileid'))
->where($builder->expr()->eq('user_id', $builder->createNamedParameter($user->getUID())))
->andWhere($builder->expr()->eq('mount_point_hash', $builder->createNamedParameter(hash('xxh128', $mountPoint))))
->setMaxResults(1);
$row = $query->executeQuery()->fetch();
return $row ? $this->dbRowToMountInfo($row) : null;
}
}

View file

@ -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 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess {
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);
}
}
/**

View file

@ -59,11 +59,14 @@ class Manager implements IMountManager {
}
public function moveMount(string $mountPoint, string $target): void {
$this->mounts[$target] = $this->mounts[$mountPoint];
unset($this->mounts[$mountPoint]);
$this->pathCache->clear();
$this->inPathCache->clear();
$this->areMountsSorted = false;
if ($mountPoint !== $target && 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;
}
}
/**

View file

@ -10,6 +10,7 @@ namespace OC\Files\Node;
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;
@ -572,6 +573,10 @@ class LazyFolder implements Folder {
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());
}

View file

@ -12,6 +12,7 @@ use OC\Files\Mount\MoveableMount;
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;
@ -490,4 +491,8 @@ class Node implements INode {
public function getMetadata(): array {
return $this->fileInfo->getMetadata();
}
public function getData(): ICacheEntry {
return $this->fileInfo->getData();
}
}

View file

@ -43,6 +43,7 @@ use OCP\Files\Events\BeforeFileSystemSetupEvent;
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;
@ -69,6 +70,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 +236,10 @@ class SetupManager {
* 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 +260,7 @@ class SetupManager {
$mount = $this->mountProviderCollection->getUserMountsForProviderClasses($user, $providerNames);
$this->userMountCache->registerMounts($user, $mount, $providerNames);
$this->usersMountsUpdated[$user->getUID()] = true;
$updatingProviders = false;
}
@ -325,6 +333,9 @@ class SetupManager {
$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([]),
@ -685,8 +696,13 @@ class SetupManager {
}
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));
@ -730,6 +746,7 @@ class SetupManager {
$this->setupUserMountProviders = [];
$this->setupMountProviderPaths = [];
$this->fullSetupRequired = [];
$this->usersMountsUpdated = [];
$this->rootSetup = false;
$this->mountManager->clear();
$this->userMountCache->clear();

View file

@ -47,6 +47,7 @@ use OCP\Share\Events\ShareAcceptedEvent;
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;
@ -1176,13 +1177,16 @@ class Manager implements IManager {
if ($share->getShareType() === IShare::TYPE_USER && $share->getSharedWith() !== $recipientId) {
throw new \InvalidArgumentException($this->l->t('Invalid share recipient'));
}
$recipient = $this->userManager->get($recipientId);
if (!$recipient) {
throw new \InvalidArgumentException($this->l->t('Unknown share recipient'));
}
if ($share->getShareType() === IShare::TYPE_GROUP) {
$sharedWith = $this->groupManager->get($share->getSharedWith());
if (is_null($sharedWith)) {
throw new \InvalidArgumentException($this->l->t('Group "%s" does not exist', [$share->getSharedWith()]));
}
$recipient = $this->userManager->get($recipientId);
if (!$sharedWith->inGroup($recipient)) {
throw new \InvalidArgumentException($this->l->t('Invalid share recipient'));
}
@ -1191,7 +1195,11 @@ class Manager implements IManager {
[$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, $recipient), 'share moved');
return $result;
}
#[Override]

View file

@ -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 @@ class Share implements IShare {
* @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
*/

View file

@ -113,7 +113,10 @@ interface IUserMountCache {
public function clear(): void;
/**
* Get all cached mounts for a user
* Get the cached mount for a path
*
* This walks up the directly tree until a mount is found, if you only want
* to get the mount at the specific path, use `getMountAtPath` instead.
*
* @param IUser $user
* @param string $path
@ -139,7 +142,7 @@ interface IUserMountCache {
*
* @since 33.0.0
*/
public function removeMount(string $mountPoint): void;
public function removeMount(string $mountPoint, ?IUser $user = null): void;
/**
* Register a new mountpoint for a user
@ -147,4 +150,11 @@ interface IUserMountCache {
* @since 33.0.0
*/
public function addMount(IUser $user, string $mountPoint, ICacheEntry $rootCacheEntry, string $mountProvider, ?int $mountId = null): void;
/**
* Get the mount at the specified path, if any
*
* @since 33.0.2
*/
public function getMountAtPath(IUser $user, string $mountPoint): ?ICachedMountInfo;
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\Files\Events;
use OCP\EventDispatcher\Event;
use OCP\Files\Mount\IMountPoint;
use OCP\IUser;
/**
* Event triggered after the users home mount has been setup, before any other
* mounts are setup.
*
* @since 34.0.0
*/
class UserHomeSetupEvent extends Event {
/**
* @since 34.0.0
*/
public function __construct(
private readonly IUser $user,
private readonly IMountPoint $homeMount,
) {
parent::__construct();
}
/**
* @since 34.0.0
*/
public function getUser(): IUser {
return $this->user;
}
/**
* @since 34.0.0
*/
public function getHomeMount(): IMountPoint {
return $this->homeMount;
}
}

View file

@ -8,6 +8,7 @@
namespace OCP\Files;
use OCP\AppFramework\Attribute\Consumable;
use OCP\Files\Cache\ICacheEntry;
use OCP\Files\Storage\IStorage;
/**
@ -308,4 +309,12 @@ interface FileInfo {
* @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;
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\Share\Events;
use OCP\EventDispatcher\Event;
use OCP\IUser;
use OCP\Share\IShare;
/**
* @since 33.0.0
*/
class ShareMovedEvent extends Event {
/**
* @since 33.0.0
*/
public function __construct(
private readonly IShare $share,
private readonly IUser $user,
) {
parent::__construct();
}
/**
* @since 33.0.0
*/
public function getShare(): IShare {
return $this->share;
}
/**
* @since 33.0.0
*/
public function getUser(): IUser {
return $this->user;
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\Share\Events;
use OCP\EventDispatcher\Event;
use OCP\Share\IShare;
/**
* @since 33.0.0
*/
class ShareTransferredEvent extends Event {
/**
* @since 33.0.0
*/
public function __construct(
private readonly IShare $share,
) {
parent::__construct();
}
/**
* @since 33.0.0
*/
public function getShare(): IShare {
return $this->share;
}
}

View file

@ -10,30 +10,25 @@ namespace OCP\Share\Events;
use OC\Files\View;
use OCP\EventDispatcher\Event;
use OCP\IUser;
use OCP\Share\IShare;
/**
* @since 19.0.0
*/
class VerifyMountPointEvent extends Event {
/** @var IShare */
private $share;
/** @var View */
private $view;
/** @var string */
private $parent;
private bool $createParent = false;
/**
* @since 19.0.0
*/
public function __construct(IShare $share,
View $view,
string $parent) {
public function __construct(
private readonly IShare $share,
private readonly View $view,
private string $parent,
private readonly IUser $user,
) {
parent::__construct();
$this->share = $share;
$this->view = $view;
$this->parent = $parent;
}
/**
@ -51,6 +46,8 @@ class VerifyMountPointEvent extends Event {
}
/**
* The parent folder where the share is placed, as relative path to the users home directory.
*
* @since 19.0.0
*/
public function getParent(): string {
@ -63,4 +60,30 @@ class VerifyMountPointEvent extends Event {
public function setParent(string $parent): void {
$this->parent = $parent;
}
/**
* @since 33.0.3
*/
public function setCreateParent(bool $create): void {
$this->createParent = $create;
}
/**
* Whether the parent folder should be created if missing.
*
* If set for `false` (the default), and the parent folder doesn't exist already,
* the share will be moved to the default share folder instead.
*
* @since 33.0.3
*/
public function createParent(): bool {
return $this->createParent;
}
/**
* @since 33.0.3
*/
public function getUser(): IUser {
return $this->user;
}
}

View file

@ -553,6 +553,13 @@ interface IShare {
*/
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.
*

View file

@ -1550,6 +1550,9 @@ class ViewTest extends \Test\TestCase {
$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 @@ class ViewTest extends \Test\TestCase {
$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');

View file

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Mock\Config;
use OCP\Exceptions\AppConfigIncorrectTypeException;
use OCP\IAppConfig;
class MockAppConfig implements IAppConfig {
public function __construct(
public array $config = [],
) {
}
public function hasKey(string $app, string $key, ?bool $lazy = false): bool {
return isset($this->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');
}
}

View file

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Mock\Config;
use Generator;
use OCP\Config\IUserConfig;
use OCP\Config\ValueType;
class MockUserConfig implements IUserConfig {
public function __construct(
public array $config = [],
) {
}
public function getUserIds(string $appId = ''): array {
return array_keys($this->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');
}
}

View file

@ -4665,6 +4665,9 @@ class ManagerTest extends \Test\TestCase {
$share->setShareType(IShare::TYPE_USER)
->setId('42')
->setProviderId('foo');
$this->userManager->method('get')
->with('recipient')
->willReturn($this->createMock(IUser::class));
$share->setSharedWith('recipient');

View file

@ -13,6 +13,7 @@ use OC\Command\QueueBus;
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 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase {
Storage::getGlobalCache()->clearCache();
}
Server::get(UserMountCache::class)->flush();
// tearDown the traits
$traits = $this->getTestTraits();
foreach ($traits as $trait) {