diff --git a/Neos.Flow/Classes/Http/ServerRequestAttributes.php b/Neos.Flow/Classes/Http/ServerRequestAttributes.php index d020f9c64b..faebc6d393 100644 --- a/Neos.Flow/Classes/Http/ServerRequestAttributes.php +++ b/Neos.Flow/Classes/Http/ServerRequestAttributes.php @@ -32,4 +32,6 @@ final class ServerRequestAttributes * @internal Don't use this. The ActionRequest is supposed to only exist inside the MVC dispatch context. */ public const ACTION_REQUEST = 'actionRequest'; + + public const REQUEST_TAGS = 'requestTags'; } diff --git a/Neos.Flow/Classes/Mvc/ActionRequest.php b/Neos.Flow/Classes/Mvc/ActionRequest.php index d3fb98ff25..6e8db8823a 100644 --- a/Neos.Flow/Classes/Mvc/ActionRequest.php +++ b/Neos.Flow/Classes/Mvc/ActionRequest.php @@ -12,6 +12,7 @@ */ use Neos\Flow\Annotations as Flow; +use Neos\Flow\Http\ServerRequestAttributes; use Neos\Http\Factories\FlowUploadedFile; use Psr\Http\Message\ServerRequestInterface as HttpRequestInterface; use Neos\Flow\ObjectManagement\Exception\UnknownObjectException; @@ -105,6 +106,8 @@ class ActionRequest */ protected $format = ''; + protected RequestTags $requestTags; + /** * The parent request – either another sub ActionRequest a main ActionRequest or null * @var ?ActionRequest @@ -130,6 +133,7 @@ class ActionRequest */ protected function __construct() { + $this->requestTags = RequestTags::empty(); } /** @@ -139,6 +143,10 @@ protected function __construct() public static function fromHttpRequest(HttpRequestInterface $request): ActionRequest { $mainActionRequest = new ActionRequest(); + if ($request->getAttribute(ServerRequestAttributes::REQUEST_TAGS)) { + $mainActionRequest->setRequestTags(RequestTags::fromString($request->getAttribute(ServerRequestAttributes::REQUEST_TAGS))); + } + $mainActionRequest->httpRequest = $request; return $mainActionRequest; } @@ -650,6 +658,16 @@ public function getFormat(): string return $this->format; } + public function getRequestTags(): RequestTags + { + return $this->requestTags; + } + + public function setRequestTags(RequestTags $requestTags): void + { + $this->requestTags = $requestTags; + } + /** * We provide our own __sleep method, where we serialize all properties *except* the parentRequest if it is * a HTTP request -- as this one contains $_SERVER etc. diff --git a/Neos.Flow/Classes/Mvc/RequestTag.php b/Neos.Flow/Classes/Mvc/RequestTag.php new file mode 100644 index 0000000000..0b3c693a1d --- /dev/null +++ b/Neos.Flow/Classes/Mvc/RequestTag.php @@ -0,0 +1,91 @@ + 64) { + throw new \InvalidArgumentException('RequestTag value cannot be longer than 64 characters', 1745266792); + } + + if (!preg_match(self::VALUE_PATTERN, $value)) { + throw new \InvalidArgumentException('RequestTag value contains invalid characters, only A-Z, a-z, 0-9, ":" and "." are allowed ', 1745266788); + } + + if (strlen($type) > 32) { + throw new \InvalidArgumentException('RequestTag type cannot be longer than 32 characters', 1745266824); + } + + if ($type !== '' && !preg_match(self::TYPE_PATTERN, $type)) { + throw new \InvalidArgumentException('RequestTag type contains invalid characters, only A-Z, a-z, 0-9 are allowed.', 1745266895); + } + + $this->value = $value; + $this->type = $type; + } + + /** + * From serialized "TheType-TheValue.WithDots:AndColons" + * + * @param string $requestTag + * @return self + */ + public static function fromString(string $requestTag): RequestTag + { + $parts = explode('-', $requestTag); + + if (count($parts) > 2) { + throw new \InvalidArgumentException('More than one separator found in RequestTag, "-" can only occur once.', 1745267312); + } + + if (count($parts) === 2) { + return new self($parts[1], $parts[0]); + } + + // untyped + return new self($parts[0], ''); + } + + public function matches(RequestTag $requestTag): bool + { + return $requestTag->type === $this->type && $requestTag->value === $this->value; + } + + public function __toString(): string + { + $result = ''; + if ($this->type !== '') { + $result .= $this->type; + $result .= '-'; + } + + $result .= $this->value; + + return $result; + } + + public function jsonSerialize(): string + { + return json_encode($this->__toString(), JSON_THROW_ON_ERROR); + } +} diff --git a/Neos.Flow/Classes/Mvc/RequestTags.php b/Neos.Flow/Classes/Mvc/RequestTags.php new file mode 100644 index 0000000000..c4b2308d35 --- /dev/null +++ b/Neos.Flow/Classes/Mvc/RequestTags.php @@ -0,0 +1,54 @@ + + */ +final readonly class RequestTags implements \IteratorAggregate, \Countable +{ + /** + * @var RequestTag[] + */ + public array $tags; + + /** + */ + public function __construct(RequestTag ...$tags) { + $this->tags = $tags; + } + + public static function fromString(string $json): RequestTags + { + $tagsAsString = json_decode($json, false, 512, JSON_THROW_ON_ERROR); + $tags = []; + foreach ($tagsAsString as $tagString) { + $tag = RequestTag::fromString($tagString); + $tags[] = $tag; + } + + return new self(...$tags); + } + + public static function empty(): RequestTags + { + return new self(); + } + + public function __toString(): string + { + return json_encode($this->tags, JSON_THROW_ON_ERROR); + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + yield from $this->tags; + } + + public function count(): int + { + return count($this->tags); + } +} diff --git a/Neos.Flow/Classes/Mvc/Routing/Route.php b/Neos.Flow/Classes/Mvc/Routing/Route.php index eb7998a333..23e8cdc0eb 100644 --- a/Neos.Flow/Classes/Mvc/Routing/Route.php +++ b/Neos.Flow/Classes/Mvc/Routing/Route.php @@ -152,6 +152,11 @@ class Route */ protected $httpMethods = []; + /** + * @var array + */ + protected array $requestTags = []; + /** * Indicates whether this route is parsed. * For better performance, routes are only parsed if needed. @@ -204,6 +209,10 @@ public static function fromConfiguration(array $configuration): static if (isset($configuration['httpMethods'])) { $route->setHttpMethods($configuration['httpMethods']); } + if (isset($configuration['requestTags'])) { + $route->setRequestTags($configuration['requestTags']); + } + return $route; } @@ -372,6 +381,16 @@ public function getHttpMethods() return $this->httpMethods; } + public function getRequestTags(): array + { + return $this->requestTags; + } + + public function setRequestTags(array $requestTags): void + { + $this->requestTags = $requestTags; + } + /** * Tags for cache items * diff --git a/Neos.Flow/Classes/Mvc/Routing/Router.php b/Neos.Flow/Classes/Mvc/Routing/Router.php index d72bcd6f6a..4546aff0f8 100644 --- a/Neos.Flow/Classes/Mvc/Routing/Router.php +++ b/Neos.Flow/Classes/Mvc/Routing/Router.php @@ -97,6 +97,9 @@ public function route(RouteContext $routeContext): array $matchResults = $route->getMatchResults(); $this->routerCachingService->storeMatchResults($routeContext, $matchResults, $route->getMatchedTags(), $route->getMatchedLifetime()); $this->logger->debug(sprintf('Router route(): Route "%s" matched the request "%s (%s)".', $route->getName(), $httpRequest->getUri(), $httpRequest->getMethod())); + if ($route->getRequestTags() !== []) { + $matchResults['@requestTags'] = json_encode($route->getRequestTags(), JSON_THROW_ON_ERROR); + } return $matchResults; } } diff --git a/Neos.Flow/Classes/Mvc/Routing/RoutingMiddleware.php b/Neos.Flow/Classes/Mvc/Routing/RoutingMiddleware.php index ee42e1d505..5d76144b81 100644 --- a/Neos.Flow/Classes/Mvc/Routing/RoutingMiddleware.php +++ b/Neos.Flow/Classes/Mvc/Routing/RoutingMiddleware.php @@ -65,6 +65,11 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $matchResults['@package'] = $this->packageManager->getCaseSensitivePackageKey($matchResults['@package']); } + if (isset($matchResults['@requestTags'])) { + $request = $request->withAttribute(ServerRequestAttributes::REQUEST_TAGS, $matchResults['@requestTags']); + unset($matchResults['@requestTags']); + } + return $next->handle($request->withAttribute(ServerRequestAttributes::ROUTING_RESULTS, $matchResults)); } } diff --git a/Neos.Flow/Classes/Security/RequestPattern/RequestTag.php b/Neos.Flow/Classes/Security/RequestPattern/RequestTag.php new file mode 100644 index 0000000000..5e5c90dccc --- /dev/null +++ b/Neos.Flow/Classes/Security/RequestPattern/RequestTag.php @@ -0,0 +1,46 @@ +options = $options; + } + + /** + * @param ActionRequest $request + * @return bool + * @throws InvalidRequestPatternException + */ + public function matchRequest(ActionRequest $request): bool + { + if (!isset($this->options["tag"]) || !is_string($this->options["tag"])) { + throw new InvalidRequestPatternException('Needs a "tag" option to match against'); + } + + $tagToMatch = \Neos\Flow\Mvc\RequestTag::fromString($this->options["tag"]); + foreach ($request->getRequestTags() as $tag) { + if ($tagToMatch->matches($tag)) { + return true; + } + } + + return false; + } +} diff --git a/Neos.Flow/Configuration/Testing/Routes.yaml b/Neos.Flow/Configuration/Testing/Routes.yaml index 82aa400f5c..50b889e8ec 100755 --- a/Neos.Flow/Configuration/Testing/Routes.yaml +++ b/Neos.Flow/Configuration/Testing/Routes.yaml @@ -113,3 +113,16 @@ '@controller': 'Standard' '@action': 'index' '@format': 'html' + +- + name: 'Functional Test: With RouteTags' + uriPattern: 'neos/flow/test/routetags' + defaults: + '@package': 'Neos.Flow' + '@subpackage': 'Tests\Functional\Mvc\Fixtures' + '@controller': 'Standard' + '@action': 'index' + '@format': 'html' + requestTags: + - Security-Neos.Neos:Backend + - Something-Neos.Flow:Testing diff --git a/Neos.Flow/Tests/Functional/Mvc/RoutingTest.php b/Neos.Flow/Tests/Functional/Mvc/RoutingTest.php index db45616b47..00701f7e9c 100644 --- a/Neos.Flow/Tests/Functional/Mvc/RoutingTest.php +++ b/Neos.Flow/Tests/Functional/Mvc/RoutingTest.php @@ -13,6 +13,7 @@ use GuzzleHttp\Psr7\Uri; use Neos\Flow\Configuration\ConfigurationManager; +use Neos\Flow\Http\ServerRequestAttributes; use Neos\Flow\Mvc\ActionRequest; use Neos\Flow\Mvc\Exception\NoMatchingRouteException; use Neos\Flow\Mvc\Routing\Dto\RouteParameters; @@ -22,6 +23,7 @@ use Neos\Flow\Mvc\Routing\TestingRoutesProvider; use Neos\Flow\Tests\Functional\Mvc\Fixtures\Controller\ActionControllerTestAController; use Neos\Flow\Tests\Functional\Mvc\Fixtures\Controller\RoutingTestAController; +use Neos\Flow\Tests\Functional\Mvc\Fixtures\Controller\StandardController; use Neos\Flow\Tests\FunctionalTestCase; use Neos\Utility\Arrays; use Psr\Http\Message\ServerRequestFactoryInterface; @@ -98,6 +100,26 @@ public function httpMethodsAreRespectedForPostRequests() self::assertEquals('second', $actionRequest->getControllerActionName()); } + public function testRouteCanProvideRequestTags() + { + $requestUri = 'http://localhost/neos/flow/test/routetags'; + $request = $this->serverRequestFactory->createServerRequest('GET', new Uri($requestUri)); + $matchResults = $this->router->route(new RouteContext($request, RouteParameters::createEmpty())); + $this->assertTrue(isset($matchResults['@requestTags']), 'Route tags should be set.'); + $request = $request->withAttribute(ServerRequestAttributes::REQUEST_TAGS, $matchResults['@requestTags']); + $actionRequest = $this->createActionRequest($request, $matchResults); + self::assertEquals(StandardController::class, $actionRequest->getControllerObjectName()); + self::assertCount(2, $actionRequest->getRequestTags()); + $expectedTags = [ + ['Security', 'Neos.Neos:Backend'], + ['Something', 'Neos.Flow:Testing'] + ]; + foreach ($actionRequest->getRequestTags() as $i => $tag) { + self::assertEquals($expectedTags[$i][0], $tag->type); + self::assertEquals($expectedTags[$i][1], $tag->value); + } + } + /** * Data provider for routeTests() *