From ab9ca592061f59c3cb70a041f54de230783c5e55 Mon Sep 17 00:00:00 2001 From: Lars Lauger Date: Fri, 1 Nov 2024 00:04:45 +0100 Subject: [PATCH 1/4] feat: Update for Neos 9 This removes the old yaml based configuration so only node based error pages can be used from now on --- .../Command/ErrorPageCommandController.php | 138 ++++-------- .../ErrorHandlerConfiguration.php | 135 +++++------- .../Configuration/NodeBasedConfiguration.php | 204 +++++++++--------- .../SettingsBasedConfiguration.php | 29 --- Classes/DataSource/ErrorPageView.php | 36 ++-- .../Handler/ProductionExceptionHandler.php | 7 - Classes/Service/ActionRequestFactory.php | 58 +++++ Classes/Service/ControllerContextFactory.php | 64 ------ Classes/Service/DestinationResolver.php | 24 +-- Classes/Service/ErrorPageResolver.php | 43 +--- Configuration/Settings.yaml | 12 -- README.md | 55 ----- Resources/Private/Fusion/Root.fusion | 2 +- composer.json | 10 +- 14 files changed, 292 insertions(+), 525 deletions(-) delete mode 100644 Classes/Configuration/SettingsBasedConfiguration.php create mode 100644 Classes/Service/ActionRequestFactory.php delete mode 100644 Classes/Service/ControllerContextFactory.php diff --git a/Classes/Command/ErrorPageCommandController.php b/Classes/Command/ErrorPageCommandController.php index b4af932..45d58fc 100644 --- a/Classes/Command/ErrorPageCommandController.php +++ b/Classes/Command/ErrorPageCommandController.php @@ -5,22 +5,27 @@ use GuzzleHttp\Client; use GuzzleHttp\Psr7\StreamWrapper; -use GuzzleHttp\Psr7\Uri; -use Neos\ContentRepository\Domain\Model\NodeInterface; -use Neos\ContentRepository\Domain\Service\Context; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; +use Neos\ContentRepository\Core\Projection\ContentGraph\Node; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; use Neos\Flow\Core\Bootstrap; -use Neos\Neos\Controller\Exception\NodeNotFoundException; -use Neos\Neos\Domain\Model\Domain; use Neos\Neos\Domain\Model\Site; use Neos\Neos\Domain\Repository\SiteRepository; -use Neos\Neos\Domain\Service\ContentContextFactory; -use Neos\Neos\Service\LinkingService; +use Neos\Neos\FrontendRouting\Exception\NodeNotFoundException; +use Neos\Neos\FrontendRouting\NodeUriBuilderFactory; +use Neos\Neos\FrontendRouting\Options; +use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\Utility\Files; use Netlogix\ErrorHandler\Configuration\ErrorHandlerConfiguration; -use Netlogix\ErrorHandler\Service\ControllerContextFactory; +use Netlogix\ErrorHandler\Service\ActionRequestFactory; use Netlogix\ErrorHandler\Service\DestinationResolver; +use Psr\Http\Message\UriInterface; use RuntimeException; use Symfony\Component\Yaml\Yaml; @@ -35,47 +40,26 @@ class ErrorPageCommandController extends CommandController { - /** - * @Flow\Inject - * @var Bootstrap - */ - protected $bootstrap; + #[Flow\Inject] + protected Bootstrap $bootstrap; - /** - * @Flow\Inject - * @var ErrorHandlerConfiguration - */ - protected $configuration; + #[Flow\Inject] + protected ErrorHandlerConfiguration $configuration; - /** - * @Flow\Inject - * @var DestinationResolver - */ - protected $destinationResolver; + #[Flow\Inject] + protected DestinationResolver $destinationResolver; - /** - * @Flow\Inject - * @var SiteRepository - */ - protected $siteRepository; + #[Flow\Inject] + protected SiteRepository $siteRepository; - /** - * @Flow\Inject - * @var ContentContextFactory - */ - protected $contentContextFactory; + #[Flow\Inject] + protected NodeUriBuilderFactory $nodeUriBuilderFactory; - /** - * @Flow\Inject - * @var LinkingService - */ - protected $linkingService; + #[Flow\Inject] + protected ContentRepositoryRegistry $contentRepositoryRegistry; - /** - * @Flow\Inject - * @var ControllerContextFactory - */ - protected $controllerContextFactory; + #[Flow\Inject] + protected ActionRequestFactory $actionRequestFactory; /** * Generate Error Pages for configured Sites @@ -144,7 +128,7 @@ public function generateCommand(bool $verbose = false) * @param bool $verbose * @throws \Exception */ - public function showConfigurationCommand() + public function showConfigurationCommand(): void { $result = [ 'Netlogix' => [ @@ -156,73 +140,35 @@ public function showConfigurationCommand() $this->output(Yaml::dump($result, 10, 2)); } - /** - * @param Site $site - * @param array $configuration - * @return Uri - * @throws NodeNotFoundException - */ - protected function getSiteUri(Site $site, array $configuration) + protected function getSiteUri(Site $site, array $configuration): UriInterface { $domain = $site->getPrimaryDomain(); if (!$domain) { throw new RuntimeException(sprintf('Site %s has no primary domain', $site->getNodeName()), 1708944032); } - $context = $this->getContextForSite($site, $domain, $configuration['dimensions']); - $source = $context->getNodeByIdentifier(substr($configuration['source'], 1)); + $contentRepository = $this->contentRepositoryRegistry->get($site->getConfiguration()->contentRepositoryId); + $contentGraph = $contentRepository->getContentGraph(WorkspaceName::fromString(WorkspaceName::WORKSPACE_NAME_LIVE)); - if (!$source instanceof NodeInterface) { - throw new NodeNotFoundException('Could not get source node in site ' . $site->getNodeName(), 1552492278); - } + $nodeAggregate = $contentGraph->findNodeAggregateById(NodeAggregateId::fromString(substr($configuration['source'], 1))); + $dimensionSpacePoint = DimensionSpacePoint::fromJsonString(json_encode($configuration['dimensions'])); + + $source = $nodeAggregate->getNodeByCoveredDimensionSpacePoint($dimensionSpacePoint); if (!$this->isNodeVisible($source)) { - throw new NodeNotFoundException('Node for site ' . $site->getNodeName() . ' is not visible', 1552492532); + throw new NodeNotFoundException('Node ' . $source->aggregateId . ' is not visible', 1552492532); } - $controllerContext = $this->controllerContextFactory->buildControllerContext(new Uri($domain->__toString())); - $nodeUri = $this->linkingService->createNodeUri($controllerContext, $source, null, null, true); + $siteDetectionResult = SiteDetectionResult::create($site->getNodeName(), $contentRepository->id); + $actionRequest = $this->actionRequestFactory->buildActionRequest($siteDetectionResult); + $nodeUriBuilder = $this->nodeUriBuilderFactory->forActionRequest($actionRequest); - return new Uri($nodeUri); + return $nodeUriBuilder->uriFor(NodeAddress::fromNode($source), Options::createForceAbsolute()); } - /** - * @param NodeInterface $node - * @return bool - */ - protected function isNodeVisible(NodeInterface $node): bool + protected function isNodeVisible(Node $node): bool { - $currentNode = $node; - - do { - if (!$currentNode->isVisible()) { - return false; - } - - $currentNode = $currentNode->getParent(); - } while ($currentNode !== null); - - return true; - } - - /** - * @param Site $site - * @param Domain $domain - * @param array $dimensions - * @return Context - */ - protected function getContextForSite(Site $site, Domain $domain, array $dimensions): Context - { - return $this->contentContextFactory->create([ - 'workspaceName' => 'live', - 'targetDimensions' => array_map(function (array $dimensionValues) { - return current($dimensionValues); - }, $dimensions), - 'dimensions' => $dimensions, - 'currentSite' => $site, - 'currentDomain' => $domain, - 'invisibleContentShown' => true - ]); + return !$node->tags->contain(SubtreeTag::disabled()); } } diff --git a/Classes/Configuration/ErrorHandlerConfiguration.php b/Classes/Configuration/ErrorHandlerConfiguration.php index 6caeeca..b9d0e91 100644 --- a/Classes/Configuration/ErrorHandlerConfiguration.php +++ b/Classes/Configuration/ErrorHandlerConfiguration.php @@ -5,18 +5,21 @@ namespace Netlogix\ErrorHandler\Configuration; use Generator; -use Neos\ContentRepository\Domain\Service\ContextFactoryInterface; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\Eel\CompilingEvaluator; -use Neos\Eel\Utility as EelUtility; use Neos\Flow\Annotations as Flow; +use Neos\Flow\Mvc\Routing\Dto\RouteParameters; +use Neos\Flow\ObjectManagement\ObjectManagerInterface; use Neos\Neos\Domain\Model\Site; -use Neos\Neos\Service\LinkingService; +use Neos\Neos\FrontendRouting\DimensionResolution\DimensionResolverFactoryInterface; +use Neos\Neos\FrontendRouting\DimensionResolution\RequestToDimensionSpacePointContext; +use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; use function array_filter; use function array_values; use function current; -use function explode; use function in_array; use function iterator_to_array; @@ -28,40 +31,37 @@ * source: string, * destination: string, * dimensionPathSegment?: string, - * pathPrefixes?: string[] + * pathPrefixes?: string[], + * position: int * } */ class ErrorHandlerConfiguration { - /** - * @Flow\Inject(lazy=false) - * @var CompilingEvaluator - */ - protected $eelEvaluator; - - /** - * @Flow\Inject(lazy=false) - * @var ContextFactoryInterface - */ - protected ContextFactoryInterface $contextFactory; - - /** - * @Flow\Inject(lazy=false) - * @var LinkingService - */ - protected LinkingService $linkingService; + #[Flow\Inject] + protected CompilingEvaluator $eelEvaluator; - /** - * @Flow\Inject(lazy=false) - * @var NodeBasedConfiguration - */ + #[Flow\Inject] protected NodeBasedConfiguration $nodeBasedConfiguration; - /** - * @Flow\Inject(lazy=false) - * @var SettingsBasedConfiguration - */ - protected SettingsBasedConfiguration $settingsBasedConfiguration; + #[Flow\Inject] + protected ObjectManagerInterface $objectManager; + + private function resolveDimensionSpacePointFromRequest(Site $site, string $requestPath): DimensionSpacePoint + { + $siteConfiguration = $site->getConfiguration(); + $dimensionResolverFactory = $this->objectManager->get( + $siteConfiguration->contentDimensionResolverFactoryClassName + ); + assert($dimensionResolverFactory instanceof DimensionResolverFactoryInterface); + $dimensionResolver = $dimensionResolverFactory->create($siteConfiguration->contentRepositoryId, $siteConfiguration); + $siteDetectionResult = SiteDetectionResult::create($site->getNodeName(), $siteConfiguration->contentRepositoryId); + $routeParameters = $siteDetectionResult->storeInRouteParameters(RouteParameters::createEmpty()); + + $dimensionResolverContext = RequestToDimensionSpacePointContext::fromUriPathAndRouteParametersAndResolvedSite($requestPath, $routeParameters, $site); + $dimensionResolverContext = $dimensionResolver->fromRequestToDimensionSpacePoint($dimensionResolverContext); + + return $dimensionResolverContext->resolvedDimensionSpacePoint; + } /** * @@ -77,12 +77,12 @@ class ErrorHandlerConfiguration */ public function findConfigurationForSite( Site $site, - UriInterface $uri, + ServerRequestInterface $request, int $statusCode ) { - $siteName = $site->getNodeName(); - $requestPath = ltrim($uri->getPath() ?? '', '/'); - $requestedDimensionPathSegment = current(explode('/', $requestPath, 2)); + $siteName = (string)$site->getNodeName(); + $requestPath = ltrim($request->getUri()->getPath() ?? '', '/'); + $dimensionSpacePoint = $this->resolveDimensionSpacePointFromRequest($site, $requestPath); $configurationsForSite = $this->getConfiguration(); $configurationsForSite = array_key_exists( @@ -90,62 +90,30 @@ public function findConfigurationForSite( $configurationsForSite ) ? $configurationsForSite[$siteName] : []; - $matchingStatusCodes = array_filter($configurationsForSite, + $configurationsForSite = array_filter($configurationsForSite, function (array $configuration) use ($statusCode) { return in_array($statusCode, $configuration['matchingStatusCodes'] ?? [], true); }); - $matchingDimensions = array_filter($matchingStatusCodes, - function (array $configuration) use ($requestedDimensionPathSegment) { - $dimensionPathSegment = $configuration['dimensionPathSegment'] ?? ''; - if ($dimensionPathSegment === '' && empty($configuration['dimensions'] ?? [])) { - return true; - } - - return $dimensionPathSegment === $requestedDimensionPathSegment; - }); - - $configurationWithPathPrefixes = array_filter($matchingDimensions, - function (array $configuration) { - return !empty($configuration['pathPrefixes'] ?? []); - }); + $configurationsForSite = array_filter($configurationsForSite, + function (array $configuration) use ($dimensionSpacePoint) { + $configuredDimensionSpacePoint = DimensionSpacePoint::fromArray($configuration['dimensions']); - $configurationsWithoutPathPrefixes = array_filter($matchingDimensions, - function (array $configuration) { - return empty($configuration['pathPrefixes'] ?? []); + return $configuredDimensionSpacePoint->equals($dimensionSpacePoint); }); - if (!empty($configurationWithPathPrefixes)) { - $matchingPathPrefixes = array_filter($matchingDimensions, - function (array $configuration) use ($requestPath) { - foreach ($configuration['pathPrefixes'] ?? [] as $pathPrefix) { - if (strpos($requestPath, ltrim($pathPrefix, '/')) === 0) { - return true; - } + $configurationsForSite = array_filter($configurationsForSite, + function (array $configuration) use ($requestPath) { + foreach ($configuration['pathPrefixes'] ?? [] as $pathPrefix) { + if (strpos($requestPath, ltrim($pathPrefix, '/')) === 0) { + return true; } + } - return false; - }); - - if (empty($matchingPathPrefixes)) { - return current($configurationsWithoutPathPrefixes); - } - - return current($matchingPathPrefixes); - } - - return current($configurationsWithoutPathPrefixes) ?: current($matchingStatusCodes) ?: null; - } + return false; + }); - /** - * @param string $expression - * @param array $context - * @return mixed - * @throws \Neos\Eel\Exception - */ - protected function evaluateEelExpression(string $expression, array $context) - { - return EelUtility::evaluateEelExpression($expression, $this->eelEvaluator, $context, []); + return current($configurationsForSite) ?: null; } /** @@ -162,11 +130,10 @@ public function getConfiguration() private function generateConfiguration(): Generator { $nodeBased = $this->nodeBasedConfiguration->getConfiguration(); - $settingsBased = $this->settingsBasedConfiguration->getConfiguration(); - $sites = array_keys($nodeBased + $settingsBased); + $sites = array_keys($nodeBased); foreach ($sites as $site) { - yield $site => array_values(array_merge(($nodeBased[$site] ?? []), ($settingsBased[$site] ?? []))); + yield $site => array_values($nodeBased[$site] ?? []); } } diff --git a/Classes/Configuration/NodeBasedConfiguration.php b/Classes/Configuration/NodeBasedConfiguration.php index edf2318..216f713 100644 --- a/Classes/Configuration/NodeBasedConfiguration.php +++ b/Classes/Configuration/NodeBasedConfiguration.php @@ -6,18 +6,26 @@ use Exception; use Generator; -use GuzzleHttp\Psr7\Uri; -use Neos\ContentRepository\Domain\Model\NodeInterface; -use Neos\ContentRepository\Domain\Service\ContentDimensionCombinator; -use Neos\ContentRepository\Domain\Service\ContentDimensionPresetSourceInterface; -use Neos\ContentRepository\Domain\Service\ContextFactoryInterface; +use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\Dimension\ContentDimension; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindRootNodeAggregatesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Node; +use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Eel\FlowQuery\FlowQuery; use Neos\Flow\Annotations as Flow; use Neos\Flow\Log\ThrowableStorageInterface; -use Neos\Neos\Domain\Service\ContentContext; -use Neos\Neos\Service\LinkingService; -use Netlogix\ErrorHandler\Service\ControllerContextFactory; +use Neos\Neos\Domain\Model\Site; +use Neos\Neos\Domain\Model\SiteNodeName; +use Neos\Neos\Domain\Repository\SiteRepository; +use Neos\Neos\FrontendRouting\NodeUriBuilderFactory; +use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; +use Netlogix\ErrorHandler\Service\ActionRequestFactory; use RuntimeException; use function array_map; @@ -33,46 +41,23 @@ */ class NodeBasedConfiguration { - /** - * @Flow\Inject(lazy=false) - * @var ContextFactoryInterface - */ - protected ContextFactoryInterface $contextFactory; - - /** - * @Flow\Inject(lazy=false) - * @var LinkingService - */ - protected LinkingService $linkingService; + #[Flow\Inject] + protected ThrowableStorageInterface $throwableStorage; - /** - * @Flow\Inject(lazy=false) - * @var ControllerContextFactory - */ - protected ControllerContextFactory $controllerContextFactory; + #[Flow\InjectConfiguration(path: 'destination')] + protected string $destination; - /** - * @Flow\Inject(lazy=false) - * @var ContentDimensionPresetSourceInterface - */ - protected ContentDimensionPresetSourceInterface $contentDimensionPresetSource; + #[Flow\Inject] + protected ContentRepositoryRegistry $contentRepositoryRegistry; - /** - * @Flow\Inject(lazy=false) - * @var ContentDimensionCombinator - */ - protected ContentDimensionCombinator $contentDimensionCombinator; + #[Flow\Inject] + protected NodeUriBuilderFactory $nodeUriBuilderFactory; - /** - * @Flow\Inject(lazy=false) - * @var ThrowableStorageInterface - */ - protected ThrowableStorageInterface $throwableStorage; + #[Flow\Inject] + protected SiteRepository $siteRepository; - /** - * @Flow\InjectConfiguration(path="destination") - */ - protected string $destination; + #[Flow\Inject] + protected ActionRequestFactory $actionRequestFactory; /** * @return array @@ -81,11 +66,12 @@ public function getConfiguration(): array { $configurationsForSite = []; $siteNodes = $this->getSiteNodes(); - $siteNodes = iterator_to_array($siteNodes, false); - foreach ($siteNodes as $siteNode) { - assert($siteNode instanceof NodeInterface); - $sitename = (string)$siteNode->getNodeName(); + foreach ($siteNodes as $siteNodeName => $siteNode) { + assert($siteNodeName instanceof SiteNodeName); + assert($siteNode instanceof Node); + + $sitename = (string)$siteNode->name; $configurationsForSite[$sitename] = $configurationsForSite[$sitename] ?? []; $errorNodes = FlowQuery::q($siteNode) @@ -93,28 +79,28 @@ public function getConfiguration(): array ->get(); foreach ($errorNodes as $errorNode) { - assert($errorNode instanceof NodeInterface); + assert($errorNode instanceof Node); try { - $errorNodeConfiguration = $this->getErrorNodeConfiguration($errorNode); + $errorNodeConfiguration = $this->getErrorNodeConfiguration($siteNodeName, $errorNode); $configurationsForSite[$sitename][json_encode($errorNodeConfiguration)] = $errorNodeConfiguration; } catch (Exception $e) { // This is used for creating error pages. // One faulty error page config must not prevent other error pages from being rendered. $this->throwableStorage->logThrowable($e); + continue; } } usort($configurationsForSite[$sitename], fn($a, $b) => $a['position'] <=> $b['position']); - $configurationsForSite[$sitename] = array_map(fn($item) => array_diff_key($item, ['position' => 0]), $configurationsForSite[$sitename]); $configurationsForSite[$sitename] = array_values($configurationsForSite[$sitename]); } - return $configurationsForSite; + return array_filter($configurationsForSite); } - public function getErrorNodeConfiguration(NodeInterface $errorNode): array + public function getErrorNodeConfiguration(SiteNodeName $siteNodeName, Node $errorNode): array { - $pathPrefixes = $this->extractPathPrefixes($errorNode); + $pathPrefixes = $this->extractPathPrefixes($siteNodeName, $errorNode); $position = 1000; foreach ($pathPrefixes as $pathPrefix) { $position -= strlen($pathPrefix); @@ -122,9 +108,8 @@ public function getErrorNodeConfiguration(NodeInterface $errorNode): array return [ 'matchingStatusCodes' => $this->extractStatusCodes($errorNode), - 'dimensions' => $this->extractDimensions($errorNode), - 'dimensionPathSegment' => $this->extractDimensionsPathSegment($errorNode), - 'source' => '#' . $errorNode->getIdentifier(), + 'dimensions' => json_decode($errorNode->dimensionSpacePoint->toJson(), true), + 'source' => '#' . $errorNode->aggregateId, 'destination' => $this->destination, 'pathPrefixes' => $pathPrefixes, 'position' => $position, @@ -135,40 +120,77 @@ public function getErrorNodeConfiguration(NodeInterface $errorNode): array * Returns all NodeInterface Site node objects currently * known to the ContentRepository context. * - * @return Generator + * @return Generator */ - protected function getSiteNodes() + protected function getSiteNodes(): iterable { - $currentContexts = $this->getContentContexts(); - foreach ($currentContexts as $contextContext) { - $rootNode = $contextContext->getRootNode(); - $sitesCollections = $rootNode->findChildNodes(); - foreach ($sitesCollections as $sitesCollection) { - yield from $sitesCollection->findChildNodes(); + $sites = $this->siteRepository->findOnline(); + + foreach ($sites as $site) { + assert($site instanceof Site); + + $contentRepository = $this->contentRepositoryRegistry->get($site->getConfiguration()->contentRepositoryId); + $contentGraph = $contentRepository->getContentGraph(WorkspaceName::fromString(WorkspaceName::WORKSPACE_NAME_LIVE)); + $rootNodeAggregates = $contentGraph->findRootNodeAggregates(FindRootNodeAggregatesFilter::create(nodeTypeName: 'Neos.Neos:Sites')); + + if ($rootNodeAggregates->count() !== 1) { + throw new RuntimeException('Expected exactly one root node aggregate for the site.', 1730410697); + } + $rootNodeAggregate = $rootNodeAggregates->first(); + + foreach ($this->combineAllDimensionSpacePoints($contentRepository) as $dimensionSpacePoint) { + $subgraph = $contentGraph->getSubgraph( + $dimensionSpacePoint, + VisibilityConstraints::frontend() + ); + + $children = $subgraph->findChildNodes( + $rootNodeAggregate->nodeAggregateId, + FindChildNodesFilter::create( + nodeTypes: 'Neos.Neos:Site' + ) + ); + + foreach ($children as $child) { + yield $site->getNodeName() => $child; + } } } } /** - * Map every dimension combination to a corresponding content context. - * - * @return ContentContext[] + * @param ContentRepository $contentRepository + * @return iterable */ - protected function getContentContexts(): array + protected function combineAllDimensionSpacePoints(ContentRepository $contentRepository): iterable { - $dimensionCombinations = $this->contentDimensionCombinator->getAllAllowedCombinations(); - foreach ($dimensionCombinations as $dimensionCombination) { - $context = $this->contextFactory->create(['dimensions' => $dimensionCombination]); - assert($context instanceof ContentContext); - $contentContextsByDimension[json_encode($dimensionCombination)] = $context; + $contentDimensions = $contentRepository->getContentDimensionSource()->getContentDimensionsOrderedByPriority(); + $combinations = []; + + foreach ($contentDimensions as $contentDimension) { + assert($contentDimension instanceof ContentDimension); + + foreach ($contentDimension->values as $contentDimensionValue) { + foreach ($contentDimensions as $otherContentDimension) { + foreach ($otherContentDimension->values as $otherContentDimensionValue) { + $coordinates = array_merge( + [(string)$contentDimension->id->jsonSerialize() => $contentDimensionValue->value], + [(string)$otherContentDimension->id->jsonSerialize() => $otherContentDimensionValue->value] + ); + ksort($coordinates); + $combinations[sha1(join('', $coordinates))] = DimensionSpacePoint::fromArray($coordinates); + } + } + } } - return array_values($contentContextsByDimension); + + return array_values($combinations); } /** * @return int[] */ - protected function extractStatusCodes(NodeInterface $errorNode): array + protected function extractStatusCodes(Node $errorNode): array { $matchingStatusCodes = $errorNode->getProperty('matchingStatusCodes'); if (is_array($matchingStatusCodes) && count($matchingStatusCodes) > 0) { @@ -182,32 +204,20 @@ protected function extractStatusCodes(NodeInterface $errorNode): array } /** - * @return array + * @return string[] */ - protected function extractDimensions(NodeInterface $errorNode): array + protected function extractPathPrefixes(SiteNodeName $siteNodeName, Node $errorNode): array { - // @phpstan-ignore-next-line - return $errorNode->getDimensions(); - } + $siteDetectionResult = SiteDetectionResult::create( + $siteNodeName, + $errorNode->contentRepositoryId + ); - protected function extractDimensionsPathSegment(NodeInterface $errorNode): string - { - $result = []; - foreach ($errorNode->getDimensions() as $singleDimensionValues) { - foreach ($singleDimensionValues as $singleDimensionValue) { - $result[] = $singleDimensionValue; - } - } - return join('-', $result); - } + $actionRequest = $this->actionRequestFactory->buildActionRequest($siteDetectionResult); + + $nodeUriBuilder = $this->nodeUriBuilderFactory->forActionRequest($actionRequest); + $errorUrl = $nodeUriBuilder->uriFor(NodeAddress::fromNode($errorNode)); - /** - * @return string[] - */ - protected function extractPathPrefixes(NodeInterface $errorNode): array - { - $controllerContext = $this->controllerContextFactory->buildControllerContext(new Uri()); - $errorUrl = new Uri($this->linkingService->createNodeUri($controllerContext, $errorNode)); return [dirname($errorUrl->getPath())]; } } diff --git a/Classes/Configuration/SettingsBasedConfiguration.php b/Classes/Configuration/SettingsBasedConfiguration.php deleted file mode 100644 index 465dbd6..0000000 --- a/Classes/Configuration/SettingsBasedConfiguration.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ - protected $pages; - - /** - * @return array - */ - public function getConfiguration(): array - { - // @phpstan-ignore-next-line - return $this->pages ?? []; - } -} diff --git a/Classes/DataSource/ErrorPageView.php b/Classes/DataSource/ErrorPageView.php index 6bbb56f..a61ad91 100644 --- a/Classes/DataSource/ErrorPageView.php +++ b/Classes/DataSource/ErrorPageView.php @@ -4,7 +4,7 @@ namespace Netlogix\ErrorHandler\DataSource; -use Neos\ContentRepository\Domain\Model\NodeInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\Neos\Domain\Model\Domain; use Neos\Neos\Domain\Repository\DomainRepository; use Neos\Neos\Service\DataSource\DataSourceInterface; @@ -14,25 +14,16 @@ final class ErrorPageView implements DataSourceInterface { - /** - * @var NodeBasedConfiguration - * @Flow\Inject - */ - protected $nodeBasedConfiguration; - - /** - * @var DomainRepository - * @Flow\Inject - */ - protected $domainRepository; - - /** - * @var DestinationResolver - * @Flow\Inject - */ - protected $destinationResolver; - - public function getData(NodeInterface $node = null, array $arguments = []): array + #[Flow\Inject] + protected NodeBasedConfiguration $nodeBasedConfiguration; + + #[Flow\Inject] + protected DomainRepository $domainRepository; + + #[Flow\Inject] + protected DestinationResolver $destinationResolver; + + public function getData(Node $node = null, array $arguments = []): array { if (!$node) { return []; @@ -41,14 +32,15 @@ public function getData(NodeInterface $node = null, array $arguments = []): arra if (!($domain instanceof Domain)) { return []; } - $config = $this->nodeBasedConfiguration->getErrorNodeConfiguration($node); + $config = $this->nodeBasedConfiguration->getErrorNodeConfiguration($domain->getSite()->getNodeName(), $node); + return [ 'data' => [ 'rows' => [ [ 'destination' => $this->destinationResolver->getDestinationForConfiguration( $config, - $domain->getSite()->getNodeName() + (string)$domain->getSite()->getNodeName() ), ], ] diff --git a/Classes/Handler/ProductionExceptionHandler.php b/Classes/Handler/ProductionExceptionHandler.php index bf07f59..9d094f3 100644 --- a/Classes/Handler/ProductionExceptionHandler.php +++ b/Classes/Handler/ProductionExceptionHandler.php @@ -10,7 +10,6 @@ class ProductionExceptionHandler extends FlowProductionExceptionHandler { - /** * @param int $statusCode * @param string|null $referenceCode @@ -38,14 +37,8 @@ protected function findErrorPageConfigurationForRequest(int $statusCode): ?strin return $errorPageResolver->findErrorPageForCurrentRequestAndStatusCode($statusCode); } - /** - * Override new method introduced in Flow 6.3.16 - * - * @return bool - */ protected function useCustomErrorView(): bool { return false; } - } \ No newline at end of file diff --git a/Classes/Service/ActionRequestFactory.php b/Classes/Service/ActionRequestFactory.php new file mode 100644 index 0000000..1c26b0d --- /dev/null +++ b/Classes/Service/ActionRequestFactory.php @@ -0,0 +1,58 @@ +siteRepository->findOneByNodeName($siteDetectionResult->siteNodeName); + $baseUri = new Uri((string)$site->getPrimaryDomain()); + + $httpRequest = self::buildHttpRequest($baseUri); + $httpRequest = $siteDetectionResult->storeInRequest($httpRequest); + + return ActionRequest::fromHttpRequest($httpRequest); + } + + private static function buildHttpRequest(UriInterface $uri): ServerRequest + { + return self::createHttpRequestFromGlobals($uri); + } + + private static function createHttpRequestFromGlobals(UriInterface $uri): ServerRequest + { + $_SERVER['FLOW_REWRITEURLS'] = '1'; + $fromGlobals = ServerRequest::fromGlobals(); + + return new ServerRequest( + $fromGlobals->getMethod(), + $uri, + $fromGlobals->getHeaders(), + $fromGlobals->getBody(), + $fromGlobals->getProtocolVersion(), + array_merge( + $fromGlobals->getServerParams(), + // Empty SCRIPT_NAME to prevent "./flow" in Uri + ['SCRIPT_NAME' => ''] + ) + ); + } +} \ No newline at end of file diff --git a/Classes/Service/ControllerContextFactory.php b/Classes/Service/ControllerContextFactory.php deleted file mode 100644 index 2ca46f9..0000000 --- a/Classes/Service/ControllerContextFactory.php +++ /dev/null @@ -1,64 +0,0 @@ -withAttribute( - ServerRequestAttributes::ROUTING_PARAMETERS, - RouteParameters::createEmpty()->withParameter('requestUriHost', $uri->getHost()) - ); - - $request = ActionRequest::fromHttpRequest($httpRequest); - - $uriBuilder = new UriBuilder(); - $uriBuilder->setRequest($request); - - return new ControllerContext( - $request, - new ActionResponse(), - new Arguments([]), - $uriBuilder - ); - } - - private static function createHttpRequestFromGlobals(UriInterface $uri): ServerRequest - { - $_SERVER['FLOW_REWRITEURLS'] = '1'; - $fromGlobals = ServerRequest::fromGlobals(); - - return new ServerRequest( - $fromGlobals->getMethod(), - $uri, - $fromGlobals->getHeaders(), - $fromGlobals->getBody(), - $fromGlobals->getProtocolVersion(), - array_merge( - $fromGlobals->getServerParams(), - // Empty SCRIPT_NAME to prevent "./flow" in Uri - ['SCRIPT_NAME' => ''] - ) - ); - } - -} diff --git a/Classes/Service/DestinationResolver.php b/Classes/Service/DestinationResolver.php index 5d9a1b3..4c57dbb 100644 --- a/Classes/Service/DestinationResolver.php +++ b/Classes/Service/DestinationResolver.php @@ -4,11 +4,9 @@ namespace Netlogix\ErrorHandler\Service; -use Neos\ContentRepository\Domain\Service\ContextFactoryInterface; use Neos\Eel\CompilingEvaluator; use Neos\Eel\Utility as EelUtility; use Neos\Flow\Annotations as Flow; -use Neos\Neos\Service\LinkingService; use function preg_match; @@ -18,23 +16,8 @@ */ class DestinationResolver { - /** - * @Flow\Inject(lazy=false) - * @var CompilingEvaluator - */ - protected $eelEvaluator; - - /** - * @Flow\Inject(lazy=false) - * @var ContextFactoryInterface - */ - protected ContextFactoryInterface $contextFactory; - - /** - * @Flow\Inject(lazy=false) - * @var LinkingService - */ - protected LinkingService $linkingService; + #[Flow\Inject] + protected CompilingEvaluator $eelEvaluator; /** * @param array $config @@ -46,7 +29,6 @@ public function getDestinationForConfiguration( array $config, string $siteNodeName ): string { - $dimensionPathSegment = $config['dimensionPathSegment'] ?? ''; $nodeIdentifier = preg_match( '/^#([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/', $config['source'] ?? '', @@ -57,8 +39,8 @@ public function getDestinationForConfiguration( return $this->evaluateEelExpression($config['destination'], [ 'site' => $siteNodeName, - 'dimensions' => $dimensionPathSegment, 'node' => $nodeIdentifier, + 'dimensions' => sha1(json_encode($config['dimensions'] ?? [])), ]); } diff --git a/Classes/Service/ErrorPageResolver.php b/Classes/Service/ErrorPageResolver.php index 0aa2043..2f6ed65 100644 --- a/Classes/Service/ErrorPageResolver.php +++ b/Classes/Service/ErrorPageResolver.php @@ -7,45 +7,23 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\Core\Bootstrap; use Neos\Flow\Http\HttpRequestHandlerInterface; +use Neos\Flow\Log\ThrowableStorageInterface; use Neos\Neos\Domain\Repository\DomainRepository; use Netlogix\ErrorHandler\Configuration\ErrorHandlerConfiguration; +use Throwable; /** * @Flow\Scope("singleton") */ class ErrorPageResolver implements ProtectedContextAwareInterface { - - /** - * @var ErrorHandlerConfiguration - */ - protected $errorHandlerConfiguration; - - /** - * @var Bootstrap - */ - private $bootstrap; - - /** - * @var DomainRepository - */ - protected $domainRepository; - - /** - * @var DestinationResolver - */ - protected $destinationResolver; - public function __construct( - ErrorHandlerConfiguration $errorHandlerConfiguration, - Bootstrap $bootstrap, - DomainRepository $domainRepository, - DestinationResolver $destinationResolver + protected ErrorHandlerConfiguration $errorHandlerConfiguration, + protected Bootstrap $bootstrap, + protected DomainRepository $domainRepository, + protected DestinationResolver $destinationResolver, + protected ThrowableStorageInterface $throwableStorage, ) { - $this->errorHandlerConfiguration = $errorHandlerConfiguration; - $this->bootstrap = $bootstrap; - $this->domainRepository = $domainRepository; - $this->destinationResolver = $destinationResolver; } public function findErrorPageForCurrentRequestAndStatusCode(int $statusCode): ?string @@ -67,7 +45,7 @@ public function findErrorPageForCurrentRequestAndStatusCode(int $statusCode): ?s $configuration = $this ->errorHandlerConfiguration - ->findConfigurationForSite($currentSite, $requestHandler->getHttpRequest()->getUri(), $statusCode); + ->findConfigurationForSite($currentSite, $requestHandler->getHttpRequest(), $statusCode); if (!$configuration) { return null; @@ -76,9 +54,10 @@ public function findErrorPageForCurrentRequestAndStatusCode(int $statusCode): ?s try { return $this->destinationResolver->getDestinationForConfiguration( $configuration, - $currentDomain->getSite()->getNodeName() + (string)$currentDomain->getSite()->getNodeName() ); - } catch (\Throwable $t) { + } catch (Throwable $t) { + $this->throwableStorage->logThrowable($t); } return null; diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 528e01a..e6eea29 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -14,15 +14,3 @@ Netlogix: ErrorHandler: destination: '${"/var/www/default/" + site + "/" + dimensions + "/" + node + ".html"}' - - pages: [] - -# 'my-site': -# - -# dimensionPathSegment: 'en_US' -# dimensions: -# language: ['en_US', 'en'] -# matchingStatusCodes: [500] -# pathPrefixes: ['/some-prefix', '/some-other-prefix'] -# source: '#550e8400-e29b-11d4-a716-446655440000' -# destination: '${"/var/www/default/" + site + "/" + dimensions + "/500.html"}' diff --git a/README.md b/README.md index 9e47029..51318ee 100644 --- a/README.md +++ b/README.md @@ -34,61 +34,6 @@ Netlogix: destination: '${"/var/www/default/" + site + "/" + dimensions + "/" + node + ".html"}' ``` -### Option 2: Provide error pages via yaml - -Provide configuration for every site and status code you need: - -```yaml -Netlogix: - ErrorHandler: - pages: - # siteNodeName of all Sites that you want to generate error pages for - - 'my-site-without-dimensions': - - - # The status codes this error page is generated for - matchingStatusCodes: [404, 410] - - # Dimensions to use for this error page. Use empty array if no dimensions are configured - dimensions: [] - - # Node identifier of documentNode to use for rendering - source: '#550e8400-e29b-11d4-a716-446655440000' - - # File path where this error page should be saved to. Available variables are `site` and `dimensions` - destination: '${"/var/www/default/mysite/errorpages/404.html"}' - - - - # You can also configure path prefixes so some site areas have different error pages. Make sure to adjust the destination path accordingly. - pathPrefixes: ['/some-special-path'] - - # The status codes this error page is generated for - matchingStatusCodes: [404, 410] - - # Dimensions to use for this error page. Use empty array if no dimensions are configured - dimensions: [] - - # Node identifier of documentNode to use for rendering - source: '#f7c8d757-391a-4a85-bdb5-81df56d5e2c0' - - # File path where this error page should be saved to. Available variables are site and dimensions - destination: '${"/var/www/default/mysite/errorpages/404-some-special-path.html"}' - - 'my-site-with-dimensions': - - - matchingStatusCodes: [500] - - # The first path segment that determines the dimensions. Use empty string if no dimensions are configured - dimensionPathSegment: 'en_US' - - # Dimensions to use for this error page. Use empty array if no dimensions are configured - dimensions: - language: ['en_US', 'en'] - - source: '#550e8400-e29b-11d4-a716-446655440000' - destination: '${"%FLOW_PATH_DATA%Persistent/ErrorPages/" + site + "-" + dimensions + "-500.html"}' -``` - ## Generate error pages To generate the static error pages, run the following Flow command: diff --git a/Resources/Private/Fusion/Root.fusion b/Resources/Private/Fusion/Root.fusion index b31ed55..28b2ca5 100644 --- a/Resources/Private/Fusion/Root.fusion +++ b/Resources/Private/Fusion/Root.fusion @@ -1,6 +1,6 @@ error = Neos.Fusion:Case { nlxErrorHandler { - @position = 'before default' + @position = 'start 1000' errorPage = ${Netlogix.ErrorPageResolver.findErrorPageForCurrentRequestAndStatusCode(statusCode)} condition = ${this.errorPage != null && File.exists(this.errorPage)} diff --git a/composer.json b/composer.json index cab9186..f6e4b7b 100644 --- a/composer.json +++ b/composer.json @@ -4,12 +4,12 @@ "type": "neos-package", "license": "MIT", "require": { - "php": "^7.3 || ^8.0", + "php": "^8.0", "ext-curl": "*", - "guzzlehttp/guzzle": "~6.0 || ~7.0", - "neos/neos": "^7.3 || ^8.0", - "neos/flow": "^7.3.2 || ^8.0", - "symfony/yaml": "^5.1" + "guzzlehttp/guzzle": "^7.9", + "neos/neos": "~9.0.0", + "neos/flow": "~9.0.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0" }, "autoload": { "psr-4": { From 440863fe1988c04f693afcbdfd9678c104c79f37 Mon Sep 17 00:00:00 2001 From: Lars Lauger Date: Wed, 1 Jul 2026 22:59:30 +0200 Subject: [PATCH 2/4] fix: Relax neos/neos and neos/flow constraint to allow 9.1 --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 4a3387c..9ed0990 100644 --- a/composer.json +++ b/composer.json @@ -7,8 +7,8 @@ "php": "^8.0", "ext-curl": "*", "guzzlehttp/guzzle": "^7.9", - "neos/neos": "~9.0.0", - "neos/flow": "~9.0.0", + "neos/neos": "^9.0", + "neos/flow": "^9.0", "symfony/yaml": "^5.4 || ^6.0 || ^7.0" }, "autoload": { From 356dc67e36536513e6f171048e5343e0164010ee Mon Sep 17 00:00:00 2001 From: Lars Lauger Date: Thu, 2 Jul 2026 09:19:37 +0200 Subject: [PATCH 3/4] fix: Discover site nodes correctly on CRs without content dimensions NodeBasedConfiguration::getSiteNodes() silently found no error page configuration at all on installations with zero content dimensions configured, which is a valid and fairly common setup (single-locale sites). combineAllDimensionSpacePoints() only ever produced a result by combining pairs of dimension values, so with no dimensions the combination loop never ran and returned an empty set; the outer lookup then never yielded a single site node, and getConfiguration() came back completely empty with no error/warning of any kind (not even the --verbose per-site output). Also fixes two issues found while getting the zero-dimension case to actually run end-to-end: - VisibilityConstraints::frontend() doesn't exist on this neos/contentrepository-core version; replaced with the documented Neos-specific equivalent (NeosVisibilityConstraints::excludeRemoved() merged with ::excludeDisabled()). - findChildNodes() against the shared "Neos.Neos:Sites" root returns every site's node, not just the current one being iterated in the outer loop, so with N sites this reprocessed (and re-fetched/re-saved the error page for) each site N times over. Filtered to the matching child node name. --- .../Configuration/NodeBasedConfiguration.php | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Classes/Configuration/NodeBasedConfiguration.php b/Classes/Configuration/NodeBasedConfiguration.php index 216f713..5ba0a54 100644 --- a/Classes/Configuration/NodeBasedConfiguration.php +++ b/Classes/Configuration/NodeBasedConfiguration.php @@ -12,7 +12,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindRootNodeAggregatesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; +use Neos\Neos\Domain\SubtreeTagging\NeosVisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; @@ -141,7 +141,7 @@ protected function getSiteNodes(): iterable foreach ($this->combineAllDimensionSpacePoints($contentRepository) as $dimensionSpacePoint) { $subgraph = $contentGraph->getSubgraph( $dimensionSpacePoint, - VisibilityConstraints::frontend() + NeosVisibilityConstraints::excludeRemoved()->merge(NeosVisibilityConstraints::excludeDisabled()) ); $children = $subgraph->findChildNodes( @@ -152,6 +152,12 @@ protected function getSiteNodes(): iterable ); foreach ($children as $child) { + // findChildNodes() returns every site under the shared "Neos.Neos:Sites" root, + // not just the one belonging to the current $site; filter down to avoid + // processing (and later saving an error page for) every other site N times over. + if ((string)$child->name !== (string)$site->getNodeName()) { + continue; + } yield $site->getNodeName() => $child; } } @@ -165,6 +171,14 @@ protected function getSiteNodes(): iterable protected function combineAllDimensionSpacePoints(ContentRepository $contentRepository): iterable { $contentDimensions = $contentRepository->getContentDimensionSource()->getContentDimensionsOrderedByPriority(); + + if (count($contentDimensions) === 0) { + // Sites without any content dimensions configured still have exactly one + // (empty) DimensionSpacePoint; the combination loop below never runs for + // zero dimensions, which would otherwise yield no site nodes at all. + return [DimensionSpacePoint::createWithoutDimensions()]; + } + $combinations = []; foreach ($contentDimensions as $contentDimension) { From 6945d1c141385a3a7cee129cc0b5e45687a27e8a Mon Sep 17 00:00:00 2001 From: Lars Lauger Date: Thu, 2 Jul 2026 09:19:55 +0200 Subject: [PATCH 4/4] fix: Use current NodeAggregate API to look up a node by covered dimension NodeAggregate::getNodeByCoveredDimensionSpacePoint() doesn't exist on this neos/contentrepository-core version, which made errorpage:generate fail to resolve every configured error page's source URI. Per NodeAggregate's own documented usage, look the node up by first resolving the occupied origin for the covered dimension space point. --- Classes/Command/ErrorPageCommandController.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Classes/Command/ErrorPageCommandController.php b/Classes/Command/ErrorPageCommandController.php index 0d178f9..0ef4e1a 100644 --- a/Classes/Command/ErrorPageCommandController.php +++ b/Classes/Command/ErrorPageCommandController.php @@ -160,7 +160,9 @@ protected function getSiteUri(Site $site, array $configuration): UriInterface $nodeAggregate = $contentGraph->findNodeAggregateById(NodeAggregateId::fromString(substr($configuration['source'], 1))); $dimensionSpacePoint = DimensionSpacePoint::fromJsonString(json_encode($configuration['dimensions'])); - $source = $nodeAggregate->getNodeByCoveredDimensionSpacePoint($dimensionSpacePoint); + $source = $nodeAggregate->getNodeByOccupiedDimensionSpacePoint( + $nodeAggregate->getOccupationByCovered($dimensionSpacePoint) + ); if (!$this->isNodeVisible($source)) { throw new NodeNotFoundException('Node ' . $source->aggregateId . ' is not visible', 1552492532);