Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
58088ad
fix: only validate mounts for new share
icewind1991 Jan 23, 2026
339ed64
feat: add event for user home mount having being setup
icewind1991 Feb 12, 2026
97d1a28
chore: move share recipient validation logic to a separate class
icewind1991 Feb 12, 2026
0ff3920
feat: postpone receiving share validation after processing a certain …
icewind1991 Feb 12, 2026
14f3f0d
test: add test for delayed share validate
icewind1991 Feb 16, 2026
866eba4
fix: disable share resolve postpone in tests
icewind1991 Feb 17, 2026
a1092d4
feat: export getData for public FileInfo interface
icewind1991 Feb 17, 2026
be370a0
fix: clear in-memory cached mounts for user when adding/removing mounts
icewind1991 Feb 18, 2026
df51fa2
test: add reusable mock implementation for IAppConfig
icewind1991 Feb 19, 2026
6e08acb
test: add reusable mock implementation for IUserConfig
icewind1991 Feb 19, 2026
7f31fbf
test: add some tests for SharesUpdatedListenerTest
icewind1991 Feb 19, 2026
3d3d295
test: add some tests for ShareRecipientUpdaterTest
icewind1991 Feb 19, 2026
374f096
feat: use time-based cutoff for share updating instead of count
icewind1991 Feb 20, 2026
f723e91
fix: improve performance of handling delete shares
icewind1991 Feb 25, 2026
511e614
feat: add output options and '--cached-only' to list mounts command
icewind1991 Feb 25, 2026
21630cb
fix: update shares on group delete
icewind1991 Feb 25, 2026
41d4b37
test: add more integration tests for share mount handling
icewind1991 Feb 25, 2026
23c17b5
fix: handle share moves
icewind1991 Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 56 additions & 26 deletions apps/files/lib/Command/Mount/ListMounts.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@

namespace OCA\Files\Command\Mount;

use OC\Core\Command\Base;
use OCP\Files\Config\ICachedMountInfo;
use OCP\Files\Config\IMountProviderCollection;
use OCP\Files\Config\IUserMountCache;
use OCP\Files\Mount\IMountPoint;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class ListMounts extends Command {
class ListMounts extends Base {
public function __construct(
private readonly IUserManager $userManager,
private readonly IUserMountCache $userMountCache,
Expand All @@ -28,52 +29,81 @@ public function __construct(
}

protected function configure(): void {
parent::configure();
$this
->setName('files:mount:list')
->setDescription('List of mounts for a user')
->addArgument('user', InputArgument::REQUIRED, 'User to list mounts for');
->addArgument('user', InputArgument::REQUIRED, 'User to list mounts for')
->addOption('cached-only', null, InputOption::VALUE_NONE, 'Only return cached mounts, prevents filesystem setup');
}

public function execute(InputInterface $input, OutputInterface $output): int {
$userId = $input->getArgument('user');
$cachedOnly = $input->getOption('cached-only');
$user = $this->userManager->get($userId);
if (!$user) {
$output->writeln("<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);

$format = $input->getOption('output');

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>");
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 (!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());
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;
}

Expand Down
2 changes: 2 additions & 0 deletions apps/files_sharing/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
'OCA\\Files_Sharing\\Listener\\ShareInteractionListener' => $baseDir . '/../lib/Listener/ShareInteractionListener.php',
'OCA\\Files_Sharing\\Listener\\SharesUpdatedListener' => $baseDir . '/../lib/Listener/SharesUpdatedListener.php',
'OCA\\Files_Sharing\\Listener\\UserAddedToGroupListener' => $baseDir . '/../lib/Listener/UserAddedToGroupListener.php',
'OCA\\Files_Sharing\\Listener\\UserHomeSetupListener' => $baseDir . '/../lib/Listener/UserHomeSetupListener.php',
'OCA\\Files_Sharing\\Listener\\UserShareAcceptanceListener' => $baseDir . '/../lib/Listener/UserShareAcceptanceListener.php',
'OCA\\Files_Sharing\\Middleware\\OCSShareAPIMiddleware' => $baseDir . '/../lib/Middleware/OCSShareAPIMiddleware.php',
'OCA\\Files_Sharing\\Middleware\\ShareInfoMiddleware' => $baseDir . '/../lib/Middleware/ShareInfoMiddleware.php',
Expand All @@ -96,6 +97,7 @@
'OCA\\Files_Sharing\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
'OCA\\Files_Sharing\\Scanner' => $baseDir . '/../lib/Scanner.php',
'OCA\\Files_Sharing\\Settings\\Personal' => $baseDir . '/../lib/Settings/Personal.php',
'OCA\\Files_Sharing\\ShareRecipientUpdater' => $baseDir . '/../lib/ShareRecipientUpdater.php',
'OCA\\Files_Sharing\\ShareTargetValidator' => $baseDir . '/../lib/ShareTargetValidator.php',
'OCA\\Files_Sharing\\SharedMount' => $baseDir . '/../lib/SharedMount.php',
'OCA\\Files_Sharing\\SharedStorage' => $baseDir . '/../lib/SharedStorage.php',
Expand Down
2 changes: 2 additions & 0 deletions apps/files_sharing/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions apps/files_sharing/lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use OCA\Files_Sharing\Listener\ShareInteractionListener;
use OCA\Files_Sharing\Listener\SharesUpdatedListener;
use OCA\Files_Sharing\Listener\UserAddedToGroupListener;
use OCA\Files_Sharing\Listener\UserHomeSetupListener;
use OCA\Files_Sharing\Listener\UserShareAcceptanceListener;
use OCA\Files_Sharing\Middleware\OCSShareAPIMiddleware;
use OCA\Files_Sharing\Middleware\ShareInfoMiddleware;
Expand All @@ -45,6 +46,8 @@
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;
Expand All @@ -54,6 +57,7 @@
use OCP\IGroup;
use OCP\Share\Events\BeforeShareDeletedEvent;
use OCP\Share\Events\ShareCreatedEvent;
use OCP\Share\Events\ShareMovedEvent;
use OCP\Share\Events\ShareTransferredEvent;
use OCP\User\Events\UserChangedEvent;
use OCP\User\Events\UserDeletedEvent;
Expand Down Expand Up @@ -119,7 +123,11 @@ function () use ($c) {
$context->registerEventListener(ShareTransferredEvent::class, SharesUpdatedListener::class);
$context->registerEventListener(UserAddedEvent::class, SharesUpdatedListener::class);
$context->registerEventListener(UserRemovedEvent::class, SharesUpdatedListener::class);
$context->registerEventListener(BeforeGroupDeletedEvent::class, SharesUpdatedListener::class);
$context->registerEventListener(GroupDeletedEvent::class, SharesUpdatedListener::class);
$context->registerEventListener(UserShareAccessUpdatedEvent::class, SharesUpdatedListener::class);
$context->registerEventListener(ShareMovedEvent::class, SharesUpdatedListener::class);
$context->registerEventListener(UserHomeSetupEvent::class, UserHomeSetupListener::class);

$context->registerConfigLexicon(ConfigLexicon::class);
}
Expand Down
8 changes: 7 additions & 1 deletion apps/files_sharing/lib/Config/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,10 +36,14 @@ public function getAppConfigs(): array {
new Entry(self::SHOW_FEDERATED_AS_INTERNAL, ValueType::BOOL, false, 'shows federated shares as internal shares', true),
new Entry(self::SHOW_FEDERATED_TO_TRUSTED_AS_INTERNAL, ValueType::BOOL, false, 'shows federated shares to trusted servers as internal shares', true),
new Entry(self::EXCLUDE_RESHARE_FROM_EDIT, ValueType::BOOL, false, 'Exclude reshare permission from "Allow editing" bundled permissions'),

new Entry(self::UPDATE_CUTOFF_TIME, ValueType::FLOAT, 3.0, 'For how how long do we update the share data immediately before switching to only marking the user'),
];
}

public function getUserConfigs(): array {
return [];
return [
new Entry(self::USER_NEEDS_SHARE_REFRESH, ValueType::BOOL, false, 'whether a user needs to have the receiving share data refreshed for possible changes'),
];
}
}
131 changes: 82 additions & 49 deletions apps/files_sharing/lib/Listener/SharesUpdatedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,93 +8,126 @@

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;

/**
* 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,
IAppConfig $appConfig,
) {
$this->cutOffMarkTime = $appConfig->getValueFloat(Application::APP_ID, ConfigLexicon::UPDATE_CUTOFF_TIME, 3.0);
}

public function handle(Event $event): void {
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
$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) {
$share = $event->getShare();
$shareTarget = $share->getTarget();
foreach ($this->shareManager->getUsersForShare($share) as $user) {
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 ShareCreatedEvent
|| $event instanceof ShareTransferredEvent
) {
foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) {
$this->updateForUser($user, true);
if ($event instanceof ShareMovedEvent) {
$share = $event->getShare();
foreach ($this->shareManager->getUsersForShare($share) as $user) {
$this->markOrRun($user, function () use ($user, $share) {
$this->shareUpdater->updateForMovedShare($user, $share);
});
}
}
if ($event instanceof BeforeShareDeletedEvent) {
foreach ($this->shareManager->getUsersForShare($event->getShare()) as $user) {
$this->updateForUser($user, false, [$event->getShare()]);
$share = $event->getShare();
foreach ($this->shareManager->getUsersForShare($share) as $user) {
$this->markOrRun($user, function () use ($user, $share) {
$this->shareUpdater->updateForDeletedShare($user, $share);
});
}
}
}

private function updateForUser(IUser $user, bool $verifyMountPoints, array $ignoreShares = []): void {
// prevent recursion
if (isset($this->inUpdate[$user->getUID()])) {
return;
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);
}
$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);
$end = floatval($this->clock->now()->format('U.u'));
$this->updatedTime += $end - $start;
}

$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);
}
}
}
private function updateOrMarkUser(IUser $user): void {
$this->markOrRun($user, function () use ($user) {
$this->shareUpdater->updateForUser($user);
});
}

if ($mountsChanged) {
$newMounts = $this->shareMountProvider->getMountsFromSuperShares($user, $shares, $this->storageFactory);
$this->userMountCache->registerMounts($user, $newMounts, [MountProvider::class]);
}
private function markUserForRefresh(IUser $user): void {
$this->userConfig->setValueBool($user->getUID(), Application::APP_ID, ConfigLexicon::USER_NEEDS_SHARE_REFRESH, true);
}

unset($this->inUpdate[$user->getUID()]);
public function setCutOffMarkTime(float|int $cutOffMarkTime): void {
$this->cutOffMarkTime = (float)$cutOffMarkTime;
}
}
Loading
Loading