From f8a15915b768003a93ea4c216ea88ee81f5c7d03 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Tue, 27 Feb 2024 14:07:32 +0100 Subject: [PATCH] feat(Contexts): API to add and remove a node to a Context Signed-off-by: Arthur Schiwon --- appinfo/routes.php | 2 + lib/AppInfo/Application.php | 3 + lib/Controller/AOCSController.php | 10 +++ lib/Controller/ContextController.php | 46 +++++++++++++ lib/Db/ContextNodeRelation.php | 6 +- lib/Db/ContextNodeRelationMapper.php | 17 +++++ lib/Db/PageContentMapper.php | 29 ++++++++ lib/Errors/BadRequestError.php | 6 ++ lib/Middleware/PermissionMiddleware.php | 89 ++++++++++++++++++++++++ lib/Service/ContextService.php | 92 ++++++++++++++++++++++--- lib/Service/PermissionsService.php | 38 +++++++++- 11 files changed, 325 insertions(+), 13 deletions(-) create mode 100644 lib/Errors/BadRequestError.php create mode 100644 lib/Middleware/PermissionMiddleware.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 1246d6bc6..ea3615715 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -127,6 +127,8 @@ ['name' => 'Context#index', 'url' => '/api/2/contexts', 'verb' => 'GET'], ['name' => 'Context#show', 'url' => '/api/2/contexts/{contextId}', 'verb' => 'GET'], ['name' => 'Context#create', 'url' => '/api/2/contexts', 'verb' => 'POST'], + ['name' => 'Context#addNode', 'url' => '/api/2/contexts/{contextId}/nodes', 'verb' => 'POST'], + ['name' => 'Context#removeNode', 'url' => '/api/2/contexts/{contextId}/nodes/{nodeRelId}', 'verb' => 'DELETE'], ] ]; diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 2120c66ae..24a66a05f 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -8,6 +8,7 @@ use OCA\Tables\Listener\AnalyticsDatasourceListener; use OCA\Tables\Listener\TablesReferenceListener; use OCA\Tables\Listener\UserDeletedListener; +use OCA\Tables\Middleware\PermissionMiddleware; use OCA\Tables\Reference\ContentReferenceProvider; use OCA\Tables\Reference\LegacyReferenceProvider; use OCA\Tables\Reference\ReferenceProvider; @@ -62,6 +63,8 @@ public function register(IRegistrationContext $context): void { } $context->registerCapability(Capabilities::class); + + $context->registerMiddleware(PermissionMiddleware::class); } public function boot(IBootContext $context): void { diff --git a/lib/Controller/AOCSController.php b/lib/Controller/AOCSController.php index 8308a615e..54cd1411f 100644 --- a/lib/Controller/AOCSController.php +++ b/lib/Controller/AOCSController.php @@ -4,6 +4,7 @@ use Exception; use OCA\Tables\AppInfo\Application; +use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; @@ -59,4 +60,13 @@ protected function handleNotFoundError(NotFoundError $e): DataResponse { return new DataResponse(['message' => $this->n->t('A not found error occurred. More details can be found in the logs. Please reach out to your administration.')], Http::STATUS_NOT_FOUND); } + /** + * @param BadRequestError $e + * @return DataResponse + */ + protected function handleBadRequestError(BadRequestError $e): DataResponse { + $this->logger->warning('An bad request was encountered: ['. $e->getCode() . ']' . $e->getMessage()); + return new DataResponse(['message' => $this->n->t('An error caused by an invalid request occurred. More details can be found in the logs. Please reach out to your administration.')], Http::STATUS_BAD_REQUEST); + } + } diff --git a/lib/Controller/ContextController.php b/lib/Controller/ContextController.php index 6e807841f..b380c3474 100644 --- a/lib/Controller/ContextController.php +++ b/lib/Controller/ContextController.php @@ -5,9 +5,13 @@ namespace OCA\Tables\Controller; use OCA\Tables\Db\Context; +use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; +use OCA\Tables\Errors\NotFoundError; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ContextService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\DB\Exception; @@ -95,6 +99,48 @@ public function create(string $name, string $iconName, string $description = '', } } + /** + * @NoAdminRequired + * @CanManageNode + */ + public function addNode(int $contextId, int $nodeId, int $nodeType, int $permissions, ?int $order = null): DataResponse { + try { + $rel = $this->contextService->addNodeToContextById($contextId, $nodeId, $nodeType, $permissions, $this->userId); + $this->contextService->addNodeRelToPage($rel, $order); + $context = $this->contextService->findById($rel->getContextId(), $this->userId); + return new DataResponse($context->jsonSerialize()); + } catch (DoesNotExistException $e) { + return $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e)); + } catch (MultipleObjectsReturnedException|Exception|InternalError $e) { + return $this->handleError($e); + } + } + + /** + * @NoAdminRequired + * @CanManageContext + */ + public function removeNode(int $contextId, int $nodeRelId): DataResponse { + // we could do without the contextId, however it is used by the Permission Middleware + // and also results in a more consistent endpoint url + try { + $context = $this->contextService->findById($contextId, $this->userId); + if (!isset($context->getNodes()[$nodeRelId])) { + return $this->handleBadRequestError(new BadRequestError('Node Relation ID not found in given Context')); + } + $nodeRelation = $this->contextService->removeNodeFromContextById($nodeRelId); + $this->contextService->removeNodeRelFromAllPages($nodeRelation); + $context = $this->contextService->findById($contextId, $this->userId); + return new DataResponse($context->jsonSerialize()); + } catch (DoesNotExistException $e) { + $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e)); + } catch (MultipleObjectsReturnedException|Exception $e) { + $this->handleError($e); + } + + return new DataResponse(); + } + /** * @param Context[] $contexts * @return array diff --git a/lib/Db/ContextNodeRelation.php b/lib/Db/ContextNodeRelation.php index 02b8e618d..7981ffcd1 100644 --- a/lib/Db/ContextNodeRelation.php +++ b/lib/Db/ContextNodeRelation.php @@ -11,8 +11,8 @@ * @method setContextId(int $value): void * @method getNodeId(): int * @method setNodeId(int $value): void - * @method getNodeType(): string - * @method setNodeType(string $value): void + * @method getNodeType(): int + * @method setNodeType(int $value): void * @method getPermissions(): int * @method setPermissions(int $value): void */ @@ -20,7 +20,7 @@ class ContextNodeRelation extends Entity implements \JsonSerializable { protected ?int $contextId = null; protected ?int $nodeId = null; - protected ?string $nodeType = null; + protected ?int $nodeType = null; protected ?int $permissions = null; public function __construct() { diff --git a/lib/Db/ContextNodeRelationMapper.php b/lib/Db/ContextNodeRelationMapper.php index 5630b60ea..7709ca5de 100644 --- a/lib/Db/ContextNodeRelationMapper.php +++ b/lib/Db/ContextNodeRelationMapper.php @@ -4,7 +4,10 @@ namespace OCA\Tables\Db; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception; use OCP\IDBConnection; /** @template-extends QBMapper */ @@ -15,4 +18,18 @@ public function __construct(IDBConnection $db) { parent::__construct($db, $this->table, ContextNodeRelation::class); } + /** + * @throws MultipleObjectsReturnedException + * @throws DoesNotExistException + * @throws Exception + */ + public function findById(int $nodeRelId): ContextNodeRelation { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($nodeRelId))); + + $row = $this->findOneQuery($qb); + return $this->mapRowToEntity($row); + } } diff --git a/lib/Db/PageContentMapper.php b/lib/Db/PageContentMapper.php index 38bed77ab..5a6810aab 100644 --- a/lib/Db/PageContentMapper.php +++ b/lib/Db/PageContentMapper.php @@ -3,6 +3,7 @@ namespace OCA\Tables\Db; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception; use OCP\IDBConnection; /** @template-extends QBMapper */ @@ -12,4 +13,32 @@ class PageContentMapper extends QBMapper { public function __construct(IDBConnection $db) { parent::__construct($db, $this->table, PageContent::class); } + + /** + * @throws Exception + */ + public function findByPageAndNodeRelation(int $pageId, int $nodeRelId): ?PageContent { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->andX( + $qb->expr()->eq('page_id', $qb->createNamedParameter($pageId)), + $qb->expr()->eq('node_rel_id', $qb->createNamedParameter($nodeRelId)), + )); + + $result = $qb->executeQuery(); + $r = $result->fetch(); + return $r ? $this->mapRowToEntity($r) : null; + } + + public function findByNodeRelation(int $nodeRelId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->andX( + $qb->expr()->eq('node_rel_id', $qb->createNamedParameter($nodeRelId)), + )); + + return $this->findEntities($qb); + } } diff --git a/lib/Errors/BadRequestError.php b/lib/Errors/BadRequestError.php new file mode 100644 index 000000000..71fdb8949 --- /dev/null +++ b/lib/Errors/BadRequestError.php @@ -0,0 +1,6 @@ +reflector = $reflector; + $this->permissionsService = $permissionsService; + $this->userId = $userId; + $this->request = $request; + } + + /** + * @throws PermissionError + * @throws InternalError + */ + public function beforeController(Controller $controller, string $methodName): void { + $this->assertCanManageNode(); + $this->assertCanManageContext(); + } + + /** + * @throws PermissionError + * @throws InternalError + */ + protected function assertCanManageNode(): void { + if ($this->reflector->hasAnnotation('CanManageNode')) { + $nodeId = $this->request->getParam('nodeId'); + $nodeType = $this->request->getParam('nodeType'); + + if (!is_numeric($nodeId) || !is_numeric($nodeType)) { + throw new InternalError('Cannot identify node'); + } + + if ($this->userId === null) { + throw new PermissionError('User not authenticated'); + } + + if (!$this->permissionsService->canManageNodeById((int)$nodeType, (int)$nodeId, $this->userId)) { + throw new PermissionError(sprintf('User %s cannot manage node %d (type %d)', + $this->userId, (int)$nodeId, (int)$nodeType + )); + } + } + } + + /** + * @throws PermissionError + * @throws InternalError + */ + protected function assertCanManageContext(): void { + if ($this->reflector->hasAnnotation('CanManageContext')) { + $contextId = $this->request->getParam('contextId'); + + if (!is_numeric($contextId)) { + throw new InternalError('Cannot identify context'); + } + + if ($this->userId === null) { + throw new PermissionError('User not authenticated'); + } + + if (!$this->permissionsService->canManageContextById((int)$contextId, $this->userId)) { + throw new PermissionError(sprintf('User %s cannot manage context %d', + $this->userId, (int)$contextId + )); + } + } + } +} diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index db7fa122a..6b67d66b3 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -15,6 +15,8 @@ use OCA\Tables\Db\PageMapper; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\PermissionError; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\DB\Exception; use Psr\Log\LoggerInterface; @@ -102,6 +104,87 @@ public function create(string $name, string $iconName, string $description, arra return $context; } + /** + * @throws MultipleObjectsReturnedException + * @throws DoesNotExistException + * @throws Exception + */ + public function addNodeToContextById(int $contextId, int $nodeId, int $nodeType, int $permissions, ?string $userId): ContextNodeRelation { + $context = $this->contextMapper->findById($contextId, $userId); + return $this->addNodeToContext($context, $nodeId, $nodeType, $permissions); + } + + /** + * @throws Exception + */ + public function removeNodeFromContext(ContextNodeRelation $nodeRelation): ContextNodeRelation { + return $this->contextNodeRelMapper->delete($nodeRelation); + } + + /** + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function removeNodeFromContextById(int $nodeRelationId): ContextNodeRelation { + $nodeRelation = $this->contextNodeRelMapper->findById($nodeRelationId); + return $this->contextNodeRelMapper->delete($nodeRelation); + } + + /** + * @throws Exception + */ + public function addNodeToContext(Context $context, int $nodeId, int $nodeType, int $permissions): ContextNodeRelation { + $contextNodeRel = new ContextNodeRelation(); + $contextNodeRel->setContextId($context->getId()); + $contextNodeRel->setNodeId($nodeId); + $contextNodeRel->setNodeType($nodeType); + $contextNodeRel->setPermissions($permissions); + + return $this->contextNodeRelMapper->insert($contextNodeRel); + } + + public function addNodeRelToPage(ContextNodeRelation $nodeRel, int $order = null, ?int $pageId = null): PageContent { + if ($pageId === null) { + // when no page is given, find the startpage to add it to + $context = $this->contextMapper->findById($nodeRel->getContextId()); + $pages = $context->getPages(); + foreach ($pages as $page) { + if ($page['page_type'] === 'startpage') { + $pageId = $page['id']; + break; + } + } + } + + $pageContent = $this->pageContentMapper->findByPageAndNodeRelation($pageId, $nodeRel->getId()); + + if ($pageContent === null) { + $pageContent = new PageContent(); + $pageContent->setPageId($pageId); + $pageContent->setNodeRelId($nodeRel->getId()); + $pageContent->setOrder($order ?? 100); //FIXME: demand or calc order + + $pageContent = $this->pageContentMapper->insert($pageContent); + } + return $pageContent; + } + + public function removeNodeRelFromAllPages(ContextNodeRelation $nodeRelation): array { + $contents = $this->pageContentMapper->findByNodeRelation($nodeRelation->getId()); + /** @var PageContent $content */ + foreach ($contents as $content) { + try { + $this->pageContentMapper->delete($content); + } catch (Exception $e) { + $this->logger->warning('Failed to delete Contexts page content with ID {pcId}', [ + 'pcId' => $content->getId(), + 'exception' => $e, + ]); + } + } + return $contents; + } protected function insertPage(Context $context): void { $page = new Page(); @@ -133,18 +216,11 @@ protected function insertNodesFromArray(Context $context, array $nodes): void { $userId = $context->getOwnerType() === Application::OWNER_TYPE_USER ? $context->getOwnerId() : null; foreach ($nodes as $node) { - $contextNodeRel = new ContextNodeRelation(); - $contextNodeRel->setContextId($context->getId()); - $contextNodeRel->setNodeId($node['id']); - $contextNodeRel->setNodeType($node['type']); - $contextNodeRel->setPermissions($node['permissions'] ?? 660); - try { if (!$this->permissionsService->canManageNodeById($node['type'], $node['id'], $userId)) { throw new PermissionError(sprintf('Owner cannot manage node %d (type %d)', $node['id'], $node['type'])); } - - $this->contextNodeRelMapper->insert($contextNodeRel); + $contextNodeRel = $this->addNodeToContext($context, $node['id'], $node['type'], $node['permissions'] ?? 660); $addedNodes[] = $contextNodeRel->jsonSerialize(); } catch (Exception $e) { $this->logger->warning('Could not add node {ntype}/{nid} to context {cid}, skipping.', [ diff --git a/lib/Service/PermissionsService.php b/lib/Service/PermissionsService.php index 390011a3b..c10c8314f 100644 --- a/lib/Service/PermissionsService.php +++ b/lib/Service/PermissionsService.php @@ -3,6 +3,7 @@ namespace OCA\Tables\Service; use OCA\Tables\AppInfo\Application; +use OCA\Tables\Db\ContextMapper; use OCA\Tables\Db\Share; use OCA\Tables\Db\ShareMapper; use OCA\Tables\Db\Table; @@ -31,8 +32,18 @@ class PermissionsService { protected ?string $userId = null; protected bool $isCli = false; - - public function __construct(LoggerInterface $logger, ?string $userId, TableMapper $tableMapper, ViewMapper $viewMapper, ShareMapper $shareMapper, UserHelper $userHelper, bool $isCLI) { + private ContextMapper $contextMapper; + + public function __construct( + LoggerInterface $logger, + ?string $userId, + TableMapper $tableMapper, + ViewMapper $viewMapper, + ShareMapper $shareMapper, + ContextMapper $contextMapper, + UserHelper $userHelper, + bool $isCLI + ) { $this->tableMapper = $tableMapper; $this->viewMapper = $viewMapper; $this->shareMapper = $shareMapper; @@ -40,6 +51,7 @@ public function __construct(LoggerInterface $logger, ?string $userId, TableMappe $this->logger = $logger; $this->userId = $userId; $this->isCli = $isCLI; + $this->contextMapper = $contextMapper; } @@ -118,6 +130,28 @@ public function canManageNodeById(int $nodeType, int $nodeId, ?string $userId = return false; } + public function canManageContextById(int $contextId, ?string $userId = null): bool { + try { + $context = $this->contextMapper->findById($contextId, $userId); + } catch (DoesNotExistException $e) { + $this->logger->warning('Context does not exist'); + return false; + } catch (MultipleObjectsReturnedException $e) { + $this->logger->warning('Multiple contexts found for this ID'); + return false; + } catch (Exception $e) { + $this->logger->warning($e->getMessage()); + return false; + } + + if ($context->getOwnerType() !== Application::OWNER_TYPE_USER) { + $this->logger->warning('Unsupported owner type'); + return false; + } + + return $context->getOwnerId() === $userId; + } + public function canAccessView(View $view, ?string $userId = null): bool { if($this->basisCheck($view, 'view', $userId)) { return true;