diff options
Diffstat (limited to 'lib/private/SystemTag')
-rw-r--r-- | lib/private/SystemTag/ManagerFactory.php | 64 | ||||
-rw-r--r-- | lib/private/SystemTag/SystemTag.php | 59 | ||||
-rw-r--r-- | lib/private/SystemTag/SystemTagManager.php | 449 | ||||
-rw-r--r-- | lib/private/SystemTag/SystemTagObjectMapper.php | 367 | ||||
-rw-r--r-- | lib/private/SystemTag/SystemTagsInFilesDetector.php | 55 |
5 files changed, 994 insertions, 0 deletions
diff --git a/lib/private/SystemTag/ManagerFactory.php b/lib/private/SystemTag/ManagerFactory.php new file mode 100644 index 00000000000..4d3a1396529 --- /dev/null +++ b/lib/private/SystemTag/ManagerFactory.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\SystemTag; + +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IServerContainer; +use OCP\IUserSession; +use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTagManagerFactory; +use OCP\SystemTag\ISystemTagObjectMapper; + +/** + * Default factory class for system tag managers + * + * @package OCP\SystemTag + * @since 9.0.0 + */ +class ManagerFactory implements ISystemTagManagerFactory { + /** + * Constructor for the system tag manager factory + */ + public function __construct( + private IServerContainer $serverContainer, + ) { + } + + /** + * Creates and returns an instance of the system tag manager + * + * @since 9.0.0 + */ + public function getManager(): ISystemTagManager { + return new SystemTagManager( + $this->serverContainer->get(IDBConnection::class), + $this->serverContainer->get(IGroupManager::class), + $this->serverContainer->get(IEventDispatcher::class), + $this->serverContainer->get(IUserSession::class), + $this->serverContainer->get(IAppConfig::class), + ); + } + + /** + * Creates and returns an instance of the system tag object + * mapper + * + * @since 9.0.0 + */ + public function getObjectMapper(): ISystemTagObjectMapper { + return new SystemTagObjectMapper( + $this->serverContainer->get(IDBConnection::class), + $this->getManager(), + $this->serverContainer->get(IEventDispatcher::class), + ); + } +} diff --git a/lib/private/SystemTag/SystemTag.php b/lib/private/SystemTag/SystemTag.php new file mode 100644 index 00000000000..1a573dabeaa --- /dev/null +++ b/lib/private/SystemTag/SystemTag.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\SystemTag; + +use OCP\SystemTag\ISystemTag; + +class SystemTag implements ISystemTag { + public function __construct( + private string $id, + private string $name, + private bool $userVisible, + private bool $userAssignable, + private ?string $etag = null, + private ?string $color = null, + ) { + } + + public function getId(): string { + return $this->id; + } + + public function getName(): string { + return $this->name; + } + + public function isUserVisible(): bool { + return $this->userVisible; + } + + public function isUserAssignable(): bool { + return $this->userAssignable; + } + + public function getAccessLevel(): int { + if (!$this->userVisible) { + return self::ACCESS_LEVEL_INVISIBLE; + } + + if (!$this->userAssignable) { + return self::ACCESS_LEVEL_RESTRICTED; + } + + return self::ACCESS_LEVEL_PUBLIC; + } + + public function getETag(): ?string { + return $this->etag; + } + + public function getColor(): ?string { + return $this->color; + } +} diff --git a/lib/private/SystemTag/SystemTagManager.php b/lib/private/SystemTag/SystemTagManager.php new file mode 100644 index 00000000000..4b421fa033a --- /dev/null +++ b/lib/private/SystemTag/SystemTagManager.php @@ -0,0 +1,449 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\SystemTag; + +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserSession; +use OCP\SystemTag\ISystemTag; +use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ManagerEvent; +use OCP\SystemTag\TagAlreadyExistsException; +use OCP\SystemTag\TagCreationForbiddenException; +use OCP\SystemTag\TagNotFoundException; +use OCP\SystemTag\TagUpdateForbiddenException; + +/** + * Manager class for system tags + */ +class SystemTagManager implements ISystemTagManager { + public const TAG_TABLE = 'systemtag'; + public const TAG_GROUP_TABLE = 'systemtag_group'; + + /** + * Prepared query for selecting tags directly + */ + private IQueryBuilder $selectTagQuery; + + public function __construct( + protected IDBConnection $connection, + protected IGroupManager $groupManager, + protected IEventDispatcher $dispatcher, + private IUserSession $userSession, + private IAppConfig $appConfig, + ) { + $query = $this->connection->getQueryBuilder(); + $this->selectTagQuery = $query->select('*') + ->from(self::TAG_TABLE) + ->where($query->expr()->eq('name', $query->createParameter('name'))) + ->andWhere($query->expr()->eq('visibility', $query->createParameter('visibility'))) + ->andWhere($query->expr()->eq('editable', $query->createParameter('editable'))); + } + + public function getTagsByIds($tagIds, ?IUser $user = null): array { + if (!\is_array($tagIds)) { + $tagIds = [$tagIds]; + } + + $tags = []; + + // note: not all databases will fail if it's a string or starts with a number + foreach ($tagIds as $tagId) { + if (!is_numeric($tagId)) { + throw new \InvalidArgumentException('Tag id must be integer'); + } + } + + $query = $this->connection->getQueryBuilder(); + $query->select('*') + ->from(self::TAG_TABLE) + ->where($query->expr()->in('id', $query->createParameter('tagids'))) + ->addOrderBy('name', 'ASC') + ->addOrderBy('visibility', 'ASC') + ->addOrderBy('editable', 'ASC') + ->setParameter('tagids', $tagIds, IQueryBuilder::PARAM_INT_ARRAY); + + $result = $query->execute(); + while ($row = $result->fetch()) { + $tag = $this->createSystemTagFromRow($row); + if ($user && !$this->canUserSeeTag($tag, $user)) { + // if a user is given, hide invisible tags + continue; + } + $tags[$row['id']] = $tag; + } + + $result->closeCursor(); + + if (\count($tags) !== \count($tagIds)) { + throw new TagNotFoundException( + 'Tag id(s) not found', 0, null, array_diff($tagIds, array_keys($tags)) + ); + } + + return $tags; + } + + public function getAllTags($visibilityFilter = null, $nameSearchPattern = null): array { + $tags = []; + + $query = $this->connection->getQueryBuilder(); + $query->select('*') + ->from(self::TAG_TABLE); + + if (!\is_null($visibilityFilter)) { + $query->andWhere($query->expr()->eq('visibility', $query->createNamedParameter((int)$visibilityFilter))); + } + + if (!empty($nameSearchPattern)) { + $query->andWhere( + $query->expr()->iLike( + 'name', + $query->createNamedParameter('%' . $this->connection->escapeLikeParameter($nameSearchPattern) . '%') + ) + ); + } + + $query + ->addOrderBy('name', 'ASC') + ->addOrderBy('visibility', 'ASC') + ->addOrderBy('editable', 'ASC'); + + $result = $query->executeQuery(); + while ($row = $result->fetch()) { + $tags[$row['id']] = $this->createSystemTagFromRow($row); + } + + $result->closeCursor(); + + return $tags; + } + + public function getTag(string $tagName, bool $userVisible, bool $userAssignable): ISystemTag { + // Length of name column is 64 + $truncatedTagName = substr($tagName, 0, 64); + $result = $this->selectTagQuery + ->setParameter('name', $truncatedTagName) + ->setParameter('visibility', $userVisible ? 1 : 0) + ->setParameter('editable', $userAssignable ? 1 : 0) + ->execute(); + + $row = $result->fetch(); + $result->closeCursor(); + if (!$row) { + throw new TagNotFoundException( + 'Tag ("' . $truncatedTagName . '", ' . $userVisible . ', ' . $userAssignable . ') does not exist' + ); + } + + return $this->createSystemTagFromRow($row); + } + + public function createTag(string $tagName, bool $userVisible, bool $userAssignable): ISystemTag { + $user = $this->userSession->getUser(); + if (!$this->canUserCreateTag($user)) { + throw new TagCreationForbiddenException(); + } + + // Check if tag already exists (case-insensitive) + $existingTags = $this->getAllTags(null, $tagName); + foreach ($existingTags as $existingTag) { + if (mb_strtolower($existingTag->getName()) === mb_strtolower($tagName)) { + throw new TagAlreadyExistsException('Tag ' . $tagName . ' already exists'); + } + } + + // Length of name column is 64 + $truncatedTagName = substr($tagName, 0, 64); + $query = $this->connection->getQueryBuilder(); + $query->insert(self::TAG_TABLE) + ->values([ + 'name' => $query->createNamedParameter($truncatedTagName), + 'visibility' => $query->createNamedParameter($userVisible ? 1 : 0), + 'editable' => $query->createNamedParameter($userAssignable ? 1 : 0), + 'etag' => $query->createNamedParameter(md5((string)time())), + ]); + + try { + $query->execute(); + } catch (UniqueConstraintViolationException $e) { + throw new TagAlreadyExistsException( + 'Tag ("' . $truncatedTagName . '", ' . $userVisible . ', ' . $userAssignable . ') already exists', + 0, + $e + ); + } + + $tagId = $query->getLastInsertId(); + + $tag = new SystemTag( + (string)$tagId, + $truncatedTagName, + $userVisible, + $userAssignable + ); + + $this->dispatcher->dispatch(ManagerEvent::EVENT_CREATE, new ManagerEvent( + ManagerEvent::EVENT_CREATE, $tag + )); + + return $tag; + } + + public function updateTag( + string $tagId, + string $newName, + bool $userVisible, + bool $userAssignable, + ?string $color, + ): void { + try { + $tags = $this->getTagsByIds($tagId); + } catch (TagNotFoundException $e) { + throw new TagNotFoundException( + 'Tag does not exist', 0, null, [$tagId] + ); + } + + $user = $this->userSession->getUser(); + if (!$this->canUserUpdateTag($user)) { + throw new TagUpdateForbiddenException(); + } + + $beforeUpdate = array_shift($tags); + // Length of name column is 64 + $newName = trim($newName); + $truncatedNewName = substr($newName, 0, 64); + $afterUpdate = new SystemTag( + $tagId, + $truncatedNewName, + $userVisible, + $userAssignable, + $beforeUpdate->getETag(), + $color + ); + + // Check if tag already exists (case-insensitive) + $existingTags = $this->getAllTags(null, $truncatedNewName); + foreach ($existingTags as $existingTag) { + if (mb_strtolower($existingTag->getName()) === mb_strtolower($truncatedNewName) + && $existingTag->getId() !== $tagId) { + throw new TagAlreadyExistsException('Tag ' . $truncatedNewName . ' already exists'); + } + } + + $query = $this->connection->getQueryBuilder(); + $query->update(self::TAG_TABLE) + ->set('name', $query->createParameter('name')) + ->set('visibility', $query->createParameter('visibility')) + ->set('editable', $query->createParameter('editable')) + ->set('color', $query->createParameter('color')) + ->where($query->expr()->eq('id', $query->createParameter('tagid'))) + ->setParameter('name', $truncatedNewName) + ->setParameter('visibility', $userVisible ? 1 : 0) + ->setParameter('editable', $userAssignable ? 1 : 0) + ->setParameter('tagid', $tagId) + ->setParameter('color', $color); + + try { + if ($query->execute() === 0) { + throw new TagNotFoundException( + 'Tag does not exist', 0, null, [$tagId] + ); + } + } catch (UniqueConstraintViolationException $e) { + throw new TagAlreadyExistsException( + 'Tag ("' . $newName . '", ' . $userVisible . ', ' . $userAssignable . ') already exists', + 0, + $e + ); + } + + $this->dispatcher->dispatch(ManagerEvent::EVENT_UPDATE, new ManagerEvent( + ManagerEvent::EVENT_UPDATE, $afterUpdate, $beforeUpdate + )); + } + + public function deleteTags($tagIds): void { + if (!\is_array($tagIds)) { + $tagIds = [$tagIds]; + } + + $tagNotFoundException = null; + $tags = []; + try { + $tags = $this->getTagsByIds($tagIds); + } catch (TagNotFoundException $e) { + $tagNotFoundException = $e; + + // Get existing tag objects for the hooks later + $existingTags = array_diff($tagIds, $tagNotFoundException->getMissingTags()); + if (!empty($existingTags)) { + try { + $tags = $this->getTagsByIds($existingTags); + } catch (TagNotFoundException $e) { + // Ignore further errors... + } + } + } + + // delete relationships first + $query = $this->connection->getQueryBuilder(); + $query->delete(SystemTagObjectMapper::RELATION_TABLE) + ->where($query->expr()->in('systemtagid', $query->createParameter('tagids'))) + ->setParameter('tagids', $tagIds, IQueryBuilder::PARAM_INT_ARRAY) + ->execute(); + + $query = $this->connection->getQueryBuilder(); + $query->delete(self::TAG_TABLE) + ->where($query->expr()->in('id', $query->createParameter('tagids'))) + ->setParameter('tagids', $tagIds, IQueryBuilder::PARAM_INT_ARRAY) + ->execute(); + + foreach ($tags as $tag) { + $this->dispatcher->dispatch(ManagerEvent::EVENT_DELETE, new ManagerEvent( + ManagerEvent::EVENT_DELETE, $tag + )); + } + + if ($tagNotFoundException !== null) { + throw new TagNotFoundException( + 'Tag id(s) not found', 0, $tagNotFoundException, $tagNotFoundException->getMissingTags() + ); + } + } + + public function canUserAssignTag(ISystemTag $tag, ?IUser $user): bool { + if ($user === null) { + return false; + } + + // early check to avoid unneeded group lookups + if ($tag->isUserAssignable() && $tag->isUserVisible()) { + return true; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + return true; + } + + if (!$tag->isUserVisible()) { + return false; + } + + $groupIds = $this->groupManager->getUserGroupIds($user); + if (!empty($groupIds)) { + $matchingGroups = array_intersect($groupIds, $this->getTagGroups($tag)); + if (!empty($matchingGroups)) { + return true; + } + } + + return false; + } + + public function canUserCreateTag(?IUser $user): bool { + if ($user === null) { + // If no user given, allows only calls from CLI + return \OC::$CLI; + } + + if ($this->appConfig->getValueBool('systemtags', 'restrict_creation_to_admin', false) === false) { + return true; + } + + return $this->groupManager->isAdmin($user->getUID()); + } + + public function canUserUpdateTag(?IUser $user): bool { + // We currently have no different permissions for updating tags than for creating them + return $this->canUserCreateTag($user); + } + + public function canUserSeeTag(ISystemTag $tag, ?IUser $user): bool { + // If no user, then we only show public tags + if (!$user && $tag->getAccessLevel() === ISystemTag::ACCESS_LEVEL_PUBLIC) { + return true; + } + + if ($tag->isUserVisible()) { + return true; + } + + // if not returned yet, and user is not logged in, then the tag is not visible + if ($user === null) { + return false; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + return true; + } + + return false; + } + + private function createSystemTagFromRow($row): SystemTag { + return new SystemTag((string)$row['id'], $row['name'], (bool)$row['visibility'], (bool)$row['editable'], $row['etag'], $row['color']); + } + + public function setTagGroups(ISystemTag $tag, array $groupIds): void { + // delete relationships first + $this->connection->beginTransaction(); + try { + $query = $this->connection->getQueryBuilder(); + $query->delete(self::TAG_GROUP_TABLE) + ->where($query->expr()->eq('systemtagid', $query->createNamedParameter($tag->getId()))) + ->execute(); + + // add each group id + $query = $this->connection->getQueryBuilder(); + $query->insert(self::TAG_GROUP_TABLE) + ->values([ + 'systemtagid' => $query->createNamedParameter($tag->getId()), + 'gid' => $query->createParameter('gid'), + ]); + foreach ($groupIds as $groupId) { + if ($groupId === '') { + continue; + } + $query->setParameter('gid', $groupId); + $query->execute(); + } + + $this->connection->commit(); + } catch (\Exception $e) { + $this->connection->rollBack(); + throw $e; + } + } + + public function getTagGroups(ISystemTag $tag): array { + $groupIds = []; + $query = $this->connection->getQueryBuilder(); + $query->select('gid') + ->from(self::TAG_GROUP_TABLE) + ->where($query->expr()->eq('systemtagid', $query->createNamedParameter($tag->getId()))) + ->orderBy('gid'); + + $result = $query->execute(); + while ($row = $result->fetch()) { + $groupIds[] = $row['gid']; + } + + $result->closeCursor(); + + return $groupIds; + } + +} diff --git a/lib/private/SystemTag/SystemTagObjectMapper.php b/lib/private/SystemTag/SystemTagObjectMapper.php new file mode 100644 index 00000000000..1fa5975dafb --- /dev/null +++ b/lib/private/SystemTag/SystemTagObjectMapper.php @@ -0,0 +1,367 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\SystemTag; + +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IDBConnection; +use OCP\SystemTag\ISystemTag; +use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTagObjectMapper; +use OCP\SystemTag\MapperEvent; +use OCP\SystemTag\TagNotFoundException; + +class SystemTagObjectMapper implements ISystemTagObjectMapper { + public const RELATION_TABLE = 'systemtag_object_mapping'; + + public function __construct( + protected IDBConnection $connection, + protected ISystemTagManager $tagManager, + protected IEventDispatcher $dispatcher, + ) { + } + + /** + * {@inheritdoc} + */ + public function getTagIdsForObjects($objIds, string $objectType): array { + if (!\is_array($objIds)) { + $objIds = [$objIds]; + } elseif (empty($objIds)) { + return []; + } + + $query = $this->connection->getQueryBuilder(); + $query->select(['systemtagid', 'objectid']) + ->from(self::RELATION_TABLE) + ->where($query->expr()->in('objectid', $query->createParameter('objectids'))) + ->andWhere($query->expr()->eq('objecttype', $query->createParameter('objecttype'))) + ->setParameter('objecttype', $objectType) + ->addOrderBy('objectid', 'ASC') + ->addOrderBy('systemtagid', 'ASC'); + $chunks = array_chunk($objIds, 900, false); + $mapping = []; + foreach ($objIds as $objId) { + $mapping[$objId] = []; + } + foreach ($chunks as $chunk) { + $query->setParameter('objectids', $chunk, IQueryBuilder::PARAM_STR_ARRAY); + $result = $query->executeQuery(); + while ($row = $result->fetch()) { + $objectId = $row['objectid']; + $mapping[$objectId][] = $row['systemtagid']; + } + + $result->closeCursor(); + } + + return $mapping; + } + + /** + * {@inheritdoc} + */ + public function getObjectIdsForTags($tagIds, string $objectType, int $limit = 0, string $offset = ''): array { + if (!\is_array($tagIds)) { + $tagIds = [$tagIds]; + } + + $this->assertTagsExist($tagIds); + + $query = $this->connection->getQueryBuilder(); + $query->selectDistinct('objectid') + ->from(self::RELATION_TABLE) + ->where($query->expr()->in('systemtagid', $query->createNamedParameter($tagIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($query->expr()->eq('objecttype', $query->createNamedParameter($objectType))); + + if ($limit) { + if (\count($tagIds) !== 1) { + throw new \InvalidArgumentException('Limit is only allowed with a single tag'); + } + + $query->setMaxResults($limit) + ->orderBy('objectid', 'ASC'); + + if ($offset !== '') { + $query->andWhere($query->expr()->gt('objectid', $query->createNamedParameter($offset))); + } + } + + $objectIds = []; + + $result = $query->executeQuery(); + while ($row = $result->fetch()) { + $objectIds[] = $row['objectid']; + } + $result->closeCursor(); + + return $objectIds; + } + + /** + * {@inheritdoc} + */ + public function assignTags(string $objId, string $objectType, $tagIds): void { + if (!\is_array($tagIds)) { + $tagIds = [$tagIds]; + } + + $this->assertTagsExist($tagIds); + $this->connection->beginTransaction(); + + $query = $this->connection->getQueryBuilder(); + $query->select('systemtagid') + ->from(self::RELATION_TABLE) + ->where($query->expr()->in('systemtagid', $query->createNamedParameter($tagIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($query->expr()->eq('objecttype', $query->createNamedParameter($objectType))) + ->andWhere($query->expr()->eq('objectid', $query->createNamedParameter($objId))); + $result = $query->executeQuery(); + $rows = $result->fetchAll(); + $existingTags = []; + foreach ($rows as $row) { + $existingTags[] = $row['systemtagid']; + } + //filter only tags that do not exist in db + $tagIds = array_diff($tagIds, $existingTags); + if (empty($tagIds)) { + // no tags to insert so return here + $this->connection->commit(); + return; + } + + $query = $this->connection->getQueryBuilder(); + $query->insert(self::RELATION_TABLE) + ->values([ + 'objectid' => $query->createNamedParameter($objId), + 'objecttype' => $query->createNamedParameter($objectType), + 'systemtagid' => $query->createParameter('tagid'), + ]); + + $tagsAssigned = []; + foreach ($tagIds as $tagId) { + try { + $query->setParameter('tagid', $tagId); + $query->execute(); + $tagsAssigned[] = $tagId; + } catch (UniqueConstraintViolationException $e) { + // ignore existing relations + } + } + + $this->updateEtagForTags($tagIds); + + $this->connection->commit(); + if (empty($tagsAssigned)) { + return; + } + + $this->dispatcher->dispatch(MapperEvent::EVENT_ASSIGN, new MapperEvent( + MapperEvent::EVENT_ASSIGN, + $objectType, + $objId, + $tagsAssigned + )); + } + + /** + * {@inheritdoc} + */ + public function unassignTags(string $objId, string $objectType, $tagIds): void { + if (!\is_array($tagIds)) { + $tagIds = [$tagIds]; + } + + $this->assertTagsExist($tagIds); + + $query = $this->connection->getQueryBuilder(); + $query->delete(self::RELATION_TABLE) + ->where($query->expr()->eq('objectid', $query->createParameter('objectid'))) + ->andWhere($query->expr()->eq('objecttype', $query->createParameter('objecttype'))) + ->andWhere($query->expr()->in('systemtagid', $query->createParameter('tagids'))) + ->setParameter('objectid', $objId) + ->setParameter('objecttype', $objectType) + ->setParameter('tagids', $tagIds, IQueryBuilder::PARAM_INT_ARRAY) + ->executeStatement(); + + $this->updateEtagForTags($tagIds); + + $this->dispatcher->dispatch(MapperEvent::EVENT_UNASSIGN, new MapperEvent( + MapperEvent::EVENT_UNASSIGN, + $objectType, + $objId, + $tagIds + )); + } + + /** + * Update the etag for the given tags. + * + * @param string[] $tagIds + */ + private function updateEtagForTags(array $tagIds): void { + // Update etag after assigning tags + $md5 = md5(json_encode(time())); + $query = $this->connection->getQueryBuilder(); + $query->update('systemtag') + ->set('etag', $query->createNamedParameter($md5)) + ->where($query->expr()->in('id', $query->createNamedParameter($tagIds, IQueryBuilder::PARAM_INT_ARRAY))); + $query->execute(); + } + + /** + * {@inheritdoc} + */ + public function haveTag($objIds, string $objectType, string $tagId, bool $all = true): bool { + $this->assertTagsExist([$tagId]); + + if (!\is_array($objIds)) { + $objIds = [$objIds]; + } + + $query = $this->connection->getQueryBuilder(); + + if (!$all) { + // If we only need one entry, we make the query lighter, by not + // counting the elements + $query->select('*') + ->setMaxResults(1); + } else { + $query->select($query->func()->count($query->expr()->literal(1))); + } + + $query->from(self::RELATION_TABLE) + ->where($query->expr()->in('objectid', $query->createParameter('objectids'))) + ->andWhere($query->expr()->eq('objecttype', $query->createParameter('objecttype'))) + ->andWhere($query->expr()->eq('systemtagid', $query->createParameter('tagid'))) + ->setParameter('objectids', $objIds, IQueryBuilder::PARAM_STR_ARRAY) + ->setParameter('tagid', $tagId) + ->setParameter('objecttype', $objectType); + + $result = $query->executeQuery(); + $row = $result->fetch(\PDO::FETCH_NUM); + $result->closeCursor(); + + if ($all) { + return ((int)$row[0] === \count($objIds)); + } + + return (bool)$row; + } + + /** + * Asserts that all the given tag ids exist. + * + * @param string[] $tagIds tag ids to check + * + * @throws \OCP\SystemTag\TagNotFoundException if at least one tag did not exist + */ + private function assertTagsExist(array $tagIds): void { + $tags = $this->tagManager->getTagsByIds($tagIds); + if (\count($tags) !== \count($tagIds)) { + // at least one tag missing, bail out + $foundTagIds = array_map( + function (ISystemTag $tag) { + return $tag->getId(); + }, + $tags + ); + $missingTagIds = array_diff($tagIds, $foundTagIds); + throw new TagNotFoundException( + 'Tags not found', 0, null, $missingTagIds + ); + } + } + + /** + * {@inheritdoc} + */ + public function setObjectIdsForTag(string $tagId, string $objectType, array $objectIds): void { + $currentObjectIds = $this->getObjectIdsForTags($tagId, $objectType); + $removedObjectIds = array_diff($currentObjectIds, $objectIds); + $addedObjectIds = array_diff($objectIds, $currentObjectIds); + + $this->connection->beginTransaction(); + $query = $this->connection->getQueryBuilder(); + $query->delete(self::RELATION_TABLE) + ->where($query->expr()->eq('systemtagid', $query->createNamedParameter($tagId, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('objecttype', $query->createNamedParameter($objectType))) + ->executeStatement(); + $this->connection->commit(); + + foreach ($removedObjectIds as $objectId) { + $this->dispatcher->dispatch(MapperEvent::EVENT_UNASSIGN, new MapperEvent( + MapperEvent::EVENT_UNASSIGN, + $objectType, + (string)$objectId, + [(int)$tagId] + )); + } + + if (empty($objectIds)) { + return; + } + + $this->connection->beginTransaction(); + $query = $this->connection->getQueryBuilder(); + $query->insert(self::RELATION_TABLE) + ->values([ + 'systemtagid' => $query->createNamedParameter($tagId, IQueryBuilder::PARAM_INT), + 'objecttype' => $query->createNamedParameter($objectType), + 'objectid' => $query->createParameter('objectid'), + ]); + + foreach (array_unique($objectIds) as $objectId) { + $query->setParameter('objectid', (string)$objectId); + $query->executeStatement(); + } + + $this->updateEtagForTags([$tagId]); + $this->connection->commit(); + + // Dispatch assign events for new object ids + foreach ($addedObjectIds as $objectId) { + $this->dispatcher->dispatch(MapperEvent::EVENT_ASSIGN, new MapperEvent( + MapperEvent::EVENT_ASSIGN, + $objectType, + (string)$objectId, + [(int)$tagId] + )); + } + + // Dispatch unassign events for removed object ids + foreach ($removedObjectIds as $objectId) { + $this->dispatcher->dispatch(MapperEvent::EVENT_UNASSIGN, new MapperEvent( + MapperEvent::EVENT_UNASSIGN, + $objectType, + (string)$objectId, + [(int)$tagId] + )); + } + } + + /** + * {@inheritdoc} + */ + public function getAvailableObjectTypes(): array { + $query = $this->connection->getQueryBuilder(); + $query->selectDistinct('objecttype') + ->from(self::RELATION_TABLE); + + $result = $query->executeQuery(); + $objectTypes = []; + while ($row = $result->fetch()) { + $objectTypes[] = $row['objecttype']; + } + $result->closeCursor(); + + return $objectTypes; + } +} diff --git a/lib/private/SystemTag/SystemTagsInFilesDetector.php b/lib/private/SystemTag/SystemTagsInFilesDetector.php new file mode 100644 index 00000000000..9268b7ab098 --- /dev/null +++ b/lib/private/SystemTag/SystemTagsInFilesDetector.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\SystemTag; + +use OC\Files\Cache\QuerySearchHelper; +use OC\Files\Node\Root; +use OC\Files\Search\SearchBinaryOperator; +use OC\Files\Search\SearchComparison; +use OC\Files\Search\SearchQuery; +use OCP\Files\Folder; +use OCP\Files\Search\ISearchBinaryOperator; +use OCP\Files\Search\ISearchComparison; + +class SystemTagsInFilesDetector { + public function __construct( + protected QuerySearchHelper $searchHelper, + ) { + } + + public function detectAssignedSystemTagsIn( + Folder $folder, + string $filteredMediaType = '', + int $limit = 0, + int $offset = 0, + ): array { + $operator = new SearchComparison(ISearchComparison::COMPARE_LIKE, 'systemtag', '%'); + // Currently query has to have exactly one search condition. If no media type is provided, + // we fall back to the presence of a system tag. + if ($filteredMediaType !== '') { + $mimeOperator = new SearchComparison(ISearchComparison::COMPARE_LIKE, 'mimetype', $filteredMediaType . '/%'); + $operator = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [$operator, $mimeOperator]); + } + + $query = new SearchQuery($operator, $limit, $offset, []); + [$caches, ] = $this->searchHelper->getCachesAndMountPointsForSearch( + $this->getRootFolder($folder), + $folder->getPath(), + ); + return $this->searchHelper->findUsedTagsInCaches($query, $caches); + } + + protected function getRootFolder(?Folder $folder): Root { + if ($folder instanceof Root) { + return $folder; + } elseif ($folder === null) { + throw new \LogicException('Could not climb up to root folder'); + } + return $this->getRootFolder($folder->getParent()); + } +} |