diff --git a/appinfo/info.xml b/appinfo/info.xml index e7e201b50..6ea182e38 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -75,7 +75,7 @@ Requirements: The app does not send any sensitive data to cloud providers or similar services. All processing is done on your Nextcloud machine, using Tensorflow.js running in Node.js. ]]> - 13.1.0 + 13.2.0-dev agpl Marcel Klehr diff --git a/lib/Classifiers/Images/ClusteringFaceClassifier.php b/lib/Classifiers/Images/ClusteringFaceClassifier.php index 81eff74a6..dcdcad4c7 100644 --- a/lib/Classifiers/Images/ClusteringFaceClassifier.php +++ b/lib/Classifiers/Images/ClusteringFaceClassifier.php @@ -92,6 +92,10 @@ public function classify(array $queueFiles): void { $classifierProcess = $this->classifyFiles(self::MODEL_NAME, $filteredQueueFiles, $timeout); + // The mount tables are read for every file in this batch, so refresh them once here + // rather than on every lookup. + $this->userMountCache->clear(); + /** * @var list $faces */ diff --git a/lib/Db/FsActionMapper.php b/lib/Db/FsActionMapper.php index 56d46585c..2b92ec409 100644 --- a/lib/Db/FsActionMapper.php +++ b/lib/Db/FsActionMapper.php @@ -54,12 +54,8 @@ public function findByStorageId(string $className, int $storageId, int $limit = * @param class-string $className * @return list * @throws \OCP\DB\Exception - * @throws \Exception */ public function find(string $className, int $limit = 0): array { - if (!in_array('storage_id', $className::$columns, true)) { - throw new \Exception('entity does not have a storage_id column'); - } $qb = $this->db->getQueryBuilder(); $qb->selectDistinct($className::$columns) ->from($className::$tableName); @@ -227,26 +223,34 @@ public function insertDeletion(int $storageId, int $nodeId): FsCreation|FsDeleti /** * @param int $nodeId - * @param string $owner - * @param list $addedUsers - * @param list $targetUsers - * @return FsCreation|FsDeletion|FsMove|FsAccessUpdate - * @throws Exception|MultipleObjectsReturnedException + * @return FsMove + * @throws Exception */ - public function insertMove(int $nodeId, string $owner, array $addedUsers, array $targetUsers): Entity { + public function insertMove(int $nodeId): FsMove { + // A move for this node may still be pending. Replace it with a fresh row rather than + // keeping it: the job deletes the rows it has processed by ID, so a row the job was + // already working on would be deleted without this move having been processed. A + // fresh row has a new ID, which the running job doesn't know about, so it survives + // until the next run. + $this->db->beginTransaction(); try { - $move = $this->findByNodeId(FsMove::class, $nodeId); - } catch (DoesNotExistException $e) { + $qb = $this->db->getQueryBuilder(); + $qb->delete(FsMove::$tableName) + ->where($qb->expr()->eq('node_id', $qb->createPositionalParameter($nodeId, IQueryBuilder::PARAM_INT))); + $qb->executeStatement(); + $move = new FsMove(); $move->setNodeId($nodeId); - $move->setOwner($owner); - $move->setAddedUsers($addedUsers); - $move->setTargetUsers($targetUsers); $this->insert($move); - $arguments = [ 'type' => FsDeletion::class ]; - if (!$this->jobList->has(ProcessFsActionsJob::class, $arguments)) { - $this->jobList->add(ProcessFsActionsJob::class, $arguments); - } + $this->db->commit(); + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + + $arguments = [ 'type' => FsMove::class ]; + if (!$this->jobList->has(ProcessFsActionsJob::class, $arguments)) { + $this->jobList->add(ProcessFsActionsJob::class, $arguments); } return $move; } diff --git a/lib/Db/FsMove.php b/lib/Db/FsMove.php index 198994b24..de0ea5427 100644 --- a/lib/Db/FsMove.php +++ b/lib/Db/FsMove.php @@ -15,60 +15,24 @@ * @package OCA\Recognize\Db * @method int getNodeId() * @method setNodeId(int $nodeId) - * @method string getOwner() - * @method setOwner(string $owner) */ final class FsMove extends Entity { protected ?int $nodeId = null; - protected ?string $owner = null; - protected ?string $addedUsers = null; - protected ?string $targetUsers = null; /** * @var string[] */ - public static array $columns = ['id', 'node_id', 'owner', 'added_users', 'target_users']; + public static array $columns = ['id', 'node_id']; /** * @var string[] */ - public static array $fields = ['id', 'nodeId', 'owner', 'addedUsers', 'targetUsers']; + public static array $fields = ['id', 'nodeId']; public static string $tableName = 'recognize_fs_moves'; public function __construct() { // add types in constructor $this->addType('nodeId', 'integer'); - $this->addType('owner', 'string'); - $this->addType('addedUsers', 'string'); - $this->addType('targetUsers', 'string'); - } - - /** - * @return list - */ - public function getAddedUsers(): array { - return explode(',', $this->addedUsers ?? ''); - } - - /** - * @return list - */ - public function getTargetUsers(): array { - return explode(',', $this->targetUsers ?? ''); - } - - /** - * @param list $users - */ - public function setAddedUsers(array $users): void { - $this->setter('addedUsers', [implode(',', $users)]); - } - - /** - * @param list $users - */ - public function setTargetUsers(array $users): void { - $this->setter('targetUsers', [implode(',', $users)]); } } diff --git a/lib/Hooks/FileListener.php b/lib/Hooks/FileListener.php index 1426b6384..17b8681d9 100644 --- a/lib/Hooks/FileListener.php +++ b/lib/Hooks/FileListener.php @@ -15,8 +15,6 @@ use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Files\Cache\CacheEntryInsertedEvent; -use OCP\Files\Config\ICachedMountInfo; -use OCP\Files\Config\IUserMountCache; use OCP\Files\Events\Node\BeforeNodeDeletedEvent; use OCP\Files\Events\Node\BeforeNodeRenamedEvent; use OCP\Files\Events\Node\NodeCreatedEvent; @@ -37,9 +35,6 @@ final class FileListener implements IEventListener { private ?bool $movingFromIgnoredTerritory; private ?array $movingDirFromIgnoredTerritory; - /** @var list */ - private array $sourceUserIds; - private ?Node $source = null; /** @var array */ private array $addedMounts = []; @@ -48,28 +43,10 @@ public function __construct( private LoggerInterface $logger, private IgnoreService $ignoreService, private IRootFolder $rootFolder, - private IUserMountCache $userMountCache, private FsActionMapper $fsActionMapper, ) { $this->movingFromIgnoredTerritory = null; $this->movingDirFromIgnoredTerritory = null; - $this->sourceUserIds = []; - } - - /** - * @param int $nodeId - * @return list - * @throws InvalidPathException - * @throws NotFoundException - */ - private function getUsersWithFileAccess(int $nodeId): array { - $this->userMountCache->clear(); - $mountInfos = $this->userMountCache->getMountsForFileId($nodeId); - $userIds = array_map(static function (ICachedMountInfo $mountInfo) { - return $mountInfo->getUser()->getUID(); - }, $mountInfos); - - return array_values(array_unique($userIds)); } public function handle(Event $event): void { @@ -112,8 +89,6 @@ public function handle(Event $event): void { } else { $this->movingDirFromIgnoredTerritory = $this->getDirIgnores($event->getSource()); } - $this->sourceUserIds = $this->getUsersWithFileAccess($event->getSource()->getId()); - $this->source = $event->getSource(); return; } if ($event instanceof NodeRenamedEvent) { @@ -171,7 +146,7 @@ public function handle(Event $event): void { return; } } - $this->postRename($this->source ?? $event->getSource(), $event->getTarget()); + $this->postRename($event->getTarget()); return; } if ($event instanceof BeforeNodeDeletedEvent) { @@ -282,20 +257,14 @@ public function postInsert(Node $node, bool $recurse = true, ?array $mimeTypes = * @throws NotFoundException * @throws Exception */ - public function postRename(Node $source, Node $target): void { - $targetUserIds = $this->getUsersWithFileAccess($target->getId()); - - $usersToAdd = array_values(array_diff($targetUserIds, $this->sourceUserIds)); - $existingUsers = array_diff($targetUserIds, $usersToAdd); - $sourceOwner = $source->getOwner(); - $targetOwner = $target->getOwner(); - $ownerId = $sourceOwner?->getUID() ?? $targetOwner?->getUID() ?? $existingUsers[0]; - + public function postRename(Node $target): void { if (preg_match('#^/[^/]*?/files/#', $target->getPath()) !== 1 && preg_match('#^/groupfolders/#', $target->getPath()) !== 1) { return; } - $this->fsActionMapper->insertMove($target->getId(), $ownerId, $usersToAdd, $targetUserIds); + // Who has access to the moved node, and to each file below it, is resolved when + // the move is processed, so the node ID is all that needs to be recorded. + $this->fsActionMapper->insertMove($target->getId()); } /** diff --git a/lib/Migration/Version013002000Date20260924120000.php b/lib/Migration/Version013002000Date20260924120000.php new file mode 100644 index 000000000..0f0bab26c --- /dev/null +++ b/lib/Migration/Version013002000Date20260924120000.php @@ -0,0 +1,47 @@ +hasTable('recognize_fs_moves')) { + return null; + } + + $changed = false; + $table = $schema->getTable('recognize_fs_moves'); + foreach (['owner', 'added_users', 'target_users'] as $column) { + if ($table->hasColumn($column)) { + $table->dropColumn($column); + $changed = true; + } + } + return $changed ? $schema : null; + } +} diff --git a/lib/Service/FsActionService.php b/lib/Service/FsActionService.php index 23bd2b643..0d7ec0bc2 100644 --- a/lib/Service/FsActionService.php +++ b/lib/Service/FsActionService.php @@ -32,6 +32,7 @@ final class FsActionService { public const BATCH_SIZE = 1000; + public function __construct( private FsActionMapper $fsActionMapper, private LoggerInterface $logger, @@ -75,6 +76,9 @@ public function processActionsByClass(string $className): void { * @param array $actions */ public function processActions(array $actions): void { + // The mount tables are read repeatedly while processing a batch, so refresh them + // once here rather than on every lookup. + $this->userMountCache->clear(); $lastUserId = null; foreach ($actions as $action) { switch ($action::class) { @@ -125,7 +129,7 @@ public function processActions(array $actions): void { break; } try { - $this->onMove($action->getOwner(), $action->getAddedUsers(), $action->getTargetUsers(), $node); + $this->onMove($node); } catch (Exception|InvalidPathException|NotFoundException $e) { $this->logger->warning('Failed to process move action: ' . $e->getMessage() . ' Continuing.', ['exception' => $e]); } @@ -149,7 +153,6 @@ public function processActions(array $actions): void { * @return list */ private function getUsersWithFileAccess(int $nodeId): array { - $this->userMountCache->clear(); $mountInfos = $this->userMountCache->getMountsForFileId($nodeId); $userIds = array_map(static function (ICachedMountInfo $mountInfo) { return $mountInfo->getUser()->getUID(); @@ -164,37 +167,73 @@ private function getUsersWithFileAccess(int $nodeId): array { * @throws Exception */ private function onAccessUpdate(int $storageId, int $rootId): void { - $userIds = $this->getUsersWithFileAccess($rootId); $files = $this->storageService->getFilesInMount($storageId, $rootId, [ClusteringFaceClassifier::MODEL_NAME], 0, 0); $userIdsToScheduleClustering = []; foreach ($files as $fileInfo) { - $node = $this->rootFolder->getFirstNodeById($fileInfo['fileid']) ?: null; - $ownerId = $node?->getOwner()?->getUID(); - if ($ownerId === null) { - continue; - } - $detectionsForFile = $this->faceDetectionMapper->findByFileId($fileInfo['fileid']); - $userHasDetectionForFile = []; - foreach ($detectionsForFile as $detection) { - $userHasDetectionForFile[$detection->getUserId()] = true; - } - foreach ($userIds as $userId) { - if ($userId === $ownerId) { - continue; - } - if ($userHasDetectionForFile[$userId] ?? false) { - continue; - } - $this->faceDetectionMapper->copyDetectionsForFileFromUserToUser($fileInfo['fileid'], $ownerId, $userId); - $userIdsToScheduleClustering[$userId] = true; - } - $this->faceDetectionMapper->removeDetectionsForFileFromUsersNotInList($fileInfo['fileid'], $userIds); + $this->syncDetectionsForFile($fileInfo['fileid'], $userIdsToScheduleClustering); } foreach (array_keys($userIdsToScheduleClustering) as $userId) { $this->jobList->add(ClusterFacesJob::class, ['userId' => (string)$userId]); } } + /** + * Gives every user with access to the file a copy of its face detections and removes + * the detections of users who lost access. + * + * Access is resolved per file rather than taken from an ancestor: a descendant can be + * shared directly, in which case it is reachable by users the ancestor is not shared + * with, and pruning against the ancestor's list would delete detections they still + * have access to. + * + * @param array $userIdsToScheduleClustering + * @throws Exception + */ + private function syncDetectionsForFile(int $fileId, array &$userIdsToScheduleClustering): void { + $detectionCountByUser = $this->getDetectionCountByUser($fileId); + if (count($detectionCountByUser) === 0) { + // Nothing detected for this file (yet): nothing to copy, nothing to prune + return; + } + $targetUserIds = $this->getUsersWithFileAccess($fileId); + if (count($targetUserIds) === 0) { + // No mounts found, e.g. because the file vanished in the meantime: don't treat + // that as everyone having lost access + return; + } + $sourceUserId = (string)array_key_first($detectionCountByUser); + foreach ($targetUserIds as $userId) { + if (isset($detectionCountByUser[$userId])) { + continue; + } + $this->faceDetectionMapper->copyDetectionsForFileFromUserToUser($fileId, $sourceUserId, $userId); + $userIdsToScheduleClustering[$userId] = true; + } + $this->faceDetectionMapper->removeDetectionsForFileFromUsersNotInList($fileId, $targetUserIds); + } + + /** + * How many face detections each user holds for a file, most complete set first. + * + * The first key is the user to copy detections from when granting access to further + * users. The file system owner cannot be used for that: group folder nodes have no + * owner when they are resolved outside of a user session, which is the case in the + * background jobs that process fs actions. Whoever already holds detections for the + * file is both a more reliable and a more direct answer to the question. + * + * @return array + * @throws Exception + */ + private function getDetectionCountByUser(int $fileId): array { + $detectionCountByUser = []; + foreach ($this->faceDetectionMapper->findByFileId($fileId) as $detection) { + $userId = (string)$detection->getUserId(); + $detectionCountByUser[$userId] = ($detectionCountByUser[$userId] ?? 0) + 1; + } + arsort($detectionCountByUser); + return $detectionCountByUser; + } + /** * @throws \OCP\Files\InvalidPathException */ @@ -325,34 +364,38 @@ public function onDeletion(int $nodeId, ?array $mimeTypes = null): void { } /** - * @param string $ownerId - * @param list $usersToAdd - * @param list $targetUserIds - * @param Node $node - * @return void * @throws Exception|InvalidPathException|NotFoundException */ - private function onMove(string $ownerId, array $usersToAdd, array $targetUserIds, Node $node): void { + private function onMove(Node $node): void { + $userIdsToScheduleClustering = []; + $this->moveNode($node, $userIdsToScheduleClustering); + foreach (array_keys($userIdsToScheduleClustering) as $userId) { + $this->jobList->add(ClusterFacesJob::class, ['userId' => (string)$userId]); + } + } + + /** + * @param array $userIdsToScheduleClustering + * @throws Exception|InvalidPathException|NotFoundException + */ + private function moveNode(Node $node, array &$userIdsToScheduleClustering): void { if ($node instanceof Folder) { - try { - foreach ($node->getDirectoryListing() as $n) { - if (!in_array($n->getMimetype(), Constants::IMAGE_FORMATS)) { - continue; - } - $this->onMove($ownerId, $usersToAdd, $targetUserIds, $n); + foreach ($node->getDirectoryListing() as $n) { + // Recurse into subfolders: we only get a rename event for the top node, + // so the whole subtree has to be walked here. + if ($n->getType() !== FileInfo::TYPE_FOLDER && !in_array($n->getMimetype(), Constants::IMAGE_FORMATS)) { + continue; + } + try { + $this->moveNode($n, $userIdsToScheduleClustering); + } catch (NotFoundException|Exception|InvalidPathException $e) { + // Per child, so one unreadable node doesn't abandon the rest of the subtree: + // the action row is deleted either way, so skipped files are never retried. + $this->logger->warning('Failed to process move for node ' . $n->getId(), ['exception' => $e]); } - } catch (NotFoundException|Exception|InvalidPathException $e) { - $this->logger->warning('Error in recognize file listener', ['exception' => $e]); } return; } - foreach ($usersToAdd as $userId) { - if (count($this->faceDetectionMapper->findByFileIdAndUser($node->getId(), $userId)) > 0) { - continue; - } - $this->faceDetectionMapper->copyDetectionsForFileFromUserToUser($node->getId(), $ownerId, $userId); - $this->jobList->add(ClusterFacesJob::class, ['userId' => $userId]); - } - $this->faceDetectionMapper->removeDetectionsForFileFromUsersNotInList($node->getId(), $targetUserIds); + $this->syncDetectionsForFile($node->getId(), $userIdsToScheduleClustering); } } diff --git a/lib/TaskProcessing/TaskResultListener.php b/lib/TaskProcessing/TaskResultListener.php index bfcad9f6f..97f5764e8 100644 --- a/lib/TaskProcessing/TaskResultListener.php +++ b/lib/TaskProcessing/TaskResultListener.php @@ -194,6 +194,9 @@ private function applyTagResults(array $fileIds, array $results, string $model, private function applyFaceResults(array $fileIds, array $results): void { $model = ClusteringFaceClassifier::MODEL_NAME; $scheduledClusterJobsFor = []; + // The mount tables are read for every file in this batch, so refresh them once here + // rather than on every lookup. + $this->userMountCache->clear(); foreach ($fileIds as $i => $fileId) { if (!isset($results[$i])) { continue; diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 190286a67..899e81642 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,10 +1,5 @@ - - - getContainer()->get(PropFindPlugin::class)]]> - - @@ -947,7 +942,12 @@ + + + + getUserId()]]> + diff --git a/tests/FsActionServiceTest.php b/tests/FsActionServiceTest.php new file mode 100644 index 000000000..3062c4859 --- /dev/null +++ b/tests/FsActionServiceTest.php @@ -0,0 +1,246 @@ +createUser(self::OWNER, self::OWNER); + $backend->createUser(self::RECIPIENT, self::RECIPIENT); + Server::get(\OCP\IUserManager::class)->registerBackend($backend); + + // The file listener relies on node events, which are emitted by hooks that + // TestCase::tearDownAfterClass() clears after every test class + \OC_Hook::clear('OC_Filesystem'); + Server::get(\OC\Files\Node\HookConnector::class)->viewToNode(); + } + + public function setUp(): void { + parent::setUp(); + $this->rootFolder = Server::get(IRootFolder::class); + $this->faceDetectionMapper = Server::get(FaceDetectionMapper::class); + $this->fsActionMapper = Server::get(FsActionMapper::class); + $this->jobList = Server::get(IJobList::class); + $this->shareManager = Server::get(IShareManager::class); + $this->db = Server::get(IDBConnection::class); + + $this->loginAsUser(self::OWNER); + $this->userFolder = $this->rootFolder->getUserFolder(self::OWNER); + + foreach ($this->shareManager->getSharesBy(self::OWNER, IShare::TYPE_USER, null, true, -1) as $share) { + $this->shareManager->deleteShare($share); + } + foreach ($this->userFolder->getDirectoryListing() as $node) { + $node->delete(); + } + \OCP\Server::get(\OCA\Files_Trashbin\Trashbin::class)->deleteAll(); + $this->refreshMounts(self::RECIPIENT); + + foreach (['recognize_face_detections', FsMove::$tableName, FsAccessUpdate::$tableName, FsCreation::$tableName, FsDeletion::$tableName] as $table) { + $qb = $this->db->getQueryBuilder(); + $qb->delete($table)->executeStatement(); + } + $this->jobList->remove(ProcessFsActionsJob::class); + $this->jobList->remove(ClusterFacesJob::class); + } + + public function testMoveQueuesExactlyOneAction(): void { + $file = $this->userFolder->newFile('photo.jpg', 'content'); + $this->runFsActionJobs(); + + $file->move($this->userFolder->getPath() . '/photo-1.jpg'); + self::assertEquals(1, $this->fsActionMapper->count(FsMove::class), 'moving a file should queue a move action'); + self::assertTrue($this->jobList->has(ProcessFsActionsJob::class, ['type' => FsMove::class]), 'moving a file should schedule the job processing move actions'); + + // A second move before the job ran replaces the pending action instead of failing or piling up + $file->move($this->userFolder->getPath() . '/photo-2.jpg'); + self::assertEquals(1, $this->fsActionMapper->count(FsMove::class), 'moving a file again should replace the pending move action'); + + $this->runFsActionJobs(); + self::assertEquals(0, $this->fsActionMapper->count(FsMove::class), 'the job should process all move actions'); + self::assertFalse($this->jobList->has(ProcessFsActionsJob::class, ['type' => FsMove::class]), 'the job should remove itself once all move actions are processed'); + } + + public function testMoveDuringProcessingIsNotLost(): void { + $file = $this->userFolder->newFile('photo.jpg', 'content'); + $this->runFsActionJobs(); + + $file->move($this->userFolder->getPath() . '/photo-1.jpg'); + // The job has fetched the pending action, but not processed it yet, when the file is moved again + $actions = $this->fsActionMapper->find(FsMove::class); + $file->move($this->userFolder->getPath() . '/photo-2.jpg'); + Server::get(FsActionService::class)->processActions($actions); + self::assertEquals(1, $this->fsActionMapper->count(FsMove::class), 'a move made while the job was processing should stay queued'); + } + + public function testMoveFileIntoAndOutOfSharedFolder(): void { + $shared = $this->userFolder->newFolder('shared'); + $this->shareWithRecipient($shared); + $file = $this->userFolder->newFile('photo.jpg', 'content'); + $this->addDetection($file->getId(), self::OWNER); + $this->runFsActionJobs(); + self::assertEquals([self::OWNER], $this->getUsersWithDetections($file->getId())); + + $file->move($shared->getPath() . '/photo.jpg'); + $this->runFsActionJobs(); + self::assertEquals([self::OWNER, self::RECIPIENT], $this->getUsersWithDetections($file->getId()), 'recipient should get detections for a file moved into a folder shared with them'); + self::assertTrue($this->jobList->has(ClusterFacesJob::class, ['userId' => self::RECIPIENT]), 'clustering should be scheduled for the recipient'); + + $file->move($this->userFolder->getPath() . '/photo.jpg'); + $this->runFsActionJobs(); + self::assertEquals([self::OWNER], $this->getUsersWithDetections($file->getId()), 'recipient should lose detections for a file moved out of a folder shared with them'); + } + + public function testMoveFolderWithSubfoldersIntoSharedFolder(): void { + $shared = $this->userFolder->newFolder('shared'); + $this->shareWithRecipient($shared); + $album = $this->userFolder->newFolder('album'); + $files = [ + $album->newFile('a.jpg', 'content'), + $album->newFolder('sub')->newFile('b.jpg', 'content'), + $album->get('sub')->newFolder('deeper')->newFile('c.jpg', 'content'), + ]; + foreach ($files as $file) { + $this->addDetection($file->getId(), self::OWNER); + } + $this->runFsActionJobs(); + + $album->move($shared->getPath() . '/album'); + $this->runFsActionJobs(); + foreach ($files as $file) { + self::assertEquals([self::OWNER, self::RECIPIENT], $this->getUsersWithDetections($file->getId()), 'recipient should get detections for ' . $file->getInternalPath()); + } + } + + public function testMoveFolderKeepsDetectionsOfDirectlySharedSubfolder(): void { + $album = $this->userFolder->newFolder('album'); + $sub = $album->newFolder('sub'); + $file = $sub->newFile('b.jpg', 'content'); + $this->shareWithRecipient($sub); + $this->addDetection($file->getId(), self::OWNER); + $this->addDetection($file->getId(), self::RECIPIENT); + $this->runFsActionJobs(); + + // The moved folder itself is not shared with the recipient, but the subfolder still is + $album->move($this->userFolder->getPath() . '/album-renamed'); + $this->runFsActionJobs(); + self::assertEquals([self::OWNER, self::RECIPIENT], $this->getUsersWithDetections($file->getId()), 'recipient should keep detections for a file in a subfolder shared with them directly'); + } + + public function testSharingAndUnsharingFolderUpdatesDetectionsInSubfolders(): void { + $folder = $this->userFolder->newFolder('toshare'); + $files = [ + $folder->newFile('a.jpg', 'content'), + $folder->newFolder('sub')->newFile('b.jpg', 'content'), + ]; + foreach ($files as $file) { + $this->addDetection($file->getId(), self::OWNER); + } + $this->runFsActionJobs(); + + $share = $this->shareWithRecipient($folder); + $this->runFsActionJobs(); + foreach ($files as $file) { + self::assertEquals([self::OWNER, self::RECIPIENT], $this->getUsersWithDetections($file->getId()), 'recipient should get detections after sharing for ' . $file->getInternalPath()); + } + + $this->shareManager->deleteShare($share); + $this->refreshMounts(self::RECIPIENT); + $this->runFsActionJobs(); + foreach ($files as $file) { + self::assertEquals([self::OWNER], $this->getUsersWithDetections($file->getId()), 'recipient should lose detections after unsharing for ' . $file->getInternalPath()); + } + } + + private function shareWithRecipient(Folder $folder): IShare { + $share = $this->shareManager->newShare(); + $share->setNode($folder) + ->setShareType(IShare::TYPE_USER) + ->setSharedWith(self::RECIPIENT) + ->setSharedBy(self::OWNER) + ->setPermissions(Constants::PERMISSION_ALL); + $share = $this->shareManager->createShare($share); + $this->refreshMounts(self::RECIPIENT); + return $share; + } + + /** + * Sets up the user's file system from scratch, so that the mount cache (and the listeners for + * mounts being added or removed) sees shares created or deleted in the meantime + */ + private function refreshMounts(string $userId): void { + Server::get(SetupManager::class)->tearDown(); + $this->rootFolder->getUserFolder($userId); + $this->loginAsUser(self::OWNER); + $this->userFolder = $this->rootFolder->getUserFolder(self::OWNER); + } + + private function addDetection(int $fileId, string $userId): void { + $detection = new FaceDetection(); + $detection->setFileId($fileId); + $detection->setUserId($userId); + $detection->setX(0.1); + $detection->setY(0.2); + $detection->setHeight(0.3); + $detection->setWidth(0.4); + $detection->setThreshold(0.5); + $detection->setVector([1, 2, 3, 4, 5, 6, 7, 8, 9, 0]); + $this->faceDetectionMapper->insertWithoutDeduplication($detection); + } + + /** + * @return list + */ + private function getUsersWithDetections(int $fileId): array { + $userIds = array_values(array_unique(array_map(static fn (FaceDetection $detection) => $detection->getUserId(), $this->faceDetectionMapper->findByFileId($fileId)))); + sort($userIds); + return $userIds; + } + + private function runFsActionJobs(): void { + $runs = 0; + while ($job = $this->jobList->getNext(jobClasses: [ProcessFsActionsJob::class])) { + // A job that fails to process its actions never removes itself + self::assertLessThan(100, $runs++, 'fs action jobs should finish'); + $this->jobList->resetBackgroundJob($job); + $job->start($this->jobList); + $this->jobList->resetBackgroundJob($job); + } + } +}