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.
This commit is contained in:
Yisroel Baum 2026-05-02 21:45:47 +03:00
parent e56cb56ce7
commit 7473af4163
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9

View file

@ -8,7 +8,9 @@ use App\Node\NodeRepository;
use App\Node\UseCases\BulkCreateNodes; use App\Node\UseCases\BulkCreateNodes;
use App\Node\UseCases\CreateNode; use App\Node\UseCases\CreateNode;
use App\Node\UseCases\CreateNodeRequest; use App\Node\UseCases\CreateNodeRequest;
use App\Text\Text;
use App\Text\TextRepository; use App\Text\TextRepository;
use App\User\User;
use DomainException; use DomainException;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
@ -20,14 +22,34 @@ class NodeController
private TextRepository $textRepository, 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); $text = $this->textRepository->find($textId);
if ($text === null) { if ($text === null) {
return $response->withStatus(404); return $response->withStatus(404);
} }
if (!$this->userMayAccessText($user, $text)) {
return $this->errorResponse(
$response,
403,
'forbidden'
);
}
$nodes = $this->nodeRepository->findByTextId($textId); $nodes = $this->nodeRepository->findByTextId($textId);
$data = array_map(function ($node) { $data = array_map(function ($node) {
@ -47,12 +69,32 @@ class NodeController
Response $response, Response $response,
CreateNode $createNodeUseCase, CreateNode $createNodeUseCase,
): Response { ): Response {
$user = $request->getAttribute('user');
if (!$user instanceof User) {
return $this->errorResponse(
$response,
401,
'unauthenticated'
);
}
$data = json_decode((string) $request->getBody(), true) ?? []; $data = json_decode((string) $request->getBody(), true) ?? [];
$textId = isset($data['textId']) ? (int) $data['textId'] : null; $textId = isset($data['textId']) ? (int) $data['textId'] : null;
$title = $data['title'] ?? null; $title = $data['title'] ?? null;
$parentNodeId = isset($data['parentNodeId']) ? (int) $data['parentNodeId'] : null; $parentNodeId = isset($data['parentNodeId']) ? (int) $data['parentNodeId'] : null;
if ($textId !== null) {
$ownershipResponse = $this->checkTextOwnership(
$user,
$textId,
$response,
);
if ($ownershipResponse !== null) {
return $ownershipResponse;
}
}
try { try {
$node = $createNodeUseCase->execute(new CreateNodeRequest( $node = $createNodeUseCase->execute(new CreateNodeRequest(
textId: $textId, textId: $textId,
@ -80,6 +122,15 @@ class NodeController
Response $response, Response $response,
BulkCreateNodes $bulkCreateNodesUseCase, BulkCreateNodes $bulkCreateNodesUseCase,
): Response { ): Response {
$user = $request->getAttribute('user');
if (!$user instanceof User) {
return $this->errorResponse(
$response,
401,
'unauthenticated'
);
}
$data = json_decode((string) $request->getBody(), true) ?? []; $data = json_decode((string) $request->getBody(), true) ?? [];
$textId = isset($data['textId']) ? (int) $data['textId'] : null; $textId = isset($data['textId']) ? (int) $data['textId'] : null;
@ -87,6 +138,17 @@ class NodeController
$titlePrefix = isset($data['titlePrefix']) ? (string) $data['titlePrefix'] : null; $titlePrefix = isset($data['titlePrefix']) ? (string) $data['titlePrefix'] : null;
$count = isset($data['count']) ? (int) $data['count'] : null; $count = isset($data['count']) ? (int) $data['count'] : null;
if ($textId !== null) {
$ownershipResponse = $this->checkTextOwnership(
$user,
$textId,
$response,
);
if ($ownershipResponse !== null) {
return $ownershipResponse;
}
}
try { try {
$nodes = $bulkCreateNodesUseCase->execute(new BulkCreateNodesRequest( $nodes = $bulkCreateNodesUseCase->execute(new BulkCreateNodesRequest(
textId: $textId, textId: $textId,
@ -113,4 +175,44 @@ class NodeController
$response->getBody()->write(json_encode(array_values($result))); $response->getBody()->write(json_encode(array_values($result)));
return $response->withStatus(201)->withHeader('Content-Type', 'application/json'); 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');
}
} }