Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion apps/files_sharing/lib/Repair/CleanupShareTarget.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,11 @@ public function run(IOutput $output) {
$userMounts[$newMountPoint] = $mount;
unset($userMounts[$oldMountPoint]);

$this->userMountCache->removeMount($oldMountPoint);
$this->userMountCache->removeMount($oldMountPoint, $recipient);
$this->userMountCache->addMount($recipient, $newMountPoint, new CacheEntry([
'fileid' => $mount->getRootId(),
'storage' => $mount->getStorageId(),
'path' => $mount->getRootInternalPath(),
]), $mount->getMountProvider(), $mount->getMountId());
}
}
Expand Down
134 changes: 134 additions & 0 deletions apps/files_sharing/tests/ShareMountEventsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Files_Sharing\Tests;

use OCA\Files_Sharing\Listener\SharesUpdatedListener;
use OCP\Constants;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Config\Event\UserMountAddedEvent;
use OCP\Files\Config\Event\UserMountRemovedEvent;
use OCP\Files\Folder;
use OCP\Server;
use OCP\Share\IShare;
use PHPUnit\Framework\Attributes\DataProvider;

/**
* Apps like recognize keep per-user data in sync with file access by listening to
* UserMountAddedEvent and UserMountRemovedEvent. These events have to be dispatched
* for the recipients of a share when it is created or deleted, at the latest when a
* recipient sets up their file system afterwards, regardless of whether the share
* mounts of the recipient are updated immediately or deferred until then.
*/
#[\PHPUnit\Framework\Attributes\Group(name: 'DB')]
class ShareMountEventsTest extends TestCase {
private const FOLDER = 'shared-folder';

private IEventDispatcher $eventDispatcher;
/** @var list<Event> */
private array $events = [];
/** @var \Closure(Event): void */
private \Closure $listener;

protected function setUp(): void {
parent::setUp();

$this->eventDispatcher = Server::get(IEventDispatcher::class);
$this->listener = function (Event $event): void {
$this->events[] = $event;
};
$this->eventDispatcher->addListener(UserMountAddedEvent::class, $this->listener);
$this->eventDispatcher->addListener(UserMountRemovedEvent::class, $this->listener);

$this->rootFolder->getUserFolder(self::TEST_FILES_SHARING_API_USER1)->newFolder(self::FOLDER);

$this->loginHelper(self::TEST_FILES_SHARING_API_USER1);
}

protected function tearDown(): void {
$this->eventDispatcher->removeListener(UserMountAddedEvent::class, $this->listener);
$this->eventDispatcher->removeListener(UserMountRemovedEvent::class, $this->listener);
Server::get(SharesUpdatedListener::class)->setCutOffMarkTime(-1);
$this->events = [];

parent::tearDown();
}

/**
* @return array<string, array{int, string, float}>
*/
public static function shareProvider(): array {
// A cutoff time of -1 updates the share mounts of recipients immediately,
// 0 marks recipients so their share mounts are updated on their next setup
return [
'user share, immediate update' => [IShare::TYPE_USER, self::TEST_FILES_SHARING_API_USER2, -1],
'user share, deferred update' => [IShare::TYPE_USER, self::TEST_FILES_SHARING_API_USER2, 0],
'group share, immediate update' => [IShare::TYPE_GROUP, self::TEST_FILES_SHARING_API_GROUP1, -1],
'group share, deferred update' => [IShare::TYPE_GROUP, self::TEST_FILES_SHARING_API_GROUP1, 0],
];
}

#[DataProvider(methodName: 'shareProvider')]
public function testMountAddedEventForCreatedShare(int $shareType, string $recipient, float $cutOffTime): void {
Server::get(SharesUpdatedListener::class)->setCutOffMarkTime($cutOffTime);

$this->share($shareType, self::FOLDER, self::TEST_FILES_SHARING_API_USER1, $recipient, Constants::PERMISSION_ALL);
$this->getUserFolder(self::TEST_FILES_SHARING_API_USER2)->get(self::FOLDER);

$this->assertContains(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix: this lets duplicate events go unnoticed, the exact amount of expected events should be checked

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo having duplicate events is fine.

'/' . self::TEST_FILES_SHARING_API_USER2 . '/files/' . self::FOLDER . '/',
$this->getMountPointsOfEvents(UserMountAddedEvent::class, self::TEST_FILES_SHARING_API_USER2),
'A UserMountAddedEvent should be dispatched for the share mount of the recipient',
);
}

#[DataProvider(methodName: 'shareProvider')]
public function testMountRemovedEventForDeletedShare(int $shareType, string $recipient, float $cutOffTime): void {
$share = $this->share($shareType, self::FOLDER, self::TEST_FILES_SHARING_API_USER1, $recipient, Constants::PERMISSION_ALL);
$this->getUserFolder(self::TEST_FILES_SHARING_API_USER2)->get(self::FOLDER);

Server::get(SharesUpdatedListener::class)->setCutOffMarkTime($cutOffTime);
$this->shareManager->deleteShare($share);
$this->assertFalse(
$this->getUserFolder(self::TEST_FILES_SHARING_API_USER2)->nodeExists(self::FOLDER),
'The share should no longer be mounted for the recipient',
);

$this->assertEquals(
['/' . self::TEST_FILES_SHARING_API_USER2 . '/files/' . self::FOLDER . '/'],
$this->getMountPointsOfEvents(UserMountRemovedEvent::class, self::TEST_FILES_SHARING_API_USER2),
'A UserMountRemovedEvent should be dispatched for the share mount of the recipient',
);
}

/**
* Get the user folder of the user as a new request by them would
*
* Logging in tears down the file system, so the mounts of the user are set up again.
* Otherwise the mounts already set up for the user earlier in this process are
* reused and changes to the shares of the user are not picked up.
*/
private function getUserFolder(string $userId): Folder {
$this->loginHelper($userId);
return $this->rootFolder->getUserFolder($userId);
}
Comment thread
marcelklehr marked this conversation as resolved.

/**
* @param class-string<UserMountAddedEvent|UserMountRemovedEvent> $eventClass
* @return list<string>
*/
private function getMountPointsOfEvents(string $eventClass, string $userId): array {
$mountPoints = [];
foreach ($this->events as $event) {
if ($event instanceof $eventClass && $event->mountPoint->getUser()->getUID() === $userId) {
$mountPoints[] = $event->mountPoint->getMountPoint();
}
}
return $mountPoints;
}
}
34 changes: 34 additions & 0 deletions lib/private/Files/Config/UserMountCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -557,8 +557,26 @@ public function getMountsInPath(IUser $user, string $path): array {
return $result;
}

private function loadMountInfo(IUser $user, string $mountPoint): ?CachedMountInfo {
$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), IQueryBuilder::PARAM_STR)));

$row = $query->executeQuery()->fetch();
return $row ? $this->dbRowToMountInfo($row) : null;
}

#[\Override]
public function removeMount(string $mountPoint, ?IUser $user = null): void {
if ($user) {
Comment thread
icewind1991 marked this conversation as resolved.
$mount = $this->loadMountInfo($user, $mountPoint);
} else {
$mount = null;
}

$query = $this->connection->getQueryBuilder();
$query->delete('mounts')
->where($query->expr()->eq('mount_point_hash', $query->createNamedParameter(hash('xxh128', $mountPoint))));
Expand All @@ -572,6 +590,10 @@ public function removeMount(string $mountPoint, ?IUser $user = null): void {
[, $userId] = $parts;
unset($this->mountsForUsers[$userId]);
}

if ($user && $mount) {
$this->eventDispatcher->dispatchTyped(new UserMountRemovedEvent($mount));
}
}

#[\Override]
Expand All @@ -596,12 +618,24 @@ public function addMount(

try {
$query->executeStatement();

unset($this->mountsForUsers[$user->getUID()]);
} catch (DbalException $e) {
if ($e->getReason() !== DbalException::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
}

$cachedMount = new CachedMountInfo(
$user,
$rootCacheEntry->getStorageId(),
$rootCacheEntry->getId(),
$mountPoint,
$mountProvider,
$mountId,
$rootCacheEntry->getPath(),
);
$this->eventDispatcher->dispatchTyped(new UserMountAddedEvent($cachedMount));
}

/**
Expand Down
Loading