Merge pull request #57760 from nextcloud/share-update-check-single
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Integration sqlite / changes (push) Waiting to run
Integration sqlite / integration-sqlite (master, main, 8.4, main, --tags ~@large files_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, capabilities_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, collaboration_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, comments_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, dav_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, federation_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, file_conversions) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, files_reminders) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, filesdrop_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, guests_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, ldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, openldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, openldap_numerical_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, remoteapi_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, routing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, setup_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, sharees_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, sharing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, theming_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, videoverification_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite-summary (push) Blocked by required conditions
Psalm static code analysis / changes (push) Has been cancelled
Psalm static code analysis / static-code-analysis (push) Has been cancelled
Psalm static code analysis / static-code-analysis-security (push) Has been cancelled
Psalm static code analysis / static-code-analysis-ocp (push) Has been cancelled
Psalm static code analysis / static-code-analysis-ncu (push) Has been cancelled
Psalm static code analysis / static-code-analysis-strict (push) Has been cancelled
Psalm static code analysis / static-code-analysis-summary (push) Has been cancelled

Active share validation/authoritative mount improvements
This commit is contained in:
Robin Appelman 2026-04-24 19:09:25 +02:00 committed by GitHub
commit d80a3a7959
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1774 additions and 163 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

@ -577,14 +577,16 @@ class OwnershipTransferService {
$output->writeln('');
}
private function transferIncomingShares(string $sourceUid,
private function transferIncomingShares(
string $sourceUid,
string $destinationUid,
array $sourceShares,
array $destinationShares,
OutputInterface $output,
string $path,
string $finalTarget,
bool $move): void {
bool $move,
): void {
$output->writeln('Restoring incoming shares ...');
$progress = new ProgressBar($output, count($sourceShares));
$prefix = "$destinationUid/files";
@ -623,8 +625,11 @@ 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);
@ -642,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) {
@ -656,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

@ -72,6 +72,7 @@ return array(
'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',
@ -96,6 +97,7 @@ return array(
'OCA\\Files_Sharing\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
'OCA\\Files_Sharing\\Scanner' => $baseDir . '/../lib/Scanner.php',
'OCA\\Files_Sharing\\Settings\\Personal' => $baseDir . '/../lib/Settings/Personal.php',
'OCA\\Files_Sharing\\ShareRecipientUpdater' => $baseDir . '/../lib/ShareRecipientUpdater.php',
'OCA\\Files_Sharing\\ShareTargetValidator' => $baseDir . '/../lib/ShareTargetValidator.php',
'OCA\\Files_Sharing\\SharedMount' => $baseDir . '/../lib/SharedMount.php',
'OCA\\Files_Sharing\\SharedStorage' => $baseDir . '/../lib/SharedStorage.php',

View file

@ -87,6 +87,7 @@ class ComposerStaticInitFiles_Sharing
'OCA\\Files_Sharing\\Listener\\ShareInteractionListener' => __DIR__ . '/..' . '/../lib/Listener/ShareInteractionListener.php',
'OCA\\Files_Sharing\\Listener\\SharesUpdatedListener' => __DIR__ . '/..' . '/../lib/Listener/SharesUpdatedListener.php',
'OCA\\Files_Sharing\\Listener\\UserAddedToGroupListener' => __DIR__ . '/..' . '/../lib/Listener/UserAddedToGroupListener.php',
'OCA\\Files_Sharing\\Listener\\UserHomeSetupListener' => __DIR__ . '/..' . '/../lib/Listener/UserHomeSetupListener.php',
'OCA\\Files_Sharing\\Listener\\UserShareAcceptanceListener' => __DIR__ . '/..' . '/../lib/Listener/UserShareAcceptanceListener.php',
'OCA\\Files_Sharing\\Middleware\\OCSShareAPIMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/OCSShareAPIMiddleware.php',
'OCA\\Files_Sharing\\Middleware\\ShareInfoMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/ShareInfoMiddleware.php',
@ -111,6 +112,7 @@ class ComposerStaticInitFiles_Sharing
'OCA\\Files_Sharing\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
'OCA\\Files_Sharing\\Scanner' => __DIR__ . '/..' . '/../lib/Scanner.php',
'OCA\\Files_Sharing\\Settings\\Personal' => __DIR__ . '/..' . '/../lib/Settings/Personal.php',
'OCA\\Files_Sharing\\ShareRecipientUpdater' => __DIR__ . '/..' . '/../lib/ShareRecipientUpdater.php',
'OCA\\Files_Sharing\\ShareTargetValidator' => __DIR__ . '/..' . '/../lib/ShareTargetValidator.php',
'OCA\\Files_Sharing\\SharedMount' => __DIR__ . '/..' . '/../lib/SharedMount.php',
'OCA\\Files_Sharing\\SharedStorage' => __DIR__ . '/..' . '/../lib/SharedStorage.php',

View file

@ -26,6 +26,7 @@ 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;
@ -45,6 +46,8 @@ 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;
@ -54,6 +57,7 @@ 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;
@ -119,7 +123,11 @@ class Application extends App implements IBootstrap {
$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, 'Maximum time in second during which 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

@ -8,93 +8,149 @@ declare(strict_types=1);
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\MountProvider;
use OCA\Files_Sharing\ShareTargetValidator;
use OCA\Files_Sharing\ShareRecipientUpdater;
use OCP\Config\IUserConfig;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Config\ICachedMountInfo;
use OCP\Files\Config\IUserMountCache;
use OCP\Files\Storage\IStorageFactory;
use OCP\Group\Events\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
*
* @template-implements IEventListener<UserAddedEvent|UserRemovedEvent|ShareCreatedEvent|ShareTransferredEvent|BeforeShareDeletedEvent|UserShareAccessUpdatedEvent>
* @psalm-type GroupEvents = UserAddedEvent|UserRemovedEvent|GroupDeletedEvent|BeforeGroupDeletedEvent
* @template-implements IEventListener<GroupEvents|ShareCreatedEvent|ShareTransferredEvent|BeforeShareDeletedEvent|UserShareAccessUpdatedEvent|ShareMovedEvent>
*/
class SharesUpdatedListener implements IEventListener {
private array $inUpdate = [];
/**
* 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;
public function __construct(
private readonly IManager $shareManager,
private readonly IUserMountCache $userMountCache,
private readonly MountProvider $shareMountProvider,
private readonly ShareTargetValidator $shareTargetValidator,
private readonly IStorageFactory $storageFactory,
private readonly ShareRecipientUpdater $shareUpdater,
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 {
// 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->updateForUser($user, true);
$this->updateOrMarkUser($user);
}
}
if ($event instanceof BeforeGroupDeletedEvent) {
// ensure the group users are loaded before the group is deleted
// `IGroup::getUsers` does internal caching, so we don't need to store this separately
$event->getGroup()->getUsers();
}
if ($event instanceof GroupDeletedEvent) {
// so we can iterate them after the group is deleted
foreach ($event->getGroup()->getUsers() as $user) {
$this->updateOrMarkUser($user);
}
}
if ($event instanceof UserAddedEvent || $event instanceof UserRemovedEvent) {
$this->updateForUser($event->getUser(), true);
$this->updateOrMarkUser($event->getUser());
}
if (
$event instanceof ShareCreatedEvent
|| $event instanceof ShareTransferredEvent
) {
foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) {
$this->updateForUser($user, true);
}
}
if ($event instanceof BeforeShareDeletedEvent) {
foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) {
$this->updateForUser($user, false, [$event->getShare()]);
}
}
}
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;
}
private function updateForUser(IUser $user, bool $verifyMountPoints, array $ignoreShares = []): void {
// prevent recursion
if (isset($this->inUpdate[$user->getUID()])) {
return;
}
$this->inUpdate[$user->getUID()] = true;
$cachedMounts = $this->userMountCache->getMountsForUser($user);
$shareMounts = array_filter($cachedMounts, fn (ICachedMountInfo $mount) => $mount->getMountProvider() === MountProvider::class);
$mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts);
$mountsByPath = array_combine($mountPoints, $cachedMounts);
$shares = $this->shareMountProvider->getSuperSharesForUser($user, $ignoreShares);
$mountsChanged = count($shares) !== count($shareMounts);
foreach ($shares as &$share) {
[$parentShare, $groupedShares] = $share;
$mountPoint = '/' . $user->getUID() . '/files/' . trim($parentShare->getTarget(), '/') . '/';
$mountKey = $parentShare->getNodeId() . '::' . $mountPoint;
if (!isset($cachedMounts[$mountKey])) {
$mountsChanged = true;
if ($verifyMountPoints) {
$this->shareTargetValidator->verifyMountPoint($user, $parentShare, $mountsByPath, $groupedShares);
if ($share->getSharedBy() !== $user->getUID()) {
$this->markOrRun($user, function () use ($user, $share) {
$this->shareUpdater->updateForAddedShare($user, $share);
});
// Share target validation might have changed the target, restore it for the next user
$share->setTarget($shareTarget);
}
}
}
if ($event instanceof ShareMovedEvent) {
$share = $event->getShare();
$user = $event->getUser();
if ($mountsChanged) {
$newMounts = $this->shareMountProvider->getMountsFromSuperShares($user, $shares, $this->storageFactory);
$this->userMountCache->registerMounts($user, $newMounts, [MountProvider::class]);
// 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);
});
}
}
unset($this->inUpdate[$user->getUID()]);
$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

@ -107,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

@ -13,8 +13,7 @@ use OC\Files\View;
use OCP\Cache\CappedMemoryCache;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Config\ICachedMountInfo;
use OCP\Files\ISetupManager;
use OCP\Files\Mount\IMountManager;
use OCP\Files\IRootFolder;
use OCP\Files\Mount\IMountPoint;
use OCP\IUser;
use OCP\Share\Events\VerifyMountPointEvent;
@ -30,8 +29,7 @@ class ShareTargetValidator {
public function __construct(
private readonly IManager $shareManager,
private readonly IEventDispatcher $eventDispatcher,
private readonly ISetupManager $setupManager,
private readonly IMountManager $mountManager,
private readonly IRootFolder $rootFolder,
) {
$this->folderExistsCache = new CappedMemoryCache();
}
@ -47,14 +45,14 @@ class ShareTargetValidator {
/**
* check if the parent folder exists otherwise move the mount point up
*
* @param array<string, ICachedMountInfo> $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());
@ -67,8 +65,10 @@ class ShareTargetValidator {
/** @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) {
@ -94,7 +94,7 @@ class ShareTargetValidator {
$share->getNodeId(),
Filesystem::normalizePath($absoluteParent . '/' . $mountPoint),
$parentMount,
$allCachedMounts,
$getMountByPath,
);
/** @psalm-suppress InternalMethod */
@ -112,13 +112,13 @@ class ShareTargetValidator {
/**
* @param ICachedMountInfo[] $allCachedMounts
* @param callable(string):?ICachedMountInfo $getMountByPath
*/
public function generateUniqueTarget(
int $shareNodeId,
string $absolutePath,
IMountPoint $parentMount,
array $allCachedMounts,
callable $getMountByPath,
): string {
$pathInfo = pathinfo($absolutePath);
$ext = isset($pathInfo['extension']) ? '.' . $pathInfo['extension'] : '';
@ -128,7 +128,7 @@ 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++;
@ -138,14 +138,14 @@ class ShareTargetValidator {
}
/**
* @param ICachedMountInfo[] $allCachedMounts
* @param callable(string):?ICachedMountInfo $getMountByPath
*/
private function hasConflictingMount(int $shareNodeId, array $allCachedMounts, string $absolutePath): bool {
if (!isset($allCachedMounts[$absolutePath . '/'])) {
private function hasConflictingMount(int $shareNodeId, callable $getMountByPath, string $absolutePath): bool {
$mount = $getMountByPath($absolutePath . '/');
if ($mount === null) {
return false;
}
$mount = $allCachedMounts[$absolutePath . '/'];
if ($mount->getMountProvider() === MountProvider::class && $mount->getRootId() === $shareNodeId) {
// "conflicting" mount is a mount for the current share
return false;

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

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

@ -9,12 +9,11 @@ declare(strict_types=1);
namespace OCA\Files_Sharing\Tests;
use OC\EventDispatcher\EventDispatcher;
use OC\Files\SetupManager;
use OCA\Files_Sharing\ShareTargetValidator;
use OCP\Constants;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Config\ICachedMountInfo;
use OCP\Files\Mount\IMountManager;
use OCP\Files\IRootFolder;
use OCP\IUser;
use OCP\Server;
use OCP\Share\Events\VerifyMountPointEvent;
@ -57,8 +56,7 @@ class ShareTargetValidatorTest extends TestCase {
$this->targetValidator = new ShareTargetValidator(
Server::get(IManager::class),
$this->eventDispatcher,
Server::get(SetupManager::class),
Server::get(IMountManager::class),
Server::get(IRootFolder::class),
);
$this->user2 = $this->createMock(IUser::class);
$this->user2->method('getUID')
@ -85,7 +83,7 @@ class ShareTargetValidatorTest extends TestCase {
$share = $this->shareManager->getShareById($share->getFullId());
$this->assertSame('/foo/bar' . $this->folder, $share->getTarget());
$this->targetValidator->verifyMountPoint($this->user2, $share, [], [$share]);
$this->targetValidator->verifyMountPoint($this->user2, $share, fn ($path) => null, [$share]);
$share = $this->shareManager->getShareById($share->getFullId());
$this->assertSame($this->folder, $share->getTarget());
@ -119,7 +117,7 @@ class ShareTargetValidatorTest extends TestCase {
$share = $this->shareManager->getShareById($share->getFullId());
$this->targetValidator->verifyMountPoint($this->user2, $share, [], [$share]);
$this->targetValidator->verifyMountPoint($this->user2, $share, fn ($path) => null, [$share]);
$share = $this->shareManager->getShareById($share->getFullId());
$this->assertSame('/bar (2)', $share->getTarget());
@ -144,9 +142,10 @@ class ShareTargetValidatorTest extends TestCase {
$this->shareManager->acceptShare($share2, self::TEST_FILES_SHARING_API_USER2);
$conflictingMount = $this->createMock(ICachedMountInfo::class);
$this->targetValidator->verifyMountPoint($this->user2, $share2, [
$conflictingMounts = [
'/' . $this->user2->getUID() . '/files' . $this->folder2 . '/' => $conflictingMount
], [$share2]);
];
$this->targetValidator->verifyMountPoint($this->user2, $share2, fn ($path) => $conflictingMounts[$path] ?? null, [$share2]);
$share2 = $this->shareManager->getShareById($share2->getFullId());
@ -181,7 +180,7 @@ class ShareTargetValidatorTest extends TestCase {
$this->eventDispatcher->addListener(VerifyMountPointEvent::class, function (VerifyMountPointEvent $event): void {
$event->setCreateParent(true);
});
$this->targetValidator->verifyMountPoint($this->user2, $share, [], [$share]);
$this->targetValidator->verifyMountPoint($this->user2, $share, fn ($path) => null, [$share]);
$share = $this->shareManager->getShareById($share->getFullId());
$this->assertSame('/foo/bar' . $this->folder, $share->getTarget());

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

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

@ -34,6 +34,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

@ -77,6 +77,49 @@ Scenario: getting all shares of a file with reshares with link share with less p
| share_with | user2 |
| share_with_displayname | user2 |
Scenario: getting all shares of a file with a received share after revoking the resharing rights with delayed share check
Given user "user0" exists
And parameter "update_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

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

@ -470,6 +470,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',
@ -849,6 +850,7 @@ 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',

View file

@ -511,6 +511,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\Files\\Events\\Node\\NodeRenamedEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeRenamedEvent.php',
'OCP\\Files\\Events\\Node\\NodeTouchedEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeTouchedEvent.php',
'OCP\\Files\\Events\\Node\\NodeWrittenEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeWrittenEvent.php',
'OCP\\Files\\Events\\UserHomeSetupEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/UserHomeSetupEvent.php',
'OCP\\Files\\File' => __DIR__ . '/../../..' . '/lib/public/Files/File.php',
'OCP\\Files\\FileInfo' => __DIR__ . '/../../..' . '/lib/public/Files/FileInfo.php',
'OCP\\Files\\FileNameTooLongException' => __DIR__ . '/../../..' . '/lib/public/Files/FileNameTooLongException.php',
@ -890,6 +891,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\Share\\Events\\ShareCreatedEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareCreatedEvent.php',
'OCP\\Share\\Events\\ShareDeletedEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareDeletedEvent.php',
'OCP\\Share\\Events\\ShareDeletedFromSelfEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareDeletedFromSelfEvent.php',
'OCP\\Share\\Events\\ShareMovedEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareMovedEvent.php',
'OCP\\Share\\Events\\ShareTransferredEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareTransferredEvent.php',
'OCP\\Share\\Events\\VerifyMountPointEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/VerifyMountPointEvent.php',
'OCP\\Share\\Exceptions\\AlreadySharedException' => __DIR__ . '/../../..' . '/lib/public/Share/Exceptions/AlreadySharedException.php',

View file

@ -5,8 +5,10 @@
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OC\Files\Config;
use OC\DB\Exceptions\DbalException;
use OC\User\LazyUser;
use OCP\Cache\CappedMemoryCache;
use OCP\DB\QueryBuilder\IQueryBuilder;
@ -32,11 +34,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 +76,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 +488,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 +502,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,23 +545,49 @@ 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 {
$this->connection->insertIgnoreConflict('mounts', [
'storage_id' => $rootCacheEntry->getStorageId(),
'root_id' => $rootCacheEntry->getId(),
'user_id' => $user->getUID(),
'mount_point' => $mountPoint,
'mount_point_hash' => hash('xxh128', $mountPoint),
'mount_id' => $mountId,
'mount_provider_class' => $mountProvider
]);
public function addMount(
IUser $user,
string $mountPoint,
ICacheEntry $rootCacheEntry,
string $mountProvider,
?int $mountId = null,
): void {
$query = $this->connection->getQueryBuilder();
$query->insert('mounts')
->values([
'storage_id' => $query->createNamedParameter($rootCacheEntry->getStorageId()),
'root_id' => $query->createNamedParameter($rootCacheEntry->getId()),
'user_id' => $query->createNamedParameter($user->getUID()),
'mount_point' => $query->createNamedParameter($mountPoint),
'mount_point_hash' => $query->createNamedParameter(hash('xxh128', $mountPoint)),
'mount_id' => $query->createNamedParameter($mountId),
'mount_provider_class' => $query->createNamedParameter($mountProvider)
]);
try {
$query->executeStatement();
unset($this->mountsForUsers[$user->getUID()]);
} catch (DbalException $e) {
if ($e->getReason() !== DbalException::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
}
}
/**
@ -546,4 +598,26 @@ class UserMountCache implements IUserMountCache {
$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\Constants;
use OCP\Files\Cache\ICacheEntry;
@ -209,8 +209,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) {
$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;
@ -570,6 +571,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

@ -14,6 +14,7 @@ use OC\Files\View;
use OCP\Constants;
use OCP\EventDispatcher\GenericEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Cache\ICacheEntry;
use OCP\Files\FileInfo;
use OCP\Files\InvalidPathException;
use OCP\Files\IRootFolder;
@ -485,4 +486,8 @@ class Node implements INode {
public function getMetadata(): array {
return $this->fileInfo->getMetadata();
}
public function getData(): ICacheEntry {
return $this->fileInfo->getData();
}
}

View file

@ -42,6 +42,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\ISetupManager;
use OCP\Files\Mount\IMountManager;
use OCP\Files\Mount\IMountPoint;
@ -336,6 +337,9 @@ class SetupManager implements ISetupManager {
$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([]),

View file

@ -51,6 +51,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;
@ -1185,13 +1186,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'));
}
@ -1200,7 +1204,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

@ -62,6 +62,8 @@ class Share implements IShare {
private ?int $parent = null;
/** @var string */
private $target;
/** @var string */
private ?string $originalTarget = null;
/** @var \DateTime */
private $shareTime;
/** @var bool */
@ -514,10 +516,21 @@ 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

@ -545,6 +545,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

@ -183,7 +183,9 @@ class UserMountCacheTest extends TestCase {
$this->eventDispatcher
->expects($this->exactly(2))
->method('dispatchTyped')
->with($this->callback(function (UserMountAddedEvent|UserMountRemovedEvent $event) use (&$operation) {
->with($this->callback(function (
UserMountAddedEvent|UserMountRemovedEvent $event,
) use (&$operation) {
return match (++$operation) {
1 => $event instanceof UserMountAddedEvent && $event->mountPoint->getMountPoint() === '/asd/',
2 => $event instanceof UserMountRemovedEvent && $event->mountPoint->getMountPoint() === '/asd/',
@ -214,7 +216,9 @@ class UserMountCacheTest extends TestCase {
$this->eventDispatcher
->expects($this->exactly(3))
->method('dispatchTyped')
->with($this->callback(function (UserMountAddedEvent|UserMountRemovedEvent $event) use (&$operation) {
->with($this->callback(function (
UserMountAddedEvent|UserMountRemovedEvent $event,
) use (&$operation) {
return match (++$operation) {
1 => $event instanceof UserMountAddedEvent && $event->mountPoint->getMountPoint() === '/bar/',
2 => $event instanceof UserMountAddedEvent && $event->mountPoint->getMountPoint() === '/foo/',
@ -250,7 +254,9 @@ class UserMountCacheTest extends TestCase {
$this->eventDispatcher
->expects($this->exactly(2))
->method('dispatchTyped')
->with($this->callback(function (UserMountAddedEvent|UserMountUpdatedEvent $event) use (&$operation) {
->with($this->callback(function (
UserMountAddedEvent|UserMountUpdatedEvent $event,
) use (&$operation) {
return match (++$operation) {
1 => $event instanceof UserMountAddedEvent && $event->mountPoint->getMountPoint() === '/foo/',
2 => $event instanceof UserMountUpdatedEvent && $event->oldMountPoint->getMountId() === null && $event->newMountPoint->getMountId() === 1,
@ -611,12 +617,59 @@ class UserMountCacheTest extends TestCase {
$this->cache->flush();
$cached = $this->cache->getMountsForUser($user);
usort($cached, fn (ICachedMountInfo $a, ICachedMountInfo $b) => $a->getMountPoint() <=> $b->getMountPoint());
usort($cached, fn (ICachedMountInfo $a, ICachedMountInfo $b,
) => $a->getMountPoint() <=> $b->getMountPoint());
$mountPoints = array_map(fn (ICachedMountInfo $mountInfo) => $mountInfo->getMountPoint(), $cached);
$mountPoints = array_map(fn (ICachedMountInfo $mountInfo,
) => $mountInfo->getMountPoint(), $cached);
$this->assertEquals(['/asd/', '/asd2/'], $mountPoints);
$mountIds = array_map(fn (ICachedMountInfo $mountInfo) => $mountInfo->getMountId(), $cached);
$mountIds = array_map(fn (ICachedMountInfo $mountInfo,
) => $mountInfo->getMountId(), $cached);
$this->assertEquals([null, 1], $mountIds);
}
public function testGetMountForPath(): void {
$user = $this->userManager->get('u1');
[$storage] = $this->getStorage(10);
$mount1 = new MountPoint($storage, '/asd/');
$mount2 = new MountPoint($storage, '/asd/foo');
$this->cache->registerMounts($user, [$mount1, $mount2]);
$this->cache->flush();
$this->assertEquals('/asd/', $this->cache->getMountForPath($user, '/asd/bar/')->getMountPoint());
$this->assertEquals('/asd/', $this->cache->getMountForPath($user, '/asd/')->getMountPoint());
$this->assertEquals('/asd/foo/', $this->cache->getMountForPath($user, '/asd/foo/bar/')->getMountPoint());
$this->assertEquals('/asd/foo/', $this->cache->getMountForPath($user, '/asd/foo/')->getMountPoint());
}
public function testGetMountsInPath(): void {
$user = $this->userManager->get('u1');
[$storage] = $this->getStorage(10);
$mount1 = new MountPoint($storage, '/asd/');
$mount2 = new MountPoint($storage, '/asd/foo/');
$mount3 = new MountPoint($storage, '/asd/foo/bar/');
$this->cache->registerMounts($user, [$mount1, $mount2, $mount3]);
$this->cache->flush();
$getMountPaths = function (string $path) use ($user) {
$mountPoints = array_values(
array_map(
fn (ICachedMountInfo $mount) => $mount->getMountPoint(),
$this->cache->getMountsInPath($user, $path)
)
);
sort($mountPoints);
return $mountPoints;
};
$this->assertEquals(['/asd/foo/', '/asd/foo/bar/'], $getMountPaths('/asd/'));
$this->assertEquals([], $getMountPaths('/asd/foo/bar/'));
$this->assertEquals(['/asd/foo/bar/'], $getMountPaths('/asd/foo/'));
$this->assertEquals([], $getMountPaths('/asd/bar/'));
}
}

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

@ -4679,6 +4679,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');