From 7473af4163afdb91e5dca359dafa40c7e28aef60 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:45:47 +0300 Subject: [PATCH] enforce text ownership on node endpoints getNodesOfText, createNode, and bulkCreateNodes now require the session user, look up the target text, and respond 403 unless the user owns the text or is an admin. paves the way for moving these endpoints out of the admin-only group. --- app/Node/NodeController.php | 106 +++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/app/Node/NodeController.php b/app/Node/NodeController.php index bb3aa30..6e39a8f 100644 --- a/app/Node/NodeController.php +++ b/app/Node/NodeController.php @@ -8,7 +8,9 @@ use App\Node\NodeRepository; use App\Node\UseCases\BulkCreateNodes; use App\Node\UseCases\CreateNode; use App\Node\UseCases\CreateNodeRequest; +use App\Text\Text; use App\Text\TextRepository; +use App\User\User; use DomainException; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -20,14 +22,34 @@ class NodeController private TextRepository $textRepository, ) {} - public function getNodesOfText(Response $response, int $textId): Response - { + public function getNodesOfText( + Request $request, + Response $response, + int $textId, + ): Response { + $user = $request->getAttribute('user'); + if (!$user instanceof User) { + return $this->errorResponse( + $response, + 401, + 'unauthenticated' + ); + } + $text = $this->textRepository->find($textId); if ($text === null) { return $response->withStatus(404); } + if (!$this->userMayAccessText($user, $text)) { + return $this->errorResponse( + $response, + 403, + 'forbidden' + ); + } + $nodes = $this->nodeRepository->findByTextId($textId); $data = array_map(function ($node) { @@ -47,12 +69,32 @@ class NodeController Response $response, CreateNode $createNodeUseCase, ): Response { + $user = $request->getAttribute('user'); + if (!$user instanceof User) { + return $this->errorResponse( + $response, + 401, + 'unauthenticated' + ); + } + $data = json_decode((string) $request->getBody(), true) ?? []; $textId = isset($data['textId']) ? (int) $data['textId'] : null; $title = $data['title'] ?? null; $parentNodeId = isset($data['parentNodeId']) ? (int) $data['parentNodeId'] : null; + if ($textId !== null) { + $ownershipResponse = $this->checkTextOwnership( + $user, + $textId, + $response, + ); + if ($ownershipResponse !== null) { + return $ownershipResponse; + } + } + try { $node = $createNodeUseCase->execute(new CreateNodeRequest( textId: $textId, @@ -80,6 +122,15 @@ class NodeController Response $response, BulkCreateNodes $bulkCreateNodesUseCase, ): Response { + $user = $request->getAttribute('user'); + if (!$user instanceof User) { + return $this->errorResponse( + $response, + 401, + 'unauthenticated' + ); + } + $data = json_decode((string) $request->getBody(), true) ?? []; $textId = isset($data['textId']) ? (int) $data['textId'] : null; @@ -87,6 +138,17 @@ class NodeController $titlePrefix = isset($data['titlePrefix']) ? (string) $data['titlePrefix'] : null; $count = isset($data['count']) ? (int) $data['count'] : null; + if ($textId !== null) { + $ownershipResponse = $this->checkTextOwnership( + $user, + $textId, + $response, + ); + if ($ownershipResponse !== null) { + return $ownershipResponse; + } + } + try { $nodes = $bulkCreateNodesUseCase->execute(new BulkCreateNodesRequest( textId: $textId, @@ -113,4 +175,44 @@ class NodeController $response->getBody()->write(json_encode(array_values($result))); return $response->withStatus(201)->withHeader('Content-Type', 'application/json'); } + + private function checkTextOwnership( + User $user, + int $textId, + Response $response, + ): ?Response { + $text = $this->textRepository->find($textId); + if ($text === null) { + return null; + } + if (!$this->userMayAccessText($user, $text)) { + return $this->errorResponse( + $response, + 403, + 'forbidden' + ); + } + return null; + } + + private function userMayAccessText(User $user, Text $text): bool + { + if ($user->isAdmin()) { + return true; + } + return $text->getUser()->getId() === $user->getId(); + } + + private function errorResponse( + Response $response, + int $status, + string $message, + ): Response { + $response->getBody()->write( + json_encode(['error' => $message]) + ); + + return $response->withStatus($status) + ->withHeader('Content-Type', 'application/json'); + } }